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

@@ -87,7 +87,7 @@ IGNIS_SYNC_DATABASE_URL=sqlite:///./ignis.db
- `GET /stats/log` - `GET /stats/log`
- `GET /auth/me` - `GET /auth/me`
Текущий контракт для `control` и `schedules` использует query-параметры. `control` и `schedules` принимают JSON body.
Поддерживаемые параметры команд: Поддерживаемые параметры команд:
@@ -100,15 +100,28 @@ IGNIS_SYNC_DATABASE_URL=sqlite:///./ignis.db
Примеры: Примеры:
```bash ```bash
curl -X POST 'http://localhost:8000/control/device/dev-1?temp=4200' \ curl -X POST 'http://localhost:8000/control/device/dev-1' \
-H 'X-API-Key: change-me' -H 'X-API-Key: change-me' \
-H 'Content-Type: application/json' \
-d '{"temp": 4200}'
``` ```
```bash ```bash
curl -X POST 'http://localhost:8000/schedules/once?target_id=bedroom&hours_from_now=2&is_group=true&temp=3200' \ curl -X POST 'http://localhost:8000/schedules/once' \
-H 'X-API-Key: change-me' -H 'X-API-Key: change-me' \
-H 'Content-Type: application/json' \
-d '{"target_id":"bedroom","hours_from_now":2,"is_group":true,"temp":3200}'
``` ```
Валидация:
- `brightness`: `10..100`
- `temp`: `2200..6500`
- `r/g/b`: `0..255`
- `scene`, `temp` и `rgb` взаимоисключаемы
- `r`, `g`, `b` нужно передавать только полной тройкой
- для `schedules/once` нужно передать ровно одно из `run_at` или `hours_from_now`
## API keys ## API keys
- список ключей возвращает публичный `key` / `key_id` - список ключей возвращает публичный `key` / `key_id`
@@ -163,5 +176,4 @@ timeout 120s .venv/bin/python -m unittest discover -s tests -v
- discovery всё ещё основан на переборе IP по подсетям - discovery всё ещё основан на переборе IP по подсетям
- UI остаётся монолитным файлом - UI остаётся монолитным файлом
- `control` и `schedules` ещё не переведены на JSON body
- stats пока простые и не заменяют нормальную аналитику - stats пока простые и не заменяют нормальную аналитику

View File

@@ -1,11 +1,19 @@
import asyncio import asyncio
import json import json
import logging import logging
from typing import Any, Optional from typing import Any
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from app.api.deps import verify_token, AuthContext 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.database import async_session
from app.core.state import state_manager from app.core.state import state_manager
from app.drivers.wiz import WizDriver, WizResponse from app.drivers.wiz import WizDriver, WizResponse
@@ -36,33 +44,6 @@ async def _log_event(
logger.error(f"Ошибка записи в лог: {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: def _resolve_action_name(params: dict[str, Any]) -> str:
if "state" in params: if "state" in params:
return "toggle_on" if params["state"] else "toggle_off" 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 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( async def control_device(
device_id: str, device_id: str,
payload: CommandRequest,
auth: AuthContext = Depends(verify_token), 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) device = state_manager.devices.get(device_id)
if not device: if not device:
raise HTTPException(status_code=404, detail="Лампа не в сети") raise HTTPException(status_code=404, detail="Лампа не в сети")
params = _build_command_params(state, brightness, scene, temp, r, g, b) params = payload.to_wiz_params()
if not params:
raise HTTPException(status_code=400, detail="Никаких команд не передано")
result = await wiz.set_pilot(device.ip, params) result = await wiz.set_pilot(device.ip, params)
if not result.ok: if not result.ok:
await log_command_result( 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( async def control_group(
group_id: str, group_id: str,
payload: CommandRequest,
auth: AuthContext = Depends(verify_token), 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) ips = state_manager.get_group_ips(group_id)
if not ips: if not ips:
raise HTTPException(status_code=404, detail="Группа не найдена или оффлайн") raise HTTPException(status_code=404, detail="Группа не найдена или оффлайн")
params = _build_command_params(state, brightness, scene, temp, r, g, b) params = payload.to_wiz_params()
if not params:
raise HTTPException(status_code=400, detail="Никаких команд не передано")
tasks = [wiz.set_pilot(ip, params) for ip in ips] tasks = [wiz.set_pilot(ip, params) for ip in ips]
results = await asyncio.gather(*tasks) results = await asyncio.gather(*tasks)
success_count, failure_count = _summarize_group_results(results) 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)): async def blink_device(device_id: str, _auth: AuthContext = Depends(verify_token)):
device = state_manager.devices.get(device_id) device = state_manager.devices.get(device_id)
if not device: 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} 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)): async def get_device_status(device_id: str, _auth: AuthContext = Depends(verify_token)):
"""Опрос реального состояния конкретной лампы.""" """Опрос реального состояния конкретной лампы."""
device = state_manager.devices.get(device_id) 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} 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)): async def get_group_status(group_id: str, _auth: AuthContext = Depends(verify_token)):
"""Опрос состояния всей группы (возвращает список статусов).""" """Опрос состояния всей группы (возвращает список статусов)."""
ips = state_manager.get_group_ips(group_id) ips = state_manager.get_group_ips(group_id)

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -386,9 +386,9 @@
}, },
getGroupName(tid) { const g = this.groups[tid]; return g ? g.name : null; }, getGroupName(tid) { const g = this.groups[tid]; return g ? g.name : null; },
async request(path, method = 'GET', params = null, body = null) { async request(path, { method = 'GET', query = null, body = null } = {}) {
let url = path; let url = path;
if (params) url += `?${new URLSearchParams(params).toString()}`; if (query) url += `?${new URLSearchParams(query).toString()}`;
try { try {
const r = await fetch(url, { method, headers: { 'X-API-Key': this.apiKey, 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : null }); const r = await fetch(url, { method, headers: { 'X-API-Key': this.apiKey, 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : null });
if (r.status === 403) { if (r.status === 403) {
@@ -432,7 +432,7 @@
} finally { this.isFetching = false; } } finally { this.isFetching = false; }
}, },
async control(id, params) { await this.request(`/control/group/${id}`, 'POST', params); }, async control(id, params) { await this.request(`/control/group/${id}`, { method: 'POST', body: params }); },
toggleGroup(id, state) { if (this.sliders[id]) this.sliders[id].state = state; this.control(id, { state }); }, toggleGroup(id, state) { if (this.sliders[id]) this.sliders[id].state = state; this.control(id, { state }); },
setBrightness(id, val) { if (this.sliders[id]) this.sliders[id].brightness = val; this.control(id, { brightness: val }); }, setBrightness(id, val) { if (this.sliders[id]) this.sliders[id].brightness = val; this.control(id, { brightness: val }); },
setTemp(id, val) { if (this.sliders[id]) this.sliders[id].temp = val; this.control(id, { temp: val }); }, setTemp(id, val) { if (this.sliders[id]) this.sliders[id].temp = val; this.control(id, { temp: val }); },
@@ -440,18 +440,18 @@
setColor(id, hex) { this.control(id, { r: parseInt(hex.slice(1,3),16), g: parseInt(hex.slice(3,5),16), b: parseInt(hex.slice(5,7),16) }); }, setColor(id, hex) { this.control(id, { r: parseInt(hex.slice(1,3),16), g: parseInt(hex.slice(3,5),16), b: parseInt(hex.slice(5,7),16) }); },
async createGroup() { async createGroup() {
const res = await this.request('/devices/groups', 'POST', null, { id: this.newGroup.id, name: this.newGroup.name, macs: this.newGroup.macs }); const res = await this.request('/devices/groups', { method: 'POST', body: { id: this.newGroup.id, name: this.newGroup.name, macs: this.newGroup.macs } });
if (res) { this.toast(`Группа "${this.newGroup.name}" создана`, 'success'); this.newGroup = { id: '', name: '', macs: [] }; await this.fetchData(); this.tab = 'control'; } if (res) { this.toast(`Группа "${this.newGroup.name}" создана`, 'success'); this.newGroup = { id: '', name: '', macs: [] }; await this.fetchData(); this.tab = 'control'; }
}, },
async deleteGroup(id) { async deleteGroup(id) {
const name = this.groups[id]?.name || id; const name = this.groups[id]?.name || id;
if (confirm(`Удалить группу "${name}"?`)) { await this.request(`/devices/groups/${id}`, 'DELETE'); this.toast(`Удалена`, 'success'); await this.fetchData(); } if (confirm(`Удалить группу "${name}"?`)) { await this.request(`/devices/groups/${id}`, { method: 'DELETE' }); this.toast(`Удалена`, 'success'); await this.fetchData(); }
}, },
async rescan() { async rescan() {
this.isRescanning = true; await this.request('/devices/rescan', 'POST'); this.toast('Сканирование...', 'info'); this.isRescanning = true; await this.request('/devices/rescan', { method: 'POST' }); this.toast('Сканирование...', 'info');
setTimeout(async () => { await this.fetchData(); this.isRescanning = false; this.toast(`Найдено ${this.devices.length} устройств`, 'success'); }, 3000); setTimeout(async () => { await this.fetchData(); this.isRescanning = false; this.toast(`Найдено ${this.devices.length} устройств`, 'success'); }, 3000);
}, },
async blink(id) { await this.request(`/control/device/${id}/blink`, 'POST'); }, async blink(id) { await this.request(`/control/device/${id}/blink`, { method: 'POST' }); },
async syncGroupStatuses() { async syncGroupStatuses() {
if (this.isLoadingStatus) return; this.isLoadingStatus = true; if (this.isLoadingStatus) return; this.isLoadingStatus = true;
@@ -471,13 +471,13 @@
async fetchTasks() { const d = await this.request('/schedules/tasks'); if (d) this.tasks = d.tasks || []; }, async fetchTasks() { const d = await this.request('/schedules/tasks'); if (d) this.tasks = d.tasks || []; },
async addSchedule() { async addSchedule() {
if (!this.newTask.target_id) { this.toast('Выберите группу', 'error'); return; } if (!this.newTask.target_id) { this.toast('Выберите группу', 'error'); return; }
const res = await this.request('/schedules/cron', 'POST', { target_id: this.newTask.target_id, hour: this.taskHour, minute: this.taskMin, is_group: true, state: this.newTask.state }); const res = await this.request('/schedules/cron', { method: 'POST', body: { target_id: this.newTask.target_id, hour: this.taskHour, minute: this.taskMin, is_group: true, state: this.newTask.state } });
if (res) { this.toast(`${this.taskHour}:${this.taskMin} добавлено`, 'success'); this.fetchTasks(); } if (res) { this.toast(`${this.taskHour}:${this.taskMin} добавлено`, 'success'); this.fetchTasks(); }
}, },
async deleteTask(id) { await this.request(`/schedules/${id}`, 'DELETE'); this.toast('Отменено', 'success'); this.fetchTasks(); }, async deleteTask(id) { await this.request(`/schedules/${id}`, { method: 'DELETE' }); this.toast('Отменено', 'success'); this.fetchTasks(); },
async setTimer4h(id) { async setTimer4h(id) {
await this.toggleGroup(id, true); await this.toggleGroup(id, true);
const res = await this.request('/schedules/once', 'POST', { target_id: id, hours_from_now: 4, is_group: true, state: false }); const res = await this.request('/schedules/once', { method: 'POST', body: { target_id: id, hours_from_now: 4, is_group: true, state: false } });
if (res) { this.toast('Таймер 4ч', 'success'); this.fetchTasks(); } if (res) { this.toast('Таймер 4ч', 'success'); this.fetchTasks(); }
}, },
@@ -489,7 +489,7 @@
async createApiKey() { async createApiKey() {
const name = this.newKeyName.trim(); const name = this.newKeyName.trim();
if (!name) return; if (!name) return;
const res = await this.request('/api-keys', 'POST', { name, is_admin: this.newKeyAdmin }); const res = await this.request('/api-keys', { method: 'POST', query: { name, is_admin: this.newKeyAdmin } });
if (res) { if (res) {
this.lastCreatedKey = res.key; this.lastCreatedKey = res.key;
this.newKeyName = ''; this.newKeyName = '';
@@ -500,13 +500,13 @@
}, },
async revokeApiKey(key, name) { async revokeApiKey(key, name) {
if (confirm(`Отозвать ключ "${name}"?`)) { if (confirm(`Отозвать ключ "${name}"?`)) {
await this.request('/api-keys/revoke', 'POST', null, { key }); await this.request('/api-keys/revoke', { method: 'POST', body: { key } });
this.toast(`Ключ "${name}" отозван`, 'success'); this.toast(`Ключ "${name}" отозван`, 'success');
this.fetchApiKeys(); this.fetchApiKeys();
} }
}, },
async activateApiKey(key, name) { async activateApiKey(key, name) {
await this.request('/api-keys/activate', 'POST', null, { key }); await this.request('/api-keys/activate', { method: 'POST', body: { key } });
this.toast(`Ключ "${name}" активирован`, 'success'); this.toast(`Ключ "${name}" активирован`, 'success');
this.fetchApiKeys(); this.fetchApiKeys();
}, },
@@ -516,12 +516,12 @@
// ─── Статистика ────────────────────────────── // ─── Статистика ──────────────────────────────
async fetchStats() { async fetchStats() {
const data = await this.request(`/stats/summary`, 'GET', { days: this.statsDays }); const data = await this.request(`/stats/summary`, { query: { days: this.statsDays } });
if (data) this.statsData = data.groups || []; if (data) this.statsData = data.groups || [];
await this.fetchEventLog(); await this.fetchEventLog();
}, },
async fetchEventLog() { async fetchEventLog() {
const data = await this.request('/stats/log', 'GET', { limit: 100 }); const data = await this.request('/stats/log', { query: { limit: 100 } });
if (data) this.eventLog = data; if (data) this.eventLog = data;
}, },
formatTime(iso) { formatTime(iso) {

View File

@@ -77,25 +77,25 @@ class ScheduleApiTests(unittest.IsolatedAsyncioTestCase):
first = await self.client.post( first = await self.client.post(
"/schedules/cron", "/schedules/cron",
headers=self._headers(), headers=self._headers(),
params={ json={
"target_id": "grp-1", "target_id": "grp-1",
"hour": "22", "hour": "22",
"minute": "00", "minute": "00",
"day_of_week": "1", "day_of_week": "1",
"is_group": "true", "is_group": True,
"state": "true", "state": True,
}, },
) )
second = await self.client.post( second = await self.client.post(
"/schedules/cron", "/schedules/cron",
headers=self._headers(), headers=self._headers(),
params={ json={
"target_id": "grp-1", "target_id": "grp-1",
"hour": "22", "hour": "22",
"minute": "00", "minute": "00",
"day_of_week": "5", "day_of_week": "5",
"is_group": "true", "is_group": True,
"state": "false", "state": False,
}, },
) )
@@ -126,11 +126,11 @@ class ScheduleApiTests(unittest.IsolatedAsyncioTestCase):
response = await self.client.post( response = await self.client.post(
"/schedules/once", "/schedules/once",
headers=self._headers(), headers=self._headers(),
params={ json={
"target_id": "grp-1", "target_id": "grp-1",
"run_at": run_at.isoformat(), "run_at": run_at.isoformat(),
"is_group": "true", "is_group": True,
"temp": "3200", "temp": 3200,
}, },
) )
@@ -162,12 +162,12 @@ class ScheduleApiTests(unittest.IsolatedAsyncioTestCase):
create_response = await self.client.post( create_response = await self.client.post(
"/schedules/cron", "/schedules/cron",
headers=self._headers(), headers=self._headers(),
params={ json={
"target_id": "grp-1", "target_id": "grp-1",
"hour": "21", "hour": "21",
"minute": "15", "minute": "15",
"is_group": "true", "is_group": True,
"state": "true", "state": True,
}, },
) )
job_id = create_response.json()["job_id"] job_id = create_response.json()["job_id"]
@@ -220,12 +220,12 @@ class ScheduleApiTests(unittest.IsolatedAsyncioTestCase):
response = await self.client.post( response = await self.client.post(
"/schedules/cron", "/schedules/cron",
headers=self._headers(), headers=self._headers(),
params={ json={
"target_id": "grp-1", "target_id": "grp-1",
"hour": "99", "hour": "99",
"minute": "99", "minute": "99",
"is_group": "true", "is_group": True,
"temp": "3200", "temp": 3200,
}, },
) )
@@ -237,3 +237,38 @@ class ScheduleApiTests(unittest.IsolatedAsyncioTestCase):
rows = result.scalars().all() rows = result.scalars().all()
self.assertEqual(rows, []) self.assertEqual(rows, [])
async def test_once_schedule_requires_exactly_one_time_selector(self):
run_at = datetime.now(app_tz) + timedelta(hours=2)
response = await self.client.post(
"/schedules/once",
headers=self._headers(),
json={
"target_id": "grp-1",
"run_at": run_at.isoformat(),
"hours_from_now": 2,
"is_group": True,
"state": False,
},
)
self.assertEqual(response.status_code, 422)
self.assertIn(
"Передайте ровно одно из полей run_at или hours_from_now",
str(response.json()),
)
async def test_schedule_rejects_legacy_query_only_contract(self):
response = await self.client.post(
"/schedules/cron",
headers=self._headers(),
params={
"target_id": "grp-1",
"hour": "7",
"minute": "30",
"is_group": "true",
"state": "true",
},
)
self.assertEqual(response.status_code, 422)

View File

@@ -229,7 +229,7 @@ class SecurityAndControlApiTests(unittest.IsolatedAsyncioTestCase):
response = await self.client.post( response = await self.client.post(
"/control/device/dev-1", "/control/device/dev-1",
headers=self._master_headers(), headers=self._master_headers(),
params={"state": "true"}, json={"state": True},
) )
self.assertEqual(response.status_code, 504) self.assertEqual(response.status_code, 504)
@@ -263,7 +263,7 @@ class SecurityAndControlApiTests(unittest.IsolatedAsyncioTestCase):
response = await self.client.post( response = await self.client.post(
"/control/group/grp-1", "/control/group/grp-1",
headers=self._master_headers(), headers=self._master_headers(),
params={"state": "true"}, json={"state": True},
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@@ -299,7 +299,7 @@ class SecurityAndControlApiTests(unittest.IsolatedAsyncioTestCase):
response = await self.client.post( response = await self.client.post(
"/control/group/grp-1", "/control/group/grp-1",
headers=self._master_headers(), headers=self._master_headers(),
params={"state": "true"}, json={"state": True},
) )
self.assertEqual(response.status_code, 504) self.assertEqual(response.status_code, 504)
@@ -368,13 +368,51 @@ class SecurityAndControlApiTests(unittest.IsolatedAsyncioTestCase):
response = await self.client.post( response = await self.client.post(
"/control/device/dev-1", "/control/device/dev-1",
headers=self._master_headers(), headers=self._master_headers(),
params={"scene": "not_a_scene"}, json={"scene": "not_a_scene"},
) )
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["detail"], "Неизвестная сцена") self.assertEqual(response.json()["detail"], "Неизвестная сцена")
self.assertEqual(await self._event_actions(), []) self.assertEqual(await self._event_actions(), [])
async def test_device_control_rejects_conflicting_scene_and_temp_in_body(self):
self._set_single_device_state()
response = await self.client.post(
"/control/device/dev-1",
headers=self._master_headers(),
json={"scene": "party", "temp": 3200},
)
self.assertEqual(response.status_code, 422)
self.assertIn(
"Можно передать только один режим из scene, temp или rgb",
str(response.json()),
)
async def test_device_control_rejects_partial_rgb_triplet(self):
self._set_single_device_state()
response = await self.client.post(
"/control/device/dev-1",
headers=self._master_headers(),
json={"r": 255, "g": 128},
)
self.assertEqual(response.status_code, 422)
self.assertIn("Поля r, g и b нужно передавать вместе", str(response.json()))
async def test_device_control_rejects_legacy_query_only_contract(self):
self._set_single_device_state()
response = await self.client.post(
"/control/device/dev-1",
headers=self._master_headers(),
params={"state": "true"},
)
self.assertEqual(response.status_code, 422)
async def test_stats_summary_counts_real_commands_without_requested_duplicates(self): async def test_stats_summary_counts_real_commands_without_requested_duplicates(self):
self._set_single_device_state() self._set_single_device_state()
@@ -393,7 +431,7 @@ class SecurityAndControlApiTests(unittest.IsolatedAsyncioTestCase):
response = await self.client.post( response = await self.client.post(
"/control/device/dev-1", "/control/device/dev-1",
headers=self._master_headers(), headers=self._master_headers(),
params={"temp": "4200"}, json={"temp": 4200},
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)