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