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

@@ -2,10 +2,9 @@ import os
import hmac
import logging
from dataclasses import dataclass
from typing import Optional
from fastapi import Depends, HTTPException
from fastapi.security import APIKeyHeader
from starlette.status import HTTP_403_FORBIDDEN
from starlette.status import HTTP_403_FORBIDDEN, HTTP_503_SERVICE_UNAVAILABLE
from dotenv import load_dotenv
from sqlalchemy import select
@@ -16,10 +15,6 @@ load_dotenv()
logger = logging.getLogger(__name__)
MASTER_KEY = os.getenv("IGNIS_API_KEY")
if not MASTER_KEY:
logger.warning("IGNIS_API_KEY не задан -- авторизация отключена!")
API_KEY_NAME = "X-API-Key"
api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False)
@@ -33,17 +28,26 @@ class AuthContext:
key_name: str # имя ключа (для логов)
def get_master_key() -> str | None:
value = os.getenv("IGNIS_API_KEY", "").strip()
return value or None
async def verify_token(header_value: str = Depends(api_key_header)) -> AuthContext:
"""
Проверка API-ключа:
1. Если IGNIS_API_KEY не задан -- авторизация отключена, полный доступ
1. IGNIS_API_KEY должен быть задан, иначе сервер закрыт (fail-closed)
2. Мастер-ключ из .env -- полный доступ
3. Ключ из БД (api_keys) -- проверяем active и is_admin
4. Иначе -- 403
"""
# Авторизация отключена
if not MASTER_KEY:
return AuthContext(is_master=True, is_admin=True, key_name="no-auth")
master_key = get_master_key()
if not master_key:
logger.error("IGNIS_API_KEY не задан: защищённые API закрыты до настройки")
raise HTTPException(
status_code=HTTP_503_SERVICE_UNAVAILABLE,
detail="Сервер не настроен: задайте IGNIS_API_KEY",
)
if not header_value:
raise HTTPException(
@@ -51,7 +55,7 @@ async def verify_token(header_value: str = Depends(api_key_header)) -> AuthConte
)
# Мастер-ключ (timing-safe сравнение)
if hmac.compare_digest(header_value, MASTER_KEY):
if hmac.compare_digest(header_value, master_key):
return AuthContext(is_master=True, is_admin=True, key_name="master")
# Ищем в БД
@@ -78,3 +82,13 @@ def require_admin(auth: AuthContext = Depends(verify_token)) -> AuthContext:
if not auth.is_admin:
raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Недостаточно прав")
return auth
def require_master(auth: AuthContext = Depends(verify_token)) -> AuthContext:
"""Dependency для роутов, доступных только мастер-ключу."""
if not auth.is_master:
raise HTTPException(
status_code=HTTP_403_FORBIDDEN,
detail="Требуется мастер-ключ",
)
return auth

View File

@@ -4,10 +4,10 @@ from sqlalchemy import select
from app.core.database import async_session
from app.models.api_key import ApiKeyModel
from app.api.deps import require_admin, AuthContext
from app.api.deps import require_master
# Все операции с ключами -- только для админов (мастер-ключ)
router = APIRouter(dependencies=[Depends(require_admin)])
# Все операции с ключами доступны только мастер-ключу.
router = APIRouter(dependencies=[Depends(require_master)])
class KeyActionRequest(BaseModel):
@@ -16,16 +16,41 @@ class KeyActionRequest(BaseModel):
key: str
async def _find_key_by_secret_or_public_id(
session, key_or_id: str
) -> ApiKeyModel | None:
result = await session.execute(
select(ApiKeyModel).where(ApiKeyModel.key == key_or_id)
)
api_key = result.scalar_one_or_none()
if api_key:
return api_key
result = await session.execute(select(ApiKeyModel))
for candidate in result.scalars().all():
if candidate.public_id == key_or_id:
return candidate
return None
@router.get("")
async def list_keys():
"""Список всех гостевых ключей."""
"""
Список всех гостевых ключей.
В ответе поле `key` содержит публичный идентификатор, а не сам секрет.
Это сохраняет совместимость с текущим UI и не раскрывает токены повторно.
"""
async with async_session() as session:
result = await session.execute(select(ApiKeyModel))
keys = result.scalars().all()
return [
{
"key": k.key,
"key": k.public_id,
"key_id": k.public_id,
"display_key": k.preview,
"name": k.name,
"is_admin": k.is_admin,
"active": k.active,
@@ -50,6 +75,8 @@ async def create_key(name: str, is_admin: bool = False):
return {
"key": new_key.key,
"key_id": new_key.public_id,
"display_key": new_key.preview,
"name": new_key.name,
"is_admin": new_key.is_admin,
"message": "Сохраните ключ -- он больше не будет показан полностью",
@@ -60,10 +87,7 @@ async def create_key(name: str, is_admin: bool = False):
async def revoke_key(body: KeyActionRequest):
"""Деактивировать (отозвать) гостевой ключ. Ключ передаётся в body, не в URL."""
async with async_session() as session:
result = await session.execute(
select(ApiKeyModel).where(ApiKeyModel.key == body.key)
)
api_key = result.scalar_one_or_none()
api_key = await _find_key_by_secret_or_public_id(session, body.key)
if not api_key:
raise HTTPException(status_code=404, detail="Ключ не найден")
@@ -71,17 +95,14 @@ async def revoke_key(body: KeyActionRequest):
session.add(api_key)
await session.commit()
return {"status": "revoked", "name": api_key.name}
return {"status": "revoked", "name": api_key.name, "key_id": api_key.public_id}
@router.post("/activate")
async def activate_key(body: KeyActionRequest):
"""Повторно активировать ключ. Ключ передаётся в body, не в URL."""
async with async_session() as session:
result = await session.execute(
select(ApiKeyModel).where(ApiKeyModel.key == body.key)
)
api_key = result.scalar_one_or_none()
api_key = await _find_key_by_secret_or_public_id(session, body.key)
if not api_key:
raise HTTPException(status_code=404, detail="Ключ не найден")
@@ -89,4 +110,4 @@ async def activate_key(body: KeyActionRequest):
session.add(api_key)
await session.commit()
return {"status": "activated", "name": api_key.name}
return {"status": "activated", "name": api_key.name, "key_id": api_key.public_id}

View File

@@ -1,12 +1,14 @@
import asyncio
import json
import logging
from typing import Optional
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException
from app.core.state import state_manager
from app.core.database import async_session
from app.drivers.wiz import WizDriver
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__)
@@ -34,21 +36,171 @@ async def _log_event(
logger.error(f"Ошибка записи в лог: {e}")
async def log_toggle(auth: AuthContext, target_type: str, target_id: str, params: dict):
"""Логирует toggle_on/toggle_off если в params есть state."""
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:
action = "toggle_on" if params["state"] else "toggle_off"
await _log_event(auth, action, target_type, target_id, 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"
async def log_toggle_by_name(
key_name: str, target_type: str, target_id: str, params: dict
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,
):
"""Логирует toggle из контекста без AuthContext (для планировщика)."""
if "state" in params:
auth = AuthContext(is_master=False, is_admin=False, key_name=key_name)
action = "toggle_on" if params["state"] else "toggle_off"
await _log_event(auth, action, target_type, target_id, params)
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}")
@@ -67,27 +219,42 @@ async def control_device(
if not device:
raise HTTPException(status_code=404, detail="Лампа не в сети")
params = {}
if state is not None:
params["state"] = state
if brightness is not None:
params["dimming"] = brightness
if scene and scene in wiz.SCENES:
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
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_toggle(auth, "device", device_id, params)
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}
return {
"device_id": device_id,
"applied": params,
"result": result.payload,
"status": "ok",
}
@router.post("/group/{group_id}")
@@ -106,25 +273,44 @@ async def control_group(
if not ips:
raise HTTPException(status_code=404, detail="Группа не найдена или оффлайн")
params = {}
if state is not None:
params["state"] = state
if brightness is not None:
params["dimming"] = brightness
if scene and scene in wiz.SCENES:
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
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]
await asyncio.gather(*tasks, return_exceptions=True)
results = await asyncio.gather(*tasks)
success_count, failure_count = _summarize_group_results(results)
await log_toggle(auth, "group", group_id, params)
await log_command_result(
auth,
"group",
group_id,
params,
success_count=success_count,
failure_count=failure_count,
target_count=len(results),
)
return {"status": "ok", "applied": params, "sent_to": ips}
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")
@@ -133,16 +319,36 @@ async def blink_device(device_id: str, _auth: AuthContext = Depends(verify_token
if not device:
raise HTTPException(status_code=404, detail="Лампа оффлайн")
try:
current = await wiz.get_pilot(device.ip)
original_state = current.get("result", {}).get("state", False)
await wiz.set_pilot(device.ip, {"state": not original_state})
await asyncio.sleep(0.5)
await wiz.set_pilot(device.ip, {"state": original_state})
return {"status": "blink_done", "original": original_state}
except Exception as e:
logger.error(f"Blink error: {e}")
raise HTTPException(status_code=500, 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")
@@ -152,11 +358,14 @@ async def get_device_status(device_id: str, _auth: AuthContext = Depends(verify_
if not device:
raise HTTPException(status_code=404, detail="Лампа оффлайн или не найдена")
try:
status = await wiz.get_pilot(device.ip)
return {"device_id": device_id, "status": status.get("result", {})}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка опроса лампы: {e}")
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")
@@ -166,14 +375,20 @@ async def get_group_status(group_id: str, _auth: AuthContext = Depends(verify_to
if not ips:
raise HTTPException(status_code=404, detail="Группа не найдена или оффлайн")
tasks = [wiz.get_pilot(ip) for ip in ips]
results = await asyncio.gather(*tasks, return_exceptions=True)
results = await asyncio.gather(*[wiz.get_pilot(ip) for ip in ips])
status_report = []
for ip, res in zip(ips, results):
if isinstance(res, Exception):
status_report.append({"ip": ip, "error": str(res)})
else:
status_report.append({"ip": ip, "status": res.get("result", {})})
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}

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="Нельзя удалить служебную задачу")

View File

@@ -8,6 +8,10 @@ from app.api.deps import require_admin
router = APIRouter(dependencies=[Depends(require_admin)])
def _is_summary_command_event(action: str) -> bool:
return not action.endswith("_requested")
@router.get("/summary")
async def get_summary(days: int = Query(default=7, ge=1, le=365)):
"""
@@ -33,6 +37,9 @@ async def get_summary(days: int = Query(default=7, ge=1, le=365)):
last_on = {}
for ev in events:
if not _is_summary_command_event(ev.action):
continue
tid = ev.target_id
if tid not in stats:
stats[tid] = {

View File

@@ -1,9 +1,14 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy import create_engine
import os
DATABASE_URL = "sqlite+aiosqlite:///./ignis.db"
SYNC_DATABASE_URL = "sqlite:///./ignis.db"
from dotenv import load_dotenv
from sqlalchemy import create_engine
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
load_dotenv()
DATABASE_URL = os.getenv("IGNIS_DATABASE_URL", "sqlite+aiosqlite:///./ignis.db")
SYNC_DATABASE_URL = os.getenv("IGNIS_SYNC_DATABASE_URL", "sqlite:///./ignis.db")
engine = create_async_engine(DATABASE_URL, echo=False)
sync_engine = create_engine(SYNC_DATABASE_URL)
@@ -16,6 +21,10 @@ class Base(DeclarativeBase):
async def init_db():
# Импортируем модели здесь, чтобы metadata была полностью зарегистрирована
# до create_all даже в тестовых и утилитных сценариях.
from app.models import api_key, device, event_log, schedule # noqa: F401
async with engine.begin() as conn:
# Создает таблицы, если их еще нет
await conn.run_sync(Base.metadata.create_all)

View File

@@ -1,15 +1,21 @@
import os
import asyncio
import logging
import pytz
import os
from datetime import datetime, timedelta
from dotenv import load_dotenv
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from uuid import uuid4
import pytz
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.schedulers.base import STATE_STOPPED
from apscheduler.triggers.cron import CronTrigger
from sqlalchemy import delete
from app.core.database import sync_engine, async_session
from apscheduler.triggers.date import DateTrigger
from dotenv import load_dotenv
from sqlalchemy import delete, select
from app.core.database import async_session, sync_engine
from app.models.event_log import EventLog
from app.drivers.wiz import WizDriver
from app.models.schedule import ScheduleTask
load_dotenv()
logger = logging.getLogger(__name__)
@@ -18,19 +24,67 @@ TZ_NAME = os.getenv("APP_TIMEZONE", "Asia/Novosibirsk")
app_tz = pytz.timezone(TZ_NAME)
RETENTION_DAYS = int(os.getenv("EVENT_LOG_RETENTION_DAYS", "30"))
INTERNAL_JOB_PREFIXES = ("cleanup_",)
jobstores = {"default": SQLAlchemyJobStore(engine=sync_engine)}
scheduler = AsyncIOScheduler(jobstores=jobstores, timezone=app_tz)
async def execute_lamp_command(ip: str, params: dict):
"""
Универсальное выполнение команды.
params может содержать: state, dimming, temp, sceneId, r, g, b
"""
driver = WizDriver()
await driver.set_pilot(ip, params)
logger.info(f"Сработало расписание для {ip}: {params}")
def is_internal_job_id(job_id: str) -> bool:
return any(job_id.startswith(prefix) for prefix in INTERNAL_JOB_PREFIXES)
def build_schedule_job_id(trigger_type: str) -> str:
return f"{trigger_type}_{uuid4().hex}"
def _parse_once_run_at(trigger_args: dict) -> datetime:
run_at = datetime.fromisoformat(trigger_args["run_at"])
if run_at.tzinfo is None:
return app_tz.localize(run_at)
return run_at.astimezone(app_tz)
def build_trigger(task: ScheduleTask):
if task.trigger_type == "once":
return DateTrigger(
run_date=_parse_once_run_at(task.trigger_args), timezone=app_tz
)
if task.trigger_type == "cron":
trigger_kwargs = dict(task.trigger_args)
trigger_kwargs["timezone"] = app_tz
return CronTrigger(**trigger_kwargs)
raise ValueError(f"Неизвестный тип триггера: {task.trigger_type}")
def serialize_trigger_args(trigger) -> tuple[str, dict] | None:
if isinstance(trigger, DateTrigger):
run_date = trigger.run_date
if run_date.tzinfo is None:
run_date = app_tz.localize(run_date)
else:
run_date = run_date.astimezone(app_tz)
return "once", {"run_at": run_date.isoformat()}
if isinstance(trigger, CronTrigger):
trigger_args = {}
for field in trigger.fields:
if field.name in {
"year",
"month",
"day",
"week",
"day_of_week",
"hour",
"minute",
"second",
}:
trigger_args[field.name] = str(field)
return "cron", trigger_args
return None
async def cleanup_old_events():
@@ -47,17 +101,272 @@ async def cleanup_old_events():
)
async def start_scheduler():
if not scheduler.running:
scheduler.start()
def ensure_internal_jobs():
scheduler.add_job(
cleanup_old_events,
CronTrigger(hour=3, minute=0, timezone=app_tz),
id="cleanup_event_log",
name="Очистка старых событий",
replace_existing=True,
)
# Очистка лога -- раз в сутки в 03:00
scheduler.add_job(
cleanup_old_events,
CronTrigger(hour=3, minute=0, timezone=app_tz),
id="cleanup_event_log",
name="Очистка старых событий",
replace_existing=True,
async def execute_schedule_job(job_id: str):
"""
Унифицированная точка входа для исполнения задач расписаний.
Рантайм-задача в APScheduler всегда адресуется только по job_id,
а вся доменная информация подтягивается из основной БД.
"""
async with async_session() as session:
result = await session.execute(
select(ScheduleTask).where(
ScheduleTask.job_id == job_id,
ScheduleTask.is_active.is_(True),
)
)
task = result.scalar_one_or_none()
if not task:
logger.warning(f"Расписание {job_id} не найдено или деактивировано")
try:
scheduler.remove_job(job_id)
except Exception:
pass
return
from app.api.routes.schedules import run_group_command
await run_group_command(
task.target_id,
task.target_type == "group",
dict(task.action_params),
)
if task.trigger_type == "once":
await delete_schedule_task(job_id, suppress_missing=True)
def add_runtime_job(task: ScheduleTask):
scheduler.add_job(
execute_schedule_job,
trigger=build_trigger(task),
args=[task.job_id],
id=task.job_id,
name=f"{task.trigger_type.upper()}: {task.target_id}",
replace_existing=True,
max_instances=1,
misfire_grace_time=300,
coalesce=(task.trigger_type == "cron"),
)
async def create_schedule_task(
*,
trigger_type: str,
target_id: str,
target_type: str,
trigger_args: dict,
action_params: dict,
) -> ScheduleTask:
task = ScheduleTask(
job_id=build_schedule_job_id(trigger_type),
trigger_type=trigger_type,
target_id=target_id,
target_type=target_type,
trigger_args=trigger_args,
action_params=action_params,
is_active=True,
)
async with async_session() as session:
session.add(task)
await session.commit()
await session.refresh(task)
try:
add_runtime_job(task)
except Exception:
async with async_session() as session:
result = await session.execute(
select(ScheduleTask).where(ScheduleTask.job_id == task.job_id)
)
persisted = result.scalar_one_or_none()
if persisted:
await session.delete(persisted)
await session.commit()
raise
return task
async def list_schedule_tasks() -> list[dict]:
async with async_session() as session:
result = await session.execute(
select(ScheduleTask)
.where(ScheduleTask.is_active.is_(True))
.order_by(ScheduleTask.created_at.asc(), ScheduleTask.id.asc())
)
tasks = result.scalars().all()
items = []
for task in tasks:
job = scheduler.get_job(task.job_id)
next_run = None
if job and job.next_run_time:
next_run = job.next_run_time.astimezone(app_tz).isoformat()
hour = None
minute = None
day_of_week = None
if task.trigger_type == "cron":
hour = task.trigger_args.get("hour")
minute = task.trigger_args.get("minute")
day_of_week = task.trigger_args.get("day_of_week", "*")
items.append(
{
"id": task.job_id,
"target_id": task.target_id,
"is_group": task.target_type == "group",
"state": task.action_params.get("state"),
"action_params": task.action_params,
"trigger_type": task.trigger_type,
"next_run": next_run,
"hour": hour,
"minute": minute,
"day_of_week": day_of_week,
"job_present": job is not None,
}
)
return items
async def delete_schedule_task(job_id: str, *, suppress_missing: bool = False) -> bool:
if is_internal_job_id(job_id):
raise ValueError("Нельзя удалить служебную задачу")
deleted = False
async with async_session() as session:
result = await session.execute(
select(ScheduleTask).where(ScheduleTask.job_id == job_id)
)
task = result.scalar_one_or_none()
if task:
await session.delete(task)
await session.commit()
deleted = True
try:
scheduler.remove_job(job_id)
deleted = True
except Exception:
pass
if not deleted and not suppress_missing:
raise KeyError(job_id)
return deleted
async def migrate_legacy_scheduler_jobs():
"""
Переносит старые APScheduler-only задачи в таблицу schedules.
Это нужно для безопасного апгрейда существующих установок, где задачи
уже лежат в apscheduler_jobs, но ещё не имеют метаданных в основной БД.
"""
async with async_session() as session:
result = await session.execute(select(ScheduleTask.job_id))
known_job_ids = set(result.scalars().all())
for job in scheduler.get_jobs():
if is_internal_job_id(job.id) or job.id in known_job_ids:
continue
serialized = serialize_trigger_args(job.trigger)
if not serialized:
logger.warning(f"Пропускаю неподдерживаемую legacy-задачу {job.id}")
continue
if len(job.args) != 3:
logger.warning(f"Legacy-задача {job.id} имеет неожиданные args")
continue
target_id, is_group, action_params = job.args
if (
not isinstance(target_id, str)
or not isinstance(is_group, bool)
or not isinstance(action_params, dict)
):
logger.warning(
f"Legacy-задача {job.id} имеет неподдерживаемую сигнатуру"
)
continue
trigger_type, trigger_args = serialized
session.add(
ScheduleTask(
job_id=job.id,
trigger_type=trigger_type,
target_id=target_id,
target_type="group" if is_group else "device",
trigger_args=trigger_args,
action_params=action_params,
is_active=True,
)
)
logger.info(f"Мигрирована legacy-задача {job.id} в таблицу schedules")
await session.commit()
async def rebuild_runtime_jobs_from_metadata():
for job in scheduler.get_jobs():
if not is_internal_job_id(job.id):
scheduler.remove_job(job.id)
async with async_session() as session:
result = await session.execute(
select(ScheduleTask).where(ScheduleTask.is_active.is_(True))
)
tasks = result.scalars().all()
now = datetime.now(app_tz)
for task in tasks:
if task.trigger_type == "once":
run_at = _parse_once_run_at(task.trigger_args)
if run_at <= now:
logger.info(f"Удаляю просроченную одноразовую задачу {task.job_id}")
await delete_schedule_task(task.job_id, suppress_missing=True)
continue
add_runtime_job(task)
async def reconcile_schedule_jobs():
await migrate_legacy_scheduler_jobs()
await rebuild_runtime_jobs_from_metadata()
async def start_scheduler():
current_loop = asyncio.get_running_loop()
bound_loop = getattr(scheduler, "_eventloop", None)
if scheduler.running and (
bound_loop is None or bound_loop.is_closed() or bound_loop is not current_loop
):
if bound_loop is not None and not bound_loop.is_closed():
scheduler.shutdown(wait=False)
else:
scheduler.state = STATE_STOPPED
scheduler._eventloop = current_loop
if not scheduler.running:
scheduler.start()
logger.info(f"Планировщик запущен. Таймзона: {TZ_NAME}")
ensure_internal_jobs()
await reconcile_schedule_jobs()

View File

@@ -1,6 +1,23 @@
import json
import asyncio
import socket
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class WizResponse:
ok: bool
ip: str
kind: str
payload: dict[str, Any] | None = None
message: str | None = None
@property
def result(self) -> dict[str, Any]:
if not self.payload:
return {}
return self.payload.get("result", {})
class WizDriver:
@@ -41,24 +58,72 @@ class WizDriver:
"steampunk": 35,
}
async def send_udp(self, ip: str, payload: dict):
async def send_udp(self, ip: str, payload: dict) -> WizResponse:
loop = asyncio.get_running_loop()
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.settimeout(2.0)
data = json.dumps(payload).encode()
await loop.run_in_executor(None, sock.sendto, data, (ip, self.PORT))
try:
await loop.run_in_executor(None, sock.sendto, data, (ip, self.PORT))
except OSError as exc:
return WizResponse(
ok=False,
ip=ip,
kind="network_error",
message=f"Ошибка отправки UDP: {exc}",
)
try:
resp, _ = await loop.run_in_executor(None, sock.recvfrom, 1024)
return json.loads(resp.decode())
except socket.timeout:
return None
return WizResponse(
ok=False,
ip=ip,
kind="timeout",
message="Таймаут ответа от лампы",
)
except OSError as exc:
return WizResponse(
ok=False,
ip=ip,
kind="network_error",
message=f"Ошибка чтения UDP: {exc}",
)
async def set_pilot(self, ip: str, params: dict):
try:
decoded = json.loads(resp.decode())
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
return WizResponse(
ok=False,
ip=ip,
kind="protocol_error",
message=f"Невалидный ответ WiZ: {exc}",
)
if isinstance(decoded, dict) and "error" in decoded:
return WizResponse(
ok=False,
ip=ip,
kind="device_error",
payload=decoded,
message=str(decoded["error"]),
)
if not isinstance(decoded, dict):
return WizResponse(
ok=False,
ip=ip,
kind="protocol_error",
message="Ответ WiZ имеет неожиданный формат",
)
return WizResponse(ok=True, ip=ip, kind="ok", payload=decoded)
async def set_pilot(self, ip: str, params: dict) -> WizResponse:
payload = {"method": "setPilot", "params": params}
return await self.send_udp(ip, payload)
async def get_pilot(self, ip: str):
async def get_pilot(self, ip: str) -> WizResponse:
payload = {"method": "getPilot", "params": {}}
return await self.send_udp(ip, payload)

View File

@@ -1,6 +1,7 @@
import hashlib
import secrets
from datetime import datetime
from sqlalchemy import String, Boolean, DateTime
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
@@ -24,3 +25,20 @@ class ApiKeyModel(Base):
def generate_key() -> str:
"""Генерация безопасного случайного токена."""
return secrets.token_urlsafe(32)
@staticmethod
def public_id_for(key: str) -> str:
"""
Возвращает безопасный публичный идентификатор ключа.
Его можно отдавать клиенту и использовать в операциях revoke/activate
вместо самого секрета.
"""
return hashlib.sha256(key.encode("utf-8")).hexdigest()[:16]
@property
def public_id(self) -> str:
return self.public_id_for(self.key)
@property
def preview(self) -> str:
return f"{self.key[:6]}...{self.key[-4:]}"

View File

@@ -1,13 +1,29 @@
from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, JSON
from datetime import datetime
from sqlalchemy import Boolean, Integer, JSON, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class ScheduleTask(Base):
"""
Персистентная метадата пользовательских расписаний.
APScheduler остаётся рантайм-движком исполнения, а эта таблица служит
источником истины для CRUD, восстановления и миграции задач.
"""
__tablename__ = "schedules"
id = Column(Integer, primary_key=True, index=True)
device_id = Column(Integer, ForeignKey("devices.id"), nullable=False)
task_type = Column(String) # 'once', 'daily', 'cron'
action_params = Column(JSON) # {'state': True, 'dimming': 50}
is_active = Column(Boolean, default=True)
job_id = Column(String, unique=True) # ID задачи в APScheduler
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
job_id: Mapped[str] = mapped_column(String, unique=True, index=True)
trigger_type: Mapped[str] = mapped_column(String) # once | cron
target_id: Mapped[str] = mapped_column(String)
target_type: Mapped[str] = mapped_column(String) # group | device
trigger_args: Mapped[dict] = mapped_column(JSON)
action_params: Mapped[dict] = mapped_column(JSON)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[str] = mapped_column(
String, default=lambda: datetime.now().isoformat()
)