Files
ClimatController/app/api.py
T

719 lines
17 KiB
Python

import logging
from contextlib import asynccontextmanager
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,
QINGPING_MAC,
QINGPING_MQTT_HOST,
QINGPING_MQTT_PORT,
MIN_FAN_SPEED,
MAX_FAN_SPEED,
MIN_TARGET_TEMP,
MAX_TARGET_TEMP,
AIR_MODE_OUTSIDE,
AIR_MODE_RECIRCULATION,
SCHEDULE_FILE,
AUTO_CONFIG_FILE,
)
from app.auto import (
AutoController,
Co2SpeedPolicy,
load_auto_config,
)
from app.tion import (
TionController,
TionService,
)
from schedule import (
ScheduleService,
ScheduledSettings,
load_schedule,
)
from app.qingping.service import QingpingService
def configure_logging() -> None:
noisy_loggers = (
"asyncio",
"bleak",
"bleak.backends",
"bleak.backends.winrt",
"bleak.backends.winrt.client",
"tion_btle",
"tion_btle.tion",
"tion_btle.s4",
"tion_btle.light_family",
)
for logger_name in noisy_loggers:
logging.getLogger(logger_name).setLevel(logging.WARNING)
configure_logging()
# ----------------------------------------------------------------------
# Tion
# ----------------------------------------------------------------------
controller = TionController(TION_MAC)
service = TionService(
controller,
poll_interval=5,
)
qingping_service = QingpingService(
host=QINGPING_MQTT_HOST,
port=QINGPING_MQTT_PORT,
mac=QINGPING_MAC,
)
schedule_service: ScheduleService | None = None
schedule_load_error: str | None = None
auto_controller: AutoController | None = None
auto_load_error: str | None = None
# ----------------------------------------------------------------------
# Application lifecycle
# ----------------------------------------------------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
global schedule_service
global schedule_load_error
global auto_controller
global auto_load_error
await service.start()
await qingping_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
try:
auto_config = load_auto_config(
AUTO_CONFIG_FILE
)
auto_policy = Co2SpeedPolicy(
base_speed=(
auto_config.co2.base_speed
),
thresholds=(
auto_config.co2.thresholds
),
hysteresis=(
auto_config.co2.hysteresis
),
)
auto_interval = (
auto_config.check_interval
)
auto_load_error = None
except Exception as exc:
auto_policy = None
# Безопасный встроенный интервал нужен,
# потому что auto.yaml сейчас недоступен.
auto_interval = 5.0
auto_load_error = (
f"{type(exc).__name__}: {exc}"
)
auto_controller = AutoController(
schedule_service=schedule_service,
qingping_service=qingping_service,
tion_service=service,
policy=auto_policy,
interval=auto_interval,
)
await auto_controller.start()
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 qingping_service.stop()
await service.stop()
# ----------------------------------------------------------------------
# FastAPI
# ----------------------------------------------------------------------
app = FastAPI(
title="Tion Controller",
version="0.1.0",
lifespan=lifespan,
)
@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
# ----------------------------------------------------------------------
def get_status() -> dict:
"""
Получить текущее состояние сервиса.
Bluetooth-запрос здесь не выполняется.
"""
state = service.state
return {
"online": service.online,
"running": service.running,
"last_seen": (
service.last_seen.isoformat()
if service.last_seen is not None
else None
),
"last_error": service.last_error,
"tion": (
state.to_dict()
if state is not None
else None
),
"auto": get_auto_status(),
"qingping": qingping_service.status(),
"schedule": get_schedule_status(),
}
def get_effective_speed() -> int:
if (
schedule_service is not None
and schedule_service.override_active
and schedule_service.override_settings.speed is not None
):
return schedule_service.override_settings.speed
state = service.state
if state is None:
raise HTTPException(
status_code=503,
detail={
"message": "Tion state unavailable",
},
)
return state.fan_speed
def get_effective_temperature() -> int:
if (
schedule_service is not None
and schedule_service.override_active
and schedule_service.override_settings.target_temp is not None
):
return (
schedule_service
.override_settings
.target_temp
)
state = service.state
if state is None:
raise HTTPException(
status_code=503,
detail={
"message": "Tion state unavailable",
},
)
return state.target_temp
async def execute_command(operation, override: ScheduledSettings | None = None):
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)
except Exception as exc:
raise HTTPException(
status_code=503,
detail={
"message": "Tion unavailable",
"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_time = (
current.when.strftime("%H:%M")
if current is not None
else None
)
next_time = (
resolution.next.when.strftime("%H:%M")
if resolution.next is not None
else None
)
next_action = (
resolution.next.point.action.type.value
if resolution.next is not None
else None
)
override_until_time = (
schedule_service.override_until.strftime("%H:%M")
if schedule_service.override_until
else None
)
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_time": current_time,
"next_time": next_time,
"next_action": next_action,
"override_until_time": override_until_time,
"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
),
}
def get_auto_status() -> dict:
if auto_controller is None:
return {
"available": False,
"state": "unavailable",
"reason": None,
"target_speed": None,
"auto_speed": None,
"last_error": None,
"config_error": auto_load_error,
}
return {
"available": True,
**auto_controller.status(),
"config_error": (
auto_load_error
),
}
# ----------------------------------------------------------------------
# Status
# ----------------------------------------------------------------------
@app.get("/api/status")
async def status():
return get_status()
# ----------------------------------------------------------------------
# Power
# ----------------------------------------------------------------------
@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(
operation,
ScheduledSettings(power=enabled),
)
# ----------------------------------------------------------------------
# Fan speed increase / decrease
# ----------------------------------------------------------------------
@app.post("/api/tion/speed/increase")
async def increase_speed():
current_speed = get_effective_speed()
new_speed = min(current_speed + 1, MAX_FAN_SPEED)
if new_speed == current_speed:
return get_status()
return await execute_command(
lambda tion:
tion.set_speed(new_speed),
ScheduledSettings(
speed=new_speed,
),
)
@app.post("/api/tion/speed/decrease")
async def decrease_speed():
current_speed = get_effective_speed()
new_speed = max(current_speed - 1, MIN_FAN_SPEED)
if new_speed == current_speed:
return get_status()
return await execute_command(
lambda tion:
tion.set_speed(new_speed),
ScheduledSettings(
speed=new_speed,
),
)
# ----------------------------------------------------------------------
# Fan speed
# ----------------------------------------------------------------------
@app.post("/api/tion/speed/{speed}")
async def set_speed(
speed: Annotated[
int,
Path(
ge=MIN_FAN_SPEED,
le=MAX_FAN_SPEED,
),
] ):
return await execute_command(
lambda tion: tion.set_speed(speed),
ScheduledSettings(speed=speed),
)
# ----------------------------------------------------------------------
# Heater
# ----------------------------------------------------------------------
@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(
operation,
ScheduledSettings(heater=enabled),
)
# ----------------------------------------------------------------------
# Target temperature increase / decrease
# ----------------------------------------------------------------------
@app.post("/api/tion/temperature/increase")
async def increase_temperature():
current_temperature = get_effective_temperature()
new_temperature = min(current_temperature + 1, MAX_TARGET_TEMP)
if new_temperature == current_temperature:
return get_status()
return await execute_command(
lambda tion:
tion.set_target_temperature(
new_temperature
),
ScheduledSettings(
target_temp=new_temperature,
),
)
@app.post("/api/tion/temperature/decrease")
async def decrease_temperature():
current_temperature = get_effective_temperature()
new_temperature = max(current_temperature - 1, MIN_TARGET_TEMP)
if new_temperature == current_temperature:
return get_status()
return await execute_command(
lambda tion:
tion.set_target_temperature(
new_temperature
),
ScheduledSettings(
target_temp=new_temperature,
),
)
# ----------------------------------------------------------------------
# Target temperature
# ----------------------------------------------------------------------
@app.post("/api/tion/temperature/{temperature}")
async def set_temperature(
temperature: Annotated[
int,
Path(
ge=MIN_TARGET_TEMP,
le=MAX_TARGET_TEMP,
),
],):
return await execute_command(
lambda tion:
tion.set_target_temperature(temperature),
ScheduledSettings(target_temp=temperature),
)
# ----------------------------------------------------------------------
# Air mode
# ----------------------------------------------------------------------
@app.post("/api/tion/mode/{mode}")
async def set_mode(
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_RECIRCULATION,
],
},
)
return await execute_command(
lambda tion:
tion.set_air_mode(mode),
ScheduledSettings(
mode=mode,
),
)
# ----------------------------------------------------------------------
# Sound
# ----------------------------------------------------------------------
@app.post("/api/tion/sound/{value}")
async def set_sound(
value: Literal["on", "off"],
):
enabled = value == "on"
if enabled:
operation = lambda tion: tion.sound_on()
else:
operation = lambda tion: tion.sound_off()
return await execute_command(
operation,
ScheduledSettings(
sound=enabled,
),
)
# ----------------------------------------------------------------------
# Light
# ----------------------------------------------------------------------
@app.post("/api/tion/light/{value}")
async def set_light(
value: Literal["on", "off"],
):
enabled = value == "on"
if enabled:
operation = lambda tion: tion.light_on()
else:
operation = lambda tion: tion.light_off()
return await execute_command(
operation,
ScheduledSettings(
light=enabled,
),
)
# ----------------------------------------------------------------------
# Переход в auto
# ----------------------------------------------------------------------
@app.post("/api/schedule/override/clear")
async def clear_schedule_override():
if schedule_service is None:
raise HTTPException(
status_code=503,
detail="Schedule service unavailable",
)
await schedule_service.clear_override()
return {
"ok": True,
"schedule": get_schedule_status(),
"auto": get_auto_status(),
}