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( (ref) => const DeviceWizProvisioningPlatformService(), ); final wizSmartPairingServiceProvider = Provider( (ref) => EspTouchWizSmartPairingService(), ); final wizProvisioningTimingProvider = Provider( (ref) => const WizProvisioningTiming(), ); final wizProvisioningProvider = NotifierProvider( WizProvisioningNotifier.new, ); class WizProvisioningNotifier extends Notifier { 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 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 requestPermissions() async { await _platform.requestPermissions(); await initialize(); } Future openWifiSettings() => _platform.openWifiSettings(); Future openAppSettings() => _platform.openAppSettings(); Future 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 = []; StreamSubscription? subscription; final firstResponse = Completer(); 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.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.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 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 _rescanUntilSettled() async { final api = ref.read(apiProvider); Object? lastError; WizRescanSummary? lastSummary; for (var attempt = 0; attempt < _timing.maxRescanAttempts; attempt += 1) { await Future.delayed( attempt == 0 ? _timing.initialRescanDelay : _timing.retryRescanDelay, ); try { final response = await api.rescanNetwork(); lastSummary = WizRescanSummary.fromMap( Map.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 _appendTimeline(List current, String event) { return List.unmodifiable([...current, event]); } }