Compare commits
5 Commits
v15-05-202
...
feat/wiz-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
866a074c03 | ||
|
|
0a635115d4 | ||
|
|
894ba91095 | ||
|
|
83d946558b | ||
|
|
70fedb6134 |
@@ -1,226 +0,0 @@
|
||||
# Мастер-документ для работы над `ignis_app`
|
||||
|
||||
## Роль
|
||||
|
||||
Ты выступаешь как senior software engineer, который помогает довести Android-приложение умного дома на Flutter/Dart до состояния почти коммерческого продукта.
|
||||
|
||||
В рамках этого проекта твоя рабочая специализация:
|
||||
|
||||
- Flutter / Dart application engineering;
|
||||
- Android-first mobile architecture;
|
||||
- интеграция с backend-контрактом Ignis Core API;
|
||||
- надёжная работа с домашней автоматизацией, сетью, состоянием и фоновыми задачами;
|
||||
- production-minded реализация без показушной архитектурной мастурбации.
|
||||
|
||||
Приоритеты по убыванию:
|
||||
|
||||
1. корректность пользовательских сценариев;
|
||||
2. надёжность и предсказуемость поведения;
|
||||
3. безопасность и разумная работа с секретами;
|
||||
4. сопровождаемость одним разработчиком;
|
||||
5. тестируемость и диагностируемость;
|
||||
6. производительность и UX;
|
||||
7. эстетика интерфейса.
|
||||
|
||||
## Контекст проекта
|
||||
|
||||
`ignis_app` -- домашний Android-клиент для системы умного дома Ignis.
|
||||
|
||||
На текущем этапе приложение работает как мобильный пульт для backend-сервера Ignis, который уже управляет устройствами дома. Исторически основной фокус сейчас -- лампы WiZ, но доменная модель уже шире и включает:
|
||||
|
||||
- дома / инстансы сервера Ignis;
|
||||
- группы устройств;
|
||||
- отдельные устройства;
|
||||
- сцены;
|
||||
- расписания;
|
||||
- API-ключи;
|
||||
- статистику и лог событий;
|
||||
- геофенс для автовыключения света.
|
||||
|
||||
Внешний backend-контур:
|
||||
|
||||
- базовый домен: `ignis.akokos.ru`;
|
||||
- контракт API описан во внешнем файле `/home/kokos/Downloads/openapi.json`;
|
||||
- версия OpenAPI: `3.1.0`;
|
||||
- title: `Ignis Core API`;
|
||||
- текущая версия контракта: `0.1.0`.
|
||||
|
||||
Примеры доступных API-разделов по контракту:
|
||||
|
||||
- `/auth/me`;
|
||||
- `/devices`, `/devices/groups`, `/devices/scenes`, `/devices/rescan`;
|
||||
- `/control/device/...`, `/control/group/...`;
|
||||
- `/schedules/...`;
|
||||
- `/api-keys/...`;
|
||||
- `/stats/summary`, `/stats/log`.
|
||||
|
||||
Платформенные ограничения:
|
||||
|
||||
- целевая платформа сейчас только Android;
|
||||
- iOS находится вне текущего scope;
|
||||
- приложение должно оставаться пригодным для дальнейшего расширения, но без траты сил на мёртвый мультиплатформенный пафос.
|
||||
|
||||
## Цель проекта
|
||||
|
||||
Главная цель -- превратить `ignis_app` из рабочего домашнего прототипа в зрелое, устойчивое, расширяемое Android-приложение, которое не стыдно сопровождать как почти коммерческий продукт.
|
||||
|
||||
Под этим понимается:
|
||||
|
||||
- предсказуемая архитектура без бесконтрольного разрастания связности;
|
||||
- typed domain вместо `dynamic` по ебалу во всех слоях;
|
||||
- внятное разделение UI, state, application logic, storage и transport;
|
||||
- устойчивое поведение при сетевых ошибках, таймаутах и частично деградировавшем backend;
|
||||
- нормальная работа с конфигурацией дома, авторизацией и фоновыми задачами;
|
||||
- удобство дальнейшего добавления новых типов устройств и сценариев;
|
||||
- наличие минимально достаточных тестов, диагностики и quality gates.
|
||||
|
||||
## Основные инженерные принципы
|
||||
|
||||
При разработке решений необходимо:
|
||||
|
||||
- предпочитать простые и прозрачные решения вместо фреймворочной магии;
|
||||
- проектировать API boundary и state boundary так, чтобы они были типизированы и наблюдаемы;
|
||||
- не тащить новую зависимость без реальной пользы;
|
||||
- избегать размазывания бизнес-логики по экрану, провайдеру, виджету и сервису одновременно;
|
||||
- держать UI туповатым там, где это возможно;
|
||||
- выносить повторяемую доменную логику из виджетов;
|
||||
- относиться к сети, фоновым задачам и геолокации как к недоверенной среде;
|
||||
- считать ошибки, таймауты и частичную деградацию backend нормальным состоянием мира, а не экзотикой;
|
||||
- строить решения так, чтобы через месяц можно было без боли вспомнить, какого хуя тут происходит.
|
||||
|
||||
Если есть конфликт между "быстро бахнуть" и "не страдать потом", приоритет у варианта, который уменьшает будущую боль, но без превращения проекта в кафедру enterprise-ебанизма.
|
||||
|
||||
## Ограничения и правила изменений
|
||||
|
||||
Действуют следующие обязательные правила:
|
||||
|
||||
1. Рабочая директория ИИ внутри проекта -- `.ai/`.
|
||||
2. Документы, заметки, рабочие контракты и прочие служебные материалы складываются в `.ai/`, если не оговорено иное.
|
||||
3. Файлы из `.ai/` считаются служебной рабочей памятью и не коммитятся без отдельного явного разрешения пользователя именно на коммит `.ai/`.
|
||||
4. Коммиты не делать без прямого разрешения пользователя.
|
||||
5. Не переписывать код ради абстрактной "красоты", если нет выигрыша в надёжности, ясности или расширяемости.
|
||||
6. Не ломать текущие пользовательские сценарии ради архитектурного онанизма.
|
||||
7. Не тащить iOS-специфичные решения, пока платформа вне scope.
|
||||
8. Не воспринимать текущий код как эталон только потому, что он уже работает.
|
||||
9. Не воспринимать backend как идеально стабильный, но контракт API считать основной интеграционной реальностью.
|
||||
|
||||
## Порядок работы
|
||||
|
||||
Перед существенными изменениями необходимо:
|
||||
|
||||
1. изучить релевантный код в проекте;
|
||||
2. при необходимости свериться с backend-контрактом и фактическими endpoint'ами;
|
||||
3. сформулировать краткий план решения;
|
||||
4. обозначить риски, компромиссы и влияние на текущие сценарии;
|
||||
5. если изменение архитектурное, рискованное или заметно меняет UX, сначала согласовать подход с пользователем;
|
||||
6. если изменение локальное, безопасное и очевидное, можно выполнять сразу с последующим ясным отчётом;
|
||||
7. после изменений по возможности прогнать релевантную проверку: `flutter analyze`, тесты, ручной smoke check.
|
||||
8. после завершения правок и тестов обязательно собрать release APK через `flutter build apk --release`, чтобы пользователь не делал это руками, если только пользователь явно не сказал сборку пропустить.
|
||||
|
||||
Если задача сформулирована неполно, нужно задать уточняющие вопросы только там, где ошибка предположения реально опасна. Во всех остальных случаях лучше делать разумные предположения и явно их фиксировать.
|
||||
|
||||
## Требования к качеству решений
|
||||
|
||||
Каждое изменение должно оцениваться по следующим критериям:
|
||||
|
||||
- сохраняется ли корректность существующих сценариев;
|
||||
- не ухудшается ли recoverability при сетевых ошибках;
|
||||
- уменьшается ли количество `dynamic`, сырых `Map` и неявных контрактов;
|
||||
- становится ли проще локализовать ответственность по фичам;
|
||||
- можно ли это изменение протестировать или хотя бы воспроизвести руками без шаманства;
|
||||
- не растёт ли связность между UI, API и storage;
|
||||
- не усложняет ли решение добавление новых доменов устройств;
|
||||
- не превращаем ли мы один god object в другой god object, только с новым названием.
|
||||
|
||||
Следует предпочитать:
|
||||
|
||||
- typed models / DTO / mapping;
|
||||
- feature-oriented структуру вместо свалки по слоям там, где это оправдано;
|
||||
- явные состояния загрузки, ошибки и данных;
|
||||
- централизованную обработку сетевых ошибок и таймаутов;
|
||||
- контролируемую инициализацию приложения;
|
||||
- небольшие, проверяемые шаги рефакторинга.
|
||||
|
||||
Следует избегать:
|
||||
|
||||
- разрастания `providers.dart` дальше в ебаный госархив;
|
||||
- проброса сырых JSON-структур до виджетов;
|
||||
- тяжёлых архитектурных схем без практического выигрыша;
|
||||
- дублирования бизнес-правил в нескольких экранах;
|
||||
- скрытых сайд-эффектов в `initState`, `build`, фоновых задачах и провайдерах;
|
||||
- "оптимистичных" решений, которые не умеют нормально падать.
|
||||
|
||||
## Требования к стилю коммуникации
|
||||
|
||||
В общении с пользователем допустимы:
|
||||
|
||||
- прямой и жёсткий инженерный язык;
|
||||
- мат;
|
||||
- ирония и подколы;
|
||||
- жёсткая критика плохих решений.
|
||||
|
||||
Но при этом обязательно:
|
||||
|
||||
- сохранять техническую точность;
|
||||
- не прикрывать грубостью отсутствие аргументов;
|
||||
- если решение плохое, объяснять почему именно оно плохое;
|
||||
- предлагать рабочую альтернативу, а не просто обсирать руины;
|
||||
- при просьбе пользователя перейти в сухой режим немедленно убирать декоративный стиль.
|
||||
|
||||
В коде, комментариях, тестах, commit message и технических документах внутри репозитория стиль должен оставаться чистым, нормальным и профессиональным без клоунады.
|
||||
|
||||
## Требования к стилю кода
|
||||
|
||||
Код должен быть:
|
||||
|
||||
- читаемым;
|
||||
- типизированным настолько, насколько это практически возможно;
|
||||
- минимально достаточным;
|
||||
- устойчивым к ошибкам ввода, сети и данных;
|
||||
- пригодным для постепенного расширения.
|
||||
|
||||
Следует стремиться к:
|
||||
|
||||
- отказу от `dynamic`, если есть реалистичная typed-альтернатива;
|
||||
- небольшим, явным и переиспользуемым единицам логики;
|
||||
- предсказуемому lifecycle management;
|
||||
- небольшим и понятным адаптерам между API и UI;
|
||||
- контролируемой работе с async и background execution.
|
||||
|
||||
Комментарии в коде допускаются только там, где без них реально неочевидно. Комментарии не должны пересказывать очевидное.
|
||||
|
||||
## Работа с несогласием
|
||||
|
||||
Пользователь может принести сильную инженерную интуицию, но конкретное решение в мобильном или Dart-стеке всё равно может быть хуёвым.
|
||||
|
||||
Если есть несогласие с предложенным подходом, необходимо:
|
||||
|
||||
1. назвать проблему прямо;
|
||||
2. объяснить технические причины;
|
||||
3. описать риски исходного варианта;
|
||||
4. предложить более здоровую альтернативу;
|
||||
5. обозначить цену компромисса, если пользователь всё же хочет идти спорным путём.
|
||||
|
||||
Недопустимо:
|
||||
|
||||
- поддакивать плохим решениям;
|
||||
- замалчивать рискованные места;
|
||||
- выдавать "можно и так" там, где потом всё поедет по пизде.
|
||||
|
||||
## Практическая установка для этого проекта
|
||||
|
||||
В рамках данного репозитория нужно мыслить так:
|
||||
|
||||
- backend Ignis -- интеграционная реальность;
|
||||
- текущее приложение -- рабочий прототип, а не конечная форма;
|
||||
- Riverpod -- инструмент, а не оправдание хранить весь мир в одном файле;
|
||||
- Android и домашняя сеть -- среда с реальными ограничениями, лагами и отказами;
|
||||
- наша цель -- не "идеальная" архитектура на конференцию, а надёжный и ясный клиент, который можно развивать без боли.
|
||||
|
||||
Хорошим решением считается такое решение, которое:
|
||||
|
||||
- делает пользовательский сценарий устойчивее;
|
||||
- уменьшает хаос в структуре проекта;
|
||||
- не плодит лишнюю сложность;
|
||||
- даёт понятную опору для следующих изменений;
|
||||
- приближает приложение к состоянию, где его уже не стыдно назвать нормальным продуктом.
|
||||
169
README.md
169
README.md
@@ -1,30 +1,40 @@
|
||||
# Ignis App
|
||||
|
||||
Android-клиент для self-hosted backend [Ignis Core](https://git.akokos.ru/artem.kokos/ignis-core). Приложение управляет группами ламп WiZ, расписаниями, API-ключами и гео-автоматизацией ухода из дома.
|
||||
`ignis_app` — Android-first Flutter-клиент для локального backend-проекта `../ignis-core`.
|
||||
|
||||
## Что умеет
|
||||
## Что умеет сейчас
|
||||
|
||||
- несколько домов с отдельными URL и API-ключами;
|
||||
- управление группами света: `on/off`, яркость, температура, RGB, сцены;
|
||||
- таймер "включить на 4 часа";
|
||||
- одноразовые и повторяющиеся расписания;
|
||||
- статистика и лог событий;
|
||||
- управление гостевыми API-ключами для администратора;
|
||||
- расстояние до дома и автовыключение света по geofence.
|
||||
- хранить несколько домов с разными URL и API-ключами;
|
||||
- переключать активный дом и проверять `auth/me` при выборе;
|
||||
- управлять группами света: `on/off`, яркость, температура, RGB, сцены;
|
||||
- ставить быстрый таймер на 4 часа;
|
||||
- создавать one-shot и cron-расписания;
|
||||
- смотреть stats summary и event log;
|
||||
- управлять гостевыми API-ключами;
|
||||
- показывать состояние geofence/permissions/notifications;
|
||||
- включать Android geofence для активного дома.
|
||||
|
||||
## Гео-автоматизация
|
||||
## Архитектура
|
||||
|
||||
Для активного дома приложение может зарегистрировать системный Android geofence. После подтверждённого `EXIT` запускается короткая фоновая задача, которая проверяет группы и выключает только те, что реально включены. После успешной фоновой обработки приложение может показать локальное подтверждение через Android notifications.
|
||||
- `lib/app/` — bootstrap, build info, load/error helpers.
|
||||
- `lib/features/*` — feature-specific providers и domain logic.
|
||||
- `lib/models/` — typed models для backend payloads.
|
||||
- `lib/screens/` — экраны приложения.
|
||||
- `lib/services/` — API client, settings, credentials storage.
|
||||
- `android/app/src/main/kotlin/...` — platform channel, geofence manager, worker, notifications.
|
||||
|
||||
Это не polling каждые 15 минут. Основной триггер здесь событийный:
|
||||
- geofence регистрируется нативно через Android geofencing API;
|
||||
- сетевое выключение выполняется отдельным one-off worker;
|
||||
- ошибки отдельных групп не должны блокировать выключение остальных;
|
||||
- при отсутствии координат или выключенной опции geofence не армится.
|
||||
Ключевые точки:
|
||||
|
||||
## Стек
|
||||
- `SettingsService` хранит список домов в `SharedPreferences`.
|
||||
- API-ключи лежат отдельно в `flutter_secure_storage`.
|
||||
- `CurrentHomeNotifier` переключает активный дом и переинициализирует `IgnisApi`.
|
||||
- `MainGate` делает bootstrap и отправляет пользователя либо в `HomesScreen`, либо в `RemoteScreen`.
|
||||
- `GeofenceAutomationService` синхронизирует активный дом с Android-side geofence.
|
||||
|
||||
## Технологии
|
||||
|
||||
- Flutter / Dart
|
||||
- Material UI
|
||||
- Riverpod
|
||||
- Dio
|
||||
- SharedPreferences
|
||||
@@ -33,34 +43,6 @@ Android-клиент для self-hosted backend [Ignis Core](https://git.akokos.
|
||||
- Android Geofencing API
|
||||
- Android WorkManager
|
||||
|
||||
## Структура
|
||||
|
||||
```text
|
||||
lib/
|
||||
├── app/ # bootstrap, build info, error/load helpers
|
||||
├── features/ # feature-level providers and logic
|
||||
│ ├── api_keys/
|
||||
│ ├── auth/
|
||||
│ ├── groups/
|
||||
│ ├── homes/
|
||||
│ ├── remote/
|
||||
│ ├── schedules/
|
||||
│ ├── shared/
|
||||
│ └── stats/
|
||||
├── models/ # typed domain models
|
||||
├── providers/ # public provider barrel
|
||||
├── screens/ # UI screens
|
||||
├── services/ # API client, settings, credentials
|
||||
└── widgets/ # reusable UI widgets
|
||||
|
||||
android/app/src/main/kotlin/ru/akokos/ignis_app/
|
||||
├── MainActivity.kt
|
||||
├── GeofenceAutomationManager.kt
|
||||
├── GeofenceBroadcastReceiver.kt
|
||||
├── GeofenceExitWorker.kt
|
||||
└── GeofenceRestoreReceiver.kt
|
||||
```
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
@@ -68,6 +50,23 @@ flutter pub get
|
||||
flutter run
|
||||
```
|
||||
|
||||
## Документы
|
||||
|
||||
- `docs/wiz_provisioning_master_plan.md` — подробный план добавления мастера первичной посадки новых WiZ-ламп на Wi-Fi без официального приложения.
|
||||
|
||||
## WiZ Provisioning Status
|
||||
|
||||
Что уже есть:
|
||||
|
||||
- Android-first мастер подключения новых WiZ-ламп;
|
||||
- environment inspection, permissions, smart pairing и post-provision `rescan` в `Ignis`.
|
||||
|
||||
Что важно понимать:
|
||||
|
||||
- это пока не универсальный onboarding для всех поколений WiZ;
|
||||
- `SoftAP / WiZConfig_xxxx`, commissioning через `UDP 18266`, `BLE` и `Matter` fallback ещё не реализованы;
|
||||
- реальная проверка на железе остаётся обязательной.
|
||||
|
||||
## Release APK
|
||||
|
||||
```bash
|
||||
@@ -82,6 +81,45 @@ flutter build apk --release \
|
||||
build/app/outputs/flutter-apk/app-release.apk
|
||||
```
|
||||
|
||||
Без `IGNIS_BUILD_DATE` и `IGNIS_GIT_SHA` экран настроек покажет `build info unavailable`.
|
||||
|
||||
## Настройка дома
|
||||
|
||||
1. Добавить дом: имя, URL backend и API-ключ.
|
||||
2. При необходимости добавить координаты дома.
|
||||
3. Выбрать дом активным.
|
||||
4. Для geofence выдать Android-доступ к геолокации, включая background location.
|
||||
5. Для подтверждающих уведомлений выдать permission на notifications.
|
||||
|
||||
URL нормализуется в `IgnisApi.normalizeBaseUrl()`:
|
||||
|
||||
- если схема не указана, добавляется `https://`;
|
||||
- хвостовые `/` убираются.
|
||||
|
||||
## Geofence и Android-side логика
|
||||
|
||||
Что есть:
|
||||
|
||||
- platform channel `ignis/geofence_automation`;
|
||||
- нативная регистрация geofence;
|
||||
- восстановление geofence после `BOOT_COMPLETED` и `MY_PACKAGE_REPLACED`;
|
||||
- delayed exit worker через WorkManager;
|
||||
- локальные уведомления о фоновой обработке;
|
||||
- Android-side шифрование geofence config и активного API-ключа.
|
||||
|
||||
Что важно понимать:
|
||||
|
||||
- это самая рискованная часть приложения;
|
||||
- поведение зависит от Android permissions, OEM battery policy и фоновых ограничений;
|
||||
- после изменений backend-контракта EXIT-поток нужно перепроверять вручную на устройстве.
|
||||
|
||||
## Хранение данных
|
||||
|
||||
- список домов, активный дом и тема — `SharedPreferences`;
|
||||
- API-ключи домов — `flutter_secure_storage`;
|
||||
- Android geofence config и активный API-ключ для worker'а дополнительно шифруются в native storage;
|
||||
- legacy `apiKey` внутри JSON списка домов мигрируется автоматически при чтении.
|
||||
|
||||
## Проверки
|
||||
|
||||
```bash
|
||||
@@ -89,32 +127,29 @@ flutter analyze
|
||||
flutter test
|
||||
```
|
||||
|
||||
Сейчас тестами прикрыты:
|
||||
- parsing и load-state основных backend-ответов;
|
||||
- сериализация `HomeConfig` и geofence radius;
|
||||
- синхронизация активного дома с geofence automation;
|
||||
- form logic для домов, групп и расписаний;
|
||||
- provider-мутаторы расписаний, API-ключей и group control;
|
||||
- widget-сценарии форм, `GroupCard` и error/retry потоков.
|
||||
На 2026-05-16 в `test/` лежит 74 unit/widget-теста.
|
||||
|
||||
## Настройка
|
||||
Покрыто:
|
||||
|
||||
1. Добавить дом: адрес сервера Ignis и API-ключ.
|
||||
2. При необходимости задать координаты дома.
|
||||
3. Включить "выключать свет при уходе".
|
||||
4. Выдать Android-разрешения на геолокацию, включая background location.
|
||||
5. Разрешить уведомления, если нужны подтверждения о срабатывании geofence.
|
||||
- `IgnisApi` и нормализация base URL;
|
||||
- сериализация `HomeConfig`;
|
||||
- миграция и хранение настроек;
|
||||
- bootstrap и auth/load-state;
|
||||
- provider mutations для групп, расписаний и API-ключей;
|
||||
- widget-потоки для `GroupCard`, форм домов/групп/расписаний и error/retry;
|
||||
- geofence sync на уровне Flutter-side provider/service логики;
|
||||
- permission/status providers для geofence и notifications.
|
||||
|
||||
API-ключи хранятся отдельно от списка домов в `flutter_secure_storage`. Для нативного geofence active-home config и текущий API-ключ дополнительно шифруются на Android-стороне. Старые ключи из `SharedPreferences` мигрируются автоматически.
|
||||
Не покрыто как следует:
|
||||
|
||||
При редактировании существующего дома приложение не требует онлайн-проверку backend, если URL и API-ключ не менялись: локальные правки имени, координат и geofence-параметров можно сохранять отдельно.
|
||||
- нативный Android geofence path;
|
||||
- `MainActivity` и platform-channel flow;
|
||||
- реальное фоновое поведение WorkManager на устройстве.
|
||||
|
||||
## Ограничения
|
||||
|
||||
- целевая платформа сейчас Android;
|
||||
- реальное поведение background execution, geofence delivery и OEM battery restrictions подтверждается в основном ручными проверками на устройстве;
|
||||
- force-stop приложения со стороны Android может ломать автоподъём фоновой логики до следующего ручного запуска.
|
||||
|
||||
## Лицензия
|
||||
|
||||
Частный проект.
|
||||
- продукт по факту поддерживается как Android-first клиент;
|
||||
- iOS, web, desktop каталоги присутствуют как Flutter scaffold, но не считаются поддерживаемыми продуктными платформами;
|
||||
- `apiProvider` конфигурируется мутирующим `init()`, поэтому переключение домов требует аккуратности;
|
||||
- крупные экраны вроде `SettingsScreen` и `SchedulesScreen` всё ещё держат много UI-ответственности;
|
||||
- release signing в репозитории не настроен.
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
|
||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
package ru.akokos.ignis_app
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.Uri
|
||||
import android.net.wifi.WifiInfo
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.location.LocationManager
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.location.LocationManagerCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
@@ -14,9 +22,13 @@ import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
private var pendingNotificationPermissionResult: MethodChannel.Result? = null
|
||||
private var pendingProvisioningPermissionResult: MethodChannel.Result? = null
|
||||
private val notificationPrefs by lazy {
|
||||
getSharedPreferences(notificationPrefsName, MODE_PRIVATE)
|
||||
}
|
||||
private val provisioningPrefs by lazy {
|
||||
getSharedPreferences(provisioningPrefsName, MODE_PRIVATE)
|
||||
}
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
@@ -94,6 +106,34 @@ class MainActivity : FlutterActivity() {
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
MethodChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
wizProvisioningChannelName,
|
||||
).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"getProvisioningEnvironment" -> {
|
||||
result.success(buildProvisioningEnvironment())
|
||||
}
|
||||
"requestProvisioningPermissions" -> {
|
||||
requestProvisioningPermissions(result)
|
||||
}
|
||||
"openWifiSettings" -> {
|
||||
startActivity(Intent(Settings.ACTION_WIFI_SETTINGS))
|
||||
result.success(null)
|
||||
}
|
||||
"openAppSettings" -> {
|
||||
val intent =
|
||||
Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts("package", packageName, null),
|
||||
)
|
||||
startActivity(intent)
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
@@ -104,6 +144,10 @@ class MainActivity : FlutterActivity() {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
|
||||
if (requestCode != notificationPermissionRequestCode) {
|
||||
if (requestCode == provisioningPermissionRequestCode) {
|
||||
pendingProvisioningPermissionResult?.success(buildProvisioningEnvironment())
|
||||
pendingProvisioningPermissionResult = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -111,6 +155,30 @@ class MainActivity : FlutterActivity() {
|
||||
pendingNotificationPermissionResult = null
|
||||
}
|
||||
|
||||
private fun requestProvisioningPermissions(result: MethodChannel.Result) {
|
||||
if (pendingProvisioningPermissionResult != null) {
|
||||
result.error(
|
||||
"request_in_progress",
|
||||
"Provisioning permission request is already in progress",
|
||||
null,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (hasProvisioningPermissions()) {
|
||||
result.success(buildProvisioningEnvironment())
|
||||
return
|
||||
}
|
||||
|
||||
pendingProvisioningPermissionResult = result
|
||||
provisioningPrefs.edit().putBoolean(provisioningPermissionRequestedKey, true).apply()
|
||||
ActivityCompat.requestPermissions(
|
||||
this,
|
||||
requiredProvisioningPermissions(),
|
||||
provisioningPermissionRequestCode,
|
||||
)
|
||||
}
|
||||
|
||||
private fun requestNotificationPermission(result: MethodChannel.Result) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
result.success(getNotificationPermissionStatusValue())
|
||||
@@ -182,10 +250,137 @@ class MainActivity : FlutterActivity() {
|
||||
NotificationManagerCompat.from(this).areNotificationsEnabled()
|
||||
}
|
||||
|
||||
private fun buildProvisioningEnvironment(): Map<String, Any?> {
|
||||
val wifiSnapshot = currentWifiSnapshot()
|
||||
return mapOf(
|
||||
"platform" to "android",
|
||||
"androidApiLevel" to Build.VERSION.SDK_INT,
|
||||
"smartPairingSupported" to true,
|
||||
"wifiSettingsSupported" to true,
|
||||
"appSettingsSupported" to true,
|
||||
"permissionStatus" to provisioningPermissionStatusValue(),
|
||||
"locationServicesEnabled" to isLocationServicesEnabled(),
|
||||
"connectedToWifi" to wifiSnapshot.connectedToWifi,
|
||||
"ssid" to wifiSnapshot.ssid,
|
||||
"bssid" to wifiSnapshot.bssid,
|
||||
"frequencyMhz" to wifiSnapshot.frequencyMhz,
|
||||
)
|
||||
}
|
||||
|
||||
private fun provisioningPermissionStatusValue(): String {
|
||||
if (hasProvisioningPermissions()) {
|
||||
return "granted"
|
||||
}
|
||||
|
||||
val wasRequestedBefore =
|
||||
provisioningPrefs.getBoolean(provisioningPermissionRequestedKey, false)
|
||||
val canShowPromptAgain =
|
||||
requiredProvisioningPermissions().any {
|
||||
ActivityCompat.shouldShowRequestPermissionRationale(this, it)
|
||||
}
|
||||
|
||||
return if (!wasRequestedBefore || canShowPromptAgain) {
|
||||
"requestable"
|
||||
} else {
|
||||
"settings_required"
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasProvisioningPermissions(): Boolean {
|
||||
return requiredProvisioningPermissions().all { permission ->
|
||||
ContextCompat.checkSelfPermission(this, permission) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
||||
private fun requiredProvisioningPermissions(): Array<String> {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
arrayOf(
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.NEARBY_WIFI_DEVICES,
|
||||
)
|
||||
} else {
|
||||
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isLocationServicesEnabled(): Boolean {
|
||||
val manager = getSystemService(Context.LOCATION_SERVICE) as? LocationManager
|
||||
return manager?.let(LocationManagerCompat::isLocationEnabled) ?: false
|
||||
}
|
||||
|
||||
private fun currentWifiSnapshot(): WifiSnapshot {
|
||||
if (!hasProvisioningPermissions()) {
|
||||
return WifiSnapshot()
|
||||
}
|
||||
|
||||
val connectivityManager =
|
||||
getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val activeNetwork = connectivityManager.activeNetwork
|
||||
val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork)
|
||||
val isWifiTransport =
|
||||
networkCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
|
||||
val wifiInfo =
|
||||
when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
|
||||
networkCapabilities?.transportInfo is WifiInfo -> {
|
||||
networkCapabilities.transportInfo as WifiInfo
|
||||
}
|
||||
else -> {
|
||||
@Suppress("DEPRECATION")
|
||||
val wifiManager =
|
||||
applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
||||
@Suppress("DEPRECATION")
|
||||
wifiManager.connectionInfo
|
||||
}
|
||||
}
|
||||
|
||||
val sanitizedSsid = sanitizeSsid(wifiInfo?.ssid)
|
||||
val sanitizedBssid = sanitizeBssid(wifiInfo?.bssid)
|
||||
val frequency = wifiInfo?.frequency?.takeIf { it > 0 }
|
||||
|
||||
return WifiSnapshot(
|
||||
connectedToWifi = isWifiTransport && sanitizedSsid != null,
|
||||
ssid = sanitizedSsid,
|
||||
bssid = sanitizedBssid,
|
||||
frequencyMhz = frequency,
|
||||
)
|
||||
}
|
||||
|
||||
private fun sanitizeSsid(raw: String?): String? {
|
||||
val trimmed = raw?.trim()?.removePrefix("\"")?.removeSuffix("\"")
|
||||
return when {
|
||||
trimmed.isNullOrEmpty() -> null
|
||||
trimmed.equals(WifiManager.UNKNOWN_SSID, ignoreCase = true) -> null
|
||||
else -> trimmed
|
||||
}
|
||||
}
|
||||
|
||||
private fun sanitizeBssid(raw: String?): String? {
|
||||
val trimmed = raw?.trim()
|
||||
return when {
|
||||
trimmed.isNullOrEmpty() -> null
|
||||
trimmed == "02:00:00:00:00:00" -> null
|
||||
else -> trimmed.lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
data class WifiSnapshot(
|
||||
val connectedToWifi: Boolean = false,
|
||||
val ssid: String? = null,
|
||||
val bssid: String? = null,
|
||||
val frequencyMhz: Int? = null,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val notificationPermissionRequestCode = 4102
|
||||
private const val notificationPrefsName = "ignis_notification_permissions"
|
||||
private const val notificationPermissionRequestedKey =
|
||||
"post_notifications_requested"
|
||||
private const val provisioningPermissionRequestCode = 4103
|
||||
private const val provisioningPrefsName = "ignis_wiz_provisioning"
|
||||
private const val provisioningPermissionRequestedKey =
|
||||
"wiz_provisioning_permissions_requested"
|
||||
private const val wizProvisioningChannelName = "ignis/wiz_provisioning"
|
||||
}
|
||||
}
|
||||
|
||||
3
docs/README.md
Normal file
3
docs/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Docs
|
||||
|
||||
- [WiZ Provisioning Master Plan](./wiz_provisioning_master_plan.md)
|
||||
865
docs/wiz_provisioning_master_plan.md
Normal file
865
docs/wiz_provisioning_master_plan.md
Normal file
@@ -0,0 +1,865 @@
|
||||
# WiZ Provisioning Master Plan
|
||||
|
||||
Статус: design / implementation brief
|
||||
Актуально на: 2026-05-16
|
||||
Основной проект: `ignis_app`
|
||||
Связанный проект: `../ignis-core`
|
||||
|
||||
## Зачем нужен этот документ
|
||||
|
||||
Этот документ нужен как рабочий implementation brief для добавления в `Ignis` мастера первичной посадки новых WiZ-ламп на домашний Wi-Fi без официального приложения WiZ.
|
||||
|
||||
Цель документа:
|
||||
|
||||
- быстро восстановить контекст через недели или месяцы без повторного ресерча;
|
||||
- понимать, что именно реализуем сначала, а что откладываем;
|
||||
- иметь точную карту действий по Flutter, Android native и интеграции с `ignis-core`;
|
||||
- заранее зафиксировать технические риски и места, где потребуются ручные проверки и апрувы.
|
||||
|
||||
## Краткий вывод
|
||||
|
||||
Сделать это **реально**, но нужно разделять минимум два поколения/режима onboarding:
|
||||
|
||||
- `SoftAP / manual setup`: лампа поднимает сеть вида `WiZConfig_xxxx`, телефон временно подключается к ней и передаёт домашние Wi-Fi credentials.
|
||||
- `BLE setup`: часть более новых устройств рекламируется и настраивается по Bluetooth.
|
||||
|
||||
Дополнительно у части устройств есть `Matter`, но его не стоит брать как первый путь реализации внутри `Ignis`.
|
||||
|
||||
### Почему задача вообще выглядит решаемой
|
||||
|
||||
Официальные материалы WiZ подтверждают:
|
||||
|
||||
- есть режим `Manual setup` через Wi-Fi лампы `WiZConfig_xxxx`;
|
||||
- есть Bluetooth-based setup для новых устройств;
|
||||
- есть отдельный локальный commissioning-порт `UDP 18266`, который используется только во время ввода устройства в строй.
|
||||
|
||||
Это означает, что задача не упирается в принципиальную закрытость экосистемы. Главная неизвестная не в том, можно ли это сделать, а в том, **какой именно payload нужно передать лампе при commissioning**.
|
||||
|
||||
## Границы задачи
|
||||
|
||||
### Что считаем целевым результатом
|
||||
|
||||
Пользователь открывает `ignis_app`, запускает мастер, выбирает домашнюю Wi-Fi сеть, вводит пароль, переводит лампу в pairing mode, приложение временно подключается к лампе, передаёт credentials, ждёт появления лампы в домашней сети и затем вызывает `POST /devices/rescan` на локальном сервере `ignis-core`.
|
||||
|
||||
### Что не входит в первую фазу
|
||||
|
||||
- полноценный iOS onboarding;
|
||||
- Matter commissioner внутри `Ignis`;
|
||||
- cloud-интеграции WiZ;
|
||||
- поддержка всех возможных поколений устройств одновременно;
|
||||
- идеальный cross-platform UX.
|
||||
|
||||
### Что делаем в первую очередь
|
||||
|
||||
1. Android-only onboarding.
|
||||
2. Сначала `SoftAP`-ветка.
|
||||
3. После неё `BLE`-ветка.
|
||||
4. `Matter` рассматривать только как отдельный fallback/будущее расширение.
|
||||
|
||||
## Текущий статус реализации
|
||||
|
||||
По состоянию на 2026-05-16 в `ignis_app` уже есть первая рабочая Android-first реализация мастера, но она закрывает только часть общей задачи.
|
||||
|
||||
### Что уже реализовано
|
||||
|
||||
- отдельный экран мастера в `ignis_app`;
|
||||
- Android environment inspection через platform channel;
|
||||
- проверка Wi-Fi контекста, системных permissions и активного дома;
|
||||
- Android-first `smart pairing` flow на базе `esp_smartconfig`;
|
||||
- post-provision `POST /devices/rescan` через уже существующий backend `ignis-core`;
|
||||
- release build, `flutter analyze` и тесты проходят;
|
||||
- entrypoint мастера перенесён в `SettingsScreen`, в секцию `Дом и подключение`.
|
||||
|
||||
### Что это означает на практике
|
||||
|
||||
Сейчас в проекте есть не "полноценный универсальный мастер WiZ", а первый технический клин в эту задачу: Android-only путь через smart pairing с последующим discovery в `Ignis`.
|
||||
|
||||
### Что критично ещё не реализовано
|
||||
|
||||
- нет `SoftAP`-ветки через `WiZConfig_xxxx`;
|
||||
- нет reverse engineering и production-реализации commissioning-протокола `UDP 18266`;
|
||||
- нет `BLE`-ветки для новых ламп;
|
||||
- нет `Matter`-fallback;
|
||||
- нет iOS-реализации;
|
||||
- нет подтверждения на реальном железе, что текущий `smart pairing` путь покрывает нужные пользователю модели ламп.
|
||||
|
||||
### Честная оценка текущего состояния
|
||||
|
||||
Тема не закрыта. Закрыт только первый этап.
|
||||
|
||||
Если текущий `smart pairing` на реальных лампах пользователя не сработает, основной следующий шаг -- это не косметика и не polish, а возврат к базовому плану:
|
||||
|
||||
1. `SoftAP / WiZConfig_xxxx`
|
||||
2. commissioning через `UDP 18266`
|
||||
3. затем `BLE`-ветка
|
||||
|
||||
### Что ещё не добито по UX и диагностике
|
||||
|
||||
- нет выбора provisioning mode (`smart pairing` / `WiZConfig_xxxx` / `BLE`);
|
||||
- нет явного fallback UX для ламп, которые не поддерживают текущий путь;
|
||||
- нет финального успешного сценария "создать группу / мигнуть лампой / открыть пульт";
|
||||
- нет отдельного protocol notes файла с результатами reverse engineering;
|
||||
- нет полной ручной test matrix по реальным устройствам разных поколений.
|
||||
|
||||
## Почему основной объём живёт в `ignis_app`
|
||||
|
||||
Onboarding должен жить в `ignis_app`, а не в `ignis-core`, потому что именно телефон:
|
||||
|
||||
- видит nearby Wi-Fi/BLE устройства;
|
||||
- может временно переключаться на AP лампы;
|
||||
- может работать с Android Wi-Fi / Bluetooth API;
|
||||
- может запросить системные permissions и показать пользователю platform dialogs.
|
||||
|
||||
`ignis-core` нужен только после успешного provisioning:
|
||||
|
||||
- сделать `POST /devices/rescan`;
|
||||
- обнаружить лампу в домашней сети;
|
||||
- дальше работать обычным локальным API, который уже реализован.
|
||||
|
||||
Связанные текущие точки в коде:
|
||||
|
||||
- `ignis_app/lib/main.dart`
|
||||
- `ignis_app/lib/screens/homes_screen.dart`
|
||||
- `ignis_app/lib/screens/remote_screen.dart`
|
||||
- `ignis_app/android/app/src/main/kotlin/ru/akokos/ignis_app/MainActivity.kt`
|
||||
- `ignis_app/lib/services/api_client.dart`
|
||||
- `ignis-core/app/api/routes/devices.py`
|
||||
|
||||
## Подтверждённые факты из источников
|
||||
|
||||
### 1. У WiZ есть ручной setup через `WiZConfig_xxxx`
|
||||
|
||||
Официальная legacy-справка WiZ говорит, что если обычное pairing не сработало, нужно идти в `Manual setup`, где телефон подключается напрямую к Wi-Fi сети лампы.
|
||||
|
||||
Источник:
|
||||
|
||||
- https://faq.wizconnected.com/hc/en/3-wiz-legacy/faq/138-adding-a-wiz-light-in-the-system/
|
||||
|
||||
Дополнительный важный нюанс: WiZ отдельно пишет, что у некоторых ламп сеть `WiZConfig_xxxx` может отсутствовать вообще. Это значит, что SoftAP-путь нельзя считать универсальным.
|
||||
|
||||
Источник:
|
||||
|
||||
- https://faq.wizconnected.com/hc/en/3-wiz-legacy/faq/147-can-t-find-wizconfig-xxxx-in-the-wi-fi-settings-during-manual-setup/
|
||||
|
||||
### 2. У новых устройств есть Bluetooth-based setup
|
||||
|
||||
Новая справка WiZ V2 указывает, что Bluetooth permission нужен для Bluetooth-enabled products и что приложение может автоматически находить такие лампы после включения питания.
|
||||
|
||||
Источник:
|
||||
|
||||
- https://faq.wizconnected.com/hc/en/7-wiz-v2/faq/767-smart-lighting---how-to-get-started/
|
||||
|
||||
Дополнительное косвенное подтверждение: в даташитах WiZ/WiZ Pro встречается `Wi-Fi + BLE` и явная фраза про setup via Bluetooth.
|
||||
|
||||
Источники:
|
||||
|
||||
- https://assets.wizconnected.com/datasheets/WiZ_Pro_A60_B22_TW_8W_230V_929002383771_DS1022.pdf
|
||||
- https://assets.wizconnected.com/datasheets/WiZ_Pro_A67_E27_RGBTW_13W_230V_929002449771_DS042023.pdf
|
||||
|
||||
### 3. У WiZ есть отдельный commissioning-порт `UDP 18266`
|
||||
|
||||
Это ключевой технический сигнал. В официальном документе по сети WiZ указано, что порт `18266/UDP` используется локально и только во время commissioning.
|
||||
|
||||
Источник:
|
||||
|
||||
- https://assets.wizconnected.com/manuals/WiZ-Network-Configuration-v2-01162024.pdf
|
||||
|
||||
Вывод: существует локальный commissioning-протокол, который, вероятно, и использует официальное приложение во время первичной посадки лампы в Wi-Fi.
|
||||
|
||||
### 4. Android официально поддерживает bootstrap к локальной Wi-Fi accessory network
|
||||
|
||||
Для Android 10+ есть штатный путь для peer-to-peer Wi-Fi bootstrap через `WifiNetworkSpecifier`.
|
||||
|
||||
Источник:
|
||||
|
||||
- https://developer.android.com/develop/connectivity/wifi/wifi-bootstrap
|
||||
|
||||
### 5. iOS тоже поддерживает accessory Wi-Fi setup, но не это наш первый приоритет
|
||||
|
||||
Для iOS есть `NEHotspotConfiguration` / `NEHotspotConfigurationManager`, а также accessory-oriented Wi-Fi configuration APIs. Это делает iOS-ветку возможной, но отдельной и более дорогой по времени.
|
||||
|
||||
Источники:
|
||||
|
||||
- https://developer.apple.com/documentation/networkextension/wi-fi_configuration
|
||||
- https://developer.apple.com/documentation/networkextension/nehotspotconfigurationmanager
|
||||
|
||||
### 6. Matter есть не на всех WiZ-устройствах
|
||||
|
||||
WiZ пишет, что Matter поддерживают только lights/smart plugs, выпущенные после `Q2 2021`. Поэтому Matter нельзя использовать как универсальный onboarding-path.
|
||||
|
||||
Источник:
|
||||
|
||||
- https://faq.wizconnected.com/hc/en/7-wiz-v2/faq/531-do-all-wiz-devices-support-matter/
|
||||
|
||||
## Принятая стратегия реализации
|
||||
|
||||
### Основной план
|
||||
|
||||
1. Реализовать Android-only `SoftAP onboarding`.
|
||||
2. Через reverse engineering добыть локальный commissioning payload для `UDP 18266`.
|
||||
3. Встроить это в `ignis_app` как отдельный мастер.
|
||||
4. После успешного provisioning вызывать existing `rescanNetwork()` через текущий `IgnisApi`.
|
||||
5. Затем добавить `BLE onboarding` как вторую ветку.
|
||||
|
||||
### Почему не начинать с BLE
|
||||
|
||||
- SoftAP-path подтверждён официальной справкой WiZ.
|
||||
- Android Wi-Fi bootstrap API проще и стабильнее, чем reverse engineering GATT-профиля с нуля.
|
||||
- BLE вероятно потребуется для новых ламп, но SoftAP даст первый рабочий end-to-end flow быстрее.
|
||||
|
||||
### Почему не начинать с Matter
|
||||
|
||||
- не все устройства его поддерживают;
|
||||
- внутри собственного Flutter-приложения Matter commissioner сильно увеличивает объём работ;
|
||||
- для цели `Ignis` нужен именно практичный локальный onboarding WiZ-лампы, а не параллельный smart-home стек.
|
||||
|
||||
## Архитектурное решение по проекту
|
||||
|
||||
## High-Level Flow
|
||||
|
||||
```text
|
||||
Flutter UI
|
||||
-> MethodChannel
|
||||
-> Android provisioning manager
|
||||
-> connect to WiZConfig_xxxx AP
|
||||
-> send commissioning payload to lamp over UDP 18266
|
||||
-> wait for device to leave AP / join home Wi-Fi
|
||||
-> call ignis-core POST /devices/rescan
|
||||
-> show discovered device / success state
|
||||
```
|
||||
|
||||
## Что добавляем в `ignis_app`
|
||||
|
||||
Новая feature-зона:
|
||||
|
||||
- `lib/features/provisioning/models/`
|
||||
- `lib/features/provisioning/providers/`
|
||||
- `lib/features/provisioning/services/`
|
||||
|
||||
Новый экран:
|
||||
|
||||
- `lib/screens/wiz_provisioning_screen.dart`
|
||||
|
||||
Новые Android native классы:
|
||||
|
||||
- `android/app/src/main/kotlin/ru/akokos/ignis_app/WizProvisioningManager.kt`
|
||||
- `android/app/src/main/kotlin/ru/akokos/ignis_app/WizSoftApProvisioner.kt`
|
||||
- `android/app/src/main/kotlin/ru/akokos/ignis_app/WizUdpCommissioningClient.kt`
|
||||
- `android/app/src/main/kotlin/ru/akokos/ignis_app/WizProvisioningModels.kt`
|
||||
|
||||
Вторая очередь, не первая:
|
||||
|
||||
- `android/app/src/main/kotlin/ru/akokos/ignis_app/WizBleProvisioner.kt`
|
||||
|
||||
## Что не нужно делать в `ignis-core`
|
||||
|
||||
Не нужно переносить туда onboarding-логику. Сервер не должен:
|
||||
|
||||
- управлять Wi-Fi телефона;
|
||||
- принимать пароль от домашней сети как основную часть provisioning;
|
||||
- реализовывать platform-specific transport.
|
||||
|
||||
В `ignis-core` достаточно использовать уже существующий:
|
||||
|
||||
- `POST /devices/rescan`
|
||||
|
||||
Текущая клиентская точка входа:
|
||||
|
||||
- `ignis_app/lib/services/api_client.dart` -> `rescanNetwork()`
|
||||
|
||||
## Фактическая реализация против плана
|
||||
|
||||
Изначальный план первой полноценной фазы предполагал старт с `SoftAP onboarding`. В коде сейчас первой реализованной веткой стал `smart pairing`.
|
||||
|
||||
### Почему это важно помнить
|
||||
|
||||
- это ускорило появление первого рабочего мастера;
|
||||
- но это не доказывает, что задача "подключение любых новых WiZ-ламп без официального приложения" уже решена;
|
||||
- исходный `SoftAP + UDP 18266` путь по-прежнему остаётся вероятно более универсальным и по-прежнему нужен, если smart pairing не перекрывает нужные модели устройств.
|
||||
|
||||
## Предлагаемая модель состояния мастера
|
||||
|
||||
Нужна явная state machine, а не набор bool-флагов.
|
||||
|
||||
### Состояния
|
||||
|
||||
- `idle`
|
||||
- `checkingCapabilities`
|
||||
- `requestingPermissions`
|
||||
- `waitingForHomeSelection`
|
||||
- `waitingForPairingMode`
|
||||
- `scanningForSoftAp`
|
||||
- `connectingToLampAp`
|
||||
- `connectedToLampAp`
|
||||
- `commissioning`
|
||||
- `waitingForLampToJoinHome`
|
||||
- `rescanningIgnis`
|
||||
- `success`
|
||||
- `failure`
|
||||
- `cancelled`
|
||||
|
||||
### Данные состояния
|
||||
|
||||
- `selectedHomeSsid`
|
||||
- `selectedHomePassphrase`
|
||||
- `selectedIgnisHomeId`
|
||||
- `lampApSsid`
|
||||
- `attempt`
|
||||
- `lastError`
|
||||
- `debugTimeline`
|
||||
- `startedAt`
|
||||
- `finishedAt`
|
||||
|
||||
### Почему нужен `debugTimeline`
|
||||
|
||||
Provisioning без timeline очень трудно дебажить. Минимум нужен список событий:
|
||||
|
||||
- permission granted/denied;
|
||||
- lamp AP discovered;
|
||||
- Wi-Fi request sent;
|
||||
- Wi-Fi request accepted/rejected;
|
||||
- UDP payload sent;
|
||||
- ack received / timeout;
|
||||
- device disappeared from AP;
|
||||
- `rescan` started/finished.
|
||||
|
||||
## UX-структура мастера
|
||||
|
||||
### Экран 1. Введение
|
||||
|
||||
Показывает:
|
||||
|
||||
- что мастер пока Android-only;
|
||||
- какие лампы могут не поддерживать SoftAP;
|
||||
- что телефон временно переключится на сеть лампы;
|
||||
- что нужен локальный сервер `Ignis` и доступный активный дом.
|
||||
|
||||
### Экран 2. Проверка контекста
|
||||
|
||||
Проверки:
|
||||
|
||||
- выбран ли активный `HomeConfig`;
|
||||
- доступен ли `auth/me` текущего дома;
|
||||
- есть ли Wi-Fi на телефоне;
|
||||
- есть ли необходимые Android permissions;
|
||||
- Android API level >= 29.
|
||||
|
||||
### Экран 3. Выбор домашней сети
|
||||
|
||||
Варианты:
|
||||
|
||||
- ручной ввод SSID и пароля;
|
||||
- позже, если потребуется, попытка показать текущий SSID как подсказку.
|
||||
|
||||
Важно: не делать магии вокруг чтения текущего SSID как обязательного пути. На Android это permission-sensitive и OEM-dependent.
|
||||
|
||||
### Экран 4. Инструкция перевода лампы в pairing mode
|
||||
|
||||
Нужно явно объяснить:
|
||||
|
||||
- включить лампу;
|
||||
- если не находится, несколько раз выключить/включить до мигания;
|
||||
- если лампа не поднимает `WiZConfig_xxxx`, значит этот путь может не поддерживаться и нужен BLE-path.
|
||||
|
||||
### Экран 5. Подключение и provisioning
|
||||
|
||||
Показывать по шагам:
|
||||
|
||||
- поиск `WiZConfig_xxxx`;
|
||||
- подключение;
|
||||
- передача настроек;
|
||||
- ожидание возврата лампы в домашнюю сеть;
|
||||
- запрос `rescan`.
|
||||
|
||||
### Экран 6. Итог
|
||||
|
||||
Успех:
|
||||
|
||||
- показать, что лампа найдена;
|
||||
- предложить перейти к созданию группы или вернуться на пульт.
|
||||
|
||||
Ошибка:
|
||||
|
||||
- показать, на каком шаге упало;
|
||||
- дать actionable retry;
|
||||
- предлагать BLE-path только когда он будет реализован.
|
||||
|
||||
## Точки встраивания в текущее приложение
|
||||
|
||||
Минимально логичное место входа:
|
||||
|
||||
- `HomesScreen`: отдельная кнопка "Добавить лампу WiZ"
|
||||
- или `RemoteScreen` в overflow menu
|
||||
|
||||
Рекомендуемый вариант первой версии:
|
||||
|
||||
- вход из `HomesScreen`, потому что provisioning логически относится к конкретному дому/серверу.
|
||||
|
||||
Причина:
|
||||
|
||||
- мастер зависит от выбранного активного дома;
|
||||
- после provisioning всё равно нужен `rescan` именно этого дома;
|
||||
- onboarding новой лампы ближе по смыслу к инфраструктуре дома, чем к управлению существующими группами.
|
||||
|
||||
## Android implementation plan
|
||||
|
||||
## Phase 1: Platform Bridge Skeleton
|
||||
|
||||
### Цель
|
||||
|
||||
Подготовить безопасный мост Flutter <-> Android без реальной provisioning-логики.
|
||||
|
||||
### Изменяемые файлы
|
||||
|
||||
- `ignis_app/android/app/src/main/kotlin/ru/akokos/ignis_app/MainActivity.kt`
|
||||
- новые Kotlin-файлы из секции выше
|
||||
- новые Dart provider/service/screen файлы
|
||||
|
||||
### MethodChannel
|
||||
|
||||
Использовать отдельный channel, не смешивать с geofence.
|
||||
|
||||
Предлагаемое имя:
|
||||
|
||||
- `ignis/wiz_provisioning`
|
||||
|
||||
### Методы канала v1
|
||||
|
||||
- `getProvisioningCapabilities`
|
||||
- `requestProvisioningPermissions`
|
||||
- `startSoftApProvisioning`
|
||||
- `cancelProvisioning`
|
||||
|
||||
### Формат `getProvisioningCapabilities`
|
||||
|
||||
Возвращать map:
|
||||
|
||||
- `platform`: `android`
|
||||
- `androidApiLevel`
|
||||
- `supportsWifiNetworkSpecifier`
|
||||
- `supportsBle`
|
||||
- `supportedModes`: `["softap"]` на первой фазе
|
||||
|
||||
### Формат `startSoftApProvisioning`
|
||||
|
||||
Вход:
|
||||
|
||||
- `homeSsid`
|
||||
- `homePassphrase`
|
||||
- `lampApPrefix` default `WiZConfig_`
|
||||
- `timeoutSeconds`
|
||||
- `activeHomeBaseUrl`
|
||||
- `activeHomeApiKey`
|
||||
|
||||
На первой фазе `activeHomeBaseUrl` и `activeHomeApiKey` можно вообще не передавать в native и оставить `rescan` на Flutter-стороне.
|
||||
|
||||
## Phase 2: Android Wi-Fi Connect to Lamp AP
|
||||
|
||||
### Цель
|
||||
|
||||
Научиться гарантированно подключать телефон к `WiZConfig_xxxx`.
|
||||
|
||||
### Android APIs
|
||||
|
||||
- `WifiNetworkSpecifier`
|
||||
- `ConnectivityManager.requestNetwork()`
|
||||
- `NetworkCallback`
|
||||
|
||||
Источник:
|
||||
|
||||
- https://developer.android.com/develop/connectivity/wifi/wifi-bootstrap
|
||||
|
||||
### Требуемые permissions и manifest changes
|
||||
|
||||
Проверить и добавить по необходимости:
|
||||
|
||||
- `android.permission.NEARBY_WIFI_DEVICES` для Android 13+
|
||||
- существующие location permissions уже частично есть
|
||||
|
||||
Текущий manifest:
|
||||
|
||||
- `ignis_app/android/app/src/main/AndroidManifest.xml`
|
||||
|
||||
### Что нужно реализовать
|
||||
|
||||
- запрос permissions из Flutter;
|
||||
- поиск/выбор AP по префиксу `WiZConfig_`;
|
||||
- запрос на временное подключение;
|
||||
- ожидание `onAvailable()`;
|
||||
- bind сокетов к этой `Network`, если потребуется;
|
||||
- корректный release callback после завершения или отмены.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- приложение может подключиться к реальной сети `WiZConfig_xxxx`;
|
||||
- есть надёжный таймаут и понятный error mapping;
|
||||
- повторный запуск после неудачи не оставляет висящих network requests.
|
||||
|
||||
## Phase 3: Reverse Engineering Commissioning Payload
|
||||
|
||||
### Это главный риск проекта
|
||||
|
||||
Пока нет публичного официального описания payload для `UDP 18266`.
|
||||
|
||||
Нужно добыть его экспериментально.
|
||||
|
||||
### Практический план reverse engineering
|
||||
|
||||
1. Взять лампу, которая точно поддерживает `WiZConfig_xxxx`.
|
||||
2. Перевести лампу в pairing mode.
|
||||
3. Запустить официальный WiZ app и пройти `Manual setup`.
|
||||
4. Снять трафик между телефоном и лампой во время provisioning.
|
||||
5. Отделить трафик к лампе от фонового шума.
|
||||
6. Найти обмен по `UDP 18266`.
|
||||
7. Зафиксировать:
|
||||
- формат payload;
|
||||
- есть ли ответ/ack;
|
||||
- есть ли checksum/nonce/session id;
|
||||
- передаётся ли SSID/пароль в открытом виде, JSON, protobuf, бинарнике и т.д.
|
||||
8. Повторить пару раз с разными SSID/password, чтобы понять структуру.
|
||||
|
||||
### Что именно нужно получить в результате
|
||||
|
||||
- пример сырого запроса;
|
||||
- пример сырого ответа;
|
||||
- описание полей;
|
||||
- минимальный payload, достаточный для посадки лампы;
|
||||
- список обязательных и необязательных параметров.
|
||||
|
||||
### Какие инструменты могут понадобиться
|
||||
|
||||
- Android device + официальный WiZ app;
|
||||
- отдельная тестовая Wi-Fi сеть;
|
||||
- Wireshark / tcpdump / mitm на уровне точки доступа;
|
||||
- при необходимости второй Android с hotspot/sniffer схемой;
|
||||
- возможно `adb bugreport` / network diagnostics как вспомогательный путь.
|
||||
|
||||
### Решение по хранению результата
|
||||
|
||||
После расшифровки протокола создать отдельный внутренний документ:
|
||||
|
||||
- `ignis_app/docs/wiz_commissioning_protocol_notes.md`
|
||||
|
||||
Если там будут чувствительные детали или сырые бинарные дампы, можно держать файл локально и не коммитить, но лучше иметь хотя бы структурированное описание в репозитории.
|
||||
|
||||
## Phase 4: Implement UDP Commissioning Client
|
||||
|
||||
### Цель
|
||||
|
||||
Собрать Kotlin-клиент, который:
|
||||
|
||||
- открывает UDP socket в сети лампы;
|
||||
- отправляет commissioning payload на `18266`;
|
||||
- ждёт подтверждение или фиксирует timeout;
|
||||
- отдаёт во Flutter понятный result object.
|
||||
|
||||
### Предлагаемый класс
|
||||
|
||||
- `WizUdpCommissioningClient.kt`
|
||||
|
||||
### Предлагаемый API
|
||||
|
||||
- `suspend fun sendCredentials(network: Network, targetIp: InetAddress, payload: ByteArray): CommissioningResult`
|
||||
|
||||
### Что нужно предусмотреть
|
||||
|
||||
- bind сокета именно к `Network`, через которую подключились к AP лампы;
|
||||
- configurable timeout;
|
||||
- раздельные ошибки:
|
||||
- `socket_open_failed`
|
||||
- `payload_build_failed`
|
||||
- `send_failed`
|
||||
- `ack_timeout`
|
||||
- `malformed_ack`
|
||||
|
||||
## Phase 5: End-to-End Flutter Flow
|
||||
|
||||
### Цель
|
||||
|
||||
Сделать законченный пользовательский мастер.
|
||||
|
||||
### Новые Dart сущности
|
||||
|
||||
Предлагаемые файлы:
|
||||
|
||||
- `lib/features/provisioning/models/wiz_provisioning_mode.dart`
|
||||
- `lib/features/provisioning/models/wiz_provisioning_state.dart`
|
||||
- `lib/features/provisioning/models/wiz_provisioning_failure.dart`
|
||||
- `lib/features/provisioning/services/wiz_provisioning_platform_service.dart`
|
||||
- `lib/features/provisioning/providers/wiz_provisioning_providers.dart`
|
||||
- `lib/screens/wiz_provisioning_screen.dart`
|
||||
|
||||
### Поведение после успешного provisioning
|
||||
|
||||
1. Flutter получает успех от native-слоя.
|
||||
2. Flutter ждёт короткое окно на переподключение лампы к домашней сети.
|
||||
3. Flutter вызывает текущий `IgnisApi.rescanNetwork()`.
|
||||
4. Flutter обновляет список устройств/групп.
|
||||
5. Flutter показывает success и, если возможно, имя/IP/MAC найденной лампы.
|
||||
|
||||
Текущая клиентская точка:
|
||||
|
||||
- `ignis_app/lib/services/api_client.dart`
|
||||
|
||||
Текущий backend route:
|
||||
|
||||
- `ignis-core/app/api/routes/devices.py`
|
||||
|
||||
## Phase 6: BLE Branch
|
||||
|
||||
### Когда переходить к BLE
|
||||
|
||||
Только после того, как SoftAP-path уже рабочий end-to-end.
|
||||
|
||||
### Что нужно выяснить перед реализацией
|
||||
|
||||
- advertise name/service UUID лампы в pairing mode;
|
||||
- GATT services/characteristics;
|
||||
- какой transport и payload используются для передачи Wi-Fi credentials;
|
||||
- есть ли там тот же commissioning payload, что и в SoftAP-path, или совсем другой протокол.
|
||||
|
||||
### Android tech stack для BLE
|
||||
|
||||
- `BluetoothManager`
|
||||
- `BluetoothAdapter`
|
||||
- `BluetoothLeScanner`
|
||||
- `ScanCallback`
|
||||
- `BluetoothGatt`
|
||||
|
||||
### Дополнительные permissions
|
||||
|
||||
- `android.permission.BLUETOOTH_SCAN`
|
||||
- `android.permission.BLUETOOTH_CONNECT`
|
||||
|
||||
### Важное ограничение
|
||||
|
||||
Пока нет сведений, что BLE-path у WiZ можно поднять без reverse engineering. Официальные источники подтверждают наличие Bluetooth setup, но не описывают низкоуровневый протокол.
|
||||
|
||||
## Что менять в UI и навигации
|
||||
|
||||
### Первая версия
|
||||
|
||||
Добавить в `HomesScreen` вторую FAB-entry или secondary action:
|
||||
|
||||
- `Добавить дом`
|
||||
- `Добавить лампу WiZ`
|
||||
|
||||
Если не хочется перегружать основной FAB, сделать extended bottom sheet / speed dial / отдельную кнопку в пустом состоянии.
|
||||
|
||||
### Более аккуратный вариант
|
||||
|
||||
Добавить на `HomesScreen` card/banner:
|
||||
|
||||
- "Новая лампа WiZ? Открыть мастер подключения"
|
||||
|
||||
### Что не делать
|
||||
|
||||
- не прятать этот flow глубоко в `SettingsScreen`;
|
||||
- не запускать onboarding из `RemoteScreen` по умолчанию, если активный дом ещё не выбран и невалиден.
|
||||
|
||||
## Безопасность и хранение Wi-Fi credentials
|
||||
|
||||
### Минимальные правила
|
||||
|
||||
- пароль домашней Wi-Fi сети не хранить в `SharedPreferences`;
|
||||
- не класть пароль в обычные debug-логи;
|
||||
- не записывать пароль в event log приложения;
|
||||
- держать пароль только в runtime memory на время onboarding-сессии;
|
||||
- по завершении или ошибке занулять/очищать state.
|
||||
|
||||
### Что можно хранить
|
||||
|
||||
- только SSID как UX convenience, если это вообще нужно;
|
||||
- debug timeline без секретов;
|
||||
- machine-readable error codes.
|
||||
|
||||
## Тестовая стратегия
|
||||
|
||||
## Unit / Widget Tests
|
||||
|
||||
Покрыть:
|
||||
|
||||
- state machine;
|
||||
- error mapping native -> Flutter;
|
||||
- retry/cancel logic;
|
||||
- post-success переход к `rescan`.
|
||||
|
||||
### Предлагаемые test-файлы
|
||||
|
||||
- `test/wiz_provisioning_state_test.dart`
|
||||
- `test/wiz_provisioning_notifier_test.dart`
|
||||
- `test/wiz_provisioning_screen_test.dart`
|
||||
|
||||
## Android Native Tests
|
||||
|
||||
Минимум:
|
||||
|
||||
- локальные unit tests на payload builder;
|
||||
- по возможности instrumentation tests на permission/result mapping.
|
||||
|
||||
Но главное здесь всё равно manual verification на реальном устройстве.
|
||||
|
||||
## Manual Test Matrix
|
||||
|
||||
Обязательные ручные сценарии:
|
||||
|
||||
1. Успешная посадка лампы через `WiZConfig_xxxx`.
|
||||
2. Таймаут при отсутствии pairing mode.
|
||||
3. Ошибочный пароль домашней сети.
|
||||
4. Повторный запуск мастера сразу после ошибки.
|
||||
5. Отмена пользователем system dialog подключения к Wi-Fi.
|
||||
6. Потеря Wi-Fi во время provisioning.
|
||||
7. Успешный `rescan` после provisioning.
|
||||
8. Provisioning success, но `rescan` не находит лампу.
|
||||
9. Лампа без `WiZConfig_xxxx` и корректный UX fallback.
|
||||
10. Проверка на Android 13+ с новыми permission flows.
|
||||
|
||||
## Acceptance Criteria для первой поставляемой версии
|
||||
|
||||
Первая версия считается завершённой, если:
|
||||
|
||||
- мастер доступен из `ignis_app`;
|
||||
- Android 10+ устройство умеет подключиться к `WiZConfig_xxxx`;
|
||||
- приложение умеет отправить commissioning payload и получить воспроизводимый результат;
|
||||
- лампа уходит в домашнюю сеть;
|
||||
- `Ignis` после `rescan` видит лампу;
|
||||
- пользователь получает понятный success/failure UX;
|
||||
- чувствительные данные не остаются в persistent storage;
|
||||
- есть тесты на Dart state machine и минимум ручной regression checklist.
|
||||
|
||||
## Пошаговая карта действий
|
||||
|
||||
## Шаг 0. Подготовка перед кодом
|
||||
|
||||
1. Подтвердить наличие тестовой WiZ-лампы с `WiZConfig_xxxx`.
|
||||
2. Подготовить Android-девайс для ручных прогонов.
|
||||
3. Подготовить отдельную тестовую Wi-Fi сеть `2.4 GHz`.
|
||||
4. Подготовить стенд с рабочим `ignis-core`.
|
||||
5. Решить, где и как снимать provisioning traffic.
|
||||
|
||||
## Шаг 1. Скелет feature в `ignis_app`
|
||||
|
||||
1. Добавить `docs/` и этот план.
|
||||
2. Создать feature-папки `lib/features/provisioning/*`.
|
||||
3. Создать Dart-модели состояния.
|
||||
4. Создать `WizProvisioningPlatformService`.
|
||||
5. Создать новый `MethodChannel`.
|
||||
6. Добавить пустой экран мастера и точку входа из `HomesScreen`.
|
||||
|
||||
## Шаг 2. Android bridge без реального provisioning
|
||||
|
||||
1. Добавить `WizProvisioningManager.kt`.
|
||||
2. Подключить channel в `MainActivity.kt`.
|
||||
3. Реализовать `getProvisioningCapabilities`.
|
||||
4. Реализовать `requestProvisioningPermissions`.
|
||||
5. Протянуть результат в UI и показать capability gate.
|
||||
|
||||
## Шаг 3. SoftAP connection layer
|
||||
|
||||
1. Добавить `WifiNetworkSpecifier` flow.
|
||||
2. Сделать поиск/подключение к `WiZConfig_`.
|
||||
3. Вернуть во Flutter structured result.
|
||||
4. Обработать cancel/timeout.
|
||||
5. Проверить ручным прогоном на тестовой точке доступа.
|
||||
|
||||
## Шаг 4. Reverse engineering commissioning payload
|
||||
|
||||
1. Снять трафик официального WiZ app.
|
||||
2. Описать протокол.
|
||||
3. Зафиксировать findings в отдельном notes-файле.
|
||||
4. Только после этого писать production `WizUdpCommissioningClient`.
|
||||
|
||||
## Шаг 5. Реальный commissioning
|
||||
|
||||
1. Реализовать payload builder.
|
||||
2. Реализовать отправку на `UDP 18266`.
|
||||
3. Обработать ack/timeout.
|
||||
4. Завести детальные internal error codes.
|
||||
|
||||
## Шаг 6. End-to-end flow с `Ignis`
|
||||
|
||||
1. После native success вызвать `rescanNetwork()`.
|
||||
2. Дождаться ответа backend.
|
||||
3. Показать найденное устройство или отдельную ошибку post-provision discovery.
|
||||
4. Протестировать повторяемость.
|
||||
|
||||
## Шаг 7. Cleanup и hardening
|
||||
|
||||
1. Удалить лишние debug-логи.
|
||||
2. Проверить, что пароль Wi-Fi нигде не сохраняется.
|
||||
3. Добавить unit/widget tests.
|
||||
4. Обновить `README.md`.
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
### Технические
|
||||
|
||||
- Как именно выглядит payload для `UDP 18266`?
|
||||
- Нужен ли specific target IP на AP лампы или есть broadcast?
|
||||
- Есть ли обязательный ack и как он кодируется?
|
||||
- Нужен ли bind сокета к `Network` для всех Android OEM или только для части?
|
||||
- Как определить успешность до `rescan`: по ack или по исчезновению AP?
|
||||
|
||||
### Продуктовые
|
||||
|
||||
- Где именно располагать entrypoint мастера в UI?
|
||||
- Нужен ли в первой версии ручной ввод SSID, или можно сразу только auto-fill + редактирование?
|
||||
- Нужен ли отдельный экран выбора provisioning mode, если BLE ещё не реализован?
|
||||
|
||||
### Операционные
|
||||
|
||||
- Есть ли в наличии лампа старого поколения с `WiZConfig_xxxx`?
|
||||
- Нужны ли апрувы на Android permissions / реальные ручные прогоны / возможную установку вспомогательных инструментов для sniffing?
|
||||
|
||||
## Что проверять первым делом при возвращении к задаче
|
||||
|
||||
Если вернулись к задаче через долгое время, стартовать так:
|
||||
|
||||
1. Перечитать этот документ целиком.
|
||||
2. Проверить, не появились ли новые официальные WiZ материалы про local commissioning.
|
||||
3. Проверить, не изменились ли Android Wi-Fi/BLE permission требования.
|
||||
4. Уточнить, какая именно тестовая лампа есть на руках.
|
||||
5. Решить, можно ли сразу идти в reverse engineering или сначала поднимать только bridge/UI skeleton.
|
||||
|
||||
## Рекомендуемый следующий practically useful шаг
|
||||
|
||||
Когда будет время на апрувы и ручные проверки, не начинать сразу с большого рефактора. Самый выгодный порядок:
|
||||
|
||||
1. закоммитить UI skeleton и Android bridge;
|
||||
2. проверить Wi-Fi connect к `WiZConfig_xxxx`;
|
||||
3. только потом тратить время на reverse engineering `UDP 18266`.
|
||||
|
||||
Это минимизирует риск закопаться в неизвестный протокол до того, как станет ясно, что platform plumbing вообще работает на конкретном устройстве.
|
||||
|
||||
## Источники
|
||||
|
||||
### WiZ
|
||||
|
||||
- WiZ manual setup / `WiZConfig_xxxx`: https://faq.wizconnected.com/hc/en/3-wiz-legacy/faq/138-adding-a-wiz-light-in-the-system/
|
||||
- WiZ: у части ламп `WiZConfig_xxxx` может отсутствовать: https://faq.wizconnected.com/hc/en/3-wiz-legacy/faq/147-can-t-find-wizconfig-xxxx-in-the-wi-fi-settings-during-manual-setup/
|
||||
- WiZ V2 getting started / Bluetooth-enabled setup: https://faq.wizconnected.com/hc/en/7-wiz-v2/faq/767-smart-lighting---how-to-get-started/
|
||||
- WiZ network configuration / commissioning port `UDP 18266`: https://assets.wizconnected.com/manuals/WiZ-Network-Configuration-v2-01162024.pdf
|
||||
- WiZ Matter support coverage: https://faq.wizconnected.com/hc/en/7-wiz-v2/faq/531-do-all-wiz-devices-support-matter/
|
||||
- WiZ Pro A60 datasheet / `Wi-Fi + BLE`: https://assets.wizconnected.com/datasheets/WiZ_Pro_A60_B22_TW_8W_230V_929002383771_DS1022.pdf
|
||||
- WiZ Pro A67 datasheet / setup via Bluetooth: https://assets.wizconnected.com/datasheets/WiZ_Pro_A67_E27_RGBTW_13W_230V_929002449771_DS042023.pdf
|
||||
|
||||
### Android
|
||||
|
||||
- Wi-Fi bootstrap / `WifiNetworkSpecifier`: https://developer.android.com/develop/connectivity/wifi/wifi-bootstrap
|
||||
- `WifiNetworkSpecifier.Builder` reference: https://developer.android.com/reference/android/net/wifi/WifiNetworkSpecifier.Builder.html
|
||||
|
||||
### Apple
|
||||
|
||||
- Wi-Fi configuration overview: https://developer.apple.com/documentation/networkextension/wi-fi_configuration
|
||||
- `NEHotspotConfigurationManager`: https://developer.apple.com/documentation/networkextension/nehotspotconfigurationmanager
|
||||
|
||||
## Связанные локальные файлы
|
||||
|
||||
- `ignis_app/lib/main.dart`
|
||||
- `ignis_app/lib/screens/homes_screen.dart`
|
||||
- `ignis_app/lib/screens/remote_screen.dart`
|
||||
- `ignis_app/lib/services/api_client.dart`
|
||||
- `ignis_app/android/app/src/main/AndroidManifest.xml`
|
||||
- `ignis_app/android/app/src/main/kotlin/ru/akokos/ignis_app/MainActivity.kt`
|
||||
- `ignis-core/app/api/routes/devices.py`
|
||||
- `ignis-core/README.md`
|
||||
@@ -1,5 +1,9 @@
|
||||
# Launch Screen Assets
|
||||
|
||||
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
|
||||
Этот каталог остался от стандартного Flutter iOS scaffold.
|
||||
|
||||
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
|
||||
Для `ignis_app` iOS сейчас не считается поддерживаемой продуктной платформой, поэтому:
|
||||
|
||||
- launch assets здесь не являются частью активного Android-first контура;
|
||||
- любые изменения этих файлов не влияют на основной пользовательский сценарий проекта;
|
||||
- если когда-нибудь начнётся реальная iOS-поддержка, этот каталог придётся актуализировать отдельно вместе с остальной iOS-конфигурацией.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
class WizProvisioningDevice {
|
||||
final String bssid;
|
||||
final String? ipAddress;
|
||||
|
||||
const WizProvisioningDevice({required this.bssid, this.ipAddress});
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
enum WizProvisioningPermissionStatus { granted, requestable, settingsRequired }
|
||||
|
||||
class WizProvisioningEnvironment {
|
||||
final String platform;
|
||||
final int? androidApiLevel;
|
||||
final bool smartPairingSupported;
|
||||
final bool wifiSettingsSupported;
|
||||
final bool appSettingsSupported;
|
||||
final WizProvisioningPermissionStatus permissionStatus;
|
||||
final bool locationServicesEnabled;
|
||||
final bool connectedToWifi;
|
||||
final String? ssid;
|
||||
final String? bssid;
|
||||
final int? frequencyMhz;
|
||||
|
||||
const WizProvisioningEnvironment({
|
||||
required this.platform,
|
||||
required this.androidApiLevel,
|
||||
required this.smartPairingSupported,
|
||||
required this.wifiSettingsSupported,
|
||||
required this.appSettingsSupported,
|
||||
required this.permissionStatus,
|
||||
required this.locationServicesEnabled,
|
||||
required this.connectedToWifi,
|
||||
required this.ssid,
|
||||
required this.bssid,
|
||||
required this.frequencyMhz,
|
||||
});
|
||||
|
||||
factory WizProvisioningEnvironment.unsupported() =>
|
||||
const WizProvisioningEnvironment(
|
||||
platform: 'unknown',
|
||||
androidApiLevel: null,
|
||||
smartPairingSupported: false,
|
||||
wifiSettingsSupported: false,
|
||||
appSettingsSupported: false,
|
||||
permissionStatus: WizProvisioningPermissionStatus.granted,
|
||||
locationServicesEnabled: true,
|
||||
connectedToWifi: false,
|
||||
ssid: null,
|
||||
bssid: null,
|
||||
frequencyMhz: null,
|
||||
);
|
||||
|
||||
factory WizProvisioningEnvironment.fromMap(Map<String, dynamic> raw) {
|
||||
return WizProvisioningEnvironment(
|
||||
platform: raw['platform'] as String? ?? 'unknown',
|
||||
androidApiLevel: (raw['androidApiLevel'] as num?)?.toInt(),
|
||||
smartPairingSupported: raw['smartPairingSupported'] == true,
|
||||
wifiSettingsSupported: raw['wifiSettingsSupported'] == true,
|
||||
appSettingsSupported: raw['appSettingsSupported'] == true,
|
||||
permissionStatus: _permissionStatusFromPlatformValue(
|
||||
raw['permissionStatus'] as String?,
|
||||
),
|
||||
locationServicesEnabled: raw['locationServicesEnabled'] != false,
|
||||
connectedToWifi: raw['connectedToWifi'] == true,
|
||||
ssid: _normalizeText(raw['ssid']),
|
||||
bssid: _normalizeText(raw['bssid']),
|
||||
frequencyMhz: (raw['frequencyMhz'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
|
||||
bool get permissionsGranted =>
|
||||
permissionStatus == WizProvisioningPermissionStatus.granted;
|
||||
|
||||
bool get permissionRequestable =>
|
||||
permissionStatus == WizProvisioningPermissionStatus.requestable;
|
||||
|
||||
bool get requiresAppSettings =>
|
||||
permissionStatus == WizProvisioningPermissionStatus.settingsRequired;
|
||||
|
||||
bool get isAndroid => platform == 'android';
|
||||
|
||||
bool get isOn24Ghz =>
|
||||
frequencyMhz != null && frequencyMhz! >= 2400 && frequencyMhz! < 2500;
|
||||
|
||||
bool get isLikelyOn5Ghz =>
|
||||
frequencyMhz != null && frequencyMhz! >= 4900 && frequencyMhz! < 6000;
|
||||
|
||||
WizProvisioningEnvironment copyWith({
|
||||
String? platform,
|
||||
int? androidApiLevel,
|
||||
bool? smartPairingSupported,
|
||||
bool? wifiSettingsSupported,
|
||||
bool? appSettingsSupported,
|
||||
WizProvisioningPermissionStatus? permissionStatus,
|
||||
bool? locationServicesEnabled,
|
||||
bool? connectedToWifi,
|
||||
String? ssid,
|
||||
String? bssid,
|
||||
int? frequencyMhz,
|
||||
bool clearWifiInfo = false,
|
||||
}) {
|
||||
return WizProvisioningEnvironment(
|
||||
platform: platform ?? this.platform,
|
||||
androidApiLevel: androidApiLevel ?? this.androidApiLevel,
|
||||
smartPairingSupported:
|
||||
smartPairingSupported ?? this.smartPairingSupported,
|
||||
wifiSettingsSupported:
|
||||
wifiSettingsSupported ?? this.wifiSettingsSupported,
|
||||
appSettingsSupported: appSettingsSupported ?? this.appSettingsSupported,
|
||||
permissionStatus: permissionStatus ?? this.permissionStatus,
|
||||
locationServicesEnabled:
|
||||
locationServicesEnabled ?? this.locationServicesEnabled,
|
||||
connectedToWifi: connectedToWifi ?? this.connectedToWifi,
|
||||
ssid: clearWifiInfo ? null : (ssid ?? this.ssid),
|
||||
bssid: clearWifiInfo ? null : (bssid ?? this.bssid),
|
||||
frequencyMhz: clearWifiInfo ? null : (frequencyMhz ?? this.frequencyMhz),
|
||||
);
|
||||
}
|
||||
|
||||
static WizProvisioningPermissionStatus _permissionStatusFromPlatformValue(
|
||||
String? value,
|
||||
) {
|
||||
switch (value) {
|
||||
case 'granted':
|
||||
return WizProvisioningPermissionStatus.granted;
|
||||
case 'settings_required':
|
||||
return WizProvisioningPermissionStatus.settingsRequired;
|
||||
case 'requestable':
|
||||
default:
|
||||
return WizProvisioningPermissionStatus.requestable;
|
||||
}
|
||||
}
|
||||
|
||||
static String? _normalizeText(Object? raw) {
|
||||
final text = raw as String?;
|
||||
if (text == null) {
|
||||
return null;
|
||||
}
|
||||
final trimmed = text.trim();
|
||||
return trimmed.isEmpty ? null : trimmed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
enum WizProvisioningFailureKind {
|
||||
noActiveHome,
|
||||
unsupportedPlatform,
|
||||
missingPermissions,
|
||||
locationServicesDisabled,
|
||||
wifiUnavailable,
|
||||
invalidSsid,
|
||||
provisioningTimedOut,
|
||||
provisioningFailed,
|
||||
rescanFailed,
|
||||
}
|
||||
|
||||
class WizProvisioningFailure {
|
||||
final WizProvisioningFailureKind kind;
|
||||
final String message;
|
||||
final String? details;
|
||||
|
||||
const WizProvisioningFailure({
|
||||
required this.kind,
|
||||
required this.message,
|
||||
this.details,
|
||||
});
|
||||
}
|
||||
114
lib/features/provisioning/models/wiz_provisioning_state.dart
Normal file
114
lib/features/provisioning/models/wiz_provisioning_state.dart
Normal file
@@ -0,0 +1,114 @@
|
||||
import 'wiz_provisioning_device.dart';
|
||||
import 'wiz_provisioning_environment.dart';
|
||||
import 'wiz_provisioning_failure.dart';
|
||||
|
||||
enum WizProvisioningStatus {
|
||||
initial,
|
||||
loadingEnvironment,
|
||||
attentionRequired,
|
||||
ready,
|
||||
provisioning,
|
||||
rescanning,
|
||||
success,
|
||||
failure,
|
||||
unsupported,
|
||||
}
|
||||
|
||||
class WizRescanSummary {
|
||||
final int found;
|
||||
final int added;
|
||||
final int updated;
|
||||
final int removedOffline;
|
||||
final int pendingRemoval;
|
||||
final int online;
|
||||
|
||||
const WizRescanSummary({
|
||||
required this.found,
|
||||
required this.added,
|
||||
required this.updated,
|
||||
required this.removedOffline,
|
||||
required this.pendingRemoval,
|
||||
required this.online,
|
||||
});
|
||||
|
||||
factory WizRescanSummary.fromMap(Map<String, dynamic> raw) {
|
||||
return WizRescanSummary(
|
||||
found: (raw['found'] as num?)?.toInt() ?? 0,
|
||||
added: (raw['added'] as num?)?.toInt() ?? 0,
|
||||
updated: (raw['updated'] as num?)?.toInt() ?? 0,
|
||||
removedOffline: (raw['removed_offline'] as num?)?.toInt() ?? 0,
|
||||
pendingRemoval: (raw['pending_removal'] as num?)?.toInt() ?? 0,
|
||||
online: (raw['online'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WizProvisioningState {
|
||||
final WizProvisioningStatus status;
|
||||
final WizProvisioningEnvironment environment;
|
||||
final String? activeHomeName;
|
||||
final WizProvisioningFailure? failure;
|
||||
final WizRescanSummary? rescanSummary;
|
||||
final List<WizProvisioningDevice> provisionedDevices;
|
||||
final List<String> timeline;
|
||||
final String? notice;
|
||||
|
||||
const WizProvisioningState({
|
||||
required this.status,
|
||||
required this.environment,
|
||||
required this.activeHomeName,
|
||||
required this.failure,
|
||||
required this.rescanSummary,
|
||||
required this.provisionedDevices,
|
||||
required this.timeline,
|
||||
required this.notice,
|
||||
});
|
||||
|
||||
factory WizProvisioningState.initial() => WizProvisioningState(
|
||||
status: WizProvisioningStatus.initial,
|
||||
environment: WizProvisioningEnvironment.unsupported(),
|
||||
activeHomeName: null,
|
||||
failure: null,
|
||||
rescanSummary: null,
|
||||
provisionedDevices: const [],
|
||||
timeline: const [],
|
||||
notice: null,
|
||||
);
|
||||
|
||||
bool get isBusy =>
|
||||
status == WizProvisioningStatus.loadingEnvironment ||
|
||||
status == WizProvisioningStatus.provisioning ||
|
||||
status == WizProvisioningStatus.rescanning;
|
||||
|
||||
bool get canStart =>
|
||||
status == WizProvisioningStatus.ready ||
|
||||
status == WizProvisioningStatus.failure ||
|
||||
status == WizProvisioningStatus.attentionRequired;
|
||||
|
||||
WizProvisioningState copyWith({
|
||||
WizProvisioningStatus? status,
|
||||
WizProvisioningEnvironment? environment,
|
||||
String? activeHomeName,
|
||||
WizProvisioningFailure? failure,
|
||||
bool clearFailure = false,
|
||||
WizRescanSummary? rescanSummary,
|
||||
bool clearRescanSummary = false,
|
||||
List<WizProvisioningDevice>? provisionedDevices,
|
||||
List<String>? timeline,
|
||||
String? notice,
|
||||
bool clearNotice = false,
|
||||
}) {
|
||||
return WizProvisioningState(
|
||||
status: status ?? this.status,
|
||||
environment: environment ?? this.environment,
|
||||
activeHomeName: activeHomeName ?? this.activeHomeName,
|
||||
failure: clearFailure ? null : (failure ?? this.failure),
|
||||
rescanSummary: clearRescanSummary
|
||||
? null
|
||||
: (rescanSummary ?? this.rescanSummary),
|
||||
provisionedDevices: provisionedDevices ?? this.provisionedDevices,
|
||||
timeline: timeline ?? this.timeline,
|
||||
notice: clearNotice ? null : (notice ?? this.notice),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
class WizProvisioningTiming {
|
||||
final Duration provisioningTimeout;
|
||||
final Duration settleAfterFirstResponse;
|
||||
final Duration initialRescanDelay;
|
||||
final Duration retryRescanDelay;
|
||||
final int maxRescanAttempts;
|
||||
|
||||
const WizProvisioningTiming({
|
||||
this.provisioningTimeout = const Duration(seconds: 45),
|
||||
this.settleAfterFirstResponse = const Duration(seconds: 3),
|
||||
this.initialRescanDelay = const Duration(seconds: 3),
|
||||
this.retryRescanDelay = const Duration(seconds: 4),
|
||||
this.maxRescanAttempts = 3,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../app/error_message.dart';
|
||||
import '../../homes/providers/homes_providers.dart';
|
||||
import '../../remote/providers/remote_providers.dart';
|
||||
import '../../shared/providers/core_providers.dart';
|
||||
import '../models/wiz_provisioning_device.dart';
|
||||
import '../models/wiz_provisioning_environment.dart';
|
||||
import '../models/wiz_provisioning_failure.dart';
|
||||
import '../models/wiz_provisioning_state.dart';
|
||||
import '../models/wiz_provisioning_timing.dart';
|
||||
import '../services/wiz_provisioning_platform_service.dart';
|
||||
import '../services/wiz_smart_pairing_service.dart';
|
||||
|
||||
final wizProvisioningPlatformServiceProvider =
|
||||
Provider<WizProvisioningPlatformService>(
|
||||
(ref) => const DeviceWizProvisioningPlatformService(),
|
||||
);
|
||||
|
||||
final wizSmartPairingServiceProvider = Provider<WizSmartPairingService>(
|
||||
(ref) => EspTouchWizSmartPairingService(),
|
||||
);
|
||||
|
||||
final wizProvisioningTimingProvider = Provider<WizProvisioningTiming>(
|
||||
(ref) => const WizProvisioningTiming(),
|
||||
);
|
||||
|
||||
final wizProvisioningProvider =
|
||||
NotifierProvider<WizProvisioningNotifier, WizProvisioningState>(
|
||||
WizProvisioningNotifier.new,
|
||||
);
|
||||
|
||||
class WizProvisioningNotifier extends Notifier<WizProvisioningState> {
|
||||
WizProvisioningPlatformService get _platform =>
|
||||
ref.read(wizProvisioningPlatformServiceProvider);
|
||||
WizSmartPairingService get _smartPairing =>
|
||||
ref.read(wizSmartPairingServiceProvider);
|
||||
WizProvisioningTiming get _timing => ref.read(wizProvisioningTimingProvider);
|
||||
|
||||
@override
|
||||
WizProvisioningState build() {
|
||||
final smartPairing = ref.watch(wizSmartPairingServiceProvider);
|
||||
ref.onDispose(() {
|
||||
unawaited(smartPairing.stopProvisioning());
|
||||
});
|
||||
return WizProvisioningState.initial();
|
||||
}
|
||||
|
||||
Future<void> initialize() async {
|
||||
final home = ref.read(currentHomeProvider);
|
||||
state = state.copyWith(
|
||||
status: WizProvisioningStatus.loadingEnvironment,
|
||||
activeHomeName: home?.name,
|
||||
clearFailure: true,
|
||||
clearNotice: true,
|
||||
);
|
||||
|
||||
final environment = await _platform.inspectEnvironment();
|
||||
state = state.copyWith(
|
||||
environment: environment,
|
||||
activeHomeName: home?.name,
|
||||
);
|
||||
|
||||
if (home == null) {
|
||||
_setFailure(
|
||||
const WizProvisioningFailure(
|
||||
kind: WizProvisioningFailureKind.noActiveHome,
|
||||
message:
|
||||
'Сначала выберите активный дом. Мастер использует именно его сервер Ignis для финального discovery.',
|
||||
),
|
||||
environment: environment,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!environment.smartPairingSupported || !environment.isAndroid) {
|
||||
state = state.copyWith(
|
||||
status: WizProvisioningStatus.unsupported,
|
||||
failure: const WizProvisioningFailure(
|
||||
kind: WizProvisioningFailureKind.unsupportedPlatform,
|
||||
message:
|
||||
'В этой сборке мастер Smart Pairing поддерживается только на Android.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!environment.permissionsGranted) {
|
||||
state = state.copyWith(
|
||||
status: WizProvisioningStatus.attentionRequired,
|
||||
failure: const WizProvisioningFailure(
|
||||
kind: WizProvisioningFailureKind.missingPermissions,
|
||||
message:
|
||||
'Нужны разрешения на доступ к Wi-Fi окружению, иначе приложение не сможет проверить текущую сеть.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!environment.locationServicesEnabled) {
|
||||
_setFailure(
|
||||
const WizProvisioningFailure(
|
||||
kind: WizProvisioningFailureKind.locationServicesDisabled,
|
||||
message:
|
||||
'На Android системные сервисы геолокации должны быть включены, иначе SSID/BSSID домашней Wi-Fi часто недоступны.',
|
||||
),
|
||||
environment: environment,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!environment.connectedToWifi || environment.ssid == null) {
|
||||
_setFailure(
|
||||
const WizProvisioningFailure(
|
||||
kind: WizProvisioningFailureKind.wifiUnavailable,
|
||||
message:
|
||||
'Сначала подключите телефон к домашней Wi-Fi сети 2.4 GHz, к которой должна присоединиться лампа.',
|
||||
),
|
||||
environment: environment,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final notice = environment.isLikelyOn5Ghz
|
||||
? 'Телефон, похоже, сидит на 5 GHz. WiZ-лампы подключаются только к 2.4 GHz, поэтому лучше переключиться на нужную сеть до старта pairing.'
|
||||
: null;
|
||||
state = state.copyWith(
|
||||
status: WizProvisioningStatus.ready,
|
||||
clearFailure: true,
|
||||
clearRescanSummary: true,
|
||||
provisionedDevices: const [],
|
||||
notice: notice,
|
||||
timeline: _appendTimeline(
|
||||
state.timeline,
|
||||
'Окружение проверено: готово к smart pairing.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> requestPermissions() async {
|
||||
await _platform.requestPermissions();
|
||||
await initialize();
|
||||
}
|
||||
|
||||
Future<void> openWifiSettings() => _platform.openWifiSettings();
|
||||
|
||||
Future<void> openAppSettings() => _platform.openAppSettings();
|
||||
|
||||
Future<void> startProvisioning({
|
||||
required String ssid,
|
||||
required String password,
|
||||
String? bssid,
|
||||
}) async {
|
||||
final normalizedSsid = ssid.trim();
|
||||
if (normalizedSsid.isEmpty) {
|
||||
_setFailure(
|
||||
const WizProvisioningFailure(
|
||||
kind: WizProvisioningFailureKind.invalidSsid,
|
||||
message: 'Укажите SSID домашней Wi-Fi сети.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final home = ref.read(currentHomeProvider);
|
||||
if (home == null) {
|
||||
_setFailure(
|
||||
const WizProvisioningFailure(
|
||||
kind: WizProvisioningFailureKind.noActiveHome,
|
||||
message:
|
||||
'Активный дом потерялся. Вернитесь на экран домов и выберите его заново.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final devices = <WizProvisioningDevice>[];
|
||||
StreamSubscription<WizProvisioningDevice>? subscription;
|
||||
final firstResponse = Completer<void>();
|
||||
|
||||
state = state.copyWith(
|
||||
status: WizProvisioningStatus.provisioning,
|
||||
clearFailure: true,
|
||||
clearNotice: true,
|
||||
clearRescanSummary: true,
|
||||
provisionedDevices: const [],
|
||||
timeline: _appendTimeline(
|
||||
state.timeline,
|
||||
'Старт smart pairing для сети "$normalizedSsid".',
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
final stream = _smartPairing.startProvisioning(
|
||||
ssid: normalizedSsid,
|
||||
password: password,
|
||||
bssid: bssid,
|
||||
);
|
||||
|
||||
subscription = stream.listen(
|
||||
(device) {
|
||||
final duplicate = devices.any((item) => item.bssid == device.bssid);
|
||||
if (duplicate) {
|
||||
return;
|
||||
}
|
||||
devices.add(device);
|
||||
state = state.copyWith(
|
||||
provisionedDevices: List<WizProvisioningDevice>.unmodifiable(
|
||||
devices,
|
||||
),
|
||||
timeline: _appendTimeline(
|
||||
state.timeline,
|
||||
'Лампа подтвердила pairing: ${device.bssid}${device.ipAddress == null ? '' : ' (${device.ipAddress})'}.',
|
||||
),
|
||||
);
|
||||
if (!firstResponse.isCompleted) {
|
||||
firstResponse.complete();
|
||||
}
|
||||
},
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
if (!firstResponse.isCompleted) {
|
||||
firstResponse.completeError(error, stackTrace);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await firstResponse.future.timeout(_timing.provisioningTimeout);
|
||||
await Future<void>.delayed(_timing.settleAfterFirstResponse);
|
||||
await _smartPairing.stopProvisioning();
|
||||
await subscription.cancel();
|
||||
subscription = null;
|
||||
|
||||
state = state.copyWith(
|
||||
status: WizProvisioningStatus.rescanning,
|
||||
timeline: _appendTimeline(
|
||||
state.timeline,
|
||||
'Pairing подтверждён, запускаю повторный discovery на сервере Ignis.',
|
||||
),
|
||||
);
|
||||
|
||||
final summary = await _rescanUntilSettled();
|
||||
final notice = (summary.added == 0 && summary.updated == 0)
|
||||
? 'Лампа ответила на smart pairing, но backend не нашёл новое устройство как added/updated. Возможно, устройство уже было известно или ему нужно чуть больше времени.'
|
||||
: null;
|
||||
|
||||
if (ref.read(groupsLoadStateProvider).status != GroupsLoadStatus.idle) {
|
||||
await ref.read(groupsProvider.notifier).refresh();
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
status: WizProvisioningStatus.success,
|
||||
rescanSummary: summary,
|
||||
notice: notice,
|
||||
timeline: _appendTimeline(
|
||||
state.timeline,
|
||||
'Discovery завершён: found=${summary.found}, added=${summary.added}, updated=${summary.updated}, online=${summary.online}.',
|
||||
),
|
||||
);
|
||||
} on TimeoutException {
|
||||
_setFailure(
|
||||
const WizProvisioningFailure(
|
||||
kind: WizProvisioningFailureKind.provisioningTimedOut,
|
||||
message:
|
||||
'Лампа не ответила вовремя. Проверьте, что она в pairing mode, телефон подключён к 2.4 GHz Wi-Fi, и попробуйте ещё раз.',
|
||||
),
|
||||
);
|
||||
} on WizProvisioningFailure catch (failure) {
|
||||
_setFailure(failure);
|
||||
} catch (error) {
|
||||
final message = describeLoadError(error);
|
||||
_setFailure(
|
||||
WizProvisioningFailure(
|
||||
kind: WizProvisioningFailureKind.provisioningFailed,
|
||||
message: 'Smart pairing завершился ошибкой.',
|
||||
details: message,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await subscription?.cancel();
|
||||
await _smartPairing.stopProvisioning();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelProvisioning({bool keepCurrentState = true}) async {
|
||||
await _smartPairing.stopProvisioning();
|
||||
if (!keepCurrentState) {
|
||||
return;
|
||||
}
|
||||
final fallbackStatus =
|
||||
state.environment.permissionsGranted &&
|
||||
state.environment.locationServicesEnabled &&
|
||||
state.environment.connectedToWifi &&
|
||||
state.activeHomeName != null
|
||||
? WizProvisioningStatus.ready
|
||||
: WizProvisioningStatus.attentionRequired;
|
||||
state = state.copyWith(
|
||||
status: fallbackStatus,
|
||||
notice: 'Текущая сессия pairing остановлена.',
|
||||
timeline: _appendTimeline(state.timeline, 'Сессия pairing отменена.'),
|
||||
);
|
||||
}
|
||||
|
||||
Future<WizRescanSummary> _rescanUntilSettled() async {
|
||||
final api = ref.read(apiProvider);
|
||||
Object? lastError;
|
||||
WizRescanSummary? lastSummary;
|
||||
|
||||
for (var attempt = 0; attempt < _timing.maxRescanAttempts; attempt += 1) {
|
||||
await Future<void>.delayed(
|
||||
attempt == 0 ? _timing.initialRescanDelay : _timing.retryRescanDelay,
|
||||
);
|
||||
|
||||
try {
|
||||
final response = await api.rescanNetwork();
|
||||
lastSummary = WizRescanSummary.fromMap(
|
||||
Map<String, dynamic>.from(response.data as Map),
|
||||
);
|
||||
state = state.copyWith(rescanSummary: lastSummary);
|
||||
if (lastSummary.added > 0 || lastSummary.updated > 0) {
|
||||
return lastSummary;
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastSummary != null) {
|
||||
return lastSummary;
|
||||
}
|
||||
|
||||
throw WizProvisioningFailure(
|
||||
kind: WizProvisioningFailureKind.rescanFailed,
|
||||
message:
|
||||
'Лампа приняла настройки, но финальный discovery на сервере Ignis не удался.',
|
||||
details: lastError == null ? null : describeLoadError(lastError),
|
||||
);
|
||||
}
|
||||
|
||||
void _setFailure(
|
||||
WizProvisioningFailure failure, {
|
||||
WizProvisioningEnvironment? environment,
|
||||
}) {
|
||||
final resolvedEnvironment = environment ?? state.environment;
|
||||
final status =
|
||||
failure.kind == WizProvisioningFailureKind.unsupportedPlatform
|
||||
? WizProvisioningStatus.unsupported
|
||||
: WizProvisioningStatus.failure;
|
||||
state = state.copyWith(
|
||||
status: status,
|
||||
environment: resolvedEnvironment,
|
||||
failure: failure,
|
||||
timeline: _appendTimeline(state.timeline, failure.message),
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _appendTimeline(List<String> current, String event) {
|
||||
return List<String>.unmodifiable(<String>[...current, event]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../models/wiz_provisioning_environment.dart';
|
||||
|
||||
abstract class WizProvisioningPlatformService {
|
||||
Future<WizProvisioningEnvironment> inspectEnvironment();
|
||||
|
||||
Future<void> requestPermissions();
|
||||
|
||||
Future<void> openWifiSettings();
|
||||
|
||||
Future<void> openAppSettings();
|
||||
}
|
||||
|
||||
class DeviceWizProvisioningPlatformService
|
||||
implements WizProvisioningPlatformService {
|
||||
const DeviceWizProvisioningPlatformService();
|
||||
|
||||
static const _channel = MethodChannel('ignis/wiz_provisioning');
|
||||
|
||||
@override
|
||||
Future<WizProvisioningEnvironment> inspectEnvironment() async {
|
||||
try {
|
||||
final raw = await _channel.invokeMapMethod<Object?, Object?>(
|
||||
'getProvisioningEnvironment',
|
||||
);
|
||||
if (raw == null) {
|
||||
return WizProvisioningEnvironment.unsupported();
|
||||
}
|
||||
return WizProvisioningEnvironment.fromMap(Map<String, dynamic>.from(raw));
|
||||
} on MissingPluginException {
|
||||
return WizProvisioningEnvironment.unsupported();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> requestPermissions() async {
|
||||
try {
|
||||
await _channel.invokeMethod<void>('requestProvisioningPermissions');
|
||||
} on MissingPluginException {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> openWifiSettings() async {
|
||||
try {
|
||||
await _channel.invokeMethod<void>('openWifiSettings');
|
||||
} on MissingPluginException {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> openAppSettings() async {
|
||||
try {
|
||||
await _channel.invokeMethod<void>('openAppSettings');
|
||||
} on MissingPluginException {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:esp_smartconfig/esp_smartconfig.dart';
|
||||
|
||||
import '../models/wiz_provisioning_device.dart';
|
||||
|
||||
abstract class WizSmartPairingService {
|
||||
Stream<WizProvisioningDevice> startProvisioning({
|
||||
required String ssid,
|
||||
required String password,
|
||||
String? bssid,
|
||||
});
|
||||
|
||||
Future<void> stopProvisioning();
|
||||
}
|
||||
|
||||
class EspTouchWizSmartPairingService implements WizSmartPairingService {
|
||||
Provisioner? _provisioner;
|
||||
StreamSubscription<ProvisioningResponse>? _subscription;
|
||||
StreamController<WizProvisioningDevice>? _controller;
|
||||
|
||||
@override
|
||||
Stream<WizProvisioningDevice> startProvisioning({
|
||||
required String ssid,
|
||||
required String password,
|
||||
String? bssid,
|
||||
}) {
|
||||
if (_provisioner != null || _controller != null) {
|
||||
throw StateError('Provisioning is already running');
|
||||
}
|
||||
|
||||
final provisioner = Provisioner.espTouch();
|
||||
final controller = StreamController<WizProvisioningDevice>.broadcast();
|
||||
_provisioner = provisioner;
|
||||
_controller = controller;
|
||||
|
||||
_subscription = provisioner.listen(
|
||||
(response) {
|
||||
controller.add(
|
||||
WizProvisioningDevice(
|
||||
bssid: response.bssidText,
|
||||
ipAddress: response.ipAddressText,
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: controller.addError,
|
||||
onDone: () async {
|
||||
if (!controller.isClosed) {
|
||||
await controller.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
final request = ProvisioningRequest.fromStrings(
|
||||
ssid: ssid,
|
||||
bssid: (bssid == null || bssid.trim().isEmpty)
|
||||
? '00:00:00:00:00:00'
|
||||
: bssid.trim(),
|
||||
password: password.isEmpty ? null : password,
|
||||
);
|
||||
|
||||
unawaited(
|
||||
provisioner.start(request).catchError((
|
||||
Object error,
|
||||
StackTrace stack,
|
||||
) async {
|
||||
if (!controller.isClosed) {
|
||||
controller.addError(error, stack);
|
||||
await controller.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stopProvisioning() async {
|
||||
final provisioner = _provisioner;
|
||||
_provisioner = null;
|
||||
|
||||
try {
|
||||
provisioner?.stop();
|
||||
} finally {
|
||||
await _subscription?.cancel();
|
||||
_subscription = null;
|
||||
if (_controller != null && !_controller!.isClosed) {
|
||||
await _controller!.close();
|
||||
}
|
||||
_controller = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export '../features/api_keys/providers/api_keys_providers.dart';
|
||||
export '../features/auth/providers/auth_providers.dart';
|
||||
export '../features/homes/providers/homes_providers.dart';
|
||||
export '../features/homes/providers/location_providers.dart';
|
||||
export '../features/provisioning/providers/wiz_provisioning_providers.dart';
|
||||
export '../features/remote/providers/remote_providers.dart';
|
||||
export '../features/schedules/providers/tasks_providers.dart';
|
||||
export '../features/shared/providers/core_providers.dart';
|
||||
|
||||
@@ -80,39 +80,19 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
|
||||
}
|
||||
|
||||
Future<void> _rescan() async {
|
||||
final beforeIds = ref
|
||||
.read(devicesProvider)
|
||||
.data
|
||||
.map((device) => device.groupMemberId)
|
||||
.toSet();
|
||||
|
||||
setState(() => _rescanning = true);
|
||||
try {
|
||||
await ref.read(apiProvider).rescanNetwork();
|
||||
|
||||
var changed = false;
|
||||
for (var attempt = 0; attempt < 6; attempt++) {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
final response = await ref.read(apiProvider).rescanNetwork();
|
||||
await ref.read(devicesProvider.notifier).load();
|
||||
final currentIds = ref
|
||||
.read(devicesProvider)
|
||||
.data
|
||||
.map((device) => device.groupMemberId)
|
||||
.toSet();
|
||||
if (!_sameSet(beforeIds, currentIds)) {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
final summary = response.data is Map
|
||||
? Map<String, dynamic>.from(response.data as Map)
|
||||
: const <String, dynamic>{};
|
||||
final message = formatRescanSummary(summary);
|
||||
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
changed
|
||||
? 'Список устройств обновился'
|
||||
: 'Сканирование завершилось, но новых устройств пока не видно',
|
||||
),
|
||||
content: Text(message),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -427,17 +407,31 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
bool _sameSet(Set<String> left, Set<String> right) {
|
||||
if (left.length != right.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int _summaryInt(Map<String, dynamic> summary, String key) {
|
||||
final value = summary[key];
|
||||
if (value is int) {
|
||||
return value;
|
||||
}
|
||||
for (final item in left) {
|
||||
if (!right.contains(item)) {
|
||||
return false;
|
||||
return int.tryParse(value?.toString() ?? '') ?? 0;
|
||||
}
|
||||
|
||||
String formatRescanSummary(Map<String, dynamic> summary) {
|
||||
final found = _summaryInt(summary, 'found');
|
||||
final added = _summaryInt(summary, 'added');
|
||||
final updated = _summaryInt(summary, 'updated');
|
||||
final removed = _summaryInt(summary, 'removed_offline');
|
||||
|
||||
if (added == 0 && updated == 0 && removed == 0 && found == 0) {
|
||||
return 'Сканирование завершено: устройства не найдены';
|
||||
}
|
||||
|
||||
if (added == 0 && removed == 0) {
|
||||
return 'Сканирование завершено: найдено $found, обновлено $updated';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return 'Сканирование завершено: найдено $found, новых $added, обновлено $updated, убрано $removed';
|
||||
}
|
||||
|
||||
class _ConflictBanner extends StatelessWidget {
|
||||
|
||||
@@ -25,6 +25,7 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
final _lonCtrl = TextEditingController();
|
||||
bool _saving = false;
|
||||
bool _loadingApiKey = false;
|
||||
bool _hasStoredApiKey = false;
|
||||
String _originalApiKey = '';
|
||||
|
||||
bool get _isEdit => widget.home != null;
|
||||
@@ -59,11 +60,11 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
final apiKey = await ref
|
||||
.read(settingsServiceProvider)
|
||||
.getHomeApiKey(widget.home!.id);
|
||||
_originalApiKey = apiKey ?? '';
|
||||
_originalApiKey = apiKey?.trim() ?? '';
|
||||
_hasStoredApiKey = _originalApiKey.isNotEmpty;
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_keyCtrl.text = _originalApiKey;
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _loadingApiKey = false);
|
||||
@@ -144,14 +145,24 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
controller: _keyCtrl,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'API Key',
|
||||
hintText: _hasStoredApiKey
|
||||
? 'Оставьте пустым, чтобы сохранить текущий ключ'
|
||||
: null,
|
||||
helperText: _loadingApiKey
|
||||
? 'Загружаем сохранённый ключ...'
|
||||
: 'Ключ проверяется только при изменении подключения',
|
||||
: _hasStoredApiKey
|
||||
? 'Сохранённый ключ хранится отдельно и не показывается в поле'
|
||||
: 'Ключ хранится отдельно в защищённом хранилище',
|
||||
prefixIcon: const Icon(Icons.key),
|
||||
),
|
||||
obscureText: true,
|
||||
validator: (value) =>
|
||||
(value?.trim().isEmpty ?? true) ? 'Укажите API key' : null,
|
||||
validator: (value) {
|
||||
final enteredKey = value?.trim() ?? '';
|
||||
if (enteredKey.isNotEmpty || _hasStoredApiKey) {
|
||||
return null;
|
||||
}
|
||||
return 'Укажите API key';
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
@@ -285,7 +296,8 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
|
||||
final name = _nameCtrl.text.trim();
|
||||
final rawUrl = _urlCtrl.text.trim();
|
||||
final key = _keyCtrl.text.trim();
|
||||
final enteredKey = _keyCtrl.text.trim();
|
||||
final key = enteredKey.isNotEmpty ? enteredKey : _originalApiKey;
|
||||
final latText = _latCtrl.text.trim();
|
||||
final lonText = _lonCtrl.text.trim();
|
||||
|
||||
@@ -370,7 +382,7 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
|
||||
if (_isEdit) {
|
||||
await ref
|
||||
.read(homesProvider.notifier)
|
||||
.update(home, apiKey: credentialsChanged ? key : null);
|
||||
.update(home, apiKey: enteredKey.isNotEmpty ? key : null);
|
||||
} else {
|
||||
await ref.read(homesProvider.notifier).add(home, apiKey: key);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../models/home_config.dart';
|
||||
import '../providers/providers.dart';
|
||||
import 'home_edit_screen.dart';
|
||||
import 'homes_screen.dart';
|
||||
import 'wiz_provisioning_screen.dart';
|
||||
|
||||
enum SettingsEntryPoint { homes, remote }
|
||||
|
||||
@@ -90,6 +91,15 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen>
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const WizProvisioningScreen(),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.lightbulb_outline),
|
||||
label: const Text('Подключить WiZ-лампу'),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => _openHomeEditor(context, currentHome),
|
||||
icon: const Icon(Icons.edit_location_alt_outlined),
|
||||
|
||||
531
lib/screens/wiz_provisioning_screen.dart
Normal file
531
lib/screens/wiz_provisioning_screen.dart
Normal file
@@ -0,0 +1,531 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../features/provisioning/models/wiz_provisioning_environment.dart';
|
||||
import '../features/provisioning/models/wiz_provisioning_state.dart';
|
||||
import '../features/provisioning/providers/wiz_provisioning_providers.dart';
|
||||
|
||||
class WizProvisioningScreen extends ConsumerStatefulWidget {
|
||||
const WizProvisioningScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<WizProvisioningScreen> createState() =>
|
||||
_WizProvisioningScreenState();
|
||||
}
|
||||
|
||||
class _WizProvisioningScreenState extends ConsumerState<WizProvisioningScreen>
|
||||
with WidgetsBindingObserver {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _ssidCtrl = TextEditingController();
|
||||
final _bssidCtrl = TextEditingController();
|
||||
final _passwordCtrl = TextEditingController();
|
||||
|
||||
bool _ssidTouched = false;
|
||||
bool _bssidTouched = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
Future<void>.microtask(
|
||||
() => ref.read(wizProvisioningProvider.notifier).initialize(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
ref
|
||||
.read(wizProvisioningProvider.notifier)
|
||||
.cancelProvisioning(keepCurrentState: false);
|
||||
ref.invalidate(wizProvisioningProvider);
|
||||
_ssidCtrl.dispose();
|
||||
_bssidCtrl.dispose();
|
||||
_passwordCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
Future<void>.microtask(
|
||||
() => ref.read(wizProvisioningProvider.notifier).initialize(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provisioningState = ref.watch(wizProvisioningProvider);
|
||||
_syncControllers(provisioningState.environment);
|
||||
final bottomInset = MediaQuery.paddingOf(context).bottom;
|
||||
|
||||
final environment = provisioningState.environment;
|
||||
final failure = provisioningState.failure;
|
||||
final canRequestPermissions =
|
||||
!environment.permissionsGranted && environment.permissionRequestable;
|
||||
final canOpenAppSettings =
|
||||
environment.requiresAppSettings && environment.appSettingsSupported;
|
||||
final needsWifiSettings =
|
||||
!environment.connectedToWifi && environment.wifiSettingsSupported;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('ПОДКЛЮЧЕНИЕ WIZ')),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
bottom: true,
|
||||
child: RefreshIndicator(
|
||||
color: Colors.deepOrange,
|
||||
onRefresh: () =>
|
||||
ref.read(wizProvisioningProvider.notifier).initialize(),
|
||||
child: ListView(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, bottomInset + 24),
|
||||
children: [
|
||||
_SectionCard(
|
||||
title: 'Что делает мастер',
|
||||
child: const Text(
|
||||
'Эта версия использует smart pairing: телефон остаётся в домашней Wi-Fi сети и передаёт её настройки новой лампе. Это Android-only поток и он лучше всего работает, когда телефон уже сидит на 2.4 GHz.',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
),
|
||||
),
|
||||
_SectionCard(
|
||||
title: 'Активный дом',
|
||||
child: Text(
|
||||
provisioningState.activeHomeName == null
|
||||
? 'Не выбран'
|
||||
: provisioningState.activeHomeName!,
|
||||
style: TextStyle(
|
||||
color: provisioningState.activeHomeName == null
|
||||
? Colors.redAccent
|
||||
: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
_SectionCard(
|
||||
title: 'Окружение',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_InfoRow(
|
||||
label: 'Платформа',
|
||||
value: environment.isAndroid
|
||||
? 'Android ${environment.androidApiLevel ?? '?'}'
|
||||
: environment.platform,
|
||||
),
|
||||
_InfoRow(
|
||||
label: 'Разрешения',
|
||||
value: _permissionStatusLabel(
|
||||
environment.permissionStatus,
|
||||
),
|
||||
),
|
||||
_InfoRow(
|
||||
label: 'Wi-Fi',
|
||||
value: environment.connectedToWifi
|
||||
? (environment.ssid ?? 'Подключено')
|
||||
: 'Нет подключения',
|
||||
),
|
||||
_InfoRow(
|
||||
label: 'BSSID',
|
||||
value: environment.bssid ?? 'Не удалось определить',
|
||||
),
|
||||
_InfoRow(
|
||||
label: 'Диапазон',
|
||||
value: environment.frequencyMhz == null
|
||||
? 'Неизвестно'
|
||||
: '${environment.frequencyMhz} MHz',
|
||||
),
|
||||
if (environment.isLikelyOn5Ghz)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
'Сейчас похоже активен 5 GHz. Для WiZ лучше заранее переключиться на 2.4 GHz.',
|
||||
style: TextStyle(color: Colors.amberAccent),
|
||||
),
|
||||
),
|
||||
if (!environment.locationServicesEnabled)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
'На Android системная геолокация должна быть включена, иначе SSID/BSSID часто скрываются системой.',
|
||||
style: TextStyle(color: Colors.amberAccent),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (failure != null)
|
||||
_SectionCard(
|
||||
title: 'Проблема',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
failure.message,
|
||||
style: const TextStyle(color: Colors.redAccent),
|
||||
),
|
||||
if (failure.details != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
failure.details!,
|
||||
style: const TextStyle(color: Colors.white54),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (provisioningState.notice != null)
|
||||
_SectionCard(
|
||||
title: 'Примечание',
|
||||
child: Text(
|
||||
provisioningState.notice!,
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
),
|
||||
_SectionCard(
|
||||
title: 'Шаги перед стартом',
|
||||
child: const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'1. Убедитесь, что телефон подключён к домашней 2.4 GHz Wi-Fi.',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
Text(
|
||||
'2. Переведите лампу в pairing mode: если нужно, несколько раз выключите и включите питание до пульсации.',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
Text(
|
||||
'3. Держите телефон рядом с лампой и не сворачивайте приложение до конца pairing.',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_SectionCard(
|
||||
title: 'Домашняя Wi-Fi',
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
child: Column(
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _ssidCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'SSID',
|
||||
hintText: 'Например: Home-2G',
|
||||
prefixIcon: Icon(Icons.wifi),
|
||||
),
|
||||
onChanged: (_) => _ssidTouched = true,
|
||||
validator: (value) {
|
||||
if ((value?.trim().isEmpty ?? true)) {
|
||||
return 'Укажите SSID';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _bssidCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'BSSID (опционально)',
|
||||
hintText: 'aa:bb:cc:dd:ee:ff',
|
||||
prefixIcon: Icon(Icons.router_outlined),
|
||||
),
|
||||
onChanged: (_) => _bssidTouched = true,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _passwordCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Пароль Wi-Fi',
|
||||
hintText: 'Оставьте пустым для открытой сети',
|
||||
prefixIcon: Icon(Icons.key),
|
||||
),
|
||||
obscureText: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
_ActionSection(
|
||||
state: provisioningState,
|
||||
canRequestPermissions: canRequestPermissions,
|
||||
canOpenAppSettings: canOpenAppSettings,
|
||||
needsWifiSettings: needsWifiSettings,
|
||||
onRequestPermissions: () => ref
|
||||
.read(wizProvisioningProvider.notifier)
|
||||
.requestPermissions(),
|
||||
onOpenAppSettings: () => ref
|
||||
.read(wizProvisioningProvider.notifier)
|
||||
.openAppSettings(),
|
||||
onOpenWifiSettings: () => ref
|
||||
.read(wizProvisioningProvider.notifier)
|
||||
.openWifiSettings(),
|
||||
onRefresh: () =>
|
||||
ref.read(wizProvisioningProvider.notifier).initialize(),
|
||||
onStart: _startProvisioning,
|
||||
onCancel: () => ref
|
||||
.read(wizProvisioningProvider.notifier)
|
||||
.cancelProvisioning(),
|
||||
),
|
||||
if (provisioningState.provisionedDevices.isNotEmpty)
|
||||
_SectionCard(
|
||||
title: 'Ответившие устройства',
|
||||
child: Column(
|
||||
children: [
|
||||
for (final device in provisioningState.provisionedDevices)
|
||||
ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(
|
||||
Icons.lightbulb_outline,
|
||||
color: Colors.deepOrange,
|
||||
),
|
||||
title: Text(device.bssid),
|
||||
subtitle: device.ipAddress == null
|
||||
? null
|
||||
: Text(device.ipAddress!),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (provisioningState.rescanSummary != null)
|
||||
_SectionCard(
|
||||
title: 'Результат discovery',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_InfoRow(
|
||||
label: 'Найдено',
|
||||
value: '${provisioningState.rescanSummary!.found}',
|
||||
),
|
||||
_InfoRow(
|
||||
label: 'Добавлено',
|
||||
value: '${provisioningState.rescanSummary!.added}',
|
||||
),
|
||||
_InfoRow(
|
||||
label: 'Обновлено',
|
||||
value: '${provisioningState.rescanSummary!.updated}',
|
||||
),
|
||||
_InfoRow(
|
||||
label: 'Онлайн',
|
||||
value: '${provisioningState.rescanSummary!.online}',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (provisioningState.timeline.isNotEmpty)
|
||||
_SectionCard(
|
||||
title: 'Ход выполнения',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final event in provisioningState.timeline)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
'• $event',
|
||||
style: const TextStyle(color: Colors.white60),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _syncControllers(WizProvisioningEnvironment environment) {
|
||||
if (!_ssidTouched &&
|
||||
environment.ssid != null &&
|
||||
environment.ssid != _ssidCtrl.text) {
|
||||
_ssidCtrl.text = environment.ssid!;
|
||||
}
|
||||
|
||||
if (!_bssidTouched &&
|
||||
environment.bssid != null &&
|
||||
environment.bssid != _bssidCtrl.text) {
|
||||
_bssidCtrl.text = environment.bssid!;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startProvisioning() async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref
|
||||
.read(wizProvisioningProvider.notifier)
|
||||
.startProvisioning(
|
||||
ssid: _ssidCtrl.text,
|
||||
password: _passwordCtrl.text,
|
||||
bssid: _bssidCtrl.text.trim().isEmpty ? null : _bssidCtrl.text,
|
||||
);
|
||||
}
|
||||
|
||||
String _permissionStatusLabel(WizProvisioningPermissionStatus status) {
|
||||
switch (status) {
|
||||
case WizProvisioningPermissionStatus.granted:
|
||||
return 'Выданы';
|
||||
case WizProvisioningPermissionStatus.requestable:
|
||||
return 'Нужно запросить';
|
||||
case WizProvisioningPermissionStatus.settingsRequired:
|
||||
return 'Нужно открыть настройки приложения';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionSection extends StatelessWidget {
|
||||
final WizProvisioningState state;
|
||||
final bool canRequestPermissions;
|
||||
final bool canOpenAppSettings;
|
||||
final bool needsWifiSettings;
|
||||
final VoidCallback onRequestPermissions;
|
||||
final VoidCallback onOpenAppSettings;
|
||||
final VoidCallback onOpenWifiSettings;
|
||||
final VoidCallback onRefresh;
|
||||
final VoidCallback onStart;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
const _ActionSection({
|
||||
required this.state,
|
||||
required this.canRequestPermissions,
|
||||
required this.canOpenAppSettings,
|
||||
required this.needsWifiSettings,
|
||||
required this.onRequestPermissions,
|
||||
required this.onOpenAppSettings,
|
||||
required this.onOpenWifiSettings,
|
||||
required this.onRefresh,
|
||||
required this.onStart,
|
||||
required this.onCancel,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final canStartProvisioning =
|
||||
!state.isBusy &&
|
||||
state.activeHomeName != null &&
|
||||
state.environment.permissionsGranted &&
|
||||
state.environment.locationServicesEnabled &&
|
||||
state.environment.connectedToWifi &&
|
||||
state.status != WizProvisioningStatus.unsupported;
|
||||
|
||||
return _SectionCard(
|
||||
title: 'Действия',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (state.isBusy)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 12),
|
||||
child: LinearProgressIndicator(color: Colors.deepOrange),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: canStartProvisioning ? onStart : null,
|
||||
icon: const Icon(Icons.flash_on),
|
||||
label: Text(
|
||||
state.status == WizProvisioningStatus.success
|
||||
? 'Повторить pairing'
|
||||
: 'Запустить smart pairing',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: state.isBusy ? onCancel : onRefresh,
|
||||
icon: Icon(
|
||||
state.isBusy ? Icons.stop_circle_outlined : Icons.refresh,
|
||||
),
|
||||
label: Text(state.isBusy ? 'Остановить' : 'Переобновить окружение'),
|
||||
),
|
||||
if (canRequestPermissions) ...[
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: state.isBusy ? null : onRequestPermissions,
|
||||
icon: const Icon(Icons.privacy_tip_outlined),
|
||||
label: const Text('Выдать разрешения'),
|
||||
),
|
||||
],
|
||||
if (canOpenAppSettings) ...[
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: state.isBusy ? null : onOpenAppSettings,
|
||||
icon: const Icon(Icons.settings_applications_outlined),
|
||||
label: const Text('Открыть настройки приложения'),
|
||||
),
|
||||
],
|
||||
if (needsWifiSettings) ...[
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: state.isBusy ? null : onOpenWifiSettings,
|
||||
icon: const Icon(Icons.wifi_find_outlined),
|
||||
label: const Text('Открыть настройки Wi-Fi'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionCard extends StatelessWidget {
|
||||
final String title;
|
||||
final Widget child;
|
||||
|
||||
const _SectionCard({required this.title, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
child,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const _InfoRow({required this.label, required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 112,
|
||||
child: Text(label, style: const TextStyle(color: Colors.white38)),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(value, style: const TextStyle(color: Colors.white70)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ import 'package:dio/dio.dart';
|
||||
/// HTTP-клиент для одного сервера Ignis.
|
||||
/// Покрывает все эндпоинты из openapi.json.
|
||||
class IgnisApi {
|
||||
final Dio _dio = Dio();
|
||||
IgnisApi({Dio? dio}) : _dio = dio ?? Dio();
|
||||
|
||||
final Dio _dio;
|
||||
Dio get dioInstance => _dio;
|
||||
|
||||
static String normalizeBaseUrl(String baseUrl) {
|
||||
@@ -70,11 +72,11 @@ class IgnisApi {
|
||||
|
||||
/// Управление группой: state, brightness, temp, scene, r/g/b
|
||||
Future<Response> controlGroup(String id, Map<String, dynamic> params) =>
|
||||
_dio.post('/control/group/$id', queryParameters: params);
|
||||
_dio.post('/control/group/$id', data: params);
|
||||
|
||||
/// Управление одной лампой
|
||||
Future<Response> controlDevice(String id, Map<String, dynamic> params) =>
|
||||
_dio.post('/control/device/$id', queryParameters: params);
|
||||
_dio.post('/control/device/$id', data: params);
|
||||
|
||||
/// Мигнуть лампой (для идентификации)
|
||||
Future<Response> blinkDevice(String id) =>
|
||||
@@ -92,11 +94,11 @@ class IgnisApi {
|
||||
|
||||
/// Одноразовое расписание (таймер)
|
||||
Future<Response> scheduleOnce(Map<String, dynamic> params) =>
|
||||
_dio.post('/schedules/once', queryParameters: params);
|
||||
_dio.post('/schedules/once', data: params);
|
||||
|
||||
/// Cron-расписание (повторяющееся)
|
||||
Future<Response> scheduleCron(Map<String, dynamic> params) =>
|
||||
_dio.post('/schedules/cron', queryParameters: params);
|
||||
_dio.post('/schedules/cron', data: params);
|
||||
|
||||
/// Все активные задачи расписания
|
||||
Future<Response> getTasks() => _dio.get('/schedules/tasks');
|
||||
|
||||
16
pubspec.lock
16
pubspec.lock
@@ -153,6 +153,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
esp_smartconfig:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: esp_smartconfig
|
||||
sha256: "43799fc5bbdbde18d6c4a7a8ab48b7042878154d792ab5815c5473759d4f575e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -584,6 +592,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pointycastle:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pointycastle
|
||||
sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
pool:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -39,6 +39,7 @@ dependencies:
|
||||
shared_preferences: ^2.5.5
|
||||
geolocator: ^13.0.2
|
||||
flutter_secure_storage: ^10.0.0
|
||||
esp_smartconfig: ^3.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
86
test/api_client_test.dart
Normal file
86
test/api_client_test.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ignis_app/services/api_client.dart';
|
||||
|
||||
class RecordingAdapter implements HttpClientAdapter {
|
||||
RequestOptions? lastRequest;
|
||||
|
||||
@override
|
||||
Future<ResponseBody> fetch(
|
||||
RequestOptions options,
|
||||
Stream<Uint8List>? requestStream,
|
||||
Future<void>? cancelFuture,
|
||||
) async {
|
||||
lastRequest = options;
|
||||
return ResponseBody.fromString(
|
||||
'{}',
|
||||
200,
|
||||
headers: {
|
||||
Headers.contentTypeHeader: ['application/json'],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('controlGroup sends command payload in request body', () async {
|
||||
final adapter = RecordingAdapter();
|
||||
final dio = Dio()..httpClientAdapter = adapter;
|
||||
final api = IgnisApi(dio: dio)..init('http://localhost:8000', 'secret');
|
||||
|
||||
await api.controlGroup('kitchen', {'state': true, 'brightness': 42});
|
||||
|
||||
expect(adapter.lastRequest, isNotNull);
|
||||
expect(adapter.lastRequest?.path, '/control/group/kitchen');
|
||||
expect(adapter.lastRequest?.queryParameters, isEmpty);
|
||||
expect(adapter.lastRequest?.data, {'state': true, 'brightness': 42});
|
||||
});
|
||||
|
||||
test('scheduleOnce sends schedule payload in request body', () async {
|
||||
final adapter = RecordingAdapter();
|
||||
final dio = Dio()..httpClientAdapter = adapter;
|
||||
final api = IgnisApi(dio: dio)..init('http://localhost:8000', 'secret');
|
||||
|
||||
await api.scheduleOnce({
|
||||
'target_id': 'hall',
|
||||
'hours_from_now': 4,
|
||||
'state': false,
|
||||
'is_group': true,
|
||||
});
|
||||
|
||||
expect(adapter.lastRequest, isNotNull);
|
||||
expect(adapter.lastRequest?.path, '/schedules/once');
|
||||
expect(adapter.lastRequest?.queryParameters, isEmpty);
|
||||
expect(adapter.lastRequest?.data, {
|
||||
'target_id': 'hall',
|
||||
'hours_from_now': 4,
|
||||
'state': false,
|
||||
'is_group': true,
|
||||
});
|
||||
});
|
||||
|
||||
test(
|
||||
'createApiKey keeps query-based contract until backend is changed',
|
||||
() async {
|
||||
final adapter = RecordingAdapter();
|
||||
final dio = Dio()..httpClientAdapter = adapter;
|
||||
final api = IgnisApi(dio: dio)..init('http://localhost:8000', 'secret');
|
||||
|
||||
await api.createApiKey('Guest', isAdmin: true);
|
||||
|
||||
expect(adapter.lastRequest, isNotNull);
|
||||
expect(adapter.lastRequest?.path, '/api-keys');
|
||||
expect(adapter.lastRequest?.queryParameters, {
|
||||
'name': 'Guest',
|
||||
'is_admin': true,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -82,6 +82,41 @@ void main() {
|
||||
expect(api.createdGroupMacs, ['AA:BB']);
|
||||
});
|
||||
|
||||
testWidgets('group edit screen shows backend rescan summary', (tester) async {
|
||||
final api =
|
||||
FakeIgnisApi(
|
||||
devicesData: {
|
||||
'devices': [
|
||||
{'mac': 'AA:BB', 'name': 'Лампа 1'},
|
||||
],
|
||||
},
|
||||
groupsData: <Object>[],
|
||||
)
|
||||
..rescanNetworkData = {
|
||||
'status': 'ok',
|
||||
'found': 3,
|
||||
'added': 1,
|
||||
'updated': 2,
|
||||
'removed_offline': 1,
|
||||
'pending_removal': 0,
|
||||
'online': 3,
|
||||
};
|
||||
|
||||
await pumpTestApp(tester, child: const GroupEditScreen(), api: api);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byTooltip('Пересканировать сеть'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text(
|
||||
'Сканирование завершено: найдено 3, новых 1, обновлено 2, убрано 1',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(api.rescanCalls, 1);
|
||||
});
|
||||
|
||||
testWidgets('api keys screen validates and shows created key banner', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -195,4 +230,57 @@ void main() {
|
||||
);
|
||||
expect(savedApiKey, 'secret-key');
|
||||
});
|
||||
|
||||
testWidgets('home edit screen keeps stored api key hidden on edit', (
|
||||
tester,
|
||||
) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final settingsService = SettingsService(
|
||||
credentialsStorage: InMemoryCredentialsStorage(),
|
||||
);
|
||||
final existingHome = HomeConfig(
|
||||
id: 'home-1',
|
||||
name: 'Квартира',
|
||||
url: 'https://ignis.akokos.ru',
|
||||
);
|
||||
await settingsService.upsertHome(existingHome, apiKey: 'stored-secret');
|
||||
|
||||
final api = FakeIgnisApi(authData: {'is_admin': true, 'name': 'owner'});
|
||||
|
||||
await pumpTestApp(
|
||||
tester,
|
||||
api: api,
|
||||
settingsService: settingsService,
|
||||
child: HomeEditScreen(home: existingHome),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final keyField = tester.widget<EditableText>(
|
||||
find.byWidgetPredicate(
|
||||
(widget) => widget is EditableText && widget.obscureText,
|
||||
),
|
||||
);
|
||||
expect(keyField.controller.text, isEmpty);
|
||||
expect(
|
||||
find.text('Сохранённый ключ хранится отдельно и не показывается в поле'),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Адрес сервера'),
|
||||
'ignis.example.com',
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.widgetWithText(ElevatedButton, 'СОХРАНИТЬ'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(api.validateCredentialsCalls, 1);
|
||||
expect(api.validatedBaseUrl, 'https://ignis.example.com');
|
||||
expect(api.validatedApiKey, 'stored-secret');
|
||||
expect(
|
||||
await settingsService.getHomeApiKey(existingHome.id),
|
||||
'stored-secret',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ class FakeIgnisApi extends IgnisApi {
|
||||
Object? eventLogData;
|
||||
Object? apiKeysData;
|
||||
Object? authData;
|
||||
Object? rescanNetworkData;
|
||||
|
||||
Object? devicesError;
|
||||
Object? scenesError;
|
||||
@@ -72,6 +73,9 @@ class FakeIgnisApi extends IgnisApi {
|
||||
String? deletedGroupId;
|
||||
Map<String, dynamic>? scheduledOnceParams;
|
||||
Map<String, dynamic>? scheduledCronParams;
|
||||
String? validatedBaseUrl;
|
||||
String? validatedApiKey;
|
||||
int validateCredentialsCalls = 0;
|
||||
int rescanCalls = 0;
|
||||
|
||||
FakeIgnisApi({
|
||||
@@ -98,6 +102,9 @@ class FakeIgnisApi extends IgnisApi {
|
||||
|
||||
@override
|
||||
Future<void> validateCredentials(String baseUrl, String apiKey) async {
|
||||
validateCredentialsCalls += 1;
|
||||
validatedBaseUrl = baseUrl;
|
||||
validatedApiKey = apiKey;
|
||||
final error = authError;
|
||||
if (error != null) throw error;
|
||||
}
|
||||
@@ -329,7 +336,17 @@ class FakeIgnisApi extends IgnisApi {
|
||||
if (error != null) throw error;
|
||||
return Response(
|
||||
requestOptions: RequestOptions(path: '/devices/rescan'),
|
||||
data: <String, dynamic>{'ok': true},
|
||||
data:
|
||||
rescanNetworkData ??
|
||||
<String, dynamic>{
|
||||
'status': 'ok',
|
||||
'found': 0,
|
||||
'added': 0,
|
||||
'updated': 0,
|
||||
'removed_offline': 0,
|
||||
'pending_removal': 0,
|
||||
'online': 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
218
test/wiz_provisioning_notifier_test.dart
Normal file
218
test/wiz_provisioning_notifier_test.dart
Normal file
@@ -0,0 +1,218 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ignis_app/features/homes/providers/homes_providers.dart';
|
||||
import 'package:ignis_app/features/provisioning/models/wiz_provisioning_device.dart';
|
||||
import 'package:ignis_app/features/provisioning/models/wiz_provisioning_environment.dart';
|
||||
import 'package:ignis_app/features/provisioning/models/wiz_provisioning_failure.dart';
|
||||
import 'package:ignis_app/features/provisioning/models/wiz_provisioning_state.dart';
|
||||
import 'package:ignis_app/features/provisioning/models/wiz_provisioning_timing.dart';
|
||||
import 'package:ignis_app/features/provisioning/providers/wiz_provisioning_providers.dart';
|
||||
import 'package:ignis_app/features/provisioning/services/wiz_provisioning_platform_service.dart';
|
||||
import 'package:ignis_app/features/provisioning/services/wiz_smart_pairing_service.dart';
|
||||
import 'package:ignis_app/features/shared/providers/core_providers.dart';
|
||||
import 'package:ignis_app/models/home_config.dart';
|
||||
|
||||
import 'test_support.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('initialize reports missing active home', () async {
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
wizProvisioningPlatformServiceProvider.overrideWithValue(
|
||||
FakeWizProvisioningPlatformService(environment: _readyEnvironment()),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(wizProvisioningProvider.notifier).initialize();
|
||||
|
||||
final state = container.read(wizProvisioningProvider);
|
||||
expect(state.status, WizProvisioningStatus.failure);
|
||||
expect(state.failure?.kind, WizProvisioningFailureKind.noActiveHome);
|
||||
});
|
||||
|
||||
test(
|
||||
'initialize asks for permissions when Wi-Fi access is not granted',
|
||||
() async {
|
||||
final home = HomeConfig(id: 'home-1', name: 'Дом', url: 'https://ignis');
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
currentHomeProvider.overrideWith(() => FakeCurrentHomeNotifier(home)),
|
||||
wizProvisioningPlatformServiceProvider.overrideWithValue(
|
||||
FakeWizProvisioningPlatformService(
|
||||
environment: _readyEnvironment(
|
||||
permissionStatus: WizProvisioningPermissionStatus.requestable,
|
||||
connectedToWifi: false,
|
||||
ssid: null,
|
||||
bssid: null,
|
||||
frequencyMhz: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(wizProvisioningProvider.notifier).initialize();
|
||||
|
||||
final state = container.read(wizProvisioningProvider);
|
||||
expect(state.status, WizProvisioningStatus.attentionRequired);
|
||||
expect(
|
||||
state.failure?.kind,
|
||||
WizProvisioningFailureKind.missingPermissions,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('successful smart pairing ends with rescan success', () async {
|
||||
final home = HomeConfig(id: 'home-1', name: 'Дом', url: 'https://ignis');
|
||||
final api = FakeIgnisApi()
|
||||
..rescanNetworkData = {
|
||||
'status': 'ok',
|
||||
'found': 1,
|
||||
'added': 1,
|
||||
'updated': 0,
|
||||
'removed_offline': 0,
|
||||
'pending_removal': 0,
|
||||
'online': 1,
|
||||
};
|
||||
final smartPairing = FakeWizSmartPairingService(
|
||||
streamFactory: () {
|
||||
final controller = StreamController<WizProvisioningDevice>();
|
||||
Future<void>.microtask(() {
|
||||
controller.add(
|
||||
const WizProvisioningDevice(
|
||||
bssid: 'aa:bb:cc:dd:ee:ff',
|
||||
ipAddress: '192.168.1.44',
|
||||
),
|
||||
);
|
||||
});
|
||||
return controller.stream;
|
||||
},
|
||||
);
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
currentHomeProvider.overrideWith(() => FakeCurrentHomeNotifier(home)),
|
||||
apiProvider.overrideWithValue(api),
|
||||
wizProvisioningPlatformServiceProvider.overrideWithValue(
|
||||
FakeWizProvisioningPlatformService(environment: _readyEnvironment()),
|
||||
),
|
||||
wizSmartPairingServiceProvider.overrideWithValue(smartPairing),
|
||||
wizProvisioningTimingProvider.overrideWithValue(
|
||||
const WizProvisioningTiming(
|
||||
provisioningTimeout: Duration(milliseconds: 200),
|
||||
settleAfterFirstResponse: Duration.zero,
|
||||
initialRescanDelay: Duration.zero,
|
||||
retryRescanDelay: Duration.zero,
|
||||
maxRescanAttempts: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(wizProvisioningProvider.notifier).initialize();
|
||||
await container
|
||||
.read(wizProvisioningProvider.notifier)
|
||||
.startProvisioning(
|
||||
ssid: 'Home-2G',
|
||||
password: 'secret',
|
||||
bssid: '11:22:33:44:55:66',
|
||||
);
|
||||
|
||||
final state = container.read(wizProvisioningProvider);
|
||||
expect(state.status, WizProvisioningStatus.success);
|
||||
expect(state.provisionedDevices, hasLength(1));
|
||||
expect(state.rescanSummary?.added, 1);
|
||||
expect(api.rescanCalls, 1);
|
||||
});
|
||||
}
|
||||
|
||||
class FakeCurrentHomeNotifier extends CurrentHomeNotifier {
|
||||
FakeCurrentHomeNotifier(this._home);
|
||||
|
||||
final HomeConfig? _home;
|
||||
|
||||
@override
|
||||
HomeConfig? build() => _home;
|
||||
}
|
||||
|
||||
class FakeWizProvisioningPlatformService
|
||||
implements WizProvisioningPlatformService {
|
||||
FakeWizProvisioningPlatformService({required this.environment});
|
||||
|
||||
final WizProvisioningEnvironment environment;
|
||||
int requestCalls = 0;
|
||||
int openWifiSettingsCalls = 0;
|
||||
int openAppSettingsCalls = 0;
|
||||
|
||||
@override
|
||||
Future<WizProvisioningEnvironment> inspectEnvironment() async => environment;
|
||||
|
||||
@override
|
||||
Future<void> requestPermissions() async {
|
||||
requestCalls += 1;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> openAppSettings() async {
|
||||
openAppSettingsCalls += 1;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> openWifiSettings() async {
|
||||
openWifiSettingsCalls += 1;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeWizSmartPairingService implements WizSmartPairingService {
|
||||
FakeWizSmartPairingService({required this.streamFactory});
|
||||
|
||||
final Stream<WizProvisioningDevice> Function() streamFactory;
|
||||
int startCalls = 0;
|
||||
int stopCalls = 0;
|
||||
|
||||
@override
|
||||
Stream<WizProvisioningDevice> startProvisioning({
|
||||
required String ssid,
|
||||
required String password,
|
||||
String? bssid,
|
||||
}) {
|
||||
startCalls += 1;
|
||||
return streamFactory();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stopProvisioning() async {
|
||||
stopCalls += 1;
|
||||
}
|
||||
}
|
||||
|
||||
WizProvisioningEnvironment _readyEnvironment({
|
||||
WizProvisioningPermissionStatus permissionStatus =
|
||||
WizProvisioningPermissionStatus.granted,
|
||||
bool connectedToWifi = true,
|
||||
bool locationServicesEnabled = true,
|
||||
String? ssid = 'Home-2G',
|
||||
String? bssid = '11:22:33:44:55:66',
|
||||
int? frequencyMhz = 2437,
|
||||
}) {
|
||||
return WizProvisioningEnvironment(
|
||||
platform: 'android',
|
||||
androidApiLevel: 35,
|
||||
smartPairingSupported: true,
|
||||
wifiSettingsSupported: true,
|
||||
appSettingsSupported: true,
|
||||
permissionStatus: permissionStatus,
|
||||
locationServicesEnabled: locationServicesEnabled,
|
||||
connectedToWifi: connectedToWifi,
|
||||
ssid: ssid,
|
||||
bssid: bssid,
|
||||
frequencyMhz: frequencyMhz,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user