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