work: Реализована работа расписания. Проведен первый тест.

This commit is contained in:
Fedorov Dmitriy
2026-09-13 23:04:35 +03:00
parent 419b8efe33
commit 4c665d9108
9 changed files with 1398 additions and 1 deletions
+102
View File
@@ -0,0 +1,102 @@
version: 1
enabled: true
timezone: local
templates:
workday:
- time: "00:00"
action:
type: set
power: off
- time: "07:00"
action:
type: set
power: on
speed: 2
heater: on
target_temp: 20
mode: outside
- time: "09:00"
action:
type: set
speed: 1
- time: "13:00"
action:
type: auto
- time: "17:00"
action:
type: set
speed: 3
- time: "17:30"
action:
type: auto
- time: "23:00"
action:
type: set
speed: 1
heater: off
weekend:
- time: "00:00"
action:
type: set
power: off
- time: "09:00"
action:
type: set
power: on
speed: 1
heater: on
target_temp: 20
mode: outside
- time: "10:00"
action:
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"
action:
type: set
speed: 1
heater: off
days:
mon: workday
tue: workday
wed: workday
thu: workday
fri: workday
sat: weekend
sun: weekend
+1
View File
@@ -1,3 +1,4 @@
tion-btle==3.3.6 tion-btle==3.3.6
fastapi fastapi
uvicorn[standard] uvicorn[standard]
PyYAML
+25
View File
@@ -0,0 +1,25 @@
from .loader import load_schedule
from .models import (
ScheduleAction,
ScheduleActionType,
ScheduleConfig,
ScheduleOccurrence,
SchedulePoint,
ScheduleResolution,
ScheduledSettings,
)
from .service import ScheduleService
__all__ = [
"load_schedule",
"ScheduleAction",
"ScheduleActionType",
"ScheduleConfig",
"ScheduleOccurrence",
"SchedulePoint",
"ScheduleResolution",
"ScheduledSettings",
"ScheduleService",
]
+400
View File
@@ -0,0 +1,400 @@
from datetime import time
from pathlib import Path
from typing import Any
import yaml
from app.my_dataclasses import (
MIN_FAN_SPEED,
MAX_FAN_SPEED,
MIN_TARGET_TEMP,
MAX_TARGET_TEMP,
SUPPORTED_AIR_MODES,
)
from .models import (
ScheduleAction,
ScheduleActionType,
ScheduleConfig,
SchedulePoint,
ScheduledSettings,
)
WEEKDAYS = (
"mon",
"tue",
"wed",
"thu",
"fri",
"sat",
"sun",
)
SET_FIELDS = {
"power",
"speed",
"heater",
"target_temp",
"mode",
"sound",
"light",
}
def _parse_time(value: Any) -> time:
if not isinstance(value, str):
raise ValueError(
f"Schedule time must be a string: {value!r}"
)
try:
parsed = time.fromisoformat(value)
except ValueError as exc:
raise ValueError(
f"Invalid schedule time: {value!r}"
) from exc
# Не разрешаем секунды.
if parsed.second != 0 or parsed.microsecond != 0:
raise ValueError(
f"Schedule time must use HH:MM: {value!r}"
)
return parsed
def _parse_on_off(
name: str,
value: Any,
) -> bool:
# PyYAML может автоматически превратить
# on/off в True/False.
if type(value) is bool:
return value
if isinstance(value, str):
normalized = value.strip().lower()
if normalized == "on":
return True
if normalized == "off":
return False
raise ValueError(
f"{name} must be 'on' or 'off'"
)
def _parse_settings(
data: dict[str, Any],
) -> ScheduledSettings:
unknown = set(data) - SET_FIELDS
if unknown:
raise ValueError(
f"Unknown SET fields: {sorted(unknown)}"
)
values: dict[str, Any] = {}
if "power" in data:
values["power"] = _parse_on_off(
"power",
data["power"],
)
if "speed" in data:
speed = data["speed"]
if type(speed) is not int:
raise ValueError(
"speed must be an integer"
)
if not MIN_FAN_SPEED <= speed <= MAX_FAN_SPEED:
raise ValueError(
f"speed must be between "
f"{MIN_FAN_SPEED} and {MAX_FAN_SPEED}"
)
values["speed"] = speed
if "heater" in data:
values["heater"] = _parse_on_off(
"heater",
data["heater"],
)
if "target_temp" in data:
temperature = data["target_temp"]
if type(temperature) is not int:
raise ValueError(
"target_temp must be an integer"
)
if not (
MIN_TARGET_TEMP
<= temperature
<= MAX_TARGET_TEMP
):
raise ValueError(
f"target_temp must be between "
f"{MIN_TARGET_TEMP} and "
f"{MAX_TARGET_TEMP}"
)
values["target_temp"] = temperature
if "mode" in data:
mode = data["mode"]
if mode not in SUPPORTED_AIR_MODES:
raise ValueError(
f"Unsupported air mode: {mode!r}"
)
values["mode"] = mode
if "sound" in data:
values["sound"] = _parse_on_off(
"sound",
data["sound"],
)
if "light" in data:
values["light"] = _parse_on_off(
"light",
data["light"],
)
settings = ScheduledSettings(**values)
if settings.empty:
raise ValueError(
"SET action must contain "
"at least one setting"
)
return settings
def _parse_action(
data: Any,
) -> ScheduleAction:
if not isinstance(data, dict):
raise ValueError(
"Schedule action must be an object"
)
action_type_raw = data.get("type")
try:
action_type = ScheduleActionType(
action_type_raw
)
except ValueError as exc:
raise ValueError(
f"Unsupported action type: "
f"{action_type_raw!r}"
) from exc
if action_type == ScheduleActionType.AUTO:
extra = set(data) - {"type"}
if extra:
raise ValueError(
"AUTO action cannot contain "
f"SET fields: {sorted(extra)}"
)
return ScheduleAction(
type=ScheduleActionType.AUTO
)
settings_data = {
key: value
for key, value in data.items()
if key != "type"
}
return ScheduleAction(
type=ScheduleActionType.SET,
settings=_parse_settings(
settings_data
),
)
def _parse_template(
name: str,
data: Any,
) -> tuple[SchedulePoint, ...]:
if not isinstance(data, list):
raise ValueError(
f"Template {name!r} must be a list"
)
if not data:
raise ValueError(
f"Template {name!r} cannot be empty"
)
points: list[SchedulePoint] = []
for item in data:
if not isinstance(item, dict):
raise ValueError(
f"Invalid point in template {name!r}"
)
if "time" not in item:
raise ValueError(
f"Point in {name!r} has no time"
)
if "action" not in item:
raise ValueError(
f"Point in {name!r} has no action"
)
points.append(
SchedulePoint(
at=_parse_time(item["time"]),
action=_parse_action(
item["action"]
),
)
)
points.sort(
key=lambda point: point.at
)
seen_times = set()
for point in points:
if point.at in seen_times:
raise ValueError(
f"Duplicate time {point.at} "
f"in template {name!r}"
)
seen_times.add(point.at)
return tuple(points)
def load_schedule(
path: str | Path,
) -> ScheduleConfig:
path = Path(path)
with path.open(
"r",
encoding="utf-8",
) as file:
raw = yaml.safe_load(file)
if not isinstance(raw, dict):
raise ValueError(
"Schedule root must be an object"
)
version = raw.get("version", 1)
if version != 1:
raise ValueError(
f"Unsupported schedule version: {version}"
)
enabled = raw.get("enabled", True)
if type(enabled) is not bool:
raise ValueError(
"enabled must be true or false"
)
timezone = raw.get(
"timezone",
"local",
)
if timezone != "local":
raise ValueError(
"Only timezone: local "
"is currently supported"
)
raw_templates = raw.get("templates")
if not isinstance(raw_templates, dict):
raise ValueError(
"templates must be an object"
)
templates = {
name: _parse_template(
name,
template,
)
for name, template
in raw_templates.items()
}
raw_days = raw.get("days")
if not isinstance(raw_days, dict):
raise ValueError(
"days must be an object"
)
days: dict[str, str] = {}
for weekday in WEEKDAYS:
if weekday not in raw_days:
raise ValueError(
f"Missing schedule day: {weekday}"
)
template_name = raw_days[weekday]
if template_name not in templates:
raise ValueError(
f"Unknown template "
f"{template_name!r} "
f"for {weekday}"
)
days[weekday] = template_name
unknown_days = (
set(raw_days)
- set(WEEKDAYS)
)
if unknown_days:
raise ValueError(
f"Unknown weekdays: "
f"{sorted(unknown_days)}"
)
return ScheduleConfig(
version=version,
enabled=enabled,
timezone=timezone,
templates=templates,
days=days,
)
+122
View File
@@ -0,0 +1,122 @@
from dataclasses import dataclass, field, fields
from datetime import datetime, time
from enum import StrEnum
from typing import Any
class ScheduleActionType(StrEnum):
SET = "set"
AUTO = "auto"
@dataclass(frozen=True, slots=True)
class ScheduledSettings:
power: bool | None = None
speed: int | None = None
heater: bool | None = None
target_temp: int | None = None
mode: str | None = None
sound: bool | None = None
light: bool | None = None
def merged(
self,
newer: "ScheduledSettings",
) -> "ScheduledSettings":
"""
Наложить более новые настройки поверх старых.
None означает:
параметр в данной точке расписания не менялся.
"""
values: dict[str, Any] = {}
for item in fields(self):
old_value = getattr(self, item.name)
new_value = getattr(newer, item.name)
values[item.name] = (
old_value
if new_value is None
else new_value
)
return ScheduledSettings(**values)
@property
def empty(self) -> bool:
return all(
getattr(self, item.name) is None
for item in fields(self)
)
def to_dict(
self,
*,
skip_none: bool = True,
) -> dict:
result = {}
for item in fields(self):
value = getattr(self, item.name)
if skip_none and value is None:
continue
result[item.name] = value
return result
@dataclass(frozen=True, slots=True)
class ScheduleAction:
type: ScheduleActionType
settings: ScheduledSettings = field(
default_factory=ScheduledSettings
)
@dataclass(frozen=True, slots=True)
class SchedulePoint:
at: time
action: ScheduleAction
@dataclass(frozen=True, slots=True)
class ScheduleConfig:
version: int
enabled: bool
timezone: str
templates: dict[
str,
tuple[SchedulePoint, ...]
]
days: dict[str, str]
@dataclass(frozen=True, slots=True)
class ScheduleOccurrence:
"""
Конкретная точка расписания уже с датой.
"""
when: datetime
weekday: str
template: str
point: SchedulePoint
@dataclass(frozen=True, slots=True)
class ScheduleResolution:
enabled: bool
current: ScheduleOccurrence | None
next: ScheduleOccurrence | None
scheduled_settings: ScheduledSettings
auto_active: bool
+369
View File
@@ -0,0 +1,369 @@
import asyncio
from app.tion.service import TionService
from datetime import (
date,
datetime,
timedelta,
)
from .models import (
ScheduleActionType,
ScheduleConfig,
ScheduleOccurrence,
ScheduleResolution,
ScheduledSettings,
)
WEEKDAYS = (
"mon",
"tue",
"wed",
"thu",
"fri",
"sat",
"sun",
)
class ScheduleService:
def __init__(
self,
config,
tion: TionService | None = None,
check_interval: float = 5.0,
):
self._config = config
self._tion = tion
self._check_interval = check_interval
self._running = False
self._task = None
self._last_applied_when = None
@property
def config(self) -> ScheduleConfig:
return self._config
def resolve(
self,
now: datetime | None = None,
) -> ScheduleResolution:
"""
Определить:
- текущую точку;
- следующую точку;
- накопленные SET-настройки;
- активен ли сейчас AUTO.
"""
if now is None:
now = datetime.now()
if not self._config.enabled:
return ScheduleResolution(
enabled=False,
current=None,
next=None,
scheduled_settings=ScheduledSettings(),
auto_active=False,
)
# Недели назад достаточно,
# поскольку расписание повторяется каждые 7 дней.
start_date = (
now.date()
- timedelta(days=7)
)
end_date = (
now.date()
+ timedelta(days=7)
)
occurrences = self._build_occurrences(
start_date,
end_date,
)
current = None
next_point = None
for occurrence in occurrences:
if occurrence.when <= now:
current = occurrence
continue
next_point = occurrence
break
if current is None:
raise RuntimeError(
"Could not determine current "
"schedule point"
)
scheduled_settings = (
self._calculate_settings(
occurrences,
current,
)
)
auto_active = (
current.point.action.type
== ScheduleActionType.AUTO
)
return ScheduleResolution(
enabled=True,
current=current,
next=next_point,
scheduled_settings=scheduled_settings,
auto_active=auto_active,
)
def _build_occurrences(
self,
start_date: date,
end_date: date,
) -> list[ScheduleOccurrence]:
result: list[ScheduleOccurrence] = []
current_date = start_date
while current_date <= end_date:
weekday = WEEKDAYS[
current_date.weekday()
]
template_name = (
self._config.days[weekday]
)
template = (
self._config.templates[
template_name
]
)
for point in template:
when = datetime.combine(
current_date,
point.at,
)
result.append(
ScheduleOccurrence(
when=when,
weekday=weekday,
template=template_name,
point=point,
)
)
current_date += timedelta(days=1)
result.sort(
key=lambda occurrence:
occurrence.when
)
return result
def _calculate_settings(
self,
occurrences: list[
ScheduleOccurrence
],
current: ScheduleOccurrence,
) -> ScheduledSettings:
"""
Восстановить последние SET-значения
каждого параметра расписания.
"""
settings = ScheduledSettings()
for occurrence in occurrences:
if occurrence.when > current.when:
break
action = occurrence.point.action
if (
action.type
!= ScheduleActionType.SET
):
continue
settings = settings.merged(
action.settings
)
return settings
async def start(self) -> None:
if self._running:
return
if self._tion is None:
raise RuntimeError(
"TionService is required to start schedule"
)
self._running = True
# При запуске сразу приводим Tion
# к текущему состоянию расписания.
await self._process(initial=True)
self._task = asyncio.create_task(
self._loop()
)
async def stop(self) -> None:
self._running = False
if self._task is not None:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
async def _loop(self) -> None:
while self._running:
await asyncio.sleep(
self._check_interval
)
await self._process()
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
if initial:
# После запуска восстанавливаем всё
# накопленное состояние расписания.
settings = resolution.scheduled_settings
else:
# При обычном переходе применяем
# только параметры новой точки.
settings = current.point.action.settings
await self._apply_settings(settings)
self._last_applied_when = current.when
async def _apply_settings(
self,
settings: ScheduledSettings,
) -> None:
if self._tion is None:
return
# Если нужно включить Tion —
# сначала включаем.
if settings.power is True:
await self._tion.execute(
lambda tion: tion.power_on()
)
if settings.mode is not None:
await self._tion.execute(
lambda tion:
tion.set_air_mode(settings.mode)
)
if settings.target_temp is not None:
await self._tion.execute(
lambda tion:
tion.set_target_temperature(
settings.target_temp
)
)
if settings.heater is not None:
if settings.heater:
await self._tion.execute(
lambda tion: tion.heater_on()
)
else:
await self._tion.execute(
lambda tion: tion.heater_off()
)
if settings.speed is not None:
await self._tion.execute(
lambda tion:
tion.set_speed(settings.speed)
)
if settings.sound is not None:
if settings.sound:
await self._tion.execute(
lambda tion: tion.sound_on()
)
else:
await self._tion.execute(
lambda tion: tion.sound_off()
)
if settings.light is not None:
if settings.light:
await self._tion.execute(
lambda tion: tion.light_on()
)
else:
await self._tion.execute(
lambda tion: tion.light_off()
)
# Если нужно выключить —
# выключаем последним.
if settings.power is False:
await self._tion.execute(
lambda tion: tion.power_off()
)
View File
+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()
+66
View File
@@ -0,0 +1,66 @@
import asyncio
import logging
from pathlib import Path
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"power={tion.state.power} "
f"speed={tion.state.fan_speed} "
f"heater={tion.state.heater}"
)
finally:
await schedule.stop()
await tion.stop()
if __name__ == "__main__":
asyncio.run(main())