From cdec9b09968b4a24bef55eac6b12db44a43824d3 Mon Sep 17 00:00:00 2001 From: Fedorov Dmitriy Date: Fri, 18 Sep 2026 23:59:42 +0300 Subject: [PATCH] =?UTF-8?q?work:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BC=D0=B0=D1=82=D0=B8=D1=87?= =?UTF-8?q?=D0=B5=D1=81=D0=BA=D0=B8=D0=B9=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api.py | 132 ++++- app/auto/__init__.py | 22 + app/auto/co2_policy.py | 129 +++++ app/auto/config.py | 310 ++++++++++++ app/auto/controller.py | 208 ++++++++ app/my_dataclasses.py | 11 + app/qingping/__init__.py | 0 app/qingping/models.py | 44 ++ app/qingping/parser.py | 88 ++++ app/qingping/service.py | 610 +++++++++++++++++++++++ config/auto.yaml | 23 + config/schedule.yaml | 22 +- requirements.txt | 3 +- schedule/loader.py | 25 +- schedule/models.py | 3 +- schedule/service.py | 25 +- tests/test_auto_config.py | 101 ++++ tests/test_auto_controller.py | 884 +++++++++++++++++++++++++++++++++ tests/test_ble_scan.py | 27 + tests/test_co2_policy.py | 261 ++++++++++ tests/test_qingping_mqtt.py | 32 ++ tests/test_qingping_raw.py | 60 +++ tests/test_qingping_service.py | 30 ++ tests/test_schedule_auto.py | 432 ++++++++++++++++ 24 files changed, 3449 insertions(+), 33 deletions(-) create mode 100644 app/auto/__init__.py create mode 100644 app/auto/co2_policy.py create mode 100644 app/auto/config.py create mode 100644 app/auto/controller.py create mode 100644 app/qingping/__init__.py create mode 100644 app/qingping/models.py create mode 100644 app/qingping/parser.py create mode 100644 app/qingping/service.py create mode 100644 config/auto.yaml create mode 100644 tests/test_auto_config.py create mode 100644 tests/test_auto_controller.py create mode 100644 tests/test_ble_scan.py create mode 100644 tests/test_co2_policy.py create mode 100644 tests/test_qingping_mqtt.py create mode 100644 tests/test_qingping_raw.py create mode 100644 tests/test_qingping_service.py create mode 100644 tests/test_schedule_auto.py diff --git a/app/api.py b/app/api.py index 45359bb..f7f3825 100644 --- a/app/api.py +++ b/app/api.py @@ -7,6 +7,9 @@ from fastapi.responses import JSONResponse from app.my_dataclasses import ( TION_MAC, + QINGPING_MAC, + QINGPING_MQTT_HOST, + QINGPING_MQTT_PORT, MIN_FAN_SPEED, MAX_FAN_SPEED, MIN_TARGET_TEMP, @@ -14,6 +17,13 @@ from app.my_dataclasses import ( AIR_MODE_OUTSIDE, AIR_MODE_RECIRCULATION, SCHEDULE_FILE, + AUTO_CONFIG_FILE, +) + +from app.auto import ( + AutoController, + Co2SpeedPolicy, + load_auto_config, ) from app.tion import ( @@ -27,7 +37,7 @@ from schedule import ( load_schedule, ) - +from app.qingping.service import QingpingService def configure_logging() -> None: noisy_loggers = ( @@ -57,9 +67,19 @@ 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 # ---------------------------------------------------------------------- @@ -68,8 +88,11 @@ schedule_load_error: str | None = None 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: @@ -84,6 +107,50 @@ async def lifespan(app: FastAPI): 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 + ), + ) + + auto_interval = ( + auto_config.check_interval + ) + + auto_load_error = None + + except Exception as exc: + auto_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, + interval=auto_interval, + ) + + await auto_controller.start() + except Exception as exc: schedule_service = None schedule_load_error = f"{type(exc).__name__}: {exc}" @@ -93,6 +160,7 @@ async def lifespan(app: FastAPI): if schedule_service is not None: await schedule_service.stop() + await qingping_service.stop() await service.stop() @@ -152,6 +220,8 @@ def get_status() -> dict: else None ), + "qingping": qingping_service.status(), + "schedule": get_schedule_status(), } @@ -349,6 +419,30 @@ def get_schedule_status() -> dict: ), } + +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 # ---------------------------------------------------------------------- @@ -401,24 +495,6 @@ async def increase_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), - ) - @app.post("/api/tion/speed/decrease") async def decrease_speed(): @@ -440,6 +516,24 @@ async def decrease_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 # ---------------------------------------------------------------------- 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..535160d --- /dev/null +++ b/app/auto/config.py @@ -0,0 +1,310 @@ +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 + + +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", + } + + 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" + ) + + if "co2" not in data: + raise ValueError( + "co2 config is required" + ) + + co2 = _parse_co2( + data["co2"] + ) + + return AutoConfig( + version=version, + check_interval=float(check_interval), + co2=co2, + ) \ No newline at end of file diff --git a/app/auto/controller.py b/app/auto/controller.py new file mode 100644 index 0000000..32d3c88 --- /dev/null +++ b/app/auto/controller.py @@ -0,0 +1,208 @@ +import asyncio +from datetime import datetime +from app.auto.co2_policy import Co2SpeedPolicy + +class AutoController: + + def __init__( + self, + schedule_service, + qingping_service, + tion_service, + policy: Co2SpeedPolicy | None = None, + interval: float = 5.0, + ): + self._schedule = schedule_service + self._qingping = qingping_service + self._tion = tion_service + self._policy = 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._last_error: str | None = None + + self._auto_speed: int | 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) + + # ---------------------------------------------- + # Активен ручной override. + # + # Пока пользователь вручную управляет Tion, + # AUTO вообще не вмешивается. + # ---------------------------------------------- + if self._schedule.override_active: + self._state = "suspended" + self._reason = "manual_override" + self._target_speed = None + self._last_error = None + self._auto_speed = None + + return + + # ---------------------------------------------- + # AUTO сейчас не активен + # ---------------------------------------------- + + if ( + not resolution.enabled + or not resolution.auto_active + ): + self._state = "inactive" + self._reason = None + self._target_speed = None + self._last_error = None + self._auto_speed = 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 + ) + + 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 + 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) + + self._last_error = 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 + + + def status(self) -> dict: + return { + "state": self._state, + "reason": self._reason, + "target_speed": self._target_speed, + "auto_speed": self._auto_speed, + "last_error": self._last_error, + } + diff --git a/app/my_dataclasses.py b/app/my_dataclasses.py index 8355687..13dacff 100644 --- a/app/my_dataclasses.py +++ b/app/my_dataclasses.py @@ -9,6 +9,11 @@ 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 @@ -31,5 +36,11 @@ SCHEDULE_FILE = ( / "schedule.yaml" ) +AUTO_CONFIG_FILE = ( + PROJECT_ROOT + / "config" + / "auto.yaml" +) + #Классы 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..2bd23d7 --- /dev/null +++ b/app/qingping/service.py @@ -0,0 +1,610 @@ +# 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 + + +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._connected_since: float | None = None + + self._heartbeat_seen = False + self._recovery_sent = False + + self._last_device_timestamp: int | None = None + self._reboot_detected = False + + @property + def state(self) -> QingpingState: + with self._lock: + return self._state + + @property + def online(self) -> bool: + state = self.state + + if not state.mqtt_connected: + return False + + if self._last_sample_monotonic is None: + return False + + return time.monotonic() - self._last_sample_monotonic < 60 + + 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: + result = self.state.to_dict() + result["online"] = self.online + return result + + def _on_connect( + self, + client, + userdata, + flags, + reason_code, + properties, + ): + if reason_code.is_failure: + logger.warning( + "Qingping MQTT connection failed: %s", + reason_code, + ) + return + + client.subscribe(self._up_topic) + + with self._lock: + self._state = replace( + self._state, + mqtt_connected=True, + ) + + self._connected_since = time.monotonic() + self._heartbeat_seen = False + self._recovery_sent = False + + logger.info( + "Qingping MQTT connected" + ) + + def _on_disconnect( + self, + client, + userdata, + disconnect_flags, + reason_code, + properties, + ): + with self._lock: + self._state = replace( + self._state, + mqtt_connected=False, + ) + + logger.warning( + "Qingping MQTT disconnected: %s", + reason_code, + ) + + def _on_message( + self, + client, + userdata, + message, + ): + try: + payload = json.loads( + message.payload.decode("utf-8") + ) + except (UnicodeDecodeError, json.JSONDecodeError): + logger.warning( + "Invalid Qingping MQTT message" + ) + return + + now = datetime.now(timezone.utc) + + with self._lock: + self._state = replace( + self._state, + last_message_at=now, + ) + + message_type = str(payload.get("type")) + + if message_type == "13": + self._handle_heartbeat(payload) + return + + if message_type in {"12", "17"}: + self._handle_sensor_data( + payload, + now, + ) + + def _handle_heartbeat( + self, + payload: dict, + ) -> None: + self._heartbeat_seen = True + + device_timestamp = payload.get("timestamp") + + if isinstance(device_timestamp, int): + previous = self._last_device_timestamp + + if ( + device_timestamp < 300 + and ( + previous is None + or previous > 300 + ) + ): + self._reboot_detected = True + self._recovery_sent = False + + logger.info( + "Qingping reboot detected" + ) + + self._last_device_timestamp = device_timestamp + + wifi_rssi = None + + wifi_info = payload.get("wifi_info") + + if isinstance(wifi_info, str): + parts = wifi_info.split(",") + + if len(parts) >= 2: + try: + wifi_rssi = int(parts[1]) + except ValueError: + pass + + with self._lock: + self._state = replace( + self._state, + wifi_rssi=wifi_rssi, + firmware=payload.get("sw_version"), + ) + + 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", + ) + + with self._lock: + 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 = ( + time.monotonic() + ) + + self._reboot_detected = False + + @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(5) + + if self._client is None: + continue + + state = self.state + + if not state.mqtt_connected: + continue + + if self._recovery_sent: + continue + + # Явно увидели reboot CGDN1. + if self._reboot_detected: + self._send_recovery() + continue + + # Или сервис подключился, heartbeat есть, + # но свежих sensorData так и не появилось. + if ( + self._connected_since is not None + and self._heartbeat_seen + and self._last_sample_monotonic is None + and time.monotonic() + - self._connected_since + > 30 + ): + self._send_recovery() + + 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: + self._recovery_sent = True + + logger.warning( + "Qingping recovery command sent" + ) + else: + logger.warning( + "Failed to send Qingping recovery: %s", + result.rc, + ) \ No newline at end of file diff --git a/config/auto.yaml b/config/auto.yaml new file mode 100644 index 0000000..1e4237b --- /dev/null +++ b/config/auto.yaml @@ -0,0 +1,23 @@ +version: 1 + +check_interval: 5.0 + +co2: + base_speed: 1 + hysteresis: 100 + + thresholds: + - ppm: 800 + speed: 2 + + - ppm: 1000 + speed: 3 + + - ppm: 1300 + speed: 4 + + - ppm: 1600 + speed: 5 + + - ppm: 2000 + speed: 6 \ No newline at end of file diff --git a/config/schedule.yaml b/config/schedule.yaml index 51d982c..d4cbb3e 100644 --- a/config/schedule.yaml +++ b/config/schedule.yaml @@ -30,6 +30,7 @@ templates: - time: "13:00" action: type: auto + speed: 1 - time: "17:00" action: @@ -47,6 +48,7 @@ templates: - time: "20:30" action: type: auto + speed: 2 - time: "20:31" @@ -60,10 +62,21 @@ templates: speed: 1 heater: off - - time: "23:58" + - time: "23:55" action: type: set - speed: 3 + power: on + speed: 1 + + - time: "23:57" + action: + type: auto + speed: 2 + + - time: "00:05" + action: + type: set + speed: 1 weekend: @@ -90,6 +103,7 @@ templates: - time: "10:00" action: type: auto + speed: 1 - time: "23:30" action: @@ -108,5 +122,5 @@ days: thu: workday fri: workday - sat: weekend - sun: weekend \ No newline at end of file + sat: workday + sun: workday \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index ff2b2d7..2cd36a9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ tion-btle==3.3.6 fastapi uvicorn[standard] -PyYAML \ No newline at end of file +PyYAML +paho-mqtt>=2.1,<3 \ No newline at end of file diff --git a/schedule/loader.py b/schedule/loader.py index bbad703..1b872a1 100644 --- a/schedule/loader.py +++ b/schedule/loader.py @@ -208,16 +208,32 @@ def _parse_action( if action_type == ScheduleActionType.AUTO: - extra = set(data) - {"type"} + extra = set(data) - { + "type", + "speed", + } if extra: raise ValueError( - "AUTO action cannot contain " - f"SET fields: {sorted(extra)}" + "AUTO action supports only " + f"'speed': {sorted(extra)}" ) + if "speed" not in data: + raise ValueError( + "AUTO action must contain " + "fallback speed" + ) + + settings = _parse_settings( + { + "speed": data["speed"], + } + ) + return ScheduleAction( - type=ScheduleActionType.AUTO + type=ScheduleActionType.AUTO, + settings=settings, ) settings_data = { @@ -233,7 +249,6 @@ def _parse_action( ), ) - def _parse_template( name: str, data: Any, diff --git a/schedule/models.py b/schedule/models.py index 042ab6b..2ba81a9 100644 --- a/schedule/models.py +++ b/schedule/models.py @@ -116,4 +116,5 @@ class ScheduleResolution: scheduled_settings: ScheduledSettings - auto_active: bool \ No newline at end of file + auto_active: bool + auto_fallback_speed: int | None \ No newline at end of file diff --git a/schedule/service.py b/schedule/service.py index 56c7f31..ef46a14 100644 --- a/schedule/service.py +++ b/schedule/service.py @@ -16,6 +16,7 @@ from .models import ( ScheduledSettings, ) +from dataclasses import replace WEEKDAYS = ( "mon", @@ -230,12 +231,20 @@ class ScheduleService: == ScheduleActionType.AUTO ) + auto_fallback_speed = None + + if auto_active: + auto_fallback_speed = ( + current.point.action.settings.speed + ) + return ScheduleResolution( enabled=True, current=current, next=next_point, scheduled_settings=scheduled_settings, auto_active=auto_active, + auto_fallback_speed=auto_fallback_speed, ) def _build_occurrences( @@ -451,18 +460,28 @@ class ScheduleService: return # -------------------------------------------------- - # 4. Приводим Tion к ПОЛНОМУ актуальному - # состоянию расписания + # 4. Применяем состояние расписания # -------------------------------------------------- - await self._apply_settings(resolution.scheduled_settings) + settings = resolution.scheduled_settings + if resolution.auto_active: + # В режиме AUTO скорость принадлежит + # AutoController. + # + # Остальные параметры расписания + # (power, heater, temperature, mode...) + # должны продолжать работать. + settings = replace(settings, speed=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: 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_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_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_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