From 13fba2fa440bba55ecc1f052a343bb6383b3c64a Mon Sep 17 00:00:00 2001 From: Artem Kokos Date: Fri, 15 May 2026 23:12:28 +0700 Subject: [PATCH] Fix API regressions and refresh project docs --- .ai/ROADMAP.md | 323 ------ .ai/master_prompt.md | 294 ----- .gitignore | 2 + README.md | 226 ++-- app/api/deps.py | 36 +- app/api/routes/api_keys.py | 51 +- app/api/routes/control.py | 347 ++++-- app/api/routes/schedules.py | 245 ++-- app/api/routes/stats.py | 7 + app/core/database.py | 19 +- app/core/scheduler.py | 359 +++++- app/drivers/wiz.py | 77 +- app/models/api_key.py | 20 +- app/models/schedule.py | 30 +- main.py | 14 +- openapi.json | 1508 +++++++++++++++++++++++++ static/index.html | 15 +- tests/test_p0_schedules.py | 239 ++++ tests/test_p0_security_and_control.py | 410 +++++++ 19 files changed, 3258 insertions(+), 964 deletions(-) delete mode 100644 .ai/ROADMAP.md delete mode 100644 .ai/master_prompt.md create mode 100644 openapi.json create mode 100644 tests/test_p0_schedules.py create mode 100644 tests/test_p0_security_and_control.py diff --git a/.ai/ROADMAP.md b/.ai/ROADMAP.md deleted file mode 100644 index ed147dd..0000000 --- a/.ai/ROADMAP.md +++ /dev/null @@ -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 можно развивать без страха всё сломать. diff --git a/.ai/master_prompt.md b/.ai/master_prompt.md deleted file mode 100644 index 52bf3ae..0000000 --- a/.ai/master_prompt.md +++ /dev/null @@ -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 - -Не добавляй слой поверх проблемы. -Сначала найди реальный источник истины. -Сделай поведение честным. -Сделай контракт явным. -Сделай проект безопасным по умолчанию. diff --git a/.gitignore b/.gitignore index 357c7c0..57cca0f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ __pycache__/ *.db .pytest_cache/ .env +.ai/ +.codex diff --git a/README.md b/README.md index 9e16a56..6626a36 100644 --- a/README.md +++ b/README.md @@ -1,139 +1,167 @@ # Ignis Core -Self-hosted сервер для управления умными лампами WiZ по локальной сети. FastAPI бэкенд с веб-интерфейсом, планировщиком расписаний и REST API для мобильного приложения. +Локальный FastAPI-сервер для управления лампами WiZ. -## Возможности +## Что есть -- **Discovery** -- автоматическое обнаружение ламп WiZ в локальной сети (UDP broadcast). Поддержка нескольких подсетей через `SCAN_NETWORK`. -- **Группы** -- объединение ламп в именованные группы (спальня, кухня, ...). Хранение в SQLite. -- **Управление** -- включение/выключение, яркость, цветовая температура, RGB-цвет, 35+ встроенных сцен. -- **Расписания** -- одноразовые таймеры и cron-задачи через APScheduler с персистентным хранилищем. -- **Веб-интерфейс** -- SPA на Vue 3 + Tailwind, встроен в сервер как статика. -- **API** -- REST API с авторизацией по API-ключу для мобильных клиентов. +- discovery устройств в локальной сети +- группы устройств +- команды для device/group +- one-shot и cron расписания +- guest/admin/master API-ключи +- event log и базовая статистика +- встроенный UI в `static/index.html` -## Быстрый старт +## Запуск ```bash -# Клонировать -git clone https://git.akokos.ru/artem.kokos/ignis-core.git -cd ignis-core - -# Виртуальное окружение python3 -m venv .venv source .venv/bin/activate - -# Зависимости pip install -r requirements.txt - -# Конфигурация cp .env.example .env -# Отредактировать .env -- указать API-ключ и таймзону - -# Запуск uvicorn main:app --host 0.0.0.0 --port 8000 ``` -Сервер будет доступен на `http://:8000`. Веб-интерфейс -- на корневом URL. +UI: `http://:8000/` -## Конфигурация (.env) +## Конфигурация + +Минимум: ```env -# API-ключ для авторизации (если не задан -- авторизация отключена) -IGNIS_API_KEY=your-secret-key - -# Таймзона для расписаний (по умолчанию Asia/Novosibirsk) -APP_TIMEZONE=Asia/Almaty - -# Подсети для сканирования (через запятую, по умолчанию -- автоопределение) -SCAN_NETWORK=192.168.1.0/24 - -# Уровень логирования +IGNIS_API_KEY=change-me +APP_TIMEZONE=Asia/Novosibirsk +SCAN_NETWORK= 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 -├── requirements.txt -- зависимости -├── .env -- конфигурация (не в git) -├── static/ -│ └── index.html -- веб-интерфейс (Vue 3 SPA) -├── app/ -│ ├── core/ -│ │ ├── database.py -- async SQLAlchemy, SQLite -│ │ ├── discovery.py -- UDP-сканирование сети WiZ -│ │ ├── scheduler.py -- APScheduler + jobstore -│ │ └── state.py -- in-memory состояние (устройства, группы) -│ ├── models/ -│ │ ├── device.py -- модели Device, Group (SQLAlchemy + Pydantic) -│ │ └── schedule.py -- модель ScheduleTask -│ ├── drivers/ -│ │ └── wiz.py -- UDP-драйвер протокола WiZ -│ └── api/ -│ ├── deps.py -- авторизация (X-API-Key) -│ └── routes/ -│ ├── devices.py -- CRUD устройств и групп -│ ├── control.py -- управление лампами -│ └── schedules.py -- расписания (once, cron) -└── ignis.db -- SQLite база (создаётся автоматически) -``` + +Замечание по discovery: + +- если на хосте есть VPN или несколько интерфейсов, лучше явно задать `SCAN_NETWORK` +- формат: `192.168.0.0/24` или список через запятую + +## Авторизация + +Заголовок: `X-API-Key` + +Роли: + +- `master`: значение из `IGNIS_API_KEY`, полный доступ +- `admin`: ключ из БД, доступ к группам, расписаниям, stats и rescan +- `guest`: обычное управление и чтение + +Сервер работает в `fail-closed`: если `IGNIS_API_KEY` не задан, защищённые маршруты недоступны. ## 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` -| Метод | Путь | Описание | -|--------|--------------------------|------------------------------| -| GET | `/devices` | Все обнаруженные лампы | -| GET | `/devices/groups` | Все группы | -| GET | `/devices/scenes` | Доступные сцены WiZ | -| POST | `/devices/groups` | Создать группу | -| DELETE | `/devices/groups/{id}` | Удалить группу | -| POST | `/devices/rescan` | Пересканировать сеть | +Текущий контракт для `control` и `schedules` использует query-параметры. -Создание группы (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' +``` -| Метод | Путь | Описание | -|-------|----------------------------------|------------------------| -| POST | `/control/device/{id}` | Управление лампой | -| POST | `/control/group/{id}` | Управление группой | -| POST | `/control/device/{id}/blink` | Мигнуть лампой | -| GET | `/control/device/{id}/status` | Статус лампы | -| GET | `/control/group/{id}/status` | Статус группы | +## API keys -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-сервер -- **SQLAlchemy 2.0** -- async ORM, SQLite через aiosqlite -- **APScheduler** -- планировщик с персистентным хранилищем -- **WiZ Protocol** -- UDP-управление лампами (порт 38899) -- **Vue 3 + Tailwind** -- встроенный веб-интерфейс +## OpenAPI -## Клиенты +Актуальная схема лежит в `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 пока простые и не заменяют нормальную аналитику diff --git a/app/api/deps.py b/app/api/deps.py index 29a45f0..4511c39 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -2,10 +2,9 @@ import os import hmac import logging from dataclasses import dataclass -from typing import Optional from fastapi import Depends, HTTPException 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 sqlalchemy import select @@ -16,10 +15,6 @@ load_dotenv() 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_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False) @@ -33,17 +28,26 @@ class AuthContext: 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: """ Проверка API-ключа: - 1. Если IGNIS_API_KEY не задан -- авторизация отключена, полный доступ + 1. IGNIS_API_KEY должен быть задан, иначе сервер закрыт (fail-closed) 2. Мастер-ключ из .env -- полный доступ 3. Ключ из БД (api_keys) -- проверяем active и is_admin 4. Иначе -- 403 """ - # Авторизация отключена - if not MASTER_KEY: - return AuthContext(is_master=True, is_admin=True, key_name="no-auth") + master_key = get_master_key() + if not master_key: + logger.error("IGNIS_API_KEY не задан: защищённые API закрыты до настройки") + raise HTTPException( + status_code=HTTP_503_SERVICE_UNAVAILABLE, + detail="Сервер не настроен: задайте IGNIS_API_KEY", + ) if not header_value: raise HTTPException( @@ -51,7 +55,7 @@ async def verify_token(header_value: str = Depends(api_key_header)) -> AuthConte ) # Мастер-ключ (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") # Ищем в БД @@ -78,3 +82,13 @@ def require_admin(auth: AuthContext = Depends(verify_token)) -> AuthContext: if not auth.is_admin: raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Недостаточно прав") 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 diff --git a/app/api/routes/api_keys.py b/app/api/routes/api_keys.py index b733933..6e12df5 100644 --- a/app/api/routes/api_keys.py +++ b/app/api/routes/api_keys.py @@ -4,10 +4,10 @@ from sqlalchemy import select from app.core.database import async_session 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): @@ -16,16 +16,41 @@ class KeyActionRequest(BaseModel): 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("") async def list_keys(): - """Список всех гостевых ключей.""" + """ + Список всех гостевых ключей. + + В ответе поле `key` содержит публичный идентификатор, а не сам секрет. + Это сохраняет совместимость с текущим UI и не раскрывает токены повторно. + """ async with async_session() as session: result = await session.execute(select(ApiKeyModel)) keys = result.scalars().all() return [ { - "key": k.key, + "key": k.public_id, + "key_id": k.public_id, + "display_key": k.preview, "name": k.name, "is_admin": k.is_admin, "active": k.active, @@ -50,6 +75,8 @@ async def create_key(name: str, is_admin: bool = False): return { "key": new_key.key, + "key_id": new_key.public_id, + "display_key": new_key.preview, "name": new_key.name, "is_admin": new_key.is_admin, "message": "Сохраните ключ -- он больше не будет показан полностью", @@ -60,10 +87,7 @@ async def create_key(name: str, is_admin: bool = False): async def revoke_key(body: KeyActionRequest): """Деактивировать (отозвать) гостевой ключ. Ключ передаётся в body, не в URL.""" async with async_session() as session: - result = await session.execute( - select(ApiKeyModel).where(ApiKeyModel.key == body.key) - ) - api_key = result.scalar_one_or_none() + api_key = await _find_key_by_secret_or_public_id(session, body.key) if not api_key: raise HTTPException(status_code=404, detail="Ключ не найден") @@ -71,17 +95,14 @@ async def revoke_key(body: KeyActionRequest): session.add(api_key) 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") async def activate_key(body: KeyActionRequest): """Повторно активировать ключ. Ключ передаётся в body, не в URL.""" async with async_session() as session: - result = await session.execute( - select(ApiKeyModel).where(ApiKeyModel.key == body.key) - ) - api_key = result.scalar_one_or_none() + api_key = await _find_key_by_secret_or_public_id(session, body.key) if not api_key: raise HTTPException(status_code=404, detail="Ключ не найден") @@ -89,4 +110,4 @@ async def activate_key(body: KeyActionRequest): session.add(api_key) await session.commit() - return {"status": "activated", "name": api_key.name} + return {"status": "activated", "name": api_key.name, "key_id": api_key.public_id} diff --git a/app/api/routes/control.py b/app/api/routes/control.py index 7972eb3..233a180 100644 --- a/app/api/routes/control.py +++ b/app/api/routes/control.py @@ -1,12 +1,14 @@ import asyncio import json import logging -from typing import Optional +from typing import Any, Optional + 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.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 logger = logging.getLogger(__name__) @@ -34,21 +36,171 @@ async def _log_event( logger.error(f"Ошибка записи в лог: {e}") -async def log_toggle(auth: AuthContext, target_type: str, target_id: str, params: dict): - """Логирует toggle_on/toggle_off если в params есть state.""" +def _build_command_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 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: - action = "toggle_on" if params["state"] else "toggle_off" - await _log_event(auth, action, target_type, target_id, params) + return "toggle_on" if params["state"] else "toggle_off" + 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( - key_name: str, target_type: str, target_id: str, params: dict +def _build_event_payload( + 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 (для планировщика).""" - if "state" in params: - auth = AuthContext(is_master=False, is_admin=False, key_name=key_name) - action = "toggle_on" if params["state"] else "toggle_off" - await _log_event(auth, action, target_type, target_id, params) + action = _resolve_action_name(params) + await _log_event( + auth, + f"{action}_requested", + 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}") @@ -67,27 +219,42 @@ async def control_device( if not device: raise HTTPException(status_code=404, detail="Лампа не в сети") - params = {} - if state is not None: - params["state"] = state - if brightness is not None: - params["dimming"] = brightness - - if scene and scene in wiz.SCENES: - params["sceneId"] = wiz.SCENES[scene] - 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 - + params = _build_command_params(state, brightness, scene, temp, r, g, b) if not params: raise HTTPException(status_code=400, detail="Никаких команд не передано") 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}") @@ -106,25 +273,44 @@ async def control_group( if not ips: raise HTTPException(status_code=404, detail="Группа не найдена или оффлайн") - params = {} - if state is not None: - params["state"] = state - if brightness is not None: - params["dimming"] = brightness - - if scene and scene in wiz.SCENES: - params["sceneId"] = wiz.SCENES[scene] - 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 + params = _build_command_params(state, brightness, scene, temp, r, g, b) + if not params: + raise HTTPException(status_code=400, detail="Никаких команд не передано") 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") @@ -133,16 +319,36 @@ async def blink_device(device_id: str, _auth: AuthContext = Depends(verify_token if not device: raise HTTPException(status_code=404, detail="Лампа оффлайн") - try: - current = await wiz.get_pilot(device.ip) - original_state = current.get("result", {}).get("state", False) - await wiz.set_pilot(device.ip, {"state": not original_state}) - await asyncio.sleep(0.5) - await wiz.set_pilot(device.ip, {"state": original_state}) - return {"status": "blink_done", "original": original_state} - except Exception as e: - logger.error(f"Blink error: {e}") - raise HTTPException(status_code=500, detail="Ошибка связи с лампой") + current = await wiz.get_pilot(device.ip) + if not current.ok: + raise HTTPException( + status_code=_response_error_status(current), + detail=_response_error_detail( + current, prefix="Не удалось получить текущее состояние лампы" + ), + ) + + 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") @@ -152,11 +358,14 @@ async def get_device_status(device_id: str, _auth: AuthContext = Depends(verify_ if not device: raise HTTPException(status_code=404, detail="Лампа оффлайн или не найдена") - try: - status = await wiz.get_pilot(device.ip) - return {"device_id": device_id, "status": status.get("result", {})} - except Exception as e: - raise HTTPException(status_code=500, detail=f"Ошибка опроса лампы: {e}") + status = await wiz.get_pilot(device.ip) + if not status.ok: + raise HTTPException( + status_code=_response_error_status(status), + detail=_response_error_detail(status, prefix="Ошибка опроса лампы"), + ) + + return {"device_id": device_id, "status": status.result} @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: raise HTTPException(status_code=404, detail="Группа не найдена или оффлайн") - tasks = [wiz.get_pilot(ip) for ip in ips] - results = await asyncio.gather(*tasks, return_exceptions=True) + results = await asyncio.gather(*[wiz.get_pilot(ip) for ip in ips]) status_report = [] for ip, res in zip(ips, results): - if isinstance(res, Exception): - status_report.append({"ip": ip, "error": str(res)}) - else: - status_report.append({"ip": ip, "status": res.get("result", {})}) + if res.ok: + status_report.append({"ip": ip, "status": res.result}) + continue + + status_report.append( + { + "ip": ip, + "error": res.message or res.kind, + "kind": res.kind, + } + ) return {"group_id": group_id, "results": status_report} diff --git a/app/api/routes/schedules.py b/app/api/routes/schedules.py index 9746ecd..27ae46d 100644 --- a/app/api/routes/schedules.py +++ b/app/api/routes/schedules.py @@ -1,28 +1,75 @@ import logging from datetime import datetime, timedelta -from typing import Optional -from fastapi import APIRouter, Depends, HTTPException -from apscheduler.triggers.cron import CronTrigger -from apscheduler.triggers.date import DateTrigger +from typing import Any, Optional -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.drivers.wiz import WizDriver -from app.api.deps import require_admin logger = logging.getLogger(__name__) 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): """ Универсальное выполнение команды по расписанию. - IP резолвится в момент выполнения, а не создания задачи -- - корректно работает при смене IP (DHCP) и изменении состава группы. + + Сигнатура специально сохранена совместимой со старым persisted jobstore, + чтобы legacy APScheduler jobs можно было безопасно мигрировать. """ if is_group: 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 local_wiz = WizDriver() + success_count = 0 + failure_count = 0 for ip in ips: try: - await local_wiz.set_pilot(ip, params) - logger.info(f"Расписание: {target_id} -> {ip}: {params}") + result = await local_wiz.set_pilot(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: + failure_count += 1 logger.error(f"Расписание: ошибка {ip}: {e}") - # Логируем toggle в event_log - # Импорт здесь, чтобы избежать циклической зависимости - from app.api.routes.control import log_toggle_by_name + from app.api.routes.control import log_command_result_by_name 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") async def schedule_once( target_id: str, - state: bool, + state: Optional[bool] = None, run_at: Optional[datetime] = None, hours_from_now: Optional[int] = None, 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 < 0: + raise HTTPException( + status_code=400, detail="hours_from_now не может быть отрицательным" + ) exec_time = datetime.now(app_tz) + timedelta(hours=hours_from_now) elif run_at: if run_at.tzinfo is None: exec_time = app_tz.localize(run_at) else: - exec_time = run_at + exec_time = run_at.astimezone(app_tz) else: raise HTTPException(status_code=400, detail="Нужно время или отступ в часах") - # 2. Проверяем что цель существует (но IP резолвится при выполнении) - if is_group: - if target_id not in state_manager.groups: - raise HTTPException(status_code=404, detail="Группа не найдена") - else: - if target_id not in state_manager.devices: - raise HTTPException(status_code=404, detail="Устройство не найдено") + if exec_time <= datetime.now(app_tz): + raise HTTPException( + status_code=400, detail="Время запуска должно быть в будущем" + ) - # 3. Регаем задачу - job_id = f"once_{target_id}_{int(exec_time.timestamp())}" + target_type = _validate_target(target_id, is_group) + action_params = _build_action_params(state, brightness, scene, temp, r, g, b) - scheduler.add_job( - run_group_command, - trigger=DateTrigger(run_date=exec_time, timezone=app_tz), - args=[target_id, is_group, {"state": state}], - id=job_id, - name=f"Once: {target_id} | {state}", - replace_existing=True, - ) + try: + task = await create_schedule_task( + trigger_type="once", + target_id=target_id, + target_type=target_type, + trigger_args={"run_at": exec_time.isoformat()}, + 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") @@ -99,84 +174,50 @@ async def add_cron_task( minute: str, day_of_week: str = "*", 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, ): - # Проверяем что цель существует - if is_group: - if target_id not in state_manager.groups: - raise HTTPException(status_code=404, detail="Группа не найдена") - else: - if target_id not in state_manager.devices: - raise HTTPException(status_code=404, detail="Устройство не найдено") + target_type = _validate_target(target_id, is_group) + action_params = _build_action_params(state, brightness, scene, temp, r, g, b) - # Одна задача на всю группу -- IP резолвятся при каждом срабатывании - trigger = CronTrigger( - hour=hour, minute=minute, day_of_week=day_of_week, timezone=app_tz - ) + trigger_args = { + "hour": hour, + "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}" - - 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} + return {"status": "cron_scheduled", "job_id": task.job_id} @router.get("/tasks") async def get_all_tasks(): - jobs = [] - 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} + return {"tasks": await list_schedule_tasks()} @router.delete("/{job_id}") async def cancel_task(job_id: str): - # Запрещаем удалять служебные задачи через API - if any(job_id.startswith(prefix) for prefix in _INTERNAL_JOB_PREFIXES): + if is_internal_job_id(job_id): raise HTTPException(status_code=403, detail="Нельзя удалить служебную задачу") try: - scheduler.remove_job(job_id) + await delete_schedule_task(job_id) return {"status": "deleted"} - except Exception: + except KeyError: raise HTTPException(status_code=404, detail="Задача не найдена") + except ValueError: + raise HTTPException(status_code=403, detail="Нельзя удалить служебную задачу") diff --git a/app/api/routes/stats.py b/app/api/routes/stats.py index a2a77fa..1a62d76 100644 --- a/app/api/routes/stats.py +++ b/app/api/routes/stats.py @@ -8,6 +8,10 @@ from app.api.deps import require_admin router = APIRouter(dependencies=[Depends(require_admin)]) +def _is_summary_command_event(action: str) -> bool: + return not action.endswith("_requested") + + @router.get("/summary") 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 = {} for ev in events: + if not _is_summary_command_event(ev.action): + continue + tid = ev.target_id if tid not in stats: stats[tid] = { diff --git a/app/core/database.py b/app/core/database.py index 414a287..9518674 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -1,9 +1,14 @@ -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from sqlalchemy.orm import DeclarativeBase -from sqlalchemy import create_engine +import os -DATABASE_URL = "sqlite+aiosqlite:///./ignis.db" -SYNC_DATABASE_URL = "sqlite:///./ignis.db" +from dotenv import load_dotenv +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) sync_engine = create_engine(SYNC_DATABASE_URL) @@ -16,6 +21,10 @@ class Base(DeclarativeBase): 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: # Создает таблицы, если их еще нет await conn.run_sync(Base.metadata.create_all) diff --git a/app/core/scheduler.py b/app/core/scheduler.py index 5c1931e..d61c631 100644 --- a/app/core/scheduler.py +++ b/app/core/scheduler.py @@ -1,15 +1,21 @@ -import os +import asyncio import logging -import pytz +import os from datetime import datetime, timedelta -from dotenv import load_dotenv -from apscheduler.schedulers.asyncio import AsyncIOScheduler +from uuid import uuid4 + +import pytz 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 sqlalchemy import delete -from app.core.database import sync_engine, async_session +from apscheduler.triggers.date import DateTrigger +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.drivers.wiz import WizDriver +from app.models.schedule import ScheduleTask load_dotenv() logger = logging.getLogger(__name__) @@ -18,19 +24,67 @@ TZ_NAME = os.getenv("APP_TIMEZONE", "Asia/Novosibirsk") app_tz = pytz.timezone(TZ_NAME) RETENTION_DAYS = int(os.getenv("EVENT_LOG_RETENTION_DAYS", "30")) +INTERNAL_JOB_PREFIXES = ("cleanup_",) jobstores = {"default": SQLAlchemyJobStore(engine=sync_engine)} scheduler = AsyncIOScheduler(jobstores=jobstores, timezone=app_tz) -async def execute_lamp_command(ip: str, params: dict): - """ - Универсальное выполнение команды. - params может содержать: state, dimming, temp, sceneId, r, g, b - """ - driver = WizDriver() - await driver.set_pilot(ip, params) - logger.info(f"Сработало расписание для {ip}: {params}") +def is_internal_job_id(job_id: str) -> bool: + return any(job_id.startswith(prefix) for prefix in INTERNAL_JOB_PREFIXES) + + +def build_schedule_job_id(trigger_type: str) -> str: + return f"{trigger_type}_{uuid4().hex}" + + +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(): @@ -47,17 +101,272 @@ async def cleanup_old_events(): ) -async def start_scheduler(): - if not scheduler.running: - scheduler.start() +def ensure_internal_jobs(): + scheduler.add_job( + cleanup_old_events, + CronTrigger(hour=3, minute=0, timezone=app_tz), + id="cleanup_event_log", + name="Очистка старых событий", + replace_existing=True, + ) - # Очистка лога -- раз в сутки в 03:00 - scheduler.add_job( - cleanup_old_events, - CronTrigger(hour=3, minute=0, timezone=app_tz), - id="cleanup_event_log", - name="Очистка старых событий", - replace_existing=True, + +async def execute_schedule_job(job_id: str): + """ + Унифицированная точка входа для исполнения задач расписаний. + + Рантайм-задача в APScheduler всегда адресуется только по job_id, + а вся доменная информация подтягивается из основной БД. + """ + 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}") + + ensure_internal_jobs() + await reconcile_schedule_jobs() diff --git a/app/drivers/wiz.py b/app/drivers/wiz.py index a40cd79..e8c9a12 100644 --- a/app/drivers/wiz.py +++ b/app/drivers/wiz.py @@ -1,6 +1,23 @@ import json import asyncio 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: @@ -41,24 +58,72 @@ class WizDriver: "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() with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: sock.settimeout(2.0) 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: resp, _ = await loop.run_in_executor(None, sock.recvfrom, 1024) - return json.loads(resp.decode()) 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} 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": {}} return await self.send_udp(ip, payload) diff --git a/app/models/api_key.py b/app/models/api_key.py index 0a10e7a..654ebcf 100644 --- a/app/models/api_key.py +++ b/app/models/api_key.py @@ -1,6 +1,7 @@ +import hashlib import secrets from datetime import datetime -from sqlalchemy import String, Boolean, DateTime +from sqlalchemy import Boolean, String from sqlalchemy.orm import Mapped, mapped_column from app.core.database import Base @@ -24,3 +25,20 @@ class ApiKeyModel(Base): def generate_key() -> str: """Генерация безопасного случайного токена.""" 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:]}" diff --git a/app/models/schedule.py b/app/models/schedule.py index e5730b6..8fa0c05 100644 --- a/app/models/schedule.py +++ b/app/models/schedule.py @@ -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 class ScheduleTask(Base): + """ + Персистентная метадата пользовательских расписаний. + + APScheduler остаётся рантайм-движком исполнения, а эта таблица служит + источником истины для CRUD, восстановления и миграции задач. + """ + __tablename__ = "schedules" - id = Column(Integer, primary_key=True, index=True) - device_id = Column(Integer, ForeignKey("devices.id"), nullable=False) - task_type = Column(String) # 'once', 'daily', 'cron' - action_params = Column(JSON) # {'state': True, 'dimming': 50} - is_active = Column(Boolean, default=True) - job_id = Column(String, unique=True) # ID задачи в APScheduler + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + job_id: Mapped[str] = mapped_column(String, unique=True, index=True) + trigger_type: Mapped[str] = mapped_column(String) # once | cron + target_id: Mapped[str] = mapped_column(String) + target_type: Mapped[str] = mapped_column(String) # group | device + 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() + ) diff --git a/main.py b/main.py index 018818e..1e7aef7 100644 --- a/main.py +++ b/main.py @@ -21,9 +21,8 @@ logger = logging.getLogger(__name__) @asynccontextmanager async def lifespan(app: FastAPI): - # 1. БД и Планировщик + # 1. БД await init_db() - await start_scheduler() # 2. Загрузка групп async with async_session() as session: @@ -32,7 +31,10 @@ async def lifespan(app: FastAPI): state_manager.groups[g.id] = g logger.info(f"📂 Загружена группа: {g.name}") - # 3. Фоновый Discovery + # 3. Планировщик после загрузки метаданных групп + await start_scheduler() + + # 4. Фоновый Discovery discovery_task = asyncio.create_task( discovery_service.start_background_discovery(state_manager) ) @@ -66,7 +68,11 @@ async def read_index(): @app.get("/auth/me") 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__": diff --git a/openapi.json b/openapi.json new file mode 100644 index 0000000..5f75cd6 --- /dev/null +++ b/openapi.json @@ -0,0 +1,1508 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Ignis Core API", + "version": "0.1.0" + }, + "paths": { + "/devices": { + "get": { + "tags": [ + "Devices & Groups" + ], + "summary": "Get All Devices", + "operationId": "get_all_devices_devices_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ] + } + }, + "/devices/groups": { + "get": { + "tags": [ + "Devices & Groups" + ], + "summary": "Get Groups", + "operationId": "get_groups_devices_groups_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ] + }, + "post": { + "tags": [ + "Devices & Groups" + ], + "summary": "Create Group", + "operationId": "create_group_devices_groups_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GroupCreateSchema" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ] + } + }, + "/devices/scenes": { + "get": { + "tags": [ + "Devices & Groups" + ], + "summary": "Get Scenes", + "operationId": "get_scenes_devices_scenes_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ] + } + }, + "/devices/groups/{group_id}": { + "delete": { + "tags": [ + "Devices & Groups" + ], + "summary": "Delete Group", + "operationId": "delete_group_devices_groups__group_id__delete", + "security": [ + { + "APIKeyHeader": [] + } + ], + "parameters": [ + { + "name": "group_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Group Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/devices/rescan": { + "post": { + "tags": [ + "Devices & Groups" + ], + "summary": "Rescan Network", + "operationId": "rescan_network_devices_rescan_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ] + } + }, + "/control/device/{device_id}": { + "post": { + "tags": [ + "Control" + ], + "summary": "Control Device", + "operationId": "control_device_control_device__device_id__post", + "security": [ + { + "APIKeyHeader": [] + } + ], + "parameters": [ + { + "name": "device_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Device Id" + } + }, + { + "name": "state", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "State" + } + }, + { + "name": "brightness", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Brightness" + } + }, + { + "name": "scene", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Scene" + } + }, + { + "name": "temp", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Temp" + } + }, + { + "name": "r", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "R" + } + }, + { + "name": "g", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "G" + } + }, + { + "name": "b", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "B" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/control/group/{group_id}": { + "post": { + "tags": [ + "Control" + ], + "summary": "Control Group", + "operationId": "control_group_control_group__group_id__post", + "security": [ + { + "APIKeyHeader": [] + } + ], + "parameters": [ + { + "name": "group_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Group Id" + } + }, + { + "name": "state", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "State" + } + }, + { + "name": "brightness", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Brightness" + } + }, + { + "name": "scene", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Scene" + } + }, + { + "name": "temp", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Temp" + } + }, + { + "name": "r", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "R" + } + }, + { + "name": "g", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "G" + } + }, + { + "name": "b", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "B" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/control/device/{device_id}/blink": { + "post": { + "tags": [ + "Control" + ], + "summary": "Blink Device", + "operationId": "blink_device_control_device__device_id__blink_post", + "security": [ + { + "APIKeyHeader": [] + } + ], + "parameters": [ + { + "name": "device_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Device Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/control/device/{device_id}/status": { + "get": { + "tags": [ + "Control" + ], + "summary": "Get Device Status", + "description": "Опрос реального состояния конкретной лампы.", + "operationId": "get_device_status_control_device__device_id__status_get", + "security": [ + { + "APIKeyHeader": [] + } + ], + "parameters": [ + { + "name": "device_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Device Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/control/group/{group_id}/status": { + "get": { + "tags": [ + "Control" + ], + "summary": "Get Group Status", + "description": "Опрос состояния всей группы (возвращает список статусов).", + "operationId": "get_group_status_control_group__group_id__status_get", + "security": [ + { + "APIKeyHeader": [] + } + ], + "parameters": [ + { + "name": "group_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Group Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/schedules/once": { + "post": { + "tags": [ + "Schedules" + ], + "summary": "Schedule Once", + "operationId": "schedule_once_schedules_once_post", + "security": [ + { + "APIKeyHeader": [] + } + ], + "parameters": [ + { + "name": "target_id", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Target Id" + } + }, + { + "name": "state", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "State" + } + }, + { + "name": "run_at", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Run At" + } + }, + { + "name": "hours_from_now", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Hours From Now" + } + }, + { + "name": "is_group", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": true, + "title": "Is Group" + } + }, + { + "name": "brightness", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Brightness" + } + }, + { + "name": "scene", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Scene" + } + }, + { + "name": "temp", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Temp" + } + }, + { + "name": "r", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "R" + } + }, + { + "name": "g", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "G" + } + }, + { + "name": "b", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "B" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/schedules/cron": { + "post": { + "tags": [ + "Schedules" + ], + "summary": "Add Cron Task", + "operationId": "add_cron_task_schedules_cron_post", + "security": [ + { + "APIKeyHeader": [] + } + ], + "parameters": [ + { + "name": "target_id", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Target Id" + } + }, + { + "name": "hour", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Hour" + } + }, + { + "name": "minute", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Minute" + } + }, + { + "name": "day_of_week", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "*", + "title": "Day Of Week" + } + }, + { + "name": "is_group", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": true, + "title": "Is Group" + } + }, + { + "name": "state", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "State" + } + }, + { + "name": "brightness", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Brightness" + } + }, + { + "name": "scene", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Scene" + } + }, + { + "name": "temp", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Temp" + } + }, + { + "name": "r", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "R" + } + }, + { + "name": "g", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "G" + } + }, + { + "name": "b", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "B" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/schedules/tasks": { + "get": { + "tags": [ + "Schedules" + ], + "summary": "Get All Tasks", + "operationId": "get_all_tasks_schedules_tasks_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ] + } + }, + "/schedules/{job_id}": { + "delete": { + "tags": [ + "Schedules" + ], + "summary": "Cancel Task", + "operationId": "cancel_task_schedules__job_id__delete", + "security": [ + { + "APIKeyHeader": [] + } + ], + "parameters": [ + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Job Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api-keys": { + "get": { + "tags": [ + "API Keys" + ], + "summary": "List Keys", + "description": "Список всех гостевых ключей.\n\nВ ответе поле `key` содержит публичный идентификатор, а не сам секрет.\nЭто сохраняет совместимость с текущим UI и не раскрывает токены повторно.", + "operationId": "list_keys_api_keys_get", + "security": [ + { + "APIKeyHeader": [] + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "post": { + "tags": [ + "API Keys" + ], + "summary": "Create Key", + "description": "Создать гостевой ключ. Возвращает сгенерированный токен.", + "operationId": "create_key_api_keys_post", + "security": [ + { + "APIKeyHeader": [] + } + ], + "parameters": [ + { + "name": "name", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Name" + } + }, + { + "name": "is_admin", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Is Admin" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api-keys/revoke": { + "post": { + "tags": [ + "API Keys" + ], + "summary": "Revoke Key", + "description": "Деактивировать (отозвать) гостевой ключ. Ключ передаётся в body, не в URL.", + "operationId": "revoke_key_api_keys_revoke_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KeyActionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ] + } + }, + "/api-keys/activate": { + "post": { + "tags": [ + "API Keys" + ], + "summary": "Activate Key", + "description": "Повторно активировать ключ. Ключ передаётся в body, не в URL.", + "operationId": "activate_key_api_keys_activate_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KeyActionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ] + } + }, + "/stats/summary": { + "get": { + "tags": [ + "Stats" + ], + "summary": "Get Summary", + "description": "Сводная статистика за последние N дней.\nВозвращает по каждой группе/устройству:\n- total_commands -- общее количество команд\n- toggles_on / toggles_off -- включений / выключений\n- estimated_hours -- оценка часов работы (по парам on/off)", + "operationId": "get_summary_stats_summary_get", + "security": [ + { + "APIKeyHeader": [] + } + ], + "parameters": [ + { + "name": "days", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 365, + "minimum": 1, + "default": 7, + "title": "Days" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/stats/log": { + "get": { + "tags": [ + "Stats" + ], + "summary": "Get Log", + "description": "Последние N событий (для просмотра лога).", + "operationId": "get_log_stats_log_get", + "security": [ + { + "APIKeyHeader": [] + } + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 500, + "minimum": 1, + "default": 50, + "title": "Limit" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/": { + "get": { + "summary": "Read Index", + "operationId": "read_index__get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/auth/me": { + "get": { + "summary": "Auth Me", + "operationId": "auth_me_auth_me_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ] + } + } + }, + "components": { + "schemas": { + "GroupCreateSchema": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "macs": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Macs" + } + }, + "type": "object", + "required": [ + "id", + "name", + "macs" + ], + "title": "GroupCreateSchema" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "KeyActionRequest": { + "properties": { + "key": { + "type": "string", + "title": "Key" + } + }, + "type": "object", + "required": [ + "key" + ], + "title": "KeyActionRequest", + "description": "Тело запроса для операций с ключом (чтобы токен не летел в URL)." + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError" + } + }, + "securitySchemes": { + "APIKeyHeader": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key" + } + } + } +} diff --git a/static/index.html b/static/index.html index eb8eae1..0a9d828 100644 --- a/static/index.html +++ b/static/index.html @@ -60,6 +60,7 @@

IgnisCore

{{ authName }} + master гость
@@ -212,7 +213,7 @@ -
+

Гостевые ключи

@@ -244,7 +245,7 @@ гость {{ k.active ? 'активен' : 'отозван' }} -
{{ k.key.slice(0, 12) }}...{{ k.key.slice(-6) }}
+
{{ k.display_key || (k.key.slice(0, 12) + '...' + k.key.slice(-6)) }}
@@ -361,6 +362,7 @@ tempKey: '', tab: 'control', isAdmin: false, + isMaster: false, authName: '', groups: {}, devices: [], sliders: {}, newGroup: { id: '', name: '', macs: [] }, @@ -377,7 +379,7 @@ saveKey() { 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) { const id = ++this.toastCounter; this.toasts.push({ id, text, type }); 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 }); if (r.status === 403) { 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; } 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'); if (!auth) { this.isLoading = false; return; } this.isAdmin = auth.is_admin; + this.isMaster = !!auth.is_master; this.authName = auth.name; await this.fetchData(); this.isLoading = false; @@ -425,7 +428,7 @@ if (dData) this.devices = Array.isArray(dData) ? dData : Object.values(dData); if (sData) this.allScenes = sData; if (this.isAdmin) this.fetchTasks(); - if (this.isAdmin) this.fetchApiKeys(); + if (this.isMaster) this.fetchApiKeys(); } finally { this.isFetching = false; } }, @@ -532,4 +535,4 @@ }).mount('#app') - \ No newline at end of file + diff --git a/tests/test_p0_schedules.py b/tests/test_p0_schedules.py new file mode 100644 index 0000000..28a2c6d --- /dev/null +++ b/tests/test_p0_schedules.py @@ -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, []) diff --git a/tests/test_p0_security_and_control.py b/tests/test_p0_security_and_control.py new file mode 100644 index 0000000..74f6b1b --- /dev/null +++ b/tests/test_p0_security_and_control.py @@ -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})