Files
ClimatController/app/api.py
T

918 lines
23 KiB
Python

import logging
import shutil
from contextlib import asynccontextmanager
from typing import Literal, Annotated
import yaml
from fastapi import FastAPI, Request, HTTPException, Path
from fastapi.exceptions import RequestValidationError
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from pathlib import Path as FilePath
from app.qingping.service import QingpingService
from app.metrics import PrometheusMetricsService
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.auto.temperature_policy import TemperaturePolicy
from app.tion import (
TionController,
TionService,
)
from schedule import (
ScheduleService,
ScheduledSettings,
load_schedule,
)
SCHEDULE_STATE_FILE = (
FilePath(SCHEDULE_FILE)
.with_name("schedule_state.json")
)
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
metrics_service: PrometheusMetricsService | None = None
# ----------------------------------------------------------------------
# Application lifecycle
# ----------------------------------------------------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
global schedule_service
global schedule_load_error
global auto_controller
global auto_load_error
global metrics_service
await service.start()
await qingping_service.start()
try:
try:
config = load_schedule(SCHEDULE_FILE)
schedule_service = ScheduleService(
config,
service,
check_interval=5,
state_path=SCHEDULE_STATE_FILE,
)
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
),
)
temperature_policy = TemperaturePolicy(auto_config.temperature)
auto_interval = (auto_config.check_interval)
auto_load_error = None
except Exception as exc:
auto_policy = None
temperature_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,
temperature_policy=temperature_policy,
interval=auto_interval,
)
await auto_controller.start()
except Exception as exc:
schedule_service = None
schedule_load_error = f"{type(exc).__name__}: {exc}"
try:
metrics_service = PrometheusMetricsService(
tion_service=service,
qingping_service=qingping_service,
schedule_service=schedule_service,
auto_controller=auto_controller,
output_path=(
"/var/lib/prometheus/"
"node-exporter/"
"climatcontroller.prom"
),
interval=5.0,
)
await metrics_service.start()
except Exception:
logging.getLogger(__name__).exception("Could not start Prometheus metrics service")
metrics_service = None
yield
finally:
if metrics_service is not None:
await metrics_service.stop()
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,
)
WEB_ROOT = FilePath(__file__).resolve().parents[1] / "web"
app.mount(
"/ui/static",
StaticFiles(directory=WEB_ROOT),
name="ui-static",
)
@app.get("/", include_in_schema=False)
async def root():
return RedirectResponse(url="/ui/panel")
@app.get("/ui", include_in_schema=False)
async def ui_root():
return RedirectResponse(url="/ui/panel")
@app.get("/ui/widget", include_in_schema=False)
async def ui_widget():
return FileResponse(WEB_ROOT / "widget.html")
@app.get("/ui/panel", include_in_schema=False)
async def ui_panel():
return FileResponse(WEB_ROOT / "panel.html")
@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.
#
# Но если расписание paused,
# мы находимся в полноценном ручном режиме,
# поэтому команда идёт напрямую в Tion.
if (
override is not None
and schedule_service is not None
and schedule_service.running
and schedule_service.config.enabled
and not schedule_service.paused
):
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 {
"auto_active": False,
"auto_fallback_speed": None,
"auto_target_temp": None,
"available": False,
"current_action": None,
"current": None,
"enabled": False,
"next": None,
"override_active": False,
"override_until": None,
"override_settings": {},
"paused": False,
"running": False,
"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,
"paused": schedule_service.paused,
"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,
"auto_fallback_speed": resolution.auto_fallback_speed,
"auto_target_temp": resolution.auto_target_temp,
"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.get("/api/schedule/config")
async def get_schedule_config():
try:
raw = yaml.safe_load(
FilePath(SCHEDULE_FILE).read_text(encoding="utf-8")
)
except (OSError, yaml.YAMLError) as exc:
raise HTTPException(
status_code=500,
detail={
"message": "Could not read schedule config",
"error": f"{type(exc).__name__}: {exc}",
},
) from exc
if not isinstance(raw, dict):
raise HTTPException(
status_code=500,
detail="Schedule config root must be an object",
)
return raw
@app.put("/api/schedule/config")
async def update_schedule_config(payload: dict):
global schedule_load_error
schedule_path = FilePath(SCHEDULE_FILE)
temp_path = schedule_path.with_name(schedule_path.name + ".tmp")
backup_path = schedule_path.with_name(schedule_path.name + ".bak")
try:
serialized = yaml.safe_dump(
payload,
allow_unicode=True,
sort_keys=False,
default_flow_style=False,
)
temp_path.write_text(serialized, encoding="utf-8")
config = load_schedule(temp_path)
except (OSError, ValueError, yaml.YAMLError) as exc:
temp_path.unlink(missing_ok=True)
raise HTTPException(
status_code=400,
detail={
"message": "Invalid schedule config",
"error": str(exc),
},
) from exc
try:
if schedule_path.exists():
shutil.copy2(schedule_path, backup_path)
temp_path.replace(schedule_path)
except OSError as exc:
temp_path.unlink(missing_ok=True)
raise HTTPException(
status_code=500,
detail={
"message": "Could not save schedule config",
"error": f"{type(exc).__name__}: {exc}",
},
) from exc
schedule_load_error = None
restart_required = schedule_service is None
if schedule_service is not None:
await schedule_service.replace_config(config)
return {
"ok": True,
"restart_required": restart_required,
"config": payload,
"schedule": get_schedule_status(),
}
@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(),
}
# ----------------------------------------------------------------------
# Переход в полностью manual
# ----------------------------------------------------------------------
@app.post("/api/schedule/pause")
async def pause_schedule():
if schedule_service is None:
raise HTTPException(
status_code=503,
detail="Schedule service unavailable",
)
await schedule_service.pause()
return {
"ok": True,
"schedule": get_schedule_status(),
}
# ----------------------------------------------------------------------
# Возвращаем AUTO
# ----------------------------------------------------------------------
@app.post("/api/schedule/resume")
async def resume_schedule():
if schedule_service is None:
raise HTTPException(
status_code=503,
detail="Schedule service unavailable",
)
await schedule_service.resume()
return {
"ok": True,
"schedule": get_schedule_status(),
}