Add WiZ provisioning wizard
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user