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

View File

@@ -1,10 +1,13 @@
import asyncio import asyncio
import logging
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from app.core.state import state_manager from app.core.state import state_manager
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
logger = logging.getLogger(__name__)
router = APIRouter(dependencies=[Depends(verify_token)]) router = APIRouter(dependencies=[Depends(verify_token)])
wiz = WizDriver() wiz = WizDriver()

View File

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

View File

@@ -42,7 +42,7 @@ class WizDriver:
} }
async def send_udp(self, ip: str, payload: dict): 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: with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.settimeout(2.0) sock.settimeout(2.0)
data = json.dumps(payload).encode() data = json.dumps(payload).encode()

View File

@@ -4,212 +4,343 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IGNIS | Smart Control</title> <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://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://cdn.tailwindcss.com"></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> <style>
: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 { body {
background: radial-gradient(circle at top right, #1e1b4b, #0f172a); background: var(--bg-deep);
color: #f8fafc; color: #e2e8f0;
font-family: 'Inter', sans-serif; font-family: 'Outfit', sans-serif;
min-height: 100vh; 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); } body::before {
input[type="range"] { -webkit-appearance: none; height: 6px; border-radius: 10px; background: #334155; outline: none; } content: '';
input[type="range"]::-webkit-slider-thumb { position: fixed; inset: 0; z-index: -1;
-webkit-appearance: none; width: 18px; height: 18px; background:
background: #fff; border-radius: 50%; cursor: pointer; border: 3px solid #f97316; 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; } .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> </style>
</head> </head>
<body> <body>
<div id="app" class="max-w-6xl mx-auto p-4 md:p-10"> <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-slate-950/90 backdrop-blur-sm p-4"> <!-- ═══════ Экран авторизации ═══════ -->
<div class="glass p-8 rounded-[2.5rem] w-full max-w-md shadow-2xl"> <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="text-center mb-8"> <div class="glass p-10 rounded-3xl w-full max-w-sm shadow-2xl fade-up text-center">
<span class="text-5xl mb-4 block">🔐</span> <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">
<h2 class="text-2xl font-black uppercase tracking-tighter">Ignis Access</h2> <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>
<p class="text-slate-400 text-sm mt-2">Введите API ключ из .env</p>
</div> </div>
<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" <input v-model="tempKey" type="password" placeholder="X-API-Key"
@keyup.enter="saveKey" @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"> 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-4 rounded-2xl font-bold transition-all shadow-lg shadow-orange-900/40"> <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> </button>
</div> </div>
</div> </div>
<!-- ═══════ Основной интерфейс ═══════ -->
<template v-else> <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"> <header class="flex flex-col md:flex-row justify-between items-center mb-10 gap-5 fade-up">
<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 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>
<div> <div>
<h1 class="text-4xl font-extrabold tracking-tighter uppercase italic">Ignis<span class="text-orange-500">Core</span></h1> <h1 class="text-2xl font-black tracking-tight uppercase">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> <button @click="logout" class="text-[9px] text-slate-600 hover:text-red-400 uppercase font-bold tracking-widest transition-colors">выйти</button>
</div> </div>
</div> </div>
<nav class="flex glass p-1.5 rounded-2xl"> <nav class="flex glass p-1 rounded-xl">
<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 v-for="t in [{id:'control',label:'ПУЛЬТ'},{id:'schedules',label:'ГРАФИК'},{id:'admin',label:'АДМИНКА'}]"
<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> :key="t.id" @click="tab = t.id"
<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> :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> </nav>
</header> </header>
<div v-if="tab === 'schedules'" class="space-y-10"> <!-- Индикатор загрузки -->
<div class="glass p-8 rounded-[2.5rem]"> <div v-if="isLoading" class="flex justify-center py-20">
<h2 class="text-2xl font-black mb-6 uppercase italic">Новая задача</h2> <div class="w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full spinner"></div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4"> </div>
<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> <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-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-xl font-black text-white flex items-center gap-2">
{{ group.name }}
<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] mono text-slate-600">
{{ id }} · {{ group.device_ids?.length || 0 }} ламп
</span>
</div>
<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="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="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-5">
<!-- Яркость -->
<div>
<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-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-4">
<div>
<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-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>
</div>
</div>
</div>
<!-- ═══════ РАСПИСАНИЯ ═══════ -->
<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> </select>
<div class="flex items-center gap-2 bg-slate-900 border border-slate-700 p-2 rounded-xl"> <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"> <select v-model="taskHour" class="bg-transparent outline-none flex-1 text-center font-bold text-sm">
<option v-for="h in 24" :value="(h-1).toString().padStart(2, '0')">{{ (h-1).toString().padStart(2, '0') }}</option> <option v-for="h in 24" :key="h" :value="String(h-1).padStart(2,'0')">{{ String(h-1).padStart(2,'0') }}</option>
</select> </select>
<span class="font-bold">:</span> <span class="font-bold text-slate-500">:</span>
<select v-model="taskMin" class="bg-transparent outline-none flex-1 text-center font-bold"> <select v-model="taskMin" class="bg-transparent outline-none flex-1 text-center font-bold text-sm">
<option v-for="m in 60" :value="(m-1).toString().padStart(2, '0')">{{ (m-1).toString().padStart(2, '0') }}</option> <option v-for="m in 60" :key="m" :value="String(m-1).padStart(2,'0')">{{ String(m-1).padStart(2,'0') }}</option>
</select> </select>
</div> </div>
<select v-model="newTask.state" class="bg-slate-900 border border-slate-700 p-4 rounded-xl outline-none"> <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="true">ВКЛЮЧИТЬ</option>
<option :value="false">ВЫКЛЮЧИТЬ</option> <option :value="false">ВЫКЛЮЧИТЬ</option>
</select> </select>
<button @click="addSchedule" class="bg-orange-600 hover:bg-orange-500 rounded-xl font-black transition-all">ДОБАВИТЬ</button>
<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> </div>
<div class="glass p-8 rounded-[2.5rem]"> <!-- Список задач -->
<h2 class="text-2xl font-black mb-6 uppercase italic">Активные задачи</h2> <div class="glass p-6 rounded-2xl">
<div class="space-y-4"> <h2 class="text-lg font-black mb-5 uppercase">Активные задачи</h2>
<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 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>
<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"> <span class="mono text-orange-400 text-lg font-bold">
<template v-if="task.hour !== null && task.hour !== undefined"> <template v-if="task.hour != null">{{ String(task.hour).padStart(2,'0') }}:{{ String(task.minute).padStart(2,'0') }}</template>
{{ String(task.hour).padStart(2, '0') }}:{{ String(task.minute).padStart(2, '0') }} <template v-else-if="task.next_run">{{ new Date(task.next_run).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}) }}</template>
</template> </span>
<template v-else-if="task.next_run"> <span :class="task.state ? 'text-green-400' : 'text-red-400'" class="text-[10px] font-bold uppercase">
{{ new Date(task.next_run).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}) }} {{ task.state ? 'ВКЛ' : 'ВЫКЛ' }}
</template>
</span> </span>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2 text-[10px] text-slate-600">
<span :class="task.state ? 'text-green-400' : 'text-red-400'" class="text-xs font-bold uppercase"> <span class="mono">{{ task.target_id }}</span>
{{ task.state ? 'ВКЛЮЧИТЬ' : 'ВЫКЛЮЧИТЬ' }} <span v-if="getGroupName(task.target_id)" class="text-slate-500">· {{ getGroupName(task.target_id) }}</span>
</span> <span v-if="task.next_run">· {{ new Date(task.next_run).toLocaleDateString() }}</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>
</div> </div>
<button @click="deleteTask(task.id)" class="text-slate-600 hover:text-red-500 p-2 transition-colors"> <button @click="deleteTask(task.id)"
<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> class="text-slate-700 hover:text-red-400 p-2 transition-colors opacity-0 group-hover:opacity-100">
</button>
</div>
<div v-if="tasks.length === 0" class="text-center py-10 text-slate-500 italic">Задач пока нет</div>
</div>
</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>
<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>
<h2 class="text-2xl 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>
</h2>
<span class="text-[10px] font-mono text-slate-500 uppercase tracking-tighter">
{{ 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> <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"> </div>
<span>🕒</span> <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> </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>
</div>
</div> </div>
<div class="space-y-8"> <!-- Создание группы -->
<div> <div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-8">
<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 v-model="newGroup.id" placeholder="ID (bedroom)"
<input type="range" min="10" max="100" class="w-full" v-model="sliders[id].brightness" @change="setBrightness(id, $event.target.value)"> class="bg-black/30 border border-slate-700/50 p-3 rounded-xl focus:border-orange-500 outline-none text-sm mono">
</div> <input v-model="newGroup.name" placeholder="Название (Спальня)"
<div> class="bg-black/30 border border-slate-700/50 p-3 rounded-xl focus:border-orange-500 outline-none text-sm">
<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>
<div class="grid grid-cols-2 gap-6">
<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)">
</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>
</select>
</div>
</div>
</div>
</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>
<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" <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> 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="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <!-- Список устройств -->
<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" <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"> :class="newGroup.macs.includes(dev.id) ? 'border-orange-500/40 bg-orange-500/5' : 'border-slate-800/50'"
<label class="flex items-center gap-4 cursor-pointer flex-1"> class="border p-3 rounded-xl flex items-center justify-between transition-all hover:border-slate-600">
<input type="checkbox" :value="dev.id" v-model="newGroup.macs" class="w-5 h-5 accent-orange-500"> <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> <div>
<p class="font-bold text-sm">{{ dev.id }}</p> <p class="font-bold text-sm mono">{{ dev.id }}</p>
<p class="text-[10px] font-mono text-slate-500">{{ dev.ip }}</p> <p class="text-[10px] mono text-slate-600">{{ dev.ip }}</p>
</div> </div>
</label> </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> </div>
<div v-if="devices.length === 0" class="text-center py-10 text-slate-600 text-sm">
Устройства не найдены. Нажмите "Сканировать".
</div>
</section> </section>
</div> </div>
</template> </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> </div>
<script> <script>
@@ -224,22 +355,21 @@
devices: [], devices: [],
sliders: {}, sliders: {},
newGroup: { id: '', name: '', macs: [] }, newGroup: { id: '', name: '', macs: [] },
isLoading: false,
isLoadingStatus: false, isLoadingStatus: false,
taskHour: '12', // Начальное значение часа isFetching: false, // защита от параллельных fetchData
taskMin: '00', // Начальное значение минут isRescanning: false,
newTask: { target_id: '', time: '', state: true }, taskHour: '22',
taskMin: '00',
newTask: { target_id: '', state: true },
tasks: [], tasks: [],
allScenes: [ allScenes: {}, // загружается с бэкенда
"ocean", "romance", "party", "fireplace", "cozy", "forest", toasts: [],
"pastel_colors", "wake_up", "bedtime", "warm_white", "daylight", toastCounter: 0,
"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"
]
} }
}, },
methods: { methods: {
// ─── Утилиты ─────────────────────────────────
saveKey() { saveKey() {
if (this.tempKey) { if (this.tempKey) {
this.apiKey = this.tempKey; this.apiKey = this.tempKey;
@@ -252,6 +382,21 @@
localStorage.removeItem('ignis_key'); localStorage.removeItem('ignis_key');
location.reload(); 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) { async request(path, method = 'GET', params = null, body = null) {
let url = path; let url = path;
if (params) { if (params) {
@@ -267,141 +412,197 @@
}, },
body: body ? JSON.stringify(body) : null body: body ? JSON.stringify(body) : null
}); });
if (response.status === 403) { this.logout(); return null; } if (response.status === 403) {
if (!response.ok) return null; 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(); return await response.json();
} catch (e) { return null; } } catch (e) {
this.toast('Сервер недоступен', 'error');
return null;
}
}, },
// ─── Данные ──────────────────────────────────
async fetchData() { async fetchData() {
if (!this.apiKey) return; if (!this.apiKey || this.isFetching) return;
const gData = await this.request('/devices/groups'); this.isFetching = true;
const dData = await this.request('/devices');
try {
const [gData, dData, sData] = await Promise.all([
this.request('/devices/groups'),
this.request('/devices'),
this.request('/devices/scenes'),
]);
if (gData) { if (gData) {
this.groups = gData; this.groups = gData;
// Инициализируем слайдеры для новых групп // Инициализируем слайдеры для новых групп
Object.keys(this.groups).forEach(id => { Object.keys(this.groups).forEach(id => {
if (!this.sliders[id]) { if (!this.sliders[id]) {
this.sliders[id] = { brightness: 100, temp: 3000, state: false }; this.sliders[id] = { brightness: 100, temp: 4000, state: false };
} }
}); });
// Сразу после получения списка групп — запрашиваем их состояние
await this.syncGroupStatuses(); await this.syncGroupStatuses();
} }
if (dData) this.devices = Object.values(dData); if (dData) {
// Бэкенд может вернуть dict или list
this.devices = Array.isArray(dData) ? dData : Object.values(dData);
}
// Сцены с бэкенда (dict: {name: sceneId})
if (sData) this.allScenes = sData;
this.fetchTasks(); this.fetchTasks();
} finally {
this.isFetching = false;
}
}, },
// ─── Управление ──────────────────────────────
async control(id, params) { async control(id, params) {
await this.request(`/control/group/${id}`, 'POST', params); await this.request(`/control/group/${id}`, 'POST', params);
await this.syncGroupStatuses(); // Не делаем полный syncGroupStatuses -- ждём следующий цикл
}, },
toggleGroup(id, state) { toggleGroup(id, state) {
// Оптимистично меняем состояние в интерфейсе
if (this.sliders[id]) { if (this.sliders[id]) {
this.sliders[id].state = state; 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 }); }, setScene(id, scene) { this.control(id, { scene }); },
setColor(id, hex) { 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 }); this.control(id, { r, g, b });
}, },
// ─── Группы ──────────────────────────────────
async createGroup() { async createGroup() {
const res = await this.request('/devices/groups', 'POST', null, { 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) { if (res) {
this.toast(`Группа "${this.newGroup.name}" создана`, 'success');
this.newGroup = { id: '', name: '', macs: [] }; this.newGroup = { id: '', name: '', macs: [] };
await this.fetchData(); await this.fetchData();
this.tab = 'control'; this.tab = 'control';
} }
}, },
async deleteGroup(id) { async deleteGroup(id) {
if (confirm(`Удалить группу ${id}?`)) { const name = this.groups[id]?.name || id;
if (confirm(`Удалить группу "${name}"?`)) {
await this.request(`/devices/groups/${id}`, 'DELETE'); await this.request(`/devices/groups/${id}`, 'DELETE');
this.toast(`Группа "${name}" удалена`, 'success');
await this.fetchData(); await this.fetchData();
} }
}, },
async rescan() { async rescan() {
this.isRescanning = true;
await this.request('/devices/rescan', 'POST'); 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 blink(deviceId) { await this.request(`/control/device/${deviceId}/blink`, 'POST'); },
// ─── Синхронизация состояния ─────────────────
async syncGroupStatuses() { async syncGroupStatuses() {
if (this.isLoadingStatus) return; if (this.isLoadingStatus) return;
this.isLoadingStatus = true; this.isLoadingStatus = true;
for (const groupId of Object.keys(this.groups)) { try {
const data = await this.request(`/control/group/${groupId}/status`); // Параллельный опрос всех групп
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) { if (data && data.results && data.results.length > 0) {
// Берем состояние первой доступной лампы в группе как эталонное
const firstValid = data.results.find(r => r.status && !r.error); const firstValid = data.results.find(r => r.status && !r.error);
if (firstValid) { if (firstValid) {
const s = firstValid.status; const s = firstValid.status;
// Обновляем ползунки, если пользователь ими сейчас не двигает this.sliders[id] = {
this.sliders[groupId] = {
brightness: s.dimming || 100, brightness: s.dimming || 100,
temp: s.temp || 3000, temp: s.temp || 4000,
state: s.state state: s.state || false,
}; };
} }
} }
} });
} finally {
this.isLoadingStatus = false; this.isLoadingStatus = false;
}
}, },
// ─── Расписания ──────────────────────────────
async fetchTasks() { async fetchTasks() {
const data = await this.request('/schedules/tasks'); const data = await this.request('/schedules/tasks');
if (data) this.tasks = data.tasks; if (data) this.tasks = data.tasks || [];
}, },
async addSchedule() { async addSchedule() {
// Проверяем, выбрана ли группа
if (!this.newTask.target_id) { if (!this.newTask.target_id) {
alert("Выбери группу!"); this.toast('Выберите группу', 'error');
return; return;
} }
const res = await this.request('/schedules/cron', 'POST', {
// Отправляем данные. Теперь берем их прямо из taskHour и taskMin
await this.request('/schedules/cron', 'POST', {
target_id: this.newTask.target_id, target_id: this.newTask.target_id,
hour: this.taskHour, hour: this.taskHour,
minute: this.taskMin, minute: this.taskMin,
is_group: true, is_group: true,
state: this.newTask.state state: this.newTask.state
}); });
if (res) {
this.fetchTasks(); // Обновляем список this.toast(`Задача добавлена: ${this.taskHour}:${this.taskMin}`, 'success');
this.fetchTasks();
}
}, },
async deleteTask(id) { async deleteTask(id) {
await this.request(`/schedules/${id}`, 'DELETE'); await this.request(`/schedules/${id}`, 'DELETE');
this.toast('Задача отменена', 'success');
this.fetchTasks(); this.fetchTasks();
}, },
async setTimer4h(id) { async setTimer4h(id) {
// 1. Включаем свет сразу
await this.toggleGroup(id, true); await this.toggleGroup(id, true);
// 2. Шлём запрос на таймер.
const res = await this.request('/schedules/once', 'POST', { const res = await this.request('/schedules/once', 'POST', {
target_id: id, target_id: id,
hours_from_now: 4, hours_from_now: 4,
is_group: true, is_group: true,
state: false state: false
}); });
if (res) { if (res) {
console.log("Таймер успешно создан:", res); this.toast('Таймер 4ч установлен', 'success');
this.fetchTasks(); this.fetchTasks();
} else {
console.error("Бэкенд проигнорировал запрос таймера");
} }
}, },
}, },
mounted() { async mounted() {
if (this.apiKey) { if (this.apiKey) {
this.fetchData(); this.isLoading = true;
setInterval(this.fetchData, 15000); await this.fetchData();
this.isLoading = false;
// Периодический опрос с защитой от наложений
setInterval(() => this.fetchData(), 15000);
} }
} }
}).mount('#app') }).mount('#app')