diff --git a/app/api/routes/control.py b/app/api/routes/control.py index dfc2adc..c9daee8 100644 --- a/app/api/routes/control.py +++ b/app/api/routes/control.py @@ -1,10 +1,13 @@ import asyncio +import json import logging from typing import Optional from fastapi import APIRouter, Depends, HTTPException from app.core.state import state_manager +from app.core.database import async_session from app.drivers.wiz import WizDriver -from app.api.deps import verify_token +from app.api.deps import verify_token, AuthContext +from app.models.event_log import EventLog logger = logging.getLogger(__name__) @@ -12,9 +15,44 @@ router = APIRouter(dependencies=[Depends(verify_token)]) wiz = WizDriver() +async def _log_event( + auth: AuthContext, action: str, target_type: str, target_id: str, params: dict +): + """Записать событие в лог.""" + try: + async with async_session() as session: + event = EventLog( + key_name=auth.key_name, + action=action, + target_type=target_type, + target_id=target_id, + params=json.dumps(params, ensure_ascii=False) if params else None, + ) + session.add(event) + await session.commit() + except Exception as e: + logger.error(f"Ошибка записи в лог: {e}") + + +def _classify_action(params: dict) -> str: + """Определить тип действия по параметрам.""" + if "state" in params and len(params) == 1: + return "toggle_on" if params["state"] else "toggle_off" + if "sceneId" in params or "scene" in params: + return "scene" + if "r" in params or "g" in params or "b" in params: + return "color" + if "temp" in params: + return "temperature" + if "dimming" in params: + return "brightness" + return "control" + + @router.post("/device/{device_id}") async def control_device( device_id: str, + auth: AuthContext = Depends(verify_token), state: Optional[bool] = None, brightness: Optional[int] = None, scene: Optional[str] = None, @@ -44,12 +82,17 @@ async def control_device( raise HTTPException(status_code=400, detail="Никаких команд не передано") result = await wiz.set_pilot(device.ip, params) + + # Логируем + await _log_event(auth, _classify_action(params), "device", device_id, params) + return {"device_id": device_id, "applied": params, "result": result} @router.post("/group/{group_id}") async def control_group( group_id: str, + auth: AuthContext = Depends(verify_token), state: Optional[bool] = None, brightness: Optional[int] = None, scene: Optional[str] = None, @@ -76,8 +119,11 @@ async def control_group( 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) + + # Логируем + await _log_event(auth, _classify_action(params), "group", group_id, params) + return {"status": "ok", "applied": params, "sent_to": ips} @@ -88,18 +134,11 @@ async def blink_device(device_id: str): raise HTTPException(status_code=404, detail="Лампа оффлайн") try: - # 1. Получаем текущее состояние current = await wiz.get_pilot(device.ip) - # Если не удалось получить статус, считаем что она выключена (False) original_state = current.get("result", {}).get("state", False) - - # 2. Инвертируем состояние await wiz.set_pilot(device.ip, {"state": not original_state}) await asyncio.sleep(0.5) - - # 3. Возвращаем как было await wiz.set_pilot(device.ip, {"state": original_state}) - return {"status": "blink_done", "original": original_state} except Exception as e: logger.error(f"Blink error: {e}") @@ -127,11 +166,9 @@ async def get_group_status(group_id: str): if not ips: raise HTTPException(status_code=404, detail="Группа не найдена или оффлайн") - # Опрашиваем все лампы группы параллельно tasks = [wiz.get_pilot(ip) for ip in ips] results = await asyncio.gather(*tasks, return_exceptions=True) - # Формируем красивый ответ status_report = [] for ip, res in zip(ips, results): if isinstance(res, Exception): diff --git a/app/api/routes/stats.py b/app/api/routes/stats.py new file mode 100644 index 0000000..97625e1 --- /dev/null +++ b/app/api/routes/stats.py @@ -0,0 +1,119 @@ +from datetime import datetime, timedelta +from fastapi import APIRouter, Depends, Query +from sqlalchemy import select, func, and_, case +from app.core.database import async_session +from app.models.event_log import EventLog +from app.api.deps import require_admin + +router = APIRouter(dependencies=[Depends(require_admin)]) + + +@router.get("/summary") +async def get_summary(days: int = Query(default=7, ge=1, le=365)): + """ + Сводная статистика за последние N дней. + Возвращает по каждой группе: + - total_commands -- общее количество команд + - toggles_on / toggles_off -- включений / выключений + - scenes / colors / brightness / temperature -- количество смен режимов + - estimated_hours -- оценка часов работы (по парам on/off) + """ + since = (datetime.now() - timedelta(days=days)).isoformat() + + async with async_session() as session: + # Все события за период + result = await session.execute( + select(EventLog) + .where(EventLog.timestamp >= since) + .order_by(EventLog.timestamp) + ) + events = result.scalars().all() + + # Агрегация по target_id + stats = {} + # Для подсчёта часов работы: запоминаем время последнего включения + last_on = {} + + for ev in events: + tid = ev.target_id + if tid not in stats: + stats[tid] = { + "target_id": tid, + "target_type": ev.target_type, + "total_commands": 0, + "toggles_on": 0, + "toggles_off": 0, + "scenes": 0, + "colors": 0, + "brightness": 0, + "temperature": 0, + "estimated_hours": 0.0, + "by_user": {}, + } + + s = stats[tid] + s["total_commands"] += 1 + + # Счётчик по пользователям + u = ev.key_name + s["by_user"][u] = s["by_user"].get(u, 0) + 1 + + # Классификация + if ev.action == "toggle_on": + s["toggles_on"] += 1 + last_on[tid] = ev.timestamp + elif ev.action == "toggle_off": + s["toggles_off"] += 1 + # Считаем время работы + if tid in last_on: + try: + t_on = datetime.fromisoformat(last_on[tid]) + t_off = datetime.fromisoformat(ev.timestamp) + delta = (t_off - t_on).total_seconds() / 3600.0 + # Защита от мусора: макс 24 часа за один сеанс + if 0 < delta < 24: + s["estimated_hours"] += delta + except (ValueError, TypeError): + pass + del last_on[tid] + elif ev.action == "scene": + s["scenes"] += 1 + elif ev.action == "color": + s["colors"] += 1 + elif ev.action == "brightness": + s["brightness"] += 1 + elif ev.action == "temperature": + s["temperature"] += 1 + + # Округляем часы + for s in stats.values(): + s["estimated_hours"] = round(s["estimated_hours"], 1) + + return { + "period_days": days, + "since": since, + "groups": list(stats.values()), + } + + +@router.get("/log") +async def get_log(limit: int = Query(default=50, ge=1, le=500)): + """Последние N событий (для просмотра лога).""" + async with async_session() as session: + result = await session.execute( + select(EventLog).order_by(EventLog.timestamp.desc()).limit(limit) + ) + events = result.scalars().all() + + return [ + { + "id": ev.id, + "timestamp": ev.timestamp, + "key_name": ev.key_name, + "action": ev.action, + "target_type": ev.target_type, + "target_id": ev.target_id, + "params": ev.params, + } + for ev in events + ] diff --git a/app/models/event_log.py b/app/models/event_log.py new file mode 100644 index 0000000..26c6507 --- /dev/null +++ b/app/models/event_log.py @@ -0,0 +1,26 @@ +from datetime import datetime +from sqlalchemy import String, Integer, DateTime, JSON +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +class EventLog(Base): + """Лог событий управления лампами.""" + + __tablename__ = "event_log" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + timestamp: Mapped[str] = mapped_column( + String, default=lambda: datetime.now().isoformat() + ) + key_name: Mapped[str] = mapped_column( + String, default="unknown" + ) # кто: "master", "vasya", ... + action: Mapped[str] = mapped_column( + String + ) # "control", "toggle_on", "toggle_off", "scene", ... + target_type: Mapped[str] = mapped_column( + String, default="group" + ) # "group" или "device" + target_id: Mapped[str] = mapped_column(String) # id группы или устройства + params: Mapped[str] = mapped_column(String, nullable=True) # JSON строка параметров diff --git a/main.py b/main.py index b64b539..018818e 100644 --- a/main.py +++ b/main.py @@ -10,7 +10,7 @@ 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, api_keys +from app.api.routes import devices, control, schedules, api_keys, stats from app.api.deps import verify_token LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() @@ -50,6 +50,7 @@ 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.include_router(api_keys.router, prefix="/api-keys", tags=["API Keys"]) +app.include_router(stats.router, prefix="/stats", tags=["Stats"]) # Статика # Мы убираем html=True из корня, чтобы 404-е ошибки API не превращались в загрузку index.html diff --git a/static/index.html b/static/index.html index f5acaaf..53b9983 100644 --- a/static/index.html +++ b/static/index.html @@ -69,6 +69,7 @@ + @@ -254,6 +255,95 @@ + + +