import asyncio from datetime import datetime from app.auto.co2_policy import Co2SpeedPolicy from app.auto.temperature_policy import TemperaturePolicy from app.my_dataclasses import MAX_TARGET_TEMP class AutoController: def __init__( self, schedule_service, qingping_service, tion_service, policy: Co2SpeedPolicy | None = None, temperature_policy: TemperaturePolicy | None = None, interval: float = 5.0, ): self._schedule = schedule_service self._qingping = qingping_service self._tion = tion_service self._policy = policy self._temperature_policy = temperature_policy self._interval = interval self._task: asyncio.Task | None = None self._state = "inactive" self._reason: str | None = None self._target_speed: int | None = None self._auto_speed: int | None = None self._target_heater: bool | None = None self._auto_heater: bool | None = None self._target_temperature: int | None = None self._temperature: float | int | None = None self._temperature_source: str | None = None self._last_error: str | None = None async def start(self) -> None: if self._task is not None: return self._task = asyncio.create_task( self._loop() ) async def stop(self) -> None: if self._task is None: return self._task.cancel() try: await self._task except asyncio.CancelledError: pass finally: self._task = None async def _loop(self) -> None: while True: try: await self._process() except asyncio.CancelledError: raise except Exception as exc: self._last_error = str(exc) await asyncio.sleep( self._interval ) async def _process(self) -> None: now = datetime.now() resolution = self._schedule.resolve(now) # ---------------------------------------------- # Активен ручной override. # # Пока пользователь вручную управляет Tion, # AUTO вообще не вмешивается. # ---------------------------------------------- if self._schedule.override_active: self._state = "suspended" self._reason = "manual_override" # AUTO больше не может считать, # что знает реальное состояние speed/heater: # пользователь мог изменить их вручную. self._target_speed = None self._auto_speed = None self._target_heater = None self._auto_heater = None self._target_temperature = None self._temperature = None self._temperature_source = None self._last_error = None return # ---------------------------------------------- # AUTO сейчас не активен # ---------------------------------------------- if ( not resolution.enabled or not resolution.auto_active ): self._state = "inactive" self._reason = None # Управление возвращается ScheduleService. # После этого AUTO уже не знает фактическое # состояние speed/heater, поэтому забываем его. self._target_speed = None self._auto_speed = None self._target_heater = None self._auto_heater = None self._target_temperature = None self._temperature = None self._temperature_source = None self._last_error = None return fallback_speed = resolution.auto_fallback_speed if fallback_speed is None: raise RuntimeError( "AUTO is active but " "fallback speed is missing" ) # ---------------------------------------------- # CO2 policy недоступна. # # Например, auto.yaml не загрузился. # AUTO работает в аварийном fallback-only режиме. # ---------------------------------------------- if self._policy is None: self._state = "fallback" self._reason = "auto_policy_unavailable" self._auto_speed = None await self._set_speed(fallback_speed) if self._temperature_policy is not None: await self._process_heater(resolution) self._last_error = None return qingping_state = self._qingping.state # ---------------------------------------------- # Qingping исправен # ---------------------------------------------- if ( self._qingping.online and qingping_state.co2 is not None ): target_speed = ( self._policy.select_speed( co2=qingping_state.co2, current_speed=self._auto_speed, ) ) self._state = "active" self._reason = None await self._set_speed( target_speed ) self._auto_speed = target_speed if self._temperature_policy is not None: await self._process_heater(resolution) self._last_error = None return # ---------------------------------------------- # AUTO не может работать. # Переходим на fallback. # ---------------------------------------------- if not self._qingping.online: reason = "qingping_offline" else: reason = "co2_missing" self._state = "fallback" self._reason = reason self._auto_speed = None await self._set_speed(fallback_speed) if self._temperature_policy is not None: await self._process_heater( resolution ) self._last_error = None async def _process_heater(self, resolution) -> None: # -------------------------------------------------- # Берём целевую температуру из накопленных # настроек расписания. # # Например: # # SET target_temp=20 # AUTO # # Во время AUTO target_temp остаётся 20. # -------------------------------------------------- target_temp = resolution.auto_target_temp # -------------------------------------------------- # Получаем фактическую температуру. # # _get_temperature() сам выбирает: # # 1. Qingping # 2. Tion как резерв # 3. None, если оба источника недоступны # -------------------------------------------------- temperature, source = self._get_temperature() self._temperature = temperature self._temperature_source = source # -------------------------------------------------- # Нет температурной policy. # # AUTO не умеет безопасно принять решение # о нагреве → выключаем heater. # -------------------------------------------------- if self._temperature_policy is None: await self._set_heater(False) self._auto_heater = False return # -------------------------------------------------- # Нет целевой температуры. # # Непонятно, до какой температуры греть. # Поэтому heater OFF. # -------------------------------------------------- if target_temp is None: await self._set_heater(False) self._auto_heater = False return # -------------------------------------------------- # Нет достоверной фактической температуры. # # Ни Qingping, ни резервный датчик Tion # использовать нельзя. # # Heater OFF. # -------------------------------------------------- if temperature is None: await self._set_heater(False) self._auto_heater = False return # -------------------------------------------------- # Есть всё необходимое: # # - фактическая температура; # - target_temp; # - TemperaturePolicy. # # Policy решает, нужно ли сейчас греть. # -------------------------------------------------- heater_required = ( self._temperature_policy .heater_required( temperature=temperature, target_temp=target_temp, heater_on=( self._auto_heater is True ), ) ) if heater_required: # Наш внешний регулятор решил, что нужно греть. # # Сначала поднимаем внутреннюю уставку Tion # до максимума, чтобы встроенный термостат # не заблокировал нагрев по своему in_temp. await self._set_target_temperature(MAX_TARGET_TEMP) # После этого разрешаем нагреватель. await self._set_heater(True) else: # Целевая температура по внешнему датчику # достигнута — нагрев запрещаем. await self._set_heater(False) self._auto_heater = heater_required def _get_temperature(self) -> tuple[float | int | None, str | None]: # -------------------------------------------------- # Основной источник — Qingping. # # Используем температуру Qingping только если # сам поток данных датчика сейчас считается online. # -------------------------------------------------- qingping_state = self._qingping.state if ( self._qingping.online and qingping_state.temperature is not None ): return ( qingping_state.temperature, "qingping", ) # -------------------------------------------------- # Qingping недоступен или температура отсутствует. # # Пробуем резервный датчик температуры Tion. # # Важно проверять tion.online: # TionService может хранить последнее состояние, # даже если связь с устройством уже потеряна. # -------------------------------------------------- tion_state = self._tion.state if ( self._tion.online and tion_state is not None and tion_state.in_temp is not None ): return ( tion_state.in_temp, "tion", ) # -------------------------------------------------- # Достоверной температуры нет вообще. # -------------------------------------------------- return None, None async def _set_speed(self, speed: int) -> None: if self._target_speed == speed: return await self._tion.execute( lambda controller: controller.set_speed(speed) ) self._target_speed = speed async def _set_heater(self,enabled: bool) -> None: # Если AutoController уже установил именно такое # состояние нагревателя, повторную команду не шлём. if self._target_heater == enabled: return if enabled: await self._tion.execute( lambda controller: controller.heater_on() ) else: await self._tion.execute( lambda controller: controller.heater_off() ) # Запоминаем состояние только после того, # как команда успешно выполнилась. self._target_heater = enabled async def _set_target_temperature( self, temperature: int, ) -> None: if self._target_temperature == temperature: return await self._tion.execute( lambda controller: controller.set_target_temperature( temperature ) ) self._target_temperature = temperature def status(self) -> dict: return { "state": self._state, "reason": self._reason, "target_speed": self._target_speed, "auto_speed": self._auto_speed, "target_heater": self._target_heater, "auto_heater": self._auto_heater, "temperature": self._temperature, "temperature_source": self._temperature_source, "last_error": self._last_error, }