doc: поправил зависимости и расписание

This commit is contained in:
Fedorov Dmitriy
2026-09-19 19:14:40 +03:00
parent 98cb97c9da
commit 1cf1407e04
54 changed files with 10729 additions and 0 deletions
View File
+37
View File
@@ -0,0 +1,37 @@
import asyncio
import logging
from app.my_dataclasses import TION_MAC
from app.tion import TionController, TionService
# Убираем служебное логирование библиотек
async def main():
logging.getLogger("asyncio").setLevel(logging.WARNING)
logging.getLogger("bleak").setLevel(logging.WARNING)
logging.getLogger("tion_btle").setLevel(logging.WARNING)
controller = TionController(TION_MAC)
service = TionService(
controller,
poll_interval=3,
)
async with service:
for seconds in range(0, 91, 3):
print(
f"{seconds:02d}s | "
f"online={service.online} | "
f"last_seen={service.last_seen} | "
f"error={service.last_error}"
)
await asyncio.sleep(3)
if __name__ == "__main__":
asyncio.run(main())
+30
View File
@@ -0,0 +1,30 @@
import asyncio
import logging
from bleak import BleakScanner
from app.my_dataclasses import TION_MAC
logging.disable(logging.CRITICAL)
async def main():
print("Ищу Tion...")
device = await BleakScanner.find_device_by_address(
TION_MAC,
timeout=10,
)
if device is None:
print("Tion НЕ найден сканером")
else:
print("Tion найден:")
print(f" name: {device.name}")
print(f" address: {device.address}")
print(f" details: {device.details}")
if __name__ == "__main__":
asyncio.run(main())
+59
View File
@@ -0,0 +1,59 @@
#d1749bebeea6
import asyncio
import json
import logging
from tion_btle import TionS4
from app.my_dataclasses import *
def print_state(state: dict):
print(json.dumps(
state,
indent=2,
ensure_ascii=False
))
async def main():
# Убираем лишнее логирование
logging.getLogger("asyncio").setLevel(logging.WARNING)
logging.getLogger("bleak").setLevel(logging.WARNING)
logging.getLogger("tion_btle").setLevel(logging.WARNING)
tion = TionS4(TION_MAC)
print("=== Подключение к Tion ===")
await tion.connect()
try:
# 1. Получаем исходное состояние
print("\n=== Исходное состояние ===")
state = await tion.get()
print_state(state)
# 2. Включаем Tion и устанавливаем скорость 2
print("\n=== Включаю Tion, скорость 2 ===")
await tion.set({
"state": "on",
"fan_speed": 2
})
await asyncio.sleep(1)
# 3. Повторно читаем реальное состояние
print("\n=== Состояние после команды ===")
state = await tion.get()
print_state(state)
finally:
print("\n=== Отключение ===")
await tion.disconnect()
if __name__ == "__main__":
asyncio.run(main())
+101
View File
@@ -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()
+884
View File
@@ -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())
+154
View File
@@ -0,0 +1,154 @@
import asyncio
from app.auto.controller import AutoController
class FakeTionController:
def __init__(self):
self.calls = []
async def heater_on(self):
self.calls.append(
("heater_on", None)
)
async def heater_off(self):
self.calls.append(
("heater_off", None)
)
class FakeTionService:
def __init__(self):
self.controller = (
FakeTionController()
)
async def execute(
self,
operation,
):
return await operation(
self.controller
)
def make_controller():
tion = FakeTionService()
controller = AutoController(
schedule_service=None,
qingping_service=None,
tion_service=tion,
)
return controller, tion
async def test_heater_on():
controller, tion = (
make_controller()
)
await controller._set_heater(
True
)
assert tion.controller.calls == [
("heater_on", None)
]
assert (
controller._target_heater
is True
)
async def test_duplicate_heater_on():
controller, tion = (
make_controller()
)
await controller._set_heater(
True
)
await controller._set_heater(
True
)
assert tion.controller.calls == [
("heater_on", None)
]
async def test_heater_off_after_on():
controller, tion = (
make_controller()
)
await controller._set_heater(
True
)
await controller._set_heater(
False
)
assert tion.controller.calls == [
("heater_on", None),
("heater_off", None),
]
assert (
controller._target_heater
is False
)
async def test_duplicate_heater_off():
controller, tion = (
make_controller()
)
await controller._set_heater(
False
)
await controller._set_heater(
False
)
assert tion.controller.calls == [
("heater_off", None)
]
async def main():
await test_heater_on()
await test_duplicate_heater_on()
await test_heater_off_after_on()
await test_duplicate_heater_off()
print()
print("=" * 70)
print(
"ALL AUTO HEATER COMMAND "
"TESTS PASSED"
)
print("=" * 70)
if __name__ == "__main__":
asyncio.run(main())
+304
View File
@@ -0,0 +1,304 @@
import asyncio
from types import SimpleNamespace
from app.auto.config import (
TemperatureConfig,
)
from app.auto.controller import (
AutoController,
)
from app.auto.temperature_policy import (
TemperaturePolicy,
)
class FakeQingpingService:
def __init__(self):
self.online = True
self.state = SimpleNamespace(
temperature=19.4,
)
class FakeTionController:
def __init__(self):
self.calls = []
async def heater_on(self):
self.calls.append(
("heater_on", None)
)
async def heater_off(self):
self.calls.append(
("heater_off", None)
)
class FakeTionService:
def __init__(self):
self.online = True
self.state = SimpleNamespace(
in_temp=18,
)
self.controller = (
FakeTionController()
)
async def execute(
self,
operation,
):
return await operation(
self.controller
)
def make_resolution(
target_temp=20.0,
):
return SimpleNamespace(
scheduled_settings=(
SimpleNamespace(
target_temp=target_temp,
)
)
)
def make_controller():
qingping = (
FakeQingpingService()
)
tion = FakeTionService()
policy = TemperaturePolicy(
TemperatureConfig(
hysteresis=0.5,
)
)
controller = AutoController(
schedule_service=None,
qingping_service=qingping,
tion_service=tion,
temperature_policy=policy,
)
return (
controller,
qingping,
tion,
)
async def test_qingping_heating():
controller, qingping, tion = (
make_controller()
)
resolution = make_resolution()
# 19.4 < 19.5
# Heater должен включиться.
await controller._process_heater(
resolution
)
assert tion.controller.calls == [
("heater_on", None)
]
assert (
controller._auto_heater
is True
)
assert (
controller._temperature
== 19.4
)
assert (
controller._temperature_source
== "qingping"
)
tion.controller.calls.clear()
# ----------------------------------------------
# 19.8
#
# Heater уже ON.
# До 20 градусов ещё не дошли.
# Продолжаем греть.
#
# Новую команду отправлять не нужно.
# ----------------------------------------------
qingping.state.temperature = 19.8
await controller._process_heater(
resolution
)
assert tion.controller.calls == []
assert (
controller._auto_heater
is True
)
# ----------------------------------------------
# 20.0
#
# Цель достигнута.
# ----------------------------------------------
qingping.state.temperature = 20.0
await controller._process_heater(
resolution
)
assert tion.controller.calls == [
("heater_off", None)
]
assert (
controller._auto_heater
is False
)
async def test_tion_temperature_fallback():
controller, qingping, tion = (
make_controller()
)
resolution = make_resolution()
# Qingping отключился.
qingping.online = False
# Используем Tion.
tion.state.in_temp = 19
await controller._process_heater(
resolution
)
assert tion.controller.calls == [
("heater_on", None)
]
assert (
controller._temperature
== 19
)
assert (
controller._temperature_source
== "tion"
)
assert (
controller._auto_heater
is True
)
async def test_no_temperature():
controller, qingping, tion = (
make_controller()
)
resolution = make_resolution()
qingping.online = False
tion.online = False
await controller._process_heater(
resolution
)
assert tion.controller.calls == [
("heater_off", None)
]
assert (
controller._temperature
is None
)
assert (
controller._temperature_source
is None
)
assert (
controller._auto_heater
is False
)
async def test_target_temperature_missing():
controller, qingping, tion = (
make_controller()
)
resolution = make_resolution(
target_temp=None
)
await controller._process_heater(
resolution
)
assert tion.controller.calls == [
("heater_off", None)
]
assert (
controller._auto_heater
is False
)
async def main():
await test_qingping_heating()
await test_tion_temperature_fallback()
await test_no_temperature()
await test_target_temperature_missing()
print()
print("=" * 70)
print(
"ALL AUTO HEATER PROCESS "
"TESTS PASSED"
)
print("=" * 70)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,138 @@
import asyncio
from types import SimpleNamespace
from app.auto.config import (
TemperatureConfig,
)
from app.auto.controller import (
AutoController,
)
from app.auto.temperature_policy import (
TemperaturePolicy,
)
class FakeQingpingService:
def __init__(self):
self.online = True
self.state = SimpleNamespace(
temperature=19.5,
)
class FakeTionController:
def __init__(self):
self.calls = []
async def heater_on(self):
self.calls.append(
("heater_on", None)
)
async def heater_off(self):
self.calls.append(
("heater_off", None)
)
class FakeTionService:
def __init__(self):
self.online = True
self.state = SimpleNamespace(
in_temp=19,
)
self.controller = (
FakeTionController()
)
async def execute(
self,
operation,
):
return await operation(
self.controller
)
async def test_auto_uses_auto_target_temperature():
qingping = FakeQingpingService()
tion = FakeTionService()
policy = TemperaturePolicy(
TemperatureConfig(
hysteresis=0.5,
)
)
controller = AutoController(
schedule_service=None,
qingping_service=qingping,
tion_service=tion,
temperature_policy=policy,
)
resolution = SimpleNamespace(
# Последний SET хотел 23°C.
scheduled_settings=SimpleNamespace(
target_temp=23,
),
# Но текущий AUTO явно хочет 20°C.
auto_target_temp=20,
)
await controller._process_heater(
resolution
)
# При 19.5°C и target=20°C:
#
# heater был OFF,
# нижняя граница = 19.5°C.
#
# Поэтому heater должен остаться OFF.
#
# Если бы контроллер ошибочно использовал
# SET target_temp=23, он бы включил heater.
assert tion.controller.calls == [
("heater_off", None)
]
assert (
controller._auto_heater
is False
)
assert (
controller._temperature
== 19.5
)
assert (
controller._temperature_source
== "qingping"
)
async def main():
await test_auto_uses_auto_target_temperature()
print()
print("=" * 70)
print(
"ALL AUTO HEATER TARGET "
"TEMPERATURE TESTS PASSED"
)
print("=" * 70)
if __name__ == "__main__":
asyncio.run(main())
+245
View File
@@ -0,0 +1,245 @@
import asyncio
from types import SimpleNamespace
from app.auto.config import TemperatureConfig
from app.auto.controller import AutoController
from app.auto.temperature_policy import TemperaturePolicy
from app.my_dataclasses import MAX_TARGET_TEMP
class FakeQingpingService:
def __init__(self):
self.online = True
self.state = SimpleNamespace(
temperature=19.0,
)
class FakeTionController:
def __init__(self):
self.calls = []
async def set_target_temperature(
self,
temperature: int,
):
self.calls.append(
(
"set_target_temperature",
temperature,
)
)
async def heater_on(self):
self.calls.append(
(
"heater_on",
None,
)
)
async def heater_off(self):
self.calls.append(
(
"heater_off",
None,
)
)
class FakeTionService:
def __init__(self):
self.online = True
self.state = SimpleNamespace(
in_temp=22,
)
self.controller = FakeTionController()
async def execute(
self,
operation,
):
return await operation(
self.controller
)
def make_controller():
qingping = FakeQingpingService()
tion = FakeTionService()
temperature_policy = TemperaturePolicy(
TemperatureConfig(
hysteresis=0.5,
)
)
controller = AutoController(
schedule_service=None,
qingping_service=qingping,
tion_service=tion,
temperature_policy=temperature_policy,
)
return (
controller,
qingping,
tion,
)
def make_resolution(
target_temp: int,
):
return SimpleNamespace(
auto_target_temp=target_temp,
)
async def test_heating_sets_tion_target_first():
controller, qingping, tion = (
make_controller()
)
resolution = make_resolution(
target_temp=20
)
# Qingping = 19.0
# AUTO target = 20
#
# Нужно греть.
#
# Сначала Tion.target_temp поднимается
# до технического максимума,
# затем включается heater.
await controller._process_heater(
resolution
)
assert tion.controller.calls == [
(
"set_target_temperature",
MAX_TARGET_TEMP,
),
(
"heater_on",
None,
),
]
assert (
controller._target_temperature
== MAX_TARGET_TEMP
)
assert (
controller._target_heater
is True
)
assert (
controller._auto_heater
is True
)
async def test_repeated_heating_does_not_repeat_commands():
controller, qingping, tion = (
make_controller()
)
resolution = make_resolution(
target_temp=20
)
await controller._process_heater(
resolution
)
tion.controller.calls.clear()
# Температура всё ещё ниже цели.
# AUTO продолжает хотеть нагрев,
# но повторно слать команды Tion не нужно.
qingping.state.temperature = 19.2
await controller._process_heater(
resolution
)
assert tion.controller.calls == []
async def test_target_reached_turns_heater_off():
controller, qingping, tion = (
make_controller()
)
resolution = make_resolution(
target_temp=20
)
# Сначала включаем нагрев.
await controller._process_heater(
resolution
)
tion.controller.calls.clear()
# Цель достигнута.
qingping.state.temperature = 20.0
await controller._process_heater(
resolution
)
# MAX_TARGET_TEMP обратно сейчас не меняем.
# Достаточно запретить нагрев.
assert tion.controller.calls == [
(
"heater_off",
None,
)
]
assert (
controller._auto_heater
is False
)
assert (
controller._target_heater
is False
)
async def main():
await test_heating_sets_tion_target_first()
await test_repeated_heating_does_not_repeat_commands()
await test_target_reached_turns_heater_off()
print()
print("=" * 70)
print(
"ALL AUTO HEATER TION TARGET "
"TESTS PASSED"
)
print("=" * 70)
if __name__ == "__main__":
asyncio.run(main())
+181
View File
@@ -0,0 +1,181 @@
import asyncio
from types import SimpleNamespace
from app.auto.controller import AutoController
class FakeScheduleService:
def __init__(self):
self.paused = True
self.override_active = False
def resolve(self, now):
return SimpleNamespace(
enabled=True,
auto_active=True,
auto_fallback_speed=1,
auto_target_temp=20,
)
class FakeQingpingService:
def __init__(self):
self.online = True
self.state = SimpleNamespace(
co2=1200,
temperature=19.0,
)
class FakeTionController:
def __init__(self):
self.calls = []
async def set_speed(self, speed):
self.calls.append(
("set_speed", speed)
)
async def heater_on(self):
self.calls.append(
("heater_on", None)
)
async def heater_off(self):
self.calls.append(
("heater_off", None)
)
async def set_target_temperature(
self,
temperature,
):
self.calls.append(
(
"set_target_temperature",
temperature,
)
)
class FakeTionService:
def __init__(self):
self.online = True
self.state = SimpleNamespace(
in_temp=19,
)
self.controller = FakeTionController()
async def execute(
self,
operation,
):
return await operation(
self.controller
)
async def test_paused_schedule_suspends_auto():
schedule = FakeScheduleService()
qingping = FakeQingpingService()
tion = FakeTionService()
controller = AutoController(
schedule_service=schedule,
qingping_service=qingping,
tion_service=tion,
)
# Имитируем состояние,
# которое AUTO помнил до pause.
controller._target_speed = 3
controller._auto_speed = 3
controller._target_heater = True
controller._auto_heater = True
controller._target_temperature = 30
controller._temperature = 19.0
controller._temperature_source = (
"qingping"
)
await controller._process()
status = controller.status()
# AUTO должен быть остановлен.
assert (
status["state"]
== "suspended"
)
assert (
status["reason"]
== "schedule_paused"
)
# Никаких команд Tion.
assert tion.controller.calls == []
# Старое владение AUTO забыто.
assert (
controller._target_speed
is None
)
assert (
controller._auto_speed
is None
)
assert (
controller._target_heater
is None
)
assert (
controller._auto_heater
is None
)
assert (
controller._target_temperature
is None
)
assert (
controller._temperature
is None
)
assert (
controller._temperature_source
is None
)
async def main():
await test_paused_schedule_suspends_auto()
print()
print("=" * 70)
print(
"ALL AUTO SCHEDULE PAUSE "
"TESTS PASSED"
)
print("=" * 70)
if __name__ == "__main__":
asyncio.run(main())
+359
View File
@@ -0,0 +1,359 @@
from pathlib import Path
from tempfile import TemporaryDirectory
from app.auto.config import (
load_auto_config,
)
BASE_CONFIG = """
version: 1
check_interval: 5.0
co2:
base_speed: 1
hysteresis: 100
thresholds:
- ppm: 800
speed: 2
temperature:
hysteresis: 0.5
"""
def load_config(text: str):
temp_dir = TemporaryDirectory()
path = (
Path(temp_dir.name)
/ "auto.yaml"
)
path.write_text(
text,
encoding="utf-8",
)
config = load_auto_config(path)
return temp_dir, config
def test_valid_temperature_config():
temp_dir, config = load_config(
BASE_CONFIG
)
try:
print()
print("=" * 70)
print("VALID TEMPERATURE CONFIG")
print("=" * 70)
print(
"hysteresis:",
config.temperature.hysteresis,
)
assert (
config.temperature.hysteresis
== 0.5
)
finally:
temp_dir.cleanup()
def test_temperature_config_required():
config_text = """
version: 1
check_interval: 5.0
co2:
base_speed: 1
hysteresis: 100
thresholds:
- ppm: 800
speed: 2
"""
try:
temp_dir, _ = load_config(
config_text
)
except ValueError as exc:
print()
print("=" * 70)
print("MISSING TEMPERATURE CONFIG")
print("=" * 70)
print(exc)
assert (
str(exc)
== "temperature config is required"
)
return
else:
temp_dir.cleanup()
raise AssertionError(
"Missing temperature config "
"must raise ValueError"
)
def test_temperature_must_be_mapping():
config_text = """
version: 1
check_interval: 5.0
co2:
base_speed: 1
hysteresis: 100
thresholds:
- ppm: 800
speed: 2
temperature: 0.5
"""
try:
temp_dir, _ = load_config(
config_text
)
except ValueError as exc:
print()
print("=" * 70)
print("TEMPERATURE MUST BE MAPPING")
print("=" * 70)
print(exc)
assert (
str(exc)
== "temperature must be an object"
)
return
else:
temp_dir.cleanup()
raise AssertionError(
"Invalid temperature mapping "
"must raise ValueError"
)
def test_temperature_hysteresis_must_be_number():
config_text = """
version: 1
check_interval: 5.0
co2:
base_speed: 1
hysteresis: 100
thresholds:
- ppm: 800
speed: 2
temperature:
hysteresis: abc
"""
try:
temp_dir, _ = load_config(
config_text
)
except ValueError as exc:
print()
print("=" * 70)
print(
"HYSTERESIS MUST BE NUMBER"
)
print("=" * 70)
print(exc)
assert (
str(exc)
== (
"temperature.hysteresis "
"must be a number"
)
)
return
else:
temp_dir.cleanup()
raise AssertionError(
"Non-numeric hysteresis "
"must raise ValueError"
)
def test_temperature_hysteresis_must_not_be_negative():
config_text = """
version: 1
check_interval: 5.0
co2:
base_speed: 1
hysteresis: 100
thresholds:
- ppm: 800
speed: 2
temperature:
hysteresis: -0.1
"""
try:
temp_dir, _ = load_config(
config_text
)
except ValueError as exc:
print()
print("=" * 70)
print(
"HYSTERESIS MUST NOT BE NEGATIVE"
)
print("=" * 70)
print(exc)
assert (
str(exc)
== (
"temperature.hysteresis "
"must be >= 0"
)
)
return
else:
temp_dir.cleanup()
raise AssertionError(
"Negative hysteresis "
"must raise ValueError"
)
def test_unknown_temperature_field():
config_text = """
version: 1
check_interval: 5.0
co2:
base_speed: 1
hysteresis: 100
thresholds:
- ppm: 800
speed: 2
temperature:
hysteresis: 0.5
something_else: 123
"""
try:
temp_dir, _ = load_config(
config_text
)
except ValueError as exc:
print()
print("=" * 70)
print(
"UNKNOWN TEMPERATURE FIELD"
)
print("=" * 70)
print(exc)
assert (
str(exc)
== (
"Unknown temperature config fields: "
"['something_else']"
)
)
return
else:
temp_dir.cleanup()
raise AssertionError(
"Unknown temperature field "
"must raise ValueError"
)
def test_temperature_is_allowed_top_level_field():
temp_dir, config = load_config(
BASE_CONFIG
)
try:
assert (
config.temperature.hysteresis
== 0.5
)
finally:
temp_dir.cleanup()
def main():
test_valid_temperature_config()
test_temperature_config_required()
test_temperature_must_be_mapping()
test_temperature_hysteresis_must_be_number()
test_temperature_hysteresis_must_not_be_negative()
test_unknown_temperature_field()
test_temperature_is_allowed_top_level_field()
print()
print("=" * 70)
print(
"ALL AUTO TEMPERATURE CONFIG "
"TESTS PASSED"
)
print("=" * 70)
if __name__ == "__main__":
main()
+116
View File
@@ -0,0 +1,116 @@
from app.auto.config import (
TemperatureConfig,
)
from app.auto.temperature_policy import (
TemperaturePolicy,
)
def make_policy() -> TemperaturePolicy:
return TemperaturePolicy(
TemperatureConfig(
hysteresis=0.5,
)
)
def test_heater_off_below_lower_limit():
policy = make_policy()
result = policy.heater_required(
temperature=19.4,
target_temp=20.0,
heater_on=False,
)
assert result is True
def test_heater_off_at_lower_limit():
policy = make_policy()
result = policy.heater_required(
temperature=19.5,
target_temp=20.0,
heater_on=False,
)
assert result is False
def test_heater_off_inside_hysteresis():
policy = make_policy()
result = policy.heater_required(
temperature=19.8,
target_temp=20.0,
heater_on=False,
)
assert result is False
def test_heater_on_below_target():
policy = make_policy()
result = policy.heater_required(
temperature=19.8,
target_temp=20.0,
heater_on=True,
)
assert result is True
def test_heater_on_at_target():
policy = make_policy()
result = policy.heater_required(
temperature=20.0,
target_temp=20.0,
heater_on=True,
)
assert result is False
def test_heater_on_above_target():
policy = make_policy()
result = policy.heater_required(
temperature=20.2,
target_temp=20.0,
heater_on=True,
)
assert result is False
def main():
test_heater_off_below_lower_limit()
test_heater_off_at_lower_limit()
test_heater_off_inside_hysteresis()
test_heater_on_below_target()
test_heater_on_at_target()
test_heater_on_above_target()
print()
print("=" * 70)
print(
"ALL AUTO TEMPERATURE POLICY "
"TESTS PASSED"
)
print("=" * 70)
if __name__ == "__main__":
main()
+169
View File
@@ -0,0 +1,169 @@
from types import SimpleNamespace
from app.auto.controller import AutoController
class FakeQingpingService:
def __init__(
self,
online: bool,
temperature,
):
self.online = online
self.state = SimpleNamespace(
temperature=temperature,
)
class FakeTionService:
def __init__(
self,
online: bool,
in_temp,
):
self.online = online
self.state = SimpleNamespace(
in_temp=in_temp,
)
def make_controller(
*,
qingping_online: bool,
qingping_temperature,
tion_online: bool,
tion_temperature,
):
qingping = FakeQingpingService(
online=qingping_online,
temperature=qingping_temperature,
)
tion = FakeTionService(
online=tion_online,
in_temp=tion_temperature,
)
return AutoController(
schedule_service=None,
qingping_service=qingping,
tion_service=tion,
)
def test_qingping_priority():
controller = make_controller(
qingping_online=True,
qingping_temperature=19.7,
tion_online=True,
tion_temperature=18,
)
temperature, source = (
controller._get_temperature()
)
assert temperature == 19.7
assert source == "qingping"
def test_tion_fallback():
controller = make_controller(
qingping_online=False,
qingping_temperature=19.7,
tion_online=True,
tion_temperature=18,
)
temperature, source = (
controller._get_temperature()
)
assert temperature == 18
assert source == "tion"
def test_tion_fallback_when_qingping_temperature_missing():
controller = make_controller(
qingping_online=True,
qingping_temperature=None,
tion_online=True,
tion_temperature=18,
)
temperature, source = (
controller._get_temperature()
)
assert temperature == 18
assert source == "tion"
def test_offline_tion_is_not_used():
controller = make_controller(
qingping_online=False,
qingping_temperature=None,
tion_online=False,
# Значение специально оставляем.
# Оно имитирует старый state Tion.
tion_temperature=18,
)
temperature, source = (
controller._get_temperature()
)
assert temperature is None
assert source is None
def test_missing_tion_temperature():
controller = make_controller(
qingping_online=False,
qingping_temperature=None,
tion_online=True,
tion_temperature=None,
)
temperature, source = (
controller._get_temperature()
)
assert temperature is None
assert source is None
def main():
test_qingping_priority()
test_tion_fallback()
test_tion_fallback_when_qingping_temperature_missing()
test_offline_tion_is_not_used()
test_missing_tion_temperature()
print()
print("=" * 70)
print(
"ALL AUTO TEMPERATURE SOURCE "
"TESTS PASSED"
)
print("=" * 70)
if __name__ == "__main__":
main()
+27
View File
@@ -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())
+261
View File
@@ -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()
+32
View File
@@ -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())
+60
View File
@@ -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()
+415
View File
@@ -0,0 +1,415 @@
import json
from types import SimpleNamespace
from unittest.mock import patch
import paho.mqtt.client as mqtt
from app.my_dataclasses import (
QINGPING_SAMPLE_TIMEOUT,
QINGPING_RECOVERY_TIMEOUT,
)
from app.qingping.service import QingpingService
class FakeClock:
def __init__(self):
self.now = 0.0
def monotonic(self) -> float:
return self.now
def advance(self, seconds: float) -> None:
self.now += seconds
class FakeMqttClient:
def __init__(self):
self.subscriptions = []
self.published = []
def subscribe(self, topic):
self.subscriptions.append(topic)
def publish(
self,
topic,
payload,
):
self.published.append(
(
topic,
json.loads(payload),
)
)
return SimpleNamespace(
rc=mqtt.MQTT_ERR_SUCCESS
)
class FakeMessage:
def __init__(self, payload: dict):
self.payload = json.dumps(
payload
).encode("utf-8")
def sensor_message(
timestamp: int,
co2: int,
) -> FakeMessage:
return FakeMessage(
{
"type": 17,
"sensorData": [
{
"timestamp": {
"value": timestamp
},
"temperature": {
"value": 25.0
},
"humidity": {
"value": 50.0
},
"co2": {
"value": co2
},
"pm25": {
"value": 1
},
"pm10": {
"value": 1
},
"battery": {
"value": 100
},
}
],
}
)
def service_message() -> FakeMessage:
return FakeMessage(
{
"type": 13,
"wifi_info": (
"TestWiFi,-50,1,"
"00:00:00:00:00:00"
),
"sw_version": "4.8.5",
}
)
def assert_state(
service: QingpingService,
*,
online: bool,
recovery_waiting: bool,
co2,
):
status = service.status()
assert status["online"] is online, status
assert (
status["recovery_waiting"]
is recovery_waiting
), status
assert status["co2"] == co2, status
def test_double_recovery_cycle() -> None:
print()
print("TEST — DOUBLE RECOVERY CYCLE")
clock = FakeClock()
service = QingpingService(
host="127.0.0.1",
)
client = FakeMqttClient()
# _send_recovery() использует self._client.
service._client = client
with patch(
"app.qingping.service.time.monotonic",
clock.monotonic,
):
# --------------------------------------------------
# START
# --------------------------------------------------
print("1. MQTT connect")
service._on_connect(
client,
None,
None,
0,
None,
)
assert len(client.published) == 1
assert_state(
service,
online=False,
recovery_waiting=True,
co2=None,
)
# --------------------------------------------------
# Первый type 17.
# --------------------------------------------------
print("2. First sensor sample")
service._on_message(
None,
None,
sensor_message(
timestamp=1000,
co2=900,
),
)
assert_state(
service,
online=True,
recovery_waiting=False,
co2=900,
)
# --------------------------------------------------
# Первый отказ.
# --------------------------------------------------
print("3. First sensor timeout")
clock.advance(
QINGPING_SAMPLE_TIMEOUT + 1
)
service._watchdog_tick(
clock.monotonic()
)
assert_state(
service,
online=False,
recovery_waiting=False,
co2=900,
)
# Recovery пока НЕ отправлялся.
assert len(client.published) == 1
# --------------------------------------------------
# Устройство снова появляется.
# Любой пакет запускает recovery.
# --------------------------------------------------
print("4. First recovery")
service._on_message(
None,
None,
service_message(),
)
assert len(client.published) == 2
assert_state(
service,
online=True,
recovery_waiting=True,
co2=None,
)
# --------------------------------------------------
# Новый type 17.
# --------------------------------------------------
print("5. First recovery completed")
service._on_message(
None,
None,
sensor_message(
timestamp=1015,
co2=1000,
),
)
assert_state(
service,
online=True,
recovery_waiting=False,
co2=1000,
)
# --------------------------------------------------
# Второй отказ.
# --------------------------------------------------
print("6. Second sensor timeout")
clock.advance(
QINGPING_SAMPLE_TIMEOUT + 1
)
service._watchdog_tick(
clock.monotonic()
)
assert_state(
service,
online=False,
recovery_waiting=False,
co2=1000,
)
assert len(client.published) == 2
# --------------------------------------------------
# Второе восстановление.
# --------------------------------------------------
print("7. Second recovery")
service._on_message(
None,
None,
service_message(),
)
assert len(client.published) == 3
assert_state(
service,
online=True,
recovery_waiting=True,
co2=None,
)
service._on_message(
None,
None,
sensor_message(
timestamp=1030,
co2=1100,
),
)
assert_state(
service,
online=True,
recovery_waiting=False,
co2=1100,
)
print("8. Second recovery completed")
def test_recovery_timeout_and_retry() -> None:
print()
print("TEST — RECOVERY TIMEOUT AND RETRY")
clock = FakeClock()
service = QingpingService(
host="127.0.0.1",
)
client = FakeMqttClient()
service._client = client
with patch(
"app.qingping.service.time.monotonic",
clock.monotonic,
):
# Startup recovery.
service._on_connect(
client,
None,
None,
0,
None,
)
assert len(client.published) == 1
assert_state(
service,
online=False,
recovery_waiting=True,
co2=None,
)
# Type 17 так и не пришёл.
clock.advance(
QINGPING_RECOVERY_TIMEOUT + 1
)
service._watchdog_tick(
clock.monotonic()
)
assert_state(
service,
online=False,
recovery_waiting=False,
co2=None,
)
# Любой следующий пакет должен разрешить
# новую recovery-попытку.
service._on_message(
None,
None,
service_message(),
)
assert len(client.published) == 2
assert_state(
service,
online=True,
recovery_waiting=True,
co2=None,
)
# Теперь приходит type 17.
service._on_message(
None,
None,
sensor_message(
timestamp=2000,
co2=1200,
),
)
assert_state(
service,
online=True,
recovery_waiting=False,
co2=1200,
)
if __name__ == "__main__":
test_double_recovery_cycle()
test_recovery_timeout_and_retry()
print()
print(
"ALL QINGPING RECOVERY TESTS PASSED"
)
+30
View File
@@ -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())
+312
View File
@@ -0,0 +1,312 @@
from datetime import datetime
from schedule import (
ScheduleActionType,
ScheduleService,
load_schedule,
)
SCHEDULE_FILE = "config/schedule.yaml"
def print_result(title, result):
print()
print("=" * 70)
print(title)
print("=" * 70)
print(f"Enabled: {result.enabled}")
if result.current is not None:
print(
f"Current: "
f"{result.current.when} | "
f"{result.current.template} | "
f"{result.current.point.action.type}"
)
else:
print("Current: None")
if result.next is not None:
print(
f"Next: "
f"{result.next.when} | "
f"{result.next.template} | "
f"{result.next.point.action.type}"
)
else:
print("Next: None")
print(f"Auto: {result.auto_active}")
print(f"Settings: {result.scheduled_settings.to_dict()}")
def main():
print("Загрузка schedule.yaml...")
config = load_schedule(SCHEDULE_FILE)
print("YAML успешно загружен и проверен.")
print()
service = ScheduleService(config)
# --------------------------------------------------------------
# TEST 1
# Понедельник 06:30
#
# Текущая точка должна быть 00:00 power off.
# Следующая — 07:00.
# --------------------------------------------------------------
result = service.resolve(
datetime(2026, 9, 14, 6, 30)
)
print_result(
"TEST 1 — Monday 06:30",
result,
)
assert result.current.when.hour == 0
assert result.next.when.hour == 7
assert result.auto_active is False
assert result.scheduled_settings.power is False
# --------------------------------------------------------------
# TEST 2
# Понедельник 07:30
#
# Должны накопиться параметры из точки 07:00.
# --------------------------------------------------------------
result = service.resolve(
datetime(2026, 9, 14, 7, 30)
)
print_result(
"TEST 2 — Monday 07:30",
result,
)
settings = result.scheduled_settings
assert result.current.when.hour == 7
assert result.next.when.hour == 9
assert result.auto_active is False
assert settings.power is True
assert settings.speed == 2
assert settings.heater is True
assert settings.target_temp == 20
assert settings.mode == "outside"
# --------------------------------------------------------------
# TEST 3
# Понедельник 10:00
#
# В 09:00 поменялась только скорость.
#
# Остальные значения должны сохраниться от 07:00.
# --------------------------------------------------------------
result = service.resolve(
datetime(2026, 9, 14, 10, 0)
)
print_result(
"TEST 3 — Monday 10:00",
result,
)
settings = result.scheduled_settings
assert result.current.when.hour == 9
assert result.next.when.hour == 13
assert settings.power is True
assert settings.speed == 1
assert settings.heater is True
assert settings.target_temp == 20
assert settings.mode == "outside"
# --------------------------------------------------------------
# TEST 4
# Понедельник 13:10
#
# Последняя точка AUTO.
# --------------------------------------------------------------
result = service.resolve(
datetime(2026, 9, 14, 13, 10)
)
print_result(
"TEST 4 — Monday 13:10 / AUTO",
result,
)
assert result.current.point.action.type == ScheduleActionType.AUTO
assert result.auto_active is True
assert result.next.when.hour == 17
assert result.next.when.minute == 0
# Накопленные настройки при AUTO не стираются.
assert result.scheduled_settings.speed == 1
assert result.scheduled_settings.power is True
# --------------------------------------------------------------
# TEST 5
# Понедельник 17:10
#
# AUTO закончился.
# Speed должен стать 3.
# --------------------------------------------------------------
result = service.resolve(
datetime(2026, 9, 14, 17, 10)
)
print_result(
"TEST 5 — Monday 17:10",
result,
)
assert result.auto_active is False
assert result.current.when.hour == 17
assert result.current.when.minute == 0
assert result.next.when.hour == 17
assert result.next.when.minute == 30
assert result.scheduled_settings.speed == 3
# --------------------------------------------------------------
# TEST 6
# Понедельник 17:40
#
# Снова AUTO.
# --------------------------------------------------------------
result = service.resolve(
datetime(2026, 9, 14, 17, 40)
)
print_result(
"TEST 6 — Monday 17:40 / AUTO",
result,
)
assert result.auto_active is True
assert result.current.when.hour == 17
assert result.current.when.minute == 30
assert result.next.when.hour == 23
# Последний scheduled speed всё равно должен помнить 3.
assert result.scheduled_settings.speed == 3
# --------------------------------------------------------------
# TEST 7
# Понедельник 23:15
#
# Heater off, speed 1.
# --------------------------------------------------------------
result = service.resolve(
datetime(2026, 9, 14, 23, 15)
)
print_result(
"TEST 7 — Monday 23:15",
result,
)
settings = result.scheduled_settings
assert result.auto_active is False
assert settings.speed == 1
assert settings.heater is False
# --------------------------------------------------------------
# TEST 8
# Суббота 09:30
#
# Проверяем переключение на weekend.
# --------------------------------------------------------------
result = service.resolve(
datetime(2026, 9, 19, 9, 30)
)
print_result(
"TEST 8 — Saturday 09:30",
result,
)
assert result.current.template == "weekend"
assert result.next.template == "weekend"
settings = result.scheduled_settings
assert settings.power is True
assert settings.speed == 1
assert settings.heater is True
assert settings.target_temp == 20
# --------------------------------------------------------------
# TEST 9
# Суббота 10:30
#
# Weekend AUTO.
# --------------------------------------------------------------
result = service.resolve(
datetime(2026, 9, 19, 10, 30)
)
print_result(
"TEST 9 — Saturday 10:30 / AUTO",
result,
)
assert result.current.template == "weekend"
assert result.auto_active is True
# --------------------------------------------------------------
# TEST 10
# Проверяем переход через полночь.
#
# Вторник 00:30.
# В 00:00 вторника уже должна действовать power off.
# --------------------------------------------------------------
result = service.resolve(
datetime(2026, 9, 15, 0, 30)
)
print_result(
"TEST 10 — Tuesday 00:30",
result,
)
assert result.current.when.day == 15
assert result.current.when.hour == 0
assert result.scheduled_settings.power is False
print()
print("=" * 70)
print("ALL SCHEDULE TESTS PASSED")
print("=" * 70)
if __name__ == "__main__":
main()
+432
View File
@@ -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())
+327
View File
@@ -0,0 +1,327 @@
import asyncio
from datetime import datetime
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
from schedule import (
ScheduleService,
load_schedule,
)
SCHEDULE_YAML = """
version: 1
enabled: true
timezone: local
templates:
test:
- time: "00:00"
action:
type: set
power: off
- time: "09:00"
action:
type: set
power: on
speed: 1
heater: on
target_temp: 20
- time: "10:00"
action:
type: auto
speed: 3
- time: "12:00"
action:
type: set
speed: 2
heater: off
days:
mon: test
tue: test
wed: test
thu: test
fri: test
sat: test
sun: test
"""
class FakeTionController:
def __init__(self):
self.calls = []
async def power_on(self):
self.calls.append(
("power_on", None)
)
async def power_off(self):
self.calls.append(
("power_off", None)
)
async def set_speed(self, speed: int):
self.calls.append(
("set_speed", speed)
)
# Оставляем оба варианта управления heater,
# чтобы fake соответствовал фактическому
# интерфейсу TionController.
async def heater_on(self):
self.calls.append(
("heater_on", None)
)
async def heater_off(self):
self.calls.append(
("heater_off", None)
)
async def set_heater(self, enabled: bool):
self.calls.append(
("set_heater", enabled)
)
async def set_target_temperature(
self,
temperature: int,
):
self.calls.append(
(
"set_target_temperature",
temperature,
)
)
class FakeTionService:
def __init__(self):
self.controller = FakeTionController()
async def execute(self, operation):
return await operation(
self.controller
)
def load_test_schedule():
temp_dir = TemporaryDirectory()
path = (
Path(temp_dir.name)
/ "schedule.yaml"
)
path.write_text(
SCHEDULE_YAML,
encoding="utf-8",
)
config = load_schedule(path)
return temp_dir, config
def heater_calls(calls):
return [
call
for call in calls
if call[0] in {
"heater_on",
"heater_off",
"set_heater",
}
]
async def process_at(
service: ScheduleService,
when: datetime,
):
with patch(
"schedule.service.datetime",
wraps=datetime,
) as mocked_datetime:
mocked_datetime.now.return_value = when
await service._process()
async def test_auto_does_not_control_heater():
temp_dir, config = (
load_test_schedule()
)
try:
tion = FakeTionService()
service = ScheduleService(
config,
tion=tion,
)
# --------------------------------------------------
# 09:30
#
# Обычный SET.
# heater=on должен принадлежать ScheduleService.
# --------------------------------------------------
await process_at(
service,
datetime(
2026,
9,
14,
9,
30,
),
)
print()
print(
"09:30 SET calls:",
tion.controller.calls,
)
calls = heater_calls(
tion.controller.calls
)
assert calls != [], (
"SET must control heater"
)
tion.controller.calls.clear()
# --------------------------------------------------
# 10:30
#
# AUTO.
#
# В накопленных scheduled_settings heater всё ещё
# должен быть True, потому что последняя SET-точка
# включила heater.
#
# Но ScheduleService НЕ должен отправлять
# никаких heater-команд.
# --------------------------------------------------
resolution = service.resolve(
datetime(
2026,
9,
14,
10,
30,
)
)
print()
print(
"10:30 scheduled heater:",
resolution.scheduled_settings.heater,
)
assert (
resolution.auto_active
is True
)
assert (
resolution.scheduled_settings.heater
is True
)
await process_at(
service,
datetime(
2026,
9,
14,
10,
30,
),
)
print(
"10:30 AUTO calls:",
tion.controller.calls,
)
calls = heater_calls(
tion.controller.calls
)
assert calls == [], (
"ScheduleService must not control "
"heater during AUTO"
)
tion.controller.calls.clear()
# --------------------------------------------------
# 12:30
#
# AUTO закончился.
# heater=off снова принадлежит ScheduleService.
# --------------------------------------------------
await process_at(
service,
datetime(
2026,
9,
14,
12,
30,
),
)
print()
print(
"12:30 SET calls:",
tion.controller.calls,
)
calls = heater_calls(
tion.controller.calls
)
assert calls != [], (
"ScheduleService must regain heater "
"control after AUTO"
)
finally:
temp_dir.cleanup()
async def main():
await test_auto_does_not_control_heater()
print()
print("=" * 70)
print(
"ALL AUTO HEATER OWNERSHIP TESTS PASSED"
)
print("=" * 70)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,217 @@
from pathlib import Path
from tempfile import TemporaryDirectory
from schedule import (
ScheduleActionType,
load_schedule,
)
VALID_YAML = """
version: 1
enabled: true
timezone: local
templates:
test:
- time: "00:00"
action:
type: auto
speed: 3
target_temp: 20
days:
mon: test
tue: test
wed: test
thu: test
fri: test
sat: test
sun: test
"""
def load_config(text: str):
temp_dir = TemporaryDirectory()
path = (
Path(temp_dir.name)
/ "schedule.yaml"
)
path.write_text(
text,
encoding="utf-8",
)
config = load_schedule(path)
return temp_dir, config
def test_auto_target_temperature():
temp_dir, config = load_config(
VALID_YAML
)
try:
point = (
config.templates["test"][0]
)
action = point.action
assert (
action.type
== ScheduleActionType.AUTO
)
assert (
action.settings.speed
== 3
)
assert (
action.settings.target_temp
== 20
)
finally:
temp_dir.cleanup()
def test_auto_rejects_heater():
config_text = """
version: 1
enabled: true
timezone: local
templates:
test:
- time: "00:00"
action:
type: auto
speed: 3
target_temp: 20
heater: on
days:
mon: test
tue: test
wed: test
thu: test
fri: test
sat: test
sun: test
"""
try:
temp_dir, _ = load_config(
config_text
)
except ValueError as exc:
print()
print(
"Expected error:",
exc,
)
assert "heater" in str(exc)
return
else:
temp_dir.cleanup()
raise AssertionError(
"AUTO heater field must "
"raise ValueError"
)
def test_auto_speed_is_required():
config_text = """
version: 1
enabled: true
timezone: local
templates:
test:
- time: "00:00"
action:
type: auto
target_temp: 20
days:
mon: test
tue: test
wed: test
thu: test
fri: test
sat: test
sun: test
"""
try:
temp_dir, _ = load_config(
config_text
)
except ValueError as exc:
print()
print(
"Expected error:",
exc,
)
assert (
str(exc)
== "AUTO action requires 'speed'"
)
return
else:
temp_dir.cleanup()
raise AssertionError(
"AUTO without speed must "
"raise ValueError"
)
def main():
test_auto_target_temperature()
test_auto_rejects_heater()
test_auto_speed_is_required()
print()
print("=" * 70)
print(
"ALL AUTO TEMPERATURE PARSER "
"TESTS PASSED"
)
print("=" * 70)
if __name__ == "__main__":
main()
@@ -0,0 +1,196 @@
from datetime import datetime
from pathlib import Path
from tempfile import TemporaryDirectory
from schedule import (
ScheduleService,
load_schedule,
)
SCHEDULE_YAML = """
version: 1
enabled: true
timezone: local
templates:
test:
- time: "09:00"
action:
type: set
power: on
speed: 1
target_temp: 23
- time: "10:00"
action:
type: auto
speed: 3
target_temp: 20
- time: "12:00"
action:
type: set
speed: 2
target_temp: 22
days:
mon: test
tue: test
wed: test
thu: test
fri: test
sat: test
sun: test
"""
def load_test_schedule():
temp_dir = TemporaryDirectory()
path = (
Path(temp_dir.name)
/ "schedule.yaml"
)
path.write_text(
SCHEDULE_YAML,
encoding="utf-8",
)
config = load_schedule(path)
return temp_dir, config
def test_auto_temperature_resolution():
temp_dir, config = (
load_test_schedule()
)
try:
service = ScheduleService(
config
)
# ----------------------------------------------
# 09:30 — обычный SET
# ----------------------------------------------
resolution = service.resolve(
datetime(
2026,
9,
14,
9,
30,
)
)
assert (
resolution.auto_active
is False
)
assert (
resolution.scheduled_settings.target_temp
== 23
)
assert (
resolution.auto_target_temp
is None
)
# ----------------------------------------------
# 10:30 — AUTO
#
# Последний SET по-прежнему хранит 23,
# но AUTO явно требует 20.
# ----------------------------------------------
resolution = service.resolve(
datetime(
2026,
9,
14,
10,
30,
)
)
assert (
resolution.auto_active
is True
)
assert (
resolution.auto_fallback_speed
== 3
)
assert (
resolution.scheduled_settings.target_temp
== 23
)
assert (
resolution.auto_target_temp
== 20
)
# ----------------------------------------------
# 12:30 — снова SET
#
# AUTO закончился.
# ----------------------------------------------
resolution = service.resolve(
datetime(
2026,
9,
14,
12,
30,
)
)
assert (
resolution.auto_active
is False
)
assert (
resolution.scheduled_settings.target_temp
== 22
)
assert (
resolution.auto_target_temp
is None
)
finally:
temp_dir.cleanup()
def main():
test_auto_temperature_resolution()
print()
print("=" * 70)
print(
"ALL AUTO TEMPERATURE RESOLUTION "
"TESTS PASSED"
)
print("=" * 70)
if __name__ == "__main__":
main()
+72
View File
@@ -0,0 +1,72 @@
import asyncio
import logging
from pathlib import Path
import datetime
from app.my_dataclasses import TION_MAC
from app.tion import TionController, TionService
from schedule import (
ScheduleService,
load_schedule,
)
logging.disable(logging.CRITICAL)
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SCHEDULE_FILE = (
PROJECT_ROOT
/ "config"
/ "schedule.yaml"
)
async def main():
config = load_schedule(SCHEDULE_FILE)
controller = TionController(TION_MAC)
tion = TionService(
controller,
poll_interval=5,
)
schedule = ScheduleService(
config,
tion,
check_interval=5,
)
await tion.start()
await schedule.start()
print("Schedule started. Ctrl+C to stop.")
try:
while True:
await asyncio.sleep(5)
if tion.state:
print(
f"{datetime.datetime.now().strftime("%y-%m-%d %H:%M:%S")} "
f"power={tion.state.power} "
f"speed={tion.state.fan_speed} "
f"heater={tion.state.heater} "
f"target_temp={tion.state.target_temp} "
f"last_error={tion.last_error} "
)
finally:
await schedule.stop()
await tion.stop()
if __name__ == "__main__":
asyncio.run(main())
+119
View File
@@ -0,0 +1,119 @@
import asyncio
import logging
from datetime import datetime
from pathlib import Path
from app.my_dataclasses import TION_MAC
from app.tion import TionController, TionService
from schedule import (
ScheduleService,
ScheduledSettings,
load_schedule,
)
logging.disable(logging.CRITICAL)
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SCHEDULE_FILE = (
PROJECT_ROOT
/ "config"
/ "schedule.yaml"
)
async def main():
config = load_schedule(SCHEDULE_FILE)
controller = TionController(TION_MAC)
tion = TionService(
controller,
poll_interval=5,
)
schedule = ScheduleService(
config,
tion,
check_interval=5,
)
await tion.start()
await schedule.start()
try:
print()
print("Schedule started")
resolution = schedule.resolve()
print(
f"Current: "
f"{resolution.current.when if resolution.current else None}"
)
print(
f"Next: "
f"{resolution.next.when if resolution.next else None}"
)
# print()
# print("Applying temporary override:")
# print("speed=6")
# print()
#
# await schedule.apply_override(
# ScheduledSettings(
# speed=6,
# )
# )
while True:
await asyncio.sleep(5)
if ( datetime.now() > datetime(2026, 9, 18, 20, 30, 30)
and datetime.now() < datetime(2026, 9, 18, 20, 30, 36) ):
print("Applying temporary override:")
print("heater=True")
print("target_temp=25")
await schedule.apply_override(
ScheduledSettings(
heater=True,
target_temp=25,
)
)
now = datetime.now().strftime(
"%y-%m-%d %H:%M:%S"
)
state = tion.state
if state is None:
print(
f"{now} state=None"
)
continue
print(
f"{now} "
f"power={state.power} "
f"speed={state.fan_speed} "
f"heater={state.heater} "
f"target_temp={state.target_temp} "
f"override={schedule.override_active} "
f"until={schedule.override_until}"
)
finally:
await schedule.stop()
await tion.stop()
if __name__ == "__main__":
asyncio.run(main())
+203
View File
@@ -0,0 +1,203 @@
import asyncio
from datetime import datetime, timedelta
from pathlib import Path
from tempfile import TemporaryDirectory
from schedule import (
ScheduleService,
ScheduledSettings,
load_schedule,
)
SCHEDULE_YAML = """
version: 1
enabled: true
timezone: local
templates:
test:
- time: "00:00"
action:
type: set
speed: 2
days:
mon: test
tue: test
wed: test
thu: test
fri: test
sat: test
sun: test
"""
class FakeTionController:
def __init__(self):
self.calls = []
async def set_speed(
self,
speed: int,
):
self.calls.append(
(
"set_speed",
speed,
)
)
class FakeTionService:
def __init__(self):
self.controller = (
FakeTionController()
)
async def execute(
self,
operation,
):
return await operation(
self.controller
)
def load_test_schedule():
temp_dir = TemporaryDirectory()
path = (
Path(temp_dir.name)
/ "schedule.yaml"
)
path.write_text(
SCHEDULE_YAML,
encoding="utf-8",
)
config = load_schedule(path)
return temp_dir, config
async def test_paused_schedule_does_not_control_tion():
temp_dir, config = (
load_test_schedule()
)
try:
tion = FakeTionService()
service = ScheduleService(
config,
tion=tion,
)
assert service.paused is False
await service.pause()
assert service.paused is True
# Даже если фоновый цикл вызывает _process(),
# никаких команд быть не должно.
await service._process()
assert tion.controller.calls == []
# Возвращаем расписание.
#
# resume() должен сразу применить
# текущее состояние расписания.
await service.resume()
assert service.paused is False
assert (
"set_speed",
2,
) in tion.controller.calls
finally:
temp_dir.cleanup()
async def test_pause_clears_override():
temp_dir, config = (
load_test_schedule()
)
try:
service = ScheduleService(
config
)
# Имитируем уже существующий
# temporary override.
service._override_settings = (
ScheduledSettings(
speed=5,
)
)
service._override_until = (
datetime.now()
+ timedelta(hours=1)
)
service._override_pending = True
assert (
service.override_settings.speed
== 5
)
await service.pause()
assert service.paused is True
assert (
service.override_settings.speed
is None
)
assert (
service.override_until
is None
)
assert (
service._override_pending
is False
)
finally:
temp_dir.cleanup()
async def main():
await test_paused_schedule_does_not_control_tion()
await test_pause_clears_override()
print()
print("=" * 70)
print(
"ALL SCHEDULE PAUSE TESTS PASSED"
)
print("=" * 70)
if __name__ == "__main__":
asyncio.run(main())
+179
View File
@@ -0,0 +1,179 @@
import asyncio
from pathlib import Path
from tempfile import TemporaryDirectory
from schedule import (
ScheduleService,
load_schedule,
)
SCHEDULE_YAML = """
version: 1
enabled: true
timezone: local
templates:
test:
- time: "00:00"
action:
type: set
speed: 2
days:
mon: test
tue: test
wed: test
thu: test
fri: test
sat: test
sun: test
"""
def create_config(
directory: Path,
):
schedule_path = (
directory
/ "schedule.yaml"
)
schedule_path.write_text(
SCHEDULE_YAML,
encoding="utf-8",
)
return load_schedule(
schedule_path
)
async def test_pause_survives_restart():
with TemporaryDirectory() as temp_dir:
directory = Path(temp_dir)
config = create_config(
directory
)
state_path = (
directory
/ "schedule_state.json"
)
# --------------------------------------
# Первый экземпляр сервиса.
# --------------------------------------
service1 = ScheduleService(
config,
state_path=state_path,
)
assert service1.paused is False
await service1.pause()
assert service1.paused is True
assert state_path.exists()
# --------------------------------------
# Имитируем перезапуск приложения:
# создаём новый ScheduleService.
# --------------------------------------
service2 = ScheduleService(
config,
state_path=state_path,
)
assert service2.paused is True
async def test_resume_survives_restart():
with TemporaryDirectory() as temp_dir:
directory = Path(temp_dir)
config = create_config(
directory
)
state_path = (
directory
/ "schedule_state.json"
)
service1 = ScheduleService(
config,
state_path=state_path,
)
await service1.pause()
assert service1.paused is True
await service1.resume()
assert service1.paused is False
# Новый экземпляр после resume
# тоже должен быть активным.
service2 = ScheduleService(
config,
state_path=state_path,
)
assert service2.paused is False
async def test_missing_state_file_defaults_to_active():
with TemporaryDirectory() as temp_dir:
directory = Path(temp_dir)
config = create_config(
directory
)
state_path = (
directory
/ "missing_state.json"
)
assert not state_path.exists()
service = ScheduleService(
config,
state_path=state_path,
)
assert service.paused is False
async def main():
await test_pause_survives_restart()
await test_resume_survives_restart()
await test_missing_state_file_defaults_to_active()
print()
print("=" * 70)
print(
"ALL SCHEDULE PAUSE "
"PERSISTENCE TESTS PASSED"
)
print("=" * 70)
if __name__ == "__main__":
asyncio.run(main())
+85
View File
@@ -0,0 +1,85 @@
import asyncio
import json
import logging
from app.tion import TionController
from app.my_dataclasses import *
def print_state(title: str, state) -> None:
print()
print("=" * 50)
print(title)
print("=" * 50)
print(json.dumps(
state.to_dict(),
indent=2,
ensure_ascii=False
))
async def main():
# Убираем лишнее логирование
logging.getLogger("asyncio").setLevel(logging.WARNING)
logging.getLogger("bleak").setLevel(logging.WARNING)
logging.getLogger("tion_btle").setLevel(logging.WARNING)
tion = TionController(TION_MAC)
print("Подключение к Tion...")
async with tion:
print(f"Connected: {tion.connected}")
# Читаем исходное состояние
state = await tion.get_state()
print_state("Исходное состояние", state)
# Включаем
state = await tion.power_on()
print_state("После POWER ON", state)
await asyncio.sleep(1)
# Скорость 2
state = await tion.set_speed(2)
print_state("После SPEED 2", state)
await asyncio.sleep(1)
# Скорость 3
state = await tion.set_speed(3)
print_state("После SPEED 3", state)
state = await tion.set_target_temperature(20)
print_state("TARGET TEMP 20°C", state)
state = await tion.set_air_mode("recirculation")
print_state("RECIRCULATION", state)
await asyncio.sleep(3)
state = await tion.set_air_mode("outside")
print_state("RECIRCULATION", state)
await asyncio.sleep(3)
state = await tion.sound_off()
print_state("SOUND OFF", state)
state = await tion.sound_on()
print_state("SOUND ON", state)
state = await tion.light_off()
print_state("LIGHT OFF", state)
state = await tion.light_on()
print_state("LIGHT ON", state)
print()
print(f"Connected after exit: {tion.connected}")
if __name__ == "__main__":
asyncio.run(main())
+84
View File
@@ -0,0 +1,84 @@
import asyncio
import json
import logging
from app.tion import (
TionController,
TionService,
)
from app.my_dataclasses import *
def print_service(service: TionService) -> None:
print()
print("=" * 60)
print(f"Running: {service.running}")
print(f"Online: {service.online}")
print(f"Last seen: {service.last_seen}")
print(f"Last error: {service.last_error}")
if service.state is not None:
print()
print(json.dumps(
service.state.to_dict(),
indent=2,
ensure_ascii=False,
))
async def main():
# Убираем лишнее логирование
logging.getLogger("asyncio").setLevel(logging.WARNING)
logging.getLogger("bleak").setLevel(logging.WARNING)
logging.getLogger("tion_btle").setLevel(logging.WARNING)
controller = TionController(TION_MAC)
service = TionService(
controller,
poll_interval=3,
)
async with service:
print("=== После запуска ===")
print_service(service)
print()
print("Ждём несколько циклов polling...")
await asyncio.sleep(60)
print_service(service)
print()
print("=== Устанавливаем скорость 2 ===")
state = await service.execute(
lambda tion: tion.set_speed(2)
)
print(json.dumps(
state.to_dict(),
indent=2,
ensure_ascii=False,
))
print()
print("=== Устанавливаем температуру 20°C ===")
await service.execute(
lambda tion: tion.set_target_temperature(20)
)
print_service(service)
print()
print("=== После stop ===")
print_service(service)
if __name__ == "__main__":
asyncio.run(main())