5 Commits

Author SHA1 Message Date
Artem Kokos
866a074c03 Add WiZ provisioning wizard 2026-05-16 17:24:28 +07:00
Artem Kokos
0a635115d4 Remove AI docs and refresh project docs 2026-05-16 15:50:40 +07:00
Artem Kokos
894ba91095 Keep stored home API keys hidden on edit 2026-05-16 11:22:02 +07:00
Artem Kokos
83d946558b Use backend rescan summary in group editor 2026-05-16 10:59:31 +07:00
Artem Kokos
70fedb6134 Adapt app client to JSON API payloads 2026-05-16 10:29:54 +07:00
27 changed files with 3008 additions and 345 deletions

View File

@@ -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
View File

@@ -1,30 +1,40 @@
# Ignis App # 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-ключами; - хранить несколько домов с разными URL и API-ключами;
- управление группами света: `on/off`, яркость, температура, RGB, сцены; - переключать активный дом и проверять `auth/me` при выборе;
- таймер "включить на 4 часа"; - управлять группами света: `on/off`, яркость, температура, RGB, сцены;
- одноразовые и повторяющиеся расписания; - ставить быстрый таймер на 4 часа;
- статистика и лог событий; - создавать one-shot и cron-расписания;
- управление гостевыми API-ключами для администратора; - смотреть stats summary и event log;
- расстояние до дома и автовыключение света по geofence. - управлять гостевыми 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 - Flutter / Dart
- Material UI
- Riverpod - Riverpod
- Dio - Dio
- SharedPreferences - SharedPreferences
@@ -33,34 +43,6 @@ Android-клиент для self-hosted backend [Ignis Core](https://git.akokos.
- Android Geofencing API - Android Geofencing API
- Android WorkManager - 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 ```bash
@@ -68,6 +50,23 @@ flutter pub get
flutter run 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 ## Release APK
```bash ```bash
@@ -82,6 +81,45 @@ flutter build apk --release \
build/app/outputs/flutter-apk/app-release.apk 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 ```bash
@@ -89,32 +127,29 @@ flutter analyze
flutter test flutter test
``` ```
Сейчас тестами прикрыты: На 2026-05-16 в `test/` лежит 74 unit/widget-теста.
- parsing и load-state основных backend-ответов;
- сериализация `HomeConfig` и geofence radius;
- синхронизация активного дома с geofence automation;
- form logic для домов, групп и расписаний;
- provider-мутаторы расписаний, API-ключей и group control;
- widget-сценарии форм, `GroupCard` и error/retry потоков.
## Настройка Покрыто:
1. Добавить дом: адрес сервера Ignis и API-ключ. - `IgnisApi` и нормализация base URL;
2. При необходимости задать координаты дома. - сериализация `HomeConfig`;
3. Включить "выключать свет при уходе". - миграция и хранение настроек;
4. Выдать Android-разрешения на геолокацию, включая background location. - bootstrap и auth/load-state;
5. Разрешить уведомления, если нужны подтверждения о срабатывании geofence. - 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; - продукт по факту поддерживается как Android-first клиент;
- реальное поведение background execution, geofence delivery и OEM battery restrictions подтверждается в основном ручными проверками на устройстве; - iOS, web, desktop каталоги присутствуют как Flutter scaffold, но не считаются поддерживаемыми продуктными платформами;
- force-stop приложения со стороны Android может ломать автоподъём фоновой логики до следующего ручного запуска. - `apiProvider` конфигурируется мутирующим `init()`, поэтому переключение домов требует аккуратности;
- крупные экраны вроде `SettingsScreen` и `SchedulesScreen` всё ещё держат много UI-ответственности;
## Лицензия - release signing в репозитории не настроен.
Частный проект.

View File

@@ -1,7 +1,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <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_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_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.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />

View File

@@ -1,11 +1,19 @@
package ru.akokos.ignis_app package ru.akokos.ignis_app
import android.Manifest import android.Manifest
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager 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.os.Build
import android.location.LocationManager
import android.provider.Settings import android.provider.Settings
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.location.LocationManagerCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
@@ -14,9 +22,13 @@ import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
private var pendingNotificationPermissionResult: MethodChannel.Result? = null private var pendingNotificationPermissionResult: MethodChannel.Result? = null
private var pendingProvisioningPermissionResult: MethodChannel.Result? = null
private val notificationPrefs by lazy { private val notificationPrefs by lazy {
getSharedPreferences(notificationPrefsName, MODE_PRIVATE) getSharedPreferences(notificationPrefsName, MODE_PRIVATE)
} }
private val provisioningPrefs by lazy {
getSharedPreferences(provisioningPrefsName, MODE_PRIVATE)
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
@@ -94,6 +106,34 @@ class MainActivity : FlutterActivity() {
else -> result.notImplemented() 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( override fun onRequestPermissionsResult(
@@ -104,6 +144,10 @@ class MainActivity : FlutterActivity() {
super.onRequestPermissionsResult(requestCode, permissions, grantResults) super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode != notificationPermissionRequestCode) { if (requestCode != notificationPermissionRequestCode) {
if (requestCode == provisioningPermissionRequestCode) {
pendingProvisioningPermissionResult?.success(buildProvisioningEnvironment())
pendingProvisioningPermissionResult = null
}
return return
} }
@@ -111,6 +155,30 @@ class MainActivity : FlutterActivity() {
pendingNotificationPermissionResult = null 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) { private fun requestNotificationPermission(result: MethodChannel.Result) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
result.success(getNotificationPermissionStatusValue()) result.success(getNotificationPermissionStatusValue())
@@ -182,10 +250,137 @@ class MainActivity : FlutterActivity() {
NotificationManagerCompat.from(this).areNotificationsEnabled() 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 { companion object {
private const val notificationPermissionRequestCode = 4102 private const val notificationPermissionRequestCode = 4102
private const val notificationPrefsName = "ignis_notification_permissions" private const val notificationPrefsName = "ignis_notification_permissions"
private const val notificationPermissionRequestedKey = private const val notificationPermissionRequestedKey =
"post_notifications_requested" "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
View File

@@ -0,0 +1,3 @@
# Docs
- [WiZ Provisioning Master Plan](./wiz_provisioning_master_plan.md)

View 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`

View File

@@ -1,5 +1,9 @@
# Launch Screen Assets # 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-конфигурацией.

View File

@@ -0,0 +1,6 @@
class WizProvisioningDevice {
final String bssid;
final String? ipAddress;
const WizProvisioningDevice({required this.bssid, this.ipAddress});
}

View File

@@ -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;
}
}

View File

@@ -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,
});
}

View 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),
);
}
}

View File

@@ -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,
});
}

View File

@@ -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]);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -2,6 +2,7 @@ export '../features/api_keys/providers/api_keys_providers.dart';
export '../features/auth/providers/auth_providers.dart'; export '../features/auth/providers/auth_providers.dart';
export '../features/homes/providers/homes_providers.dart'; export '../features/homes/providers/homes_providers.dart';
export '../features/homes/providers/location_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/remote/providers/remote_providers.dart';
export '../features/schedules/providers/tasks_providers.dart'; export '../features/schedules/providers/tasks_providers.dart';
export '../features/shared/providers/core_providers.dart'; export '../features/shared/providers/core_providers.dart';

View File

@@ -80,39 +80,19 @@ class _GroupEditScreenState extends ConsumerState<GroupEditScreen> {
} }
Future<void> _rescan() async { Future<void> _rescan() async {
final beforeIds = ref
.read(devicesProvider)
.data
.map((device) => device.groupMemberId)
.toSet();
setState(() => _rescanning = true); setState(() => _rescanning = true);
try { try {
await ref.read(apiProvider).rescanNetwork(); final response = await ref.read(apiProvider).rescanNetwork();
var changed = false;
for (var attempt = 0; attempt < 6; attempt++) {
await Future.delayed(const Duration(seconds: 1));
await ref.read(devicesProvider.notifier).load(); await ref.read(devicesProvider.notifier).load();
final currentIds = ref final summary = response.data is Map
.read(devicesProvider) ? Map<String, dynamic>.from(response.data as Map)
.data : const <String, dynamic>{};
.map((device) => device.groupMemberId) final message = formatRescanSummary(summary);
.toSet();
if (!_sameSet(beforeIds, currentIds)) {
changed = true;
break;
}
}
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(message),
changed
? 'Список устройств обновился'
: 'Сканирование завершилось, но новых устройств пока не видно',
),
duration: const Duration(seconds: 2), 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;
} }
for (final item in left) {
if (!right.contains(item)) { int _summaryInt(Map<String, dynamic> summary, String key) {
return false; final value = summary[key];
if (value is int) {
return value;
} }
return int.tryParse(value?.toString() ?? '') ?? 0;
} }
return true;
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 'Сканирование завершено: найдено $found, новых $added, обновлено $updated, убрано $removed';
} }
class _ConflictBanner extends StatelessWidget { class _ConflictBanner extends StatelessWidget {

View File

@@ -25,6 +25,7 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
final _lonCtrl = TextEditingController(); final _lonCtrl = TextEditingController();
bool _saving = false; bool _saving = false;
bool _loadingApiKey = false; bool _loadingApiKey = false;
bool _hasStoredApiKey = false;
String _originalApiKey = ''; String _originalApiKey = '';
bool get _isEdit => widget.home != null; bool get _isEdit => widget.home != null;
@@ -59,11 +60,11 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
final apiKey = await ref final apiKey = await ref
.read(settingsServiceProvider) .read(settingsServiceProvider)
.getHomeApiKey(widget.home!.id); .getHomeApiKey(widget.home!.id);
_originalApiKey = apiKey ?? ''; _originalApiKey = apiKey?.trim() ?? '';
_hasStoredApiKey = _originalApiKey.isNotEmpty;
if (!mounted) { if (!mounted) {
return; return;
} }
_keyCtrl.text = _originalApiKey;
} finally { } finally {
if (mounted) { if (mounted) {
setState(() => _loadingApiKey = false); setState(() => _loadingApiKey = false);
@@ -144,14 +145,24 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
controller: _keyCtrl, controller: _keyCtrl,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'API Key', labelText: 'API Key',
hintText: _hasStoredApiKey
? 'Оставьте пустым, чтобы сохранить текущий ключ'
: null,
helperText: _loadingApiKey helperText: _loadingApiKey
? 'Загружаем сохранённый ключ...' ? 'Загружаем сохранённый ключ...'
: 'Ключ проверяется только при изменении подключения', : _hasStoredApiKey
? 'Сохранённый ключ хранится отдельно и не показывается в поле'
: 'Ключ хранится отдельно в защищённом хранилище',
prefixIcon: const Icon(Icons.key), prefixIcon: const Icon(Icons.key),
), ),
obscureText: true, obscureText: true,
validator: (value) => validator: (value) {
(value?.trim().isEmpty ?? true) ? 'Укажите API key' : null, final enteredKey = value?.trim() ?? '';
if (enteredKey.isNotEmpty || _hasStoredApiKey) {
return null;
}
return 'Укажите API key';
},
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
const Text( const Text(
@@ -285,7 +296,8 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
final name = _nameCtrl.text.trim(); final name = _nameCtrl.text.trim();
final rawUrl = _urlCtrl.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 latText = _latCtrl.text.trim();
final lonText = _lonCtrl.text.trim(); final lonText = _lonCtrl.text.trim();
@@ -370,7 +382,7 @@ class _HomeEditScreenState extends ConsumerState<HomeEditScreen> {
if (_isEdit) { if (_isEdit) {
await ref await ref
.read(homesProvider.notifier) .read(homesProvider.notifier)
.update(home, apiKey: credentialsChanged ? key : null); .update(home, apiKey: enteredKey.isNotEmpty ? key : null);
} else { } else {
await ref.read(homesProvider.notifier).add(home, apiKey: key); await ref.read(homesProvider.notifier).add(home, apiKey: key);
} }

View File

@@ -13,6 +13,7 @@ import '../models/home_config.dart';
import '../providers/providers.dart'; import '../providers/providers.dart';
import 'home_edit_screen.dart'; import 'home_edit_screen.dart';
import 'homes_screen.dart'; import 'homes_screen.dart';
import 'wiz_provisioning_screen.dart';
enum SettingsEntryPoint { homes, remote } enum SettingsEntryPoint { homes, remote }
@@ -90,6 +91,15 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen>
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: [ children: [
FilledButton.icon(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const WizProvisioningScreen(),
),
),
icon: const Icon(Icons.lightbulb_outline),
label: const Text('Подключить WiZ-лампу'),
),
FilledButton.tonalIcon( FilledButton.tonalIcon(
onPressed: () => _openHomeEditor(context, currentHome), onPressed: () => _openHomeEditor(context, currentHome),
icon: const Icon(Icons.edit_location_alt_outlined), icon: const Icon(Icons.edit_location_alt_outlined),

View 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)),
),
],
),
);
}
}

View File

@@ -3,7 +3,9 @@ import 'package:dio/dio.dart';
/// HTTP-клиент для одного сервера Ignis. /// HTTP-клиент для одного сервера Ignis.
/// Покрывает все эндпоинты из openapi.json. /// Покрывает все эндпоинты из openapi.json.
class IgnisApi { class IgnisApi {
final Dio _dio = Dio(); IgnisApi({Dio? dio}) : _dio = dio ?? Dio();
final Dio _dio;
Dio get dioInstance => _dio; Dio get dioInstance => _dio;
static String normalizeBaseUrl(String baseUrl) { static String normalizeBaseUrl(String baseUrl) {
@@ -70,11 +72,11 @@ class IgnisApi {
/// Управление группой: state, brightness, temp, scene, r/g/b /// Управление группой: state, brightness, temp, scene, r/g/b
Future<Response> controlGroup(String id, Map<String, dynamic> params) => 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) => 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) => Future<Response> blinkDevice(String id) =>
@@ -92,11 +94,11 @@ class IgnisApi {
/// Одноразовое расписание (таймер) /// Одноразовое расписание (таймер)
Future<Response> scheduleOnce(Map<String, dynamic> params) => Future<Response> scheduleOnce(Map<String, dynamic> params) =>
_dio.post('/schedules/once', queryParameters: params); _dio.post('/schedules/once', data: params);
/// Cron-расписание (повторяющееся) /// Cron-расписание (повторяющееся)
Future<Response> scheduleCron(Map<String, dynamic> params) => 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'); Future<Response> getTasks() => _dio.get('/schedules/tasks');

View File

@@ -153,6 +153,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" 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: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -584,6 +592,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
pointycastle:
dependency: transitive
description:
name: pointycastle
sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5"
url: "https://pub.dev"
source: hosted
version: "4.0.0"
pool: pool:
dependency: transitive dependency: transitive
description: description:

View File

@@ -39,6 +39,7 @@ dependencies:
shared_preferences: ^2.5.5 shared_preferences: ^2.5.5
geolocator: ^13.0.2 geolocator: ^13.0.2
flutter_secure_storage: ^10.0.0 flutter_secure_storage: ^10.0.0
esp_smartconfig: ^3.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

86
test/api_client_test.dart Normal file
View 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,
});
},
);
}

View File

@@ -82,6 +82,41 @@ void main() {
expect(api.createdGroupMacs, ['AA:BB']); 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', ( testWidgets('api keys screen validates and shows created key banner', (
tester, tester,
) async { ) async {
@@ -195,4 +230,57 @@ void main() {
); );
expect(savedApiKey, 'secret-key'); 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',
);
});
} }

View File

@@ -36,6 +36,7 @@ class FakeIgnisApi extends IgnisApi {
Object? eventLogData; Object? eventLogData;
Object? apiKeysData; Object? apiKeysData;
Object? authData; Object? authData;
Object? rescanNetworkData;
Object? devicesError; Object? devicesError;
Object? scenesError; Object? scenesError;
@@ -72,6 +73,9 @@ class FakeIgnisApi extends IgnisApi {
String? deletedGroupId; String? deletedGroupId;
Map<String, dynamic>? scheduledOnceParams; Map<String, dynamic>? scheduledOnceParams;
Map<String, dynamic>? scheduledCronParams; Map<String, dynamic>? scheduledCronParams;
String? validatedBaseUrl;
String? validatedApiKey;
int validateCredentialsCalls = 0;
int rescanCalls = 0; int rescanCalls = 0;
FakeIgnisApi({ FakeIgnisApi({
@@ -98,6 +102,9 @@ class FakeIgnisApi extends IgnisApi {
@override @override
Future<void> validateCredentials(String baseUrl, String apiKey) async { Future<void> validateCredentials(String baseUrl, String apiKey) async {
validateCredentialsCalls += 1;
validatedBaseUrl = baseUrl;
validatedApiKey = apiKey;
final error = authError; final error = authError;
if (error != null) throw error; if (error != null) throw error;
} }
@@ -329,7 +336,17 @@ class FakeIgnisApi extends IgnisApi {
if (error != null) throw error; if (error != null) throw error;
return Response( return Response(
requestOptions: RequestOptions(path: '/devices/rescan'), 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,
},
); );
} }
} }

View 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,
);
}