Refactor: Clean main.py

This commit is contained in:
Артём Кокос
2026-02-21 13:34:47 +07:00
parent b30ce9ce2a
commit 765bfd3201
7 changed files with 328 additions and 403 deletions

21
app/api/deps.py Normal file
View File

@@ -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"
)

91
app/api/routes/control.py Normal file
View File

@@ -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"}

64
app/api/routes/devices.py Normal file
View File

@@ -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)}

115
app/api/routes/schedules.py Normal file
View File

@@ -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="Задача не найдена")

View File

@@ -86,3 +86,17 @@ class DiscoveryService:
# Фильтруем дубликаты (если лампа ответила несколько раз) # Фильтруем дубликаты (если лампа ответила несколько раз)
unique_devices = list({d["mac"]: d for d in found_devices}.values()) unique_devices = list({d["mac"]: d for d in found_devices}.values())
return unique_devices 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)

View File

@@ -1,6 +1,7 @@
import logging import logging
from typing import Dict, List, Optional from typing import Dict, List, Optional
from app.models.device import DeviceSchema, GroupModel from app.models.device import DeviceSchema, GroupModel
from app.core.discovery import DiscoveryService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -36,3 +37,4 @@ class StateManager:
state_manager = StateManager() state_manager = StateManager()
discovery_service = DiscoveryService()

424
main.py
View File

@@ -1,437 +1,55 @@
import asyncio
import logging import logging
import os import asyncio
from typing import Optional, List
from datetime import datetime, timedelta
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from dotenv import load_dotenv from fastapi import FastAPI
from fastapi import FastAPI, Depends, HTTPException, Security, APIRouter
from fastapi.staticfiles import StaticFiles 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.core.database import init_db, async_session
from app.models.device import GroupModel, DeviceModel, GroupCreateSchema from app.core.scheduler import start_scheduler
from app.models.schedule import ScheduleTask 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( logging.basicConfig(
level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s" level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s"
) )
logger = logging.getLogger(__name__) 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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Жизненный цикл приложения.""" # 1. БД и Планировщик
# 1. Инициализация БД (создание таблиц)
await init_db() await init_db()
logger.info("🗄️ База данных инициализирована")
await start_scheduler() await start_scheduler()
# 2. Загрузка групп из БД в память # 2. Загрузка групп
async with async_session() as session: async with async_session() as session:
result = await session.execute(select(GroupModel)) result = await session.execute(select(GroupModel))
groups = result.scalars().all() for g in result.scalars().all():
for g in groups:
state_manager.groups[g.id] = g state_manager.groups[g.id] = g
logger.info(f"📂 Загружена группа: {g.name} (MACs: {g.device_ids})") logger.info(f"📂 Загружена группа: {g.name}")
async def periodic_discovery(): # 3. Фоновый Discovery
logger.info("🚀 Запущена фоновая служба Discovery") discovery_task = asyncio.create_task(
discovery_service.start_background_discovery(state_manager)
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 yield
discovery_task.cancel() discovery_task.cancel()
logger.info("🛑 Фоновая служба Discovery остановлена") logger.info("🛑 Ignis Core остановлен")
app = FastAPI(title="Ignis Core API", lifespan=lifespan) app = FastAPI(title="Ignis Core API", lifespan=lifespan)
# --- Эндпоинты Устройств --- # Регистрация роутеров
app.include_router(devices.router, prefix="/devices", tags=["Devices & Groups"])
app.include_router(control.router, prefix="/control", tags=["Control"])
@api_router.get("/devices") app.include_router(schedules.router, prefix="/schedules", tags=["Schedules"])
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.mount("/", StaticFiles(directory="static", html=True), name="static") app.mount("/", StaticFiles(directory="static", html=True), name="static")