work: добавил автоматический режим
This commit is contained in:
+113
-19
@@ -7,6 +7,9 @@ from fastapi.responses import JSONResponse
|
|||||||
|
|
||||||
from app.my_dataclasses import (
|
from app.my_dataclasses import (
|
||||||
TION_MAC,
|
TION_MAC,
|
||||||
|
QINGPING_MAC,
|
||||||
|
QINGPING_MQTT_HOST,
|
||||||
|
QINGPING_MQTT_PORT,
|
||||||
MIN_FAN_SPEED,
|
MIN_FAN_SPEED,
|
||||||
MAX_FAN_SPEED,
|
MAX_FAN_SPEED,
|
||||||
MIN_TARGET_TEMP,
|
MIN_TARGET_TEMP,
|
||||||
@@ -14,6 +17,13 @@ from app.my_dataclasses import (
|
|||||||
AIR_MODE_OUTSIDE,
|
AIR_MODE_OUTSIDE,
|
||||||
AIR_MODE_RECIRCULATION,
|
AIR_MODE_RECIRCULATION,
|
||||||
SCHEDULE_FILE,
|
SCHEDULE_FILE,
|
||||||
|
AUTO_CONFIG_FILE,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.auto import (
|
||||||
|
AutoController,
|
||||||
|
Co2SpeedPolicy,
|
||||||
|
load_auto_config,
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.tion import (
|
from app.tion import (
|
||||||
@@ -27,7 +37,7 @@ from schedule import (
|
|||||||
load_schedule,
|
load_schedule,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from app.qingping.service import QingpingService
|
||||||
|
|
||||||
def configure_logging() -> None:
|
def configure_logging() -> None:
|
||||||
noisy_loggers = (
|
noisy_loggers = (
|
||||||
@@ -57,9 +67,19 @@ service = TionService(
|
|||||||
controller,
|
controller,
|
||||||
poll_interval=5,
|
poll_interval=5,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
qingping_service = QingpingService(
|
||||||
|
host=QINGPING_MQTT_HOST,
|
||||||
|
port=QINGPING_MQTT_PORT,
|
||||||
|
mac=QINGPING_MAC,
|
||||||
|
)
|
||||||
|
|
||||||
schedule_service: ScheduleService | None = None
|
schedule_service: ScheduleService | None = None
|
||||||
schedule_load_error: str | None = None
|
schedule_load_error: str | None = None
|
||||||
|
|
||||||
|
auto_controller: AutoController | None = None
|
||||||
|
auto_load_error: str | None = None
|
||||||
|
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
# Application lifecycle
|
# Application lifecycle
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
@@ -68,8 +88,11 @@ schedule_load_error: str | None = None
|
|||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
global schedule_service
|
global schedule_service
|
||||||
global schedule_load_error
|
global schedule_load_error
|
||||||
|
global auto_controller
|
||||||
|
global auto_load_error
|
||||||
|
|
||||||
await service.start()
|
await service.start()
|
||||||
|
await qingping_service.start()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
@@ -84,6 +107,50 @@ async def lifespan(app: FastAPI):
|
|||||||
await schedule_service.start()
|
await schedule_service.start()
|
||||||
schedule_load_error = None
|
schedule_load_error = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
auto_config = load_auto_config(
|
||||||
|
AUTO_CONFIG_FILE
|
||||||
|
)
|
||||||
|
|
||||||
|
auto_policy = Co2SpeedPolicy(
|
||||||
|
base_speed=(
|
||||||
|
auto_config.co2.base_speed
|
||||||
|
),
|
||||||
|
thresholds=(
|
||||||
|
auto_config.co2.thresholds
|
||||||
|
),
|
||||||
|
hysteresis=(
|
||||||
|
auto_config.co2.hysteresis
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
auto_interval = (
|
||||||
|
auto_config.check_interval
|
||||||
|
)
|
||||||
|
|
||||||
|
auto_load_error = None
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
auto_policy = None
|
||||||
|
|
||||||
|
# Безопасный встроенный интервал нужен,
|
||||||
|
# потому что auto.yaml сейчас недоступен.
|
||||||
|
auto_interval = 5.0
|
||||||
|
|
||||||
|
auto_load_error = (
|
||||||
|
f"{type(exc).__name__}: {exc}"
|
||||||
|
)
|
||||||
|
|
||||||
|
auto_controller = AutoController(
|
||||||
|
schedule_service=schedule_service,
|
||||||
|
qingping_service=qingping_service,
|
||||||
|
tion_service=service,
|
||||||
|
policy=auto_policy,
|
||||||
|
interval=auto_interval,
|
||||||
|
)
|
||||||
|
|
||||||
|
await auto_controller.start()
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
schedule_service = None
|
schedule_service = None
|
||||||
schedule_load_error = f"{type(exc).__name__}: {exc}"
|
schedule_load_error = f"{type(exc).__name__}: {exc}"
|
||||||
@@ -93,6 +160,7 @@ async def lifespan(app: FastAPI):
|
|||||||
if schedule_service is not None:
|
if schedule_service is not None:
|
||||||
await schedule_service.stop()
|
await schedule_service.stop()
|
||||||
|
|
||||||
|
await qingping_service.stop()
|
||||||
await service.stop()
|
await service.stop()
|
||||||
|
|
||||||
|
|
||||||
@@ -152,6 +220,8 @@ def get_status() -> dict:
|
|||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
|
|
||||||
|
"qingping": qingping_service.status(),
|
||||||
|
|
||||||
"schedule": get_schedule_status(),
|
"schedule": get_schedule_status(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,6 +419,30 @@ def get_schedule_status() -> dict:
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_auto_status() -> dict:
|
||||||
|
|
||||||
|
if auto_controller is None:
|
||||||
|
return {
|
||||||
|
"available": False,
|
||||||
|
"state": "unavailable",
|
||||||
|
"reason": None,
|
||||||
|
"target_speed": None,
|
||||||
|
"auto_speed": None,
|
||||||
|
"last_error": None,
|
||||||
|
"config_error": (
|
||||||
|
auto_load_error
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"available": True,
|
||||||
|
**auto_controller.status(),
|
||||||
|
"config_error": (
|
||||||
|
auto_load_error
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
# Status
|
# Status
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
@@ -401,24 +495,6 @@ async def increase_speed():
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ----------------------------------------------------------------------
|
|
||||||
# Fan speed
|
|
||||||
# ----------------------------------------------------------------------
|
|
||||||
@app.post("/api/tion/speed/{speed}")
|
|
||||||
async def set_speed(
|
|
||||||
speed: Annotated[
|
|
||||||
int,
|
|
||||||
Path(
|
|
||||||
ge=MIN_FAN_SPEED,
|
|
||||||
le=MAX_FAN_SPEED,
|
|
||||||
),
|
|
||||||
] ):
|
|
||||||
|
|
||||||
return await execute_command(
|
|
||||||
lambda tion: tion.set_speed(speed),
|
|
||||||
ScheduledSettings(speed=speed),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/tion/speed/decrease")
|
@app.post("/api/tion/speed/decrease")
|
||||||
async def decrease_speed():
|
async def decrease_speed():
|
||||||
@@ -440,6 +516,24 @@ async def decrease_speed():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Fan speed
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
@app.post("/api/tion/speed/{speed}")
|
||||||
|
async def set_speed(
|
||||||
|
speed: Annotated[
|
||||||
|
int,
|
||||||
|
Path(
|
||||||
|
ge=MIN_FAN_SPEED,
|
||||||
|
le=MAX_FAN_SPEED,
|
||||||
|
),
|
||||||
|
] ):
|
||||||
|
|
||||||
|
return await execute_command(
|
||||||
|
lambda tion: tion.set_speed(speed),
|
||||||
|
ScheduledSettings(speed=speed),
|
||||||
|
)
|
||||||
|
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
# Heater
|
# Heater
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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,310 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from app.my_dataclasses import (
|
||||||
|
MIN_FAN_SPEED,
|
||||||
|
MAX_FAN_SPEED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(
|
||||||
|
frozen=True,
|
||||||
|
slots=True,
|
||||||
|
)
|
||||||
|
class Co2Config:
|
||||||
|
base_speed: int
|
||||||
|
hysteresis: int
|
||||||
|
|
||||||
|
thresholds: tuple[
|
||||||
|
tuple[int, int],
|
||||||
|
...
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(
|
||||||
|
frozen=True,
|
||||||
|
slots=True,
|
||||||
|
)
|
||||||
|
class AutoConfig:
|
||||||
|
version: int
|
||||||
|
check_interval: float
|
||||||
|
co2: Co2Config
|
||||||
|
|
||||||
|
|
||||||
|
def _require_dict(name: str, value: Any) -> dict:
|
||||||
|
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError(
|
||||||
|
f"{name} must be an object"
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_speed(name: str, value: Any) -> int:
|
||||||
|
|
||||||
|
if type(value) is not int:
|
||||||
|
raise ValueError(
|
||||||
|
f"{name} must be an integer"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not (
|
||||||
|
MIN_FAN_SPEED
|
||||||
|
<= value
|
||||||
|
<= MAX_FAN_SPEED
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"{name} must be between "
|
||||||
|
f"{MIN_FAN_SPEED} and "
|
||||||
|
f"{MAX_FAN_SPEED}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_thresholds(value: Any) -> tuple[
|
||||||
|
tuple[int, int],
|
||||||
|
...
|
||||||
|
]:
|
||||||
|
|
||||||
|
if not isinstance(value, list):
|
||||||
|
raise ValueError(
|
||||||
|
"co2.thresholds must be a list"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not value:
|
||||||
|
raise ValueError(
|
||||||
|
"co2.thresholds cannot be empty"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
|
||||||
|
previous_ppm = None
|
||||||
|
previous_speed = None
|
||||||
|
|
||||||
|
for index, item in enumerate(value):
|
||||||
|
|
||||||
|
item = _require_dict(
|
||||||
|
f"co2.thresholds[{index}]",
|
||||||
|
item,
|
||||||
|
)
|
||||||
|
|
||||||
|
unknown = set(item) - {
|
||||||
|
"ppm",
|
||||||
|
"speed",
|
||||||
|
}
|
||||||
|
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown fields in "
|
||||||
|
f"co2.thresholds[{index}]: "
|
||||||
|
f"{sorted(unknown)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "ppm" not in item:
|
||||||
|
raise ValueError(
|
||||||
|
f"co2.thresholds[{index}].ppm "
|
||||||
|
f"is required"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "speed" not in item:
|
||||||
|
raise ValueError(
|
||||||
|
f"co2.thresholds[{index}].speed "
|
||||||
|
f"is required"
|
||||||
|
)
|
||||||
|
|
||||||
|
ppm = item["ppm"]
|
||||||
|
|
||||||
|
if type(ppm) is not int:
|
||||||
|
raise ValueError(
|
||||||
|
f"co2.thresholds[{index}].ppm "
|
||||||
|
f"must be an integer"
|
||||||
|
)
|
||||||
|
|
||||||
|
if ppm <= 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"co2.thresholds[{index}].ppm "
|
||||||
|
f"must be > 0"
|
||||||
|
)
|
||||||
|
|
||||||
|
speed = _parse_speed(
|
||||||
|
(
|
||||||
|
f"co2.thresholds"
|
||||||
|
f"[{index}].speed"
|
||||||
|
),
|
||||||
|
item["speed"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
previous_ppm is not None
|
||||||
|
and ppm <= previous_ppm
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"CO2 thresholds must be "
|
||||||
|
"strictly increasing"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
previous_speed is not None
|
||||||
|
and speed <= previous_speed
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"AUTO speeds must be "
|
||||||
|
"strictly increasing"
|
||||||
|
)
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
(
|
||||||
|
ppm,
|
||||||
|
speed,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
previous_ppm = ppm
|
||||||
|
previous_speed = speed
|
||||||
|
|
||||||
|
return tuple(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_co2(value: Any) -> Co2Config:
|
||||||
|
|
||||||
|
data = _require_dict(
|
||||||
|
"co2",
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
|
||||||
|
unknown = set(data) - {
|
||||||
|
"base_speed",
|
||||||
|
"hysteresis",
|
||||||
|
"thresholds",
|
||||||
|
}
|
||||||
|
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown CO2 config fields: "
|
||||||
|
f"{sorted(unknown)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "base_speed" not in data:
|
||||||
|
raise ValueError(
|
||||||
|
"co2.base_speed is required"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "hysteresis" not in data:
|
||||||
|
raise ValueError(
|
||||||
|
"co2.hysteresis is required"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "thresholds" not in data:
|
||||||
|
raise ValueError(
|
||||||
|
"co2.thresholds is required"
|
||||||
|
)
|
||||||
|
|
||||||
|
base_speed = _parse_speed(
|
||||||
|
"co2.base_speed",
|
||||||
|
data["base_speed"],
|
||||||
|
)
|
||||||
|
|
||||||
|
hysteresis = data["hysteresis"]
|
||||||
|
|
||||||
|
if type(hysteresis) is not int:
|
||||||
|
raise ValueError(
|
||||||
|
"co2.hysteresis must be "
|
||||||
|
"an integer"
|
||||||
|
)
|
||||||
|
|
||||||
|
if hysteresis < 0:
|
||||||
|
raise ValueError(
|
||||||
|
"co2.hysteresis must be >= 0"
|
||||||
|
)
|
||||||
|
|
||||||
|
thresholds = _parse_thresholds(
|
||||||
|
data["thresholds"]
|
||||||
|
)
|
||||||
|
|
||||||
|
first_speed = thresholds[0][1]
|
||||||
|
|
||||||
|
if first_speed <= base_speed:
|
||||||
|
raise ValueError(
|
||||||
|
"First threshold speed must be "
|
||||||
|
"greater than base_speed"
|
||||||
|
)
|
||||||
|
|
||||||
|
return Co2Config(
|
||||||
|
base_speed=base_speed,
|
||||||
|
hysteresis=hysteresis,
|
||||||
|
thresholds=thresholds,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_auto_config(path: str | Path) -> AutoConfig:
|
||||||
|
|
||||||
|
path = Path(path)
|
||||||
|
|
||||||
|
with path.open(
|
||||||
|
"r",
|
||||||
|
encoding="utf-8",
|
||||||
|
) as file:
|
||||||
|
raw = yaml.safe_load(file)
|
||||||
|
|
||||||
|
data = _require_dict(
|
||||||
|
"AUTO config",
|
||||||
|
raw,
|
||||||
|
)
|
||||||
|
|
||||||
|
unknown = set(data) - {
|
||||||
|
"version",
|
||||||
|
"check_interval",
|
||||||
|
"co2",
|
||||||
|
}
|
||||||
|
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown AUTO config fields: "
|
||||||
|
f"{sorted(unknown)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
version = data.get("version")
|
||||||
|
|
||||||
|
if version != 1:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported AUTO config "
|
||||||
|
f"version: {version!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
check_interval = data.get(
|
||||||
|
"check_interval"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
type(check_interval) not in {
|
||||||
|
int,
|
||||||
|
float,
|
||||||
|
}
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"check_interval must be a number"
|
||||||
|
)
|
||||||
|
|
||||||
|
if check_interval <= 0:
|
||||||
|
raise ValueError(
|
||||||
|
"check_interval must be > 0"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "co2" not in data:
|
||||||
|
raise ValueError(
|
||||||
|
"co2 config is required"
|
||||||
|
)
|
||||||
|
|
||||||
|
co2 = _parse_co2(
|
||||||
|
data["co2"]
|
||||||
|
)
|
||||||
|
|
||||||
|
return AutoConfig(
|
||||||
|
version=version,
|
||||||
|
check_interval=float(check_interval),
|
||||||
|
co2=co2,
|
||||||
|
)
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import asyncio
|
||||||
|
from datetime import datetime
|
||||||
|
from app.auto.co2_policy import Co2SpeedPolicy
|
||||||
|
|
||||||
|
class AutoController:
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
schedule_service,
|
||||||
|
qingping_service,
|
||||||
|
tion_service,
|
||||||
|
policy: Co2SpeedPolicy | None = None,
|
||||||
|
interval: float = 5.0,
|
||||||
|
):
|
||||||
|
self._schedule = schedule_service
|
||||||
|
self._qingping = qingping_service
|
||||||
|
self._tion = tion_service
|
||||||
|
self._policy = policy
|
||||||
|
|
||||||
|
self._interval = interval
|
||||||
|
|
||||||
|
self._task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
self._state = "inactive"
|
||||||
|
self._reason: str | None = None
|
||||||
|
|
||||||
|
self._target_speed: int | None = None
|
||||||
|
self._last_error: str | None = None
|
||||||
|
|
||||||
|
self._auto_speed: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
|
||||||
|
if self._task is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._task = asyncio.create_task( self._loop() )
|
||||||
|
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
|
||||||
|
if self._task is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._task.cancel()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._task
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
finally:
|
||||||
|
self._task = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _loop(self) -> None:
|
||||||
|
|
||||||
|
while True:
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._process()
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
self._last_error = str(exc)
|
||||||
|
|
||||||
|
await asyncio.sleep( self._interval )
|
||||||
|
|
||||||
|
|
||||||
|
async def _process(self) -> None:
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
resolution = self._schedule.resolve(now)
|
||||||
|
|
||||||
|
# ----------------------------------------------
|
||||||
|
# Активен ручной override.
|
||||||
|
#
|
||||||
|
# Пока пользователь вручную управляет Tion,
|
||||||
|
# AUTO вообще не вмешивается.
|
||||||
|
# ----------------------------------------------
|
||||||
|
if self._schedule.override_active:
|
||||||
|
self._state = "suspended"
|
||||||
|
self._reason = "manual_override"
|
||||||
|
self._target_speed = None
|
||||||
|
self._last_error = None
|
||||||
|
self._auto_speed = None
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
# ----------------------------------------------
|
||||||
|
# AUTO сейчас не активен
|
||||||
|
# ----------------------------------------------
|
||||||
|
|
||||||
|
if (
|
||||||
|
not resolution.enabled
|
||||||
|
or not resolution.auto_active
|
||||||
|
):
|
||||||
|
self._state = "inactive"
|
||||||
|
self._reason = None
|
||||||
|
self._target_speed = None
|
||||||
|
self._last_error = None
|
||||||
|
self._auto_speed = None
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
fallback_speed = resolution.auto_fallback_speed
|
||||||
|
|
||||||
|
if fallback_speed is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"AUTO is active but "
|
||||||
|
"fallback speed is missing"
|
||||||
|
)
|
||||||
|
# ----------------------------------------------
|
||||||
|
# CO2 policy недоступна.
|
||||||
|
#
|
||||||
|
# Например, auto.yaml не загрузился.
|
||||||
|
# AUTO работает в аварийном fallback-only режиме.
|
||||||
|
# ----------------------------------------------
|
||||||
|
if self._policy is None:
|
||||||
|
self._state = "fallback"
|
||||||
|
self._reason = "auto_policy_unavailable"
|
||||||
|
self._auto_speed = None
|
||||||
|
|
||||||
|
await self._set_speed(
|
||||||
|
fallback_speed
|
||||||
|
)
|
||||||
|
|
||||||
|
self._last_error = None
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
qingping_state = self._qingping.state
|
||||||
|
|
||||||
|
# ----------------------------------------------
|
||||||
|
# Qingping исправен
|
||||||
|
# ----------------------------------------------
|
||||||
|
|
||||||
|
if (
|
||||||
|
self._qingping.online
|
||||||
|
and qingping_state.co2 is not None
|
||||||
|
):
|
||||||
|
target_speed = (
|
||||||
|
self._policy.select_speed(
|
||||||
|
co2=qingping_state.co2,
|
||||||
|
current_speed=self._auto_speed,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self._state = "active"
|
||||||
|
self._reason = None
|
||||||
|
|
||||||
|
await self._set_speed(
|
||||||
|
target_speed
|
||||||
|
)
|
||||||
|
|
||||||
|
self._auto_speed = target_speed
|
||||||
|
self._last_error = None
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
# ----------------------------------------------
|
||||||
|
# AUTO не может работать.
|
||||||
|
# Переходим на fallback.
|
||||||
|
# ----------------------------------------------
|
||||||
|
|
||||||
|
if not self._qingping.online:
|
||||||
|
reason = "qingping_offline"
|
||||||
|
|
||||||
|
else:
|
||||||
|
reason = "co2_missing"
|
||||||
|
|
||||||
|
self._state = "fallback"
|
||||||
|
self._reason = reason
|
||||||
|
|
||||||
|
self._auto_speed = None
|
||||||
|
|
||||||
|
await self._set_speed(fallback_speed)
|
||||||
|
|
||||||
|
self._last_error = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _set_speed(self, speed: int) -> None:
|
||||||
|
|
||||||
|
if self._target_speed == speed:
|
||||||
|
return
|
||||||
|
|
||||||
|
await self._tion.execute(
|
||||||
|
lambda controller:
|
||||||
|
controller.set_speed(speed)
|
||||||
|
)
|
||||||
|
|
||||||
|
self._target_speed = speed
|
||||||
|
|
||||||
|
|
||||||
|
def status(self) -> dict:
|
||||||
|
return {
|
||||||
|
"state": self._state,
|
||||||
|
"reason": self._reason,
|
||||||
|
"target_speed": self._target_speed,
|
||||||
|
"auto_speed": self._auto_speed,
|
||||||
|
"last_error": self._last_error,
|
||||||
|
}
|
||||||
|
|
||||||
@@ -9,6 +9,11 @@ from typing import Any, Mapping
|
|||||||
# MAC Bluetooth бризера
|
# MAC Bluetooth бризера
|
||||||
TION_MAC = "d1:74:9b:eb:ee:a6"
|
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
|
MIN_FAN_SPEED = 1
|
||||||
MAX_FAN_SPEED = 6
|
MAX_FAN_SPEED = 6
|
||||||
|
|
||||||
@@ -31,5 +36,11 @@ SCHEDULE_FILE = (
|
|||||||
/ "schedule.yaml"
|
/ "schedule.yaml"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
AUTO_CONFIG_FILE = (
|
||||||
|
PROJECT_ROOT
|
||||||
|
/ "config"
|
||||||
|
/ "auto.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
#Классы
|
#Классы
|
||||||
|
|
||||||
|
|||||||
@@ -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,610 @@
|
|||||||
|
# from datetime import datetime, timedelta, timezone
|
||||||
|
#
|
||||||
|
# from bleak import BleakScanner
|
||||||
|
# from bleak.backends.device import BLEDevice
|
||||||
|
# from bleak.backends.scanner import AdvertisementData
|
||||||
|
#
|
||||||
|
# from app.qingping.models import QingpingState
|
||||||
|
# from app.qingping.parser import parse_cgdn1
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# QINGPING_SERVICE_UUID = "0000fdcd-0000-1000-8000-00805f9b34fb"
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# class QingpingService:
|
||||||
|
# def __init__(self, mac: str, stale_after: float = 30.0):
|
||||||
|
# self._mac = mac.upper()
|
||||||
|
# self._stale_after = stale_after
|
||||||
|
#
|
||||||
|
# self._scanner: BleakScanner | None = None
|
||||||
|
# self._state: QingpingState | None = None
|
||||||
|
#
|
||||||
|
# self._last_seen: datetime | None = None
|
||||||
|
# self._last_error: str | None = None
|
||||||
|
#
|
||||||
|
# self._running = False
|
||||||
|
#
|
||||||
|
# @property
|
||||||
|
# def state(self) -> QingpingState | None:
|
||||||
|
# return self._state
|
||||||
|
#
|
||||||
|
# @property
|
||||||
|
# def running(self) -> bool:
|
||||||
|
# return self._running
|
||||||
|
#
|
||||||
|
# @property
|
||||||
|
# def last_seen(self) -> datetime | None:
|
||||||
|
# return self._last_seen
|
||||||
|
#
|
||||||
|
# @property
|
||||||
|
# def last_error(self) -> str | None:
|
||||||
|
# return self._last_error
|
||||||
|
#
|
||||||
|
# @property
|
||||||
|
# def online(self) -> bool:
|
||||||
|
# if not self._running:
|
||||||
|
# return False
|
||||||
|
#
|
||||||
|
# if self._last_seen is None:
|
||||||
|
# return False
|
||||||
|
#
|
||||||
|
# age = datetime.now(timezone.utc) - self._last_seen
|
||||||
|
#
|
||||||
|
# return age <= timedelta(
|
||||||
|
# seconds=self._stale_after
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
# async def start(self):
|
||||||
|
# if self._running:
|
||||||
|
# return
|
||||||
|
#
|
||||||
|
# try:
|
||||||
|
# self._scanner = BleakScanner(
|
||||||
|
# self._on_advertisement,
|
||||||
|
# # service_uuids=[
|
||||||
|
# # QINGPING_SERVICE_UUID,
|
||||||
|
# # ],
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
# await self._scanner.start()
|
||||||
|
#
|
||||||
|
# self._running = True
|
||||||
|
# self._last_error = None
|
||||||
|
#
|
||||||
|
# except Exception as exc:
|
||||||
|
# self._scanner = None
|
||||||
|
# self._running = False
|
||||||
|
# self._last_error = str(exc)
|
||||||
|
#
|
||||||
|
# raise
|
||||||
|
#
|
||||||
|
# async def stop(self):
|
||||||
|
# scanner = self._scanner
|
||||||
|
#
|
||||||
|
# self._scanner = None
|
||||||
|
# self._running = False
|
||||||
|
#
|
||||||
|
# if scanner is not None:
|
||||||
|
# await scanner.stop()
|
||||||
|
#
|
||||||
|
# # def _on_advertisement(
|
||||||
|
# # self,
|
||||||
|
# # device: BLEDevice,
|
||||||
|
# # advertisement: AdvertisementData,
|
||||||
|
# # ):
|
||||||
|
# # if device.address.upper() != self._mac:
|
||||||
|
# # return
|
||||||
|
# #
|
||||||
|
# # data = advertisement.service_data.get(
|
||||||
|
# # QINGPING_SERVICE_UUID
|
||||||
|
# # )
|
||||||
|
# #
|
||||||
|
# # if not data:
|
||||||
|
# # return
|
||||||
|
# #
|
||||||
|
# # try:
|
||||||
|
# # state = parse_cgdn1(
|
||||||
|
# # data,
|
||||||
|
# # rssi=getattr(
|
||||||
|
# # advertisement,
|
||||||
|
# # "rssi",
|
||||||
|
# # None,
|
||||||
|
# # ),
|
||||||
|
# # )
|
||||||
|
# #
|
||||||
|
# # if state is None:
|
||||||
|
# # return
|
||||||
|
# #
|
||||||
|
# # self._state = state
|
||||||
|
# #
|
||||||
|
# # self._last_seen = datetime.now(
|
||||||
|
# # timezone.utc
|
||||||
|
# # )
|
||||||
|
# #
|
||||||
|
# # self._last_error = None
|
||||||
|
# #
|
||||||
|
# # except Exception as exc:
|
||||||
|
# # self._last_error = str(exc)
|
||||||
|
#
|
||||||
|
# def _on_advertisement(
|
||||||
|
# self,
|
||||||
|
# device: BLEDevice,
|
||||||
|
# advertisement: AdvertisementData,
|
||||||
|
# ):
|
||||||
|
# data = advertisement.service_data.get(
|
||||||
|
# QINGPING_SERVICE_UUID
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
# if not data:
|
||||||
|
# return
|
||||||
|
#
|
||||||
|
# print(
|
||||||
|
# "QINGPING:",
|
||||||
|
# device.address,
|
||||||
|
# device.name,
|
||||||
|
# data.hex(" "),
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
# if device.address.upper() != self._mac:
|
||||||
|
# print(
|
||||||
|
# "MAC mismatch:",
|
||||||
|
# device.address,
|
||||||
|
# "!=",
|
||||||
|
# self._mac,
|
||||||
|
# )
|
||||||
|
# return
|
||||||
|
#
|
||||||
|
# try:
|
||||||
|
# state = parse_cgdn1(
|
||||||
|
# data,
|
||||||
|
# rssi=getattr(
|
||||||
|
# advertisement,
|
||||||
|
# "rssi",
|
||||||
|
# None,
|
||||||
|
# ),
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
# print("PARSED:", state)
|
||||||
|
#
|
||||||
|
# if state is None:
|
||||||
|
# return
|
||||||
|
#
|
||||||
|
# self._state = state
|
||||||
|
# self._last_seen = datetime.now(
|
||||||
|
# timezone.utc
|
||||||
|
# )
|
||||||
|
# self._last_error = None
|
||||||
|
#
|
||||||
|
# except Exception as exc:
|
||||||
|
# self._last_error = str(exc)
|
||||||
|
# print("Qingping parse error:", exc)
|
||||||
|
#
|
||||||
|
# async def __aenter__(self):
|
||||||
|
# await self.start()
|
||||||
|
# return self
|
||||||
|
#
|
||||||
|
# async def __aexit__(
|
||||||
|
# self,
|
||||||
|
# exc_type,
|
||||||
|
# exc_val,
|
||||||
|
# exc_tb,
|
||||||
|
# ):
|
||||||
|
# await self.stop()
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import replace
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
|
||||||
|
from .models import QingpingState
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class QingpingService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
port: int = 1883,
|
||||||
|
mac: str = "CCB5D131BA93",
|
||||||
|
):
|
||||||
|
self._host = host
|
||||||
|
self._port = port
|
||||||
|
self._mac = mac
|
||||||
|
|
||||||
|
self._up_topic = f"qingping/{mac}/up"
|
||||||
|
self._down_topic = f"qingping/{mac}/down"
|
||||||
|
|
||||||
|
self._state = QingpingState()
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
self._client: mqtt.Client | None = None
|
||||||
|
self._watchdog_task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
self._last_sample_monotonic: float | None = None
|
||||||
|
self._connected_since: float | None = None
|
||||||
|
|
||||||
|
self._heartbeat_seen = False
|
||||||
|
self._recovery_sent = False
|
||||||
|
|
||||||
|
self._last_device_timestamp: int | None = None
|
||||||
|
self._reboot_detected = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> QingpingState:
|
||||||
|
with self._lock:
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
@property
|
||||||
|
def online(self) -> bool:
|
||||||
|
state = self.state
|
||||||
|
|
||||||
|
if not state.mqtt_connected:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self._last_sample_monotonic is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return time.monotonic() - self._last_sample_monotonic < 60
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
client = mqtt.Client(
|
||||||
|
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
||||||
|
client_id="tioncontroller-qingping",
|
||||||
|
)
|
||||||
|
|
||||||
|
client.on_connect = self._on_connect
|
||||||
|
client.on_disconnect = self._on_disconnect
|
||||||
|
client.on_message = self._on_message
|
||||||
|
|
||||||
|
client.reconnect_delay_set(
|
||||||
|
min_delay=1,
|
||||||
|
max_delay=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
client.connect_async(
|
||||||
|
self._host,
|
||||||
|
self._port,
|
||||||
|
keepalive=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
client.loop_start()
|
||||||
|
|
||||||
|
self._watchdog_task = asyncio.create_task(
|
||||||
|
self._watchdog_loop()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
if self._watchdog_task is not None:
|
||||||
|
self._watchdog_task.cancel()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._watchdog_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self._watchdog_task = None
|
||||||
|
|
||||||
|
if self._client is not None:
|
||||||
|
self._client.disconnect()
|
||||||
|
self._client.loop_stop()
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
def status(self) -> dict:
|
||||||
|
result = self.state.to_dict()
|
||||||
|
result["online"] = self.online
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _on_connect(
|
||||||
|
self,
|
||||||
|
client,
|
||||||
|
userdata,
|
||||||
|
flags,
|
||||||
|
reason_code,
|
||||||
|
properties,
|
||||||
|
):
|
||||||
|
if reason_code.is_failure:
|
||||||
|
logger.warning(
|
||||||
|
"Qingping MQTT connection failed: %s",
|
||||||
|
reason_code,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
client.subscribe(self._up_topic)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._state = replace(
|
||||||
|
self._state,
|
||||||
|
mqtt_connected=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._connected_since = time.monotonic()
|
||||||
|
self._heartbeat_seen = False
|
||||||
|
self._recovery_sent = False
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Qingping MQTT connected"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_disconnect(
|
||||||
|
self,
|
||||||
|
client,
|
||||||
|
userdata,
|
||||||
|
disconnect_flags,
|
||||||
|
reason_code,
|
||||||
|
properties,
|
||||||
|
):
|
||||||
|
with self._lock:
|
||||||
|
self._state = replace(
|
||||||
|
self._state,
|
||||||
|
mqtt_connected=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"Qingping MQTT disconnected: %s",
|
||||||
|
reason_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_message(
|
||||||
|
self,
|
||||||
|
client,
|
||||||
|
userdata,
|
||||||
|
message,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
payload = json.loads(
|
||||||
|
message.payload.decode("utf-8")
|
||||||
|
)
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||||
|
logger.warning(
|
||||||
|
"Invalid Qingping MQTT message"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._state = replace(
|
||||||
|
self._state,
|
||||||
|
last_message_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
message_type = str(payload.get("type"))
|
||||||
|
|
||||||
|
if message_type == "13":
|
||||||
|
self._handle_heartbeat(payload)
|
||||||
|
return
|
||||||
|
|
||||||
|
if message_type in {"12", "17"}:
|
||||||
|
self._handle_sensor_data(
|
||||||
|
payload,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _handle_heartbeat(
|
||||||
|
self,
|
||||||
|
payload: dict,
|
||||||
|
) -> None:
|
||||||
|
self._heartbeat_seen = True
|
||||||
|
|
||||||
|
device_timestamp = payload.get("timestamp")
|
||||||
|
|
||||||
|
if isinstance(device_timestamp, int):
|
||||||
|
previous = self._last_device_timestamp
|
||||||
|
|
||||||
|
if (
|
||||||
|
device_timestamp < 300
|
||||||
|
and (
|
||||||
|
previous is None
|
||||||
|
or previous > 300
|
||||||
|
)
|
||||||
|
):
|
||||||
|
self._reboot_detected = True
|
||||||
|
self._recovery_sent = False
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Qingping reboot detected"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._last_device_timestamp = device_timestamp
|
||||||
|
|
||||||
|
wifi_rssi = None
|
||||||
|
|
||||||
|
wifi_info = payload.get("wifi_info")
|
||||||
|
|
||||||
|
if isinstance(wifi_info, str):
|
||||||
|
parts = wifi_info.split(",")
|
||||||
|
|
||||||
|
if len(parts) >= 2:
|
||||||
|
try:
|
||||||
|
wifi_rssi = int(parts[1])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._state = replace(
|
||||||
|
self._state,
|
||||||
|
wifi_rssi=wifi_rssi,
|
||||||
|
firmware=payload.get("sw_version"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _handle_sensor_data(
|
||||||
|
self,
|
||||||
|
payload: dict,
|
||||||
|
received_at: datetime,
|
||||||
|
) -> None:
|
||||||
|
sensor_data = payload.get("sensorData")
|
||||||
|
|
||||||
|
if not isinstance(sensor_data, list):
|
||||||
|
return
|
||||||
|
|
||||||
|
if not sensor_data:
|
||||||
|
return
|
||||||
|
|
||||||
|
sample = max(
|
||||||
|
sensor_data,
|
||||||
|
key=self._sample_timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
sample_timestamp = self._sample_timestamp(
|
||||||
|
sample
|
||||||
|
)
|
||||||
|
|
||||||
|
if sample_timestamp <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
current_timestamp = (
|
||||||
|
self.state.sample_timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
# CGDN1 после запуска может несколько раз
|
||||||
|
# присылать одну и ту же историческую точку.
|
||||||
|
if (
|
||||||
|
current_timestamp is not None
|
||||||
|
and sample_timestamp <= current_timestamp
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
temperature = self._value(
|
||||||
|
sample,
|
||||||
|
"temperature",
|
||||||
|
)
|
||||||
|
humidity = self._value(
|
||||||
|
sample,
|
||||||
|
"humidity",
|
||||||
|
)
|
||||||
|
co2 = self._value(
|
||||||
|
sample,
|
||||||
|
"co2",
|
||||||
|
)
|
||||||
|
pm25 = self._value(
|
||||||
|
sample,
|
||||||
|
"pm25",
|
||||||
|
)
|
||||||
|
pm10 = self._value(
|
||||||
|
sample,
|
||||||
|
"pm10",
|
||||||
|
)
|
||||||
|
battery = self._value(
|
||||||
|
sample,
|
||||||
|
"battery",
|
||||||
|
)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._state = replace(
|
||||||
|
self._state,
|
||||||
|
temperature=temperature,
|
||||||
|
humidity=humidity,
|
||||||
|
co2=co2,
|
||||||
|
pm25=pm25,
|
||||||
|
pm10=pm10,
|
||||||
|
battery=battery,
|
||||||
|
sample_timestamp=sample_timestamp,
|
||||||
|
sample_received_at=received_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._last_sample_monotonic = (
|
||||||
|
time.monotonic()
|
||||||
|
)
|
||||||
|
|
||||||
|
self._reboot_detected = False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sample_timestamp(
|
||||||
|
sample: dict,
|
||||||
|
) -> int:
|
||||||
|
timestamp = sample.get("timestamp")
|
||||||
|
|
||||||
|
if isinstance(timestamp, dict):
|
||||||
|
timestamp = timestamp.get("value")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return int(timestamp)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _value(
|
||||||
|
sample: dict,
|
||||||
|
key: str,
|
||||||
|
):
|
||||||
|
value = sample.get(key)
|
||||||
|
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return value.get("value")
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
async def _watchdog_loop(self) -> None:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
if self._client is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
state = self.state
|
||||||
|
|
||||||
|
if not state.mqtt_connected:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if self._recovery_sent:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Явно увидели reboot CGDN1.
|
||||||
|
if self._reboot_detected:
|
||||||
|
self._send_recovery()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Или сервис подключился, heartbeat есть,
|
||||||
|
# но свежих sensorData так и не появилось.
|
||||||
|
if (
|
||||||
|
self._connected_since is not None
|
||||||
|
and self._heartbeat_seen
|
||||||
|
and self._last_sample_monotonic is None
|
||||||
|
and time.monotonic()
|
||||||
|
- self._connected_since
|
||||||
|
> 30
|
||||||
|
):
|
||||||
|
self._send_recovery()
|
||||||
|
|
||||||
|
def _send_recovery(self) -> None:
|
||||||
|
if self._client is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"type": "17",
|
||||||
|
"timestamp": int(time.time()),
|
||||||
|
"setting": {
|
||||||
|
"report_interval": 15,
|
||||||
|
"collect_interval": 15,
|
||||||
|
"need_ack": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = self._client.publish(
|
||||||
|
self._down_topic,
|
||||||
|
json.dumps(
|
||||||
|
payload,
|
||||||
|
separators=(",", ":"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.rc == mqtt.MQTT_ERR_SUCCESS:
|
||||||
|
self._recovery_sent = True
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"Qingping recovery command sent"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to send Qingping recovery: %s",
|
||||||
|
result.rc,
|
||||||
|
)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
version: 1
|
||||||
|
|
||||||
|
check_interval: 5.0
|
||||||
|
|
||||||
|
co2:
|
||||||
|
base_speed: 1
|
||||||
|
hysteresis: 100
|
||||||
|
|
||||||
|
thresholds:
|
||||||
|
- ppm: 800
|
||||||
|
speed: 2
|
||||||
|
|
||||||
|
- ppm: 1000
|
||||||
|
speed: 3
|
||||||
|
|
||||||
|
- ppm: 1300
|
||||||
|
speed: 4
|
||||||
|
|
||||||
|
- ppm: 1600
|
||||||
|
speed: 5
|
||||||
|
|
||||||
|
- ppm: 2000
|
||||||
|
speed: 6
|
||||||
+18
-4
@@ -30,6 +30,7 @@ templates:
|
|||||||
- time: "13:00"
|
- time: "13:00"
|
||||||
action:
|
action:
|
||||||
type: auto
|
type: auto
|
||||||
|
speed: 1
|
||||||
|
|
||||||
- time: "17:00"
|
- time: "17:00"
|
||||||
action:
|
action:
|
||||||
@@ -47,6 +48,7 @@ templates:
|
|||||||
- time: "20:30"
|
- time: "20:30"
|
||||||
action:
|
action:
|
||||||
type: auto
|
type: auto
|
||||||
|
speed: 2
|
||||||
|
|
||||||
|
|
||||||
- time: "20:31"
|
- time: "20:31"
|
||||||
@@ -60,10 +62,21 @@ templates:
|
|||||||
speed: 1
|
speed: 1
|
||||||
heater: off
|
heater: off
|
||||||
|
|
||||||
- time: "23:58"
|
- time: "23:55"
|
||||||
action:
|
action:
|
||||||
type: set
|
type: set
|
||||||
speed: 3
|
power: on
|
||||||
|
speed: 1
|
||||||
|
|
||||||
|
- time: "23:57"
|
||||||
|
action:
|
||||||
|
type: auto
|
||||||
|
speed: 2
|
||||||
|
|
||||||
|
- time: "00:05"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
speed: 1
|
||||||
|
|
||||||
|
|
||||||
weekend:
|
weekend:
|
||||||
@@ -90,6 +103,7 @@ templates:
|
|||||||
- time: "10:00"
|
- time: "10:00"
|
||||||
action:
|
action:
|
||||||
type: auto
|
type: auto
|
||||||
|
speed: 1
|
||||||
|
|
||||||
- time: "23:30"
|
- time: "23:30"
|
||||||
action:
|
action:
|
||||||
@@ -108,5 +122,5 @@ days:
|
|||||||
thu: workday
|
thu: workday
|
||||||
fri: workday
|
fri: workday
|
||||||
|
|
||||||
sat: weekend
|
sat: workday
|
||||||
sun: weekend
|
sun: workday
|
||||||
@@ -2,3 +2,4 @@ tion-btle==3.3.6
|
|||||||
fastapi
|
fastapi
|
||||||
uvicorn[standard]
|
uvicorn[standard]
|
||||||
PyYAML
|
PyYAML
|
||||||
|
paho-mqtt>=2.1,<3
|
||||||
+20
-5
@@ -208,16 +208,32 @@ def _parse_action(
|
|||||||
|
|
||||||
if action_type == ScheduleActionType.AUTO:
|
if action_type == ScheduleActionType.AUTO:
|
||||||
|
|
||||||
extra = set(data) - {"type"}
|
extra = set(data) - {
|
||||||
|
"type",
|
||||||
|
"speed",
|
||||||
|
}
|
||||||
|
|
||||||
if extra:
|
if extra:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"AUTO action cannot contain "
|
"AUTO action supports only "
|
||||||
f"SET fields: {sorted(extra)}"
|
f"'speed': {sorted(extra)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "speed" not in data:
|
||||||
|
raise ValueError(
|
||||||
|
"AUTO action must contain "
|
||||||
|
"fallback speed"
|
||||||
|
)
|
||||||
|
|
||||||
|
settings = _parse_settings(
|
||||||
|
{
|
||||||
|
"speed": data["speed"],
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return ScheduleAction(
|
return ScheduleAction(
|
||||||
type=ScheduleActionType.AUTO
|
type=ScheduleActionType.AUTO,
|
||||||
|
settings=settings,
|
||||||
)
|
)
|
||||||
|
|
||||||
settings_data = {
|
settings_data = {
|
||||||
@@ -233,7 +249,6 @@ def _parse_action(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parse_template(
|
def _parse_template(
|
||||||
name: str,
|
name: str,
|
||||||
data: Any,
|
data: Any,
|
||||||
|
|||||||
@@ -117,3 +117,4 @@ class ScheduleResolution:
|
|||||||
scheduled_settings: ScheduledSettings
|
scheduled_settings: ScheduledSettings
|
||||||
|
|
||||||
auto_active: bool
|
auto_active: bool
|
||||||
|
auto_fallback_speed: int | None
|
||||||
+22
-3
@@ -16,6 +16,7 @@ from .models import (
|
|||||||
ScheduledSettings,
|
ScheduledSettings,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
WEEKDAYS = (
|
WEEKDAYS = (
|
||||||
"mon",
|
"mon",
|
||||||
@@ -230,12 +231,20 @@ class ScheduleService:
|
|||||||
== ScheduleActionType.AUTO
|
== ScheduleActionType.AUTO
|
||||||
)
|
)
|
||||||
|
|
||||||
|
auto_fallback_speed = None
|
||||||
|
|
||||||
|
if auto_active:
|
||||||
|
auto_fallback_speed = (
|
||||||
|
current.point.action.settings.speed
|
||||||
|
)
|
||||||
|
|
||||||
return ScheduleResolution(
|
return ScheduleResolution(
|
||||||
enabled=True,
|
enabled=True,
|
||||||
current=current,
|
current=current,
|
||||||
next=next_point,
|
next=next_point,
|
||||||
scheduled_settings=scheduled_settings,
|
scheduled_settings=scheduled_settings,
|
||||||
auto_active=auto_active,
|
auto_active=auto_active,
|
||||||
|
auto_fallback_speed=auto_fallback_speed,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _build_occurrences(
|
def _build_occurrences(
|
||||||
@@ -451,18 +460,28 @@ class ScheduleService:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# --------------------------------------------------
|
# --------------------------------------------------
|
||||||
# 4. Приводим Tion к ПОЛНОМУ актуальному
|
# 4. Применяем состояние расписания
|
||||||
# состоянию расписания
|
|
||||||
# --------------------------------------------------
|
# --------------------------------------------------
|
||||||
|
|
||||||
await self._apply_settings(resolution.scheduled_settings)
|
settings = resolution.scheduled_settings
|
||||||
|
|
||||||
|
if resolution.auto_active:
|
||||||
|
# В режиме AUTO скорость принадлежит
|
||||||
|
# AutoController.
|
||||||
|
#
|
||||||
|
# Остальные параметры расписания
|
||||||
|
# (power, heater, temperature, mode...)
|
||||||
|
# должны продолжать работать.
|
||||||
|
settings = replace(settings, speed=None)
|
||||||
|
|
||||||
|
await self._apply_settings(settings)
|
||||||
|
|
||||||
# Ставим только ПОСЛЕ успешного применения.
|
# Ставим только ПОСЛЕ успешного применения.
|
||||||
self._last_applied_when = current.when
|
self._last_applied_when = current.when
|
||||||
self._last_error = None
|
self._last_error = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def _apply_settings(self, settings: ScheduledSettings) -> None:
|
async def _apply_settings(self, settings: ScheduledSettings) -> None:
|
||||||
|
|
||||||
if self._tion is None:
|
if self._tion is None:
|
||||||
|
|||||||
@@ -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,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,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,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())
|
||||||
Reference in New Issue
Block a user