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