diff --git a/.idea/TionController.iml b/.idea/TionController.iml index b2ea363..ad3df1f 100644 --- a/.idea/TionController.iml +++ b/.idea/TionController.iml @@ -2,6 +2,7 @@ + diff --git a/app/api.py b/app/api.py index 60dbade..1b1a71b 100644 --- a/app/api.py +++ b/app/api.py @@ -1,8 +1,9 @@ import logging from contextlib import asynccontextmanager -from typing import Literal - -from fastapi import FastAPI, HTTPException, Path +from typing import Literal, Annotated +from fastapi import FastAPI, Request, HTTPException, Path +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse from app.my_dataclasses import ( TION_MAC, @@ -12,6 +13,7 @@ from app.my_dataclasses import ( MAX_TARGET_TEMP, AIR_MODE_OUTSIDE, AIR_MODE_RECIRCULATION, + SCHEDULE_FILE, ) from app.tion import ( @@ -19,6 +21,13 @@ from app.tion import ( TionService, ) +from schedule import ( + ScheduleService, + ScheduledSettings, + load_schedule, +) + + def configure_logging() -> None: noisy_loggers = ( @@ -48,6 +57,8 @@ service = TionService( controller, poll_interval=5, ) +schedule_service: ScheduleService | None = None +schedule_load_error: str | None = None # ---------------------------------------------------------------------- # Application lifecycle @@ -55,11 +66,33 @@ service = TionService( @asynccontextmanager async def lifespan(app: FastAPI): + global schedule_service + global schedule_load_error + await service.start() try: + try: + config = load_schedule(SCHEDULE_FILE) + + schedule_service = ScheduleService( + config, + service, + check_interval=5, + ) + + await schedule_service.start() + schedule_load_error = None + + except Exception as exc: + schedule_service = None + schedule_load_error = f"{type(exc).__name__}: {exc}" yield + finally: + if schedule_service is not None: + await schedule_service.stop() + await service.stop() @@ -74,6 +107,20 @@ app = FastAPI( ) +@app.exception_handler(RequestValidationError) +async def validation_exception_handler( + request: Request, + exc: RequestValidationError, +): + return JSONResponse( + status_code=400, + content={ + "message": "Invalid request", + "errors": exc.errors(), + }, + ) + + # ---------------------------------------------------------------------- # Helpers # ---------------------------------------------------------------------- @@ -104,29 +151,128 @@ def get_status() -> dict: if state is not None else None ), + + "schedule": get_schedule_status(), } -async def execute_command(operation) -> dict: - """ - Выполнить команду Tion и вернуть обновлённый status. - """ +async def execute_command(operation, override: ScheduledSettings | None = None): try: - await service.execute(operation) + # Если расписание реально работает, + # ручная команда становится + # temporary override. + if ( + override is not None + and schedule_service is not None + and schedule_service.running + and schedule_service.config.enabled + ): + await schedule_service.apply_override(override) + + # Если расписание выключено или + # недоступно — обычное ручное управление. + else: + await service.execute(operation) except Exception as exc: raise HTTPException( status_code=503, detail={ "message": "Tion unavailable", - "error": f"{type(exc).__name__}: {exc}", + "error": ( + f"{type(exc).__name__}: {exc}" + ), }, ) from exc return get_status() +def occurrence_to_dict(occurrence) -> dict | None: + + if occurrence is None: + return None + + return { + "when": occurrence.when.isoformat(), + "weekday": occurrence.weekday, + "template": occurrence.template, + "action": occurrence.point.action.type.value, + "settings": ( + occurrence + .point + .action + .settings + .to_dict() + ), + } + + +def get_schedule_status() -> dict: + + if schedule_service is None: + return { + "available": False, + "enabled": False, + "running": False, + "current_action": None, + "current": None, + "next": None, + "auto_active": False, + "override_active": False, + "override_until": None, + "override_settings": {}, + "scheduled_settings": {}, + "last_error": schedule_load_error, + } + + resolution = schedule_service.resolve() + + current = resolution.current + + current_action = None + + if current is not None: + current_action = (current.point.action.type.value) + + return { + "available": True, + "enabled": resolution.enabled, + "running": schedule_service.running, + "current_action": current_action, + + "current": occurrence_to_dict(resolution.current), + "next": occurrence_to_dict(resolution.next), + + "auto_active": resolution.auto_active, + "scheduled_settings": ( + resolution + .scheduled_settings + .to_dict() + ), + + "override_active":schedule_service.override_active, + + "override_until": ( + schedule_service + .override_until + .isoformat() + if schedule_service.override_until + else None + ), + + "override_settings": ( + schedule_service + .override_settings + .to_dict() + ), + + "last_error": ( + schedule_service.last_error + ), + } + # ---------------------------------------------------------------------- # Status # ---------------------------------------------------------------------- @@ -140,17 +286,19 @@ async def status(): # Power # ---------------------------------------------------------------------- -@app.post("/api/tion/power/{value}") -async def set_power( - value: Literal["on", "off"], -): - if value == "on": - return await execute_command( - lambda tion: tion.power_on() - ) +@app.post("/api/tion/power/{state}") +async def set_power(state: Literal["on", "off"]): + + enabled = state == "on" + + if enabled: + operation = lambda tion: tion.power_on() + else: + operation = lambda tion: tion.power_off() return await execute_command( - lambda tion: tion.power_off() + operation, + ScheduledSettings(power=enabled), ) @@ -160,13 +308,19 @@ async def set_power( @app.post("/api/tion/speed/{speed}") async def set_speed( - speed: int = Path( - ge=MIN_FAN_SPEED, - le=MAX_FAN_SPEED, - ), -): + speed: Annotated[ + int, + Path( + ge=MIN_FAN_SPEED, + le=MAX_FAN_SPEED, + ), + ] ): + + + return await execute_command( - lambda tion: tion.set_speed(speed) + lambda tion: tion.set_speed(speed), + ScheduledSettings(speed=speed), ) @@ -174,17 +328,20 @@ async def set_speed( # Heater # ---------------------------------------------------------------------- -@app.post("/api/tion/heater/{value}") -async def set_heater( - value: Literal["on", "off"], -): - if value == "on": - return await execute_command( - lambda tion: tion.heater_on() - ) +@app.post("/api/tion/heater/{state}") +async def set_heater(state: Literal["on", "off"]): + + enabled = state == "on" + + if enabled: + operation = lambda tion: tion.heater_on() + else: + operation = lambda tion: tion.heater_off() + return await execute_command( - lambda tion: tion.heater_off() + operation, + ScheduledSettings(heater=enabled), ) @@ -193,16 +350,20 @@ async def set_heater( # ---------------------------------------------------------------------- @app.post("/api/tion/temperature/{temperature}") + async def set_temperature( - temperature: int = Path( - ge=MIN_TARGET_TEMP, - le=MAX_TARGET_TEMP, - ), -): + temperature: Annotated[ + int, + Path( + ge=MIN_TARGET_TEMP, + le=MAX_TARGET_TEMP, + ), + ],): + return await execute_command( - lambda tion: tion.set_target_temperature( - temperature - ) + lambda tion: + tion.set_target_temperature(temperature), + ScheduledSettings(target_temp=temperature), ) @@ -211,16 +372,32 @@ async def set_temperature( # ---------------------------------------------------------------------- @app.post("/api/tion/mode/{mode}") -async def set_air_mode( - mode: Literal[ +async def set_mode( + mode: str, +): + if mode not in ( AIR_MODE_OUTSIDE, AIR_MODE_RECIRCULATION, - ], -): - return await execute_command( - lambda tion: tion.set_air_mode(mode) - ) + ): + raise HTTPException( + status_code=400, + detail={ + "message": "Invalid air mode", + "allowed": [ + AIR_MODE_OUTSIDE, + AIR_MODE_RECIRCULATION, + ], + }, + ) + return await execute_command( + lambda tion: + tion.set_air_mode(mode), + + ScheduledSettings( + mode=mode, + ), + ) # ---------------------------------------------------------------------- # Sound @@ -230,13 +407,18 @@ async def set_air_mode( async def set_sound( value: Literal["on", "off"], ): - if value == "on": - return await execute_command( - lambda tion: tion.sound_on() - ) + enabled = value == "on" + + if enabled: + operation = lambda tion: tion.sound_on() + else: + operation = lambda tion: tion.sound_off() return await execute_command( - lambda tion: tion.sound_off() + operation, + ScheduledSettings( + sound=enabled, + ), ) @@ -248,11 +430,16 @@ async def set_sound( async def set_light( value: Literal["on", "off"], ): - if value == "on": - return await execute_command( - lambda tion: tion.light_on() - ) + enabled = value == "on" + + if enabled: + operation = lambda tion: tion.light_on() + else: + operation = lambda tion: tion.light_off() return await execute_command( - lambda tion: tion.light_off() + operation, + ScheduledSettings( + light=enabled, + ), ) \ No newline at end of file diff --git a/app/my_dataclasses.py b/app/my_dataclasses.py index ef9f5a1..8355687 100644 --- a/app/my_dataclasses.py +++ b/app/my_dataclasses.py @@ -1,3 +1,4 @@ +from pathlib import Path from dataclasses import asdict, dataclass from typing import Any, Mapping @@ -22,5 +23,13 @@ SUPPORTED_AIR_MODES = { AIR_MODE_RECIRCULATION, } +PROJECT_ROOT = Path(__file__).resolve().parents[1] + +SCHEDULE_FILE = ( + PROJECT_ROOT + / "config" + / "schedule.yaml" +) + #Классы