import asyncio import logging from contextlib import asynccontextmanager from typing import Optional, List from datetime import datetime, timedelta from fastapi import FastAPI, HTTPException from fastapi.staticfiles import StaticFiles from sqlalchemy import select from app.core.discovery import DiscoveryService from app.core.state import state_manager from app.core.scheduler import start_scheduler, scheduler, execute_lamp_command from app.drivers.wiz import WizDriver from app.core.database import init_db, async_session from app.models.device import GroupModel, DeviceModel, GroupCreateSchema from app.models.schedule import ScheduleTask logging.basicConfig( level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s" ) logger = logging.getLogger(__name__) discovery = DiscoveryService() wiz = WizDriver() @asynccontextmanager async def lifespan(app: FastAPI): """Жизненный цикл приложения.""" # 1. Инициализация БД (создание таблиц) await init_db() logger.info("🗄️ База данных инициализирована") await start_scheduler() # 2. Загрузка групп из БД в память async with async_session() as session: result = await session.execute(select(GroupModel)) groups = result.scalars().all() for g in groups: state_manager.groups[g.id] = g logger.info(f"📂 Загружена группа: {g.name} (MACs: {g.device_ids})") async def periodic_discovery(): logger.info("🚀 Запущена фоновая служба Discovery") while True: try: found_devices = await discovery.scan_network() for dev_data in found_devices: state_manager.update_device(dev_data) logger.info( f"📡 Сеть просканирована. Устройств онлайн: {len(state_manager.devices)}" ) except Exception as e: logger.error(f"❌ Ошибка в цикле Discovery: {e}") await asyncio.sleep(600) discovery_task = asyncio.create_task(periodic_discovery()) yield discovery_task.cancel() logger.info("🛑 Фоновая служба Discovery остановлена") app = FastAPI(title="Ignis Core API", lifespan=lifespan) # --- Эндпоинты Устройств --- @app.get("/devices") async def get_all_devices(): """Список всех обнаруженных ламп.""" return state_manager.devices @app.get("/groups") async def get_groups(): """Список всех созданных люстр/групп.""" return state_manager.groups @app.get("/scenes") async def get_scenes(): return wiz.SCENES @app.post("/groups") async def create_group(data: GroupCreateSchema): async with async_session() as session: existing = await session.get(GroupModel, data.id) if existing: raise HTTPException(status_code=400, detail="Группа с таким ID уже есть") # Берем данные из схемы new_group = GroupModel(id=data.id, name=data.name, device_ids=data.macs) session.add(new_group) await session.commit() state_manager.groups[data.id] = new_group return {"status": "created", "group": data.name} @app.post("/control/device/{device_id}") async def control_device( device_id: str, state: Optional[bool] = None, brightness: Optional[int] = None, scene: Optional[str] = None, temp: Optional[int] = None, r: Optional[int] = None, g: Optional[int] = None, b: Optional[int] = None, ): device = state_manager.devices.get(device_id) if not device: raise HTTPException(status_code=404, detail="Лампа не в сети") params = {} if state is not None: params["state"] = state if brightness is not None: params["dimming"] = brightness if scene and scene in wiz.SCENES: params["sceneId"] = wiz.SCENES[scene] elif temp is not None: params["temp"] = temp elif r is not None or g is not None or b is not None: params["r"], params["g"], params["b"] = r or 0, g or 0, b or 0 if not params: raise HTTPException(status_code=400, detail="Никаких команд не передано") result = await wiz.set_pilot(device.ip, params) return {"device_id": device_id, "applied": params, "result": result} @app.post("/control/group/{group_id}") async def control_group( group_id: str, state: Optional[bool] = None, brightness: Optional[int] = None, scene: Optional[str] = None, temp: Optional[int] = None, r: Optional[int] = None, g: Optional[int] = None, b: Optional[int] = None, ): ips = state_manager.get_group_ips(group_id) if not ips: raise HTTPException(status_code=404, detail="Группа не найдена или оффлайн") params = {} if state is not None: params["state"] = state if brightness is not None: params["dimming"] = brightness if scene and scene in wiz.SCENES: params["sceneId"] = wiz.SCENES[scene] elif temp is not None: params["temp"] = temp elif r is not None or g is not None or b is not None: params["r"], params["g"], params["b"] = r or 0, g or 0, b or 0 if not params: raise HTTPException(status_code=400, detail="Никаких команд не передано") # Используем return_exceptions=True, чтобы ошибки одной лампы не ломали всё tasks = [wiz.set_pilot(ip, params) for ip in ips] await asyncio.gather(*tasks, return_exceptions=True) return {"status": "ok", "applied": params, "sent_to": ips} @app.delete("/groups/{group_id}") async def delete_group(group_id: str): async with async_session() as session: # Ищем в базе result = await session.execute( select(GroupModel).where(GroupModel.id == group_id) ) group = result.scalar_one_or_none() if not group: raise HTTPException(status_code=404, detail="Группа не найдена") await session.delete(group) await session.commit() # Удаляем из оперативной памяти if group_id in state_manager.groups: del state_manager.groups[group_id] return {"status": "deleted", "id": group_id} @app.post("/discovery/rescan") async def rescan_network(): logger.info("🔄 Ручной перезапуск сканирования сети...") found_devices = await discovery.scan_network() for dev_data in found_devices: state_manager.update_device(dev_data) return {"status": "ok", "found": len(state_manager.devices)} @app.post("/control/device/{device_id}/blink") async def blink_device(device_id: str): device = state_manager.devices.get(device_id) if not device: raise HTTPException(status_code=404, detail="Лампа оффлайн") # Сцена 34 в WiZ — это пульсация/мигание await wiz.set_pilot(device.ip, {"sceneId": 34, "speed": 100}) # Через 3 секунды выключаем, чтобы не мигала вечно await asyncio.sleep(3) await wiz.set_pilot(device.ip, {"state": False}) return {"status": "blink_sent"} @app.post("/schedules/once") async def add_once_task(device_id: str, minutes: int, state: bool): device = state_manager.devices.get(device_id) if not device: raise HTTPException(status_code=404, detail="Лампа не найдена") # Получаем TZ из самого шедулера, чтобы они были синхронны run_time = datetime.now(scheduler.timezone) + timedelta(minutes=minutes) job = scheduler.add_job( execute_lamp_command, "date", run_date=run_time, args=[device.ip, {"state": state}], ) return {"status": "scheduled", "job_id": job.id, "run_at": run_time.isoformat()} @app.get("/schedules/active") async def get_active_jobs(): jobs = [] for job in scheduler.get_jobs(): jobs.append( { "id": job.id, "next_run": job.next_run_time, "func": job.func_ref, "args": str(job.args), } ) return {"active_jobs": jobs} @app.delete("/schedules/{job_id}") async def cancel_task(job_id: str): try: scheduler.remove_job(job_id) return {"status": "deleted"} except: raise HTTPException(status_code=404, detail="Задача не найдена") # --- МОНТИРОВАНИЕ СТАТИКИ (ДОЛЖНО БЫТЬ ПОСЛЕ ВСЕХ API МАРШРУТОВ) --- app.mount("/", StaticFiles(directory="static", html=True), name="static") if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000)