work: Рефакторинг API с расписанием и ручным управлением.

This commit is contained in:
Fedorov Dmitriy
2026-09-14 22:12:02 +03:00
parent dec14b1d12
commit a3dafb4f59
3 changed files with 255 additions and 58 deletions
+1
View File
@@ -2,6 +2,7 @@
<module type="PYTHON_MODULE" version="4"> <module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager"> <component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$"> <content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.venv" /> <excludeFolder url="file://$MODULE_DIR$/.venv" />
</content> </content>
<orderEntry type="jdk" jdkName="Python 3.14 (TionController)" jdkType="Python SDK" /> <orderEntry type="jdk" jdkName="Python 3.14 (TionController)" jdkType="Python SDK" />
+236 -49
View File
@@ -1,8 +1,9 @@
import logging import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Literal from typing import Literal, Annotated
from fastapi import FastAPI, Request, HTTPException, Path
from fastapi import FastAPI, HTTPException, Path from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from app.my_dataclasses import ( from app.my_dataclasses import (
TION_MAC, TION_MAC,
@@ -12,6 +13,7 @@ from app.my_dataclasses import (
MAX_TARGET_TEMP, MAX_TARGET_TEMP,
AIR_MODE_OUTSIDE, AIR_MODE_OUTSIDE,
AIR_MODE_RECIRCULATION, AIR_MODE_RECIRCULATION,
SCHEDULE_FILE,
) )
from app.tion import ( from app.tion import (
@@ -19,6 +21,13 @@ from app.tion import (
TionService, TionService,
) )
from schedule import (
ScheduleService,
ScheduledSettings,
load_schedule,
)
def configure_logging() -> None: def configure_logging() -> None:
noisy_loggers = ( noisy_loggers = (
@@ -48,6 +57,8 @@ service = TionService(
controller, controller,
poll_interval=5, poll_interval=5,
) )
schedule_service: ScheduleService | None = None
schedule_load_error: str | None = None
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# Application lifecycle # Application lifecycle
@@ -55,11 +66,33 @@ service = TionService(
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
global schedule_service
global schedule_load_error
await service.start() await service.start()
try: 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 yield
finally: finally:
if schedule_service is not None:
await schedule_service.stop()
await 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 # Helpers
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
@@ -104,15 +151,28 @@ def get_status() -> dict:
if state is not None if state is not None
else None else None
), ),
"schedule": get_schedule_status(),
} }
async def execute_command(operation) -> dict: async def execute_command(operation, override: ScheduledSettings | None = None):
"""
Выполнить команду Tion и вернуть обновлённый status.
"""
try: try:
# Если расписание реально работает,
# ручная команда становится
# 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) await service.execute(operation)
except Exception as exc: except Exception as exc:
@@ -120,13 +180,99 @@ async def execute_command(operation) -> dict:
status_code=503, status_code=503,
detail={ detail={
"message": "Tion unavailable", "message": "Tion unavailable",
"error": f"{type(exc).__name__}: {exc}", "error": (
f"{type(exc).__name__}: {exc}"
),
}, },
) from exc ) from exc
return get_status() 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 # Status
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
@@ -140,17 +286,19 @@ async def status():
# Power # Power
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
@app.post("/api/tion/power/{value}") @app.post("/api/tion/power/{state}")
async def set_power( async def set_power(state: Literal["on", "off"]):
value: Literal["on", "off"],
): enabled = state == "on"
if value == "on":
return await execute_command( if enabled:
lambda tion: tion.power_on() operation = lambda tion: tion.power_on()
) else:
operation = lambda tion: tion.power_off()
return await execute_command( 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}") @app.post("/api/tion/speed/{speed}")
async def set_speed( async def set_speed(
speed: int = Path( speed: Annotated[
int,
Path(
ge=MIN_FAN_SPEED, ge=MIN_FAN_SPEED,
le=MAX_FAN_SPEED, le=MAX_FAN_SPEED,
), ),
): ] ):
return await execute_command( 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 # Heater
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
@app.post("/api/tion/heater/{value}") @app.post("/api/tion/heater/{state}")
async def set_heater( async def set_heater(state: Literal["on", "off"]):
value: Literal["on", "off"],
): enabled = state == "on"
if value == "on":
return await execute_command( if enabled:
lambda tion: tion.heater_on() operation = lambda tion: tion.heater_on()
) else:
operation = lambda tion: tion.heater_off()
return await execute_command( 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}") @app.post("/api/tion/temperature/{temperature}")
async def set_temperature( async def set_temperature(
temperature: int = Path( temperature: Annotated[
int,
Path(
ge=MIN_TARGET_TEMP, ge=MIN_TARGET_TEMP,
le=MAX_TARGET_TEMP, le=MAX_TARGET_TEMP,
), ),
): ],):
return await execute_command( return await execute_command(
lambda tion: tion.set_target_temperature( lambda tion:
temperature tion.set_target_temperature(temperature),
) ScheduledSettings(target_temp=temperature),
) )
@@ -211,16 +372,32 @@ async def set_temperature(
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
@app.post("/api/tion/mode/{mode}") @app.post("/api/tion/mode/{mode}")
async def set_air_mode( async def set_mode(
mode: Literal[ mode: str,
):
if mode not in (
AIR_MODE_OUTSIDE,
AIR_MODE_RECIRCULATION,
):
raise HTTPException(
status_code=400,
detail={
"message": "Invalid air mode",
"allowed": [
AIR_MODE_OUTSIDE, AIR_MODE_OUTSIDE,
AIR_MODE_RECIRCULATION, AIR_MODE_RECIRCULATION,
], ],
): },
return await execute_command(
lambda tion: tion.set_air_mode(mode)
) )
return await execute_command(
lambda tion:
tion.set_air_mode(mode),
ScheduledSettings(
mode=mode,
),
)
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# Sound # Sound
@@ -230,13 +407,18 @@ async def set_air_mode(
async def set_sound( async def set_sound(
value: Literal["on", "off"], value: Literal["on", "off"],
): ):
if value == "on": enabled = value == "on"
return await execute_command(
lambda tion: tion.sound_on() if enabled:
) operation = lambda tion: tion.sound_on()
else:
operation = lambda tion: tion.sound_off()
return await execute_command( 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( async def set_light(
value: Literal["on", "off"], value: Literal["on", "off"],
): ):
if value == "on": enabled = value == "on"
return await execute_command(
lambda tion: tion.light_on() if enabled:
) operation = lambda tion: tion.light_on()
else:
operation = lambda tion: tion.light_off()
return await execute_command( return await execute_command(
lambda tion: tion.light_off() operation,
ScheduledSettings(
light=enabled,
),
) )
+9
View File
@@ -1,3 +1,4 @@
from pathlib import Path
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from typing import Any, Mapping from typing import Any, Mapping
@@ -22,5 +23,13 @@ SUPPORTED_AIR_MODES = {
AIR_MODE_RECIRCULATION, AIR_MODE_RECIRCULATION,
} }
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SCHEDULE_FILE = (
PROJECT_ROOT
/ "config"
/ "schedule.yaml"
)
#Классы #Классы