work: Протестировано расписание и ручная установка значений во время работающего расписания.

This commit is contained in:
Fedorov Dmitriy
2026-09-14 21:24:22 +03:00
parent 4c665d9108
commit dec14b1d12
5 changed files with 357 additions and 61 deletions
+28 -18
View File
@@ -36,23 +36,47 @@ templates:
type: set type: set
speed: 3 speed: 3
- time: "17:30" - time: "19:00"
action:
type: set
power: on
speed: 6
heater: on
target_temp: 20
- time: "20:30"
action: action:
type: auto type: auto
- time: "20:31"
action:
type: set
speed: 3
- time: "23:00" - time: "23:00"
action: action:
type: set type: set
speed: 1 speed: 1
heater: off heater: off
- time: "23:58"
action:
type: set
speed: 3
weekend: weekend:
- time: "00:00" - time: "00:00"
action: action:
type: set type: set
power: off speed: 6
- time: "00:02"
action:
type: set
speed: 1
- time: "09:00" - time: "09:00"
action: action:
@@ -67,22 +91,6 @@ templates:
action: action:
type: auto type: auto
- time: "22:54"
action:
type: set
power: on
speed: 2
- time: "22:55"
action:
type: set
speed: 4
- time: "22:56"
action:
type: set
speed: 6
- time: "23:30" - time: "23:30"
action: action:
type: set type: set
@@ -90,6 +98,8 @@ templates:
heater: off heater: off
days: days:
mon: workday mon: workday
+1 -4
View File
@@ -19,10 +19,7 @@ class ScheduledSettings:
sound: bool | None = None sound: bool | None = None
light: bool | None = None light: bool | None = None
def merged( def merged(self, newer: "ScheduledSettings") -> "ScheduledSettings":
self,
newer: "ScheduledSettings",
) -> "ScheduledSettings":
""" """
Наложить более новые настройки поверх старых. Наложить более новые настройки поверх старых.
+201 -37
View File
@@ -42,12 +42,123 @@ class ScheduleService:
self._running = False self._running = False
self._task = None self._task = None
self._last_applied_when = None
# Последняя успешно применённая точка расписания.
self._last_applied_when: datetime | None = None
# Temporary override.
self._override_settings = ScheduledSettings()
self._override_until: datetime | None = None
# True означает:
# override записан, но применить его к Tion
# пока не удалось.
self._override_pending = False
# Ошибка именно ScheduleService.
self._last_error: str | None = None
@property
def running(self) -> bool:
return self._running
@property
def last_error(self) -> str | None:
return self._last_error
@property
def override_settings(self) -> ScheduledSettings:
return self._override_settings
@property
def override_until(self) -> datetime | None:
return self._override_until
@property
def override_active(self) -> bool:
if self._override_until is None:
return False
return datetime.now() < self._override_until
@property @property
def config(self) -> ScheduleConfig: def config(self) -> ScheduleConfig:
return self._config return self._config
async def apply_override(self, settings: ScheduledSettings) -> None:
if settings.empty:
raise ValueError(
"Override settings cannot be empty"
)
if self._tion is None:
raise RuntimeError(
"TionService is required"
)
now = datetime.now()
resolution = self.resolve(now)
if not resolution.enabled:
raise RuntimeError(
"Schedule is disabled"
)
if resolution.current is None:
raise RuntimeError(
"Current schedule point not found"
)
if resolution.next is None:
raise RuntimeError(
"Next schedule point not found"
)
# Если старый override уже закончился,
# перед созданием нового очищаем его.
if ( self._override_until is not None
and now >= self._override_until
):
self._clear_override()
# Добавляем новые ручные параметры
# к уже существующему override.
self._override_settings = ( self._override_settings.merged(settings) )
# Override действует строго до
# следующей точки расписания.
self._override_until = resolution.next.when
# Считаем его неприменённым,
# пока команда реально не прошла.
self._override_pending = True
try:
await self._apply_settings(settings)
except Exception as exc:
self._last_error = (
f"{type(exc).__name__}: {exc}"
)
# Override остаётся сохранённым.
# Фоновый цикл попробует ещё раз.
raise
else:
self._override_pending = False
self._last_error = None
def _clear_override(self) -> None:
self._override_settings = ScheduledSettings()
self._override_until = None
self._override_pending = False
def resolve( def resolve(
self, self,
now: datetime | None = None, now: datetime | None = None,
@@ -216,21 +327,24 @@ class ScheduleService:
return return
if self._tion is None: if self._tion is None:
raise RuntimeError( raise RuntimeError("TionService is required to start schedule")
"TionService is required to start schedule"
)
self._running = True self._running = True
# При запуске сразу приводим Tion # При запуске сразу приводим Tion
# к текущему состоянию расписания. # к текущему состоянию расписания.
await self._process(initial=True) try:
await self._process()
except Exception as exc:
self._last_error = f"{type(exc).__name__}: {exc}"
self._task = asyncio.create_task(self._loop())
self._task = asyncio.create_task(
self._loop()
)
async def stop(self) -> None: async def stop(self) -> None:
if not self._running:
return
self._running = False self._running = False
if self._task is not None: if self._task is not None:
@@ -243,21 +357,57 @@ class ScheduleService:
self._task = None self._task = None
async def _loop(self) -> None: async def _loop(self) -> None:
while self._running: while self._running:
await asyncio.sleep( await asyncio.sleep(
self._check_interval self._check_interval
) )
try:
await self._process() await self._process()
async def _process( except Exception as exc:
self, self._last_error = f"{type(exc).__name__}: {exc}"
*,
initial: bool = False,
) -> None:
resolution = self.resolve() # async def _process(self, *, initial: bool = False) -> None:
#
# resolution = self.resolve()
#
# if not resolution.enabled:
# return
#
# current = resolution.current
#
# if current is None:
# return
#
# # Эта точка уже применена.
# if (
# not initial
# and self._last_applied_when == current.when
# ):
# return
#
# # AUTO пока только распознаём.
# # Сам AutoController подключим позже.
# if (
# current.point.action.type
# == ScheduleActionType.AUTO
# ):
# self._last_applied_when = current.when
# return
#
# settings = resolution.scheduled_settings
#
# await self._apply_settings(settings)
#
# self._last_applied_when = current.when
async def _process(self) -> None:
now = datetime.now()
resolution = self.resolve(now)
if not resolution.enabled: if not resolution.enabled:
return return
@@ -267,39 +417,53 @@ class ScheduleService:
if current is None: if current is None:
return return
# Эта точка уже применена. # --------------------------------------------------
# 1. Проверяем окончание temporary override
# --------------------------------------------------
if ( if (
not initial self._override_until is not None
and self._last_applied_when == current.when and now >= self._override_until
): ):
self._clear_override()
# --------------------------------------------------
# 2. Пока override действует,
# расписание Tion не трогает
# --------------------------------------------------
if self.override_active:
# Если override ранее не удалось применить,
# пробуем снова.
if self._override_pending:
await self._apply_settings(self._override_settings)
self._override_pending = False
self._last_error = None
return return
# AUTO пока только распознаём. # --------------------------------------------------
# Сам AutoController подключим позже. # 3. Если текущая точка уже успешно применена,
if ( # ничего не делаем
current.point.action.type # --------------------------------------------------
== ScheduleActionType.AUTO
): if self._last_applied_when == current.when:
self._last_applied_when = current.when
return return
if initial: # --------------------------------------------------
# После запуска восстанавливаем всё # 4. Приводим Tion к ПОЛНОМУ актуальному
# накопленное состояние расписания. # состоянию расписания
settings = resolution.scheduled_settings # --------------------------------------------------
else:
# При обычном переходе применяем
# только параметры новой точки.
settings = current.point.action.settings
await self._apply_settings(settings) await self._apply_settings(resolution.scheduled_settings)
# Ставим только ПОСЛЕ успешного применения.
self._last_applied_when = current.when self._last_applied_when = current.when
self._last_error = None
async def _apply_settings(
self, async def _apply_settings(self, settings: ScheduledSettings) -> None:
settings: ScheduledSettings,
) -> None:
if self._tion is None: if self._tion is None:
return return
+6
View File
@@ -1,6 +1,7 @@
import asyncio import asyncio
import logging import logging
from pathlib import Path from pathlib import Path
import datetime
from app.my_dataclasses import TION_MAC from app.my_dataclasses import TION_MAC
from app.tion import TionController, TionService from app.tion import TionController, TionService
@@ -52,11 +53,16 @@ async def main():
if tion.state: if tion.state:
print( print(
f"{datetime.datetime.now().strftime("%y-%m-%d %H:%M:%S")} "
f"power={tion.state.power} " f"power={tion.state.power} "
f"speed={tion.state.fan_speed} " f"speed={tion.state.fan_speed} "
f"heater={tion.state.heater} " f"heater={tion.state.heater} "
f"target_temp={tion.state.target_temp} "
f"last_error={tion.last_error} "
) )
finally: finally:
await schedule.stop() await schedule.stop()
await tion.stop() await tion.stop()
+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())