Files
ignis-core/app/api/routes/control.py
2026-05-16 10:29:54 +07:00

374 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import json
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from app.api.deps import verify_token, AuthContext
from app.api.schemas import (
BlinkResponse,
CommandRequest,
DeviceControlResponse,
DeviceStatusResponse,
GroupControlResponse,
GroupStatusResponse,
)
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 _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}",
response_model=DeviceControlResponse,
response_model_exclude_none=True,
)
async def control_device(
device_id: str,
payload: CommandRequest,
auth: AuthContext = Depends(verify_token),
):
device = state_manager.devices.get(device_id)
if not device:
raise HTTPException(status_code=404, detail="Лампа не в сети")
params = payload.to_wiz_params()
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}",
response_model=GroupControlResponse,
response_model_exclude_none=True,
)
async def control_group(
group_id: str,
payload: CommandRequest,
auth: AuthContext = Depends(verify_token),
):
ips = state_manager.get_group_ips(group_id)
if not ips:
raise HTTPException(status_code=404, detail="Группа не найдена или оффлайн")
params = payload.to_wiz_params()
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", response_model=BlinkResponse)
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",
response_model=DeviceStatusResponse,
response_model_exclude_none=True,
)
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",
response_model=GroupStatusResponse,
response_model_exclude_none=True,
)
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}