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
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
tion-btle==3.3.6
|
||||
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