work: Добваил нагрев в автоматизацию
This commit is contained in:
+16
-11
@@ -26,6 +26,8 @@ from app.auto import (
|
|||||||
load_auto_config,
|
load_auto_config,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from app.auto.temperature_policy import TemperaturePolicy
|
||||||
|
|
||||||
from app.tion import (
|
from app.tion import (
|
||||||
TionController,
|
TionController,
|
||||||
TionService,
|
TionService,
|
||||||
@@ -108,9 +110,7 @@ async def lifespan(app: FastAPI):
|
|||||||
schedule_load_error = None
|
schedule_load_error = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
auto_config = load_auto_config(
|
auto_config = load_auto_config(AUTO_CONFIG_FILE)
|
||||||
AUTO_CONFIG_FILE
|
|
||||||
)
|
|
||||||
|
|
||||||
auto_policy = Co2SpeedPolicy(
|
auto_policy = Co2SpeedPolicy(
|
||||||
base_speed=(
|
base_speed=(
|
||||||
@@ -124,14 +124,15 @@ async def lifespan(app: FastAPI):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
auto_interval = (
|
temperature_policy = TemperaturePolicy(auto_config.temperature)
|
||||||
auto_config.check_interval
|
|
||||||
)
|
auto_interval = (auto_config.check_interval)
|
||||||
|
|
||||||
auto_load_error = None
|
auto_load_error = None
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
auto_policy = None
|
auto_policy = None
|
||||||
|
temperature_policy = None
|
||||||
|
|
||||||
# Безопасный встроенный интервал нужен,
|
# Безопасный встроенный интервал нужен,
|
||||||
# потому что auto.yaml сейчас недоступен.
|
# потому что auto.yaml сейчас недоступен.
|
||||||
@@ -146,6 +147,7 @@ async def lifespan(app: FastAPI):
|
|||||||
qingping_service=qingping_service,
|
qingping_service=qingping_service,
|
||||||
tion_service=service,
|
tion_service=service,
|
||||||
policy=auto_policy,
|
policy=auto_policy,
|
||||||
|
temperature_policy=temperature_policy,
|
||||||
interval=auto_interval,
|
interval=auto_interval,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -336,6 +338,8 @@ def get_schedule_status() -> dict:
|
|||||||
"current": None,
|
"current": None,
|
||||||
"next": None,
|
"next": None,
|
||||||
"auto_active": False,
|
"auto_active": False,
|
||||||
|
"auto_fallback_speed": None,
|
||||||
|
"auto_target_temp": None,
|
||||||
"override_active": False,
|
"override_active": False,
|
||||||
"override_until": None,
|
"override_until": None,
|
||||||
"override_settings": {},
|
"override_settings": {},
|
||||||
@@ -393,11 +397,12 @@ def get_schedule_status() -> dict:
|
|||||||
"next": occurrence_to_dict(resolution.next),
|
"next": occurrence_to_dict(resolution.next),
|
||||||
|
|
||||||
"auto_active": resolution.auto_active,
|
"auto_active": resolution.auto_active,
|
||||||
"scheduled_settings": (
|
|
||||||
resolution
|
"auto_fallback_speed": resolution.auto_fallback_speed,
|
||||||
.scheduled_settings
|
|
||||||
.to_dict()
|
"auto_target_temp": resolution.auto_target_temp,
|
||||||
),
|
|
||||||
|
"scheduled_settings": resolution.scheduled_settings.to_dict(),
|
||||||
|
|
||||||
"override_active":schedule_service.override_active,
|
"override_active":schedule_service.override_active,
|
||||||
|
|
||||||
|
|||||||
+81
-1
@@ -32,6 +32,14 @@ class AutoConfig:
|
|||||||
version: int
|
version: int
|
||||||
check_interval: float
|
check_interval: float
|
||||||
co2: Co2Config
|
co2: Co2Config
|
||||||
|
temperature: TemperatureConfig
|
||||||
|
|
||||||
|
@dataclass(
|
||||||
|
frozen=True,
|
||||||
|
slots=True,
|
||||||
|
)
|
||||||
|
class TemperatureConfig:
|
||||||
|
hysteresis: float
|
||||||
|
|
||||||
|
|
||||||
def _require_dict(name: str, value: Any) -> dict:
|
def _require_dict(name: str, value: Any) -> dict:
|
||||||
@@ -259,6 +267,7 @@ def load_auto_config(path: str | Path) -> AutoConfig:
|
|||||||
"version",
|
"version",
|
||||||
"check_interval",
|
"check_interval",
|
||||||
"co2",
|
"co2",
|
||||||
|
"temperature",
|
||||||
}
|
}
|
||||||
|
|
||||||
if unknown:
|
if unknown:
|
||||||
@@ -294,6 +303,10 @@ def load_auto_config(path: str | Path) -> AutoConfig:
|
|||||||
"check_interval must be > 0"
|
"check_interval must be > 0"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# CO2
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
if "co2" not in data:
|
if "co2" not in data:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"co2 config is required"
|
"co2 config is required"
|
||||||
@@ -303,8 +316,75 @@ def load_auto_config(path: str | Path) -> AutoConfig:
|
|||||||
data["co2"]
|
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(
|
return AutoConfig(
|
||||||
version=version,
|
version=version,
|
||||||
check_interval=float(check_interval),
|
check_interval=float(
|
||||||
|
check_interval
|
||||||
|
),
|
||||||
co2=co2,
|
co2=co2,
|
||||||
|
temperature=temperature,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+255
-7
@@ -1,6 +1,8 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from app.auto.co2_policy import Co2SpeedPolicy
|
from app.auto.co2_policy import Co2SpeedPolicy
|
||||||
|
from app.auto.temperature_policy import TemperaturePolicy
|
||||||
|
from app.my_dataclasses import MAX_TARGET_TEMP
|
||||||
|
|
||||||
class AutoController:
|
class AutoController:
|
||||||
|
|
||||||
@@ -10,12 +12,14 @@ class AutoController:
|
|||||||
qingping_service,
|
qingping_service,
|
||||||
tion_service,
|
tion_service,
|
||||||
policy: Co2SpeedPolicy | None = None,
|
policy: Co2SpeedPolicy | None = None,
|
||||||
|
temperature_policy: TemperaturePolicy | None = None,
|
||||||
interval: float = 5.0,
|
interval: float = 5.0,
|
||||||
):
|
):
|
||||||
self._schedule = schedule_service
|
self._schedule = schedule_service
|
||||||
self._qingping = qingping_service
|
self._qingping = qingping_service
|
||||||
self._tion = tion_service
|
self._tion = tion_service
|
||||||
self._policy = policy
|
self._policy = policy
|
||||||
|
self._temperature_policy = temperature_policy
|
||||||
|
|
||||||
self._interval = interval
|
self._interval = interval
|
||||||
|
|
||||||
@@ -25,10 +29,17 @@ class AutoController:
|
|||||||
self._reason: str | None = None
|
self._reason: str | None = None
|
||||||
|
|
||||||
self._target_speed: int | None = None
|
self._target_speed: int | None = None
|
||||||
self._last_error: str | None = None
|
|
||||||
|
|
||||||
self._auto_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:
|
async def start(self) -> None:
|
||||||
|
|
||||||
@@ -86,10 +97,21 @@ class AutoController:
|
|||||||
if self._schedule.override_active:
|
if self._schedule.override_active:
|
||||||
self._state = "suspended"
|
self._state = "suspended"
|
||||||
self._reason = "manual_override"
|
self._reason = "manual_override"
|
||||||
|
# AUTO больше не может считать,
|
||||||
|
# что знает реальное состояние speed/heater:
|
||||||
|
# пользователь мог изменить их вручную.
|
||||||
self._target_speed = None
|
self._target_speed = None
|
||||||
self._last_error = None
|
|
||||||
self._auto_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
|
return
|
||||||
|
|
||||||
# ----------------------------------------------
|
# ----------------------------------------------
|
||||||
@@ -102,10 +124,21 @@ class AutoController:
|
|||||||
):
|
):
|
||||||
self._state = "inactive"
|
self._state = "inactive"
|
||||||
self._reason = None
|
self._reason = None
|
||||||
|
# Управление возвращается ScheduleService.
|
||||||
|
# После этого AUTO уже не знает фактическое
|
||||||
|
# состояние speed/heater, поэтому забываем его.
|
||||||
self._target_speed = None
|
self._target_speed = None
|
||||||
self._last_error = None
|
|
||||||
self._auto_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
|
return
|
||||||
|
|
||||||
fallback_speed = resolution.auto_fallback_speed
|
fallback_speed = resolution.auto_fallback_speed
|
||||||
@@ -126,9 +159,10 @@ class AutoController:
|
|||||||
self._reason = "auto_policy_unavailable"
|
self._reason = "auto_policy_unavailable"
|
||||||
self._auto_speed = None
|
self._auto_speed = None
|
||||||
|
|
||||||
await self._set_speed(
|
await self._set_speed(fallback_speed)
|
||||||
fallback_speed
|
|
||||||
)
|
if self._temperature_policy is not None:
|
||||||
|
await self._process_heater(resolution)
|
||||||
|
|
||||||
self._last_error = None
|
self._last_error = None
|
||||||
|
|
||||||
@@ -159,6 +193,10 @@ class AutoController:
|
|||||||
)
|
)
|
||||||
|
|
||||||
self._auto_speed = target_speed
|
self._auto_speed = target_speed
|
||||||
|
|
||||||
|
if self._temperature_policy is not None:
|
||||||
|
await self._process_heater(resolution)
|
||||||
|
|
||||||
self._last_error = None
|
self._last_error = None
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -181,9 +219,171 @@ class AutoController:
|
|||||||
|
|
||||||
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
|
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:
|
async def _set_speed(self, speed: int) -> None:
|
||||||
|
|
||||||
if self._target_speed == speed:
|
if self._target_speed == speed:
|
||||||
@@ -196,13 +396,61 @@ class AutoController:
|
|||||||
|
|
||||||
self._target_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:
|
def status(self) -> dict:
|
||||||
return {
|
return {
|
||||||
"state": self._state,
|
"state": self._state,
|
||||||
"reason": self._reason,
|
"reason": self._reason,
|
||||||
|
|
||||||
"target_speed": self._target_speed,
|
"target_speed": self._target_speed,
|
||||||
"auto_speed": self._auto_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,
|
"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
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -21,3 +21,6 @@ co2:
|
|||||||
|
|
||||||
- ppm: 2000
|
- ppm: 2000
|
||||||
speed: 6
|
speed: 6
|
||||||
|
|
||||||
|
temperature:
|
||||||
|
hysteresis: 0.5
|
||||||
@@ -17,25 +17,25 @@ templates:
|
|||||||
target_temp: 20
|
target_temp: 20
|
||||||
mode: outside
|
mode: outside
|
||||||
|
|
||||||
- time: "16:17"
|
- time: "18:01"
|
||||||
action:
|
action:
|
||||||
type: set
|
type: set
|
||||||
speed: 1
|
speed: 1
|
||||||
|
|
||||||
- time: "16:18"
|
- time: "18:07"
|
||||||
action:
|
action:
|
||||||
type: auto
|
type: auto
|
||||||
speed: 1
|
speed: 1
|
||||||
|
target_temp: 30
|
||||||
|
|
||||||
- time: "16:20"
|
- time: "18:08"
|
||||||
action:
|
action:
|
||||||
type: set
|
type: set
|
||||||
|
target_temp: 30
|
||||||
speed: 6
|
speed: 6
|
||||||
|
|
||||||
- time: "17:00"
|
|
||||||
action:
|
|
||||||
type: set
|
|
||||||
speed: 3
|
|
||||||
|
|
||||||
- time: "19:00"
|
- time: "19:00"
|
||||||
action:
|
action:
|
||||||
|
|||||||
+14
-10
@@ -196,9 +196,7 @@ def _parse_action(
|
|||||||
action_type_raw = data.get("type")
|
action_type_raw = data.get("type")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
action_type = ScheduleActionType(
|
action_type = ScheduleActionType(action_type_raw)
|
||||||
action_type_raw
|
|
||||||
)
|
|
||||||
|
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -208,32 +206,38 @@ def _parse_action(
|
|||||||
|
|
||||||
if action_type == ScheduleActionType.AUTO:
|
if action_type == ScheduleActionType.AUTO:
|
||||||
|
|
||||||
extra = set(data) - {
|
allowed_fields = {
|
||||||
"type",
|
"type",
|
||||||
"speed",
|
"speed",
|
||||||
|
"target_temp",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extra = set(data) - allowed_fields
|
||||||
|
|
||||||
if extra:
|
if extra:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"AUTO action supports only "
|
"AUTO action supports only "
|
||||||
f"'speed': {sorted(extra)}"
|
"'speed' and 'target_temp': "
|
||||||
|
f"{sorted(extra)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if "speed" not in data:
|
if "speed" not in data:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"AUTO action must contain "
|
"AUTO action requires 'speed'"
|
||||||
"fallback speed"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
settings = _parse_settings(
|
settings_data = {
|
||||||
{
|
|
||||||
"speed": data["speed"],
|
"speed": data["speed"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if "target_temp" in data:
|
||||||
|
settings_data["target_temp"] = (
|
||||||
|
data["target_temp"]
|
||||||
)
|
)
|
||||||
|
|
||||||
return ScheduleAction(
|
return ScheduleAction(
|
||||||
type=ScheduleActionType.AUTO,
|
type=ScheduleActionType.AUTO,
|
||||||
settings=settings,
|
settings=_parse_settings(settings_data),
|
||||||
)
|
)
|
||||||
|
|
||||||
settings_data = {
|
settings_data = {
|
||||||
|
|||||||
@@ -118,3 +118,4 @@ class ScheduleResolution:
|
|||||||
|
|
||||||
auto_active: bool
|
auto_active: bool
|
||||||
auto_fallback_speed: int | None
|
auto_fallback_speed: int | None
|
||||||
|
auto_target_temp: int | None = None
|
||||||
+13
-49
@@ -191,15 +191,9 @@ class ScheduleService:
|
|||||||
|
|
||||||
# Недели назад достаточно,
|
# Недели назад достаточно,
|
||||||
# поскольку расписание повторяется каждые 7 дней.
|
# поскольку расписание повторяется каждые 7 дней.
|
||||||
start_date = (
|
start_date = now.date() - timedelta(days=7)
|
||||||
now.date()
|
|
||||||
- timedelta(days=7)
|
|
||||||
)
|
|
||||||
|
|
||||||
end_date = (
|
end_date = now.date() + timedelta(days=7)
|
||||||
now.date()
|
|
||||||
+ timedelta(days=7)
|
|
||||||
)
|
|
||||||
|
|
||||||
occurrences = self._build_occurrences(
|
occurrences = self._build_occurrences(
|
||||||
start_date,
|
start_date,
|
||||||
@@ -231,17 +225,14 @@ class ScheduleService:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
auto_active = (
|
auto_active = current.point.action.type == ScheduleActionType.AUTO
|
||||||
current.point.action.type
|
|
||||||
== ScheduleActionType.AUTO
|
|
||||||
)
|
|
||||||
|
|
||||||
auto_fallback_speed = None
|
auto_fallback_speed = None
|
||||||
|
auto_target_temp = None
|
||||||
|
|
||||||
if auto_active:
|
if auto_active:
|
||||||
auto_fallback_speed = (
|
auto_fallback_speed = current.point.action.settings.speed
|
||||||
current.point.action.settings.speed
|
auto_target_temp = current.point.action.settings.target_temp
|
||||||
)
|
|
||||||
|
|
||||||
return ScheduleResolution(
|
return ScheduleResolution(
|
||||||
enabled=True,
|
enabled=True,
|
||||||
@@ -250,6 +241,7 @@ class ScheduleService:
|
|||||||
scheduled_settings=scheduled_settings,
|
scheduled_settings=scheduled_settings,
|
||||||
auto_active=auto_active,
|
auto_active=auto_active,
|
||||||
auto_fallback_speed=auto_fallback_speed,
|
auto_fallback_speed=auto_fallback_speed,
|
||||||
|
auto_target_temp=auto_target_temp,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _build_occurrences(
|
def _build_occurrences(
|
||||||
@@ -384,39 +376,6 @@ class ScheduleService:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._last_error = f"{type(exc).__name__}: {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:
|
async def _process(self) -> None:
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
@@ -477,7 +436,12 @@ class ScheduleService:
|
|||||||
# Остальные параметры расписания
|
# Остальные параметры расписания
|
||||||
# (power, heater, temperature, mode...)
|
# (power, heater, temperature, mode...)
|
||||||
# должны продолжать работать.
|
# должны продолжать работать.
|
||||||
settings = replace(settings, speed=None)
|
settings = replace(
|
||||||
|
settings,
|
||||||
|
speed=None,
|
||||||
|
heater=None,
|
||||||
|
target_temp=None,
|
||||||
|
)
|
||||||
|
|
||||||
await self._apply_settings(settings)
|
await self._apply_settings(settings)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from app.auto.controller import AutoController
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionController:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def heater_on(self):
|
||||||
|
self.calls.append(
|
||||||
|
("heater_on", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def heater_off(self):
|
||||||
|
self.calls.append(
|
||||||
|
("heater_off", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.controller = (
|
||||||
|
FakeTionController()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
operation,
|
||||||
|
):
|
||||||
|
return await operation(
|
||||||
|
self.controller
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_controller():
|
||||||
|
|
||||||
|
tion = FakeTionService()
|
||||||
|
|
||||||
|
controller = AutoController(
|
||||||
|
schedule_service=None,
|
||||||
|
qingping_service=None,
|
||||||
|
tion_service=tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
return controller, tion
|
||||||
|
|
||||||
|
|
||||||
|
async def test_heater_on():
|
||||||
|
|
||||||
|
controller, tion = (
|
||||||
|
make_controller()
|
||||||
|
)
|
||||||
|
|
||||||
|
await controller._set_heater(
|
||||||
|
True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("heater_on", None)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._target_heater
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_duplicate_heater_on():
|
||||||
|
|
||||||
|
controller, tion = (
|
||||||
|
make_controller()
|
||||||
|
)
|
||||||
|
|
||||||
|
await controller._set_heater(
|
||||||
|
True
|
||||||
|
)
|
||||||
|
|
||||||
|
await controller._set_heater(
|
||||||
|
True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("heater_on", None)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_heater_off_after_on():
|
||||||
|
|
||||||
|
controller, tion = (
|
||||||
|
make_controller()
|
||||||
|
)
|
||||||
|
|
||||||
|
await controller._set_heater(
|
||||||
|
True
|
||||||
|
)
|
||||||
|
|
||||||
|
await controller._set_heater(
|
||||||
|
False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("heater_on", None),
|
||||||
|
("heater_off", None),
|
||||||
|
]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._target_heater
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_duplicate_heater_off():
|
||||||
|
|
||||||
|
controller, tion = (
|
||||||
|
make_controller()
|
||||||
|
)
|
||||||
|
|
||||||
|
await controller._set_heater(
|
||||||
|
False
|
||||||
|
)
|
||||||
|
|
||||||
|
await controller._set_heater(
|
||||||
|
False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("heater_off", None)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
await test_heater_on()
|
||||||
|
|
||||||
|
await test_duplicate_heater_on()
|
||||||
|
|
||||||
|
await test_heater_off_after_on()
|
||||||
|
|
||||||
|
await test_duplicate_heater_off()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO HEATER COMMAND "
|
||||||
|
"TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from app.auto.config import (
|
||||||
|
TemperatureConfig,
|
||||||
|
)
|
||||||
|
from app.auto.controller import (
|
||||||
|
AutoController,
|
||||||
|
)
|
||||||
|
from app.auto.temperature_policy import (
|
||||||
|
TemperaturePolicy,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeQingpingService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
|
||||||
|
self.online = True
|
||||||
|
|
||||||
|
self.state = SimpleNamespace(
|
||||||
|
temperature=19.4,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionController:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def heater_on(self):
|
||||||
|
self.calls.append(
|
||||||
|
("heater_on", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def heater_off(self):
|
||||||
|
self.calls.append(
|
||||||
|
("heater_off", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
|
||||||
|
self.online = True
|
||||||
|
|
||||||
|
self.state = SimpleNamespace(
|
||||||
|
in_temp=18,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.controller = (
|
||||||
|
FakeTionController()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
operation,
|
||||||
|
):
|
||||||
|
return await operation(
|
||||||
|
self.controller
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_resolution(
|
||||||
|
target_temp=20.0,
|
||||||
|
):
|
||||||
|
|
||||||
|
return SimpleNamespace(
|
||||||
|
scheduled_settings=(
|
||||||
|
SimpleNamespace(
|
||||||
|
target_temp=target_temp,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_controller():
|
||||||
|
|
||||||
|
qingping = (
|
||||||
|
FakeQingpingService()
|
||||||
|
)
|
||||||
|
|
||||||
|
tion = FakeTionService()
|
||||||
|
|
||||||
|
policy = TemperaturePolicy(
|
||||||
|
TemperatureConfig(
|
||||||
|
hysteresis=0.5,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
controller = AutoController(
|
||||||
|
schedule_service=None,
|
||||||
|
qingping_service=qingping,
|
||||||
|
tion_service=tion,
|
||||||
|
temperature_policy=policy,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
controller,
|
||||||
|
qingping,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_qingping_heating():
|
||||||
|
|
||||||
|
controller, qingping, tion = (
|
||||||
|
make_controller()
|
||||||
|
)
|
||||||
|
|
||||||
|
resolution = make_resolution()
|
||||||
|
|
||||||
|
# 19.4 < 19.5
|
||||||
|
# Heater должен включиться.
|
||||||
|
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("heater_on", None)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._auto_heater
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._temperature
|
||||||
|
== 19.4
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._temperature_source
|
||||||
|
== "qingping"
|
||||||
|
)
|
||||||
|
|
||||||
|
tion.controller.calls.clear()
|
||||||
|
|
||||||
|
# ----------------------------------------------
|
||||||
|
# 19.8
|
||||||
|
#
|
||||||
|
# Heater уже ON.
|
||||||
|
# До 20 градусов ещё не дошли.
|
||||||
|
# Продолжаем греть.
|
||||||
|
#
|
||||||
|
# Новую команду отправлять не нужно.
|
||||||
|
# ----------------------------------------------
|
||||||
|
|
||||||
|
qingping.state.temperature = 19.8
|
||||||
|
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == []
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._auto_heater
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
# ----------------------------------------------
|
||||||
|
# 20.0
|
||||||
|
#
|
||||||
|
# Цель достигнута.
|
||||||
|
# ----------------------------------------------
|
||||||
|
|
||||||
|
qingping.state.temperature = 20.0
|
||||||
|
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("heater_off", None)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._auto_heater
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tion_temperature_fallback():
|
||||||
|
|
||||||
|
controller, qingping, tion = (
|
||||||
|
make_controller()
|
||||||
|
)
|
||||||
|
|
||||||
|
resolution = make_resolution()
|
||||||
|
|
||||||
|
# Qingping отключился.
|
||||||
|
qingping.online = False
|
||||||
|
|
||||||
|
# Используем Tion.
|
||||||
|
tion.state.in_temp = 19
|
||||||
|
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("heater_on", None)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._temperature
|
||||||
|
== 19
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._temperature_source
|
||||||
|
== "tion"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._auto_heater
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_no_temperature():
|
||||||
|
|
||||||
|
controller, qingping, tion = (
|
||||||
|
make_controller()
|
||||||
|
)
|
||||||
|
|
||||||
|
resolution = make_resolution()
|
||||||
|
|
||||||
|
qingping.online = False
|
||||||
|
tion.online = False
|
||||||
|
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("heater_off", None)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._temperature
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._temperature_source
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._auto_heater
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_target_temperature_missing():
|
||||||
|
|
||||||
|
controller, qingping, tion = (
|
||||||
|
make_controller()
|
||||||
|
)
|
||||||
|
|
||||||
|
resolution = make_resolution(
|
||||||
|
target_temp=None
|
||||||
|
)
|
||||||
|
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("heater_off", None)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._auto_heater
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
await test_qingping_heating()
|
||||||
|
|
||||||
|
await test_tion_temperature_fallback()
|
||||||
|
|
||||||
|
await test_no_temperature()
|
||||||
|
|
||||||
|
await test_target_temperature_missing()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO HEATER PROCESS "
|
||||||
|
"TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from app.auto.config import (
|
||||||
|
TemperatureConfig,
|
||||||
|
)
|
||||||
|
from app.auto.controller import (
|
||||||
|
AutoController,
|
||||||
|
)
|
||||||
|
from app.auto.temperature_policy import (
|
||||||
|
TemperaturePolicy,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeQingpingService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.online = True
|
||||||
|
|
||||||
|
self.state = SimpleNamespace(
|
||||||
|
temperature=19.5,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionController:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def heater_on(self):
|
||||||
|
self.calls.append(
|
||||||
|
("heater_on", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def heater_off(self):
|
||||||
|
self.calls.append(
|
||||||
|
("heater_off", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.online = True
|
||||||
|
|
||||||
|
self.state = SimpleNamespace(
|
||||||
|
in_temp=19,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.controller = (
|
||||||
|
FakeTionController()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
operation,
|
||||||
|
):
|
||||||
|
return await operation(
|
||||||
|
self.controller
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_auto_uses_auto_target_temperature():
|
||||||
|
|
||||||
|
qingping = FakeQingpingService()
|
||||||
|
tion = FakeTionService()
|
||||||
|
|
||||||
|
policy = TemperaturePolicy(
|
||||||
|
TemperatureConfig(
|
||||||
|
hysteresis=0.5,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
controller = AutoController(
|
||||||
|
schedule_service=None,
|
||||||
|
qingping_service=qingping,
|
||||||
|
tion_service=tion,
|
||||||
|
temperature_policy=policy,
|
||||||
|
)
|
||||||
|
|
||||||
|
resolution = SimpleNamespace(
|
||||||
|
# Последний SET хотел 23°C.
|
||||||
|
scheduled_settings=SimpleNamespace(
|
||||||
|
target_temp=23,
|
||||||
|
),
|
||||||
|
|
||||||
|
# Но текущий AUTO явно хочет 20°C.
|
||||||
|
auto_target_temp=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
# При 19.5°C и target=20°C:
|
||||||
|
#
|
||||||
|
# heater был OFF,
|
||||||
|
# нижняя граница = 19.5°C.
|
||||||
|
#
|
||||||
|
# Поэтому heater должен остаться OFF.
|
||||||
|
#
|
||||||
|
# Если бы контроллер ошибочно использовал
|
||||||
|
# SET target_temp=23, он бы включил heater.
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("heater_off", None)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._auto_heater
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._temperature
|
||||||
|
== 19.5
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._temperature_source
|
||||||
|
== "qingping"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
await test_auto_uses_auto_target_temperature()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO HEATER TARGET "
|
||||||
|
"TEMPERATURE TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from app.auto.config import TemperatureConfig
|
||||||
|
from app.auto.controller import AutoController
|
||||||
|
from app.auto.temperature_policy import TemperaturePolicy
|
||||||
|
from app.my_dataclasses import MAX_TARGET_TEMP
|
||||||
|
|
||||||
|
|
||||||
|
class FakeQingpingService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.online = True
|
||||||
|
|
||||||
|
self.state = SimpleNamespace(
|
||||||
|
temperature=19.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionController:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def set_target_temperature(
|
||||||
|
self,
|
||||||
|
temperature: int,
|
||||||
|
):
|
||||||
|
self.calls.append(
|
||||||
|
(
|
||||||
|
"set_target_temperature",
|
||||||
|
temperature,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def heater_on(self):
|
||||||
|
self.calls.append(
|
||||||
|
(
|
||||||
|
"heater_on",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def heater_off(self):
|
||||||
|
self.calls.append(
|
||||||
|
(
|
||||||
|
"heater_off",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.online = True
|
||||||
|
|
||||||
|
self.state = SimpleNamespace(
|
||||||
|
in_temp=22,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.controller = FakeTionController()
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
operation,
|
||||||
|
):
|
||||||
|
return await operation(
|
||||||
|
self.controller
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_controller():
|
||||||
|
|
||||||
|
qingping = FakeQingpingService()
|
||||||
|
tion = FakeTionService()
|
||||||
|
|
||||||
|
temperature_policy = TemperaturePolicy(
|
||||||
|
TemperatureConfig(
|
||||||
|
hysteresis=0.5,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
controller = AutoController(
|
||||||
|
schedule_service=None,
|
||||||
|
qingping_service=qingping,
|
||||||
|
tion_service=tion,
|
||||||
|
temperature_policy=temperature_policy,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
controller,
|
||||||
|
qingping,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_resolution(
|
||||||
|
target_temp: int,
|
||||||
|
):
|
||||||
|
return SimpleNamespace(
|
||||||
|
auto_target_temp=target_temp,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_heating_sets_tion_target_first():
|
||||||
|
|
||||||
|
controller, qingping, tion = (
|
||||||
|
make_controller()
|
||||||
|
)
|
||||||
|
|
||||||
|
resolution = make_resolution(
|
||||||
|
target_temp=20
|
||||||
|
)
|
||||||
|
|
||||||
|
# Qingping = 19.0
|
||||||
|
# AUTO target = 20
|
||||||
|
#
|
||||||
|
# Нужно греть.
|
||||||
|
#
|
||||||
|
# Сначала Tion.target_temp поднимается
|
||||||
|
# до технического максимума,
|
||||||
|
# затем включается heater.
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
(
|
||||||
|
"set_target_temperature",
|
||||||
|
MAX_TARGET_TEMP,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"heater_on",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._target_temperature
|
||||||
|
== MAX_TARGET_TEMP
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._target_heater
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._auto_heater
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_repeated_heating_does_not_repeat_commands():
|
||||||
|
|
||||||
|
controller, qingping, tion = (
|
||||||
|
make_controller()
|
||||||
|
)
|
||||||
|
|
||||||
|
resolution = make_resolution(
|
||||||
|
target_temp=20
|
||||||
|
)
|
||||||
|
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
tion.controller.calls.clear()
|
||||||
|
|
||||||
|
# Температура всё ещё ниже цели.
|
||||||
|
# AUTO продолжает хотеть нагрев,
|
||||||
|
# но повторно слать команды Tion не нужно.
|
||||||
|
qingping.state.temperature = 19.2
|
||||||
|
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_target_reached_turns_heater_off():
|
||||||
|
|
||||||
|
controller, qingping, tion = (
|
||||||
|
make_controller()
|
||||||
|
)
|
||||||
|
|
||||||
|
resolution = make_resolution(
|
||||||
|
target_temp=20
|
||||||
|
)
|
||||||
|
|
||||||
|
# Сначала включаем нагрев.
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
tion.controller.calls.clear()
|
||||||
|
|
||||||
|
# Цель достигнута.
|
||||||
|
qingping.state.temperature = 20.0
|
||||||
|
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
# MAX_TARGET_TEMP обратно сейчас не меняем.
|
||||||
|
# Достаточно запретить нагрев.
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
(
|
||||||
|
"heater_off",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._auto_heater
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._target_heater
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
await test_heating_sets_tion_target_first()
|
||||||
|
|
||||||
|
await test_repeated_heating_does_not_repeat_commands()
|
||||||
|
|
||||||
|
await test_target_reached_turns_heater_off()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO HEATER TION TARGET "
|
||||||
|
"TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
from app.auto.config import (
|
||||||
|
load_auto_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
BASE_CONFIG = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
check_interval: 5.0
|
||||||
|
|
||||||
|
co2:
|
||||||
|
base_speed: 1
|
||||||
|
hysteresis: 100
|
||||||
|
|
||||||
|
thresholds:
|
||||||
|
- ppm: 800
|
||||||
|
speed: 2
|
||||||
|
|
||||||
|
temperature:
|
||||||
|
hysteresis: 0.5
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(text: str):
|
||||||
|
temp_dir = TemporaryDirectory()
|
||||||
|
|
||||||
|
path = (
|
||||||
|
Path(temp_dir.name)
|
||||||
|
/ "auto.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
path.write_text(
|
||||||
|
text,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
config = load_auto_config(path)
|
||||||
|
|
||||||
|
return temp_dir, config
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_temperature_config():
|
||||||
|
|
||||||
|
temp_dir, config = load_config(
|
||||||
|
BASE_CONFIG
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("VALID TEMPERATURE CONFIG")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"hysteresis:",
|
||||||
|
config.temperature.hysteresis,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
config.temperature.hysteresis
|
||||||
|
== 0.5
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
def test_temperature_config_required():
|
||||||
|
|
||||||
|
config_text = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
check_interval: 5.0
|
||||||
|
|
||||||
|
co2:
|
||||||
|
base_speed: 1
|
||||||
|
hysteresis: 100
|
||||||
|
|
||||||
|
thresholds:
|
||||||
|
- ppm: 800
|
||||||
|
speed: 2
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
temp_dir, _ = load_config(
|
||||||
|
config_text
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as exc:
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("MISSING TEMPERATURE CONFIG")
|
||||||
|
print("=" * 70)
|
||||||
|
print(exc)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
str(exc)
|
||||||
|
== "temperature config is required"
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
else:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
raise AssertionError(
|
||||||
|
"Missing temperature config "
|
||||||
|
"must raise ValueError"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_temperature_must_be_mapping():
|
||||||
|
|
||||||
|
config_text = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
check_interval: 5.0
|
||||||
|
|
||||||
|
co2:
|
||||||
|
base_speed: 1
|
||||||
|
hysteresis: 100
|
||||||
|
|
||||||
|
thresholds:
|
||||||
|
- ppm: 800
|
||||||
|
speed: 2
|
||||||
|
|
||||||
|
temperature: 0.5
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
temp_dir, _ = load_config(
|
||||||
|
config_text
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as exc:
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("TEMPERATURE MUST BE MAPPING")
|
||||||
|
print("=" * 70)
|
||||||
|
print(exc)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
str(exc)
|
||||||
|
== "temperature must be an object"
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
else:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
raise AssertionError(
|
||||||
|
"Invalid temperature mapping "
|
||||||
|
"must raise ValueError"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_temperature_hysteresis_must_be_number():
|
||||||
|
|
||||||
|
config_text = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
check_interval: 5.0
|
||||||
|
|
||||||
|
co2:
|
||||||
|
base_speed: 1
|
||||||
|
hysteresis: 100
|
||||||
|
|
||||||
|
thresholds:
|
||||||
|
- ppm: 800
|
||||||
|
speed: 2
|
||||||
|
|
||||||
|
temperature:
|
||||||
|
hysteresis: abc
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
temp_dir, _ = load_config(
|
||||||
|
config_text
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as exc:
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"HYSTERESIS MUST BE NUMBER"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
print(exc)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
str(exc)
|
||||||
|
== (
|
||||||
|
"temperature.hysteresis "
|
||||||
|
"must be a number"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
else:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
raise AssertionError(
|
||||||
|
"Non-numeric hysteresis "
|
||||||
|
"must raise ValueError"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_temperature_hysteresis_must_not_be_negative():
|
||||||
|
|
||||||
|
config_text = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
check_interval: 5.0
|
||||||
|
|
||||||
|
co2:
|
||||||
|
base_speed: 1
|
||||||
|
hysteresis: 100
|
||||||
|
|
||||||
|
thresholds:
|
||||||
|
- ppm: 800
|
||||||
|
speed: 2
|
||||||
|
|
||||||
|
temperature:
|
||||||
|
hysteresis: -0.1
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
temp_dir, _ = load_config(
|
||||||
|
config_text
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as exc:
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"HYSTERESIS MUST NOT BE NEGATIVE"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
print(exc)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
str(exc)
|
||||||
|
== (
|
||||||
|
"temperature.hysteresis "
|
||||||
|
"must be >= 0"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
else:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
raise AssertionError(
|
||||||
|
"Negative hysteresis "
|
||||||
|
"must raise ValueError"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_temperature_field():
|
||||||
|
|
||||||
|
config_text = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
check_interval: 5.0
|
||||||
|
|
||||||
|
co2:
|
||||||
|
base_speed: 1
|
||||||
|
hysteresis: 100
|
||||||
|
|
||||||
|
thresholds:
|
||||||
|
- ppm: 800
|
||||||
|
speed: 2
|
||||||
|
|
||||||
|
temperature:
|
||||||
|
hysteresis: 0.5
|
||||||
|
something_else: 123
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
temp_dir, _ = load_config(
|
||||||
|
config_text
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as exc:
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"UNKNOWN TEMPERATURE FIELD"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
print(exc)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
str(exc)
|
||||||
|
== (
|
||||||
|
"Unknown temperature config fields: "
|
||||||
|
"['something_else']"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
else:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
raise AssertionError(
|
||||||
|
"Unknown temperature field "
|
||||||
|
"must raise ValueError"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_temperature_is_allowed_top_level_field():
|
||||||
|
|
||||||
|
temp_dir, config = load_config(
|
||||||
|
BASE_CONFIG
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
assert (
|
||||||
|
config.temperature.hysteresis
|
||||||
|
== 0.5
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
|
||||||
|
test_valid_temperature_config()
|
||||||
|
test_temperature_config_required()
|
||||||
|
test_temperature_must_be_mapping()
|
||||||
|
test_temperature_hysteresis_must_be_number()
|
||||||
|
test_temperature_hysteresis_must_not_be_negative()
|
||||||
|
test_unknown_temperature_field()
|
||||||
|
test_temperature_is_allowed_top_level_field()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO TEMPERATURE CONFIG "
|
||||||
|
"TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
from app.auto.config import (
|
||||||
|
TemperatureConfig,
|
||||||
|
)
|
||||||
|
from app.auto.temperature_policy import (
|
||||||
|
TemperaturePolicy,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_policy() -> TemperaturePolicy:
|
||||||
|
|
||||||
|
return TemperaturePolicy(
|
||||||
|
TemperatureConfig(
|
||||||
|
hysteresis=0.5,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_heater_off_below_lower_limit():
|
||||||
|
|
||||||
|
policy = make_policy()
|
||||||
|
|
||||||
|
result = policy.heater_required(
|
||||||
|
temperature=19.4,
|
||||||
|
target_temp=20.0,
|
||||||
|
heater_on=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_heater_off_at_lower_limit():
|
||||||
|
|
||||||
|
policy = make_policy()
|
||||||
|
|
||||||
|
result = policy.heater_required(
|
||||||
|
temperature=19.5,
|
||||||
|
target_temp=20.0,
|
||||||
|
heater_on=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_heater_off_inside_hysteresis():
|
||||||
|
|
||||||
|
policy = make_policy()
|
||||||
|
|
||||||
|
result = policy.heater_required(
|
||||||
|
temperature=19.8,
|
||||||
|
target_temp=20.0,
|
||||||
|
heater_on=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_heater_on_below_target():
|
||||||
|
|
||||||
|
policy = make_policy()
|
||||||
|
|
||||||
|
result = policy.heater_required(
|
||||||
|
temperature=19.8,
|
||||||
|
target_temp=20.0,
|
||||||
|
heater_on=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_heater_on_at_target():
|
||||||
|
|
||||||
|
policy = make_policy()
|
||||||
|
|
||||||
|
result = policy.heater_required(
|
||||||
|
temperature=20.0,
|
||||||
|
target_temp=20.0,
|
||||||
|
heater_on=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_heater_on_above_target():
|
||||||
|
|
||||||
|
policy = make_policy()
|
||||||
|
|
||||||
|
result = policy.heater_required(
|
||||||
|
temperature=20.2,
|
||||||
|
target_temp=20.0,
|
||||||
|
heater_on=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
|
||||||
|
test_heater_off_below_lower_limit()
|
||||||
|
test_heater_off_at_lower_limit()
|
||||||
|
test_heater_off_inside_hysteresis()
|
||||||
|
|
||||||
|
test_heater_on_below_target()
|
||||||
|
test_heater_on_at_target()
|
||||||
|
test_heater_on_above_target()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO TEMPERATURE POLICY "
|
||||||
|
"TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from app.auto.controller import AutoController
|
||||||
|
|
||||||
|
|
||||||
|
class FakeQingpingService:
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
online: bool,
|
||||||
|
temperature,
|
||||||
|
):
|
||||||
|
self.online = online
|
||||||
|
|
||||||
|
self.state = SimpleNamespace(
|
||||||
|
temperature=temperature,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionService:
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
online: bool,
|
||||||
|
in_temp,
|
||||||
|
):
|
||||||
|
self.online = online
|
||||||
|
|
||||||
|
self.state = SimpleNamespace(
|
||||||
|
in_temp=in_temp,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_controller(
|
||||||
|
*,
|
||||||
|
qingping_online: bool,
|
||||||
|
qingping_temperature,
|
||||||
|
tion_online: bool,
|
||||||
|
tion_temperature,
|
||||||
|
):
|
||||||
|
|
||||||
|
qingping = FakeQingpingService(
|
||||||
|
online=qingping_online,
|
||||||
|
temperature=qingping_temperature,
|
||||||
|
)
|
||||||
|
|
||||||
|
tion = FakeTionService(
|
||||||
|
online=tion_online,
|
||||||
|
in_temp=tion_temperature,
|
||||||
|
)
|
||||||
|
|
||||||
|
return AutoController(
|
||||||
|
schedule_service=None,
|
||||||
|
qingping_service=qingping,
|
||||||
|
tion_service=tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_qingping_priority():
|
||||||
|
|
||||||
|
controller = make_controller(
|
||||||
|
qingping_online=True,
|
||||||
|
qingping_temperature=19.7,
|
||||||
|
tion_online=True,
|
||||||
|
tion_temperature=18,
|
||||||
|
)
|
||||||
|
|
||||||
|
temperature, source = (
|
||||||
|
controller._get_temperature()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert temperature == 19.7
|
||||||
|
assert source == "qingping"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tion_fallback():
|
||||||
|
|
||||||
|
controller = make_controller(
|
||||||
|
qingping_online=False,
|
||||||
|
qingping_temperature=19.7,
|
||||||
|
tion_online=True,
|
||||||
|
tion_temperature=18,
|
||||||
|
)
|
||||||
|
|
||||||
|
temperature, source = (
|
||||||
|
controller._get_temperature()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert temperature == 18
|
||||||
|
assert source == "tion"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tion_fallback_when_qingping_temperature_missing():
|
||||||
|
|
||||||
|
controller = make_controller(
|
||||||
|
qingping_online=True,
|
||||||
|
qingping_temperature=None,
|
||||||
|
tion_online=True,
|
||||||
|
tion_temperature=18,
|
||||||
|
)
|
||||||
|
|
||||||
|
temperature, source = (
|
||||||
|
controller._get_temperature()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert temperature == 18
|
||||||
|
assert source == "tion"
|
||||||
|
|
||||||
|
|
||||||
|
def test_offline_tion_is_not_used():
|
||||||
|
|
||||||
|
controller = make_controller(
|
||||||
|
qingping_online=False,
|
||||||
|
qingping_temperature=None,
|
||||||
|
tion_online=False,
|
||||||
|
|
||||||
|
# Значение специально оставляем.
|
||||||
|
# Оно имитирует старый state Tion.
|
||||||
|
tion_temperature=18,
|
||||||
|
)
|
||||||
|
|
||||||
|
temperature, source = (
|
||||||
|
controller._get_temperature()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert temperature is None
|
||||||
|
assert source is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_tion_temperature():
|
||||||
|
|
||||||
|
controller = make_controller(
|
||||||
|
qingping_online=False,
|
||||||
|
qingping_temperature=None,
|
||||||
|
tion_online=True,
|
||||||
|
tion_temperature=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
temperature, source = (
|
||||||
|
controller._get_temperature()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert temperature is None
|
||||||
|
assert source is None
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
|
||||||
|
test_qingping_priority()
|
||||||
|
|
||||||
|
test_tion_fallback()
|
||||||
|
|
||||||
|
test_tion_fallback_when_qingping_temperature_missing()
|
||||||
|
|
||||||
|
test_offline_tion_is_not_used()
|
||||||
|
|
||||||
|
test_missing_tion_temperature()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO TEMPERATURE SOURCE "
|
||||||
|
"TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import asyncio
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from schedule import (
|
||||||
|
ScheduleService,
|
||||||
|
load_schedule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SCHEDULE_YAML = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
enabled: true
|
||||||
|
timezone: local
|
||||||
|
|
||||||
|
templates:
|
||||||
|
|
||||||
|
test:
|
||||||
|
|
||||||
|
- time: "00:00"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
power: off
|
||||||
|
|
||||||
|
- time: "09:00"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
power: on
|
||||||
|
speed: 1
|
||||||
|
heater: on
|
||||||
|
target_temp: 20
|
||||||
|
|
||||||
|
- time: "10:00"
|
||||||
|
action:
|
||||||
|
type: auto
|
||||||
|
speed: 3
|
||||||
|
|
||||||
|
- time: "12:00"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
speed: 2
|
||||||
|
heater: off
|
||||||
|
|
||||||
|
days:
|
||||||
|
mon: test
|
||||||
|
tue: test
|
||||||
|
wed: test
|
||||||
|
thu: test
|
||||||
|
fri: test
|
||||||
|
sat: test
|
||||||
|
sun: test
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionController:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def power_on(self):
|
||||||
|
self.calls.append(
|
||||||
|
("power_on", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def power_off(self):
|
||||||
|
self.calls.append(
|
||||||
|
("power_off", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_speed(self, speed: int):
|
||||||
|
self.calls.append(
|
||||||
|
("set_speed", speed)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Оставляем оба варианта управления heater,
|
||||||
|
# чтобы fake соответствовал фактическому
|
||||||
|
# интерфейсу TionController.
|
||||||
|
async def heater_on(self):
|
||||||
|
self.calls.append(
|
||||||
|
("heater_on", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def heater_off(self):
|
||||||
|
self.calls.append(
|
||||||
|
("heater_off", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_heater(self, enabled: bool):
|
||||||
|
self.calls.append(
|
||||||
|
("set_heater", enabled)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_target_temperature(
|
||||||
|
self,
|
||||||
|
temperature: int,
|
||||||
|
):
|
||||||
|
self.calls.append(
|
||||||
|
(
|
||||||
|
"set_target_temperature",
|
||||||
|
temperature,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.controller = FakeTionController()
|
||||||
|
|
||||||
|
async def execute(self, operation):
|
||||||
|
return await operation(
|
||||||
|
self.controller
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_test_schedule():
|
||||||
|
temp_dir = TemporaryDirectory()
|
||||||
|
|
||||||
|
path = (
|
||||||
|
Path(temp_dir.name)
|
||||||
|
/ "schedule.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
path.write_text(
|
||||||
|
SCHEDULE_YAML,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
config = load_schedule(path)
|
||||||
|
|
||||||
|
return temp_dir, config
|
||||||
|
|
||||||
|
|
||||||
|
def heater_calls(calls):
|
||||||
|
return [
|
||||||
|
call
|
||||||
|
for call in calls
|
||||||
|
if call[0] in {
|
||||||
|
"heater_on",
|
||||||
|
"heater_off",
|
||||||
|
"set_heater",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def process_at(
|
||||||
|
service: ScheduleService,
|
||||||
|
when: datetime,
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"schedule.service.datetime",
|
||||||
|
wraps=datetime,
|
||||||
|
) as mocked_datetime:
|
||||||
|
|
||||||
|
mocked_datetime.now.return_value = when
|
||||||
|
|
||||||
|
await service._process()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_auto_does_not_control_heater():
|
||||||
|
|
||||||
|
temp_dir, config = (
|
||||||
|
load_test_schedule()
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
tion = FakeTionService()
|
||||||
|
|
||||||
|
service = ScheduleService(
|
||||||
|
config,
|
||||||
|
tion=tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# 09:30
|
||||||
|
#
|
||||||
|
# Обычный SET.
|
||||||
|
# heater=on должен принадлежать ScheduleService.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
await process_at(
|
||||||
|
service,
|
||||||
|
datetime(
|
||||||
|
2026,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
9,
|
||||||
|
30,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
"09:30 SET calls:",
|
||||||
|
tion.controller.calls,
|
||||||
|
)
|
||||||
|
|
||||||
|
calls = heater_calls(
|
||||||
|
tion.controller.calls
|
||||||
|
)
|
||||||
|
|
||||||
|
assert calls != [], (
|
||||||
|
"SET must control heater"
|
||||||
|
)
|
||||||
|
|
||||||
|
tion.controller.calls.clear()
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# 10:30
|
||||||
|
#
|
||||||
|
# AUTO.
|
||||||
|
#
|
||||||
|
# В накопленных scheduled_settings heater всё ещё
|
||||||
|
# должен быть True, потому что последняя SET-точка
|
||||||
|
# включила heater.
|
||||||
|
#
|
||||||
|
# Но ScheduleService НЕ должен отправлять
|
||||||
|
# никаких heater-команд.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
resolution = service.resolve(
|
||||||
|
datetime(
|
||||||
|
2026,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
10,
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
"10:30 scheduled heater:",
|
||||||
|
resolution.scheduled_settings.heater,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.auto_active
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.scheduled_settings.heater
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
await process_at(
|
||||||
|
service,
|
||||||
|
datetime(
|
||||||
|
2026,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
10,
|
||||||
|
30,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"10:30 AUTO calls:",
|
||||||
|
tion.controller.calls,
|
||||||
|
)
|
||||||
|
|
||||||
|
calls = heater_calls(
|
||||||
|
tion.controller.calls
|
||||||
|
)
|
||||||
|
|
||||||
|
assert calls == [], (
|
||||||
|
"ScheduleService must not control "
|
||||||
|
"heater during AUTO"
|
||||||
|
)
|
||||||
|
|
||||||
|
tion.controller.calls.clear()
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# 12:30
|
||||||
|
#
|
||||||
|
# AUTO закончился.
|
||||||
|
# heater=off снова принадлежит ScheduleService.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
await process_at(
|
||||||
|
service,
|
||||||
|
datetime(
|
||||||
|
2026,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
12,
|
||||||
|
30,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
"12:30 SET calls:",
|
||||||
|
tion.controller.calls,
|
||||||
|
)
|
||||||
|
|
||||||
|
calls = heater_calls(
|
||||||
|
tion.controller.calls
|
||||||
|
)
|
||||||
|
|
||||||
|
assert calls != [], (
|
||||||
|
"ScheduleService must regain heater "
|
||||||
|
"control after AUTO"
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
await test_auto_does_not_control_heater()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO HEATER OWNERSHIP TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
from schedule import (
|
||||||
|
ScheduleActionType,
|
||||||
|
load_schedule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
VALID_YAML = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
enabled: true
|
||||||
|
timezone: local
|
||||||
|
|
||||||
|
templates:
|
||||||
|
|
||||||
|
test:
|
||||||
|
|
||||||
|
- time: "00:00"
|
||||||
|
action:
|
||||||
|
type: auto
|
||||||
|
speed: 3
|
||||||
|
target_temp: 20
|
||||||
|
|
||||||
|
days:
|
||||||
|
mon: test
|
||||||
|
tue: test
|
||||||
|
wed: test
|
||||||
|
thu: test
|
||||||
|
fri: test
|
||||||
|
sat: test
|
||||||
|
sun: test
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(text: str):
|
||||||
|
|
||||||
|
temp_dir = TemporaryDirectory()
|
||||||
|
|
||||||
|
path = (
|
||||||
|
Path(temp_dir.name)
|
||||||
|
/ "schedule.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
path.write_text(
|
||||||
|
text,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
config = load_schedule(path)
|
||||||
|
|
||||||
|
return temp_dir, config
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_target_temperature():
|
||||||
|
|
||||||
|
temp_dir, config = load_config(
|
||||||
|
VALID_YAML
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
point = (
|
||||||
|
config.templates["test"][0]
|
||||||
|
)
|
||||||
|
|
||||||
|
action = point.action
|
||||||
|
|
||||||
|
assert (
|
||||||
|
action.type
|
||||||
|
== ScheduleActionType.AUTO
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
action.settings.speed
|
||||||
|
== 3
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
action.settings.target_temp
|
||||||
|
== 20
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_rejects_heater():
|
||||||
|
|
||||||
|
config_text = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
enabled: true
|
||||||
|
timezone: local
|
||||||
|
|
||||||
|
templates:
|
||||||
|
|
||||||
|
test:
|
||||||
|
|
||||||
|
- time: "00:00"
|
||||||
|
action:
|
||||||
|
type: auto
|
||||||
|
speed: 3
|
||||||
|
target_temp: 20
|
||||||
|
heater: on
|
||||||
|
|
||||||
|
days:
|
||||||
|
mon: test
|
||||||
|
tue: test
|
||||||
|
wed: test
|
||||||
|
thu: test
|
||||||
|
fri: test
|
||||||
|
sat: test
|
||||||
|
sun: test
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
temp_dir, _ = load_config(
|
||||||
|
config_text
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as exc:
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
"Expected error:",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "heater" in str(exc)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
else:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
raise AssertionError(
|
||||||
|
"AUTO heater field must "
|
||||||
|
"raise ValueError"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_speed_is_required():
|
||||||
|
|
||||||
|
config_text = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
enabled: true
|
||||||
|
timezone: local
|
||||||
|
|
||||||
|
templates:
|
||||||
|
|
||||||
|
test:
|
||||||
|
|
||||||
|
- time: "00:00"
|
||||||
|
action:
|
||||||
|
type: auto
|
||||||
|
target_temp: 20
|
||||||
|
|
||||||
|
days:
|
||||||
|
mon: test
|
||||||
|
tue: test
|
||||||
|
wed: test
|
||||||
|
thu: test
|
||||||
|
fri: test
|
||||||
|
sat: test
|
||||||
|
sun: test
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
temp_dir, _ = load_config(
|
||||||
|
config_text
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as exc:
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
"Expected error:",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
str(exc)
|
||||||
|
== "AUTO action requires 'speed'"
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
else:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
raise AssertionError(
|
||||||
|
"AUTO without speed must "
|
||||||
|
"raise ValueError"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
|
||||||
|
test_auto_target_temperature()
|
||||||
|
|
||||||
|
test_auto_rejects_heater()
|
||||||
|
|
||||||
|
test_auto_speed_is_required()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO TEMPERATURE PARSER "
|
||||||
|
"TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
from schedule import (
|
||||||
|
ScheduleService,
|
||||||
|
load_schedule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SCHEDULE_YAML = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
enabled: true
|
||||||
|
timezone: local
|
||||||
|
|
||||||
|
templates:
|
||||||
|
|
||||||
|
test:
|
||||||
|
|
||||||
|
- time: "09:00"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
power: on
|
||||||
|
speed: 1
|
||||||
|
target_temp: 23
|
||||||
|
|
||||||
|
- time: "10:00"
|
||||||
|
action:
|
||||||
|
type: auto
|
||||||
|
speed: 3
|
||||||
|
target_temp: 20
|
||||||
|
|
||||||
|
- time: "12:00"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
speed: 2
|
||||||
|
target_temp: 22
|
||||||
|
|
||||||
|
days:
|
||||||
|
mon: test
|
||||||
|
tue: test
|
||||||
|
wed: test
|
||||||
|
thu: test
|
||||||
|
fri: test
|
||||||
|
sat: test
|
||||||
|
sun: test
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def load_test_schedule():
|
||||||
|
|
||||||
|
temp_dir = TemporaryDirectory()
|
||||||
|
|
||||||
|
path = (
|
||||||
|
Path(temp_dir.name)
|
||||||
|
/ "schedule.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
path.write_text(
|
||||||
|
SCHEDULE_YAML,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
config = load_schedule(path)
|
||||||
|
|
||||||
|
return temp_dir, config
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_temperature_resolution():
|
||||||
|
|
||||||
|
temp_dir, config = (
|
||||||
|
load_test_schedule()
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
service = ScheduleService(
|
||||||
|
config
|
||||||
|
)
|
||||||
|
|
||||||
|
# ----------------------------------------------
|
||||||
|
# 09:30 — обычный SET
|
||||||
|
# ----------------------------------------------
|
||||||
|
|
||||||
|
resolution = service.resolve(
|
||||||
|
datetime(
|
||||||
|
2026,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
9,
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.auto_active
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.scheduled_settings.target_temp
|
||||||
|
== 23
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.auto_target_temp
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
# ----------------------------------------------
|
||||||
|
# 10:30 — AUTO
|
||||||
|
#
|
||||||
|
# Последний SET по-прежнему хранит 23,
|
||||||
|
# но AUTO явно требует 20.
|
||||||
|
# ----------------------------------------------
|
||||||
|
|
||||||
|
resolution = service.resolve(
|
||||||
|
datetime(
|
||||||
|
2026,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
10,
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.auto_active
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.auto_fallback_speed
|
||||||
|
== 3
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.scheduled_settings.target_temp
|
||||||
|
== 23
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.auto_target_temp
|
||||||
|
== 20
|
||||||
|
)
|
||||||
|
|
||||||
|
# ----------------------------------------------
|
||||||
|
# 12:30 — снова SET
|
||||||
|
#
|
||||||
|
# AUTO закончился.
|
||||||
|
# ----------------------------------------------
|
||||||
|
|
||||||
|
resolution = service.resolve(
|
||||||
|
datetime(
|
||||||
|
2026,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
12,
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.auto_active
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.scheduled_settings.target_temp
|
||||||
|
== 22
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.auto_target_temp
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
|
||||||
|
test_auto_temperature_resolution()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO TEMPERATURE RESOLUTION "
|
||||||
|
"TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user