diff --git a/README.md b/README.md index 9db69d2..4033032 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,23 @@ flutter pub get flutter run ``` +## Документы + +- `docs/wiz_provisioning_master_plan.md` — подробный план добавления мастера первичной посадки новых WiZ-ламп на Wi-Fi без официального приложения. + +## WiZ Provisioning Status + +Что уже есть: + +- Android-first мастер подключения новых WiZ-ламп; +- environment inspection, permissions, smart pairing и post-provision `rescan` в `Ignis`. + +Что важно понимать: + +- это пока не универсальный onboarding для всех поколений WiZ; +- `SoftAP / WiZConfig_xxxx`, commissioning через `UDP 18266`, `BLE` и `Matter` fallback ещё не реализованы; +- реальная проверка на железе остаётся обязательной. + ## Release APK ```bash diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 635377a..b2bed55 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,7 +1,10 @@ + + + diff --git a/android/app/src/main/kotlin/ru/akokos/ignis_app/MainActivity.kt b/android/app/src/main/kotlin/ru/akokos/ignis_app/MainActivity.kt index f2edf8d..bec8e73 100644 --- a/android/app/src/main/kotlin/ru/akokos/ignis_app/MainActivity.kt +++ b/android/app/src/main/kotlin/ru/akokos/ignis_app/MainActivity.kt @@ -1,11 +1,19 @@ package ru.akokos.ignis_app import android.Manifest +import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.Uri +import android.net.wifi.WifiInfo +import android.net.wifi.WifiManager import android.os.Build +import android.location.LocationManager import android.provider.Settings import androidx.core.app.ActivityCompat +import androidx.core.location.LocationManagerCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import io.flutter.embedding.engine.FlutterEngine @@ -14,9 +22,13 @@ import io.flutter.plugin.common.MethodChannel class MainActivity : FlutterActivity() { private var pendingNotificationPermissionResult: MethodChannel.Result? = null + private var pendingProvisioningPermissionResult: MethodChannel.Result? = null private val notificationPrefs by lazy { getSharedPreferences(notificationPrefsName, MODE_PRIVATE) } + private val provisioningPrefs by lazy { + getSharedPreferences(provisioningPrefsName, MODE_PRIVATE) + } override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) @@ -94,6 +106,34 @@ class MainActivity : FlutterActivity() { else -> result.notImplemented() } } + + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + wizProvisioningChannelName, + ).setMethodCallHandler { call, result -> + when (call.method) { + "getProvisioningEnvironment" -> { + result.success(buildProvisioningEnvironment()) + } + "requestProvisioningPermissions" -> { + requestProvisioningPermissions(result) + } + "openWifiSettings" -> { + startActivity(Intent(Settings.ACTION_WIFI_SETTINGS)) + result.success(null) + } + "openAppSettings" -> { + val intent = + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", packageName, null), + ) + startActivity(intent) + result.success(null) + } + else -> result.notImplemented() + } + } } override fun onRequestPermissionsResult( @@ -104,6 +144,10 @@ class MainActivity : FlutterActivity() { super.onRequestPermissionsResult(requestCode, permissions, grantResults) if (requestCode != notificationPermissionRequestCode) { + if (requestCode == provisioningPermissionRequestCode) { + pendingProvisioningPermissionResult?.success(buildProvisioningEnvironment()) + pendingProvisioningPermissionResult = null + } return } @@ -111,6 +155,30 @@ class MainActivity : FlutterActivity() { pendingNotificationPermissionResult = null } + private fun requestProvisioningPermissions(result: MethodChannel.Result) { + if (pendingProvisioningPermissionResult != null) { + result.error( + "request_in_progress", + "Provisioning permission request is already in progress", + null, + ) + return + } + + if (hasProvisioningPermissions()) { + result.success(buildProvisioningEnvironment()) + return + } + + pendingProvisioningPermissionResult = result + provisioningPrefs.edit().putBoolean(provisioningPermissionRequestedKey, true).apply() + ActivityCompat.requestPermissions( + this, + requiredProvisioningPermissions(), + provisioningPermissionRequestCode, + ) + } + private fun requestNotificationPermission(result: MethodChannel.Result) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { result.success(getNotificationPermissionStatusValue()) @@ -182,10 +250,137 @@ class MainActivity : FlutterActivity() { NotificationManagerCompat.from(this).areNotificationsEnabled() } + private fun buildProvisioningEnvironment(): Map { + 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 { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.NEARBY_WIFI_DEVICES, + ) + } else { + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) + } + } + + private fun isLocationServicesEnabled(): Boolean { + val manager = getSystemService(Context.LOCATION_SERVICE) as? LocationManager + return manager?.let(LocationManagerCompat::isLocationEnabled) ?: false + } + + private fun currentWifiSnapshot(): WifiSnapshot { + if (!hasProvisioningPermissions()) { + return WifiSnapshot() + } + + val connectivityManager = + getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val activeNetwork = connectivityManager.activeNetwork + val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork) + val isWifiTransport = + networkCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true + val wifiInfo = + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + networkCapabilities?.transportInfo is WifiInfo -> { + networkCapabilities.transportInfo as WifiInfo + } + else -> { + @Suppress("DEPRECATION") + val wifiManager = + applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + @Suppress("DEPRECATION") + wifiManager.connectionInfo + } + } + + val sanitizedSsid = sanitizeSsid(wifiInfo?.ssid) + val sanitizedBssid = sanitizeBssid(wifiInfo?.bssid) + val frequency = wifiInfo?.frequency?.takeIf { it > 0 } + + return WifiSnapshot( + connectedToWifi = isWifiTransport && sanitizedSsid != null, + ssid = sanitizedSsid, + bssid = sanitizedBssid, + frequencyMhz = frequency, + ) + } + + private fun sanitizeSsid(raw: String?): String? { + val trimmed = raw?.trim()?.removePrefix("\"")?.removeSuffix("\"") + return when { + trimmed.isNullOrEmpty() -> null + trimmed.equals(WifiManager.UNKNOWN_SSID, ignoreCase = true) -> null + else -> trimmed + } + } + + private fun sanitizeBssid(raw: String?): String? { + val trimmed = raw?.trim() + return when { + trimmed.isNullOrEmpty() -> null + trimmed == "02:00:00:00:00:00" -> null + else -> trimmed.lowercase() + } + } + + data class WifiSnapshot( + val connectedToWifi: Boolean = false, + val ssid: String? = null, + val bssid: String? = null, + val frequencyMhz: Int? = null, + ) + companion object { private const val notificationPermissionRequestCode = 4102 private const val notificationPrefsName = "ignis_notification_permissions" private const val notificationPermissionRequestedKey = "post_notifications_requested" + private const val provisioningPermissionRequestCode = 4103 + private const val provisioningPrefsName = "ignis_wiz_provisioning" + private const val provisioningPermissionRequestedKey = + "wiz_provisioning_permissions_requested" + private const val wizProvisioningChannelName = "ignis/wiz_provisioning" } } diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..c3713fd --- /dev/null +++ b/docs/README.md @@ -0,0 +1,3 @@ +# Docs + +- [WiZ Provisioning Master Plan](./wiz_provisioning_master_plan.md) diff --git a/docs/wiz_provisioning_master_plan.md b/docs/wiz_provisioning_master_plan.md new file mode 100644 index 0000000..49510c3 --- /dev/null +++ b/docs/wiz_provisioning_master_plan.md @@ -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` diff --git a/lib/features/provisioning/models/wiz_provisioning_device.dart b/lib/features/provisioning/models/wiz_provisioning_device.dart new file mode 100644 index 0000000..e588245 --- /dev/null +++ b/lib/features/provisioning/models/wiz_provisioning_device.dart @@ -0,0 +1,6 @@ +class WizProvisioningDevice { + final String bssid; + final String? ipAddress; + + const WizProvisioningDevice({required this.bssid, this.ipAddress}); +} diff --git a/lib/features/provisioning/models/wiz_provisioning_environment.dart b/lib/features/provisioning/models/wiz_provisioning_environment.dart new file mode 100644 index 0000000..30d52de --- /dev/null +++ b/lib/features/provisioning/models/wiz_provisioning_environment.dart @@ -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 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; + } +} diff --git a/lib/features/provisioning/models/wiz_provisioning_failure.dart b/lib/features/provisioning/models/wiz_provisioning_failure.dart new file mode 100644 index 0000000..de1ac4a --- /dev/null +++ b/lib/features/provisioning/models/wiz_provisioning_failure.dart @@ -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, + }); +} diff --git a/lib/features/provisioning/models/wiz_provisioning_state.dart b/lib/features/provisioning/models/wiz_provisioning_state.dart new file mode 100644 index 0000000..4c12524 --- /dev/null +++ b/lib/features/provisioning/models/wiz_provisioning_state.dart @@ -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 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 provisionedDevices; + final List 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? provisionedDevices, + List? 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), + ); + } +} diff --git a/lib/features/provisioning/models/wiz_provisioning_timing.dart b/lib/features/provisioning/models/wiz_provisioning_timing.dart new file mode 100644 index 0000000..d4686ae --- /dev/null +++ b/lib/features/provisioning/models/wiz_provisioning_timing.dart @@ -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, + }); +} diff --git a/lib/features/provisioning/providers/wiz_provisioning_providers.dart b/lib/features/provisioning/providers/wiz_provisioning_providers.dart new file mode 100644 index 0000000..3c25a5e --- /dev/null +++ b/lib/features/provisioning/providers/wiz_provisioning_providers.dart @@ -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( + (ref) => const DeviceWizProvisioningPlatformService(), + ); + +final wizSmartPairingServiceProvider = Provider( + (ref) => EspTouchWizSmartPairingService(), +); + +final wizProvisioningTimingProvider = Provider( + (ref) => const WizProvisioningTiming(), +); + +final wizProvisioningProvider = + NotifierProvider( + WizProvisioningNotifier.new, + ); + +class WizProvisioningNotifier extends Notifier { + 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 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 requestPermissions() async { + await _platform.requestPermissions(); + await initialize(); + } + + Future openWifiSettings() => _platform.openWifiSettings(); + + Future openAppSettings() => _platform.openAppSettings(); + + Future 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 = []; + StreamSubscription? subscription; + final firstResponse = Completer(); + + 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.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.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 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 _rescanUntilSettled() async { + final api = ref.read(apiProvider); + Object? lastError; + WizRescanSummary? lastSummary; + + for (var attempt = 0; attempt < _timing.maxRescanAttempts; attempt += 1) { + await Future.delayed( + attempt == 0 ? _timing.initialRescanDelay : _timing.retryRescanDelay, + ); + + try { + final response = await api.rescanNetwork(); + lastSummary = WizRescanSummary.fromMap( + Map.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 _appendTimeline(List current, String event) { + return List.unmodifiable([...current, event]); + } +} diff --git a/lib/features/provisioning/services/wiz_provisioning_platform_service.dart b/lib/features/provisioning/services/wiz_provisioning_platform_service.dart new file mode 100644 index 0000000..1697749 --- /dev/null +++ b/lib/features/provisioning/services/wiz_provisioning_platform_service.dart @@ -0,0 +1,62 @@ +import 'package:flutter/services.dart'; + +import '../models/wiz_provisioning_environment.dart'; + +abstract class WizProvisioningPlatformService { + Future inspectEnvironment(); + + Future requestPermissions(); + + Future openWifiSettings(); + + Future openAppSettings(); +} + +class DeviceWizProvisioningPlatformService + implements WizProvisioningPlatformService { + const DeviceWizProvisioningPlatformService(); + + static const _channel = MethodChannel('ignis/wiz_provisioning'); + + @override + Future inspectEnvironment() async { + try { + final raw = await _channel.invokeMapMethod( + 'getProvisioningEnvironment', + ); + if (raw == null) { + return WizProvisioningEnvironment.unsupported(); + } + return WizProvisioningEnvironment.fromMap(Map.from(raw)); + } on MissingPluginException { + return WizProvisioningEnvironment.unsupported(); + } + } + + @override + Future requestPermissions() async { + try { + await _channel.invokeMethod('requestProvisioningPermissions'); + } on MissingPluginException { + return; + } + } + + @override + Future openWifiSettings() async { + try { + await _channel.invokeMethod('openWifiSettings'); + } on MissingPluginException { + return; + } + } + + @override + Future openAppSettings() async { + try { + await _channel.invokeMethod('openAppSettings'); + } on MissingPluginException { + return; + } + } +} diff --git a/lib/features/provisioning/services/wiz_smart_pairing_service.dart b/lib/features/provisioning/services/wiz_smart_pairing_service.dart new file mode 100644 index 0000000..d699ff2 --- /dev/null +++ b/lib/features/provisioning/services/wiz_smart_pairing_service.dart @@ -0,0 +1,93 @@ +import 'dart:async'; + +import 'package:esp_smartconfig/esp_smartconfig.dart'; + +import '../models/wiz_provisioning_device.dart'; + +abstract class WizSmartPairingService { + Stream startProvisioning({ + required String ssid, + required String password, + String? bssid, + }); + + Future stopProvisioning(); +} + +class EspTouchWizSmartPairingService implements WizSmartPairingService { + Provisioner? _provisioner; + StreamSubscription? _subscription; + StreamController? _controller; + + @override + Stream 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.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 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; + } + } +} diff --git a/lib/providers/providers.dart b/lib/providers/providers.dart index aef8901..5d5c76f 100644 --- a/lib/providers/providers.dart +++ b/lib/providers/providers.dart @@ -2,6 +2,7 @@ export '../features/api_keys/providers/api_keys_providers.dart'; export '../features/auth/providers/auth_providers.dart'; export '../features/homes/providers/homes_providers.dart'; export '../features/homes/providers/location_providers.dart'; +export '../features/provisioning/providers/wiz_provisioning_providers.dart'; export '../features/remote/providers/remote_providers.dart'; export '../features/schedules/providers/tasks_providers.dart'; export '../features/shared/providers/core_providers.dart'; diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 42426b1..1157cdb 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -13,6 +13,7 @@ import '../models/home_config.dart'; import '../providers/providers.dart'; import 'home_edit_screen.dart'; import 'homes_screen.dart'; +import 'wiz_provisioning_screen.dart'; enum SettingsEntryPoint { homes, remote } @@ -90,6 +91,15 @@ class _SettingsScreenState extends ConsumerState spacing: 8, runSpacing: 8, children: [ + FilledButton.icon( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const WizProvisioningScreen(), + ), + ), + icon: const Icon(Icons.lightbulb_outline), + label: const Text('Подключить WiZ-лампу'), + ), FilledButton.tonalIcon( onPressed: () => _openHomeEditor(context, currentHome), icon: const Icon(Icons.edit_location_alt_outlined), diff --git a/lib/screens/wiz_provisioning_screen.dart b/lib/screens/wiz_provisioning_screen.dart new file mode 100644 index 0000000..7bab939 --- /dev/null +++ b/lib/screens/wiz_provisioning_screen.dart @@ -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 createState() => + _WizProvisioningScreenState(); +} + +class _WizProvisioningScreenState extends ConsumerState + with WidgetsBindingObserver { + final _formKey = GlobalKey(); + 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.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.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 _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)), + ), + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 6a06851..2e425e8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -153,6 +153,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + esp_smartconfig: + dependency: "direct main" + description: + name: esp_smartconfig + sha256: "43799fc5bbdbde18d6c4a7a8ab48b7042878154d792ab5815c5473759d4f575e" + url: "https://pub.dev" + source: hosted + version: "3.0.0" fake_async: dependency: transitive description: @@ -584,6 +592,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" pool: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 69daa90..5179469 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,7 @@ dependencies: shared_preferences: ^2.5.5 geolocator: ^13.0.2 flutter_secure_storage: ^10.0.0 + esp_smartconfig: ^3.0.0 dev_dependencies: flutter_test: diff --git a/test/wiz_provisioning_notifier_test.dart b/test/wiz_provisioning_notifier_test.dart new file mode 100644 index 0000000..693c168 --- /dev/null +++ b/test/wiz_provisioning_notifier_test.dart @@ -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(); + Future.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 inspectEnvironment() async => environment; + + @override + Future requestPermissions() async { + requestCalls += 1; + } + + @override + Future openAppSettings() async { + openAppSettingsCalls += 1; + } + + @override + Future openWifiSettings() async { + openWifiSettingsCalls += 1; + } +} + +class FakeWizSmartPairingService implements WizSmartPairingService { + FakeWizSmartPairingService({required this.streamFactory}); + + final Stream Function() streamFactory; + int startCalls = 0; + int stopCalls = 0; + + @override + Stream startProvisioning({ + required String ssid, + required String password, + String? bssid, + }) { + startCalls += 1; + return streamFactory(); + } + + @override + Future 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, + ); +}