diff --git a/app/api/deps.py b/app/api/deps.py new file mode 100644 index 0000000..1cc351f --- /dev/null +++ b/app/api/deps.py @@ -0,0 +1,21 @@ +import os +from fastapi import Depends, HTTPException, Security +from fastapi.security import APIKeyHeader +from starlette.status import HTTP_403_FORBIDDEN +from dotenv import load_dotenv + +load_dotenv() + +API_KEY = os.getenv("IGNIS_API_KEY") +API_KEY_NAME = "X-API-Key" +api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False) + + +async def verify_token(header_value: str = Depends(api_key_header)): + if not API_KEY: + return None + if header_value == API_KEY: + return header_value + raise HTTPException( + status_code=HTTP_403_FORBIDDEN, detail="Could not validate credentials" + ) diff --git a/app/api/routes/control.py b/app/api/routes/control.py new file mode 100644 index 0000000..d5a4164 --- /dev/null +++ b/app/api/routes/control.py @@ -0,0 +1,91 @@ +import asyncio +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException +from app.core.state import state_manager +from app.drivers.wiz import WizDriver +from app.api.deps import verify_token + +router = APIRouter(dependencies=[Depends(verify_token)]) +wiz = WizDriver() + + +@router.post("/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 any(v is not None for v in [r, g, b]): + 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} + + +@router.post("/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 any(v is not None for v in [r, g, b]): + params["r"], params["g"], params["b"] = r or 0, g or 0, b or 0 + + 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} + + +@router.post("/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="Лампа оффлайн") + + await wiz.set_pilot(device.ip, {"sceneId": 34, "speed": 100}) + await asyncio.sleep(3) + await wiz.set_pilot(device.ip, {"state": False}) + + return {"status": "blink_sent"} diff --git a/app/api/routes/devices.py b/app/api/routes/devices.py new file mode 100644 index 0000000..7ebaae4 --- /dev/null +++ b/app/api/routes/devices.py @@ -0,0 +1,64 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from app.core.state import state_manager, discovery_service +from app.core.database import async_session +from app.models.device import GroupModel, GroupCreateSchema +from app.api.deps import verify_token +from app.drivers.wiz import WizDriver + +# Создаем роутер с защитой +router = APIRouter(dependencies=[Depends(verify_token)]) +wiz = WizDriver() + + +@router.get("") +async def get_all_devices(): + return state_manager.devices + + +@router.get("/groups") +async def get_groups(): + return state_manager.groups + + +@router.get("/scenes") +async def get_scenes(): + return wiz.SCENES + + +@router.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} + + +@router.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() + state_manager.groups.pop(group_id, None) + return {"status": "deleted", "id": group_id} + + +@router.post("/rescan") +async def rescan_network(): + found_devices = await discovery_service.scan_network() + for dev_data in found_devices: + state_manager.update_device(dev_data) + return {"status": "ok", "found": len(state_manager.devices)} diff --git a/app/api/routes/schedules.py b/app/api/routes/schedules.py new file mode 100644 index 0000000..b9fd24d --- /dev/null +++ b/app/api/routes/schedules.py @@ -0,0 +1,115 @@ +from datetime import datetime, timedelta +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException +from apscheduler.triggers.cron import CronTrigger +from apscheduler.triggers.date import DateTrigger + +from app.core.state import state_manager +from app.core.scheduler import scheduler, execute_lamp_command +from app.drivers.wiz import WizDriver +from app.api.deps import verify_token + +router = APIRouter(dependencies=[Depends(verify_token)]) +wiz = WizDriver() + + +@router.post("/at") +async def add_task_at_time( + target_id: str, + run_at: datetime, + is_group: bool = False, + state: Optional[bool] = None, + brightness: Optional[int] = None, + scene: Optional[str] = None, +): + 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] + + ips = state_manager.get_group_ips(target_id) if is_group else [] + if not is_group: + dev = state_manager.devices.get(target_id) + if dev: + ips = [dev.ip] + + if not ips: + raise HTTPException(status_code=404, detail="Цель не найдена") + + job_ids = [] + for ip in ips: + job = scheduler.add_job( + execute_lamp_command, + DateTrigger(run_date=run_at, timezone=scheduler.timezone), + args=[ip, params], + name=f"{'Group' if is_group else 'Device'}: {target_id} | {params}", + ) + job_ids.append(job.id) + + return {"status": "scheduled", "jobs": job_ids, "run_at": run_at} + + +@router.post("/cron") +async def add_cron_task( + target_id: str, + hour: str, + minute: str, + day_of_week: str = "*", + is_group: bool = True, + state: bool = True, +): + ips = state_manager.get_group_ips(target_id) if is_group else [] + if not is_group: + dev = state_manager.devices.get(target_id) + if dev: + ips = [dev.ip] + + if not ips: + raise HTTPException(status_code=404, detail="Цель не найдена") + + trigger = CronTrigger( + hour=hour, minute=minute, day_of_week=day_of_week, timezone=scheduler.timezone + ) + + job_ids = [] + for ip in ips: + job = scheduler.add_job( + execute_lamp_command, + trigger, + args=[ip, {"state": state}], + name=f"CRON: {target_id} | {hour}:{minute}", + ) + job_ids.append(job.id) + + return {"status": "cron_scheduled", "jobs": job_ids} + + +@router.get("/tasks") +async def get_all_tasks(): + jobs = [] + + for job in scheduler.get_jobs(): + jobs.append( + { + "id": job.id, + "name": job.name, + "next_run": ( + job.next_run_time.isoformat() if job.next_run_time else None + ), + "params": str(job.args[1]) if len(job.args) > 1 else None, + } + ) + + return {"tasks": jobs} + + +@router.delete("/{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="Задача не найдена") diff --git a/app/core/discovery.py b/app/core/discovery.py index 9519aee..5dad275 100644 --- a/app/core/discovery.py +++ b/app/core/discovery.py @@ -86,3 +86,17 @@ class DiscoveryService: # Фильтруем дубликаты (если лампа ответила несколько раз) unique_devices = list({d["mac"]: d for d in found_devices}.values()) return unique_devices + + async def start_background_discovery(self, state_manager, interval=600): + """Запускает бесконечный цикл сканирования.""" + while True: + try: + found_devices = await self.scan_network() + for dev_data in found_devices: + state_manager.update_device(dev_data) + logger.info( + f"📡 Discovery: онлайн {len(state_manager.devices)} устройств" + ) + except Exception as e: + print(f"❌ Discovery error: {e}") + await asyncio.sleep(interval) diff --git a/app/core/state.py b/app/core/state.py index 9840d3e..d981857 100644 --- a/app/core/state.py +++ b/app/core/state.py @@ -1,6 +1,7 @@ import logging from typing import Dict, List, Optional from app.models.device import DeviceSchema, GroupModel +from app.core.discovery import DiscoveryService logger = logging.getLogger(__name__) @@ -36,3 +37,4 @@ class StateManager: state_manager = StateManager() +discovery_service = DiscoveryService() diff --git a/main.py b/main.py index ba27ffd..c67c8d9 100644 --- a/main.py +++ b/main.py @@ -1,437 +1,55 @@ -import asyncio import logging -import os -from typing import Optional, List -from datetime import datetime, timedelta +import asyncio from contextlib import asynccontextmanager -from dotenv import load_dotenv - -from fastapi import FastAPI, Depends, HTTPException, Security, APIRouter +from fastapi import FastAPI from fastapi.staticfiles import StaticFiles -from fastapi.security import APIKeyHeader -from starlette.status import HTTP_403_FORBIDDEN -from sqlalchemy import select - -from apscheduler.triggers.cron import CronTrigger -from apscheduler.triggers.date import DateTrigger - -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 +from app.core.scheduler import start_scheduler +from app.core.state import state_manager, discovery_service +from sqlalchemy import select +from app.models.device import GroupModel +from app.api.routes import devices, control, schedules -load_dotenv() - -API_KEY = os.getenv("IGNIS_API_KEY") -api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) - logging.basicConfig( level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s" ) logger = logging.getLogger(__name__) -discovery = DiscoveryService() -wiz = WizDriver() - - -async def verify_token(header_value: str = Depends(api_key_header)): - # Если ключ не задан в .env, пускаем (для локальной разработки), - # либо строго требуем, если мы в продакшене - if not API_KEY: - return None - if header_value == API_KEY: - return header_value - raise HTTPException( - status_code=HTTP_403_FORBIDDEN, detail="Could not validate credentials" - ) - - -api_router = APIRouter(dependencies=[Depends(verify_token)]) - @asynccontextmanager async def lifespan(app: FastAPI): - """Жизненный цикл приложения.""" - # 1. Инициализация БД (создание таблиц) + # 1. БД и Планировщик await init_db() - logger.info("🗄️ База данных инициализирована") - await start_scheduler() - # 2. Загрузка групп из БД в память + # 2. Загрузка групп async with async_session() as session: result = await session.execute(select(GroupModel)) - groups = result.scalars().all() - for g in groups: + for g in result.scalars().all(): state_manager.groups[g.id] = g - logger.info(f"📂 Загружена группа: {g.name} (MACs: {g.device_ids})") + logger.info(f"📂 Загружена группа: {g.name}") - 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()) + # 3. Фоновый Discovery + discovery_task = asyncio.create_task( + discovery_service.start_background_discovery(state_manager) + ) yield discovery_task.cancel() - logger.info("🛑 Фоновая служба Discovery остановлена") + logger.info("🛑 Ignis Core остановлен") app = FastAPI(title="Ignis Core API", lifespan=lifespan) -# --- Эндпоинты Устройств --- - - -@api_router.get("/devices") -async def get_all_devices(): - """Список всех обнаруженных ламп.""" - return state_manager.devices - - -@api_router.get("/groups") -async def get_groups(): - """Список всех созданных люстр/групп.""" - return state_manager.groups - - -@api_router.get("/scenes") -async def get_scenes(): - return wiz.SCENES - - -@api_router.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} - - -@api_router.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} - - -@api_router.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} - - -@api_router.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} - - -@api_router.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)} - - -@api_router.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"} - - -@api_router.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()} - - -@api_router.post("/schedules/group/once") -async def add_group_once_task(group_id: str, minutes: int, state: bool): - group = state_manager.groups.get(group_id) - if not group: - raise HTTPException(status_code=404, detail="Группа не найдена") - - # Собираем все IP ламп группы - ips = [ - state_manager.devices[mac].ip - for mac in group.device_ids - if mac in state_manager.devices - ] - run_time = datetime.now(scheduler.timezone) + timedelta(minutes=minutes) - - for ip in ips: - scheduler.add_job( - execute_lamp_command, "date", run_date=run_time, args=[ip, {"state": state}] - ) - return { - "status": "scheduled", - "group": group_id, - "lamps": len(ips), - "run_at": run_time, - } - - -@api_router.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} - - -@api_router.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_router.post("/schedules/at") -async def add_task_at_time( - target_id: str, - run_at: datetime, - is_group: bool = False, - state: Optional[bool] = None, - brightness: Optional[int] = None, - scene: Optional[str] = None, -): - """Запуск команды в конкретную дату и время (ISO формат)""" - # Собираем параметры команды - 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] - - # Определяем список IP - ips = [] - if is_group: - ips = state_manager.get_group_ips(target_id) - else: - dev = state_manager.devices.get(target_id) - if dev: - ips = [dev.ip] - - if not ips: - raise HTTPException(status_code=404, detail="Цель не найдена или оффлайн") - - job_ids = [] - for ip in ips: - job = scheduler.add_job( - execute_lamp_command, - DateTrigger(run_date=run_at, timezone=scheduler.timezone), - args=[ip, params], - name=f"{'Group' if is_group else 'Device'}: {target_id} | Action: {params}", - ) - job_ids.append(job.id) - - return {"status": "scheduled", "jobs": job_ids, "run_at": run_at} - - -@api_router.post("/schedules/cron") -async def add_cron_task( - target_id: str, - hour: str, - minute: str, - day_of_week: str = "*", - is_group: bool = True, - state: bool = True, -): - """ - Многоразовая задача (Cron). - Пример: hour="7", minute="30", day_of_week="mon-fri" - """ - ips = state_manager.get_group_ips(target_id) if is_group else [] - if not is_group: - dev = state_manager.devices.get(target_id) - if dev: - ips = [dev.ip] - - if not ips: - raise HTTPException(status_code=404, detail="Цель не найдена") - - params = {"state": state} - trigger = CronTrigger( - hour=hour, minute=minute, day_of_week=day_of_week, timezone=scheduler.timezone - ) - - job_ids = [] - for ip in ips: - job = scheduler.add_job( - execute_lamp_command, - trigger, - args=[ip, params], - name=f"CRON: {target_id} | {hour}:{minute} | {day_of_week}", - ) - job_ids.append(job.id) - - return {"status": "cron_scheduled", "jobs": job_ids} - - -@api_router.get("/schedules/tasks") -async def get_all_tasks(): - """Список задач для бота или фронта""" - jobs = [] - for job in scheduler.get_jobs(): - jobs.append( - { - "id": job.id, - "name": job.name, # Мы сохранили описание в поле name выше - "next_run": ( - job.next_run_time.isoformat() if job.next_run_time else None - ), - "params": str(job.args[1]) if len(job.args) > 1 else None, - } - ) - return {"tasks": jobs} - - -app.include_router(api_router) - -# --- МОНТИРОВАНИЕ СТАТИКИ (ДОЛЖНО БЫТЬ ПОСЛЕ ВСЕХ API МАРШРУТОВ) --- +# Регистрация роутеров +app.include_router(devices.router, prefix="/devices", tags=["Devices & Groups"]) +app.include_router(control.router, prefix="/control", tags=["Control"]) +app.include_router(schedules.router, prefix="/schedules", tags=["Schedules"]) +# Статика app.mount("/", StaticFiles(directory="static", html=True), name="static")