work: добавил режим ручного управления. Теперь можно отключать расписание!

This commit is contained in:
Fedorov Dmitriy
2026-09-19 19:04:45 +03:00
parent b02d18d283
commit c22e8bc71d
7 changed files with 784 additions and 20 deletions
+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())
+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())