1 Commits

Author SHA1 Message Date
Artem Kokos
866a074c03 Add WiZ provisioning wizard 2026-05-16 17:24:28 +07:00
19 changed files with 2668 additions and 0 deletions

View File

@@ -50,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

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

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

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

@@ -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:

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