work: Добваил нагрев в автоматизацию
This commit is contained in:
+82
-2
@@ -32,6 +32,14 @@ class AutoConfig:
|
||||
version: int
|
||||
check_interval: float
|
||||
co2: Co2Config
|
||||
temperature: TemperatureConfig
|
||||
|
||||
@dataclass(
|
||||
frozen=True,
|
||||
slots=True,
|
||||
)
|
||||
class TemperatureConfig:
|
||||
hysteresis: float
|
||||
|
||||
|
||||
def _require_dict(name: str, value: Any) -> dict:
|
||||
@@ -259,6 +267,7 @@ def load_auto_config(path: str | Path) -> AutoConfig:
|
||||
"version",
|
||||
"check_interval",
|
||||
"co2",
|
||||
"temperature",
|
||||
}
|
||||
|
||||
if unknown:
|
||||
@@ -294,6 +303,10 @@ def load_auto_config(path: str | Path) -> AutoConfig:
|
||||
"check_interval must be > 0"
|
||||
)
|
||||
|
||||
# --------------------------------------------------
|
||||
# CO2
|
||||
# --------------------------------------------------
|
||||
|
||||
if "co2" not in data:
|
||||
raise ValueError(
|
||||
"co2 config is required"
|
||||
@@ -303,8 +316,75 @@ def load_auto_config(path: str | Path) -> AutoConfig:
|
||||
data["co2"]
|
||||
)
|
||||
|
||||
# --------------------------------------------------
|
||||
# Temperature
|
||||
# --------------------------------------------------
|
||||
|
||||
if "temperature" not in data:
|
||||
raise ValueError(
|
||||
"temperature config is required"
|
||||
)
|
||||
|
||||
temperature_data = _require_dict(
|
||||
"temperature",
|
||||
data["temperature"],
|
||||
)
|
||||
|
||||
unknown_temperature = (
|
||||
set(temperature_data)
|
||||
- {
|
||||
"hysteresis",
|
||||
}
|
||||
)
|
||||
|
||||
if unknown_temperature:
|
||||
raise ValueError(
|
||||
f"Unknown temperature config fields: "
|
||||
f"{sorted(unknown_temperature)}"
|
||||
)
|
||||
|
||||
temperature_hysteresis = (
|
||||
temperature_data.get(
|
||||
"hysteresis"
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
type(temperature_hysteresis)
|
||||
not in {
|
||||
int,
|
||||
float,
|
||||
}
|
||||
):
|
||||
raise ValueError(
|
||||
"temperature.hysteresis "
|
||||
"must be a number"
|
||||
)
|
||||
|
||||
temperature_hysteresis = float(
|
||||
temperature_hysteresis
|
||||
)
|
||||
|
||||
if temperature_hysteresis < 0:
|
||||
raise ValueError(
|
||||
"temperature.hysteresis "
|
||||
"must be >= 0"
|
||||
)
|
||||
|
||||
temperature = TemperatureConfig(
|
||||
hysteresis=temperature_hysteresis,
|
||||
)
|
||||
|
||||
# --------------------------------------------------
|
||||
# Result
|
||||
# --------------------------------------------------
|
||||
|
||||
return AutoConfig(
|
||||
version=version,
|
||||
check_interval=float(check_interval),
|
||||
check_interval=float(
|
||||
check_interval
|
||||
),
|
||||
co2=co2,
|
||||
)
|
||||
temperature=temperature,
|
||||
)
|
||||
|
||||
|
||||
+255
-7
@@ -1,6 +1,8 @@
|
||||
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:
|
||||
|
||||
@@ -10,12 +12,14 @@ class AutoController:
|
||||
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
|
||||
|
||||
@@ -25,10 +29,17 @@ class AutoController:
|
||||
self._reason: str | None = None
|
||||
|
||||
self._target_speed: int | None = None
|
||||
self._last_error: str | 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:
|
||||
|
||||
@@ -86,10 +97,21 @@ class AutoController:
|
||||
if self._schedule.override_active:
|
||||
self._state = "suspended"
|
||||
self._reason = "manual_override"
|
||||
# AUTO больше не может считать,
|
||||
# что знает реальное состояние speed/heater:
|
||||
# пользователь мог изменить их вручную.
|
||||
self._target_speed = None
|
||||
self._last_error = 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
|
||||
|
||||
# ----------------------------------------------
|
||||
@@ -102,10 +124,21 @@ class AutoController:
|
||||
):
|
||||
self._state = "inactive"
|
||||
self._reason = None
|
||||
# Управление возвращается ScheduleService.
|
||||
# После этого AUTO уже не знает фактическое
|
||||
# состояние speed/heater, поэтому забываем его.
|
||||
self._target_speed = None
|
||||
self._last_error = 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
|
||||
@@ -126,9 +159,10 @@ class AutoController:
|
||||
self._reason = "auto_policy_unavailable"
|
||||
self._auto_speed = None
|
||||
|
||||
await self._set_speed(
|
||||
fallback_speed
|
||||
)
|
||||
await self._set_speed(fallback_speed)
|
||||
|
||||
if self._temperature_policy is not None:
|
||||
await self._process_heater(resolution)
|
||||
|
||||
self._last_error = None
|
||||
|
||||
@@ -159,6 +193,10 @@ class AutoController:
|
||||
)
|
||||
|
||||
self._auto_speed = target_speed
|
||||
|
||||
if self._temperature_policy is not None:
|
||||
await self._process_heater(resolution)
|
||||
|
||||
self._last_error = None
|
||||
|
||||
return
|
||||
@@ -181,9 +219,171 @@ class AutoController:
|
||||
|
||||
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:
|
||||
@@ -196,13 +396,61 @@ class AutoController:
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
from .config import TemperatureConfig
|
||||
|
||||
|
||||
class TemperaturePolicy:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: TemperatureConfig,
|
||||
):
|
||||
self._config = config
|
||||
|
||||
def heater_required(
|
||||
self,
|
||||
temperature: float,
|
||||
target_temp: float,
|
||||
heater_on: bool,
|
||||
) -> bool:
|
||||
|
||||
# Нагреватель уже включён.
|
||||
#
|
||||
# Продолжаем греть, пока температура
|
||||
# не достигла целевой.
|
||||
if heater_on:
|
||||
return (
|
||||
temperature
|
||||
< target_temp
|
||||
)
|
||||
|
||||
# Нагреватель выключен.
|
||||
#
|
||||
# Повторно включаем его только после
|
||||
# падения температуры ниже нижней
|
||||
# границы гистерезиса.
|
||||
return (
|
||||
temperature
|
||||
< (
|
||||
target_temp
|
||||
- self._config.hysteresis
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user