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
+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()