Vibecoded fixes & readme

This commit is contained in:
Artem Kokos
2026-03-28 20:11:23 +07:00
parent 62af4e46af
commit d024ba78ab
7 changed files with 631 additions and 259 deletions

11
.env.example Normal file
View File

@@ -0,0 +1,11 @@
# API-ключ для авторизации (если не задан -- авторизация отключена)
IGNIS_API_KEY=
# Таймзона для расписаний
APP_TIMEZONE=Asia/Almaty
# Подсети для сканирования (через запятую, пусто = автоопределение)
SCAN_NETWORK=
# Уровень логирования (DEBUG, INFO, WARNING, ERROR)
LOG_LEVEL=INFO

139
README.md Normal file
View File

@@ -0,0 +1,139 @@
# Ignis Core
Self-hosted сервер для управления умными лампами WiZ по локальной сети. FastAPI бэкенд с веб-интерфейсом, планировщиком расписаний и REST API для мобильного приложения.
## Возможности
- **Discovery** -- автоматическое обнаружение ламп WiZ в локальной сети (UDP broadcast). Поддержка нескольких подсетей через `SCAN_NETWORK`.
- **Группы** -- объединение ламп в именованные группы (спальня, кухня, ...). Хранение в SQLite.
- **Управление** -- включение/выключение, яркость, цветовая температура, RGB-цвет, 35+ встроенных сцен.
- **Расписания** -- одноразовые таймеры и cron-задачи через APScheduler с персистентным хранилищем.
- **Веб-интерфейс** -- SPA на Vue 3 + Tailwind, встроен в сервер как статика.
- **API** -- REST API с авторизацией по API-ключу для мобильных клиентов.
## Быстрый старт
```bash
# Клонировать
git clone https://git.akokos.ru/artem.kokos/ignis-core.git
cd ignis-core
# Виртуальное окружение
python3 -m venv .venv
source .venv/bin/activate
# Зависимости
pip install -r requirements.txt
# Конфигурация
cp .env.example .env
# Отредактировать .env -- указать API-ключ и таймзону
# Запуск
uvicorn main:app --host 0.0.0.0 --port 8000
```
Сервер будет доступен на `http://<ip>:8000`. Веб-интерфейс -- на корневом URL.
## Конфигурация (.env)
```env
# API-ключ для авторизации (если не задан -- авторизация отключена)
IGNIS_API_KEY=your-secret-key
# Таймзона для расписаний (по умолчанию Asia/Novosibirsk)
APP_TIMEZONE=Asia/Almaty
# Подсети для сканирования (через запятую, по умолчанию -- автоопределение)
SCAN_NETWORK=192.168.1.0/24
# Уровень логирования
LOG_LEVEL=INFO
```
## Структура проекта
```
ignis-core/
├── main.py -- точка входа FastAPI, lifespan
├── requirements.txt -- зависимости
├── .env -- конфигурация (не в git)
├── static/
│ └── index.html -- веб-интерфейс (Vue 3 SPA)
├── app/
│ ├── core/
│ │ ├── database.py -- async SQLAlchemy, SQLite
│ │ ├── discovery.py -- UDP-сканирование сети WiZ
│ │ ├── scheduler.py -- APScheduler + jobstore
│ │ └── state.py -- in-memory состояние (устройства, группы)
│ ├── models/
│ │ ├── device.py -- модели Device, Group (SQLAlchemy + Pydantic)
│ │ └── schedule.py -- модель ScheduleTask
│ ├── drivers/
│ │ └── wiz.py -- UDP-драйвер протокола WiZ
│ └── api/
│ ├── deps.py -- авторизация (X-API-Key)
│ └── routes/
│ ├── devices.py -- CRUD устройств и групп
│ ├── control.py -- управление лампами
│ └── schedules.py -- расписания (once, cron)
└── ignis.db -- SQLite база (создаётся автоматически)
```
## API
Авторизация: заголовок `X-API-Key`.
### Устройства и группы
| Метод | Путь | Описание |
|--------|--------------------------|------------------------------|
| GET | `/devices` | Все обнаруженные лампы |
| GET | `/devices/groups` | Все группы |
| GET | `/devices/scenes` | Доступные сцены WiZ |
| POST | `/devices/groups` | Создать группу |
| DELETE | `/devices/groups/{id}` | Удалить группу |
| POST | `/devices/rescan` | Пересканировать сеть |
Создание группы (JSON body):
```json
{"id": "bedroom", "name": "Спальня", "macs": ["a8bb50aabbcc", "a8bb50ddeeff"]}
```
### Управление
| Метод | Путь | Описание |
|-------|----------------------------------|------------------------|
| POST | `/control/device/{id}` | Управление лампой |
| POST | `/control/group/{id}` | Управление группой |
| POST | `/control/device/{id}/blink` | Мигнуть лампой |
| GET | `/control/device/{id}/status` | Статус лампы |
| GET | `/control/group/{id}/status` | Статус группы |
Query-параметры управления: `state` (bool), `brightness` (int, 10--100), `temp` (int, 2700--6500), `scene` (string), `r`/`g`/`b` (int, 0--255).
### Расписания
| Метод | Путь | Описание |
|--------|-----------------------|-------------------------|
| POST | `/schedules/once` | Одноразовый таймер |
| POST | `/schedules/cron` | Повторяющаяся задача |
| GET | `/schedules/tasks` | Все активные задачи |
| DELETE | `/schedules/{job_id}` | Отменить задачу |
## Стек
- **FastAPI** -- async HTTP-сервер
- **SQLAlchemy 2.0** -- async ORM, SQLite через aiosqlite
- **APScheduler** -- планировщик с персистентным хранилищем
- **WiZ Protocol** -- UDP-управление лампами (порт 38899)
- **Vue 3 + Tailwind** -- встроенный веб-интерфейс
## Клиенты
- Веб-интерфейс -- встроен в сервер (`static/index.html`)
- [Ignis App](https://git.akokos.ru/artem.kokos/ignis_app) -- мобильное приложение (Flutter)
## Лицензия
Частный проект.

View File

@@ -1,4 +1,5 @@
import os
import logging
from fastapi import Depends, HTTPException, Security
from fastapi.security import APIKeyHeader
from starlette.status import HTTP_403_FORBIDDEN
@@ -6,7 +7,12 @@ from dotenv import load_dotenv
load_dotenv()
logger = logging.getLogger(__name__)
API_KEY = os.getenv("IGNIS_API_KEY")
if not API_KEY:
logger.warning("IGNIS_API_KEY не задан -- авторизация отключена!")
API_KEY_NAME = "X-API-Key"
api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False)

View File

@@ -1,10 +1,13 @@
import asyncio
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from app.core.state import state_manager
from app.drivers.wiz import WizDriver
from app.api.deps import verify_token
logger = logging.getLogger(__name__)
router = APIRouter(dependencies=[Depends(verify_token)])
wiz = WizDriver()

View File

@@ -1,27 +1,43 @@
import asyncio
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 app.core.scheduler import app_tz, scheduler, execute_lamp_command
from app.core.scheduler import app_tz, scheduler
from app.core.state import state_manager
from app.drivers.wiz import WizDriver
from app.api.deps import verify_token
logger = logging.getLogger(__name__)
router = APIRouter(dependencies=[Depends(verify_token)])
wiz = WizDriver()
async def run_delayed_command(ips: list[str], state: bool):
"""Вспомогательная функция для разовых задач"""
async def run_group_command(target_id: str, is_group: bool, params: dict):
"""
Универсальное выполнение команды по расписанию.
IP резолвится в момент выполнения, а не создания задачи --
корректно работает при смене IP (DHCP) и изменении состава группы.
"""
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()
for ip in ips:
try:
await local_wiz.set_pilot(ip, {"state": state})
except Exception:
pass # Игнорим ошибки отдельных ламп
await local_wiz.set_pilot(ip, params)
logger.info(f"⏰ Расписание: {target_id} -> {ip}: {params}")
except Exception as e:
logger.error(f"⏰ Расписание: ошибка {ip}: {e}")
@router.post("/once")
@@ -43,23 +59,21 @@ async def schedule_once(
else:
raise HTTPException(status_code=400, detail="Нужно время или отступ в часах")
# 2. Получаем IP
# 2. Проверяем что цель существует (но IP резолвится при выполнении)
if is_group:
ips = state_manager.get_group_ips(target_id)
if target_id not in state_manager.groups:
raise HTTPException(status_code=404, detail="Группа не найдена")
else:
dev = state_manager.devices.get(target_id)
ips = [dev.ip] if dev else []
if not ips:
raise HTTPException(status_code=404, detail="Цель не найдена")
if target_id not in state_manager.devices:
raise HTTPException(status_code=404, detail="Устройство не найдено")
# 3. Регаем задачу
job_id = f"once_{target_id}_{int(exec_time.timestamp())}"
scheduler.add_job(
run_delayed_command,
run_group_command,
trigger=DateTrigger(run_date=exec_time, timezone=app_tz),
args=[ips, state],
args=[target_id, is_group, {"state": state}],
id=job_id,
name=f"Once: {target_id} | {state}",
replace_existing=True,
@@ -77,33 +91,31 @@ async def add_cron_task(
is_group: bool = True,
state: bool = True,
):
ips = state_manager.get_group_ips(target_id) if is_group else []
if not is_group:
dev = state_manager.devices.get(target_id)
if dev:
ips = [dev.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 not ips:
raise HTTPException(status_code=404, detail="Цель не найдена")
# Используем таймзону приложения для крона
# Одна задача на всю группу -- IP резолвятся при каждом срабатывании
trigger = CronTrigger(
hour=hour, minute=minute, day_of_week=day_of_week, timezone=app_tz
)
job_ids = []
for ip in ips:
job = scheduler.add_job(
execute_lamp_command,
trigger,
args=[ip, {"state": state}],
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_id = f"cron_{target_id}_{hour}_{minute}"
return {"status": "cron_scheduled", "jobs": job_ids}
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}
@router.get("/tasks")
@@ -119,13 +131,13 @@ async def get_all_tasks():
next_run_str = None
if job.next_run_time:
# ПЕРЕВОДИМ ИЗ UTC В ЛОКАЛЬНУЮ ТАЙМЗОНУ ДЛЯ ВЫВОДА
# Переводим из 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])
@@ -148,5 +160,5 @@ async def cancel_task(job_id: str):
try:
scheduler.remove_job(job_id)
return {"status": "deleted"}
except:
except Exception:
raise HTTPException(status_code=404, detail="Задача не найдена")

View File

@@ -42,7 +42,7 @@ class WizDriver:
}
async def send_udp(self, ip: str, payload: dict):
loop = asyncio.get_event_loop()
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()

View File

@@ -4,175 +4,212 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IGNIS | Smart Control</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔥</text></svg>">
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700;800&family=Outfit:wght@300;400;600;800;900&display=swap" rel="stylesheet">
<style>
body {
background: radial-gradient(circle at top right, #1e1b4b, #0f172a);
color: #f8fafc;
font-family: 'Inter', sans-serif;
:root {
--bg-deep: #08090c;
--bg-card: rgba(20, 22, 30, 0.8);
--border-subtle: rgba(255,255,255,0.06);
--border-hover: rgba(249, 115, 22, 0.4);
--accent: #f97316;
--accent-glow: rgba(249, 115, 22, 0.15);
}
body {
background: var(--bg-deep);
color: #e2e8f0;
font-family: 'Outfit', sans-serif;
min-height: 100vh;
}
.glass { background: rgba(30, 41, 59, 0.7); backdrop-filter: blur(12px); border: 1px solid rgba(255,255,255,0.1); }
.active-tab { background: #f97316; box-shadow: 0 0 20px rgba(249, 115, 22, 0.4); }
input[type="range"] { -webkit-appearance: none; height: 6px; border-radius: 10px; background: #334155; outline: none; }
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; width: 18px; height: 18px;
background: #fff; border-radius: 50%; cursor: pointer; border: 3px solid #f97316;
/* Тонкий паттерн на фоне */
body::before {
content: '';
position: fixed; inset: 0; z-index: -1;
background:
radial-gradient(ellipse at 20% 0%, rgba(249,115,22,0.06) 0%, transparent 60%),
radial-gradient(ellipse at 80% 100%, rgba(30,27,75,0.15) 0%, transparent 60%);
}
.glass {
background: var(--bg-card);
backdrop-filter: blur(16px);
border: 1px solid var(--border-subtle);
}
.glass:hover { border-color: var(--border-hover); }
.active-tab {
background: var(--accent);
box-shadow: 0 0 24px var(--accent-glow), inset 0 1px 0 rgba(255,255,255,0.15);
}
/* Слайдеры */
input[type="range"] {
-webkit-appearance: none; height: 6px;
border-radius: 10px; background: #1e293b; outline: none;
transition: opacity 0.2s;
}
input[type="range"]:disabled { opacity: 0.25; cursor: not-allowed; }
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; width: 20px; height: 20px;
background: #fff; border-radius: 50%; cursor: pointer;
border: 3px solid var(--accent);
box-shadow: 0 0 8px rgba(0,0,0,0.4);
transition: transform 0.15s;
}
input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.15); }
input[type="range"]:disabled::-webkit-slider-thumb { border-color: #475569; cursor: not-allowed; transform: none; }
.temp-gradient { background: linear-gradient(to right, #ffcc66, #ffffff, #99ccff) !important; }
select { appearance: none; background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e"); background-repeat: no-repeat; background-position: right 1rem center; background-size: 1em; }
/* Селекты */
select {
appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 0.75rem center;
background-size: 1em;
}
/* Моно для технических данных */
.mono { font-family: 'JetBrains Mono', monospace; }
/* Анимации появления */
@keyframes fadeUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
.fade-up { animation: fadeUp 0.4s ease-out both; }
/* Toast-уведомления */
.toast-enter { animation: fadeUp 0.3s ease-out; }
.toast-leave { animation: fadeUp 0.3s ease-in reverse; }
/* Пульсация статуса */
@keyframes pulse-dot { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
.pulse-on { animation: pulse-dot 2s ease-in-out infinite; }
/* Спиннер */
@keyframes spin { to { transform: rotate(360deg); } }
.spinner { animation: spin 0.8s linear infinite; }
</style>
</head>
<body>
<div id="app" class="max-w-6xl mx-auto p-4 md:p-10">
<div v-if="!apiKey" class="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/90 backdrop-blur-sm p-4">
<div class="glass p-8 rounded-[2.5rem] w-full max-w-md shadow-2xl">
<div class="text-center mb-8">
<span class="text-5xl mb-4 block">🔐</span>
<h2 class="text-2xl font-black uppercase tracking-tighter">Ignis Access</h2>
<p class="text-slate-400 text-sm mt-2">Введите API ключ из .env</p>
<div id="app" class="max-w-6xl mx-auto p-4 md:p-8">
<!-- ═══════ Экран авторизации ═══════ -->
<div v-if="!apiKey" class="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-md p-4">
<div class="glass p-10 rounded-3xl w-full max-w-sm shadow-2xl fade-up text-center">
<div class="w-16 h-16 mx-auto mb-6 bg-gradient-to-br from-orange-500 to-red-600 rounded-2xl flex items-center justify-center shadow-lg shadow-orange-900/30">
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</div>
<input v-model="tempKey" type="password" placeholder="X-API-Key"
<h2 class="text-2xl font-black uppercase tracking-tight mb-1">Ignis</h2>
<p class="text-slate-500 text-xs mb-8">API-ключ из .env сервера</p>
<input v-model="tempKey" type="password" placeholder="X-API-Key"
@keyup.enter="saveKey"
class="w-full bg-slate-900 border border-slate-700 p-4 rounded-2xl mb-4 focus:border-orange-500 outline-none text-center tracking-widest">
<button @click="saveKey" class="w-full bg-orange-600 hover:bg-orange-500 py-4 rounded-2xl font-bold transition-all shadow-lg shadow-orange-900/40">
class="w-full bg-black/40 border border-slate-700/50 p-4 rounded-xl mb-4 focus:border-orange-500 outline-none text-center mono tracking-widest text-sm">
<button @click="saveKey" class="w-full bg-orange-600 hover:bg-orange-500 py-3.5 rounded-xl font-bold transition-all shadow-lg shadow-orange-900/30 active:scale-[0.98]">
ВОЙТИ
</button>
</div>
</div>
<!-- ═══════ Основной интерфейс ═══════ -->
<template v-else>
<header class="flex flex-col md:flex-row justify-between items-center mb-12 gap-6">
<div class="flex items-center gap-4">
<div class="bg-orange-600 p-3 rounded-2xl shadow-lg">
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
<!-- Хедер -->
<header class="flex flex-col md:flex-row justify-between items-center mb-10 gap-5 fade-up">
<div class="flex items-center gap-3">
<div class="bg-gradient-to-br from-orange-500 to-red-600 p-2.5 rounded-xl shadow-lg shadow-orange-900/20">
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</div>
<div>
<h1 class="text-4xl font-extrabold tracking-tighter uppercase italic">Ignis<span class="text-orange-500">Core</span></h1>
<button @click="logout" class="text-[10px] text-slate-500 hover:text-red-400 uppercase font-bold tracking-widest transition-colors">Выйти</button>
<h1 class="text-2xl font-black tracking-tight uppercase">Ignis<span class="text-orange-500">Core</span></h1>
<button @click="logout" class="text-[9px] text-slate-600 hover:text-red-400 uppercase font-bold tracking-widest transition-colors">выйти</button>
</div>
</div>
<nav class="flex glass p-1.5 rounded-2xl">
<button @click="tab = 'control'" :class="tab === 'control' ? 'active-tab' : 'text-slate-400'" class="px-8 py-2.5 rounded-xl font-bold transition-all">ПУЛЬТ</button>
<button @click="tab = 'schedules'" :class="tab === 'schedules' ? 'active-tab' : 'text-slate-400'" class="px-8 py-2.5 rounded-xl font-bold transition-all">ГРАФИК</button>
<button @click="tab = 'admin'" :class="tab === 'admin' ? 'active-tab' : 'text-slate-400'" class="px-8 py-2.5 rounded-xl font-bold transition-all">АДМИНКА</button>
<nav class="flex glass p-1 rounded-xl">
<button v-for="t in [{id:'control',label:'ПУЛЬТ'},{id:'schedules',label:'ГРАФИК'},{id:'admin',label:'АДМИНКА'}]"
:key="t.id" @click="tab = t.id"
:class="tab === t.id ? 'active-tab text-white' : 'text-slate-500 hover:text-slate-300'"
class="px-6 py-2 rounded-lg font-bold text-sm transition-all">
{{ t.label }}
</button>
</nav>
</header>
<div v-if="tab === 'schedules'" class="space-y-10">
<div class="glass p-8 rounded-[2.5rem]">
<h2 class="text-2xl font-black mb-6 uppercase italic">Новая задача</h2>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<select v-model="newTask.target_id" class="bg-slate-900 border border-slate-700 p-4 rounded-xl outline-none focus:border-orange-500">
<option value="" disabled>Выберите группу...</option>
<option v-for="(g, id) in groups" :value="id">{{ g.name }}</option>
</select>
<div class="flex items-center gap-2 bg-slate-900 border border-slate-700 p-2 rounded-xl">
<select v-model="taskHour" class="bg-transparent outline-none flex-1 text-center font-bold">
<option v-for="h in 24" :value="(h-1).toString().padStart(2, '0')">{{ (h-1).toString().padStart(2, '0') }}</option>
</select>
<span class="font-bold">:</span>
<select v-model="taskMin" class="bg-transparent outline-none flex-1 text-center font-bold">
<option v-for="m in 60" :value="(m-1).toString().padStart(2, '0')">{{ (m-1).toString().padStart(2, '0') }}</option>
</select>
</div>
<select v-model="newTask.state" class="bg-slate-900 border border-slate-700 p-4 rounded-xl outline-none">
<option :value="true">ВКЛЮЧИТЬ</option>
<option :value="false">ВЫКЛЮЧИТЬ</option>
</select>
<button @click="addSchedule" class="bg-orange-600 hover:bg-orange-500 rounded-xl font-black transition-all">ДОБАВИТЬ</button>
</div>
</div>
<div class="glass p-8 rounded-[2.5rem]">
<h2 class="text-2xl font-black mb-6 uppercase italic">Активные задачи</h2>
<div class="space-y-4">
<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 class="flex items-center gap-3 mb-1">
<span class="text-orange-500 text-xl font-black tracking-tighter">
<template v-if="task.hour !== null && task.hour !== undefined">
{{ 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>
</div>
<div class="flex items-center gap-2">
<span :class="task.state ? 'text-green-400' : 'text-red-400'" class="text-xs font-bold uppercase">
{{ task.state ? 'ВКЛЮЧИТЬ' : 'ВЫКЛЮЧИТЬ' }}
</span>
<span class="text-[10px] text-slate-600"></span>
<span class="text-[10px] text-slate-500 uppercase tracking-widest">
{{ task.next_run ? 'След. запуск: ' + new Date(task.next_run).toLocaleDateString() : 'Разовая задача' }}
</span>
</div>
</div>
<button @click="deleteTask(task.id)" class="text-slate-600 hover:text-red-500 p-2 transition-colors">
<svg class="w-6 h-6" 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>
</div>
<div v-if="tasks.length === 0" class="text-center py-10 text-slate-500 italic">Задач пока нет</div>
</div>
</div>
<!-- Индикатор загрузки -->
<div v-if="isLoading" class="flex justify-center py-20">
<div class="w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full spinner"></div>
</div>
<div v-if="tab === 'control'" class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div v-if="Object.keys(groups).length === 0" class="col-span-full text-center py-20 glass rounded-3xl opacity-50">
<p class="text-xl italic text-slate-400">Создайте группу в админке, чтобы управлять лампами</p>
<!-- ═══════ ПУЛЬТ ═══════ -->
<div v-if="tab === 'control' && !isLoading" class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div v-if="Object.keys(groups).length === 0" class="col-span-full text-center py-20 glass rounded-2xl">
<p class="text-slate-500">Создайте группу в админке</p>
</div>
<div v-for="(group, id) in groups" :key="id" class="glass p-8 rounded-[2.5rem] relative group hover:border-orange-500/50 transition-all">
<div class="flex justify-between items-start mb-8">
<div v-for="(group, id) in groups" :key="id"
class="glass p-6 rounded-2xl transition-all fade-up"
:style="sliders[id]?.state ? 'border-color: rgba(249,115,22,0.2)' : ''">
<!-- Шапка группы -->
<div class="flex justify-between items-start mb-6">
<div>
<h2 class="text-2xl font-black text-white flex items-center gap-2">
<h2 class="text-xl font-black text-white flex items-center gap-2">
{{ group.name }}
<span :class="sliders[id]?.state ? 'bg-green-500' : 'bg-slate-600'"
class="w-2 h-2 rounded-full shadow-[0_0_8px_rgba(0,0,0,0.5)]"></span>
<span :class="sliders[id]?.state ? 'bg-green-400 pulse-on' : 'bg-slate-600'"
class="w-2 h-2 rounded-full inline-block"></span>
</h2>
<span class="text-[10px] font-mono text-slate-500 uppercase tracking-tighter">
{{ id }} {{ group.device_ids?.length || 0 }} ламп
<span class="text-[10px] mono text-slate-600">
{{ id }} · {{ group.device_ids?.length || 0 }} ламп
</span>
</div>
<div class="flex gap-2">
<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>
<div class="flex gap-1.5">
<button @click="deleteGroup(id)" class="p-2 rounded-lg bg-slate-800/50 hover:bg-red-900/40 text-slate-600 hover:text-red-400 transition-all" title="Удалить">
<svg class="w-4 h-4" 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 @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 @click="setTimer4h(id)" class="p-2 px-3 rounded-lg bg-blue-600/20 hover:bg-blue-600/40 text-blue-400 text-xs font-bold transition-all" title="Включить на 4 часа">
</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, true)"
:class="sliders[id]?.state ? 'bg-orange-600 text-white' : 'bg-slate-800/50 text-slate-400 hover:bg-orange-600/30 hover:text-orange-300'"
class="p-2 px-4 rounded-lg font-bold text-sm transition-all">ВКЛ</button>
<button @click="toggleGroup(id, false)"
:class="!sliders[id]?.state ? 'bg-slate-600 text-white' : 'bg-slate-800/50 text-slate-400 hover:bg-slate-600/50'"
class="p-2 px-4 rounded-lg font-bold text-sm transition-all">ВЫКЛ</button>
</div>
</div>
<div class="space-y-8">
<!-- Управление -->
<div class="space-y-5">
<!-- Яркость -->
<div>
<div class="flex justify-between text-[10px] font-black uppercase text-slate-400 mb-3"><span>Яркость</span><span class="text-orange-400">{{ sliders[id]?.brightness }}%</span></div>
<input type="range" min="10" max="100" class="w-full" v-model="sliders[id].brightness" @change="setBrightness(id, $event.target.value)">
<div class="flex justify-between text-[10px] font-bold uppercase text-slate-500 mb-2">
<span>Яркость</span>
<span :class="sliders[id]?.state ? 'text-orange-400' : 'text-slate-600'" class="mono">{{ sliders[id]?.brightness || 100 }}%</span>
</div>
<input type="range" min="10" max="100" step="10" class="w-full"
:disabled="!sliders[id]?.state"
:value="sliders[id]?.brightness || 100"
@change="setBrightness(id, +$event.target.value)">
</div>
<!-- Температура -->
<div>
<div class="flex justify-between text-[10px] font-black uppercase text-slate-400 mb-3"><span>Температура</span><span class="text-blue-300">{{ sliders[id]?.temp }}K</span></div>
<input type="range" min="2700" max="6500" step="100" class="w-full temp-gradient" v-model="sliders[id].temp" @change="setTemp(id, $event.target.value)">
<div class="flex justify-between text-[10px] font-bold uppercase text-slate-500 mb-2">
<span>Температура</span>
<span :class="sliders[id]?.state ? 'text-blue-300' : 'text-slate-600'" class="mono">{{ sliders[id]?.temp || 4000 }}K</span>
</div>
<input type="range" min="2700" max="6500" step="100" class="w-full temp-gradient"
:disabled="!sliders[id]?.state"
:value="sliders[id]?.temp || 4000"
@change="setTemp(id, +$event.target.value)">
</div>
<div class="grid grid-cols-2 gap-6">
<!-- Цвет и Сцена -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-[10px] font-black uppercase text-slate-400 mb-3 block">Цвет</label>
<input type="color" class="w-full h-12 bg-transparent border-2 border-slate-700 rounded-xl cursor-pointer" @input="setColor(id, $event.target.value)">
<label class="text-[10px] font-bold uppercase text-slate-500 mb-2 block">Цвет</label>
<input type="color" class="w-full h-10 bg-transparent border border-slate-700/50 rounded-lg cursor-pointer"
:disabled="!sliders[id]?.state"
@input="setColor(id, $event.target.value)">
</div>
<div>
<label class="text-[10px] font-black uppercase text-slate-400 mb-3 block">Все сцены</label>
<select @change="setScene(id, $event.target.value)" class="w-full bg-slate-900 border border-slate-700 p-3 rounded-xl text-sm outline-none focus:border-orange-500">
<option value="" disabled selected>Выбрать пресет...</option>
<option v-for="scene in allScenes" :key="scene" :value="scene">{{ scene }}</option>
<label class="text-[10px] font-bold uppercase text-slate-500 mb-2 block">Сцена</label>
<select @change="setScene(id, $event.target.value); $event.target.value=''"
:disabled="!sliders[id]?.state"
class="w-full bg-black/30 border border-slate-700/50 p-2.5 rounded-lg text-xs outline-none focus:border-orange-500 disabled:opacity-25">
<option value="" disabled selected>Пресет...</option>
<option v-for="(sceneId, sceneName) in allScenes" :key="sceneName" :value="sceneName">{{ sceneName }}</option>
</select>
</div>
</div>
@@ -180,36 +217,130 @@
</div>
</div>
<div v-if="tab === 'admin'" class="space-y-10">
<section class="glass p-8 rounded-[2.5rem]">
<div class="flex justify-between items-center mb-8">
<h2 class="text-2xl font-bold italic">Устройства в сети</h2>
<button @click="rescan" class="bg-blue-600 hover:bg-blue-500 px-6 py-2 rounded-xl text-xs font-black transition-all">🔄 ПЕРЕСКАНИРОВАТЬ</button>
<!-- ═══════ РАСПИСАНИЯ ═══════ -->
<div v-if="tab === 'schedules' && !isLoading" class="space-y-8 fade-up">
<!-- Создание -->
<div class="glass p-6 rounded-2xl">
<h2 class="text-lg font-black mb-5 uppercase">Новая задача</h2>
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
<select v-model="newTask.target_id" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl outline-none focus:border-orange-500 text-sm">
<option value="" disabled>Группа...</option>
<option v-for="(g, id) in groups" :key="id" :value="id">{{ g.name }}</option>
</select>
<div class="flex items-center gap-1 bg-black/30 border border-slate-700/50 p-1 rounded-xl">
<select v-model="taskHour" class="bg-transparent outline-none flex-1 text-center font-bold text-sm">
<option v-for="h in 24" :key="h" :value="String(h-1).padStart(2,'0')">{{ String(h-1).padStart(2,'0') }}</option>
</select>
<span class="font-bold text-slate-500">:</span>
<select v-model="taskMin" class="bg-transparent outline-none flex-1 text-center font-bold text-sm">
<option v-for="m in 60" :key="m" :value="String(m-1).padStart(2,'0')">{{ String(m-1).padStart(2,'0') }}</option>
</select>
</div>
<select v-model="newTask.state" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl outline-none text-sm">
<option :value="true">ВКЛЮЧИТЬ</option>
<option :value="false">ВЫКЛЮЧИТЬ</option>
</select>
<button @click="addSchedule"
:disabled="!newTask.target_id"
class="bg-orange-600 hover:bg-orange-500 disabled:opacity-30 disabled:cursor-not-allowed rounded-xl font-bold transition-all text-sm active:scale-[0.97]">
ДОБАВИТЬ
</button>
</div>
</div>
<!-- Список задач -->
<div class="glass p-6 rounded-2xl">
<h2 class="text-lg font-black mb-5 uppercase">Активные задачи</h2>
<div class="space-y-3">
<div v-for="task in tasks" :key="task.id"
class="bg-black/20 border border-slate-800/50 p-4 rounded-xl flex justify-between items-center group hover:border-slate-700 transition-all">
<div>
<div class="flex items-center gap-3 mb-1">
<span class="mono text-orange-400 text-lg font-bold">
<template v-if="task.hour != null">{{ 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 :class="task.state ? 'text-green-400' : 'text-red-400'" class="text-[10px] font-bold uppercase">
{{ task.state ? 'ВКЛ' : 'ВЫКЛ' }}
</span>
</div>
<div class="flex items-center gap-2 text-[10px] text-slate-600">
<span class="mono">{{ task.target_id }}</span>
<span v-if="getGroupName(task.target_id)" class="text-slate-500">· {{ getGroupName(task.target_id) }}</span>
<span v-if="task.next_run">· {{ new Date(task.next_run).toLocaleDateString() }}</span>
</div>
</div>
<button @click="deleteTask(task.id)"
class="text-slate-700 hover:text-red-400 p-2 transition-colors opacity-0 group-hover:opacity-100">
<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>
</div>
<div v-if="tasks.length === 0" class="text-center py-12 text-slate-600 text-sm">Задач пока нет</div>
</div>
</div>
</div>
<!-- ═══════ АДМИНКА ═══════ -->
<div v-if="tab === 'admin' && !isLoading" class="space-y-8 fade-up">
<section class="glass p-6 rounded-2xl">
<div class="flex justify-between items-center mb-6">
<h2 class="text-lg font-black uppercase">Устройства в сети</h2>
<button @click="rescan" :disabled="isRescanning"
class="bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 px-4 py-2 rounded-lg text-xs font-bold transition-all disabled:opacity-50 flex items-center gap-2">
<span :class="isRescanning ? 'spinner' : ''" class="inline-block">🔄</span> СКАНИРОВАТЬ
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-10">
<input v-model="newGroup.id" placeholder="ID (например, bedroom)" class="bg-slate-900 border border-slate-700 p-4 rounded-xl focus:border-orange-500 outline-none">
<input v-model="newGroup.name" placeholder="Имя (например, Спальня)" class="bg-slate-900 border border-slate-700 p-4 rounded-xl focus:border-orange-500 outline-none">
<button @click="createGroup" :disabled="!newGroup.id || !newGroup.macs.length"
class="bg-orange-600 hover:bg-orange-500 disabled:opacity-30 rounded-xl font-black transition-all shadow-lg">СОЗДАТЬ ГРУППУ</button>
<!-- Создание группы -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-8">
<input v-model="newGroup.id" placeholder="ID (bedroom)"
class="bg-black/30 border border-slate-700/50 p-3 rounded-xl focus:border-orange-500 outline-none text-sm mono">
<input v-model="newGroup.name" placeholder="Название (Спальня)"
class="bg-black/30 border border-slate-700/50 p-3 rounded-xl focus:border-orange-500 outline-none text-sm">
<button @click="createGroup" :disabled="!newGroup.id || !newGroup.macs.length"
class="bg-orange-600 hover:bg-orange-500 disabled:opacity-30 disabled:cursor-not-allowed rounded-xl font-bold transition-all text-sm active:scale-[0.97]">
СОЗДАТЬ ГРУППУ
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-for="dev in devices" :key="dev.id"
class="bg-slate-900/50 border border-slate-800 p-4 rounded-2xl flex items-center justify-between hover:border-slate-600 transition-all">
<label class="flex items-center gap-4 cursor-pointer flex-1">
<input type="checkbox" :value="dev.id" v-model="newGroup.macs" class="w-5 h-5 accent-orange-500">
<!-- Список устройств -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<div v-for="dev in devices" :key="dev.id"
:class="newGroup.macs.includes(dev.id) ? 'border-orange-500/40 bg-orange-500/5' : 'border-slate-800/50'"
class="border p-3 rounded-xl flex items-center justify-between transition-all hover:border-slate-600">
<label class="flex items-center gap-3 cursor-pointer flex-1">
<input type="checkbox" :value="dev.id" v-model="newGroup.macs" class="w-4 h-4 accent-orange-500 rounded">
<div>
<p class="font-bold text-sm">{{ dev.id }}</p>
<p class="text-[10px] font-mono text-slate-500">{{ dev.ip }}</p>
<p class="font-bold text-sm mono">{{ dev.id }}</p>
<p class="text-[10px] mono text-slate-600">{{ dev.ip }}</p>
</div>
</label>
<button @click="blink(dev.id)" class="p-3 bg-slate-800 rounded-xl hover:bg-orange-600 transition-all shadow-md" title="Мигнуть лампой">👁️</button>
<button @click="blink(dev.id)" class="p-2 rounded-lg bg-slate-800/30 hover:bg-orange-600/30 hover:text-orange-400 transition-all text-slate-600" title="Мигнуть">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</button>
</div>
</div>
<div v-if="devices.length === 0" class="text-center py-10 text-slate-600 text-sm">
Устройства не найдены. Нажмите "Сканировать".
</div>
</section>
</div>
</template>
<!-- ═══════ Toast-уведомления ═══════ -->
<div class="fixed bottom-6 right-6 z-50 space-y-2">
<div v-for="(toast, i) in toasts" :key="toast.id"
:class="toast.type === 'error' ? 'border-red-500/30 text-red-300' : toast.type === 'success' ? 'border-green-500/30 text-green-300' : 'border-slate-700 text-slate-300'"
class="glass border px-5 py-3 rounded-xl text-sm font-medium toast-enter shadow-xl max-w-xs">
{{ toast.text }}
</div>
</div>
</div>
<script>
@@ -224,22 +355,21 @@
devices: [],
sliders: {},
newGroup: { id: '', name: '', macs: [] },
isLoading: false,
isLoadingStatus: false,
taskHour: '12', // Начальное значение часа
taskMin: '00', // Начальное значение минут
newTask: { target_id: '', time: '', state: true },
isFetching: false, // защита от параллельных fetchData
isRescanning: false,
taskHour: '22',
taskMin: '00',
newTask: { target_id: '', state: true },
tasks: [],
allScenes: [
"ocean", "romance", "party", "fireplace", "cozy", "forest",
"pastel_colors", "wake_up", "bedtime", "warm_white", "daylight",
"cool_white", "night_light", "focus", "relax", "true_colors",
"tv_time", "plant_growth", "spring", "summer", "fall", "deep_dive",
"jungle", "mojito", "club", "christmas", "halloween", "candlelight",
"golden_white", "pulse", "steampunk"
]
allScenes: {}, // загружается с бэкенда
toasts: [],
toastCounter: 0,
}
},
methods: {
// ─── Утилиты ─────────────────────────────────
saveKey() {
if (this.tempKey) {
this.apiKey = this.tempKey;
@@ -252,6 +382,21 @@
localStorage.removeItem('ignis_key');
location.reload();
},
// Toast-уведомление
toast(text, type = 'info', duration = 3000) {
const id = ++this.toastCounter;
this.toasts.push({ id, text, type });
setTimeout(() => {
this.toasts = this.toasts.filter(t => t.id !== id);
}, duration);
},
// Имя группы по id (для расписаний)
getGroupName(targetId) {
const g = this.groups[targetId];
return g ? g.name : null;
},
// ─── HTTP ────────────────────────────────────
async request(path, method = 'GET', params = null, body = null) {
let url = path;
if (params) {
@@ -261,147 +406,203 @@
try {
const response = await fetch(url, {
method,
headers: {
headers: {
'X-API-Key': this.apiKey,
'Content-Type': 'application/json'
},
body: body ? JSON.stringify(body) : null
});
if (response.status === 403) { this.logout(); return null; }
if (!response.ok) return null;
if (response.status === 403) {
this.toast('Неверный API-ключ', 'error');
this.logout();
return null;
}
if (!response.ok) {
const err = await response.json().catch(() => ({}));
this.toast(err.detail || `Ошибка ${response.status}`, 'error');
return null;
}
return await response.json();
} catch (e) { return null; }
},
async fetchData() {
if (!this.apiKey) return;
const gData = await this.request('/devices/groups');
const dData = await this.request('/devices');
if (gData) {
this.groups = gData;
// Инициализируем слайдеры для новых групп
Object.keys(this.groups).forEach(id => {
if (!this.sliders[id]) {
this.sliders[id] = { brightness: 100, temp: 3000, state: false };
}
});
// Сразу после получения списка групп — запрашиваем их состояние
await this.syncGroupStatuses();
} catch (e) {
this.toast('Сервер недоступен', 'error');
return null;
}
if (dData) this.devices = Object.values(dData);
this.fetchTasks();
},
async control(id, params) {
// ─── Данные ──────────────────────────────────
async fetchData() {
if (!this.apiKey || this.isFetching) return;
this.isFetching = true;
try {
const [gData, dData, sData] = await Promise.all([
this.request('/devices/groups'),
this.request('/devices'),
this.request('/devices/scenes'),
]);
if (gData) {
this.groups = gData;
// Инициализируем слайдеры для новых групп
Object.keys(this.groups).forEach(id => {
if (!this.sliders[id]) {
this.sliders[id] = { brightness: 100, temp: 4000, state: false };
}
});
await this.syncGroupStatuses();
}
if (dData) {
// Бэкенд может вернуть dict или list
this.devices = Array.isArray(dData) ? dData : Object.values(dData);
}
// Сцены с бэкенда (dict: {name: sceneId})
if (sData) this.allScenes = sData;
this.fetchTasks();
} finally {
this.isFetching = false;
}
},
// ─── Управление ──────────────────────────────
async control(id, params) {
await this.request(`/control/group/${id}`, 'POST', params);
await this.syncGroupStatuses();
// Не делаем полный syncGroupStatuses -- ждём следующий цикл
},
toggleGroup(id, state) {
// Оптимистично меняем состояние в интерфейсе
toggleGroup(id, state) {
if (this.sliders[id]) {
this.sliders[id].state = state;
}
this.control(id, { state: state });
this.control(id, { state });
},
setBrightness(id, val) {
if (this.sliders[id]) this.sliders[id].brightness = val;
this.control(id, { brightness: val });
},
setTemp(id, val) {
if (this.sliders[id]) this.sliders[id].temp = val;
this.control(id, { temp: val });
},
setBrightness(id, val) { this.control(id, { brightness: val }); },
setTemp(id, val) { this.control(id, { temp: val }); },
setScene(id, scene) { this.control(id, { scene }); },
setColor(id, hex) {
const r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16);
const r = parseInt(hex.slice(1,3),16);
const g = parseInt(hex.slice(3,5),16);
const b = parseInt(hex.slice(5,7),16);
this.control(id, { r, g, b });
},
// ─── Группы ──────────────────────────────────
async createGroup() {
const res = await this.request('/devices/groups', 'POST', null, {
id: this.newGroup.id, name: this.newGroup.name, macs: this.newGroup.macs
id: this.newGroup.id,
name: this.newGroup.name,
macs: this.newGroup.macs
});
if (res) {
this.toast(`Группа "${this.newGroup.name}" создана`, 'success');
this.newGroup = { id: '', name: '', macs: [] };
await this.fetchData();
this.tab = 'control';
}
},
async deleteGroup(id) {
if (confirm(`Удалить группу ${id}?`)) {
const name = this.groups[id]?.name || id;
if (confirm(`Удалить группу "${name}"?`)) {
await this.request(`/devices/groups/${id}`, 'DELETE');
this.toast(`Группа "${name}" удалена`, 'success');
await this.fetchData();
}
},
async rescan() {
this.isRescanning = true;
await this.request('/devices/rescan', 'POST');
setTimeout(this.fetchData, 2000);
this.toast('Сканирование запущено...', 'info');
setTimeout(async () => {
await this.fetchData();
this.isRescanning = false;
this.toast(`Найдено ${this.devices.length} устройств`, 'success');
}, 3000);
},
async blink(deviceId) { await this.request(`/control/device/${deviceId}/blink`, 'POST'); },
// ─── Синхронизация состояния ─────────────────
async syncGroupStatuses() {
if (this.isLoadingStatus) return;
this.isLoadingStatus = true;
for (const groupId of Object.keys(this.groups)) {
const data = await this.request(`/control/group/${groupId}/status`);
if (data && data.results && data.results.length > 0) {
// Берем состояние первой доступной лампы в группе как эталонное
const firstValid = data.results.find(r => r.status && !r.error);
if (firstValid) {
const s = firstValid.status;
// Обновляем ползунки, если пользователь ими сейчас не двигает
this.sliders[groupId] = {
brightness: s.dimming || 100,
temp: s.temp || 3000,
state: s.state
};
try {
// Параллельный опрос всех групп
const groupIds = Object.keys(this.groups);
const results = await Promise.all(
groupIds.map(id => this.request(`/control/group/${id}/status`))
);
groupIds.forEach((id, i) => {
const data = results[i];
if (data && data.results && data.results.length > 0) {
const firstValid = data.results.find(r => r.status && !r.error);
if (firstValid) {
const s = firstValid.status;
this.sliders[id] = {
brightness: s.dimming || 100,
temp: s.temp || 4000,
state: s.state || false,
};
}
}
}
});
} finally {
this.isLoadingStatus = false;
}
this.isLoadingStatus = false;
},
// ─── Расписания ──────────────────────────────
async fetchTasks() {
const data = await this.request('/schedules/tasks');
if (data) this.tasks = data.tasks;
if (data) this.tasks = data.tasks || [];
},
async addSchedule() {
// Проверяем, выбрана ли группа
if (!this.newTask.target_id) {
alert("Выбери группу!");
this.toast('Выберите группу', 'error');
return;
}
// Отправляем данные. Теперь берем их прямо из taskHour и taskMin
await this.request('/schedules/cron', 'POST', {
const res = await this.request('/schedules/cron', 'POST', {
target_id: this.newTask.target_id,
hour: this.taskHour,
minute: this.taskMin,
is_group: true,
state: this.newTask.state
});
this.fetchTasks(); // Обновляем список
if (res) {
this.toast(`Задача добавлена: ${this.taskHour}:${this.taskMin}`, 'success');
this.fetchTasks();
}
},
async deleteTask(id) {
await this.request(`/schedules/${id}`, 'DELETE');
this.toast('Задача отменена', 'success');
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.toast('Таймер 4ч установлен', 'success');
this.fetchTasks();
} else {
console.error("Бэкенд проигнорировал запрос таймера");
}
},
},
mounted() {
async mounted() {
if (this.apiKey) {
this.fetchData();
setInterval(this.fetchData, 15000);
this.isLoading = true;
await this.fetchData();
this.isLoading = false;
// Периодический опрос с защитой от наложений
setInterval(() => this.fetchData(), 15000);
}
}
}).mount('#app')