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,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]);
}
}