import asyncio import json import logging from typing import Any, Optional from fastapi import APIRouter, Depends, HTTPException from app.api.deps import verify_token, AuthContext from app.core.database import async_session from app.core.state import state_manager from app.drivers.wiz import WizDriver, WizResponse from app.models.event_log import EventLog logger = logging.getLogger(__name__) router = APIRouter() 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 _build_command_params( state: Optional[bool], brightness: Optional[int], scene: Optional[str], temp: Optional[int], r: Optional[int], g: Optional[int], b: Optional[int], ) -> dict[str, Any]: params: dict[str, Any] = {} if state is not None: params["state"] = state if brightness is not None: params["dimming"] = brightness if scene is not None: if scene not in wiz.SCENES: raise HTTPException(status_code=400, detail="Неизвестная сцена") 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 return params def _resolve_action_name(params: dict[str, Any]) -> str: if "state" in params: return "toggle_on" if params["state"] else "toggle_off" if "sceneId" in params: return "scene" if any(channel in params for channel in ("r", "g", "b")): return "color" if "temp" in params: return "temperature" if "dimming" in params: return "brightness" return "command" def _build_event_payload( params: dict[str, Any], *, success_count: int | None = None, failure_count: int | None = None, target_count: int | None = None, status: str | None = None, ) -> dict[str, Any]: payload: dict[str, Any] = {"command": params} outcome = { "status": status, "success_count": success_count, "failure_count": failure_count, "target_count": target_count, } payload["outcome"] = {k: v for k, v in outcome.items() if v is not None} return payload async def log_command_result( auth: AuthContext, target_type: str, target_id: str, params: dict[str, Any], *, success_count: int, failure_count: int, target_count: int, ): action = _resolve_action_name(params) await _log_event( auth, f"{action}_requested", target_type, target_id, _build_event_payload(params, target_count=target_count, status="requested"), ) if success_count == 0: await _log_event( auth, f"{action}_failed", target_type, target_id, _build_event_payload( params, success_count=0, failure_count=failure_count, target_count=target_count, status="failed", ), ) return outcome_status = "ok" if failure_count == 0 else "partial" await _log_event( auth, action, target_type, target_id, _build_event_payload( params, success_count=success_count, failure_count=failure_count, target_count=target_count, status=outcome_status, ), ) async def log_command_result_by_name( key_name: str, target_type: str, target_id: str, params: dict, *, success_count: int = 1, failure_count: int = 0, target_count: int = 1, ): """Логирует результат команды из контекста без AuthContext (для планировщика).""" auth = AuthContext(is_master=False, is_admin=False, key_name=key_name) await log_command_result( auth, target_type, target_id, params, success_count=success_count, failure_count=failure_count, target_count=target_count, ) def _response_error_status(result: WizResponse) -> int: if result.kind == "timeout": return 504 return 502 def _response_error_detail(result: WizResponse, *, prefix: str) -> str: if result.message: return f"{prefix}: {result.message}" if result.kind == "timeout": return f"{prefix}: таймаут ответа" return f"{prefix}: ошибка обмена с лампой" def _serialize_wiz_result(ip: str, result: WizResponse) -> dict[str, Any]: payload: dict[str, Any] = { "ip": ip, "ok": result.ok, "kind": result.kind, } if result.ok: payload["result"] = result.result else: payload["error"] = result.message or result.kind return payload def _summarize_group_results(results: list[WizResponse]) -> tuple[int, int]: success_count = sum(1 for item in results if item.ok) failure_count = len(results) - success_count return success_count, failure_count @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, 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 = _build_command_params(state, brightness, scene, temp, r, g, b) if not params: raise HTTPException(status_code=400, detail="Никаких команд не передано") result = await wiz.set_pilot(device.ip, params) if not result.ok: await log_command_result( auth, "device", device_id, params, success_count=0, failure_count=1, target_count=1, ) raise HTTPException( status_code=_response_error_status(result), detail=_response_error_detail(result, prefix="Команда лампе не доставлена"), ) await log_command_result( auth, "device", device_id, params, success_count=1, failure_count=0, target_count=1, ) return { "device_id": device_id, "applied": params, "result": result.payload, "status": "ok", } @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, 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 = _build_command_params(state, brightness, scene, temp, r, g, b) if not params: raise HTTPException(status_code=400, detail="Никаких команд не передано") tasks = [wiz.set_pilot(ip, params) for ip in ips] results = await asyncio.gather(*tasks) success_count, failure_count = _summarize_group_results(results) await log_command_result( auth, "group", group_id, params, success_count=success_count, failure_count=failure_count, target_count=len(results), ) if success_count == 0: first_failure = next(item for item in results if not item.ok) raise HTTPException( status_code=_response_error_status(first_failure), detail=_response_error_detail( first_failure, prefix="Команда группе не доставлена" ), ) group_status = "ok" if failure_count == 0 else "partial" return { "status": group_status, "applied": params, "sent_to": ips, "success_count": success_count, "failure_count": failure_count, "results": [ _serialize_wiz_result(ip, result) for ip, result in zip(ips, results) ], } @router.post("/device/{device_id}/blink") 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="Лампа оффлайн") current = await wiz.get_pilot(device.ip) if not current.ok: raise HTTPException( status_code=_response_error_status(current), detail=_response_error_detail( current, prefix="Не удалось получить текущее состояние лампы" ), ) original_state = current.result.get("state", False) first_toggle = await wiz.set_pilot(device.ip, {"state": not original_state}) if not first_toggle.ok: raise HTTPException( status_code=_response_error_status(first_toggle), detail=_response_error_detail( first_toggle, prefix="Не удалось выполнить первую фазу blink" ), ) await asyncio.sleep(0.5) second_toggle = await wiz.set_pilot(device.ip, {"state": original_state}) if not second_toggle.ok: raise HTTPException( status_code=_response_error_status(second_toggle), detail=_response_error_detail( second_toggle, prefix="Не удалось восстановить исходное состояние" ), ) return {"status": "blink_done", "original": original_state} @router.get("/device/{device_id}/status") async def get_device_status(device_id: str, _auth: AuthContext = Depends(verify_token)): """Опрос реального состояния конкретной лампы.""" device = state_manager.devices.get(device_id) if not device: raise HTTPException(status_code=404, detail="Лампа оффлайн или не найдена") status = await wiz.get_pilot(device.ip) if not status.ok: raise HTTPException( status_code=_response_error_status(status), detail=_response_error_detail(status, prefix="Ошибка опроса лампы"), ) return {"device_id": device_id, "status": status.result} @router.get("/group/{group_id}/status") async def get_group_status(group_id: str, _auth: AuthContext = Depends(verify_token)): """Опрос состояния всей группы (возвращает список статусов).""" ips = state_manager.get_group_ips(group_id) if not ips: raise HTTPException(status_code=404, detail="Группа не найдена или оффлайн") results = await asyncio.gather(*[wiz.get_pilot(ip) for ip in ips]) status_report = [] for ip, res in zip(ips, results): if res.ok: status_report.append({"ip": ip, "status": res.result}) continue status_report.append( { "ip": ip, "error": res.message or res.kind, "kind": res.kind, } ) return {"group_id": group_id, "results": status_report}