From f1af4b8b1736358a4e29440e0a9f66daf90bd0cc Mon Sep 17 00:00:00 2001 From: Dominic Pham Date: Sun, 15 Feb 2026 14:33:16 -0800 Subject: [PATCH 1/7] Device registration --- .../scanner/domain/entities/scanner_state.dart | 14 +++++++------- .../domain/value_objects/serial_patterns.dart | 12 ++++++------ .../presentation/screens/scanner_screen_v2.dart | 4 ++-- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/lib/features/scanner/domain/entities/scanner_state.dart b/lib/features/scanner/domain/entities/scanner_state.dart index ee14ce6..d5e2764 100644 --- a/lib/features/scanner/domain/entities/scanner_state.dart +++ b/lib/features/scanner/domain/entities/scanner_state.dart @@ -15,13 +15,13 @@ enum ScanMode { /// RxG credentials QR code. rxg, - /// Access Point (1K9/1M3/1HN serials). + /// Access Point (1K9/1M3/1HN/C0C serials). accessPoint, /// ONT device (ALCL serials). ont, - /// Network Switch (LL/EC serials). + /// Network Switch (LL serials). switchDevice, } @@ -93,7 +93,7 @@ class AccumulatedScanData with _$AccumulatedScanData { // AP requires: MAC + AP Serial (1K9/1M3/1HN) return mac.isNotEmpty && serialNumber.isNotEmpty && hasValidSerial; case ScanMode.switchDevice: - // Switch requires: MAC + LL/EC Serial + // Switch requires: MAC + LL Serial return mac.isNotEmpty && serialNumber.isNotEmpty && hasValidSerial; case ScanMode.rxg: case ScanMode.auto: @@ -119,11 +119,11 @@ class AccumulatedScanData with _$AccumulatedScanData { } case ScanMode.accessPoint: if (serialNumber.isEmpty || !hasValidSerial) { - missing.add('Serial Number (1K9/1M3/1HN)'); + missing.add('Serial Number (1K9/1M3/1HN/C0C)'); } case ScanMode.switchDevice: if (serialNumber.isEmpty || !hasValidSerial) { - missing.add('Serial Number (LL/EC)'); + missing.add('Serial Number (LL)'); } case ScanMode.rxg: case ScanMode.auto: @@ -317,9 +317,9 @@ extension ScanModeX on ScanMode { case ScanMode.ont: return ['MAC Address', 'Serial Number (ALCL)', 'Part Number']; case ScanMode.accessPoint: - return ['MAC Address', 'Serial Number (1K9/1M3/1HN)']; + return ['MAC Address', 'Serial Number (1K9/1M3/1HN/C0C)']; case ScanMode.switchDevice: - return ['MAC Address', 'Serial Number (LL/EC)']; + return ['MAC Address', 'Serial Number (LL)']; case ScanMode.rxg: return ['QR Code']; case ScanMode.auto: diff --git a/lib/features/scanner/domain/value_objects/serial_patterns.dart b/lib/features/scanner/domain/value_objects/serial_patterns.dart index 49aadc0..7510eb9 100644 --- a/lib/features/scanner/domain/value_objects/serial_patterns.dart +++ b/lib/features/scanner/domain/value_objects/serial_patterns.dart @@ -4,13 +4,13 @@ class SerialPatterns { SerialPatterns._(); // AP serial prefixes (Access Points) - static const List apPrefixes = ['1K9', '1M3', '1HN']; + static const List apPrefixes = ['1K9', '1M3', '1HN', 'C0C']; // ONT serial prefixes (Optical Network Terminal / Media Converter) static const List ontPrefixes = ['ALCL']; - // Switch serial prefixes (LL for legacy, EC for Edge-core) - static const List switchPrefixes = ['LL', 'EC']; + // Switch serial prefixes + static const List switchPrefixes = ['LL']; /// Check if serial is an Access Point serial number. /// AP serials start with 1K9, 1M3, or 1HN and are at least 10 characters. @@ -27,10 +27,10 @@ class SerialPatterns { } /// Check if serial is a Switch serial number. - /// Switch serials start with LL or EC and are at least 12 characters. + /// Switch serials start with LL and are at least 14 characters. static bool isSwitchSerial(String serial) { final s = serial.toUpperCase().trim(); - return s.length >= 12 && + return s.length >= 14 && switchPrefixes.any((prefix) => s.startsWith(prefix)); } @@ -69,7 +69,7 @@ class SerialPatterns { case DeviceTypeFromSerial.ont: return 'ONT serials start with ${ontPrefixes.join(", ")} (exactly 12 chars)'; case DeviceTypeFromSerial.switchDevice: - return 'Switch serials start with ${switchPrefixes.join(", ")} (min 12 chars)'; + return 'Switch serials start with ${switchPrefixes.join(", ")} (min 14 chars)'; } } } diff --git a/lib/features/scanner/presentation/screens/scanner_screen_v2.dart b/lib/features/scanner/presentation/screens/scanner_screen_v2.dart index c890d57..c2c87ca 100644 --- a/lib/features/scanner/presentation/screens/scanner_screen_v2.dart +++ b/lib/features/scanner/presentation/screens/scanner_screen_v2.dart @@ -985,11 +985,11 @@ class _ModeSelectorSheet extends StatelessWidget { case ScanMode.rxg: return 'Scan RxG credentials QR code'; case ScanMode.accessPoint: - return 'Scan AP (1K9/1M3/1HN serial)'; + return 'Scan AP (1K9/1M3/1HN/C0C serial)'; case ScanMode.ont: return 'Scan ONT (ALCL serial + part number)'; case ScanMode.switchDevice: - return 'Scan Switch (LL/EC serial)'; + return 'Scan Switch (LL serial)'; } } } From 1e269e20316eeffa1ca145cc5268bf5a5f5e8102 Mon Sep 17 00:00:00 2001 From: Dominic Pham Date: Sun, 15 Feb 2026 14:33:39 -0800 Subject: [PATCH 2/7] handle edgecore --- .../providers/devices_provider.g.dart | 2 +- .../domain/entities/scanner_state.dart | 12 +-- .../domain/value_objects/serial_patterns.dart | 68 ++++++++++--- .../device_registration_provider.dart | 24 ++++- .../device_registration_provider.g.dart | 2 +- .../providers/scanner_notifier_v2.dart | 99 ++++++++++++++++++- .../providers/scanner_notifier_v2.g.dart | 2 +- .../screens/scanner_screen_v2.dart | 14 +-- .../widgets/scanner_registration_popup.dart | 3 +- 9 files changed, 190 insertions(+), 36 deletions(-) diff --git a/lib/features/devices/presentation/providers/devices_provider.g.dart b/lib/features/devices/presentation/providers/devices_provider.g.dart index 35f30a3..8a0f82e 100644 --- a/lib/features/devices/presentation/providers/devices_provider.g.dart +++ b/lib/features/devices/presentation/providers/devices_provider.g.dart @@ -6,7 +6,7 @@ part of 'devices_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$devicesNotifierHash() => r'766bf18de3493cae7dc0846a98e088328641f561'; +String _$devicesNotifierHash() => r'ccc7d42c205bed71cd8a57fb43c75d374a432a55'; /// See also [DevicesNotifier]. @ProviderFor(DevicesNotifier) diff --git a/lib/features/scanner/domain/entities/scanner_state.dart b/lib/features/scanner/domain/entities/scanner_state.dart index d5e2764..cb3f34b 100644 --- a/lib/features/scanner/domain/entities/scanner_state.dart +++ b/lib/features/scanner/domain/entities/scanner_state.dart @@ -15,13 +15,13 @@ enum ScanMode { /// RxG credentials QR code. rxg, - /// Access Point (1K9/1M3/1HN/C0C serials). + /// Access Point (1K9/1M3/1HN/EC2 serials). accessPoint, /// ONT device (ALCL serials). ont, - /// Network Switch (LL serials). + /// Network Switch (LL/EC2 serials). switchDevice, } @@ -119,11 +119,11 @@ class AccumulatedScanData with _$AccumulatedScanData { } case ScanMode.accessPoint: if (serialNumber.isEmpty || !hasValidSerial) { - missing.add('Serial Number (1K9/1M3/1HN/C0C)'); + missing.add('Serial Number (1K9/1M3/1HN/EC2)'); } case ScanMode.switchDevice: if (serialNumber.isEmpty || !hasValidSerial) { - missing.add('Serial Number (LL)'); + missing.add('Serial Number (LL/EC2)'); } case ScanMode.rxg: case ScanMode.auto: @@ -317,9 +317,9 @@ extension ScanModeX on ScanMode { case ScanMode.ont: return ['MAC Address', 'Serial Number (ALCL)', 'Part Number']; case ScanMode.accessPoint: - return ['MAC Address', 'Serial Number (1K9/1M3/1HN/C0C)']; + return ['MAC Address', 'Serial Number (1K9/1M3/1HN/EC2)']; case ScanMode.switchDevice: - return ['MAC Address', 'Serial Number (LL)']; + return ['MAC Address', 'Serial Number (LL/EC2)']; case ScanMode.rxg: return ['QR Code']; case ScanMode.auto: diff --git a/lib/features/scanner/domain/value_objects/serial_patterns.dart b/lib/features/scanner/domain/value_objects/serial_patterns.dart index 7510eb9..b0c46d4 100644 --- a/lib/features/scanner/domain/value_objects/serial_patterns.dart +++ b/lib/features/scanner/domain/value_objects/serial_patterns.dart @@ -3,14 +3,27 @@ class SerialPatterns { SerialPatterns._(); - // AP serial prefixes (Access Points) - static const List apPrefixes = ['1K9', '1M3', '1HN', 'C0C']; + // AP serial prefixes (Access Points) - for manual mode validation + static const List apPrefixes = ['1K9', '1M3', '1HN', 'EC2']; + + // AP prefixes for auto-detect (excludes ambiguous EC2) + static const List _apAutoDetectPrefixes = ['1K9', '1M3', '1HN']; // ONT serial prefixes (Optical Network Terminal / Media Converter) static const List ontPrefixes = ['ALCL']; - // Switch serial prefixes - static const List switchPrefixes = ['LL']; + // Switch serial prefixes - for manual mode validation + static const List switchPrefixes = ['LL', 'EC2']; + + // Switch prefixes for auto-detect (excludes ambiguous EC2) + static const List _switchAutoDetectPrefixes = ['LL']; + + // Part number prefixes for Edge-core device differentiation + // FI2WL = Edge-core AP (EAP models) + static const List apPartNumberPrefixes = ['FI2WL']; + + // F0PWL = Edge-core Switch (ECS models) + static const List switchPartNumberPrefixes = ['F0PWL']; /// Check if serial is an Access Point serial number. /// AP serials start with 1K9, 1M3, or 1HN and are at least 10 characters. @@ -27,25 +40,58 @@ class SerialPatterns { } /// Check if serial is a Switch serial number. - /// Switch serials start with LL and are at least 14 characters. + /// Switch serials start with LL or EC2 and are at least 12 characters. static bool isSwitchSerial(String serial) { final s = serial.toUpperCase().trim(); - return s.length >= 14 && + return s.length >= 12 && switchPrefixes.any((prefix) => s.startsWith(prefix)); } - /// Detect device type from serial number. - /// Returns null if serial doesn't match any known pattern. + /// Check if serial is an ambiguous EC2 serial (could be AP or Switch). + static bool isAmbiguousEC2Serial(String serial) { + final s = serial.toUpperCase().trim(); + return s.startsWith('EC2') && s.length >= 10; + } + + /// Detect device type from part number (for EC2 devices). + /// Returns null if part number doesn't match known patterns. + static DeviceTypeFromSerial? detectDeviceTypeFromPartNumber(String partNumber) { + final p = partNumber.toUpperCase().trim(); + + // Check AP part number prefixes (FI2WL = Edge-core EAP) + if (apPartNumberPrefixes.any((prefix) => p.startsWith(prefix))) { + return DeviceTypeFromSerial.accessPoint; + } + + // Check Switch part number prefixes (F0PWL = Edge-core ECS) + if (switchPartNumberPrefixes.any((prefix) => p.startsWith(prefix))) { + return DeviceTypeFromSerial.switchDevice; + } + + return null; + } + + /// Detect device type from serial number for auto-detect mode. + /// Returns null if serial doesn't match any unique pattern. + /// Note: EC2 serials return null - use detectDeviceTypeFromPartNumber instead. static DeviceTypeFromSerial? detectDeviceType(String serial) { - if (isAPSerial(serial)) { + final s = serial.toUpperCase().trim(); + + // Check AP auto-detect prefixes (excludes EC2) + if (s.length >= 10 && _apAutoDetectPrefixes.any((prefix) => s.startsWith(prefix))) { return DeviceTypeFromSerial.accessPoint; } + + // Check ONT if (isONTSerial(serial)) { return DeviceTypeFromSerial.ont; } - if (isSwitchSerial(serial)) { + + // Check Switch auto-detect prefixes (excludes EC2) + if (s.length >= 12 && _switchAutoDetectPrefixes.any((prefix) => s.startsWith(prefix))) { return DeviceTypeFromSerial.switchDevice; } + return null; } @@ -69,7 +115,7 @@ class SerialPatterns { case DeviceTypeFromSerial.ont: return 'ONT serials start with ${ontPrefixes.join(", ")} (exactly 12 chars)'; case DeviceTypeFromSerial.switchDevice: - return 'Switch serials start with ${switchPrefixes.join(", ")} (min 14 chars)'; + return 'Switch serials start with ${switchPrefixes.join(", ")} (min 12 chars)'; } } } diff --git a/lib/features/scanner/presentation/providers/device_registration_provider.dart b/lib/features/scanner/presentation/providers/device_registration_provider.dart index 0627f72..1d33e6e 100644 --- a/lib/features/scanner/presentation/providers/device_registration_provider.dart +++ b/lib/features/scanner/presentation/providers/device_registration_provider.dart @@ -509,10 +509,14 @@ class DeviceRegistrationNotifier extends _$DeviceRegistrationNotifier { ); try { - // Validate serial pattern - final detectedType = SerialPatterns.detectDeviceType(serial); - if (detectedType == null) { - final error = 'Invalid serial number format for ${deviceType.displayName}'; + // Validate serial pattern matches the device type + // Use isValidForType instead of detectDeviceType to support EC2 serials + // (EC2 is valid for both AP and Switch in manual mode) + final serialType = _deviceTypeToSerialType(deviceType); + final isValidSerial = SerialPatterns.isValidForType(serial, serialType); + if (!isValidSerial) { + final expected = SerialPatterns.getExpectedFormat(serialType); + final error = 'Invalid serial number format for ${deviceType.displayName}. $expected'; state = state.copyWith( status: RegistrationStatus.error, errorMessage: error, @@ -597,6 +601,18 @@ class DeviceRegistrationNotifier extends _$DeviceRegistrationNotifier { _deviceIndexByMac.clear(); _deviceIndexBySerial.clear(); } + + /// Convert DeviceType to DeviceTypeFromSerial for serial validation. + DeviceTypeFromSerial _deviceTypeToSerialType(DeviceType type) { + switch (type) { + case DeviceType.accessPoint: + return DeviceTypeFromSerial.accessPoint; + case DeviceType.ont: + return DeviceTypeFromSerial.ont; + case DeviceType.switchDevice: + return DeviceTypeFromSerial.switchDevice; + } + } } /// Stream provider for device registration events from WebSocket. diff --git a/lib/features/scanner/presentation/providers/device_registration_provider.g.dart b/lib/features/scanner/presentation/providers/device_registration_provider.g.dart index d9e4305..53c157f 100644 --- a/lib/features/scanner/presentation/providers/device_registration_provider.g.dart +++ b/lib/features/scanner/presentation/providers/device_registration_provider.g.dart @@ -46,7 +46,7 @@ final deviceWebSocketEventsProvider = typedef DeviceWebSocketEventsRef = AutoDisposeStreamProviderRef; String _$deviceRegistrationNotifierHash() => - r'cd4ecef75b04489157cd8594d31fe47c6c60af40'; + r'6a6fcd7ddfcab8f52b94d232f1abb5f65ae5ecf0'; /// Provider for device registration with WebSocket integration. /// Handles checking existing devices and registering new ones. diff --git a/lib/features/scanner/presentation/providers/scanner_notifier_v2.dart b/lib/features/scanner/presentation/providers/scanner_notifier_v2.dart index d6e2350..fa327d3 100644 --- a/lib/features/scanner/presentation/providers/scanner_notifier_v2.dart +++ b/lib/features/scanner/presentation/providers/scanner_notifier_v2.dart @@ -63,13 +63,25 @@ class ScannerNotifierV2 extends _$ScannerNotifierV2 { return; } + // Check if it's an ambiguous EC2 serial + if (SerialPatterns.isAmbiguousEC2Serial(value)) { + // In manual mode (AP or Switch), process as valid serial for that mode + if (state.scanMode == ScanMode.accessPoint || state.scanMode == ScanMode.switchDevice) { + _processManualModeEC2Serial(value); + } else { + // In auto mode, wait for part number to determine type + _processAmbiguousEC2Serial(value); + } + return; + } + // Check if it's a MAC address if (_isMacAddress(value)) { _processMacAddress(value); return; } - // Check for part number pattern (ONT) + // Check for part number pattern if (_isPartNumber(value)) { _processPartNumber(value); return; @@ -78,6 +90,52 @@ class ScannerNotifierV2 extends _$ScannerNotifierV2 { LoggerService.debug('Barcode did not match any known pattern: $value', tag: _tag); } + /// Process an ambiguous EC2 serial (could be AP or Switch). + /// Stores the serial but doesn't auto-lock mode - waits for part number. + void _processAmbiguousEC2Serial(String serial) { + final upperSerial = serial.toUpperCase(); + LoggerService.debug('Processing ambiguous EC2 serial: $upperSerial (awaiting part number)', tag: _tag); + + // Store the serial but don't auto-lock mode yet + state = state.copyWith( + lastSerialSeenAt: DateTime.now(), + scanData: state.scanData.copyWith( + serialNumber: upperSerial, + hasValidSerial: true, + scanHistory: [ + ...state.scanData.scanHistory, + ScanRecord(value: upperSerial, scannedAt: DateTime.now(), fieldType: 'serial'), + ], + ), + ); + + // Don't check completion - we need part number to determine device type + } + + /// Process an EC2 serial when user has manually selected AP or Switch mode. + /// No need to wait for part number - user already chose the device type. + void _processManualModeEC2Serial(String serial) { + final upperSerial = serial.toUpperCase(); + LoggerService.debug( + 'Processing EC2 serial in manual ${state.scanMode.displayName} mode: $upperSerial', + tag: _tag, + ); + + state = state.copyWith( + lastSerialSeenAt: DateTime.now(), + scanData: state.scanData.copyWith( + serialNumber: upperSerial, + hasValidSerial: true, + scanHistory: [ + ...state.scanData.scanHistory, + ScanRecord(value: upperSerial, scannedAt: DateTime.now(), fieldType: 'serial'), + ], + ), + ); + + _checkCompletion(); + } + /// Process a serial number barcode. void _processSerial(String serial, DeviceTypeFromSerial detectedType) { final upperSerial = serial.toUpperCase(); @@ -153,10 +211,42 @@ class ScannerNotifierV2 extends _$ScannerNotifierV2 { _checkCompletion(); } - /// Process a part number barcode (for ONT). + /// Process a part number barcode. + /// For EC2 devices, uses part number to determine AP vs Switch. void _processPartNumber(String partNumber) { LoggerService.debug('Processing part number: $partNumber', tag: _tag); + // Check if we have an EC2 serial and are in auto mode - use part number to determine type + if (state.scanMode == ScanMode.auto && + state.scanData.serialNumber.isNotEmpty && + SerialPatterns.isAmbiguousEC2Serial(state.scanData.serialNumber)) { + final detectedType = SerialPatterns.detectDeviceTypeFromPartNumber(partNumber); + + if (detectedType != null) { + final newMode = _deviceTypeToScanMode(detectedType); + LoggerService.debug( + 'Part number detected device type: ${detectedType.displayName}, auto-locking to $newMode', + tag: _tag, + ); + + state = state.copyWith( + scanMode: newMode, + isAutoLocked: true, + scanData: state.scanData.copyWith( + partNumber: partNumber, + scanHistory: [ + ...state.scanData.scanHistory, + ScanRecord(value: partNumber, scannedAt: DateTime.now(), fieldType: 'partNumber'), + ], + ), + ); + + _checkCompletion(); + return; + } + } + + // Normal part number processing (for ONT or manual mode) state = state.copyWith( scanData: state.scanData.copyWith( partNumber: partNumber, @@ -202,9 +292,12 @@ class ScannerNotifierV2 extends _$ScannerNotifierV2 { /// Hide the registration popup. void hideRegistrationPopup() { LoggerService.debug('Hiding registration popup', tag: _tag); + // If scan data is still complete, go back to success state so user can tap Register again + // Otherwise go to idle state + final newUiState = state.isScanComplete ? ScannerUIState.success : ScannerUIState.idle; state = state.copyWith( isPopupShowing: false, - uiState: ScannerUIState.idle, + uiState: newUiState, ); } diff --git a/lib/features/scanner/presentation/providers/scanner_notifier_v2.g.dart b/lib/features/scanner/presentation/providers/scanner_notifier_v2.g.dart index 19a6dd3..508e65d 100644 --- a/lib/features/scanner/presentation/providers/scanner_notifier_v2.g.dart +++ b/lib/features/scanner/presentation/providers/scanner_notifier_v2.g.dart @@ -6,7 +6,7 @@ part of 'scanner_notifier_v2.dart'; // RiverpodGenerator // ************************************************************************** -String _$scannerNotifierV2Hash() => r'92c4b30d37c23353d925e36b46402942e4025eb0'; +String _$scannerNotifierV2Hash() => r'662523f1967017803df625f56f4d7cc724d827cf'; /// New scanner notifier using freezed ScannerState with auto-detection support. /// diff --git a/lib/features/scanner/presentation/screens/scanner_screen_v2.dart b/lib/features/scanner/presentation/screens/scanner_screen_v2.dart index c2c87ca..84c48f0 100644 --- a/lib/features/scanner/presentation/screens/scanner_screen_v2.dart +++ b/lib/features/scanner/presentation/screens/scanner_screen_v2.dart @@ -277,11 +277,11 @@ class _ScannerScreenV2State extends ConsumerState LoggerService.debug('Showing registration popup', tag: _tag); ScannerRegistrationPopup.show(context).then((result) { - if (result == true) { - // Registration successful - reset for next scan - ref.read(scannerNotifierV2Provider.notifier).clearScanData(); - } - // Always reset lastScannedCode so same barcode can be re-scanned if needed + // Always clear scan data and reset state when popup is dismissed + ref.read(scannerNotifierV2Provider.notifier).clearScanData(); + ref.read(scannerNotifierV2Provider.notifier).hideRegistrationPopup(); + + // Reset lastScannedCode so same barcode can be re-scanned setState(() { _lastScannedCode = null; }); @@ -985,11 +985,11 @@ class _ModeSelectorSheet extends StatelessWidget { case ScanMode.rxg: return 'Scan RxG credentials QR code'; case ScanMode.accessPoint: - return 'Scan AP (1K9/1M3/1HN/C0C serial)'; + return 'Scan AP (1K9/1M3/1HN/EC2 serial)'; case ScanMode.ont: return 'Scan ONT (ALCL serial + part number)'; case ScanMode.switchDevice: - return 'Scan Switch (LL serial)'; + return 'Scan Switch (LL/EC2 serial)'; } } } diff --git a/lib/features/scanner/presentation/widgets/scanner_registration_popup.dart b/lib/features/scanner/presentation/widgets/scanner_registration_popup.dart index 8532df5..8acc485 100644 --- a/lib/features/scanner/presentation/widgets/scanner_registration_popup.dart +++ b/lib/features/scanner/presentation/widgets/scanner_registration_popup.dart @@ -949,8 +949,7 @@ class _ScannerRegistrationPopupState } void _handleCancel() { - ref.read(scannerNotifierV2Provider.notifier).hideRegistrationPopup(); - ref.read(scannerNotifierV2Provider.notifier).clearScanData(); + // Just pop with false - let the .then() callback in scanner_screen handle state cleanup Navigator.pop(context, false); widget.onCancel?.call(); } From 5dcd02d0a6c6b288de99240e01d6be027d58d99c Mon Sep 17 00:00:00 2001 From: Dominic Pham Date: Tue, 17 Feb 2026 00:55:29 -0800 Subject: [PATCH 3/7] Remove duplicate scan --- .../presentation/screens/scanner_screen_v2.dart | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/lib/features/scanner/presentation/screens/scanner_screen_v2.dart b/lib/features/scanner/presentation/screens/scanner_screen_v2.dart index 84c48f0..e3242dd 100644 --- a/lib/features/scanner/presentation/screens/scanner_screen_v2.dart +++ b/lib/features/scanner/presentation/screens/scanner_screen_v2.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -34,7 +32,6 @@ class _ScannerScreenV2State extends ConsumerState static const String _tag = 'ScannerScreenV2'; MobileScannerController? _controller; - StreamSubscription? _barcodeSubscription; String? _lastScannedCode; late AnimationController _pulseController; late Animation _pulseAnimation; @@ -77,14 +74,6 @@ class _ScannerScreenV2State extends ConsumerState ], ); - // Subscribe directly to barcode stream - _barcodeSubscription = _controller!.barcodes.listen( - _handleBarcode, - onError: (Object error) { - LoggerService.error('Barcode stream error', error: error, tag: _tag); - }, - ); - LoggerService.debug('Scanner controller initialized', tag: _tag); } on Exception catch (e) { LoggerService.error('Failed to initialize scanner', error: e, tag: _tag); @@ -93,8 +82,6 @@ class _ScannerScreenV2State extends ConsumerState @override void dispose() { - _barcodeSubscription?.cancel(); - _barcodeSubscription = null; // Turn off flash if it's on before disposing if (_isFlashOn && _controller != null) { _controller!.toggleTorch(); From b22213d7f8f025dc7418940199df777226640e5b Mon Sep 17 00:00:00 2001 From: Dominic Pham Date: Tue, 17 Feb 2026 18:08:00 -0800 Subject: [PATCH 4/7] Debouncing off launch and prevent scanner running multiple time --- .../services/websocket_data_sync_service.dart | 22 +- .../services/device_registration_service.dart | 92 ++--- .../services/scanner_validation_service.dart | 135 +++++-- .../device_registration_provider.dart | 359 +++++++++--------- .../device_registration_provider.g.dart | 2 +- .../providers/scanner_notifier_v2.dart | 137 +++++++ .../providers/scanner_notifier_v2.g.dart | 2 +- .../screens/scanner_screen_v2.dart | 38 +- .../widgets/scanner_registration_popup.dart | 157 +++++++- 9 files changed, 658 insertions(+), 286 deletions(-) diff --git a/lib/core/services/websocket_data_sync_service.dart b/lib/core/services/websocket_data_sync_service.dart index 64ab5e9..cfbfc15 100644 --- a/lib/core/services/websocket_data_sync_service.dart +++ b/lib/core/services/websocket_data_sync_service.dart @@ -89,6 +89,11 @@ class WebSocketDataSyncService { Future? _pendingRoomCache; final _eventController = StreamController.broadcast(); + /// Debounce timer for device cache events. + /// Collapses rapid AP/ONT/Switch/WLAN caching into a single event. + Timer? _deviceCacheDebounce; + int _pendingDeviceCount = 0; + bool get isRunning => _started; Stream get events => _eventController.stream; @@ -119,6 +124,7 @@ class WebSocketDataSyncService { Future dispose() async { await stop(); + _deviceCacheDebounce?.cancel(); await _eventController.close(); _apLocalDataSource.dispose(); _ontLocalDataSource.dispose(); @@ -436,7 +442,21 @@ class WebSocketDataSyncService { DeviceFieldSets.listFields, ); _cacheManager.invalidate(cacheKey); - _eventController.add(WebSocketDataSyncEvent.devicesCached(count: count)); + + // Debounce: accumulate counts and fire a single event after 600ms of quiet. + // This collapses rapid AP/ONT/Switch/WLAN caching into one provider refresh. + _pendingDeviceCount += count; + _deviceCacheDebounce?.cancel(); + _deviceCacheDebounce = Timer(const Duration(milliseconds: 600), () { + _logger.i( + 'WebSocketDataSync: Emitting debounced devicesCached ' + '(total=$_pendingDeviceCount)', + ); + _eventController.add( + WebSocketDataSyncEvent.devicesCached(count: _pendingDeviceCount), + ); + _pendingDeviceCount = 0; + }); } void _handleRoomSnapshot( diff --git a/lib/features/scanner/data/services/device_registration_service.dart b/lib/features/scanner/data/services/device_registration_service.dart index 1f21f7e..b3fa4d0 100644 --- a/lib/features/scanner/data/services/device_registration_service.dart +++ b/lib/features/scanner/data/services/device_registration_service.dart @@ -2,22 +2,27 @@ import 'package:rgnets_fdk/core/services/logger_service.dart'; import 'package:rgnets_fdk/core/services/websocket_service.dart'; import 'package:rgnets_fdk/features/scanner/domain/entities/scan_session.dart'; -/// Service for registering devices via WebSocket. +/// Service for registering devices via WebSocket ActionCable channel. class DeviceRegistrationService { DeviceRegistrationService({required WebSocketService webSocketService}) : _wsService = webSocketService; static const String _tag = 'DeviceRegistration'; + static const Duration _registrationTimeout = Duration(seconds: 10); final WebSocketService _wsService; /// Check if WebSocket is connected. bool get isConnected => _wsService.isConnected; - /// Register a device via WebSocket. - /// Sends a 'device.register' message with device data. - /// Returns true if message was sent, false if WebSocket not connected. - bool registerDevice({ + /// Register a device via the RxgChannel ActionCable channel. + /// + /// Uses 'create_resource' for new devices or 'update_resource' for existing + /// ones, with device fields nested under 'params' per the RxgChannel API. + /// + /// Throws [StateError] if WebSocket is not connected. + /// Throws [TimeoutException] if the server does not confirm within 10s. + Future registerDevice({ required DeviceType deviceType, required String mac, required String serialNumber, @@ -26,71 +31,42 @@ class DeviceRegistrationService { String? model, int? existingDeviceId, }) { - if (!_wsService.isConnected) { - LoggerService.error( - 'Cannot register device: WebSocket not connected', - tag: _tag, - ); - return false; - } - - final payload = _buildPayload( - deviceType: deviceType, - mac: mac, - serialNumber: serialNumber, - pmsRoomId: pmsRoomId, - partNumber: partNumber, - model: model, - existingDeviceId: existingDeviceId, - ); + final resourceType = _deviceTypeToResourceType(deviceType); + final isUpdate = existingDeviceId != null; + final action = isUpdate ? 'update_resource' : 'create_resource'; LoggerService.info( - 'Registering ${deviceType.displayName} via WebSocket', + 'Registering ${deviceType.displayName} via ActionCable ' + '($action on $resourceType, existingId=$existingDeviceId)', tag: _tag, ); - _wsService.sendType('device.register', payload: payload); - return true; - } - - Map _buildPayload({ - required DeviceType deviceType, - required String mac, - required String serialNumber, - required int pmsRoomId, - String? partNumber, - String? model, - int? existingDeviceId, - }) { - switch (deviceType) { - case DeviceType.ont: - return { - 'device_type': 'ont', + return _wsService.requestActionCable( + action: action, + resourceType: resourceType, + additionalData: { + if (isUpdate) 'id': existingDeviceId, + 'params': { 'mac': mac, 'serial_number': serialNumber, 'pms_room_id': pmsRoomId, if (partNumber != null) 'part_number': partNumber, - if (existingDeviceId != null) 'id': existingDeviceId, - }; + if (model != null) 'model': model, + }, + }, + timeout: _registrationTimeout, + ); + } + /// Maps DeviceType to the Rails resource type name. + String _deviceTypeToResourceType(DeviceType deviceType) { + switch (deviceType) { + case DeviceType.ont: + return 'media_converters'; case DeviceType.accessPoint: - return { - 'device_type': 'access_point', - 'mac': mac, - 'serial_number': serialNumber, - 'pms_room_id': pmsRoomId, - if (existingDeviceId != null) 'id': existingDeviceId, - }; - + return 'access_points'; case DeviceType.switchDevice: - return { - 'device_type': 'switch', - 'mac': mac, - 'serial_number': serialNumber, - 'pms_room_id': pmsRoomId, - if (model != null) 'model': model, - if (existingDeviceId != null) 'id': existingDeviceId, - }; + return 'switch_devices'; } } } diff --git a/lib/features/scanner/data/services/scanner_validation_service.dart b/lib/features/scanner/data/services/scanner_validation_service.dart index 28f9561..c9b9d84 100644 --- a/lib/features/scanner/data/services/scanner_validation_service.dart +++ b/lib/features/scanner/data/services/scanner_validation_service.dart @@ -56,6 +56,11 @@ class ScannerValidationService { /// Parses ONT barcodes from multiple scanned values. /// STRICT MODE: Requires ALCL serial, valid MAC, and part number. + /// + /// Uses two-pass parsing with OUI-based disambiguation to correctly + /// distinguish MAC addresses from part numbers (both are 12 hex chars). + /// Pass 1: Classify unambiguous values (ALCL serial, prefixed part numbers). + /// Pass 2: Resolve ambiguous 12-hex candidates using OUI database lookup. static ParsedDeviceData parseONTBarcodes(List barcodes) { LoggerService.debug( 'Parsing ONT barcodes from ${barcodes.length} values', @@ -66,25 +71,29 @@ class ScannerValidationService { String mac = ''; String serialNumber = ''; bool hasALCLSerial = false; + final pnRegex = RegExp(r'^[A-Z0-9]{8,12}[A-Z]$'); + // Collect ambiguous 12-hex candidates for OUI-based resolution + final hexCandidates = []; + + // --- Pass 1: Classify unambiguous values --- for (String barcode in barcodes) { if (barcode.isEmpty) continue; String value = barcode.trim(); - // Remove known ONT prefixes + // Remove known ONT prefixes — these are definitively part numbers if (value.startsWith('1P')) { - value = value.substring(2); + partNumber = value.substring(2); + LoggerService.debug('Found prefixed part number (1P): $partNumber', tag: _tag); + continue; } else if (value.startsWith('23S')) { - value = value.substring(3); - } else if (value.startsWith('S')) { - value = value.substring(1); - } - - // Check for MAC address (12 hex chars) - if (value.length == 12 && _isValidMacAddress(value) && mac.isEmpty) { - mac = value.toUpperCase(); - LoggerService.debug('Found MAC: $mac', tag: _tag); + partNumber = value.substring(3); + LoggerService.debug('Found prefixed part number (23S): $partNumber', tag: _tag); + continue; + } else if (value.startsWith('S') && value.length > 1 && !SerialPatterns.isONTSerial(value)) { + partNumber = value.substring(1); + LoggerService.debug('Found prefixed part number (S): $partNumber', tag: _tag); continue; } @@ -96,10 +105,15 @@ class ScannerValidationService { continue; } - // Check for Part Number pattern - final pnRegex = RegExp(r'^[A-Z0-9]{8,12}[A-Z]$'); - if (pnRegex.hasMatch(value) && value.length >= 8 && partNumber.isEmpty) { - partNumber = value; + // Ambiguous: 12-hex value could be MAC or part number — defer to Pass 2 + if (value.length == 12 && _isValidMacAddress(value)) { + hexCandidates.add(value.toUpperCase()); + continue; + } + + // Non-hex part number (matches regex but wasn't 12 hex chars) + if (pnRegex.hasMatch(value.toUpperCase()) && value.length >= 8 && partNumber.isEmpty) { + partNumber = value.toUpperCase(); LoggerService.debug('Found part number: $partNumber', tag: _tag); continue; } @@ -110,6 +124,80 @@ class ScannerValidationService { } } + // --- Pass 2: Resolve 12-hex candidates using OUI database --- + if (hexCandidates.isNotEmpty) { + // Deduplicate while preserving order + final seen = {}; + final unique = hexCandidates.where(seen.add).toList(); + + LoggerService.debug( + 'Resolving ${unique.length} hex candidate(s) via OUI lookup: $unique', + tag: _tag, + ); + + if (unique.length == 1) { + // Single candidate: assign to whichever slot is empty + final value = unique.first; + if (mac.isEmpty) { + mac = value; + LoggerService.debug('Single hex candidate → MAC: $mac', tag: _tag); + } else if (partNumber.isEmpty) { + partNumber = value; + LoggerService.debug('Single hex candidate → part number: $partNumber', tag: _tag); + } + } else { + // Multiple candidates: use OUI to disambiguate + final withOUI = []; + final withoutOUI = []; + + for (final value in unique) { + if (macDatabase.isLoaded && macDatabase.isKnownMAC(value)) { + withOUI.add(value); + } else { + withoutOUI.add(value); + } + } + + LoggerService.debug( + 'OUI results — known: $withOUI, unknown: $withoutOUI', + tag: _tag, + ); + + // Assign MAC from known-OUI candidates + if (mac.isEmpty && withOUI.isNotEmpty) { + mac = withOUI.first; + LoggerService.debug('OUI-resolved MAC: $mac', tag: _tag); + } + + // Assign part number from unknown-OUI candidates + if (partNumber.isEmpty && withoutOUI.isNotEmpty) { + partNumber = withoutOUI.first; + LoggerService.debug('OUI-resolved part number: $partNumber', tag: _tag); + } + + // Fallback: if OUI database wasn't loaded or all candidates had same + // OUI status, fall back to first-come = MAC, second = part number + if (mac.isEmpty && partNumber.isEmpty && unique.length >= 2) { + mac = unique.first; + partNumber = unique[1]; + LoggerService.debug( + 'OUI unavailable, fallback order → MAC: $mac, PN: $partNumber', + tag: _tag, + ); + } else if (mac.isEmpty && unique.isNotEmpty) { + mac = unique.first; + LoggerService.debug('Fallback → MAC: $mac', tag: _tag); + } else if (partNumber.isEmpty && unique.isNotEmpty) { + // MAC already set, remaining candidate is part number + final remaining = unique.where((v) => v != mac).toList(); + if (remaining.isNotEmpty) { + partNumber = remaining.first; + LoggerService.debug('Remaining hex candidate → part number: $partNumber', tag: _tag); + } + } + } + } + // STRICT validation: ALL three required for ONT final isComplete = mac.isNotEmpty && partNumber.isNotEmpty && hasALCLSerial; @@ -281,21 +369,10 @@ class ScannerValidationService { } /// Auto-detect device type from a single barcode value. + /// Uses auto-detect-safe prefixes that exclude ambiguous EC2 serials, + /// so the caller can route EC2 to the ambiguous handler instead. static DeviceTypeFromSerial? detectDeviceTypeFromBarcode(String barcode) { - final value = barcode.trim().toUpperCase(); - - // Check serial patterns - if (SerialPatterns.isAPSerial(value)) { - return DeviceTypeFromSerial.accessPoint; - } - if (SerialPatterns.isONTSerial(value)) { - return DeviceTypeFromSerial.ont; - } - if (SerialPatterns.isSwitchSerial(value)) { - return DeviceTypeFromSerial.switchDevice; - } - - return null; + return SerialPatterns.detectDeviceType(barcode); } /// Parses RxG credentials from QR code. diff --git a/lib/features/scanner/presentation/providers/device_registration_provider.dart b/lib/features/scanner/presentation/providers/device_registration_provider.dart index 1d33e6e..ed18300 100644 --- a/lib/features/scanner/presentation/providers/device_registration_provider.dart +++ b/lib/features/scanner/presentation/providers/device_registration_provider.dart @@ -186,7 +186,7 @@ class DeviceRegistrationNotifier extends _$DeviceRegistrationNotifier { } /// Query the backend via WebSocket to find devices matching MAC or serial. - /// Returns a list of matching devices from all device types. + /// Runs all queries in parallel for fast lookups. Future>> _queryDevicesFromBackend({ String? mac, String? serial, @@ -200,30 +200,35 @@ class DeviceRegistrationNotifier extends _$DeviceRegistrationNotifier { return []; } - final results = >[]; final resourceTypes = ['access_points', 'media_converters', 'switch_devices']; + final futures = >>>[]; + + final formattedMac = (mac != null && mac.isNotEmpty) + ? _formatMacForBackend(mac) + : null; for (final resourceType in resourceTypes) { - // Query by MAC if provided - format for backend (lowercase with colons) - if (mac != null && mac.isNotEmpty) { - final formattedMac = _formatMacForBackend(mac); - LoggerService.debug( - 'DeviceRegistration: Querying $resourceType with formatted MAC: $formattedMac (original: $mac)', - tag: 'DeviceRegistration', - ); - final byMac = await _queryResource(wsService, resourceType, {'mac': formattedMac}); - results.addAll(byMac); + if (formattedMac != null) { + futures.add(_queryResource(wsService, resourceType, {'mac': formattedMac})); } - - // Query by serial if provided if (serial != null && serial.isNotEmpty) { - final bySerial = await _queryResource(wsService, resourceType, {'serial_number': serial}); - // Avoid duplicates if already found by MAC - for (final device in bySerial) { - final deviceId = device['id']; - if (!results.any((d) => d['id'] == deviceId)) { - results.add(device); - } + futures.add(_queryResource(wsService, resourceType, {'serial_number': serial})); + } + } + + if (futures.isEmpty) { + return []; + } + + final allResults = await Future.wait(futures); + + // Flatten and deduplicate by device ID + final seen = {}; + final results = >[]; + for (final batch in allResults) { + for (final device in batch) { + if (seen.add(device['id'])) { + results.add(device); } } } @@ -301,7 +306,7 @@ class DeviceRegistrationNotifier extends _$DeviceRegistrationNotifier { } /// Check if a device already exists with the given MAC and serial. - /// Queries the backend via WebSocket to find existing devices. + /// Checks local cache first (O(1) lookup), falls back to backend query. Future checkDeviceMatch({ required String mac, required String serial, @@ -321,12 +326,34 @@ class DeviceRegistrationNotifier extends _$DeviceRegistrationNotifier { final normalizedMac = _normalizeMac(mac); final normalizedSerial = serial.toUpperCase().trim(); + // Try local cache first — O(1) lookup from WebSocket-populated indexes + final deviceByMac = normalizedMac.isNotEmpty + ? _deviceIndexByMac[normalizedMac] + : null; + final deviceBySerial = normalizedSerial.isNotEmpty + ? _deviceIndexBySerial[normalizedSerial] + : null; + + if (deviceByMac != null || deviceBySerial != null) { + LoggerService.debug( + 'DeviceRegistration: Cache hit — MAC=${deviceByMac != null}, Serial=${deviceBySerial != null}', + tag: 'DeviceRegistration', + ); + _resolveMatch( + deviceByMac: deviceByMac, + deviceBySerial: deviceBySerial, + normalizedMac: normalizedMac, + normalizedSerial: normalizedSerial, + ); + return; + } + + // Cache miss — query backend (parallel across resource types) LoggerService.debug( - 'DeviceRegistration: Querying backend for MAC=$normalizedMac, Serial=$normalizedSerial', + 'DeviceRegistration: Cache miss, querying backend for MAC=$normalizedMac, Serial=$normalizedSerial', tag: 'DeviceRegistration', ); - // Query the backend for devices matching MAC or serial final matchingDevices = await _queryDevicesFromBackend( mac: normalizedMac, serial: normalizedSerial, @@ -338,7 +365,6 @@ class DeviceRegistrationNotifier extends _$DeviceRegistrationNotifier { ); if (matchingDevices.isEmpty) { - // No existing device found state = state.copyWith( status: RegistrationStatus.idle, matchStatus: DeviceMatchStatus.noMatch, @@ -346,138 +372,32 @@ class DeviceRegistrationNotifier extends _$DeviceRegistrationNotifier { return; } - // Find devices that match by MAC and/or serial - Map? deviceByMac; - Map? deviceBySerial; + // Find devices that match by MAC and/or serial from query results + Map? queryDeviceByMac; + Map? queryDeviceBySerial; for (final device in matchingDevices) { - final rawMac = (device['mac'] ?? device['mac_address'] ?? '').toString(); - final devMac = _normalizeMac(rawMac); - final rawSerial = (device['serial_number'] ?? device['sn'] ?? '').toString(); - final devSerial = rawSerial.toUpperCase().trim(); - - LoggerService.debug( - 'DeviceRegistration: Checking device ${device['id']} - ' - 'rawMac="$rawMac" normalizedMac="$devMac" vs scannedMac="$normalizedMac" | ' - 'rawSerial="$rawSerial" normalizedSerial="$devSerial" vs scannedSerial="$normalizedSerial"', - tag: 'DeviceRegistration', - ); - - // Compare normalized values - if scanned value is empty, don't set match - if (normalizedMac.isNotEmpty && devMac.isNotEmpty && devMac == normalizedMac) { - LoggerService.debug('DeviceRegistration: MAC match found!', tag: 'DeviceRegistration'); - deviceByMac = device; - } - if (normalizedSerial.isNotEmpty && devSerial.isNotEmpty && devSerial == normalizedSerial) { - LoggerService.debug('DeviceRegistration: Serial match found!', tag: 'DeviceRegistration'); - deviceBySerial = device; - } - } - - LoggerService.debug( - 'DeviceRegistration: After matching - deviceByMac=${deviceByMac != null ? "found(${deviceByMac['id']})" : "null"}, ' - 'deviceBySerial=${deviceBySerial != null ? "found(${deviceBySerial['id']})" : "null"}', - tag: 'DeviceRegistration', - ); - - // If we found any device, it's a match - don't be strict about both fields - // The backend found the device by MAC or serial, so it exists - if (matchingDevices.isNotEmpty) { - final existingDevice = matchingDevices.first; - final existingMac = _normalizeMac( - (existingDevice['mac'] ?? existingDevice['mac_address'] ?? '').toString(), + final devMac = _normalizeMac( + (device['mac'] ?? device['mac_address'] ?? '').toString(), ); - final existingSerial = (existingDevice['serial_number'] ?? existingDevice['sn'] ?? '') + final devSerial = (device['serial_number'] ?? device['sn'] ?? '') .toString() .toUpperCase() .trim(); - // Check if BOTH fields were scanned AND the device has BOTH fields AND they DIFFER - // This is the only true mismatch case - final hasMacMismatch = normalizedMac.isNotEmpty && - existingMac.isNotEmpty && - normalizedMac != existingMac; - final hasSerialMismatch = normalizedSerial.isNotEmpty && - existingSerial.isNotEmpty && - normalizedSerial != existingSerial; - - LoggerService.debug( - 'DeviceRegistration: Mismatch check - hasMacMismatch=$hasMacMismatch, hasSerialMismatch=$hasSerialMismatch', - tag: 'DeviceRegistration', - ); - - // Only flag as mismatch if we have conflicting data - // (e.g., scanned MAC matches device A, but scanned serial matches device B) - if (deviceByMac != null && deviceBySerial != null && deviceByMac['id'] != deviceBySerial['id']) { - LoggerService.debug( - 'DeviceRegistration: Multiple devices match - MAC matches ${deviceByMac['id']}, serial matches ${deviceBySerial['id']}', - tag: 'DeviceRegistration', - ); - state = state.copyWith( - status: RegistrationStatus.idle, - matchStatus: DeviceMatchStatus.multipleMatch, - errorMessage: 'MAC and serial match different devices', - ); - return; + if (normalizedMac.isNotEmpty && devMac == normalizedMac) { + queryDeviceByMac = device; } - - // If we found a device by one field, but the OTHER scanned field doesn't match - // that device's stored value, it's a true mismatch - if ((deviceByMac != null && hasSerialMismatch) || (deviceBySerial != null && hasMacMismatch)) { - final mismatches = []; - if (hasMacMismatch) mismatches.add('MAC Address'); - if (hasSerialMismatch) mismatches.add('Serial Number'); - - LoggerService.debug( - 'DeviceRegistration: Data mismatch detected - $mismatches', - tag: 'DeviceRegistration', - ); - - state = state.copyWith( - status: RegistrationStatus.idle, - matchStatus: DeviceMatchStatus.mismatch, - matchedDeviceId: existingDevice['id'] as int?, - matchedDeviceName: existingDevice['name'] as String?, - mismatchInfo: MatchMismatchInfo( - mismatchedFields: mismatches, - expected: { - 'mac': existingMac, - 'serial_number': existingSerial, - }, - scanned: { - 'mac': normalizedMac, - 'serial_number': normalizedSerial, - }, - ), - ); - return; + if (normalizedSerial.isNotEmpty && devSerial == normalizedSerial) { + queryDeviceBySerial = device; } - - // Device found - this is a full match (existing device) - final roomId = _extractRoomId(existingDevice); - final roomName = _extractRoomName(existingDevice); - - LoggerService.debug( - 'DeviceRegistration: Full match - device ${existingDevice['id']} in room $roomName', - tag: 'DeviceRegistration', - ); - - state = state.copyWith( - status: RegistrationStatus.idle, - matchStatus: DeviceMatchStatus.fullMatch, - matchedDeviceId: existingDevice['id'] as int?, - matchedDeviceName: existingDevice['name'] as String?, - matchedDeviceRoomId: roomId, - matchedDeviceRoomName: roomName, - ); - return; } - // No devices found at all - LoggerService.debug('DeviceRegistration: No matching devices found', tag: 'DeviceRegistration'); - state = state.copyWith( - status: RegistrationStatus.idle, - matchStatus: DeviceMatchStatus.noMatch, + _resolveMatch( + deviceByMac: queryDeviceByMac, + deviceBySerial: queryDeviceBySerial, + normalizedMac: normalizedMac, + normalizedSerial: normalizedSerial, ); } catch (e, stack) { LoggerService.error( @@ -493,6 +413,108 @@ class DeviceRegistrationNotifier extends _$DeviceRegistrationNotifier { } } + /// Resolve match status from device lookups (shared by cache and query paths). + void _resolveMatch({ + required Map? deviceByMac, + required Map? deviceBySerial, + required String normalizedMac, + required String normalizedSerial, + }) { + if (deviceByMac == null && deviceBySerial == null) { + state = state.copyWith( + status: RegistrationStatus.idle, + matchStatus: DeviceMatchStatus.noMatch, + ); + return; + } + + // Check if MAC and serial point to different devices + if (deviceByMac != null && + deviceBySerial != null && + deviceByMac['id'] != deviceBySerial['id']) { + LoggerService.debug( + 'DeviceRegistration: Multiple devices — MAC→${deviceByMac['id']}, Serial→${deviceBySerial['id']}', + tag: 'DeviceRegistration', + ); + state = state.copyWith( + status: RegistrationStatus.idle, + matchStatus: DeviceMatchStatus.multipleMatch, + errorMessage: 'MAC and serial match different devices', + ); + return; + } + + final existingDevice = deviceByMac ?? deviceBySerial!; + final existingMac = _normalizeMac( + (existingDevice['mac'] ?? existingDevice['mac_address'] ?? '').toString(), + ); + final existingSerial = (existingDevice['serial_number'] ?? existingDevice['sn'] ?? '') + .toString() + .toUpperCase() + .trim(); + + final hasMacMismatch = normalizedMac.isNotEmpty && + existingMac.isNotEmpty && + normalizedMac != existingMac; + final hasSerialMismatch = normalizedSerial.isNotEmpty && + existingSerial.isNotEmpty && + normalizedSerial != existingSerial; + + // Mismatch: found device by one field but the other field conflicts + if ((deviceByMac != null && hasSerialMismatch) || + (deviceBySerial != null && hasMacMismatch)) { + final mismatches = []; + if (hasMacMismatch) { + mismatches.add('MAC Address'); + } + if (hasSerialMismatch) { + mismatches.add('Serial Number'); + } + + LoggerService.debug( + 'DeviceRegistration: Mismatch — $mismatches', + tag: 'DeviceRegistration', + ); + + state = state.copyWith( + status: RegistrationStatus.idle, + matchStatus: DeviceMatchStatus.mismatch, + matchedDeviceId: existingDevice['id'] as int?, + matchedDeviceName: existingDevice['name'] as String?, + mismatchInfo: MatchMismatchInfo( + mismatchedFields: mismatches, + expected: { + 'mac': existingMac, + 'serial_number': existingSerial, + }, + scanned: { + 'mac': normalizedMac, + 'serial_number': normalizedSerial, + }, + ), + ); + return; + } + + // Full match — existing device found + final roomId = _extractRoomId(existingDevice); + final roomName = _extractRoomName(existingDevice); + + LoggerService.debug( + 'DeviceRegistration: Full match — device ${existingDevice['id']} in room $roomName', + tag: 'DeviceRegistration', + ); + + state = state.copyWith( + status: RegistrationStatus.idle, + matchStatus: DeviceMatchStatus.fullMatch, + matchedDeviceId: existingDevice['id'] as int?, + matchedDeviceName: existingDevice['name'] as String?, + matchedDeviceRoomId: roomId, + matchedDeviceRoomName: roomName, + ); + } + /// Register a new device via WebSocket. Future registerDevice({ required String mac, @@ -524,20 +546,9 @@ class DeviceRegistrationNotifier extends _$DeviceRegistrationNotifier { return RegistrationResult.failure(message: error); } - // Get the registration service and register via WebSocket final registrationService = ref.read(deviceRegistrationServiceProvider); - // Check if WebSocket is connected before attempting registration - if (!registrationService.isConnected) { - const error = 'Cannot register device: WebSocket not connected'; - state = state.copyWith( - status: RegistrationStatus.error, - errorMessage: error, - ); - return RegistrationResult.failure(message: error); - } - - final sent = registrationService.registerDevice( + final response = await registrationService.registerDevice( deviceType: deviceType, mac: mac, serialNumber: serial, @@ -547,33 +558,39 @@ class DeviceRegistrationNotifier extends _$DeviceRegistrationNotifier { existingDeviceId: existingDeviceId, ); - if (!sent) { - const error = 'Failed to send registration message'; - state = state.copyWith( - status: RegistrationStatus.error, - errorMessage: error, - ); - return RegistrationResult.failure(message: error); - } + // Server confirmed — extract device ID from response + final data = response.payload['data'] ?? response.payload; + final deviceId = data is Map + ? (data['id'] as int? ?? existingDeviceId ?? 0) + : (existingDeviceId ?? 0); LoggerService.info( - 'DeviceRegistration: Sent registration via WebSocket - $deviceType MAC=$mac, SN=$serial, Room=$pmsRoomId', + 'DeviceRegistration: Server confirmed registration - $deviceType MAC=$mac, SN=$serial, Room=$pmsRoomId', tag: 'DeviceRegistration', ); - // WebSocket registration is fire-and-forget - // The device.created event will be received via the WebSocket listener - // Mark as pending until we receive confirmation state = state.copyWith( status: RegistrationStatus.success, registeredAt: DateTime.now(), ); return RegistrationResult.success( - deviceId: existingDeviceId ?? 0, + deviceId: deviceId, deviceType: deviceType.name, ); - } catch (e, stack) { + } on TimeoutException { + const error = + 'Server did not confirm registration. Check the device on the RXG.'; + LoggerService.warning( + 'DeviceRegistration: Registration timed out - $deviceType MAC=$mac, SN=$serial', + tag: 'DeviceRegistration', + ); + state = state.copyWith( + status: RegistrationStatus.error, + errorMessage: error, + ); + return const RegistrationResult.failure(message: error); + } on Exception catch (e, stack) { LoggerService.error( 'DeviceRegistration: Registration failed', error: e, diff --git a/lib/features/scanner/presentation/providers/device_registration_provider.g.dart b/lib/features/scanner/presentation/providers/device_registration_provider.g.dart index 53c157f..e4260d4 100644 --- a/lib/features/scanner/presentation/providers/device_registration_provider.g.dart +++ b/lib/features/scanner/presentation/providers/device_registration_provider.g.dart @@ -46,7 +46,7 @@ final deviceWebSocketEventsProvider = typedef DeviceWebSocketEventsRef = AutoDisposeStreamProviderRef; String _$deviceRegistrationNotifierHash() => - r'6a6fcd7ddfcab8f52b94d232f1abb5f65ae5ecf0'; + r'c3cf365b3b0bbd1b06a7efe3c4d975c1732710a3'; /// Provider for device registration with WebSocket integration. /// Handles checking existing devices and registering new ones. diff --git a/lib/features/scanner/presentation/providers/scanner_notifier_v2.dart b/lib/features/scanner/presentation/providers/scanner_notifier_v2.dart index fa327d3..df2ebd4 100644 --- a/lib/features/scanner/presentation/providers/scanner_notifier_v2.dart +++ b/lib/features/scanner/presentation/providers/scanner_notifier_v2.dart @@ -40,6 +40,58 @@ class ScannerNotifierV2 extends _$ScannerNotifierV2 { } } + /// Process all barcodes from a single camera frame as a batch. + /// + /// Critical for ONT disambiguation where MAC and part number are both + /// 12-hex characters. Batch parsing uses ordering/elimination to + /// correctly assign fields. Falls back to individual [processBarcode] + /// if no device type can be determined. + void processBarcodeFrame(List barcodes) { + if (!state.canProcessBarcode && state.uiState != ScannerUIState.idle) { + return; + } + + final values = + barcodes.map((b) => b.trim()).where((b) => b.isNotEmpty).toList(); + if (values.isEmpty) { + return; + } + + // Determine device type from current mode or auto-detect + DeviceTypeFromSerial? detectedType; + if (state.isAutoLocked || state.isInDeviceMode) { + detectedType = _scanModeToDeviceType(state.scanMode); + } else { + for (final value in values) { + detectedType = + ScannerValidationService.detectDeviceTypeFromBarcode(value); + if (detectedType != null) { + break; + } + } + } + + if (detectedType != null) { + // Replay raw scan history instead of pre-classified state values. + // This allows the batch parser to re-disambiguate from scratch when + // the device type becomes known (e.g. a 12-hex value initially + // classified as MAC in auto mode may actually be a part number). + final allValues = [ + ...state.scanData.scanHistory.map((r) => r.value), + ...values, + ]; + final result = + ScannerValidationService.parseBarcodesForType(allValues, detectedType); + _applyBatchResult(result, detectedType); + return; + } + + // No type detected — fall back to individual processing + for (final value in values) { + processBarcode(value); + } + } + /// Process a scanned barcode value. /// /// Handles auto-detection of device type from serial patterns, @@ -77,6 +129,17 @@ class ScannerNotifierV2 extends _$ScannerNotifierV2 { // Check if it's a MAC address if (_isMacAddress(value)) { + // Guard: if MAC is already captured and this value also matches part + // number heuristics, classify as part number instead. Prevents a 12-hex + // part number from overwriting the real MAC during individual processing. + if (state.scanData.mac.isNotEmpty && _isPartNumber(value)) { + LoggerService.debug( + 'MAC already set, 12-hex value matches part number pattern → part number: $value', + tag: _tag, + ); + _processPartNumber(value); + return; + } _processMacAddress(value); return; } @@ -361,6 +424,80 @@ class ScannerNotifierV2 extends _$ScannerNotifierV2 { // Helper methods + /// Apply batch-parsed result from [ScannerValidationService] to state. + void _applyBatchResult( + ParsedDeviceData result, + DeviceTypeFromSerial detectedType, + ) { + final now = DateTime.now(); + final newHistory = [...state.scanData.scanHistory]; + + var newMac = state.scanData.mac; + var newSerial = state.scanData.serialNumber; + var newPartNumber = state.scanData.partNumber; + var newHasValidSerial = state.scanData.hasValidSerial; + + if (result.mac.isNotEmpty && result.mac != state.scanData.mac) { + newMac = result.mac; + newHistory.add( + ScanRecord(value: result.mac, scannedAt: now, fieldType: 'mac'), + ); + } + + if (result.serialNumber.isNotEmpty && + result.serialNumber != state.scanData.serialNumber) { + newSerial = result.serialNumber; + newHasValidSerial = true; + newHistory.add( + ScanRecord(value: result.serialNumber, scannedAt: now, fieldType: 'serial'), + ); + } + + if (result.partNumber != null && + result.partNumber!.isNotEmpty && + result.partNumber != state.scanData.partNumber) { + newPartNumber = result.partNumber!; + newHistory.add( + ScanRecord(value: result.partNumber!, scannedAt: now, fieldType: 'partNumber'), + ); + } + + final newMode = state.scanMode == ScanMode.auto + ? _deviceTypeToScanMode(detectedType) + : state.scanMode; + final autoLocked = state.scanMode == ScanMode.auto || state.isAutoLocked; + + state = state.copyWith( + scanMode: newMode, + isAutoLocked: autoLocked, + lastSerialSeenAt: newSerial.isNotEmpty ? now : state.lastSerialSeenAt, + scanData: state.scanData.copyWith( + mac: newMac, + serialNumber: newSerial, + partNumber: newPartNumber, + hasValidSerial: newHasValidSerial, + scanHistory: newHistory, + ), + ); + + _checkCompletion(); + } + + /// Convert [ScanMode] to [DeviceTypeFromSerial] for batch parsing. + DeviceTypeFromSerial? _scanModeToDeviceType(ScanMode mode) { + switch (mode) { + case ScanMode.accessPoint: + return DeviceTypeFromSerial.accessPoint; + case ScanMode.ont: + return DeviceTypeFromSerial.ont; + case ScanMode.switchDevice: + return DeviceTypeFromSerial.switchDevice; + case ScanMode.auto: + case ScanMode.rxg: + return null; + } + } + bool _isMacAddress(String value) { return MACNormalizer.tryNormalize(value) != null; } diff --git a/lib/features/scanner/presentation/providers/scanner_notifier_v2.g.dart b/lib/features/scanner/presentation/providers/scanner_notifier_v2.g.dart index 508e65d..12bd48e 100644 --- a/lib/features/scanner/presentation/providers/scanner_notifier_v2.g.dart +++ b/lib/features/scanner/presentation/providers/scanner_notifier_v2.g.dart @@ -6,7 +6,7 @@ part of 'scanner_notifier_v2.dart'; // RiverpodGenerator // ************************************************************************** -String _$scannerNotifierV2Hash() => r'662523f1967017803df625f56f4d7cc724d827cf'; +String _$scannerNotifierV2Hash() => r'36b1e87dafb5a7c4da3a8ea3acf3b563cf9255b9'; /// New scanner notifier using freezed ScannerState with auto-detection support. /// diff --git a/lib/features/scanner/presentation/screens/scanner_screen_v2.dart b/lib/features/scanner/presentation/screens/scanner_screen_v2.dart index e3242dd..d1cebfa 100644 --- a/lib/features/scanner/presentation/screens/scanner_screen_v2.dart +++ b/lib/features/scanner/presentation/screens/scanner_screen_v2.dart @@ -69,8 +69,6 @@ class _ScannerScreenV2State extends ConsumerState formats: const [ BarcodeFormat.qrCode, BarcodeFormat.code128, - BarcodeFormat.code39, - BarcodeFormat.dataMatrix, ], ); @@ -195,23 +193,35 @@ class _ScannerScreenV2State extends ConsumerState return; } + // Collect all new barcodes from this frame + final frameBarcodes = []; for (final barcode in capture.barcodes) { if (barcode.rawValue != null && barcode.rawValue != _lastScannedCode) { - LoggerService.debug('Barcode detected: ${barcode.rawValue}', tag: _tag); + frameBarcodes.add(barcode.rawValue!); + } + } - setState(() { - _lastScannedCode = barcode.rawValue; - }); + if (frameBarcodes.isEmpty) { + return; + } - // Process through the new notifier - ref.read(scannerNotifierV2Provider.notifier).processBarcode(barcode.rawValue!); + // Update last scanned code to prevent re-processing same frame + setState(() { + _lastScannedCode = frameBarcodes.last; + }); - // Check if scan is now complete - final updatedState = ref.read(scannerNotifierV2Provider); - if (updatedState.isScanComplete && !updatedState.isPopupShowing) { - _showRegistrationPopup(); - } - } + LoggerService.debug( + 'Frame detected ${frameBarcodes.length} barcode(s): $frameBarcodes', + tag: _tag, + ); + + // Process entire frame as a batch for correct ONT disambiguation + ref.read(scannerNotifierV2Provider.notifier).processBarcodeFrame(frameBarcodes); + + // Check if scan is now complete + final updatedState = ref.read(scannerNotifierV2Provider); + if (updatedState.isScanComplete && !updatedState.isPopupShowing) { + _showRegistrationPopup(); } } diff --git a/lib/features/scanner/presentation/widgets/scanner_registration_popup.dart b/lib/features/scanner/presentation/widgets/scanner_registration_popup.dart index 8acc485..8e6e59e 100644 --- a/lib/features/scanner/presentation/widgets/scanner_registration_popup.dart +++ b/lib/features/scanner/presentation/widgets/scanner_registration_popup.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:rgnets_fdk/core/services/logger_service.dart'; import 'package:rgnets_fdk/features/devices/domain/constants/device_types.dart'; import 'package:rgnets_fdk/features/devices/domain/entities/device.dart'; import 'package:rgnets_fdk/features/devices/domain/entities/room.dart'; @@ -843,6 +844,10 @@ class _ScannerRegistrationPopupState if (value == 'create_new') { _createNewDevice = true; _selectedDevice = null; + LoggerService.info( + 'Dropdown: selected Create New', + tag: 'ScannerRegistration', + ); } else { _createNewDevice = false; final parts = value.split('_'); @@ -853,6 +858,13 @@ class _ScannerRegistrationPopupState if (_selectedDevice == null) { _createNewDevice = true; } + LoggerService.info( + 'Dropdown: value=$value, parsedId=$deviceId, ' + 'found=${_selectedDevice != null}, ' + 'deviceName=${_selectedDevice?.name}, ' + 'createNew=$_createNewDevice', + tag: 'ScannerRegistration', + ); } } }); @@ -909,17 +921,10 @@ class _ScannerRegistrationPopupState const SizedBox(width: 16), Expanded( flex: 2, - child: FilledButton( - onPressed: canRegister ? _handleRegister : null, - style: buttonColor != null - ? FilledButton.styleFrom( - backgroundColor: buttonColor, - padding: const EdgeInsets.symmetric(vertical: 16), - ) - : FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - ), - child: Text(buttonText), + child: _HoldToConfirmButton( + onConfirmed: canRegister ? _handleRegister : null, + color: buttonColor ?? Theme.of(context).colorScheme.primary, + label: buttonText, ), ), ], @@ -969,6 +974,15 @@ class _ScannerRegistrationPopupState existingDeviceId = int.tryParse(_selectedDevice!.id); } + LoggerService.info( + 'Register: isExisting=$isExisting, _createNewDevice=$_createNewDevice, ' + '_selectedDevice=${_selectedDevice?.id}/${_selectedDevice?.name}, ' + 'existingDeviceId=$existingDeviceId, ' + 'matchStatus=${scannerState.matchStatus}, ' + 'roomId=${scannerState.selectedRoomId}', + tag: 'ScannerRegistration', + ); + try { final result = await ref .read(deviceRegistrationNotifierProvider.notifier) @@ -1308,3 +1322,124 @@ class _RoomListTile extends StatelessWidget { ); } } + +/// Hold-to-confirm button matching ATT-FE-Tool (1.6s hold duration). +class _HoldToConfirmButton extends StatefulWidget { + const _HoldToConfirmButton({ + required this.onConfirmed, + required this.color, + required this.label, + }); + + final VoidCallback? onConfirmed; + final Color color; + final String label; + + @override + State<_HoldToConfirmButton> createState() => _HoldToConfirmButtonState(); +} + +class _HoldToConfirmButtonState extends State<_HoldToConfirmButton> + with SingleTickerProviderStateMixin { + static const _holdDuration = Duration(milliseconds: 1600); + + late AnimationController _controller; + bool _isHolding = false; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this, duration: _holdDuration) + ..addStatusListener((status) { + if (status == AnimationStatus.completed) { + setState(() { + _isHolding = false; + }); + widget.onConfirmed?.call(); + } + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _onPointerDown() { + if (widget.onConfirmed == null) { + return; + } + setState(() { + _isHolding = true; + }); + _controller.forward(from: 0); + } + + void _onPointerUp() { + if (!_isHolding) { + return; + } + setState(() { + _isHolding = false; + }); + _controller.reset(); + } + + @override + Widget build(BuildContext context) { + final enabled = widget.onConfirmed != null; + final foreground = enabled ? Colors.white : Colors.white70; + final background = + enabled ? widget.color : widget.color.withValues(alpha: 0.4); + + return GestureDetector( + onLongPressStart: enabled ? (_) => _onPointerDown() : null, + onLongPressEnd: enabled ? (_) => _onPointerUp() : null, + onLongPressCancel: enabled ? _onPointerUp : null, + child: AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(12), + ), + child: Stack( + children: [ + // Progress fill + if (_isHolding) + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Align( + alignment: Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: _controller.value, + child: Container( + color: Colors.white.withValues(alpha: 0.25), + ), + ), + ), + ), + ), + // Label + Center( + child: Text( + _isHolding ? 'Hold to confirm...' : widget.label, + style: TextStyle( + color: foreground, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + ), + ], + ), + ); + }, + ), + ); + } +} From ca21340a037e26bcc8418f217397fb2910e467ee Mon Sep 17 00:00:00 2001 From: Dominic Pham Date: Wed, 18 Feb 2026 14:24:59 -0800 Subject: [PATCH 5/7] Holds scanning state and fix caching --- .../services/device_registration_service.dart | 8 +- .../services/scanner_validation_service.dart | 45 +++++------ .../device_registration_provider.dart | 26 ++++++- .../providers/scanner_notifier_v2.dart | 76 +++---------------- .../widgets/scanner_registration_popup.dart | 11 ++- 5 files changed, 64 insertions(+), 102 deletions(-) diff --git a/lib/features/scanner/data/services/device_registration_service.dart b/lib/features/scanner/data/services/device_registration_service.dart index b3fa4d0..64f1fb0 100644 --- a/lib/features/scanner/data/services/device_registration_service.dart +++ b/lib/features/scanner/data/services/device_registration_service.dart @@ -32,20 +32,18 @@ class DeviceRegistrationService { int? existingDeviceId, }) { final resourceType = _deviceTypeToResourceType(deviceType); - final isUpdate = existingDeviceId != null; - final action = isUpdate ? 'update_resource' : 'create_resource'; LoggerService.info( 'Registering ${deviceType.displayName} via ActionCable ' - '($action on $resourceType, existingId=$existingDeviceId)', + '(update_resource on $resourceType, deviceId=$existingDeviceId)', tag: _tag, ); return _wsService.requestActionCable( - action: action, + action: 'update_resource', resourceType: resourceType, additionalData: { - if (isUpdate) 'id': existingDeviceId, + if (existingDeviceId != null) 'id': existingDeviceId, 'params': { 'mac': mac, 'serial_number': serialNumber, diff --git a/lib/features/scanner/data/services/scanner_validation_service.dart b/lib/features/scanner/data/services/scanner_validation_service.dart index c9b9d84..6437554 100644 --- a/lib/features/scanner/data/services/scanner_validation_service.dart +++ b/lib/features/scanner/data/services/scanner_validation_service.dart @@ -223,7 +223,7 @@ class ScannerValidationService { } /// Parses AP barcodes from multiple scanned values. - /// STRICT MODE: Requires AP serial (1K9/1M3/1HN) and valid MAC. + /// Requires AP serial (1K9/1M3/1HN/EC2) and valid MAC. static ParsedDeviceData parseAPBarcodes(List barcodes) { LoggerService.debug( 'Parsing AP barcodes from ${barcodes.length} values', @@ -246,23 +246,15 @@ class ScannerValidationService { continue; } - // AP-SPECIFIC: Only accept approved prefixes (strict) + // Accept approved AP prefixes including EC2 if (SerialPatterns.isAPSerial(value)) { serialNumber = value.toUpperCase(); hasAPSerial = true; - LoggerService.debug('Found AP serial (1K9/1M3/1HN): $serialNumber', tag: _tag); + LoggerService.debug('Found AP serial: $serialNumber', tag: _tag); continue; } - - // Log ignored non-AP serials - if (serialNumber.isEmpty && - value.length >= 10 && - !SerialPatterns.apPrefixes.any((p) => value.toUpperCase().startsWith(p))) { - LoggerService.warning('Ignored non-AP serial format in AP mode: $value', tag: _tag); - } } - // STRICT validation: Both required for AP final isComplete = mac.isNotEmpty && hasAPSerial; if (!isComplete) { @@ -279,13 +271,13 @@ class ScannerValidationService { detectedType: DeviceTypeFromSerial.accessPoint, missingFields: [ if (mac.isEmpty) 'MAC Address', - if (!hasAPSerial) 'Serial Number (1K9/1M3/1HN)', + if (!hasAPSerial) 'Serial Number', ], ); } /// Parses Switch barcodes from multiple scanned values. - /// STRICT MODE: Requires valid MAC and LL serial (14+ chars). + /// Requires valid MAC and Switch serial (LL or EC2). static ParsedDeviceData parseSwitchBarcodes(List barcodes) { LoggerService.debug( 'Parsing Switch barcodes from ${barcodes.length} values', @@ -295,7 +287,7 @@ class ScannerValidationService { String mac = ''; String serialNumber = ''; String model = ''; - bool hasLLSerial = false; + bool hasSwitchSerial = false; for (String barcode in barcodes) { if (barcode.isEmpty) continue; @@ -309,33 +301,30 @@ class ScannerValidationService { continue; } - // SWITCH-SPECIFIC: Only accept LL serials (14+ chars) + // Accept LL or EC2 switch serials if (SerialPatterns.isSwitchSerial(value) && serialNumber.isEmpty) { - // Validate characters after LL (alphanumeric and dashes) - final afterLL = value.substring(2); - if (RegExp(r'^[A-Za-z0-9\-]+$').hasMatch(afterLL)) { - serialNumber = value.toUpperCase(); - hasLLSerial = true; - LoggerService.debug('Found LL serial: $serialNumber', tag: _tag); - continue; - } + serialNumber = value.toUpperCase(); + hasSwitchSerial = true; + LoggerService.debug('Found Switch serial: $serialNumber', tag: _tag); + continue; } // Check for model number (optional) if (value.length >= 4 && value.length <= 20 && model.isEmpty) { - if (!value.toUpperCase().startsWith('LL') && !_isValidMacAddress(value)) { + if (!value.toUpperCase().startsWith('LL') && + !value.toUpperCase().startsWith('EC2') && + !_isValidMacAddress(value)) { model = value; LoggerService.debug('Found possible model: $model', tag: _tag); } } } - // STRICT validation: require both MAC and LL serial - final isComplete = mac.isNotEmpty && serialNumber.isNotEmpty && hasLLSerial; + final isComplete = mac.isNotEmpty && hasSwitchSerial; if (!isComplete) { LoggerService.warning( - 'Switch incomplete - MAC: ${mac.isNotEmpty}, LL: $hasLLSerial', + 'Switch incomplete - MAC: ${mac.isNotEmpty}, serial: $hasSwitchSerial', tag: _tag, ); } @@ -348,7 +337,7 @@ class ScannerValidationService { detectedType: DeviceTypeFromSerial.switchDevice, missingFields: [ if (mac.isEmpty) 'MAC Address', - if (!hasLLSerial) 'Serial Number (LL)', + if (!hasSwitchSerial) 'Serial Number', ], ); } diff --git a/lib/features/scanner/presentation/providers/device_registration_provider.dart b/lib/features/scanner/presentation/providers/device_registration_provider.dart index ed18300..748ab41 100644 --- a/lib/features/scanner/presentation/providers/device_registration_provider.dart +++ b/lib/features/scanner/presentation/providers/device_registration_provider.dart @@ -27,9 +27,10 @@ DeviceRegistrationService deviceRegistrationService( class DeviceRegistrationNotifier extends _$DeviceRegistrationNotifier { StreamSubscription? _wsSubscription; - // Dual-index cache for O(1) device lookup (populated from WebSocket events) + // Triple-index cache for O(1) device lookup (populated from WebSocket events) final Map> _deviceIndexByMac = {}; final Map> _deviceIndexBySerial = {}; + final Map> _deviceIndexByName = {}; @override DeviceRegistrationState build() { @@ -98,6 +99,10 @@ class DeviceRegistrationNotifier extends _$DeviceRegistrationNotifier { if (serial.isNotEmpty) { _deviceIndexBySerial[serial] = device; } + final name = (device['name'] ?? '').toString(); + if (name.isNotEmpty) { + _deviceIndexByName[name] = device; + } LoggerService.debug( 'DeviceRegistration: Upserted device MAC=$mac, SN=$serial', @@ -126,6 +131,7 @@ class DeviceRegistrationNotifier extends _$DeviceRegistrationNotifier { // Rebuild indexes from snapshot _deviceIndexByMac.clear(); _deviceIndexBySerial.clear(); + _deviceIndexByName.clear(); for (final item in items) { if (item is Map) { @@ -617,6 +623,24 @@ class DeviceRegistrationNotifier extends _$DeviceRegistrationNotifier { void clearIndexes() { _deviceIndexByMac.clear(); _deviceIndexBySerial.clear(); + _deviceIndexByName.clear(); + } + + /// Look up a device's numeric server ID by its name. + /// Returns null if no device with that name is cached. + int? lookupDeviceIdByName(String name) { + final device = _deviceIndexByName[name]; + if (device == null) { + return null; + } + final id = device['id']; + if (id is int) { + return id; + } + if (id is String) { + return int.tryParse(id); + } + return null; } /// Convert DeviceType to DeviceTypeFromSerial for serial validation. diff --git a/lib/features/scanner/presentation/providers/scanner_notifier_v2.dart b/lib/features/scanner/presentation/providers/scanner_notifier_v2.dart index df2ebd4..6171178 100644 --- a/lib/features/scanner/presentation/providers/scanner_notifier_v2.dart +++ b/lib/features/scanner/presentation/providers/scanner_notifier_v2.dart @@ -115,15 +115,9 @@ class ScannerNotifierV2 extends _$ScannerNotifierV2 { return; } - // Check if it's an ambiguous EC2 serial + // EC2 serials are always manually placed (user selects AP or Switch mode) if (SerialPatterns.isAmbiguousEC2Serial(value)) { - // In manual mode (AP or Switch), process as valid serial for that mode - if (state.scanMode == ScanMode.accessPoint || state.scanMode == ScanMode.switchDevice) { - _processManualModeEC2Serial(value); - } else { - // In auto mode, wait for part number to determine type - _processAmbiguousEC2Serial(value); - } + _processManualModeEC2Serial(value); return; } @@ -153,30 +147,8 @@ class ScannerNotifierV2 extends _$ScannerNotifierV2 { LoggerService.debug('Barcode did not match any known pattern: $value', tag: _tag); } - /// Process an ambiguous EC2 serial (could be AP or Switch). - /// Stores the serial but doesn't auto-lock mode - waits for part number. - void _processAmbiguousEC2Serial(String serial) { - final upperSerial = serial.toUpperCase(); - LoggerService.debug('Processing ambiguous EC2 serial: $upperSerial (awaiting part number)', tag: _tag); - - // Store the serial but don't auto-lock mode yet - state = state.copyWith( - lastSerialSeenAt: DateTime.now(), - scanData: state.scanData.copyWith( - serialNumber: upperSerial, - hasValidSerial: true, - scanHistory: [ - ...state.scanData.scanHistory, - ScanRecord(value: upperSerial, scannedAt: DateTime.now(), fieldType: 'serial'), - ], - ), - ); - - // Don't check completion - we need part number to determine device type - } - - /// Process an EC2 serial when user has manually selected AP or Switch mode. - /// No need to wait for part number - user already chose the device type. + /// Process an EC2 serial. EC2 devices are always manually placed, + /// so no part number disambiguation is needed. void _processManualModeEC2Serial(String serial) { final upperSerial = serial.toUpperCase(); LoggerService.debug( @@ -275,41 +247,9 @@ class ScannerNotifierV2 extends _$ScannerNotifierV2 { } /// Process a part number barcode. - /// For EC2 devices, uses part number to determine AP vs Switch. void _processPartNumber(String partNumber) { LoggerService.debug('Processing part number: $partNumber', tag: _tag); - // Check if we have an EC2 serial and are in auto mode - use part number to determine type - if (state.scanMode == ScanMode.auto && - state.scanData.serialNumber.isNotEmpty && - SerialPatterns.isAmbiguousEC2Serial(state.scanData.serialNumber)) { - final detectedType = SerialPatterns.detectDeviceTypeFromPartNumber(partNumber); - - if (detectedType != null) { - final newMode = _deviceTypeToScanMode(detectedType); - LoggerService.debug( - 'Part number detected device type: ${detectedType.displayName}, auto-locking to $newMode', - tag: _tag, - ); - - state = state.copyWith( - scanMode: newMode, - isAutoLocked: true, - scanData: state.scanData.copyWith( - partNumber: partNumber, - scanHistory: [ - ...state.scanData.scanHistory, - ScanRecord(value: partNumber, scannedAt: DateTime.now(), fieldType: 'partNumber'), - ], - ), - ); - - _checkCompletion(); - return; - } - } - - // Normal part number processing (for ONT or manual mode) state = state.copyWith( scanData: state.scanData.copyWith( partNumber: partNumber, @@ -397,9 +337,12 @@ class ScannerNotifierV2 extends _$ScannerNotifierV2 { ); } - /// Clear all accumulated scan data and reset mode to auto. + /// Clear all accumulated scan data, preserving the current scan mode. void clearScanData() { - LoggerService.debug('Clearing scan data and resetting to auto mode', tag: _tag); + LoggerService.debug( + 'Clearing scan data (preserving mode: ${state.scanMode.displayName})', + tag: _tag, + ); state = state.copyWith( scanData: const AccumulatedScanData(), selectedRoomId: null, @@ -412,7 +355,6 @@ class ScannerNotifierV2 extends _$ScannerNotifierV2 { isAutoLocked: false, wasAutoReverted: false, lastSerialSeenAt: null, - scanMode: ScanMode.auto, ); } diff --git a/lib/features/scanner/presentation/widgets/scanner_registration_popup.dart b/lib/features/scanner/presentation/widgets/scanner_registration_popup.dart index 8e6e59e..7258a22 100644 --- a/lib/features/scanner/presentation/widgets/scanner_registration_popup.dart +++ b/lib/features/scanner/presentation/widgets/scanner_registration_popup.dart @@ -971,7 +971,7 @@ class _ScannerRegistrationPopupState if (isExisting) { existingDeviceId = scannerState.matchedDeviceId; } else if (!_createNewDevice && _selectedDevice != null) { - existingDeviceId = int.tryParse(_selectedDevice!.id); + existingDeviceId = _parseDeviceId(_selectedDevice!.id); } LoggerService.info( @@ -1053,6 +1053,15 @@ class _ScannerRegistrationPopupState } } + /// Extract numeric server ID from prefixed device ID (e.g., "ap_123" → 123). + int? _parseDeviceId(String id) { + final underscoreIndex = id.indexOf('_'); + if (underscoreIndex >= 0 && underscoreIndex < id.length - 1) { + return int.tryParse(id.substring(underscoreIndex + 1)); + } + return int.tryParse(id); + } + String _getDeviceTypeName(ScanMode mode) { switch (mode) { case ScanMode.accessPoint: From 2f069558bb57f1e13adae94eac97f25c49aaea75 Mon Sep 17 00:00:00 2001 From: Dominic Pham Date: Wed, 25 Feb 2026 14:05:37 -0800 Subject: [PATCH 6/7] Added switchport fixed slow scanning --- lib/core/constants/device_field_sets.dart | 1 + lib/core/services/device_normalizer.dart | 8 +- .../services/websocket_cache_integration.dart | 229 +++++++++++++++++- .../services/websocket_data_sync_service.dart | 163 +++++++++++++ .../providers/room_device_view_model.dart | 17 +- .../providers/room_device_view_model.g.dart | 2 +- .../providers/room_view_models.dart | 14 +- .../services/scanner_validation_service.dart | 34 +-- .../device_registration_provider.g.dart | 2 +- .../providers/scanner_notifier_v2.g.dart | 2 +- 10 files changed, 439 insertions(+), 33 deletions(-) diff --git a/lib/core/constants/device_field_sets.dart b/lib/core/constants/device_field_sets.dart index 75079a7..7a35f40 100644 --- a/lib/core/constants/device_field_sets.dart +++ b/lib/core/constants/device_field_sets.dart @@ -21,6 +21,7 @@ class DeviceFieldSets { 'mac', // Access points/ONTs use 'mac' 'scratch', // Switches store MAC in 'scratch' 'pms_room', // Full nested object + 'pms_room_id', // Flat field (switches may not have nested pms_room) 'location', 'last_seen', 'signal_strength', diff --git a/lib/core/services/device_normalizer.dart b/lib/core/services/device_normalizer.dart index e6a74b4..e56de3b 100644 --- a/lib/core/services/device_normalizer.dart +++ b/lib/core/services/device_normalizer.dart @@ -9,7 +9,7 @@ class DeviceNormalizer { /// Normalize raw JSON to APModel APModel normalizeToAP(Map data) { return APModel( - id: (data['id'] ?? '').toString(), + id: 'ap_${data['id'] ?? ''}', name: data['name']?.toString() ?? 'Unknown AP', status: determineStatus(data), pmsRoomId: extractPmsRoomId(data), @@ -41,7 +41,7 @@ class DeviceNormalizer { /// Normalize raw JSON to ONTModel ONTModel normalizeToONT(Map data) { return ONTModel( - id: (data['id'] ?? '').toString(), + id: 'ont_${data['id'] ?? ''}', name: data['name']?.toString() ?? 'Unknown ONT', status: determineStatus(data), pmsRoomId: extractPmsRoomId(data), @@ -70,7 +70,7 @@ class DeviceNormalizer { /// Normalize raw JSON to SwitchModel SwitchModel normalizeToSwitch(Map data) { return SwitchModel( - id: (data['id'] ?? '').toString(), + id: 'sw_${data['id'] ?? ''}', name: data['name']?.toString() ?? 'Unknown Switch', status: determineStatus(data), pmsRoomId: extractPmsRoomId(data), @@ -94,7 +94,7 @@ class DeviceNormalizer { /// Normalize raw JSON to WLANModel WLANModel normalizeToWLAN(Map data) { return WLANModel( - id: (data['id'] ?? '').toString(), + id: 'wlan_${data['id'] ?? ''}', name: data['name']?.toString() ?? 'Unknown WLAN', status: determineStatus(data), pmsRoomId: extractPmsRoomId(data), diff --git a/lib/core/services/websocket_cache_integration.dart b/lib/core/services/websocket_cache_integration.dart index abd8541..70774eb 100644 --- a/lib/core/services/websocket_cache_integration.dart +++ b/lib/core/services/websocket_cache_integration.dart @@ -53,16 +53,22 @@ class WebSocketCacheIntegration { /// Room resource type. static const String _roomResourceType = 'pms_rooms'; + /// Switch ports resource — primary source for switch→room associations. + /// SwitchDevice has no pms_room_id; the link is only through switch_ports + /// (infrastructure_device_id → pms_room_id). + static const String _switchPortResourceType = 'switch_ports'; + /// Speed test resource types. static const String _speedTestConfigResourceType = 'speed_tests'; static const String _speedTestResultResourceType = 'speed_test_results'; - /// All resource types (devices + rooms + speed tests). + /// All resource types (devices + rooms + switch_ports + speed tests). static const List _resourceTypes = [ 'access_points', 'switch_devices', 'media_converters', 'pms_rooms', + 'switch_ports', 'speed_tests', 'speed_test_results', ]; @@ -96,6 +102,9 @@ class WebSocketCacheIntegration { /// Cached room data. final List> _roomCache = []; + /// Cached switch_ports data for building switch→room index. + final List> _switchPortCache = []; + /// Callbacks for when room data is received. final List>)> _roomDataCallbacks = []; @@ -1271,9 +1280,163 @@ class WebSocketCacheIntegration { return null; } + int? _parseIntId(dynamic value) { + if (value is int) return value; + if (value is String) return int.tryParse(value); + if (value is num) return value.toInt(); + return null; + } + + /// Build switch device ID → room ID index. + /// + /// Primary source: `_switchPortCache` — each record has + /// `infrastructure_device_id` (switch) and `pms_room_id` (room). + /// Fallback: embedded `switch_ports`/`switch_devices` in room data. + Map _buildSwitchToRoomIndex() { + final index = {}; + final sampleSwitchIds = []; + + // Primary: build from dedicated switch_ports snapshot + if (_switchPortCache.isNotEmpty) { + for (final port in _switchPortCache) { + final swId = _parseIntId( + port['infrastructure_device_id'] ?? port['switch_device_id'], + ); + final roomId = _parseIntId(port['pms_room_id']); + if (swId != null && roomId != null) { + index[swId] = roomId; + if (sampleSwitchIds.length < 5) { + sampleSwitchIds.add(swId); + } + } + } + _logger.i( + 'WebSocketCacheIntegration: switch→room index built from switch_ports ' + '(ports=${_switchPortCache.length}, mappings=${index.length}, ' + 'sample=${sampleSwitchIds.join(', ')})', + ); + return index; + } + + // Fallback: extract from room data (if server embeds switch_ports in rooms) + var switchPortEntries = 0; + var switchDeviceEntries = 0; + for (final room in _roomCache) { + final roomId = _parseIntId(room['id']); + if (roomId == null) { + continue; + } + + final switchPorts = room['switch_ports']; + if (switchPorts is List && switchPorts.isNotEmpty) { + for (final entry in switchPorts) { + if (entry is! Map) continue; + final sw = entry['switch_device']; + final swIdRaw = sw is Map + ? sw['id'] + : entry['switch_device_id'] ?? entry['id']; + final swId = _parseIntId(swIdRaw); + if (swId != null) { + index[swId] = roomId; + switchPortEntries++; + if (sampleSwitchIds.length < 5) { + sampleSwitchIds.add(swId); + } + } + } + } else { + final switchDevices = room['switch_devices']; + if (switchDevices is List) { + for (final entry in switchDevices) { + if (entry is! Map) continue; + final swId = _parseIntId(entry['id']); + if (swId != null) { + index[swId] = roomId; + switchDeviceEntries++; + if (sampleSwitchIds.length < 5) { + sampleSwitchIds.add(swId); + } + } + } + } + } + } + _logger.i( + 'WebSocketCacheIntegration: switch→room index built from rooms (fallback) ' + '(rooms=${_roomCache.length}, index=${index.length}, ' + 'switch_ports=$switchPortEntries, switch_devices=$switchDeviceEntries, ' + 'sample=${sampleSwitchIds.join(', ')})', + ); + return index; + } + + /// Stamp pms_room_id onto cached switch devices using room data. + /// Returns the number of devices stamped. + int _backPopulateSwitchRoomIds() { + final switches = _deviceCache['switch_devices']; + if (switches == null || switches.isEmpty) { + _logger.i( + 'WebSocketCacheIntegration: Back-populate skipped (no switch cache)', + ); + return 0; + } + if (_switchPortCache.isEmpty && _roomCache.isEmpty) { + _logger.i( + 'WebSocketCacheIntegration: Back-populate skipped (no switch_ports or room cache)', + ); + return 0; + } + + final index = _buildSwitchToRoomIndex(); + if (index.isEmpty) { + _logger.i( + 'WebSocketCacheIntegration: Back-populate skipped (empty index)', + ); + return 0; + } + + var stamped = 0; + var missingMapping = 0; + var alreadySet = 0; + var invalidIds = 0; + final sampleMissing = []; + for (final sw in switches) { + final swId = _parseIntId(sw['id']); + if (swId == null) { + invalidIds++; + continue; + } + final roomId = index[swId]; + if (roomId == null) { + missingMapping++; + if (sampleMissing.length < 5) { + sampleMissing.add(swId); + } + continue; + } + if (sw['pms_room_id'] == null) { + sw['pms_room_id'] = roomId; + stamped++; + } else { + alreadySet++; + } + } + + _logger.i( + 'WebSocketCacheIntegration: Back-populate summary ' + '(switches=${switches.length}, stamped=$stamped, ' + 'alreadySet=$alreadySet, missingMap=$missingMapping, ' + 'invalidIds=$invalidIds, sampleMissing=${sampleMissing.join(', ')})', + ); + return stamped; + } + void _applySnapshot(String resourceType, List> items) { if (resourceType == _roomResourceType) { // Handle room data + _logger.i( + 'WebSocketCacheIntegration: rooms snapshot - ${items.length} items', + ); _roomCache ..clear() ..addAll(items); @@ -1283,6 +1446,41 @@ class WebSocketCacheIntegration { for (final callback in _roomDataCallbacks) { callback(items); } + + // Back-populate pms_room_id onto switch devices now that rooms are updated + final stamped = _backPopulateSwitchRoomIds(); + if (stamped > 0) { + _bumpDeviceUpdate(); + final switchCache = _deviceCache['switch_devices']; + if (switchCache != null) { + for (final callback in _deviceDataCallbacks) { + callback('switch_devices', switchCache); + } + } + } + } else if (resourceType == _switchPortResourceType) { + // Handle switch_ports — build switch→room index directly from port records. + // SwitchDevice has no pms_room_id; the association is only through + // switch_ports (infrastructure_device_id → pms_room_id). + _logger.i( + 'WebSocketCacheIntegration: switch_ports snapshot - ${items.length} items', + ); + _switchPortCache + ..clear() + ..addAll(items); + _bumpLastUpdate(); + + // Back-populate switches using the new switch_ports data + final spStamped = _backPopulateSwitchRoomIds(); + if (spStamped > 0) { + _bumpDeviceUpdate(); + final switchCache = _deviceCache['switch_devices']; + if (switchCache != null) { + for (final callback in _deviceDataCallbacks) { + callback('switch_devices', switchCache); + } + } + } } else if (resourceType == _speedTestConfigResourceType) { // Handle speed test config data _speedTestConfigCache @@ -1317,6 +1515,11 @@ class WebSocketCacheIntegration { _bumpLastUpdate(); _bumpDeviceUpdate(); + // Back-populate pms_room_id onto switch devices from room data + if (resourceType == 'switch_devices') { + _backPopulateSwitchRoomIds(); + } + // Debug: Log first device's keys to see what fields backend is sending if (items.isNotEmpty) { final firstItem = items.first; @@ -1446,6 +1649,18 @@ class WebSocketCacheIntegration { for (final callback in _roomDataCallbacks) { callback(_roomCache); } + + // Back-populate pms_room_id onto switch devices now that room data changed + final stamped = _backPopulateSwitchRoomIds(); + if (stamped > 0) { + _bumpDeviceUpdate(); + final switchCache = _deviceCache['switch_devices']; + if (switchCache != null) { + for (final callback in _deviceDataCallbacks) { + callback('switch_devices', switchCache); + } + } + } } else if (resourceType == _speedTestConfigResourceType) { // Handle speed test config upsert final index = _speedTestConfigCache.indexWhere((item) => item['id'] == id); @@ -1482,6 +1697,11 @@ class WebSocketCacheIntegration { _bumpLastUpdate(); _bumpDeviceUpdate(); + // Back-populate pms_room_id onto switch devices from room data + if (resourceType == 'switch_devices') { + _backPopulateSwitchRoomIds(); + } + // Notify device callbacks for (final callback in _deviceDataCallbacks) { callback(resourceType, cache); @@ -1628,9 +1848,10 @@ class WebSocketCacheIntegration { void clearDataAndRefresh() { _logger.i('WebSocketCacheIntegration: Clearing data caches and requesting refresh'); - // Clear device, room, and speed test caches + // Clear device, room, switch_ports, and speed test caches _deviceCache.clear(); _roomCache.clear(); + _switchPortCache.clear(); _speedTestConfigCache.clear(); _speedTestResultCache.clear(); @@ -1650,9 +1871,10 @@ class WebSocketCacheIntegration { void clearCaches() { _logger.i('WebSocketCacheIntegration: Clearing all caches'); - // Clear device, room, and speed test caches + // Clear device, room, switch_ports, and speed test caches _deviceCache.clear(); _roomCache.clear(); + _switchPortCache.clear(); _speedTestConfigCache.clear(); _speedTestResultCache.clear(); @@ -1697,6 +1919,7 @@ class WebSocketCacheIntegration { _speedTestResultCallbacks.clear(); _deviceCache.clear(); _roomCache.clear(); + _switchPortCache.clear(); _speedTestConfigCache.clear(); _speedTestResultCache.clear(); } diff --git a/lib/core/services/websocket_data_sync_service.dart b/lib/core/services/websocket_data_sync_service.dart index cfbfc15..d3a2939 100644 --- a/lib/core/services/websocket_data_sync_service.dart +++ b/lib/core/services/websocket_data_sync_service.dart @@ -83,6 +83,16 @@ class WebSocketDataSyncService { /// ID-to-Type index for routing device lookups final Map _idToTypeIndex = {}; + /// Switch device ID → room ID index built from port ID overlap. + Map _switchToRoomIndex = {}; + + /// Port ID → switch raw int ID, built from switch devices' embedded ports. + /// Used to correlate room-embedded port IDs back to their parent switch. + Map _portToSwitchIndex = {}; + + List>? _lastSwitchSnapshotItems; + List> _lastRoomSnapshotItems = []; + final Map> _roomSnapshots = {}; final Set _pendingSnapshots = {}; Completer? _initialSyncCompleter; @@ -212,6 +222,7 @@ class WebSocketDataSyncService { ..sendSubscribe(resource) ..sendSnapshotRequest(resource); } + } void _handleMessage(SocketMessage message) { @@ -250,7 +261,9 @@ class WebSocketDataSyncService { _handleRoomSnapshot(snapshotItems, resourceType: resourceType); _pendingSnapshots.remove(resourceType); _markSnapshotHandled(); + return; } + } String? _resolveResourceType(SocketMessage message) { @@ -293,6 +306,83 @@ class WebSocketDataSyncService { return null; } + int? _parseIntId(dynamic value) { + if (value is int) return value; + if (value is String) return int.tryParse(value); + if (value is num) return value.toInt(); + return null; + } + + /// Build switch device ID → room ID index from raw room items. + /// + /// Strategy: rooms embed switch_ports as [{id, name}] — only the port's own + /// ID, no switch device FK. Switch devices embed the same ports with the same + /// IDs. We use [_portToSwitchIndex] (portId → switchId) built from switch + /// device snapshots to look up which switch owns each room-embedded port. + void _rebuildSwitchToRoomIndex(List> items) { + final index = {}; + for (final room in items) { + final roomId = _parseIntId(room['id']); + if (roomId == null) continue; + + final switchPorts = room['switch_ports']; + if (switchPorts is List && switchPorts.isNotEmpty) { + for (final entry in switchPorts) { + if (entry is! Map) continue; + + // Try direct FK first (future-proof if server ever embeds it) + final sw = entry['switch_device']; + final directId = _parseIntId( + sw is Map ? sw['id'] : entry['switch_device_id'], + ); + if (directId != null) { + index[directId] = roomId; + continue; + } + + // Primary: look up port ID in switch-device-embedded ports index + final portId = _parseIntId(entry['id']); + if (portId != null) { + final swId = _portToSwitchIndex[portId]; + if (swId != null) { + index[swId] = roomId; + } + } + } + } else { + // Fallback: room directly embeds switch_devices list + final switchDevices = room['switch_devices']; + if (switchDevices is List) { + for (final entry in switchDevices) { + if (entry is! Map) continue; + final swId = _parseIntId(entry['id']); + if (swId != null) index[swId] = roomId; + } + } + } + } + _switchToRoomIndex = index; + _logger.i( + 'WebSocketDataSync: Built switch→room index ' + '(rooms=${items.length}, mappings=${index.length}, ' + 'portIndex=${_portToSwitchIndex.length})', + ); + } + + bool _shouldBackfillSwitchRooms(List> items) { + if (_switchToRoomIndex.isEmpty) return false; + for (final item in items) { + if (item['pms_room_id'] != null) { + continue; + } + final swId = _parseIntId(item['id']); + if (swId != null && _switchToRoomIndex.containsKey(swId)) { + return true; + } + } + return false; + } + void _handleDeviceSnapshot( List> items, { required String? resourceType, @@ -399,9 +489,49 @@ class WebSocketDataSyncService { } void _cacheSwitchDevices(List> items) { + // Build portId → switchRawId from each switch device's embedded ports. + // Room snapshots only embed port {id, name} — this index lets us look up + // the owning switch from that port ID. + for (final item in items) { + final swRawId = _parseIntId(item['id']); + if (swRawId == null) continue; + final ports = item['switch_ports']; + if (ports is List) { + for (final port in ports) { + if (port is! Map) continue; + final portId = _parseIntId(port['id']); + if (portId != null) _portToSwitchIndex[portId] = swRawId; + } + } + } + + // If rooms arrived before switches, the switchToRoom index was built with + // an empty portToSwitch index. Rebuild it now that we have port data. + if (_lastRoomSnapshotItems.isNotEmpty && _switchToRoomIndex.isEmpty) { + _rebuildSwitchToRoomIndex(_lastRoomSnapshotItems); + } + final models = []; + var injected = 0; + var missingMapping = 0; + var invalidIds = 0; for (final item in items) { try { + // Back-populate pms_room_id from room index if missing + if (item['pms_room_id'] == null && _switchToRoomIndex.isNotEmpty) { + final swId = _parseIntId(item['id']); + if (swId != null) { + final roomId = _switchToRoomIndex[swId]; + if (roomId != null) { + item['pms_room_id'] = roomId; + injected++; + } else { + missingMapping++; + } + } else { + invalidIds++; + } + } final normalized = _deviceNormalizer.normalizeToSwitch(item); models.add(normalized); _idToTypeIndex[normalized.id] = DeviceModelSealed.typeSwitch; @@ -409,6 +539,18 @@ class WebSocketDataSyncService { _logger.w('WebSocketDataSync: Failed to parse Switch: $e'); } } + _lastSwitchSnapshotItems = + items.map((item) => Map.from(item)).toList(); + if (injected > 0 || + (missingMapping > 0 && _switchToRoomIndex.isNotEmpty) || + invalidIds > 0) { + _logger.i( + 'WebSocketDataSync: Switch backfill summary ' + '(items=${items.length}, injected=$injected, ' + 'missingMap=$missingMapping, invalidIds=$invalidIds, ' + 'index=${_switchToRoomIndex.length})', + ); + } // Always cache to clear stale data when snapshot is empty unawaited(_switchLocalDataSource.cacheDevices(models)); _logger.d('WebSocketDataSync: Cached ${models.length} Switches'); @@ -463,6 +605,27 @@ class WebSocketDataSyncService { List> items, { required String? resourceType, }) { + // Save raw items so _cacheSwitchDevices can rebuild the index if switches + // arrive after rooms (when _portToSwitchIndex was empty at room-arrival time). + _lastRoomSnapshotItems = List>.from(items); + + // Rebuild switch→room index from raw room data before converting to models + _rebuildSwitchToRoomIndex(items); + final lastSwitchItems = _lastSwitchSnapshotItems; + if (lastSwitchItems != null && + lastSwitchItems.isNotEmpty && + _shouldBackfillSwitchRooms(lastSwitchItems)) { + _logger.i( + 'WebSocketDataSync: Re-caching switches after room snapshot ' + '(switchItems=${lastSwitchItems.length}, index=${_switchToRoomIndex.length})', + ); + _cacheSwitchDevices(lastSwitchItems); + } else if (lastSwitchItems == null || lastSwitchItems.isEmpty) { + _logger.i( + 'WebSocketDataSync: No cached switch snapshot to backfill after rooms', + ); + } + final models = []; for (final item in items) { try { diff --git a/lib/features/rooms/presentation/providers/room_device_view_model.dart b/lib/features/rooms/presentation/providers/room_device_view_model.dart index 3edf82e..0f6de39 100644 --- a/lib/features/rooms/presentation/providers/room_device_view_model.dart +++ b/lib/features/rooms/presentation/providers/room_device_view_model.dart @@ -129,12 +129,23 @@ class RoomDeviceNotifier extends _$RoomDeviceNotifier { } } - /// Filter devices for a specific room + /// Get the room's deviceIds from the room view model for fallback matching. + Set? _getRoomDeviceIds(String roomId) { + final roomVm = ref.read(roomViewModelByIdProvider(roomId)); + return roomVm?.deviceIds?.toSet(); + } + + /// Filter devices for a specific room. + /// Matches by pmsRoomId first, then falls back to the room's deviceIds + /// list (populated from switch_ports/switch_devices in room data). + /// This ensures switches are included even when they lack a direct pms_room_id. List _filterDevicesForRoom(List allDevices, int roomIdInt) { try { + final deviceIdSet = _getRoomDeviceIds(roomIdInt.toString()); final filtered = allDevices.where((device) { - // Use pmsRoomId for room association - return device.pmsRoomId == roomIdInt; + if (device.pmsRoomId == roomIdInt) return true; + if (deviceIdSet != null && deviceIdSet.contains(device.id)) return true; + return false; }).toList(); return filtered; diff --git a/lib/features/rooms/presentation/providers/room_device_view_model.g.dart b/lib/features/rooms/presentation/providers/room_device_view_model.g.dart index da4493d..1fc542a 100644 --- a/lib/features/rooms/presentation/providers/room_device_view_model.g.dart +++ b/lib/features/rooms/presentation/providers/room_device_view_model.g.dart @@ -7,7 +7,7 @@ part of 'room_device_view_model.dart'; // ************************************************************************** String _$roomDeviceNotifierHash() => - r'8eae409abe0df127ea7096fe124d8a820acb5424'; + r'7e8b1ef46b2e7a6a0d69d94e847ee96df5feb72e'; /// Copied from Dart SDK class _SystemHash { diff --git a/lib/features/rooms/presentation/providers/room_view_models.dart b/lib/features/rooms/presentation/providers/room_view_models.dart index eb07f87..e1e70c0 100644 --- a/lib/features/rooms/presentation/providers/room_view_models.dart +++ b/lib/features/rooms/presentation/providers/room_view_models.dart @@ -216,9 +216,15 @@ class RoomStats { final int empty; } -/// Private helper to get devices for a room using unified approach -/// Matches devices by pmsRoomId (consistent for both mock and API data) +/// Private helper to get devices for a room using unified approach. +/// Matches devices by pmsRoomId first, then falls back to the room's +/// deviceIds list (populated from switch_ports/switch_devices in room data). +/// This ensures switches are included even when they lack a direct pms_room_id. List _getDevicesForRoom(Room room, List allDevices) { - // Filter devices where pmsRoomId matches the room's numeric ID - return allDevices.where((device) => device.pmsRoomId == room.id).toList(); + final deviceIdSet = room.deviceIds?.toSet(); + return allDevices.where((device) { + if (device.pmsRoomId == room.id) return true; + if (deviceIdSet != null && deviceIdSet.contains(device.id)) return true; + return false; + }).toList(); } diff --git a/lib/features/scanner/data/services/scanner_validation_service.dart b/lib/features/scanner/data/services/scanner_validation_service.dart index 6437554..ebe7427 100644 --- a/lib/features/scanner/data/services/scanner_validation_service.dart +++ b/lib/features/scanner/data/services/scanner_validation_service.dart @@ -239,20 +239,21 @@ class ScannerValidationService { String value = barcode.trim(); - // Check for MAC address (12 hex chars) - if (value.length == 12 && _isValidMacAddress(value) && mac.isEmpty) { - mac = value.toUpperCase(); - LoggerService.debug('Found MAC: $mac', tag: _tag); - continue; - } - - // Accept approved AP prefixes including EC2 + // Check for AP serial FIRST (before MAC, to avoid EC2 hex serials + // being misclassified as MAC addresses) if (SerialPatterns.isAPSerial(value)) { serialNumber = value.toUpperCase(); hasAPSerial = true; LoggerService.debug('Found AP serial: $serialNumber', tag: _tag); continue; } + + // Check for MAC address (12 hex chars) + if (value.length == 12 && _isValidMacAddress(value) && mac.isEmpty) { + mac = value.toUpperCase(); + LoggerService.debug('Found MAC: $mac', tag: _tag); + continue; + } } final isComplete = mac.isNotEmpty && hasAPSerial; @@ -294,14 +295,8 @@ class ScannerValidationService { String value = barcode.trim(); - // Check for MAC address first (12 hex chars) - if (value.length == 12 && _isValidMacAddress(value) && mac.isEmpty) { - mac = value.toUpperCase(); - LoggerService.debug('Found MAC: $mac', tag: _tag); - continue; - } - - // Accept LL or EC2 switch serials + // Check for switch serial FIRST (before MAC, to avoid EC2 hex serials + // being misclassified as MAC addresses) if (SerialPatterns.isSwitchSerial(value) && serialNumber.isEmpty) { serialNumber = value.toUpperCase(); hasSwitchSerial = true; @@ -309,6 +304,13 @@ class ScannerValidationService { continue; } + // Check for MAC address (12 hex chars) + if (value.length == 12 && _isValidMacAddress(value) && mac.isEmpty) { + mac = value.toUpperCase(); + LoggerService.debug('Found MAC: $mac', tag: _tag); + continue; + } + // Check for model number (optional) if (value.length >= 4 && value.length <= 20 && model.isEmpty) { if (!value.toUpperCase().startsWith('LL') && diff --git a/lib/features/scanner/presentation/providers/device_registration_provider.g.dart b/lib/features/scanner/presentation/providers/device_registration_provider.g.dart index e4260d4..eec50d3 100644 --- a/lib/features/scanner/presentation/providers/device_registration_provider.g.dart +++ b/lib/features/scanner/presentation/providers/device_registration_provider.g.dart @@ -46,7 +46,7 @@ final deviceWebSocketEventsProvider = typedef DeviceWebSocketEventsRef = AutoDisposeStreamProviderRef; String _$deviceRegistrationNotifierHash() => - r'c3cf365b3b0bbd1b06a7efe3c4d975c1732710a3'; + r'5391cec078e8349d2bddd4c3cd7c4463bbf0b97e'; /// Provider for device registration with WebSocket integration. /// Handles checking existing devices and registering new ones. diff --git a/lib/features/scanner/presentation/providers/scanner_notifier_v2.g.dart b/lib/features/scanner/presentation/providers/scanner_notifier_v2.g.dart index 12bd48e..e821d76 100644 --- a/lib/features/scanner/presentation/providers/scanner_notifier_v2.g.dart +++ b/lib/features/scanner/presentation/providers/scanner_notifier_v2.g.dart @@ -6,7 +6,7 @@ part of 'scanner_notifier_v2.dart'; // RiverpodGenerator // ************************************************************************** -String _$scannerNotifierV2Hash() => r'36b1e87dafb5a7c4da3a8ea3acf3b563cf9255b9'; +String _$scannerNotifierV2Hash() => r'f3852a562b3bb14760972c39117f979461dccbab'; /// New scanner notifier using freezed ScannerState with auto-detection support. /// From 0a1d323a7fdf819d9bc3b857153395f555a76661 Mon Sep 17 00:00:00 2001 From: Dominic Pham Date: Fri, 27 Feb 2026 10:34:32 -0800 Subject: [PATCH 7/7] Switch port not properly added to cache integration --- lib/core/constants/device_field_sets.dart | 1 + .../services/websocket_cache_integration.dart | 139 ++++++++---------- .../services/websocket_data_sync_service.dart | 33 ++++- 3 files changed, 94 insertions(+), 79 deletions(-) diff --git a/lib/core/constants/device_field_sets.dart b/lib/core/constants/device_field_sets.dart index 7a35f40..f6467a5 100644 --- a/lib/core/constants/device_field_sets.dart +++ b/lib/core/constants/device_field_sets.dart @@ -22,6 +22,7 @@ class DeviceFieldSets { 'scratch', // Switches store MAC in 'scratch' 'pms_room', // Full nested object 'pms_room_id', // Flat field (switches may not have nested pms_room) + 'switch_ports', // Switch port list — used to correlate switches to PMS rooms 'location', 'last_seen', 'signal_strength', diff --git a/lib/core/services/websocket_cache_integration.dart b/lib/core/services/websocket_cache_integration.dart index 70774eb..42d344f 100644 --- a/lib/core/services/websocket_cache_integration.dart +++ b/lib/core/services/websocket_cache_integration.dart @@ -53,22 +53,16 @@ class WebSocketCacheIntegration { /// Room resource type. static const String _roomResourceType = 'pms_rooms'; - /// Switch ports resource — primary source for switch→room associations. - /// SwitchDevice has no pms_room_id; the link is only through switch_ports - /// (infrastructure_device_id → pms_room_id). - static const String _switchPortResourceType = 'switch_ports'; - /// Speed test resource types. static const String _speedTestConfigResourceType = 'speed_tests'; static const String _speedTestResultResourceType = 'speed_test_results'; - /// All resource types (devices + rooms + switch_ports + speed tests). + /// All resource types (devices + rooms + speed tests). static const List _resourceTypes = [ 'access_points', 'switch_devices', 'media_converters', 'pms_rooms', - 'switch_ports', 'speed_tests', 'speed_test_results', ]; @@ -102,8 +96,9 @@ class WebSocketCacheIntegration { /// Cached room data. final List> _roomCache = []; - /// Cached switch_ports data for building switch→room index. - final List> _switchPortCache = []; + /// Port ID → switch raw int ID, built from switch devices' embedded ports. + /// Used to correlate room-embedded port IDs back to their parent switch. + final Map _portToSwitchIndex = {}; /// Callbacks for when room data is received. final List>)> _roomDataCallbacks = []; @@ -1287,38 +1282,15 @@ class WebSocketCacheIntegration { return null; } - /// Build switch device ID → room ID index. + /// Build switch device ID → room ID index using port ID overlap. /// - /// Primary source: `_switchPortCache` — each record has - /// `infrastructure_device_id` (switch) and `pms_room_id` (room). - /// Fallback: embedded `switch_ports`/`switch_devices` in room data. + /// Rooms embed switch_ports as [{id, name}] — only the port's own ID. + /// Switch devices embed the same ports with the same IDs. + /// [_portToSwitchIndex] (portId → switchId) bridges the two. Map _buildSwitchToRoomIndex() { final index = {}; final sampleSwitchIds = []; - // Primary: build from dedicated switch_ports snapshot - if (_switchPortCache.isNotEmpty) { - for (final port in _switchPortCache) { - final swId = _parseIntId( - port['infrastructure_device_id'] ?? port['switch_device_id'], - ); - final roomId = _parseIntId(port['pms_room_id']); - if (swId != null && roomId != null) { - index[swId] = roomId; - if (sampleSwitchIds.length < 5) { - sampleSwitchIds.add(swId); - } - } - } - _logger.i( - 'WebSocketCacheIntegration: switch→room index built from switch_ports ' - '(ports=${_switchPortCache.length}, mappings=${index.length}, ' - 'sample=${sampleSwitchIds.join(', ')})', - ); - return index; - } - - // Fallback: extract from room data (if server embeds switch_ports in rooms) var switchPortEntries = 0; var switchDeviceEntries = 0; for (final room in _roomCache) { @@ -1331,16 +1303,27 @@ class WebSocketCacheIntegration { if (switchPorts is List && switchPorts.isNotEmpty) { for (final entry in switchPorts) { if (entry is! Map) continue; + + // Try direct FK first (future-proof if server ever embeds it) final sw = entry['switch_device']; - final swIdRaw = sw is Map - ? sw['id'] - : entry['switch_device_id'] ?? entry['id']; - final swId = _parseIntId(swIdRaw); - if (swId != null) { - index[swId] = roomId; + final directId = _parseIntId( + sw is Map ? sw['id'] : entry['switch_device_id'], + ); + if (directId != null) { + index[directId] = roomId; switchPortEntries++; - if (sampleSwitchIds.length < 5) { - sampleSwitchIds.add(swId); + if (sampleSwitchIds.length < 5) sampleSwitchIds.add(directId); + continue; + } + + // Primary: look up port ID in port→switch index + final portId = _parseIntId(entry['id']); + if (portId != null) { + final swId = _portToSwitchIndex[portId]; + if (swId != null) { + index[swId] = roomId; + switchPortEntries++; + if (sampleSwitchIds.length < 5) sampleSwitchIds.add(swId); } } } @@ -1353,17 +1336,16 @@ class WebSocketCacheIntegration { if (swId != null) { index[swId] = roomId; switchDeviceEntries++; - if (sampleSwitchIds.length < 5) { - sampleSwitchIds.add(swId); - } + if (sampleSwitchIds.length < 5) sampleSwitchIds.add(swId); } } } } } _logger.i( - 'WebSocketCacheIntegration: switch→room index built from rooms (fallback) ' + 'WebSocketCacheIntegration: switch→room index built ' '(rooms=${_roomCache.length}, index=${index.length}, ' + 'portIndex=${_portToSwitchIndex.length}, ' 'switch_ports=$switchPortEntries, switch_devices=$switchDeviceEntries, ' 'sample=${sampleSwitchIds.join(', ')})', ); @@ -1380,9 +1362,9 @@ class WebSocketCacheIntegration { ); return 0; } - if (_switchPortCache.isEmpty && _roomCache.isEmpty) { + if (_roomCache.isEmpty) { _logger.i( - 'WebSocketCacheIntegration: Back-populate skipped (no switch_ports or room cache)', + 'WebSocketCacheIntegration: Back-populate skipped (no room cache)', ); return 0; } @@ -1458,29 +1440,6 @@ class WebSocketCacheIntegration { } } } - } else if (resourceType == _switchPortResourceType) { - // Handle switch_ports — build switch→room index directly from port records. - // SwitchDevice has no pms_room_id; the association is only through - // switch_ports (infrastructure_device_id → pms_room_id). - _logger.i( - 'WebSocketCacheIntegration: switch_ports snapshot - ${items.length} items', - ); - _switchPortCache - ..clear() - ..addAll(items); - _bumpLastUpdate(); - - // Back-populate switches using the new switch_ports data - final spStamped = _backPopulateSwitchRoomIds(); - if (spStamped > 0) { - _bumpDeviceUpdate(); - final switchCache = _deviceCache['switch_devices']; - if (switchCache != null) { - for (final callback in _deviceDataCallbacks) { - callback('switch_devices', switchCache); - } - } - } } else if (resourceType == _speedTestConfigResourceType) { // Handle speed test config data _speedTestConfigCache @@ -1517,6 +1476,30 @@ class WebSocketCacheIntegration { // Back-populate pms_room_id onto switch devices from room data if (resourceType == 'switch_devices') { + // Build portId → switchId index from each switch device's embedded ports. + // Room snapshots only embed port {id, name} — this index lets us look up + // the owning switch from that port ID. + _portToSwitchIndex.clear(); + var switchesWithPorts = 0; + for (final sw in items) { + final swRawId = _parseIntId(sw['id']); + if (swRawId == null) continue; + final ports = sw['switch_ports']; + if (ports is List && ports.isNotEmpty) { + switchesWithPorts++; + for (final port in ports) { + if (port is! Map) continue; + final portId = _parseIntId(port['id']); + if (portId != null) _portToSwitchIndex[portId] = swRawId; + } + } + } + LoggerService.info( + '🔌 [SWITCH-PORT] CacheIntegration switch_devices snapshot — ' + '${items.length} items, switchesWithPorts=$switchesWithPorts, ' + 'portIndexSize=${_portToSwitchIndex.length}', + tag: 'SwitchPort', + ); _backPopulateSwitchRoomIds(); } @@ -1848,10 +1831,10 @@ class WebSocketCacheIntegration { void clearDataAndRefresh() { _logger.i('WebSocketCacheIntegration: Clearing data caches and requesting refresh'); - // Clear device, room, switch_ports, and speed test caches + // Clear device, room, and speed test caches _deviceCache.clear(); _roomCache.clear(); - _switchPortCache.clear(); + _portToSwitchIndex.clear(); _speedTestConfigCache.clear(); _speedTestResultCache.clear(); @@ -1871,10 +1854,10 @@ class WebSocketCacheIntegration { void clearCaches() { _logger.i('WebSocketCacheIntegration: Clearing all caches'); - // Clear device, room, switch_ports, and speed test caches + // Clear device, room, and speed test caches _deviceCache.clear(); _roomCache.clear(); - _switchPortCache.clear(); + _portToSwitchIndex.clear(); _speedTestConfigCache.clear(); _speedTestResultCache.clear(); @@ -1919,7 +1902,7 @@ class WebSocketCacheIntegration { _speedTestResultCallbacks.clear(); _deviceCache.clear(); _roomCache.clear(); - _switchPortCache.clear(); + _portToSwitchIndex.clear(); _speedTestConfigCache.clear(); _speedTestResultCache.clear(); } diff --git a/lib/core/services/websocket_data_sync_service.dart b/lib/core/services/websocket_data_sync_service.dart index d3a2939..693bc3a 100644 --- a/lib/core/services/websocket_data_sync_service.dart +++ b/lib/core/services/websocket_data_sync_service.dart @@ -5,6 +5,7 @@ import 'package:logger/logger.dart'; import 'package:rgnets_fdk/core/constants/device_field_sets.dart'; import 'package:rgnets_fdk/core/services/cache_manager.dart'; import 'package:rgnets_fdk/core/services/device_normalizer.dart'; +import 'package:rgnets_fdk/core/services/logger_service.dart'; import 'package:rgnets_fdk/core/services/room_data_processor.dart'; import 'package:rgnets_fdk/core/services/snapshot_request_service.dart'; import 'package:rgnets_fdk/core/services/storage_service.dart'; @@ -489,14 +490,33 @@ class WebSocketDataSyncService { } void _cacheSwitchDevices(List> items) { + LoggerService.info( + '🔌 [SWITCH-PORT] _cacheSwitchDevices called — ${items.length} items', + tag: 'SwitchPort', + ); + if (items.isNotEmpty) { + LoggerService.info( + '🔌 [SWITCH-PORT] First switch raw keys: ${items.first.keys.toList()}', + tag: 'SwitchPort', + ); + final rawPorts = items.first['switch_ports']; + LoggerService.info( + '🔌 [SWITCH-PORT] First switch embedded switch_ports: ' + '${rawPorts == null ? "NULL" : (rawPorts is List ? "${rawPorts.length} ports → $rawPorts" : rawPorts.runtimeType)}', + tag: 'SwitchPort', + ); + } + // Build portId → switchRawId from each switch device's embedded ports. // Room snapshots only embed port {id, name} — this index lets us look up // the owning switch from that port ID. + var switchesWithPorts = 0; for (final item in items) { final swRawId = _parseIntId(item['id']); if (swRawId == null) continue; final ports = item['switch_ports']; - if (ports is List) { + if (ports is List && ports.isNotEmpty) { + switchesWithPorts++; for (final port in ports) { if (port is! Map) continue; final portId = _parseIntId(port['id']); @@ -505,6 +525,17 @@ class WebSocketDataSyncService { } } + // Diagnostic: show whether switch devices embed their ports + if (items.isNotEmpty) { + final firstItem = items.first; + _logger.i( + 'WebSocketDataSync: Switch devices snapshot diagnostics ' + '(total=${items.length}, withEmbeddedPorts=$switchesWithPorts, ' + 'portIndexSize=${_portToSwitchIndex.length}, ' + 'firstItemKeys=${firstItem.keys.toList()})', + ); + } + // If rooms arrived before switches, the switchToRoom index was built with // an empty portToSwitch index. Rebuild it now that we have port data. if (_lastRoomSnapshotItems.isNotEmpty && _switchToRoomIndex.isEmpty) {