Files

651 lines
17 KiB
Python

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
async def replace_config(
self,
config: ScheduleConfig,
) -> None:
"""Заменить расписание без перезапуска приложения."""
if not isinstance(config, ScheduleConfig):
raise TypeError("config must be ScheduleConfig")
self._config = config
self._last_applied_when = None
self._clear_override()
self._last_error = None
# Сохранение расписания не должно завершаться ошибкой только из-за
# временно недоступного Bluetooth. Фоновый цикл повторит применение.
if self._running and not self._paused:
try:
await self._process()
except Exception as exc:
self._last_error = f"{type(exc).__name__}: {exc}"
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()
)