Add WiZ provisioning wizard

This commit is contained in:
Artem Kokos
2026-05-16 17:24:28 +07:00
parent 0a635115d4
commit 866a074c03
19 changed files with 2668 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,134 @@
enum WizProvisioningPermissionStatus { granted, requestable, settingsRequired }
class WizProvisioningEnvironment {
final String platform;
final int? androidApiLevel;
final bool smartPairingSupported;
final bool wifiSettingsSupported;
final bool appSettingsSupported;
final WizProvisioningPermissionStatus permissionStatus;
final bool locationServicesEnabled;
final bool connectedToWifi;
final String? ssid;
final String? bssid;
final int? frequencyMhz;
const WizProvisioningEnvironment({
required this.platform,
required this.androidApiLevel,
required this.smartPairingSupported,
required this.wifiSettingsSupported,
required this.appSettingsSupported,
required this.permissionStatus,
required this.locationServicesEnabled,
required this.connectedToWifi,
required this.ssid,
required this.bssid,
required this.frequencyMhz,
});
factory WizProvisioningEnvironment.unsupported() =>
const WizProvisioningEnvironment(
platform: 'unknown',
androidApiLevel: null,
smartPairingSupported: false,
wifiSettingsSupported: false,
appSettingsSupported: false,
permissionStatus: WizProvisioningPermissionStatus.granted,
locationServicesEnabled: true,
connectedToWifi: false,
ssid: null,
bssid: null,
frequencyMhz: null,
);
factory WizProvisioningEnvironment.fromMap(Map<String, dynamic> raw) {
return WizProvisioningEnvironment(
platform: raw['platform'] as String? ?? 'unknown',
androidApiLevel: (raw['androidApiLevel'] as num?)?.toInt(),
smartPairingSupported: raw['smartPairingSupported'] == true,
wifiSettingsSupported: raw['wifiSettingsSupported'] == true,
appSettingsSupported: raw['appSettingsSupported'] == true,
permissionStatus: _permissionStatusFromPlatformValue(
raw['permissionStatus'] as String?,
),
locationServicesEnabled: raw['locationServicesEnabled'] != false,
connectedToWifi: raw['connectedToWifi'] == true,
ssid: _normalizeText(raw['ssid']),
bssid: _normalizeText(raw['bssid']),
frequencyMhz: (raw['frequencyMhz'] as num?)?.toInt(),
);
}
bool get permissionsGranted =>
permissionStatus == WizProvisioningPermissionStatus.granted;
bool get permissionRequestable =>
permissionStatus == WizProvisioningPermissionStatus.requestable;
bool get requiresAppSettings =>
permissionStatus == WizProvisioningPermissionStatus.settingsRequired;
bool get isAndroid => platform == 'android';
bool get isOn24Ghz =>
frequencyMhz != null && frequencyMhz! >= 2400 && frequencyMhz! < 2500;
bool get isLikelyOn5Ghz =>
frequencyMhz != null && frequencyMhz! >= 4900 && frequencyMhz! < 6000;
WizProvisioningEnvironment copyWith({
String? platform,
int? androidApiLevel,
bool? smartPairingSupported,
bool? wifiSettingsSupported,
bool? appSettingsSupported,
WizProvisioningPermissionStatus? permissionStatus,
bool? locationServicesEnabled,
bool? connectedToWifi,
String? ssid,
String? bssid,
int? frequencyMhz,
bool clearWifiInfo = false,
}) {
return WizProvisioningEnvironment(
platform: platform ?? this.platform,
androidApiLevel: androidApiLevel ?? this.androidApiLevel,
smartPairingSupported:
smartPairingSupported ?? this.smartPairingSupported,
wifiSettingsSupported:
wifiSettingsSupported ?? this.wifiSettingsSupported,
appSettingsSupported: appSettingsSupported ?? this.appSettingsSupported,
permissionStatus: permissionStatus ?? this.permissionStatus,
locationServicesEnabled:
locationServicesEnabled ?? this.locationServicesEnabled,
connectedToWifi: connectedToWifi ?? this.connectedToWifi,
ssid: clearWifiInfo ? null : (ssid ?? this.ssid),
bssid: clearWifiInfo ? null : (bssid ?? this.bssid),
frequencyMhz: clearWifiInfo ? null : (frequencyMhz ?? this.frequencyMhz),
);
}
static WizProvisioningPermissionStatus _permissionStatusFromPlatformValue(
String? value,
) {
switch (value) {
case 'granted':
return WizProvisioningPermissionStatus.granted;
case 'settings_required':
return WizProvisioningPermissionStatus.settingsRequired;
case 'requestable':
default:
return WizProvisioningPermissionStatus.requestable;
}
}
static String? _normalizeText(Object? raw) {
final text = raw as String?;
if (text == null) {
return null;
}
final trimmed = text.trim();
return trimmed.isEmpty ? null : trimmed;
}
}

View File

@@ -0,0 +1,23 @@
enum WizProvisioningFailureKind {
noActiveHome,
unsupportedPlatform,
missingPermissions,
locationServicesDisabled,
wifiUnavailable,
invalidSsid,
provisioningTimedOut,
provisioningFailed,
rescanFailed,
}
class WizProvisioningFailure {
final WizProvisioningFailureKind kind;
final String message;
final String? details;
const WizProvisioningFailure({
required this.kind,
required this.message,
this.details,
});
}

View File

@@ -0,0 +1,114 @@
import 'wiz_provisioning_device.dart';
import 'wiz_provisioning_environment.dart';
import 'wiz_provisioning_failure.dart';
enum WizProvisioningStatus {
initial,
loadingEnvironment,
attentionRequired,
ready,
provisioning,
rescanning,
success,
failure,
unsupported,
}
class WizRescanSummary {
final int found;
final int added;
final int updated;
final int removedOffline;
final int pendingRemoval;
final int online;
const WizRescanSummary({
required this.found,
required this.added,
required this.updated,
required this.removedOffline,
required this.pendingRemoval,
required this.online,
});
factory WizRescanSummary.fromMap(Map<String, dynamic> raw) {
return WizRescanSummary(
found: (raw['found'] as num?)?.toInt() ?? 0,
added: (raw['added'] as num?)?.toInt() ?? 0,
updated: (raw['updated'] as num?)?.toInt() ?? 0,
removedOffline: (raw['removed_offline'] as num?)?.toInt() ?? 0,
pendingRemoval: (raw['pending_removal'] as num?)?.toInt() ?? 0,
online: (raw['online'] as num?)?.toInt() ?? 0,
);
}
}
class WizProvisioningState {
final WizProvisioningStatus status;
final WizProvisioningEnvironment environment;
final String? activeHomeName;
final WizProvisioningFailure? failure;
final WizRescanSummary? rescanSummary;
final List<WizProvisioningDevice> provisionedDevices;
final List<String> timeline;
final String? notice;
const WizProvisioningState({
required this.status,
required this.environment,
required this.activeHomeName,
required this.failure,
required this.rescanSummary,
required this.provisionedDevices,
required this.timeline,
required this.notice,
});
factory WizProvisioningState.initial() => WizProvisioningState(
status: WizProvisioningStatus.initial,
environment: WizProvisioningEnvironment.unsupported(),
activeHomeName: null,
failure: null,
rescanSummary: null,
provisionedDevices: const [],
timeline: const [],
notice: null,
);
bool get isBusy =>
status == WizProvisioningStatus.loadingEnvironment ||
status == WizProvisioningStatus.provisioning ||
status == WizProvisioningStatus.rescanning;
bool get canStart =>
status == WizProvisioningStatus.ready ||
status == WizProvisioningStatus.failure ||
status == WizProvisioningStatus.attentionRequired;
WizProvisioningState copyWith({
WizProvisioningStatus? status,
WizProvisioningEnvironment? environment,
String? activeHomeName,
WizProvisioningFailure? failure,
bool clearFailure = false,
WizRescanSummary? rescanSummary,
bool clearRescanSummary = false,
List<WizProvisioningDevice>? provisionedDevices,
List<String>? timeline,
String? notice,
bool clearNotice = false,
}) {
return WizProvisioningState(
status: status ?? this.status,
environment: environment ?? this.environment,
activeHomeName: activeHomeName ?? this.activeHomeName,
failure: clearFailure ? null : (failure ?? this.failure),
rescanSummary: clearRescanSummary
? null
: (rescanSummary ?? this.rescanSummary),
provisionedDevices: provisionedDevices ?? this.provisionedDevices,
timeline: timeline ?? this.timeline,
notice: clearNotice ? null : (notice ?? this.notice),
);
}
}

View File

@@ -0,0 +1,15 @@
class WizProvisioningTiming {
final Duration provisioningTimeout;
final Duration settleAfterFirstResponse;
final Duration initialRescanDelay;
final Duration retryRescanDelay;
final int maxRescanAttempts;
const WizProvisioningTiming({
this.provisioningTimeout = const Duration(seconds: 45),
this.settleAfterFirstResponse = const Duration(seconds: 3),
this.initialRescanDelay = const Duration(seconds: 3),
this.retryRescanDelay = const Duration(seconds: 4),
this.maxRescanAttempts = 3,
});
}

View File

@@ -0,0 +1,361 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/error_message.dart';
import '../../homes/providers/homes_providers.dart';
import '../../remote/providers/remote_providers.dart';
import '../../shared/providers/core_providers.dart';
import '../models/wiz_provisioning_device.dart';
import '../models/wiz_provisioning_environment.dart';
import '../models/wiz_provisioning_failure.dart';
import '../models/wiz_provisioning_state.dart';
import '../models/wiz_provisioning_timing.dart';
import '../services/wiz_provisioning_platform_service.dart';
import '../services/wiz_smart_pairing_service.dart';
final wizProvisioningPlatformServiceProvider =
Provider<WizProvisioningPlatformService>(
(ref) => const DeviceWizProvisioningPlatformService(),
);
final wizSmartPairingServiceProvider = Provider<WizSmartPairingService>(
(ref) => EspTouchWizSmartPairingService(),
);
final wizProvisioningTimingProvider = Provider<WizProvisioningTiming>(
(ref) => const WizProvisioningTiming(),
);
final wizProvisioningProvider =
NotifierProvider<WizProvisioningNotifier, WizProvisioningState>(
WizProvisioningNotifier.new,
);
class WizProvisioningNotifier extends Notifier<WizProvisioningState> {
WizProvisioningPlatformService get _platform =>
ref.read(wizProvisioningPlatformServiceProvider);
WizSmartPairingService get _smartPairing =>
ref.read(wizSmartPairingServiceProvider);
WizProvisioningTiming get _timing => ref.read(wizProvisioningTimingProvider);
@override
WizProvisioningState build() {
final smartPairing = ref.watch(wizSmartPairingServiceProvider);
ref.onDispose(() {
unawaited(smartPairing.stopProvisioning());
});
return WizProvisioningState.initial();
}
Future<void> initialize() async {
final home = ref.read(currentHomeProvider);
state = state.copyWith(
status: WizProvisioningStatus.loadingEnvironment,
activeHomeName: home?.name,
clearFailure: true,
clearNotice: true,
);
final environment = await _platform.inspectEnvironment();
state = state.copyWith(
environment: environment,
activeHomeName: home?.name,
);
if (home == null) {
_setFailure(
const WizProvisioningFailure(
kind: WizProvisioningFailureKind.noActiveHome,
message:
'Сначала выберите активный дом. Мастер использует именно его сервер Ignis для финального discovery.',
),
environment: environment,
);
return;
}
if (!environment.smartPairingSupported || !environment.isAndroid) {
state = state.copyWith(
status: WizProvisioningStatus.unsupported,
failure: const WizProvisioningFailure(
kind: WizProvisioningFailureKind.unsupportedPlatform,
message:
'В этой сборке мастер Smart Pairing поддерживается только на Android.',
),
);
return;
}
if (!environment.permissionsGranted) {
state = state.copyWith(
status: WizProvisioningStatus.attentionRequired,
failure: const WizProvisioningFailure(
kind: WizProvisioningFailureKind.missingPermissions,
message:
'Нужны разрешения на доступ к Wi-Fi окружению, иначе приложение не сможет проверить текущую сеть.',
),
);
return;
}
if (!environment.locationServicesEnabled) {
_setFailure(
const WizProvisioningFailure(
kind: WizProvisioningFailureKind.locationServicesDisabled,
message:
'На Android системные сервисы геолокации должны быть включены, иначе SSID/BSSID домашней Wi-Fi часто недоступны.',
),
environment: environment,
);
return;
}
if (!environment.connectedToWifi || environment.ssid == null) {
_setFailure(
const WizProvisioningFailure(
kind: WizProvisioningFailureKind.wifiUnavailable,
message:
'Сначала подключите телефон к домашней Wi-Fi сети 2.4 GHz, к которой должна присоединиться лампа.',
),
environment: environment,
);
return;
}
final notice = environment.isLikelyOn5Ghz
? 'Телефон, похоже, сидит на 5 GHz. WiZ-лампы подключаются только к 2.4 GHz, поэтому лучше переключиться на нужную сеть до старта pairing.'
: null;
state = state.copyWith(
status: WizProvisioningStatus.ready,
clearFailure: true,
clearRescanSummary: true,
provisionedDevices: const [],
notice: notice,
timeline: _appendTimeline(
state.timeline,
'Окружение проверено: готово к smart pairing.',
),
);
}
Future<void> requestPermissions() async {
await _platform.requestPermissions();
await initialize();
}
Future<void> openWifiSettings() => _platform.openWifiSettings();
Future<void> openAppSettings() => _platform.openAppSettings();
Future<void> startProvisioning({
required String ssid,
required String password,
String? bssid,
}) async {
final normalizedSsid = ssid.trim();
if (normalizedSsid.isEmpty) {
_setFailure(
const WizProvisioningFailure(
kind: WizProvisioningFailureKind.invalidSsid,
message: 'Укажите SSID домашней Wi-Fi сети.',
),
);
return;
}
final home = ref.read(currentHomeProvider);
if (home == null) {
_setFailure(
const WizProvisioningFailure(
kind: WizProvisioningFailureKind.noActiveHome,
message:
'Активный дом потерялся. Вернитесь на экран домов и выберите его заново.',
),
);
return;
}
final devices = <WizProvisioningDevice>[];
StreamSubscription<WizProvisioningDevice>? subscription;
final firstResponse = Completer<void>();
state = state.copyWith(
status: WizProvisioningStatus.provisioning,
clearFailure: true,
clearNotice: true,
clearRescanSummary: true,
provisionedDevices: const [],
timeline: _appendTimeline(
state.timeline,
'Старт smart pairing для сети "$normalizedSsid".',
),
);
try {
final stream = _smartPairing.startProvisioning(
ssid: normalizedSsid,
password: password,
bssid: bssid,
);
subscription = stream.listen(
(device) {
final duplicate = devices.any((item) => item.bssid == device.bssid);
if (duplicate) {
return;
}
devices.add(device);
state = state.copyWith(
provisionedDevices: List<WizProvisioningDevice>.unmodifiable(
devices,
),
timeline: _appendTimeline(
state.timeline,
'Лампа подтвердила pairing: ${device.bssid}${device.ipAddress == null ? '' : ' (${device.ipAddress})'}.',
),
);
if (!firstResponse.isCompleted) {
firstResponse.complete();
}
},
onError: (Object error, StackTrace stackTrace) {
if (!firstResponse.isCompleted) {
firstResponse.completeError(error, stackTrace);
}
},
);
await firstResponse.future.timeout(_timing.provisioningTimeout);
await Future<void>.delayed(_timing.settleAfterFirstResponse);
await _smartPairing.stopProvisioning();
await subscription.cancel();
subscription = null;
state = state.copyWith(
status: WizProvisioningStatus.rescanning,
timeline: _appendTimeline(
state.timeline,
'Pairing подтверждён, запускаю повторный discovery на сервере Ignis.',
),
);
final summary = await _rescanUntilSettled();
final notice = (summary.added == 0 && summary.updated == 0)
? 'Лампа ответила на smart pairing, но backend не нашёл новое устройство как added/updated. Возможно, устройство уже было известно или ему нужно чуть больше времени.'
: null;
if (ref.read(groupsLoadStateProvider).status != GroupsLoadStatus.idle) {
await ref.read(groupsProvider.notifier).refresh();
}
state = state.copyWith(
status: WizProvisioningStatus.success,
rescanSummary: summary,
notice: notice,
timeline: _appendTimeline(
state.timeline,
'Discovery завершён: found=${summary.found}, added=${summary.added}, updated=${summary.updated}, online=${summary.online}.',
),
);
} on TimeoutException {
_setFailure(
const WizProvisioningFailure(
kind: WizProvisioningFailureKind.provisioningTimedOut,
message:
'Лампа не ответила вовремя. Проверьте, что она в pairing mode, телефон подключён к 2.4 GHz Wi-Fi, и попробуйте ещё раз.',
),
);
} on WizProvisioningFailure catch (failure) {
_setFailure(failure);
} catch (error) {
final message = describeLoadError(error);
_setFailure(
WizProvisioningFailure(
kind: WizProvisioningFailureKind.provisioningFailed,
message: 'Smart pairing завершился ошибкой.',
details: message,
),
);
} finally {
await subscription?.cancel();
await _smartPairing.stopProvisioning();
}
}
Future<void> cancelProvisioning({bool keepCurrentState = true}) async {
await _smartPairing.stopProvisioning();
if (!keepCurrentState) {
return;
}
final fallbackStatus =
state.environment.permissionsGranted &&
state.environment.locationServicesEnabled &&
state.environment.connectedToWifi &&
state.activeHomeName != null
? WizProvisioningStatus.ready
: WizProvisioningStatus.attentionRequired;
state = state.copyWith(
status: fallbackStatus,
notice: 'Текущая сессия pairing остановлена.',
timeline: _appendTimeline(state.timeline, 'Сессия pairing отменена.'),
);
}
Future<WizRescanSummary> _rescanUntilSettled() async {
final api = ref.read(apiProvider);
Object? lastError;
WizRescanSummary? lastSummary;
for (var attempt = 0; attempt < _timing.maxRescanAttempts; attempt += 1) {
await Future<void>.delayed(
attempt == 0 ? _timing.initialRescanDelay : _timing.retryRescanDelay,
);
try {
final response = await api.rescanNetwork();
lastSummary = WizRescanSummary.fromMap(
Map<String, dynamic>.from(response.data as Map),
);
state = state.copyWith(rescanSummary: lastSummary);
if (lastSummary.added > 0 || lastSummary.updated > 0) {
return lastSummary;
}
} catch (error) {
lastError = error;
}
}
if (lastSummary != null) {
return lastSummary;
}
throw WizProvisioningFailure(
kind: WizProvisioningFailureKind.rescanFailed,
message:
'Лампа приняла настройки, но финальный discovery на сервере Ignis не удался.',
details: lastError == null ? null : describeLoadError(lastError),
);
}
void _setFailure(
WizProvisioningFailure failure, {
WizProvisioningEnvironment? environment,
}) {
final resolvedEnvironment = environment ?? state.environment;
final status =
failure.kind == WizProvisioningFailureKind.unsupportedPlatform
? WizProvisioningStatus.unsupported
: WizProvisioningStatus.failure;
state = state.copyWith(
status: status,
environment: resolvedEnvironment,
failure: failure,
timeline: _appendTimeline(state.timeline, failure.message),
);
}
List<String> _appendTimeline(List<String> current, String event) {
return List<String>.unmodifiable(<String>[...current, event]);
}
}

View File

@@ -0,0 +1,62 @@
import 'package:flutter/services.dart';
import '../models/wiz_provisioning_environment.dart';
abstract class WizProvisioningPlatformService {
Future<WizProvisioningEnvironment> inspectEnvironment();
Future<void> requestPermissions();
Future<void> openWifiSettings();
Future<void> openAppSettings();
}
class DeviceWizProvisioningPlatformService
implements WizProvisioningPlatformService {
const DeviceWizProvisioningPlatformService();
static const _channel = MethodChannel('ignis/wiz_provisioning');
@override
Future<WizProvisioningEnvironment> inspectEnvironment() async {
try {
final raw = await _channel.invokeMapMethod<Object?, Object?>(
'getProvisioningEnvironment',
);
if (raw == null) {
return WizProvisioningEnvironment.unsupported();
}
return WizProvisioningEnvironment.fromMap(Map<String, dynamic>.from(raw));
} on MissingPluginException {
return WizProvisioningEnvironment.unsupported();
}
}
@override
Future<void> requestPermissions() async {
try {
await _channel.invokeMethod<void>('requestProvisioningPermissions');
} on MissingPluginException {
return;
}
}
@override
Future<void> openWifiSettings() async {
try {
await _channel.invokeMethod<void>('openWifiSettings');
} on MissingPluginException {
return;
}
}
@override
Future<void> openAppSettings() async {
try {
await _channel.invokeMethod<void>('openAppSettings');
} on MissingPluginException {
return;
}
}
}

View File

@@ -0,0 +1,93 @@
import 'dart:async';
import 'package:esp_smartconfig/esp_smartconfig.dart';
import '../models/wiz_provisioning_device.dart';
abstract class WizSmartPairingService {
Stream<WizProvisioningDevice> startProvisioning({
required String ssid,
required String password,
String? bssid,
});
Future<void> stopProvisioning();
}
class EspTouchWizSmartPairingService implements WizSmartPairingService {
Provisioner? _provisioner;
StreamSubscription<ProvisioningResponse>? _subscription;
StreamController<WizProvisioningDevice>? _controller;
@override
Stream<WizProvisioningDevice> startProvisioning({
required String ssid,
required String password,
String? bssid,
}) {
if (_provisioner != null || _controller != null) {
throw StateError('Provisioning is already running');
}
final provisioner = Provisioner.espTouch();
final controller = StreamController<WizProvisioningDevice>.broadcast();
_provisioner = provisioner;
_controller = controller;
_subscription = provisioner.listen(
(response) {
controller.add(
WizProvisioningDevice(
bssid: response.bssidText,
ipAddress: response.ipAddressText,
),
);
},
onError: controller.addError,
onDone: () async {
if (!controller.isClosed) {
await controller.close();
}
},
);
final request = ProvisioningRequest.fromStrings(
ssid: ssid,
bssid: (bssid == null || bssid.trim().isEmpty)
? '00:00:00:00:00:00'
: bssid.trim(),
password: password.isEmpty ? null : password,
);
unawaited(
provisioner.start(request).catchError((
Object error,
StackTrace stack,
) async {
if (!controller.isClosed) {
controller.addError(error, stack);
await controller.close();
}
}),
);
return controller.stream;
}
@override
Future<void> stopProvisioning() async {
final provisioner = _provisioner;
_provisioner = null;
try {
provisioner?.stop();
} finally {
await _subscription?.cancel();
_subscription = null;
if (_controller != null && !_controller!.isClosed) {
await _controller!.close();
}
_controller = null;
}
}
}