doc: поправил зависимости и расписание

This commit is contained in:
Fedorov Dmitriy
2026-09-19 19:14:40 +03:00
parent 98cb97c9da
commit 1cf1407e04
54 changed files with 10729 additions and 0 deletions
+1
View File
@@ -2,6 +2,7 @@
<module type="PYTHON_MODULE" version="4"> <module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager"> <component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$"> <content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.venv" /> <excludeFolder url="file://$MODULE_DIR$/.venv" />
</content> </content>
<orderEntry type="jdk" jdkName="Python 3.14 (TionController)" jdkType="Python SDK" /> <orderEntry type="jdk" jdkName="Python 3.14 (TionController)" jdkType="Python SDK" />
+779
View File
@@ -0,0 +1,779 @@
import logging
from contextlib import asynccontextmanager
from typing import Literal, Annotated
from fastapi import FastAPI, Request, HTTPException, Path
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pathlib import Path as FilePath
from app.qingping.service import QingpingService
from app.my_dataclasses import (
TION_MAC,
QINGPING_MAC,
QINGPING_MQTT_HOST,
QINGPING_MQTT_PORT,
MIN_FAN_SPEED,
MAX_FAN_SPEED,
MIN_TARGET_TEMP,
MAX_TARGET_TEMP,
AIR_MODE_OUTSIDE,
AIR_MODE_RECIRCULATION,
SCHEDULE_FILE,
AUTO_CONFIG_FILE,
)
from app.auto import (
AutoController,
Co2SpeedPolicy,
load_auto_config,
)
from app.auto.temperature_policy import TemperaturePolicy
from app.tion import (
TionController,
TionService,
)
from schedule import (
ScheduleService,
ScheduledSettings,
load_schedule,
)
SCHEDULE_STATE_FILE = (
FilePath(SCHEDULE_FILE)
.with_name("schedule_state.json")
)
def configure_logging() -> None:
noisy_loggers = (
"asyncio",
"bleak",
"bleak.backends",
"bleak.backends.winrt",
"bleak.backends.winrt.client",
"tion_btle",
"tion_btle.tion",
"tion_btle.s4",
"tion_btle.light_family",
)
for logger_name in noisy_loggers:
logging.getLogger(logger_name).setLevel(logging.WARNING)
configure_logging()
# ----------------------------------------------------------------------
# Tion
# ----------------------------------------------------------------------
controller = TionController(TION_MAC)
service = TionService(
controller,
poll_interval=5,
)
qingping_service = QingpingService(
host=QINGPING_MQTT_HOST,
port=QINGPING_MQTT_PORT,
mac=QINGPING_MAC,
)
schedule_service: ScheduleService | None = None
schedule_load_error: str | None = None
auto_controller: AutoController | None = None
auto_load_error: str | None = None
# ----------------------------------------------------------------------
# Application lifecycle
# ----------------------------------------------------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
global schedule_service
global schedule_load_error
global auto_controller
global auto_load_error
await service.start()
await qingping_service.start()
try:
try:
config = load_schedule(SCHEDULE_FILE)
schedule_service = ScheduleService(
config,
service,
check_interval=5,
state_path=SCHEDULE_STATE_FILE,
)
await schedule_service.start()
schedule_load_error = None
try:
auto_config = load_auto_config(AUTO_CONFIG_FILE)
auto_policy = Co2SpeedPolicy(
base_speed=(
auto_config.co2.base_speed
),
thresholds=(
auto_config.co2.thresholds
),
hysteresis=(
auto_config.co2.hysteresis
),
)
temperature_policy = TemperaturePolicy(auto_config.temperature)
auto_interval = (auto_config.check_interval)
auto_load_error = None
except Exception as exc:
auto_policy = None
temperature_policy = None
# Безопасный встроенный интервал нужен,
# потому что auto.yaml сейчас недоступен.
auto_interval = 5.0
auto_load_error = (
f"{type(exc).__name__}: {exc}"
)
auto_controller = AutoController(
schedule_service=schedule_service,
qingping_service=qingping_service,
tion_service=service,
policy=auto_policy,
temperature_policy=temperature_policy,
interval=auto_interval,
)
await auto_controller.start()
except Exception as exc:
schedule_service = None
schedule_load_error = f"{type(exc).__name__}: {exc}"
yield
finally:
if schedule_service is not None:
await schedule_service.stop()
await qingping_service.stop()
await service.stop()
# ----------------------------------------------------------------------
# FastAPI
# ----------------------------------------------------------------------
app = FastAPI(
title="Tion Controller",
version="0.1.0",
lifespan=lifespan,
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request,
exc: RequestValidationError,
):
return JSONResponse(
status_code=400,
content={
"message": "Invalid request",
"errors": exc.errors(),
},
)
# ----------------------------------------------------------------------
# Helpers
# ----------------------------------------------------------------------
def get_status() -> dict:
"""
Получить текущее состояние сервиса.
Bluetooth-запрос здесь не выполняется.
"""
state = service.state
return {
"online": service.online,
"running": service.running,
"last_seen": (
service.last_seen.isoformat()
if service.last_seen is not None
else None
),
"last_error": service.last_error,
"tion": (
state.to_dict()
if state is not None
else None
),
"auto": get_auto_status(),
"qingping": qingping_service.status(),
"schedule": get_schedule_status(),
}
def get_effective_speed() -> int:
if (
schedule_service is not None
and schedule_service.override_active
and schedule_service.override_settings.speed is not None
):
return schedule_service.override_settings.speed
state = service.state
if state is None:
raise HTTPException(
status_code=503,
detail={
"message": "Tion state unavailable",
},
)
return state.fan_speed
def get_effective_temperature() -> int:
if (
schedule_service is not None
and schedule_service.override_active
and schedule_service.override_settings.target_temp is not None
):
return (
schedule_service
.override_settings
.target_temp
)
state = service.state
if state is None:
raise HTTPException(
status_code=503,
detail={
"message": "Tion state unavailable",
},
)
return state.target_temp
async def execute_command(operation, override: ScheduledSettings | None = None):
try:
# При активном расписании ручная команда
# становится temporary override.
#
# Но если расписание paused,
# мы находимся в полноценном ручном режиме,
# поэтому команда идёт напрямую в Tion.
if (
override is not None
and schedule_service is not None
and schedule_service.running
and schedule_service.config.enabled
and not schedule_service.paused
):
await schedule_service.apply_override(
override
)
else:
await service.execute(
operation
)
except Exception as exc:
raise HTTPException(
status_code=503,
detail={
"message": "Tion unavailable",
"error": (
f"{type(exc).__name__}: {exc}"
),
},
) from exc
return get_status()
def occurrence_to_dict(occurrence) -> dict | None:
if occurrence is None:
return None
return {
"when": occurrence.when.isoformat(),
"weekday": occurrence.weekday,
"template": occurrence.template,
"action": occurrence.point.action.type.value,
"settings": (
occurrence
.point
.action
.settings
.to_dict()
),
}
def get_schedule_status() -> dict:
if schedule_service is None:
return {
"auto_active": False,
"auto_fallback_speed": None,
"auto_target_temp": None,
"available": False,
"current_action": None,
"current": None,
"enabled": False,
"next": None,
"override_active": False,
"override_until": None,
"override_settings": {},
"paused": False,
"running": False,
"scheduled_settings": {},
"last_error": schedule_load_error,
}
resolution = schedule_service.resolve()
current = resolution.current
current_time = (
current.when.strftime("%H:%M")
if current is not None
else None
)
next_time = (
resolution.next.when.strftime("%H:%M")
if resolution.next is not None
else None
)
next_action = (
resolution.next.point.action.type.value
if resolution.next is not None
else None
)
override_until_time = (
schedule_service.override_until.strftime("%H:%M")
if schedule_service.override_until
else None
)
current_action = None
if current is not None:
current_action = (current.point.action.type.value)
return {
"available": True,
"enabled": resolution.enabled,
"running": schedule_service.running,
"paused": schedule_service.paused,
"current_action": current_action,
"current_time": current_time,
"next_time": next_time,
"next_action": next_action,
"override_until_time": override_until_time,
"current": occurrence_to_dict(resolution.current),
"next": occurrence_to_dict(resolution.next),
"auto_active": resolution.auto_active,
"auto_fallback_speed": resolution.auto_fallback_speed,
"auto_target_temp": resolution.auto_target_temp,
"scheduled_settings": resolution.scheduled_settings.to_dict(),
"override_active":schedule_service.override_active,
"override_until": (
schedule_service
.override_until
.isoformat()
if schedule_service.override_until
else None
),
"override_settings": (
schedule_service
.override_settings
.to_dict()
),
"last_error": (
schedule_service.last_error
),
}
def get_auto_status() -> dict:
if auto_controller is None:
return {
"available": False,
"state": "unavailable",
"reason": None,
"target_speed": None,
"auto_speed": None,
"last_error": None,
"config_error": auto_load_error,
}
return {
"available": True,
**auto_controller.status(),
"config_error": (
auto_load_error
),
}
# ----------------------------------------------------------------------
# Status
# ----------------------------------------------------------------------
@app.get("/api/status")
async def status():
return get_status()
# ----------------------------------------------------------------------
# Power
# ----------------------------------------------------------------------
@app.post("/api/tion/power/{state}")
async def set_power(state: Literal["on", "off"]):
enabled = state == "on"
if enabled:
operation = lambda tion: tion.power_on()
else:
operation = lambda tion: tion.power_off()
return await execute_command(
operation,
ScheduledSettings(power=enabled),
)
# ----------------------------------------------------------------------
# Fan speed increase / decrease
# ----------------------------------------------------------------------
@app.post("/api/tion/speed/increase")
async def increase_speed():
current_speed = get_effective_speed()
new_speed = min(current_speed + 1, MAX_FAN_SPEED)
if new_speed == current_speed:
return get_status()
return await execute_command(
lambda tion:
tion.set_speed(new_speed),
ScheduledSettings(
speed=new_speed,
),
)
@app.post("/api/tion/speed/decrease")
async def decrease_speed():
current_speed = get_effective_speed()
new_speed = max(current_speed - 1, MIN_FAN_SPEED)
if new_speed == current_speed:
return get_status()
return await execute_command(
lambda tion:
tion.set_speed(new_speed),
ScheduledSettings(
speed=new_speed,
),
)
# ----------------------------------------------------------------------
# Fan speed
# ----------------------------------------------------------------------
@app.post("/api/tion/speed/{speed}")
async def set_speed(
speed: Annotated[
int,
Path(
ge=MIN_FAN_SPEED,
le=MAX_FAN_SPEED,
),
] ):
return await execute_command(
lambda tion: tion.set_speed(speed),
ScheduledSettings(speed=speed),
)
# ----------------------------------------------------------------------
# Heater
# ----------------------------------------------------------------------
@app.post("/api/tion/heater/{state}")
async def set_heater(state: Literal["on", "off"]):
enabled = state == "on"
if enabled:
operation = lambda tion: tion.heater_on()
else:
operation = lambda tion: tion.heater_off()
return await execute_command(
operation,
ScheduledSettings(heater=enabled),
)
# ----------------------------------------------------------------------
# Target temperature increase / decrease
# ----------------------------------------------------------------------
@app.post("/api/tion/temperature/increase")
async def increase_temperature():
current_temperature = get_effective_temperature()
new_temperature = min(current_temperature + 1, MAX_TARGET_TEMP)
if new_temperature == current_temperature:
return get_status()
return await execute_command(
lambda tion:
tion.set_target_temperature(
new_temperature
),
ScheduledSettings(
target_temp=new_temperature,
),
)
@app.post("/api/tion/temperature/decrease")
async def decrease_temperature():
current_temperature = get_effective_temperature()
new_temperature = max(current_temperature - 1, MIN_TARGET_TEMP)
if new_temperature == current_temperature:
return get_status()
return await execute_command(
lambda tion:
tion.set_target_temperature(
new_temperature
),
ScheduledSettings(
target_temp=new_temperature,
),
)
# ----------------------------------------------------------------------
# Target temperature
# ----------------------------------------------------------------------
@app.post("/api/tion/temperature/{temperature}")
async def set_temperature(
temperature: Annotated[
int,
Path(
ge=MIN_TARGET_TEMP,
le=MAX_TARGET_TEMP,
),
],):
return await execute_command(
lambda tion:
tion.set_target_temperature(temperature),
ScheduledSettings(target_temp=temperature),
)
# ----------------------------------------------------------------------
# Air mode
# ----------------------------------------------------------------------
@app.post("/api/tion/mode/{mode}")
async def set_mode(
mode: str,
):
if mode not in (
AIR_MODE_OUTSIDE,
AIR_MODE_RECIRCULATION,
):
raise HTTPException(
status_code=400,
detail={
"message": "Invalid air mode",
"allowed": [
AIR_MODE_OUTSIDE,
AIR_MODE_RECIRCULATION,
],
},
)
return await execute_command(
lambda tion:
tion.set_air_mode(mode),
ScheduledSettings(
mode=mode,
),
)
# ----------------------------------------------------------------------
# Sound
# ----------------------------------------------------------------------
@app.post("/api/tion/sound/{value}")
async def set_sound(
value: Literal["on", "off"],
):
enabled = value == "on"
if enabled:
operation = lambda tion: tion.sound_on()
else:
operation = lambda tion: tion.sound_off()
return await execute_command(
operation,
ScheduledSettings(
sound=enabled,
),
)
# ----------------------------------------------------------------------
# Light
# ----------------------------------------------------------------------
@app.post("/api/tion/light/{value}")
async def set_light(
value: Literal["on", "off"],
):
enabled = value == "on"
if enabled:
operation = lambda tion: tion.light_on()
else:
operation = lambda tion: tion.light_off()
return await execute_command(
operation,
ScheduledSettings(
light=enabled,
),
)
# ----------------------------------------------------------------------
# Переход в auto
# ----------------------------------------------------------------------
@app.post("/api/schedule/override/clear")
async def clear_schedule_override():
if schedule_service is None:
raise HTTPException(
status_code=503,
detail="Schedule service unavailable",
)
await schedule_service.clear_override()
return {
"ok": True,
"schedule": get_schedule_status(),
"auto": get_auto_status(),
}
# ----------------------------------------------------------------------
# Переход в полностью manual
# ----------------------------------------------------------------------
@app.post("/api/schedule/pause")
async def pause_schedule():
if schedule_service is None:
raise HTTPException(
status_code=503,
detail="Schedule service unavailable",
)
await schedule_service.pause()
return {
"ok": True,
"schedule": get_schedule_status(),
}
# ----------------------------------------------------------------------
# Возвращаем AUTO
# ----------------------------------------------------------------------
@app.post("/api/schedule/resume")
async def resume_schedule():
if schedule_service is None:
raise HTTPException(
status_code=503,
detail="Schedule service unavailable",
)
await schedule_service.resume()
return {
"ok": True,
"schedule": get_schedule_status(),
}
+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
+390
View File
@@ -0,0 +1,390 @@
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
from app.my_dataclasses import (
MIN_FAN_SPEED,
MAX_FAN_SPEED,
)
@dataclass(
frozen=True,
slots=True,
)
class Co2Config:
base_speed: int
hysteresis: int
thresholds: tuple[
tuple[int, int],
...
]
@dataclass(
frozen=True,
slots=True,
)
class AutoConfig:
version: int
check_interval: float
co2: Co2Config
temperature: TemperatureConfig
@dataclass(
frozen=True,
slots=True,
)
class TemperatureConfig:
hysteresis: float
def _require_dict(name: str, value: Any) -> dict:
if not isinstance(value, dict):
raise ValueError(
f"{name} must be an object"
)
return value
def _parse_speed(name: str, value: Any) -> int:
if type(value) is not int:
raise ValueError(
f"{name} must be an integer"
)
if not (
MIN_FAN_SPEED
<= value
<= MAX_FAN_SPEED
):
raise ValueError(
f"{name} must be between "
f"{MIN_FAN_SPEED} and "
f"{MAX_FAN_SPEED}"
)
return value
def _parse_thresholds(value: Any) -> tuple[
tuple[int, int],
...
]:
if not isinstance(value, list):
raise ValueError(
"co2.thresholds must be a list"
)
if not value:
raise ValueError(
"co2.thresholds cannot be empty"
)
result = []
previous_ppm = None
previous_speed = None
for index, item in enumerate(value):
item = _require_dict(
f"co2.thresholds[{index}]",
item,
)
unknown = set(item) - {
"ppm",
"speed",
}
if unknown:
raise ValueError(
f"Unknown fields in "
f"co2.thresholds[{index}]: "
f"{sorted(unknown)}"
)
if "ppm" not in item:
raise ValueError(
f"co2.thresholds[{index}].ppm "
f"is required"
)
if "speed" not in item:
raise ValueError(
f"co2.thresholds[{index}].speed "
f"is required"
)
ppm = item["ppm"]
if type(ppm) is not int:
raise ValueError(
f"co2.thresholds[{index}].ppm "
f"must be an integer"
)
if ppm <= 0:
raise ValueError(
f"co2.thresholds[{index}].ppm "
f"must be > 0"
)
speed = _parse_speed(
(
f"co2.thresholds"
f"[{index}].speed"
),
item["speed"],
)
if (
previous_ppm is not None
and ppm <= previous_ppm
):
raise ValueError(
"CO2 thresholds must be "
"strictly increasing"
)
if (
previous_speed is not None
and speed <= previous_speed
):
raise ValueError(
"AUTO speeds must be "
"strictly increasing"
)
result.append(
(
ppm,
speed,
)
)
previous_ppm = ppm
previous_speed = speed
return tuple(result)
def _parse_co2(value: Any) -> Co2Config:
data = _require_dict(
"co2",
value,
)
unknown = set(data) - {
"base_speed",
"hysteresis",
"thresholds",
}
if unknown:
raise ValueError(
f"Unknown CO2 config fields: "
f"{sorted(unknown)}"
)
if "base_speed" not in data:
raise ValueError(
"co2.base_speed is required"
)
if "hysteresis" not in data:
raise ValueError(
"co2.hysteresis is required"
)
if "thresholds" not in data:
raise ValueError(
"co2.thresholds is required"
)
base_speed = _parse_speed(
"co2.base_speed",
data["base_speed"],
)
hysteresis = data["hysteresis"]
if type(hysteresis) is not int:
raise ValueError(
"co2.hysteresis must be "
"an integer"
)
if hysteresis < 0:
raise ValueError(
"co2.hysteresis must be >= 0"
)
thresholds = _parse_thresholds(
data["thresholds"]
)
first_speed = thresholds[0][1]
if first_speed <= base_speed:
raise ValueError(
"First threshold speed must be "
"greater than base_speed"
)
return Co2Config(
base_speed=base_speed,
hysteresis=hysteresis,
thresholds=thresholds,
)
def load_auto_config(path: str | Path) -> AutoConfig:
path = Path(path)
with path.open(
"r",
encoding="utf-8",
) as file:
raw = yaml.safe_load(file)
data = _require_dict(
"AUTO config",
raw,
)
unknown = set(data) - {
"version",
"check_interval",
"co2",
"temperature",
}
if unknown:
raise ValueError(
f"Unknown AUTO config fields: "
f"{sorted(unknown)}"
)
version = data.get("version")
if version != 1:
raise ValueError(
f"Unsupported AUTO config "
f"version: {version!r}"
)
check_interval = data.get(
"check_interval"
)
if (
type(check_interval) not in {
int,
float,
}
):
raise ValueError(
"check_interval must be a number"
)
if check_interval <= 0:
raise ValueError(
"check_interval must be > 0"
)
# --------------------------------------------------
# CO2
# --------------------------------------------------
if "co2" not in data:
raise ValueError(
"co2 config is required"
)
co2 = _parse_co2(
data["co2"]
)
# --------------------------------------------------
# Temperature
# --------------------------------------------------
if "temperature" not in data:
raise ValueError(
"temperature config is required"
)
temperature_data = _require_dict(
"temperature",
data["temperature"],
)
unknown_temperature = (
set(temperature_data)
- {
"hysteresis",
}
)
if unknown_temperature:
raise ValueError(
f"Unknown temperature config fields: "
f"{sorted(unknown_temperature)}"
)
temperature_hysteresis = (
temperature_data.get(
"hysteresis"
)
)
if (
type(temperature_hysteresis)
not in {
int,
float,
}
):
raise ValueError(
"temperature.hysteresis "
"must be a number"
)
temperature_hysteresis = float(
temperature_hysteresis
)
if temperature_hysteresis < 0:
raise ValueError(
"temperature.hysteresis "
"must be >= 0"
)
temperature = TemperatureConfig(
hysteresis=temperature_hysteresis,
)
# --------------------------------------------------
# Result
# --------------------------------------------------
return AutoConfig(
version=version,
check_interval=float(
check_interval
),
co2=co2,
temperature=temperature,
)
+490
View File
@@ -0,0 +1,490 @@
import asyncio
from datetime import datetime
from app.auto.co2_policy import Co2SpeedPolicy
from app.auto.temperature_policy import TemperaturePolicy
from app.my_dataclasses import MAX_TARGET_TEMP
class AutoController:
def __init__(
self,
schedule_service,
qingping_service,
tion_service,
policy: Co2SpeedPolicy | None = None,
temperature_policy: TemperaturePolicy | None = None,
interval: float = 5.0,
):
self._schedule = schedule_service
self._qingping = qingping_service
self._tion = tion_service
self._policy = policy
self._temperature_policy = temperature_policy
self._interval = interval
self._task: asyncio.Task | None = None
self._state = "inactive"
self._reason: str | None = None
self._target_speed: int | None = None
self._auto_speed: int | None = None
self._target_heater: bool | None = None
self._auto_heater: bool | None = None
self._target_temperature: int | None = None
self._temperature: float | int | None = None
self._temperature_source: str | None = None
self._last_error: str | None = None
async def start(self) -> None:
if self._task is not None:
return
self._task = asyncio.create_task( self._loop() )
async def stop(self) -> None:
if self._task is None:
return
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
finally:
self._task = None
async def _loop(self) -> None:
while True:
try:
await self._process()
except asyncio.CancelledError:
raise
except Exception as exc:
self._last_error = str(exc)
await asyncio.sleep( self._interval )
async def _process(self) -> None:
now = datetime.now()
resolution = self._schedule.resolve(now)
# ----------------------------------------------
# Расписание поставлено на паузу.
#
# Это полноценный ручной режим:
# AUTO вообще не имеет права управлять Tion.
# ----------------------------------------------
if getattr(
self._schedule,
"paused",
False,
):
self._state = "suspended"
self._reason = "schedule_paused"
# После перехода в ручной режим пользователь
# может изменить эти параметры напрямую.
# Поэтому старые значения AUTO больше
# нельзя считать достоверными.
self._target_speed = None
self._auto_speed = None
self._target_heater = None
self._auto_heater = None
self._target_temperature = None
self._temperature = None
self._temperature_source = None
self._last_error = None
return
# ----------------------------------------------
# Активен ручной override.
#
# Пока пользователь вручную управляет Tion,
# AUTO вообще не вмешивается.
# ----------------------------------------------
if self._schedule.override_active:
self._state = "suspended"
self._reason = "manual_override"
# AUTO больше не может считать,
# что знает реальное состояние speed/heater:
# пользователь мог изменить их вручную.
self._target_speed = None
self._auto_speed = None
self._target_heater = None
self._auto_heater = None
self._target_temperature = None
self._temperature = None
self._temperature_source = None
self._last_error = None
return
# ----------------------------------------------
# AUTO сейчас не активен
# ----------------------------------------------
if (
not resolution.enabled
or not resolution.auto_active
):
self._state = "inactive"
self._reason = None
# Управление возвращается ScheduleService.
# После этого AUTO уже не знает фактическое
# состояние speed/heater, поэтому забываем его.
self._target_speed = None
self._auto_speed = None
self._target_heater = None
self._auto_heater = None
self._target_temperature = None
self._temperature = None
self._temperature_source = None
self._last_error = None
return
fallback_speed = resolution.auto_fallback_speed
if fallback_speed is None:
raise RuntimeError(
"AUTO is active but "
"fallback speed is missing"
)
# ----------------------------------------------
# CO2 policy недоступна.
#
# Например, auto.yaml не загрузился.
# AUTO работает в аварийном fallback-only режиме.
# ----------------------------------------------
if self._policy is None:
self._state = "fallback"
self._reason = "auto_policy_unavailable"
self._auto_speed = None
await self._set_speed(fallback_speed)
if self._temperature_policy is not None:
await self._process_heater(resolution)
self._last_error = None
return
qingping_state = self._qingping.state
# ----------------------------------------------
# Qingping исправен
# ----------------------------------------------
if (
self._qingping.online
and qingping_state.co2 is not None
):
target_speed = (
self._policy.select_speed(
co2=qingping_state.co2,
current_speed=self._auto_speed,
)
)
self._state = "active"
self._reason = None
await self._set_speed(
target_speed
)
self._auto_speed = target_speed
if self._temperature_policy is not None:
await self._process_heater(resolution)
self._last_error = None
return
# ----------------------------------------------
# AUTO не может работать.
# Переходим на fallback.
# ----------------------------------------------
if not self._qingping.online:
reason = "qingping_offline"
else:
reason = "co2_missing"
self._state = "fallback"
self._reason = reason
self._auto_speed = None
await self._set_speed(fallback_speed)
if self._temperature_policy is not None:
await self._process_heater(
resolution
)
self._last_error = None
async def _process_heater(self, resolution) -> None:
# --------------------------------------------------
# Берём целевую температуру из накопленных
# настроек расписания.
#
# Например:
#
# SET target_temp=20
# AUTO
#
# Во время AUTO target_temp остаётся 20.
# --------------------------------------------------
target_temp = resolution.auto_target_temp
# --------------------------------------------------
# Получаем фактическую температуру.
#
# _get_temperature() сам выбирает:
#
# 1. Qingping
# 2. Tion как резерв
# 3. None, если оба источника недоступны
# --------------------------------------------------
temperature, source = self._get_temperature()
self._temperature = temperature
self._temperature_source = source
# --------------------------------------------------
# Нет температурной policy.
#
# AUTO не умеет безопасно принять решение
# о нагреве → выключаем heater.
# --------------------------------------------------
if self._temperature_policy is None:
await self._set_heater(False)
self._auto_heater = False
return
# --------------------------------------------------
# Нет целевой температуры.
#
# Непонятно, до какой температуры греть.
# Поэтому heater OFF.
# --------------------------------------------------
if target_temp is None:
await self._set_heater(False)
self._auto_heater = False
return
# --------------------------------------------------
# Нет достоверной фактической температуры.
#
# Ни Qingping, ни резервный датчик Tion
# использовать нельзя.
#
# Heater OFF.
# --------------------------------------------------
if temperature is None:
await self._set_heater(False)
self._auto_heater = False
return
# --------------------------------------------------
# Есть всё необходимое:
#
# - фактическая температура;
# - target_temp;
# - TemperaturePolicy.
#
# Policy решает, нужно ли сейчас греть.
# --------------------------------------------------
heater_required = (
self._temperature_policy
.heater_required(
temperature=temperature,
target_temp=target_temp,
heater_on=(
self._auto_heater
is True
),
)
)
if heater_required:
# Наш внешний регулятор решил, что нужно греть.
#
# Сначала поднимаем внутреннюю уставку Tion
# до максимума, чтобы встроенный термостат
# не заблокировал нагрев по своему in_temp.
await self._set_target_temperature(MAX_TARGET_TEMP)
# После этого разрешаем нагреватель.
await self._set_heater(True)
else:
# Целевая температура по внешнему датчику
# достигнута — нагрев запрещаем.
await self._set_heater(False)
self._auto_heater = heater_required
def _get_temperature(self) -> tuple[float | int | None, str | None]:
# --------------------------------------------------
# Основной источник — Qingping.
#
# Используем температуру Qingping только если
# сам поток данных датчика сейчас считается online.
# --------------------------------------------------
qingping_state = self._qingping.state
if (
self._qingping.online
and qingping_state.temperature is not None
):
return (
qingping_state.temperature,
"qingping",
)
# --------------------------------------------------
# Qingping недоступен или температура отсутствует.
#
# Пробуем резервный датчик температуры Tion.
#
# Важно проверять tion.online:
# TionService может хранить последнее состояние,
# даже если связь с устройством уже потеряна.
# --------------------------------------------------
tion_state = self._tion.state
if (
self._tion.online
and tion_state is not None
and tion_state.in_temp is not None
):
return (
tion_state.in_temp,
"tion",
)
# --------------------------------------------------
# Достоверной температуры нет вообще.
# --------------------------------------------------
return None, None
async def _set_speed(self, speed: int) -> None:
if self._target_speed == speed:
return
await self._tion.execute(
lambda controller:
controller.set_speed(speed)
)
self._target_speed = speed
async def _set_heater(self,enabled: bool) -> None:
# Если AutoController уже установил именно такое
# состояние нагревателя, повторную команду не шлём.
if self._target_heater == enabled:
return
if enabled:
await self._tion.execute(
lambda controller:
controller.heater_on()
)
else:
await self._tion.execute(
lambda controller:
controller.heater_off()
)
# Запоминаем состояние только после того,
# как команда успешно выполнилась.
self._target_heater = enabled
async def _set_target_temperature(
self,
temperature: int,
) -> None:
if self._target_temperature == temperature:
return
await self._tion.execute(
lambda controller:
controller.set_target_temperature(
temperature
)
)
self._target_temperature = temperature
def status(self) -> dict:
return {
"state": self._state,
"reason": self._reason,
"target_speed": self._target_speed,
"auto_speed": self._auto_speed,
"target_heater": self._target_heater,
"auto_heater": self._auto_heater,
"temperature": self._temperature,
"temperature_source": self._temperature_source,
"last_error": self._last_error,
}
+40
View File
@@ -0,0 +1,40 @@
from .config import TemperatureConfig
class TemperaturePolicy:
def __init__(
self,
config: TemperatureConfig,
):
self._config = config
def heater_required(
self,
temperature: float,
target_temp: float,
heater_on: bool,
) -> bool:
# Нагреватель уже включён.
#
# Продолжаем греть, пока температура
# не достигла целевой.
if heater_on:
return (
temperature
< target_temp
)
# Нагреватель выключен.
#
# Повторно включаем его только после
# падения температуры ниже нижней
# границы гистерезиса.
return (
temperature
< (
target_temp
- self._config.hysteresis
)
)
+68
View File
@@ -0,0 +1,68 @@
from pathlib import Path
from dataclasses import asdict, dataclass
from typing import Any, Mapping
# Константы и классы
# Константы
# MAC Bluetooth бризера
TION_MAC = "d1:74:9b:eb:ee:a6"
QINGPING_MAC = "CCB5D131BA93"
QINGPING_MQTT_HOST = "192.168.7.3"
QINGPING_MQTT_PORT = 1883
MIN_FAN_SPEED = 1
MAX_FAN_SPEED = 6
MIN_TARGET_TEMP = 5
MAX_TARGET_TEMP = 30
AIR_MODE_OUTSIDE = "outside"
AIR_MODE_RECIRCULATION = "recirculation"
SUPPORTED_AIR_MODES = {
AIR_MODE_OUTSIDE,
AIR_MODE_RECIRCULATION,
}
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SCHEDULE_FILE = (
PROJECT_ROOT
/ "config"
/ "schedule.yaml"
)
AUTO_CONFIG_FILE = (
PROJECT_ROOT
/ "config"
/ "auto.yaml"
)
WATCHDOG_INTERVAL = 5.0
# Type 13 обычно приходит примерно раз в 60 секунд.
# Даём дополнительный запас.
HEARTBEAT_STALE_AFTER = 90.0
# После появления устройства ждём первый sensor sample
# не дольше этого времени.
FIRST_SAMPLE_TIMEOUT = 30.0
# Если новые sensor samples не приходят дольше этого времени,
# считаем sensor stream зависшим.
SAMPLE_STALE_AFTER = 45.0
QINGPING_SAMPLE_TIMEOUT = 60.0
QINGPING_RECOVERY_TIMEOUT = 30.0
#Классы
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,
)
+900
View File
@@ -0,0 +1,900 @@
# from datetime import datetime, timedelta, timezone
#
# from bleak import BleakScanner
# from bleak.backends.device import BLEDevice
# from bleak.backends.scanner import AdvertisementData
#
# from app.qingping.models import QingpingState
# from app.qingping.parser import parse_cgdn1
#
#
# QINGPING_SERVICE_UUID = "0000fdcd-0000-1000-8000-00805f9b34fb"
#
#
# class QingpingService:
# def __init__(self, mac: str, stale_after: float = 30.0):
# self._mac = mac.upper()
# self._stale_after = stale_after
#
# self._scanner: BleakScanner | None = None
# self._state: QingpingState | None = None
#
# self._last_seen: datetime | None = None
# self._last_error: str | None = None
#
# self._running = False
#
# @property
# def state(self) -> QingpingState | None:
# return self._state
#
# @property
# def running(self) -> bool:
# return self._running
#
# @property
# def last_seen(self) -> datetime | None:
# return self._last_seen
#
# @property
# def last_error(self) -> str | None:
# return self._last_error
#
# @property
# def online(self) -> bool:
# if not self._running:
# return False
#
# if self._last_seen is None:
# return False
#
# age = datetime.now(timezone.utc) - self._last_seen
#
# return age <= timedelta(
# seconds=self._stale_after
# )
#
# async def start(self):
# if self._running:
# return
#
# try:
# self._scanner = BleakScanner(
# self._on_advertisement,
# # service_uuids=[
# # QINGPING_SERVICE_UUID,
# # ],
# )
#
# await self._scanner.start()
#
# self._running = True
# self._last_error = None
#
# except Exception as exc:
# self._scanner = None
# self._running = False
# self._last_error = str(exc)
#
# raise
#
# async def stop(self):
# scanner = self._scanner
#
# self._scanner = None
# self._running = False
#
# if scanner is not None:
# await scanner.stop()
#
# # def _on_advertisement(
# # self,
# # device: BLEDevice,
# # advertisement: AdvertisementData,
# # ):
# # if device.address.upper() != self._mac:
# # return
# #
# # data = advertisement.service_data.get(
# # QINGPING_SERVICE_UUID
# # )
# #
# # if not data:
# # return
# #
# # try:
# # state = parse_cgdn1(
# # data,
# # rssi=getattr(
# # advertisement,
# # "rssi",
# # None,
# # ),
# # )
# #
# # if state is None:
# # return
# #
# # self._state = state
# #
# # self._last_seen = datetime.now(
# # timezone.utc
# # )
# #
# # self._last_error = None
# #
# # except Exception as exc:
# # self._last_error = str(exc)
#
# def _on_advertisement(
# self,
# device: BLEDevice,
# advertisement: AdvertisementData,
# ):
# data = advertisement.service_data.get(
# QINGPING_SERVICE_UUID
# )
#
# if not data:
# return
#
# print(
# "QINGPING:",
# device.address,
# device.name,
# data.hex(" "),
# )
#
# if device.address.upper() != self._mac:
# print(
# "MAC mismatch:",
# device.address,
# "!=",
# self._mac,
# )
# return
#
# try:
# state = parse_cgdn1(
# data,
# rssi=getattr(
# advertisement,
# "rssi",
# None,
# ),
# )
#
# print("PARSED:", state)
#
# if state is None:
# return
#
# self._state = state
# self._last_seen = datetime.now(
# timezone.utc
# )
# self._last_error = None
#
# except Exception as exc:
# self._last_error = str(exc)
# print("Qingping parse error:", exc)
#
# async def __aenter__(self):
# await self.start()
# return self
#
# async def __aexit__(
# self,
# exc_type,
# exc_val,
# exc_tb,
# ):
# await self.stop()
import asyncio
import json
import logging
import threading
import time
from dataclasses import replace
from datetime import datetime, timezone
import paho.mqtt.client as mqtt
from .models import QingpingState
from app.my_dataclasses import (
WATCHDOG_INTERVAL,
QINGPING_SAMPLE_TIMEOUT,
QINGPING_RECOVERY_TIMEOUT,
)
logger = logging.getLogger(__name__)
class QingpingService:
def __init__(
self,
host: str,
port: int = 1883,
mac: str = "CCB5D131BA93",
):
self._host = host
self._port = port
self._mac = mac
self._up_topic = f"qingping/{mac}/up"
self._down_topic = f"qingping/{mac}/down"
self._state = QingpingState()
self._lock = threading.Lock()
self._client: mqtt.Client | None = None
self._watchdog_task: asyncio.Task | None = None
self._last_sample_monotonic: float | None = None
self._device_online = False
self._recovery_waiting = False
self._recovery_started_monotonic: float | None = None
@property
def state(self) -> QingpingState:
with self._lock:
return self._state
@property
def online(self) -> bool:
with self._lock:
return (
self._state.mqtt_connected
and self._device_online
)
async def start(self) -> None:
client = mqtt.Client(
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
client_id="tioncontroller-qingping",
)
client.on_connect = self._on_connect
client.on_disconnect = self._on_disconnect
client.on_message = self._on_message
client.reconnect_delay_set(
min_delay=1,
max_delay=30,
)
self._client = client
client.connect_async(
self._host,
self._port,
keepalive=60,
)
client.loop_start()
self._watchdog_task = asyncio.create_task(
self._watchdog_loop()
)
async def stop(self) -> None:
if self._watchdog_task is not None:
self._watchdog_task.cancel()
try:
await self._watchdog_task
except asyncio.CancelledError:
pass
self._watchdog_task = None
if self._client is not None:
self._client.disconnect()
self._client.loop_stop()
self._client = None
def status(self) -> dict:
with self._lock:
state = self._state
online = (
state.mqtt_connected
and self._device_online
)
recovery_waiting = (
self._recovery_waiting
)
return {
**state.to_dict(),
"online": online,
"recovery_waiting": recovery_waiting,
}
def _on_connect(
self,
client,
_userdata,
_flags,
reason_code,
_properties=None,
) -> None:
if reason_code != 0:
logger.error(
"Qingping MQTT connection failed: %s",
reason_code,
)
return
logger.info(
"Qingping MQTT connected"
)
client.subscribe(self._up_topic)
now_monotonic = time.monotonic()
with self._lock:
self._state = replace(
self._state,
mqtt_connected=True,
temperature=None,
humidity=None,
co2=None,
pm25=None,
pm10=None,
battery=None,
sample_timestamp=None,
sample_received_at=None,
)
self._last_sample_monotonic = None
self._device_online = False
self._recovery_waiting = True
self._recovery_started_monotonic = (
now_monotonic
)
logger.info(
"Qingping startup initialization"
)
self._send_recovery()
def _on_disconnect(
self,
_client,
_userdata,
_disconnect_flags,
reason_code,
_properties=None,
) -> None:
logger.warning(
"Qingping MQTT disconnected: %s",
reason_code,
)
with self._lock:
self._state = replace(
self._state,
mqtt_connected=False,
)
self._device_online = False
self._recovery_waiting = False
self._recovery_started_monotonic = None
def _on_message(
self,
_client,
_userdata,
message,
) -> None:
try:
payload = json.loads(
message.payload.decode("utf-8")
)
now = datetime.now(timezone.utc)
now_monotonic = time.monotonic()
message_type = int(
payload.get("type")
)
logger.debug(
"Qingping MQTT packet received: type=%s",
message_type,
)
# Любой пакет означает, что устройство
# физически присутствует в MQTT.
with self._lock:
self._state = replace(
self._state,
last_message_at=now,
)
start_recovery = False
with self._lock:
if (
not self._device_online
and
not self._recovery_waiting
):
self._state = replace(
self._state,
temperature=None,
humidity=None,
co2=None,
pm25=None,
pm10=None,
battery=None,
sample_timestamp=None,
sample_received_at=None,
)
self._last_sample_monotonic = None
self._device_online = True
self._recovery_waiting = True
self._recovery_started_monotonic = (
now_monotonic
)
start_recovery = True
if start_recovery:
logger.info(
"Qingping packet received while offline: "
"type=%s -> starting recovery",
message_type,
)
self._send_recovery()
# Единственный пакет, который реально
# обрабатываем как данные.
if message_type == 17:
self._handle_sensor_data(
payload,
now,
)
return
if message_type == 13:
self._handle_heartbeat(
payload,
)
return
logger.debug(
"Qingping service packet received: type=%s",
message_type,
)
except Exception:
logger.exception(
"Qingping MQTT packet processing failed"
)
def _handle_heartbeat(
self,
payload: dict,
) -> None:
wifi_info = payload.get("wifi_info")
wifi_rssi = None
if isinstance(wifi_info, str):
parts = wifi_info.split(",")
if len(parts) >= 2:
try:
wifi_rssi = int(parts[1])
except ValueError:
logger.warning(
"Qingping invalid RSSI in wifi_info: %r",
wifi_info,
)
firmware = payload.get("sw_version")
with self._lock:
self._state = replace(
self._state,
wifi_rssi=wifi_rssi,
firmware=firmware,
)
logger.debug(
"Qingping heartbeat updated: "
"rssi=%s firmware=%s",
wifi_rssi,
firmware,
)
def _handle_sensor_data(
self,
payload: dict,
received_at: datetime,
) -> None:
sensor_data = payload.get("sensorData")
if not isinstance(sensor_data, list):
return
if not sensor_data:
return
sample = max(
sensor_data,
key=self._sample_timestamp,
)
sample_timestamp = self._sample_timestamp(
sample
)
if sample_timestamp <= 0:
return
current_timestamp = (
self.state.sample_timestamp
)
# CGDN1 после запуска может несколько раз
# присылать одну и ту же историческую точку.
if (
current_timestamp is not None
and sample_timestamp <= current_timestamp
):
return
temperature = self._value(
sample,
"temperature",
)
humidity = self._value(
sample,
"humidity",
)
co2 = self._value(
sample,
"co2",
)
pm25 = self._value(
sample,
"pm25",
)
pm10 = self._value(
sample,
"pm10",
)
battery = self._value(
sample,
"battery",
)
now_monotonic = time.monotonic()
with self._lock:
current_timestamp = (
self._state.sample_timestamp
)
# CGDN1 может повторять одну историческую точку.
# Такой пакет НЕ считается новым измерением.
if (
current_timestamp is not None
and sample_timestamp <= current_timestamp
):
logger.debug(
"Qingping duplicate sensor sample ignored: "
"timestamp=%s current=%s",
sample_timestamp,
current_timestamp,
)
return
was_recovering = self._recovery_waiting
was_offline = not self._device_online
self._state = replace(
self._state,
temperature=temperature,
humidity=humidity,
co2=co2,
pm25=pm25,
pm10=pm10,
battery=battery,
sample_timestamp=sample_timestamp,
sample_received_at=received_at,
)
self._last_sample_monotonic = (
now_monotonic
)
self._device_online = True
self._recovery_waiting = False
self._recovery_started_monotonic = None
# if was_offline or was_recovering:
# logger.info(
# "Qingping sensor stream online: "
# "timestamp=%s co2=%s",
# sample_timestamp,
# co2,
# )
# else:
# logger.debug(
# "Qingping sensor sample accepted: "
# "timestamp=%s co2=%s",
# sample_timestamp,
# co2,
# )
async def _watchdog_loop(self) -> None:
while True:
await asyncio.sleep(
WATCHDOG_INTERVAL
)
self._watchdog_tick(
time.monotonic()
)
def _watchdog_tick(
self,
now: float,
) -> None:
recovery_timeout = None
sample_timeout = None
invalid_recovery_state = False
with self._lock:
# Ждём type 17 после recovery.
if self._recovery_waiting:
if (
self._recovery_started_monotonic
is None
):
invalid_recovery_state = True
self._recovery_waiting = False
self._device_online = False
else:
elapsed = (
now
- self._recovery_started_monotonic
)
if (
elapsed
> QINGPING_RECOVERY_TIMEOUT
):
recovery_timeout = elapsed
self._recovery_waiting = False
self._recovery_started_monotonic = None
self._device_online = False
# Нормальная работа:
# следим за последним НОВЫМ type 17.
elif self._last_sample_monotonic is not None:
elapsed = (
now
- self._last_sample_monotonic
)
if (
elapsed
> QINGPING_SAMPLE_TIMEOUT
and
self._device_online
):
sample_timeout = elapsed
self._device_online = False
if invalid_recovery_state:
logger.error(
"Qingping invalid recovery state: "
"recovery_waiting=True but "
"recovery_started_monotonic=None"
)
if recovery_timeout is not None:
logger.warning(
"Qingping recovery timeout: "
"no type 17 for %.1f sec "
"-> offline",
recovery_timeout,
)
if sample_timeout is not None:
logger.warning(
"Qingping sensor stream lost: "
"no type 17 for %.1f sec "
"-> offline",
sample_timeout,
)
@staticmethod
def _sample_timestamp(sample: dict) -> int:
timestamp = sample.get("timestamp")
if isinstance(timestamp, dict):
timestamp = timestamp.get("value")
try:
return int(timestamp)
except (TypeError, ValueError):
return 0
@staticmethod
def _value(sample: dict, key: str):
value = sample.get(key)
if isinstance(value, dict):
return value.get("value")
return value
async def _watchdog_loop(self) -> None:
while True:
await asyncio.sleep(
WATCHDOG_INTERVAL
)
now = time.monotonic()
# --------------------------------------------------
# СЦЕНАРИЙ 1
#
# Recovery уже отправлен.
# Ждём type 17 максимум 30 секунд.
# --------------------------------------------------
if self._recovery_waiting:
if self._recovery_started_monotonic is None:
logger.error(
"Qingping invalid recovery state: "
"recovery_waiting=True but "
"recovery_started_monotonic=None"
)
self._recovery_waiting = False
self._device_online = False
continue
elapsed = now - self._recovery_started_monotonic
if elapsed > QINGPING_RECOVERY_TIMEOUT:
logger.warning(
"Qingping recovery timeout: "
"no type 17 for %.1f sec "
"-> offline",
elapsed,
)
self._recovery_waiting = False
self._recovery_started_monotonic = (
None
)
self._device_online = False
continue
# --------------------------------------------------
# СЦЕНАРИЙ 2
#
# До сих пор не получили вообще ни одного
# измерения type 17.
#
# Ничего делать не надо.
# Первый любой MQTT-пакет запустит recovery.
# --------------------------------------------------
if self._last_sample_monotonic is None:
continue
# --------------------------------------------------
# СЦЕНАРИЙ 3
#
# Нормально работали, но type 17
# перестали приходить.
# --------------------------------------------------
elapsed = (
now
- self._last_sample_monotonic
)
if (
elapsed
> QINGPING_SAMPLE_TIMEOUT
and
self._device_online
):
logger.warning(
"Qingping sensor stream lost: "
"no type 17 for %.1f sec "
"-> offline",
elapsed,
)
self._device_online = False
def _send_recovery(self) -> None:
if self._client is None:
return
payload = {
"type": "17",
"timestamp": int(time.time()),
"setting": {
"report_interval": 15,
"collect_interval": 15,
"need_ack": 0,
},
}
result = self._client.publish(
self._down_topic,
json.dumps(
payload,
separators=(",", ":"),
),
)
if result.rc == mqtt.MQTT_ERR_SUCCESS:
logger.warning(
"Qingping recovery command sent"
)
else:
logger.warning(
"Failed to send Qingping recovery: %s",
result.rc,
)
def _reset_sample_session(self) -> None:
logger.debug(
"Qingping sensor session reset"
)
with self._lock:
self._state = replace(
self._state,
temperature=None,
humidity=None,
co2=None,
pm25=None,
pm10=None,
battery=None,
sample_timestamp=None,
sample_received_at=None,
)
self._last_sample_monotonic = None
+10
View File
@@ -0,0 +1,10 @@
from .controller import TionController
from .models import TionState
from .service import TionService
__all__ = [
"TionController",
"TionService",
"TionState",
]
+275
View File
@@ -0,0 +1,275 @@
import asyncio
from typing import Any
from bleak import BleakScanner
from tion_btle import TionS4
from .models import TionState
from app.my_dataclasses import *
class TionController:
"""
Высокоуровневый контроллер Tion 4S.
Один экземпляр TionController должен владеть одним BLE-соединением
с бризером на протяжении всей работы приложения.
"""
def __init__(self, mac: str):
self._mac = mac
self._device: TionS4 | None = None
# Не позволяем двум частям программы одновременно работать с BLE.
self._lock = asyncio.Lock()
# Показывает, что мы удерживаем внешнее соединение tion-btle.
self._started = False
# Последнее успешно прочитанное состояние.
self._state: TionState | None = None
@property
def mac(self) -> str:
return self._mac
@property
def connected(self) -> bool:
return (
self._device is not None
and self._device.connection_status == "connected"
)
async def _reset_device(self) -> None:
"""
Полностью уничтожить текущий BLE transport.
Важно:
tion-btle не вызывает BleakClient.disconnect(),
если WinRT уже считает устройство disconnected.
Поэтому при полном reset принудительно закрываем
BleakClient напрямую.
"""
old_device = self._device
# Controller сразу больше не считает старый объект рабочим.
self._device = None
self._started = False
if old_device is None:
return
try:
# Нам нужен именно настоящий BleakClient.disconnect().
#
# Не old_device.disconnect(), потому что tion-btle
# может пропустить физический cleanup при
# connection_status == "disc".
await old_device._btle.disconnect()
except Exception:
# Старый transport всё равно больше использоваться
# не будет.
pass
"""
Соединение
"""
async def connect(self) -> None:
async with self._lock:
if self.connected:
self._started = True
return
# Полностью закрываем всё, что осталось
# от предыдущего соединения.
await self._reset_device()
# Получаем свежий BLEDevice.
ble_device = await BleakScanner.find_device_by_address(
self._mac,
timeout=5.0,
)
if ble_device is None:
raise ConnectionError(
f"Tion {self._mac} not found"
)
# Новый TionS4 = новый BleakClient.
device = TionS4(ble_device)
try:
await device.connect()
except Exception:
# ВАЖНО:
# освобождаем даже частично созданную
# WinRT/GATT-сессию.
try:
await device._btle.disconnect()
except Exception:
pass
raise
self._device = device
self._started = True
async def disconnect(self) -> None:
"""
Закрыть BLE-соединение.
"""
async with self._lock:
await self._reset_device()
"""
Состояние
"""
async def get_state(self) -> TionState:
"""
Получить реальное текущее состояние бризера.
"""
self._ensure_started()
async with self._lock:
raw_state = await self._device.get()
state = TionState.from_raw(raw_state)
self._state = state
return state
"""
Питание
"""
async def power_on(self) -> TionState:
return await self._set({
"state": "on"
})
async def power_off(self) -> TionState:
return await self._set({
"state": "off"
})
"""
Скорость вентилятора
"""
async def set_speed(self, speed: int) -> TionState:
if not MIN_FAN_SPEED <= speed <= MAX_FAN_SPEED:
raise ValueError("Tion fan speed must be between 1 and 6")
return await self._set({
"fan_speed": speed
})
"""
Целевая температура
"""
async def set_target_temperature(self, temperature: int) -> TionState:
if not MIN_TARGET_TEMP <= temperature <= MAX_TARGET_TEMP:
raise ValueError(
f"Tion target temperature must be between "
f"{MIN_TARGET_TEMP} and {MAX_TARGET_TEMP} °C"
)
return await self._set({
"heater_temp": temperature
})
"""
Нагрев
"""
async def heater_on(self) -> TionState:
return await self._set({
"heater": "on"
})
async def heater_off(self) -> TionState:
return await self._set({
"heater": "off"
})
"""
Забор воздуха
"""
async def set_air_mode(self, mode: str) -> TionState:
if mode not in SUPPORTED_AIR_MODES:
raise ValueError(
f"Unsupported Tion air mode: {mode}. "
f"Available modes: {sorted(SUPPORTED_AIR_MODES)}"
)
return await self._set({
"mode": mode
})
"""
Звук
"""
async def sound_on(self) -> TionState:
return await self._set({
"sound": "on"
})
async def sound_off(self) -> TionState:
return await self._set({
"sound": "off"
})
"""
Световая индикация
"""
async def light_on(self) -> TionState:
return await self._set({
"light": "on"
})
async def light_off(self) -> TionState:
return await self._set({
"light": "off"
})
async def _set(self, settings: dict[str, Any]) -> TionState:
"""
Отправить настройки в Tion и затем прочитать
фактическое состояние устройства.
"""
self._ensure_started()
async with self._lock:
await self._device.set(settings)
raw_state = await self._device.get()
state = TionState.from_raw(raw_state)
self._state = state
return state
def _ensure_started(self) -> None:
if not self._started:
raise RuntimeError(
"TionController is not connected. "
"Call await controller.connect() first."
)
async def __aenter__(self) -> "TionController":
await self.connect()
return self
async def __aexit__(self, exc_type, exc_value, traceback) -> None:
await self.disconnect()
+56
View File
@@ -0,0 +1,56 @@
from dataclasses import asdict, dataclass
from typing import Any, Mapping
def _is_on(value: Any) -> bool:
return value == "on"
@dataclass(frozen=True, slots=True)
class TionState:
power: bool
heater: bool
heating: bool
sound: bool
mode: str
out_temp: int
in_temp: int
target_temp: int
fan_speed: int
filter_remain: float
device_time: str
request_error_code: int
model: str
light: bool | None = None
@classmethod
def from_raw(cls, data: Mapping[str, Any]) -> "TionState":
light_raw = data.get("light")
return cls(
power=_is_on(data["state"]),
heater=_is_on(data["heater"]),
heating=_is_on(data["heating"]),
sound=_is_on(data["sound"]),
mode=str(data["mode"]),
out_temp=int(data["out_temp"]),
in_temp=int(data["in_temp"]),
target_temp=int(data["heater_temp"]),
fan_speed=int(data["fan_speed"]),
filter_remain=float(data["filter_remain"]),
device_time=str(data["time"]),
request_error_code=int(data["request_error_code"]),
model=str(data["model"]),
light=None if light_raw is None else _is_on(light_raw),
)
def to_dict(self) -> dict:
return asdict(self)
+307
View File
@@ -0,0 +1,307 @@
import asyncio
from collections.abc import Awaitable, Callable
from contextlib import suppress
from datetime import datetime, timezone
from .controller import TionController
from .models import TionState
TionOperation = Callable[
[TionController],
Awaitable[TionState]
]
class TionService:
"""
Долгоживущий сервис работы с Tion.
Отвечает за:
- подключение;
- периодический опрос состояния;
- online/offline;
- last_seen;
- автоматическое переподключение;
- синхронизацию команд с polling.
"""
def __init__(
self,
controller: TionController,
poll_interval: float = 5.0,
):
if poll_interval <= 0:
raise ValueError("poll_interval must be greater than 0")
self._controller = controller
self._poll_interval = poll_interval
self._state: TionState | None = None
self._online = False
self._last_seen: datetime | None = None
self._last_error: str | None = None
self._running = False
self._poll_task: asyncio.Task | None = None
# Защищает последовательность:
#
# reconnect -> command -> update state
#
# от вмешательства polling или другой команды.
self._operation_lock = asyncio.Lock()
# Защищает start / stop.
self._lifecycle_lock = asyncio.Lock()
# ------------------------------------------------------------------
# Properties
# ------------------------------------------------------------------
@property
def state(self) -> TionState | None:
"""
Последнее успешно полученное состояние Tion.
Bluetooth-запрос не выполняется.
"""
return self._state
@property
def online(self) -> bool:
return self._online
@property
def running(self) -> bool:
return self._running
@property
def last_seen(self) -> datetime | None:
"""
Время последнего успешного обмена с Tion.
"""
return self._last_seen
@property
def last_error(self) -> str | None:
"""
Последняя ошибка связи.
После успешного обмена сбрасывается в None.
"""
return self._last_error
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
async def start(self) -> None:
"""
Запустить сервис.
Первая попытка подключения и чтения состояния выполняется сразу.
После этого запускается фоновый polling.
"""
async with self._lifecycle_lock:
if self._running:
return
self._running = True
# Сразу пытаемся получить состояние.
# Если Tion недоступен, сервис всё равно продолжит работу.
await self.refresh_state()
self._poll_task = asyncio.create_task(
self._poll_loop(),
name="tion-poll",
)
async def stop(self) -> None:
"""
Остановить polling и корректно закрыть BLE-соединение.
"""
async with self._lifecycle_lock:
if not self._running:
return
self._running = False
poll_task = self._poll_task
self._poll_task = None
if poll_task is not None:
poll_task.cancel()
with suppress(asyncio.CancelledError):
await poll_task
async with self._operation_lock:
await self._safe_disconnect()
self._online = False
# ------------------------------------------------------------------
# State
# ------------------------------------------------------------------
async def refresh_state(self) -> TionState | None:
"""
Принудительно обновить состояние Tion.
При ошибке:
- online становится False;
- last_error обновляется;
- старый state сохраняется;
- исключение наружу не выбрасывается.
Возвращает None при ошибке.
"""
async with self._operation_lock:
return await self._execute_locked(
lambda controller: controller.get_state(),
raise_on_error=False,
)
# ------------------------------------------------------------------
# Commands
# ------------------------------------------------------------------
async def execute(
self,
operation: TionOperation,
) -> TionState:
"""
Выполнить любую команду TionController.
Пример:
await service.execute(
lambda tion: tion.set_speed(3)
)
После команды состояние Service автоматически обновляется.
"""
async with self._operation_lock:
state = await self._execute_locked(
operation,
raise_on_error=True,
)
# Здесь None невозможен, потому что raise_on_error=True.
assert state is not None
return state
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
async def _execute_locked(
self,
operation: TionOperation,
*,
raise_on_error: bool,
) -> TionState | None:
"""
Выполнение BLE-операции.
Вызывается только при занятом _operation_lock.
"""
try:
await self._ensure_connected()
state = await operation(self._controller)
except asyncio.CancelledError:
raise
except Exception as exc:
self._mark_offline(exc)
# После BLE-ошибки считаем соединение повреждённым.
# На следующей попытке будет создано новое.
await self._safe_disconnect()
if raise_on_error:
raise
return None
self._mark_online(state)
return state
async def _ensure_connected(self) -> None:
"""
Убедиться, что имеется рабочее BLE-соединение.
Если физического соединения нет, старое состояние подключения
сбрасывается и выполняется новое connect().
"""
if self._controller.connected:
return
await self._safe_disconnect()
await self._controller.connect()
async def _safe_disconnect(self) -> None:
"""
Закрыть соединение, не распространяя ошибку disconnect наружу.
"""
with suppress(Exception):
await self._controller.disconnect()
def _mark_online(self, state: TionState) -> None:
self._state = state
self._online = True
self._last_seen = datetime.now(timezone.utc)
self._last_error = None
def _mark_offline(self, exc: Exception) -> None:
self._online = False
self._last_error = (
f"{type(exc).__name__}: {exc}"
)
# ------------------------------------------------------------------
# Background polling
# ------------------------------------------------------------------
async def _poll_loop(self) -> None:
"""
Фоновый цикл обновления состояния.
"""
while self._running:
await asyncio.sleep(self._poll_interval)
if not self._running:
break
await self.refresh_state()
# ------------------------------------------------------------------
# Context manager
# ------------------------------------------------------------------
async def __aenter__(self) -> "TionService":
await self.start()
return self
async def __aexit__(
self,
exc_type,
exc_value,
traceback,
) -> None:
await self.stop()
+26
View File
@@ -0,0 +1,26 @@
version: 1
check_interval: 5.0
co2:
base_speed: 1
hysteresis: 100
thresholds:
- ppm: 700
speed: 2
- ppm: 900
speed: 3
- ppm: 1300
speed: 4
- ppm: 1600
speed: 5
- ppm: 2000
speed: 6
temperature:
hysteresis: 0.5
+76
View File
@@ -0,0 +1,76 @@
version: 1
enabled: true
timezone: local
templates:
workday:
- time: "09:00"
action:
power: on
type: set
speed: 6
heater: on
target_temp: 21
- time: "09:30"
action:
type: auto
speed: 2
target_temp: 25
- time: "22:00"
action:
type: set
speed: 6
- time: "22:45"
action:
type: set
speed: 1
weekend:
- time: "10:00"
action:
power: on
type: set
speed: 6
heater: on
target_temp: 21
- time: "10:30"
action:
type: auto
speed: 2
target_temp: 25
- time: "22:00"
action:
type: set
speed: 6
- time: "22:45"
action:
type: set
speed: 1
days:
mon: workday
tue: workday
wed: workday
thu: workday
fri: workday
sat: weekend
sun: weekend
+6
View File
@@ -0,0 +1,6 @@
tion-btle==3.3.6
fastapi
uvicorn[standard]
PyYAML
paho-mqtt>=2.1,<3
paho-mqtt
+25
View File
@@ -0,0 +1,25 @@
from .loader import load_schedule
from .models import (
ScheduleAction,
ScheduleActionType,
ScheduleConfig,
ScheduleOccurrence,
SchedulePoint,
ScheduleResolution,
ScheduledSettings,
)
from .service import ScheduleService
__all__ = [
"load_schedule",
"ScheduleAction",
"ScheduleActionType",
"ScheduleConfig",
"ScheduleOccurrence",
"SchedulePoint",
"ScheduleResolution",
"ScheduledSettings",
"ScheduleService",
]
+419
View File
@@ -0,0 +1,419 @@
from datetime import time
from pathlib import Path
from typing import Any
import yaml
from app.my_dataclasses import (
MIN_FAN_SPEED,
MAX_FAN_SPEED,
MIN_TARGET_TEMP,
MAX_TARGET_TEMP,
SUPPORTED_AIR_MODES,
)
from .models import (
ScheduleAction,
ScheduleActionType,
ScheduleConfig,
SchedulePoint,
ScheduledSettings,
)
WEEKDAYS = (
"mon",
"tue",
"wed",
"thu",
"fri",
"sat",
"sun",
)
SET_FIELDS = {
"power",
"speed",
"heater",
"target_temp",
"mode",
"sound",
"light",
}
def _parse_time(value: Any) -> time:
if not isinstance(value, str):
raise ValueError(
f"Schedule time must be a string: {value!r}"
)
try:
parsed = time.fromisoformat(value)
except ValueError as exc:
raise ValueError(
f"Invalid schedule time: {value!r}"
) from exc
# Не разрешаем секунды.
if parsed.second != 0 or parsed.microsecond != 0:
raise ValueError(
f"Schedule time must use HH:MM: {value!r}"
)
return parsed
def _parse_on_off(
name: str,
value: Any,
) -> bool:
# PyYAML может автоматически превратить
# on/off в True/False.
if type(value) is bool:
return value
if isinstance(value, str):
normalized = value.strip().lower()
if normalized == "on":
return True
if normalized == "off":
return False
raise ValueError(
f"{name} must be 'on' or 'off'"
)
def _parse_settings(
data: dict[str, Any],
) -> ScheduledSettings:
unknown = set(data) - SET_FIELDS
if unknown:
raise ValueError(
f"Unknown SET fields: {sorted(unknown)}"
)
values: dict[str, Any] = {}
if "power" in data:
values["power"] = _parse_on_off(
"power",
data["power"],
)
if "speed" in data:
speed = data["speed"]
if type(speed) is not int:
raise ValueError(
"speed must be an integer"
)
if not MIN_FAN_SPEED <= speed <= MAX_FAN_SPEED:
raise ValueError(
f"speed must be between "
f"{MIN_FAN_SPEED} and {MAX_FAN_SPEED}"
)
values["speed"] = speed
if "heater" in data:
values["heater"] = _parse_on_off(
"heater",
data["heater"],
)
if "target_temp" in data:
temperature = data["target_temp"]
if type(temperature) is not int:
raise ValueError(
"target_temp must be an integer"
)
if not (
MIN_TARGET_TEMP
<= temperature
<= MAX_TARGET_TEMP
):
raise ValueError(
f"target_temp must be between "
f"{MIN_TARGET_TEMP} and "
f"{MAX_TARGET_TEMP}"
)
values["target_temp"] = temperature
if "mode" in data:
mode = data["mode"]
if mode not in SUPPORTED_AIR_MODES:
raise ValueError(
f"Unsupported air mode: {mode!r}"
)
values["mode"] = mode
if "sound" in data:
values["sound"] = _parse_on_off(
"sound",
data["sound"],
)
if "light" in data:
values["light"] = _parse_on_off(
"light",
data["light"],
)
settings = ScheduledSettings(**values)
if settings.empty:
raise ValueError(
"SET action must contain "
"at least one setting"
)
return settings
def _parse_action(
data: Any,
) -> ScheduleAction:
if not isinstance(data, dict):
raise ValueError(
"Schedule action must be an object"
)
action_type_raw = data.get("type")
try:
action_type = ScheduleActionType(action_type_raw)
except ValueError as exc:
raise ValueError(
f"Unsupported action type: "
f"{action_type_raw!r}"
) from exc
if action_type == ScheduleActionType.AUTO:
allowed_fields = {
"type",
"speed",
"target_temp",
}
extra = set(data) - allowed_fields
if extra:
raise ValueError(
"AUTO action supports only "
"'speed' and 'target_temp': "
f"{sorted(extra)}"
)
if "speed" not in data:
raise ValueError(
"AUTO action requires 'speed'"
)
settings_data = {
"speed": data["speed"],
}
if "target_temp" in data:
settings_data["target_temp"] = (
data["target_temp"]
)
return ScheduleAction(
type=ScheduleActionType.AUTO,
settings=_parse_settings(settings_data),
)
settings_data = {
key: value
for key, value in data.items()
if key != "type"
}
return ScheduleAction(
type=ScheduleActionType.SET,
settings=_parse_settings(
settings_data
),
)
def _parse_template(
name: str,
data: Any,
) -> tuple[SchedulePoint, ...]:
if not isinstance(data, list):
raise ValueError(
f"Template {name!r} must be a list"
)
if not data:
raise ValueError(
f"Template {name!r} cannot be empty"
)
points: list[SchedulePoint] = []
for item in data:
if not isinstance(item, dict):
raise ValueError(
f"Invalid point in template {name!r}"
)
if "time" not in item:
raise ValueError(
f"Point in {name!r} has no time"
)
if "action" not in item:
raise ValueError(
f"Point in {name!r} has no action"
)
points.append(
SchedulePoint(
at=_parse_time(item["time"]),
action=_parse_action(
item["action"]
),
)
)
points.sort(
key=lambda point: point.at
)
seen_times = set()
for point in points:
if point.at in seen_times:
raise ValueError(
f"Duplicate time {point.at} "
f"in template {name!r}"
)
seen_times.add(point.at)
return tuple(points)
def load_schedule(
path: str | Path,
) -> ScheduleConfig:
path = Path(path)
with path.open(
"r",
encoding="utf-8",
) as file:
raw = yaml.safe_load(file)
if not isinstance(raw, dict):
raise ValueError(
"Schedule root must be an object"
)
version = raw.get("version", 1)
if version != 1:
raise ValueError(
f"Unsupported schedule version: {version}"
)
enabled = raw.get("enabled", True)
if type(enabled) is not bool:
raise ValueError(
"enabled must be true or false"
)
timezone = raw.get(
"timezone",
"local",
)
if timezone != "local":
raise ValueError(
"Only timezone: local "
"is currently supported"
)
raw_templates = raw.get("templates")
if not isinstance(raw_templates, dict):
raise ValueError(
"templates must be an object"
)
templates = {
name: _parse_template(
name,
template,
)
for name, template
in raw_templates.items()
}
raw_days = raw.get("days")
if not isinstance(raw_days, dict):
raise ValueError(
"days must be an object"
)
days: dict[str, str] = {}
for weekday in WEEKDAYS:
if weekday not in raw_days:
raise ValueError(
f"Missing schedule day: {weekday}"
)
template_name = raw_days[weekday]
if template_name not in templates:
raise ValueError(
f"Unknown template "
f"{template_name!r} "
f"for {weekday}"
)
days[weekday] = template_name
unknown_days = (
set(raw_days)
- set(WEEKDAYS)
)
if unknown_days:
raise ValueError(
f"Unknown weekdays: "
f"{sorted(unknown_days)}"
)
return ScheduleConfig(
version=version,
enabled=enabled,
timezone=timezone,
templates=templates,
days=days,
)
+121
View File
@@ -0,0 +1,121 @@
from dataclasses import dataclass, field, fields
from datetime import datetime, time
from enum import StrEnum
from typing import Any
class ScheduleActionType(StrEnum):
SET = "set"
AUTO = "auto"
@dataclass(frozen=True, slots=True)
class ScheduledSettings:
power: bool | None = None
speed: int | None = None
heater: bool | None = None
target_temp: int | None = None
mode: str | None = None
sound: bool | None = None
light: bool | None = None
def merged(self, newer: "ScheduledSettings") -> "ScheduledSettings":
"""
Наложить более новые настройки поверх старых.
None означает:
параметр в данной точке расписания не менялся.
"""
values: dict[str, Any] = {}
for item in fields(self):
old_value = getattr(self, item.name)
new_value = getattr(newer, item.name)
values[item.name] = (
old_value
if new_value is None
else new_value
)
return ScheduledSettings(**values)
@property
def empty(self) -> bool:
return all(
getattr(self, item.name) is None
for item in fields(self)
)
def to_dict(
self,
*,
skip_none: bool = True,
) -> dict:
result = {}
for item in fields(self):
value = getattr(self, item.name)
if skip_none and value is None:
continue
result[item.name] = value
return result
@dataclass(frozen=True, slots=True)
class ScheduleAction:
type: ScheduleActionType
settings: ScheduledSettings = field(
default_factory=ScheduledSettings
)
@dataclass(frozen=True, slots=True)
class SchedulePoint:
at: time
action: ScheduleAction
@dataclass(frozen=True, slots=True)
class ScheduleConfig:
version: int
enabled: bool
timezone: str
templates: dict[
str,
tuple[SchedulePoint, ...]
]
days: dict[str, str]
@dataclass(frozen=True, slots=True)
class ScheduleOccurrence:
"""
Конкретная точка расписания уже с датой.
"""
when: datetime
weekday: str
template: str
point: SchedulePoint
@dataclass(frozen=True, slots=True)
class ScheduleResolution:
enabled: bool
current: ScheduleOccurrence | None
next: ScheduleOccurrence | None
scheduled_settings: ScheduledSettings
auto_active: bool
auto_fallback_speed: int | None
auto_target_temp: int | None = None
+629
View File
@@ -0,0 +1,629 @@
import asyncio
import json
from pathlib import Path
from app.tion.service import TionService
from datetime import (
date,
datetime,
timedelta,
)
from .models import (
ScheduleActionType,
ScheduleConfig,
ScheduleOccurrence,
ScheduleResolution,
ScheduledSettings,
)
from dataclasses import replace
WEEKDAYS = (
"mon",
"tue",
"wed",
"thu",
"fri",
"sat",
"sun",
)
class ScheduleService:
def __init__(
self,
config,
tion: TionService | None = None,
check_interval: float = 5.0,
state_path: str | Path | None = None,
):
self._config = config
self._tion = tion
self._check_interval = check_interval
self._running = False
self._task = None
# Последняя успешно применённая точка расписания.
self._last_applied_when: datetime | None = None
# Temporary override.
self._override_settings = ScheduledSettings()
self._override_until: datetime | None = None
# True означает:
# override записан, но применить его к Tion
# пока не удалось.
self._override_pending = False
# Ошибка именно ScheduleService.
self._last_error: str | None = None
self._paused = False
self._state_path = (
Path(state_path)
if state_path is not None
else None
)
self._load_runtime_state()
@property
def paused(self) -> bool:
return self._paused
@property
def running(self) -> bool:
return self._running
@property
def last_error(self) -> str | None:
return self._last_error
@property
def override_settings(self) -> ScheduledSettings:
return self._override_settings
@property
def override_until(self) -> datetime | None:
return self._override_until
@property
def override_active(self) -> bool:
if self._override_until is None:
return False
return datetime.now() < self._override_until
@property
def config(self) -> ScheduleConfig:
return self._config
def _load_runtime_state(self) -> None:
if self._state_path is None:
return
if not self._state_path.exists():
return
try:
data = json.loads(
self._state_path.read_text(
encoding="utf-8"
)
)
except (
OSError,
json.JSONDecodeError,
):
return
paused = data.get("paused")
if isinstance(paused, bool):
self._paused = paused
def _save_runtime_state(self) -> None:
if self._state_path is None:
return
data = {
"paused": self._paused,
}
self._state_path.parent.mkdir(
parents=True,
exist_ok=True,
)
temp_path = (
self._state_path.with_suffix(
self._state_path.suffix + ".tmp"
)
)
temp_path.write_text(
json.dumps(
data,
indent=2,
),
encoding="utf-8",
)
temp_path.replace(
self._state_path
)
async def apply_override(self, settings: ScheduledSettings) -> None:
if settings.empty:
raise ValueError(
"Override settings cannot be empty"
)
if self._tion is None:
raise RuntimeError(
"TionService is required"
)
now = datetime.now()
resolution = self.resolve(now)
if not resolution.enabled:
raise RuntimeError(
"Schedule is disabled"
)
if resolution.current is None:
raise RuntimeError(
"Current schedule point not found"
)
if resolution.next is None:
raise RuntimeError(
"Next schedule point not found"
)
# Если старый override уже закончился,
# перед созданием нового очищаем его.
if ( self._override_until is not None
and now >= self._override_until
):
self._clear_override()
# Добавляем новые ручные параметры
# к уже существующему override.
self._override_settings = ( self._override_settings.merged(settings) )
# Override действует строго до
# следующей точки расписания.
self._override_until = resolution.next.when
# Считаем его неприменённым,
# пока команда реально не прошла.
self._override_pending = True
try:
await self._apply_settings(settings)
except Exception as exc:
self._last_error = (
f"{type(exc).__name__}: {exc}"
)
# Override остаётся сохранённым.
# Фоновый цикл попробует ещё раз.
raise
else:
self._override_pending = False
self._last_error = None
def _clear_override(self) -> None:
self._override_settings = ScheduledSettings()
self._override_until = None
self._override_pending = False
async def clear_override(self) -> None:
self._clear_override()
# Сразу пересчитываем и применяем текущее состояние расписания.
await self._process()
async def pause(self) -> None:
if self._paused:
return
# Сначала запрещаем расписанию вмешиваться.
self._paused = True
# Temporary override больше не имеет смысла:
# мы переходим в полноценный ручной режим.
self._clear_override()
self._last_applied_when = None
self._save_runtime_state()
async def resume(self) -> None:
if not self._paused:
return
self._paused = False
self._save_runtime_state()
# Сразу применяем состояние расписания,
# актуальное именно сейчас.
await self._process()
def resolve(
self,
now: datetime | None = None,
) -> ScheduleResolution:
"""
Определить:
- текущую точку;
- следующую точку;
- накопленные SET-настройки;
- активен ли сейчас AUTO.
"""
if now is None:
now = datetime.now()
if not self._config.enabled:
return ScheduleResolution(
enabled=False,
current=None,
next=None,
scheduled_settings=ScheduledSettings(),
auto_active=False,
)
# Недели назад достаточно,
# поскольку расписание повторяется каждые 7 дней.
start_date = now.date() - timedelta(days=7)
end_date = now.date() + timedelta(days=7)
occurrences = self._build_occurrences(
start_date,
end_date,
)
current = None
next_point = None
for occurrence in occurrences:
if occurrence.when <= now:
current = occurrence
continue
next_point = occurrence
break
if current is None:
raise RuntimeError(
"Could not determine current "
"schedule point"
)
scheduled_settings = (
self._calculate_settings(
occurrences,
current,
)
)
auto_active = current.point.action.type == ScheduleActionType.AUTO
auto_fallback_speed = None
auto_target_temp = None
if auto_active:
auto_fallback_speed = current.point.action.settings.speed
auto_target_temp = current.point.action.settings.target_temp
return ScheduleResolution(
enabled=True,
current=current,
next=next_point,
scheduled_settings=scheduled_settings,
auto_active=auto_active,
auto_fallback_speed=auto_fallback_speed,
auto_target_temp=auto_target_temp,
)
def _build_occurrences(
self,
start_date: date,
end_date: date,
) -> list[ScheduleOccurrence]:
result: list[ScheduleOccurrence] = []
current_date = start_date
while current_date <= end_date:
weekday = WEEKDAYS[
current_date.weekday()
]
template_name = (
self._config.days[weekday]
)
template = (
self._config.templates[
template_name
]
)
for point in template:
when = datetime.combine(
current_date,
point.at,
)
result.append(
ScheduleOccurrence(
when=when,
weekday=weekday,
template=template_name,
point=point,
)
)
current_date += timedelta(days=1)
result.sort(
key=lambda occurrence:
occurrence.when
)
return result
def _calculate_settings(
self,
occurrences: list[
ScheduleOccurrence
],
current: ScheduleOccurrence,
) -> ScheduledSettings:
"""
Восстановить последние SET-значения
каждого параметра расписания.
"""
settings = ScheduledSettings()
for occurrence in occurrences:
if occurrence.when > current.when:
break
action = occurrence.point.action
if (
action.type
!= ScheduleActionType.SET
):
continue
settings = settings.merged(
action.settings
)
return settings
async def start(self) -> None:
if self._running:
return
if self._tion is None:
raise RuntimeError("TionService is required to start schedule")
self._running = True
# При запуске сразу приводим Tion
# к текущему состоянию расписания.
try:
await self._process()
except Exception as exc:
self._last_error = f"{type(exc).__name__}: {exc}"
self._task = asyncio.create_task(self._loop())
async def stop(self) -> None:
if not self._running:
return
self._running = False
if self._task is not None:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
async def _loop(self) -> None:
while self._running:
await asyncio.sleep(
self._check_interval
)
try:
await self._process()
except Exception as exc:
self._last_error = f"{type(exc).__name__}: {exc}"
async def _process(self) -> None:
#Если ручное управление то сразу выходим
if self._paused:
return
now = datetime.now()
resolution = self.resolve(now)
if not resolution.enabled:
return
current = resolution.current
if current is None:
return
# --------------------------------------------------
# 1. Проверяем окончание temporary override
# --------------------------------------------------
if (
self._override_until is not None
and now >= self._override_until
):
self._clear_override()
# --------------------------------------------------
# 2. Пока override действует,
# расписание Tion не трогает
# --------------------------------------------------
if self.override_active:
# Если override ранее не удалось применить,
# пробуем снова.
if self._override_pending:
await self._apply_settings(self._override_settings)
self._override_pending = False
self._last_error = None
return
# --------------------------------------------------
# 3. Если текущая точка уже успешно применена,
# ничего не делаем
# --------------------------------------------------
if self._last_applied_when == current.when:
return
# --------------------------------------------------
# 4. Применяем состояние расписания
# --------------------------------------------------
settings = resolution.scheduled_settings
if resolution.auto_active:
# В режиме AUTO скорость принадлежит
# AutoController.
#
# Остальные параметры расписания
# (power, heater, temperature, mode...)
# должны продолжать работать.
settings = replace(
settings,
speed=None,
heater=None,
target_temp=None,
)
await self._apply_settings(settings)
# Ставим только ПОСЛЕ успешного применения.
self._last_applied_when = current.when
self._last_error = None
async def _apply_settings(self, settings: ScheduledSettings) -> None:
if self._tion is None:
return
# Если нужно включить Tion —
# сначала включаем.
if settings.power is True:
await self._tion.execute(
lambda tion: tion.power_on()
)
if settings.mode is not None:
await self._tion.execute(
lambda tion:
tion.set_air_mode(settings.mode)
)
if settings.target_temp is not None:
await self._tion.execute(
lambda tion:
tion.set_target_temperature(
settings.target_temp
)
)
if settings.heater is not None:
if settings.heater:
await self._tion.execute(
lambda tion: tion.heater_on()
)
else:
await self._tion.execute(
lambda tion: tion.heater_off()
)
if settings.speed is not None:
await self._tion.execute(
lambda tion:
tion.set_speed(settings.speed)
)
if settings.sound is not None:
if settings.sound:
await self._tion.execute(
lambda tion: tion.sound_on()
)
else:
await self._tion.execute(
lambda tion: tion.sound_off()
)
if settings.light is not None:
if settings.light:
await self._tion.execute(
lambda tion: tion.light_on()
)
else:
await self._tion.execute(
lambda tion: tion.light_off()
)
# Если нужно выключить —
# выключаем последним.
if settings.power is False:
await self._tion.execute(
lambda tion: tion.power_off()
)
View File
+37
View File
@@ -0,0 +1,37 @@
import asyncio
import logging
from app.my_dataclasses import TION_MAC
from app.tion import TionController, TionService
# Убираем служебное логирование библиотек
async def main():
logging.getLogger("asyncio").setLevel(logging.WARNING)
logging.getLogger("bleak").setLevel(logging.WARNING)
logging.getLogger("tion_btle").setLevel(logging.WARNING)
controller = TionController(TION_MAC)
service = TionService(
controller,
poll_interval=3,
)
async with service:
for seconds in range(0, 91, 3):
print(
f"{seconds:02d}s | "
f"online={service.online} | "
f"last_seen={service.last_seen} | "
f"error={service.last_error}"
)
await asyncio.sleep(3)
if __name__ == "__main__":
asyncio.run(main())
+30
View File
@@ -0,0 +1,30 @@
import asyncio
import logging
from bleak import BleakScanner
from app.my_dataclasses import TION_MAC
logging.disable(logging.CRITICAL)
async def main():
print("Ищу Tion...")
device = await BleakScanner.find_device_by_address(
TION_MAC,
timeout=10,
)
if device is None:
print("Tion НЕ найден сканером")
else:
print("Tion найден:")
print(f" name: {device.name}")
print(f" address: {device.address}")
print(f" details: {device.details}")
if __name__ == "__main__":
asyncio.run(main())
+59
View File
@@ -0,0 +1,59 @@
#d1749bebeea6
import asyncio
import json
import logging
from tion_btle import TionS4
from app.my_dataclasses import *
def print_state(state: dict):
print(json.dumps(
state,
indent=2,
ensure_ascii=False
))
async def main():
# Убираем лишнее логирование
logging.getLogger("asyncio").setLevel(logging.WARNING)
logging.getLogger("bleak").setLevel(logging.WARNING)
logging.getLogger("tion_btle").setLevel(logging.WARNING)
tion = TionS4(TION_MAC)
print("=== Подключение к Tion ===")
await tion.connect()
try:
# 1. Получаем исходное состояние
print("\n=== Исходное состояние ===")
state = await tion.get()
print_state(state)
# 2. Включаем Tion и устанавливаем скорость 2
print("\n=== Включаю Tion, скорость 2 ===")
await tion.set({
"state": "on",
"fan_speed": 2
})
await asyncio.sleep(1)
# 3. Повторно читаем реальное состояние
print("\n=== Состояние после команды ===")
state = await tion.get()
print_state(state)
finally:
print("\n=== Отключение ===")
await tion.disconnect()
if __name__ == "__main__":
asyncio.run(main())
+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())
+154
View File
@@ -0,0 +1,154 @@
import asyncio
from app.auto.controller import AutoController
class FakeTionController:
def __init__(self):
self.calls = []
async def heater_on(self):
self.calls.append(
("heater_on", None)
)
async def heater_off(self):
self.calls.append(
("heater_off", None)
)
class FakeTionService:
def __init__(self):
self.controller = (
FakeTionController()
)
async def execute(
self,
operation,
):
return await operation(
self.controller
)
def make_controller():
tion = FakeTionService()
controller = AutoController(
schedule_service=None,
qingping_service=None,
tion_service=tion,
)
return controller, tion
async def test_heater_on():
controller, tion = (
make_controller()
)
await controller._set_heater(
True
)
assert tion.controller.calls == [
("heater_on", None)
]
assert (
controller._target_heater
is True
)
async def test_duplicate_heater_on():
controller, tion = (
make_controller()
)
await controller._set_heater(
True
)
await controller._set_heater(
True
)
assert tion.controller.calls == [
("heater_on", None)
]
async def test_heater_off_after_on():
controller, tion = (
make_controller()
)
await controller._set_heater(
True
)
await controller._set_heater(
False
)
assert tion.controller.calls == [
("heater_on", None),
("heater_off", None),
]
assert (
controller._target_heater
is False
)
async def test_duplicate_heater_off():
controller, tion = (
make_controller()
)
await controller._set_heater(
False
)
await controller._set_heater(
False
)
assert tion.controller.calls == [
("heater_off", None)
]
async def main():
await test_heater_on()
await test_duplicate_heater_on()
await test_heater_off_after_on()
await test_duplicate_heater_off()
print()
print("=" * 70)
print(
"ALL AUTO HEATER COMMAND "
"TESTS PASSED"
)
print("=" * 70)
if __name__ == "__main__":
asyncio.run(main())
+304
View File
@@ -0,0 +1,304 @@
import asyncio
from types import SimpleNamespace
from app.auto.config import (
TemperatureConfig,
)
from app.auto.controller import (
AutoController,
)
from app.auto.temperature_policy import (
TemperaturePolicy,
)
class FakeQingpingService:
def __init__(self):
self.online = True
self.state = SimpleNamespace(
temperature=19.4,
)
class FakeTionController:
def __init__(self):
self.calls = []
async def heater_on(self):
self.calls.append(
("heater_on", None)
)
async def heater_off(self):
self.calls.append(
("heater_off", None)
)
class FakeTionService:
def __init__(self):
self.online = True
self.state = SimpleNamespace(
in_temp=18,
)
self.controller = (
FakeTionController()
)
async def execute(
self,
operation,
):
return await operation(
self.controller
)
def make_resolution(
target_temp=20.0,
):
return SimpleNamespace(
scheduled_settings=(
SimpleNamespace(
target_temp=target_temp,
)
)
)
def make_controller():
qingping = (
FakeQingpingService()
)
tion = FakeTionService()
policy = TemperaturePolicy(
TemperatureConfig(
hysteresis=0.5,
)
)
controller = AutoController(
schedule_service=None,
qingping_service=qingping,
tion_service=tion,
temperature_policy=policy,
)
return (
controller,
qingping,
tion,
)
async def test_qingping_heating():
controller, qingping, tion = (
make_controller()
)
resolution = make_resolution()
# 19.4 < 19.5
# Heater должен включиться.
await controller._process_heater(
resolution
)
assert tion.controller.calls == [
("heater_on", None)
]
assert (
controller._auto_heater
is True
)
assert (
controller._temperature
== 19.4
)
assert (
controller._temperature_source
== "qingping"
)
tion.controller.calls.clear()
# ----------------------------------------------
# 19.8
#
# Heater уже ON.
# До 20 градусов ещё не дошли.
# Продолжаем греть.
#
# Новую команду отправлять не нужно.
# ----------------------------------------------
qingping.state.temperature = 19.8
await controller._process_heater(
resolution
)
assert tion.controller.calls == []
assert (
controller._auto_heater
is True
)
# ----------------------------------------------
# 20.0
#
# Цель достигнута.
# ----------------------------------------------
qingping.state.temperature = 20.0
await controller._process_heater(
resolution
)
assert tion.controller.calls == [
("heater_off", None)
]
assert (
controller._auto_heater
is False
)
async def test_tion_temperature_fallback():
controller, qingping, tion = (
make_controller()
)
resolution = make_resolution()
# Qingping отключился.
qingping.online = False
# Используем Tion.
tion.state.in_temp = 19
await controller._process_heater(
resolution
)
assert tion.controller.calls == [
("heater_on", None)
]
assert (
controller._temperature
== 19
)
assert (
controller._temperature_source
== "tion"
)
assert (
controller._auto_heater
is True
)
async def test_no_temperature():
controller, qingping, tion = (
make_controller()
)
resolution = make_resolution()
qingping.online = False
tion.online = False
await controller._process_heater(
resolution
)
assert tion.controller.calls == [
("heater_off", None)
]
assert (
controller._temperature
is None
)
assert (
controller._temperature_source
is None
)
assert (
controller._auto_heater
is False
)
async def test_target_temperature_missing():
controller, qingping, tion = (
make_controller()
)
resolution = make_resolution(
target_temp=None
)
await controller._process_heater(
resolution
)
assert tion.controller.calls == [
("heater_off", None)
]
assert (
controller._auto_heater
is False
)
async def main():
await test_qingping_heating()
await test_tion_temperature_fallback()
await test_no_temperature()
await test_target_temperature_missing()
print()
print("=" * 70)
print(
"ALL AUTO HEATER PROCESS "
"TESTS PASSED"
)
print("=" * 70)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,138 @@
import asyncio
from types import SimpleNamespace
from app.auto.config import (
TemperatureConfig,
)
from app.auto.controller import (
AutoController,
)
from app.auto.temperature_policy import (
TemperaturePolicy,
)
class FakeQingpingService:
def __init__(self):
self.online = True
self.state = SimpleNamespace(
temperature=19.5,
)
class FakeTionController:
def __init__(self):
self.calls = []
async def heater_on(self):
self.calls.append(
("heater_on", None)
)
async def heater_off(self):
self.calls.append(
("heater_off", None)
)
class FakeTionService:
def __init__(self):
self.online = True
self.state = SimpleNamespace(
in_temp=19,
)
self.controller = (
FakeTionController()
)
async def execute(
self,
operation,
):
return await operation(
self.controller
)
async def test_auto_uses_auto_target_temperature():
qingping = FakeQingpingService()
tion = FakeTionService()
policy = TemperaturePolicy(
TemperatureConfig(
hysteresis=0.5,
)
)
controller = AutoController(
schedule_service=None,
qingping_service=qingping,
tion_service=tion,
temperature_policy=policy,
)
resolution = SimpleNamespace(
# Последний SET хотел 23°C.
scheduled_settings=SimpleNamespace(
target_temp=23,
),
# Но текущий AUTO явно хочет 20°C.
auto_target_temp=20,
)
await controller._process_heater(
resolution
)
# При 19.5°C и target=20°C:
#
# heater был OFF,
# нижняя граница = 19.5°C.
#
# Поэтому heater должен остаться OFF.
#
# Если бы контроллер ошибочно использовал
# SET target_temp=23, он бы включил heater.
assert tion.controller.calls == [
("heater_off", None)
]
assert (
controller._auto_heater
is False
)
assert (
controller._temperature
== 19.5
)
assert (
controller._temperature_source
== "qingping"
)
async def main():
await test_auto_uses_auto_target_temperature()
print()
print("=" * 70)
print(
"ALL AUTO HEATER TARGET "
"TEMPERATURE TESTS PASSED"
)
print("=" * 70)
if __name__ == "__main__":
asyncio.run(main())
+245
View File
@@ -0,0 +1,245 @@
import asyncio
from types import SimpleNamespace
from app.auto.config import TemperatureConfig
from app.auto.controller import AutoController
from app.auto.temperature_policy import TemperaturePolicy
from app.my_dataclasses import MAX_TARGET_TEMP
class FakeQingpingService:
def __init__(self):
self.online = True
self.state = SimpleNamespace(
temperature=19.0,
)
class FakeTionController:
def __init__(self):
self.calls = []
async def set_target_temperature(
self,
temperature: int,
):
self.calls.append(
(
"set_target_temperature",
temperature,
)
)
async def heater_on(self):
self.calls.append(
(
"heater_on",
None,
)
)
async def heater_off(self):
self.calls.append(
(
"heater_off",
None,
)
)
class FakeTionService:
def __init__(self):
self.online = True
self.state = SimpleNamespace(
in_temp=22,
)
self.controller = FakeTionController()
async def execute(
self,
operation,
):
return await operation(
self.controller
)
def make_controller():
qingping = FakeQingpingService()
tion = FakeTionService()
temperature_policy = TemperaturePolicy(
TemperatureConfig(
hysteresis=0.5,
)
)
controller = AutoController(
schedule_service=None,
qingping_service=qingping,
tion_service=tion,
temperature_policy=temperature_policy,
)
return (
controller,
qingping,
tion,
)
def make_resolution(
target_temp: int,
):
return SimpleNamespace(
auto_target_temp=target_temp,
)
async def test_heating_sets_tion_target_first():
controller, qingping, tion = (
make_controller()
)
resolution = make_resolution(
target_temp=20
)
# Qingping = 19.0
# AUTO target = 20
#
# Нужно греть.
#
# Сначала Tion.target_temp поднимается
# до технического максимума,
# затем включается heater.
await controller._process_heater(
resolution
)
assert tion.controller.calls == [
(
"set_target_temperature",
MAX_TARGET_TEMP,
),
(
"heater_on",
None,
),
]
assert (
controller._target_temperature
== MAX_TARGET_TEMP
)
assert (
controller._target_heater
is True
)
assert (
controller._auto_heater
is True
)
async def test_repeated_heating_does_not_repeat_commands():
controller, qingping, tion = (
make_controller()
)
resolution = make_resolution(
target_temp=20
)
await controller._process_heater(
resolution
)
tion.controller.calls.clear()
# Температура всё ещё ниже цели.
# AUTO продолжает хотеть нагрев,
# но повторно слать команды Tion не нужно.
qingping.state.temperature = 19.2
await controller._process_heater(
resolution
)
assert tion.controller.calls == []
async def test_target_reached_turns_heater_off():
controller, qingping, tion = (
make_controller()
)
resolution = make_resolution(
target_temp=20
)
# Сначала включаем нагрев.
await controller._process_heater(
resolution
)
tion.controller.calls.clear()
# Цель достигнута.
qingping.state.temperature = 20.0
await controller._process_heater(
resolution
)
# MAX_TARGET_TEMP обратно сейчас не меняем.
# Достаточно запретить нагрев.
assert tion.controller.calls == [
(
"heater_off",
None,
)
]
assert (
controller._auto_heater
is False
)
assert (
controller._target_heater
is False
)
async def main():
await test_heating_sets_tion_target_first()
await test_repeated_heating_does_not_repeat_commands()
await test_target_reached_turns_heater_off()
print()
print("=" * 70)
print(
"ALL AUTO HEATER TION TARGET "
"TESTS PASSED"
)
print("=" * 70)
if __name__ == "__main__":
asyncio.run(main())
+181
View File
@@ -0,0 +1,181 @@
import asyncio
from types import SimpleNamespace
from app.auto.controller import AutoController
class FakeScheduleService:
def __init__(self):
self.paused = True
self.override_active = False
def resolve(self, now):
return SimpleNamespace(
enabled=True,
auto_active=True,
auto_fallback_speed=1,
auto_target_temp=20,
)
class FakeQingpingService:
def __init__(self):
self.online = True
self.state = SimpleNamespace(
co2=1200,
temperature=19.0,
)
class FakeTionController:
def __init__(self):
self.calls = []
async def set_speed(self, speed):
self.calls.append(
("set_speed", speed)
)
async def heater_on(self):
self.calls.append(
("heater_on", None)
)
async def heater_off(self):
self.calls.append(
("heater_off", None)
)
async def set_target_temperature(
self,
temperature,
):
self.calls.append(
(
"set_target_temperature",
temperature,
)
)
class FakeTionService:
def __init__(self):
self.online = True
self.state = SimpleNamespace(
in_temp=19,
)
self.controller = FakeTionController()
async def execute(
self,
operation,
):
return await operation(
self.controller
)
async def test_paused_schedule_suspends_auto():
schedule = FakeScheduleService()
qingping = FakeQingpingService()
tion = FakeTionService()
controller = AutoController(
schedule_service=schedule,
qingping_service=qingping,
tion_service=tion,
)
# Имитируем состояние,
# которое AUTO помнил до pause.
controller._target_speed = 3
controller._auto_speed = 3
controller._target_heater = True
controller._auto_heater = True
controller._target_temperature = 30
controller._temperature = 19.0
controller._temperature_source = (
"qingping"
)
await controller._process()
status = controller.status()
# AUTO должен быть остановлен.
assert (
status["state"]
== "suspended"
)
assert (
status["reason"]
== "schedule_paused"
)
# Никаких команд Tion.
assert tion.controller.calls == []
# Старое владение AUTO забыто.
assert (
controller._target_speed
is None
)
assert (
controller._auto_speed
is None
)
assert (
controller._target_heater
is None
)
assert (
controller._auto_heater
is None
)
assert (
controller._target_temperature
is None
)
assert (
controller._temperature
is None
)
assert (
controller._temperature_source
is None
)
async def main():
await test_paused_schedule_suspends_auto()
print()
print("=" * 70)
print(
"ALL AUTO SCHEDULE PAUSE "
"TESTS PASSED"
)
print("=" * 70)
if __name__ == "__main__":
asyncio.run(main())
+359
View File
@@ -0,0 +1,359 @@
from pathlib import Path
from tempfile import TemporaryDirectory
from app.auto.config import (
load_auto_config,
)
BASE_CONFIG = """
version: 1
check_interval: 5.0
co2:
base_speed: 1
hysteresis: 100
thresholds:
- ppm: 800
speed: 2
temperature:
hysteresis: 0.5
"""
def load_config(text: str):
temp_dir = TemporaryDirectory()
path = (
Path(temp_dir.name)
/ "auto.yaml"
)
path.write_text(
text,
encoding="utf-8",
)
config = load_auto_config(path)
return temp_dir, config
def test_valid_temperature_config():
temp_dir, config = load_config(
BASE_CONFIG
)
try:
print()
print("=" * 70)
print("VALID TEMPERATURE CONFIG")
print("=" * 70)
print(
"hysteresis:",
config.temperature.hysteresis,
)
assert (
config.temperature.hysteresis
== 0.5
)
finally:
temp_dir.cleanup()
def test_temperature_config_required():
config_text = """
version: 1
check_interval: 5.0
co2:
base_speed: 1
hysteresis: 100
thresholds:
- ppm: 800
speed: 2
"""
try:
temp_dir, _ = load_config(
config_text
)
except ValueError as exc:
print()
print("=" * 70)
print("MISSING TEMPERATURE CONFIG")
print("=" * 70)
print(exc)
assert (
str(exc)
== "temperature config is required"
)
return
else:
temp_dir.cleanup()
raise AssertionError(
"Missing temperature config "
"must raise ValueError"
)
def test_temperature_must_be_mapping():
config_text = """
version: 1
check_interval: 5.0
co2:
base_speed: 1
hysteresis: 100
thresholds:
- ppm: 800
speed: 2
temperature: 0.5
"""
try:
temp_dir, _ = load_config(
config_text
)
except ValueError as exc:
print()
print("=" * 70)
print("TEMPERATURE MUST BE MAPPING")
print("=" * 70)
print(exc)
assert (
str(exc)
== "temperature must be an object"
)
return
else:
temp_dir.cleanup()
raise AssertionError(
"Invalid temperature mapping "
"must raise ValueError"
)
def test_temperature_hysteresis_must_be_number():
config_text = """
version: 1
check_interval: 5.0
co2:
base_speed: 1
hysteresis: 100
thresholds:
- ppm: 800
speed: 2
temperature:
hysteresis: abc
"""
try:
temp_dir, _ = load_config(
config_text
)
except ValueError as exc:
print()
print("=" * 70)
print(
"HYSTERESIS MUST BE NUMBER"
)
print("=" * 70)
print(exc)
assert (
str(exc)
== (
"temperature.hysteresis "
"must be a number"
)
)
return
else:
temp_dir.cleanup()
raise AssertionError(
"Non-numeric hysteresis "
"must raise ValueError"
)
def test_temperature_hysteresis_must_not_be_negative():
config_text = """
version: 1
check_interval: 5.0
co2:
base_speed: 1
hysteresis: 100
thresholds:
- ppm: 800
speed: 2
temperature:
hysteresis: -0.1
"""
try:
temp_dir, _ = load_config(
config_text
)
except ValueError as exc:
print()
print("=" * 70)
print(
"HYSTERESIS MUST NOT BE NEGATIVE"
)
print("=" * 70)
print(exc)
assert (
str(exc)
== (
"temperature.hysteresis "
"must be >= 0"
)
)
return
else:
temp_dir.cleanup()
raise AssertionError(
"Negative hysteresis "
"must raise ValueError"
)
def test_unknown_temperature_field():
config_text = """
version: 1
check_interval: 5.0
co2:
base_speed: 1
hysteresis: 100
thresholds:
- ppm: 800
speed: 2
temperature:
hysteresis: 0.5
something_else: 123
"""
try:
temp_dir, _ = load_config(
config_text
)
except ValueError as exc:
print()
print("=" * 70)
print(
"UNKNOWN TEMPERATURE FIELD"
)
print("=" * 70)
print(exc)
assert (
str(exc)
== (
"Unknown temperature config fields: "
"['something_else']"
)
)
return
else:
temp_dir.cleanup()
raise AssertionError(
"Unknown temperature field "
"must raise ValueError"
)
def test_temperature_is_allowed_top_level_field():
temp_dir, config = load_config(
BASE_CONFIG
)
try:
assert (
config.temperature.hysteresis
== 0.5
)
finally:
temp_dir.cleanup()
def main():
test_valid_temperature_config()
test_temperature_config_required()
test_temperature_must_be_mapping()
test_temperature_hysteresis_must_be_number()
test_temperature_hysteresis_must_not_be_negative()
test_unknown_temperature_field()
test_temperature_is_allowed_top_level_field()
print()
print("=" * 70)
print(
"ALL AUTO TEMPERATURE CONFIG "
"TESTS PASSED"
)
print("=" * 70)
if __name__ == "__main__":
main()
+116
View File
@@ -0,0 +1,116 @@
from app.auto.config import (
TemperatureConfig,
)
from app.auto.temperature_policy import (
TemperaturePolicy,
)
def make_policy() -> TemperaturePolicy:
return TemperaturePolicy(
TemperatureConfig(
hysteresis=0.5,
)
)
def test_heater_off_below_lower_limit():
policy = make_policy()
result = policy.heater_required(
temperature=19.4,
target_temp=20.0,
heater_on=False,
)
assert result is True
def test_heater_off_at_lower_limit():
policy = make_policy()
result = policy.heater_required(
temperature=19.5,
target_temp=20.0,
heater_on=False,
)
assert result is False
def test_heater_off_inside_hysteresis():
policy = make_policy()
result = policy.heater_required(
temperature=19.8,
target_temp=20.0,
heater_on=False,
)
assert result is False
def test_heater_on_below_target():
policy = make_policy()
result = policy.heater_required(
temperature=19.8,
target_temp=20.0,
heater_on=True,
)
assert result is True
def test_heater_on_at_target():
policy = make_policy()
result = policy.heater_required(
temperature=20.0,
target_temp=20.0,
heater_on=True,
)
assert result is False
def test_heater_on_above_target():
policy = make_policy()
result = policy.heater_required(
temperature=20.2,
target_temp=20.0,
heater_on=True,
)
assert result is False
def main():
test_heater_off_below_lower_limit()
test_heater_off_at_lower_limit()
test_heater_off_inside_hysteresis()
test_heater_on_below_target()
test_heater_on_at_target()
test_heater_on_above_target()
print()
print("=" * 70)
print(
"ALL AUTO TEMPERATURE POLICY "
"TESTS PASSED"
)
print("=" * 70)
if __name__ == "__main__":
main()
+169
View File
@@ -0,0 +1,169 @@
from types import SimpleNamespace
from app.auto.controller import AutoController
class FakeQingpingService:
def __init__(
self,
online: bool,
temperature,
):
self.online = online
self.state = SimpleNamespace(
temperature=temperature,
)
class FakeTionService:
def __init__(
self,
online: bool,
in_temp,
):
self.online = online
self.state = SimpleNamespace(
in_temp=in_temp,
)
def make_controller(
*,
qingping_online: bool,
qingping_temperature,
tion_online: bool,
tion_temperature,
):
qingping = FakeQingpingService(
online=qingping_online,
temperature=qingping_temperature,
)
tion = FakeTionService(
online=tion_online,
in_temp=tion_temperature,
)
return AutoController(
schedule_service=None,
qingping_service=qingping,
tion_service=tion,
)
def test_qingping_priority():
controller = make_controller(
qingping_online=True,
qingping_temperature=19.7,
tion_online=True,
tion_temperature=18,
)
temperature, source = (
controller._get_temperature()
)
assert temperature == 19.7
assert source == "qingping"
def test_tion_fallback():
controller = make_controller(
qingping_online=False,
qingping_temperature=19.7,
tion_online=True,
tion_temperature=18,
)
temperature, source = (
controller._get_temperature()
)
assert temperature == 18
assert source == "tion"
def test_tion_fallback_when_qingping_temperature_missing():
controller = make_controller(
qingping_online=True,
qingping_temperature=None,
tion_online=True,
tion_temperature=18,
)
temperature, source = (
controller._get_temperature()
)
assert temperature == 18
assert source == "tion"
def test_offline_tion_is_not_used():
controller = make_controller(
qingping_online=False,
qingping_temperature=None,
tion_online=False,
# Значение специально оставляем.
# Оно имитирует старый state Tion.
tion_temperature=18,
)
temperature, source = (
controller._get_temperature()
)
assert temperature is None
assert source is None
def test_missing_tion_temperature():
controller = make_controller(
qingping_online=False,
qingping_temperature=None,
tion_online=True,
tion_temperature=None,
)
temperature, source = (
controller._get_temperature()
)
assert temperature is None
assert source is None
def main():
test_qingping_priority()
test_tion_fallback()
test_tion_fallback_when_qingping_temperature_missing()
test_offline_tion_is_not_used()
test_missing_tion_temperature()
print()
print("=" * 70)
print(
"ALL AUTO TEMPERATURE SOURCE "
"TESTS PASSED"
)
print("=" * 70)
if __name__ == "__main__":
main()
+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()
+415
View File
@@ -0,0 +1,415 @@
import json
from types import SimpleNamespace
from unittest.mock import patch
import paho.mqtt.client as mqtt
from app.my_dataclasses import (
QINGPING_SAMPLE_TIMEOUT,
QINGPING_RECOVERY_TIMEOUT,
)
from app.qingping.service import QingpingService
class FakeClock:
def __init__(self):
self.now = 0.0
def monotonic(self) -> float:
return self.now
def advance(self, seconds: float) -> None:
self.now += seconds
class FakeMqttClient:
def __init__(self):
self.subscriptions = []
self.published = []
def subscribe(self, topic):
self.subscriptions.append(topic)
def publish(
self,
topic,
payload,
):
self.published.append(
(
topic,
json.loads(payload),
)
)
return SimpleNamespace(
rc=mqtt.MQTT_ERR_SUCCESS
)
class FakeMessage:
def __init__(self, payload: dict):
self.payload = json.dumps(
payload
).encode("utf-8")
def sensor_message(
timestamp: int,
co2: int,
) -> FakeMessage:
return FakeMessage(
{
"type": 17,
"sensorData": [
{
"timestamp": {
"value": timestamp
},
"temperature": {
"value": 25.0
},
"humidity": {
"value": 50.0
},
"co2": {
"value": co2
},
"pm25": {
"value": 1
},
"pm10": {
"value": 1
},
"battery": {
"value": 100
},
}
],
}
)
def service_message() -> FakeMessage:
return FakeMessage(
{
"type": 13,
"wifi_info": (
"TestWiFi,-50,1,"
"00:00:00:00:00:00"
),
"sw_version": "4.8.5",
}
)
def assert_state(
service: QingpingService,
*,
online: bool,
recovery_waiting: bool,
co2,
):
status = service.status()
assert status["online"] is online, status
assert (
status["recovery_waiting"]
is recovery_waiting
), status
assert status["co2"] == co2, status
def test_double_recovery_cycle() -> None:
print()
print("TEST — DOUBLE RECOVERY CYCLE")
clock = FakeClock()
service = QingpingService(
host="127.0.0.1",
)
client = FakeMqttClient()
# _send_recovery() использует self._client.
service._client = client
with patch(
"app.qingping.service.time.monotonic",
clock.monotonic,
):
# --------------------------------------------------
# START
# --------------------------------------------------
print("1. MQTT connect")
service._on_connect(
client,
None,
None,
0,
None,
)
assert len(client.published) == 1
assert_state(
service,
online=False,
recovery_waiting=True,
co2=None,
)
# --------------------------------------------------
# Первый type 17.
# --------------------------------------------------
print("2. First sensor sample")
service._on_message(
None,
None,
sensor_message(
timestamp=1000,
co2=900,
),
)
assert_state(
service,
online=True,
recovery_waiting=False,
co2=900,
)
# --------------------------------------------------
# Первый отказ.
# --------------------------------------------------
print("3. First sensor timeout")
clock.advance(
QINGPING_SAMPLE_TIMEOUT + 1
)
service._watchdog_tick(
clock.monotonic()
)
assert_state(
service,
online=False,
recovery_waiting=False,
co2=900,
)
# Recovery пока НЕ отправлялся.
assert len(client.published) == 1
# --------------------------------------------------
# Устройство снова появляется.
# Любой пакет запускает recovery.
# --------------------------------------------------
print("4. First recovery")
service._on_message(
None,
None,
service_message(),
)
assert len(client.published) == 2
assert_state(
service,
online=True,
recovery_waiting=True,
co2=None,
)
# --------------------------------------------------
# Новый type 17.
# --------------------------------------------------
print("5. First recovery completed")
service._on_message(
None,
None,
sensor_message(
timestamp=1015,
co2=1000,
),
)
assert_state(
service,
online=True,
recovery_waiting=False,
co2=1000,
)
# --------------------------------------------------
# Второй отказ.
# --------------------------------------------------
print("6. Second sensor timeout")
clock.advance(
QINGPING_SAMPLE_TIMEOUT + 1
)
service._watchdog_tick(
clock.monotonic()
)
assert_state(
service,
online=False,
recovery_waiting=False,
co2=1000,
)
assert len(client.published) == 2
# --------------------------------------------------
# Второе восстановление.
# --------------------------------------------------
print("7. Second recovery")
service._on_message(
None,
None,
service_message(),
)
assert len(client.published) == 3
assert_state(
service,
online=True,
recovery_waiting=True,
co2=None,
)
service._on_message(
None,
None,
sensor_message(
timestamp=1030,
co2=1100,
),
)
assert_state(
service,
online=True,
recovery_waiting=False,
co2=1100,
)
print("8. Second recovery completed")
def test_recovery_timeout_and_retry() -> None:
print()
print("TEST — RECOVERY TIMEOUT AND RETRY")
clock = FakeClock()
service = QingpingService(
host="127.0.0.1",
)
client = FakeMqttClient()
service._client = client
with patch(
"app.qingping.service.time.monotonic",
clock.monotonic,
):
# Startup recovery.
service._on_connect(
client,
None,
None,
0,
None,
)
assert len(client.published) == 1
assert_state(
service,
online=False,
recovery_waiting=True,
co2=None,
)
# Type 17 так и не пришёл.
clock.advance(
QINGPING_RECOVERY_TIMEOUT + 1
)
service._watchdog_tick(
clock.monotonic()
)
assert_state(
service,
online=False,
recovery_waiting=False,
co2=None,
)
# Любой следующий пакет должен разрешить
# новую recovery-попытку.
service._on_message(
None,
None,
service_message(),
)
assert len(client.published) == 2
assert_state(
service,
online=True,
recovery_waiting=True,
co2=None,
)
# Теперь приходит type 17.
service._on_message(
None,
None,
sensor_message(
timestamp=2000,
co2=1200,
),
)
assert_state(
service,
online=True,
recovery_waiting=False,
co2=1200,
)
if __name__ == "__main__":
test_double_recovery_cycle()
test_recovery_timeout_and_retry()
print()
print(
"ALL QINGPING RECOVERY TESTS PASSED"
)
+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())
+312
View File
@@ -0,0 +1,312 @@
from datetime import datetime
from schedule import (
ScheduleActionType,
ScheduleService,
load_schedule,
)
SCHEDULE_FILE = "config/schedule.yaml"
def print_result(title, result):
print()
print("=" * 70)
print(title)
print("=" * 70)
print(f"Enabled: {result.enabled}")
if result.current is not None:
print(
f"Current: "
f"{result.current.when} | "
f"{result.current.template} | "
f"{result.current.point.action.type}"
)
else:
print("Current: None")
if result.next is not None:
print(
f"Next: "
f"{result.next.when} | "
f"{result.next.template} | "
f"{result.next.point.action.type}"
)
else:
print("Next: None")
print(f"Auto: {result.auto_active}")
print(f"Settings: {result.scheduled_settings.to_dict()}")
def main():
print("Загрузка schedule.yaml...")
config = load_schedule(SCHEDULE_FILE)
print("YAML успешно загружен и проверен.")
print()
service = ScheduleService(config)
# --------------------------------------------------------------
# TEST 1
# Понедельник 06:30
#
# Текущая точка должна быть 00:00 power off.
# Следующая — 07:00.
# --------------------------------------------------------------
result = service.resolve(
datetime(2026, 9, 14, 6, 30)
)
print_result(
"TEST 1 — Monday 06:30",
result,
)
assert result.current.when.hour == 0
assert result.next.when.hour == 7
assert result.auto_active is False
assert result.scheduled_settings.power is False
# --------------------------------------------------------------
# TEST 2
# Понедельник 07:30
#
# Должны накопиться параметры из точки 07:00.
# --------------------------------------------------------------
result = service.resolve(
datetime(2026, 9, 14, 7, 30)
)
print_result(
"TEST 2 — Monday 07:30",
result,
)
settings = result.scheduled_settings
assert result.current.when.hour == 7
assert result.next.when.hour == 9
assert result.auto_active is False
assert settings.power is True
assert settings.speed == 2
assert settings.heater is True
assert settings.target_temp == 20
assert settings.mode == "outside"
# --------------------------------------------------------------
# TEST 3
# Понедельник 10:00
#
# В 09:00 поменялась только скорость.
#
# Остальные значения должны сохраниться от 07:00.
# --------------------------------------------------------------
result = service.resolve(
datetime(2026, 9, 14, 10, 0)
)
print_result(
"TEST 3 — Monday 10:00",
result,
)
settings = result.scheduled_settings
assert result.current.when.hour == 9
assert result.next.when.hour == 13
assert settings.power is True
assert settings.speed == 1
assert settings.heater is True
assert settings.target_temp == 20
assert settings.mode == "outside"
# --------------------------------------------------------------
# TEST 4
# Понедельник 13:10
#
# Последняя точка AUTO.
# --------------------------------------------------------------
result = service.resolve(
datetime(2026, 9, 14, 13, 10)
)
print_result(
"TEST 4 — Monday 13:10 / AUTO",
result,
)
assert result.current.point.action.type == ScheduleActionType.AUTO
assert result.auto_active is True
assert result.next.when.hour == 17
assert result.next.when.minute == 0
# Накопленные настройки при AUTO не стираются.
assert result.scheduled_settings.speed == 1
assert result.scheduled_settings.power is True
# --------------------------------------------------------------
# TEST 5
# Понедельник 17:10
#
# AUTO закончился.
# Speed должен стать 3.
# --------------------------------------------------------------
result = service.resolve(
datetime(2026, 9, 14, 17, 10)
)
print_result(
"TEST 5 — Monday 17:10",
result,
)
assert result.auto_active is False
assert result.current.when.hour == 17
assert result.current.when.minute == 0
assert result.next.when.hour == 17
assert result.next.when.minute == 30
assert result.scheduled_settings.speed == 3
# --------------------------------------------------------------
# TEST 6
# Понедельник 17:40
#
# Снова AUTO.
# --------------------------------------------------------------
result = service.resolve(
datetime(2026, 9, 14, 17, 40)
)
print_result(
"TEST 6 — Monday 17:40 / AUTO",
result,
)
assert result.auto_active is True
assert result.current.when.hour == 17
assert result.current.when.minute == 30
assert result.next.when.hour == 23
# Последний scheduled speed всё равно должен помнить 3.
assert result.scheduled_settings.speed == 3
# --------------------------------------------------------------
# TEST 7
# Понедельник 23:15
#
# Heater off, speed 1.
# --------------------------------------------------------------
result = service.resolve(
datetime(2026, 9, 14, 23, 15)
)
print_result(
"TEST 7 — Monday 23:15",
result,
)
settings = result.scheduled_settings
assert result.auto_active is False
assert settings.speed == 1
assert settings.heater is False
# --------------------------------------------------------------
# TEST 8
# Суббота 09:30
#
# Проверяем переключение на weekend.
# --------------------------------------------------------------
result = service.resolve(
datetime(2026, 9, 19, 9, 30)
)
print_result(
"TEST 8 — Saturday 09:30",
result,
)
assert result.current.template == "weekend"
assert result.next.template == "weekend"
settings = result.scheduled_settings
assert settings.power is True
assert settings.speed == 1
assert settings.heater is True
assert settings.target_temp == 20
# --------------------------------------------------------------
# TEST 9
# Суббота 10:30
#
# Weekend AUTO.
# --------------------------------------------------------------
result = service.resolve(
datetime(2026, 9, 19, 10, 30)
)
print_result(
"TEST 9 — Saturday 10:30 / AUTO",
result,
)
assert result.current.template == "weekend"
assert result.auto_active is True
# --------------------------------------------------------------
# TEST 10
# Проверяем переход через полночь.
#
# Вторник 00:30.
# В 00:00 вторника уже должна действовать power off.
# --------------------------------------------------------------
result = service.resolve(
datetime(2026, 9, 15, 0, 30)
)
print_result(
"TEST 10 — Tuesday 00:30",
result,
)
assert result.current.when.day == 15
assert result.current.when.hour == 0
assert result.scheduled_settings.power is False
print()
print("=" * 70)
print("ALL SCHEDULE TESTS PASSED")
print("=" * 70)
if __name__ == "__main__":
main()
+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())
+327
View File
@@ -0,0 +1,327 @@
import asyncio
from datetime import datetime
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
from schedule import (
ScheduleService,
load_schedule,
)
SCHEDULE_YAML = """
version: 1
enabled: true
timezone: local
templates:
test:
- time: "00:00"
action:
type: set
power: off
- time: "09:00"
action:
type: set
power: on
speed: 1
heater: on
target_temp: 20
- time: "10:00"
action:
type: auto
speed: 3
- time: "12:00"
action:
type: set
speed: 2
heater: off
days:
mon: test
tue: test
wed: test
thu: test
fri: test
sat: test
sun: test
"""
class FakeTionController:
def __init__(self):
self.calls = []
async def power_on(self):
self.calls.append(
("power_on", None)
)
async def power_off(self):
self.calls.append(
("power_off", None)
)
async def set_speed(self, speed: int):
self.calls.append(
("set_speed", speed)
)
# Оставляем оба варианта управления heater,
# чтобы fake соответствовал фактическому
# интерфейсу TionController.
async def heater_on(self):
self.calls.append(
("heater_on", None)
)
async def heater_off(self):
self.calls.append(
("heater_off", None)
)
async def set_heater(self, enabled: bool):
self.calls.append(
("set_heater", enabled)
)
async def set_target_temperature(
self,
temperature: int,
):
self.calls.append(
(
"set_target_temperature",
temperature,
)
)
class FakeTionService:
def __init__(self):
self.controller = FakeTionController()
async def execute(self, operation):
return await operation(
self.controller
)
def load_test_schedule():
temp_dir = TemporaryDirectory()
path = (
Path(temp_dir.name)
/ "schedule.yaml"
)
path.write_text(
SCHEDULE_YAML,
encoding="utf-8",
)
config = load_schedule(path)
return temp_dir, config
def heater_calls(calls):
return [
call
for call in calls
if call[0] in {
"heater_on",
"heater_off",
"set_heater",
}
]
async def process_at(
service: ScheduleService,
when: datetime,
):
with patch(
"schedule.service.datetime",
wraps=datetime,
) as mocked_datetime:
mocked_datetime.now.return_value = when
await service._process()
async def test_auto_does_not_control_heater():
temp_dir, config = (
load_test_schedule()
)
try:
tion = FakeTionService()
service = ScheduleService(
config,
tion=tion,
)
# --------------------------------------------------
# 09:30
#
# Обычный SET.
# heater=on должен принадлежать ScheduleService.
# --------------------------------------------------
await process_at(
service,
datetime(
2026,
9,
14,
9,
30,
),
)
print()
print(
"09:30 SET calls:",
tion.controller.calls,
)
calls = heater_calls(
tion.controller.calls
)
assert calls != [], (
"SET must control heater"
)
tion.controller.calls.clear()
# --------------------------------------------------
# 10:30
#
# AUTO.
#
# В накопленных scheduled_settings heater всё ещё
# должен быть True, потому что последняя SET-точка
# включила heater.
#
# Но ScheduleService НЕ должен отправлять
# никаких heater-команд.
# --------------------------------------------------
resolution = service.resolve(
datetime(
2026,
9,
14,
10,
30,
)
)
print()
print(
"10:30 scheduled heater:",
resolution.scheduled_settings.heater,
)
assert (
resolution.auto_active
is True
)
assert (
resolution.scheduled_settings.heater
is True
)
await process_at(
service,
datetime(
2026,
9,
14,
10,
30,
),
)
print(
"10:30 AUTO calls:",
tion.controller.calls,
)
calls = heater_calls(
tion.controller.calls
)
assert calls == [], (
"ScheduleService must not control "
"heater during AUTO"
)
tion.controller.calls.clear()
# --------------------------------------------------
# 12:30
#
# AUTO закончился.
# heater=off снова принадлежит ScheduleService.
# --------------------------------------------------
await process_at(
service,
datetime(
2026,
9,
14,
12,
30,
),
)
print()
print(
"12:30 SET calls:",
tion.controller.calls,
)
calls = heater_calls(
tion.controller.calls
)
assert calls != [], (
"ScheduleService must regain heater "
"control after AUTO"
)
finally:
temp_dir.cleanup()
async def main():
await test_auto_does_not_control_heater()
print()
print("=" * 70)
print(
"ALL AUTO HEATER OWNERSHIP TESTS PASSED"
)
print("=" * 70)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,217 @@
from pathlib import Path
from tempfile import TemporaryDirectory
from schedule import (
ScheduleActionType,
load_schedule,
)
VALID_YAML = """
version: 1
enabled: true
timezone: local
templates:
test:
- time: "00:00"
action:
type: auto
speed: 3
target_temp: 20
days:
mon: test
tue: test
wed: test
thu: test
fri: test
sat: test
sun: test
"""
def load_config(text: str):
temp_dir = TemporaryDirectory()
path = (
Path(temp_dir.name)
/ "schedule.yaml"
)
path.write_text(
text,
encoding="utf-8",
)
config = load_schedule(path)
return temp_dir, config
def test_auto_target_temperature():
temp_dir, config = load_config(
VALID_YAML
)
try:
point = (
config.templates["test"][0]
)
action = point.action
assert (
action.type
== ScheduleActionType.AUTO
)
assert (
action.settings.speed
== 3
)
assert (
action.settings.target_temp
== 20
)
finally:
temp_dir.cleanup()
def test_auto_rejects_heater():
config_text = """
version: 1
enabled: true
timezone: local
templates:
test:
- time: "00:00"
action:
type: auto
speed: 3
target_temp: 20
heater: on
days:
mon: test
tue: test
wed: test
thu: test
fri: test
sat: test
sun: test
"""
try:
temp_dir, _ = load_config(
config_text
)
except ValueError as exc:
print()
print(
"Expected error:",
exc,
)
assert "heater" in str(exc)
return
else:
temp_dir.cleanup()
raise AssertionError(
"AUTO heater field must "
"raise ValueError"
)
def test_auto_speed_is_required():
config_text = """
version: 1
enabled: true
timezone: local
templates:
test:
- time: "00:00"
action:
type: auto
target_temp: 20
days:
mon: test
tue: test
wed: test
thu: test
fri: test
sat: test
sun: test
"""
try:
temp_dir, _ = load_config(
config_text
)
except ValueError as exc:
print()
print(
"Expected error:",
exc,
)
assert (
str(exc)
== "AUTO action requires 'speed'"
)
return
else:
temp_dir.cleanup()
raise AssertionError(
"AUTO without speed must "
"raise ValueError"
)
def main():
test_auto_target_temperature()
test_auto_rejects_heater()
test_auto_speed_is_required()
print()
print("=" * 70)
print(
"ALL AUTO TEMPERATURE PARSER "
"TESTS PASSED"
)
print("=" * 70)
if __name__ == "__main__":
main()
@@ -0,0 +1,196 @@
from datetime import datetime
from pathlib import Path
from tempfile import TemporaryDirectory
from schedule import (
ScheduleService,
load_schedule,
)
SCHEDULE_YAML = """
version: 1
enabled: true
timezone: local
templates:
test:
- time: "09:00"
action:
type: set
power: on
speed: 1
target_temp: 23
- time: "10:00"
action:
type: auto
speed: 3
target_temp: 20
- time: "12:00"
action:
type: set
speed: 2
target_temp: 22
days:
mon: test
tue: test
wed: test
thu: test
fri: test
sat: test
sun: test
"""
def load_test_schedule():
temp_dir = TemporaryDirectory()
path = (
Path(temp_dir.name)
/ "schedule.yaml"
)
path.write_text(
SCHEDULE_YAML,
encoding="utf-8",
)
config = load_schedule(path)
return temp_dir, config
def test_auto_temperature_resolution():
temp_dir, config = (
load_test_schedule()
)
try:
service = ScheduleService(
config
)
# ----------------------------------------------
# 09:30 — обычный SET
# ----------------------------------------------
resolution = service.resolve(
datetime(
2026,
9,
14,
9,
30,
)
)
assert (
resolution.auto_active
is False
)
assert (
resolution.scheduled_settings.target_temp
== 23
)
assert (
resolution.auto_target_temp
is None
)
# ----------------------------------------------
# 10:30 — AUTO
#
# Последний SET по-прежнему хранит 23,
# но AUTO явно требует 20.
# ----------------------------------------------
resolution = service.resolve(
datetime(
2026,
9,
14,
10,
30,
)
)
assert (
resolution.auto_active
is True
)
assert (
resolution.auto_fallback_speed
== 3
)
assert (
resolution.scheduled_settings.target_temp
== 23
)
assert (
resolution.auto_target_temp
== 20
)
# ----------------------------------------------
# 12:30 — снова SET
#
# AUTO закончился.
# ----------------------------------------------
resolution = service.resolve(
datetime(
2026,
9,
14,
12,
30,
)
)
assert (
resolution.auto_active
is False
)
assert (
resolution.scheduled_settings.target_temp
== 22
)
assert (
resolution.auto_target_temp
is None
)
finally:
temp_dir.cleanup()
def main():
test_auto_temperature_resolution()
print()
print("=" * 70)
print(
"ALL AUTO TEMPERATURE RESOLUTION "
"TESTS PASSED"
)
print("=" * 70)
if __name__ == "__main__":
main()
+72
View File
@@ -0,0 +1,72 @@
import asyncio
import logging
from pathlib import Path
import datetime
from app.my_dataclasses import TION_MAC
from app.tion import TionController, TionService
from schedule import (
ScheduleService,
load_schedule,
)
logging.disable(logging.CRITICAL)
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SCHEDULE_FILE = (
PROJECT_ROOT
/ "config"
/ "schedule.yaml"
)
async def main():
config = load_schedule(SCHEDULE_FILE)
controller = TionController(TION_MAC)
tion = TionService(
controller,
poll_interval=5,
)
schedule = ScheduleService(
config,
tion,
check_interval=5,
)
await tion.start()
await schedule.start()
print("Schedule started. Ctrl+C to stop.")
try:
while True:
await asyncio.sleep(5)
if tion.state:
print(
f"{datetime.datetime.now().strftime("%y-%m-%d %H:%M:%S")} "
f"power={tion.state.power} "
f"speed={tion.state.fan_speed} "
f"heater={tion.state.heater} "
f"target_temp={tion.state.target_temp} "
f"last_error={tion.last_error} "
)
finally:
await schedule.stop()
await tion.stop()
if __name__ == "__main__":
asyncio.run(main())
+119
View File
@@ -0,0 +1,119 @@
import asyncio
import logging
from datetime import datetime
from pathlib import Path
from app.my_dataclasses import TION_MAC
from app.tion import TionController, TionService
from schedule import (
ScheduleService,
ScheduledSettings,
load_schedule,
)
logging.disable(logging.CRITICAL)
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SCHEDULE_FILE = (
PROJECT_ROOT
/ "config"
/ "schedule.yaml"
)
async def main():
config = load_schedule(SCHEDULE_FILE)
controller = TionController(TION_MAC)
tion = TionService(
controller,
poll_interval=5,
)
schedule = ScheduleService(
config,
tion,
check_interval=5,
)
await tion.start()
await schedule.start()
try:
print()
print("Schedule started")
resolution = schedule.resolve()
print(
f"Current: "
f"{resolution.current.when if resolution.current else None}"
)
print(
f"Next: "
f"{resolution.next.when if resolution.next else None}"
)
# print()
# print("Applying temporary override:")
# print("speed=6")
# print()
#
# await schedule.apply_override(
# ScheduledSettings(
# speed=6,
# )
# )
while True:
await asyncio.sleep(5)
if ( datetime.now() > datetime(2026, 9, 18, 20, 30, 30)
and datetime.now() < datetime(2026, 9, 18, 20, 30, 36) ):
print("Applying temporary override:")
print("heater=True")
print("target_temp=25")
await schedule.apply_override(
ScheduledSettings(
heater=True,
target_temp=25,
)
)
now = datetime.now().strftime(
"%y-%m-%d %H:%M:%S"
)
state = tion.state
if state is None:
print(
f"{now} state=None"
)
continue
print(
f"{now} "
f"power={state.power} "
f"speed={state.fan_speed} "
f"heater={state.heater} "
f"target_temp={state.target_temp} "
f"override={schedule.override_active} "
f"until={schedule.override_until}"
)
finally:
await schedule.stop()
await tion.stop()
if __name__ == "__main__":
asyncio.run(main())
+203
View File
@@ -0,0 +1,203 @@
import asyncio
from datetime import datetime, timedelta
from pathlib import Path
from tempfile import TemporaryDirectory
from schedule import (
ScheduleService,
ScheduledSettings,
load_schedule,
)
SCHEDULE_YAML = """
version: 1
enabled: true
timezone: local
templates:
test:
- time: "00:00"
action:
type: set
speed: 2
days:
mon: test
tue: test
wed: test
thu: test
fri: test
sat: test
sun: test
"""
class FakeTionController:
def __init__(self):
self.calls = []
async def set_speed(
self,
speed: int,
):
self.calls.append(
(
"set_speed",
speed,
)
)
class FakeTionService:
def __init__(self):
self.controller = (
FakeTionController()
)
async def execute(
self,
operation,
):
return await operation(
self.controller
)
def load_test_schedule():
temp_dir = TemporaryDirectory()
path = (
Path(temp_dir.name)
/ "schedule.yaml"
)
path.write_text(
SCHEDULE_YAML,
encoding="utf-8",
)
config = load_schedule(path)
return temp_dir, config
async def test_paused_schedule_does_not_control_tion():
temp_dir, config = (
load_test_schedule()
)
try:
tion = FakeTionService()
service = ScheduleService(
config,
tion=tion,
)
assert service.paused is False
await service.pause()
assert service.paused is True
# Даже если фоновый цикл вызывает _process(),
# никаких команд быть не должно.
await service._process()
assert tion.controller.calls == []
# Возвращаем расписание.
#
# resume() должен сразу применить
# текущее состояние расписания.
await service.resume()
assert service.paused is False
assert (
"set_speed",
2,
) in tion.controller.calls
finally:
temp_dir.cleanup()
async def test_pause_clears_override():
temp_dir, config = (
load_test_schedule()
)
try:
service = ScheduleService(
config
)
# Имитируем уже существующий
# temporary override.
service._override_settings = (
ScheduledSettings(
speed=5,
)
)
service._override_until = (
datetime.now()
+ timedelta(hours=1)
)
service._override_pending = True
assert (
service.override_settings.speed
== 5
)
await service.pause()
assert service.paused is True
assert (
service.override_settings.speed
is None
)
assert (
service.override_until
is None
)
assert (
service._override_pending
is False
)
finally:
temp_dir.cleanup()
async def main():
await test_paused_schedule_does_not_control_tion()
await test_pause_clears_override()
print()
print("=" * 70)
print(
"ALL SCHEDULE PAUSE TESTS PASSED"
)
print("=" * 70)
if __name__ == "__main__":
asyncio.run(main())
+179
View File
@@ -0,0 +1,179 @@
import asyncio
from pathlib import Path
from tempfile import TemporaryDirectory
from schedule import (
ScheduleService,
load_schedule,
)
SCHEDULE_YAML = """
version: 1
enabled: true
timezone: local
templates:
test:
- time: "00:00"
action:
type: set
speed: 2
days:
mon: test
tue: test
wed: test
thu: test
fri: test
sat: test
sun: test
"""
def create_config(
directory: Path,
):
schedule_path = (
directory
/ "schedule.yaml"
)
schedule_path.write_text(
SCHEDULE_YAML,
encoding="utf-8",
)
return load_schedule(
schedule_path
)
async def test_pause_survives_restart():
with TemporaryDirectory() as temp_dir:
directory = Path(temp_dir)
config = create_config(
directory
)
state_path = (
directory
/ "schedule_state.json"
)
# --------------------------------------
# Первый экземпляр сервиса.
# --------------------------------------
service1 = ScheduleService(
config,
state_path=state_path,
)
assert service1.paused is False
await service1.pause()
assert service1.paused is True
assert state_path.exists()
# --------------------------------------
# Имитируем перезапуск приложения:
# создаём новый ScheduleService.
# --------------------------------------
service2 = ScheduleService(
config,
state_path=state_path,
)
assert service2.paused is True
async def test_resume_survives_restart():
with TemporaryDirectory() as temp_dir:
directory = Path(temp_dir)
config = create_config(
directory
)
state_path = (
directory
/ "schedule_state.json"
)
service1 = ScheduleService(
config,
state_path=state_path,
)
await service1.pause()
assert service1.paused is True
await service1.resume()
assert service1.paused is False
# Новый экземпляр после resume
# тоже должен быть активным.
service2 = ScheduleService(
config,
state_path=state_path,
)
assert service2.paused is False
async def test_missing_state_file_defaults_to_active():
with TemporaryDirectory() as temp_dir:
directory = Path(temp_dir)
config = create_config(
directory
)
state_path = (
directory
/ "missing_state.json"
)
assert not state_path.exists()
service = ScheduleService(
config,
state_path=state_path,
)
assert service.paused is False
async def main():
await test_pause_survives_restart()
await test_resume_survives_restart()
await test_missing_state_file_defaults_to_active()
print()
print("=" * 70)
print(
"ALL SCHEDULE PAUSE "
"PERSISTENCE TESTS PASSED"
)
print("=" * 70)
if __name__ == "__main__":
asyncio.run(main())
+85
View File
@@ -0,0 +1,85 @@
import asyncio
import json
import logging
from app.tion import TionController
from app.my_dataclasses import *
def print_state(title: str, state) -> None:
print()
print("=" * 50)
print(title)
print("=" * 50)
print(json.dumps(
state.to_dict(),
indent=2,
ensure_ascii=False
))
async def main():
# Убираем лишнее логирование
logging.getLogger("asyncio").setLevel(logging.WARNING)
logging.getLogger("bleak").setLevel(logging.WARNING)
logging.getLogger("tion_btle").setLevel(logging.WARNING)
tion = TionController(TION_MAC)
print("Подключение к Tion...")
async with tion:
print(f"Connected: {tion.connected}")
# Читаем исходное состояние
state = await tion.get_state()
print_state("Исходное состояние", state)
# Включаем
state = await tion.power_on()
print_state("После POWER ON", state)
await asyncio.sleep(1)
# Скорость 2
state = await tion.set_speed(2)
print_state("После SPEED 2", state)
await asyncio.sleep(1)
# Скорость 3
state = await tion.set_speed(3)
print_state("После SPEED 3", state)
state = await tion.set_target_temperature(20)
print_state("TARGET TEMP 20°C", state)
state = await tion.set_air_mode("recirculation")
print_state("RECIRCULATION", state)
await asyncio.sleep(3)
state = await tion.set_air_mode("outside")
print_state("RECIRCULATION", state)
await asyncio.sleep(3)
state = await tion.sound_off()
print_state("SOUND OFF", state)
state = await tion.sound_on()
print_state("SOUND ON", state)
state = await tion.light_off()
print_state("LIGHT OFF", state)
state = await tion.light_on()
print_state("LIGHT ON", state)
print()
print(f"Connected after exit: {tion.connected}")
if __name__ == "__main__":
asyncio.run(main())
+84
View File
@@ -0,0 +1,84 @@
import asyncio
import json
import logging
from app.tion import (
TionController,
TionService,
)
from app.my_dataclasses import *
def print_service(service: TionService) -> None:
print()
print("=" * 60)
print(f"Running: {service.running}")
print(f"Online: {service.online}")
print(f"Last seen: {service.last_seen}")
print(f"Last error: {service.last_error}")
if service.state is not None:
print()
print(json.dumps(
service.state.to_dict(),
indent=2,
ensure_ascii=False,
))
async def main():
# Убираем лишнее логирование
logging.getLogger("asyncio").setLevel(logging.WARNING)
logging.getLogger("bleak").setLevel(logging.WARNING)
logging.getLogger("tion_btle").setLevel(logging.WARNING)
controller = TionController(TION_MAC)
service = TionService(
controller,
poll_interval=3,
)
async with service:
print("=== После запуска ===")
print_service(service)
print()
print("Ждём несколько циклов polling...")
await asyncio.sleep(60)
print_service(service)
print()
print("=== Устанавливаем скорость 2 ===")
state = await service.execute(
lambda tion: tion.set_speed(2)
)
print(json.dumps(
state.to_dict(),
indent=2,
ensure_ascii=False,
))
print()
print("=== Устанавливаем температуру 20°C ===")
await service.execute(
lambda tion: tion.set_target_temperature(20)
)
print_service(service)
print()
print("=== После stop ===")
print_service(service)
if __name__ == "__main__":
asyncio.run(main())