doc: поправил зависимости и расписание
This commit is contained in:
@@ -0,0 +1,629 @@
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from app.tion.service import TionService
|
||||
|
||||
from datetime import (
|
||||
date,
|
||||
datetime,
|
||||
timedelta,
|
||||
)
|
||||
|
||||
from .models import (
|
||||
ScheduleActionType,
|
||||
ScheduleConfig,
|
||||
ScheduleOccurrence,
|
||||
ScheduleResolution,
|
||||
ScheduledSettings,
|
||||
)
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
WEEKDAYS = (
|
||||
"mon",
|
||||
"tue",
|
||||
"wed",
|
||||
"thu",
|
||||
"fri",
|
||||
"sat",
|
||||
"sun",
|
||||
)
|
||||
|
||||
|
||||
|
||||
class ScheduleService:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config,
|
||||
tion: TionService | None = None,
|
||||
check_interval: float = 5.0,
|
||||
state_path: str | Path | None = None,
|
||||
):
|
||||
self._config = config
|
||||
self._tion = tion
|
||||
self._check_interval = check_interval
|
||||
|
||||
self._running = False
|
||||
self._task = None
|
||||
|
||||
# Последняя успешно применённая точка расписания.
|
||||
self._last_applied_when: datetime | None = None
|
||||
|
||||
# Temporary override.
|
||||
self._override_settings = ScheduledSettings()
|
||||
self._override_until: datetime | None = None
|
||||
|
||||
# True означает:
|
||||
# override записан, но применить его к Tion
|
||||
# пока не удалось.
|
||||
self._override_pending = False
|
||||
|
||||
# Ошибка именно ScheduleService.
|
||||
self._last_error: str | None = None
|
||||
|
||||
self._paused = False
|
||||
|
||||
self._state_path = (
|
||||
Path(state_path)
|
||||
if state_path is not None
|
||||
else None
|
||||
)
|
||||
|
||||
self._load_runtime_state()
|
||||
|
||||
|
||||
|
||||
@property
|
||||
def paused(self) -> bool:
|
||||
return self._paused
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return self._running
|
||||
|
||||
@property
|
||||
def last_error(self) -> str | None:
|
||||
return self._last_error
|
||||
|
||||
@property
|
||||
def override_settings(self) -> ScheduledSettings:
|
||||
return self._override_settings
|
||||
|
||||
@property
|
||||
def override_until(self) -> datetime | None:
|
||||
return self._override_until
|
||||
|
||||
@property
|
||||
def override_active(self) -> bool:
|
||||
if self._override_until is None:
|
||||
return False
|
||||
|
||||
return datetime.now() < self._override_until
|
||||
|
||||
@property
|
||||
def config(self) -> ScheduleConfig:
|
||||
return self._config
|
||||
|
||||
def _load_runtime_state(self) -> None:
|
||||
|
||||
if self._state_path is None:
|
||||
return
|
||||
|
||||
if not self._state_path.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
data = json.loads(
|
||||
self._state_path.read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
except (
|
||||
OSError,
|
||||
json.JSONDecodeError,
|
||||
):
|
||||
return
|
||||
|
||||
paused = data.get("paused")
|
||||
|
||||
if isinstance(paused, bool):
|
||||
self._paused = paused
|
||||
|
||||
def _save_runtime_state(self) -> None:
|
||||
|
||||
if self._state_path is None:
|
||||
return
|
||||
|
||||
data = {
|
||||
"paused": self._paused,
|
||||
}
|
||||
|
||||
self._state_path.parent.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True,
|
||||
)
|
||||
|
||||
temp_path = (
|
||||
self._state_path.with_suffix(
|
||||
self._state_path.suffix + ".tmp"
|
||||
)
|
||||
)
|
||||
|
||||
temp_path.write_text(
|
||||
json.dumps(
|
||||
data,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
temp_path.replace(
|
||||
self._state_path
|
||||
)
|
||||
|
||||
async def apply_override(self, settings: ScheduledSettings) -> None:
|
||||
|
||||
if settings.empty:
|
||||
raise ValueError(
|
||||
"Override settings cannot be empty"
|
||||
)
|
||||
|
||||
if self._tion is None:
|
||||
raise RuntimeError(
|
||||
"TionService is required"
|
||||
)
|
||||
|
||||
now = datetime.now()
|
||||
|
||||
resolution = self.resolve(now)
|
||||
|
||||
if not resolution.enabled:
|
||||
raise RuntimeError(
|
||||
"Schedule is disabled"
|
||||
)
|
||||
|
||||
if resolution.current is None:
|
||||
raise RuntimeError(
|
||||
"Current schedule point not found"
|
||||
)
|
||||
|
||||
if resolution.next is None:
|
||||
raise RuntimeError(
|
||||
"Next schedule point not found"
|
||||
)
|
||||
|
||||
# Если старый override уже закончился,
|
||||
# перед созданием нового очищаем его.
|
||||
if ( self._override_until is not None
|
||||
and now >= self._override_until
|
||||
):
|
||||
self._clear_override()
|
||||
|
||||
# Добавляем новые ручные параметры
|
||||
# к уже существующему override.
|
||||
self._override_settings = ( self._override_settings.merged(settings) )
|
||||
|
||||
# Override действует строго до
|
||||
# следующей точки расписания.
|
||||
self._override_until = resolution.next.when
|
||||
|
||||
# Считаем его неприменённым,
|
||||
# пока команда реально не прошла.
|
||||
self._override_pending = True
|
||||
|
||||
try:
|
||||
await self._apply_settings(settings)
|
||||
|
||||
except Exception as exc:
|
||||
self._last_error = (
|
||||
f"{type(exc).__name__}: {exc}"
|
||||
)
|
||||
|
||||
# Override остаётся сохранённым.
|
||||
# Фоновый цикл попробует ещё раз.
|
||||
raise
|
||||
|
||||
else:
|
||||
self._override_pending = False
|
||||
self._last_error = None
|
||||
|
||||
def _clear_override(self) -> None:
|
||||
self._override_settings = ScheduledSettings()
|
||||
self._override_until = None
|
||||
self._override_pending = False
|
||||
|
||||
async def clear_override(self) -> None:
|
||||
self._clear_override()
|
||||
|
||||
# Сразу пересчитываем и применяем текущее состояние расписания.
|
||||
await self._process()
|
||||
|
||||
|
||||
async def pause(self) -> None:
|
||||
|
||||
if self._paused:
|
||||
return
|
||||
|
||||
# Сначала запрещаем расписанию вмешиваться.
|
||||
self._paused = True
|
||||
|
||||
# Temporary override больше не имеет смысла:
|
||||
# мы переходим в полноценный ручной режим.
|
||||
self._clear_override()
|
||||
self._last_applied_when = None
|
||||
|
||||
self._save_runtime_state()
|
||||
|
||||
async def resume(self) -> None:
|
||||
|
||||
if not self._paused:
|
||||
return
|
||||
|
||||
self._paused = False
|
||||
|
||||
self._save_runtime_state()
|
||||
|
||||
# Сразу применяем состояние расписания,
|
||||
# актуальное именно сейчас.
|
||||
await self._process()
|
||||
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
now: datetime | None = None,
|
||||
) -> ScheduleResolution:
|
||||
"""
|
||||
Определить:
|
||||
- текущую точку;
|
||||
- следующую точку;
|
||||
- накопленные SET-настройки;
|
||||
- активен ли сейчас AUTO.
|
||||
"""
|
||||
|
||||
if now is None:
|
||||
now = datetime.now()
|
||||
|
||||
if not self._config.enabled:
|
||||
return ScheduleResolution(
|
||||
enabled=False,
|
||||
current=None,
|
||||
next=None,
|
||||
scheduled_settings=ScheduledSettings(),
|
||||
auto_active=False,
|
||||
)
|
||||
|
||||
# Недели назад достаточно,
|
||||
# поскольку расписание повторяется каждые 7 дней.
|
||||
start_date = now.date() - timedelta(days=7)
|
||||
|
||||
end_date = now.date() + timedelta(days=7)
|
||||
|
||||
occurrences = self._build_occurrences(
|
||||
start_date,
|
||||
end_date,
|
||||
)
|
||||
|
||||
current = None
|
||||
next_point = None
|
||||
|
||||
for occurrence in occurrences:
|
||||
|
||||
if occurrence.when <= now:
|
||||
current = occurrence
|
||||
continue
|
||||
|
||||
next_point = occurrence
|
||||
break
|
||||
|
||||
if current is None:
|
||||
raise RuntimeError(
|
||||
"Could not determine current "
|
||||
"schedule point"
|
||||
)
|
||||
|
||||
scheduled_settings = (
|
||||
self._calculate_settings(
|
||||
occurrences,
|
||||
current,
|
||||
)
|
||||
)
|
||||
|
||||
auto_active = current.point.action.type == ScheduleActionType.AUTO
|
||||
|
||||
auto_fallback_speed = None
|
||||
auto_target_temp = None
|
||||
|
||||
if auto_active:
|
||||
auto_fallback_speed = current.point.action.settings.speed
|
||||
auto_target_temp = current.point.action.settings.target_temp
|
||||
|
||||
return ScheduleResolution(
|
||||
enabled=True,
|
||||
current=current,
|
||||
next=next_point,
|
||||
scheduled_settings=scheduled_settings,
|
||||
auto_active=auto_active,
|
||||
auto_fallback_speed=auto_fallback_speed,
|
||||
auto_target_temp=auto_target_temp,
|
||||
)
|
||||
|
||||
def _build_occurrences(
|
||||
self,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
) -> list[ScheduleOccurrence]:
|
||||
|
||||
result: list[ScheduleOccurrence] = []
|
||||
|
||||
current_date = start_date
|
||||
|
||||
while current_date <= end_date:
|
||||
|
||||
weekday = WEEKDAYS[
|
||||
current_date.weekday()
|
||||
]
|
||||
|
||||
template_name = (
|
||||
self._config.days[weekday]
|
||||
)
|
||||
|
||||
template = (
|
||||
self._config.templates[
|
||||
template_name
|
||||
]
|
||||
)
|
||||
|
||||
for point in template:
|
||||
|
||||
when = datetime.combine(
|
||||
current_date,
|
||||
point.at,
|
||||
)
|
||||
|
||||
result.append(
|
||||
ScheduleOccurrence(
|
||||
when=when,
|
||||
weekday=weekday,
|
||||
template=template_name,
|
||||
point=point,
|
||||
)
|
||||
)
|
||||
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
result.sort(
|
||||
key=lambda occurrence:
|
||||
occurrence.when
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def _calculate_settings(
|
||||
self,
|
||||
occurrences: list[
|
||||
ScheduleOccurrence
|
||||
],
|
||||
current: ScheduleOccurrence,
|
||||
) -> ScheduledSettings:
|
||||
"""
|
||||
Восстановить последние SET-значения
|
||||
каждого параметра расписания.
|
||||
"""
|
||||
|
||||
settings = ScheduledSettings()
|
||||
|
||||
for occurrence in occurrences:
|
||||
|
||||
if occurrence.when > current.when:
|
||||
break
|
||||
|
||||
action = occurrence.point.action
|
||||
|
||||
if (
|
||||
action.type
|
||||
!= ScheduleActionType.SET
|
||||
):
|
||||
continue
|
||||
|
||||
settings = settings.merged(
|
||||
action.settings
|
||||
)
|
||||
|
||||
return settings
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
if self._tion is None:
|
||||
raise RuntimeError("TionService is required to start schedule")
|
||||
|
||||
self._running = True
|
||||
|
||||
# При запуске сразу приводим Tion
|
||||
# к текущему состоянию расписания.
|
||||
try:
|
||||
await self._process()
|
||||
except Exception as exc:
|
||||
self._last_error = f"{type(exc).__name__}: {exc}"
|
||||
|
||||
self._task = asyncio.create_task(self._loop())
|
||||
|
||||
|
||||
async def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
self._running = False
|
||||
|
||||
if self._task is not None:
|
||||
self._task.cancel()
|
||||
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
self._task = None
|
||||
|
||||
|
||||
async def _loop(self) -> None:
|
||||
while self._running:
|
||||
await asyncio.sleep(
|
||||
self._check_interval
|
||||
)
|
||||
|
||||
try:
|
||||
await self._process()
|
||||
|
||||
except Exception as exc:
|
||||
self._last_error = f"{type(exc).__name__}: {exc}"
|
||||
|
||||
|
||||
async def _process(self) -> None:
|
||||
#Если ручное управление то сразу выходим
|
||||
if self._paused:
|
||||
return
|
||||
|
||||
now = datetime.now()
|
||||
|
||||
resolution = self.resolve(now)
|
||||
|
||||
if not resolution.enabled:
|
||||
return
|
||||
|
||||
current = resolution.current
|
||||
|
||||
if current is None:
|
||||
return
|
||||
|
||||
# --------------------------------------------------
|
||||
# 1. Проверяем окончание temporary override
|
||||
# --------------------------------------------------
|
||||
|
||||
if (
|
||||
self._override_until is not None
|
||||
and now >= self._override_until
|
||||
):
|
||||
self._clear_override()
|
||||
|
||||
# --------------------------------------------------
|
||||
# 2. Пока override действует,
|
||||
# расписание Tion не трогает
|
||||
# --------------------------------------------------
|
||||
|
||||
if self.override_active:
|
||||
|
||||
# Если override ранее не удалось применить,
|
||||
# пробуем снова.
|
||||
if self._override_pending:
|
||||
await self._apply_settings(self._override_settings)
|
||||
self._override_pending = False
|
||||
self._last_error = None
|
||||
return
|
||||
|
||||
# --------------------------------------------------
|
||||
# 3. Если текущая точка уже успешно применена,
|
||||
# ничего не делаем
|
||||
# --------------------------------------------------
|
||||
|
||||
if self._last_applied_when == current.when:
|
||||
return
|
||||
|
||||
# --------------------------------------------------
|
||||
# 4. Применяем состояние расписания
|
||||
# --------------------------------------------------
|
||||
|
||||
settings = resolution.scheduled_settings
|
||||
|
||||
if resolution.auto_active:
|
||||
# В режиме AUTO скорость принадлежит
|
||||
# AutoController.
|
||||
#
|
||||
# Остальные параметры расписания
|
||||
# (power, heater, temperature, mode...)
|
||||
# должны продолжать работать.
|
||||
settings = replace(
|
||||
settings,
|
||||
speed=None,
|
||||
heater=None,
|
||||
target_temp=None,
|
||||
)
|
||||
|
||||
await self._apply_settings(settings)
|
||||
|
||||
# Ставим только ПОСЛЕ успешного применения.
|
||||
self._last_applied_when = current.when
|
||||
self._last_error = None
|
||||
|
||||
|
||||
|
||||
async def _apply_settings(self, settings: ScheduledSettings) -> None:
|
||||
|
||||
if self._tion is None:
|
||||
return
|
||||
|
||||
# Если нужно включить Tion —
|
||||
# сначала включаем.
|
||||
if settings.power is True:
|
||||
await self._tion.execute(
|
||||
lambda tion: tion.power_on()
|
||||
)
|
||||
|
||||
if settings.mode is not None:
|
||||
await self._tion.execute(
|
||||
lambda tion:
|
||||
tion.set_air_mode(settings.mode)
|
||||
)
|
||||
|
||||
if settings.target_temp is not None:
|
||||
await self._tion.execute(
|
||||
lambda tion:
|
||||
tion.set_target_temperature(
|
||||
settings.target_temp
|
||||
)
|
||||
)
|
||||
|
||||
if settings.heater is not None:
|
||||
if settings.heater:
|
||||
await self._tion.execute(
|
||||
lambda tion: tion.heater_on()
|
||||
)
|
||||
else:
|
||||
await self._tion.execute(
|
||||
lambda tion: tion.heater_off()
|
||||
)
|
||||
|
||||
if settings.speed is not None:
|
||||
await self._tion.execute(
|
||||
lambda tion:
|
||||
tion.set_speed(settings.speed)
|
||||
)
|
||||
|
||||
if settings.sound is not None:
|
||||
if settings.sound:
|
||||
await self._tion.execute(
|
||||
lambda tion: tion.sound_on()
|
||||
)
|
||||
else:
|
||||
await self._tion.execute(
|
||||
lambda tion: tion.sound_off()
|
||||
)
|
||||
|
||||
if settings.light is not None:
|
||||
if settings.light:
|
||||
await self._tion.execute(
|
||||
lambda tion: tion.light_on()
|
||||
)
|
||||
else:
|
||||
await self._tion.execute(
|
||||
lambda tion: tion.light_off()
|
||||
)
|
||||
|
||||
# Если нужно выключить —
|
||||
# выключаем последним.
|
||||
if settings.power is False:
|
||||
await self._tion.execute(
|
||||
lambda tion: tion.power_off()
|
||||
)
|
||||
Reference in New Issue
Block a user