Switch control and schedules to JSON payloads

This commit is contained in:
Artem Kokos
2026-05-16 10:29:54 +07:00
parent 13fba2fa44
commit 15529961d6
8 changed files with 1171 additions and 748 deletions

View File

@@ -1,11 +1,19 @@
import asyncio
import json
import logging
from typing import Any, Optional
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
@@ -36,33 +44,6 @@ async def _log_event(
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"
@@ -203,26 +184,21 @@ def _summarize_group_results(results: list[WizResponse]) -> tuple[int, int]:
return success_count, failure_count
@router.post("/device/{device_id}")
@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),
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="Никаких команд не передано")
params = payload.to_wiz_params()
result = await wiz.set_pilot(device.ip, params)
if not result.ok:
await log_command_result(
@@ -257,26 +233,21 @@ async def control_device(
}
@router.post("/group/{group_id}")
@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),
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="Никаких команд не передано")
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)
@@ -313,7 +284,7 @@ async def control_group(
}
@router.post("/device/{device_id}/blink")
@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:
@@ -351,7 +322,11 @@ async def blink_device(device_id: str, _auth: AuthContext = Depends(verify_token
return {"status": "blink_done", "original": original_state}
@router.get("/device/{device_id}/status")
@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)
@@ -368,7 +343,11 @@ async def get_device_status(device_id: str, _auth: AuthContext = Depends(verify_
return {"device_id": device_id, "status": status.result}
@router.get("/group/{group_id}/status")
@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)

View File

@@ -1,10 +1,16 @@
import logging
from datetime import datetime, timedelta
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException
from app.api.deps import require_admin
from app.api.schemas import (
DeleteStatusResponse,
ScheduleCreateResponse,
ScheduleCronRequest,
ScheduleOnceRequest,
ScheduleTasksResponse,
)
from app.core.scheduler import (
app_tz,
create_schedule_task,
@@ -31,39 +37,6 @@ def _validate_target(target_id: str, is_group: bool):
return "device"
def _build_action_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 WizDriver.SCENES:
raise HTTPException(status_code=400, detail="Неизвестная сцена")
params["sceneId"] = WizDriver.SCENES[scene]
elif temp is not None:
params["temp"] = temp
elif any(channel is not None for channel in (r, g, b)):
params["r"] = r or 0
params["g"] = g or 0
params["b"] = b or 0
if not params:
raise HTTPException(status_code=400, detail="Никаких команд не передано")
return params
async def run_group_command(target_id: str, is_group: bool, params: dict):
"""
Универсальное выполнение команды по расписанию.
@@ -113,31 +86,19 @@ async def run_group_command(target_id: str, is_group: bool, params: dict):
)
@router.post("/once")
async def schedule_once(
target_id: str,
state: Optional[bool] = None,
run_at: Optional[datetime] = None,
hours_from_now: Optional[int] = None,
is_group: bool = True,
brightness: Optional[int] = None,
scene: Optional[str] = None,
temp: Optional[int] = None,
r: Optional[int] = None,
g: Optional[int] = None,
b: Optional[int] = None,
):
if hours_from_now is not None:
if hours_from_now < 0:
raise HTTPException(
status_code=400, detail="hours_from_now не может быть отрицательным"
)
exec_time = datetime.now(app_tz) + timedelta(hours=hours_from_now)
elif run_at:
if run_at.tzinfo is None:
exec_time = app_tz.localize(run_at)
@router.post(
"/once",
response_model=ScheduleCreateResponse,
response_model_exclude_none=True,
)
async def schedule_once(payload: ScheduleOnceRequest):
if payload.hours_from_now is not None:
exec_time = datetime.now(app_tz) + timedelta(hours=payload.hours_from_now)
elif payload.run_at is not None:
if payload.run_at.tzinfo is None:
exec_time = app_tz.localize(payload.run_at)
else:
exec_time = run_at.astimezone(app_tz)
exec_time = payload.run_at.astimezone(app_tz)
else:
raise HTTPException(status_code=400, detail="Нужно время или отступ в часах")
@@ -146,13 +107,13 @@ async def schedule_once(
status_code=400, detail="Время запуска должно быть в будущем"
)
target_type = _validate_target(target_id, is_group)
action_params = _build_action_params(state, brightness, scene, temp, r, g, b)
target_type = _validate_target(payload.target_id, payload.is_group)
action_params = payload.to_wiz_params()
try:
task = await create_schedule_task(
trigger_type="once",
target_id=target_id,
target_id=payload.target_id,
target_type=target_type,
trigger_args={"run_at": exec_time.isoformat()},
action_params=action_params,
@@ -167,33 +128,24 @@ async def schedule_once(
}
@router.post("/cron")
async def add_cron_task(
target_id: str,
hour: str,
minute: str,
day_of_week: str = "*",
is_group: bool = True,
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,
):
target_type = _validate_target(target_id, is_group)
action_params = _build_action_params(state, brightness, scene, temp, r, g, b)
@router.post(
"/cron",
response_model=ScheduleCreateResponse,
response_model_exclude_none=True,
)
async def add_cron_task(payload: ScheduleCronRequest):
target_type = _validate_target(payload.target_id, payload.is_group)
action_params = payload.to_wiz_params()
trigger_args = {
"hour": hour,
"minute": minute,
"day_of_week": day_of_week,
"hour": payload.hour,
"minute": payload.minute,
"day_of_week": payload.day_of_week,
}
try:
task = await create_schedule_task(
trigger_type="cron",
target_id=target_id,
target_id=payload.target_id,
target_type=target_type,
trigger_args=trigger_args,
action_params=action_params,
@@ -204,12 +156,12 @@ async def add_cron_task(
return {"status": "cron_scheduled", "job_id": task.job_id}
@router.get("/tasks")
@router.get("/tasks", response_model=ScheduleTasksResponse)
async def get_all_tasks():
return {"tasks": await list_schedule_tasks()}
@router.delete("/{job_id}")
@router.delete("/{job_id}", response_model=DeleteStatusResponse)
async def cancel_task(job_id: str):
if is_internal_job_id(job_id):
raise HTTPException(status_code=403, detail="Нельзя удалить служебную задачу")

170
app/api/schemas.py Normal file
View File

@@ -0,0 +1,170 @@
from datetime import datetime
from typing import Any, Literal
from fastapi import HTTPException
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.drivers.wiz import WizDriver
class CommandRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
state: bool | None = None
brightness: int | None = Field(default=None, ge=10, le=100)
scene: str | None = None
temp: int | None = Field(default=None, ge=2200, le=6500)
r: int | None = Field(default=None, ge=0, le=255)
g: int | None = Field(default=None, ge=0, le=255)
b: int | None = Field(default=None, ge=0, le=255)
@property
def has_rgb(self) -> bool:
return all(channel is not None for channel in (self.r, self.g, self.b))
@model_validator(mode="after")
def validate_payload(self):
if all(
value is None
for value in (
self.state,
self.brightness,
self.scene,
self.temp,
self.r,
self.g,
self.b,
)
):
raise ValueError("Никаких команд не передано")
rgb_values = (self.r, self.g, self.b)
if any(value is not None for value in rgb_values) and not self.has_rgb:
raise ValueError("Поля r, g и b нужно передавать вместе")
exclusive_modes = (
self.scene is not None,
self.temp is not None,
self.has_rgb,
)
if sum(1 for enabled in exclusive_modes if enabled) > 1:
raise ValueError("Можно передать только один режим из scene, temp или rgb")
return self
def to_wiz_params(self) -> dict[str, Any]:
params: dict[str, Any] = {}
if self.state is not None:
params["state"] = self.state
if self.brightness is not None:
params["dimming"] = self.brightness
if self.scene is not None:
if self.scene not in WizDriver.SCENES:
raise HTTPException(status_code=400, detail="Неизвестная сцена")
params["sceneId"] = WizDriver.SCENES[self.scene]
elif self.temp is not None:
params["temp"] = self.temp
elif self.has_rgb:
params["r"] = self.r
params["g"] = self.g
params["b"] = self.b
return params
class ScheduleOnceRequest(CommandRequest):
target_id: str = Field(min_length=1)
run_at: datetime | None = None
hours_from_now: int | None = Field(default=None, ge=0)
is_group: bool = True
@model_validator(mode="after")
def validate_schedule_target(self):
has_run_at = self.run_at is not None
has_hours_from_now = self.hours_from_now is not None
if has_run_at == has_hours_from_now:
raise ValueError("Передайте ровно одно из полей run_at или hours_from_now")
return self
class ScheduleCronRequest(CommandRequest):
target_id: str = Field(min_length=1)
hour: str = Field(min_length=1)
minute: str = Field(min_length=1)
day_of_week: str = "*"
is_group: bool = True
class DeviceControlResponse(BaseModel):
device_id: str
applied: dict[str, Any]
result: dict[str, Any] | None = None
status: Literal["ok"]
class GroupCommandResult(BaseModel):
ip: str
ok: bool
kind: str
result: dict[str, Any] | None = None
error: str | None = None
class GroupControlResponse(BaseModel):
status: Literal["ok", "partial"]
applied: dict[str, Any]
sent_to: list[str]
success_count: int
failure_count: int
results: list[GroupCommandResult]
class BlinkResponse(BaseModel):
status: Literal["blink_done"]
original: bool
class DeviceStatusResponse(BaseModel):
device_id: str
status: dict[str, Any]
class GroupStatusItem(BaseModel):
ip: str
status: dict[str, Any] | None = None
error: str | None = None
kind: str | None = None
class GroupStatusResponse(BaseModel):
group_id: str
results: list[GroupStatusItem]
class ScheduleCreateResponse(BaseModel):
status: str
job_id: str
run_at: str | None = None
class ScheduleTaskItem(BaseModel):
id: str
target_id: str
is_group: bool
state: bool | None = None
action_params: dict[str, Any]
trigger_type: str
next_run: str | None = None
hour: str | None = None
minute: str | None = None
day_of_week: str | None = None
job_present: bool
class ScheduleTasksResponse(BaseModel):
tasks: list[ScheduleTaskItem]
class DeleteStatusResponse(BaseModel):
status: Literal["deleted"]