Fix API regressions and refresh project docs

This commit is contained in:
Artem Kokos
2026-05-15 23:12:28 +07:00
parent 654f64bb90
commit 13fba2fa44
19 changed files with 3258 additions and 964 deletions

View File

@@ -1,323 +0,0 @@
# Ignis Core Roadmap
Этот roadmap собран по итогам полного обзора текущего состояния `ignis-core`.
Он нужен как рабочий план, а не как абстрактный wishlist.
## Цели проекта
1. Сделать сервер безопасным по умолчанию.
2. Убрать ложные успехи и хрупкое поведение при работе с лампами и сетью.
3. Привести API, UI и документацию к одному контракту.
4. Подготовить кодовую базу к развитию без накопления хаоса.
5. Довести продукт до состояния "можно спокойно жить в проде дома".
## Текущее состояние в двух словах
- Бэкенд маленький и понятный, но уже накопил архитектурный долг.
- Основная логика работает на in-memory state и прямых вызовах драйвера.
- Безопасность и роли реализованы опасно и неполно.
- Фронтенд функциональный, но монолитный и плохо масштабируется.
- Тестовой, миграционной и операционной инфраструктуры почти нет.
## P0: Критично исправить прежде всего
### P0.1 Безопасность по умолчанию
Проблемы:
- При пустом `IGNIS_API_KEY` сервер открывает полный доступ всем.
- Любой `admin`-ключ может управлять всеми гостевыми ключами.
- Полные API-ключи возвращаются из списка ключей.
- Текущий ключ хранится в `localStorage`.
- UI зависит от внешних CDN, что плохо и для безопасности, и для офлайна.
Что сделать:
1. Перевести авторизацию в `fail-closed`.
2. Явно разделить роли `master`, `admin`, `guest`.
3. Разрешить управление API-ключами только мастер-доступу.
4. Перестать возвращать полные ключи из `GET /api-keys`.
5. Возвращать полный токен только в момент создания.
6. Продумать более безопасное хранение ключа в браузере.
7. По возможности убрать CDN-зависимости или хотя бы сделать локальный fallback.
Критерий готовности:
- Сервер без мастер-ключа не выдаёт админ-доступ.
- Гостевой `admin` не может создавать или отзывать другие ключи.
- Повторный запрос списка ключей не раскрывает секреты.
### P0.2 Надёжность команд и статусов
Проблемы:
- Таймауты WiZ дают `None`, а код затем вызывает `.get(...)`.
- Групповое управление отвечает `ok`, даже если отправка провалилась.
- Логирование фиксирует действие без подтверждения результата.
- Ошибка одной лампы может сломать групповой status endpoint.
Что сделать:
1. Нормализовать ответ драйвера: success, timeout, error, payload.
2. Привести все control/status endpoints к одному формату ошибок.
3. Возвращать частичный результат по группам, а не фальшивый успех.
4. Логировать отдельно intent и фактический outcome.
5. Развести ошибки сети, таймауты, офлайн и ошибки протокола.
Критерий готовности:
- Один timeout не валит весь запрос.
- API честно сообщает, сколько устройств обработано успешно.
- Логи не врут о выполнении команды.
### P0.3 Исправление расписаний
Проблемы:
- `cron`-задачи могут затирать друг друга из-за конфликтующих `job_id`.
- Расписания умеют почти только `on/off`.
- Отсутствует нормальная модель хранения пользовательских задач.
- В коде есть мёртвая модель `ScheduleTask`, не совпадающая с реальностью.
Что сделать:
1. Исправить формирование `job_id`.
2. Ввести явную доменную модель расписания.
3. Решить, где источник истины: APScheduler jobstore, своя таблица или оба слоя.
4. Добавить payload для brightness, temp, scene, color.
5. Добавить валидацию cron/once запросов.
6. Сделать idempotent CRUD для задач.
Критерий готовности:
- Разные задачи не затирают друг друга.
- Пользователь может создавать и редактировать полезные сценарии, а не только toggle.
- В модели и БД нет мёртвых или вводящих в заблуждение сущностей.
## P1: Высокий приоритет
### P1.1 Привести API к нормальному контракту
Проблемы:
- Почти все POST-эндпоинты принимают query-параметры.
- Нет явных request/response schemas.
- Нет нормальной валидации диапазонов и конфликтов полей.
- `openapi.json`, код и README расходятся.
Что сделать:
1. Перевести команды управления и расписаний на JSON body.
2. Описать Pydantic-модели для всех запросов и ответов.
3. Добавить строгую валидацию:
- brightness range
- temp range
- RGB range
- допустимые комбинации scene/temp/rgb
4. Нормализовать коды ошибок и detail messages.
5. Генерировать `openapi.json` из кода и перестать хранить его как случайный артефакт.
6. Обновить README под фактический API.
Критерий готовности:
- Фронт, бэк и OpenAPI описывают одно и то же.
- Внешнему клиенту не нужно угадывать, что именно принимает endpoint.
### P1.2 Починить и переосмыслить discovery
Проблемы:
- README обещает broadcast, а код делает unicast по всей подсети.
- Фоновый discovery не удаляет офлайн-устройства.
- Автоопределение сети упрощённое и часто неверное.
- Большие подсети молча режутся.
Что сделать:
1. Определиться с настоящей стратегией discovery.
2. Привести README к реальной реализации.
3. Добавить понятную стратегию offline detection.
4. Развести initial scan, manual rescan и background refresh.
5. Сохранять полезные метаданные устройства, если они доступны.
6. Логировать сканирование структурированно, а не только строками.
Критерий готовности:
- Список устройств стабилен и предсказуем.
- Пользователь понимает, что именно сканируется и почему устройство исчезло.
### P1.3 Привести event log и stats к реальности
Проблемы:
- Логируются почти только `toggle_on/off`.
- UI уже показывает аналитику по scene/color/brightness/temp, но backend её не считает.
- Оценка часов работы грубая и легко искажается.
Что сделать:
1. Сделать нормальную модель событий:
- command_requested
- command_applied
- command_failed
- schedule_triggered
2. Хранить полезные параметры структурированно.
3. Переписать stats на основе реальных типов событий.
4. Либо убрать из UI несуществующие метрики, либо реально их реализовать.
Критерий готовности:
- Статистика отражает то, что реально происходило.
- UI не обещает данные, которых нет.
## P2: Поддерживаемость и развитие
### P2.1 Разобрать архитектурный долг
Проблемы:
- В одном месте SQLAlchemy-модели, в другом in-memory state, в третьем прямые драйверные вызовы.
- Есть мёртвые модели и недоведённый дизайн.
- Границы между доменной логикой, API и интеграцией размыты.
Что сделать:
1. Ввести сервисный слой:
- auth service
- device service
- group service
- control service
- schedule service
- stats service
2. Отделить transport layer от доменной логики.
3. Удалить или довести до конца мёртвые сущности.
4. Вынести общие DTO и контракты.
5. Перестать хранить SQLAlchemy-объекты прямо в `state_manager`.
Критерий готовности:
- Роуты тонкие.
- Бизнес-логика тестируется без HTTP.
- Структура проекта подсказывает, где что живёт.
### P2.2 Нормальный фронтенд-контур
Проблемы:
- Весь UI живёт в одном `static/index.html`.
- Нет компонентности, тестов, сборки и типизации.
- Интерфейс уже перерос формат одного файла.
Что сделать:
1. Решить, остаётся ли UI встроенным SPA или выносится в отдельный frontend package.
2. Если остаётся встроенным:
- разбить на компоненты
- завести build pipeline
- локализовать ассеты
3. Если выносится:
- описать стабильный API-контракт
- организовать сборку и публикацию статики
4. Добавить базовые UI-тесты хотя бы на критические потоки.
Критерий готовности:
- Добавление новой вкладки или формы не требует править гигантский HTML-файл.
### P2.3 Тесты и инженерная обвязка
Сейчас не хватает:
- unit tests
- integration tests
- API smoke tests
- linting
- CI
- миграций
- health endpoint
- backup/restore стратегии
- нормальной операционной документации
Что сделать:
1. Добавить `pytest`.
2. Покрыть минимум:
- auth
- api keys permissions
- control param validation
- schedules
- stats aggregation
3. Добавить миграции, вероятнее всего через Alembic.
4. Добавить `/health` и `/ready`.
5. Добавить базовый CI pipeline.
6. Описать развёртывание и обновление без потери данных.
Критерий готовности:
- Любое опасное изменение ловится до ручной проверки на живых лампах.
## P3: Продуктовые улучшения
### Фичи, которых явно не хватает
1. Редактирование групп, а не только создание и удаление.
2. Нормальные имена устройств и комнат.
3. Управление отдельными устройствами из UI.
4. Расписания с payload, а не только `on/off`.
5. Шаблоны сценариев:
- bedtime
- morning
- away mode
- timer presets
6. История действий и журнал ошибок в UI.
7. Понятный onboarding при первом запуске.
8. Индикация реального статуса подключения и последнего ответа лампы.
9. Импорт/экспорт конфигурации групп и ключей.
10. Более точное разграничение прав между домашними ролями.
## Рекомендуемый порядок выполнения
### Этап 1
- P0.1 Безопасность по умолчанию
- P0.2 Надёжность команд и статусов
### Этап 2
- P0.3 Расписания
- P1.1 Контракт API
### Этап 3
- P1.2 Discovery
- P1.3 Event log и stats
### Этап 4
- P2.1 Архитектурная чистка
- P2.3 Тесты и инфраструктура
### Этап 5
- P2.2 Фронтенд-контур
- P3 Продуктовые улучшения
## Анти-цели
Пока не стоит:
- Бездумно наращивать фичи поверх текущих проблем безопасности.
- Делать косметический рефакторинг без закрытия P0.
- Добавлять новый клиентский функционал без фикса API-контракта.
- Трогать много слоёв сразу без тестового контура.
## Definition of Done для проекта
Проект можно считать приведённым в сильное состояние, когда:
1. Нет полного доступа без явной конфигурации безопасности.
2. Команды и расписания честно отражают результат выполнения.
3. API, фронтенд и документация синхронизированы.
4. Есть тестовый минимум на критические сценарии.
5. Нет мёртвых моделей и двусмысленных источников истины.
6. UI и backend можно развивать без страха всё сломать.

View File

@@ -1,294 +0,0 @@
# Ignis Core Master Prompt
Этот документ предназначен для следующей рабочей сессии по `ignis-core`.
Он описывает, как нужно думать, исследовать, исправлять и развивать проект.
Важно: этот prompt собран внутри текущего репозитория и не основан напрямую на файле из `ignis_app`, потому что в этой сессии запрещено выходить выше директории `ignis-core`.
## Роль
Ты работаешь как сильный инженер по backend/platform/product quality, а не как "генератор патчей".
Твоя задача не просто править код, а последовательно вытаскивать `ignis-core` в состояние безопасного, надёжного и поддерживаемого сервера для управления WiZ-лампами.
## Манера общения
Это не официальный корпоративный проект, а живой личный проект в приватном контуре.
Поэтому ты не обязан разговаривать стерильно и канцелярски.
Разрешается и местами даже рекомендуется материться, если это:
- уместно по тону разговора
- помогает яснее подчеркнуть риск, кривизну или срочность
- не мешает пониманию
Но:
- не заменяй матом смысл
- не превращай ответы в поток брани
- не матерись внутри кода, имён сущностей, API-контрактов и документации для внешних пользователей без явной причины
- в технических выводах сначала должна быть суть, а потом уже эмоциональная окраска
Коротко: говорить можно по-человечески, жёстко и с матом, если это помогает делу.
## Контекст проекта
`ignis-core` это self-hosted сервер на FastAPI для:
- локального discovery ламп WiZ
- управления устройствами и группами
- расписаний
- встроенного веб-интерфейса
- REST API для связанного клиента
- управления гостевыми API-ключами
- логирования событий и базовой статистики
Проект компактный, но в нём уже есть:
- опасные дефолты в безопасности
- смешение доменной логики, API, состояния и инфраструктуры
- частично недоделанная модель данных
- рассинхрон между backend, UI, README и OpenAPI
- отсутствие тестовой и миграционной дисциплины
## Главная цель
Довести проект до состояния, где он:
1. безопасен по умолчанию
2. честно отражает успехи и ошибки
3. имеет понятный API-контракт
4. не ломается от роста фич
5. поддерживается без страха и угадываний
## Приоритеты
Всегда держи такой порядок:
1. Безопасность
2. Корректность поведения
3. Надёжность и прозрачность ошибок
4. Согласованность контракта
5. Поддерживаемость
6. Новые фичи
Если новая фича конфликтует с P0-проблемой, сначала закрывай P0.
## Жёсткие правила работы
1. Не выходи выше директории текущего репозитория.
2. Не делай коммиты без явного апрува пользователя.
3. Не трогай unrelated changes.
4. Не делай широкие рефакторы без понимания источников истины.
5. Не объявляй успех, если код возвращает фальшивый успех.
6. Не оставляй мёртвые сущности, если уже затронул связанный слой.
7. Не раздувай UI поверх сломанного API.
## Рабочий подход
### 1. Сначала понять реальность
Перед правками всегда выясняй:
- где реальный источник истины
- что хранится только в памяти
- что реально в БД
- что обещает README
- что реально принимает API
- что ожидает фронтенд
Нельзя исходить из комментариев и имён функций, пока они не подтверждены кодом.
### 2. Проверять весь поток целиком
Любое изменение оценивай от входа до эффекта:
- запрос клиента
- валидация
- auth/permissions
- доменная логика
- драйвер WiZ
- состояние
- логирование
- статистика
- ответ API
- влияние на UI
### 3. Предпочитать честность удобству
Если операция выполнилась частично или не подтвердилась:
- не возвращай фальшивый `ok`
- не логируй это как безусловный успех
- не скрывай ошибку под общим `500`, если можно дать точный тип проблемы
### 4. Убирать двусмысленность
Если в проекте есть:
- мёртвая модель
- старый путь
- комментарий, противоречащий коду
- README, не совпадающий с реализацией
это нужно либо починить, либо удалить, либо явно отметить как технический долг.
## Что сейчас считается проблемными зонами
### Безопасность
- `IGNIS_API_KEY` с `fail-open`
- слабое разграничение ролей
- избыточная видимость токенов
- хранение ключа в браузере
- внешние CDN в локальном админском UI
### Управление лампами
- timeout/`None`-кейсы
- ложные успехи
- отсутствие единого контракта результата
- слабая обработка частичных ошибок
### Расписания
- конфликтующие `job_id`
- слишком бедный payload
- неясный источник истины
- несогласованность модели и реальности
### Discovery
- поведение отличается от описания
- оффлайн-устройства обрабатываются несимметрично
- подсети определяются грубо
### Frontend
- весь UI в одном файле
- нет модульности
- UI местами ожидает несуществующие backend-данные
### Data model
- есть недоведённые сущности
- нет миграций
- структура БД живёт скорее по инерции, чем по стратегии
## Как принимать архитектурные решения
Если возникает выбор, придерживайся следующих принципов:
1. Один слой отвечает за одну вещь.
2. Роуты должны быть тонкими.
3. Валидация должна быть явной и типизированной.
4. Состояние в памяти не должно притворяться постоянным хранилищем.
5. SQLAlchemy-модели не должны свободно течь по приложению как доменные объекты.
6. Внешний контракт важнее локального удобства хендлера.
7. Логи и статистика должны строиться на фактах, а не на догадках.
## Формат хорошего изменения
Хорошее изменение в этом проекте обычно включает:
1. исправление причины, а не только симптома
2. корректировку API-контракта при необходимости
3. обновление документации, если внешний интерфейс изменился
4. тест или хотя бы проверяемый сценарий
5. честную обработку ошибок
## Формат плохого изменения
Плохим считается изменение, которое:
- только маскирует исключение
- добавляет новый путь поверх старого хаоса
- закрепляет небезопасный дефолт
- плодит ещё один источник истины
- чинит backend, но ломает UI или наоборот
- оставляет README и API рассинхронизированными
## Приоритетный roadmap для исполнения
Работай по такому порядку, если пользователь не задаст другой:
### Фаза 1
- закрыть проблемы безопасности по умолчанию
- исправить модель ролей и доступов
- убрать утечки ключей
### Фаза 2
- нормализовать ответы драйвера
- починить control/status endpoints
- убрать ложные успехи
### Фаза 3
- исправить расписания
- определить нормальную модель задач
- расширить payload расписаний
### Фаза 4
- привести API к body-схемам и нормальной валидации
- синхронизировать OpenAPI, README и фронт
### Фаза 5
- стабилизировать discovery
- починить event log и stats
### Фаза 6
- ввести сервисный слой
- убрать мёртвые модели
- добавить тесты, миграции и health endpoints
### Фаза 7
- декомпозировать фронтенд
- развивать продуктовые фичи
## Ожидаемый стиль коммуникации
Когда работаешь:
- сначала коротко сообщай, что именно изучаешь или меняешь
- если есть риск или развилка, называй её прямо
- не перегружай пользователя шумом
- после изменений объясняй, что реально стало лучше
- если что-то не удалось проверить, говори об этом честно
- не бойся нормального живого тона и умеренного мата, если он уместен и делает мысль точнее
## Что делать перед любым редактированием
1. Проверить актуальное состояние репозитория.
2. Найти все связанные места через поиск по коду.
3. Убедиться, что правка не ломает UI/API/БД-контракт.
4. Понять, нет ли уже мёртвой или конкурирующей реализации.
## Что делать после любого редактирования
1. Проверить статически изменённые участки.
2. Прогнать доступные тесты или хотя бы таргетную проверку.
3. Сверить документацию, если изменился внешний контракт.
4. Зафиксировать остаточные риски.
## Definition of Done для каждой задачи
Задача считается доведённой, только если:
1. причина проблемы устранена
2. поведение проверено
3. контракты не стали менее ясными
4. код не стал более двусмысленным
5. пользователь понимает, что изменилось и что осталось
## Мантра для работы над Ignis Core
Не добавляй слой поверх проблемы.
Сначала найди реальный источник истины.
Сделай поведение честным.
Сделай контракт явным.
Сделай проект безопасным по умолчанию.

2
.gitignore vendored
View File

@@ -3,3 +3,5 @@ __pycache__/
*.db *.db
.pytest_cache/ .pytest_cache/
.env .env
.ai/
.codex

226
README.md
View File

@@ -1,139 +1,167 @@
# Ignis Core # Ignis Core
Self-hosted сервер для управления умными лампами WiZ по локальной сети. FastAPI бэкенд с веб-интерфейсом, планировщиком расписаний и REST API для мобильного приложения. Локальный FastAPI-сервер для управления лампами WiZ.
## Возможности ## Что есть
- **Discovery** -- автоматическое обнаружение ламп WiZ в локальной сети (UDP broadcast). Поддержка нескольких подсетей через `SCAN_NETWORK`. - discovery устройств в локальной сети
- **Группы** -- объединение ламп в именованные группы (спальня, кухня, ...). Хранение в SQLite. - группы устройств
- **Управление** -- включение/выключение, яркость, цветовая температура, RGB-цвет, 35+ встроенных сцен. - команды для device/group
- **Расписания** -- одноразовые таймеры и cron-задачи через APScheduler с персистентным хранилищем. - one-shot и cron расписания
- **Веб-интерфейс** -- SPA на Vue 3 + Tailwind, встроен в сервер как статика. - guest/admin/master API-ключи
- **API** -- REST API с авторизацией по API-ключу для мобильных клиентов. - event log и базовая статистика
- встроенный UI в `static/index.html`
## Быстрый старт ## Запуск
```bash ```bash
# Клонировать
git clone https://git.akokos.ru/artem.kokos/ignis-core.git
cd ignis-core
# Виртуальное окружение
python3 -m venv .venv python3 -m venv .venv
source .venv/bin/activate source .venv/bin/activate
# Зависимости
pip install -r requirements.txt pip install -r requirements.txt
# Конфигурация
cp .env.example .env cp .env.example .env
# Отредактировать .env -- указать API-ключ и таймзону
# Запуск
uvicorn main:app --host 0.0.0.0 --port 8000 uvicorn main:app --host 0.0.0.0 --port 8000
``` ```
Сервер будет доступен на `http://<ip>:8000`. Веб-интерфейс -- на корневом URL. UI: `http://<host>:8000/`
## Конфигурация (.env) ## Конфигурация
Минимум:
```env ```env
# API-ключ для авторизации (если не задан -- авторизация отключена) IGNIS_API_KEY=change-me
IGNIS_API_KEY=your-secret-key APP_TIMEZONE=Asia/Novosibirsk
SCAN_NETWORK=
# Таймзона для расписаний (по умолчанию Asia/Novosibirsk)
APP_TIMEZONE=Asia/Almaty
# Подсети для сканирования (через запятую, по умолчанию -- автоопределение)
SCAN_NETWORK=192.168.1.0/24
# Уровень логирования
LOG_LEVEL=INFO LOG_LEVEL=INFO
EVENT_LOG_RETENTION_DAYS=30
``` ```
## Структура проекта БД:
```env
IGNIS_DATABASE_URL=sqlite+aiosqlite:///./ignis.db
IGNIS_SYNC_DATABASE_URL=sqlite:///./ignis.db
``` ```
ignis-core/
├── main.py -- точка входа FastAPI, lifespan Замечание по discovery:
├── requirements.txt -- зависимости
├── .env -- конфигурация (не в git) - если на хосте есть VPN или несколько интерфейсов, лучше явно задать `SCAN_NETWORK`
├── static/ - формат: `192.168.0.0/24` или список через запятую
│ └── index.html -- веб-интерфейс (Vue 3 SPA)
├── app/ ## Авторизация
│ ├── core/
│ │ ├── database.py -- async SQLAlchemy, SQLite Заголовок: `X-API-Key`
│ │ ├── discovery.py -- UDP-сканирование сети WiZ
│ │ ├── scheduler.py -- APScheduler + jobstore Роли:
│ │ └── state.py -- in-memory состояние (устройства, группы)
│ ├── models/ - `master`: значение из `IGNIS_API_KEY`, полный доступ
│ │ ├── device.py -- модели Device, Group (SQLAlchemy + Pydantic) - `admin`: ключ из БД, доступ к группам, расписаниям, stats и rescan
│ │ └── schedule.py -- модель ScheduleTask - `guest`: обычное управление и чтение
│ ├── drivers/
│ │ └── wiz.py -- UDP-драйвер протокола WiZ Сервер работает в `fail-closed`: если `IGNIS_API_KEY` не задан, защищённые маршруты недоступны.
│ └── api/
│ ├── deps.py -- авторизация (X-API-Key)
│ └── routes/
│ ├── devices.py -- CRUD устройств и групп
│ ├── control.py -- управление лампами
│ └── schedules.py -- расписания (once, cron)
└── ignis.db -- SQLite база (создаётся автоматически)
```
## API ## API
Авторизация: заголовок `X-API-Key`. Основные маршруты:
### Устройства и группы - `GET /devices`
- `GET /devices/groups`
- `GET /devices/scenes`
- `POST /devices/groups`
- `DELETE /devices/groups/{group_id}`
- `POST /devices/rescan`
- `POST /control/device/{device_id}`
- `POST /control/group/{group_id}`
- `POST /control/device/{device_id}/blink`
- `GET /control/device/{device_id}/status`
- `GET /control/group/{group_id}/status`
- `POST /schedules/once`
- `POST /schedules/cron`
- `GET /schedules/tasks`
- `DELETE /schedules/{job_id}`
- `GET /api-keys`
- `POST /api-keys`
- `POST /api-keys/revoke`
- `POST /api-keys/activate`
- `GET /stats/summary`
- `GET /stats/log`
- `GET /auth/me`
| Метод | Путь | Описание | Текущий контракт для `control` и `schedules` использует query-параметры.
|--------|--------------------------|------------------------------|
| 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"]} - `state`
- `brightness`
- `scene`
- `temp`
- `r`, `g`, `b`
Примеры:
```bash
curl -X POST 'http://localhost:8000/control/device/dev-1?temp=4200' \
-H 'X-API-Key: change-me'
``` ```
### Управление ```bash
curl -X POST 'http://localhost:8000/schedules/once?target_id=bedroom&hours_from_now=2&is_group=true&temp=3200' \
-H 'X-API-Key: change-me'
```
| Метод | Путь | Описание | ## API keys
|-------|----------------------------------|------------------------|
| 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). - список ключей возвращает публичный `key` / `key_id`
- полный секрет возвращается только при создании
- маршруты `/api-keys/*` доступны только `master`
### Расписания ## Хранилище
| Метод | Путь | Описание | Основные таблицы:
|--------|-----------------------|-------------------------|
| POST | `/schedules/once` | Одноразовый таймер |
| POST | `/schedules/cron` | Повторяющаяся задача |
| GET | `/schedules/tasks` | Все активные задачи |
| DELETE | `/schedules/{job_id}` | Отменить задачу |
## Стек - `groups`
- `api_keys`
- `event_log`
- `schedules`
- `apscheduler_jobs`
- **FastAPI** -- async HTTP-сервер ## OpenAPI
- **SQLAlchemy 2.0** -- async ORM, SQLite через aiosqlite
- **APScheduler** -- планировщик с персистентным хранилищем
- **WiZ Protocol** -- UDP-управление лампами (порт 38899)
- **Vue 3 + Tailwind** -- встроенный веб-интерфейс
## Клиенты Актуальная схема лежит в `openapi.json`.
- Веб-интерфейс -- встроен в сервер (`static/index.html`) Перегенерация:
- [Ignis App](https://git.akokos.ru/artem.kokos/ignis_app) -- мобильное приложение (Flutter)
## Лицензия ```bash
.venv/bin/python -c 'import json, os, pathlib; os.environ.setdefault("IGNIS_API_KEY", "openapi-export"); from main import app; pathlib.Path("openapi.json").write_text(json.dumps(app.openapi(), ensure_ascii=False, indent=2) + "\n", encoding="utf-8")'
```
Частный проект. ## Тесты
Проверка синтаксиса:
```bash
.venv/bin/python -m compileall app tests main.py
```
Полный прогон:
```bash
timeout 120s .venv/bin/python -m unittest discover -s tests -v
```
Сейчас есть 17 тестов. Покрыты:
- auth и роли
- lifecycle API-ключей
- control/status ошибки и partial success
- валидация scene
- one-shot и cron расписания
- миграция legacy jobs
- агрегация stats без двойного счёта `*_requested`
## Ограничения
- discovery всё ещё основан на переборе IP по подсетям
- UI остаётся монолитным файлом
- `control` и `schedules` ещё не переведены на JSON body
- stats пока простые и не заменяют нормальную аналитику

View File

@@ -2,10 +2,9 @@ import os
import hmac import hmac
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional
from fastapi import Depends, HTTPException from fastapi import Depends, HTTPException
from fastapi.security import APIKeyHeader from fastapi.security import APIKeyHeader
from starlette.status import HTTP_403_FORBIDDEN from starlette.status import HTTP_403_FORBIDDEN, HTTP_503_SERVICE_UNAVAILABLE
from dotenv import load_dotenv from dotenv import load_dotenv
from sqlalchemy import select from sqlalchemy import select
@@ -16,10 +15,6 @@ load_dotenv()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MASTER_KEY = os.getenv("IGNIS_API_KEY")
if not MASTER_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)
@@ -33,17 +28,26 @@ class AuthContext:
key_name: str # имя ключа (для логов) key_name: str # имя ключа (для логов)
def get_master_key() -> str | None:
value = os.getenv("IGNIS_API_KEY", "").strip()
return value or None
async def verify_token(header_value: str = Depends(api_key_header)) -> AuthContext: async def verify_token(header_value: str = Depends(api_key_header)) -> AuthContext:
""" """
Проверка API-ключа: Проверка API-ключа:
1. Если IGNIS_API_KEY не задан -- авторизация отключена, полный доступ 1. IGNIS_API_KEY должен быть задан, иначе сервер закрыт (fail-closed)
2. Мастер-ключ из .env -- полный доступ 2. Мастер-ключ из .env -- полный доступ
3. Ключ из БД (api_keys) -- проверяем active и is_admin 3. Ключ из БД (api_keys) -- проверяем active и is_admin
4. Иначе -- 403 4. Иначе -- 403
""" """
# Авторизация отключена master_key = get_master_key()
if not MASTER_KEY: if not master_key:
return AuthContext(is_master=True, is_admin=True, key_name="no-auth") logger.error("IGNIS_API_KEY не задан: защищённые API закрыты до настройки")
raise HTTPException(
status_code=HTTP_503_SERVICE_UNAVAILABLE,
detail="Сервер не настроен: задайте IGNIS_API_KEY",
)
if not header_value: if not header_value:
raise HTTPException( raise HTTPException(
@@ -51,7 +55,7 @@ async def verify_token(header_value: str = Depends(api_key_header)) -> AuthConte
) )
# Мастер-ключ (timing-safe сравнение) # Мастер-ключ (timing-safe сравнение)
if hmac.compare_digest(header_value, MASTER_KEY): if hmac.compare_digest(header_value, master_key):
return AuthContext(is_master=True, is_admin=True, key_name="master") return AuthContext(is_master=True, is_admin=True, key_name="master")
# Ищем в БД # Ищем в БД
@@ -78,3 +82,13 @@ def require_admin(auth: AuthContext = Depends(verify_token)) -> AuthContext:
if not auth.is_admin: if not auth.is_admin:
raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Недостаточно прав") raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Недостаточно прав")
return auth return auth
def require_master(auth: AuthContext = Depends(verify_token)) -> AuthContext:
"""Dependency для роутов, доступных только мастер-ключу."""
if not auth.is_master:
raise HTTPException(
status_code=HTTP_403_FORBIDDEN,
detail="Требуется мастер-ключ",
)
return auth

View File

@@ -4,10 +4,10 @@ from sqlalchemy import select
from app.core.database import async_session from app.core.database import async_session
from app.models.api_key import ApiKeyModel from app.models.api_key import ApiKeyModel
from app.api.deps import require_admin, AuthContext from app.api.deps import require_master
# Все операции с ключами -- только для админов (мастер-ключ) # Все операции с ключами доступны только мастер-ключу.
router = APIRouter(dependencies=[Depends(require_admin)]) router = APIRouter(dependencies=[Depends(require_master)])
class KeyActionRequest(BaseModel): class KeyActionRequest(BaseModel):
@@ -16,16 +16,41 @@ class KeyActionRequest(BaseModel):
key: str key: str
async def _find_key_by_secret_or_public_id(
session, key_or_id: str
) -> ApiKeyModel | None:
result = await session.execute(
select(ApiKeyModel).where(ApiKeyModel.key == key_or_id)
)
api_key = result.scalar_one_or_none()
if api_key:
return api_key
result = await session.execute(select(ApiKeyModel))
for candidate in result.scalars().all():
if candidate.public_id == key_or_id:
return candidate
return None
@router.get("") @router.get("")
async def list_keys(): async def list_keys():
"""Список всех гостевых ключей.""" """
Список всех гостевых ключей.
В ответе поле `key` содержит публичный идентификатор, а не сам секрет.
Это сохраняет совместимость с текущим UI и не раскрывает токены повторно.
"""
async with async_session() as session: async with async_session() as session:
result = await session.execute(select(ApiKeyModel)) result = await session.execute(select(ApiKeyModel))
keys = result.scalars().all() keys = result.scalars().all()
return [ return [
{ {
"key": k.key, "key": k.public_id,
"key_id": k.public_id,
"display_key": k.preview,
"name": k.name, "name": k.name,
"is_admin": k.is_admin, "is_admin": k.is_admin,
"active": k.active, "active": k.active,
@@ -50,6 +75,8 @@ async def create_key(name: str, is_admin: bool = False):
return { return {
"key": new_key.key, "key": new_key.key,
"key_id": new_key.public_id,
"display_key": new_key.preview,
"name": new_key.name, "name": new_key.name,
"is_admin": new_key.is_admin, "is_admin": new_key.is_admin,
"message": "Сохраните ключ -- он больше не будет показан полностью", "message": "Сохраните ключ -- он больше не будет показан полностью",
@@ -60,10 +87,7 @@ async def create_key(name: str, is_admin: bool = False):
async def revoke_key(body: KeyActionRequest): async def revoke_key(body: KeyActionRequest):
"""Деактивировать (отозвать) гостевой ключ. Ключ передаётся в body, не в URL.""" """Деактивировать (отозвать) гостевой ключ. Ключ передаётся в body, не в URL."""
async with async_session() as session: async with async_session() as session:
result = await session.execute( api_key = await _find_key_by_secret_or_public_id(session, body.key)
select(ApiKeyModel).where(ApiKeyModel.key == body.key)
)
api_key = result.scalar_one_or_none()
if not api_key: if not api_key:
raise HTTPException(status_code=404, detail="Ключ не найден") raise HTTPException(status_code=404, detail="Ключ не найден")
@@ -71,17 +95,14 @@ async def revoke_key(body: KeyActionRequest):
session.add(api_key) session.add(api_key)
await session.commit() await session.commit()
return {"status": "revoked", "name": api_key.name} return {"status": "revoked", "name": api_key.name, "key_id": api_key.public_id}
@router.post("/activate") @router.post("/activate")
async def activate_key(body: KeyActionRequest): async def activate_key(body: KeyActionRequest):
"""Повторно активировать ключ. Ключ передаётся в body, не в URL.""" """Повторно активировать ключ. Ключ передаётся в body, не в URL."""
async with async_session() as session: async with async_session() as session:
result = await session.execute( api_key = await _find_key_by_secret_or_public_id(session, body.key)
select(ApiKeyModel).where(ApiKeyModel.key == body.key)
)
api_key = result.scalar_one_or_none()
if not api_key: if not api_key:
raise HTTPException(status_code=404, detail="Ключ не найден") raise HTTPException(status_code=404, detail="Ключ не найден")
@@ -89,4 +110,4 @@ async def activate_key(body: KeyActionRequest):
session.add(api_key) session.add(api_key)
await session.commit() await session.commit()
return {"status": "activated", "name": api_key.name} return {"status": "activated", "name": api_key.name, "key_id": api_key.public_id}

View File

@@ -1,12 +1,14 @@
import asyncio import asyncio
import json import json
import logging import logging
from typing import Optional from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from app.core.state import state_manager
from app.core.database import async_session
from app.drivers.wiz import WizDriver
from app.api.deps import verify_token, AuthContext from app.api.deps import verify_token, AuthContext
from app.core.database import async_session
from app.core.state import state_manager
from app.drivers.wiz import WizDriver, WizResponse
from app.models.event_log import EventLog from app.models.event_log import EventLog
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -34,21 +36,171 @@ async def _log_event(
logger.error(f"Ошибка записи в лог: {e}") logger.error(f"Ошибка записи в лог: {e}")
async def log_toggle(auth: AuthContext, target_type: str, target_id: str, params: dict): def _build_command_params(
"""Логирует toggle_on/toggle_off если в params есть state.""" state: Optional[bool],
brightness: Optional[int],
scene: Optional[str],
temp: Optional[int],
r: Optional[int],
g: Optional[int],
b: Optional[int],
) -> dict[str, Any]:
params: dict[str, Any] = {}
if state is not None:
params["state"] = state
if brightness is not None:
params["dimming"] = brightness
if scene is not None:
if scene not in wiz.SCENES:
raise HTTPException(status_code=400, detail="Неизвестная сцена")
params["sceneId"] = wiz.SCENES[scene]
elif temp is not None:
params["temp"] = temp
elif any(v is not None for v in [r, g, b]):
params["r"], params["g"], params["b"] = r or 0, g or 0, b or 0
return params
def _resolve_action_name(params: dict[str, Any]) -> str:
if "state" in params: if "state" in params:
action = "toggle_on" if params["state"] else "toggle_off" return "toggle_on" if params["state"] else "toggle_off"
await _log_event(auth, action, target_type, target_id, params) if "sceneId" in params:
return "scene"
if any(channel in params for channel in ("r", "g", "b")):
return "color"
if "temp" in params:
return "temperature"
if "dimming" in params:
return "brightness"
return "command"
async def log_toggle_by_name( def _build_event_payload(
key_name: str, target_type: str, target_id: str, params: dict params: dict[str, Any],
*,
success_count: int | None = None,
failure_count: int | None = None,
target_count: int | None = None,
status: str | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {"command": params}
outcome = {
"status": status,
"success_count": success_count,
"failure_count": failure_count,
"target_count": target_count,
}
payload["outcome"] = {k: v for k, v in outcome.items() if v is not None}
return payload
async def log_command_result(
auth: AuthContext,
target_type: str,
target_id: str,
params: dict[str, Any],
*,
success_count: int,
failure_count: int,
target_count: int,
): ):
"""Логирует toggle из контекста без AuthContext (для планировщика).""" action = _resolve_action_name(params)
if "state" in params: await _log_event(
auth = AuthContext(is_master=False, is_admin=False, key_name=key_name) auth,
action = "toggle_on" if params["state"] else "toggle_off" f"{action}_requested",
await _log_event(auth, action, target_type, target_id, params) target_type,
target_id,
_build_event_payload(params, target_count=target_count, status="requested"),
)
if success_count == 0:
await _log_event(
auth,
f"{action}_failed",
target_type,
target_id,
_build_event_payload(
params,
success_count=0,
failure_count=failure_count,
target_count=target_count,
status="failed",
),
)
return
outcome_status = "ok" if failure_count == 0 else "partial"
await _log_event(
auth,
action,
target_type,
target_id,
_build_event_payload(
params,
success_count=success_count,
failure_count=failure_count,
target_count=target_count,
status=outcome_status,
),
)
async def log_command_result_by_name(
key_name: str,
target_type: str,
target_id: str,
params: dict,
*,
success_count: int = 1,
failure_count: int = 0,
target_count: int = 1,
):
"""Логирует результат команды из контекста без AuthContext (для планировщика)."""
auth = AuthContext(is_master=False, is_admin=False, key_name=key_name)
await log_command_result(
auth,
target_type,
target_id,
params,
success_count=success_count,
failure_count=failure_count,
target_count=target_count,
)
def _response_error_status(result: WizResponse) -> int:
if result.kind == "timeout":
return 504
return 502
def _response_error_detail(result: WizResponse, *, prefix: str) -> str:
if result.message:
return f"{prefix}: {result.message}"
if result.kind == "timeout":
return f"{prefix}: таймаут ответа"
return f"{prefix}: ошибка обмена с лампой"
def _serialize_wiz_result(ip: str, result: WizResponse) -> dict[str, Any]:
payload: dict[str, Any] = {
"ip": ip,
"ok": result.ok,
"kind": result.kind,
}
if result.ok:
payload["result"] = result.result
else:
payload["error"] = result.message or result.kind
return payload
def _summarize_group_results(results: list[WizResponse]) -> tuple[int, int]:
success_count = sum(1 for item in results if item.ok)
failure_count = len(results) - success_count
return success_count, failure_count
@router.post("/device/{device_id}") @router.post("/device/{device_id}")
@@ -67,27 +219,42 @@ async def control_device(
if not device: if not device:
raise HTTPException(status_code=404, detail="Лампа не в сети") raise HTTPException(status_code=404, detail="Лампа не в сети")
params = {} params = _build_command_params(state, brightness, scene, temp, r, g, b)
if state is not None:
params["state"] = state
if brightness is not None:
params["dimming"] = brightness
if scene and scene in wiz.SCENES:
params["sceneId"] = wiz.SCENES[scene]
elif temp is not None:
params["temp"] = temp
elif any(v is not None for v in [r, g, b]):
params["r"], params["g"], params["b"] = r or 0, g or 0, b or 0
if not params: if not params:
raise HTTPException(status_code=400, detail="Никаких команд не передано") raise HTTPException(status_code=400, detail="Никаких команд не передано")
result = await wiz.set_pilot(device.ip, params) result = await wiz.set_pilot(device.ip, params)
if not result.ok:
await log_command_result(
auth,
"device",
device_id,
params,
success_count=0,
failure_count=1,
target_count=1,
)
raise HTTPException(
status_code=_response_error_status(result),
detail=_response_error_detail(result, prefix="Команда лампе не доставлена"),
)
await log_toggle(auth, "device", device_id, params) await log_command_result(
auth,
"device",
device_id,
params,
success_count=1,
failure_count=0,
target_count=1,
)
return {"device_id": device_id, "applied": params, "result": result} return {
"device_id": device_id,
"applied": params,
"result": result.payload,
"status": "ok",
}
@router.post("/group/{group_id}") @router.post("/group/{group_id}")
@@ -106,25 +273,44 @@ async def control_group(
if not ips: if not ips:
raise HTTPException(status_code=404, detail="Группа не найдена или оффлайн") raise HTTPException(status_code=404, detail="Группа не найдена или оффлайн")
params = {} params = _build_command_params(state, brightness, scene, temp, r, g, b)
if state is not None: if not params:
params["state"] = state raise HTTPException(status_code=400, detail="Никаких команд не передано")
if brightness is not None:
params["dimming"] = brightness
if scene and scene in wiz.SCENES:
params["sceneId"] = wiz.SCENES[scene]
elif temp is not None:
params["temp"] = temp
elif any(v is not None for v in [r, g, b]):
params["r"], params["g"], params["b"] = r or 0, g or 0, b or 0
tasks = [wiz.set_pilot(ip, params) for ip in ips] tasks = [wiz.set_pilot(ip, params) for ip in ips]
await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks)
success_count, failure_count = _summarize_group_results(results)
await log_toggle(auth, "group", group_id, params) await log_command_result(
auth,
"group",
group_id,
params,
success_count=success_count,
failure_count=failure_count,
target_count=len(results),
)
return {"status": "ok", "applied": params, "sent_to": ips} if success_count == 0:
first_failure = next(item for item in results if not item.ok)
raise HTTPException(
status_code=_response_error_status(first_failure),
detail=_response_error_detail(
first_failure, prefix="Команда группе не доставлена"
),
)
group_status = "ok" if failure_count == 0 else "partial"
return {
"status": group_status,
"applied": params,
"sent_to": ips,
"success_count": success_count,
"failure_count": failure_count,
"results": [
_serialize_wiz_result(ip, result) for ip, result in zip(ips, results)
],
}
@router.post("/device/{device_id}/blink") @router.post("/device/{device_id}/blink")
@@ -133,16 +319,36 @@ async def blink_device(device_id: str, _auth: AuthContext = Depends(verify_token
if not device: if not device:
raise HTTPException(status_code=404, detail="Лампа оффлайн") raise HTTPException(status_code=404, detail="Лампа оффлайн")
try: current = await wiz.get_pilot(device.ip)
current = await wiz.get_pilot(device.ip) if not current.ok:
original_state = current.get("result", {}).get("state", False) raise HTTPException(
await wiz.set_pilot(device.ip, {"state": not original_state}) status_code=_response_error_status(current),
await asyncio.sleep(0.5) detail=_response_error_detail(
await wiz.set_pilot(device.ip, {"state": original_state}) current, prefix="Не удалось получить текущее состояние лампы"
return {"status": "blink_done", "original": original_state} ),
except Exception as e: )
logger.error(f"Blink error: {e}")
raise HTTPException(status_code=500, detail="Ошибка связи с лампой") original_state = current.result.get("state", False)
first_toggle = await wiz.set_pilot(device.ip, {"state": not original_state})
if not first_toggle.ok:
raise HTTPException(
status_code=_response_error_status(first_toggle),
detail=_response_error_detail(
first_toggle, prefix="Не удалось выполнить первую фазу blink"
),
)
await asyncio.sleep(0.5)
second_toggle = await wiz.set_pilot(device.ip, {"state": original_state})
if not second_toggle.ok:
raise HTTPException(
status_code=_response_error_status(second_toggle),
detail=_response_error_detail(
second_toggle, prefix="Не удалось восстановить исходное состояние"
),
)
return {"status": "blink_done", "original": original_state}
@router.get("/device/{device_id}/status") @router.get("/device/{device_id}/status")
@@ -152,11 +358,14 @@ async def get_device_status(device_id: str, _auth: AuthContext = Depends(verify_
if not device: if not device:
raise HTTPException(status_code=404, detail="Лампа оффлайн или не найдена") raise HTTPException(status_code=404, detail="Лампа оффлайн или не найдена")
try: status = await wiz.get_pilot(device.ip)
status = await wiz.get_pilot(device.ip) if not status.ok:
return {"device_id": device_id, "status": status.get("result", {})} raise HTTPException(
except Exception as e: status_code=_response_error_status(status),
raise HTTPException(status_code=500, detail=f"Ошибка опроса лампы: {e}") detail=_response_error_detail(status, prefix="Ошибка опроса лампы"),
)
return {"device_id": device_id, "status": status.result}
@router.get("/group/{group_id}/status") @router.get("/group/{group_id}/status")
@@ -166,14 +375,20 @@ async def get_group_status(group_id: str, _auth: AuthContext = Depends(verify_to
if not ips: if not ips:
raise HTTPException(status_code=404, detail="Группа не найдена или оффлайн") raise HTTPException(status_code=404, detail="Группа не найдена или оффлайн")
tasks = [wiz.get_pilot(ip) for ip in ips] results = await asyncio.gather(*[wiz.get_pilot(ip) for ip in ips])
results = await asyncio.gather(*tasks, return_exceptions=True)
status_report = [] status_report = []
for ip, res in zip(ips, results): for ip, res in zip(ips, results):
if isinstance(res, Exception): if res.ok:
status_report.append({"ip": ip, "error": str(res)}) status_report.append({"ip": ip, "status": res.result})
else: continue
status_report.append({"ip": ip, "status": res.get("result", {})})
status_report.append(
{
"ip": ip,
"error": res.message or res.kind,
"kind": res.kind,
}
)
return {"group_id": group_id, "results": status_report} return {"group_id": group_id, "results": status_report}

View File

@@ -1,28 +1,75 @@
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Any, 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 from fastapi import APIRouter, Depends, HTTPException
from app.api.deps import require_admin
from app.core.scheduler import (
app_tz,
create_schedule_task,
delete_schedule_task,
is_internal_job_id,
list_schedule_tasks,
)
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 require_admin
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(dependencies=[Depends(require_admin)]) router = APIRouter(dependencies=[Depends(require_admin)])
# Префиксы служебных задач -- не показываем на фронте
_INTERNAL_JOB_PREFIXES = ("cleanup_",) def _validate_target(target_id: str, is_group: bool):
if is_group:
if target_id not in state_manager.groups:
raise HTTPException(status_code=404, detail="Группа не найдена")
return "group"
if target_id not in state_manager.devices:
raise HTTPException(status_code=404, detail="Устройство не найдено")
return "device"
def _build_action_params(
state: Optional[bool],
brightness: Optional[int],
scene: Optional[str],
temp: Optional[int],
r: Optional[int],
g: Optional[int],
b: Optional[int],
) -> dict[str, Any]:
params: dict[str, Any] = {}
if state is not None:
params["state"] = state
if brightness is not None:
params["dimming"] = brightness
if scene is not None:
if scene not in WizDriver.SCENES:
raise HTTPException(status_code=400, detail="Неизвестная сцена")
params["sceneId"] = WizDriver.SCENES[scene]
elif temp is not None:
params["temp"] = temp
elif any(channel is not None for channel in (r, g, b)):
params["r"] = r or 0
params["g"] = g or 0
params["b"] = b or 0
if not params:
raise HTTPException(status_code=400, detail="Никаких команд не передано")
return params
async def run_group_command(target_id: str, is_group: bool, params: dict): async def run_group_command(target_id: str, is_group: bool, params: dict):
""" """
Универсальное выполнение команды по расписанию. Универсальное выполнение команды по расписанию.
IP резолвится в момент выполнения, а не создания задачи --
корректно работает при смене IP (DHCP) и изменении состава группы. Сигнатура специально сохранена совместимой со старым persisted jobstore,
чтобы legacy APScheduler jobs можно было безопасно мигрировать.
""" """
if is_group: if is_group:
ips = state_manager.get_group_ips(target_id) ips = state_manager.get_group_ips(target_id)
@@ -35,61 +82,89 @@ async def run_group_command(target_id: str, is_group: bool, params: dict):
return return
local_wiz = WizDriver() local_wiz = WizDriver()
success_count = 0
failure_count = 0
for ip in ips: for ip in ips:
try: try:
await local_wiz.set_pilot(ip, params) result = await local_wiz.set_pilot(ip, params)
logger.info(f"Расписание: {target_id} -> {ip}: {params}") if result.ok:
success_count += 1
logger.info(f"Расписание: {target_id} -> {ip}: {params}")
else:
failure_count += 1
logger.error(
f"Расписание: ошибка {ip}: {result.message or result.kind}"
)
except Exception as e: except Exception as e:
failure_count += 1
logger.error(f"Расписание: ошибка {ip}: {e}") logger.error(f"Расписание: ошибка {ip}: {e}")
# Логируем toggle в event_log from app.api.routes.control import log_command_result_by_name
# Импорт здесь, чтобы избежать циклической зависимости
from app.api.routes.control import log_toggle_by_name
target_type = "group" if is_group else "device" target_type = "group" if is_group else "device"
await log_toggle_by_name("scheduler", target_type, target_id, params) await log_command_result_by_name(
"scheduler",
target_type,
target_id,
params,
success_count=success_count,
failure_count=failure_count,
target_count=len(ips),
)
@router.post("/once") @router.post("/once")
async def schedule_once( async def schedule_once(
target_id: str, target_id: str,
state: bool, state: Optional[bool] = None,
run_at: Optional[datetime] = None, run_at: Optional[datetime] = None,
hours_from_now: Optional[int] = None, hours_from_now: Optional[int] = None,
is_group: bool = True, is_group: bool = True,
brightness: Optional[int] = None,
scene: Optional[str] = None,
temp: Optional[int] = None,
r: Optional[int] = None,
g: Optional[int] = None,
b: Optional[int] = None,
): ):
# 1. Определяем время запуска в правильной таймзоне
if hours_from_now is not None: if hours_from_now is not None:
if hours_from_now < 0:
raise HTTPException(
status_code=400, detail="hours_from_now не может быть отрицательным"
)
exec_time = datetime.now(app_tz) + timedelta(hours=hours_from_now) exec_time = datetime.now(app_tz) + timedelta(hours=hours_from_now)
elif run_at: elif run_at:
if run_at.tzinfo is None: if run_at.tzinfo is None:
exec_time = app_tz.localize(run_at) exec_time = app_tz.localize(run_at)
else: else:
exec_time = run_at exec_time = run_at.astimezone(app_tz)
else: else:
raise HTTPException(status_code=400, detail="Нужно время или отступ в часах") raise HTTPException(status_code=400, detail="Нужно время или отступ в часах")
# 2. Проверяем что цель существует (но IP резолвится при выполнении) if exec_time <= datetime.now(app_tz):
if is_group: raise HTTPException(
if target_id not in state_manager.groups: status_code=400, detail="Время запуска должно быть в будущем"
raise HTTPException(status_code=404, detail="Группа не найдена") )
else:
if target_id not in state_manager.devices:
raise HTTPException(status_code=404, detail="Устройство не найдено")
# 3. Регаем задачу target_type = _validate_target(target_id, is_group)
job_id = f"once_{target_id}_{int(exec_time.timestamp())}" action_params = _build_action_params(state, brightness, scene, temp, r, g, b)
scheduler.add_job( try:
run_group_command, task = await create_schedule_task(
trigger=DateTrigger(run_date=exec_time, timezone=app_tz), trigger_type="once",
args=[target_id, is_group, {"state": state}], target_id=target_id,
id=job_id, target_type=target_type,
name=f"Once: {target_id} | {state}", trigger_args={"run_at": exec_time.isoformat()},
replace_existing=True, action_params=action_params,
) )
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {"status": "scheduled", "run_at": exec_time.isoformat()} return {
"status": "scheduled",
"job_id": task.job_id,
"run_at": exec_time.isoformat(),
}
@router.post("/cron") @router.post("/cron")
@@ -99,84 +174,50 @@ async def add_cron_task(
minute: str, minute: str,
day_of_week: str = "*", day_of_week: str = "*",
is_group: bool = True, is_group: bool = True,
state: bool = True, state: Optional[bool] = None,
brightness: Optional[int] = None,
scene: Optional[str] = None,
temp: Optional[int] = None,
r: Optional[int] = None,
g: Optional[int] = None,
b: Optional[int] = None,
): ):
# Проверяем что цель существует target_type = _validate_target(target_id, is_group)
if is_group: action_params = _build_action_params(state, brightness, scene, temp, r, g, b)
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="Устройство не найдено")
# Одна задача на всю группу -- IP резолвятся при каждом срабатывании trigger_args = {
trigger = CronTrigger( "hour": hour,
hour=hour, minute=minute, day_of_week=day_of_week, timezone=app_tz "minute": minute,
) "day_of_week": day_of_week,
}
try:
task = await create_schedule_task(
trigger_type="cron",
target_id=target_id,
target_type=target_type,
trigger_args=trigger_args,
action_params=action_params,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
job_id = f"cron_{target_id}_{hour}_{minute}" return {"status": "cron_scheduled", "job_id": task.job_id}
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") @router.get("/tasks")
async def get_all_tasks(): async def get_all_tasks():
jobs = [] return {"tasks": await list_schedule_tasks()}
for job in scheduler.get_jobs():
# Пропускаем служебные задачи
if any(job.id.startswith(prefix) for prefix in _INTERNAL_JOB_PREFIXES):
continue
# Парсим имя
name_parts = job.name.split("|")
target = name_parts[0].replace("CRON:", "").replace("Once:", "").strip()
is_on = "True" in job.name or "true" in job.name.lower()
h, m = None, None
next_run_str = None
if job.next_run_time:
# Переводим из UTC в локальную таймзону для вывода
local_time = job.next_run_time.astimezone(app_tz)
h = str(local_time.hour).zfill(2)
m = str(local_time.minute).zfill(2)
next_run_str = local_time.isoformat()
# Если это крон, подтягиваем значения из триггера
if hasattr(job.trigger, "fields"):
h = str(job.trigger.fields[5])
m = str(job.trigger.fields[6])
jobs.append(
{
"id": job.id,
"target_id": target,
"state": is_on,
"next_run": next_run_str,
"hour": h,
"minute": m,
}
)
return {"tasks": jobs}
@router.delete("/{job_id}") @router.delete("/{job_id}")
async def cancel_task(job_id: str): async def cancel_task(job_id: str):
# Запрещаем удалять служебные задачи через API if is_internal_job_id(job_id):
if any(job_id.startswith(prefix) for prefix in _INTERNAL_JOB_PREFIXES):
raise HTTPException(status_code=403, detail="Нельзя удалить служебную задачу") raise HTTPException(status_code=403, detail="Нельзя удалить служебную задачу")
try: try:
scheduler.remove_job(job_id) await delete_schedule_task(job_id)
return {"status": "deleted"} return {"status": "deleted"}
except Exception: except KeyError:
raise HTTPException(status_code=404, detail="Задача не найдена") raise HTTPException(status_code=404, detail="Задача не найдена")
except ValueError:
raise HTTPException(status_code=403, detail="Нельзя удалить служебную задачу")

View File

@@ -8,6 +8,10 @@ from app.api.deps import require_admin
router = APIRouter(dependencies=[Depends(require_admin)]) router = APIRouter(dependencies=[Depends(require_admin)])
def _is_summary_command_event(action: str) -> bool:
return not action.endswith("_requested")
@router.get("/summary") @router.get("/summary")
async def get_summary(days: int = Query(default=7, ge=1, le=365)): async def get_summary(days: int = Query(default=7, ge=1, le=365)):
""" """
@@ -33,6 +37,9 @@ async def get_summary(days: int = Query(default=7, ge=1, le=365)):
last_on = {} last_on = {}
for ev in events: for ev in events:
if not _is_summary_command_event(ev.action):
continue
tid = ev.target_id tid = ev.target_id
if tid not in stats: if tid not in stats:
stats[tid] = { stats[tid] = {

View File

@@ -1,9 +1,14 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker import os
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy import create_engine
DATABASE_URL = "sqlite+aiosqlite:///./ignis.db" from dotenv import load_dotenv
SYNC_DATABASE_URL = "sqlite:///./ignis.db" from sqlalchemy import create_engine
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
load_dotenv()
DATABASE_URL = os.getenv("IGNIS_DATABASE_URL", "sqlite+aiosqlite:///./ignis.db")
SYNC_DATABASE_URL = os.getenv("IGNIS_SYNC_DATABASE_URL", "sqlite:///./ignis.db")
engine = create_async_engine(DATABASE_URL, echo=False) engine = create_async_engine(DATABASE_URL, echo=False)
sync_engine = create_engine(SYNC_DATABASE_URL) sync_engine = create_engine(SYNC_DATABASE_URL)
@@ -16,6 +21,10 @@ class Base(DeclarativeBase):
async def init_db(): async def init_db():
# Импортируем модели здесь, чтобы metadata была полностью зарегистрирована
# до create_all даже в тестовых и утилитных сценариях.
from app.models import api_key, device, event_log, schedule # noqa: F401
async with engine.begin() as conn: async with engine.begin() as conn:
# Создает таблицы, если их еще нет # Создает таблицы, если их еще нет
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)

View File

@@ -1,15 +1,21 @@
import os import asyncio
import logging import logging
import pytz import os
from datetime import datetime, timedelta from datetime import datetime, timedelta
from dotenv import load_dotenv from uuid import uuid4
from apscheduler.schedulers.asyncio import AsyncIOScheduler
import pytz
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.schedulers.base import STATE_STOPPED
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from sqlalchemy import delete from apscheduler.triggers.date import DateTrigger
from app.core.database import sync_engine, async_session from dotenv import load_dotenv
from sqlalchemy import delete, select
from app.core.database import async_session, sync_engine
from app.models.event_log import EventLog from app.models.event_log import EventLog
from app.drivers.wiz import WizDriver from app.models.schedule import ScheduleTask
load_dotenv() load_dotenv()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -18,19 +24,67 @@ TZ_NAME = os.getenv("APP_TIMEZONE", "Asia/Novosibirsk")
app_tz = pytz.timezone(TZ_NAME) app_tz = pytz.timezone(TZ_NAME)
RETENTION_DAYS = int(os.getenv("EVENT_LOG_RETENTION_DAYS", "30")) RETENTION_DAYS = int(os.getenv("EVENT_LOG_RETENTION_DAYS", "30"))
INTERNAL_JOB_PREFIXES = ("cleanup_",)
jobstores = {"default": SQLAlchemyJobStore(engine=sync_engine)} jobstores = {"default": SQLAlchemyJobStore(engine=sync_engine)}
scheduler = AsyncIOScheduler(jobstores=jobstores, timezone=app_tz) scheduler = AsyncIOScheduler(jobstores=jobstores, timezone=app_tz)
async def execute_lamp_command(ip: str, params: dict): def is_internal_job_id(job_id: str) -> bool:
""" return any(job_id.startswith(prefix) for prefix in INTERNAL_JOB_PREFIXES)
Универсальное выполнение команды.
params может содержать: state, dimming, temp, sceneId, r, g, b
""" def build_schedule_job_id(trigger_type: str) -> str:
driver = WizDriver() return f"{trigger_type}_{uuid4().hex}"
await driver.set_pilot(ip, params)
logger.info(f"Сработало расписание для {ip}: {params}")
def _parse_once_run_at(trigger_args: dict) -> datetime:
run_at = datetime.fromisoformat(trigger_args["run_at"])
if run_at.tzinfo is None:
return app_tz.localize(run_at)
return run_at.astimezone(app_tz)
def build_trigger(task: ScheduleTask):
if task.trigger_type == "once":
return DateTrigger(
run_date=_parse_once_run_at(task.trigger_args), timezone=app_tz
)
if task.trigger_type == "cron":
trigger_kwargs = dict(task.trigger_args)
trigger_kwargs["timezone"] = app_tz
return CronTrigger(**trigger_kwargs)
raise ValueError(f"Неизвестный тип триггера: {task.trigger_type}")
def serialize_trigger_args(trigger) -> tuple[str, dict] | None:
if isinstance(trigger, DateTrigger):
run_date = trigger.run_date
if run_date.tzinfo is None:
run_date = app_tz.localize(run_date)
else:
run_date = run_date.astimezone(app_tz)
return "once", {"run_at": run_date.isoformat()}
if isinstance(trigger, CronTrigger):
trigger_args = {}
for field in trigger.fields:
if field.name in {
"year",
"month",
"day",
"week",
"day_of_week",
"hour",
"minute",
"second",
}:
trigger_args[field.name] = str(field)
return "cron", trigger_args
return None
async def cleanup_old_events(): async def cleanup_old_events():
@@ -47,17 +101,272 @@ async def cleanup_old_events():
) )
async def start_scheduler(): def ensure_internal_jobs():
if not scheduler.running: scheduler.add_job(
scheduler.start() cleanup_old_events,
CronTrigger(hour=3, minute=0, timezone=app_tz),
id="cleanup_event_log",
name="Очистка старых событий",
replace_existing=True,
)
# Очистка лога -- раз в сутки в 03:00
scheduler.add_job( async def execute_schedule_job(job_id: str):
cleanup_old_events, """
CronTrigger(hour=3, minute=0, timezone=app_tz), Унифицированная точка входа для исполнения задач расписаний.
id="cleanup_event_log",
name="Очистка старых событий", Рантайм-задача в APScheduler всегда адресуется только по job_id,
replace_existing=True, а вся доменная информация подтягивается из основной БД.
"""
async with async_session() as session:
result = await session.execute(
select(ScheduleTask).where(
ScheduleTask.job_id == job_id,
ScheduleTask.is_active.is_(True),
)
)
task = result.scalar_one_or_none()
if not task:
logger.warning(f"Расписание {job_id} не найдено или деактивировано")
try:
scheduler.remove_job(job_id)
except Exception:
pass
return
from app.api.routes.schedules import run_group_command
await run_group_command(
task.target_id,
task.target_type == "group",
dict(task.action_params),
)
if task.trigger_type == "once":
await delete_schedule_task(job_id, suppress_missing=True)
def add_runtime_job(task: ScheduleTask):
scheduler.add_job(
execute_schedule_job,
trigger=build_trigger(task),
args=[task.job_id],
id=task.job_id,
name=f"{task.trigger_type.upper()}: {task.target_id}",
replace_existing=True,
max_instances=1,
misfire_grace_time=300,
coalesce=(task.trigger_type == "cron"),
)
async def create_schedule_task(
*,
trigger_type: str,
target_id: str,
target_type: str,
trigger_args: dict,
action_params: dict,
) -> ScheduleTask:
task = ScheduleTask(
job_id=build_schedule_job_id(trigger_type),
trigger_type=trigger_type,
target_id=target_id,
target_type=target_type,
trigger_args=trigger_args,
action_params=action_params,
is_active=True,
)
async with async_session() as session:
session.add(task)
await session.commit()
await session.refresh(task)
try:
add_runtime_job(task)
except Exception:
async with async_session() as session:
result = await session.execute(
select(ScheduleTask).where(ScheduleTask.job_id == task.job_id)
)
persisted = result.scalar_one_or_none()
if persisted:
await session.delete(persisted)
await session.commit()
raise
return task
async def list_schedule_tasks() -> list[dict]:
async with async_session() as session:
result = await session.execute(
select(ScheduleTask)
.where(ScheduleTask.is_active.is_(True))
.order_by(ScheduleTask.created_at.asc(), ScheduleTask.id.asc())
)
tasks = result.scalars().all()
items = []
for task in tasks:
job = scheduler.get_job(task.job_id)
next_run = None
if job and job.next_run_time:
next_run = job.next_run_time.astimezone(app_tz).isoformat()
hour = None
minute = None
day_of_week = None
if task.trigger_type == "cron":
hour = task.trigger_args.get("hour")
minute = task.trigger_args.get("minute")
day_of_week = task.trigger_args.get("day_of_week", "*")
items.append(
{
"id": task.job_id,
"target_id": task.target_id,
"is_group": task.target_type == "group",
"state": task.action_params.get("state"),
"action_params": task.action_params,
"trigger_type": task.trigger_type,
"next_run": next_run,
"hour": hour,
"minute": minute,
"day_of_week": day_of_week,
"job_present": job is not None,
}
) )
return items
async def delete_schedule_task(job_id: str, *, suppress_missing: bool = False) -> bool:
if is_internal_job_id(job_id):
raise ValueError("Нельзя удалить служебную задачу")
deleted = False
async with async_session() as session:
result = await session.execute(
select(ScheduleTask).where(ScheduleTask.job_id == job_id)
)
task = result.scalar_one_or_none()
if task:
await session.delete(task)
await session.commit()
deleted = True
try:
scheduler.remove_job(job_id)
deleted = True
except Exception:
pass
if not deleted and not suppress_missing:
raise KeyError(job_id)
return deleted
async def migrate_legacy_scheduler_jobs():
"""
Переносит старые APScheduler-only задачи в таблицу schedules.
Это нужно для безопасного апгрейда существующих установок, где задачи
уже лежат в apscheduler_jobs, но ещё не имеют метаданных в основной БД.
"""
async with async_session() as session:
result = await session.execute(select(ScheduleTask.job_id))
known_job_ids = set(result.scalars().all())
for job in scheduler.get_jobs():
if is_internal_job_id(job.id) or job.id in known_job_ids:
continue
serialized = serialize_trigger_args(job.trigger)
if not serialized:
logger.warning(f"Пропускаю неподдерживаемую legacy-задачу {job.id}")
continue
if len(job.args) != 3:
logger.warning(f"Legacy-задача {job.id} имеет неожиданные args")
continue
target_id, is_group, action_params = job.args
if (
not isinstance(target_id, str)
or not isinstance(is_group, bool)
or not isinstance(action_params, dict)
):
logger.warning(
f"Legacy-задача {job.id} имеет неподдерживаемую сигнатуру"
)
continue
trigger_type, trigger_args = serialized
session.add(
ScheduleTask(
job_id=job.id,
trigger_type=trigger_type,
target_id=target_id,
target_type="group" if is_group else "device",
trigger_args=trigger_args,
action_params=action_params,
is_active=True,
)
)
logger.info(f"Мигрирована legacy-задача {job.id} в таблицу schedules")
await session.commit()
async def rebuild_runtime_jobs_from_metadata():
for job in scheduler.get_jobs():
if not is_internal_job_id(job.id):
scheduler.remove_job(job.id)
async with async_session() as session:
result = await session.execute(
select(ScheduleTask).where(ScheduleTask.is_active.is_(True))
)
tasks = result.scalars().all()
now = datetime.now(app_tz)
for task in tasks:
if task.trigger_type == "once":
run_at = _parse_once_run_at(task.trigger_args)
if run_at <= now:
logger.info(f"Удаляю просроченную одноразовую задачу {task.job_id}")
await delete_schedule_task(task.job_id, suppress_missing=True)
continue
add_runtime_job(task)
async def reconcile_schedule_jobs():
await migrate_legacy_scheduler_jobs()
await rebuild_runtime_jobs_from_metadata()
async def start_scheduler():
current_loop = asyncio.get_running_loop()
bound_loop = getattr(scheduler, "_eventloop", None)
if scheduler.running and (
bound_loop is None or bound_loop.is_closed() or bound_loop is not current_loop
):
if bound_loop is not None and not bound_loop.is_closed():
scheduler.shutdown(wait=False)
else:
scheduler.state = STATE_STOPPED
scheduler._eventloop = current_loop
if not scheduler.running:
scheduler.start()
logger.info(f"Планировщик запущен. Таймзона: {TZ_NAME}") logger.info(f"Планировщик запущен. Таймзона: {TZ_NAME}")
ensure_internal_jobs()
await reconcile_schedule_jobs()

View File

@@ -1,6 +1,23 @@
import json import json
import asyncio import asyncio
import socket import socket
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class WizResponse:
ok: bool
ip: str
kind: str
payload: dict[str, Any] | None = None
message: str | None = None
@property
def result(self) -> dict[str, Any]:
if not self.payload:
return {}
return self.payload.get("result", {})
class WizDriver: class WizDriver:
@@ -41,24 +58,72 @@ class WizDriver:
"steampunk": 35, "steampunk": 35,
} }
async def send_udp(self, ip: str, payload: dict): async def send_udp(self, ip: str, payload: dict) -> WizResponse:
loop = asyncio.get_running_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()
await loop.run_in_executor(None, sock.sendto, data, (ip, self.PORT)) try:
await loop.run_in_executor(None, sock.sendto, data, (ip, self.PORT))
except OSError as exc:
return WizResponse(
ok=False,
ip=ip,
kind="network_error",
message=f"Ошибка отправки UDP: {exc}",
)
try: try:
resp, _ = await loop.run_in_executor(None, sock.recvfrom, 1024) resp, _ = await loop.run_in_executor(None, sock.recvfrom, 1024)
return json.loads(resp.decode())
except socket.timeout: except socket.timeout:
return None return WizResponse(
ok=False,
ip=ip,
kind="timeout",
message="Таймаут ответа от лампы",
)
except OSError as exc:
return WizResponse(
ok=False,
ip=ip,
kind="network_error",
message=f"Ошибка чтения UDP: {exc}",
)
async def set_pilot(self, ip: str, params: dict): try:
decoded = json.loads(resp.decode())
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
return WizResponse(
ok=False,
ip=ip,
kind="protocol_error",
message=f"Невалидный ответ WiZ: {exc}",
)
if isinstance(decoded, dict) and "error" in decoded:
return WizResponse(
ok=False,
ip=ip,
kind="device_error",
payload=decoded,
message=str(decoded["error"]),
)
if not isinstance(decoded, dict):
return WizResponse(
ok=False,
ip=ip,
kind="protocol_error",
message="Ответ WiZ имеет неожиданный формат",
)
return WizResponse(ok=True, ip=ip, kind="ok", payload=decoded)
async def set_pilot(self, ip: str, params: dict) -> WizResponse:
payload = {"method": "setPilot", "params": params} payload = {"method": "setPilot", "params": params}
return await self.send_udp(ip, payload) return await self.send_udp(ip, payload)
async def get_pilot(self, ip: str): async def get_pilot(self, ip: str) -> WizResponse:
payload = {"method": "getPilot", "params": {}} payload = {"method": "getPilot", "params": {}}
return await self.send_udp(ip, payload) return await self.send_udp(ip, payload)

View File

@@ -1,6 +1,7 @@
import hashlib
import secrets import secrets
from datetime import datetime from datetime import datetime
from sqlalchemy import String, Boolean, DateTime from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base from app.core.database import Base
@@ -24,3 +25,20 @@ class ApiKeyModel(Base):
def generate_key() -> str: def generate_key() -> str:
"""Генерация безопасного случайного токена.""" """Генерация безопасного случайного токена."""
return secrets.token_urlsafe(32) return secrets.token_urlsafe(32)
@staticmethod
def public_id_for(key: str) -> str:
"""
Возвращает безопасный публичный идентификатор ключа.
Его можно отдавать клиенту и использовать в операциях revoke/activate
вместо самого секрета.
"""
return hashlib.sha256(key.encode("utf-8")).hexdigest()[:16]
@property
def public_id(self) -> str:
return self.public_id_for(self.key)
@property
def preview(self) -> str:
return f"{self.key[:6]}...{self.key[-4:]}"

View File

@@ -1,13 +1,29 @@
from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, JSON from datetime import datetime
from sqlalchemy import Boolean, Integer, JSON, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base from app.core.database import Base
class ScheduleTask(Base): class ScheduleTask(Base):
"""
Персистентная метадата пользовательских расписаний.
APScheduler остаётся рантайм-движком исполнения, а эта таблица служит
источником истины для CRUD, восстановления и миграции задач.
"""
__tablename__ = "schedules" __tablename__ = "schedules"
id = Column(Integer, primary_key=True, index=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
device_id = Column(Integer, ForeignKey("devices.id"), nullable=False) job_id: Mapped[str] = mapped_column(String, unique=True, index=True)
task_type = Column(String) # 'once', 'daily', 'cron' trigger_type: Mapped[str] = mapped_column(String) # once | cron
action_params = Column(JSON) # {'state': True, 'dimming': 50} target_id: Mapped[str] = mapped_column(String)
is_active = Column(Boolean, default=True) target_type: Mapped[str] = mapped_column(String) # group | device
job_id = Column(String, unique=True) # ID задачи в APScheduler trigger_args: Mapped[dict] = mapped_column(JSON)
action_params: Mapped[dict] = mapped_column(JSON)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[str] = mapped_column(
String, default=lambda: datetime.now().isoformat()
)

14
main.py
View File

@@ -21,9 +21,8 @@ logger = logging.getLogger(__name__)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# 1. БД и Планировщик # 1. БД
await init_db() await init_db()
await start_scheduler()
# 2. Загрузка групп # 2. Загрузка групп
async with async_session() as session: async with async_session() as session:
@@ -32,7 +31,10 @@ async def lifespan(app: FastAPI):
state_manager.groups[g.id] = g state_manager.groups[g.id] = g
logger.info(f"📂 Загружена группа: {g.name}") logger.info(f"📂 Загружена группа: {g.name}")
# 3. Фоновый Discovery # 3. Планировщик после загрузки метаданных групп
await start_scheduler()
# 4. Фоновый Discovery
discovery_task = asyncio.create_task( discovery_task = asyncio.create_task(
discovery_service.start_background_discovery(state_manager) discovery_service.start_background_discovery(state_manager)
) )
@@ -66,7 +68,11 @@ async def read_index():
@app.get("/auth/me") @app.get("/auth/me")
async def auth_me(auth=Depends(verify_token)): async def auth_me(auth=Depends(verify_token)):
return {"is_admin": auth.is_admin, "name": auth.key_name} return {
"is_admin": auth.is_admin,
"is_master": auth.is_master,
"name": auth.key_name,
}
if __name__ == "__main__": if __name__ == "__main__":

1508
openapi.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -60,6 +60,7 @@
<h1 class="text-2xl font-black tracking-tight uppercase">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>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span v-if="authName" class="text-[9px] mono text-slate-600">{{ authName }}</span> <span v-if="authName" class="text-[9px] mono text-slate-600">{{ authName }}</span>
<span v-if="isMaster" class="text-[9px] text-orange-500 font-bold uppercase">master</span>
<span v-if="!isAdmin" class="text-[9px] text-yellow-600 font-bold uppercase">гость</span> <span v-if="!isAdmin" class="text-[9px] text-yellow-600 font-bold uppercase">гость</span>
<button @click="logout" class="text-[9px] text-slate-600 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>
@@ -212,7 +213,7 @@
</section> </section>
<!-- Гостевые API-ключи --> <!-- Гостевые API-ключи -->
<section class="glass p-6 rounded-2xl"> <section v-if="isMaster" class="glass p-6 rounded-2xl">
<h2 class="text-lg font-black mb-6 uppercase">Гостевые ключи</h2> <h2 class="text-lg font-black mb-6 uppercase">Гостевые ключи</h2>
<!-- Создание --> <!-- Создание -->
@@ -244,7 +245,7 @@
<span v-else class="text-[9px] text-slate-500 font-bold uppercase bg-slate-500/10 px-2 py-0.5 rounded">гость</span> <span v-else class="text-[9px] text-slate-500 font-bold uppercase bg-slate-500/10 px-2 py-0.5 rounded">гость</span>
<span :class="k.active ? 'text-green-500' : 'text-red-500'" class="text-[9px] font-bold uppercase">{{ k.active ? 'активен' : 'отозван' }}</span> <span :class="k.active ? 'text-green-500' : 'text-red-500'" class="text-[9px] font-bold uppercase">{{ k.active ? 'активен' : 'отозван' }}</span>
</div> </div>
<div class="mono text-[10px] text-slate-600 mt-1">{{ k.key.slice(0, 12) }}...{{ k.key.slice(-6) }}</div> <div class="mono text-[10px] text-slate-600 mt-1">{{ k.display_key || (k.key.slice(0, 12) + '...' + k.key.slice(-6)) }}</div>
</div> </div>
<div class="flex gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity"> <div class="flex gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button v-if="k.active" @click="revokeApiKey(k.key, k.name)" class="p-2 rounded-lg bg-red-900/20 hover:bg-red-900/40 text-red-400 text-xs font-bold transition-all" title="Отозвать">ОТОЗВАТЬ</button> <button v-if="k.active" @click="revokeApiKey(k.key, k.name)" class="p-2 rounded-lg bg-red-900/20 hover:bg-red-900/40 text-red-400 text-xs font-bold transition-all" title="Отозвать">ОТОЗВАТЬ</button>
@@ -361,6 +362,7 @@
tempKey: '', tempKey: '',
tab: 'control', tab: 'control',
isAdmin: false, isAdmin: false,
isMaster: false,
authName: '', authName: '',
groups: {}, devices: [], sliders: {}, groups: {}, devices: [], sliders: {},
newGroup: { id: '', name: '', macs: [] }, newGroup: { id: '', name: '', macs: [] },
@@ -377,7 +379,7 @@
saveKey() { saveKey() {
if (this.tempKey) { this.apiKey = this.tempKey; localStorage.setItem('ignis_key', this.tempKey); this.initApp(); } if (this.tempKey) { this.apiKey = this.tempKey; localStorage.setItem('ignis_key', this.tempKey); this.initApp(); }
}, },
logout() { this.apiKey = ''; this.isAdmin = false; this.authName = ''; localStorage.removeItem('ignis_key'); location.reload(); }, logout() { this.apiKey = ''; this.isAdmin = false; this.isMaster = false; this.authName = ''; localStorage.removeItem('ignis_key'); location.reload(); },
toast(text, type = 'info', duration = 3000) { toast(text, type = 'info', duration = 3000) {
const id = ++this.toastCounter; this.toasts.push({ id, text, type }); const id = ++this.toastCounter; this.toasts.push({ id, text, type });
setTimeout(() => { this.toasts = this.toasts.filter(t => t.id !== id); }, duration); setTimeout(() => { this.toasts = this.toasts.filter(t => t.id !== id); }, duration);
@@ -391,7 +393,7 @@
const r = await fetch(url, { method, headers: { 'X-API-Key': this.apiKey, 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : null }); const r = await fetch(url, { method, headers: { 'X-API-Key': this.apiKey, 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : null });
if (r.status === 403) { if (r.status === 403) {
const err = await r.json().catch(() => ({})); const err = await r.json().catch(() => ({}));
if (err.detail === 'Недостаточно прав') { this.toast('Нет прав', 'error'); return null; } if (err.detail === 'Недостаточно прав' || err.detail === 'Требуется мастер-ключ') { this.toast('Нет прав', 'error'); return null; }
this.toast('Неверный API-ключ', 'error'); this.logout(); return null; this.toast('Неверный API-ключ', 'error'); this.logout(); return null;
} }
if (!r.ok) { const err = await r.json().catch(() => ({})); this.toast(err.detail || `Ошибка ${r.status}`, 'error'); return null; } if (!r.ok) { const err = await r.json().catch(() => ({})); this.toast(err.detail || `Ошибка ${r.status}`, 'error'); return null; }
@@ -404,6 +406,7 @@
const auth = await this.request('/auth/me'); const auth = await this.request('/auth/me');
if (!auth) { this.isLoading = false; return; } if (!auth) { this.isLoading = false; return; }
this.isAdmin = auth.is_admin; this.isAdmin = auth.is_admin;
this.isMaster = !!auth.is_master;
this.authName = auth.name; this.authName = auth.name;
await this.fetchData(); await this.fetchData();
this.isLoading = false; this.isLoading = false;
@@ -425,7 +428,7 @@
if (dData) this.devices = Array.isArray(dData) ? dData : Object.values(dData); if (dData) this.devices = Array.isArray(dData) ? dData : Object.values(dData);
if (sData) this.allScenes = sData; if (sData) this.allScenes = sData;
if (this.isAdmin) this.fetchTasks(); if (this.isAdmin) this.fetchTasks();
if (this.isAdmin) this.fetchApiKeys(); if (this.isMaster) this.fetchApiKeys();
} finally { this.isFetching = false; } } finally { this.isFetching = false; }
}, },

239
tests/test_p0_schedules.py Normal file
View File

@@ -0,0 +1,239 @@
import os
import unittest
from datetime import datetime, timedelta
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from apscheduler.triggers.cron import CronTrigger
from httpx import ASGITransport, AsyncClient
from sqlalchemy import delete, select
TEST_DB_PATH = Path(__file__).with_name("test_ignis.db")
MASTER_KEY = "master-secret-for-tests"
os.environ["IGNIS_API_KEY"] = MASTER_KEY
os.environ["IGNIS_DATABASE_URL"] = f"sqlite+aiosqlite:///{TEST_DB_PATH}"
os.environ["IGNIS_SYNC_DATABASE_URL"] = f"sqlite:///{TEST_DB_PATH}"
import main # noqa: E402
from app.core.database import async_session, init_db # noqa: E402
from app.core.scheduler import ( # noqa: E402
app_tz,
execute_schedule_job,
reconcile_schedule_jobs,
scheduler,
start_scheduler,
)
from app.core.state import state_manager # noqa: E402
from app.models.event_log import EventLog # noqa: E402
from app.models.schedule import ScheduleTask # noqa: E402
class ScheduleApiTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
os.environ["IGNIS_API_KEY"] = MASTER_KEY
await init_db()
await start_scheduler()
await self._reset_database()
self._reset_state()
self.client = AsyncClient(
transport=ASGITransport(app=main.app),
base_url="http://testserver",
)
async def asyncTearDown(self):
await self.client.aclose()
self._clear_runtime_jobs()
state_manager.devices.clear()
state_manager.groups.clear()
def _reset_state(self):
state_manager.devices.clear()
state_manager.groups.clear()
state_manager.devices["dev-1"] = SimpleNamespace(id="dev-1", ip="192.168.1.10")
state_manager.devices["dev-2"] = SimpleNamespace(id="dev-2", ip="192.168.1.11")
state_manager.groups["grp-1"] = SimpleNamespace(
id="grp-1",
name="Office",
device_ids=["dev-1", "dev-2"],
)
def _clear_runtime_jobs(self):
for job in scheduler.get_jobs():
if not job.id.startswith("cleanup_"):
scheduler.remove_job(job.id)
async def _reset_database(self):
self._clear_runtime_jobs()
async with async_session() as session:
await session.execute(delete(EventLog))
await session.execute(delete(ScheduleTask))
await session.commit()
def _headers(self) -> dict[str, str]:
return {"X-API-Key": MASTER_KEY}
async def test_cron_tasks_do_not_collide_and_are_persisted(self):
first = await self.client.post(
"/schedules/cron",
headers=self._headers(),
params={
"target_id": "grp-1",
"hour": "22",
"minute": "00",
"day_of_week": "1",
"is_group": "true",
"state": "true",
},
)
second = await self.client.post(
"/schedules/cron",
headers=self._headers(),
params={
"target_id": "grp-1",
"hour": "22",
"minute": "00",
"day_of_week": "5",
"is_group": "true",
"state": "false",
},
)
self.assertEqual(first.status_code, 200)
self.assertEqual(second.status_code, 200)
self.assertNotEqual(first.json()["job_id"], second.json()["job_id"])
tasks_response = await self.client.get(
"/schedules/tasks", headers=self._headers()
)
self.assertEqual(tasks_response.status_code, 200)
tasks = tasks_response.json()["tasks"]
self.assertEqual(len(tasks), 2)
async with async_session() as session:
result = await session.execute(
select(ScheduleTask).order_by(ScheduleTask.id)
)
rows = result.scalars().all()
self.assertEqual(len(rows), 2)
self.assertEqual(rows[0].trigger_args["day_of_week"], "1")
self.assertEqual(rows[1].trigger_args["day_of_week"], "5")
async def test_once_task_supports_non_toggle_payload_and_cleans_itself_after_execution(
self,
):
run_at = datetime.now(app_tz) + timedelta(hours=2)
response = await self.client.post(
"/schedules/once",
headers=self._headers(),
params={
"target_id": "grp-1",
"run_at": run_at.isoformat(),
"is_group": "true",
"temp": "3200",
},
)
self.assertEqual(response.status_code, 200)
payload = response.json()
job_id = payload["job_id"]
async with async_session() as session:
result = await session.execute(
select(ScheduleTask).where(ScheduleTask.job_id == job_id)
)
task = result.scalar_one()
self.assertEqual(task.action_params, {"temp": 3200})
with patch(
"app.api.routes.schedules.run_group_command",
AsyncMock(return_value=None),
) as mocked_runner:
await execute_schedule_job(job_id)
mocked_runner.assert_awaited_once_with("grp-1", True, {"temp": 3200})
async with async_session() as session:
result = await session.execute(
select(ScheduleTask).where(ScheduleTask.job_id == job_id)
)
self.assertIsNone(result.scalar_one_or_none())
async def test_cancel_task_removes_db_metadata_and_runtime_job(self):
create_response = await self.client.post(
"/schedules/cron",
headers=self._headers(),
params={
"target_id": "grp-1",
"hour": "21",
"minute": "15",
"is_group": "true",
"state": "true",
},
)
job_id = create_response.json()["job_id"]
self.assertIsNotNone(scheduler.get_job(job_id))
delete_response = await self.client.delete(
f"/schedules/{job_id}",
headers=self._headers(),
)
self.assertEqual(delete_response.status_code, 200)
self.assertIsNone(scheduler.get_job(job_id))
async with async_session() as session:
result = await session.execute(
select(ScheduleTask).where(ScheduleTask.job_id == job_id)
)
self.assertIsNone(result.scalar_one_or_none())
async def test_reconcile_migrates_legacy_scheduler_job_into_metadata_and_rebuilds_runtime_job(
self,
):
from app.api.routes.schedules import run_group_command
scheduler.add_job(
run_group_command,
trigger=CronTrigger(hour="23", minute="05", timezone=app_tz),
args=["grp-1", True, {"state": True}],
id="legacy_job",
name="CRON: grp-1 | 23:05 | True",
replace_existing=True,
)
await reconcile_schedule_jobs()
async with async_session() as session:
result = await session.execute(
select(ScheduleTask).where(ScheduleTask.job_id == "legacy_job")
)
task = result.scalar_one_or_none()
self.assertIsNotNone(task)
self.assertEqual(task.target_type, "group")
self.assertEqual(task.action_params, {"state": True})
rebuilt_job = scheduler.get_job("legacy_job")
self.assertIsNotNone(rebuilt_job)
self.assertEqual(list(rebuilt_job.args), ["legacy_job"])
async def test_invalid_cron_values_return_400_without_persisting_task(self):
response = await self.client.post(
"/schedules/cron",
headers=self._headers(),
params={
"target_id": "grp-1",
"hour": "99",
"minute": "99",
"is_group": "true",
"temp": "3200",
},
)
self.assertEqual(response.status_code, 400)
self.assertIn("Error validating expression", response.json()["detail"])
async with async_session() as session:
result = await session.execute(select(ScheduleTask))
rows = result.scalars().all()
self.assertEqual(rows, [])

View File

@@ -0,0 +1,410 @@
import asyncio
import os
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from httpx import ASGITransport, AsyncClient
from sqlalchemy import delete, select
TEST_DB_PATH = Path(__file__).with_name("test_ignis.db")
if TEST_DB_PATH.exists():
TEST_DB_PATH.unlink()
MASTER_KEY = "master-secret-for-tests"
os.environ["IGNIS_API_KEY"] = MASTER_KEY
os.environ["IGNIS_DATABASE_URL"] = f"sqlite+aiosqlite:///{TEST_DB_PATH}"
os.environ["IGNIS_SYNC_DATABASE_URL"] = f"sqlite:///{TEST_DB_PATH}"
import main # noqa: E402
from app.api.routes import control # noqa: E402
from app.core.database import async_session, engine, init_db, sync_engine # noqa: E402
from app.core.state import state_manager # noqa: E402
from app.drivers.wiz import WizResponse # noqa: E402
from app.models.api_key import ApiKeyModel # noqa: E402
from app.models.device import DeviceSchema # noqa: E402
from app.models.event_log import EventLog # noqa: E402
class SecurityAndControlApiTests(unittest.IsolatedAsyncioTestCase):
@classmethod
def tearDownClass(cls):
sync_engine.dispose()
asyncio.run(engine.dispose())
if TEST_DB_PATH.exists():
TEST_DB_PATH.unlink()
super().tearDownClass()
async def asyncSetUp(self):
os.environ["IGNIS_API_KEY"] = MASTER_KEY
await init_db()
await self._reset_database()
state_manager.devices.clear()
state_manager.groups.clear()
self.client = AsyncClient(
transport=ASGITransport(app=main.app),
base_url="http://testserver",
)
async def asyncTearDown(self):
await self.client.aclose()
state_manager.devices.clear()
state_manager.groups.clear()
async def _reset_database(self):
async with async_session() as session:
await session.execute(delete(EventLog))
await session.execute(delete(ApiKeyModel))
await session.commit()
async def _event_actions(self) -> list[str]:
async with async_session() as session:
result = await session.execute(
select(EventLog.action).order_by(EventLog.id)
)
return list(result.scalars().all())
def _master_headers(self) -> dict[str, str]:
return {"X-API-Key": MASTER_KEY}
def _set_single_device_state(self):
state_manager.devices["dev-1"] = DeviceSchema(
id="dev-1",
ip="192.168.1.10",
name="Lamp 1",
room="Office",
)
def _set_group_state(self):
state_manager.devices["dev-1"] = DeviceSchema(
id="dev-1",
ip="192.168.1.10",
name="Lamp 1",
room="Office",
)
state_manager.devices["dev-2"] = DeviceSchema(
id="dev-2",
ip="192.168.1.11",
name="Lamp 2",
room="Office",
)
state_manager.groups["grp-1"] = SimpleNamespace(
id="grp-1",
name="Office",
device_ids=["dev-1", "dev-2"],
)
async def test_auth_is_fail_closed_when_master_key_missing(self):
os.environ["IGNIS_API_KEY"] = ""
response = await self.client.get("/auth/me")
self.assertEqual(response.status_code, 503)
self.assertEqual(
response.json()["detail"], "Сервер не настроен: задайте IGNIS_API_KEY"
)
async def test_master_can_create_key_and_list_endpoint_returns_public_id(self):
create_response = await self.client.post(
"/api-keys",
headers=self._master_headers(),
params={"name": "tablet", "is_admin": True},
)
self.assertEqual(create_response.status_code, 200)
created = create_response.json()
self.assertIn("key", created)
self.assertIn("key_id", created)
self.assertNotEqual(created["key"], created["key_id"])
list_response = await self.client.get(
"/api-keys",
headers=self._master_headers(),
)
self.assertEqual(list_response.status_code, 200)
listed = list_response.json()
self.assertEqual(len(listed), 1)
self.assertEqual(listed[0]["name"], "tablet")
self.assertEqual(listed[0]["key"], created["key_id"])
self.assertNotEqual(listed[0]["key"], created["key"])
self.assertIn("display_key", listed[0])
async def test_admin_guest_cannot_manage_api_keys(self):
create_response = await self.client.post(
"/api-keys",
headers=self._master_headers(),
params={"name": "guest-admin", "is_admin": True},
)
guest_key = create_response.json()["key"]
auth_response = await self.client.get(
"/auth/me",
headers={"X-API-Key": guest_key},
)
self.assertEqual(auth_response.status_code, 200)
self.assertEqual(
auth_response.json(),
{"is_admin": True, "is_master": False, "name": "guest-admin"},
)
forbidden_response = await self.client.get(
"/api-keys",
headers={"X-API-Key": guest_key},
)
self.assertEqual(forbidden_response.status_code, 403)
self.assertEqual(forbidden_response.json()["detail"], "Требуется мастер-ключ")
async def test_master_can_revoke_and_activate_by_public_key_id(self):
create_response = await self.client.post(
"/api-keys",
headers=self._master_headers(),
params={"name": "wall-panel"},
)
payload = create_response.json()
public_id = payload["key_id"]
secret = payload["key"]
revoke_response = await self.client.post(
"/api-keys/revoke",
headers=self._master_headers(),
json={"key": public_id},
)
self.assertEqual(revoke_response.status_code, 200)
revoked_auth = await self.client.get(
"/auth/me",
headers={"X-API-Key": secret},
)
self.assertEqual(revoked_auth.status_code, 403)
activate_response = await self.client.post(
"/api-keys/activate",
headers=self._master_headers(),
json={"key": public_id},
)
self.assertEqual(activate_response.status_code, 200)
active_auth = await self.client.get(
"/auth/me",
headers={"X-API-Key": secret},
)
self.assertEqual(active_auth.status_code, 200)
async def test_admin_guest_keeps_access_to_admin_routes_except_master_only_ones(
self,
):
create_response = await self.client.post(
"/api-keys",
headers=self._master_headers(),
params={"name": "guest-admin", "is_admin": True},
)
guest_key = create_response.json()["key"]
response = await self.client.post(
"/devices/groups",
headers={"X-API-Key": guest_key},
json={"id": "bedroom", "name": "Bedroom", "macs": ["dev-1"]},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["status"], "created")
async def test_device_control_timeout_returns_504_and_does_not_log_false_success(
self,
):
self._set_single_device_state()
with patch.object(
control.wiz,
"set_pilot",
AsyncMock(
return_value=WizResponse(
ok=False,
ip="192.168.1.10",
kind="timeout",
message="Таймаут ответа от лампы",
)
),
):
response = await self.client.post(
"/control/device/dev-1",
headers=self._master_headers(),
params={"state": "true"},
)
self.assertEqual(response.status_code, 504)
self.assertIn("Команда лампе не доставлена", response.json()["detail"])
self.assertEqual(
await self._event_actions(), ["toggle_on_requested", "toggle_on_failed"]
)
async def test_group_control_partial_success_reports_partial_and_logs_applied_result(
self,
):
self._set_group_state()
mocked_results = [
WizResponse(
ok=True,
ip="192.168.1.10",
kind="ok",
payload={"result": {"success": True}},
),
WizResponse(
ok=False,
ip="192.168.1.11",
kind="timeout",
message="Таймаут ответа от лампы",
),
]
with patch.object(
control.wiz, "set_pilot", AsyncMock(side_effect=mocked_results)
):
response = await self.client.post(
"/control/group/grp-1",
headers=self._master_headers(),
params={"state": "true"},
)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["status"], "partial")
self.assertEqual(payload["success_count"], 1)
self.assertEqual(payload["failure_count"], 1)
self.assertEqual(len(payload["results"]), 2)
self.assertEqual(
await self._event_actions(), ["toggle_on_requested", "toggle_on"]
)
async def test_group_control_total_failure_returns_gateway_error(self):
self._set_group_state()
mocked_results = [
WizResponse(
ok=False,
ip="192.168.1.10",
kind="timeout",
message="Таймаут ответа от лампы",
),
WizResponse(
ok=False,
ip="192.168.1.11",
kind="timeout",
message="Таймаут ответа от лампы",
),
]
with patch.object(
control.wiz, "set_pilot", AsyncMock(side_effect=mocked_results)
):
response = await self.client.post(
"/control/group/grp-1",
headers=self._master_headers(),
params={"state": "true"},
)
self.assertEqual(response.status_code, 504)
self.assertIn("Команда группе не доставлена", response.json()["detail"])
self.assertEqual(
await self._event_actions(), ["toggle_on_requested", "toggle_on_failed"]
)
async def test_group_status_returns_per_device_errors_instead_of_500(self):
self._set_group_state()
mocked_results = [
WizResponse(
ok=False,
ip="192.168.1.10",
kind="timeout",
message="Таймаут ответа от лампы",
),
WizResponse(
ok=True,
ip="192.168.1.11",
kind="ok",
payload={"result": {"state": True, "dimming": 50}},
),
]
with patch.object(
control.wiz, "get_pilot", AsyncMock(side_effect=mocked_results)
):
response = await self.client.get(
"/control/group/grp-1/status",
headers=self._master_headers(),
)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["group_id"], "grp-1")
self.assertEqual(payload["results"][0]["kind"], "timeout")
self.assertEqual(payload["results"][1]["status"]["dimming"], 50)
async def test_device_status_timeout_is_reported_as_504(self):
self._set_single_device_state()
with patch.object(
control.wiz,
"get_pilot",
AsyncMock(
return_value=WizResponse(
ok=False,
ip="192.168.1.10",
kind="timeout",
message="Таймаут ответа от лампы",
)
),
):
response = await self.client.get(
"/control/device/dev-1/status",
headers=self._master_headers(),
)
self.assertEqual(response.status_code, 504)
self.assertIn("Ошибка опроса лампы", response.json()["detail"])
async def test_device_control_unknown_scene_returns_clear_400(self):
self._set_single_device_state()
response = await self.client.post(
"/control/device/dev-1",
headers=self._master_headers(),
params={"scene": "not_a_scene"},
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["detail"], "Неизвестная сцена")
self.assertEqual(await self._event_actions(), [])
async def test_stats_summary_counts_real_commands_without_requested_duplicates(self):
self._set_single_device_state()
with patch.object(
control.wiz,
"set_pilot",
AsyncMock(
return_value=WizResponse(
ok=True,
ip="192.168.1.10",
kind="ok",
payload={"result": {"success": True}},
)
),
):
response = await self.client.post(
"/control/device/dev-1",
headers=self._master_headers(),
params={"temp": "4200"},
)
self.assertEqual(response.status_code, 200)
summary_response = await self.client.get(
"/stats/summary",
headers=self._master_headers(),
)
self.assertEqual(summary_response.status_code, 200)
payload = summary_response.json()
self.assertEqual(len(payload["groups"]), 1)
self.assertEqual(payload["groups"][0]["target_id"], "dev-1")
self.assertEqual(payload["groups"][0]["total_commands"], 1)
self.assertEqual(payload["groups"][0]["by_user"], {"master": 1})