diff --git a/app/api/deps.py b/app/api/deps.py index d5ddbae..29a45f0 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -1,4 +1,5 @@ import os +import hmac import logging from dataclasses import dataclass from typing import Optional @@ -49,8 +50,8 @@ async def verify_token(header_value: str = Depends(api_key_header)) -> AuthConte status_code=HTTP_403_FORBIDDEN, detail="API-ключ не передан" ) - # Мастер-ключ - if header_value == MASTER_KEY: + # Мастер-ключ (timing-safe сравнение) + if hmac.compare_digest(header_value, MASTER_KEY): return AuthContext(is_master=True, is_admin=True, key_name="master") # Ищем в БД diff --git a/app/api/routes/api_keys.py b/app/api/routes/api_keys.py index 2d5aa9c..b733933 100644 --- a/app/api/routes/api_keys.py +++ b/app/api/routes/api_keys.py @@ -1,4 +1,5 @@ from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel from sqlalchemy import select from app.core.database import async_session @@ -9,6 +10,12 @@ from app.api.deps import require_admin, AuthContext router = APIRouter(dependencies=[Depends(require_admin)]) +class KeyActionRequest(BaseModel): + """Тело запроса для операций с ключом (чтобы токен не летел в URL).""" + + key: str + + @router.get("") async def list_keys(): """Список всех гостевых ключей.""" @@ -49,12 +56,12 @@ async def create_key(name: str, is_admin: bool = False): } -@router.delete("/{key}") -async def revoke_key(key: str): - """Деактивировать (отозвать) гостевой ключ.""" +@router.post("/revoke") +async def revoke_key(body: KeyActionRequest): + """Деактивировать (отозвать) гостевой ключ. Ключ передаётся в body, не в URL.""" async with async_session() as session: result = await session.execute( - select(ApiKeyModel).where(ApiKeyModel.key == key) + select(ApiKeyModel).where(ApiKeyModel.key == body.key) ) api_key = result.scalar_one_or_none() if not api_key: @@ -67,12 +74,12 @@ async def revoke_key(key: str): return {"status": "revoked", "name": api_key.name} -@router.post("/{key}/activate") -async def activate_key(key: str): - """Повторно активировать ключ.""" +@router.post("/activate") +async def activate_key(body: KeyActionRequest): + """Повторно активировать ключ. Ключ передаётся в body, не в URL.""" async with async_session() as session: result = await session.execute( - select(ApiKeyModel).where(ApiKeyModel.key == key) + select(ApiKeyModel).where(ApiKeyModel.key == body.key) ) api_key = result.scalar_one_or_none() if not api_key: diff --git a/app/api/routes/control.py b/app/api/routes/control.py index c9daee8..7972eb3 100644 --- a/app/api/routes/control.py +++ b/app/api/routes/control.py @@ -11,7 +11,7 @@ from app.models.event_log import EventLog logger = logging.getLogger(__name__) -router = APIRouter(dependencies=[Depends(verify_token)]) +router = APIRouter() wiz = WizDriver() @@ -34,19 +34,21 @@ async def _log_event( 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" +async def log_toggle(auth: AuthContext, target_type: str, target_id: str, params: dict): + """Логирует toggle_on/toggle_off если в params есть state.""" + if "state" in params: + action = "toggle_on" if params["state"] else "toggle_off" + await _log_event(auth, action, target_type, target_id, params) + + +async def log_toggle_by_name( + key_name: str, target_type: str, target_id: str, params: dict +): + """Логирует toggle из контекста без AuthContext (для планировщика).""" + if "state" in params: + auth = AuthContext(is_master=False, is_admin=False, key_name=key_name) + action = "toggle_on" if params["state"] else "toggle_off" + await _log_event(auth, action, target_type, target_id, params) @router.post("/device/{device_id}") @@ -83,8 +85,7 @@ async def control_device( result = await wiz.set_pilot(device.ip, params) - # Логируем - await _log_event(auth, _classify_action(params), "device", device_id, params) + await log_toggle(auth, "device", device_id, params) return {"device_id": device_id, "applied": params, "result": result} @@ -121,14 +122,13 @@ async def control_group( 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) + await log_toggle(auth, "group", group_id, params) return {"status": "ok", "applied": params, "sent_to": ips} @router.post("/device/{device_id}/blink") -async def blink_device(device_id: str): +async def blink_device(device_id: str, _auth: AuthContext = Depends(verify_token)): device = state_manager.devices.get(device_id) if not device: raise HTTPException(status_code=404, detail="Лампа оффлайн") @@ -146,7 +146,7 @@ async def blink_device(device_id: str): @router.get("/device/{device_id}/status") -async def get_device_status(device_id: str): +async def get_device_status(device_id: str, _auth: AuthContext = Depends(verify_token)): """Опрос реального состояния конкретной лампы.""" device = state_manager.devices.get(device_id) if not device: @@ -160,7 +160,7 @@ async def get_device_status(device_id: str): @router.get("/group/{group_id}/status") -async def get_group_status(group_id: str): +async def get_group_status(group_id: str, _auth: AuthContext = Depends(verify_token)): """Опрос состояния всей группы (возвращает список статусов).""" ips = state_manager.get_group_ips(group_id) if not ips: diff --git a/app/api/routes/devices.py b/app/api/routes/devices.py index 9d522f3..f8f48d0 100644 --- a/app/api/routes/devices.py +++ b/app/api/routes/devices.py @@ -1,3 +1,4 @@ +import logging from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select from app.core.state import state_manager, discovery_service @@ -6,6 +7,8 @@ from app.models.device import GroupModel, GroupCreateSchema from app.api.deps import verify_token, require_admin from app.drivers.wiz import WizDriver +logger = logging.getLogger(__name__) + # Создаем роутер с защитой router = APIRouter(dependencies=[Depends(verify_token)]) wiz = WizDriver() @@ -36,6 +39,8 @@ async def create_group(data: GroupCreateSchema): new_group = GroupModel(id=data.id, name=data.name, device_ids=data.macs) session.add(new_group) await session.commit() + # Обновляем атрибуты из БД, чтобы избежать DetachedInstanceError + await session.refresh(new_group) state_manager.groups[data.id] = new_group return {"status": "created", "group": data.name} @@ -59,6 +64,22 @@ async def delete_group(group_id: str): @router.post("/rescan", dependencies=[Depends(require_admin)]) async def rescan_network(): found_devices = await discovery_service.scan_network() + + # MAC-адреса найденных ламп + found_macs = {dev["mac"] for dev in found_devices} + + # Удаляем устройства, которые не ответили (оффлайн) + offline_macs = [mac for mac in state_manager.devices if mac not in found_macs] + for mac in offline_macs: + del state_manager.devices[mac] + logger.info(f"Устройство {mac} не ответило -- убрано из списка") + + # Обновляем/добавляем найденные for dev_data in found_devices: state_manager.update_device(dev_data) - return {"status": "ok", "found": len(state_manager.devices)} + + return { + "status": "ok", + "found": len(found_macs), + "removed_offline": len(offline_macs), + } diff --git a/app/api/routes/schedules.py b/app/api/routes/schedules.py index 4531bdd..9746ecd 100644 --- a/app/api/routes/schedules.py +++ b/app/api/routes/schedules.py @@ -14,6 +14,9 @@ logger = logging.getLogger(__name__) router = APIRouter(dependencies=[Depends(require_admin)]) +# Префиксы служебных задач -- не показываем на фронте +_INTERNAL_JOB_PREFIXES = ("cleanup_",) + async def run_group_command(target_id: str, is_group: bool, params: dict): """ @@ -35,9 +38,16 @@ async def run_group_command(target_id: str, is_group: bool, params: dict): for ip in ips: try: await local_wiz.set_pilot(ip, params) - logger.info(f"⏰ Расписание: {target_id} -> {ip}: {params}") + logger.info(f"Расписание: {target_id} -> {ip}: {params}") except Exception as e: - logger.error(f"⏰ Расписание: ошибка {ip}: {e}") + logger.error(f"Расписание: ошибка {ip}: {e}") + + # Логируем toggle в event_log + # Импорт здесь, чтобы избежать циклической зависимости + from app.api.routes.control import log_toggle_by_name + + target_type = "group" if is_group else "device" + await log_toggle_by_name("scheduler", target_type, target_id, params) @router.post("/once") @@ -122,6 +132,10 @@ async def add_cron_task( async def get_all_tasks(): jobs = [] for job in scheduler.get_jobs(): + # Пропускаем служебные задачи + if any(job.id.startswith(prefix) for prefix in _INTERNAL_JOB_PREFIXES): + continue + # Парсим имя name_parts = job.name.split("|") target = name_parts[0].replace("CRON:", "").replace("Once:", "").strip() @@ -157,6 +171,10 @@ async def get_all_tasks(): @router.delete("/{job_id}") async def cancel_task(job_id: str): + # Запрещаем удалять служебные задачи через API + if any(job_id.startswith(prefix) for prefix in _INTERNAL_JOB_PREFIXES): + raise HTTPException(status_code=403, detail="Нельзя удалить служебную задачу") + try: scheduler.remove_job(job_id) return {"status": "deleted"} diff --git a/app/api/routes/stats.py b/app/api/routes/stats.py index 97625e1..a2a77fa 100644 --- a/app/api/routes/stats.py +++ b/app/api/routes/stats.py @@ -1,6 +1,6 @@ from datetime import datetime, timedelta from fastapi import APIRouter, Depends, Query -from sqlalchemy import select, func, and_, case +from sqlalchemy import select from app.core.database import async_session from app.models.event_log import EventLog from app.api.deps import require_admin @@ -12,16 +12,14 @@ router = APIRouter(dependencies=[Depends(require_admin)]) 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) @@ -43,10 +41,6 @@ async def get_summary(days: int = Query(default=7, ge=1, le=365)): "total_commands": 0, "toggles_on": 0, "toggles_off": 0, - "scenes": 0, - "colors": 0, - "brightness": 0, - "temperature": 0, "estimated_hours": 0.0, "by_user": {}, } @@ -58,13 +52,11 @@ async def get_summary(days: int = Query(default=7, ge=1, le=365)): 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]) @@ -76,14 +68,18 @@ async def get_summary(days: int = Query(default=7, ge=1, le=365)): 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 + + # Учитываем незакрытые сессии (лампа ещё включена) + now = datetime.now() + for tid, ts in last_on.items(): + if tid in stats: + try: + t_on = datetime.fromisoformat(ts) + delta = (now - t_on).total_seconds() / 3600.0 + if 0 < delta < 24: + stats[tid]["estimated_hours"] += delta + except (ValueError, TypeError): + pass # Округляем часы for s in stats.values(): diff --git a/app/core/discovery.py b/app/core/discovery.py index ba4368b..9aaf21e 100644 --- a/app/core/discovery.py +++ b/app/core/discovery.py @@ -8,6 +8,10 @@ from typing import List, Dict logger = logging.getLogger(__name__) +# Минимальный допустимый prefixlen (больше число = меньше сеть) +# /16 = 65534 хоста, /8 = 16M хостов -- слишком много +MIN_PREFIX_LEN = 16 + class DiscoveryService: def __init__(self, port: int = 38899): @@ -23,9 +27,25 @@ class DiscoveryService: """ env_network = os.getenv("SCAN_NETWORK") if env_network: - return [s.strip() for s in env_network.split(",")] + subnets = [] + for s in env_network.split(","): + s = s.strip() + try: + net = ipaddress.IPv4Network(s, strict=False) + if net.prefixlen < MIN_PREFIX_LEN: + logger.warning( + f"Подсеть {s} слишком большая (/{net.prefixlen}), " + f"ограничиваю до /{MIN_PREFIX_LEN}" + ) + net = ipaddress.IPv4Network( + f"{net.network_address}/{MIN_PREFIX_LEN}", strict=False + ) + subnets.append(str(net)) + except ValueError as e: + logger.error(f"Неверный формат подсети {s}: {e}") + return subnets if subnets else ["192.168.1.0/24"] - # Автоопределение (твой старый метод) + # Автоопределение try: with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: # Коннект не создает трафика, но заставляет ОС выбрать нужный интерфейс @@ -49,7 +69,7 @@ class DiscoveryService: loop = asyncio.get_running_loop() message = json.dumps(self.discover_msg).encode() - logger.debug(f"🚀 Начинаю сканирование сетей: {', '.join(subnets)}...") + logger.debug(f"Начинаю сканирование сетей: {', '.join(subnets)}...") # Рассылаем запросы по всем целевым сетям for subnet in subnets: @@ -61,7 +81,7 @@ class DiscoveryService: except Exception: continue except ValueError as e: - logger.error(f"❌ Неверный формат подсети {subnet}: {e}") + logger.error(f"Неверный формат подсети {subnet}: {e}") # Собираем ответы start_time = loop.time() @@ -107,9 +127,7 @@ class DiscoveryService: 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)} устройств" - ) + logger.info(f"Discovery: онлайн {len(state_manager.devices)} устройств") except Exception as e: - logger.error(f"❌ Discovery background error: {e}") + logger.error(f"Discovery background error: {e}") await asyncio.sleep(interval) diff --git a/app/core/scheduler.py b/app/core/scheduler.py index 87e6aa8..5c1931e 100644 --- a/app/core/scheduler.py +++ b/app/core/scheduler.py @@ -1,11 +1,14 @@ import os import logging import pytz -from datetime import datetime +from datetime import datetime, timedelta from dotenv import load_dotenv from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore -from app.core.database import sync_engine +from apscheduler.triggers.cron import CronTrigger +from sqlalchemy import delete +from app.core.database import sync_engine, async_session +from app.models.event_log import EventLog from app.drivers.wiz import WizDriver load_dotenv() @@ -14,6 +17,8 @@ logger = logging.getLogger(__name__) TZ_NAME = os.getenv("APP_TIMEZONE", "Asia/Novosibirsk") app_tz = pytz.timezone(TZ_NAME) +RETENTION_DAYS = int(os.getenv("EVENT_LOG_RETENTION_DAYS", "30")) + jobstores = {"default": SQLAlchemyJobStore(engine=sync_engine)} scheduler = AsyncIOScheduler(jobstores=jobstores, timezone=app_tz) @@ -25,10 +30,34 @@ async def execute_lamp_command(ip: str, params: dict): """ driver = WizDriver() await driver.set_pilot(ip, params) - logger.info(f"⏰ Сработало расписание для {ip}: {params}") + logger.info(f"Сработало расписание для {ip}: {params}") + + +async def cleanup_old_events(): + """Удаляет записи event_log старше RETENTION_DAYS.""" + cutoff = (datetime.now() - timedelta(days=RETENTION_DAYS)).isoformat() + async with async_session() as session: + result = await session.execute( + delete(EventLog).where(EventLog.timestamp < cutoff) + ) + await session.commit() + if result.rowcount: + logger.info( + f"Очистка лога: удалено {result.rowcount} записей старше {RETENTION_DAYS} дней" + ) async def start_scheduler(): if not scheduler.running: scheduler.start() - logger.info(f"🚀 Планировщик запущен. Таймзона: {TZ_NAME}") + + # Очистка лога -- раз в сутки в 03:00 + scheduler.add_job( + cleanup_old_events, + CronTrigger(hour=3, minute=0, timezone=app_tz), + id="cleanup_event_log", + name="Очистка старых событий", + replace_existing=True, + ) + + logger.info(f"Планировщик запущен. Таймзона: {TZ_NAME}") diff --git a/app/drivers/wiz.py b/app/drivers/wiz.py index 7dcd3f7..a40cd79 100644 --- a/app/drivers/wiz.py +++ b/app/drivers/wiz.py @@ -50,7 +50,7 @@ class WizDriver: await loop.run_in_executor(None, sock.sendto, data, (ip, self.PORT)) try: - resp, _ = sock.recvfrom(1024) + resp, _ = await loop.run_in_executor(None, sock.recvfrom, 1024) return json.loads(resp.decode()) except socket.timeout: return None diff --git a/app/models/event_log.py b/app/models/event_log.py index 26c6507..700c574 100644 --- a/app/models/event_log.py +++ b/app/models/event_log.py @@ -15,10 +15,8 @@ class EventLog(Base): ) key_name: Mapped[str] = mapped_column( String, default="unknown" - ) # кто: "master", "vasya", ... - action: Mapped[str] = mapped_column( - String - ) # "control", "toggle_on", "toggle_off", "scene", ... + ) # кто: "master", "vasya", "scheduler", ... + action: Mapped[str] = mapped_column(String) # "toggle_on", "toggle_off" target_type: Mapped[str] = mapped_column( String, default="group" ) # "group" или "device" diff --git a/static/index.html b/static/index.html index 53b9983..eb8eae1 100644 --- a/static/index.html +++ b/static/index.html @@ -497,13 +497,13 @@ }, async revokeApiKey(key, name) { if (confirm(`Отозвать ключ "${name}"?`)) { - await this.request(`/api-keys/${key}`, 'DELETE'); + await this.request('/api-keys/revoke', 'POST', null, { key }); this.toast(`Ключ "${name}" отозван`, 'success'); this.fetchApiKeys(); } }, async activateApiKey(key, name) { - await this.request(`/api-keys/${key}/activate`, 'POST'); + await this.request('/api-keys/activate', 'POST', null, { key }); this.toast(`Ключ "${name}" активирован`, 'success'); this.fetchApiKeys(); }, @@ -532,4 +532,4 @@ }).mount('#app') - + \ No newline at end of file