import asyncio 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, ): 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 @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 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 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 if auto_active: auto_fallback_speed = ( current.point.action.settings.speed ) return ScheduleResolution( enabled=True, current=current, next=next_point, scheduled_settings=scheduled_settings, auto_active=auto_active, auto_fallback_speed=auto_fallback_speed, ) 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, *, initial: bool = False) -> None: # # resolution = self.resolve() # # if not resolution.enabled: # return # # current = resolution.current # # if current is None: # return # # # Эта точка уже применена. # if ( # not initial # and self._last_applied_when == current.when # ): # return # # # AUTO пока только распознаём. # # Сам AutoController подключим позже. # if ( # current.point.action.type # == ScheduleActionType.AUTO # ): # self._last_applied_when = current.when # return # # settings = resolution.scheduled_settings # # await self._apply_settings(settings) # # self._last_applied_when = current.when async def _process(self) -> None: 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) 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() )