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(), }