diff --git a/app/api/routes/schedules.py b/app/api/routes/schedules.py index 676b9f2..d5eec6f 100644 --- a/app/api/routes/schedules.py +++ b/app/api/routes/schedules.py @@ -1,11 +1,12 @@ +import asyncio 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 app.core.scheduler import app_tz, scheduler, execute_lamp_command from app.core.state import state_manager -from app.core.scheduler import scheduler, execute_lamp_command from app.drivers.wiz import WizDriver from app.api.deps import verify_token @@ -13,43 +14,58 @@ router = APIRouter(dependencies=[Depends(verify_token)]) wiz = WizDriver() -@router.post("/at") -async def add_task_at_time( - target_id: str, - run_at: datetime, - is_group: bool = False, - state: Optional[bool] = None, - brightness: Optional[int] = None, - scene: Optional[str] = None, -): - 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] +async def run_delayed_command(ips: list[str], state: bool): + """Вспомогательная функция для разовых задач""" + local_wiz = WizDriver() + for ip in ips: + try: + await local_wiz.set_pilot(ip, {"state": state}) + except Exception: + pass # Игнорим ошибки отдельных ламп - ips = state_manager.get_group_ips(target_id) if is_group else [] - if not is_group: + +@router.post("/once") +async def schedule_once( + target_id: str, + state: bool, + run_at: Optional[datetime] = None, + hours_from_now: Optional[int] = None, + is_group: bool = True, +): + # 1. Определяем время запуска в правильной таймзоне + if hours_from_now is not None: + 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 + else: + raise HTTPException(status_code=400, detail="Нужно время или отступ в часах") + + # 2. Получаем IP + if is_group: + ips = state_manager.get_group_ips(target_id) + else: dev = state_manager.devices.get(target_id) - if dev: - ips = [dev.ip] + ips = [dev.ip] if dev else [] if not ips: raise HTTPException(status_code=404, detail="Цель не найдена") - job_ids = [] - for ip in ips: - job = scheduler.add_job( - execute_lamp_command, - DateTrigger(run_date=run_at, timezone=scheduler.timezone), - args=[ip, params], - name=f"{'Group' if is_group else 'Device'}: {target_id} | {params}", - ) - job_ids.append(job.id) + # 3. Регаем задачу + job_id = f"once_{target_id}_{int(exec_time.timestamp())}" - return {"status": "scheduled", "jobs": job_ids, "run_at": run_at} + scheduler.add_job( + run_delayed_command, + trigger=DateTrigger(run_date=exec_time, timezone=app_tz), + args=[ips, state], + id=job_id, + name=f"Once: {target_id} | {state}", + replace_existing=True, + ) + + return {"status": "scheduled", "run_at": exec_time.isoformat()} @router.post("/cron") @@ -70,8 +86,9 @@ async def add_cron_task( if not ips: raise HTTPException(status_code=404, detail="Цель не найдена") + # Используем таймзону приложения для крона trigger = CronTrigger( - hour=hour, minute=minute, day_of_week=day_of_week, timezone=scheduler.timezone + hour=hour, minute=minute, day_of_week=day_of_week, timezone=app_tz ) job_ids = [] @@ -80,7 +97,9 @@ async def add_cron_task( execute_lamp_command, trigger, args=[ip, {"state": state}], - name=f"CRON: {target_id} | {hour}:{minute}", + id=f"cron_{target_id}_{ip}_{hour}_{minute}", + name=f"CRON: {target_id} | {hour}:{minute} | {state}", + replace_existing=True, ) job_ids.append(job.id) @@ -91,34 +110,34 @@ async def add_cron_task( async def get_all_tasks(): jobs = [] for job in scheduler.get_jobs(): - # Разбираем строку типа "Group: bedroom | {'state': True}" - # Вытаскиваем цель (bedroom) и состояние (True/False) + # Парсим имя name_parts = job.name.split("|") - target = name_parts[0].replace("Group:", "").replace("Device:", "").strip() + target = name_parts[0].replace("CRON:", "").replace("Once:", "").strip() + is_on = "True" in job.name or "true" in job.name.lower() - # Пытаемся понять, ВКЛ или ВЫКЛ задача - is_on = "True" in job.name + 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, - # Достаем время из триггера APScheduler - "next_run": ( - job.next_run_time.isoformat() if job.next_run_time else None - ), - # Вытаскиваем час и минуту прямо из настроек триггера для красоты - "hour": ( - str(job.trigger.fields[5]).zfill(2) - if hasattr(job.trigger, "fields") - else "??" - ), - "minute": ( - str(job.trigger.fields[6]).zfill(2) - if hasattr(job.trigger, "fields") - else "??" - ), + "next_run": next_run_str, + "hour": h, + "minute": m, } ) return {"tasks": jobs} diff --git a/static/index.html b/static/index.html index 9f93ba6..01183a5 100644 --- a/static/index.html +++ b/static/index.html @@ -96,9 +96,13 @@
- {{ task.hour }}:{{ task.minute }} - - {{ groups[task.target_id]?.name || task.target_id }} + + +
@@ -141,6 +145,10 @@ + +
@@ -369,7 +377,26 @@ async deleteTask(id) { await this.request(`/schedules/${id}`, 'DELETE'); this.fetchTasks(); - } + }, + async setTimer4h(id) { + // 1. Включаем свет сразу + await this.toggleGroup(id, true); + + // 2. Шлём запрос на таймер. + const res = await this.request('/schedules/once', 'POST', { + target_id: id, + hours_from_now: 4, + is_group: true, + state: false + }); + + if (res) { + console.log("Таймер успешно создан:", res); + this.fetchTasks(); + } else { + console.error("Бэкенд проигнорировал запрос таймера"); + } + }, }, mounted() { if (this.apiKey) {