work: добавил автоматический режим

This commit is contained in:
Fedorov Dmitriy
2026-09-18 23:59:42 +03:00
parent 7dbfd8b069
commit cdec9b0996
24 changed files with 3449 additions and 33 deletions
+113 -19
View File
@@ -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
# ----------------------------------------------------------------------
+22
View File
@@ -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",
]
+129
View File
@@ -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
+310
View File
@@ -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,
)
+208
View File
@@ -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,
}
+11
View File
@@ -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"
)
#Классы
View File
+44
View File
@@ -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
+88
View File
@@ -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,
)
+610
View File
@@ -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,
)
+23
View File
@@ -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
+18 -4
View File
@@ -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
sat: workday
sun: workday
+2 -1
View File
@@ -1,4 +1,5 @@
tion-btle==3.3.6
fastapi
uvicorn[standard]
PyYAML
PyYAML
paho-mqtt>=2.1,<3
+20 -5
View File
@@ -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,
+2 -1
View File
@@ -116,4 +116,5 @@ class ScheduleResolution:
scheduled_settings: ScheduledSettings
auto_active: bool
auto_active: bool
auto_fallback_speed: int | None
+22 -3
View File
@@ -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:
+101
View File
@@ -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()
+884
View File
@@ -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())
+27
View File
@@ -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())
+261
View File
@@ -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()
+32
View File
@@ -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())
+60
View File
@@ -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()
+30
View File
@@ -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())
+432
View File
@@ -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())