diff --git a/.idea/TionController.iml b/.idea/TionController.iml index b2ea363..ad3df1f 100644 --- a/.idea/TionController.iml +++ b/.idea/TionController.iml @@ -2,6 +2,7 @@ + diff --git a/app/api.py b/app/api.py new file mode 100644 index 0000000..5f67b0f --- /dev/null +++ b/app/api.py @@ -0,0 +1,779 @@ +import logging +from contextlib import asynccontextmanager +from typing import Literal, Annotated +from fastapi import FastAPI, Request, HTTPException, Path +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from pathlib import Path as FilePath +from app.qingping.service import QingpingService + +from app.my_dataclasses import ( + TION_MAC, + QINGPING_MAC, + QINGPING_MQTT_HOST, + QINGPING_MQTT_PORT, + MIN_FAN_SPEED, + MAX_FAN_SPEED, + MIN_TARGET_TEMP, + MAX_TARGET_TEMP, + AIR_MODE_OUTSIDE, + AIR_MODE_RECIRCULATION, + SCHEDULE_FILE, + AUTO_CONFIG_FILE, +) + +from app.auto import ( + AutoController, + Co2SpeedPolicy, + load_auto_config, +) + +from app.auto.temperature_policy import TemperaturePolicy + +from app.tion import ( + TionController, + TionService, +) + +from schedule import ( + ScheduleService, + ScheduledSettings, + load_schedule, +) + +SCHEDULE_STATE_FILE = ( + FilePath(SCHEDULE_FILE) + .with_name("schedule_state.json") +) + + + +def configure_logging() -> None: + noisy_loggers = ( + "asyncio", + "bleak", + "bleak.backends", + "bleak.backends.winrt", + "bleak.backends.winrt.client", + "tion_btle", + "tion_btle.tion", + "tion_btle.s4", + "tion_btle.light_family", + ) + + for logger_name in noisy_loggers: + logging.getLogger(logger_name).setLevel(logging.WARNING) + +configure_logging() + +# ---------------------------------------------------------------------- +# Tion +# ---------------------------------------------------------------------- + +controller = TionController(TION_MAC) + +service = TionService( + controller, + poll_interval=5, +) + +qingping_service = QingpingService( + host=QINGPING_MQTT_HOST, + port=QINGPING_MQTT_PORT, + mac=QINGPING_MAC, +) + +schedule_service: ScheduleService | None = None +schedule_load_error: str | None = None + +auto_controller: AutoController | None = None +auto_load_error: str | None = None + +# ---------------------------------------------------------------------- +# Application lifecycle +# ---------------------------------------------------------------------- + +@asynccontextmanager +async def lifespan(app: FastAPI): + global schedule_service + global schedule_load_error + global auto_controller + global auto_load_error + + await service.start() + await qingping_service.start() + + try: + try: + config = load_schedule(SCHEDULE_FILE) + + schedule_service = ScheduleService( + config, + service, + check_interval=5, + state_path=SCHEDULE_STATE_FILE, + ) + + await schedule_service.start() + schedule_load_error = None + + try: + auto_config = load_auto_config(AUTO_CONFIG_FILE) + + auto_policy = Co2SpeedPolicy( + base_speed=( + auto_config.co2.base_speed + ), + thresholds=( + auto_config.co2.thresholds + ), + hysteresis=( + auto_config.co2.hysteresis + ), + ) + + 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 сейчас недоступен. + auto_interval = 5.0 + + auto_load_error = ( + f"{type(exc).__name__}: {exc}" + ) + + auto_controller = AutoController( + schedule_service=schedule_service, + qingping_service=qingping_service, + tion_service=service, + policy=auto_policy, + temperature_policy=temperature_policy, + interval=auto_interval, + ) + + await auto_controller.start() + + except Exception as exc: + schedule_service = None + schedule_load_error = f"{type(exc).__name__}: {exc}" + yield + + finally: + if schedule_service is not None: + await schedule_service.stop() + + await qingping_service.stop() + await service.stop() + + +# ---------------------------------------------------------------------- +# FastAPI +# ---------------------------------------------------------------------- + +app = FastAPI( + title="Tion Controller", + version="0.1.0", + lifespan=lifespan, +) + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler( + request: Request, + exc: RequestValidationError, +): + return JSONResponse( + status_code=400, + content={ + "message": "Invalid request", + "errors": exc.errors(), + }, + ) + + +# ---------------------------------------------------------------------- +# Helpers +# ---------------------------------------------------------------------- + +def get_status() -> dict: + """ + Получить текущее состояние сервиса. + + Bluetooth-запрос здесь не выполняется. + """ + + state = service.state + + return { + "online": service.online, + "running": service.running, + + "last_seen": ( + service.last_seen.isoformat() + if service.last_seen is not None + else None + ), + + "last_error": service.last_error, + + "tion": ( + state.to_dict() + if state is not None + else None + ), + "auto": get_auto_status(), + + "qingping": qingping_service.status(), + + "schedule": get_schedule_status(), + } + +def get_effective_speed() -> int: + + if ( + schedule_service is not None + and schedule_service.override_active + and schedule_service.override_settings.speed is not None + ): + return schedule_service.override_settings.speed + + state = service.state + + if state is None: + raise HTTPException( + status_code=503, + detail={ + "message": "Tion state unavailable", + }, + ) + + return state.fan_speed + +def get_effective_temperature() -> int: + + if ( + schedule_service is not None + and schedule_service.override_active + and schedule_service.override_settings.target_temp is not None + ): + return ( + schedule_service + .override_settings + .target_temp + ) + + state = service.state + + if state is None: + raise HTTPException( + status_code=503, + detail={ + "message": "Tion state unavailable", + }, + ) + + return state.target_temp + +async def execute_command(operation, override: ScheduledSettings | None = None): + + try: + # При активном расписании ручная команда + # становится temporary override. + # + # Но если расписание paused, + # мы находимся в полноценном ручном режиме, + # поэтому команда идёт напрямую в Tion. + if ( + override is not None + and schedule_service is not None + and schedule_service.running + and schedule_service.config.enabled + and not schedule_service.paused + ): + await schedule_service.apply_override( + override + ) + + else: + await service.execute( + operation + ) + + except Exception as exc: + raise HTTPException( + status_code=503, + detail={ + "message": "Tion unavailable", + "error": ( + f"{type(exc).__name__}: {exc}" + ), + }, + ) from exc + + return get_status() + + +def occurrence_to_dict(occurrence) -> dict | None: + + if occurrence is None: + return None + + return { + "when": occurrence.when.isoformat(), + "weekday": occurrence.weekday, + "template": occurrence.template, + "action": occurrence.point.action.type.value, + "settings": ( + occurrence + .point + .action + .settings + .to_dict() + ), + } + + +def get_schedule_status() -> dict: + + if schedule_service is None: + return { + "auto_active": False, + "auto_fallback_speed": None, + "auto_target_temp": None, + "available": False, + "current_action": None, + "current": None, + "enabled": False, + "next": None, + "override_active": False, + "override_until": None, + "override_settings": {}, + "paused": False, + "running": False, + "scheduled_settings": {}, + "last_error": schedule_load_error, + } + + resolution = schedule_service.resolve() + + current = resolution.current + + current_time = ( + current.when.strftime("%H:%M") + if current is not None + else None + ) + + next_time = ( + resolution.next.when.strftime("%H:%M") + if resolution.next is not None + else None + ) + + next_action = ( + resolution.next.point.action.type.value + if resolution.next is not None + else None + ) + + override_until_time = ( + schedule_service.override_until.strftime("%H:%M") + if schedule_service.override_until + else None + ) + + current_action = None + + if current is not None: + current_action = (current.point.action.type.value) + + return { + "available": True, + "enabled": resolution.enabled, + "running": schedule_service.running, + + "paused": schedule_service.paused, + + "current_action": current_action, + "current_time": current_time, + + "next_time": next_time, + "next_action": next_action, + + "override_until_time": override_until_time, + + "current": occurrence_to_dict(resolution.current), + "next": occurrence_to_dict(resolution.next), + + "auto_active": resolution.auto_active, + + "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, + + "override_until": ( + schedule_service + .override_until + .isoformat() + if schedule_service.override_until + else None + ), + + "override_settings": ( + schedule_service + .override_settings + .to_dict() + ), + + "last_error": ( + schedule_service.last_error + ), + } + + +def get_auto_status() -> dict: + + if auto_controller is None: + return { + "available": False, + "state": "unavailable", + "reason": None, + "target_speed": None, + "auto_speed": None, + "last_error": None, + "config_error": auto_load_error, + } + + return { + "available": True, + **auto_controller.status(), + "config_error": ( + auto_load_error + ), + } + +# ---------------------------------------------------------------------- +# Status +# ---------------------------------------------------------------------- + +@app.get("/api/status") +async def status(): + return get_status() + + +# ---------------------------------------------------------------------- +# Power +# ---------------------------------------------------------------------- + +@app.post("/api/tion/power/{state}") +async def set_power(state: Literal["on", "off"]): + + enabled = state == "on" + + if enabled: + operation = lambda tion: tion.power_on() + else: + operation = lambda tion: tion.power_off() + + return await execute_command( + operation, + ScheduledSettings(power=enabled), + ) + + +# ---------------------------------------------------------------------- +# Fan speed increase / decrease +# ---------------------------------------------------------------------- + +@app.post("/api/tion/speed/increase") +async def increase_speed(): + + current_speed = get_effective_speed() + + new_speed = min(current_speed + 1, MAX_FAN_SPEED) + + if new_speed == current_speed: + return get_status() + + return await execute_command( + lambda tion: + tion.set_speed(new_speed), + + ScheduledSettings( + speed=new_speed, + ), + ) + + +@app.post("/api/tion/speed/decrease") +async def decrease_speed(): + + current_speed = get_effective_speed() + + new_speed = max(current_speed - 1, MIN_FAN_SPEED) + + if new_speed == current_speed: + return get_status() + + return await execute_command( + lambda tion: + tion.set_speed(new_speed), + + ScheduledSettings( + speed=new_speed, + ), + ) + + +# ---------------------------------------------------------------------- +# Fan speed +# ---------------------------------------------------------------------- +@app.post("/api/tion/speed/{speed}") +async def set_speed( + speed: Annotated[ + int, + Path( + ge=MIN_FAN_SPEED, + le=MAX_FAN_SPEED, + ), + ] ): + + return await execute_command( + lambda tion: tion.set_speed(speed), + ScheduledSettings(speed=speed), + ) + +# ---------------------------------------------------------------------- +# Heater +# ---------------------------------------------------------------------- + +@app.post("/api/tion/heater/{state}") +async def set_heater(state: Literal["on", "off"]): + + enabled = state == "on" + + if enabled: + operation = lambda tion: tion.heater_on() + else: + operation = lambda tion: tion.heater_off() + + + return await execute_command( + operation, + ScheduledSettings(heater=enabled), + ) + +# ---------------------------------------------------------------------- +# Target temperature increase / decrease +# ---------------------------------------------------------------------- + +@app.post("/api/tion/temperature/increase") +async def increase_temperature(): + + current_temperature = get_effective_temperature() + + new_temperature = min(current_temperature + 1, MAX_TARGET_TEMP) + + if new_temperature == current_temperature: + return get_status() + + return await execute_command( + lambda tion: + tion.set_target_temperature( + new_temperature + ), + + ScheduledSettings( + target_temp=new_temperature, + ), + ) + + +@app.post("/api/tion/temperature/decrease") +async def decrease_temperature(): + + current_temperature = get_effective_temperature() + + new_temperature = max(current_temperature - 1, MIN_TARGET_TEMP) + + if new_temperature == current_temperature: + return get_status() + + return await execute_command( + lambda tion: + tion.set_target_temperature( + new_temperature + ), + + ScheduledSettings( + target_temp=new_temperature, + ), + ) + +# ---------------------------------------------------------------------- +# Target temperature +# ---------------------------------------------------------------------- + +@app.post("/api/tion/temperature/{temperature}") + +async def set_temperature( + temperature: Annotated[ + int, + Path( + ge=MIN_TARGET_TEMP, + le=MAX_TARGET_TEMP, + ), + ],): + + return await execute_command( + lambda tion: + tion.set_target_temperature(temperature), + ScheduledSettings(target_temp=temperature), + ) + + +# ---------------------------------------------------------------------- +# Air mode +# ---------------------------------------------------------------------- + +@app.post("/api/tion/mode/{mode}") +async def set_mode( + mode: str, +): + if mode not in ( + AIR_MODE_OUTSIDE, + AIR_MODE_RECIRCULATION, + ): + raise HTTPException( + status_code=400, + detail={ + "message": "Invalid air mode", + "allowed": [ + AIR_MODE_OUTSIDE, + AIR_MODE_RECIRCULATION, + ], + }, + ) + + return await execute_command( + lambda tion: + tion.set_air_mode(mode), + + ScheduledSettings( + mode=mode, + ), + ) + +# ---------------------------------------------------------------------- +# Sound +# ---------------------------------------------------------------------- + +@app.post("/api/tion/sound/{value}") +async def set_sound( + value: Literal["on", "off"], +): + enabled = value == "on" + + if enabled: + operation = lambda tion: tion.sound_on() + else: + operation = lambda tion: tion.sound_off() + + return await execute_command( + operation, + ScheduledSettings( + sound=enabled, + ), + ) + + +# ---------------------------------------------------------------------- +# Light +# ---------------------------------------------------------------------- + +@app.post("/api/tion/light/{value}") +async def set_light( + value: Literal["on", "off"], +): + enabled = value == "on" + + if enabled: + operation = lambda tion: tion.light_on() + else: + operation = lambda tion: tion.light_off() + + return await execute_command( + operation, + ScheduledSettings( + light=enabled, + ), + ) + +# ---------------------------------------------------------------------- +# Переход в auto +# ---------------------------------------------------------------------- +@app.post("/api/schedule/override/clear") +async def clear_schedule_override(): + if schedule_service is None: + raise HTTPException( + status_code=503, + detail="Schedule service unavailable", + ) + + await schedule_service.clear_override() + + return { + "ok": True, + "schedule": get_schedule_status(), + "auto": get_auto_status(), + } + +# ---------------------------------------------------------------------- +# Переход в полностью manual +# ---------------------------------------------------------------------- +@app.post("/api/schedule/pause") +async def pause_schedule(): + + if schedule_service is None: + raise HTTPException( + status_code=503, + detail="Schedule service unavailable", + ) + + await schedule_service.pause() + + return { + "ok": True, + "schedule": get_schedule_status(), + } + +# ---------------------------------------------------------------------- +# Возвращаем AUTO +# ---------------------------------------------------------------------- +@app.post("/api/schedule/resume") +async def resume_schedule(): + + if schedule_service is None: + raise HTTPException( + status_code=503, + detail="Schedule service unavailable", + ) + + await schedule_service.resume() + + return { + "ok": True, + "schedule": get_schedule_status(), + } \ No newline at end of file diff --git a/app/auto/__init__.py b/app/auto/__init__.py new file mode 100644 index 0000000..7b0d95a --- /dev/null +++ b/app/auto/__init__.py @@ -0,0 +1,22 @@ +from .config import ( + AutoConfig, + Co2Config, + load_auto_config, +) + +from .co2_policy import ( + Co2SpeedPolicy, +) + +from .controller import ( + AutoController, +) + + +__all__ = [ + "AutoConfig", + "Co2Config", + "load_auto_config", + "Co2SpeedPolicy", + "AutoController", +] \ No newline at end of file diff --git a/app/auto/co2_policy.py b/app/auto/co2_policy.py new file mode 100644 index 0000000..d4a961d --- /dev/null +++ b/app/auto/co2_policy.py @@ -0,0 +1,129 @@ +class Co2SpeedPolicy: + + def __init__( + self, + *, + base_speed: int, + thresholds: tuple[ + tuple[int, int], + ... + ], + hysteresis: int, + ): + if hysteresis < 0: + raise ValueError( + "hysteresis must be >= 0" + ) + + if not thresholds: + raise ValueError( + "thresholds cannot be empty" + ) + + previous_ppm = None + + speeds = [base_speed] + + for ppm, speed in thresholds: + + if previous_ppm is not None: + if ppm <= previous_ppm: + raise ValueError( + "CO2 thresholds must " + "be strictly increasing" + ) + + if speed in speeds: + raise ValueError( + "AUTO speeds must be unique" + ) + + speeds.append(speed) + previous_ppm = ppm + + self._base_speed = base_speed + self._thresholds = thresholds + self._hysteresis = hysteresis + self._speeds = tuple(speeds) + + + def select_speed(self, co2: int, current_speed: int | None) -> int: + + if co2 < 0: + raise ValueError( + "CO2 cannot be negative" + ) + + # -------------------------------------------------- + # Первое решение AUTO. + # + # Гистерезис пока применять не к чему: + # предыдущей AUTO-скорости ещё нет. + # -------------------------------------------------- + + if ( + current_speed is None + or current_speed not in self._speeds + ): + return self._select_initial_speed(co2) + + index = self._speeds.index(current_speed) + + # -------------------------------------------------- + # CO2 растёт. + # + # Проверяем пороги перехода вверх. + # За один вызов можем перепрыгнуть + # сразу несколько скоростей. + # -------------------------------------------------- + + while index < len( + self._thresholds + ): + + ppm, _ = self._thresholds[index] + + if co2 < ppm: + break + + index += 1 + + # -------------------------------------------------- + # CO2 падает. + # + # Для перехода вниз используем: + # + # threshold - hysteresis + # + # Поэтому скорость не будет прыгать + # туда-сюда около одного порога. + # -------------------------------------------------- + + while index > 0: + + ppm, _ = self._thresholds[index - 1] + + down_threshold = ppm - self._hysteresis + + if co2 > down_threshold: + break + + index -= 1 + + return self._speeds[index] + + + def _select_initial_speed(self, co2: int) -> int: + + speed = self._base_speed + + for ppm, candidate_speed in ( + self._thresholds + ): + + if co2 < ppm: + break + + speed = candidate_speed + + return speed \ No newline at end of file diff --git a/app/auto/config.py b/app/auto/config.py new file mode 100644 index 0000000..285fcf9 --- /dev/null +++ b/app/auto/config.py @@ -0,0 +1,390 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + +from app.my_dataclasses import ( + MIN_FAN_SPEED, + MAX_FAN_SPEED, +) + + +@dataclass( + frozen=True, + slots=True, +) +class Co2Config: + base_speed: int + hysteresis: int + + thresholds: tuple[ + tuple[int, int], + ... + ] + + +@dataclass( + frozen=True, + slots=True, +) +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: + + if not isinstance(value, dict): + raise ValueError( + f"{name} must be an object" + ) + + return value + + +def _parse_speed(name: str, value: Any) -> int: + + if type(value) is not int: + raise ValueError( + f"{name} must be an integer" + ) + + if not ( + MIN_FAN_SPEED + <= value + <= MAX_FAN_SPEED + ): + raise ValueError( + f"{name} must be between " + f"{MIN_FAN_SPEED} and " + f"{MAX_FAN_SPEED}" + ) + + return value + + +def _parse_thresholds(value: Any) -> tuple[ + tuple[int, int], + ... +]: + + if not isinstance(value, list): + raise ValueError( + "co2.thresholds must be a list" + ) + + if not value: + raise ValueError( + "co2.thresholds cannot be empty" + ) + + result = [] + + previous_ppm = None + previous_speed = None + + for index, item in enumerate(value): + + item = _require_dict( + f"co2.thresholds[{index}]", + item, + ) + + unknown = set(item) - { + "ppm", + "speed", + } + + if unknown: + raise ValueError( + f"Unknown fields in " + f"co2.thresholds[{index}]: " + f"{sorted(unknown)}" + ) + + if "ppm" not in item: + raise ValueError( + f"co2.thresholds[{index}].ppm " + f"is required" + ) + + if "speed" not in item: + raise ValueError( + f"co2.thresholds[{index}].speed " + f"is required" + ) + + ppm = item["ppm"] + + if type(ppm) is not int: + raise ValueError( + f"co2.thresholds[{index}].ppm " + f"must be an integer" + ) + + if ppm <= 0: + raise ValueError( + f"co2.thresholds[{index}].ppm " + f"must be > 0" + ) + + speed = _parse_speed( + ( + f"co2.thresholds" + f"[{index}].speed" + ), + item["speed"], + ) + + if ( + previous_ppm is not None + and ppm <= previous_ppm + ): + raise ValueError( + "CO2 thresholds must be " + "strictly increasing" + ) + + if ( + previous_speed is not None + and speed <= previous_speed + ): + raise ValueError( + "AUTO speeds must be " + "strictly increasing" + ) + + result.append( + ( + ppm, + speed, + ) + ) + + previous_ppm = ppm + previous_speed = speed + + return tuple(result) + + +def _parse_co2(value: Any) -> Co2Config: + + data = _require_dict( + "co2", + value, + ) + + unknown = set(data) - { + "base_speed", + "hysteresis", + "thresholds", + } + + if unknown: + raise ValueError( + f"Unknown CO2 config fields: " + f"{sorted(unknown)}" + ) + + if "base_speed" not in data: + raise ValueError( + "co2.base_speed is required" + ) + + if "hysteresis" not in data: + raise ValueError( + "co2.hysteresis is required" + ) + + if "thresholds" not in data: + raise ValueError( + "co2.thresholds is required" + ) + + base_speed = _parse_speed( + "co2.base_speed", + data["base_speed"], + ) + + hysteresis = data["hysteresis"] + + if type(hysteresis) is not int: + raise ValueError( + "co2.hysteresis must be " + "an integer" + ) + + if hysteresis < 0: + raise ValueError( + "co2.hysteresis must be >= 0" + ) + + thresholds = _parse_thresholds( + data["thresholds"] + ) + + first_speed = thresholds[0][1] + + if first_speed <= base_speed: + raise ValueError( + "First threshold speed must be " + "greater than base_speed" + ) + + return Co2Config( + base_speed=base_speed, + hysteresis=hysteresis, + thresholds=thresholds, + ) + + +def load_auto_config(path: str | Path) -> AutoConfig: + + path = Path(path) + + with path.open( + "r", + encoding="utf-8", + ) as file: + raw = yaml.safe_load(file) + + data = _require_dict( + "AUTO config", + raw, + ) + + unknown = set(data) - { + "version", + "check_interval", + "co2", + "temperature", + } + + if unknown: + raise ValueError( + f"Unknown AUTO config fields: " + f"{sorted(unknown)}" + ) + + version = data.get("version") + + if version != 1: + raise ValueError( + f"Unsupported AUTO config " + f"version: {version!r}" + ) + + check_interval = data.get( + "check_interval" + ) + + if ( + type(check_interval) not in { + int, + float, + } + ): + raise ValueError( + "check_interval must be a number" + ) + + if check_interval <= 0: + raise ValueError( + "check_interval must be > 0" + ) + + # -------------------------------------------------- + # CO2 + # -------------------------------------------------- + + if "co2" not in data: + raise ValueError( + "co2 config is required" + ) + + co2 = _parse_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( + version=version, + check_interval=float( + check_interval + ), + co2=co2, + temperature=temperature, + ) + diff --git a/app/auto/controller.py b/app/auto/controller.py new file mode 100644 index 0000000..bc561ae --- /dev/null +++ b/app/auto/controller.py @@ -0,0 +1,490 @@ +import asyncio +from datetime import datetime +from app.auto.co2_policy import Co2SpeedPolicy +from app.auto.temperature_policy import TemperaturePolicy +from app.my_dataclasses import MAX_TARGET_TEMP + +class AutoController: + + def __init__( + self, + schedule_service, + qingping_service, + tion_service, + policy: Co2SpeedPolicy | None = None, + temperature_policy: TemperaturePolicy | None = None, + interval: float = 5.0, + ): + self._schedule = schedule_service + self._qingping = qingping_service + self._tion = tion_service + self._policy = policy + self._temperature_policy = temperature_policy + + self._interval = interval + + self._task: asyncio.Task | None = None + + self._state = "inactive" + self._reason: str | None = None + + self._target_speed: int | None = None + self._auto_speed: int | None = None + + self._target_heater: bool | None = None + self._auto_heater: bool | None = None + self._target_temperature: int | None = None + + self._temperature: float | int | None = None + self._temperature_source: str | None = None + + self._last_error: str | None = None + + + async def start(self) -> None: + + if self._task is not None: + return + + self._task = asyncio.create_task( self._loop() ) + + + async def stop(self) -> None: + + if self._task is None: + return + + self._task.cancel() + + try: + await self._task + + except asyncio.CancelledError: + pass + + finally: + self._task = None + + + async def _loop(self) -> None: + + while True: + + try: + await self._process() + + except asyncio.CancelledError: + raise + + except Exception as exc: + self._last_error = str(exc) + + await asyncio.sleep( self._interval ) + + + async def _process(self) -> None: + + now = datetime.now() + + resolution = self._schedule.resolve(now) + + # ---------------------------------------------- + # Расписание поставлено на паузу. + # + # Это полноценный ручной режим: + # AUTO вообще не имеет права управлять Tion. + # ---------------------------------------------- + + if getattr( + self._schedule, + "paused", + False, + ): + self._state = "suspended" + self._reason = "schedule_paused" + + # После перехода в ручной режим пользователь + # может изменить эти параметры напрямую. + # Поэтому старые значения AUTO больше + # нельзя считать достоверными. + self._target_speed = None + self._auto_speed = None + + self._target_heater = None + self._auto_heater = None + + self._target_temperature = None + + self._temperature = None + self._temperature_source = None + + self._last_error = None + + return + + # ---------------------------------------------- + # Активен ручной override. + # + # Пока пользователь вручную управляет Tion, + # AUTO вообще не вмешивается. + # ---------------------------------------------- + if self._schedule.override_active: + self._state = "suspended" + self._reason = "manual_override" + # AUTO больше не может считать, + # что знает реальное состояние speed/heater: + # пользователь мог изменить их вручную. + self._target_speed = None + self._auto_speed = None + + self._target_heater = None + self._auto_heater = None + self._target_temperature = None + + self._temperature = None + self._temperature_source = None + + self._last_error = None + + return + + # ---------------------------------------------- + # AUTO сейчас не активен + # ---------------------------------------------- + + if ( + not resolution.enabled + or not resolution.auto_active + ): + self._state = "inactive" + self._reason = None + # Управление возвращается ScheduleService. + # После этого AUTO уже не знает фактическое + # состояние speed/heater, поэтому забываем его. + self._target_speed = None + self._auto_speed = None + + self._target_heater = None + self._auto_heater = None + self._target_temperature = None + + self._temperature = None + self._temperature_source = None + + self._last_error = None + + return + + fallback_speed = resolution.auto_fallback_speed + + if fallback_speed is None: + raise RuntimeError( + "AUTO is active but " + "fallback speed is missing" + ) + # ---------------------------------------------- + # CO2 policy недоступна. + # + # Например, auto.yaml не загрузился. + # AUTO работает в аварийном fallback-only режиме. + # ---------------------------------------------- + if self._policy is None: + self._state = "fallback" + self._reason = "auto_policy_unavailable" + self._auto_speed = None + + await self._set_speed(fallback_speed) + + if self._temperature_policy is not None: + await self._process_heater(resolution) + + self._last_error = None + + return + + qingping_state = self._qingping.state + + # ---------------------------------------------- + # Qingping исправен + # ---------------------------------------------- + + if ( + self._qingping.online + and qingping_state.co2 is not None + ): + target_speed = ( + self._policy.select_speed( + co2=qingping_state.co2, + current_speed=self._auto_speed, + ) + ) + + self._state = "active" + self._reason = None + + await self._set_speed( + target_speed + ) + + self._auto_speed = target_speed + + if self._temperature_policy is not None: + await self._process_heater(resolution) + + self._last_error = None + + return + + # ---------------------------------------------- + # AUTO не может работать. + # Переходим на fallback. + # ---------------------------------------------- + + if not self._qingping.online: + reason = "qingping_offline" + + else: + reason = "co2_missing" + + self._state = "fallback" + self._reason = reason + + self._auto_speed = None + + await self._set_speed(fallback_speed) + + if self._temperature_policy is not None: + await self._process_heater( + resolution + ) + + self._last_error = None + + + async def _process_heater(self, resolution) -> None: + + # -------------------------------------------------- + # Берём целевую температуру из накопленных + # настроек расписания. + # + # Например: + # + # SET target_temp=20 + # AUTO + # + # Во время AUTO target_temp остаётся 20. + # -------------------------------------------------- + + target_temp = resolution.auto_target_temp + + # -------------------------------------------------- + # Получаем фактическую температуру. + # + # _get_temperature() сам выбирает: + # + # 1. Qingping + # 2. Tion как резерв + # 3. None, если оба источника недоступны + # -------------------------------------------------- + + temperature, source = self._get_temperature() + + self._temperature = temperature + self._temperature_source = source + + # -------------------------------------------------- + # Нет температурной policy. + # + # AUTO не умеет безопасно принять решение + # о нагреве → выключаем heater. + # -------------------------------------------------- + + if self._temperature_policy is None: + await self._set_heater(False) + self._auto_heater = False + return + + # -------------------------------------------------- + # Нет целевой температуры. + # + # Непонятно, до какой температуры греть. + # Поэтому heater OFF. + # -------------------------------------------------- + + if target_temp is None: + await self._set_heater(False) + self._auto_heater = False + return + + # -------------------------------------------------- + # Нет достоверной фактической температуры. + # + # Ни Qingping, ни резервный датчик Tion + # использовать нельзя. + # + # Heater OFF. + # -------------------------------------------------- + + if temperature is None: + await self._set_heater(False) + self._auto_heater = False + return + + # -------------------------------------------------- + # Есть всё необходимое: + # + # - фактическая температура; + # - target_temp; + # - TemperaturePolicy. + # + # Policy решает, нужно ли сейчас греть. + # -------------------------------------------------- + + heater_required = ( + self._temperature_policy + .heater_required( + temperature=temperature, + target_temp=target_temp, + heater_on=( + self._auto_heater + is True + ), + ) + ) + + if heater_required: + # Наш внешний регулятор решил, что нужно греть. + # + # Сначала поднимаем внутреннюю уставку Tion + # до максимума, чтобы встроенный термостат + # не заблокировал нагрев по своему in_temp. + await self._set_target_temperature(MAX_TARGET_TEMP) + # После этого разрешаем нагреватель. + await self._set_heater(True) + else: + # Целевая температура по внешнему датчику + # достигнута — нагрев запрещаем. + await self._set_heater(False) + + self._auto_heater = heater_required + + + def _get_temperature(self) -> tuple[float | int | None, str | None]: + + # -------------------------------------------------- + # Основной источник — Qingping. + # + # Используем температуру Qingping только если + # сам поток данных датчика сейчас считается online. + # -------------------------------------------------- + + qingping_state = self._qingping.state + + if ( + self._qingping.online + and qingping_state.temperature is not None + ): + return ( + qingping_state.temperature, + "qingping", + ) + + # -------------------------------------------------- + # Qingping недоступен или температура отсутствует. + # + # Пробуем резервный датчик температуры Tion. + # + # Важно проверять tion.online: + # TionService может хранить последнее состояние, + # даже если связь с устройством уже потеряна. + # -------------------------------------------------- + + tion_state = self._tion.state + + if ( + self._tion.online + and tion_state is not None + and tion_state.in_temp is not None + ): + return ( + tion_state.in_temp, + "tion", + ) + + # -------------------------------------------------- + # Достоверной температуры нет вообще. + # -------------------------------------------------- + + return None, None + + + async def _set_speed(self, speed: int) -> None: + + if self._target_speed == speed: + return + + await self._tion.execute( + lambda controller: + controller.set_speed(speed) + ) + + self._target_speed = speed + + async def _set_heater(self,enabled: bool) -> None: + + # Если AutoController уже установил именно такое + # состояние нагревателя, повторную команду не шлём. + if self._target_heater == enabled: + return + + if enabled: + await self._tion.execute( + lambda controller: + controller.heater_on() + ) + else: + await self._tion.execute( + lambda controller: + controller.heater_off() + ) + + # Запоминаем состояние только после того, + # как команда успешно выполнилась. + self._target_heater = enabled + + + async def _set_target_temperature( + self, + temperature: int, + ) -> None: + + if self._target_temperature == temperature: + return + + await self._tion.execute( + lambda controller: + controller.set_target_temperature( + temperature + ) + ) + + self._target_temperature = temperature + + + def status(self) -> dict: + return { + "state": self._state, + "reason": self._reason, + + "target_speed": self._target_speed, + "auto_speed": self._auto_speed, + + "target_heater": self._target_heater, + "auto_heater": self._auto_heater, + + "temperature": self._temperature, + "temperature_source": self._temperature_source, + + "last_error": self._last_error, + } + 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/app/my_dataclasses.py b/app/my_dataclasses.py new file mode 100644 index 0000000..72663e1 --- /dev/null +++ b/app/my_dataclasses.py @@ -0,0 +1,68 @@ +from pathlib import Path +from dataclasses import asdict, dataclass +from typing import Any, Mapping + +# Константы и классы + +# Константы + +# MAC Bluetooth бризера +TION_MAC = "d1:74:9b:eb:ee:a6" + +QINGPING_MAC = "CCB5D131BA93" +QINGPING_MQTT_HOST = "192.168.7.3" +QINGPING_MQTT_PORT = 1883 + + +MIN_FAN_SPEED = 1 +MAX_FAN_SPEED = 6 + +MIN_TARGET_TEMP = 5 +MAX_TARGET_TEMP = 30 + +AIR_MODE_OUTSIDE = "outside" +AIR_MODE_RECIRCULATION = "recirculation" + +SUPPORTED_AIR_MODES = { + AIR_MODE_OUTSIDE, + AIR_MODE_RECIRCULATION, +} + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + +SCHEDULE_FILE = ( + PROJECT_ROOT + / "config" + / "schedule.yaml" +) + +AUTO_CONFIG_FILE = ( + PROJECT_ROOT + / "config" + / "auto.yaml" +) + + + + +WATCHDOG_INTERVAL = 5.0 + +# Type 13 обычно приходит примерно раз в 60 секунд. +# Даём дополнительный запас. +HEARTBEAT_STALE_AFTER = 90.0 + +# После появления устройства ждём первый sensor sample +# не дольше этого времени. +FIRST_SAMPLE_TIMEOUT = 30.0 + +# Если новые sensor samples не приходят дольше этого времени, +# считаем sensor stream зависшим. +SAMPLE_STALE_AFTER = 45.0 + + + +QINGPING_SAMPLE_TIMEOUT = 60.0 +QINGPING_RECOVERY_TIMEOUT = 30.0 + +#Классы + diff --git a/app/qingping/__init__.py b/app/qingping/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/qingping/models.py b/app/qingping/models.py new file mode 100644 index 0000000..cf17e16 --- /dev/null +++ b/app/qingping/models.py @@ -0,0 +1,44 @@ +# from dataclasses import asdict, dataclass +# +# +# @dataclass(frozen=True, slots=True) +# class QingpingState: +# temperature: float +# humidity: float +# co2: int +# pm25: int +# pm10: int +# rssi: int | None = None +# +# def to_dict(self) -> dict: +# return asdict(self) +# +# +from dataclasses import asdict, dataclass +from datetime import datetime + +@dataclass(frozen=True, slots=True) +class QingpingState: + temperature: float | None = None + humidity: float | None = None + co2: int | None = None + pm25: int | None = None + pm10: int | None = None + battery: int | None = None + + sample_timestamp: int | None = None + sample_received_at: datetime | None = None + last_message_at: datetime | None = None + + wifi_rssi: int | None = None + firmware: str | None = None + mqtt_connected: bool = False + + def to_dict(self) -> dict: + result = asdict(self) + + for key in ("sample_received_at", "last_message_at"): + if result[key] is not None: + result[key] = result[key].isoformat() + + return result \ No newline at end of file diff --git a/app/qingping/parser.py b/app/qingping/parser.py new file mode 100644 index 0000000..302aa47 --- /dev/null +++ b/app/qingping/parser.py @@ -0,0 +1,88 @@ +from app.qingping.models import QingpingState + + +def parse_cgdn1(data: bytes, rssi: int | None = None) -> QingpingState | None: + + # Восемь первых байт — заголовок CGDN1. + if len(data) < 8: + return None + + temperature = None + humidity = None + pm25 = None + pm10 = None + co2 = None + + pos = 8 + + while pos + 2 <= len(data): + field_type = data[pos] + length = data[pos + 1] + + pos += 2 + + if pos + length > len(data): + return None + + value = data[pos:pos + length] + pos += length + + # Temperature + Humidity + if field_type == 0x01 and length == 4: + temperature = ( + int.from_bytes( + value[0:2], + byteorder="little", + signed=True, + ) + / 10 + ) + + humidity = ( + int.from_bytes( + value[2:4], + byteorder="little", + signed=False, + ) + / 10 + ) + + # PM2.5 + PM10 + elif field_type == 0x12 and length == 4: + pm25 = int.from_bytes( + value[0:2], + byteorder="little", + ) + + pm10 = int.from_bytes( + value[2:4], + byteorder="little", + ) + + # CO2 + elif field_type == 0x13 and length == 2: + co2 = int.from_bytes( + value, + byteorder="little", + ) + + if any( + value is None + for value in ( + temperature, + humidity, + pm25, + pm10, + co2, + ) + ): + return None + + return QingpingState( + temperature=temperature, + humidity=humidity, + co2=co2, + pm25=pm25, + pm10=pm10, + rssi=rssi, + ) \ No newline at end of file diff --git a/app/qingping/service.py b/app/qingping/service.py new file mode 100644 index 0000000..c06f2a6 --- /dev/null +++ b/app/qingping/service.py @@ -0,0 +1,900 @@ +# from datetime import datetime, timedelta, timezone +# +# from bleak import BleakScanner +# from bleak.backends.device import BLEDevice +# from bleak.backends.scanner import AdvertisementData +# +# from app.qingping.models import QingpingState +# from app.qingping.parser import parse_cgdn1 +# +# +# QINGPING_SERVICE_UUID = "0000fdcd-0000-1000-8000-00805f9b34fb" +# +# +# class QingpingService: +# def __init__(self, mac: str, stale_after: float = 30.0): +# self._mac = mac.upper() +# self._stale_after = stale_after +# +# self._scanner: BleakScanner | None = None +# self._state: QingpingState | None = None +# +# self._last_seen: datetime | None = None +# self._last_error: str | None = None +# +# self._running = False +# +# @property +# def state(self) -> QingpingState | None: +# return self._state +# +# @property +# def running(self) -> bool: +# return self._running +# +# @property +# def last_seen(self) -> datetime | None: +# return self._last_seen +# +# @property +# def last_error(self) -> str | None: +# return self._last_error +# +# @property +# def online(self) -> bool: +# if not self._running: +# return False +# +# if self._last_seen is None: +# return False +# +# age = datetime.now(timezone.utc) - self._last_seen +# +# return age <= timedelta( +# seconds=self._stale_after +# ) +# +# async def start(self): +# if self._running: +# return +# +# try: +# self._scanner = BleakScanner( +# self._on_advertisement, +# # service_uuids=[ +# # QINGPING_SERVICE_UUID, +# # ], +# ) +# +# await self._scanner.start() +# +# self._running = True +# self._last_error = None +# +# except Exception as exc: +# self._scanner = None +# self._running = False +# self._last_error = str(exc) +# +# raise +# +# async def stop(self): +# scanner = self._scanner +# +# self._scanner = None +# self._running = False +# +# if scanner is not None: +# await scanner.stop() +# +# # def _on_advertisement( +# # self, +# # device: BLEDevice, +# # advertisement: AdvertisementData, +# # ): +# # if device.address.upper() != self._mac: +# # return +# # +# # data = advertisement.service_data.get( +# # QINGPING_SERVICE_UUID +# # ) +# # +# # if not data: +# # return +# # +# # try: +# # state = parse_cgdn1( +# # data, +# # rssi=getattr( +# # advertisement, +# # "rssi", +# # None, +# # ), +# # ) +# # +# # if state is None: +# # return +# # +# # self._state = state +# # +# # self._last_seen = datetime.now( +# # timezone.utc +# # ) +# # +# # self._last_error = None +# # +# # except Exception as exc: +# # self._last_error = str(exc) +# +# def _on_advertisement( +# self, +# device: BLEDevice, +# advertisement: AdvertisementData, +# ): +# data = advertisement.service_data.get( +# QINGPING_SERVICE_UUID +# ) +# +# if not data: +# return +# +# print( +# "QINGPING:", +# device.address, +# device.name, +# data.hex(" "), +# ) +# +# if device.address.upper() != self._mac: +# print( +# "MAC mismatch:", +# device.address, +# "!=", +# self._mac, +# ) +# return +# +# try: +# state = parse_cgdn1( +# data, +# rssi=getattr( +# advertisement, +# "rssi", +# None, +# ), +# ) +# +# print("PARSED:", state) +# +# if state is None: +# return +# +# self._state = state +# self._last_seen = datetime.now( +# timezone.utc +# ) +# self._last_error = None +# +# except Exception as exc: +# self._last_error = str(exc) +# print("Qingping parse error:", exc) +# +# async def __aenter__(self): +# await self.start() +# return self +# +# async def __aexit__( +# self, +# exc_type, +# exc_val, +# exc_tb, +# ): +# await self.stop() + +import asyncio +import json +import logging +import threading +import time +from dataclasses import replace +from datetime import datetime, timezone + +import paho.mqtt.client as mqtt + +from .models import QingpingState +from app.my_dataclasses import ( + WATCHDOG_INTERVAL, + QINGPING_SAMPLE_TIMEOUT, + QINGPING_RECOVERY_TIMEOUT, + ) + +logger = logging.getLogger(__name__) + + +class QingpingService: + def __init__( + self, + host: str, + port: int = 1883, + mac: str = "CCB5D131BA93", + ): + self._host = host + self._port = port + self._mac = mac + + self._up_topic = f"qingping/{mac}/up" + self._down_topic = f"qingping/{mac}/down" + + self._state = QingpingState() + self._lock = threading.Lock() + + self._client: mqtt.Client | None = None + self._watchdog_task: asyncio.Task | None = None + + self._last_sample_monotonic: float | None = None + + self._device_online = False + + self._recovery_waiting = False + self._recovery_started_monotonic: float | None = None + + + @property + def state(self) -> QingpingState: + with self._lock: + return self._state + + @property + def online(self) -> bool: + with self._lock: + return ( + self._state.mqtt_connected + and self._device_online + ) + + async def start(self) -> None: + client = mqtt.Client( + callback_api_version=mqtt.CallbackAPIVersion.VERSION2, + client_id="tioncontroller-qingping", + ) + + client.on_connect = self._on_connect + client.on_disconnect = self._on_disconnect + client.on_message = self._on_message + + client.reconnect_delay_set( + min_delay=1, + max_delay=30, + ) + + self._client = client + + client.connect_async( + self._host, + self._port, + keepalive=60, + ) + + client.loop_start() + + self._watchdog_task = asyncio.create_task( + self._watchdog_loop() + ) + + async def stop(self) -> None: + if self._watchdog_task is not None: + self._watchdog_task.cancel() + + try: + await self._watchdog_task + except asyncio.CancelledError: + pass + + self._watchdog_task = None + + if self._client is not None: + self._client.disconnect() + self._client.loop_stop() + self._client = None + + + def status(self) -> dict: + with self._lock: + state = self._state + + online = ( + state.mqtt_connected + and self._device_online + ) + + recovery_waiting = ( + self._recovery_waiting + ) + + return { + **state.to_dict(), + "online": online, + "recovery_waiting": recovery_waiting, + } + + def _on_connect( + self, + client, + _userdata, + _flags, + reason_code, + _properties=None, + ) -> None: + if reason_code != 0: + logger.error( + "Qingping MQTT connection failed: %s", + reason_code, + ) + return + + logger.info( + "Qingping MQTT connected" + ) + + client.subscribe(self._up_topic) + + now_monotonic = time.monotonic() + + with self._lock: + self._state = replace( + self._state, + mqtt_connected=True, + temperature=None, + humidity=None, + co2=None, + pm25=None, + pm10=None, + battery=None, + sample_timestamp=None, + sample_received_at=None, + ) + + self._last_sample_monotonic = None + + self._device_online = False + + self._recovery_waiting = True + self._recovery_started_monotonic = ( + now_monotonic + ) + + logger.info( + "Qingping startup initialization" + ) + + self._send_recovery() + + def _on_disconnect( + self, + _client, + _userdata, + _disconnect_flags, + reason_code, + _properties=None, + ) -> None: + logger.warning( + "Qingping MQTT disconnected: %s", + reason_code, + ) + + with self._lock: + self._state = replace( + self._state, + mqtt_connected=False, + ) + + self._device_online = False + self._recovery_waiting = False + self._recovery_started_monotonic = None + + def _on_message( + self, + _client, + _userdata, + message, + ) -> None: + try: + payload = json.loads( + message.payload.decode("utf-8") + ) + + now = datetime.now(timezone.utc) + now_monotonic = time.monotonic() + + message_type = int( + payload.get("type") + ) + + logger.debug( + "Qingping MQTT packet received: type=%s", + message_type, + ) + + # Любой пакет означает, что устройство + # физически присутствует в MQTT. + with self._lock: + self._state = replace( + self._state, + last_message_at=now, + ) + + start_recovery = False + + with self._lock: + if ( + not self._device_online + and + not self._recovery_waiting + ): + self._state = replace( + self._state, + temperature=None, + humidity=None, + co2=None, + pm25=None, + pm10=None, + battery=None, + sample_timestamp=None, + sample_received_at=None, + ) + + self._last_sample_monotonic = None + + self._device_online = True + + self._recovery_waiting = True + self._recovery_started_monotonic = ( + now_monotonic + ) + + start_recovery = True + + if start_recovery: + logger.info( + "Qingping packet received while offline: " + "type=%s -> starting recovery", + message_type, + ) + + self._send_recovery() + + # Единственный пакет, который реально + # обрабатываем как данные. + if message_type == 17: + self._handle_sensor_data( + payload, + now, + ) + return + + if message_type == 13: + self._handle_heartbeat( + payload, + ) + return + + logger.debug( + "Qingping service packet received: type=%s", + message_type, + ) + + except Exception: + logger.exception( + "Qingping MQTT packet processing failed" + ) + + + def _handle_heartbeat( + self, + payload: dict, + ) -> None: + wifi_info = payload.get("wifi_info") + + wifi_rssi = None + + if isinstance(wifi_info, str): + parts = wifi_info.split(",") + + if len(parts) >= 2: + try: + wifi_rssi = int(parts[1]) + except ValueError: + logger.warning( + "Qingping invalid RSSI in wifi_info: %r", + wifi_info, + ) + + firmware = payload.get("sw_version") + + with self._lock: + self._state = replace( + self._state, + wifi_rssi=wifi_rssi, + firmware=firmware, + ) + + logger.debug( + "Qingping heartbeat updated: " + "rssi=%s firmware=%s", + wifi_rssi, + firmware, + ) + def _handle_sensor_data( + self, + payload: dict, + received_at: datetime, + ) -> None: + sensor_data = payload.get("sensorData") + + if not isinstance(sensor_data, list): + return + + if not sensor_data: + return + + sample = max( + sensor_data, + key=self._sample_timestamp, + ) + + sample_timestamp = self._sample_timestamp( + sample + ) + + if sample_timestamp <= 0: + return + + current_timestamp = ( + self.state.sample_timestamp + ) + + # CGDN1 после запуска может несколько раз + # присылать одну и ту же историческую точку. + if ( + current_timestamp is not None + and sample_timestamp <= current_timestamp + ): + return + + temperature = self._value( + sample, + "temperature", + ) + humidity = self._value( + sample, + "humidity", + ) + co2 = self._value( + sample, + "co2", + ) + pm25 = self._value( + sample, + "pm25", + ) + pm10 = self._value( + sample, + "pm10", + ) + battery = self._value( + sample, + "battery", + ) + + now_monotonic = time.monotonic() + + with self._lock: + current_timestamp = ( + self._state.sample_timestamp + ) + + # CGDN1 может повторять одну историческую точку. + # Такой пакет НЕ считается новым измерением. + if ( + current_timestamp is not None + and sample_timestamp <= current_timestamp + ): + logger.debug( + "Qingping duplicate sensor sample ignored: " + "timestamp=%s current=%s", + sample_timestamp, + current_timestamp, + ) + return + + was_recovering = self._recovery_waiting + was_offline = not self._device_online + + self._state = replace( + self._state, + temperature=temperature, + humidity=humidity, + co2=co2, + pm25=pm25, + pm10=pm10, + battery=battery, + sample_timestamp=sample_timestamp, + sample_received_at=received_at, + ) + + self._last_sample_monotonic = ( + now_monotonic + ) + + self._device_online = True + + self._recovery_waiting = False + self._recovery_started_monotonic = None + + # if was_offline or was_recovering: + # logger.info( + # "Qingping sensor stream online: " + # "timestamp=%s co2=%s", + # sample_timestamp, + # co2, + # ) + # else: + # logger.debug( + # "Qingping sensor sample accepted: " + # "timestamp=%s co2=%s", + # sample_timestamp, + # co2, + # ) + + async def _watchdog_loop(self) -> None: + while True: + await asyncio.sleep( + WATCHDOG_INTERVAL + ) + + self._watchdog_tick( + time.monotonic() + ) + + def _watchdog_tick( + self, + now: float, + ) -> None: + recovery_timeout = None + sample_timeout = None + invalid_recovery_state = False + + with self._lock: + + # Ждём type 17 после recovery. + if self._recovery_waiting: + + if ( + self._recovery_started_monotonic + is None + ): + invalid_recovery_state = True + + self._recovery_waiting = False + self._device_online = False + + else: + elapsed = ( + now + - self._recovery_started_monotonic + ) + + if ( + elapsed + > QINGPING_RECOVERY_TIMEOUT + ): + recovery_timeout = elapsed + + self._recovery_waiting = False + self._recovery_started_monotonic = None + self._device_online = False + + # Нормальная работа: + # следим за последним НОВЫМ type 17. + elif self._last_sample_monotonic is not None: + + elapsed = ( + now + - self._last_sample_monotonic + ) + + if ( + elapsed + > QINGPING_SAMPLE_TIMEOUT + and + self._device_online + ): + sample_timeout = elapsed + + self._device_online = False + + if invalid_recovery_state: + logger.error( + "Qingping invalid recovery state: " + "recovery_waiting=True but " + "recovery_started_monotonic=None" + ) + + if recovery_timeout is not None: + logger.warning( + "Qingping recovery timeout: " + "no type 17 for %.1f sec " + "-> offline", + recovery_timeout, + ) + + if sample_timeout is not None: + logger.warning( + "Qingping sensor stream lost: " + "no type 17 for %.1f sec " + "-> offline", + sample_timeout, + ) + + @staticmethod + def _sample_timestamp(sample: dict) -> int: + + timestamp = sample.get("timestamp") + + if isinstance(timestamp, dict): + timestamp = timestamp.get("value") + + try: + return int(timestamp) + except (TypeError, ValueError): + return 0 + + @staticmethod + def _value(sample: dict, key: str): + value = sample.get(key) + + if isinstance(value, dict): + return value.get("value") + + return value + + async def _watchdog_loop(self) -> None: + while True: + await asyncio.sleep( + WATCHDOG_INTERVAL + ) + + now = time.monotonic() + + # -------------------------------------------------- + # СЦЕНАРИЙ 1 + # + # Recovery уже отправлен. + # Ждём type 17 максимум 30 секунд. + # -------------------------------------------------- + + if self._recovery_waiting: + + if self._recovery_started_monotonic is None: + logger.error( + "Qingping invalid recovery state: " + "recovery_waiting=True but " + "recovery_started_monotonic=None" + ) + + self._recovery_waiting = False + self._device_online = False + continue + + elapsed = now - self._recovery_started_monotonic + + if elapsed > QINGPING_RECOVERY_TIMEOUT: + + logger.warning( + "Qingping recovery timeout: " + "no type 17 for %.1f sec " + "-> offline", + elapsed, + ) + + self._recovery_waiting = False + self._recovery_started_monotonic = ( + None + ) + + self._device_online = False + + continue + + # -------------------------------------------------- + # СЦЕНАРИЙ 2 + # + # До сих пор не получили вообще ни одного + # измерения type 17. + # + # Ничего делать не надо. + # Первый любой MQTT-пакет запустит recovery. + # -------------------------------------------------- + + if self._last_sample_monotonic is None: + continue + + # -------------------------------------------------- + # СЦЕНАРИЙ 3 + # + # Нормально работали, но type 17 + # перестали приходить. + # -------------------------------------------------- + + elapsed = ( + now + - self._last_sample_monotonic + ) + + if ( + elapsed + > QINGPING_SAMPLE_TIMEOUT + and + self._device_online + ): + logger.warning( + "Qingping sensor stream lost: " + "no type 17 for %.1f sec " + "-> offline", + elapsed, + ) + + self._device_online = False + + + def _send_recovery(self) -> None: + if self._client is None: + return + + payload = { + "type": "17", + "timestamp": int(time.time()), + "setting": { + "report_interval": 15, + "collect_interval": 15, + "need_ack": 0, + }, + } + + result = self._client.publish( + self._down_topic, + json.dumps( + payload, + separators=(",", ":"), + ), + ) + + if result.rc == mqtt.MQTT_ERR_SUCCESS: + logger.warning( + "Qingping recovery command sent" + ) + else: + logger.warning( + "Failed to send Qingping recovery: %s", + result.rc, + ) + + def _reset_sample_session(self) -> None: + logger.debug( + "Qingping sensor session reset" + ) + + with self._lock: + self._state = replace( + self._state, + temperature=None, + humidity=None, + co2=None, + pm25=None, + pm10=None, + battery=None, + sample_timestamp=None, + sample_received_at=None, + ) + + self._last_sample_monotonic = None \ No newline at end of file diff --git a/app/tion/__init__.py b/app/tion/__init__.py new file mode 100644 index 0000000..df97824 --- /dev/null +++ b/app/tion/__init__.py @@ -0,0 +1,10 @@ +from .controller import TionController +from .models import TionState +from .service import TionService + + +__all__ = [ + "TionController", + "TionService", + "TionState", +] diff --git a/app/tion/controller.py b/app/tion/controller.py new file mode 100644 index 0000000..b29f67b --- /dev/null +++ b/app/tion/controller.py @@ -0,0 +1,275 @@ +import asyncio +from typing import Any +from bleak import BleakScanner +from tion_btle import TionS4 + +from .models import TionState + +from app.my_dataclasses import * + + +class TionController: + """ + Высокоуровневый контроллер Tion 4S. + + Один экземпляр TionController должен владеть одним BLE-соединением + с бризером на протяжении всей работы приложения. + """ + + def __init__(self, mac: str): + self._mac = mac + self._device: TionS4 | None = None + + # Не позволяем двум частям программы одновременно работать с BLE. + self._lock = asyncio.Lock() + + # Показывает, что мы удерживаем внешнее соединение tion-btle. + self._started = False + + # Последнее успешно прочитанное состояние. + self._state: TionState | None = None + + @property + def mac(self) -> str: + return self._mac + + @property + def connected(self) -> bool: + return ( + self._device is not None + and self._device.connection_status == "connected" + ) + + async def _reset_device(self) -> None: + """ + Полностью уничтожить текущий BLE transport. + + Важно: + tion-btle не вызывает BleakClient.disconnect(), + если WinRT уже считает устройство disconnected. + + Поэтому при полном reset принудительно закрываем + BleakClient напрямую. + """ + + old_device = self._device + + # Controller сразу больше не считает старый объект рабочим. + self._device = None + self._started = False + + if old_device is None: + return + + try: + # Нам нужен именно настоящий BleakClient.disconnect(). + # + # Не old_device.disconnect(), потому что tion-btle + # может пропустить физический cleanup при + # connection_status == "disc". + await old_device._btle.disconnect() + + except Exception: + # Старый transport всё равно больше использоваться + # не будет. + pass + + """ + Соединение + """ + + async def connect(self) -> None: + async with self._lock: + if self.connected: + self._started = True + return + + # Полностью закрываем всё, что осталось + # от предыдущего соединения. + await self._reset_device() + + # Получаем свежий BLEDevice. + ble_device = await BleakScanner.find_device_by_address( + self._mac, + timeout=5.0, + ) + + if ble_device is None: + raise ConnectionError( + f"Tion {self._mac} not found" + ) + + # Новый TionS4 = новый BleakClient. + device = TionS4(ble_device) + + try: + await device.connect() + + except Exception: + # ВАЖНО: + # освобождаем даже частично созданную + # WinRT/GATT-сессию. + try: + await device._btle.disconnect() + except Exception: + pass + + raise + + self._device = device + self._started = True + + async def disconnect(self) -> None: + """ + Закрыть BLE-соединение. + """ + async with self._lock: + await self._reset_device() + + + """ + Состояние + """ + + async def get_state(self) -> TionState: + """ + Получить реальное текущее состояние бризера. + """ + self._ensure_started() + + async with self._lock: + raw_state = await self._device.get() + + state = TionState.from_raw(raw_state) + self._state = state + + return state + + """ + Питание + """ + + async def power_on(self) -> TionState: + return await self._set({ + "state": "on" + }) + + async def power_off(self) -> TionState: + return await self._set({ + "state": "off" + }) + + """ + Скорость вентилятора + """ + + async def set_speed(self, speed: int) -> TionState: + if not MIN_FAN_SPEED <= speed <= MAX_FAN_SPEED: + raise ValueError("Tion fan speed must be between 1 and 6") + + return await self._set({ + "fan_speed": speed + }) + + """ + Целевая температура + """ + + async def set_target_temperature(self, temperature: int) -> TionState: + if not MIN_TARGET_TEMP <= temperature <= MAX_TARGET_TEMP: + raise ValueError( + f"Tion target temperature must be between " + f"{MIN_TARGET_TEMP} and {MAX_TARGET_TEMP} °C" + ) + + return await self._set({ + "heater_temp": temperature + }) + + """ + Нагрев + """ + + async def heater_on(self) -> TionState: + return await self._set({ + "heater": "on" + }) + + async def heater_off(self) -> TionState: + return await self._set({ + "heater": "off" + }) + + """ + Забор воздуха + """ + + async def set_air_mode(self, mode: str) -> TionState: + if mode not in SUPPORTED_AIR_MODES: + raise ValueError( + f"Unsupported Tion air mode: {mode}. " + f"Available modes: {sorted(SUPPORTED_AIR_MODES)}" + ) + + return await self._set({ + "mode": mode + }) + + """ + Звук + """ + async def sound_on(self) -> TionState: + return await self._set({ + "sound": "on" + }) + + async def sound_off(self) -> TionState: + return await self._set({ + "sound": "off" + }) + + """ + Световая индикация + """ + + async def light_on(self) -> TionState: + return await self._set({ + "light": "on" + }) + + async def light_off(self) -> TionState: + return await self._set({ + "light": "off" + }) + + async def _set(self, settings: dict[str, Any]) -> TionState: + """ + Отправить настройки в Tion и затем прочитать + фактическое состояние устройства. + """ + + self._ensure_started() + + async with self._lock: + await self._device.set(settings) + + raw_state = await self._device.get() + + state = TionState.from_raw(raw_state) + self._state = state + + return state + + def _ensure_started(self) -> None: + if not self._started: + raise RuntimeError( + "TionController is not connected. " + "Call await controller.connect() first." + ) + + async def __aenter__(self) -> "TionController": + await self.connect() + return self + + async def __aexit__(self, exc_type, exc_value, traceback) -> None: + await self.disconnect() \ No newline at end of file diff --git a/app/tion/models.py b/app/tion/models.py new file mode 100644 index 0000000..06c0677 --- /dev/null +++ b/app/tion/models.py @@ -0,0 +1,56 @@ +from dataclasses import asdict, dataclass +from typing import Any, Mapping + + +def _is_on(value: Any) -> bool: + return value == "on" + + +@dataclass(frozen=True, slots=True) +class TionState: + power: bool + heater: bool + heating: bool + sound: bool + mode: str + + out_temp: int + in_temp: int + target_temp: int + + fan_speed: int + filter_remain: float + + device_time: str + request_error_code: int + model: str + + light: bool | None = None + + @classmethod + def from_raw(cls, data: Mapping[str, Any]) -> "TionState": + light_raw = data.get("light") + + return cls( + power=_is_on(data["state"]), + heater=_is_on(data["heater"]), + heating=_is_on(data["heating"]), + sound=_is_on(data["sound"]), + mode=str(data["mode"]), + + out_temp=int(data["out_temp"]), + in_temp=int(data["in_temp"]), + target_temp=int(data["heater_temp"]), + + fan_speed=int(data["fan_speed"]), + filter_remain=float(data["filter_remain"]), + + device_time=str(data["time"]), + request_error_code=int(data["request_error_code"]), + model=str(data["model"]), + + light=None if light_raw is None else _is_on(light_raw), + ) + + def to_dict(self) -> dict: + return asdict(self) \ No newline at end of file diff --git a/app/tion/service.py b/app/tion/service.py new file mode 100644 index 0000000..26b62f0 --- /dev/null +++ b/app/tion/service.py @@ -0,0 +1,307 @@ +import asyncio + +from collections.abc import Awaitable, Callable +from contextlib import suppress +from datetime import datetime, timezone + +from .controller import TionController +from .models import TionState + + +TionOperation = Callable[ + [TionController], + Awaitable[TionState] +] + + +class TionService: + """ + Долгоживущий сервис работы с Tion. + + Отвечает за: + - подключение; + - периодический опрос состояния; + - online/offline; + - last_seen; + - автоматическое переподключение; + - синхронизацию команд с polling. + """ + + def __init__( + self, + controller: TionController, + poll_interval: float = 5.0, + ): + if poll_interval <= 0: + raise ValueError("poll_interval must be greater than 0") + + self._controller = controller + self._poll_interval = poll_interval + + self._state: TionState | None = None + self._online = False + self._last_seen: datetime | None = None + self._last_error: str | None = None + + self._running = False + self._poll_task: asyncio.Task | None = None + + # Защищает последовательность: + # + # reconnect -> command -> update state + # + # от вмешательства polling или другой команды. + self._operation_lock = asyncio.Lock() + + # Защищает start / stop. + self._lifecycle_lock = asyncio.Lock() + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def state(self) -> TionState | None: + """ + Последнее успешно полученное состояние Tion. + + Bluetooth-запрос не выполняется. + """ + return self._state + + @property + def online(self) -> bool: + return self._online + + @property + def running(self) -> bool: + return self._running + + @property + def last_seen(self) -> datetime | None: + """ + Время последнего успешного обмена с Tion. + """ + return self._last_seen + + @property + def last_error(self) -> str | None: + """ + Последняя ошибка связи. + + После успешного обмена сбрасывается в None. + """ + return self._last_error + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def start(self) -> None: + """ + Запустить сервис. + + Первая попытка подключения и чтения состояния выполняется сразу. + После этого запускается фоновый polling. + """ + + async with self._lifecycle_lock: + if self._running: + return + + self._running = True + + # Сразу пытаемся получить состояние. + # Если Tion недоступен, сервис всё равно продолжит работу. + await self.refresh_state() + + self._poll_task = asyncio.create_task( + self._poll_loop(), + name="tion-poll", + ) + + async def stop(self) -> None: + """ + Остановить polling и корректно закрыть BLE-соединение. + """ + + async with self._lifecycle_lock: + if not self._running: + return + + self._running = False + + poll_task = self._poll_task + self._poll_task = None + + if poll_task is not None: + poll_task.cancel() + + with suppress(asyncio.CancelledError): + await poll_task + + async with self._operation_lock: + await self._safe_disconnect() + + self._online = False + + # ------------------------------------------------------------------ + # State + # ------------------------------------------------------------------ + + async def refresh_state(self) -> TionState | None: + """ + Принудительно обновить состояние Tion. + + При ошибке: + - online становится False; + - last_error обновляется; + - старый state сохраняется; + - исключение наружу не выбрасывается. + + Возвращает None при ошибке. + """ + + async with self._operation_lock: + return await self._execute_locked( + lambda controller: controller.get_state(), + raise_on_error=False, + ) + + # ------------------------------------------------------------------ + # Commands + # ------------------------------------------------------------------ + + async def execute( + self, + operation: TionOperation, + ) -> TionState: + """ + Выполнить любую команду TionController. + + Пример: + + await service.execute( + lambda tion: tion.set_speed(3) + ) + + После команды состояние Service автоматически обновляется. + """ + + async with self._operation_lock: + state = await self._execute_locked( + operation, + raise_on_error=True, + ) + + # Здесь None невозможен, потому что raise_on_error=True. + assert state is not None + + return state + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + async def _execute_locked( + self, + operation: TionOperation, + *, + raise_on_error: bool, + ) -> TionState | None: + """ + Выполнение BLE-операции. + + Вызывается только при занятом _operation_lock. + """ + + try: + await self._ensure_connected() + + state = await operation(self._controller) + + except asyncio.CancelledError: + raise + + except Exception as exc: + self._mark_offline(exc) + + # После BLE-ошибки считаем соединение повреждённым. + # На следующей попытке будет создано новое. + await self._safe_disconnect() + + if raise_on_error: + raise + + return None + + self._mark_online(state) + + return state + + async def _ensure_connected(self) -> None: + """ + Убедиться, что имеется рабочее BLE-соединение. + + Если физического соединения нет, старое состояние подключения + сбрасывается и выполняется новое connect(). + """ + + if self._controller.connected: + return + + await self._safe_disconnect() + await self._controller.connect() + + async def _safe_disconnect(self) -> None: + """ + Закрыть соединение, не распространяя ошибку disconnect наружу. + """ + + with suppress(Exception): + await self._controller.disconnect() + + def _mark_online(self, state: TionState) -> None: + self._state = state + self._online = True + self._last_seen = datetime.now(timezone.utc) + self._last_error = None + + def _mark_offline(self, exc: Exception) -> None: + self._online = False + self._last_error = ( + f"{type(exc).__name__}: {exc}" + ) + + # ------------------------------------------------------------------ + # Background polling + # ------------------------------------------------------------------ + + async def _poll_loop(self) -> None: + """ + Фоновый цикл обновления состояния. + """ + + while self._running: + await asyncio.sleep(self._poll_interval) + + if not self._running: + break + + await self.refresh_state() + + # ------------------------------------------------------------------ + # Context manager + # ------------------------------------------------------------------ + + async def __aenter__(self) -> "TionService": + await self.start() + return self + + async def __aexit__( + self, + exc_type, + exc_value, + traceback, + ) -> None: + await self.stop() \ No newline at end of file diff --git a/config/auto.yaml b/config/auto.yaml new file mode 100644 index 0000000..0245bb5 --- /dev/null +++ b/config/auto.yaml @@ -0,0 +1,26 @@ +version: 1 + +check_interval: 5.0 + +co2: + base_speed: 1 + hysteresis: 100 + + thresholds: + - ppm: 700 + speed: 2 + + - ppm: 900 + speed: 3 + + - ppm: 1300 + speed: 4 + + - ppm: 1600 + speed: 5 + + - ppm: 2000 + speed: 6 + +temperature: + hysteresis: 0.5 \ No newline at end of file diff --git a/config/schedule.yaml b/config/schedule.yaml new file mode 100644 index 0000000..f8b6566 --- /dev/null +++ b/config/schedule.yaml @@ -0,0 +1,76 @@ +version: 1 + +enabled: true + +timezone: local + +templates: + + workday: + + + - time: "09:00" + action: + power: on + type: set + speed: 6 + heater: on + target_temp: 21 + + - time: "09:30" + action: + type: auto + speed: 2 + target_temp: 25 + + - time: "22:00" + action: + type: set + speed: 6 + + - time: "22:45" + action: + type: set + speed: 1 + + + + weekend: + + - time: "10:00" + action: + power: on + type: set + speed: 6 + heater: on + target_temp: 21 + + - time: "10:30" + action: + type: auto + speed: 2 + target_temp: 25 + + - time: "22:00" + action: + type: set + speed: 6 + + - time: "22:45" + action: + type: set + speed: 1 + + + + +days: + + mon: workday + tue: workday + wed: workday + thu: workday + fri: workday + + sat: weekend + sun: weekend \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e6d0489 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +tion-btle==3.3.6 +fastapi +uvicorn[standard] +PyYAML +paho-mqtt>=2.1,<3 +paho-mqtt \ No newline at end of file diff --git a/schedule/__init__.py b/schedule/__init__.py new file mode 100644 index 0000000..9cdc4eb --- /dev/null +++ b/schedule/__init__.py @@ -0,0 +1,25 @@ +from .loader import load_schedule +from .models import ( + ScheduleAction, + ScheduleActionType, + ScheduleConfig, + ScheduleOccurrence, + SchedulePoint, + ScheduleResolution, + ScheduledSettings, +) +from .service import ScheduleService + + +__all__ = [ + "load_schedule", + "ScheduleAction", + "ScheduleActionType", + "ScheduleConfig", + "ScheduleOccurrence", + "SchedulePoint", + "ScheduleResolution", + "ScheduledSettings", + "ScheduleService", +] + diff --git a/schedule/loader.py b/schedule/loader.py new file mode 100644 index 0000000..b289c72 --- /dev/null +++ b/schedule/loader.py @@ -0,0 +1,419 @@ +from datetime import time +from pathlib import Path +from typing import Any + +import yaml + +from app.my_dataclasses import ( + MIN_FAN_SPEED, + MAX_FAN_SPEED, + MIN_TARGET_TEMP, + MAX_TARGET_TEMP, + SUPPORTED_AIR_MODES, +) + +from .models import ( + ScheduleAction, + ScheduleActionType, + ScheduleConfig, + SchedulePoint, + ScheduledSettings, +) + + +WEEKDAYS = ( + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun", +) + + +SET_FIELDS = { + "power", + "speed", + "heater", + "target_temp", + "mode", + "sound", + "light", +} + + +def _parse_time(value: Any) -> time: + if not isinstance(value, str): + raise ValueError( + f"Schedule time must be a string: {value!r}" + ) + + try: + parsed = time.fromisoformat(value) + + except ValueError as exc: + raise ValueError( + f"Invalid schedule time: {value!r}" + ) from exc + + # Не разрешаем секунды. + if parsed.second != 0 or parsed.microsecond != 0: + raise ValueError( + f"Schedule time must use HH:MM: {value!r}" + ) + + return parsed + + +def _parse_on_off( + name: str, + value: Any, +) -> bool: + # PyYAML может автоматически превратить + # on/off в True/False. + if type(value) is bool: + return value + + if isinstance(value, str): + normalized = value.strip().lower() + + if normalized == "on": + return True + + if normalized == "off": + return False + + raise ValueError( + f"{name} must be 'on' or 'off'" + ) + + +def _parse_settings( + data: dict[str, Any], +) -> ScheduledSettings: + + unknown = set(data) - SET_FIELDS + + if unknown: + raise ValueError( + f"Unknown SET fields: {sorted(unknown)}" + ) + + values: dict[str, Any] = {} + + if "power" in data: + values["power"] = _parse_on_off( + "power", + data["power"], + ) + + if "speed" in data: + speed = data["speed"] + + if type(speed) is not int: + raise ValueError( + "speed must be an integer" + ) + + if not MIN_FAN_SPEED <= speed <= MAX_FAN_SPEED: + raise ValueError( + f"speed must be between " + f"{MIN_FAN_SPEED} and {MAX_FAN_SPEED}" + ) + + values["speed"] = speed + + if "heater" in data: + values["heater"] = _parse_on_off( + "heater", + data["heater"], + ) + + if "target_temp" in data: + temperature = data["target_temp"] + + if type(temperature) is not int: + raise ValueError( + "target_temp must be an integer" + ) + + if not ( + MIN_TARGET_TEMP + <= temperature + <= MAX_TARGET_TEMP + ): + raise ValueError( + f"target_temp must be between " + f"{MIN_TARGET_TEMP} and " + f"{MAX_TARGET_TEMP}" + ) + + values["target_temp"] = temperature + + if "mode" in data: + mode = data["mode"] + + if mode not in SUPPORTED_AIR_MODES: + raise ValueError( + f"Unsupported air mode: {mode!r}" + ) + + values["mode"] = mode + + if "sound" in data: + values["sound"] = _parse_on_off( + "sound", + data["sound"], + ) + + if "light" in data: + values["light"] = _parse_on_off( + "light", + data["light"], + ) + + settings = ScheduledSettings(**values) + + if settings.empty: + raise ValueError( + "SET action must contain " + "at least one setting" + ) + + return settings + + +def _parse_action( + data: Any, +) -> ScheduleAction: + + if not isinstance(data, dict): + raise ValueError( + "Schedule action must be an object" + ) + + action_type_raw = data.get("type") + + try: + action_type = ScheduleActionType(action_type_raw) + + except ValueError as exc: + raise ValueError( + f"Unsupported action type: " + f"{action_type_raw!r}" + ) from exc + + if action_type == ScheduleActionType.AUTO: + + allowed_fields = { + "type", + "speed", + "target_temp", + } + + extra = set(data) - allowed_fields + + if extra: + raise ValueError( + "AUTO action supports only " + "'speed' and 'target_temp': " + f"{sorted(extra)}" + ) + + if "speed" not in data: + raise ValueError( + "AUTO action requires 'speed'" + ) + + settings_data = { + "speed": data["speed"], + } + + if "target_temp" in data: + settings_data["target_temp"] = ( + data["target_temp"] + ) + + return ScheduleAction( + type=ScheduleActionType.AUTO, + settings=_parse_settings(settings_data), + ) + + settings_data = { + key: value + for key, value in data.items() + if key != "type" + } + + return ScheduleAction( + type=ScheduleActionType.SET, + settings=_parse_settings( + settings_data + ), + ) + +def _parse_template( + name: str, + data: Any, +) -> tuple[SchedulePoint, ...]: + + if not isinstance(data, list): + raise ValueError( + f"Template {name!r} must be a list" + ) + + if not data: + raise ValueError( + f"Template {name!r} cannot be empty" + ) + + points: list[SchedulePoint] = [] + + for item in data: + + if not isinstance(item, dict): + raise ValueError( + f"Invalid point in template {name!r}" + ) + + if "time" not in item: + raise ValueError( + f"Point in {name!r} has no time" + ) + + if "action" not in item: + raise ValueError( + f"Point in {name!r} has no action" + ) + + points.append( + SchedulePoint( + at=_parse_time(item["time"]), + action=_parse_action( + item["action"] + ), + ) + ) + + points.sort( + key=lambda point: point.at + ) + + seen_times = set() + + for point in points: + if point.at in seen_times: + raise ValueError( + f"Duplicate time {point.at} " + f"in template {name!r}" + ) + + seen_times.add(point.at) + + return tuple(points) + + +def load_schedule( + path: str | Path, +) -> ScheduleConfig: + + path = Path(path) + + with path.open( + "r", + encoding="utf-8", + ) as file: + raw = yaml.safe_load(file) + + if not isinstance(raw, dict): + raise ValueError( + "Schedule root must be an object" + ) + + version = raw.get("version", 1) + + if version != 1: + raise ValueError( + f"Unsupported schedule version: {version}" + ) + + enabled = raw.get("enabled", True) + + if type(enabled) is not bool: + raise ValueError( + "enabled must be true or false" + ) + + timezone = raw.get( + "timezone", + "local", + ) + + if timezone != "local": + raise ValueError( + "Only timezone: local " + "is currently supported" + ) + + raw_templates = raw.get("templates") + + if not isinstance(raw_templates, dict): + raise ValueError( + "templates must be an object" + ) + + templates = { + name: _parse_template( + name, + template, + ) + for name, template + in raw_templates.items() + } + + raw_days = raw.get("days") + + if not isinstance(raw_days, dict): + raise ValueError( + "days must be an object" + ) + + days: dict[str, str] = {} + + for weekday in WEEKDAYS: + + if weekday not in raw_days: + raise ValueError( + f"Missing schedule day: {weekday}" + ) + + template_name = raw_days[weekday] + + if template_name not in templates: + raise ValueError( + f"Unknown template " + f"{template_name!r} " + f"for {weekday}" + ) + + days[weekday] = template_name + + unknown_days = ( + set(raw_days) + - set(WEEKDAYS) + ) + + if unknown_days: + raise ValueError( + f"Unknown weekdays: " + f"{sorted(unknown_days)}" + ) + + return ScheduleConfig( + version=version, + enabled=enabled, + timezone=timezone, + templates=templates, + days=days, + ) \ No newline at end of file diff --git a/schedule/models.py b/schedule/models.py new file mode 100644 index 0000000..ee0316f --- /dev/null +++ b/schedule/models.py @@ -0,0 +1,121 @@ +from dataclasses import dataclass, field, fields +from datetime import datetime, time +from enum import StrEnum +from typing import Any + + +class ScheduleActionType(StrEnum): + SET = "set" + AUTO = "auto" + + +@dataclass(frozen=True, slots=True) +class ScheduledSettings: + power: bool | None = None + speed: int | None = None + heater: bool | None = None + target_temp: int | None = None + mode: str | None = None + sound: bool | None = None + light: bool | None = None + + def merged(self, newer: "ScheduledSettings") -> "ScheduledSettings": + """ + Наложить более новые настройки поверх старых. + + None означает: + параметр в данной точке расписания не менялся. + """ + + values: dict[str, Any] = {} + + for item in fields(self): + old_value = getattr(self, item.name) + new_value = getattr(newer, item.name) + + values[item.name] = ( + old_value + if new_value is None + else new_value + ) + + return ScheduledSettings(**values) + + @property + def empty(self) -> bool: + return all( + getattr(self, item.name) is None + for item in fields(self) + ) + + def to_dict( + self, + *, + skip_none: bool = True, + ) -> dict: + result = {} + + for item in fields(self): + value = getattr(self, item.name) + + if skip_none and value is None: + continue + + result[item.name] = value + + return result + + +@dataclass(frozen=True, slots=True) +class ScheduleAction: + type: ScheduleActionType + + settings: ScheduledSettings = field( + default_factory=ScheduledSettings + ) + + +@dataclass(frozen=True, slots=True) +class SchedulePoint: + at: time + action: ScheduleAction + + +@dataclass(frozen=True, slots=True) +class ScheduleConfig: + version: int + enabled: bool + timezone: str + + templates: dict[ + str, + tuple[SchedulePoint, ...] + ] + + days: dict[str, str] + + +@dataclass(frozen=True, slots=True) +class ScheduleOccurrence: + """ + Конкретная точка расписания уже с датой. + """ + + when: datetime + weekday: str + template: str + point: SchedulePoint + + +@dataclass(frozen=True, slots=True) +class ScheduleResolution: + enabled: bool + + current: ScheduleOccurrence | None + next: ScheduleOccurrence | None + + scheduled_settings: ScheduledSettings + + auto_active: bool + 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 new file mode 100644 index 0000000..ad4d226 --- /dev/null +++ b/schedule/service.py @@ -0,0 +1,629 @@ +import asyncio +import json +from pathlib import Path +from app.tion.service import TionService + +from datetime import ( + date, + datetime, + timedelta, +) + +from .models import ( + ScheduleActionType, + ScheduleConfig, + ScheduleOccurrence, + ScheduleResolution, + ScheduledSettings, +) + +from dataclasses import replace + +WEEKDAYS = ( + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun", +) + + + +class ScheduleService: + + def __init__( + self, + config, + tion: TionService | None = None, + check_interval: float = 5.0, + state_path: str | Path | None = None, + ): + self._config = config + self._tion = tion + self._check_interval = check_interval + + self._running = False + self._task = None + + # Последняя успешно применённая точка расписания. + self._last_applied_when: datetime | None = None + + # Temporary override. + self._override_settings = ScheduledSettings() + self._override_until: datetime | None = None + + # True означает: + # override записан, но применить его к Tion + # пока не удалось. + self._override_pending = False + + # Ошибка именно ScheduleService. + self._last_error: str | None = None + + self._paused = False + + self._state_path = ( + Path(state_path) + if state_path is not None + else None + ) + + self._load_runtime_state() + + + + @property + def paused(self) -> bool: + return self._paused + + @property + def running(self) -> bool: + return self._running + + @property + def last_error(self) -> str | None: + return self._last_error + + @property + def override_settings(self) -> ScheduledSettings: + return self._override_settings + + @property + def override_until(self) -> datetime | None: + return self._override_until + + @property + def override_active(self) -> bool: + if self._override_until is None: + return False + + return datetime.now() < self._override_until + + @property + def config(self) -> ScheduleConfig: + return self._config + + def _load_runtime_state(self) -> None: + + if self._state_path is None: + return + + if not self._state_path.exists(): + return + + try: + data = json.loads( + self._state_path.read_text( + encoding="utf-8" + ) + ) + except ( + OSError, + json.JSONDecodeError, + ): + return + + paused = data.get("paused") + + if isinstance(paused, bool): + self._paused = paused + + def _save_runtime_state(self) -> None: + + if self._state_path is None: + return + + data = { + "paused": self._paused, + } + + self._state_path.parent.mkdir( + parents=True, + exist_ok=True, + ) + + temp_path = ( + self._state_path.with_suffix( + self._state_path.suffix + ".tmp" + ) + ) + + temp_path.write_text( + json.dumps( + data, + indent=2, + ), + encoding="utf-8", + ) + + temp_path.replace( + self._state_path + ) + + async def apply_override(self, settings: ScheduledSettings) -> None: + + if settings.empty: + raise ValueError( + "Override settings cannot be empty" + ) + + if self._tion is None: + raise RuntimeError( + "TionService is required" + ) + + now = datetime.now() + + resolution = self.resolve(now) + + if not resolution.enabled: + raise RuntimeError( + "Schedule is disabled" + ) + + if resolution.current is None: + raise RuntimeError( + "Current schedule point not found" + ) + + if resolution.next is None: + raise RuntimeError( + "Next schedule point not found" + ) + + # Если старый override уже закончился, + # перед созданием нового очищаем его. + if ( self._override_until is not None + and now >= self._override_until + ): + self._clear_override() + + # Добавляем новые ручные параметры + # к уже существующему override. + self._override_settings = ( self._override_settings.merged(settings) ) + + # Override действует строго до + # следующей точки расписания. + self._override_until = resolution.next.when + + # Считаем его неприменённым, + # пока команда реально не прошла. + self._override_pending = True + + try: + await self._apply_settings(settings) + + except Exception as exc: + self._last_error = ( + f"{type(exc).__name__}: {exc}" + ) + + # Override остаётся сохранённым. + # Фоновый цикл попробует ещё раз. + raise + + else: + self._override_pending = False + self._last_error = None + + def _clear_override(self) -> None: + self._override_settings = ScheduledSettings() + self._override_until = None + self._override_pending = False + + async def clear_override(self) -> None: + self._clear_override() + + # Сразу пересчитываем и применяем текущее состояние расписания. + await self._process() + + + async def pause(self) -> None: + + if self._paused: + return + + # Сначала запрещаем расписанию вмешиваться. + self._paused = True + + # Temporary override больше не имеет смысла: + # мы переходим в полноценный ручной режим. + self._clear_override() + self._last_applied_when = None + + self._save_runtime_state() + + async def resume(self) -> None: + + if not self._paused: + return + + self._paused = False + + self._save_runtime_state() + + # Сразу применяем состояние расписания, + # актуальное именно сейчас. + await self._process() + + + def resolve( + self, + now: datetime | None = None, + ) -> ScheduleResolution: + """ + Определить: + - текущую точку; + - следующую точку; + - накопленные SET-настройки; + - активен ли сейчас AUTO. + """ + + if now is None: + now = datetime.now() + + if not self._config.enabled: + return ScheduleResolution( + enabled=False, + current=None, + next=None, + scheduled_settings=ScheduledSettings(), + auto_active=False, + ) + + # Недели назад достаточно, + # поскольку расписание повторяется каждые 7 дней. + start_date = now.date() - timedelta(days=7) + + end_date = now.date() + timedelta(days=7) + + occurrences = self._build_occurrences( + start_date, + end_date, + ) + + current = None + next_point = None + + for occurrence in occurrences: + + if occurrence.when <= now: + current = occurrence + continue + + next_point = occurrence + break + + if current is None: + raise RuntimeError( + "Could not determine current " + "schedule point" + ) + + scheduled_settings = ( + self._calculate_settings( + occurrences, + current, + ) + ) + + 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_target_temp = current.point.action.settings.target_temp + + return ScheduleResolution( + enabled=True, + current=current, + next=next_point, + scheduled_settings=scheduled_settings, + auto_active=auto_active, + auto_fallback_speed=auto_fallback_speed, + auto_target_temp=auto_target_temp, + ) + + def _build_occurrences( + self, + start_date: date, + end_date: date, + ) -> list[ScheduleOccurrence]: + + result: list[ScheduleOccurrence] = [] + + current_date = start_date + + while current_date <= end_date: + + weekday = WEEKDAYS[ + current_date.weekday() + ] + + template_name = ( + self._config.days[weekday] + ) + + template = ( + self._config.templates[ + template_name + ] + ) + + for point in template: + + when = datetime.combine( + current_date, + point.at, + ) + + result.append( + ScheduleOccurrence( + when=when, + weekday=weekday, + template=template_name, + point=point, + ) + ) + + current_date += timedelta(days=1) + + result.sort( + key=lambda occurrence: + occurrence.when + ) + + return result + + def _calculate_settings( + self, + occurrences: list[ + ScheduleOccurrence + ], + current: ScheduleOccurrence, + ) -> ScheduledSettings: + """ + Восстановить последние SET-значения + каждого параметра расписания. + """ + + settings = ScheduledSettings() + + for occurrence in occurrences: + + if occurrence.when > current.when: + break + + action = occurrence.point.action + + if ( + action.type + != ScheduleActionType.SET + ): + continue + + settings = settings.merged( + action.settings + ) + + return settings + + async def start(self) -> None: + if self._running: + return + + if self._tion is None: + raise RuntimeError("TionService is required to start schedule") + + self._running = True + + # При запуске сразу приводим Tion + # к текущему состоянию расписания. + try: + await self._process() + except Exception as exc: + self._last_error = f"{type(exc).__name__}: {exc}" + + self._task = asyncio.create_task(self._loop()) + + + async def stop(self) -> None: + if not self._running: + return + + self._running = False + + if self._task is not None: + self._task.cancel() + + try: + await self._task + except asyncio.CancelledError: + pass + + self._task = None + + + async def _loop(self) -> None: + while self._running: + await asyncio.sleep( + self._check_interval + ) + + try: + await self._process() + + except Exception as exc: + self._last_error = f"{type(exc).__name__}: {exc}" + + + async def _process(self) -> None: + #Если ручное управление то сразу выходим + if self._paused: + return + + now = datetime.now() + + resolution = self.resolve(now) + + if not resolution.enabled: + return + + current = resolution.current + + if current is None: + return + + # -------------------------------------------------- + # 1. Проверяем окончание temporary override + # -------------------------------------------------- + + if ( + self._override_until is not None + and now >= self._override_until + ): + self._clear_override() + + # -------------------------------------------------- + # 2. Пока override действует, + # расписание Tion не трогает + # -------------------------------------------------- + + if self.override_active: + + # Если override ранее не удалось применить, + # пробуем снова. + if self._override_pending: + await self._apply_settings(self._override_settings) + self._override_pending = False + self._last_error = None + return + + # -------------------------------------------------- + # 3. Если текущая точка уже успешно применена, + # ничего не делаем + # -------------------------------------------------- + + if self._last_applied_when == current.when: + return + + # -------------------------------------------------- + # 4. Применяем состояние расписания + # -------------------------------------------------- + + settings = resolution.scheduled_settings + + if resolution.auto_active: + # В режиме AUTO скорость принадлежит + # AutoController. + # + # Остальные параметры расписания + # (power, heater, temperature, mode...) + # должны продолжать работать. + settings = replace( + settings, + speed=None, + heater=None, + target_temp=None, + ) + + await self._apply_settings(settings) + + # Ставим только ПОСЛЕ успешного применения. + self._last_applied_when = current.when + self._last_error = None + + + + async def _apply_settings(self, settings: ScheduledSettings) -> None: + + if self._tion is None: + return + + # Если нужно включить Tion — + # сначала включаем. + if settings.power is True: + await self._tion.execute( + lambda tion: tion.power_on() + ) + + if settings.mode is not None: + await self._tion.execute( + lambda tion: + tion.set_air_mode(settings.mode) + ) + + if settings.target_temp is not None: + await self._tion.execute( + lambda tion: + tion.set_target_temperature( + settings.target_temp + ) + ) + + if settings.heater is not None: + if settings.heater: + await self._tion.execute( + lambda tion: tion.heater_on() + ) + else: + await self._tion.execute( + lambda tion: tion.heater_off() + ) + + if settings.speed is not None: + await self._tion.execute( + lambda tion: + tion.set_speed(settings.speed) + ) + + if settings.sound is not None: + if settings.sound: + await self._tion.execute( + lambda tion: tion.sound_on() + ) + else: + await self._tion.execute( + lambda tion: tion.sound_off() + ) + + if settings.light is not None: + if settings.light: + await self._tion.execute( + lambda tion: tion.light_on() + ) + else: + await self._tion.execute( + lambda tion: tion.light_off() + ) + + # Если нужно выключить — + # выключаем последним. + if settings.power is False: + await self._tion.execute( + lambda tion: tion.power_off() + ) \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test-reconnect.py b/tests/test-reconnect.py new file mode 100644 index 0000000..83655ad --- /dev/null +++ b/tests/test-reconnect.py @@ -0,0 +1,37 @@ +import asyncio +import logging + + + +from app.my_dataclasses import TION_MAC +from app.tion import TionController, TionService + + +# Убираем служебное логирование библиотек + + +async def main(): + logging.getLogger("asyncio").setLevel(logging.WARNING) + logging.getLogger("bleak").setLevel(logging.WARNING) + logging.getLogger("tion_btle").setLevel(logging.WARNING) + controller = TionController(TION_MAC) + + service = TionService( + controller, + poll_interval=3, + ) + + async with service: + for seconds in range(0, 91, 3): + print( + f"{seconds:02d}s | " + f"online={service.online} | " + f"last_seen={service.last_seen} | " + f"error={service.last_error}" + ) + + await asyncio.sleep(3) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/tests/test-scanning.py b/tests/test-scanning.py new file mode 100644 index 0000000..b3c12e7 --- /dev/null +++ b/tests/test-scanning.py @@ -0,0 +1,30 @@ +import asyncio +import logging + +from bleak import BleakScanner + +from app.my_dataclasses import TION_MAC + + +logging.disable(logging.CRITICAL) + + +async def main(): + print("Ищу Tion...") + + device = await BleakScanner.find_device_by_address( + TION_MAC, + timeout=10, + ) + + if device is None: + print("Tion НЕ найден сканером") + else: + print("Tion найден:") + print(f" name: {device.name}") + print(f" address: {device.address}") + print(f" details: {device.details}") + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/tests/test-tion.py b/tests/test-tion.py index e69de29..98d66de 100644 --- a/tests/test-tion.py +++ b/tests/test-tion.py @@ -0,0 +1,59 @@ +#d1749bebeea6 + +import asyncio +import json +import logging + +from tion_btle import TionS4 +from app.my_dataclasses import * + + +def print_state(state: dict): + print(json.dumps( + state, + indent=2, + ensure_ascii=False + )) + + +async def main(): + # Убираем лишнее логирование + logging.getLogger("asyncio").setLevel(logging.WARNING) + logging.getLogger("bleak").setLevel(logging.WARNING) + logging.getLogger("tion_btle").setLevel(logging.WARNING) + + tion = TionS4(TION_MAC) + + print("=== Подключение к Tion ===") + await tion.connect() + + try: + # 1. Получаем исходное состояние + print("\n=== Исходное состояние ===") + state = await tion.get() + print_state(state) + + # 2. Включаем Tion и устанавливаем скорость 2 + print("\n=== Включаю Tion, скорость 2 ===") + + await tion.set({ + "state": "on", + "fan_speed": 2 + }) + + await asyncio.sleep(1) + + # 3. Повторно читаем реальное состояние + print("\n=== Состояние после команды ===") + state = await tion.get() + print_state(state) + + + + finally: + print("\n=== Отключение ===") + await tion.disconnect() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/test_auto_config.py b/tests/test_auto_config.py new file mode 100644 index 0000000..3868c67 --- /dev/null +++ b/tests/test_auto_config.py @@ -0,0 +1,101 @@ +from pathlib import Path + +from app.auto.config import ( + load_auto_config, +) + + +PROJECT_ROOT = ( + Path(__file__) + .resolve() + .parents[1] +) + +AUTO_CONFIG_FILE = ( + PROJECT_ROOT + / "config" + / "auto.yaml" +) + + +def main(): + + config = load_auto_config( + AUTO_CONFIG_FILE + ) + + print() + print("=" * 70) + print("AUTO CONFIG") + print("=" * 70) + + print( + "Version:", + config.version, + ) + + print( + "Check interval:", + config.check_interval, + ) + + print( + "Base speed:", + config.co2.base_speed, + ) + + print( + "Hysteresis:", + config.co2.hysteresis, + ) + + print( + "Thresholds:" + ) + + for ppm, speed in ( + config.co2.thresholds + ): + print( + f" CO2 >= {ppm:<4} " + f"-> speed {speed}" + ) + + assert config.version == 1 + + assert ( + config.check_interval + == 5.0 + ) + + assert ( + config.co2.base_speed + == 1 + ) + + assert ( + config.co2.hysteresis + == 100 + ) + + assert ( + config.co2.thresholds + == ( + (800, 2), + (1000, 3), + (1300, 4), + (1600, 5), + (2000, 6), + ) + ) + + print() + print("=" * 70) + print( + "AUTO CONFIG TEST PASSED" + ) + print("=" * 70) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/test_auto_controller.py b/tests/test_auto_controller.py new file mode 100644 index 0000000..fe27b71 --- /dev/null +++ b/tests/test_auto_controller.py @@ -0,0 +1,884 @@ +import asyncio +from types import SimpleNamespace + +from app.auto.co2_policy import Co2SpeedPolicy +from app.auto.controller import AutoController + + +# ============================================================ +# Fake ScheduleService +# ============================================================ + +class FakeScheduleService: + + def __init__( + self, + *, + enabled: bool = True, + auto_active: bool = False, + fallback_speed: int | None = None, + ): + self.enabled = enabled + self.auto_active = auto_active + self.fallback_speed = fallback_speed + + self.override_active = False + + def resolve(self, now): + return SimpleNamespace( + enabled=self.enabled, + auto_active=self.auto_active, + auto_fallback_speed=self.fallback_speed, + ) + + +# ============================================================ +# Fake QingpingService +# ============================================================ + +class FakeQingpingService: + + def __init__( + self, + *, + online: bool = False, + co2: int | None = None, + ): + self.online = online + + self.state = SimpleNamespace( + co2=co2, + ) + + +# ============================================================ +# Fake Tion +# ============================================================ + +class FakeTionController: + + def __init__(self): + self.calls = [] + + async def set_speed( + self, + speed: int, + ): + self.calls.append( + ("set_speed", speed) + ) + + +class FakeTionService: + + def __init__(self): + self.controller = FakeTionController() + + async def execute( + self, + operation, + ): + return await operation( + self.controller + ) + + +# ============================================================ +# Helper +# ============================================================ + +def print_state( + title: str, + auto: AutoController, + tion: FakeTionService, +): + print() + print("=" * 70) + print(title) + print("=" * 70) + + print( + "Auto status:", + auto.status(), + ) + + print( + "Tion calls:", + tion.controller.calls, + ) + + +# ============================================================ +# Main test +# ============================================================ + +async def main(): + + schedule = FakeScheduleService() + + qingping = FakeQingpingService() + + tion = FakeTionService() + + policy = Co2SpeedPolicy( + base_speed=1, + thresholds=( + (800, 2), + (1000, 3), + (1300, 4), + (1600, 5), + (2000, 6), + ), + hysteresis=100, + ) + + auto = AutoController( + schedule_service=schedule, + qingping_service=qingping, + tion_service=tion, + policy=policy, + ) + + # ======================================================== + # TEST 1 + # + # AUTO не активен. + # + # AutoController не должен ничего делать. + # ======================================================== + + schedule.enabled = True + schedule.auto_active = False + schedule.fallback_speed = None + schedule.override_active = False + + qingping.online = True + qingping.state.co2 = 1200 + + await auto._process() + + print_state( + "TEST 1 — AUTO INACTIVE", + auto, + tion, + ) + + assert ( + auto.status()["state"] + == "inactive" + ) + + assert ( + auto.status()["reason"] + is None + ) + + assert ( + auto.status()["target_speed"] + is None + ) + + assert ( + auto.status()["auto_speed"] + is None + ) + + assert tion.controller.calls == [] + + + # ======================================================== + # TEST 2 + # + # AUTO активен. + # Qingping работает. + # CO2 = 700. + # + # Ожидаем speed 1. + # ======================================================== + + schedule.auto_active = True + schedule.fallback_speed = 2 + + qingping.online = True + qingping.state.co2 = 700 + + await auto._process() + + print_state( + "TEST 2 — CO2 700", + auto, + tion, + ) + + assert ( + auto.status()["state"] + == "active" + ) + + assert ( + auto.status()["reason"] + is None + ) + + assert ( + auto.status()["auto_speed"] + == 1 + ) + + assert ( + auto.status()["target_speed"] + == 1 + ) + + assert tion.controller.calls == [ + ("set_speed", 1), + ] + + + # ======================================================== + # TEST 3 + # + # CO2 вырос до 850. + # + # Ожидаем переход: + # + # speed 1 -> speed 2 + # ======================================================== + + qingping.state.co2 = 850 + + await auto._process() + + print_state( + "TEST 3 — CO2 850", + auto, + tion, + ) + + assert ( + auto.status()["auto_speed"] + == 2 + ) + + assert ( + auto.status()["target_speed"] + == 2 + ) + + assert tion.controller.calls == [ + ("set_speed", 1), + ("set_speed", 2), + ] + + + # ======================================================== + # TEST 4 + # + # CO2 вырос до 1050. + # + # Ожидаем: + # + # speed 2 -> speed 3 + # ======================================================== + + qingping.state.co2 = 1050 + + await auto._process() + + print_state( + "TEST 4 — CO2 1050", + auto, + tion, + ) + + assert ( + auto.status()["auto_speed"] + == 3 + ) + + assert ( + auto.status()["target_speed"] + == 3 + ) + + assert tion.controller.calls == [ + ("set_speed", 1), + ("set_speed", 2), + ("set_speed", 3), + ] + + + # ======================================================== + # TEST 5 + # + # CO2 опустился до 950. + # + # Порог speed 3: + # + # вверх = 1000 + # вниз = 900 + # + # Поэтому остаёмся на speed 3. + # ======================================================== + + qingping.state.co2 = 950 + + await auto._process() + + print_state( + "TEST 5 — HYSTERESIS CO2 950", + auto, + tion, + ) + + assert ( + auto.status()["auto_speed"] + == 3 + ) + + assert ( + auto.status()["target_speed"] + == 3 + ) + + # Новой команды быть не должно. + assert tion.controller.calls == [ + ("set_speed", 1), + ("set_speed", 2), + ("set_speed", 3), + ] + + + # ======================================================== + # TEST 6 + # + # CO2 дошёл до 900. + # + # Теперь переходим: + # + # speed 3 -> speed 2 + # ======================================================== + + qingping.state.co2 = 900 + + await auto._process() + + print_state( + "TEST 6 — CO2 900", + auto, + tion, + ) + + assert ( + auto.status()["auto_speed"] + == 2 + ) + + assert ( + auto.status()["target_speed"] + == 2 + ) + + assert tion.controller.calls == [ + ("set_speed", 1), + ("set_speed", 2), + ("set_speed", 3), + ("set_speed", 2), + ] + + + # ======================================================== + # TEST 7 + # + # Qingping offline. + # + # AUTO должен перейти в fallback. + # + # fallback speed = 2 + # + # Но Tion уже находится на speed 2, + # поэтому повторная команда не нужна. + # ======================================================== + + qingping.online = False + + await auto._process() + + print_state( + "TEST 7 — QINGPING OFFLINE", + auto, + tion, + ) + + assert ( + auto.status()["state"] + == "fallback" + ) + + assert ( + auto.status()["reason"] + == "qingping_offline" + ) + + assert ( + auto.status()["auto_speed"] + is None + ) + + assert ( + auto.status()["target_speed"] + == 2 + ) + + assert tion.controller.calls == [ + ("set_speed", 1), + ("set_speed", 2), + ("set_speed", 3), + ("set_speed", 2), + ] + + + # ======================================================== + # TEST 8 + # + # Qingping всё ещё offline. + # + # Одинаковую fallback-команду повторять нельзя. + # ======================================================== + + await auto._process() + + print_state( + "TEST 8 — FALLBACK NOT REPEATED", + auto, + tion, + ) + + assert ( + auto.status()["state"] + == "fallback" + ) + + assert tion.controller.calls == [ + ("set_speed", 1), + ("set_speed", 2), + ("set_speed", 3), + ("set_speed", 2), + ] + + + # ======================================================== + # TEST 9 + # + # Qingping online, + # но CO2 отсутствует. + # + # Это тоже fallback. + # ======================================================== + + qingping.online = True + qingping.state.co2 = None + + await auto._process() + + print_state( + "TEST 9 — CO2 MISSING", + auto, + tion, + ) + + assert ( + auto.status()["state"] + == "fallback" + ) + + assert ( + auto.status()["reason"] + == "co2_missing" + ) + + assert ( + auto.status()["target_speed"] + == 2 + ) + + assert ( + auto.status()["auto_speed"] + is None + ) + + assert tion.controller.calls == [ + ("set_speed", 1), + ("set_speed", 2), + ("set_speed", 3), + ("set_speed", 2), + ] + + + # ======================================================== + # TEST 10 + # + # Qingping восстановился. + # + # CO2 = 1700. + # + # После fallback auto_speed был сброшен, + # поэтому скорость выбирается заново. + # + # Ожидаем speed 5. + # ======================================================== + + qingping.online = True + qingping.state.co2 = 1700 + + await auto._process() + + print_state( + "TEST 10 — RECOVERY CO2 1700", + auto, + tion, + ) + + assert ( + auto.status()["state"] + == "active" + ) + + assert ( + auto.status()["reason"] + is None + ) + + assert ( + auto.status()["auto_speed"] + == 5 + ) + + assert ( + auto.status()["target_speed"] + == 5 + ) + + assert tion.controller.calls == [ + ("set_speed", 1), + ("set_speed", 2), + ("set_speed", 3), + ("set_speed", 2), + ("set_speed", 5), + ] + + + # ======================================================== + # TEST 11 + # + # Проверяем максимальную скорость 6. + # + # CO2 = 2200 + # ======================================================== + + qingping.state.co2 = 2200 + + await auto._process() + + print_state( + "TEST 11 — CO2 2200", + auto, + tion, + ) + + assert ( + auto.status()["auto_speed"] + == 6 + ) + + assert ( + auto.status()["target_speed"] + == 6 + ) + + assert tion.controller.calls[-1] == ( + "set_speed", + 6, + ) + + + # ======================================================== + # TEST 12 + # + # Manual override. + # + # Пользователь управляет Tion вручную. + # + # AUTO должен полностью отойти в сторону. + # ======================================================== + + schedule.override_active = True + + await auto._process() + + print_state( + "TEST 12 — MANUAL OVERRIDE", + auto, + tion, + ) + + assert ( + auto.status()["state"] + == "suspended" + ) + + assert ( + auto.status()["reason"] + == "manual_override" + ) + + assert ( + auto.status()["target_speed"] + is None + ) + + assert ( + auto.status()["auto_speed"] + is None + ) + + # Никаких новых команд. + assert tion.controller.calls[-1] == ( + "set_speed", + 6, + ) + + + # ======================================================== + # TEST 13 + # + # Manual override закончился. + # + # AUTO должен заново выбрать скорость + # по текущему CO2. + # + # CO2 остаётся 2200 -> speed 6. + # ======================================================== + + schedule.override_active = False + + await auto._process() + + print_state( + "TEST 13 — OVERRIDE FINISHED", + auto, + tion, + ) + + assert ( + auto.status()["state"] + == "active" + ) + + assert ( + auto.status()["auto_speed"] + == 6 + ) + + assert ( + auto.status()["target_speed"] + == 6 + ) + + # target_speed был сброшен во время override, + # поэтому команда должна быть отправлена заново. + assert tion.controller.calls[-1] == ( + "set_speed", + 6, + ) + + + # ======================================================== + # TEST 14 + # + # Qingping снова падает. + # + # fallback = 2. + # ======================================================== + + qingping.online = False + + await auto._process() + + print_state( + "TEST 14 — SECOND FAILURE", + auto, + tion, + ) + + assert ( + auto.status()["state"] + == "fallback" + ) + + assert ( + auto.status()["target_speed"] + == 2 + ) + + assert ( + auto.status()["auto_speed"] + is None + ) + + assert tion.controller.calls[-1] == ( + "set_speed", + 2, + ) + + + # ======================================================== + # TEST 15 + # + # Началась другая AUTO-точка расписания. + # + # Новый fallback = 3. + # + # Qingping по-прежнему offline. + # + # Ожидаем смену fallback: + # + # 2 -> 3 + # ======================================================== + + schedule.fallback_speed = 3 + + await auto._process() + + print_state( + "TEST 15 — FALLBACK CHANGED", + auto, + tion, + ) + + assert ( + auto.status()["state"] + == "fallback" + ) + + assert ( + auto.status()["target_speed"] + == 3 + ) + + assert tion.controller.calls[-1] == ( + "set_speed", + 3, + ) + + + # ======================================================== + # TEST 16 + # + # AUTO закончился. + # + # AutoController перестаёт управлять Tion. + # ======================================================== + + schedule.auto_active = False + schedule.fallback_speed = None + + await auto._process() + + print_state( + "TEST 16 — AUTO FINISHED", + auto, + tion, + ) + + assert ( + auto.status()["state"] + == "inactive" + ) + + assert ( + auto.status()["reason"] + is None + ) + + assert ( + auto.status()["target_speed"] + is None + ) + + assert ( + auto.status()["auto_speed"] + is None + ) + # ======================================================== + # TEST 17 + # + # CO2 policy отсутствует. + # + # Например, auto.yaml повреждён. + # AUTO обязан использовать fallback. + # ======================================================== + + schedule2 = FakeScheduleService( + enabled=True, + auto_active=True, + fallback_speed=4, + ) + + qingping2 = FakeQingpingService( + online=True, + co2=2500, + ) + + tion2 = FakeTionService() + + auto2 = AutoController( + schedule_service=schedule2, + qingping_service=qingping2, + tion_service=tion2, + policy=None, + ) + + await auto2._process() + + print_state( + "TEST 17 — POLICY UNAVAILABLE", + auto2, + tion2, + ) + + assert ( + auto2.status()["state"] + == "fallback" + ) + + assert ( + auto2.status()["reason"] + == "auto_policy_unavailable" + ) + + assert ( + auto2.status()["target_speed"] + == 4 + ) + + assert ( + auto2.status()["auto_speed"] + is None + ) + + assert tion2.controller.calls == [ + ("set_speed", 4), + ] + + print() + print("=" * 70) + print( + "ALL AUTO CONTROLLER TESTS PASSED" + ) + print("=" * 70) + + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file 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_schedule_pause.py b/tests/test_auto_schedule_pause.py new file mode 100644 index 0000000..627bfb1 --- /dev/null +++ b/tests/test_auto_schedule_pause.py @@ -0,0 +1,181 @@ +import asyncio +from types import SimpleNamespace + +from app.auto.controller import AutoController + + +class FakeScheduleService: + + def __init__(self): + self.paused = True + self.override_active = False + + def resolve(self, now): + return SimpleNamespace( + enabled=True, + auto_active=True, + auto_fallback_speed=1, + auto_target_temp=20, + ) + + +class FakeQingpingService: + + def __init__(self): + self.online = True + + self.state = SimpleNamespace( + co2=1200, + temperature=19.0, + ) + + +class FakeTionController: + + def __init__(self): + self.calls = [] + + async def set_speed(self, speed): + self.calls.append( + ("set_speed", speed) + ) + + 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_target_temperature( + self, + temperature, + ): + self.calls.append( + ( + "set_target_temperature", + temperature, + ) + ) + + +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_paused_schedule_suspends_auto(): + + schedule = FakeScheduleService() + qingping = FakeQingpingService() + tion = FakeTionService() + + controller = AutoController( + schedule_service=schedule, + qingping_service=qingping, + tion_service=tion, + ) + + # Имитируем состояние, + # которое AUTO помнил до pause. + controller._target_speed = 3 + controller._auto_speed = 3 + + controller._target_heater = True + controller._auto_heater = True + + controller._target_temperature = 30 + + controller._temperature = 19.0 + controller._temperature_source = ( + "qingping" + ) + + await controller._process() + + status = controller.status() + + # AUTO должен быть остановлен. + assert ( + status["state"] + == "suspended" + ) + + assert ( + status["reason"] + == "schedule_paused" + ) + + # Никаких команд Tion. + assert tion.controller.calls == [] + + # Старое владение AUTO забыто. + assert ( + controller._target_speed + is None + ) + + assert ( + controller._auto_speed + is None + ) + + assert ( + controller._target_heater + is None + ) + + assert ( + controller._auto_heater + is None + ) + + assert ( + controller._target_temperature + is None + ) + + assert ( + controller._temperature + is None + ) + + assert ( + controller._temperature_source + is None + ) + + +async def main(): + + await test_paused_schedule_suspends_auto() + + print() + print("=" * 70) + print( + "ALL AUTO SCHEDULE PAUSE " + "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_ble_scan.py b/tests/test_ble_scan.py new file mode 100644 index 0000000..342b618 --- /dev/null +++ b/tests/test_ble_scan.py @@ -0,0 +1,27 @@ +import asyncio + +from bleak import BleakScanner + + +async def main(): + print("Scanning for 20 seconds...") + + devices = await BleakScanner.discover( + timeout=20.0, + return_adv=True, + ) + + print() + print(f"Found: {len(devices)} devices") + print() + + for address, (device, adv) in devices.items(): + print("ADDRESS:", address) + print("NAME:", device.name) + print("RSSI:", adv.rssi) + print("UUIDS:", adv.service_uuids) + print("SERVICE DATA:", adv.service_data) + print("-" * 60) + + +asyncio.run(main()) \ No newline at end of file diff --git a/tests/test_co2_policy.py b/tests/test_co2_policy.py new file mode 100644 index 0000000..9d8078b --- /dev/null +++ b/tests/test_co2_policy.py @@ -0,0 +1,261 @@ +from app.auto.co2_policy import ( + Co2SpeedPolicy, +) + + +def main(): + policy = Co2SpeedPolicy( + base_speed=1, + thresholds=( + (800, 2), + (1000, 3), + (1300, 4), + (1600, 5), + (2000, 6), + ), + hysteresis=100, + ) + + print() + print("=" * 70) + print("TEST 1 — INITIAL SPEED") + print("=" * 70) + + cases = ( + (500, 1), + (799, 1), + (800, 2), + (999, 2), + (1000, 3), + (1299, 3), + (1300, 4), + (1599, 4), + (1600, 5), + (1999, 5), + (2000, 6), + (2500, 6), + ) + + for co2, expected in cases: + + result = policy.select_speed( + co2, + current_speed=None, + ) + + print( + f"CO2={co2:<4} " + f"-> speed={result}" + ) + + assert result == expected + + + # ======================================================== + # TEST 2 + # + # Переход 1 -> 2 при 800 ppm. + # ======================================================== + + print() + print("=" * 70) + print("TEST 2 — SPEED UP") + print("=" * 70) + + speed = 1 + + speed = policy.select_speed( + 790, + speed, + ) + + assert speed == 1 + + speed = policy.select_speed( + 805, + speed, + ) + + assert speed == 2 + + print( + "790 -> speed 1" + ) + + print( + "805 -> speed 2" + ) + + + # ======================================================== + # TEST 3 + # + # CO2 немного упал ниже 800. + # + # Без гистерезиса получили бы: + # 2 -> 1. + # + # Но пока CO2 > 700, + # остаёмся на второй скорости. + # ======================================================== + + print() + print("=" * 70) + print("TEST 3 — HYSTERESIS") + print("=" * 70) + + speed = policy.select_speed( + 790, + current_speed=2, + ) + + print( + "speed=2, CO2=790 " + f"-> speed={speed}" + ) + + assert speed == 2 + + speed = policy.select_speed( + 750, + current_speed=2, + ) + + print( + "speed=2, CO2=750 " + f"-> speed={speed}" + ) + + assert speed == 2 + + speed = policy.select_speed( + 700, + current_speed=2, + ) + + print( + "speed=2, CO2=700 " + f"-> speed={speed}" + ) + + assert speed == 1 + + + # ======================================================== + # TEST 4 + # + # Гистерезис между speed 2 и speed 3. + # + # Вверх: + # 1000 ppm. + # + # Вниз: + # 900 ppm. + # ======================================================== + + print() + print("=" * 70) + print("TEST 4 — SPEED 2 / SPEED 3") + print("=" * 70) + + speed = policy.select_speed( + 1005, + current_speed=2, + ) + + assert speed == 3 + + print( + "speed=2, CO2=1005 " + f"-> speed={speed}" + ) + + speed = policy.select_speed( + 950, + current_speed=3, + ) + + assert speed == 3 + + print( + "speed=3, CO2=950 " + f"-> speed={speed}" + ) + + speed = policy.select_speed( + 900, + current_speed=3, + ) + + assert speed == 2 + + print( + "speed=3, CO2=900 " + f"-> speed={speed}" + ) + + + # ======================================================== + # TEST 5 + # + # Резкий рост CO2. + # + # Не нужно ждать: + # 1 -> 2 -> 3 -> 4 -> 5 + # + # Можно сразу выбрать необходимую скорость. + # ======================================================== + + print() + print("=" * 70) + print("TEST 5 — LARGE CO2 JUMP") + print("=" * 70) + + speed = policy.select_speed( + 2200, + current_speed=1, + ) + + print( + "speed=1, CO2=2200 " + f"-> speed={speed}" + ) + + assert speed == 6 + + + # ======================================================== + # TEST 6 + # + # Аналогично при сильном падении CO2 + # разрешаем сразу снизить несколько ступеней. + # ======================================================== + + print() + print("=" * 70) + print("TEST 6 — LARGE CO2 DROP") + print("=" * 70) + + speed = policy.select_speed( + 650, + current_speed=6, + ) + + print( + "speed=6, CO2=650 " + f"-> speed={speed}" + ) + + assert speed == 1 + + + print() + print("=" * 70) + print( + "ALL CO2 POLICY TESTS PASSED" + ) + print("=" * 70) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/test_qingping_mqtt.py b/tests/test_qingping_mqtt.py new file mode 100644 index 0000000..12d9a2e --- /dev/null +++ b/tests/test_qingping_mqtt.py @@ -0,0 +1,32 @@ +import asyncio +import json + +from app.qingping.service import QingpingService + + +async def main(): + service = QingpingService( + host="192.168.7.3", + port=1883, + mac="CCB5D131BA93", + ) + + await service.start() + + try: + while True: + await asyncio.sleep(15) + + print( + json.dumps( + service.status(), + indent=2, + ensure_ascii=False, + ) + ) + + finally: + await service.stop() + + +asyncio.run(main()) \ No newline at end of file diff --git a/tests/test_qingping_raw.py b/tests/test_qingping_raw.py new file mode 100644 index 0000000..ba0a194 --- /dev/null +++ b/tests/test_qingping_raw.py @@ -0,0 +1,60 @@ +import json +import paho.mqtt.client as mqtt + + +HOST = "192.168.7.3" +PORT = 1883 +TOPIC = "qingping/CCB5D131BA93/up" + + +def on_connect(client, userdata, flags, reason_code, properties): + print("Connected:", reason_code) + + client.subscribe(TOPIC) + + +def on_message(client, userdata, message): + try: + payload = json.loads(message.payload.decode("utf-8")) + except Exception: + print("RAW:", message.payload) + return + + message_type = str(payload.get("type")) + message_id = payload.get("id") + message_timestamp = payload.get("timestamp") + + sensor_timestamps = [] + + sensor_data = payload.get("sensorData") + + if isinstance(sensor_data, list): + for sample in sensor_data: + timestamp = sample.get("timestamp") + + if isinstance(timestamp, dict): + timestamp = timestamp.get("value") + + sensor_timestamps.append(timestamp) + + print( + f"id={message_id!s:<4} " + f"type={message_type:<3} " + f"msg_ts={message_timestamp!s:<12} " + f"samples={sensor_timestamps}" + ) + + +client = mqtt.Client( + callback_api_version=mqtt.CallbackAPIVersion.VERSION2, + client_id="qingping-raw-debug", +) + +client.on_connect = on_connect +client.on_message = on_message + +client.connect(HOST, PORT, keepalive=60) + +print(f"Listening: {TOPIC}") + +client.loop_forever() \ No newline at end of file diff --git a/tests/test_qingping_recovery.py b/tests/test_qingping_recovery.py new file mode 100644 index 0000000..38e1248 --- /dev/null +++ b/tests/test_qingping_recovery.py @@ -0,0 +1,415 @@ +import json +from types import SimpleNamespace +from unittest.mock import patch + +import paho.mqtt.client as mqtt + +from app.my_dataclasses import ( + QINGPING_SAMPLE_TIMEOUT, + QINGPING_RECOVERY_TIMEOUT, +) +from app.qingping.service import QingpingService + + +class FakeClock: + def __init__(self): + self.now = 0.0 + + def monotonic(self) -> float: + return self.now + + def advance(self, seconds: float) -> None: + self.now += seconds + + +class FakeMqttClient: + def __init__(self): + self.subscriptions = [] + self.published = [] + + def subscribe(self, topic): + self.subscriptions.append(topic) + + def publish( + self, + topic, + payload, + ): + self.published.append( + ( + topic, + json.loads(payload), + ) + ) + + return SimpleNamespace( + rc=mqtt.MQTT_ERR_SUCCESS + ) + + +class FakeMessage: + def __init__(self, payload: dict): + self.payload = json.dumps( + payload + ).encode("utf-8") + + +def sensor_message( + timestamp: int, + co2: int, +) -> FakeMessage: + return FakeMessage( + { + "type": 17, + "sensorData": [ + { + "timestamp": { + "value": timestamp + }, + "temperature": { + "value": 25.0 + }, + "humidity": { + "value": 50.0 + }, + "co2": { + "value": co2 + }, + "pm25": { + "value": 1 + }, + "pm10": { + "value": 1 + }, + "battery": { + "value": 100 + }, + } + ], + } + ) + + +def service_message() -> FakeMessage: + return FakeMessage( + { + "type": 13, + "wifi_info": ( + "TestWiFi,-50,1," + "00:00:00:00:00:00" + ), + "sw_version": "4.8.5", + } + ) + + +def assert_state( + service: QingpingService, + *, + online: bool, + recovery_waiting: bool, + co2, +): + status = service.status() + + assert status["online"] is online, status + assert ( + status["recovery_waiting"] + is recovery_waiting + ), status + + assert status["co2"] == co2, status + + +def test_double_recovery_cycle() -> None: + print() + print("TEST — DOUBLE RECOVERY CYCLE") + + clock = FakeClock() + + service = QingpingService( + host="127.0.0.1", + ) + + client = FakeMqttClient() + + # _send_recovery() использует self._client. + service._client = client + + with patch( + "app.qingping.service.time.monotonic", + clock.monotonic, + ): + + # -------------------------------------------------- + # START + # -------------------------------------------------- + + print("1. MQTT connect") + + service._on_connect( + client, + None, + None, + 0, + None, + ) + + assert len(client.published) == 1 + + assert_state( + service, + online=False, + recovery_waiting=True, + co2=None, + ) + + # -------------------------------------------------- + # Первый type 17. + # -------------------------------------------------- + + print("2. First sensor sample") + + service._on_message( + None, + None, + sensor_message( + timestamp=1000, + co2=900, + ), + ) + + assert_state( + service, + online=True, + recovery_waiting=False, + co2=900, + ) + + # -------------------------------------------------- + # Первый отказ. + # -------------------------------------------------- + + print("3. First sensor timeout") + + clock.advance( + QINGPING_SAMPLE_TIMEOUT + 1 + ) + + service._watchdog_tick( + clock.monotonic() + ) + + assert_state( + service, + online=False, + recovery_waiting=False, + co2=900, + ) + + # Recovery пока НЕ отправлялся. + assert len(client.published) == 1 + + # -------------------------------------------------- + # Устройство снова появляется. + # Любой пакет запускает recovery. + # -------------------------------------------------- + + print("4. First recovery") + + service._on_message( + None, + None, + service_message(), + ) + + assert len(client.published) == 2 + + assert_state( + service, + online=True, + recovery_waiting=True, + co2=None, + ) + + # -------------------------------------------------- + # Новый type 17. + # -------------------------------------------------- + + print("5. First recovery completed") + + service._on_message( + None, + None, + sensor_message( + timestamp=1015, + co2=1000, + ), + ) + + assert_state( + service, + online=True, + recovery_waiting=False, + co2=1000, + ) + + # -------------------------------------------------- + # Второй отказ. + # -------------------------------------------------- + + print("6. Second sensor timeout") + + clock.advance( + QINGPING_SAMPLE_TIMEOUT + 1 + ) + + service._watchdog_tick( + clock.monotonic() + ) + + assert_state( + service, + online=False, + recovery_waiting=False, + co2=1000, + ) + + assert len(client.published) == 2 + + # -------------------------------------------------- + # Второе восстановление. + # -------------------------------------------------- + + print("7. Second recovery") + + service._on_message( + None, + None, + service_message(), + ) + + assert len(client.published) == 3 + + assert_state( + service, + online=True, + recovery_waiting=True, + co2=None, + ) + + service._on_message( + None, + None, + sensor_message( + timestamp=1030, + co2=1100, + ), + ) + + assert_state( + service, + online=True, + recovery_waiting=False, + co2=1100, + ) + + print("8. Second recovery completed") + + +def test_recovery_timeout_and_retry() -> None: + print() + print("TEST — RECOVERY TIMEOUT AND RETRY") + + clock = FakeClock() + + service = QingpingService( + host="127.0.0.1", + ) + + client = FakeMqttClient() + + service._client = client + + with patch( + "app.qingping.service.time.monotonic", + clock.monotonic, + ): + + # Startup recovery. + service._on_connect( + client, + None, + None, + 0, + None, + ) + + assert len(client.published) == 1 + + assert_state( + service, + online=False, + recovery_waiting=True, + co2=None, + ) + + # Type 17 так и не пришёл. + clock.advance( + QINGPING_RECOVERY_TIMEOUT + 1 + ) + + service._watchdog_tick( + clock.monotonic() + ) + + assert_state( + service, + online=False, + recovery_waiting=False, + co2=None, + ) + + # Любой следующий пакет должен разрешить + # новую recovery-попытку. + service._on_message( + None, + None, + service_message(), + ) + + assert len(client.published) == 2 + + assert_state( + service, + online=True, + recovery_waiting=True, + co2=None, + ) + + # Теперь приходит type 17. + service._on_message( + None, + None, + sensor_message( + timestamp=2000, + co2=1200, + ), + ) + + assert_state( + service, + online=True, + recovery_waiting=False, + co2=1200, + ) + + +if __name__ == "__main__": + test_double_recovery_cycle() + test_recovery_timeout_and_retry() + + print() + print( + "ALL QINGPING RECOVERY TESTS PASSED" + ) \ No newline at end of file diff --git a/tests/test_qingping_service.py b/tests/test_qingping_service.py new file mode 100644 index 0000000..22fe853 --- /dev/null +++ b/tests/test_qingping_service.py @@ -0,0 +1,30 @@ +import asyncio + +from app.my_dataclasses import QINGPING_MAC +from app.qingping.service import QingpingService + + +async def main(): + service = QingpingService( + QINGPING_MAC + ) + + async with service: + print("Qingping service started") + + while True: + state = service.state + + print( + "online:", + service.online, + "| state:", + state.to_dict() + if state + else None, + ) + + await asyncio.sleep(5) + + +asyncio.run(main()) \ No newline at end of file diff --git a/tests/test_schedule.py b/tests/test_schedule.py new file mode 100644 index 0000000..e1f4104 --- /dev/null +++ b/tests/test_schedule.py @@ -0,0 +1,312 @@ +from datetime import datetime + +from schedule import ( + ScheduleActionType, + ScheduleService, + load_schedule, +) + + +SCHEDULE_FILE = "config/schedule.yaml" + + +def print_result(title, result): + print() + print("=" * 70) + print(title) + print("=" * 70) + + print(f"Enabled: {result.enabled}") + + if result.current is not None: + print( + f"Current: " + f"{result.current.when} | " + f"{result.current.template} | " + f"{result.current.point.action.type}" + ) + else: + print("Current: None") + + if result.next is not None: + print( + f"Next: " + f"{result.next.when} | " + f"{result.next.template} | " + f"{result.next.point.action.type}" + ) + else: + print("Next: None") + + print(f"Auto: {result.auto_active}") + print(f"Settings: {result.scheduled_settings.to_dict()}") + + +def main(): + print("Загрузка schedule.yaml...") + + config = load_schedule(SCHEDULE_FILE) + + print("YAML успешно загружен и проверен.") + print() + + service = ScheduleService(config) + + # -------------------------------------------------------------- + # TEST 1 + # Понедельник 06:30 + # + # Текущая точка должна быть 00:00 power off. + # Следующая — 07:00. + # -------------------------------------------------------------- + + result = service.resolve( + datetime(2026, 9, 14, 6, 30) + ) + + print_result( + "TEST 1 — Monday 06:30", + result, + ) + + assert result.current.when.hour == 0 + assert result.next.when.hour == 7 + + assert result.auto_active is False + + assert result.scheduled_settings.power is False + + # -------------------------------------------------------------- + # TEST 2 + # Понедельник 07:30 + # + # Должны накопиться параметры из точки 07:00. + # -------------------------------------------------------------- + + result = service.resolve( + datetime(2026, 9, 14, 7, 30) + ) + + print_result( + "TEST 2 — Monday 07:30", + result, + ) + + settings = result.scheduled_settings + + assert result.current.when.hour == 7 + assert result.next.when.hour == 9 + + assert result.auto_active is False + + assert settings.power is True + assert settings.speed == 2 + assert settings.heater is True + assert settings.target_temp == 20 + assert settings.mode == "outside" + + # -------------------------------------------------------------- + # TEST 3 + # Понедельник 10:00 + # + # В 09:00 поменялась только скорость. + # + # Остальные значения должны сохраниться от 07:00. + # -------------------------------------------------------------- + + result = service.resolve( + datetime(2026, 9, 14, 10, 0) + ) + + print_result( + "TEST 3 — Monday 10:00", + result, + ) + + settings = result.scheduled_settings + + assert result.current.when.hour == 9 + assert result.next.when.hour == 13 + + assert settings.power is True + assert settings.speed == 1 + assert settings.heater is True + assert settings.target_temp == 20 + assert settings.mode == "outside" + + # -------------------------------------------------------------- + # TEST 4 + # Понедельник 13:10 + # + # Последняя точка AUTO. + # -------------------------------------------------------------- + + result = service.resolve( + datetime(2026, 9, 14, 13, 10) + ) + + print_result( + "TEST 4 — Monday 13:10 / AUTO", + result, + ) + + assert result.current.point.action.type == ScheduleActionType.AUTO + assert result.auto_active is True + + assert result.next.when.hour == 17 + assert result.next.when.minute == 0 + + # Накопленные настройки при AUTO не стираются. + assert result.scheduled_settings.speed == 1 + assert result.scheduled_settings.power is True + + # -------------------------------------------------------------- + # TEST 5 + # Понедельник 17:10 + # + # AUTO закончился. + # Speed должен стать 3. + # -------------------------------------------------------------- + + result = service.resolve( + datetime(2026, 9, 14, 17, 10) + ) + + print_result( + "TEST 5 — Monday 17:10", + result, + ) + + assert result.auto_active is False + + assert result.current.when.hour == 17 + assert result.current.when.minute == 0 + + assert result.next.when.hour == 17 + assert result.next.when.minute == 30 + + assert result.scheduled_settings.speed == 3 + + # -------------------------------------------------------------- + # TEST 6 + # Понедельник 17:40 + # + # Снова AUTO. + # -------------------------------------------------------------- + + result = service.resolve( + datetime(2026, 9, 14, 17, 40) + ) + + print_result( + "TEST 6 — Monday 17:40 / AUTO", + result, + ) + + assert result.auto_active is True + + assert result.current.when.hour == 17 + assert result.current.when.minute == 30 + + assert result.next.when.hour == 23 + + # Последний scheduled speed всё равно должен помнить 3. + assert result.scheduled_settings.speed == 3 + + # -------------------------------------------------------------- + # TEST 7 + # Понедельник 23:15 + # + # Heater off, speed 1. + # -------------------------------------------------------------- + + result = service.resolve( + datetime(2026, 9, 14, 23, 15) + ) + + print_result( + "TEST 7 — Monday 23:15", + result, + ) + + settings = result.scheduled_settings + + assert result.auto_active is False + assert settings.speed == 1 + assert settings.heater is False + + # -------------------------------------------------------------- + # TEST 8 + # Суббота 09:30 + # + # Проверяем переключение на weekend. + # -------------------------------------------------------------- + + result = service.resolve( + datetime(2026, 9, 19, 9, 30) + ) + + print_result( + "TEST 8 — Saturday 09:30", + result, + ) + + assert result.current.template == "weekend" + assert result.next.template == "weekend" + + settings = result.scheduled_settings + + assert settings.power is True + assert settings.speed == 1 + assert settings.heater is True + assert settings.target_temp == 20 + + # -------------------------------------------------------------- + # TEST 9 + # Суббота 10:30 + # + # Weekend AUTO. + # -------------------------------------------------------------- + + result = service.resolve( + datetime(2026, 9, 19, 10, 30) + ) + + print_result( + "TEST 9 — Saturday 10:30 / AUTO", + result, + ) + + assert result.current.template == "weekend" + assert result.auto_active is True + + # -------------------------------------------------------------- + # TEST 10 + # Проверяем переход через полночь. + # + # Вторник 00:30. + # В 00:00 вторника уже должна действовать power off. + # -------------------------------------------------------------- + + result = service.resolve( + datetime(2026, 9, 15, 0, 30) + ) + + print_result( + "TEST 10 — Tuesday 00:30", + result, + ) + + assert result.current.when.day == 15 + assert result.current.when.hour == 0 + + assert result.scheduled_settings.power is False + + print() + print("=" * 70) + print("ALL SCHEDULE TESTS PASSED") + print("=" * 70) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/test_schedule_auto.py b/tests/test_schedule_auto.py new file mode 100644 index 0000000..41a84d6 --- /dev/null +++ b/tests/test_schedule_auto.py @@ -0,0 +1,432 @@ +import asyncio +from datetime import datetime +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from schedule import ( + ScheduleActionType, + 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 + + - time: "10:00" + action: + type: auto + speed: 3 + + - time: "12:00" + action: + type: set + speed: 2 + +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) + ) + + +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 print_resolution( + title, + resolution, +): + print() + print("=" * 70) + print(title) + print("=" * 70) + + print( + "Current:", + resolution.current.when + if resolution.current + else None, + ) + + print( + "Action:", + resolution.current.point.action.type + if resolution.current + else None, + ) + + print( + "Scheduled speed:", + resolution.scheduled_settings.speed, + ) + + print( + "AUTO:", + resolution.auto_active, + ) + + print( + "Fallback:", + resolution.auto_fallback_speed, + ) + + +def test_resolve(service: ScheduleService): + + # -------------------------------------------------- + # 09:30 + # Обычный SET speed=1 + # -------------------------------------------------- + + resolution = service.resolve( + datetime( + 2026, + 9, + 14, + 9, + 30, + ) + ) + + print_resolution( + "TEST 1 — SET", + resolution, + ) + + assert resolution.auto_active is False + + assert ( + resolution.auto_fallback_speed + is None + ) + + assert ( + resolution.scheduled_settings.speed + == 1 + ) + + # -------------------------------------------------- + # 10:30 + # AUTO speed=3 + # + # scheduled_settings.speed всё ещё 1, + # потому что AUTO не участвует + # в накоплении SET-настроек. + # + # Но fallback должен быть 3. + # -------------------------------------------------- + + resolution = service.resolve( + datetime( + 2026, + 9, + 14, + 10, + 30, + ) + ) + + print_resolution( + "TEST 2 — AUTO", + resolution, + ) + + assert ( + resolution.current.point.action.type + == ScheduleActionType.AUTO + ) + + assert resolution.auto_active is True + + assert ( + resolution.auto_fallback_speed + == 3 + ) + + assert ( + resolution.scheduled_settings.speed + == 1 + ) + + # -------------------------------------------------- + # 12:30 + # AUTO закончился. + # SET speed=2. + # -------------------------------------------------- + + resolution = service.resolve( + datetime( + 2026, + 9, + 14, + 12, + 30, + ) + ) + + print_resolution( + "TEST 3 — AFTER AUTO", + resolution, + ) + + assert resolution.auto_active is False + + assert ( + resolution.auto_fallback_speed + is None + ) + + assert ( + resolution.scheduled_settings.speed + == 2 + ) + + +async def test_process( + service: ScheduleService, + tion: FakeTionService, +): + + # -------------------------------------------------- + # 09:30 + # + # Обычный SET. + # ScheduleService должен применить speed=1. + # -------------------------------------------------- + + fixed_now = datetime( + 2026, + 9, + 14, + 9, + 30, + ) + + with patch( + "schedule.service.datetime", + wraps=datetime, + ) as mocked_datetime: + + mocked_datetime.now.return_value = ( + fixed_now + ) + + await service._process() + + print() + print( + "09:30 calls:", + tion.controller.calls, + ) + + assert ( + "set_speed", + 1, + ) in tion.controller.calls + + # Очищаем историю команд. + tion.controller.calls.clear() + + # -------------------------------------------------- + # 10:30 + # + # AUTO. + # + # В расписании fallback speed=3, + # но ScheduleService НЕ должен + # отправить ни speed=1, ни speed=3. + # + # Скорость теперь принадлежит + # будущему AutoController. + # -------------------------------------------------- + + fixed_now = datetime( + 2026, + 9, + 14, + 10, + 30, + ) + + with patch( + "schedule.service.datetime", + wraps=datetime, + ) as mocked_datetime: + + mocked_datetime.now.return_value = ( + fixed_now + ) + + await service._process() + + print() + print( + "10:30 AUTO calls:", + tion.controller.calls, + ) + + speed_calls = [ + call + for call in tion.controller.calls + if call[0] == "set_speed" + ] + + assert speed_calls == [] + + # Очищаем историю. + tion.controller.calls.clear() + + # -------------------------------------------------- + # 12:30 + # + # AUTO закончился. + # Расписание снова должно поставить speed=2. + # -------------------------------------------------- + + fixed_now = datetime( + 2026, + 9, + 14, + 12, + 30, + ) + + with patch( + "schedule.service.datetime", + wraps=datetime, + ) as mocked_datetime: + + mocked_datetime.now.return_value = ( + fixed_now + ) + + await service._process() + + print() + print( + "12:30 calls:", + tion.controller.calls, + ) + + assert ( + "set_speed", + 2, + ) in tion.controller.calls + + +async def main(): + + temp_dir, config = ( + load_test_schedule() + ) + + try: + # ---------------------------------------------- + # Проверка resolve() + # ---------------------------------------------- + + service = ScheduleService( + config + ) + + test_resolve(service) + + # ---------------------------------------------- + # Проверка реального _process(), + # но через FakeTion. + # ---------------------------------------------- + + tion = FakeTionService() + + service = ScheduleService( + config, + tion=tion, + ) + + await test_process( + service, + tion, + ) + + finally: + temp_dir.cleanup() + + print() + print("=" * 70) + print("ALL AUTO SCHEDULE TESTS PASSED") + print("=" * 70) + + +if __name__ == "__main__": + asyncio.run(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 diff --git a/tests/test_schedule_live.py b/tests/test_schedule_live.py new file mode 100644 index 0000000..703e4c5 --- /dev/null +++ b/tests/test_schedule_live.py @@ -0,0 +1,72 @@ +import asyncio +import logging +from pathlib import Path +import datetime + +from app.my_dataclasses import TION_MAC +from app.tion import TionController, TionService + +from schedule import ( + ScheduleService, + load_schedule, +) + + +logging.disable(logging.CRITICAL) + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + +SCHEDULE_FILE = ( + PROJECT_ROOT + / "config" + / "schedule.yaml" +) + + +async def main(): + + config = load_schedule(SCHEDULE_FILE) + + controller = TionController(TION_MAC) + + tion = TionService( + controller, + poll_interval=5, + ) + + schedule = ScheduleService( + config, + tion, + check_interval=5, + ) + + await tion.start() + await schedule.start() + + print("Schedule started. Ctrl+C to stop.") + + try: + while True: + + await asyncio.sleep(5) + + if tion.state: + print( + f"{datetime.datetime.now().strftime("%y-%m-%d %H:%M:%S")} " + f"power={tion.state.power} " + f"speed={tion.state.fan_speed} " + f"heater={tion.state.heater} " + f"target_temp={tion.state.target_temp} " + f"last_error={tion.last_error} " + ) + + + + finally: + await schedule.stop() + await tion.stop() + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/tests/test_schedule_override.py b/tests/test_schedule_override.py new file mode 100644 index 0000000..ebb8ad6 --- /dev/null +++ b/tests/test_schedule_override.py @@ -0,0 +1,119 @@ +import asyncio +import logging +from datetime import datetime +from pathlib import Path + +from app.my_dataclasses import TION_MAC +from app.tion import TionController, TionService + +from schedule import ( + ScheduleService, + ScheduledSettings, + load_schedule, +) + + +logging.disable(logging.CRITICAL) + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + +SCHEDULE_FILE = ( + PROJECT_ROOT + / "config" + / "schedule.yaml" +) + + +async def main(): + + config = load_schedule(SCHEDULE_FILE) + + controller = TionController(TION_MAC) + + tion = TionService( + controller, + poll_interval=5, + ) + + schedule = ScheduleService( + config, + tion, + check_interval=5, + ) + + await tion.start() + await schedule.start() + + try: + print() + print("Schedule started") + + resolution = schedule.resolve() + + print( + f"Current: " + f"{resolution.current.when if resolution.current else None}" + ) + + print( + f"Next: " + f"{resolution.next.when if resolution.next else None}" + ) + + # print() + # print("Applying temporary override:") + # print("speed=6") + # print() + # + # await schedule.apply_override( + # ScheduledSettings( + # speed=6, + # ) + # ) + + while True: + + await asyncio.sleep(5) + + if ( datetime.now() > datetime(2026, 9, 18, 20, 30, 30) + and datetime.now() < datetime(2026, 9, 18, 20, 30, 36) ): + print("Applying temporary override:") + print("heater=True") + print("target_temp=25") + await schedule.apply_override( + ScheduledSettings( + heater=True, + target_temp=25, + ) + ) + + now = datetime.now().strftime( + "%y-%m-%d %H:%M:%S" + ) + + state = tion.state + + if state is None: + print( + f"{now} state=None" + ) + continue + + print( + f"{now} " + f"power={state.power} " + f"speed={state.fan_speed} " + f"heater={state.heater} " + f"target_temp={state.target_temp} " + f"override={schedule.override_active} " + f"until={schedule.override_until}" + ) + + finally: + await schedule.stop() + await tion.stop() + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/tests/test_schedule_pause.py b/tests/test_schedule_pause.py new file mode 100644 index 0000000..d2ba1a1 --- /dev/null +++ b/tests/test_schedule_pause.py @@ -0,0 +1,203 @@ +import asyncio +from datetime import datetime, timedelta +from pathlib import Path +from tempfile import TemporaryDirectory + +from schedule import ( + ScheduleService, + ScheduledSettings, + load_schedule, +) + + +SCHEDULE_YAML = """ +version: 1 + +enabled: true +timezone: local + +templates: + + test: + + - time: "00:00" + action: + type: set + speed: 2 + +days: + mon: test + tue: test + wed: test + thu: test + fri: test + sat: test + sun: test +""" + + +class FakeTionController: + + def __init__(self): + self.calls = [] + + async def set_speed( + self, + speed: int, + ): + self.calls.append( + ( + "set_speed", + speed, + ) + ) + + +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 + + +async def test_paused_schedule_does_not_control_tion(): + + temp_dir, config = ( + load_test_schedule() + ) + + try: + tion = FakeTionService() + + service = ScheduleService( + config, + tion=tion, + ) + + assert service.paused is False + + await service.pause() + + assert service.paused is True + + # Даже если фоновый цикл вызывает _process(), + # никаких команд быть не должно. + await service._process() + + assert tion.controller.calls == [] + + # Возвращаем расписание. + # + # resume() должен сразу применить + # текущее состояние расписания. + await service.resume() + + assert service.paused is False + + assert ( + "set_speed", + 2, + ) in tion.controller.calls + + finally: + temp_dir.cleanup() + + +async def test_pause_clears_override(): + + temp_dir, config = ( + load_test_schedule() + ) + + try: + service = ScheduleService( + config + ) + + # Имитируем уже существующий + # temporary override. + service._override_settings = ( + ScheduledSettings( + speed=5, + ) + ) + + service._override_until = ( + datetime.now() + + timedelta(hours=1) + ) + + service._override_pending = True + + assert ( + service.override_settings.speed + == 5 + ) + + await service.pause() + + assert service.paused is True + + assert ( + service.override_settings.speed + is None + ) + + assert ( + service.override_until + is None + ) + + assert ( + service._override_pending + is False + ) + + finally: + temp_dir.cleanup() + + +async def main(): + + await test_paused_schedule_does_not_control_tion() + + await test_pause_clears_override() + + print() + print("=" * 70) + print( + "ALL SCHEDULE PAUSE TESTS PASSED" + ) + print("=" * 70) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/tests/test_schedule_pause_persistence.py b/tests/test_schedule_pause_persistence.py new file mode 100644 index 0000000..6ca12c5 --- /dev/null +++ b/tests/test_schedule_pause_persistence.py @@ -0,0 +1,179 @@ +import asyncio +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: "00:00" + action: + type: set + speed: 2 + +days: + mon: test + tue: test + wed: test + thu: test + fri: test + sat: test + sun: test +""" + + +def create_config( + directory: Path, +): + + schedule_path = ( + directory + / "schedule.yaml" + ) + + schedule_path.write_text( + SCHEDULE_YAML, + encoding="utf-8", + ) + + return load_schedule( + schedule_path + ) + + +async def test_pause_survives_restart(): + + with TemporaryDirectory() as temp_dir: + + directory = Path(temp_dir) + + config = create_config( + directory + ) + + state_path = ( + directory + / "schedule_state.json" + ) + + # -------------------------------------- + # Первый экземпляр сервиса. + # -------------------------------------- + + service1 = ScheduleService( + config, + state_path=state_path, + ) + + assert service1.paused is False + + await service1.pause() + + assert service1.paused is True + assert state_path.exists() + + # -------------------------------------- + # Имитируем перезапуск приложения: + # создаём новый ScheduleService. + # -------------------------------------- + + service2 = ScheduleService( + config, + state_path=state_path, + ) + + assert service2.paused is True + + +async def test_resume_survives_restart(): + + with TemporaryDirectory() as temp_dir: + + directory = Path(temp_dir) + + config = create_config( + directory + ) + + state_path = ( + directory + / "schedule_state.json" + ) + + service1 = ScheduleService( + config, + state_path=state_path, + ) + + await service1.pause() + + assert service1.paused is True + + await service1.resume() + + assert service1.paused is False + + # Новый экземпляр после resume + # тоже должен быть активным. + service2 = ScheduleService( + config, + state_path=state_path, + ) + + assert service2.paused is False + + +async def test_missing_state_file_defaults_to_active(): + + with TemporaryDirectory() as temp_dir: + + directory = Path(temp_dir) + + config = create_config( + directory + ) + + state_path = ( + directory + / "missing_state.json" + ) + + assert not state_path.exists() + + service = ScheduleService( + config, + state_path=state_path, + ) + + assert service.paused is False + + +async def main(): + + await test_pause_survives_restart() + await test_resume_survives_restart() + await test_missing_state_file_defaults_to_active() + + print() + print("=" * 70) + print( + "ALL SCHEDULE PAUSE " + "PERSISTENCE TESTS PASSED" + ) + print("=" * 70) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/tests/test_tion_controller.py b/tests/test_tion_controller.py new file mode 100644 index 0000000..63eaf95 --- /dev/null +++ b/tests/test_tion_controller.py @@ -0,0 +1,85 @@ +import asyncio +import json +import logging + +from app.tion import TionController +from app.my_dataclasses import * + + + + +def print_state(title: str, state) -> None: + print() + print("=" * 50) + print(title) + print("=" * 50) + + print(json.dumps( + state.to_dict(), + indent=2, + ensure_ascii=False + )) + + +async def main(): + # Убираем лишнее логирование + logging.getLogger("asyncio").setLevel(logging.WARNING) + logging.getLogger("bleak").setLevel(logging.WARNING) + logging.getLogger("tion_btle").setLevel(logging.WARNING) + + tion = TionController(TION_MAC) + + print("Подключение к Tion...") + + async with tion: + + print(f"Connected: {tion.connected}") + + # Читаем исходное состояние + state = await tion.get_state() + print_state("Исходное состояние", state) + + # Включаем + state = await tion.power_on() + print_state("После POWER ON", state) + + await asyncio.sleep(1) + + # Скорость 2 + state = await tion.set_speed(2) + print_state("После SPEED 2", state) + + await asyncio.sleep(1) + + # Скорость 3 + state = await tion.set_speed(3) + print_state("После SPEED 3", state) + + state = await tion.set_target_temperature(20) + print_state("TARGET TEMP 20°C", state) + + state = await tion.set_air_mode("recirculation") + print_state("RECIRCULATION", state) + await asyncio.sleep(3) + + state = await tion.set_air_mode("outside") + print_state("RECIRCULATION", state) + await asyncio.sleep(3) + + state = await tion.sound_off() + print_state("SOUND OFF", state) + + state = await tion.sound_on() + print_state("SOUND ON", state) + + state = await tion.light_off() + print_state("LIGHT OFF", state) + + state = await tion.light_on() + print_state("LIGHT ON", state) + print() + print(f"Connected after exit: {tion.connected}") + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/tests/test_tion_service.py b/tests/test_tion_service.py new file mode 100644 index 0000000..46e538e --- /dev/null +++ b/tests/test_tion_service.py @@ -0,0 +1,84 @@ +import asyncio +import json +import logging +from app.tion import ( + TionController, + TionService, +) + +from app.my_dataclasses import * + + +def print_service(service: TionService) -> None: + print() + print("=" * 60) + + print(f"Running: {service.running}") + print(f"Online: {service.online}") + print(f"Last seen: {service.last_seen}") + print(f"Last error: {service.last_error}") + + if service.state is not None: + print() + print(json.dumps( + service.state.to_dict(), + indent=2, + ensure_ascii=False, + )) + + +async def main(): + + # Убираем лишнее логирование + logging.getLogger("asyncio").setLevel(logging.WARNING) + logging.getLogger("bleak").setLevel(logging.WARNING) + logging.getLogger("tion_btle").setLevel(logging.WARNING) + + controller = TionController(TION_MAC) + + service = TionService( + controller, + poll_interval=3, + ) + + async with service: + + print("=== После запуска ===") + print_service(service) + + print() + print("Ждём несколько циклов polling...") + + await asyncio.sleep(60) + + print_service(service) + + print() + print("=== Устанавливаем скорость 2 ===") + + state = await service.execute( + lambda tion: tion.set_speed(2) + ) + + print(json.dumps( + state.to_dict(), + indent=2, + ensure_ascii=False, + )) + + print() + print("=== Устанавливаем температуру 20°C ===") + + await service.execute( + lambda tion: tion.set_target_temperature(20) + ) + + print_service(service) + + print() + print("=== После stop ===") + print_service(service) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file