457 lines
14 KiB
Python
457 lines
14 KiB
Python
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,
|
|
}
|
|
|