Add WiZ provisioning wizard
This commit is contained in:
17
README.md
17
README.md
@@ -50,6 +50,23 @@ flutter pub get
|
|||||||
flutter run
|
flutter run
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Документы
|
||||||
|
|
||||||
|
- `docs/wiz_provisioning_master_plan.md` — подробный план добавления мастера первичной посадки новых WiZ-ламп на Wi-Fi без официального приложения.
|
||||||
|
|
||||||
|
## WiZ Provisioning Status
|
||||||
|
|
||||||
|
Что уже есть:
|
||||||
|
|
||||||
|
- Android-first мастер подключения новых WiZ-ламп;
|
||||||
|
- environment inspection, permissions, smart pairing и post-provision `rescan` в `Ignis`.
|
||||||
|
|
||||||
|
Что важно понимать:
|
||||||
|
|
||||||
|
- это пока не универсальный onboarding для всех поколений WiZ;
|
||||||
|
- `SoftAP / WiZConfig_xxxx`, commissioning через `UDP 18266`, `BLE` и `Matter` fallback ещё не реализованы;
|
||||||
|
- реальная проверка на железе остаётся обязательной.
|
||||||
|
|
||||||
## Release APK
|
## Release APK
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
package ru.akokos.ignis_app
|
package ru.akokos.ignis_app
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.Uri
|
||||||
|
import android.net.wifi.WifiInfo
|
||||||
|
import android.net.wifi.WifiManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.location.LocationManager
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
|
import androidx.core.location.LocationManagerCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
@@ -14,9 +22,13 @@ import io.flutter.plugin.common.MethodChannel
|
|||||||
|
|
||||||
class MainActivity : FlutterActivity() {
|
class MainActivity : FlutterActivity() {
|
||||||
private var pendingNotificationPermissionResult: MethodChannel.Result? = null
|
private var pendingNotificationPermissionResult: MethodChannel.Result? = null
|
||||||
|
private var pendingProvisioningPermissionResult: MethodChannel.Result? = null
|
||||||
private val notificationPrefs by lazy {
|
private val notificationPrefs by lazy {
|
||||||
getSharedPreferences(notificationPrefsName, MODE_PRIVATE)
|
getSharedPreferences(notificationPrefsName, MODE_PRIVATE)
|
||||||
}
|
}
|
||||||
|
private val provisioningPrefs by lazy {
|
||||||
|
getSharedPreferences(provisioningPrefsName, MODE_PRIVATE)
|
||||||
|
}
|
||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
@@ -94,6 +106,34 @@ class MainActivity : FlutterActivity() {
|
|||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MethodChannel(
|
||||||
|
flutterEngine.dartExecutor.binaryMessenger,
|
||||||
|
wizProvisioningChannelName,
|
||||||
|
).setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"getProvisioningEnvironment" -> {
|
||||||
|
result.success(buildProvisioningEnvironment())
|
||||||
|
}
|
||||||
|
"requestProvisioningPermissions" -> {
|
||||||
|
requestProvisioningPermissions(result)
|
||||||
|
}
|
||||||
|
"openWifiSettings" -> {
|
||||||
|
startActivity(Intent(Settings.ACTION_WIFI_SETTINGS))
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"openAppSettings" -> {
|
||||||
|
val intent =
|
||||||
|
Intent(
|
||||||
|
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||||
|
Uri.fromParts("package", packageName, null),
|
||||||
|
)
|
||||||
|
startActivity(intent)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRequestPermissionsResult(
|
override fun onRequestPermissionsResult(
|
||||||
@@ -104,6 +144,10 @@ class MainActivity : FlutterActivity() {
|
|||||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||||
|
|
||||||
if (requestCode != notificationPermissionRequestCode) {
|
if (requestCode != notificationPermissionRequestCode) {
|
||||||
|
if (requestCode == provisioningPermissionRequestCode) {
|
||||||
|
pendingProvisioningPermissionResult?.success(buildProvisioningEnvironment())
|
||||||
|
pendingProvisioningPermissionResult = null
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +155,30 @@ class MainActivity : FlutterActivity() {
|
|||||||
pendingNotificationPermissionResult = null
|
pendingNotificationPermissionResult = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun requestProvisioningPermissions(result: MethodChannel.Result) {
|
||||||
|
if (pendingProvisioningPermissionResult != null) {
|
||||||
|
result.error(
|
||||||
|
"request_in_progress",
|
||||||
|
"Provisioning permission request is already in progress",
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasProvisioningPermissions()) {
|
||||||
|
result.success(buildProvisioningEnvironment())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingProvisioningPermissionResult = result
|
||||||
|
provisioningPrefs.edit().putBoolean(provisioningPermissionRequestedKey, true).apply()
|
||||||
|
ActivityCompat.requestPermissions(
|
||||||
|
this,
|
||||||
|
requiredProvisioningPermissions(),
|
||||||
|
provisioningPermissionRequestCode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun requestNotificationPermission(result: MethodChannel.Result) {
|
private fun requestNotificationPermission(result: MethodChannel.Result) {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||||
result.success(getNotificationPermissionStatusValue())
|
result.success(getNotificationPermissionStatusValue())
|
||||||
@@ -182,10 +250,137 @@ class MainActivity : FlutterActivity() {
|
|||||||
NotificationManagerCompat.from(this).areNotificationsEnabled()
|
NotificationManagerCompat.from(this).areNotificationsEnabled()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun buildProvisioningEnvironment(): Map<String, Any?> {
|
||||||
|
val wifiSnapshot = currentWifiSnapshot()
|
||||||
|
return mapOf(
|
||||||
|
"platform" to "android",
|
||||||
|
"androidApiLevel" to Build.VERSION.SDK_INT,
|
||||||
|
"smartPairingSupported" to true,
|
||||||
|
"wifiSettingsSupported" to true,
|
||||||
|
"appSettingsSupported" to true,
|
||||||
|
"permissionStatus" to provisioningPermissionStatusValue(),
|
||||||
|
"locationServicesEnabled" to isLocationServicesEnabled(),
|
||||||
|
"connectedToWifi" to wifiSnapshot.connectedToWifi,
|
||||||
|
"ssid" to wifiSnapshot.ssid,
|
||||||
|
"bssid" to wifiSnapshot.bssid,
|
||||||
|
"frequencyMhz" to wifiSnapshot.frequencyMhz,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun provisioningPermissionStatusValue(): String {
|
||||||
|
if (hasProvisioningPermissions()) {
|
||||||
|
return "granted"
|
||||||
|
}
|
||||||
|
|
||||||
|
val wasRequestedBefore =
|
||||||
|
provisioningPrefs.getBoolean(provisioningPermissionRequestedKey, false)
|
||||||
|
val canShowPromptAgain =
|
||||||
|
requiredProvisioningPermissions().any {
|
||||||
|
ActivityCompat.shouldShowRequestPermissionRationale(this, it)
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (!wasRequestedBefore || canShowPromptAgain) {
|
||||||
|
"requestable"
|
||||||
|
} else {
|
||||||
|
"settings_required"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasProvisioningPermissions(): Boolean {
|
||||||
|
return requiredProvisioningPermissions().all { permission ->
|
||||||
|
ContextCompat.checkSelfPermission(this, permission) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requiredProvisioningPermissions(): Array<String> {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
arrayOf(
|
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||||
|
Manifest.permission.NEARBY_WIFI_DEVICES,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isLocationServicesEnabled(): Boolean {
|
||||||
|
val manager = getSystemService(Context.LOCATION_SERVICE) as? LocationManager
|
||||||
|
return manager?.let(LocationManagerCompat::isLocationEnabled) ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun currentWifiSnapshot(): WifiSnapshot {
|
||||||
|
if (!hasProvisioningPermissions()) {
|
||||||
|
return WifiSnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
val connectivityManager =
|
||||||
|
getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
val activeNetwork = connectivityManager.activeNetwork
|
||||||
|
val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork)
|
||||||
|
val isWifiTransport =
|
||||||
|
networkCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
|
||||||
|
val wifiInfo =
|
||||||
|
when {
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
|
||||||
|
networkCapabilities?.transportInfo is WifiInfo -> {
|
||||||
|
networkCapabilities.transportInfo as WifiInfo
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val wifiManager =
|
||||||
|
applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
wifiManager.connectionInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val sanitizedSsid = sanitizeSsid(wifiInfo?.ssid)
|
||||||
|
val sanitizedBssid = sanitizeBssid(wifiInfo?.bssid)
|
||||||
|
val frequency = wifiInfo?.frequency?.takeIf { it > 0 }
|
||||||
|
|
||||||
|
return WifiSnapshot(
|
||||||
|
connectedToWifi = isWifiTransport && sanitizedSsid != null,
|
||||||
|
ssid = sanitizedSsid,
|
||||||
|
bssid = sanitizedBssid,
|
||||||
|
frequencyMhz = frequency,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sanitizeSsid(raw: String?): String? {
|
||||||
|
val trimmed = raw?.trim()?.removePrefix("\"")?.removeSuffix("\"")
|
||||||
|
return when {
|
||||||
|
trimmed.isNullOrEmpty() -> null
|
||||||
|
trimmed.equals(WifiManager.UNKNOWN_SSID, ignoreCase = true) -> null
|
||||||
|
else -> trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sanitizeBssid(raw: String?): String? {
|
||||||
|
val trimmed = raw?.trim()
|
||||||
|
return when {
|
||||||
|
trimmed.isNullOrEmpty() -> null
|
||||||
|
trimmed == "02:00:00:00:00:00" -> null
|
||||||
|
else -> trimmed.lowercase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class WifiSnapshot(
|
||||||
|
val connectedToWifi: Boolean = false,
|
||||||
|
val ssid: String? = null,
|
||||||
|
val bssid: String? = null,
|
||||||
|
val frequencyMhz: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val notificationPermissionRequestCode = 4102
|
private const val notificationPermissionRequestCode = 4102
|
||||||
private const val notificationPrefsName = "ignis_notification_permissions"
|
private const val notificationPrefsName = "ignis_notification_permissions"
|
||||||
private const val notificationPermissionRequestedKey =
|
private const val notificationPermissionRequestedKey =
|
||||||
"post_notifications_requested"
|
"post_notifications_requested"
|
||||||
|
private const val provisioningPermissionRequestCode = 4103
|
||||||
|
private const val provisioningPrefsName = "ignis_wiz_provisioning"
|
||||||
|
private const val provisioningPermissionRequestedKey =
|
||||||
|
"wiz_provisioning_permissions_requested"
|
||||||
|
private const val wizProvisioningChannelName = "ignis/wiz_provisioning"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
docs/README.md
Normal file
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/auth/providers/auth_providers.dart';
|
||||||
export '../features/homes/providers/homes_providers.dart';
|
export '../features/homes/providers/homes_providers.dart';
|
||||||
export '../features/homes/providers/location_providers.dart';
|
export '../features/homes/providers/location_providers.dart';
|
||||||
|
export '../features/provisioning/providers/wiz_provisioning_providers.dart';
|
||||||
export '../features/remote/providers/remote_providers.dart';
|
export '../features/remote/providers/remote_providers.dart';
|
||||||
export '../features/schedules/providers/tasks_providers.dart';
|
export '../features/schedules/providers/tasks_providers.dart';
|
||||||
export '../features/shared/providers/core_providers.dart';
|
export '../features/shared/providers/core_providers.dart';
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import '../models/home_config.dart';
|
|||||||
import '../providers/providers.dart';
|
import '../providers/providers.dart';
|
||||||
import 'home_edit_screen.dart';
|
import 'home_edit_screen.dart';
|
||||||
import 'homes_screen.dart';
|
import 'homes_screen.dart';
|
||||||
|
import 'wiz_provisioning_screen.dart';
|
||||||
|
|
||||||
enum SettingsEntryPoint { homes, remote }
|
enum SettingsEntryPoint { homes, remote }
|
||||||
|
|
||||||
@@ -90,6 +91,15 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen>
|
|||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () => Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const WizProvisioningScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.lightbulb_outline),
|
||||||
|
label: const Text('Подключить WiZ-лампу'),
|
||||||
|
),
|
||||||
FilledButton.tonalIcon(
|
FilledButton.tonalIcon(
|
||||||
onPressed: () => _openHomeEditor(context, currentHome),
|
onPressed: () => _openHomeEditor(context, currentHome),
|
||||||
icon: const Icon(Icons.edit_location_alt_outlined),
|
icon: const Icon(Icons.edit_location_alt_outlined),
|
||||||
|
|||||||
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"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.2"
|
||||||
|
esp_smartconfig:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: esp_smartconfig
|
||||||
|
sha256: "43799fc5bbdbde18d6c4a7a8ab48b7042878154d792ab5815c5473759d4f575e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -584,6 +592,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
version: "2.1.8"
|
||||||
|
pointycastle:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pointycastle
|
||||||
|
sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.0"
|
||||||
pool:
|
pool:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ dependencies:
|
|||||||
shared_preferences: ^2.5.5
|
shared_preferences: ^2.5.5
|
||||||
geolocator: ^13.0.2
|
geolocator: ^13.0.2
|
||||||
flutter_secure_storage: ^10.0.0
|
flutter_secure_storage: ^10.0.0
|
||||||
|
esp_smartconfig: ^3.0.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
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