work: Реализована работа расписания. Проведен первый тест.
This commit is contained in:
@@ -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,3 +1,4 @@
|
|||||||
tion-btle==3.3.6
|
tion-btle==3.3.6
|
||||||
fastapi
|
fastapi
|
||||||
uvicorn[standard]
|
uvicorn[standard]
|
||||||
|
PyYAML
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user