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.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 logger = logging.getLogger(__name__) router = APIRouter(dependencies=[Depends(require_admin)]) 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): """ Универсальное выполнение команды по расписанию. Сигнатура специально сохранена совместимой со старым persisted jobstore, чтобы legacy APScheduler jobs можно было безопасно мигрировать. """ if is_group: ips = state_manager.get_group_ips(target_id) else: dev = state_manager.devices.get(target_id) ips = [dev.ip] if dev else [] if not ips: logger.warning(f"Расписание: цель {target_id} не найдена (0 IP)") return local_wiz = WizDriver() success_count = 0 failure_count = 0 for ip in ips: try: 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}") from app.api.routes.control import log_command_result_by_name target_type = "group" if is_group else "device" 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: 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) else: exec_time = run_at.astimezone(app_tz) else: raise HTTPException(status_code=400, detail="Нужно время или отступ в часах") if exec_time <= datetime.now(app_tz): raise HTTPException( status_code=400, detail="Время запуска должно быть в будущем" ) target_type = _validate_target(target_id, is_group) action_params = _build_action_params(state, brightness, scene, temp, r, g, b) 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", "job_id": task.job_id, "run_at": exec_time.isoformat(), } @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) 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)) return {"status": "cron_scheduled", "job_id": task.job_id} @router.get("/tasks") async def get_all_tasks(): return {"tasks": await list_schedule_tasks()} @router.delete("/{job_id}") async def cancel_task(job_id: str): if is_internal_job_id(job_id): raise HTTPException(status_code=403, detail="Нельзя удалить служебную задачу") try: await delete_schedule_task(job_id) return {"status": "deleted"} except KeyError: raise HTTPException(status_code=404, detail="Задача не найдена") except ValueError: raise HTTPException(status_code=403, detail="Нельзя удалить служебную задачу")