work: добавил автоматический режим
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user