362 lines
12 KiB
Dart
362 lines
12 KiB
Dart
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]);
|
||
}
|
||
}
|