diff --git a/config/schedule.yaml b/config/schedule.yaml new file mode 100644 index 0000000..a6c97de --- /dev/null +++ b/config/schedule.yaml @@ -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 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 1e9b3a8..ff2b2d7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ tion-btle==3.3.6 fastapi -uvicorn[standard] \ No newline at end of file +uvicorn[standard] +PyYAML \ No newline at end of file diff --git a/schedule/__init__.py b/schedule/__init__.py new file mode 100644 index 0000000..9cdc4eb --- /dev/null +++ b/schedule/__init__.py @@ -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", +] + diff --git a/schedule/loader.py b/schedule/loader.py new file mode 100644 index 0000000..bbad703 --- /dev/null +++ b/schedule/loader.py @@ -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, + ) \ No newline at end of file diff --git a/schedule/models.py b/schedule/models.py new file mode 100644 index 0000000..7784a01 --- /dev/null +++ b/schedule/models.py @@ -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 \ No newline at end of file diff --git a/schedule/service.py b/schedule/service.py new file mode 100644 index 0000000..1f04746 --- /dev/null +++ b/schedule/service.py @@ -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() + ) \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_schedule.py b/tests/test_schedule.py new file mode 100644 index 0000000..e1f4104 --- /dev/null +++ b/tests/test_schedule.py @@ -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() \ No newline at end of file diff --git a/tests/test_schedule_live.py b/tests/test_schedule_live.py new file mode 100644 index 0000000..94d5226 --- /dev/null +++ b/tests/test_schedule_live.py @@ -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()) \ No newline at end of file