diff --git a/lib/core/constants/device_field_sets.dart b/lib/core/constants/device_field_sets.dart index 75079a7..f6467a5 100644 --- a/lib/core/constants/device_field_sets.dart +++ b/lib/core/constants/device_field_sets.dart @@ -21,6 +21,8 @@ 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) + 'switch_ports', // Switch port list — used to correlate switches to PMS rooms '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..42d344f 100644 --- a/lib/core/services/websocket_cache_integration.dart +++ b/lib/core/services/websocket_cache_integration.dart @@ -96,6 +96,10 @@ class WebSocketCacheIntegration { /// Cached room data. final List> _roomCache = []; + /// 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 = []; @@ -1271,9 +1275,150 @@ 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 using port ID overlap. + /// + /// 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 = []; + + 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; + + // 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; + switchPortEntries++; + 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); + } + } + } + } 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 ' + '(rooms=${_roomCache.length}, index=${index.length}, ' + 'portIndex=${_portToSwitchIndex.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 (_roomCache.isEmpty) { + _logger.i( + 'WebSocketCacheIntegration: Back-populate skipped (no 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 +1428,18 @@ 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 == _speedTestConfigResourceType) { // Handle speed test config data _speedTestConfigCache @@ -1317,6 +1474,35 @@ class WebSocketCacheIntegration { _bumpLastUpdate(); _bumpDeviceUpdate(); + // 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(); + } + // Debug: Log first device's keys to see what fields backend is sending if (items.isNotEmpty) { final firstItem = items.first; @@ -1446,6 +1632,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 +1680,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); @@ -1631,6 +1834,7 @@ class WebSocketCacheIntegration { // Clear device, room, and speed test caches _deviceCache.clear(); _roomCache.clear(); + _portToSwitchIndex.clear(); _speedTestConfigCache.clear(); _speedTestResultCache.clear(); @@ -1653,6 +1857,7 @@ class WebSocketCacheIntegration { // Clear device, room, and speed test caches _deviceCache.clear(); _roomCache.clear(); + _portToSwitchIndex.clear(); _speedTestConfigCache.clear(); _speedTestResultCache.clear(); @@ -1697,6 +1902,7 @@ class WebSocketCacheIntegration { _speedTestResultCallbacks.clear(); _deviceCache.clear(); _roomCache.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 64ab5e9..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'; @@ -83,12 +84,27 @@ 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; 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 +135,7 @@ class WebSocketDataSyncService { Future dispose() async { await stop(); + _deviceCacheDebounce?.cancel(); await _eventController.close(); _apLocalDataSource.dispose(); _ontLocalDataSource.dispose(); @@ -206,6 +223,7 @@ class WebSocketDataSyncService { ..sendSubscribe(resource) ..sendSnapshotRequest(resource); } + } void _handleMessage(SocketMessage message) { @@ -244,7 +262,9 @@ class WebSocketDataSyncService { _handleRoomSnapshot(snapshotItems, resourceType: resourceType); _pendingSnapshots.remove(resourceType); _markSnapshotHandled(); + return; } + } String? _resolveResourceType(SocketMessage message) { @@ -287,6 +307,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, @@ -393,9 +490,79 @@ 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 && ports.isNotEmpty) { + switchesWithPorts++; + for (final port in ports) { + if (port is! Map) continue; + final portId = _parseIntId(port['id']); + if (portId != null) _portToSwitchIndex[portId] = swRawId; + } + } + } + + // 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) { + _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; @@ -403,6 +570,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'); @@ -436,13 +615,48 @@ 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( 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/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/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/device_registration_service.dart b/lib/features/scanner/data/services/device_registration_service.dart index 1f21f7e..64f1fb0 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,40 @@ 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); LoggerService.info( - 'Registering ${deviceType.displayName} via WebSocket', + 'Registering ${deviceType.displayName} via ActionCable ' + '(update_resource on $resourceType, deviceId=$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: 'update_resource', + resourceType: resourceType, + additionalData: { + if (existingDeviceId != null) '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..ebe7427 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; @@ -135,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', @@ -151,30 +239,23 @@ 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; - } - - // AP-SPECIFIC: Only accept approved prefixes (strict) + // 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 (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); + // 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; } } - // STRICT validation: Both required for AP final isComplete = mac.isNotEmpty && hasAPSerial; if (!isComplete) { @@ -191,13 +272,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', @@ -207,47 +288,45 @@ class ScannerValidationService { String mac = ''; String serialNumber = ''; String model = ''; - bool hasLLSerial = false; + bool hasSwitchSerial = false; for (String barcode in barcodes) { if (barcode.isEmpty) continue; String value = barcode.trim(); - // Check for MAC address first (12 hex chars) + // 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; + LoggerService.debug('Found Switch 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; } - // SWITCH-SPECIFIC: Only accept LL serials (14+ chars) - 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; - } - } - // 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, ); } @@ -260,7 +339,7 @@ class ScannerValidationService { detectedType: DeviceTypeFromSerial.switchDevice, missingFields: [ if (mac.isEmpty) 'MAC Address', - if (!hasLLSerial) 'Serial Number (LL)', + if (!hasSwitchSerial) 'Serial Number', ], ); } @@ -281,21 +360,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/domain/entities/scanner_state.dart b/lib/features/scanner/domain/entities/scanner_state.dart index ee14ce6..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 serials). + /// Access Point (1K9/1M3/1HN/EC2 serials). accessPoint, /// ONT device (ALCL serials). ont, - /// Network Switch (LL/EC serials). + /// Network Switch (LL/EC2 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/EC2)'); } case ScanMode.switchDevice: if (serialNumber.isEmpty || !hasValidSerial) { - missing.add('Serial Number (LL/EC)'); + 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)']; + return ['MAC Address', 'Serial Number (1K9/1M3/1HN/EC2)']; case ScanMode.switchDevice: - return ['MAC Address', 'Serial Number (LL/EC)']; + 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 49aadc0..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']; + // 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 (LL for legacy, EC for Edge-core) - static const List switchPrefixes = ['LL', 'EC']; + // 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 or EC and are at least 12 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 >= 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; } diff --git a/lib/features/scanner/presentation/providers/device_registration_provider.dart b/lib/features/scanner/presentation/providers/device_registration_provider.dart index 0627f72..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) { @@ -186,7 +192,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 +206,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 +312,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 +332,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 +371,6 @@ class DeviceRegistrationNotifier extends _$DeviceRegistrationNotifier { ); if (matchingDevices.isEmpty) { - // No existing device found state = state.copyWith( status: RegistrationStatus.idle, matchStatus: DeviceMatchStatus.noMatch, @@ -346,138 +378,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 +419,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, @@ -509,10 +537,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, @@ -520,20 +552,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, @@ -543,33 +564,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, @@ -596,6 +623,36 @@ 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. + DeviceTypeFromSerial _deviceTypeToSerialType(DeviceType type) { + switch (type) { + case DeviceType.accessPoint: + return DeviceTypeFromSerial.accessPoint; + case DeviceType.ont: + return DeviceTypeFromSerial.ont; + case DeviceType.switchDevice: + return DeviceTypeFromSerial.switchDevice; + } } } 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..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'cd4ecef75b04489157cd8594d31fe47c6c60af40'; + 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.dart b/lib/features/scanner/presentation/providers/scanner_notifier_v2.dart index d6e2350..6171178 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, @@ -63,13 +115,30 @@ class ScannerNotifierV2 extends _$ScannerNotifierV2 { return; } + // EC2 serials are always manually placed (user selects AP or Switch mode) + if (SerialPatterns.isAmbiguousEC2Serial(value)) { + _processManualModeEC2Serial(value); + return; + } + // 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; } - // Check for part number pattern (ONT) + // Check for part number pattern if (_isPartNumber(value)) { _processPartNumber(value); return; @@ -78,6 +147,30 @@ class ScannerNotifierV2 extends _$ScannerNotifierV2 { LoggerService.debug('Barcode did not match any known pattern: $value', tag: _tag); } + /// 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( + '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,7 +246,7 @@ class ScannerNotifierV2 extends _$ScannerNotifierV2 { _checkCompletion(); } - /// Process a part number barcode (for ONT). + /// Process a part number barcode. void _processPartNumber(String partNumber) { LoggerService.debug('Processing part number: $partNumber', tag: _tag); @@ -202,9 +295,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, ); } @@ -241,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, @@ -256,7 +355,6 @@ class ScannerNotifierV2 extends _$ScannerNotifierV2 { isAutoLocked: false, wasAutoReverted: false, lastSerialSeenAt: null, - scanMode: ScanMode.auto, ); } @@ -268,6 +366,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 19a6dd3..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'92c4b30d37c23353d925e36b46402942e4025eb0'; +String _$scannerNotifierV2Hash() => r'f3852a562b3bb14760972c39117f979461dccbab'; /// 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 c890d57..d1cebfa 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; @@ -72,19 +69,9 @@ class _ScannerScreenV2State extends ConsumerState formats: const [ BarcodeFormat.qrCode, BarcodeFormat.code128, - BarcodeFormat.code39, - BarcodeFormat.dataMatrix, ], ); - // 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 +80,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(); @@ -208,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(); } } @@ -277,11 +274,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 +982,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/EC2 serial)'; case ScanMode.ont: return 'Scan ONT (ALCL serial + part number)'; case ScanMode.switchDevice: - return 'Scan Switch (LL/EC 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..7258a22 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, ), ), ], @@ -949,8 +954,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(); } @@ -967,9 +971,18 @@ class _ScannerRegistrationPopupState if (isExisting) { existingDeviceId = scannerState.matchedDeviceId; } else if (!_createNewDevice && _selectedDevice != null) { - existingDeviceId = int.tryParse(_selectedDevice!.id); + existingDeviceId = _parseDeviceId(_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) @@ -1040,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: @@ -1309,3 +1331,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, + ), + ), + ), + ], + ), + ); + }, + ), + ); + } +}