work: Добваил нагрев в автоматизацию

This commit is contained in:
Fedorov Dmitriy
2026-09-19 18:09:03 +03:00
parent bff8efb85b
commit c6d92c10ee
19 changed files with 2661 additions and 91 deletions
+82 -2
View File
@@ -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
View File
@@ -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,
}
+40
View File
@@ -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
)
)