diff --git a/app/api.py b/app/api.py index 9ae6d8d..e609b61 100644 --- a/app/api.py +++ b/app/api.py @@ -26,6 +26,8 @@ from app.auto import ( load_auto_config, ) +from app.auto.temperature_policy import TemperaturePolicy + from app.tion import ( TionController, TionService, @@ -108,9 +110,7 @@ async def lifespan(app: FastAPI): schedule_load_error = None try: - auto_config = load_auto_config( - AUTO_CONFIG_FILE - ) + auto_config = load_auto_config(AUTO_CONFIG_FILE) auto_policy = Co2SpeedPolicy( base_speed=( @@ -124,14 +124,15 @@ async def lifespan(app: FastAPI): ), ) - auto_interval = ( - auto_config.check_interval - ) + temperature_policy = TemperaturePolicy(auto_config.temperature) + + auto_interval = (auto_config.check_interval) auto_load_error = None except Exception as exc: auto_policy = None + temperature_policy = None # Безопасный встроенный интервал нужен, # потому что auto.yaml сейчас недоступен. @@ -146,6 +147,7 @@ async def lifespan(app: FastAPI): qingping_service=qingping_service, tion_service=service, policy=auto_policy, + temperature_policy=temperature_policy, interval=auto_interval, ) @@ -336,6 +338,8 @@ def get_schedule_status() -> dict: "current": None, "next": None, "auto_active": False, + "auto_fallback_speed": None, + "auto_target_temp": None, "override_active": False, "override_until": None, "override_settings": {}, @@ -393,11 +397,12 @@ def get_schedule_status() -> dict: "next": occurrence_to_dict(resolution.next), "auto_active": resolution.auto_active, - "scheduled_settings": ( - resolution - .scheduled_settings - .to_dict() - ), + + "auto_fallback_speed": resolution.auto_fallback_speed, + + "auto_target_temp": resolution.auto_target_temp, + + "scheduled_settings": resolution.scheduled_settings.to_dict(), "override_active":schedule_service.override_active, diff --git a/app/auto/config.py b/app/auto/config.py index 535160d..285fcf9 100644 --- a/app/auto/config.py +++ b/app/auto/config.py @@ -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, - ) \ No newline at end of file + temperature=temperature, + ) + diff --git a/app/auto/controller.py b/app/auto/controller.py index 32d3c88..2740690 100644 --- a/app/auto/controller.py +++ b/app/auto/controller.py @@ -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, } diff --git a/app/auto/temperature_policy.py b/app/auto/temperature_policy.py new file mode 100644 index 0000000..72ecf0a --- /dev/null +++ b/app/auto/temperature_policy.py @@ -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 + ) + ) \ No newline at end of file diff --git a/config/auto.yaml b/config/auto.yaml index bb936b2..0245bb5 100644 --- a/config/auto.yaml +++ b/config/auto.yaml @@ -20,4 +20,7 @@ co2: speed: 5 - ppm: 2000 - speed: 6 \ No newline at end of file + speed: 6 + +temperature: + hysteresis: 0.5 \ No newline at end of file diff --git a/config/schedule.yaml b/config/schedule.yaml index a1fc9d9..eb48536 100644 --- a/config/schedule.yaml +++ b/config/schedule.yaml @@ -17,25 +17,25 @@ templates: target_temp: 20 mode: outside - - time: "16:17" + - time: "18:01" action: type: set speed: 1 - - time: "16:18" + - time: "18:07" action: type: auto speed: 1 + target_temp: 30 - - time: "16:20" + - time: "18:08" action: type: set + target_temp: 30 speed: 6 - - time: "17:00" - action: - type: set - speed: 3 + + - time: "19:00" action: diff --git a/schedule/loader.py b/schedule/loader.py index 1b872a1..b289c72 100644 --- a/schedule/loader.py +++ b/schedule/loader.py @@ -196,9 +196,7 @@ def _parse_action( action_type_raw = data.get("type") try: - action_type = ScheduleActionType( - action_type_raw - ) + action_type = ScheduleActionType(action_type_raw) except ValueError as exc: raise ValueError( @@ -208,32 +206,38 @@ def _parse_action( if action_type == ScheduleActionType.AUTO: - extra = set(data) - { + allowed_fields = { "type", "speed", + "target_temp", } + extra = set(data) - allowed_fields + if extra: raise ValueError( "AUTO action supports only " - f"'speed': {sorted(extra)}" + "'speed' and 'target_temp': " + f"{sorted(extra)}" ) if "speed" not in data: raise ValueError( - "AUTO action must contain " - "fallback speed" + "AUTO action requires 'speed'" ) - settings = _parse_settings( - { - "speed": data["speed"], - } - ) + settings_data = { + "speed": data["speed"], + } + + if "target_temp" in data: + settings_data["target_temp"] = ( + data["target_temp"] + ) return ScheduleAction( type=ScheduleActionType.AUTO, - settings=settings, + settings=_parse_settings(settings_data), ) settings_data = { diff --git a/schedule/models.py b/schedule/models.py index 2ba81a9..ee0316f 100644 --- a/schedule/models.py +++ b/schedule/models.py @@ -117,4 +117,5 @@ class ScheduleResolution: scheduled_settings: ScheduledSettings auto_active: bool - auto_fallback_speed: int | None \ No newline at end of file + auto_fallback_speed: int | None + auto_target_temp: int | None = None \ No newline at end of file diff --git a/schedule/service.py b/schedule/service.py index 4a8355a..465ec2e 100644 --- a/schedule/service.py +++ b/schedule/service.py @@ -191,15 +191,9 @@ class ScheduleService: # Недели назад достаточно, # поскольку расписание повторяется каждые 7 дней. - start_date = ( - now.date() - - timedelta(days=7) - ) + start_date = now.date() - timedelta(days=7) - end_date = ( - now.date() - + timedelta(days=7) - ) + end_date = now.date() + timedelta(days=7) occurrences = self._build_occurrences( start_date, @@ -231,17 +225,14 @@ class ScheduleService: ) ) - auto_active = ( - current.point.action.type - == ScheduleActionType.AUTO - ) + auto_active = current.point.action.type == ScheduleActionType.AUTO auto_fallback_speed = None + auto_target_temp = None if auto_active: - auto_fallback_speed = ( - current.point.action.settings.speed - ) + auto_fallback_speed = current.point.action.settings.speed + auto_target_temp = current.point.action.settings.target_temp return ScheduleResolution( enabled=True, @@ -250,6 +241,7 @@ class ScheduleService: scheduled_settings=scheduled_settings, auto_active=auto_active, auto_fallback_speed=auto_fallback_speed, + auto_target_temp=auto_target_temp, ) def _build_occurrences( @@ -384,39 +376,6 @@ class ScheduleService: except Exception as 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: now = datetime.now() @@ -477,7 +436,12 @@ class ScheduleService: # Остальные параметры расписания # (power, heater, temperature, mode...) # должны продолжать работать. - settings = replace(settings, speed=None) + settings = replace( + settings, + speed=None, + heater=None, + target_temp=None, + ) await self._apply_settings(settings) diff --git a/tests/test_auto_heater_command.py b/tests/test_auto_heater_command.py new file mode 100644 index 0000000..87d60de --- /dev/null +++ b/tests/test_auto_heater_command.py @@ -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()) \ No newline at end of file diff --git a/tests/test_auto_heater_process.py b/tests/test_auto_heater_process.py new file mode 100644 index 0000000..1b29db6 --- /dev/null +++ b/tests/test_auto_heater_process.py @@ -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()) \ No newline at end of file diff --git a/tests/test_auto_heater_target_temperature.py b/tests/test_auto_heater_target_temperature.py new file mode 100644 index 0000000..15cbaf6 --- /dev/null +++ b/tests/test_auto_heater_target_temperature.py @@ -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()) \ No newline at end of file diff --git a/tests/test_auto_heater_tion_target.py b/tests/test_auto_heater_tion_target.py new file mode 100644 index 0000000..a1e886d --- /dev/null +++ b/tests/test_auto_heater_tion_target.py @@ -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()) \ No newline at end of file diff --git a/tests/test_auto_temperature_config.py b/tests/test_auto_temperature_config.py new file mode 100644 index 0000000..ca5651f --- /dev/null +++ b/tests/test_auto_temperature_config.py @@ -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() \ No newline at end of file diff --git a/tests/test_auto_temperature_policy.py b/tests/test_auto_temperature_policy.py new file mode 100644 index 0000000..d30893e --- /dev/null +++ b/tests/test_auto_temperature_policy.py @@ -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() \ No newline at end of file diff --git a/tests/test_auto_temperature_source.py b/tests/test_auto_temperature_source.py new file mode 100644 index 0000000..062ce50 --- /dev/null +++ b/tests/test_auto_temperature_source.py @@ -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() \ No newline at end of file diff --git a/tests/test_schedule_auto_heater.py b/tests/test_schedule_auto_heater.py new file mode 100644 index 0000000..a54c553 --- /dev/null +++ b/tests/test_schedule_auto_heater.py @@ -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()) \ No newline at end of file diff --git a/tests/test_schedule_auto_temperature_parser.py b/tests/test_schedule_auto_temperature_parser.py new file mode 100644 index 0000000..e4750a3 --- /dev/null +++ b/tests/test_schedule_auto_temperature_parser.py @@ -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() \ No newline at end of file diff --git a/tests/test_schedule_auto_temperature_resolution.py b/tests/test_schedule_auto_temperature_resolution.py new file mode 100644 index 0000000..a39bd17 --- /dev/null +++ b/tests/test_schedule_auto_temperature_resolution.py @@ -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() \ No newline at end of file