Enable for 4 hours feature

This commit is contained in:
Артём Кокос
2026-03-02 21:44:45 +07:00
parent c661e2450d
commit 62af4e46af
2 changed files with 103 additions and 57 deletions

View File

@@ -1,11 +1,12 @@
import asyncio
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger 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.state import state_manager
from app.core.scheduler import scheduler, execute_lamp_command
from app.drivers.wiz import WizDriver from app.drivers.wiz import WizDriver
from app.api.deps import verify_token from app.api.deps import verify_token
@@ -13,43 +14,58 @@ router = APIRouter(dependencies=[Depends(verify_token)])
wiz = WizDriver() wiz = WizDriver()
@router.post("/at") async def run_delayed_command(ips: list[str], state: bool):
async def add_task_at_time( """Вспомогательная функция для разовых задач"""
target_id: str, local_wiz = WizDriver()
run_at: datetime, for ip in ips:
is_group: bool = False, try:
state: Optional[bool] = None, await local_wiz.set_pilot(ip, {"state": state})
brightness: Optional[int] = None, except Exception:
scene: Optional[str] = None, pass # Игнорим ошибки отдельных ламп
):
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]
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) dev = state_manager.devices.get(target_id)
if dev: ips = [dev.ip] if dev else []
ips = [dev.ip]
if not ips: if not ips:
raise HTTPException(status_code=404, detail="Цель не найдена") raise HTTPException(status_code=404, detail="Цель не найдена")
job_ids = [] # 3. Регаем задачу
for ip in ips: job_id = f"once_{target_id}_{int(exec_time.timestamp())}"
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)
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") @router.post("/cron")
@@ -70,8 +86,9 @@ async def add_cron_task(
if not ips: if not ips:
raise HTTPException(status_code=404, detail="Цель не найдена") raise HTTPException(status_code=404, detail="Цель не найдена")
# Используем таймзону приложения для крона
trigger = CronTrigger( 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 = [] job_ids = []
@@ -80,7 +97,9 @@ async def add_cron_task(
execute_lamp_command, execute_lamp_command,
trigger, trigger,
args=[ip, {"state": state}], 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) job_ids.append(job.id)
@@ -91,34 +110,34 @@ async def add_cron_task(
async def get_all_tasks(): async def get_all_tasks():
jobs = [] jobs = []
for job in scheduler.get_jobs(): for job in scheduler.get_jobs():
# Разбираем строку типа "Group: bedroom | {'state': True}" # Парсим имя
# Вытаскиваем цель (bedroom) и состояние (True/False)
name_parts = job.name.split("|") 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()
# Пытаемся понять, ВКЛ или ВЫКЛ задача h, m = None, None
is_on = "True" in job.name 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( jobs.append(
{ {
"id": job.id, "id": job.id,
"target_id": target, "target_id": target,
"state": is_on, "state": is_on,
# Достаем время из триггера APScheduler "next_run": next_run_str,
"next_run": ( "hour": h,
job.next_run_time.isoformat() if job.next_run_time else None "minute": m,
),
# Вытаскиваем час и минуту прямо из настроек триггера для красоты
"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 "??"
),
} }
) )
return {"tasks": jobs} return {"tasks": jobs}

View File

@@ -96,9 +96,13 @@
<div v-for="task in tasks" :key="task.id" class="bg-slate-900/50 border border-slate-800 p-6 rounded-2xl flex justify-between items-center group"> <div v-for="task in tasks" :key="task.id" class="bg-slate-900/50 border border-slate-800 p-6 rounded-2xl flex justify-between items-center group">
<div> <div>
<div class="flex items-center gap-3 mb-1"> <div class="flex items-center gap-3 mb-1">
<span class="text-orange-500 text-xl font-black tracking-tighter">{{ task.hour }}:{{ task.minute }}</span> <span class="text-orange-500 text-xl font-black tracking-tighter">
<span class="text-[10px] bg-slate-800 px-2 py-0.5 rounded text-slate-400 uppercase font-bold"> <template v-if="task.hour !== null && task.hour !== undefined">
{{ groups[task.target_id]?.name || task.target_id }} {{ String(task.hour).padStart(2, '0') }}:{{ String(task.minute).padStart(2, '0') }}
</template>
<template v-else-if="task.next_run">
{{ new Date(task.next_run).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}) }}
</template>
</span> </span>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -141,6 +145,10 @@
<button @click="deleteGroup(id)" class="bg-slate-800 hover:bg-red-900/40 p-3 rounded-xl transition-all text-slate-500 hover:text-red-500" title="Удалить группу"> <button @click="deleteGroup(id)" class="bg-slate-800 hover:bg-red-900/40 p-3 rounded-xl transition-all text-slate-500 hover:text-red-500" title="Удалить группу">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button> </button>
<button @click="setTimer4h(id)" class="bg-blue-600 hover:bg-blue-500 p-3 rounded-xl font-bold px-4 transition-all text-[10px] flex items-center gap-1">
<span>🕒</span>
</button>
<button @click="toggleGroup(id, true)" class="bg-orange-600 hover:bg-orange-500 p-3 rounded-xl font-bold px-5 transition-all">ВКЛ</button> <button @click="toggleGroup(id, true)" class="bg-orange-600 hover:bg-orange-500 p-3 rounded-xl font-bold px-5 transition-all">ВКЛ</button>
<button @click="toggleGroup(id, false)" class="bg-slate-700 hover:bg-slate-600 p-3 rounded-xl font-bold px-5 transition-all">ВЫКЛ</button> <button @click="toggleGroup(id, false)" class="bg-slate-700 hover:bg-slate-600 p-3 rounded-xl font-bold px-5 transition-all">ВЫКЛ</button>
</div> </div>
@@ -369,8 +377,27 @@
async deleteTask(id) { async deleteTask(id) {
await this.request(`/schedules/${id}`, 'DELETE'); await this.request(`/schedules/${id}`, 'DELETE');
this.fetchTasks(); 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() { mounted() {
if (this.apiKey) { if (this.apiKey) {
this.fetchData(); this.fetchData();