diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..61e700e --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# API-ключ для авторизации (если не задан -- авторизация отключена) +IGNIS_API_KEY= + +# Таймзона для расписаний +APP_TIMEZONE=Asia/Almaty + +# Подсети для сканирования (через запятую, пусто = автоопределение) +SCAN_NETWORK= + +# Уровень логирования (DEBUG, INFO, WARNING, ERROR) +LOG_LEVEL=INFO diff --git a/README.md b/README.md new file mode 100644 index 0000000..9e16a56 --- /dev/null +++ b/README.md @@ -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://: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) + +## Лицензия + +Частный проект. diff --git a/app/api/deps.py b/app/api/deps.py index 1cc351f..254cad4 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -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) diff --git a/app/api/routes/control.py b/app/api/routes/control.py index 480d87c..dfc2adc 100644 --- a/app/api/routes/control.py +++ b/app/api/routes/control.py @@ -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() diff --git a/app/api/routes/schedules.py b/app/api/routes/schedules.py index d5eec6f..1e30ce5 100644 --- a/app/api/routes/schedules.py +++ b/app/api/routes/schedules.py @@ -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="Задача не найдена") diff --git a/app/drivers/wiz.py b/app/drivers/wiz.py index f02eb20..7dcd3f7 100644 --- a/app/drivers/wiz.py +++ b/app/drivers/wiz.py @@ -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() diff --git a/static/index.html b/static/index.html index 01183a5..4774cc2 100644 --- a/static/index.html +++ b/static/index.html @@ -4,175 +4,212 @@ IGNIS | Smart Control + - + -
- -
-
-
- 🔐 -

Ignis Access

-

Введите API ключ из .env

+
+ + +
+
+
+
- Ignis +

API-ключ из .env сервера

+ -
+ + + +
+
+ {{ toast.text }} +
+