Compare commits
1 Commits
main
...
feat/wiz-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
866a074c03 |
17
README.md
17
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
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
|
||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
package ru.akokos.ignis_app
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.Uri
|
||||
import android.net.wifi.WifiInfo
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.location.LocationManager
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.location.LocationManagerCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
@@ -14,9 +22,13 @@ import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
private var pendingNotificationPermissionResult: MethodChannel.Result? = null
|
||||
private var pendingProvisioningPermissionResult: MethodChannel.Result? = null
|
||||
private val notificationPrefs by lazy {
|
||||
getSharedPreferences(notificationPrefsName, MODE_PRIVATE)
|
||||
}
|
||||
private val provisioningPrefs by lazy {
|
||||
getSharedPreferences(provisioningPrefsName, MODE_PRIVATE)
|
||||
}
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
@@ -94,6 +106,34 @@ class MainActivity : FlutterActivity() {
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
MethodChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
wizProvisioningChannelName,
|
||||
).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"getProvisioningEnvironment" -> {
|
||||
result.success(buildProvisioningEnvironment())
|
||||
}
|
||||
"requestProvisioningPermissions" -> {
|
||||
requestProvisioningPermissions(result)
|
||||
}
|
||||
"openWifiSettings" -> {
|
||||
startActivity(Intent(Settings.ACTION_WIFI_SETTINGS))
|
||||
result.success(null)
|
||||
}
|
||||
"openAppSettings" -> {
|
||||
val intent =
|
||||
Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts("package", packageName, null),
|
||||
)
|
||||
startActivity(intent)
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
@@ -104,6 +144,10 @@ class MainActivity : FlutterActivity() {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
|
||||
if (requestCode != notificationPermissionRequestCode) {
|
||||
if (requestCode == provisioningPermissionRequestCode) {
|
||||
pendingProvisioningPermissionResult?.success(buildProvisioningEnvironment())
|
||||
pendingProvisioningPermissionResult = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -111,6 +155,30 @@ class MainActivity : FlutterActivity() {
|
||||
pendingNotificationPermissionResult = null
|
||||
}
|
||||
|
||||
private fun requestProvisioningPermissions(result: MethodChannel.Result) {
|
||||
if (pendingProvisioningPermissionResult != null) {
|
||||
result.error(
|
||||
"request_in_progress",
|
||||
"Provisioning permission request is already in progress",
|
||||
null,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (hasProvisioningPermissions()) {
|
||||
result.success(buildProvisioningEnvironment())
|
||||
return
|
||||
}
|
||||
|
||||
pendingProvisioningPermissionResult = result
|
||||
provisioningPrefs.edit().putBoolean(provisioningPermissionRequestedKey, true).apply()
|
||||
ActivityCompat.requestPermissions(
|
||||
this,
|
||||
requiredProvisioningPermissions(),
|
||||
provisioningPermissionRequestCode,
|
||||
)
|
||||
}
|
||||
|
||||
private fun requestNotificationPermission(result: MethodChannel.Result) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
result.success(getNotificationPermissionStatusValue())
|
||||
@@ -182,10 +250,137 @@ class MainActivity : FlutterActivity() {
|
||||
NotificationManagerCompat.from(this).areNotificationsEnabled()
|
||||
}
|
||||
|
||||
private fun buildProvisioningEnvironment(): Map<String, Any?> {
|
||||
val wifiSnapshot = currentWifiSnapshot()
|
||||
return mapOf(
|
||||
"platform" to "android",
|
||||
"androidApiLevel" to Build.VERSION.SDK_INT,
|
||||
"smartPairingSupported" to true,
|
||||
"wifiSettingsSupported" to true,
|
||||
"appSettingsSupported" to true,
|
||||
"permissionStatus" to provisioningPermissionStatusValue(),
|
||||
"locationServicesEnabled" to isLocationServicesEnabled(),
|
||||
"connectedToWifi" to wifiSnapshot.connectedToWifi,
|
||||
"ssid" to wifiSnapshot.ssid,
|
||||
"bssid" to wifiSnapshot.bssid,
|
||||
"frequencyMhz" to wifiSnapshot.frequencyMhz,
|
||||
)
|
||||
}
|
||||
|
||||
private fun provisioningPermissionStatusValue(): String {
|
||||
if (hasProvisioningPermissions()) {
|
||||
return "granted"
|
||||
}
|
||||
|
||||
val wasRequestedBefore =
|
||||
provisioningPrefs.getBoolean(provisioningPermissionRequestedKey, false)
|
||||
val canShowPromptAgain =
|
||||
requiredProvisioningPermissions().any {
|
||||
ActivityCompat.shouldShowRequestPermissionRationale(this, it)
|
||||
}
|
||||
|
||||
return if (!wasRequestedBefore || canShowPromptAgain) {
|
||||
"requestable"
|
||||
} else {
|
||||
"settings_required"
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasProvisioningPermissions(): Boolean {
|
||||
return requiredProvisioningPermissions().all { permission ->
|
||||
ContextCompat.checkSelfPermission(this, permission) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
||||
private fun requiredProvisioningPermissions(): Array<String> {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
arrayOf(
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.NEARBY_WIFI_DEVICES,
|
||||
)
|
||||
} else {
|
||||
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isLocationServicesEnabled(): Boolean {
|
||||
val manager = getSystemService(Context.LOCATION_SERVICE) as? LocationManager
|
||||
return manager?.let(LocationManagerCompat::isLocationEnabled) ?: false
|
||||
}
|
||||
|
||||
private fun currentWifiSnapshot(): WifiSnapshot {
|
||||
if (!hasProvisioningPermissions()) {
|
||||
return WifiSnapshot()
|
||||
}
|
||||
|
||||
val connectivityManager =
|
||||
getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val activeNetwork = connectivityManager.activeNetwork
|
||||
val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork)
|
||||
val isWifiTransport =
|
||||
networkCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
|
||||
val wifiInfo =
|
||||
when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
|
||||
networkCapabilities?.transportInfo is WifiInfo -> {
|
||||
networkCapabilities.transportInfo as WifiInfo
|
||||
}
|
||||
else -> {
|
||||
@Suppress("DEPRECATION")
|
||||
val wifiManager =
|
||||
applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
||||
@Suppress("DEPRECATION")
|
||||
wifiManager.connectionInfo
|
||||
}
|
||||
}
|
||||
|
||||
val sanitizedSsid = sanitizeSsid(wifiInfo?.ssid)
|
||||
val sanitizedBssid = sanitizeBssid(wifiInfo?.bssid)
|
||||
val frequency = wifiInfo?.frequency?.takeIf { it > 0 }
|
||||
|
||||
return WifiSnapshot(
|
||||
connectedToWifi = isWifiTransport && sanitizedSsid != null,
|
||||
ssid = sanitizedSsid,
|
||||
bssid = sanitizedBssid,
|
||||
frequencyMhz = frequency,
|
||||
)
|
||||
}
|
||||
|
||||
private fun sanitizeSsid(raw: String?): String? {
|
||||
val trimmed = raw?.trim()?.removePrefix("\"")?.removeSuffix("\"")
|
||||
return when {
|
||||
trimmed.isNullOrEmpty() -> null
|
||||
trimmed.equals(WifiManager.UNKNOWN_SSID, ignoreCase = true) -> null
|
||||
else -> trimmed
|
||||
}
|
||||
}
|
||||
|
||||
private fun sanitizeBssid(raw: String?): String? {
|
||||
val trimmed = raw?.trim()
|
||||
return when {
|
||||
trimmed.isNullOrEmpty() -> null
|
||||
trimmed == "02:00:00:00:00:00" -> null
|
||||
else -> trimmed.lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
data class WifiSnapshot(
|
||||
val connectedToWifi: Boolean = false,
|
||||
val ssid: String? = null,
|
||||
val bssid: String? = null,
|
||||
val frequencyMhz: Int? = null,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val notificationPermissionRequestCode = 4102
|
||||
private const val notificationPrefsName = "ignis_notification_permissions"
|
||||
private const val notificationPermissionRequestedKey =
|
||||
"post_notifications_requested"
|
||||
private const val provisioningPermissionRequestCode = 4103
|
||||
private const val provisioningPrefsName = "ignis_wiz_provisioning"
|
||||
private const val provisioningPermissionRequestedKey =
|
||||
"wiz_provisioning_permissions_requested"
|
||||
private const val wizProvisioningChannelName = "ignis/wiz_provisioning"
|
||||
}
|
||||
}
|
||||
|
||||
3
docs/README.md
Normal file
3
docs/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Docs
|
||||
|
||||
- [WiZ Provisioning Master Plan](./wiz_provisioning_master_plan.md)
|
||||
865
docs/wiz_provisioning_master_plan.md
Normal file
865
docs/wiz_provisioning_master_plan.md
Normal file
@@ -0,0 +1,865 @@
|
||||
# WiZ Provisioning Master Plan
|
||||
|
||||
Статус: design / implementation brief
|
||||
Актуально на: 2026-05-16
|
||||
Основной проект: `ignis_app`
|
||||
Связанный проект: `../ignis-core`
|
||||
|
||||
## Зачем нужен этот документ
|
||||
|
||||
Этот документ нужен как рабочий implementation brief для добавления в `Ignis` мастера первичной посадки новых WiZ-ламп на домашний Wi-Fi без официального приложения WiZ.
|
||||
|
||||
Цель документа:
|
||||
|
||||
- быстро восстановить контекст через недели или месяцы без повторного ресерча;
|
||||
- понимать, что именно реализуем сначала, а что откладываем;
|
||||
- иметь точную карту действий по Flutter, Android native и интеграции с `ignis-core`;
|
||||
- заранее зафиксировать технические риски и места, где потребуются ручные проверки и апрувы.
|
||||
|
||||
## Краткий вывод
|
||||
|
||||
Сделать это **реально**, но нужно разделять минимум два поколения/режима onboarding:
|
||||
|
||||
- `SoftAP / manual setup`: лампа поднимает сеть вида `WiZConfig_xxxx`, телефон временно подключается к ней и передаёт домашние Wi-Fi credentials.
|
||||
- `BLE setup`: часть более новых устройств рекламируется и настраивается по Bluetooth.
|
||||
|
||||
Дополнительно у части устройств есть `Matter`, но его не стоит брать как первый путь реализации внутри `Ignis`.
|
||||
|
||||
### Почему задача вообще выглядит решаемой
|
||||
|
||||
Официальные материалы WiZ подтверждают:
|
||||
|
||||
- есть режим `Manual setup` через Wi-Fi лампы `WiZConfig_xxxx`;
|
||||
- есть Bluetooth-based setup для новых устройств;
|
||||
- есть отдельный локальный commissioning-порт `UDP 18266`, который используется только во время ввода устройства в строй.
|
||||
|
||||
Это означает, что задача не упирается в принципиальную закрытость экосистемы. Главная неизвестная не в том, можно ли это сделать, а в том, **какой именно payload нужно передать лампе при commissioning**.
|
||||
|
||||
## Границы задачи
|
||||
|
||||
### Что считаем целевым результатом
|
||||
|
||||
Пользователь открывает `ignis_app`, запускает мастер, выбирает домашнюю Wi-Fi сеть, вводит пароль, переводит лампу в pairing mode, приложение временно подключается к лампе, передаёт credentials, ждёт появления лампы в домашней сети и затем вызывает `POST /devices/rescan` на локальном сервере `ignis-core`.
|
||||
|
||||
### Что не входит в первую фазу
|
||||
|
||||
- полноценный iOS onboarding;
|
||||
- Matter commissioner внутри `Ignis`;
|
||||
- cloud-интеграции WiZ;
|
||||
- поддержка всех возможных поколений устройств одновременно;
|
||||
- идеальный cross-platform UX.
|
||||
|
||||
### Что делаем в первую очередь
|
||||
|
||||
1. Android-only onboarding.
|
||||
2. Сначала `SoftAP`-ветка.
|
||||
3. После неё `BLE`-ветка.
|
||||
4. `Matter` рассматривать только как отдельный fallback/будущее расширение.
|
||||
|
||||
## Текущий статус реализации
|
||||
|
||||
По состоянию на 2026-05-16 в `ignis_app` уже есть первая рабочая Android-first реализация мастера, но она закрывает только часть общей задачи.
|
||||
|
||||
### Что уже реализовано
|
||||
|
||||
- отдельный экран мастера в `ignis_app`;
|
||||
- Android environment inspection через platform channel;
|
||||
- проверка Wi-Fi контекста, системных permissions и активного дома;
|
||||
- Android-first `smart pairing` flow на базе `esp_smartconfig`;
|
||||
- post-provision `POST /devices/rescan` через уже существующий backend `ignis-core`;
|
||||
- release build, `flutter analyze` и тесты проходят;
|
||||
- entrypoint мастера перенесён в `SettingsScreen`, в секцию `Дом и подключение`.
|
||||
|
||||
### Что это означает на практике
|
||||
|
||||
Сейчас в проекте есть не "полноценный универсальный мастер WiZ", а первый технический клин в эту задачу: Android-only путь через smart pairing с последующим discovery в `Ignis`.
|
||||
|
||||
### Что критично ещё не реализовано
|
||||
|
||||
- нет `SoftAP`-ветки через `WiZConfig_xxxx`;
|
||||
- нет reverse engineering и production-реализации commissioning-протокола `UDP 18266`;
|
||||
- нет `BLE`-ветки для новых ламп;
|
||||
- нет `Matter`-fallback;
|
||||
- нет iOS-реализации;
|
||||
- нет подтверждения на реальном железе, что текущий `smart pairing` путь покрывает нужные пользователю модели ламп.
|
||||
|
||||
### Честная оценка текущего состояния
|
||||
|
||||
Тема не закрыта. Закрыт только первый этап.
|
||||
|
||||
Если текущий `smart pairing` на реальных лампах пользователя не сработает, основной следующий шаг -- это не косметика и не polish, а возврат к базовому плану:
|
||||
|
||||
1. `SoftAP / WiZConfig_xxxx`
|
||||
2. commissioning через `UDP 18266`
|
||||
3. затем `BLE`-ветка
|
||||
|
||||
### Что ещё не добито по UX и диагностике
|
||||
|
||||
- нет выбора provisioning mode (`smart pairing` / `WiZConfig_xxxx` / `BLE`);
|
||||
- нет явного fallback UX для ламп, которые не поддерживают текущий путь;
|
||||
- нет финального успешного сценария "создать группу / мигнуть лампой / открыть пульт";
|
||||
- нет отдельного protocol notes файла с результатами reverse engineering;
|
||||
- нет полной ручной test matrix по реальным устройствам разных поколений.
|
||||
|
||||
## Почему основной объём живёт в `ignis_app`
|
||||
|
||||
Onboarding должен жить в `ignis_app`, а не в `ignis-core`, потому что именно телефон:
|
||||
|
||||
- видит nearby Wi-Fi/BLE устройства;
|
||||
- может временно переключаться на AP лампы;
|
||||
- может работать с Android Wi-Fi / Bluetooth API;
|
||||
- может запросить системные permissions и показать пользователю platform dialogs.
|
||||
|
||||
`ignis-core` нужен только после успешного provisioning:
|
||||
|
||||
- сделать `POST /devices/rescan`;
|
||||
- обнаружить лампу в домашней сети;
|
||||
- дальше работать обычным локальным API, который уже реализован.
|
||||
|
||||
Связанные текущие точки в коде:
|
||||
|
||||
- `ignis_app/lib/main.dart`
|
||||
- `ignis_app/lib/screens/homes_screen.dart`
|
||||
- `ignis_app/lib/screens/remote_screen.dart`
|
||||
- `ignis_app/android/app/src/main/kotlin/ru/akokos/ignis_app/MainActivity.kt`
|
||||
- `ignis_app/lib/services/api_client.dart`
|
||||
- `ignis-core/app/api/routes/devices.py`
|
||||
|
||||
## Подтверждённые факты из источников
|
||||
|
||||
### 1. У WiZ есть ручной setup через `WiZConfig_xxxx`
|
||||
|
||||
Официальная legacy-справка WiZ говорит, что если обычное pairing не сработало, нужно идти в `Manual setup`, где телефон подключается напрямую к Wi-Fi сети лампы.
|
||||
|
||||
Источник:
|
||||
|
||||
- https://faq.wizconnected.com/hc/en/3-wiz-legacy/faq/138-adding-a-wiz-light-in-the-system/
|
||||
|
||||
Дополнительный важный нюанс: WiZ отдельно пишет, что у некоторых ламп сеть `WiZConfig_xxxx` может отсутствовать вообще. Это значит, что SoftAP-путь нельзя считать универсальным.
|
||||
|
||||
Источник:
|
||||
|
||||
- https://faq.wizconnected.com/hc/en/3-wiz-legacy/faq/147-can-t-find-wizconfig-xxxx-in-the-wi-fi-settings-during-manual-setup/
|
||||
|
||||
### 2. У новых устройств есть Bluetooth-based setup
|
||||
|
||||
Новая справка WiZ V2 указывает, что Bluetooth permission нужен для Bluetooth-enabled products и что приложение может автоматически находить такие лампы после включения питания.
|
||||
|
||||
Источник:
|
||||
|
||||
- https://faq.wizconnected.com/hc/en/7-wiz-v2/faq/767-smart-lighting---how-to-get-started/
|
||||
|
||||
Дополнительное косвенное подтверждение: в даташитах WiZ/WiZ Pro встречается `Wi-Fi + BLE` и явная фраза про setup via Bluetooth.
|
||||
|
||||
Источники:
|
||||
|
||||
- https://assets.wizconnected.com/datasheets/WiZ_Pro_A60_B22_TW_8W_230V_929002383771_DS1022.pdf
|
||||
- https://assets.wizconnected.com/datasheets/WiZ_Pro_A67_E27_RGBTW_13W_230V_929002449771_DS042023.pdf
|
||||
|
||||
### 3. У WiZ есть отдельный commissioning-порт `UDP 18266`
|
||||
|
||||
Это ключевой технический сигнал. В официальном документе по сети WiZ указано, что порт `18266/UDP` используется локально и только во время commissioning.
|
||||
|
||||
Источник:
|
||||
|
||||
- https://assets.wizconnected.com/manuals/WiZ-Network-Configuration-v2-01162024.pdf
|
||||
|
||||
Вывод: существует локальный commissioning-протокол, который, вероятно, и использует официальное приложение во время первичной посадки лампы в Wi-Fi.
|
||||
|
||||
### 4. Android официально поддерживает bootstrap к локальной Wi-Fi accessory network
|
||||
|
||||
Для Android 10+ есть штатный путь для peer-to-peer Wi-Fi bootstrap через `WifiNetworkSpecifier`.
|
||||
|
||||
Источник:
|
||||
|
||||
- https://developer.android.com/develop/connectivity/wifi/wifi-bootstrap
|
||||
|
||||
### 5. iOS тоже поддерживает accessory Wi-Fi setup, но не это наш первый приоритет
|
||||
|
||||
Для iOS есть `NEHotspotConfiguration` / `NEHotspotConfigurationManager`, а также accessory-oriented Wi-Fi configuration APIs. Это делает iOS-ветку возможной, но отдельной и более дорогой по времени.
|
||||
|
||||
Источники:
|
||||
|
||||
- https://developer.apple.com/documentation/networkextension/wi-fi_configuration
|
||||
- https://developer.apple.com/documentation/networkextension/nehotspotconfigurationmanager
|
||||
|
||||
### 6. Matter есть не на всех WiZ-устройствах
|
||||
|
||||
WiZ пишет, что Matter поддерживают только lights/smart plugs, выпущенные после `Q2 2021`. Поэтому Matter нельзя использовать как универсальный onboarding-path.
|
||||
|
||||
Источник:
|
||||
|
||||
- https://faq.wizconnected.com/hc/en/7-wiz-v2/faq/531-do-all-wiz-devices-support-matter/
|
||||
|
||||
## Принятая стратегия реализации
|
||||
|
||||
### Основной план
|
||||
|
||||
1. Реализовать Android-only `SoftAP onboarding`.
|
||||
2. Через reverse engineering добыть локальный commissioning payload для `UDP 18266`.
|
||||
3. Встроить это в `ignis_app` как отдельный мастер.
|
||||
4. После успешного provisioning вызывать existing `rescanNetwork()` через текущий `IgnisApi`.
|
||||
5. Затем добавить `BLE onboarding` как вторую ветку.
|
||||
|
||||
### Почему не начинать с BLE
|
||||
|
||||
- SoftAP-path подтверждён официальной справкой WiZ.
|
||||
- Android Wi-Fi bootstrap API проще и стабильнее, чем reverse engineering GATT-профиля с нуля.
|
||||
- BLE вероятно потребуется для новых ламп, но SoftAP даст первый рабочий end-to-end flow быстрее.
|
||||
|
||||
### Почему не начинать с Matter
|
||||
|
||||
- не все устройства его поддерживают;
|
||||
- внутри собственного Flutter-приложения Matter commissioner сильно увеличивает объём работ;
|
||||
- для цели `Ignis` нужен именно практичный локальный onboarding WiZ-лампы, а не параллельный smart-home стек.
|
||||
|
||||
## Архитектурное решение по проекту
|
||||
|
||||
## High-Level Flow
|
||||
|
||||
```text
|
||||
Flutter UI
|
||||
-> MethodChannel
|
||||
-> Android provisioning manager
|
||||
-> connect to WiZConfig_xxxx AP
|
||||
-> send commissioning payload to lamp over UDP 18266
|
||||
-> wait for device to leave AP / join home Wi-Fi
|
||||
-> call ignis-core POST /devices/rescan
|
||||
-> show discovered device / success state
|
||||
```
|
||||
|
||||
## Что добавляем в `ignis_app`
|
||||
|
||||
Новая feature-зона:
|
||||
|
||||
- `lib/features/provisioning/models/`
|
||||
- `lib/features/provisioning/providers/`
|
||||
- `lib/features/provisioning/services/`
|
||||
|
||||
Новый экран:
|
||||
|
||||
- `lib/screens/wiz_provisioning_screen.dart`
|
||||
|
||||
Новые Android native классы:
|
||||
|
||||
- `android/app/src/main/kotlin/ru/akokos/ignis_app/WizProvisioningManager.kt`
|
||||
- `android/app/src/main/kotlin/ru/akokos/ignis_app/WizSoftApProvisioner.kt`
|
||||
- `android/app/src/main/kotlin/ru/akokos/ignis_app/WizUdpCommissioningClient.kt`
|
||||
- `android/app/src/main/kotlin/ru/akokos/ignis_app/WizProvisioningModels.kt`
|
||||
|
||||
Вторая очередь, не первая:
|
||||
|
||||
- `android/app/src/main/kotlin/ru/akokos/ignis_app/WizBleProvisioner.kt`
|
||||
|
||||
## Что не нужно делать в `ignis-core`
|
||||
|
||||
Не нужно переносить туда onboarding-логику. Сервер не должен:
|
||||
|
||||
- управлять Wi-Fi телефона;
|
||||
- принимать пароль от домашней сети как основную часть provisioning;
|
||||
- реализовывать platform-specific transport.
|
||||
|
||||
В `ignis-core` достаточно использовать уже существующий:
|
||||
|
||||
- `POST /devices/rescan`
|
||||
|
||||
Текущая клиентская точка входа:
|
||||
|
||||
- `ignis_app/lib/services/api_client.dart` -> `rescanNetwork()`
|
||||
|
||||
## Фактическая реализация против плана
|
||||
|
||||
Изначальный план первой полноценной фазы предполагал старт с `SoftAP onboarding`. В коде сейчас первой реализованной веткой стал `smart pairing`.
|
||||
|
||||
### Почему это важно помнить
|
||||
|
||||
- это ускорило появление первого рабочего мастера;
|
||||
- но это не доказывает, что задача "подключение любых новых WiZ-ламп без официального приложения" уже решена;
|
||||
- исходный `SoftAP + UDP 18266` путь по-прежнему остаётся вероятно более универсальным и по-прежнему нужен, если smart pairing не перекрывает нужные модели устройств.
|
||||
|
||||
## Предлагаемая модель состояния мастера
|
||||
|
||||
Нужна явная state machine, а не набор bool-флагов.
|
||||
|
||||
### Состояния
|
||||
|
||||
- `idle`
|
||||
- `checkingCapabilities`
|
||||
- `requestingPermissions`
|
||||
- `waitingForHomeSelection`
|
||||
- `waitingForPairingMode`
|
||||
- `scanningForSoftAp`
|
||||
- `connectingToLampAp`
|
||||
- `connectedToLampAp`
|
||||
- `commissioning`
|
||||
- `waitingForLampToJoinHome`
|
||||
- `rescanningIgnis`
|
||||
- `success`
|
||||
- `failure`
|
||||
- `cancelled`
|
||||
|
||||
### Данные состояния
|
||||
|
||||
- `selectedHomeSsid`
|
||||
- `selectedHomePassphrase`
|
||||
- `selectedIgnisHomeId`
|
||||
- `lampApSsid`
|
||||
- `attempt`
|
||||
- `lastError`
|
||||
- `debugTimeline`
|
||||
- `startedAt`
|
||||
- `finishedAt`
|
||||
|
||||
### Почему нужен `debugTimeline`
|
||||
|
||||
Provisioning без timeline очень трудно дебажить. Минимум нужен список событий:
|
||||
|
||||
- permission granted/denied;
|
||||
- lamp AP discovered;
|
||||
- Wi-Fi request sent;
|
||||
- Wi-Fi request accepted/rejected;
|
||||
- UDP payload sent;
|
||||
- ack received / timeout;
|
||||
- device disappeared from AP;
|
||||
- `rescan` started/finished.
|
||||
|
||||
## UX-структура мастера
|
||||
|
||||
### Экран 1. Введение
|
||||
|
||||
Показывает:
|
||||
|
||||
- что мастер пока Android-only;
|
||||
- какие лампы могут не поддерживать SoftAP;
|
||||
- что телефон временно переключится на сеть лампы;
|
||||
- что нужен локальный сервер `Ignis` и доступный активный дом.
|
||||
|
||||
### Экран 2. Проверка контекста
|
||||
|
||||
Проверки:
|
||||
|
||||
- выбран ли активный `HomeConfig`;
|
||||
- доступен ли `auth/me` текущего дома;
|
||||
- есть ли Wi-Fi на телефоне;
|
||||
- есть ли необходимые Android permissions;
|
||||
- Android API level >= 29.
|
||||
|
||||
### Экран 3. Выбор домашней сети
|
||||
|
||||
Варианты:
|
||||
|
||||
- ручной ввод SSID и пароля;
|
||||
- позже, если потребуется, попытка показать текущий SSID как подсказку.
|
||||
|
||||
Важно: не делать магии вокруг чтения текущего SSID как обязательного пути. На Android это permission-sensitive и OEM-dependent.
|
||||
|
||||
### Экран 4. Инструкция перевода лампы в pairing mode
|
||||
|
||||
Нужно явно объяснить:
|
||||
|
||||
- включить лампу;
|
||||
- если не находится, несколько раз выключить/включить до мигания;
|
||||
- если лампа не поднимает `WiZConfig_xxxx`, значит этот путь может не поддерживаться и нужен BLE-path.
|
||||
|
||||
### Экран 5. Подключение и provisioning
|
||||
|
||||
Показывать по шагам:
|
||||
|
||||
- поиск `WiZConfig_xxxx`;
|
||||
- подключение;
|
||||
- передача настроек;
|
||||
- ожидание возврата лампы в домашнюю сеть;
|
||||
- запрос `rescan`.
|
||||
|
||||
### Экран 6. Итог
|
||||
|
||||
Успех:
|
||||
|
||||
- показать, что лампа найдена;
|
||||
- предложить перейти к созданию группы или вернуться на пульт.
|
||||
|
||||
Ошибка:
|
||||
|
||||
- показать, на каком шаге упало;
|
||||
- дать actionable retry;
|
||||
- предлагать BLE-path только когда он будет реализован.
|
||||
|
||||
## Точки встраивания в текущее приложение
|
||||
|
||||
Минимально логичное место входа:
|
||||
|
||||
- `HomesScreen`: отдельная кнопка "Добавить лампу WiZ"
|
||||
- или `RemoteScreen` в overflow menu
|
||||
|
||||
Рекомендуемый вариант первой версии:
|
||||
|
||||
- вход из `HomesScreen`, потому что provisioning логически относится к конкретному дому/серверу.
|
||||
|
||||
Причина:
|
||||
|
||||
- мастер зависит от выбранного активного дома;
|
||||
- после provisioning всё равно нужен `rescan` именно этого дома;
|
||||
- onboarding новой лампы ближе по смыслу к инфраструктуре дома, чем к управлению существующими группами.
|
||||
|
||||
## Android implementation plan
|
||||
|
||||
## Phase 1: Platform Bridge Skeleton
|
||||
|
||||
### Цель
|
||||
|
||||
Подготовить безопасный мост Flutter <-> Android без реальной provisioning-логики.
|
||||
|
||||
### Изменяемые файлы
|
||||
|
||||
- `ignis_app/android/app/src/main/kotlin/ru/akokos/ignis_app/MainActivity.kt`
|
||||
- новые Kotlin-файлы из секции выше
|
||||
- новые Dart provider/service/screen файлы
|
||||
|
||||
### MethodChannel
|
||||
|
||||
Использовать отдельный channel, не смешивать с geofence.
|
||||
|
||||
Предлагаемое имя:
|
||||
|
||||
- `ignis/wiz_provisioning`
|
||||
|
||||
### Методы канала v1
|
||||
|
||||
- `getProvisioningCapabilities`
|
||||
- `requestProvisioningPermissions`
|
||||
- `startSoftApProvisioning`
|
||||
- `cancelProvisioning`
|
||||
|
||||
### Формат `getProvisioningCapabilities`
|
||||
|
||||
Возвращать map:
|
||||
|
||||
- `platform`: `android`
|
||||
- `androidApiLevel`
|
||||
- `supportsWifiNetworkSpecifier`
|
||||
- `supportsBle`
|
||||
- `supportedModes`: `["softap"]` на первой фазе
|
||||
|
||||
### Формат `startSoftApProvisioning`
|
||||
|
||||
Вход:
|
||||
|
||||
- `homeSsid`
|
||||
- `homePassphrase`
|
||||
- `lampApPrefix` default `WiZConfig_`
|
||||
- `timeoutSeconds`
|
||||
- `activeHomeBaseUrl`
|
||||
- `activeHomeApiKey`
|
||||
|
||||
На первой фазе `activeHomeBaseUrl` и `activeHomeApiKey` можно вообще не передавать в native и оставить `rescan` на Flutter-стороне.
|
||||
|
||||
## Phase 2: Android Wi-Fi Connect to Lamp AP
|
||||
|
||||
### Цель
|
||||
|
||||
Научиться гарантированно подключать телефон к `WiZConfig_xxxx`.
|
||||
|
||||
### Android APIs
|
||||
|
||||
- `WifiNetworkSpecifier`
|
||||
- `ConnectivityManager.requestNetwork()`
|
||||
- `NetworkCallback`
|
||||
|
||||
Источник:
|
||||
|
||||
- https://developer.android.com/develop/connectivity/wifi/wifi-bootstrap
|
||||
|
||||
### Требуемые permissions и manifest changes
|
||||
|
||||
Проверить и добавить по необходимости:
|
||||
|
||||
- `android.permission.NEARBY_WIFI_DEVICES` для Android 13+
|
||||
- существующие location permissions уже частично есть
|
||||
|
||||
Текущий manifest:
|
||||
|
||||
- `ignis_app/android/app/src/main/AndroidManifest.xml`
|
||||
|
||||
### Что нужно реализовать
|
||||
|
||||
- запрос permissions из Flutter;
|
||||
- поиск/выбор AP по префиксу `WiZConfig_`;
|
||||
- запрос на временное подключение;
|
||||
- ожидание `onAvailable()`;
|
||||
- bind сокетов к этой `Network`, если потребуется;
|
||||
- корректный release callback после завершения или отмены.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- приложение может подключиться к реальной сети `WiZConfig_xxxx`;
|
||||
- есть надёжный таймаут и понятный error mapping;
|
||||
- повторный запуск после неудачи не оставляет висящих network requests.
|
||||
|
||||
## Phase 3: Reverse Engineering Commissioning Payload
|
||||
|
||||
### Это главный риск проекта
|
||||
|
||||
Пока нет публичного официального описания payload для `UDP 18266`.
|
||||
|
||||
Нужно добыть его экспериментально.
|
||||
|
||||
### Практический план reverse engineering
|
||||
|
||||
1. Взять лампу, которая точно поддерживает `WiZConfig_xxxx`.
|
||||
2. Перевести лампу в pairing mode.
|
||||
3. Запустить официальный WiZ app и пройти `Manual setup`.
|
||||
4. Снять трафик между телефоном и лампой во время provisioning.
|
||||
5. Отделить трафик к лампе от фонового шума.
|
||||
6. Найти обмен по `UDP 18266`.
|
||||
7. Зафиксировать:
|
||||
- формат payload;
|
||||
- есть ли ответ/ack;
|
||||
- есть ли checksum/nonce/session id;
|
||||
- передаётся ли SSID/пароль в открытом виде, JSON, protobuf, бинарнике и т.д.
|
||||
8. Повторить пару раз с разными SSID/password, чтобы понять структуру.
|
||||
|
||||
### Что именно нужно получить в результате
|
||||
|
||||
- пример сырого запроса;
|
||||
- пример сырого ответа;
|
||||
- описание полей;
|
||||
- минимальный payload, достаточный для посадки лампы;
|
||||
- список обязательных и необязательных параметров.
|
||||
|
||||
### Какие инструменты могут понадобиться
|
||||
|
||||
- Android device + официальный WiZ app;
|
||||
- отдельная тестовая Wi-Fi сеть;
|
||||
- Wireshark / tcpdump / mitm на уровне точки доступа;
|
||||
- при необходимости второй Android с hotspot/sniffer схемой;
|
||||
- возможно `adb bugreport` / network diagnostics как вспомогательный путь.
|
||||
|
||||
### Решение по хранению результата
|
||||
|
||||
После расшифровки протокола создать отдельный внутренний документ:
|
||||
|
||||
- `ignis_app/docs/wiz_commissioning_protocol_notes.md`
|
||||
|
||||
Если там будут чувствительные детали или сырые бинарные дампы, можно держать файл локально и не коммитить, но лучше иметь хотя бы структурированное описание в репозитории.
|
||||
|
||||
## Phase 4: Implement UDP Commissioning Client
|
||||
|
||||
### Цель
|
||||
|
||||
Собрать Kotlin-клиент, который:
|
||||
|
||||
- открывает UDP socket в сети лампы;
|
||||
- отправляет commissioning payload на `18266`;
|
||||
- ждёт подтверждение или фиксирует timeout;
|
||||
- отдаёт во Flutter понятный result object.
|
||||
|
||||
### Предлагаемый класс
|
||||
|
||||
- `WizUdpCommissioningClient.kt`
|
||||
|
||||
### Предлагаемый API
|
||||
|
||||
- `suspend fun sendCredentials(network: Network, targetIp: InetAddress, payload: ByteArray): CommissioningResult`
|
||||
|
||||
### Что нужно предусмотреть
|
||||
|
||||
- bind сокета именно к `Network`, через которую подключились к AP лампы;
|
||||
- configurable timeout;
|
||||
- раздельные ошибки:
|
||||
- `socket_open_failed`
|
||||
- `payload_build_failed`
|
||||
- `send_failed`
|
||||
- `ack_timeout`
|
||||
- `malformed_ack`
|
||||
|
||||
## Phase 5: End-to-End Flutter Flow
|
||||
|
||||
### Цель
|
||||
|
||||
Сделать законченный пользовательский мастер.
|
||||
|
||||
### Новые Dart сущности
|
||||
|
||||
Предлагаемые файлы:
|
||||
|
||||
- `lib/features/provisioning/models/wiz_provisioning_mode.dart`
|
||||
- `lib/features/provisioning/models/wiz_provisioning_state.dart`
|
||||
- `lib/features/provisioning/models/wiz_provisioning_failure.dart`
|
||||
- `lib/features/provisioning/services/wiz_provisioning_platform_service.dart`
|
||||
- `lib/features/provisioning/providers/wiz_provisioning_providers.dart`
|
||||
- `lib/screens/wiz_provisioning_screen.dart`
|
||||
|
||||
### Поведение после успешного provisioning
|
||||
|
||||
1. Flutter получает успех от native-слоя.
|
||||
2. Flutter ждёт короткое окно на переподключение лампы к домашней сети.
|
||||
3. Flutter вызывает текущий `IgnisApi.rescanNetwork()`.
|
||||
4. Flutter обновляет список устройств/групп.
|
||||
5. Flutter показывает success и, если возможно, имя/IP/MAC найденной лампы.
|
||||
|
||||
Текущая клиентская точка:
|
||||
|
||||
- `ignis_app/lib/services/api_client.dart`
|
||||
|
||||
Текущий backend route:
|
||||
|
||||
- `ignis-core/app/api/routes/devices.py`
|
||||
|
||||
## Phase 6: BLE Branch
|
||||
|
||||
### Когда переходить к BLE
|
||||
|
||||
Только после того, как SoftAP-path уже рабочий end-to-end.
|
||||
|
||||
### Что нужно выяснить перед реализацией
|
||||
|
||||
- advertise name/service UUID лампы в pairing mode;
|
||||
- GATT services/characteristics;
|
||||
- какой transport и payload используются для передачи Wi-Fi credentials;
|
||||
- есть ли там тот же commissioning payload, что и в SoftAP-path, или совсем другой протокол.
|
||||
|
||||
### Android tech stack для BLE
|
||||
|
||||
- `BluetoothManager`
|
||||
- `BluetoothAdapter`
|
||||
- `BluetoothLeScanner`
|
||||
- `ScanCallback`
|
||||
- `BluetoothGatt`
|
||||
|
||||
### Дополнительные permissions
|
||||
|
||||
- `android.permission.BLUETOOTH_SCAN`
|
||||
- `android.permission.BLUETOOTH_CONNECT`
|
||||
|
||||
### Важное ограничение
|
||||
|
||||
Пока нет сведений, что BLE-path у WiZ можно поднять без reverse engineering. Официальные источники подтверждают наличие Bluetooth setup, но не описывают низкоуровневый протокол.
|
||||
|
||||
## Что менять в UI и навигации
|
||||
|
||||
### Первая версия
|
||||
|
||||
Добавить в `HomesScreen` вторую FAB-entry или secondary action:
|
||||
|
||||
- `Добавить дом`
|
||||
- `Добавить лампу WiZ`
|
||||
|
||||
Если не хочется перегружать основной FAB, сделать extended bottom sheet / speed dial / отдельную кнопку в пустом состоянии.
|
||||
|
||||
### Более аккуратный вариант
|
||||
|
||||
Добавить на `HomesScreen` card/banner:
|
||||
|
||||
- "Новая лампа WiZ? Открыть мастер подключения"
|
||||
|
||||
### Что не делать
|
||||
|
||||
- не прятать этот flow глубоко в `SettingsScreen`;
|
||||
- не запускать onboarding из `RemoteScreen` по умолчанию, если активный дом ещё не выбран и невалиден.
|
||||
|
||||
## Безопасность и хранение Wi-Fi credentials
|
||||
|
||||
### Минимальные правила
|
||||
|
||||
- пароль домашней Wi-Fi сети не хранить в `SharedPreferences`;
|
||||
- не класть пароль в обычные debug-логи;
|
||||
- не записывать пароль в event log приложения;
|
||||
- держать пароль только в runtime memory на время onboarding-сессии;
|
||||
- по завершении или ошибке занулять/очищать state.
|
||||
|
||||
### Что можно хранить
|
||||
|
||||
- только SSID как UX convenience, если это вообще нужно;
|
||||
- debug timeline без секретов;
|
||||
- machine-readable error codes.
|
||||
|
||||
## Тестовая стратегия
|
||||
|
||||
## Unit / Widget Tests
|
||||
|
||||
Покрыть:
|
||||
|
||||
- state machine;
|
||||
- error mapping native -> Flutter;
|
||||
- retry/cancel logic;
|
||||
- post-success переход к `rescan`.
|
||||
|
||||
### Предлагаемые test-файлы
|
||||
|
||||
- `test/wiz_provisioning_state_test.dart`
|
||||
- `test/wiz_provisioning_notifier_test.dart`
|
||||
- `test/wiz_provisioning_screen_test.dart`
|
||||
|
||||
## Android Native Tests
|
||||
|
||||
Минимум:
|
||||
|
||||
- локальные unit tests на payload builder;
|
||||
- по возможности instrumentation tests на permission/result mapping.
|
||||
|
||||
Но главное здесь всё равно manual verification на реальном устройстве.
|
||||
|
||||
## Manual Test Matrix
|
||||
|
||||
Обязательные ручные сценарии:
|
||||
|
||||
1. Успешная посадка лампы через `WiZConfig_xxxx`.
|
||||
2. Таймаут при отсутствии pairing mode.
|
||||
3. Ошибочный пароль домашней сети.
|
||||
4. Повторный запуск мастера сразу после ошибки.
|
||||
5. Отмена пользователем system dialog подключения к Wi-Fi.
|
||||
6. Потеря Wi-Fi во время provisioning.
|
||||
7. Успешный `rescan` после provisioning.
|
||||
8. Provisioning success, но `rescan` не находит лампу.
|
||||
9. Лампа без `WiZConfig_xxxx` и корректный UX fallback.
|
||||
10. Проверка на Android 13+ с новыми permission flows.
|
||||
|
||||
## Acceptance Criteria для первой поставляемой версии
|
||||
|
||||
Первая версия считается завершённой, если:
|
||||
|
||||
- мастер доступен из `ignis_app`;
|
||||
- Android 10+ устройство умеет подключиться к `WiZConfig_xxxx`;
|
||||
- приложение умеет отправить commissioning payload и получить воспроизводимый результат;
|
||||
- лампа уходит в домашнюю сеть;
|
||||
- `Ignis` после `rescan` видит лампу;
|
||||
- пользователь получает понятный success/failure UX;
|
||||
- чувствительные данные не остаются в persistent storage;
|
||||
- есть тесты на Dart state machine и минимум ручной regression checklist.
|
||||
|
||||
## Пошаговая карта действий
|
||||
|
||||
## Шаг 0. Подготовка перед кодом
|
||||
|
||||
1. Подтвердить наличие тестовой WiZ-лампы с `WiZConfig_xxxx`.
|
||||
2. Подготовить Android-девайс для ручных прогонов.
|
||||
3. Подготовить отдельную тестовую Wi-Fi сеть `2.4 GHz`.
|
||||
4. Подготовить стенд с рабочим `ignis-core`.
|
||||
5. Решить, где и как снимать provisioning traffic.
|
||||
|
||||
## Шаг 1. Скелет feature в `ignis_app`
|
||||
|
||||
1. Добавить `docs/` и этот план.
|
||||
2. Создать feature-папки `lib/features/provisioning/*`.
|
||||
3. Создать Dart-модели состояния.
|
||||
4. Создать `WizProvisioningPlatformService`.
|
||||
5. Создать новый `MethodChannel`.
|
||||
6. Добавить пустой экран мастера и точку входа из `HomesScreen`.
|
||||
|
||||
## Шаг 2. Android bridge без реального provisioning
|
||||
|
||||
1. Добавить `WizProvisioningManager.kt`.
|
||||
2. Подключить channel в `MainActivity.kt`.
|
||||
3. Реализовать `getProvisioningCapabilities`.
|
||||
4. Реализовать `requestProvisioningPermissions`.
|
||||
5. Протянуть результат в UI и показать capability gate.
|
||||
|
||||
## Шаг 3. SoftAP connection layer
|
||||
|
||||
1. Добавить `WifiNetworkSpecifier` flow.
|
||||
2. Сделать поиск/подключение к `WiZConfig_`.
|
||||
3. Вернуть во Flutter structured result.
|
||||
4. Обработать cancel/timeout.
|
||||
5. Проверить ручным прогоном на тестовой точке доступа.
|
||||
|
||||
## Шаг 4. Reverse engineering commissioning payload
|
||||
|
||||
1. Снять трафик официального WiZ app.
|
||||
2. Описать протокол.
|
||||
3. Зафиксировать findings в отдельном notes-файле.
|
||||
4. Только после этого писать production `WizUdpCommissioningClient`.
|
||||
|
||||
## Шаг 5. Реальный commissioning
|
||||
|
||||
1. Реализовать payload builder.
|
||||
2. Реализовать отправку на `UDP 18266`.
|
||||
3. Обработать ack/timeout.
|
||||
4. Завести детальные internal error codes.
|
||||
|
||||
## Шаг 6. End-to-end flow с `Ignis`
|
||||
|
||||
1. После native success вызвать `rescanNetwork()`.
|
||||
2. Дождаться ответа backend.
|
||||
3. Показать найденное устройство или отдельную ошибку post-provision discovery.
|
||||
4. Протестировать повторяемость.
|
||||
|
||||
## Шаг 7. Cleanup и hardening
|
||||
|
||||
1. Удалить лишние debug-логи.
|
||||
2. Проверить, что пароль Wi-Fi нигде не сохраняется.
|
||||
3. Добавить unit/widget tests.
|
||||
4. Обновить `README.md`.
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
### Технические
|
||||
|
||||
- Как именно выглядит payload для `UDP 18266`?
|
||||
- Нужен ли specific target IP на AP лампы или есть broadcast?
|
||||
- Есть ли обязательный ack и как он кодируется?
|
||||
- Нужен ли bind сокета к `Network` для всех Android OEM или только для части?
|
||||
- Как определить успешность до `rescan`: по ack или по исчезновению AP?
|
||||
|
||||
### Продуктовые
|
||||
|
||||
- Где именно располагать entrypoint мастера в UI?
|
||||
- Нужен ли в первой версии ручной ввод SSID, или можно сразу только auto-fill + редактирование?
|
||||
- Нужен ли отдельный экран выбора provisioning mode, если BLE ещё не реализован?
|
||||
|
||||
### Операционные
|
||||
|
||||
- Есть ли в наличии лампа старого поколения с `WiZConfig_xxxx`?
|
||||
- Нужны ли апрувы на Android permissions / реальные ручные прогоны / возможную установку вспомогательных инструментов для sniffing?
|
||||
|
||||
## Что проверять первым делом при возвращении к задаче
|
||||
|
||||
Если вернулись к задаче через долгое время, стартовать так:
|
||||
|
||||
1. Перечитать этот документ целиком.
|
||||
2. Проверить, не появились ли новые официальные WiZ материалы про local commissioning.
|
||||
3. Проверить, не изменились ли Android Wi-Fi/BLE permission требования.
|
||||
4. Уточнить, какая именно тестовая лампа есть на руках.
|
||||
5. Решить, можно ли сразу идти в reverse engineering или сначала поднимать только bridge/UI skeleton.
|
||||
|
||||
## Рекомендуемый следующий practically useful шаг
|
||||
|
||||
Когда будет время на апрувы и ручные проверки, не начинать сразу с большого рефактора. Самый выгодный порядок:
|
||||
|
||||
1. закоммитить UI skeleton и Android bridge;
|
||||
2. проверить Wi-Fi connect к `WiZConfig_xxxx`;
|
||||
3. только потом тратить время на reverse engineering `UDP 18266`.
|
||||
|
||||
Это минимизирует риск закопаться в неизвестный протокол до того, как станет ясно, что platform plumbing вообще работает на конкретном устройстве.
|
||||
|
||||
## Источники
|
||||
|
||||
### WiZ
|
||||
|
||||
- WiZ manual setup / `WiZConfig_xxxx`: https://faq.wizconnected.com/hc/en/3-wiz-legacy/faq/138-adding-a-wiz-light-in-the-system/
|
||||
- WiZ: у части ламп `WiZConfig_xxxx` может отсутствовать: https://faq.wizconnected.com/hc/en/3-wiz-legacy/faq/147-can-t-find-wizconfig-xxxx-in-the-wi-fi-settings-during-manual-setup/
|
||||
- WiZ V2 getting started / Bluetooth-enabled setup: https://faq.wizconnected.com/hc/en/7-wiz-v2/faq/767-smart-lighting---how-to-get-started/
|
||||
- WiZ network configuration / commissioning port `UDP 18266`: https://assets.wizconnected.com/manuals/WiZ-Network-Configuration-v2-01162024.pdf
|
||||
- WiZ Matter support coverage: https://faq.wizconnected.com/hc/en/7-wiz-v2/faq/531-do-all-wiz-devices-support-matter/
|
||||
- WiZ Pro A60 datasheet / `Wi-Fi + BLE`: https://assets.wizconnected.com/datasheets/WiZ_Pro_A60_B22_TW_8W_230V_929002383771_DS1022.pdf
|
||||
- WiZ Pro A67 datasheet / setup via Bluetooth: https://assets.wizconnected.com/datasheets/WiZ_Pro_A67_E27_RGBTW_13W_230V_929002449771_DS042023.pdf
|
||||
|
||||
### Android
|
||||
|
||||
- Wi-Fi bootstrap / `WifiNetworkSpecifier`: https://developer.android.com/develop/connectivity/wifi/wifi-bootstrap
|
||||
- `WifiNetworkSpecifier.Builder` reference: https://developer.android.com/reference/android/net/wifi/WifiNetworkSpecifier.Builder.html
|
||||
|
||||
### Apple
|
||||
|
||||
- Wi-Fi configuration overview: https://developer.apple.com/documentation/networkextension/wi-fi_configuration
|
||||
- `NEHotspotConfigurationManager`: https://developer.apple.com/documentation/networkextension/nehotspotconfigurationmanager
|
||||
|
||||
## Связанные локальные файлы
|
||||
|
||||
- `ignis_app/lib/main.dart`
|
||||
- `ignis_app/lib/screens/homes_screen.dart`
|
||||
- `ignis_app/lib/screens/remote_screen.dart`
|
||||
- `ignis_app/lib/services/api_client.dart`
|
||||
- `ignis_app/android/app/src/main/AndroidManifest.xml`
|
||||
- `ignis_app/android/app/src/main/kotlin/ru/akokos/ignis_app/MainActivity.kt`
|
||||
- `ignis-core/app/api/routes/devices.py`
|
||||
- `ignis-core/README.md`
|
||||
@@ -0,0 +1,6 @@
|
||||
class WizProvisioningDevice {
|
||||
final String bssid;
|
||||
final String? ipAddress;
|
||||
|
||||
const WizProvisioningDevice({required this.bssid, this.ipAddress});
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
enum WizProvisioningPermissionStatus { granted, requestable, settingsRequired }
|
||||
|
||||
class WizProvisioningEnvironment {
|
||||
final String platform;
|
||||
final int? androidApiLevel;
|
||||
final bool smartPairingSupported;
|
||||
final bool wifiSettingsSupported;
|
||||
final bool appSettingsSupported;
|
||||
final WizProvisioningPermissionStatus permissionStatus;
|
||||
final bool locationServicesEnabled;
|
||||
final bool connectedToWifi;
|
||||
final String? ssid;
|
||||
final String? bssid;
|
||||
final int? frequencyMhz;
|
||||
|
||||
const WizProvisioningEnvironment({
|
||||
required this.platform,
|
||||
required this.androidApiLevel,
|
||||
required this.smartPairingSupported,
|
||||
required this.wifiSettingsSupported,
|
||||
required this.appSettingsSupported,
|
||||
required this.permissionStatus,
|
||||
required this.locationServicesEnabled,
|
||||
required this.connectedToWifi,
|
||||
required this.ssid,
|
||||
required this.bssid,
|
||||
required this.frequencyMhz,
|
||||
});
|
||||
|
||||
factory WizProvisioningEnvironment.unsupported() =>
|
||||
const WizProvisioningEnvironment(
|
||||
platform: 'unknown',
|
||||
androidApiLevel: null,
|
||||
smartPairingSupported: false,
|
||||
wifiSettingsSupported: false,
|
||||
appSettingsSupported: false,
|
||||
permissionStatus: WizProvisioningPermissionStatus.granted,
|
||||
locationServicesEnabled: true,
|
||||
connectedToWifi: false,
|
||||
ssid: null,
|
||||
bssid: null,
|
||||
frequencyMhz: null,
|
||||
);
|
||||
|
||||
factory WizProvisioningEnvironment.fromMap(Map<String, dynamic> raw) {
|
||||
return WizProvisioningEnvironment(
|
||||
platform: raw['platform'] as String? ?? 'unknown',
|
||||
androidApiLevel: (raw['androidApiLevel'] as num?)?.toInt(),
|
||||
smartPairingSupported: raw['smartPairingSupported'] == true,
|
||||
wifiSettingsSupported: raw['wifiSettingsSupported'] == true,
|
||||
appSettingsSupported: raw['appSettingsSupported'] == true,
|
||||
permissionStatus: _permissionStatusFromPlatformValue(
|
||||
raw['permissionStatus'] as String?,
|
||||
),
|
||||
locationServicesEnabled: raw['locationServicesEnabled'] != false,
|
||||
connectedToWifi: raw['connectedToWifi'] == true,
|
||||
ssid: _normalizeText(raw['ssid']),
|
||||
bssid: _normalizeText(raw['bssid']),
|
||||
frequencyMhz: (raw['frequencyMhz'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
|
||||
bool get permissionsGranted =>
|
||||
permissionStatus == WizProvisioningPermissionStatus.granted;
|
||||
|
||||
bool get permissionRequestable =>
|
||||
permissionStatus == WizProvisioningPermissionStatus.requestable;
|
||||
|
||||
bool get requiresAppSettings =>
|
||||
permissionStatus == WizProvisioningPermissionStatus.settingsRequired;
|
||||
|
||||
bool get isAndroid => platform == 'android';
|
||||
|
||||
bool get isOn24Ghz =>
|
||||
frequencyMhz != null && frequencyMhz! >= 2400 && frequencyMhz! < 2500;
|
||||
|
||||
bool get isLikelyOn5Ghz =>
|
||||
frequencyMhz != null && frequencyMhz! >= 4900 && frequencyMhz! < 6000;
|
||||
|
||||
WizProvisioningEnvironment copyWith({
|
||||
String? platform,
|
||||
int? androidApiLevel,
|
||||
bool? smartPairingSupported,
|
||||
bool? wifiSettingsSupported,
|
||||
bool? appSettingsSupported,
|
||||
WizProvisioningPermissionStatus? permissionStatus,
|
||||
bool? locationServicesEnabled,
|
||||
bool? connectedToWifi,
|
||||
String? ssid,
|
||||
String? bssid,
|
||||
int? frequencyMhz,
|
||||
bool clearWifiInfo = false,
|
||||
}) {
|
||||
return WizProvisioningEnvironment(
|
||||
platform: platform ?? this.platform,
|
||||
androidApiLevel: androidApiLevel ?? this.androidApiLevel,
|
||||
smartPairingSupported:
|
||||
smartPairingSupported ?? this.smartPairingSupported,
|
||||
wifiSettingsSupported:
|
||||
wifiSettingsSupported ?? this.wifiSettingsSupported,
|
||||
appSettingsSupported: appSettingsSupported ?? this.appSettingsSupported,
|
||||
permissionStatus: permissionStatus ?? this.permissionStatus,
|
||||
locationServicesEnabled:
|
||||
locationServicesEnabled ?? this.locationServicesEnabled,
|
||||
connectedToWifi: connectedToWifi ?? this.connectedToWifi,
|
||||
ssid: clearWifiInfo ? null : (ssid ?? this.ssid),
|
||||
bssid: clearWifiInfo ? null : (bssid ?? this.bssid),
|
||||
frequencyMhz: clearWifiInfo ? null : (frequencyMhz ?? this.frequencyMhz),
|
||||
);
|
||||
}
|
||||
|
||||
static WizProvisioningPermissionStatus _permissionStatusFromPlatformValue(
|
||||
String? value,
|
||||
) {
|
||||
switch (value) {
|
||||
case 'granted':
|
||||
return WizProvisioningPermissionStatus.granted;
|
||||
case 'settings_required':
|
||||
return WizProvisioningPermissionStatus.settingsRequired;
|
||||
case 'requestable':
|
||||
default:
|
||||
return WizProvisioningPermissionStatus.requestable;
|
||||
}
|
||||
}
|
||||
|
||||
static String? _normalizeText(Object? raw) {
|
||||
final text = raw as String?;
|
||||
if (text == null) {
|
||||
return null;
|
||||
}
|
||||
final trimmed = text.trim();
|
||||
return trimmed.isEmpty ? null : trimmed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
enum WizProvisioningFailureKind {
|
||||
noActiveHome,
|
||||
unsupportedPlatform,
|
||||
missingPermissions,
|
||||
locationServicesDisabled,
|
||||
wifiUnavailable,
|
||||
invalidSsid,
|
||||
provisioningTimedOut,
|
||||
provisioningFailed,
|
||||
rescanFailed,
|
||||
}
|
||||
|
||||
class WizProvisioningFailure {
|
||||
final WizProvisioningFailureKind kind;
|
||||
final String message;
|
||||
final String? details;
|
||||
|
||||
const WizProvisioningFailure({
|
||||
required this.kind,
|
||||
required this.message,
|
||||
this.details,
|
||||
});
|
||||
}
|
||||
114
lib/features/provisioning/models/wiz_provisioning_state.dart
Normal file
114
lib/features/provisioning/models/wiz_provisioning_state.dart
Normal file
@@ -0,0 +1,114 @@
|
||||
import 'wiz_provisioning_device.dart';
|
||||
import 'wiz_provisioning_environment.dart';
|
||||
import 'wiz_provisioning_failure.dart';
|
||||
|
||||
enum WizProvisioningStatus {
|
||||
initial,
|
||||
loadingEnvironment,
|
||||
attentionRequired,
|
||||
ready,
|
||||
provisioning,
|
||||
rescanning,
|
||||
success,
|
||||
failure,
|
||||
unsupported,
|
||||
}
|
||||
|
||||
class WizRescanSummary {
|
||||
final int found;
|
||||
final int added;
|
||||
final int updated;
|
||||
final int removedOffline;
|
||||
final int pendingRemoval;
|
||||
final int online;
|
||||
|
||||
const WizRescanSummary({
|
||||
required this.found,
|
||||
required this.added,
|
||||
required this.updated,
|
||||
required this.removedOffline,
|
||||
required this.pendingRemoval,
|
||||
required this.online,
|
||||
});
|
||||
|
||||
factory WizRescanSummary.fromMap(Map<String, dynamic> raw) {
|
||||
return WizRescanSummary(
|
||||
found: (raw['found'] as num?)?.toInt() ?? 0,
|
||||
added: (raw['added'] as num?)?.toInt() ?? 0,
|
||||
updated: (raw['updated'] as num?)?.toInt() ?? 0,
|
||||
removedOffline: (raw['removed_offline'] as num?)?.toInt() ?? 0,
|
||||
pendingRemoval: (raw['pending_removal'] as num?)?.toInt() ?? 0,
|
||||
online: (raw['online'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WizProvisioningState {
|
||||
final WizProvisioningStatus status;
|
||||
final WizProvisioningEnvironment environment;
|
||||
final String? activeHomeName;
|
||||
final WizProvisioningFailure? failure;
|
||||
final WizRescanSummary? rescanSummary;
|
||||
final List<WizProvisioningDevice> provisionedDevices;
|
||||
final List<String> timeline;
|
||||
final String? notice;
|
||||
|
||||
const WizProvisioningState({
|
||||
required this.status,
|
||||
required this.environment,
|
||||
required this.activeHomeName,
|
||||
required this.failure,
|
||||
required this.rescanSummary,
|
||||
required this.provisionedDevices,
|
||||
required this.timeline,
|
||||
required this.notice,
|
||||
});
|
||||
|
||||
factory WizProvisioningState.initial() => WizProvisioningState(
|
||||
status: WizProvisioningStatus.initial,
|
||||
environment: WizProvisioningEnvironment.unsupported(),
|
||||
activeHomeName: null,
|
||||
failure: null,
|
||||
rescanSummary: null,
|
||||
provisionedDevices: const [],
|
||||
timeline: const [],
|
||||
notice: null,
|
||||
);
|
||||
|
||||
bool get isBusy =>
|
||||
status == WizProvisioningStatus.loadingEnvironment ||
|
||||
status == WizProvisioningStatus.provisioning ||
|
||||
status == WizProvisioningStatus.rescanning;
|
||||
|
||||
bool get canStart =>
|
||||
status == WizProvisioningStatus.ready ||
|
||||
status == WizProvisioningStatus.failure ||
|
||||
status == WizProvisioningStatus.attentionRequired;
|
||||
|
||||
WizProvisioningState copyWith({
|
||||
WizProvisioningStatus? status,
|
||||
WizProvisioningEnvironment? environment,
|
||||
String? activeHomeName,
|
||||
WizProvisioningFailure? failure,
|
||||
bool clearFailure = false,
|
||||
WizRescanSummary? rescanSummary,
|
||||
bool clearRescanSummary = false,
|
||||
List<WizProvisioningDevice>? provisionedDevices,
|
||||
List<String>? timeline,
|
||||
String? notice,
|
||||
bool clearNotice = false,
|
||||
}) {
|
||||
return WizProvisioningState(
|
||||
status: status ?? this.status,
|
||||
environment: environment ?? this.environment,
|
||||
activeHomeName: activeHomeName ?? this.activeHomeName,
|
||||
failure: clearFailure ? null : (failure ?? this.failure),
|
||||
rescanSummary: clearRescanSummary
|
||||
? null
|
||||
: (rescanSummary ?? this.rescanSummary),
|
||||
provisionedDevices: provisionedDevices ?? this.provisionedDevices,
|
||||
timeline: timeline ?? this.timeline,
|
||||
notice: clearNotice ? null : (notice ?? this.notice),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
class WizProvisioningTiming {
|
||||
final Duration provisioningTimeout;
|
||||
final Duration settleAfterFirstResponse;
|
||||
final Duration initialRescanDelay;
|
||||
final Duration retryRescanDelay;
|
||||
final int maxRescanAttempts;
|
||||
|
||||
const WizProvisioningTiming({
|
||||
this.provisioningTimeout = const Duration(seconds: 45),
|
||||
this.settleAfterFirstResponse = const Duration(seconds: 3),
|
||||
this.initialRescanDelay = const Duration(seconds: 3),
|
||||
this.retryRescanDelay = const Duration(seconds: 4),
|
||||
this.maxRescanAttempts = 3,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../app/error_message.dart';
|
||||
import '../../homes/providers/homes_providers.dart';
|
||||
import '../../remote/providers/remote_providers.dart';
|
||||
import '../../shared/providers/core_providers.dart';
|
||||
import '../models/wiz_provisioning_device.dart';
|
||||
import '../models/wiz_provisioning_environment.dart';
|
||||
import '../models/wiz_provisioning_failure.dart';
|
||||
import '../models/wiz_provisioning_state.dart';
|
||||
import '../models/wiz_provisioning_timing.dart';
|
||||
import '../services/wiz_provisioning_platform_service.dart';
|
||||
import '../services/wiz_smart_pairing_service.dart';
|
||||
|
||||
final wizProvisioningPlatformServiceProvider =
|
||||
Provider<WizProvisioningPlatformService>(
|
||||
(ref) => const DeviceWizProvisioningPlatformService(),
|
||||
);
|
||||
|
||||
final wizSmartPairingServiceProvider = Provider<WizSmartPairingService>(
|
||||
(ref) => EspTouchWizSmartPairingService(),
|
||||
);
|
||||
|
||||
final wizProvisioningTimingProvider = Provider<WizProvisioningTiming>(
|
||||
(ref) => const WizProvisioningTiming(),
|
||||
);
|
||||
|
||||
final wizProvisioningProvider =
|
||||
NotifierProvider<WizProvisioningNotifier, WizProvisioningState>(
|
||||
WizProvisioningNotifier.new,
|
||||
);
|
||||
|
||||
class WizProvisioningNotifier extends Notifier<WizProvisioningState> {
|
||||
WizProvisioningPlatformService get _platform =>
|
||||
ref.read(wizProvisioningPlatformServiceProvider);
|
||||
WizSmartPairingService get _smartPairing =>
|
||||
ref.read(wizSmartPairingServiceProvider);
|
||||
WizProvisioningTiming get _timing => ref.read(wizProvisioningTimingProvider);
|
||||
|
||||
@override
|
||||
WizProvisioningState build() {
|
||||
final smartPairing = ref.watch(wizSmartPairingServiceProvider);
|
||||
ref.onDispose(() {
|
||||
unawaited(smartPairing.stopProvisioning());
|
||||
});
|
||||
return WizProvisioningState.initial();
|
||||
}
|
||||
|
||||
Future<void> initialize() async {
|
||||
final home = ref.read(currentHomeProvider);
|
||||
state = state.copyWith(
|
||||
status: WizProvisioningStatus.loadingEnvironment,
|
||||
activeHomeName: home?.name,
|
||||
clearFailure: true,
|
||||
clearNotice: true,
|
||||
);
|
||||
|
||||
final environment = await _platform.inspectEnvironment();
|
||||
state = state.copyWith(
|
||||
environment: environment,
|
||||
activeHomeName: home?.name,
|
||||
);
|
||||
|
||||
if (home == null) {
|
||||
_setFailure(
|
||||
const WizProvisioningFailure(
|
||||
kind: WizProvisioningFailureKind.noActiveHome,
|
||||
message:
|
||||
'Сначала выберите активный дом. Мастер использует именно его сервер Ignis для финального discovery.',
|
||||
),
|
||||
environment: environment,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!environment.smartPairingSupported || !environment.isAndroid) {
|
||||
state = state.copyWith(
|
||||
status: WizProvisioningStatus.unsupported,
|
||||
failure: const WizProvisioningFailure(
|
||||
kind: WizProvisioningFailureKind.unsupportedPlatform,
|
||||
message:
|
||||
'В этой сборке мастер Smart Pairing поддерживается только на Android.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!environment.permissionsGranted) {
|
||||
state = state.copyWith(
|
||||
status: WizProvisioningStatus.attentionRequired,
|
||||
failure: const WizProvisioningFailure(
|
||||
kind: WizProvisioningFailureKind.missingPermissions,
|
||||
message:
|
||||
'Нужны разрешения на доступ к Wi-Fi окружению, иначе приложение не сможет проверить текущую сеть.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!environment.locationServicesEnabled) {
|
||||
_setFailure(
|
||||
const WizProvisioningFailure(
|
||||
kind: WizProvisioningFailureKind.locationServicesDisabled,
|
||||
message:
|
||||
'На Android системные сервисы геолокации должны быть включены, иначе SSID/BSSID домашней Wi-Fi часто недоступны.',
|
||||
),
|
||||
environment: environment,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!environment.connectedToWifi || environment.ssid == null) {
|
||||
_setFailure(
|
||||
const WizProvisioningFailure(
|
||||
kind: WizProvisioningFailureKind.wifiUnavailable,
|
||||
message:
|
||||
'Сначала подключите телефон к домашней Wi-Fi сети 2.4 GHz, к которой должна присоединиться лампа.',
|
||||
),
|
||||
environment: environment,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final notice = environment.isLikelyOn5Ghz
|
||||
? 'Телефон, похоже, сидит на 5 GHz. WiZ-лампы подключаются только к 2.4 GHz, поэтому лучше переключиться на нужную сеть до старта pairing.'
|
||||
: null;
|
||||
state = state.copyWith(
|
||||
status: WizProvisioningStatus.ready,
|
||||
clearFailure: true,
|
||||
clearRescanSummary: true,
|
||||
provisionedDevices: const [],
|
||||
notice: notice,
|
||||
timeline: _appendTimeline(
|
||||
state.timeline,
|
||||
'Окружение проверено: готово к smart pairing.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> requestPermissions() async {
|
||||
await _platform.requestPermissions();
|
||||
await initialize();
|
||||
}
|
||||
|
||||
Future<void> openWifiSettings() => _platform.openWifiSettings();
|
||||
|
||||
Future<void> openAppSettings() => _platform.openAppSettings();
|
||||
|
||||
Future<void> startProvisioning({
|
||||
required String ssid,
|
||||
required String password,
|
||||
String? bssid,
|
||||
}) async {
|
||||
final normalizedSsid = ssid.trim();
|
||||
if (normalizedSsid.isEmpty) {
|
||||
_setFailure(
|
||||
const WizProvisioningFailure(
|
||||
kind: WizProvisioningFailureKind.invalidSsid,
|
||||
message: 'Укажите SSID домашней Wi-Fi сети.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final home = ref.read(currentHomeProvider);
|
||||
if (home == null) {
|
||||
_setFailure(
|
||||
const WizProvisioningFailure(
|
||||
kind: WizProvisioningFailureKind.noActiveHome,
|
||||
message:
|
||||
'Активный дом потерялся. Вернитесь на экран домов и выберите его заново.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final devices = <WizProvisioningDevice>[];
|
||||
StreamSubscription<WizProvisioningDevice>? subscription;
|
||||
final firstResponse = Completer<void>();
|
||||
|
||||
state = state.copyWith(
|
||||
status: WizProvisioningStatus.provisioning,
|
||||
clearFailure: true,
|
||||
clearNotice: true,
|
||||
clearRescanSummary: true,
|
||||
provisionedDevices: const [],
|
||||
timeline: _appendTimeline(
|
||||
state.timeline,
|
||||
'Старт smart pairing для сети "$normalizedSsid".',
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
final stream = _smartPairing.startProvisioning(
|
||||
ssid: normalizedSsid,
|
||||
password: password,
|
||||
bssid: bssid,
|
||||
);
|
||||
|
||||
subscription = stream.listen(
|
||||
(device) {
|
||||
final duplicate = devices.any((item) => item.bssid == device.bssid);
|
||||
if (duplicate) {
|
||||
return;
|
||||
}
|
||||
devices.add(device);
|
||||
state = state.copyWith(
|
||||
provisionedDevices: List<WizProvisioningDevice>.unmodifiable(
|
||||
devices,
|
||||
),
|
||||
timeline: _appendTimeline(
|
||||
state.timeline,
|
||||
'Лампа подтвердила pairing: ${device.bssid}${device.ipAddress == null ? '' : ' (${device.ipAddress})'}.',
|
||||
),
|
||||
);
|
||||
if (!firstResponse.isCompleted) {
|
||||
firstResponse.complete();
|
||||
}
|
||||
},
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
if (!firstResponse.isCompleted) {
|
||||
firstResponse.completeError(error, stackTrace);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await firstResponse.future.timeout(_timing.provisioningTimeout);
|
||||
await Future<void>.delayed(_timing.settleAfterFirstResponse);
|
||||
await _smartPairing.stopProvisioning();
|
||||
await subscription.cancel();
|
||||
subscription = null;
|
||||
|
||||
state = state.copyWith(
|
||||
status: WizProvisioningStatus.rescanning,
|
||||
timeline: _appendTimeline(
|
||||
state.timeline,
|
||||
'Pairing подтверждён, запускаю повторный discovery на сервере Ignis.',
|
||||
),
|
||||
);
|
||||
|
||||
final summary = await _rescanUntilSettled();
|
||||
final notice = (summary.added == 0 && summary.updated == 0)
|
||||
? 'Лампа ответила на smart pairing, но backend не нашёл новое устройство как added/updated. Возможно, устройство уже было известно или ему нужно чуть больше времени.'
|
||||
: null;
|
||||
|
||||
if (ref.read(groupsLoadStateProvider).status != GroupsLoadStatus.idle) {
|
||||
await ref.read(groupsProvider.notifier).refresh();
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
status: WizProvisioningStatus.success,
|
||||
rescanSummary: summary,
|
||||
notice: notice,
|
||||
timeline: _appendTimeline(
|
||||
state.timeline,
|
||||
'Discovery завершён: found=${summary.found}, added=${summary.added}, updated=${summary.updated}, online=${summary.online}.',
|
||||
),
|
||||
);
|
||||
} on TimeoutException {
|
||||
_setFailure(
|
||||
const WizProvisioningFailure(
|
||||
kind: WizProvisioningFailureKind.provisioningTimedOut,
|
||||
message:
|
||||
'Лампа не ответила вовремя. Проверьте, что она в pairing mode, телефон подключён к 2.4 GHz Wi-Fi, и попробуйте ещё раз.',
|
||||
),
|
||||
);
|
||||
} on WizProvisioningFailure catch (failure) {
|
||||
_setFailure(failure);
|
||||
} catch (error) {
|
||||
final message = describeLoadError(error);
|
||||
_setFailure(
|
||||
WizProvisioningFailure(
|
||||
kind: WizProvisioningFailureKind.provisioningFailed,
|
||||
message: 'Smart pairing завершился ошибкой.',
|
||||
details: message,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await subscription?.cancel();
|
||||
await _smartPairing.stopProvisioning();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelProvisioning({bool keepCurrentState = true}) async {
|
||||
await _smartPairing.stopProvisioning();
|
||||
if (!keepCurrentState) {
|
||||
return;
|
||||
}
|
||||
final fallbackStatus =
|
||||
state.environment.permissionsGranted &&
|
||||
state.environment.locationServicesEnabled &&
|
||||
state.environment.connectedToWifi &&
|
||||
state.activeHomeName != null
|
||||
? WizProvisioningStatus.ready
|
||||
: WizProvisioningStatus.attentionRequired;
|
||||
state = state.copyWith(
|
||||
status: fallbackStatus,
|
||||
notice: 'Текущая сессия pairing остановлена.',
|
||||
timeline: _appendTimeline(state.timeline, 'Сессия pairing отменена.'),
|
||||
);
|
||||
}
|
||||
|
||||
Future<WizRescanSummary> _rescanUntilSettled() async {
|
||||
final api = ref.read(apiProvider);
|
||||
Object? lastError;
|
||||
WizRescanSummary? lastSummary;
|
||||
|
||||
for (var attempt = 0; attempt < _timing.maxRescanAttempts; attempt += 1) {
|
||||
await Future<void>.delayed(
|
||||
attempt == 0 ? _timing.initialRescanDelay : _timing.retryRescanDelay,
|
||||
);
|
||||
|
||||
try {
|
||||
final response = await api.rescanNetwork();
|
||||
lastSummary = WizRescanSummary.fromMap(
|
||||
Map<String, dynamic>.from(response.data as Map),
|
||||
);
|
||||
state = state.copyWith(rescanSummary: lastSummary);
|
||||
if (lastSummary.added > 0 || lastSummary.updated > 0) {
|
||||
return lastSummary;
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastSummary != null) {
|
||||
return lastSummary;
|
||||
}
|
||||
|
||||
throw WizProvisioningFailure(
|
||||
kind: WizProvisioningFailureKind.rescanFailed,
|
||||
message:
|
||||
'Лампа приняла настройки, но финальный discovery на сервере Ignis не удался.',
|
||||
details: lastError == null ? null : describeLoadError(lastError),
|
||||
);
|
||||
}
|
||||
|
||||
void _setFailure(
|
||||
WizProvisioningFailure failure, {
|
||||
WizProvisioningEnvironment? environment,
|
||||
}) {
|
||||
final resolvedEnvironment = environment ?? state.environment;
|
||||
final status =
|
||||
failure.kind == WizProvisioningFailureKind.unsupportedPlatform
|
||||
? WizProvisioningStatus.unsupported
|
||||
: WizProvisioningStatus.failure;
|
||||
state = state.copyWith(
|
||||
status: status,
|
||||
environment: resolvedEnvironment,
|
||||
failure: failure,
|
||||
timeline: _appendTimeline(state.timeline, failure.message),
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _appendTimeline(List<String> current, String event) {
|
||||
return List<String>.unmodifiable(<String>[...current, event]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../models/wiz_provisioning_environment.dart';
|
||||
|
||||
abstract class WizProvisioningPlatformService {
|
||||
Future<WizProvisioningEnvironment> inspectEnvironment();
|
||||
|
||||
Future<void> requestPermissions();
|
||||
|
||||
Future<void> openWifiSettings();
|
||||
|
||||
Future<void> openAppSettings();
|
||||
}
|
||||
|
||||
class DeviceWizProvisioningPlatformService
|
||||
implements WizProvisioningPlatformService {
|
||||
const DeviceWizProvisioningPlatformService();
|
||||
|
||||
static const _channel = MethodChannel('ignis/wiz_provisioning');
|
||||
|
||||
@override
|
||||
Future<WizProvisioningEnvironment> inspectEnvironment() async {
|
||||
try {
|
||||
final raw = await _channel.invokeMapMethod<Object?, Object?>(
|
||||
'getProvisioningEnvironment',
|
||||
);
|
||||
if (raw == null) {
|
||||
return WizProvisioningEnvironment.unsupported();
|
||||
}
|
||||
return WizProvisioningEnvironment.fromMap(Map<String, dynamic>.from(raw));
|
||||
} on MissingPluginException {
|
||||
return WizProvisioningEnvironment.unsupported();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> requestPermissions() async {
|
||||
try {
|
||||
await _channel.invokeMethod<void>('requestProvisioningPermissions');
|
||||
} on MissingPluginException {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> openWifiSettings() async {
|
||||
try {
|
||||
await _channel.invokeMethod<void>('openWifiSettings');
|
||||
} on MissingPluginException {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> openAppSettings() async {
|
||||
try {
|
||||
await _channel.invokeMethod<void>('openAppSettings');
|
||||
} on MissingPluginException {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:esp_smartconfig/esp_smartconfig.dart';
|
||||
|
||||
import '../models/wiz_provisioning_device.dart';
|
||||
|
||||
abstract class WizSmartPairingService {
|
||||
Stream<WizProvisioningDevice> startProvisioning({
|
||||
required String ssid,
|
||||
required String password,
|
||||
String? bssid,
|
||||
});
|
||||
|
||||
Future<void> stopProvisioning();
|
||||
}
|
||||
|
||||
class EspTouchWizSmartPairingService implements WizSmartPairingService {
|
||||
Provisioner? _provisioner;
|
||||
StreamSubscription<ProvisioningResponse>? _subscription;
|
||||
StreamController<WizProvisioningDevice>? _controller;
|
||||
|
||||
@override
|
||||
Stream<WizProvisioningDevice> startProvisioning({
|
||||
required String ssid,
|
||||
required String password,
|
||||
String? bssid,
|
||||
}) {
|
||||
if (_provisioner != null || _controller != null) {
|
||||
throw StateError('Provisioning is already running');
|
||||
}
|
||||
|
||||
final provisioner = Provisioner.espTouch();
|
||||
final controller = StreamController<WizProvisioningDevice>.broadcast();
|
||||
_provisioner = provisioner;
|
||||
_controller = controller;
|
||||
|
||||
_subscription = provisioner.listen(
|
||||
(response) {
|
||||
controller.add(
|
||||
WizProvisioningDevice(
|
||||
bssid: response.bssidText,
|
||||
ipAddress: response.ipAddressText,
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: controller.addError,
|
||||
onDone: () async {
|
||||
if (!controller.isClosed) {
|
||||
await controller.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
final request = ProvisioningRequest.fromStrings(
|
||||
ssid: ssid,
|
||||
bssid: (bssid == null || bssid.trim().isEmpty)
|
||||
? '00:00:00:00:00:00'
|
||||
: bssid.trim(),
|
||||
password: password.isEmpty ? null : password,
|
||||
);
|
||||
|
||||
unawaited(
|
||||
provisioner.start(request).catchError((
|
||||
Object error,
|
||||
StackTrace stack,
|
||||
) async {
|
||||
if (!controller.isClosed) {
|
||||
controller.addError(error, stack);
|
||||
await controller.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stopProvisioning() async {
|
||||
final provisioner = _provisioner;
|
||||
_provisioner = null;
|
||||
|
||||
try {
|
||||
provisioner?.stop();
|
||||
} finally {
|
||||
await _subscription?.cancel();
|
||||
_subscription = null;
|
||||
if (_controller != null && !_controller!.isClosed) {
|
||||
await _controller!.close();
|
||||
}
|
||||
_controller = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export '../features/api_keys/providers/api_keys_providers.dart';
|
||||
export '../features/auth/providers/auth_providers.dart';
|
||||
export '../features/homes/providers/homes_providers.dart';
|
||||
export '../features/homes/providers/location_providers.dart';
|
||||
export '../features/provisioning/providers/wiz_provisioning_providers.dart';
|
||||
export '../features/remote/providers/remote_providers.dart';
|
||||
export '../features/schedules/providers/tasks_providers.dart';
|
||||
export '../features/shared/providers/core_providers.dart';
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../models/home_config.dart';
|
||||
import '../providers/providers.dart';
|
||||
import 'home_edit_screen.dart';
|
||||
import 'homes_screen.dart';
|
||||
import 'wiz_provisioning_screen.dart';
|
||||
|
||||
enum SettingsEntryPoint { homes, remote }
|
||||
|
||||
@@ -90,6 +91,15 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen>
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const WizProvisioningScreen(),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.lightbulb_outline),
|
||||
label: const Text('Подключить WiZ-лампу'),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => _openHomeEditor(context, currentHome),
|
||||
icon: const Icon(Icons.edit_location_alt_outlined),
|
||||
|
||||
531
lib/screens/wiz_provisioning_screen.dart
Normal file
531
lib/screens/wiz_provisioning_screen.dart
Normal file
@@ -0,0 +1,531 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../features/provisioning/models/wiz_provisioning_environment.dart';
|
||||
import '../features/provisioning/models/wiz_provisioning_state.dart';
|
||||
import '../features/provisioning/providers/wiz_provisioning_providers.dart';
|
||||
|
||||
class WizProvisioningScreen extends ConsumerStatefulWidget {
|
||||
const WizProvisioningScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<WizProvisioningScreen> createState() =>
|
||||
_WizProvisioningScreenState();
|
||||
}
|
||||
|
||||
class _WizProvisioningScreenState extends ConsumerState<WizProvisioningScreen>
|
||||
with WidgetsBindingObserver {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _ssidCtrl = TextEditingController();
|
||||
final _bssidCtrl = TextEditingController();
|
||||
final _passwordCtrl = TextEditingController();
|
||||
|
||||
bool _ssidTouched = false;
|
||||
bool _bssidTouched = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
Future<void>.microtask(
|
||||
() => ref.read(wizProvisioningProvider.notifier).initialize(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
ref
|
||||
.read(wizProvisioningProvider.notifier)
|
||||
.cancelProvisioning(keepCurrentState: false);
|
||||
ref.invalidate(wizProvisioningProvider);
|
||||
_ssidCtrl.dispose();
|
||||
_bssidCtrl.dispose();
|
||||
_passwordCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
Future<void>.microtask(
|
||||
() => ref.read(wizProvisioningProvider.notifier).initialize(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provisioningState = ref.watch(wizProvisioningProvider);
|
||||
_syncControllers(provisioningState.environment);
|
||||
final bottomInset = MediaQuery.paddingOf(context).bottom;
|
||||
|
||||
final environment = provisioningState.environment;
|
||||
final failure = provisioningState.failure;
|
||||
final canRequestPermissions =
|
||||
!environment.permissionsGranted && environment.permissionRequestable;
|
||||
final canOpenAppSettings =
|
||||
environment.requiresAppSettings && environment.appSettingsSupported;
|
||||
final needsWifiSettings =
|
||||
!environment.connectedToWifi && environment.wifiSettingsSupported;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('ПОДКЛЮЧЕНИЕ WIZ')),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
bottom: true,
|
||||
child: RefreshIndicator(
|
||||
color: Colors.deepOrange,
|
||||
onRefresh: () =>
|
||||
ref.read(wizProvisioningProvider.notifier).initialize(),
|
||||
child: ListView(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, bottomInset + 24),
|
||||
children: [
|
||||
_SectionCard(
|
||||
title: 'Что делает мастер',
|
||||
child: const Text(
|
||||
'Эта версия использует smart pairing: телефон остаётся в домашней Wi-Fi сети и передаёт её настройки новой лампе. Это Android-only поток и он лучше всего работает, когда телефон уже сидит на 2.4 GHz.',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
),
|
||||
),
|
||||
_SectionCard(
|
||||
title: 'Активный дом',
|
||||
child: Text(
|
||||
provisioningState.activeHomeName == null
|
||||
? 'Не выбран'
|
||||
: provisioningState.activeHomeName!,
|
||||
style: TextStyle(
|
||||
color: provisioningState.activeHomeName == null
|
||||
? Colors.redAccent
|
||||
: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
_SectionCard(
|
||||
title: 'Окружение',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_InfoRow(
|
||||
label: 'Платформа',
|
||||
value: environment.isAndroid
|
||||
? 'Android ${environment.androidApiLevel ?? '?'}'
|
||||
: environment.platform,
|
||||
),
|
||||
_InfoRow(
|
||||
label: 'Разрешения',
|
||||
value: _permissionStatusLabel(
|
||||
environment.permissionStatus,
|
||||
),
|
||||
),
|
||||
_InfoRow(
|
||||
label: 'Wi-Fi',
|
||||
value: environment.connectedToWifi
|
||||
? (environment.ssid ?? 'Подключено')
|
||||
: 'Нет подключения',
|
||||
),
|
||||
_InfoRow(
|
||||
label: 'BSSID',
|
||||
value: environment.bssid ?? 'Не удалось определить',
|
||||
),
|
||||
_InfoRow(
|
||||
label: 'Диапазон',
|
||||
value: environment.frequencyMhz == null
|
||||
? 'Неизвестно'
|
||||
: '${environment.frequencyMhz} MHz',
|
||||
),
|
||||
if (environment.isLikelyOn5Ghz)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
'Сейчас похоже активен 5 GHz. Для WiZ лучше заранее переключиться на 2.4 GHz.',
|
||||
style: TextStyle(color: Colors.amberAccent),
|
||||
),
|
||||
),
|
||||
if (!environment.locationServicesEnabled)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
'На Android системная геолокация должна быть включена, иначе SSID/BSSID часто скрываются системой.',
|
||||
style: TextStyle(color: Colors.amberAccent),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (failure != null)
|
||||
_SectionCard(
|
||||
title: 'Проблема',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
failure.message,
|
||||
style: const TextStyle(color: Colors.redAccent),
|
||||
),
|
||||
if (failure.details != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
failure.details!,
|
||||
style: const TextStyle(color: Colors.white54),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (provisioningState.notice != null)
|
||||
_SectionCard(
|
||||
title: 'Примечание',
|
||||
child: Text(
|
||||
provisioningState.notice!,
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
),
|
||||
_SectionCard(
|
||||
title: 'Шаги перед стартом',
|
||||
child: const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'1. Убедитесь, что телефон подключён к домашней 2.4 GHz Wi-Fi.',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
Text(
|
||||
'2. Переведите лампу в pairing mode: если нужно, несколько раз выключите и включите питание до пульсации.',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
Text(
|
||||
'3. Держите телефон рядом с лампой и не сворачивайте приложение до конца pairing.',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_SectionCard(
|
||||
title: 'Домашняя Wi-Fi',
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
child: Column(
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _ssidCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'SSID',
|
||||
hintText: 'Например: Home-2G',
|
||||
prefixIcon: Icon(Icons.wifi),
|
||||
),
|
||||
onChanged: (_) => _ssidTouched = true,
|
||||
validator: (value) {
|
||||
if ((value?.trim().isEmpty ?? true)) {
|
||||
return 'Укажите SSID';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _bssidCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'BSSID (опционально)',
|
||||
hintText: 'aa:bb:cc:dd:ee:ff',
|
||||
prefixIcon: Icon(Icons.router_outlined),
|
||||
),
|
||||
onChanged: (_) => _bssidTouched = true,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _passwordCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Пароль Wi-Fi',
|
||||
hintText: 'Оставьте пустым для открытой сети',
|
||||
prefixIcon: Icon(Icons.key),
|
||||
),
|
||||
obscureText: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
_ActionSection(
|
||||
state: provisioningState,
|
||||
canRequestPermissions: canRequestPermissions,
|
||||
canOpenAppSettings: canOpenAppSettings,
|
||||
needsWifiSettings: needsWifiSettings,
|
||||
onRequestPermissions: () => ref
|
||||
.read(wizProvisioningProvider.notifier)
|
||||
.requestPermissions(),
|
||||
onOpenAppSettings: () => ref
|
||||
.read(wizProvisioningProvider.notifier)
|
||||
.openAppSettings(),
|
||||
onOpenWifiSettings: () => ref
|
||||
.read(wizProvisioningProvider.notifier)
|
||||
.openWifiSettings(),
|
||||
onRefresh: () =>
|
||||
ref.read(wizProvisioningProvider.notifier).initialize(),
|
||||
onStart: _startProvisioning,
|
||||
onCancel: () => ref
|
||||
.read(wizProvisioningProvider.notifier)
|
||||
.cancelProvisioning(),
|
||||
),
|
||||
if (provisioningState.provisionedDevices.isNotEmpty)
|
||||
_SectionCard(
|
||||
title: 'Ответившие устройства',
|
||||
child: Column(
|
||||
children: [
|
||||
for (final device in provisioningState.provisionedDevices)
|
||||
ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(
|
||||
Icons.lightbulb_outline,
|
||||
color: Colors.deepOrange,
|
||||
),
|
||||
title: Text(device.bssid),
|
||||
subtitle: device.ipAddress == null
|
||||
? null
|
||||
: Text(device.ipAddress!),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (provisioningState.rescanSummary != null)
|
||||
_SectionCard(
|
||||
title: 'Результат discovery',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_InfoRow(
|
||||
label: 'Найдено',
|
||||
value: '${provisioningState.rescanSummary!.found}',
|
||||
),
|
||||
_InfoRow(
|
||||
label: 'Добавлено',
|
||||
value: '${provisioningState.rescanSummary!.added}',
|
||||
),
|
||||
_InfoRow(
|
||||
label: 'Обновлено',
|
||||
value: '${provisioningState.rescanSummary!.updated}',
|
||||
),
|
||||
_InfoRow(
|
||||
label: 'Онлайн',
|
||||
value: '${provisioningState.rescanSummary!.online}',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (provisioningState.timeline.isNotEmpty)
|
||||
_SectionCard(
|
||||
title: 'Ход выполнения',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final event in provisioningState.timeline)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
'• $event',
|
||||
style: const TextStyle(color: Colors.white60),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _syncControllers(WizProvisioningEnvironment environment) {
|
||||
if (!_ssidTouched &&
|
||||
environment.ssid != null &&
|
||||
environment.ssid != _ssidCtrl.text) {
|
||||
_ssidCtrl.text = environment.ssid!;
|
||||
}
|
||||
|
||||
if (!_bssidTouched &&
|
||||
environment.bssid != null &&
|
||||
environment.bssid != _bssidCtrl.text) {
|
||||
_bssidCtrl.text = environment.bssid!;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startProvisioning() async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref
|
||||
.read(wizProvisioningProvider.notifier)
|
||||
.startProvisioning(
|
||||
ssid: _ssidCtrl.text,
|
||||
password: _passwordCtrl.text,
|
||||
bssid: _bssidCtrl.text.trim().isEmpty ? null : _bssidCtrl.text,
|
||||
);
|
||||
}
|
||||
|
||||
String _permissionStatusLabel(WizProvisioningPermissionStatus status) {
|
||||
switch (status) {
|
||||
case WizProvisioningPermissionStatus.granted:
|
||||
return 'Выданы';
|
||||
case WizProvisioningPermissionStatus.requestable:
|
||||
return 'Нужно запросить';
|
||||
case WizProvisioningPermissionStatus.settingsRequired:
|
||||
return 'Нужно открыть настройки приложения';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionSection extends StatelessWidget {
|
||||
final WizProvisioningState state;
|
||||
final bool canRequestPermissions;
|
||||
final bool canOpenAppSettings;
|
||||
final bool needsWifiSettings;
|
||||
final VoidCallback onRequestPermissions;
|
||||
final VoidCallback onOpenAppSettings;
|
||||
final VoidCallback onOpenWifiSettings;
|
||||
final VoidCallback onRefresh;
|
||||
final VoidCallback onStart;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
const _ActionSection({
|
||||
required this.state,
|
||||
required this.canRequestPermissions,
|
||||
required this.canOpenAppSettings,
|
||||
required this.needsWifiSettings,
|
||||
required this.onRequestPermissions,
|
||||
required this.onOpenAppSettings,
|
||||
required this.onOpenWifiSettings,
|
||||
required this.onRefresh,
|
||||
required this.onStart,
|
||||
required this.onCancel,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final canStartProvisioning =
|
||||
!state.isBusy &&
|
||||
state.activeHomeName != null &&
|
||||
state.environment.permissionsGranted &&
|
||||
state.environment.locationServicesEnabled &&
|
||||
state.environment.connectedToWifi &&
|
||||
state.status != WizProvisioningStatus.unsupported;
|
||||
|
||||
return _SectionCard(
|
||||
title: 'Действия',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (state.isBusy)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 12),
|
||||
child: LinearProgressIndicator(color: Colors.deepOrange),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: canStartProvisioning ? onStart : null,
|
||||
icon: const Icon(Icons.flash_on),
|
||||
label: Text(
|
||||
state.status == WizProvisioningStatus.success
|
||||
? 'Повторить pairing'
|
||||
: 'Запустить smart pairing',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: state.isBusy ? onCancel : onRefresh,
|
||||
icon: Icon(
|
||||
state.isBusy ? Icons.stop_circle_outlined : Icons.refresh,
|
||||
),
|
||||
label: Text(state.isBusy ? 'Остановить' : 'Переобновить окружение'),
|
||||
),
|
||||
if (canRequestPermissions) ...[
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: state.isBusy ? null : onRequestPermissions,
|
||||
icon: const Icon(Icons.privacy_tip_outlined),
|
||||
label: const Text('Выдать разрешения'),
|
||||
),
|
||||
],
|
||||
if (canOpenAppSettings) ...[
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: state.isBusy ? null : onOpenAppSettings,
|
||||
icon: const Icon(Icons.settings_applications_outlined),
|
||||
label: const Text('Открыть настройки приложения'),
|
||||
),
|
||||
],
|
||||
if (needsWifiSettings) ...[
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: state.isBusy ? null : onOpenWifiSettings,
|
||||
icon: const Icon(Icons.wifi_find_outlined),
|
||||
label: const Text('Открыть настройки Wi-Fi'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionCard extends StatelessWidget {
|
||||
final String title;
|
||||
final Widget child;
|
||||
|
||||
const _SectionCard({required this.title, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
child,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const _InfoRow({required this.label, required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 112,
|
||||
child: Text(label, style: const TextStyle(color: Colors.white38)),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(value, style: const TextStyle(color: Colors.white70)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
16
pubspec.lock
16
pubspec.lock
@@ -153,6 +153,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
esp_smartconfig:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: esp_smartconfig
|
||||
sha256: "43799fc5bbdbde18d6c4a7a8ab48b7042878154d792ab5815c5473759d4f575e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -584,6 +592,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pointycastle:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pointycastle
|
||||
sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
pool:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -39,6 +39,7 @@ dependencies:
|
||||
shared_preferences: ^2.5.5
|
||||
geolocator: ^13.0.2
|
||||
flutter_secure_storage: ^10.0.0
|
||||
esp_smartconfig: ^3.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
218
test/wiz_provisioning_notifier_test.dart
Normal file
218
test/wiz_provisioning_notifier_test.dart
Normal file
@@ -0,0 +1,218 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ignis_app/features/homes/providers/homes_providers.dart';
|
||||
import 'package:ignis_app/features/provisioning/models/wiz_provisioning_device.dart';
|
||||
import 'package:ignis_app/features/provisioning/models/wiz_provisioning_environment.dart';
|
||||
import 'package:ignis_app/features/provisioning/models/wiz_provisioning_failure.dart';
|
||||
import 'package:ignis_app/features/provisioning/models/wiz_provisioning_state.dart';
|
||||
import 'package:ignis_app/features/provisioning/models/wiz_provisioning_timing.dart';
|
||||
import 'package:ignis_app/features/provisioning/providers/wiz_provisioning_providers.dart';
|
||||
import 'package:ignis_app/features/provisioning/services/wiz_provisioning_platform_service.dart';
|
||||
import 'package:ignis_app/features/provisioning/services/wiz_smart_pairing_service.dart';
|
||||
import 'package:ignis_app/features/shared/providers/core_providers.dart';
|
||||
import 'package:ignis_app/models/home_config.dart';
|
||||
|
||||
import 'test_support.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('initialize reports missing active home', () async {
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
wizProvisioningPlatformServiceProvider.overrideWithValue(
|
||||
FakeWizProvisioningPlatformService(environment: _readyEnvironment()),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(wizProvisioningProvider.notifier).initialize();
|
||||
|
||||
final state = container.read(wizProvisioningProvider);
|
||||
expect(state.status, WizProvisioningStatus.failure);
|
||||
expect(state.failure?.kind, WizProvisioningFailureKind.noActiveHome);
|
||||
});
|
||||
|
||||
test(
|
||||
'initialize asks for permissions when Wi-Fi access is not granted',
|
||||
() async {
|
||||
final home = HomeConfig(id: 'home-1', name: 'Дом', url: 'https://ignis');
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
currentHomeProvider.overrideWith(() => FakeCurrentHomeNotifier(home)),
|
||||
wizProvisioningPlatformServiceProvider.overrideWithValue(
|
||||
FakeWizProvisioningPlatformService(
|
||||
environment: _readyEnvironment(
|
||||
permissionStatus: WizProvisioningPermissionStatus.requestable,
|
||||
connectedToWifi: false,
|
||||
ssid: null,
|
||||
bssid: null,
|
||||
frequencyMhz: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(wizProvisioningProvider.notifier).initialize();
|
||||
|
||||
final state = container.read(wizProvisioningProvider);
|
||||
expect(state.status, WizProvisioningStatus.attentionRequired);
|
||||
expect(
|
||||
state.failure?.kind,
|
||||
WizProvisioningFailureKind.missingPermissions,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('successful smart pairing ends with rescan success', () async {
|
||||
final home = HomeConfig(id: 'home-1', name: 'Дом', url: 'https://ignis');
|
||||
final api = FakeIgnisApi()
|
||||
..rescanNetworkData = {
|
||||
'status': 'ok',
|
||||
'found': 1,
|
||||
'added': 1,
|
||||
'updated': 0,
|
||||
'removed_offline': 0,
|
||||
'pending_removal': 0,
|
||||
'online': 1,
|
||||
};
|
||||
final smartPairing = FakeWizSmartPairingService(
|
||||
streamFactory: () {
|
||||
final controller = StreamController<WizProvisioningDevice>();
|
||||
Future<void>.microtask(() {
|
||||
controller.add(
|
||||
const WizProvisioningDevice(
|
||||
bssid: 'aa:bb:cc:dd:ee:ff',
|
||||
ipAddress: '192.168.1.44',
|
||||
),
|
||||
);
|
||||
});
|
||||
return controller.stream;
|
||||
},
|
||||
);
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
currentHomeProvider.overrideWith(() => FakeCurrentHomeNotifier(home)),
|
||||
apiProvider.overrideWithValue(api),
|
||||
wizProvisioningPlatformServiceProvider.overrideWithValue(
|
||||
FakeWizProvisioningPlatformService(environment: _readyEnvironment()),
|
||||
),
|
||||
wizSmartPairingServiceProvider.overrideWithValue(smartPairing),
|
||||
wizProvisioningTimingProvider.overrideWithValue(
|
||||
const WizProvisioningTiming(
|
||||
provisioningTimeout: Duration(milliseconds: 200),
|
||||
settleAfterFirstResponse: Duration.zero,
|
||||
initialRescanDelay: Duration.zero,
|
||||
retryRescanDelay: Duration.zero,
|
||||
maxRescanAttempts: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(wizProvisioningProvider.notifier).initialize();
|
||||
await container
|
||||
.read(wizProvisioningProvider.notifier)
|
||||
.startProvisioning(
|
||||
ssid: 'Home-2G',
|
||||
password: 'secret',
|
||||
bssid: '11:22:33:44:55:66',
|
||||
);
|
||||
|
||||
final state = container.read(wizProvisioningProvider);
|
||||
expect(state.status, WizProvisioningStatus.success);
|
||||
expect(state.provisionedDevices, hasLength(1));
|
||||
expect(state.rescanSummary?.added, 1);
|
||||
expect(api.rescanCalls, 1);
|
||||
});
|
||||
}
|
||||
|
||||
class FakeCurrentHomeNotifier extends CurrentHomeNotifier {
|
||||
FakeCurrentHomeNotifier(this._home);
|
||||
|
||||
final HomeConfig? _home;
|
||||
|
||||
@override
|
||||
HomeConfig? build() => _home;
|
||||
}
|
||||
|
||||
class FakeWizProvisioningPlatformService
|
||||
implements WizProvisioningPlatformService {
|
||||
FakeWizProvisioningPlatformService({required this.environment});
|
||||
|
||||
final WizProvisioningEnvironment environment;
|
||||
int requestCalls = 0;
|
||||
int openWifiSettingsCalls = 0;
|
||||
int openAppSettingsCalls = 0;
|
||||
|
||||
@override
|
||||
Future<WizProvisioningEnvironment> inspectEnvironment() async => environment;
|
||||
|
||||
@override
|
||||
Future<void> requestPermissions() async {
|
||||
requestCalls += 1;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> openAppSettings() async {
|
||||
openAppSettingsCalls += 1;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> openWifiSettings() async {
|
||||
openWifiSettingsCalls += 1;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeWizSmartPairingService implements WizSmartPairingService {
|
||||
FakeWizSmartPairingService({required this.streamFactory});
|
||||
|
||||
final Stream<WizProvisioningDevice> Function() streamFactory;
|
||||
int startCalls = 0;
|
||||
int stopCalls = 0;
|
||||
|
||||
@override
|
||||
Stream<WizProvisioningDevice> startProvisioning({
|
||||
required String ssid,
|
||||
required String password,
|
||||
String? bssid,
|
||||
}) {
|
||||
startCalls += 1;
|
||||
return streamFactory();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stopProvisioning() async {
|
||||
stopCalls += 1;
|
||||
}
|
||||
}
|
||||
|
||||
WizProvisioningEnvironment _readyEnvironment({
|
||||
WizProvisioningPermissionStatus permissionStatus =
|
||||
WizProvisioningPermissionStatus.granted,
|
||||
bool connectedToWifi = true,
|
||||
bool locationServicesEnabled = true,
|
||||
String? ssid = 'Home-2G',
|
||||
String? bssid = '11:22:33:44:55:66',
|
||||
int? frequencyMhz = 2437,
|
||||
}) {
|
||||
return WizProvisioningEnvironment(
|
||||
platform: 'android',
|
||||
androidApiLevel: 35,
|
||||
smartPairingSupported: true,
|
||||
wifiSettingsSupported: true,
|
||||
appSettingsSupported: true,
|
||||
permissionStatus: permissionStatus,
|
||||
locationServicesEnabled: locationServicesEnabled,
|
||||
connectedToWifi: connectedToWifi,
|
||||
ssid: ssid,
|
||||
bssid: bssid,
|
||||
frequencyMhz: frequencyMhz,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user