diff --git a/app/api.py b/app/api.py new file mode 100644 index 0000000..60dbade --- /dev/null +++ b/app/api.py @@ -0,0 +1,258 @@ +import logging +from contextlib import asynccontextmanager +from typing import Literal + +from fastapi import FastAPI, HTTPException, Path + +from app.my_dataclasses import ( + TION_MAC, + MIN_FAN_SPEED, + MAX_FAN_SPEED, + MIN_TARGET_TEMP, + MAX_TARGET_TEMP, + AIR_MODE_OUTSIDE, + AIR_MODE_RECIRCULATION, +) + +from app.tion import ( + TionController, + TionService, +) + + +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, +) + +# ---------------------------------------------------------------------- +# Application lifecycle +# ---------------------------------------------------------------------- + +@asynccontextmanager +async def lifespan(app: FastAPI): + await service.start() + + try: + yield + finally: + await service.stop() + + +# ---------------------------------------------------------------------- +# FastAPI +# ---------------------------------------------------------------------- + +app = FastAPI( + title="Tion Controller", + version="0.1.0", + lifespan=lifespan, +) + + +# ---------------------------------------------------------------------- +# 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 + ), + } + + +async def execute_command(operation) -> dict: + """ + Выполнить команду Tion и вернуть обновлённый status. + """ + + try: + 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() + + +# ---------------------------------------------------------------------- +# Status +# ---------------------------------------------------------------------- + +@app.get("/api/status") +async def status(): + return get_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() + ) + + return await execute_command( + lambda tion: tion.power_off() + ) + + +# ---------------------------------------------------------------------- +# Fan speed +# ---------------------------------------------------------------------- + +@app.post("/api/tion/speed/{speed}") +async def set_speed( + speed: int = Path( + ge=MIN_FAN_SPEED, + le=MAX_FAN_SPEED, + ), +): + return await execute_command( + lambda tion: tion.set_speed(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() + ) + + return await execute_command( + lambda tion: tion.heater_off() + ) + + +# ---------------------------------------------------------------------- +# Target temperature +# ---------------------------------------------------------------------- + +@app.post("/api/tion/temperature/{temperature}") +async def set_temperature( + temperature: int = Path( + ge=MIN_TARGET_TEMP, + le=MAX_TARGET_TEMP, + ), +): + return await execute_command( + lambda tion: tion.set_target_temperature( + temperature + ) + ) + + +# ---------------------------------------------------------------------- +# Air mode +# ---------------------------------------------------------------------- + +@app.post("/api/tion/mode/{mode}") +async def set_air_mode( + mode: Literal[ + AIR_MODE_OUTSIDE, + AIR_MODE_RECIRCULATION, + ], +): + return await execute_command( + lambda tion: tion.set_air_mode(mode) + ) + + +# ---------------------------------------------------------------------- +# Sound +# ---------------------------------------------------------------------- + +@app.post("/api/tion/sound/{value}") +async def set_sound( + value: Literal["on", "off"], +): + if value == "on": + return await execute_command( + lambda tion: tion.sound_on() + ) + + return await execute_command( + lambda tion: tion.sound_off() + ) + + +# ---------------------------------------------------------------------- +# Light +# ---------------------------------------------------------------------- + +@app.post("/api/tion/light/{value}") +async def set_light( + value: Literal["on", "off"], +): + if value == "on": + return await execute_command( + lambda tion: tion.light_on() + ) + + return await execute_command( + lambda tion: tion.light_off() + ) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index a83753b..1e9b3a8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ -tion-btle==3.3.6 \ No newline at end of file +tion-btle==3.3.6 +fastapi +uvicorn[standard] \ No newline at end of file