Fix API regressions and refresh project docs

This commit is contained in:
Artem Kokos
2026-05-15 23:12:28 +07:00
parent 654f64bb90
commit 13fba2fa44
19 changed files with 3258 additions and 964 deletions

View File

@@ -1,28 +1,75 @@
import logging
from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger
from typing import Any, Optional
from app.core.scheduler import app_tz, scheduler
from fastapi import APIRouter, Depends, HTTPException
from app.api.deps import require_admin
from app.core.scheduler import (
app_tz,
create_schedule_task,
delete_schedule_task,
is_internal_job_id,
list_schedule_tasks,
)
from app.core.state import state_manager
from app.drivers.wiz import WizDriver
from app.api.deps import require_admin
logger = logging.getLogger(__name__)
router = APIRouter(dependencies=[Depends(require_admin)])
# Префиксы служебных задач -- не показываем на фронте
_INTERNAL_JOB_PREFIXES = ("cleanup_",)
def _validate_target(target_id: str, is_group: bool):
if is_group:
if target_id not in state_manager.groups:
raise HTTPException(status_code=404, detail="Группа не найдена")
return "group"
if target_id not in state_manager.devices:
raise HTTPException(status_code=404, detail="Устройство не найдено")
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):
"""
Универсальное выполнение команды по расписанию.
IP резолвится в момент выполнения, а не создания задачи --
корректно работает при смене IP (DHCP) и изменении состава группы.
Сигнатура специально сохранена совместимой со старым persisted jobstore,
чтобы legacy APScheduler jobs можно было безопасно мигрировать.
"""
if is_group:
ips = state_manager.get_group_ips(target_id)
@@ -35,61 +82,89 @@ async def run_group_command(target_id: str, is_group: bool, params: dict):
return
local_wiz = WizDriver()
success_count = 0
failure_count = 0
for ip in ips:
try:
await local_wiz.set_pilot(ip, params)
logger.info(f"Расписание: {target_id} -> {ip}: {params}")
result = await local_wiz.set_pilot(ip, params)
if result.ok:
success_count += 1
logger.info(f"Расписание: {target_id} -> {ip}: {params}")
else:
failure_count += 1
logger.error(
f"Расписание: ошибка {ip}: {result.message or result.kind}"
)
except Exception as e:
failure_count += 1
logger.error(f"Расписание: ошибка {ip}: {e}")
# Логируем toggle в event_log
# Импорт здесь, чтобы избежать циклической зависимости
from app.api.routes.control import log_toggle_by_name
from app.api.routes.control import log_command_result_by_name
target_type = "group" if is_group else "device"
await log_toggle_by_name("scheduler", target_type, target_id, params)
await log_command_result_by_name(
"scheduler",
target_type,
target_id,
params,
success_count=success_count,
failure_count=failure_count,
target_count=len(ips),
)
@router.post("/once")
async def schedule_once(
target_id: str,
state: bool,
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,
):
# 1. Определяем время запуска в правильной таймзоне
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:
exec_time = run_at
exec_time = run_at.astimezone(app_tz)
else:
raise HTTPException(status_code=400, detail="Нужно время или отступ в часах")
# 2. Проверяем что цель существует (но IP резолвится при выполнении)
if is_group:
if target_id not in state_manager.groups:
raise HTTPException(status_code=404, detail="Группа не найдена")
else:
if target_id not in state_manager.devices:
raise HTTPException(status_code=404, detail="Устройство не найдено")
if exec_time <= datetime.now(app_tz):
raise HTTPException(
status_code=400, detail="Время запуска должно быть в будущем"
)
# 3. Регаем задачу
job_id = f"once_{target_id}_{int(exec_time.timestamp())}"
target_type = _validate_target(target_id, is_group)
action_params = _build_action_params(state, brightness, scene, temp, r, g, b)
scheduler.add_job(
run_group_command,
trigger=DateTrigger(run_date=exec_time, timezone=app_tz),
args=[target_id, is_group, {"state": state}],
id=job_id,
name=f"Once: {target_id} | {state}",
replace_existing=True,
)
try:
task = await create_schedule_task(
trigger_type="once",
target_id=target_id,
target_type=target_type,
trigger_args={"run_at": exec_time.isoformat()},
action_params=action_params,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {"status": "scheduled", "run_at": exec_time.isoformat()}
return {
"status": "scheduled",
"job_id": task.job_id,
"run_at": exec_time.isoformat(),
}
@router.post("/cron")
@@ -99,84 +174,50 @@ async def add_cron_task(
minute: str,
day_of_week: str = "*",
is_group: bool = True,
state: 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,
):
# Проверяем что цель существует
if is_group:
if target_id not in state_manager.groups:
raise HTTPException(status_code=404, detail="Группа не найдена")
else:
if target_id not in state_manager.devices:
raise HTTPException(status_code=404, detail="Устройство не найдено")
target_type = _validate_target(target_id, is_group)
action_params = _build_action_params(state, brightness, scene, temp, r, g, b)
# Одна задача на всю группу -- IP резолвятся при каждом срабатывании
trigger = CronTrigger(
hour=hour, minute=minute, day_of_week=day_of_week, timezone=app_tz
)
trigger_args = {
"hour": hour,
"minute": minute,
"day_of_week": day_of_week,
}
try:
task = await create_schedule_task(
trigger_type="cron",
target_id=target_id,
target_type=target_type,
trigger_args=trigger_args,
action_params=action_params,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
job_id = f"cron_{target_id}_{hour}_{minute}"
scheduler.add_job(
run_group_command,
trigger,
args=[target_id, is_group, {"state": state}],
id=job_id,
name=f"CRON: {target_id} | {hour}:{minute} | {state}",
replace_existing=True,
)
return {"status": "cron_scheduled", "job_id": job_id}
return {"status": "cron_scheduled", "job_id": task.job_id}
@router.get("/tasks")
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()
is_on = "True" in job.name or "true" in job.name.lower()
h, m = None, None
next_run_str = None
if job.next_run_time:
# Переводим из UTC в локальную таймзону для вывода
local_time = job.next_run_time.astimezone(app_tz)
h = str(local_time.hour).zfill(2)
m = str(local_time.minute).zfill(2)
next_run_str = local_time.isoformat()
# Если это крон, подтягиваем значения из триггера
if hasattr(job.trigger, "fields"):
h = str(job.trigger.fields[5])
m = str(job.trigger.fields[6])
jobs.append(
{
"id": job.id,
"target_id": target,
"state": is_on,
"next_run": next_run_str,
"hour": h,
"minute": m,
}
)
return {"tasks": jobs}
return {"tasks": await list_schedule_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):
if is_internal_job_id(job_id):
raise HTTPException(status_code=403, detail="Нельзя удалить служебную задачу")
try:
scheduler.remove_job(job_id)
await delete_schedule_task(job_id)
return {"status": "deleted"}
except Exception:
except KeyError:
raise HTTPException(status_code=404, detail="Задача не найдена")
except ValueError:
raise HTTPException(status_code=403, detail="Нельзя удалить служебную задачу")