doc: поправил зависимости и расписание
This commit is contained in:
+779
@@ -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(),
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
|
||||
#Классы
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1,10 @@
|
||||
from .controller import TionController
|
||||
from .models import TionState
|
||||
from .service import TionService
|
||||
|
||||
|
||||
__all__ = [
|
||||
"TionController",
|
||||
"TionService",
|
||||
"TionState",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user