work: Рефакторинг API с расписанием и ручным управлением.
This commit is contained in:
+245
-58
@@ -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,
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user