Compare commits
1
Commits
a000fb6c44
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cf1407e04 |
Generated
+1
@@ -2,6 +2,7 @@
|
|||||||
<module type="PYTHON_MODULE" version="4">
|
<module type="PYTHON_MODULE" version="4">
|
||||||
<component name="NewModuleRootManager">
|
<component name="NewModuleRootManager">
|
||||||
<content url="file://$MODULE_DIR$">
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="jdk" jdkName="Python 3.14 (TionController)" jdkType="Python SDK" />
|
<orderEntry type="jdk" jdkName="Python 3.14 (TionController)" jdkType="Python SDK" />
|
||||||
|
|||||||
+779
@@ -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()
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
version: 1
|
||||||
|
|
||||||
|
check_interval: 5.0
|
||||||
|
|
||||||
|
co2:
|
||||||
|
base_speed: 1
|
||||||
|
hysteresis: 100
|
||||||
|
|
||||||
|
thresholds:
|
||||||
|
- ppm: 700
|
||||||
|
speed: 2
|
||||||
|
|
||||||
|
- ppm: 900
|
||||||
|
speed: 3
|
||||||
|
|
||||||
|
- ppm: 1300
|
||||||
|
speed: 4
|
||||||
|
|
||||||
|
- ppm: 1600
|
||||||
|
speed: 5
|
||||||
|
|
||||||
|
- ppm: 2000
|
||||||
|
speed: 6
|
||||||
|
|
||||||
|
temperature:
|
||||||
|
hysteresis: 0.5
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
version: 1
|
||||||
|
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
timezone: local
|
||||||
|
|
||||||
|
templates:
|
||||||
|
|
||||||
|
workday:
|
||||||
|
|
||||||
|
|
||||||
|
- time: "09:00"
|
||||||
|
action:
|
||||||
|
power: on
|
||||||
|
type: set
|
||||||
|
speed: 6
|
||||||
|
heater: on
|
||||||
|
target_temp: 21
|
||||||
|
|
||||||
|
- time: "09:30"
|
||||||
|
action:
|
||||||
|
type: auto
|
||||||
|
speed: 2
|
||||||
|
target_temp: 25
|
||||||
|
|
||||||
|
- time: "22:00"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
speed: 6
|
||||||
|
|
||||||
|
- time: "22:45"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
speed: 1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
weekend:
|
||||||
|
|
||||||
|
- time: "10:00"
|
||||||
|
action:
|
||||||
|
power: on
|
||||||
|
type: set
|
||||||
|
speed: 6
|
||||||
|
heater: on
|
||||||
|
target_temp: 21
|
||||||
|
|
||||||
|
- time: "10:30"
|
||||||
|
action:
|
||||||
|
type: auto
|
||||||
|
speed: 2
|
||||||
|
target_temp: 25
|
||||||
|
|
||||||
|
- time: "22:00"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
speed: 6
|
||||||
|
|
||||||
|
- time: "22:45"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
speed: 1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
days:
|
||||||
|
|
||||||
|
mon: workday
|
||||||
|
tue: workday
|
||||||
|
wed: workday
|
||||||
|
thu: workday
|
||||||
|
fri: workday
|
||||||
|
|
||||||
|
sat: weekend
|
||||||
|
sun: weekend
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
tion-btle==3.3.6
|
||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
PyYAML
|
||||||
|
paho-mqtt>=2.1,<3
|
||||||
|
paho-mqtt
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from .loader import load_schedule
|
||||||
|
from .models import (
|
||||||
|
ScheduleAction,
|
||||||
|
ScheduleActionType,
|
||||||
|
ScheduleConfig,
|
||||||
|
ScheduleOccurrence,
|
||||||
|
SchedulePoint,
|
||||||
|
ScheduleResolution,
|
||||||
|
ScheduledSettings,
|
||||||
|
)
|
||||||
|
from .service import ScheduleService
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"load_schedule",
|
||||||
|
"ScheduleAction",
|
||||||
|
"ScheduleActionType",
|
||||||
|
"ScheduleConfig",
|
||||||
|
"ScheduleOccurrence",
|
||||||
|
"SchedulePoint",
|
||||||
|
"ScheduleResolution",
|
||||||
|
"ScheduledSettings",
|
||||||
|
"ScheduleService",
|
||||||
|
]
|
||||||
|
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
from datetime import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from app.my_dataclasses import (
|
||||||
|
MIN_FAN_SPEED,
|
||||||
|
MAX_FAN_SPEED,
|
||||||
|
MIN_TARGET_TEMP,
|
||||||
|
MAX_TARGET_TEMP,
|
||||||
|
SUPPORTED_AIR_MODES,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
ScheduleAction,
|
||||||
|
ScheduleActionType,
|
||||||
|
ScheduleConfig,
|
||||||
|
SchedulePoint,
|
||||||
|
ScheduledSettings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
WEEKDAYS = (
|
||||||
|
"mon",
|
||||||
|
"tue",
|
||||||
|
"wed",
|
||||||
|
"thu",
|
||||||
|
"fri",
|
||||||
|
"sat",
|
||||||
|
"sun",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SET_FIELDS = {
|
||||||
|
"power",
|
||||||
|
"speed",
|
||||||
|
"heater",
|
||||||
|
"target_temp",
|
||||||
|
"mode",
|
||||||
|
"sound",
|
||||||
|
"light",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_time(value: Any) -> time:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError(
|
||||||
|
f"Schedule time must be a string: {value!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = time.fromisoformat(value)
|
||||||
|
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid schedule time: {value!r}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
# Не разрешаем секунды.
|
||||||
|
if parsed.second != 0 or parsed.microsecond != 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"Schedule time must use HH:MM: {value!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_on_off(
|
||||||
|
name: str,
|
||||||
|
value: Any,
|
||||||
|
) -> bool:
|
||||||
|
# PyYAML может автоматически превратить
|
||||||
|
# on/off в True/False.
|
||||||
|
if type(value) is bool:
|
||||||
|
return value
|
||||||
|
|
||||||
|
if isinstance(value, str):
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
|
||||||
|
if normalized == "on":
|
||||||
|
return True
|
||||||
|
|
||||||
|
if normalized == "off":
|
||||||
|
return False
|
||||||
|
|
||||||
|
raise ValueError(
|
||||||
|
f"{name} must be 'on' or 'off'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_settings(
|
||||||
|
data: dict[str, Any],
|
||||||
|
) -> ScheduledSettings:
|
||||||
|
|
||||||
|
unknown = set(data) - SET_FIELDS
|
||||||
|
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown SET fields: {sorted(unknown)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
values: dict[str, Any] = {}
|
||||||
|
|
||||||
|
if "power" in data:
|
||||||
|
values["power"] = _parse_on_off(
|
||||||
|
"power",
|
||||||
|
data["power"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if "speed" in data:
|
||||||
|
speed = data["speed"]
|
||||||
|
|
||||||
|
if type(speed) is not int:
|
||||||
|
raise ValueError(
|
||||||
|
"speed must be an integer"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not MIN_FAN_SPEED <= speed <= MAX_FAN_SPEED:
|
||||||
|
raise ValueError(
|
||||||
|
f"speed must be between "
|
||||||
|
f"{MIN_FAN_SPEED} and {MAX_FAN_SPEED}"
|
||||||
|
)
|
||||||
|
|
||||||
|
values["speed"] = speed
|
||||||
|
|
||||||
|
if "heater" in data:
|
||||||
|
values["heater"] = _parse_on_off(
|
||||||
|
"heater",
|
||||||
|
data["heater"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if "target_temp" in data:
|
||||||
|
temperature = data["target_temp"]
|
||||||
|
|
||||||
|
if type(temperature) is not int:
|
||||||
|
raise ValueError(
|
||||||
|
"target_temp must be an integer"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not (
|
||||||
|
MIN_TARGET_TEMP
|
||||||
|
<= temperature
|
||||||
|
<= MAX_TARGET_TEMP
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"target_temp must be between "
|
||||||
|
f"{MIN_TARGET_TEMP} and "
|
||||||
|
f"{MAX_TARGET_TEMP}"
|
||||||
|
)
|
||||||
|
|
||||||
|
values["target_temp"] = temperature
|
||||||
|
|
||||||
|
if "mode" in data:
|
||||||
|
mode = data["mode"]
|
||||||
|
|
||||||
|
if mode not in SUPPORTED_AIR_MODES:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported air mode: {mode!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
values["mode"] = mode
|
||||||
|
|
||||||
|
if "sound" in data:
|
||||||
|
values["sound"] = _parse_on_off(
|
||||||
|
"sound",
|
||||||
|
data["sound"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if "light" in data:
|
||||||
|
values["light"] = _parse_on_off(
|
||||||
|
"light",
|
||||||
|
data["light"],
|
||||||
|
)
|
||||||
|
|
||||||
|
settings = ScheduledSettings(**values)
|
||||||
|
|
||||||
|
if settings.empty:
|
||||||
|
raise ValueError(
|
||||||
|
"SET action must contain "
|
||||||
|
"at least one setting"
|
||||||
|
)
|
||||||
|
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_action(
|
||||||
|
data: Any,
|
||||||
|
) -> ScheduleAction:
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError(
|
||||||
|
"Schedule action must be an object"
|
||||||
|
)
|
||||||
|
|
||||||
|
action_type_raw = data.get("type")
|
||||||
|
|
||||||
|
try:
|
||||||
|
action_type = ScheduleActionType(action_type_raw)
|
||||||
|
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported action type: "
|
||||||
|
f"{action_type_raw!r}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if action_type == ScheduleActionType.AUTO:
|
||||||
|
|
||||||
|
allowed_fields = {
|
||||||
|
"type",
|
||||||
|
"speed",
|
||||||
|
"target_temp",
|
||||||
|
}
|
||||||
|
|
||||||
|
extra = set(data) - allowed_fields
|
||||||
|
|
||||||
|
if extra:
|
||||||
|
raise ValueError(
|
||||||
|
"AUTO action supports only "
|
||||||
|
"'speed' and 'target_temp': "
|
||||||
|
f"{sorted(extra)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "speed" not in data:
|
||||||
|
raise ValueError(
|
||||||
|
"AUTO action requires 'speed'"
|
||||||
|
)
|
||||||
|
|
||||||
|
settings_data = {
|
||||||
|
"speed": data["speed"],
|
||||||
|
}
|
||||||
|
|
||||||
|
if "target_temp" in data:
|
||||||
|
settings_data["target_temp"] = (
|
||||||
|
data["target_temp"]
|
||||||
|
)
|
||||||
|
|
||||||
|
return ScheduleAction(
|
||||||
|
type=ScheduleActionType.AUTO,
|
||||||
|
settings=_parse_settings(settings_data),
|
||||||
|
)
|
||||||
|
|
||||||
|
settings_data = {
|
||||||
|
key: value
|
||||||
|
for key, value in data.items()
|
||||||
|
if key != "type"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ScheduleAction(
|
||||||
|
type=ScheduleActionType.SET,
|
||||||
|
settings=_parse_settings(
|
||||||
|
settings_data
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_template(
|
||||||
|
name: str,
|
||||||
|
data: Any,
|
||||||
|
) -> tuple[SchedulePoint, ...]:
|
||||||
|
|
||||||
|
if not isinstance(data, list):
|
||||||
|
raise ValueError(
|
||||||
|
f"Template {name!r} must be a list"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
raise ValueError(
|
||||||
|
f"Template {name!r} cannot be empty"
|
||||||
|
)
|
||||||
|
|
||||||
|
points: list[SchedulePoint] = []
|
||||||
|
|
||||||
|
for item in data:
|
||||||
|
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid point in template {name!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "time" not in item:
|
||||||
|
raise ValueError(
|
||||||
|
f"Point in {name!r} has no time"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "action" not in item:
|
||||||
|
raise ValueError(
|
||||||
|
f"Point in {name!r} has no action"
|
||||||
|
)
|
||||||
|
|
||||||
|
points.append(
|
||||||
|
SchedulePoint(
|
||||||
|
at=_parse_time(item["time"]),
|
||||||
|
action=_parse_action(
|
||||||
|
item["action"]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
points.sort(
|
||||||
|
key=lambda point: point.at
|
||||||
|
)
|
||||||
|
|
||||||
|
seen_times = set()
|
||||||
|
|
||||||
|
for point in points:
|
||||||
|
if point.at in seen_times:
|
||||||
|
raise ValueError(
|
||||||
|
f"Duplicate time {point.at} "
|
||||||
|
f"in template {name!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
seen_times.add(point.at)
|
||||||
|
|
||||||
|
return tuple(points)
|
||||||
|
|
||||||
|
|
||||||
|
def load_schedule(
|
||||||
|
path: str | Path,
|
||||||
|
) -> ScheduleConfig:
|
||||||
|
|
||||||
|
path = Path(path)
|
||||||
|
|
||||||
|
with path.open(
|
||||||
|
"r",
|
||||||
|
encoding="utf-8",
|
||||||
|
) as file:
|
||||||
|
raw = yaml.safe_load(file)
|
||||||
|
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ValueError(
|
||||||
|
"Schedule root must be an object"
|
||||||
|
)
|
||||||
|
|
||||||
|
version = raw.get("version", 1)
|
||||||
|
|
||||||
|
if version != 1:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported schedule version: {version}"
|
||||||
|
)
|
||||||
|
|
||||||
|
enabled = raw.get("enabled", True)
|
||||||
|
|
||||||
|
if type(enabled) is not bool:
|
||||||
|
raise ValueError(
|
||||||
|
"enabled must be true or false"
|
||||||
|
)
|
||||||
|
|
||||||
|
timezone = raw.get(
|
||||||
|
"timezone",
|
||||||
|
"local",
|
||||||
|
)
|
||||||
|
|
||||||
|
if timezone != "local":
|
||||||
|
raise ValueError(
|
||||||
|
"Only timezone: local "
|
||||||
|
"is currently supported"
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_templates = raw.get("templates")
|
||||||
|
|
||||||
|
if not isinstance(raw_templates, dict):
|
||||||
|
raise ValueError(
|
||||||
|
"templates must be an object"
|
||||||
|
)
|
||||||
|
|
||||||
|
templates = {
|
||||||
|
name: _parse_template(
|
||||||
|
name,
|
||||||
|
template,
|
||||||
|
)
|
||||||
|
for name, template
|
||||||
|
in raw_templates.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
raw_days = raw.get("days")
|
||||||
|
|
||||||
|
if not isinstance(raw_days, dict):
|
||||||
|
raise ValueError(
|
||||||
|
"days must be an object"
|
||||||
|
)
|
||||||
|
|
||||||
|
days: dict[str, str] = {}
|
||||||
|
|
||||||
|
for weekday in WEEKDAYS:
|
||||||
|
|
||||||
|
if weekday not in raw_days:
|
||||||
|
raise ValueError(
|
||||||
|
f"Missing schedule day: {weekday}"
|
||||||
|
)
|
||||||
|
|
||||||
|
template_name = raw_days[weekday]
|
||||||
|
|
||||||
|
if template_name not in templates:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown template "
|
||||||
|
f"{template_name!r} "
|
||||||
|
f"for {weekday}"
|
||||||
|
)
|
||||||
|
|
||||||
|
days[weekday] = template_name
|
||||||
|
|
||||||
|
unknown_days = (
|
||||||
|
set(raw_days)
|
||||||
|
- set(WEEKDAYS)
|
||||||
|
)
|
||||||
|
|
||||||
|
if unknown_days:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown weekdays: "
|
||||||
|
f"{sorted(unknown_days)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ScheduleConfig(
|
||||||
|
version=version,
|
||||||
|
enabled=enabled,
|
||||||
|
timezone=timezone,
|
||||||
|
templates=templates,
|
||||||
|
days=days,
|
||||||
|
)
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
from dataclasses import dataclass, field, fields
|
||||||
|
from datetime import datetime, time
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleActionType(StrEnum):
|
||||||
|
SET = "set"
|
||||||
|
AUTO = "auto"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ScheduledSettings:
|
||||||
|
power: bool | None = None
|
||||||
|
speed: int | None = None
|
||||||
|
heater: bool | None = None
|
||||||
|
target_temp: int | None = None
|
||||||
|
mode: str | None = None
|
||||||
|
sound: bool | None = None
|
||||||
|
light: bool | None = None
|
||||||
|
|
||||||
|
def merged(self, newer: "ScheduledSettings") -> "ScheduledSettings":
|
||||||
|
"""
|
||||||
|
Наложить более новые настройки поверх старых.
|
||||||
|
|
||||||
|
None означает:
|
||||||
|
параметр в данной точке расписания не менялся.
|
||||||
|
"""
|
||||||
|
|
||||||
|
values: dict[str, Any] = {}
|
||||||
|
|
||||||
|
for item in fields(self):
|
||||||
|
old_value = getattr(self, item.name)
|
||||||
|
new_value = getattr(newer, item.name)
|
||||||
|
|
||||||
|
values[item.name] = (
|
||||||
|
old_value
|
||||||
|
if new_value is None
|
||||||
|
else new_value
|
||||||
|
)
|
||||||
|
|
||||||
|
return ScheduledSettings(**values)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def empty(self) -> bool:
|
||||||
|
return all(
|
||||||
|
getattr(self, item.name) is None
|
||||||
|
for item in fields(self)
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
skip_none: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
for item in fields(self):
|
||||||
|
value = getattr(self, item.name)
|
||||||
|
|
||||||
|
if skip_none and value is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
result[item.name] = value
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ScheduleAction:
|
||||||
|
type: ScheduleActionType
|
||||||
|
|
||||||
|
settings: ScheduledSettings = field(
|
||||||
|
default_factory=ScheduledSettings
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SchedulePoint:
|
||||||
|
at: time
|
||||||
|
action: ScheduleAction
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ScheduleConfig:
|
||||||
|
version: int
|
||||||
|
enabled: bool
|
||||||
|
timezone: str
|
||||||
|
|
||||||
|
templates: dict[
|
||||||
|
str,
|
||||||
|
tuple[SchedulePoint, ...]
|
||||||
|
]
|
||||||
|
|
||||||
|
days: dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ScheduleOccurrence:
|
||||||
|
"""
|
||||||
|
Конкретная точка расписания уже с датой.
|
||||||
|
"""
|
||||||
|
|
||||||
|
when: datetime
|
||||||
|
weekday: str
|
||||||
|
template: str
|
||||||
|
point: SchedulePoint
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ScheduleResolution:
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
current: ScheduleOccurrence | None
|
||||||
|
next: ScheduleOccurrence | None
|
||||||
|
|
||||||
|
scheduled_settings: ScheduledSettings
|
||||||
|
|
||||||
|
auto_active: bool
|
||||||
|
auto_fallback_speed: int | None
|
||||||
|
auto_target_temp: int | None = None
|
||||||
@@ -0,0 +1,629 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from app.tion.service import TionService
|
||||||
|
|
||||||
|
from datetime import (
|
||||||
|
date,
|
||||||
|
datetime,
|
||||||
|
timedelta,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
ScheduleActionType,
|
||||||
|
ScheduleConfig,
|
||||||
|
ScheduleOccurrence,
|
||||||
|
ScheduleResolution,
|
||||||
|
ScheduledSettings,
|
||||||
|
)
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
WEEKDAYS = (
|
||||||
|
"mon",
|
||||||
|
"tue",
|
||||||
|
"wed",
|
||||||
|
"thu",
|
||||||
|
"fri",
|
||||||
|
"sat",
|
||||||
|
"sun",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleService:
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config,
|
||||||
|
tion: TionService | None = None,
|
||||||
|
check_interval: float = 5.0,
|
||||||
|
state_path: str | Path | None = None,
|
||||||
|
):
|
||||||
|
self._config = config
|
||||||
|
self._tion = tion
|
||||||
|
self._check_interval = check_interval
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
self._task = None
|
||||||
|
|
||||||
|
# Последняя успешно применённая точка расписания.
|
||||||
|
self._last_applied_when: datetime | None = None
|
||||||
|
|
||||||
|
# Temporary override.
|
||||||
|
self._override_settings = ScheduledSettings()
|
||||||
|
self._override_until: datetime | None = None
|
||||||
|
|
||||||
|
# True означает:
|
||||||
|
# override записан, но применить его к Tion
|
||||||
|
# пока не удалось.
|
||||||
|
self._override_pending = False
|
||||||
|
|
||||||
|
# Ошибка именно ScheduleService.
|
||||||
|
self._last_error: str | None = None
|
||||||
|
|
||||||
|
self._paused = False
|
||||||
|
|
||||||
|
self._state_path = (
|
||||||
|
Path(state_path)
|
||||||
|
if state_path is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
self._load_runtime_state()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@property
|
||||||
|
def paused(self) -> bool:
|
||||||
|
return self._paused
|
||||||
|
|
||||||
|
@property
|
||||||
|
def running(self) -> bool:
|
||||||
|
return self._running
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_error(self) -> str | None:
|
||||||
|
return self._last_error
|
||||||
|
|
||||||
|
@property
|
||||||
|
def override_settings(self) -> ScheduledSettings:
|
||||||
|
return self._override_settings
|
||||||
|
|
||||||
|
@property
|
||||||
|
def override_until(self) -> datetime | None:
|
||||||
|
return self._override_until
|
||||||
|
|
||||||
|
@property
|
||||||
|
def override_active(self) -> bool:
|
||||||
|
if self._override_until is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return datetime.now() < self._override_until
|
||||||
|
|
||||||
|
@property
|
||||||
|
def config(self) -> ScheduleConfig:
|
||||||
|
return self._config
|
||||||
|
|
||||||
|
def _load_runtime_state(self) -> None:
|
||||||
|
|
||||||
|
if self._state_path is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self._state_path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(
|
||||||
|
self._state_path.read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except (
|
||||||
|
OSError,
|
||||||
|
json.JSONDecodeError,
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
paused = data.get("paused")
|
||||||
|
|
||||||
|
if isinstance(paused, bool):
|
||||||
|
self._paused = paused
|
||||||
|
|
||||||
|
def _save_runtime_state(self) -> None:
|
||||||
|
|
||||||
|
if self._state_path is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"paused": self._paused,
|
||||||
|
}
|
||||||
|
|
||||||
|
self._state_path.parent.mkdir(
|
||||||
|
parents=True,
|
||||||
|
exist_ok=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
temp_path = (
|
||||||
|
self._state_path.with_suffix(
|
||||||
|
self._state_path.suffix + ".tmp"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
temp_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
data,
|
||||||
|
indent=2,
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
temp_path.replace(
|
||||||
|
self._state_path
|
||||||
|
)
|
||||||
|
|
||||||
|
async def apply_override(self, settings: ScheduledSettings) -> None:
|
||||||
|
|
||||||
|
if settings.empty:
|
||||||
|
raise ValueError(
|
||||||
|
"Override settings cannot be empty"
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._tion is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"TionService is required"
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
resolution = self.resolve(now)
|
||||||
|
|
||||||
|
if not resolution.enabled:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Schedule is disabled"
|
||||||
|
)
|
||||||
|
|
||||||
|
if resolution.current is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Current schedule point not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if resolution.next is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Next schedule point not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Если старый override уже закончился,
|
||||||
|
# перед созданием нового очищаем его.
|
||||||
|
if ( self._override_until is not None
|
||||||
|
and now >= self._override_until
|
||||||
|
):
|
||||||
|
self._clear_override()
|
||||||
|
|
||||||
|
# Добавляем новые ручные параметры
|
||||||
|
# к уже существующему override.
|
||||||
|
self._override_settings = ( self._override_settings.merged(settings) )
|
||||||
|
|
||||||
|
# Override действует строго до
|
||||||
|
# следующей точки расписания.
|
||||||
|
self._override_until = resolution.next.when
|
||||||
|
|
||||||
|
# Считаем его неприменённым,
|
||||||
|
# пока команда реально не прошла.
|
||||||
|
self._override_pending = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._apply_settings(settings)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
self._last_error = (
|
||||||
|
f"{type(exc).__name__}: {exc}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Override остаётся сохранённым.
|
||||||
|
# Фоновый цикл попробует ещё раз.
|
||||||
|
raise
|
||||||
|
|
||||||
|
else:
|
||||||
|
self._override_pending = False
|
||||||
|
self._last_error = None
|
||||||
|
|
||||||
|
def _clear_override(self) -> None:
|
||||||
|
self._override_settings = ScheduledSettings()
|
||||||
|
self._override_until = None
|
||||||
|
self._override_pending = False
|
||||||
|
|
||||||
|
async def clear_override(self) -> None:
|
||||||
|
self._clear_override()
|
||||||
|
|
||||||
|
# Сразу пересчитываем и применяем текущее состояние расписания.
|
||||||
|
await self._process()
|
||||||
|
|
||||||
|
|
||||||
|
async def pause(self) -> None:
|
||||||
|
|
||||||
|
if self._paused:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Сначала запрещаем расписанию вмешиваться.
|
||||||
|
self._paused = True
|
||||||
|
|
||||||
|
# Temporary override больше не имеет смысла:
|
||||||
|
# мы переходим в полноценный ручной режим.
|
||||||
|
self._clear_override()
|
||||||
|
self._last_applied_when = None
|
||||||
|
|
||||||
|
self._save_runtime_state()
|
||||||
|
|
||||||
|
async def resume(self) -> None:
|
||||||
|
|
||||||
|
if not self._paused:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._paused = False
|
||||||
|
|
||||||
|
self._save_runtime_state()
|
||||||
|
|
||||||
|
# Сразу применяем состояние расписания,
|
||||||
|
# актуальное именно сейчас.
|
||||||
|
await self._process()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve(
|
||||||
|
self,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> ScheduleResolution:
|
||||||
|
"""
|
||||||
|
Определить:
|
||||||
|
- текущую точку;
|
||||||
|
- следующую точку;
|
||||||
|
- накопленные SET-настройки;
|
||||||
|
- активен ли сейчас AUTO.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if now is None:
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
if not self._config.enabled:
|
||||||
|
return ScheduleResolution(
|
||||||
|
enabled=False,
|
||||||
|
current=None,
|
||||||
|
next=None,
|
||||||
|
scheduled_settings=ScheduledSettings(),
|
||||||
|
auto_active=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Недели назад достаточно,
|
||||||
|
# поскольку расписание повторяется каждые 7 дней.
|
||||||
|
start_date = now.date() - timedelta(days=7)
|
||||||
|
|
||||||
|
end_date = now.date() + timedelta(days=7)
|
||||||
|
|
||||||
|
occurrences = self._build_occurrences(
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
current = None
|
||||||
|
next_point = None
|
||||||
|
|
||||||
|
for occurrence in occurrences:
|
||||||
|
|
||||||
|
if occurrence.when <= now:
|
||||||
|
current = occurrence
|
||||||
|
continue
|
||||||
|
|
||||||
|
next_point = occurrence
|
||||||
|
break
|
||||||
|
|
||||||
|
if current is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Could not determine current "
|
||||||
|
"schedule point"
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduled_settings = (
|
||||||
|
self._calculate_settings(
|
||||||
|
occurrences,
|
||||||
|
current,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
auto_active = current.point.action.type == ScheduleActionType.AUTO
|
||||||
|
|
||||||
|
auto_fallback_speed = None
|
||||||
|
auto_target_temp = None
|
||||||
|
|
||||||
|
if auto_active:
|
||||||
|
auto_fallback_speed = current.point.action.settings.speed
|
||||||
|
auto_target_temp = current.point.action.settings.target_temp
|
||||||
|
|
||||||
|
return ScheduleResolution(
|
||||||
|
enabled=True,
|
||||||
|
current=current,
|
||||||
|
next=next_point,
|
||||||
|
scheduled_settings=scheduled_settings,
|
||||||
|
auto_active=auto_active,
|
||||||
|
auto_fallback_speed=auto_fallback_speed,
|
||||||
|
auto_target_temp=auto_target_temp,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_occurrences(
|
||||||
|
self,
|
||||||
|
start_date: date,
|
||||||
|
end_date: date,
|
||||||
|
) -> list[ScheduleOccurrence]:
|
||||||
|
|
||||||
|
result: list[ScheduleOccurrence] = []
|
||||||
|
|
||||||
|
current_date = start_date
|
||||||
|
|
||||||
|
while current_date <= end_date:
|
||||||
|
|
||||||
|
weekday = WEEKDAYS[
|
||||||
|
current_date.weekday()
|
||||||
|
]
|
||||||
|
|
||||||
|
template_name = (
|
||||||
|
self._config.days[weekday]
|
||||||
|
)
|
||||||
|
|
||||||
|
template = (
|
||||||
|
self._config.templates[
|
||||||
|
template_name
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
for point in template:
|
||||||
|
|
||||||
|
when = datetime.combine(
|
||||||
|
current_date,
|
||||||
|
point.at,
|
||||||
|
)
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
ScheduleOccurrence(
|
||||||
|
when=when,
|
||||||
|
weekday=weekday,
|
||||||
|
template=template_name,
|
||||||
|
point=point,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
current_date += timedelta(days=1)
|
||||||
|
|
||||||
|
result.sort(
|
||||||
|
key=lambda occurrence:
|
||||||
|
occurrence.when
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _calculate_settings(
|
||||||
|
self,
|
||||||
|
occurrences: list[
|
||||||
|
ScheduleOccurrence
|
||||||
|
],
|
||||||
|
current: ScheduleOccurrence,
|
||||||
|
) -> ScheduledSettings:
|
||||||
|
"""
|
||||||
|
Восстановить последние SET-значения
|
||||||
|
каждого параметра расписания.
|
||||||
|
"""
|
||||||
|
|
||||||
|
settings = ScheduledSettings()
|
||||||
|
|
||||||
|
for occurrence in occurrences:
|
||||||
|
|
||||||
|
if occurrence.when > current.when:
|
||||||
|
break
|
||||||
|
|
||||||
|
action = occurrence.point.action
|
||||||
|
|
||||||
|
if (
|
||||||
|
action.type
|
||||||
|
!= ScheduleActionType.SET
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
settings = settings.merged(
|
||||||
|
action.settings
|
||||||
|
)
|
||||||
|
|
||||||
|
return settings
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._tion is None:
|
||||||
|
raise RuntimeError("TionService is required to start schedule")
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
|
||||||
|
# При запуске сразу приводим Tion
|
||||||
|
# к текущему состоянию расписания.
|
||||||
|
try:
|
||||||
|
await self._process()
|
||||||
|
except Exception as exc:
|
||||||
|
self._last_error = f"{type(exc).__name__}: {exc}"
|
||||||
|
|
||||||
|
self._task = asyncio.create_task(self._loop())
|
||||||
|
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
if self._task is not None:
|
||||||
|
self._task.cancel()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self._task = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _loop(self) -> None:
|
||||||
|
while self._running:
|
||||||
|
await asyncio.sleep(
|
||||||
|
self._check_interval
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._process()
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
self._last_error = f"{type(exc).__name__}: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _process(self) -> None:
|
||||||
|
#Если ручное управление то сразу выходим
|
||||||
|
if self._paused:
|
||||||
|
return
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
resolution = self.resolve(now)
|
||||||
|
|
||||||
|
if not resolution.enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
current = resolution.current
|
||||||
|
|
||||||
|
if current is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# 1. Проверяем окончание temporary override
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
if (
|
||||||
|
self._override_until is not None
|
||||||
|
and now >= self._override_until
|
||||||
|
):
|
||||||
|
self._clear_override()
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# 2. Пока override действует,
|
||||||
|
# расписание Tion не трогает
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
if self.override_active:
|
||||||
|
|
||||||
|
# Если override ранее не удалось применить,
|
||||||
|
# пробуем снова.
|
||||||
|
if self._override_pending:
|
||||||
|
await self._apply_settings(self._override_settings)
|
||||||
|
self._override_pending = False
|
||||||
|
self._last_error = None
|
||||||
|
return
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# 3. Если текущая точка уже успешно применена,
|
||||||
|
# ничего не делаем
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
if self._last_applied_when == current.when:
|
||||||
|
return
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# 4. Применяем состояние расписания
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
settings = resolution.scheduled_settings
|
||||||
|
|
||||||
|
if resolution.auto_active:
|
||||||
|
# В режиме AUTO скорость принадлежит
|
||||||
|
# AutoController.
|
||||||
|
#
|
||||||
|
# Остальные параметры расписания
|
||||||
|
# (power, heater, temperature, mode...)
|
||||||
|
# должны продолжать работать.
|
||||||
|
settings = replace(
|
||||||
|
settings,
|
||||||
|
speed=None,
|
||||||
|
heater=None,
|
||||||
|
target_temp=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._apply_settings(settings)
|
||||||
|
|
||||||
|
# Ставим только ПОСЛЕ успешного применения.
|
||||||
|
self._last_applied_when = current.when
|
||||||
|
self._last_error = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_settings(self, settings: ScheduledSettings) -> None:
|
||||||
|
|
||||||
|
if self._tion is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Если нужно включить Tion —
|
||||||
|
# сначала включаем.
|
||||||
|
if settings.power is True:
|
||||||
|
await self._tion.execute(
|
||||||
|
lambda tion: tion.power_on()
|
||||||
|
)
|
||||||
|
|
||||||
|
if settings.mode is not None:
|
||||||
|
await self._tion.execute(
|
||||||
|
lambda tion:
|
||||||
|
tion.set_air_mode(settings.mode)
|
||||||
|
)
|
||||||
|
|
||||||
|
if settings.target_temp is not None:
|
||||||
|
await self._tion.execute(
|
||||||
|
lambda tion:
|
||||||
|
tion.set_target_temperature(
|
||||||
|
settings.target_temp
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if settings.heater is not None:
|
||||||
|
if settings.heater:
|
||||||
|
await self._tion.execute(
|
||||||
|
lambda tion: tion.heater_on()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self._tion.execute(
|
||||||
|
lambda tion: tion.heater_off()
|
||||||
|
)
|
||||||
|
|
||||||
|
if settings.speed is not None:
|
||||||
|
await self._tion.execute(
|
||||||
|
lambda tion:
|
||||||
|
tion.set_speed(settings.speed)
|
||||||
|
)
|
||||||
|
|
||||||
|
if settings.sound is not None:
|
||||||
|
if settings.sound:
|
||||||
|
await self._tion.execute(
|
||||||
|
lambda tion: tion.sound_on()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self._tion.execute(
|
||||||
|
lambda tion: tion.sound_off()
|
||||||
|
)
|
||||||
|
|
||||||
|
if settings.light is not None:
|
||||||
|
if settings.light:
|
||||||
|
await self._tion.execute(
|
||||||
|
lambda tion: tion.light_on()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self._tion.execute(
|
||||||
|
lambda tion: tion.light_off()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Если нужно выключить —
|
||||||
|
# выключаем последним.
|
||||||
|
if settings.power is False:
|
||||||
|
await self._tion.execute(
|
||||||
|
lambda tion: tion.power_off()
|
||||||
|
)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
from app.my_dataclasses import TION_MAC
|
||||||
|
from app.tion import TionController, TionService
|
||||||
|
|
||||||
|
|
||||||
|
# Убираем служебное логирование библиотек
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
logging.getLogger("asyncio").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("bleak").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("tion_btle").setLevel(logging.WARNING)
|
||||||
|
controller = TionController(TION_MAC)
|
||||||
|
|
||||||
|
service = TionService(
|
||||||
|
controller,
|
||||||
|
poll_interval=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with service:
|
||||||
|
for seconds in range(0, 91, 3):
|
||||||
|
print(
|
||||||
|
f"{seconds:02d}s | "
|
||||||
|
f"online={service.online} | "
|
||||||
|
f"last_seen={service.last_seen} | "
|
||||||
|
f"error={service.last_error}"
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from bleak import BleakScanner
|
||||||
|
|
||||||
|
from app.my_dataclasses import TION_MAC
|
||||||
|
|
||||||
|
|
||||||
|
logging.disable(logging.CRITICAL)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print("Ищу Tion...")
|
||||||
|
|
||||||
|
device = await BleakScanner.find_device_by_address(
|
||||||
|
TION_MAC,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
if device is None:
|
||||||
|
print("Tion НЕ найден сканером")
|
||||||
|
else:
|
||||||
|
print("Tion найден:")
|
||||||
|
print(f" name: {device.name}")
|
||||||
|
print(f" address: {device.address}")
|
||||||
|
print(f" details: {device.details}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#d1749bebeea6
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from tion_btle import TionS4
|
||||||
|
from app.my_dataclasses import *
|
||||||
|
|
||||||
|
|
||||||
|
def print_state(state: dict):
|
||||||
|
print(json.dumps(
|
||||||
|
state,
|
||||||
|
indent=2,
|
||||||
|
ensure_ascii=False
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
# Убираем лишнее логирование
|
||||||
|
logging.getLogger("asyncio").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("bleak").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("tion_btle").setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
tion = TionS4(TION_MAC)
|
||||||
|
|
||||||
|
print("=== Подключение к Tion ===")
|
||||||
|
await tion.connect()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Получаем исходное состояние
|
||||||
|
print("\n=== Исходное состояние ===")
|
||||||
|
state = await tion.get()
|
||||||
|
print_state(state)
|
||||||
|
|
||||||
|
# 2. Включаем Tion и устанавливаем скорость 2
|
||||||
|
print("\n=== Включаю Tion, скорость 2 ===")
|
||||||
|
|
||||||
|
await tion.set({
|
||||||
|
"state": "on",
|
||||||
|
"fan_speed": 2
|
||||||
|
})
|
||||||
|
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
# 3. Повторно читаем реальное состояние
|
||||||
|
print("\n=== Состояние после команды ===")
|
||||||
|
state = await tion.get()
|
||||||
|
print_state(state)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
finally:
|
||||||
|
print("\n=== Отключение ===")
|
||||||
|
await tion.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.auto.config import (
|
||||||
|
load_auto_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = (
|
||||||
|
Path(__file__)
|
||||||
|
.resolve()
|
||||||
|
.parents[1]
|
||||||
|
)
|
||||||
|
|
||||||
|
AUTO_CONFIG_FILE = (
|
||||||
|
PROJECT_ROOT
|
||||||
|
/ "config"
|
||||||
|
/ "auto.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
|
||||||
|
config = load_auto_config(
|
||||||
|
AUTO_CONFIG_FILE
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("AUTO CONFIG")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"Version:",
|
||||||
|
config.version,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"Check interval:",
|
||||||
|
config.check_interval,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"Base speed:",
|
||||||
|
config.co2.base_speed,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"Hysteresis:",
|
||||||
|
config.co2.hysteresis,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"Thresholds:"
|
||||||
|
)
|
||||||
|
|
||||||
|
for ppm, speed in (
|
||||||
|
config.co2.thresholds
|
||||||
|
):
|
||||||
|
print(
|
||||||
|
f" CO2 >= {ppm:<4} "
|
||||||
|
f"-> speed {speed}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert config.version == 1
|
||||||
|
|
||||||
|
assert (
|
||||||
|
config.check_interval
|
||||||
|
== 5.0
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
config.co2.base_speed
|
||||||
|
== 1
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
config.co2.hysteresis
|
||||||
|
== 100
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
config.co2.thresholds
|
||||||
|
== (
|
||||||
|
(800, 2),
|
||||||
|
(1000, 3),
|
||||||
|
(1300, 4),
|
||||||
|
(1600, 5),
|
||||||
|
(2000, 6),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"AUTO CONFIG TEST PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,884 @@
|
|||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from app.auto.co2_policy import Co2SpeedPolicy
|
||||||
|
from app.auto.controller import AutoController
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Fake ScheduleService
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class FakeScheduleService:
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
enabled: bool = True,
|
||||||
|
auto_active: bool = False,
|
||||||
|
fallback_speed: int | None = None,
|
||||||
|
):
|
||||||
|
self.enabled = enabled
|
||||||
|
self.auto_active = auto_active
|
||||||
|
self.fallback_speed = fallback_speed
|
||||||
|
|
||||||
|
self.override_active = False
|
||||||
|
|
||||||
|
def resolve(self, now):
|
||||||
|
return SimpleNamespace(
|
||||||
|
enabled=self.enabled,
|
||||||
|
auto_active=self.auto_active,
|
||||||
|
auto_fallback_speed=self.fallback_speed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Fake QingpingService
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class FakeQingpingService:
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
online: bool = False,
|
||||||
|
co2: int | None = None,
|
||||||
|
):
|
||||||
|
self.online = online
|
||||||
|
|
||||||
|
self.state = SimpleNamespace(
|
||||||
|
co2=co2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Fake Tion
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class FakeTionController:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def set_speed(
|
||||||
|
self,
|
||||||
|
speed: int,
|
||||||
|
):
|
||||||
|
self.calls.append(
|
||||||
|
("set_speed", speed)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.controller = FakeTionController()
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
operation,
|
||||||
|
):
|
||||||
|
return await operation(
|
||||||
|
self.controller
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Helper
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def print_state(
|
||||||
|
title: str,
|
||||||
|
auto: AutoController,
|
||||||
|
tion: FakeTionService,
|
||||||
|
):
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(title)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"Auto status:",
|
||||||
|
auto.status(),
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"Tion calls:",
|
||||||
|
tion.controller.calls,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Main test
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
schedule = FakeScheduleService()
|
||||||
|
|
||||||
|
qingping = FakeQingpingService()
|
||||||
|
|
||||||
|
tion = FakeTionService()
|
||||||
|
|
||||||
|
policy = Co2SpeedPolicy(
|
||||||
|
base_speed=1,
|
||||||
|
thresholds=(
|
||||||
|
(800, 2),
|
||||||
|
(1000, 3),
|
||||||
|
(1300, 4),
|
||||||
|
(1600, 5),
|
||||||
|
(2000, 6),
|
||||||
|
),
|
||||||
|
hysteresis=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
auto = AutoController(
|
||||||
|
schedule_service=schedule,
|
||||||
|
qingping_service=qingping,
|
||||||
|
tion_service=tion,
|
||||||
|
policy=policy,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 1
|
||||||
|
#
|
||||||
|
# AUTO не активен.
|
||||||
|
#
|
||||||
|
# AutoController не должен ничего делать.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
schedule.enabled = True
|
||||||
|
schedule.auto_active = False
|
||||||
|
schedule.fallback_speed = None
|
||||||
|
schedule.override_active = False
|
||||||
|
|
||||||
|
qingping.online = True
|
||||||
|
qingping.state.co2 = 1200
|
||||||
|
|
||||||
|
await auto._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 1 — AUTO INACTIVE",
|
||||||
|
auto,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["state"]
|
||||||
|
== "inactive"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["reason"]
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["target_speed"]
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["auto_speed"]
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 2
|
||||||
|
#
|
||||||
|
# AUTO активен.
|
||||||
|
# Qingping работает.
|
||||||
|
# CO2 = 700.
|
||||||
|
#
|
||||||
|
# Ожидаем speed 1.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
schedule.auto_active = True
|
||||||
|
schedule.fallback_speed = 2
|
||||||
|
|
||||||
|
qingping.online = True
|
||||||
|
qingping.state.co2 = 700
|
||||||
|
|
||||||
|
await auto._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 2 — CO2 700",
|
||||||
|
auto,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["state"]
|
||||||
|
== "active"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["reason"]
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["auto_speed"]
|
||||||
|
== 1
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["target_speed"]
|
||||||
|
== 1
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("set_speed", 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 3
|
||||||
|
#
|
||||||
|
# CO2 вырос до 850.
|
||||||
|
#
|
||||||
|
# Ожидаем переход:
|
||||||
|
#
|
||||||
|
# speed 1 -> speed 2
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
qingping.state.co2 = 850
|
||||||
|
|
||||||
|
await auto._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 3 — CO2 850",
|
||||||
|
auto,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["auto_speed"]
|
||||||
|
== 2
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["target_speed"]
|
||||||
|
== 2
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("set_speed", 1),
|
||||||
|
("set_speed", 2),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 4
|
||||||
|
#
|
||||||
|
# CO2 вырос до 1050.
|
||||||
|
#
|
||||||
|
# Ожидаем:
|
||||||
|
#
|
||||||
|
# speed 2 -> speed 3
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
qingping.state.co2 = 1050
|
||||||
|
|
||||||
|
await auto._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 4 — CO2 1050",
|
||||||
|
auto,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["auto_speed"]
|
||||||
|
== 3
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["target_speed"]
|
||||||
|
== 3
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("set_speed", 1),
|
||||||
|
("set_speed", 2),
|
||||||
|
("set_speed", 3),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 5
|
||||||
|
#
|
||||||
|
# CO2 опустился до 950.
|
||||||
|
#
|
||||||
|
# Порог speed 3:
|
||||||
|
#
|
||||||
|
# вверх = 1000
|
||||||
|
# вниз = 900
|
||||||
|
#
|
||||||
|
# Поэтому остаёмся на speed 3.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
qingping.state.co2 = 950
|
||||||
|
|
||||||
|
await auto._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 5 — HYSTERESIS CO2 950",
|
||||||
|
auto,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["auto_speed"]
|
||||||
|
== 3
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["target_speed"]
|
||||||
|
== 3
|
||||||
|
)
|
||||||
|
|
||||||
|
# Новой команды быть не должно.
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("set_speed", 1),
|
||||||
|
("set_speed", 2),
|
||||||
|
("set_speed", 3),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 6
|
||||||
|
#
|
||||||
|
# CO2 дошёл до 900.
|
||||||
|
#
|
||||||
|
# Теперь переходим:
|
||||||
|
#
|
||||||
|
# speed 3 -> speed 2
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
qingping.state.co2 = 900
|
||||||
|
|
||||||
|
await auto._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 6 — CO2 900",
|
||||||
|
auto,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["auto_speed"]
|
||||||
|
== 2
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["target_speed"]
|
||||||
|
== 2
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("set_speed", 1),
|
||||||
|
("set_speed", 2),
|
||||||
|
("set_speed", 3),
|
||||||
|
("set_speed", 2),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 7
|
||||||
|
#
|
||||||
|
# Qingping offline.
|
||||||
|
#
|
||||||
|
# AUTO должен перейти в fallback.
|
||||||
|
#
|
||||||
|
# fallback speed = 2
|
||||||
|
#
|
||||||
|
# Но Tion уже находится на speed 2,
|
||||||
|
# поэтому повторная команда не нужна.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
qingping.online = False
|
||||||
|
|
||||||
|
await auto._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 7 — QINGPING OFFLINE",
|
||||||
|
auto,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["state"]
|
||||||
|
== "fallback"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["reason"]
|
||||||
|
== "qingping_offline"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["auto_speed"]
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["target_speed"]
|
||||||
|
== 2
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("set_speed", 1),
|
||||||
|
("set_speed", 2),
|
||||||
|
("set_speed", 3),
|
||||||
|
("set_speed", 2),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 8
|
||||||
|
#
|
||||||
|
# Qingping всё ещё offline.
|
||||||
|
#
|
||||||
|
# Одинаковую fallback-команду повторять нельзя.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
await auto._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 8 — FALLBACK NOT REPEATED",
|
||||||
|
auto,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["state"]
|
||||||
|
== "fallback"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("set_speed", 1),
|
||||||
|
("set_speed", 2),
|
||||||
|
("set_speed", 3),
|
||||||
|
("set_speed", 2),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 9
|
||||||
|
#
|
||||||
|
# Qingping online,
|
||||||
|
# но CO2 отсутствует.
|
||||||
|
#
|
||||||
|
# Это тоже fallback.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
qingping.online = True
|
||||||
|
qingping.state.co2 = None
|
||||||
|
|
||||||
|
await auto._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 9 — CO2 MISSING",
|
||||||
|
auto,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["state"]
|
||||||
|
== "fallback"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["reason"]
|
||||||
|
== "co2_missing"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["target_speed"]
|
||||||
|
== 2
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["auto_speed"]
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("set_speed", 1),
|
||||||
|
("set_speed", 2),
|
||||||
|
("set_speed", 3),
|
||||||
|
("set_speed", 2),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 10
|
||||||
|
#
|
||||||
|
# Qingping восстановился.
|
||||||
|
#
|
||||||
|
# CO2 = 1700.
|
||||||
|
#
|
||||||
|
# После fallback auto_speed был сброшен,
|
||||||
|
# поэтому скорость выбирается заново.
|
||||||
|
#
|
||||||
|
# Ожидаем speed 5.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
qingping.online = True
|
||||||
|
qingping.state.co2 = 1700
|
||||||
|
|
||||||
|
await auto._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 10 — RECOVERY CO2 1700",
|
||||||
|
auto,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["state"]
|
||||||
|
== "active"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["reason"]
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["auto_speed"]
|
||||||
|
== 5
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["target_speed"]
|
||||||
|
== 5
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("set_speed", 1),
|
||||||
|
("set_speed", 2),
|
||||||
|
("set_speed", 3),
|
||||||
|
("set_speed", 2),
|
||||||
|
("set_speed", 5),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 11
|
||||||
|
#
|
||||||
|
# Проверяем максимальную скорость 6.
|
||||||
|
#
|
||||||
|
# CO2 = 2200
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
qingping.state.co2 = 2200
|
||||||
|
|
||||||
|
await auto._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 11 — CO2 2200",
|
||||||
|
auto,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["auto_speed"]
|
||||||
|
== 6
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["target_speed"]
|
||||||
|
== 6
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls[-1] == (
|
||||||
|
"set_speed",
|
||||||
|
6,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 12
|
||||||
|
#
|
||||||
|
# Manual override.
|
||||||
|
#
|
||||||
|
# Пользователь управляет Tion вручную.
|
||||||
|
#
|
||||||
|
# AUTO должен полностью отойти в сторону.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
schedule.override_active = True
|
||||||
|
|
||||||
|
await auto._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 12 — MANUAL OVERRIDE",
|
||||||
|
auto,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["state"]
|
||||||
|
== "suspended"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["reason"]
|
||||||
|
== "manual_override"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["target_speed"]
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["auto_speed"]
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Никаких новых команд.
|
||||||
|
assert tion.controller.calls[-1] == (
|
||||||
|
"set_speed",
|
||||||
|
6,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 13
|
||||||
|
#
|
||||||
|
# Manual override закончился.
|
||||||
|
#
|
||||||
|
# AUTO должен заново выбрать скорость
|
||||||
|
# по текущему CO2.
|
||||||
|
#
|
||||||
|
# CO2 остаётся 2200 -> speed 6.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
schedule.override_active = False
|
||||||
|
|
||||||
|
await auto._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 13 — OVERRIDE FINISHED",
|
||||||
|
auto,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["state"]
|
||||||
|
== "active"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["auto_speed"]
|
||||||
|
== 6
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["target_speed"]
|
||||||
|
== 6
|
||||||
|
)
|
||||||
|
|
||||||
|
# target_speed был сброшен во время override,
|
||||||
|
# поэтому команда должна быть отправлена заново.
|
||||||
|
assert tion.controller.calls[-1] == (
|
||||||
|
"set_speed",
|
||||||
|
6,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 14
|
||||||
|
#
|
||||||
|
# Qingping снова падает.
|
||||||
|
#
|
||||||
|
# fallback = 2.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
qingping.online = False
|
||||||
|
|
||||||
|
await auto._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 14 — SECOND FAILURE",
|
||||||
|
auto,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["state"]
|
||||||
|
== "fallback"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["target_speed"]
|
||||||
|
== 2
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["auto_speed"]
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls[-1] == (
|
||||||
|
"set_speed",
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 15
|
||||||
|
#
|
||||||
|
# Началась другая AUTO-точка расписания.
|
||||||
|
#
|
||||||
|
# Новый fallback = 3.
|
||||||
|
#
|
||||||
|
# Qingping по-прежнему offline.
|
||||||
|
#
|
||||||
|
# Ожидаем смену fallback:
|
||||||
|
#
|
||||||
|
# 2 -> 3
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
schedule.fallback_speed = 3
|
||||||
|
|
||||||
|
await auto._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 15 — FALLBACK CHANGED",
|
||||||
|
auto,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["state"]
|
||||||
|
== "fallback"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["target_speed"]
|
||||||
|
== 3
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls[-1] == (
|
||||||
|
"set_speed",
|
||||||
|
3,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 16
|
||||||
|
#
|
||||||
|
# AUTO закончился.
|
||||||
|
#
|
||||||
|
# AutoController перестаёт управлять Tion.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
schedule.auto_active = False
|
||||||
|
schedule.fallback_speed = None
|
||||||
|
|
||||||
|
await auto._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 16 — AUTO FINISHED",
|
||||||
|
auto,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["state"]
|
||||||
|
== "inactive"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["reason"]
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["target_speed"]
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["auto_speed"]
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
# ========================================================
|
||||||
|
# TEST 17
|
||||||
|
#
|
||||||
|
# CO2 policy отсутствует.
|
||||||
|
#
|
||||||
|
# Например, auto.yaml повреждён.
|
||||||
|
# AUTO обязан использовать fallback.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
schedule2 = FakeScheduleService(
|
||||||
|
enabled=True,
|
||||||
|
auto_active=True,
|
||||||
|
fallback_speed=4,
|
||||||
|
)
|
||||||
|
|
||||||
|
qingping2 = FakeQingpingService(
|
||||||
|
online=True,
|
||||||
|
co2=2500,
|
||||||
|
)
|
||||||
|
|
||||||
|
tion2 = FakeTionService()
|
||||||
|
|
||||||
|
auto2 = AutoController(
|
||||||
|
schedule_service=schedule2,
|
||||||
|
qingping_service=qingping2,
|
||||||
|
tion_service=tion2,
|
||||||
|
policy=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
await auto2._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 17 — POLICY UNAVAILABLE",
|
||||||
|
auto2,
|
||||||
|
tion2,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto2.status()["state"]
|
||||||
|
== "fallback"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto2.status()["reason"]
|
||||||
|
== "auto_policy_unavailable"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto2.status()["target_speed"]
|
||||||
|
== 4
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto2.status()["auto_speed"]
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion2.controller.calls == [
|
||||||
|
("set_speed", 4),
|
||||||
|
]
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO CONTROLLER TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from app.auto.controller import AutoController
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionController:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def heater_on(self):
|
||||||
|
self.calls.append(
|
||||||
|
("heater_on", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def heater_off(self):
|
||||||
|
self.calls.append(
|
||||||
|
("heater_off", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.controller = (
|
||||||
|
FakeTionController()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
operation,
|
||||||
|
):
|
||||||
|
return await operation(
|
||||||
|
self.controller
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_controller():
|
||||||
|
|
||||||
|
tion = FakeTionService()
|
||||||
|
|
||||||
|
controller = AutoController(
|
||||||
|
schedule_service=None,
|
||||||
|
qingping_service=None,
|
||||||
|
tion_service=tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
return controller, tion
|
||||||
|
|
||||||
|
|
||||||
|
async def test_heater_on():
|
||||||
|
|
||||||
|
controller, tion = (
|
||||||
|
make_controller()
|
||||||
|
)
|
||||||
|
|
||||||
|
await controller._set_heater(
|
||||||
|
True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("heater_on", None)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._target_heater
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_duplicate_heater_on():
|
||||||
|
|
||||||
|
controller, tion = (
|
||||||
|
make_controller()
|
||||||
|
)
|
||||||
|
|
||||||
|
await controller._set_heater(
|
||||||
|
True
|
||||||
|
)
|
||||||
|
|
||||||
|
await controller._set_heater(
|
||||||
|
True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("heater_on", None)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_heater_off_after_on():
|
||||||
|
|
||||||
|
controller, tion = (
|
||||||
|
make_controller()
|
||||||
|
)
|
||||||
|
|
||||||
|
await controller._set_heater(
|
||||||
|
True
|
||||||
|
)
|
||||||
|
|
||||||
|
await controller._set_heater(
|
||||||
|
False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("heater_on", None),
|
||||||
|
("heater_off", None),
|
||||||
|
]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._target_heater
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_duplicate_heater_off():
|
||||||
|
|
||||||
|
controller, tion = (
|
||||||
|
make_controller()
|
||||||
|
)
|
||||||
|
|
||||||
|
await controller._set_heater(
|
||||||
|
False
|
||||||
|
)
|
||||||
|
|
||||||
|
await controller._set_heater(
|
||||||
|
False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("heater_off", None)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
await test_heater_on()
|
||||||
|
|
||||||
|
await test_duplicate_heater_on()
|
||||||
|
|
||||||
|
await test_heater_off_after_on()
|
||||||
|
|
||||||
|
await test_duplicate_heater_off()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO HEATER COMMAND "
|
||||||
|
"TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from app.auto.config import (
|
||||||
|
TemperatureConfig,
|
||||||
|
)
|
||||||
|
from app.auto.controller import (
|
||||||
|
AutoController,
|
||||||
|
)
|
||||||
|
from app.auto.temperature_policy import (
|
||||||
|
TemperaturePolicy,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeQingpingService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
|
||||||
|
self.online = True
|
||||||
|
|
||||||
|
self.state = SimpleNamespace(
|
||||||
|
temperature=19.4,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionController:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def heater_on(self):
|
||||||
|
self.calls.append(
|
||||||
|
("heater_on", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def heater_off(self):
|
||||||
|
self.calls.append(
|
||||||
|
("heater_off", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
|
||||||
|
self.online = True
|
||||||
|
|
||||||
|
self.state = SimpleNamespace(
|
||||||
|
in_temp=18,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.controller = (
|
||||||
|
FakeTionController()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
operation,
|
||||||
|
):
|
||||||
|
return await operation(
|
||||||
|
self.controller
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_resolution(
|
||||||
|
target_temp=20.0,
|
||||||
|
):
|
||||||
|
|
||||||
|
return SimpleNamespace(
|
||||||
|
scheduled_settings=(
|
||||||
|
SimpleNamespace(
|
||||||
|
target_temp=target_temp,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_controller():
|
||||||
|
|
||||||
|
qingping = (
|
||||||
|
FakeQingpingService()
|
||||||
|
)
|
||||||
|
|
||||||
|
tion = FakeTionService()
|
||||||
|
|
||||||
|
policy = TemperaturePolicy(
|
||||||
|
TemperatureConfig(
|
||||||
|
hysteresis=0.5,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
controller = AutoController(
|
||||||
|
schedule_service=None,
|
||||||
|
qingping_service=qingping,
|
||||||
|
tion_service=tion,
|
||||||
|
temperature_policy=policy,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
controller,
|
||||||
|
qingping,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_qingping_heating():
|
||||||
|
|
||||||
|
controller, qingping, tion = (
|
||||||
|
make_controller()
|
||||||
|
)
|
||||||
|
|
||||||
|
resolution = make_resolution()
|
||||||
|
|
||||||
|
# 19.4 < 19.5
|
||||||
|
# Heater должен включиться.
|
||||||
|
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("heater_on", None)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._auto_heater
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._temperature
|
||||||
|
== 19.4
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._temperature_source
|
||||||
|
== "qingping"
|
||||||
|
)
|
||||||
|
|
||||||
|
tion.controller.calls.clear()
|
||||||
|
|
||||||
|
# ----------------------------------------------
|
||||||
|
# 19.8
|
||||||
|
#
|
||||||
|
# Heater уже ON.
|
||||||
|
# До 20 градусов ещё не дошли.
|
||||||
|
# Продолжаем греть.
|
||||||
|
#
|
||||||
|
# Новую команду отправлять не нужно.
|
||||||
|
# ----------------------------------------------
|
||||||
|
|
||||||
|
qingping.state.temperature = 19.8
|
||||||
|
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == []
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._auto_heater
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
# ----------------------------------------------
|
||||||
|
# 20.0
|
||||||
|
#
|
||||||
|
# Цель достигнута.
|
||||||
|
# ----------------------------------------------
|
||||||
|
|
||||||
|
qingping.state.temperature = 20.0
|
||||||
|
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("heater_off", None)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._auto_heater
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tion_temperature_fallback():
|
||||||
|
|
||||||
|
controller, qingping, tion = (
|
||||||
|
make_controller()
|
||||||
|
)
|
||||||
|
|
||||||
|
resolution = make_resolution()
|
||||||
|
|
||||||
|
# Qingping отключился.
|
||||||
|
qingping.online = False
|
||||||
|
|
||||||
|
# Используем Tion.
|
||||||
|
tion.state.in_temp = 19
|
||||||
|
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("heater_on", None)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._temperature
|
||||||
|
== 19
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._temperature_source
|
||||||
|
== "tion"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._auto_heater
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_no_temperature():
|
||||||
|
|
||||||
|
controller, qingping, tion = (
|
||||||
|
make_controller()
|
||||||
|
)
|
||||||
|
|
||||||
|
resolution = make_resolution()
|
||||||
|
|
||||||
|
qingping.online = False
|
||||||
|
tion.online = False
|
||||||
|
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("heater_off", None)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._temperature
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._temperature_source
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._auto_heater
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_target_temperature_missing():
|
||||||
|
|
||||||
|
controller, qingping, tion = (
|
||||||
|
make_controller()
|
||||||
|
)
|
||||||
|
|
||||||
|
resolution = make_resolution(
|
||||||
|
target_temp=None
|
||||||
|
)
|
||||||
|
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("heater_off", None)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._auto_heater
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
await test_qingping_heating()
|
||||||
|
|
||||||
|
await test_tion_temperature_fallback()
|
||||||
|
|
||||||
|
await test_no_temperature()
|
||||||
|
|
||||||
|
await test_target_temperature_missing()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO HEATER PROCESS "
|
||||||
|
"TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from app.auto.config import (
|
||||||
|
TemperatureConfig,
|
||||||
|
)
|
||||||
|
from app.auto.controller import (
|
||||||
|
AutoController,
|
||||||
|
)
|
||||||
|
from app.auto.temperature_policy import (
|
||||||
|
TemperaturePolicy,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeQingpingService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.online = True
|
||||||
|
|
||||||
|
self.state = SimpleNamespace(
|
||||||
|
temperature=19.5,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionController:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def heater_on(self):
|
||||||
|
self.calls.append(
|
||||||
|
("heater_on", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def heater_off(self):
|
||||||
|
self.calls.append(
|
||||||
|
("heater_off", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.online = True
|
||||||
|
|
||||||
|
self.state = SimpleNamespace(
|
||||||
|
in_temp=19,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.controller = (
|
||||||
|
FakeTionController()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
operation,
|
||||||
|
):
|
||||||
|
return await operation(
|
||||||
|
self.controller
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_auto_uses_auto_target_temperature():
|
||||||
|
|
||||||
|
qingping = FakeQingpingService()
|
||||||
|
tion = FakeTionService()
|
||||||
|
|
||||||
|
policy = TemperaturePolicy(
|
||||||
|
TemperatureConfig(
|
||||||
|
hysteresis=0.5,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
controller = AutoController(
|
||||||
|
schedule_service=None,
|
||||||
|
qingping_service=qingping,
|
||||||
|
tion_service=tion,
|
||||||
|
temperature_policy=policy,
|
||||||
|
)
|
||||||
|
|
||||||
|
resolution = SimpleNamespace(
|
||||||
|
# Последний SET хотел 23°C.
|
||||||
|
scheduled_settings=SimpleNamespace(
|
||||||
|
target_temp=23,
|
||||||
|
),
|
||||||
|
|
||||||
|
# Но текущий AUTO явно хочет 20°C.
|
||||||
|
auto_target_temp=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
# При 19.5°C и target=20°C:
|
||||||
|
#
|
||||||
|
# heater был OFF,
|
||||||
|
# нижняя граница = 19.5°C.
|
||||||
|
#
|
||||||
|
# Поэтому heater должен остаться OFF.
|
||||||
|
#
|
||||||
|
# Если бы контроллер ошибочно использовал
|
||||||
|
# SET target_temp=23, он бы включил heater.
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("heater_off", None)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._auto_heater
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._temperature
|
||||||
|
== 19.5
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._temperature_source
|
||||||
|
== "qingping"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
await test_auto_uses_auto_target_temperature()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO HEATER TARGET "
|
||||||
|
"TEMPERATURE TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from app.auto.config import TemperatureConfig
|
||||||
|
from app.auto.controller import AutoController
|
||||||
|
from app.auto.temperature_policy import TemperaturePolicy
|
||||||
|
from app.my_dataclasses import MAX_TARGET_TEMP
|
||||||
|
|
||||||
|
|
||||||
|
class FakeQingpingService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.online = True
|
||||||
|
|
||||||
|
self.state = SimpleNamespace(
|
||||||
|
temperature=19.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionController:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def set_target_temperature(
|
||||||
|
self,
|
||||||
|
temperature: int,
|
||||||
|
):
|
||||||
|
self.calls.append(
|
||||||
|
(
|
||||||
|
"set_target_temperature",
|
||||||
|
temperature,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def heater_on(self):
|
||||||
|
self.calls.append(
|
||||||
|
(
|
||||||
|
"heater_on",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def heater_off(self):
|
||||||
|
self.calls.append(
|
||||||
|
(
|
||||||
|
"heater_off",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.online = True
|
||||||
|
|
||||||
|
self.state = SimpleNamespace(
|
||||||
|
in_temp=22,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.controller = FakeTionController()
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
operation,
|
||||||
|
):
|
||||||
|
return await operation(
|
||||||
|
self.controller
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_controller():
|
||||||
|
|
||||||
|
qingping = FakeQingpingService()
|
||||||
|
tion = FakeTionService()
|
||||||
|
|
||||||
|
temperature_policy = TemperaturePolicy(
|
||||||
|
TemperatureConfig(
|
||||||
|
hysteresis=0.5,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
controller = AutoController(
|
||||||
|
schedule_service=None,
|
||||||
|
qingping_service=qingping,
|
||||||
|
tion_service=tion,
|
||||||
|
temperature_policy=temperature_policy,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
controller,
|
||||||
|
qingping,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_resolution(
|
||||||
|
target_temp: int,
|
||||||
|
):
|
||||||
|
return SimpleNamespace(
|
||||||
|
auto_target_temp=target_temp,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_heating_sets_tion_target_first():
|
||||||
|
|
||||||
|
controller, qingping, tion = (
|
||||||
|
make_controller()
|
||||||
|
)
|
||||||
|
|
||||||
|
resolution = make_resolution(
|
||||||
|
target_temp=20
|
||||||
|
)
|
||||||
|
|
||||||
|
# Qingping = 19.0
|
||||||
|
# AUTO target = 20
|
||||||
|
#
|
||||||
|
# Нужно греть.
|
||||||
|
#
|
||||||
|
# Сначала Tion.target_temp поднимается
|
||||||
|
# до технического максимума,
|
||||||
|
# затем включается heater.
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
(
|
||||||
|
"set_target_temperature",
|
||||||
|
MAX_TARGET_TEMP,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"heater_on",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._target_temperature
|
||||||
|
== MAX_TARGET_TEMP
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._target_heater
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._auto_heater
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_repeated_heating_does_not_repeat_commands():
|
||||||
|
|
||||||
|
controller, qingping, tion = (
|
||||||
|
make_controller()
|
||||||
|
)
|
||||||
|
|
||||||
|
resolution = make_resolution(
|
||||||
|
target_temp=20
|
||||||
|
)
|
||||||
|
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
tion.controller.calls.clear()
|
||||||
|
|
||||||
|
# Температура всё ещё ниже цели.
|
||||||
|
# AUTO продолжает хотеть нагрев,
|
||||||
|
# но повторно слать команды Tion не нужно.
|
||||||
|
qingping.state.temperature = 19.2
|
||||||
|
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_target_reached_turns_heater_off():
|
||||||
|
|
||||||
|
controller, qingping, tion = (
|
||||||
|
make_controller()
|
||||||
|
)
|
||||||
|
|
||||||
|
resolution = make_resolution(
|
||||||
|
target_temp=20
|
||||||
|
)
|
||||||
|
|
||||||
|
# Сначала включаем нагрев.
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
tion.controller.calls.clear()
|
||||||
|
|
||||||
|
# Цель достигнута.
|
||||||
|
qingping.state.temperature = 20.0
|
||||||
|
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
# MAX_TARGET_TEMP обратно сейчас не меняем.
|
||||||
|
# Достаточно запретить нагрев.
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
(
|
||||||
|
"heater_off",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._auto_heater
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._target_heater
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
await test_heating_sets_tion_target_first()
|
||||||
|
|
||||||
|
await test_repeated_heating_does_not_repeat_commands()
|
||||||
|
|
||||||
|
await test_target_reached_turns_heater_off()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO HEATER TION TARGET "
|
||||||
|
"TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from app.auto.controller import AutoController
|
||||||
|
|
||||||
|
|
||||||
|
class FakeScheduleService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.paused = True
|
||||||
|
self.override_active = False
|
||||||
|
|
||||||
|
def resolve(self, now):
|
||||||
|
return SimpleNamespace(
|
||||||
|
enabled=True,
|
||||||
|
auto_active=True,
|
||||||
|
auto_fallback_speed=1,
|
||||||
|
auto_target_temp=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeQingpingService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.online = True
|
||||||
|
|
||||||
|
self.state = SimpleNamespace(
|
||||||
|
co2=1200,
|
||||||
|
temperature=19.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionController:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def set_speed(self, speed):
|
||||||
|
self.calls.append(
|
||||||
|
("set_speed", speed)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def heater_on(self):
|
||||||
|
self.calls.append(
|
||||||
|
("heater_on", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def heater_off(self):
|
||||||
|
self.calls.append(
|
||||||
|
("heater_off", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_target_temperature(
|
||||||
|
self,
|
||||||
|
temperature,
|
||||||
|
):
|
||||||
|
self.calls.append(
|
||||||
|
(
|
||||||
|
"set_target_temperature",
|
||||||
|
temperature,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.online = True
|
||||||
|
|
||||||
|
self.state = SimpleNamespace(
|
||||||
|
in_temp=19,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.controller = FakeTionController()
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
operation,
|
||||||
|
):
|
||||||
|
return await operation(
|
||||||
|
self.controller
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_paused_schedule_suspends_auto():
|
||||||
|
|
||||||
|
schedule = FakeScheduleService()
|
||||||
|
qingping = FakeQingpingService()
|
||||||
|
tion = FakeTionService()
|
||||||
|
|
||||||
|
controller = AutoController(
|
||||||
|
schedule_service=schedule,
|
||||||
|
qingping_service=qingping,
|
||||||
|
tion_service=tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Имитируем состояние,
|
||||||
|
# которое AUTO помнил до pause.
|
||||||
|
controller._target_speed = 3
|
||||||
|
controller._auto_speed = 3
|
||||||
|
|
||||||
|
controller._target_heater = True
|
||||||
|
controller._auto_heater = True
|
||||||
|
|
||||||
|
controller._target_temperature = 30
|
||||||
|
|
||||||
|
controller._temperature = 19.0
|
||||||
|
controller._temperature_source = (
|
||||||
|
"qingping"
|
||||||
|
)
|
||||||
|
|
||||||
|
await controller._process()
|
||||||
|
|
||||||
|
status = controller.status()
|
||||||
|
|
||||||
|
# AUTO должен быть остановлен.
|
||||||
|
assert (
|
||||||
|
status["state"]
|
||||||
|
== "suspended"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
status["reason"]
|
||||||
|
== "schedule_paused"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Никаких команд Tion.
|
||||||
|
assert tion.controller.calls == []
|
||||||
|
|
||||||
|
# Старое владение AUTO забыто.
|
||||||
|
assert (
|
||||||
|
controller._target_speed
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._auto_speed
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._target_heater
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._auto_heater
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._target_temperature
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._temperature
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._temperature_source
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
await test_paused_schedule_suspends_auto()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO SCHEDULE PAUSE "
|
||||||
|
"TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
from app.auto.config import (
|
||||||
|
load_auto_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
BASE_CONFIG = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
check_interval: 5.0
|
||||||
|
|
||||||
|
co2:
|
||||||
|
base_speed: 1
|
||||||
|
hysteresis: 100
|
||||||
|
|
||||||
|
thresholds:
|
||||||
|
- ppm: 800
|
||||||
|
speed: 2
|
||||||
|
|
||||||
|
temperature:
|
||||||
|
hysteresis: 0.5
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(text: str):
|
||||||
|
temp_dir = TemporaryDirectory()
|
||||||
|
|
||||||
|
path = (
|
||||||
|
Path(temp_dir.name)
|
||||||
|
/ "auto.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
path.write_text(
|
||||||
|
text,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
config = load_auto_config(path)
|
||||||
|
|
||||||
|
return temp_dir, config
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_temperature_config():
|
||||||
|
|
||||||
|
temp_dir, config = load_config(
|
||||||
|
BASE_CONFIG
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("VALID TEMPERATURE CONFIG")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"hysteresis:",
|
||||||
|
config.temperature.hysteresis,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
config.temperature.hysteresis
|
||||||
|
== 0.5
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
def test_temperature_config_required():
|
||||||
|
|
||||||
|
config_text = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
check_interval: 5.0
|
||||||
|
|
||||||
|
co2:
|
||||||
|
base_speed: 1
|
||||||
|
hysteresis: 100
|
||||||
|
|
||||||
|
thresholds:
|
||||||
|
- ppm: 800
|
||||||
|
speed: 2
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
temp_dir, _ = load_config(
|
||||||
|
config_text
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as exc:
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("MISSING TEMPERATURE CONFIG")
|
||||||
|
print("=" * 70)
|
||||||
|
print(exc)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
str(exc)
|
||||||
|
== "temperature config is required"
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
else:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
raise AssertionError(
|
||||||
|
"Missing temperature config "
|
||||||
|
"must raise ValueError"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_temperature_must_be_mapping():
|
||||||
|
|
||||||
|
config_text = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
check_interval: 5.0
|
||||||
|
|
||||||
|
co2:
|
||||||
|
base_speed: 1
|
||||||
|
hysteresis: 100
|
||||||
|
|
||||||
|
thresholds:
|
||||||
|
- ppm: 800
|
||||||
|
speed: 2
|
||||||
|
|
||||||
|
temperature: 0.5
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
temp_dir, _ = load_config(
|
||||||
|
config_text
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as exc:
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("TEMPERATURE MUST BE MAPPING")
|
||||||
|
print("=" * 70)
|
||||||
|
print(exc)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
str(exc)
|
||||||
|
== "temperature must be an object"
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
else:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
raise AssertionError(
|
||||||
|
"Invalid temperature mapping "
|
||||||
|
"must raise ValueError"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_temperature_hysteresis_must_be_number():
|
||||||
|
|
||||||
|
config_text = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
check_interval: 5.0
|
||||||
|
|
||||||
|
co2:
|
||||||
|
base_speed: 1
|
||||||
|
hysteresis: 100
|
||||||
|
|
||||||
|
thresholds:
|
||||||
|
- ppm: 800
|
||||||
|
speed: 2
|
||||||
|
|
||||||
|
temperature:
|
||||||
|
hysteresis: abc
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
temp_dir, _ = load_config(
|
||||||
|
config_text
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as exc:
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"HYSTERESIS MUST BE NUMBER"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
print(exc)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
str(exc)
|
||||||
|
== (
|
||||||
|
"temperature.hysteresis "
|
||||||
|
"must be a number"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
else:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
raise AssertionError(
|
||||||
|
"Non-numeric hysteresis "
|
||||||
|
"must raise ValueError"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_temperature_hysteresis_must_not_be_negative():
|
||||||
|
|
||||||
|
config_text = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
check_interval: 5.0
|
||||||
|
|
||||||
|
co2:
|
||||||
|
base_speed: 1
|
||||||
|
hysteresis: 100
|
||||||
|
|
||||||
|
thresholds:
|
||||||
|
- ppm: 800
|
||||||
|
speed: 2
|
||||||
|
|
||||||
|
temperature:
|
||||||
|
hysteresis: -0.1
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
temp_dir, _ = load_config(
|
||||||
|
config_text
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as exc:
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"HYSTERESIS MUST NOT BE NEGATIVE"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
print(exc)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
str(exc)
|
||||||
|
== (
|
||||||
|
"temperature.hysteresis "
|
||||||
|
"must be >= 0"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
else:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
raise AssertionError(
|
||||||
|
"Negative hysteresis "
|
||||||
|
"must raise ValueError"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_temperature_field():
|
||||||
|
|
||||||
|
config_text = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
check_interval: 5.0
|
||||||
|
|
||||||
|
co2:
|
||||||
|
base_speed: 1
|
||||||
|
hysteresis: 100
|
||||||
|
|
||||||
|
thresholds:
|
||||||
|
- ppm: 800
|
||||||
|
speed: 2
|
||||||
|
|
||||||
|
temperature:
|
||||||
|
hysteresis: 0.5
|
||||||
|
something_else: 123
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
temp_dir, _ = load_config(
|
||||||
|
config_text
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as exc:
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"UNKNOWN TEMPERATURE FIELD"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
print(exc)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
str(exc)
|
||||||
|
== (
|
||||||
|
"Unknown temperature config fields: "
|
||||||
|
"['something_else']"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
else:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
raise AssertionError(
|
||||||
|
"Unknown temperature field "
|
||||||
|
"must raise ValueError"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_temperature_is_allowed_top_level_field():
|
||||||
|
|
||||||
|
temp_dir, config = load_config(
|
||||||
|
BASE_CONFIG
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
assert (
|
||||||
|
config.temperature.hysteresis
|
||||||
|
== 0.5
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
|
||||||
|
test_valid_temperature_config()
|
||||||
|
test_temperature_config_required()
|
||||||
|
test_temperature_must_be_mapping()
|
||||||
|
test_temperature_hysteresis_must_be_number()
|
||||||
|
test_temperature_hysteresis_must_not_be_negative()
|
||||||
|
test_unknown_temperature_field()
|
||||||
|
test_temperature_is_allowed_top_level_field()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO TEMPERATURE CONFIG "
|
||||||
|
"TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
from app.auto.config import (
|
||||||
|
TemperatureConfig,
|
||||||
|
)
|
||||||
|
from app.auto.temperature_policy import (
|
||||||
|
TemperaturePolicy,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_policy() -> TemperaturePolicy:
|
||||||
|
|
||||||
|
return TemperaturePolicy(
|
||||||
|
TemperatureConfig(
|
||||||
|
hysteresis=0.5,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_heater_off_below_lower_limit():
|
||||||
|
|
||||||
|
policy = make_policy()
|
||||||
|
|
||||||
|
result = policy.heater_required(
|
||||||
|
temperature=19.4,
|
||||||
|
target_temp=20.0,
|
||||||
|
heater_on=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_heater_off_at_lower_limit():
|
||||||
|
|
||||||
|
policy = make_policy()
|
||||||
|
|
||||||
|
result = policy.heater_required(
|
||||||
|
temperature=19.5,
|
||||||
|
target_temp=20.0,
|
||||||
|
heater_on=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_heater_off_inside_hysteresis():
|
||||||
|
|
||||||
|
policy = make_policy()
|
||||||
|
|
||||||
|
result = policy.heater_required(
|
||||||
|
temperature=19.8,
|
||||||
|
target_temp=20.0,
|
||||||
|
heater_on=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_heater_on_below_target():
|
||||||
|
|
||||||
|
policy = make_policy()
|
||||||
|
|
||||||
|
result = policy.heater_required(
|
||||||
|
temperature=19.8,
|
||||||
|
target_temp=20.0,
|
||||||
|
heater_on=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_heater_on_at_target():
|
||||||
|
|
||||||
|
policy = make_policy()
|
||||||
|
|
||||||
|
result = policy.heater_required(
|
||||||
|
temperature=20.0,
|
||||||
|
target_temp=20.0,
|
||||||
|
heater_on=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_heater_on_above_target():
|
||||||
|
|
||||||
|
policy = make_policy()
|
||||||
|
|
||||||
|
result = policy.heater_required(
|
||||||
|
temperature=20.2,
|
||||||
|
target_temp=20.0,
|
||||||
|
heater_on=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
|
||||||
|
test_heater_off_below_lower_limit()
|
||||||
|
test_heater_off_at_lower_limit()
|
||||||
|
test_heater_off_inside_hysteresis()
|
||||||
|
|
||||||
|
test_heater_on_below_target()
|
||||||
|
test_heater_on_at_target()
|
||||||
|
test_heater_on_above_target()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO TEMPERATURE POLICY "
|
||||||
|
"TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from app.auto.controller import AutoController
|
||||||
|
|
||||||
|
|
||||||
|
class FakeQingpingService:
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
online: bool,
|
||||||
|
temperature,
|
||||||
|
):
|
||||||
|
self.online = online
|
||||||
|
|
||||||
|
self.state = SimpleNamespace(
|
||||||
|
temperature=temperature,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionService:
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
online: bool,
|
||||||
|
in_temp,
|
||||||
|
):
|
||||||
|
self.online = online
|
||||||
|
|
||||||
|
self.state = SimpleNamespace(
|
||||||
|
in_temp=in_temp,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_controller(
|
||||||
|
*,
|
||||||
|
qingping_online: bool,
|
||||||
|
qingping_temperature,
|
||||||
|
tion_online: bool,
|
||||||
|
tion_temperature,
|
||||||
|
):
|
||||||
|
|
||||||
|
qingping = FakeQingpingService(
|
||||||
|
online=qingping_online,
|
||||||
|
temperature=qingping_temperature,
|
||||||
|
)
|
||||||
|
|
||||||
|
tion = FakeTionService(
|
||||||
|
online=tion_online,
|
||||||
|
in_temp=tion_temperature,
|
||||||
|
)
|
||||||
|
|
||||||
|
return AutoController(
|
||||||
|
schedule_service=None,
|
||||||
|
qingping_service=qingping,
|
||||||
|
tion_service=tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_qingping_priority():
|
||||||
|
|
||||||
|
controller = make_controller(
|
||||||
|
qingping_online=True,
|
||||||
|
qingping_temperature=19.7,
|
||||||
|
tion_online=True,
|
||||||
|
tion_temperature=18,
|
||||||
|
)
|
||||||
|
|
||||||
|
temperature, source = (
|
||||||
|
controller._get_temperature()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert temperature == 19.7
|
||||||
|
assert source == "qingping"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tion_fallback():
|
||||||
|
|
||||||
|
controller = make_controller(
|
||||||
|
qingping_online=False,
|
||||||
|
qingping_temperature=19.7,
|
||||||
|
tion_online=True,
|
||||||
|
tion_temperature=18,
|
||||||
|
)
|
||||||
|
|
||||||
|
temperature, source = (
|
||||||
|
controller._get_temperature()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert temperature == 18
|
||||||
|
assert source == "tion"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tion_fallback_when_qingping_temperature_missing():
|
||||||
|
|
||||||
|
controller = make_controller(
|
||||||
|
qingping_online=True,
|
||||||
|
qingping_temperature=None,
|
||||||
|
tion_online=True,
|
||||||
|
tion_temperature=18,
|
||||||
|
)
|
||||||
|
|
||||||
|
temperature, source = (
|
||||||
|
controller._get_temperature()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert temperature == 18
|
||||||
|
assert source == "tion"
|
||||||
|
|
||||||
|
|
||||||
|
def test_offline_tion_is_not_used():
|
||||||
|
|
||||||
|
controller = make_controller(
|
||||||
|
qingping_online=False,
|
||||||
|
qingping_temperature=None,
|
||||||
|
tion_online=False,
|
||||||
|
|
||||||
|
# Значение специально оставляем.
|
||||||
|
# Оно имитирует старый state Tion.
|
||||||
|
tion_temperature=18,
|
||||||
|
)
|
||||||
|
|
||||||
|
temperature, source = (
|
||||||
|
controller._get_temperature()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert temperature is None
|
||||||
|
assert source is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_tion_temperature():
|
||||||
|
|
||||||
|
controller = make_controller(
|
||||||
|
qingping_online=False,
|
||||||
|
qingping_temperature=None,
|
||||||
|
tion_online=True,
|
||||||
|
tion_temperature=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
temperature, source = (
|
||||||
|
controller._get_temperature()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert temperature is None
|
||||||
|
assert source is None
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
|
||||||
|
test_qingping_priority()
|
||||||
|
|
||||||
|
test_tion_fallback()
|
||||||
|
|
||||||
|
test_tion_fallback_when_qingping_temperature_missing()
|
||||||
|
|
||||||
|
test_offline_tion_is_not_used()
|
||||||
|
|
||||||
|
test_missing_tion_temperature()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO TEMPERATURE SOURCE "
|
||||||
|
"TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from bleak import BleakScanner
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print("Scanning for 20 seconds...")
|
||||||
|
|
||||||
|
devices = await BleakScanner.discover(
|
||||||
|
timeout=20.0,
|
||||||
|
return_adv=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f"Found: {len(devices)} devices")
|
||||||
|
print()
|
||||||
|
|
||||||
|
for address, (device, adv) in devices.items():
|
||||||
|
print("ADDRESS:", address)
|
||||||
|
print("NAME:", device.name)
|
||||||
|
print("RSSI:", adv.rssi)
|
||||||
|
print("UUIDS:", adv.service_uuids)
|
||||||
|
print("SERVICE DATA:", adv.service_data)
|
||||||
|
print("-" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
from app.auto.co2_policy import (
|
||||||
|
Co2SpeedPolicy,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
policy = Co2SpeedPolicy(
|
||||||
|
base_speed=1,
|
||||||
|
thresholds=(
|
||||||
|
(800, 2),
|
||||||
|
(1000, 3),
|
||||||
|
(1300, 4),
|
||||||
|
(1600, 5),
|
||||||
|
(2000, 6),
|
||||||
|
),
|
||||||
|
hysteresis=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("TEST 1 — INITIAL SPEED")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
cases = (
|
||||||
|
(500, 1),
|
||||||
|
(799, 1),
|
||||||
|
(800, 2),
|
||||||
|
(999, 2),
|
||||||
|
(1000, 3),
|
||||||
|
(1299, 3),
|
||||||
|
(1300, 4),
|
||||||
|
(1599, 4),
|
||||||
|
(1600, 5),
|
||||||
|
(1999, 5),
|
||||||
|
(2000, 6),
|
||||||
|
(2500, 6),
|
||||||
|
)
|
||||||
|
|
||||||
|
for co2, expected in cases:
|
||||||
|
|
||||||
|
result = policy.select_speed(
|
||||||
|
co2,
|
||||||
|
current_speed=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"CO2={co2:<4} "
|
||||||
|
f"-> speed={result}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 2
|
||||||
|
#
|
||||||
|
# Переход 1 -> 2 при 800 ppm.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("TEST 2 — SPEED UP")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
speed = 1
|
||||||
|
|
||||||
|
speed = policy.select_speed(
|
||||||
|
790,
|
||||||
|
speed,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert speed == 1
|
||||||
|
|
||||||
|
speed = policy.select_speed(
|
||||||
|
805,
|
||||||
|
speed,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert speed == 2
|
||||||
|
|
||||||
|
print(
|
||||||
|
"790 -> speed 1"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"805 -> speed 2"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 3
|
||||||
|
#
|
||||||
|
# CO2 немного упал ниже 800.
|
||||||
|
#
|
||||||
|
# Без гистерезиса получили бы:
|
||||||
|
# 2 -> 1.
|
||||||
|
#
|
||||||
|
# Но пока CO2 > 700,
|
||||||
|
# остаёмся на второй скорости.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("TEST 3 — HYSTERESIS")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
speed = policy.select_speed(
|
||||||
|
790,
|
||||||
|
current_speed=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"speed=2, CO2=790 "
|
||||||
|
f"-> speed={speed}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert speed == 2
|
||||||
|
|
||||||
|
speed = policy.select_speed(
|
||||||
|
750,
|
||||||
|
current_speed=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"speed=2, CO2=750 "
|
||||||
|
f"-> speed={speed}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert speed == 2
|
||||||
|
|
||||||
|
speed = policy.select_speed(
|
||||||
|
700,
|
||||||
|
current_speed=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"speed=2, CO2=700 "
|
||||||
|
f"-> speed={speed}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert speed == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 4
|
||||||
|
#
|
||||||
|
# Гистерезис между speed 2 и speed 3.
|
||||||
|
#
|
||||||
|
# Вверх:
|
||||||
|
# 1000 ppm.
|
||||||
|
#
|
||||||
|
# Вниз:
|
||||||
|
# 900 ppm.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("TEST 4 — SPEED 2 / SPEED 3")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
speed = policy.select_speed(
|
||||||
|
1005,
|
||||||
|
current_speed=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert speed == 3
|
||||||
|
|
||||||
|
print(
|
||||||
|
"speed=2, CO2=1005 "
|
||||||
|
f"-> speed={speed}"
|
||||||
|
)
|
||||||
|
|
||||||
|
speed = policy.select_speed(
|
||||||
|
950,
|
||||||
|
current_speed=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert speed == 3
|
||||||
|
|
||||||
|
print(
|
||||||
|
"speed=3, CO2=950 "
|
||||||
|
f"-> speed={speed}"
|
||||||
|
)
|
||||||
|
|
||||||
|
speed = policy.select_speed(
|
||||||
|
900,
|
||||||
|
current_speed=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert speed == 2
|
||||||
|
|
||||||
|
print(
|
||||||
|
"speed=3, CO2=900 "
|
||||||
|
f"-> speed={speed}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 5
|
||||||
|
#
|
||||||
|
# Резкий рост CO2.
|
||||||
|
#
|
||||||
|
# Не нужно ждать:
|
||||||
|
# 1 -> 2 -> 3 -> 4 -> 5
|
||||||
|
#
|
||||||
|
# Можно сразу выбрать необходимую скорость.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("TEST 5 — LARGE CO2 JUMP")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
speed = policy.select_speed(
|
||||||
|
2200,
|
||||||
|
current_speed=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"speed=1, CO2=2200 "
|
||||||
|
f"-> speed={speed}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert speed == 6
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 6
|
||||||
|
#
|
||||||
|
# Аналогично при сильном падении CO2
|
||||||
|
# разрешаем сразу снизить несколько ступеней.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("TEST 6 — LARGE CO2 DROP")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
speed = policy.select_speed(
|
||||||
|
650,
|
||||||
|
current_speed=6,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"speed=6, CO2=650 "
|
||||||
|
f"-> speed={speed}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert speed == 1
|
||||||
|
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL CO2 POLICY TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
from app.qingping.service import QingpingService
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
service = QingpingService(
|
||||||
|
host="192.168.7.3",
|
||||||
|
port=1883,
|
||||||
|
mac="CCB5D131BA93",
|
||||||
|
)
|
||||||
|
|
||||||
|
await service.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(15)
|
||||||
|
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
service.status(),
|
||||||
|
indent=2,
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await service.stop()
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import json
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
|
||||||
|
|
||||||
|
HOST = "192.168.7.3"
|
||||||
|
PORT = 1883
|
||||||
|
TOPIC = "qingping/CCB5D131BA93/up"
|
||||||
|
|
||||||
|
|
||||||
|
def on_connect(client, userdata, flags, reason_code, properties):
|
||||||
|
print("Connected:", reason_code)
|
||||||
|
|
||||||
|
client.subscribe(TOPIC)
|
||||||
|
|
||||||
|
|
||||||
|
def on_message(client, userdata, message):
|
||||||
|
try:
|
||||||
|
payload = json.loads(message.payload.decode("utf-8"))
|
||||||
|
except Exception:
|
||||||
|
print("RAW:", message.payload)
|
||||||
|
return
|
||||||
|
|
||||||
|
message_type = str(payload.get("type"))
|
||||||
|
message_id = payload.get("id")
|
||||||
|
message_timestamp = payload.get("timestamp")
|
||||||
|
|
||||||
|
sensor_timestamps = []
|
||||||
|
|
||||||
|
sensor_data = payload.get("sensorData")
|
||||||
|
|
||||||
|
if isinstance(sensor_data, list):
|
||||||
|
for sample in sensor_data:
|
||||||
|
timestamp = sample.get("timestamp")
|
||||||
|
|
||||||
|
if isinstance(timestamp, dict):
|
||||||
|
timestamp = timestamp.get("value")
|
||||||
|
|
||||||
|
sensor_timestamps.append(timestamp)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"id={message_id!s:<4} "
|
||||||
|
f"type={message_type:<3} "
|
||||||
|
f"msg_ts={message_timestamp!s:<12} "
|
||||||
|
f"samples={sensor_timestamps}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
client = mqtt.Client(
|
||||||
|
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
||||||
|
client_id="qingping-raw-debug",
|
||||||
|
)
|
||||||
|
|
||||||
|
client.on_connect = on_connect
|
||||||
|
client.on_message = on_message
|
||||||
|
|
||||||
|
client.connect(HOST, PORT, keepalive=60)
|
||||||
|
|
||||||
|
print(f"Listening: {TOPIC}")
|
||||||
|
|
||||||
|
client.loop_forever()
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
|
||||||
|
from app.my_dataclasses import (
|
||||||
|
QINGPING_SAMPLE_TIMEOUT,
|
||||||
|
QINGPING_RECOVERY_TIMEOUT,
|
||||||
|
)
|
||||||
|
from app.qingping.service import QingpingService
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClock:
|
||||||
|
def __init__(self):
|
||||||
|
self.now = 0.0
|
||||||
|
|
||||||
|
def monotonic(self) -> float:
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
def advance(self, seconds: float) -> None:
|
||||||
|
self.now += seconds
|
||||||
|
|
||||||
|
|
||||||
|
class FakeMqttClient:
|
||||||
|
def __init__(self):
|
||||||
|
self.subscriptions = []
|
||||||
|
self.published = []
|
||||||
|
|
||||||
|
def subscribe(self, topic):
|
||||||
|
self.subscriptions.append(topic)
|
||||||
|
|
||||||
|
def publish(
|
||||||
|
self,
|
||||||
|
topic,
|
||||||
|
payload,
|
||||||
|
):
|
||||||
|
self.published.append(
|
||||||
|
(
|
||||||
|
topic,
|
||||||
|
json.loads(payload),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return SimpleNamespace(
|
||||||
|
rc=mqtt.MQTT_ERR_SUCCESS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeMessage:
|
||||||
|
def __init__(self, payload: dict):
|
||||||
|
self.payload = json.dumps(
|
||||||
|
payload
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def sensor_message(
|
||||||
|
timestamp: int,
|
||||||
|
co2: int,
|
||||||
|
) -> FakeMessage:
|
||||||
|
return FakeMessage(
|
||||||
|
{
|
||||||
|
"type": 17,
|
||||||
|
"sensorData": [
|
||||||
|
{
|
||||||
|
"timestamp": {
|
||||||
|
"value": timestamp
|
||||||
|
},
|
||||||
|
"temperature": {
|
||||||
|
"value": 25.0
|
||||||
|
},
|
||||||
|
"humidity": {
|
||||||
|
"value": 50.0
|
||||||
|
},
|
||||||
|
"co2": {
|
||||||
|
"value": co2
|
||||||
|
},
|
||||||
|
"pm25": {
|
||||||
|
"value": 1
|
||||||
|
},
|
||||||
|
"pm10": {
|
||||||
|
"value": 1
|
||||||
|
},
|
||||||
|
"battery": {
|
||||||
|
"value": 100
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def service_message() -> FakeMessage:
|
||||||
|
return FakeMessage(
|
||||||
|
{
|
||||||
|
"type": 13,
|
||||||
|
"wifi_info": (
|
||||||
|
"TestWiFi,-50,1,"
|
||||||
|
"00:00:00:00:00:00"
|
||||||
|
),
|
||||||
|
"sw_version": "4.8.5",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_state(
|
||||||
|
service: QingpingService,
|
||||||
|
*,
|
||||||
|
online: bool,
|
||||||
|
recovery_waiting: bool,
|
||||||
|
co2,
|
||||||
|
):
|
||||||
|
status = service.status()
|
||||||
|
|
||||||
|
assert status["online"] is online, status
|
||||||
|
assert (
|
||||||
|
status["recovery_waiting"]
|
||||||
|
is recovery_waiting
|
||||||
|
), status
|
||||||
|
|
||||||
|
assert status["co2"] == co2, status
|
||||||
|
|
||||||
|
|
||||||
|
def test_double_recovery_cycle() -> None:
|
||||||
|
print()
|
||||||
|
print("TEST — DOUBLE RECOVERY CYCLE")
|
||||||
|
|
||||||
|
clock = FakeClock()
|
||||||
|
|
||||||
|
service = QingpingService(
|
||||||
|
host="127.0.0.1",
|
||||||
|
)
|
||||||
|
|
||||||
|
client = FakeMqttClient()
|
||||||
|
|
||||||
|
# _send_recovery() использует self._client.
|
||||||
|
service._client = client
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.qingping.service.time.monotonic",
|
||||||
|
clock.monotonic,
|
||||||
|
):
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# START
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
print("1. MQTT connect")
|
||||||
|
|
||||||
|
service._on_connect(
|
||||||
|
client,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(client.published) == 1
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=False,
|
||||||
|
recovery_waiting=True,
|
||||||
|
co2=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Первый type 17.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
print("2. First sensor sample")
|
||||||
|
|
||||||
|
service._on_message(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
sensor_message(
|
||||||
|
timestamp=1000,
|
||||||
|
co2=900,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=True,
|
||||||
|
recovery_waiting=False,
|
||||||
|
co2=900,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Первый отказ.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
print("3. First sensor timeout")
|
||||||
|
|
||||||
|
clock.advance(
|
||||||
|
QINGPING_SAMPLE_TIMEOUT + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
service._watchdog_tick(
|
||||||
|
clock.monotonic()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=False,
|
||||||
|
recovery_waiting=False,
|
||||||
|
co2=900,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Recovery пока НЕ отправлялся.
|
||||||
|
assert len(client.published) == 1
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Устройство снова появляется.
|
||||||
|
# Любой пакет запускает recovery.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
print("4. First recovery")
|
||||||
|
|
||||||
|
service._on_message(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
service_message(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(client.published) == 2
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=True,
|
||||||
|
recovery_waiting=True,
|
||||||
|
co2=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Новый type 17.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
print("5. First recovery completed")
|
||||||
|
|
||||||
|
service._on_message(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
sensor_message(
|
||||||
|
timestamp=1015,
|
||||||
|
co2=1000,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=True,
|
||||||
|
recovery_waiting=False,
|
||||||
|
co2=1000,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Второй отказ.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
print("6. Second sensor timeout")
|
||||||
|
|
||||||
|
clock.advance(
|
||||||
|
QINGPING_SAMPLE_TIMEOUT + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
service._watchdog_tick(
|
||||||
|
clock.monotonic()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=False,
|
||||||
|
recovery_waiting=False,
|
||||||
|
co2=1000,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(client.published) == 2
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Второе восстановление.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
print("7. Second recovery")
|
||||||
|
|
||||||
|
service._on_message(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
service_message(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(client.published) == 3
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=True,
|
||||||
|
recovery_waiting=True,
|
||||||
|
co2=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
service._on_message(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
sensor_message(
|
||||||
|
timestamp=1030,
|
||||||
|
co2=1100,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=True,
|
||||||
|
recovery_waiting=False,
|
||||||
|
co2=1100,
|
||||||
|
)
|
||||||
|
|
||||||
|
print("8. Second recovery completed")
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovery_timeout_and_retry() -> None:
|
||||||
|
print()
|
||||||
|
print("TEST — RECOVERY TIMEOUT AND RETRY")
|
||||||
|
|
||||||
|
clock = FakeClock()
|
||||||
|
|
||||||
|
service = QingpingService(
|
||||||
|
host="127.0.0.1",
|
||||||
|
)
|
||||||
|
|
||||||
|
client = FakeMqttClient()
|
||||||
|
|
||||||
|
service._client = client
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.qingping.service.time.monotonic",
|
||||||
|
clock.monotonic,
|
||||||
|
):
|
||||||
|
|
||||||
|
# Startup recovery.
|
||||||
|
service._on_connect(
|
||||||
|
client,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(client.published) == 1
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=False,
|
||||||
|
recovery_waiting=True,
|
||||||
|
co2=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Type 17 так и не пришёл.
|
||||||
|
clock.advance(
|
||||||
|
QINGPING_RECOVERY_TIMEOUT + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
service._watchdog_tick(
|
||||||
|
clock.monotonic()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=False,
|
||||||
|
recovery_waiting=False,
|
||||||
|
co2=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Любой следующий пакет должен разрешить
|
||||||
|
# новую recovery-попытку.
|
||||||
|
service._on_message(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
service_message(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(client.published) == 2
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=True,
|
||||||
|
recovery_waiting=True,
|
||||||
|
co2=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Теперь приходит type 17.
|
||||||
|
service._on_message(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
sensor_message(
|
||||||
|
timestamp=2000,
|
||||||
|
co2=1200,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=True,
|
||||||
|
recovery_waiting=False,
|
||||||
|
co2=1200,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_double_recovery_cycle()
|
||||||
|
test_recovery_timeout_and_retry()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
"ALL QINGPING RECOVERY TESTS PASSED"
|
||||||
|
)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from app.my_dataclasses import QINGPING_MAC
|
||||||
|
from app.qingping.service import QingpingService
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
service = QingpingService(
|
||||||
|
QINGPING_MAC
|
||||||
|
)
|
||||||
|
|
||||||
|
async with service:
|
||||||
|
print("Qingping service started")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
state = service.state
|
||||||
|
|
||||||
|
print(
|
||||||
|
"online:",
|
||||||
|
service.online,
|
||||||
|
"| state:",
|
||||||
|
state.to_dict()
|
||||||
|
if state
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from schedule import (
|
||||||
|
ScheduleActionType,
|
||||||
|
ScheduleService,
|
||||||
|
load_schedule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SCHEDULE_FILE = "config/schedule.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
def print_result(title, result):
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(title)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
print(f"Enabled: {result.enabled}")
|
||||||
|
|
||||||
|
if result.current is not None:
|
||||||
|
print(
|
||||||
|
f"Current: "
|
||||||
|
f"{result.current.when} | "
|
||||||
|
f"{result.current.template} | "
|
||||||
|
f"{result.current.point.action.type}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print("Current: None")
|
||||||
|
|
||||||
|
if result.next is not None:
|
||||||
|
print(
|
||||||
|
f"Next: "
|
||||||
|
f"{result.next.when} | "
|
||||||
|
f"{result.next.template} | "
|
||||||
|
f"{result.next.point.action.type}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print("Next: None")
|
||||||
|
|
||||||
|
print(f"Auto: {result.auto_active}")
|
||||||
|
print(f"Settings: {result.scheduled_settings.to_dict()}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("Загрузка schedule.yaml...")
|
||||||
|
|
||||||
|
config = load_schedule(SCHEDULE_FILE)
|
||||||
|
|
||||||
|
print("YAML успешно загружен и проверен.")
|
||||||
|
print()
|
||||||
|
|
||||||
|
service = ScheduleService(config)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# TEST 1
|
||||||
|
# Понедельник 06:30
|
||||||
|
#
|
||||||
|
# Текущая точка должна быть 00:00 power off.
|
||||||
|
# Следующая — 07:00.
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
|
||||||
|
result = service.resolve(
|
||||||
|
datetime(2026, 9, 14, 6, 30)
|
||||||
|
)
|
||||||
|
|
||||||
|
print_result(
|
||||||
|
"TEST 1 — Monday 06:30",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.current.when.hour == 0
|
||||||
|
assert result.next.when.hour == 7
|
||||||
|
|
||||||
|
assert result.auto_active is False
|
||||||
|
|
||||||
|
assert result.scheduled_settings.power is False
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# TEST 2
|
||||||
|
# Понедельник 07:30
|
||||||
|
#
|
||||||
|
# Должны накопиться параметры из точки 07:00.
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
|
||||||
|
result = service.resolve(
|
||||||
|
datetime(2026, 9, 14, 7, 30)
|
||||||
|
)
|
||||||
|
|
||||||
|
print_result(
|
||||||
|
"TEST 2 — Monday 07:30",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
settings = result.scheduled_settings
|
||||||
|
|
||||||
|
assert result.current.when.hour == 7
|
||||||
|
assert result.next.when.hour == 9
|
||||||
|
|
||||||
|
assert result.auto_active is False
|
||||||
|
|
||||||
|
assert settings.power is True
|
||||||
|
assert settings.speed == 2
|
||||||
|
assert settings.heater is True
|
||||||
|
assert settings.target_temp == 20
|
||||||
|
assert settings.mode == "outside"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# TEST 3
|
||||||
|
# Понедельник 10:00
|
||||||
|
#
|
||||||
|
# В 09:00 поменялась только скорость.
|
||||||
|
#
|
||||||
|
# Остальные значения должны сохраниться от 07:00.
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
|
||||||
|
result = service.resolve(
|
||||||
|
datetime(2026, 9, 14, 10, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
print_result(
|
||||||
|
"TEST 3 — Monday 10:00",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
settings = result.scheduled_settings
|
||||||
|
|
||||||
|
assert result.current.when.hour == 9
|
||||||
|
assert result.next.when.hour == 13
|
||||||
|
|
||||||
|
assert settings.power is True
|
||||||
|
assert settings.speed == 1
|
||||||
|
assert settings.heater is True
|
||||||
|
assert settings.target_temp == 20
|
||||||
|
assert settings.mode == "outside"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# TEST 4
|
||||||
|
# Понедельник 13:10
|
||||||
|
#
|
||||||
|
# Последняя точка AUTO.
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
|
||||||
|
result = service.resolve(
|
||||||
|
datetime(2026, 9, 14, 13, 10)
|
||||||
|
)
|
||||||
|
|
||||||
|
print_result(
|
||||||
|
"TEST 4 — Monday 13:10 / AUTO",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.current.point.action.type == ScheduleActionType.AUTO
|
||||||
|
assert result.auto_active is True
|
||||||
|
|
||||||
|
assert result.next.when.hour == 17
|
||||||
|
assert result.next.when.minute == 0
|
||||||
|
|
||||||
|
# Накопленные настройки при AUTO не стираются.
|
||||||
|
assert result.scheduled_settings.speed == 1
|
||||||
|
assert result.scheduled_settings.power is True
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# TEST 5
|
||||||
|
# Понедельник 17:10
|
||||||
|
#
|
||||||
|
# AUTO закончился.
|
||||||
|
# Speed должен стать 3.
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
|
||||||
|
result = service.resolve(
|
||||||
|
datetime(2026, 9, 14, 17, 10)
|
||||||
|
)
|
||||||
|
|
||||||
|
print_result(
|
||||||
|
"TEST 5 — Monday 17:10",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.auto_active is False
|
||||||
|
|
||||||
|
assert result.current.when.hour == 17
|
||||||
|
assert result.current.when.minute == 0
|
||||||
|
|
||||||
|
assert result.next.when.hour == 17
|
||||||
|
assert result.next.when.minute == 30
|
||||||
|
|
||||||
|
assert result.scheduled_settings.speed == 3
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# TEST 6
|
||||||
|
# Понедельник 17:40
|
||||||
|
#
|
||||||
|
# Снова AUTO.
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
|
||||||
|
result = service.resolve(
|
||||||
|
datetime(2026, 9, 14, 17, 40)
|
||||||
|
)
|
||||||
|
|
||||||
|
print_result(
|
||||||
|
"TEST 6 — Monday 17:40 / AUTO",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.auto_active is True
|
||||||
|
|
||||||
|
assert result.current.when.hour == 17
|
||||||
|
assert result.current.when.minute == 30
|
||||||
|
|
||||||
|
assert result.next.when.hour == 23
|
||||||
|
|
||||||
|
# Последний scheduled speed всё равно должен помнить 3.
|
||||||
|
assert result.scheduled_settings.speed == 3
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# TEST 7
|
||||||
|
# Понедельник 23:15
|
||||||
|
#
|
||||||
|
# Heater off, speed 1.
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
|
||||||
|
result = service.resolve(
|
||||||
|
datetime(2026, 9, 14, 23, 15)
|
||||||
|
)
|
||||||
|
|
||||||
|
print_result(
|
||||||
|
"TEST 7 — Monday 23:15",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
settings = result.scheduled_settings
|
||||||
|
|
||||||
|
assert result.auto_active is False
|
||||||
|
assert settings.speed == 1
|
||||||
|
assert settings.heater is False
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# TEST 8
|
||||||
|
# Суббота 09:30
|
||||||
|
#
|
||||||
|
# Проверяем переключение на weekend.
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
|
||||||
|
result = service.resolve(
|
||||||
|
datetime(2026, 9, 19, 9, 30)
|
||||||
|
)
|
||||||
|
|
||||||
|
print_result(
|
||||||
|
"TEST 8 — Saturday 09:30",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.current.template == "weekend"
|
||||||
|
assert result.next.template == "weekend"
|
||||||
|
|
||||||
|
settings = result.scheduled_settings
|
||||||
|
|
||||||
|
assert settings.power is True
|
||||||
|
assert settings.speed == 1
|
||||||
|
assert settings.heater is True
|
||||||
|
assert settings.target_temp == 20
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# TEST 9
|
||||||
|
# Суббота 10:30
|
||||||
|
#
|
||||||
|
# Weekend AUTO.
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
|
||||||
|
result = service.resolve(
|
||||||
|
datetime(2026, 9, 19, 10, 30)
|
||||||
|
)
|
||||||
|
|
||||||
|
print_result(
|
||||||
|
"TEST 9 — Saturday 10:30 / AUTO",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.current.template == "weekend"
|
||||||
|
assert result.auto_active is True
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# TEST 10
|
||||||
|
# Проверяем переход через полночь.
|
||||||
|
#
|
||||||
|
# Вторник 00:30.
|
||||||
|
# В 00:00 вторника уже должна действовать power off.
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
|
||||||
|
result = service.resolve(
|
||||||
|
datetime(2026, 9, 15, 0, 30)
|
||||||
|
)
|
||||||
|
|
||||||
|
print_result(
|
||||||
|
"TEST 10 — Tuesday 00:30",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.current.when.day == 15
|
||||||
|
assert result.current.when.hour == 0
|
||||||
|
|
||||||
|
assert result.scheduled_settings.power is False
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("ALL SCHEDULE TESTS PASSED")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
import asyncio
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from schedule import (
|
||||||
|
ScheduleActionType,
|
||||||
|
ScheduleService,
|
||||||
|
load_schedule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SCHEDULE_YAML = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
enabled: true
|
||||||
|
timezone: local
|
||||||
|
|
||||||
|
templates:
|
||||||
|
|
||||||
|
test:
|
||||||
|
|
||||||
|
- time: "00:00"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
power: off
|
||||||
|
|
||||||
|
- time: "09:00"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
power: on
|
||||||
|
speed: 1
|
||||||
|
|
||||||
|
- time: "10:00"
|
||||||
|
action:
|
||||||
|
type: auto
|
||||||
|
speed: 3
|
||||||
|
|
||||||
|
- time: "12:00"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
speed: 2
|
||||||
|
|
||||||
|
days:
|
||||||
|
mon: test
|
||||||
|
tue: test
|
||||||
|
wed: test
|
||||||
|
thu: test
|
||||||
|
fri: test
|
||||||
|
sat: test
|
||||||
|
sun: test
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionController:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def power_on(self):
|
||||||
|
self.calls.append(
|
||||||
|
("power_on", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def power_off(self):
|
||||||
|
self.calls.append(
|
||||||
|
("power_off", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_speed(self, speed: int):
|
||||||
|
self.calls.append(
|
||||||
|
("set_speed", speed)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.controller = FakeTionController()
|
||||||
|
|
||||||
|
async def execute(self, operation):
|
||||||
|
return await operation(
|
||||||
|
self.controller
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_test_schedule():
|
||||||
|
temp_dir = TemporaryDirectory()
|
||||||
|
|
||||||
|
path = (
|
||||||
|
Path(temp_dir.name)
|
||||||
|
/ "schedule.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
path.write_text(
|
||||||
|
SCHEDULE_YAML,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
config = load_schedule(path)
|
||||||
|
|
||||||
|
return temp_dir, config
|
||||||
|
|
||||||
|
|
||||||
|
def print_resolution(
|
||||||
|
title,
|
||||||
|
resolution,
|
||||||
|
):
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(title)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"Current:",
|
||||||
|
resolution.current.when
|
||||||
|
if resolution.current
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"Action:",
|
||||||
|
resolution.current.point.action.type
|
||||||
|
if resolution.current
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"Scheduled speed:",
|
||||||
|
resolution.scheduled_settings.speed,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"AUTO:",
|
||||||
|
resolution.auto_active,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"Fallback:",
|
||||||
|
resolution.auto_fallback_speed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve(service: ScheduleService):
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# 09:30
|
||||||
|
# Обычный SET speed=1
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
resolution = service.resolve(
|
||||||
|
datetime(
|
||||||
|
2026,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
9,
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
print_resolution(
|
||||||
|
"TEST 1 — SET",
|
||||||
|
resolution,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resolution.auto_active is False
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.auto_fallback_speed
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.scheduled_settings.speed
|
||||||
|
== 1
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# 10:30
|
||||||
|
# AUTO speed=3
|
||||||
|
#
|
||||||
|
# scheduled_settings.speed всё ещё 1,
|
||||||
|
# потому что AUTO не участвует
|
||||||
|
# в накоплении SET-настроек.
|
||||||
|
#
|
||||||
|
# Но fallback должен быть 3.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
resolution = service.resolve(
|
||||||
|
datetime(
|
||||||
|
2026,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
10,
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
print_resolution(
|
||||||
|
"TEST 2 — AUTO",
|
||||||
|
resolution,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.current.point.action.type
|
||||||
|
== ScheduleActionType.AUTO
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resolution.auto_active is True
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.auto_fallback_speed
|
||||||
|
== 3
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.scheduled_settings.speed
|
||||||
|
== 1
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# 12:30
|
||||||
|
# AUTO закончился.
|
||||||
|
# SET speed=2.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
resolution = service.resolve(
|
||||||
|
datetime(
|
||||||
|
2026,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
12,
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
print_resolution(
|
||||||
|
"TEST 3 — AFTER AUTO",
|
||||||
|
resolution,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resolution.auto_active is False
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.auto_fallback_speed
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.scheduled_settings.speed
|
||||||
|
== 2
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_process(
|
||||||
|
service: ScheduleService,
|
||||||
|
tion: FakeTionService,
|
||||||
|
):
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# 09:30
|
||||||
|
#
|
||||||
|
# Обычный SET.
|
||||||
|
# ScheduleService должен применить speed=1.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
fixed_now = datetime(
|
||||||
|
2026,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
9,
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"schedule.service.datetime",
|
||||||
|
wraps=datetime,
|
||||||
|
) as mocked_datetime:
|
||||||
|
|
||||||
|
mocked_datetime.now.return_value = (
|
||||||
|
fixed_now
|
||||||
|
)
|
||||||
|
|
||||||
|
await service._process()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
"09:30 calls:",
|
||||||
|
tion.controller.calls,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"set_speed",
|
||||||
|
1,
|
||||||
|
) in tion.controller.calls
|
||||||
|
|
||||||
|
# Очищаем историю команд.
|
||||||
|
tion.controller.calls.clear()
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# 10:30
|
||||||
|
#
|
||||||
|
# AUTO.
|
||||||
|
#
|
||||||
|
# В расписании fallback speed=3,
|
||||||
|
# но ScheduleService НЕ должен
|
||||||
|
# отправить ни speed=1, ни speed=3.
|
||||||
|
#
|
||||||
|
# Скорость теперь принадлежит
|
||||||
|
# будущему AutoController.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
fixed_now = datetime(
|
||||||
|
2026,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
10,
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"schedule.service.datetime",
|
||||||
|
wraps=datetime,
|
||||||
|
) as mocked_datetime:
|
||||||
|
|
||||||
|
mocked_datetime.now.return_value = (
|
||||||
|
fixed_now
|
||||||
|
)
|
||||||
|
|
||||||
|
await service._process()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
"10:30 AUTO calls:",
|
||||||
|
tion.controller.calls,
|
||||||
|
)
|
||||||
|
|
||||||
|
speed_calls = [
|
||||||
|
call
|
||||||
|
for call in tion.controller.calls
|
||||||
|
if call[0] == "set_speed"
|
||||||
|
]
|
||||||
|
|
||||||
|
assert speed_calls == []
|
||||||
|
|
||||||
|
# Очищаем историю.
|
||||||
|
tion.controller.calls.clear()
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# 12:30
|
||||||
|
#
|
||||||
|
# AUTO закончился.
|
||||||
|
# Расписание снова должно поставить speed=2.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
fixed_now = datetime(
|
||||||
|
2026,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
12,
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"schedule.service.datetime",
|
||||||
|
wraps=datetime,
|
||||||
|
) as mocked_datetime:
|
||||||
|
|
||||||
|
mocked_datetime.now.return_value = (
|
||||||
|
fixed_now
|
||||||
|
)
|
||||||
|
|
||||||
|
await service._process()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
"12:30 calls:",
|
||||||
|
tion.controller.calls,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"set_speed",
|
||||||
|
2,
|
||||||
|
) in tion.controller.calls
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
temp_dir, config = (
|
||||||
|
load_test_schedule()
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# ----------------------------------------------
|
||||||
|
# Проверка resolve()
|
||||||
|
# ----------------------------------------------
|
||||||
|
|
||||||
|
service = ScheduleService(
|
||||||
|
config
|
||||||
|
)
|
||||||
|
|
||||||
|
test_resolve(service)
|
||||||
|
|
||||||
|
# ----------------------------------------------
|
||||||
|
# Проверка реального _process(),
|
||||||
|
# но через FakeTion.
|
||||||
|
# ----------------------------------------------
|
||||||
|
|
||||||
|
tion = FakeTionService()
|
||||||
|
|
||||||
|
service = ScheduleService(
|
||||||
|
config,
|
||||||
|
tion=tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
await test_process(
|
||||||
|
service,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("ALL AUTO SCHEDULE TESTS PASSED")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import asyncio
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from schedule import (
|
||||||
|
ScheduleService,
|
||||||
|
load_schedule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SCHEDULE_YAML = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
enabled: true
|
||||||
|
timezone: local
|
||||||
|
|
||||||
|
templates:
|
||||||
|
|
||||||
|
test:
|
||||||
|
|
||||||
|
- time: "00:00"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
power: off
|
||||||
|
|
||||||
|
- time: "09:00"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
power: on
|
||||||
|
speed: 1
|
||||||
|
heater: on
|
||||||
|
target_temp: 20
|
||||||
|
|
||||||
|
- time: "10:00"
|
||||||
|
action:
|
||||||
|
type: auto
|
||||||
|
speed: 3
|
||||||
|
|
||||||
|
- time: "12:00"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
speed: 2
|
||||||
|
heater: off
|
||||||
|
|
||||||
|
days:
|
||||||
|
mon: test
|
||||||
|
tue: test
|
||||||
|
wed: test
|
||||||
|
thu: test
|
||||||
|
fri: test
|
||||||
|
sat: test
|
||||||
|
sun: test
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionController:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def power_on(self):
|
||||||
|
self.calls.append(
|
||||||
|
("power_on", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def power_off(self):
|
||||||
|
self.calls.append(
|
||||||
|
("power_off", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_speed(self, speed: int):
|
||||||
|
self.calls.append(
|
||||||
|
("set_speed", speed)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Оставляем оба варианта управления heater,
|
||||||
|
# чтобы fake соответствовал фактическому
|
||||||
|
# интерфейсу TionController.
|
||||||
|
async def heater_on(self):
|
||||||
|
self.calls.append(
|
||||||
|
("heater_on", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def heater_off(self):
|
||||||
|
self.calls.append(
|
||||||
|
("heater_off", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_heater(self, enabled: bool):
|
||||||
|
self.calls.append(
|
||||||
|
("set_heater", enabled)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_target_temperature(
|
||||||
|
self,
|
||||||
|
temperature: int,
|
||||||
|
):
|
||||||
|
self.calls.append(
|
||||||
|
(
|
||||||
|
"set_target_temperature",
|
||||||
|
temperature,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.controller = FakeTionController()
|
||||||
|
|
||||||
|
async def execute(self, operation):
|
||||||
|
return await operation(
|
||||||
|
self.controller
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_test_schedule():
|
||||||
|
temp_dir = TemporaryDirectory()
|
||||||
|
|
||||||
|
path = (
|
||||||
|
Path(temp_dir.name)
|
||||||
|
/ "schedule.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
path.write_text(
|
||||||
|
SCHEDULE_YAML,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
config = load_schedule(path)
|
||||||
|
|
||||||
|
return temp_dir, config
|
||||||
|
|
||||||
|
|
||||||
|
def heater_calls(calls):
|
||||||
|
return [
|
||||||
|
call
|
||||||
|
for call in calls
|
||||||
|
if call[0] in {
|
||||||
|
"heater_on",
|
||||||
|
"heater_off",
|
||||||
|
"set_heater",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def process_at(
|
||||||
|
service: ScheduleService,
|
||||||
|
when: datetime,
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"schedule.service.datetime",
|
||||||
|
wraps=datetime,
|
||||||
|
) as mocked_datetime:
|
||||||
|
|
||||||
|
mocked_datetime.now.return_value = when
|
||||||
|
|
||||||
|
await service._process()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_auto_does_not_control_heater():
|
||||||
|
|
||||||
|
temp_dir, config = (
|
||||||
|
load_test_schedule()
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
tion = FakeTionService()
|
||||||
|
|
||||||
|
service = ScheduleService(
|
||||||
|
config,
|
||||||
|
tion=tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# 09:30
|
||||||
|
#
|
||||||
|
# Обычный SET.
|
||||||
|
# heater=on должен принадлежать ScheduleService.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
await process_at(
|
||||||
|
service,
|
||||||
|
datetime(
|
||||||
|
2026,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
9,
|
||||||
|
30,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
"09:30 SET calls:",
|
||||||
|
tion.controller.calls,
|
||||||
|
)
|
||||||
|
|
||||||
|
calls = heater_calls(
|
||||||
|
tion.controller.calls
|
||||||
|
)
|
||||||
|
|
||||||
|
assert calls != [], (
|
||||||
|
"SET must control heater"
|
||||||
|
)
|
||||||
|
|
||||||
|
tion.controller.calls.clear()
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# 10:30
|
||||||
|
#
|
||||||
|
# AUTO.
|
||||||
|
#
|
||||||
|
# В накопленных scheduled_settings heater всё ещё
|
||||||
|
# должен быть True, потому что последняя SET-точка
|
||||||
|
# включила heater.
|
||||||
|
#
|
||||||
|
# Но ScheduleService НЕ должен отправлять
|
||||||
|
# никаких heater-команд.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
resolution = service.resolve(
|
||||||
|
datetime(
|
||||||
|
2026,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
10,
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
"10:30 scheduled heater:",
|
||||||
|
resolution.scheduled_settings.heater,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.auto_active
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.scheduled_settings.heater
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
await process_at(
|
||||||
|
service,
|
||||||
|
datetime(
|
||||||
|
2026,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
10,
|
||||||
|
30,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"10:30 AUTO calls:",
|
||||||
|
tion.controller.calls,
|
||||||
|
)
|
||||||
|
|
||||||
|
calls = heater_calls(
|
||||||
|
tion.controller.calls
|
||||||
|
)
|
||||||
|
|
||||||
|
assert calls == [], (
|
||||||
|
"ScheduleService must not control "
|
||||||
|
"heater during AUTO"
|
||||||
|
)
|
||||||
|
|
||||||
|
tion.controller.calls.clear()
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# 12:30
|
||||||
|
#
|
||||||
|
# AUTO закончился.
|
||||||
|
# heater=off снова принадлежит ScheduleService.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
await process_at(
|
||||||
|
service,
|
||||||
|
datetime(
|
||||||
|
2026,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
12,
|
||||||
|
30,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
"12:30 SET calls:",
|
||||||
|
tion.controller.calls,
|
||||||
|
)
|
||||||
|
|
||||||
|
calls = heater_calls(
|
||||||
|
tion.controller.calls
|
||||||
|
)
|
||||||
|
|
||||||
|
assert calls != [], (
|
||||||
|
"ScheduleService must regain heater "
|
||||||
|
"control after AUTO"
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
await test_auto_does_not_control_heater()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO HEATER OWNERSHIP TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
from schedule import (
|
||||||
|
ScheduleActionType,
|
||||||
|
load_schedule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
VALID_YAML = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
enabled: true
|
||||||
|
timezone: local
|
||||||
|
|
||||||
|
templates:
|
||||||
|
|
||||||
|
test:
|
||||||
|
|
||||||
|
- time: "00:00"
|
||||||
|
action:
|
||||||
|
type: auto
|
||||||
|
speed: 3
|
||||||
|
target_temp: 20
|
||||||
|
|
||||||
|
days:
|
||||||
|
mon: test
|
||||||
|
tue: test
|
||||||
|
wed: test
|
||||||
|
thu: test
|
||||||
|
fri: test
|
||||||
|
sat: test
|
||||||
|
sun: test
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(text: str):
|
||||||
|
|
||||||
|
temp_dir = TemporaryDirectory()
|
||||||
|
|
||||||
|
path = (
|
||||||
|
Path(temp_dir.name)
|
||||||
|
/ "schedule.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
path.write_text(
|
||||||
|
text,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
config = load_schedule(path)
|
||||||
|
|
||||||
|
return temp_dir, config
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_target_temperature():
|
||||||
|
|
||||||
|
temp_dir, config = load_config(
|
||||||
|
VALID_YAML
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
point = (
|
||||||
|
config.templates["test"][0]
|
||||||
|
)
|
||||||
|
|
||||||
|
action = point.action
|
||||||
|
|
||||||
|
assert (
|
||||||
|
action.type
|
||||||
|
== ScheduleActionType.AUTO
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
action.settings.speed
|
||||||
|
== 3
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
action.settings.target_temp
|
||||||
|
== 20
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_rejects_heater():
|
||||||
|
|
||||||
|
config_text = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
enabled: true
|
||||||
|
timezone: local
|
||||||
|
|
||||||
|
templates:
|
||||||
|
|
||||||
|
test:
|
||||||
|
|
||||||
|
- time: "00:00"
|
||||||
|
action:
|
||||||
|
type: auto
|
||||||
|
speed: 3
|
||||||
|
target_temp: 20
|
||||||
|
heater: on
|
||||||
|
|
||||||
|
days:
|
||||||
|
mon: test
|
||||||
|
tue: test
|
||||||
|
wed: test
|
||||||
|
thu: test
|
||||||
|
fri: test
|
||||||
|
sat: test
|
||||||
|
sun: test
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
temp_dir, _ = load_config(
|
||||||
|
config_text
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as exc:
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
"Expected error:",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "heater" in str(exc)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
else:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
raise AssertionError(
|
||||||
|
"AUTO heater field must "
|
||||||
|
"raise ValueError"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_speed_is_required():
|
||||||
|
|
||||||
|
config_text = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
enabled: true
|
||||||
|
timezone: local
|
||||||
|
|
||||||
|
templates:
|
||||||
|
|
||||||
|
test:
|
||||||
|
|
||||||
|
- time: "00:00"
|
||||||
|
action:
|
||||||
|
type: auto
|
||||||
|
target_temp: 20
|
||||||
|
|
||||||
|
days:
|
||||||
|
mon: test
|
||||||
|
tue: test
|
||||||
|
wed: test
|
||||||
|
thu: test
|
||||||
|
fri: test
|
||||||
|
sat: test
|
||||||
|
sun: test
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
temp_dir, _ = load_config(
|
||||||
|
config_text
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as exc:
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
"Expected error:",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
str(exc)
|
||||||
|
== "AUTO action requires 'speed'"
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
else:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
raise AssertionError(
|
||||||
|
"AUTO without speed must "
|
||||||
|
"raise ValueError"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
|
||||||
|
test_auto_target_temperature()
|
||||||
|
|
||||||
|
test_auto_rejects_heater()
|
||||||
|
|
||||||
|
test_auto_speed_is_required()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO TEMPERATURE PARSER "
|
||||||
|
"TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
from schedule import (
|
||||||
|
ScheduleService,
|
||||||
|
load_schedule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SCHEDULE_YAML = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
enabled: true
|
||||||
|
timezone: local
|
||||||
|
|
||||||
|
templates:
|
||||||
|
|
||||||
|
test:
|
||||||
|
|
||||||
|
- time: "09:00"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
power: on
|
||||||
|
speed: 1
|
||||||
|
target_temp: 23
|
||||||
|
|
||||||
|
- time: "10:00"
|
||||||
|
action:
|
||||||
|
type: auto
|
||||||
|
speed: 3
|
||||||
|
target_temp: 20
|
||||||
|
|
||||||
|
- time: "12:00"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
speed: 2
|
||||||
|
target_temp: 22
|
||||||
|
|
||||||
|
days:
|
||||||
|
mon: test
|
||||||
|
tue: test
|
||||||
|
wed: test
|
||||||
|
thu: test
|
||||||
|
fri: test
|
||||||
|
sat: test
|
||||||
|
sun: test
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def load_test_schedule():
|
||||||
|
|
||||||
|
temp_dir = TemporaryDirectory()
|
||||||
|
|
||||||
|
path = (
|
||||||
|
Path(temp_dir.name)
|
||||||
|
/ "schedule.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
path.write_text(
|
||||||
|
SCHEDULE_YAML,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
config = load_schedule(path)
|
||||||
|
|
||||||
|
return temp_dir, config
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_temperature_resolution():
|
||||||
|
|
||||||
|
temp_dir, config = (
|
||||||
|
load_test_schedule()
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
service = ScheduleService(
|
||||||
|
config
|
||||||
|
)
|
||||||
|
|
||||||
|
# ----------------------------------------------
|
||||||
|
# 09:30 — обычный SET
|
||||||
|
# ----------------------------------------------
|
||||||
|
|
||||||
|
resolution = service.resolve(
|
||||||
|
datetime(
|
||||||
|
2026,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
9,
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.auto_active
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.scheduled_settings.target_temp
|
||||||
|
== 23
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.auto_target_temp
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
# ----------------------------------------------
|
||||||
|
# 10:30 — AUTO
|
||||||
|
#
|
||||||
|
# Последний SET по-прежнему хранит 23,
|
||||||
|
# но AUTO явно требует 20.
|
||||||
|
# ----------------------------------------------
|
||||||
|
|
||||||
|
resolution = service.resolve(
|
||||||
|
datetime(
|
||||||
|
2026,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
10,
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.auto_active
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.auto_fallback_speed
|
||||||
|
== 3
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.scheduled_settings.target_temp
|
||||||
|
== 23
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.auto_target_temp
|
||||||
|
== 20
|
||||||
|
)
|
||||||
|
|
||||||
|
# ----------------------------------------------
|
||||||
|
# 12:30 — снова SET
|
||||||
|
#
|
||||||
|
# AUTO закончился.
|
||||||
|
# ----------------------------------------------
|
||||||
|
|
||||||
|
resolution = service.resolve(
|
||||||
|
datetime(
|
||||||
|
2026,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
12,
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.auto_active
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.scheduled_settings.target_temp
|
||||||
|
== 22
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.auto_target_temp
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
|
||||||
|
test_auto_temperature_resolution()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO TEMPERATURE RESOLUTION "
|
||||||
|
"TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from app.my_dataclasses import TION_MAC
|
||||||
|
from app.tion import TionController, TionService
|
||||||
|
|
||||||
|
from schedule import (
|
||||||
|
ScheduleService,
|
||||||
|
load_schedule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
logging.disable(logging.CRITICAL)
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
SCHEDULE_FILE = (
|
||||||
|
PROJECT_ROOT
|
||||||
|
/ "config"
|
||||||
|
/ "schedule.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
config = load_schedule(SCHEDULE_FILE)
|
||||||
|
|
||||||
|
controller = TionController(TION_MAC)
|
||||||
|
|
||||||
|
tion = TionService(
|
||||||
|
controller,
|
||||||
|
poll_interval=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
schedule = ScheduleService(
|
||||||
|
config,
|
||||||
|
tion,
|
||||||
|
check_interval=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
await tion.start()
|
||||||
|
await schedule.start()
|
||||||
|
|
||||||
|
print("Schedule started. Ctrl+C to stop.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
if tion.state:
|
||||||
|
print(
|
||||||
|
f"{datetime.datetime.now().strftime("%y-%m-%d %H:%M:%S")} "
|
||||||
|
f"power={tion.state.power} "
|
||||||
|
f"speed={tion.state.fan_speed} "
|
||||||
|
f"heater={tion.state.heater} "
|
||||||
|
f"target_temp={tion.state.target_temp} "
|
||||||
|
f"last_error={tion.last_error} "
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await schedule.stop()
|
||||||
|
await tion.stop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.my_dataclasses import TION_MAC
|
||||||
|
from app.tion import TionController, TionService
|
||||||
|
|
||||||
|
from schedule import (
|
||||||
|
ScheduleService,
|
||||||
|
ScheduledSettings,
|
||||||
|
load_schedule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
logging.disable(logging.CRITICAL)
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
SCHEDULE_FILE = (
|
||||||
|
PROJECT_ROOT
|
||||||
|
/ "config"
|
||||||
|
/ "schedule.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
config = load_schedule(SCHEDULE_FILE)
|
||||||
|
|
||||||
|
controller = TionController(TION_MAC)
|
||||||
|
|
||||||
|
tion = TionService(
|
||||||
|
controller,
|
||||||
|
poll_interval=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
schedule = ScheduleService(
|
||||||
|
config,
|
||||||
|
tion,
|
||||||
|
check_interval=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
await tion.start()
|
||||||
|
await schedule.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
print()
|
||||||
|
print("Schedule started")
|
||||||
|
|
||||||
|
resolution = schedule.resolve()
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"Current: "
|
||||||
|
f"{resolution.current.when if resolution.current else None}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"Next: "
|
||||||
|
f"{resolution.next.when if resolution.next else None}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# print()
|
||||||
|
# print("Applying temporary override:")
|
||||||
|
# print("speed=6")
|
||||||
|
# print()
|
||||||
|
#
|
||||||
|
# await schedule.apply_override(
|
||||||
|
# ScheduledSettings(
|
||||||
|
# speed=6,
|
||||||
|
# )
|
||||||
|
# )
|
||||||
|
|
||||||
|
while True:
|
||||||
|
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
if ( datetime.now() > datetime(2026, 9, 18, 20, 30, 30)
|
||||||
|
and datetime.now() < datetime(2026, 9, 18, 20, 30, 36) ):
|
||||||
|
print("Applying temporary override:")
|
||||||
|
print("heater=True")
|
||||||
|
print("target_temp=25")
|
||||||
|
await schedule.apply_override(
|
||||||
|
ScheduledSettings(
|
||||||
|
heater=True,
|
||||||
|
target_temp=25,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now().strftime(
|
||||||
|
"%y-%m-%d %H:%M:%S"
|
||||||
|
)
|
||||||
|
|
||||||
|
state = tion.state
|
||||||
|
|
||||||
|
if state is None:
|
||||||
|
print(
|
||||||
|
f"{now} state=None"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"{now} "
|
||||||
|
f"power={state.power} "
|
||||||
|
f"speed={state.fan_speed} "
|
||||||
|
f"heater={state.heater} "
|
||||||
|
f"target_temp={state.target_temp} "
|
||||||
|
f"override={schedule.override_active} "
|
||||||
|
f"until={schedule.override_until}"
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await schedule.stop()
|
||||||
|
await tion.stop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
from schedule import (
|
||||||
|
ScheduleService,
|
||||||
|
ScheduledSettings,
|
||||||
|
load_schedule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SCHEDULE_YAML = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
enabled: true
|
||||||
|
timezone: local
|
||||||
|
|
||||||
|
templates:
|
||||||
|
|
||||||
|
test:
|
||||||
|
|
||||||
|
- time: "00:00"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
speed: 2
|
||||||
|
|
||||||
|
days:
|
||||||
|
mon: test
|
||||||
|
tue: test
|
||||||
|
wed: test
|
||||||
|
thu: test
|
||||||
|
fri: test
|
||||||
|
sat: test
|
||||||
|
sun: test
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionController:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def set_speed(
|
||||||
|
self,
|
||||||
|
speed: int,
|
||||||
|
):
|
||||||
|
self.calls.append(
|
||||||
|
(
|
||||||
|
"set_speed",
|
||||||
|
speed,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.controller = (
|
||||||
|
FakeTionController()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
operation,
|
||||||
|
):
|
||||||
|
return await operation(
|
||||||
|
self.controller
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_test_schedule():
|
||||||
|
|
||||||
|
temp_dir = TemporaryDirectory()
|
||||||
|
|
||||||
|
path = (
|
||||||
|
Path(temp_dir.name)
|
||||||
|
/ "schedule.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
path.write_text(
|
||||||
|
SCHEDULE_YAML,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
config = load_schedule(path)
|
||||||
|
|
||||||
|
return temp_dir, config
|
||||||
|
|
||||||
|
|
||||||
|
async def test_paused_schedule_does_not_control_tion():
|
||||||
|
|
||||||
|
temp_dir, config = (
|
||||||
|
load_test_schedule()
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
tion = FakeTionService()
|
||||||
|
|
||||||
|
service = ScheduleService(
|
||||||
|
config,
|
||||||
|
tion=tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert service.paused is False
|
||||||
|
|
||||||
|
await service.pause()
|
||||||
|
|
||||||
|
assert service.paused is True
|
||||||
|
|
||||||
|
# Даже если фоновый цикл вызывает _process(),
|
||||||
|
# никаких команд быть не должно.
|
||||||
|
await service._process()
|
||||||
|
|
||||||
|
assert tion.controller.calls == []
|
||||||
|
|
||||||
|
# Возвращаем расписание.
|
||||||
|
#
|
||||||
|
# resume() должен сразу применить
|
||||||
|
# текущее состояние расписания.
|
||||||
|
await service.resume()
|
||||||
|
|
||||||
|
assert service.paused is False
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"set_speed",
|
||||||
|
2,
|
||||||
|
) in tion.controller.calls
|
||||||
|
|
||||||
|
finally:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pause_clears_override():
|
||||||
|
|
||||||
|
temp_dir, config = (
|
||||||
|
load_test_schedule()
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
service = ScheduleService(
|
||||||
|
config
|
||||||
|
)
|
||||||
|
|
||||||
|
# Имитируем уже существующий
|
||||||
|
# temporary override.
|
||||||
|
service._override_settings = (
|
||||||
|
ScheduledSettings(
|
||||||
|
speed=5,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
service._override_until = (
|
||||||
|
datetime.now()
|
||||||
|
+ timedelta(hours=1)
|
||||||
|
)
|
||||||
|
|
||||||
|
service._override_pending = True
|
||||||
|
|
||||||
|
assert (
|
||||||
|
service.override_settings.speed
|
||||||
|
== 5
|
||||||
|
)
|
||||||
|
|
||||||
|
await service.pause()
|
||||||
|
|
||||||
|
assert service.paused is True
|
||||||
|
|
||||||
|
assert (
|
||||||
|
service.override_settings.speed
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
service.override_until
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
service._override_pending
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
await test_paused_schedule_does_not_control_tion()
|
||||||
|
|
||||||
|
await test_pause_clears_override()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL SCHEDULE PAUSE TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
from schedule import (
|
||||||
|
ScheduleService,
|
||||||
|
load_schedule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SCHEDULE_YAML = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
enabled: true
|
||||||
|
timezone: local
|
||||||
|
|
||||||
|
templates:
|
||||||
|
|
||||||
|
test:
|
||||||
|
|
||||||
|
- time: "00:00"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
speed: 2
|
||||||
|
|
||||||
|
days:
|
||||||
|
mon: test
|
||||||
|
tue: test
|
||||||
|
wed: test
|
||||||
|
thu: test
|
||||||
|
fri: test
|
||||||
|
sat: test
|
||||||
|
sun: test
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def create_config(
|
||||||
|
directory: Path,
|
||||||
|
):
|
||||||
|
|
||||||
|
schedule_path = (
|
||||||
|
directory
|
||||||
|
/ "schedule.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
schedule_path.write_text(
|
||||||
|
SCHEDULE_YAML,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
return load_schedule(
|
||||||
|
schedule_path
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pause_survives_restart():
|
||||||
|
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
|
||||||
|
directory = Path(temp_dir)
|
||||||
|
|
||||||
|
config = create_config(
|
||||||
|
directory
|
||||||
|
)
|
||||||
|
|
||||||
|
state_path = (
|
||||||
|
directory
|
||||||
|
/ "schedule_state.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------
|
||||||
|
# Первый экземпляр сервиса.
|
||||||
|
# --------------------------------------
|
||||||
|
|
||||||
|
service1 = ScheduleService(
|
||||||
|
config,
|
||||||
|
state_path=state_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert service1.paused is False
|
||||||
|
|
||||||
|
await service1.pause()
|
||||||
|
|
||||||
|
assert service1.paused is True
|
||||||
|
assert state_path.exists()
|
||||||
|
|
||||||
|
# --------------------------------------
|
||||||
|
# Имитируем перезапуск приложения:
|
||||||
|
# создаём новый ScheduleService.
|
||||||
|
# --------------------------------------
|
||||||
|
|
||||||
|
service2 = ScheduleService(
|
||||||
|
config,
|
||||||
|
state_path=state_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert service2.paused is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_resume_survives_restart():
|
||||||
|
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
|
||||||
|
directory = Path(temp_dir)
|
||||||
|
|
||||||
|
config = create_config(
|
||||||
|
directory
|
||||||
|
)
|
||||||
|
|
||||||
|
state_path = (
|
||||||
|
directory
|
||||||
|
/ "schedule_state.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
service1 = ScheduleService(
|
||||||
|
config,
|
||||||
|
state_path=state_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
await service1.pause()
|
||||||
|
|
||||||
|
assert service1.paused is True
|
||||||
|
|
||||||
|
await service1.resume()
|
||||||
|
|
||||||
|
assert service1.paused is False
|
||||||
|
|
||||||
|
# Новый экземпляр после resume
|
||||||
|
# тоже должен быть активным.
|
||||||
|
service2 = ScheduleService(
|
||||||
|
config,
|
||||||
|
state_path=state_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert service2.paused is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_missing_state_file_defaults_to_active():
|
||||||
|
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
|
||||||
|
directory = Path(temp_dir)
|
||||||
|
|
||||||
|
config = create_config(
|
||||||
|
directory
|
||||||
|
)
|
||||||
|
|
||||||
|
state_path = (
|
||||||
|
directory
|
||||||
|
/ "missing_state.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not state_path.exists()
|
||||||
|
|
||||||
|
service = ScheduleService(
|
||||||
|
config,
|
||||||
|
state_path=state_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert service.paused is False
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
await test_pause_survives_restart()
|
||||||
|
await test_resume_survives_restart()
|
||||||
|
await test_missing_state_file_defaults_to_active()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL SCHEDULE PAUSE "
|
||||||
|
"PERSISTENCE TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.tion import TionController
|
||||||
|
from app.my_dataclasses import *
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def print_state(title: str, state) -> None:
|
||||||
|
print()
|
||||||
|
print("=" * 50)
|
||||||
|
print(title)
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
print(json.dumps(
|
||||||
|
state.to_dict(),
|
||||||
|
indent=2,
|
||||||
|
ensure_ascii=False
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
# Убираем лишнее логирование
|
||||||
|
logging.getLogger("asyncio").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("bleak").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("tion_btle").setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
tion = TionController(TION_MAC)
|
||||||
|
|
||||||
|
print("Подключение к Tion...")
|
||||||
|
|
||||||
|
async with tion:
|
||||||
|
|
||||||
|
print(f"Connected: {tion.connected}")
|
||||||
|
|
||||||
|
# Читаем исходное состояние
|
||||||
|
state = await tion.get_state()
|
||||||
|
print_state("Исходное состояние", state)
|
||||||
|
|
||||||
|
# Включаем
|
||||||
|
state = await tion.power_on()
|
||||||
|
print_state("После POWER ON", state)
|
||||||
|
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
# Скорость 2
|
||||||
|
state = await tion.set_speed(2)
|
||||||
|
print_state("После SPEED 2", state)
|
||||||
|
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
# Скорость 3
|
||||||
|
state = await tion.set_speed(3)
|
||||||
|
print_state("После SPEED 3", state)
|
||||||
|
|
||||||
|
state = await tion.set_target_temperature(20)
|
||||||
|
print_state("TARGET TEMP 20°C", state)
|
||||||
|
|
||||||
|
state = await tion.set_air_mode("recirculation")
|
||||||
|
print_state("RECIRCULATION", state)
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
|
||||||
|
state = await tion.set_air_mode("outside")
|
||||||
|
print_state("RECIRCULATION", state)
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
|
||||||
|
state = await tion.sound_off()
|
||||||
|
print_state("SOUND OFF", state)
|
||||||
|
|
||||||
|
state = await tion.sound_on()
|
||||||
|
print_state("SOUND ON", state)
|
||||||
|
|
||||||
|
state = await tion.light_off()
|
||||||
|
print_state("LIGHT OFF", state)
|
||||||
|
|
||||||
|
state = await tion.light_on()
|
||||||
|
print_state("LIGHT ON", state)
|
||||||
|
print()
|
||||||
|
print(f"Connected after exit: {tion.connected}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from app.tion import (
|
||||||
|
TionController,
|
||||||
|
TionService,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.my_dataclasses import *
|
||||||
|
|
||||||
|
|
||||||
|
def print_service(service: TionService) -> None:
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
print(f"Running: {service.running}")
|
||||||
|
print(f"Online: {service.online}")
|
||||||
|
print(f"Last seen: {service.last_seen}")
|
||||||
|
print(f"Last error: {service.last_error}")
|
||||||
|
|
||||||
|
if service.state is not None:
|
||||||
|
print()
|
||||||
|
print(json.dumps(
|
||||||
|
service.state.to_dict(),
|
||||||
|
indent=2,
|
||||||
|
ensure_ascii=False,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
# Убираем лишнее логирование
|
||||||
|
logging.getLogger("asyncio").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("bleak").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("tion_btle").setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
controller = TionController(TION_MAC)
|
||||||
|
|
||||||
|
service = TionService(
|
||||||
|
controller,
|
||||||
|
poll_interval=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with service:
|
||||||
|
|
||||||
|
print("=== После запуска ===")
|
||||||
|
print_service(service)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("Ждём несколько циклов polling...")
|
||||||
|
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
|
||||||
|
print_service(service)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== Устанавливаем скорость 2 ===")
|
||||||
|
|
||||||
|
state = await service.execute(
|
||||||
|
lambda tion: tion.set_speed(2)
|
||||||
|
)
|
||||||
|
|
||||||
|
print(json.dumps(
|
||||||
|
state.to_dict(),
|
||||||
|
indent=2,
|
||||||
|
ensure_ascii=False,
|
||||||
|
))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== Устанавливаем температуру 20°C ===")
|
||||||
|
|
||||||
|
await service.execute(
|
||||||
|
lambda tion: tion.set_target_temperature(20)
|
||||||
|
)
|
||||||
|
|
||||||
|
print_service(service)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== После stop ===")
|
||||||
|
print_service(service)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
Reference in New Issue
Block a user