work: добавил автоматический режим

This commit is contained in:
Fedorov Dmitriy
2026-09-18 23:59:42 +03:00
parent 7dbfd8b069
commit cdec9b0996
24 changed files with 3449 additions and 33 deletions
+22
View File
@@ -0,0 +1,22 @@
from .config import (
AutoConfig,
Co2Config,
load_auto_config,
)
from .co2_policy import (
Co2SpeedPolicy,
)
from .controller import (
AutoController,
)
__all__ = [
"AutoConfig",
"Co2Config",
"load_auto_config",
"Co2SpeedPolicy",
"AutoController",
]
+129
View File
@@ -0,0 +1,129 @@
class Co2SpeedPolicy:
def __init__(
self,
*,
base_speed: int,
thresholds: tuple[
tuple[int, int],
...
],
hysteresis: int,
):
if hysteresis < 0:
raise ValueError(
"hysteresis must be >= 0"
)
if not thresholds:
raise ValueError(
"thresholds cannot be empty"
)
previous_ppm = None
speeds = [base_speed]
for ppm, speed in thresholds:
if previous_ppm is not None:
if ppm <= previous_ppm:
raise ValueError(
"CO2 thresholds must "
"be strictly increasing"
)
if speed in speeds:
raise ValueError(
"AUTO speeds must be unique"
)
speeds.append(speed)
previous_ppm = ppm
self._base_speed = base_speed
self._thresholds = thresholds
self._hysteresis = hysteresis
self._speeds = tuple(speeds)
def select_speed(self, co2: int, current_speed: int | None) -> int:
if co2 < 0:
raise ValueError(
"CO2 cannot be negative"
)
# --------------------------------------------------
# Первое решение AUTO.
#
# Гистерезис пока применять не к чему:
# предыдущей AUTO-скорости ещё нет.
# --------------------------------------------------
if (
current_speed is None
or current_speed not in self._speeds
):
return self._select_initial_speed(co2)
index = self._speeds.index(current_speed)
# --------------------------------------------------
# CO2 растёт.
#
# Проверяем пороги перехода вверх.
# За один вызов можем перепрыгнуть
# сразу несколько скоростей.
# --------------------------------------------------
while index < len(
self._thresholds
):
ppm, _ = self._thresholds[index]
if co2 < ppm:
break
index += 1
# --------------------------------------------------
# CO2 падает.
#
# Для перехода вниз используем:
#
# threshold - hysteresis
#
# Поэтому скорость не будет прыгать
# туда-сюда около одного порога.
# --------------------------------------------------
while index > 0:
ppm, _ = self._thresholds[index - 1]
down_threshold = ppm - self._hysteresis
if co2 > down_threshold:
break
index -= 1
return self._speeds[index]
def _select_initial_speed(self, co2: int) -> int:
speed = self._base_speed
for ppm, candidate_speed in (
self._thresholds
):
if co2 < ppm:
break
speed = candidate_speed
return speed
+310
View File
@@ -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,
)
+208
View File
@@ -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,
}