import 'dart:convert'; import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:latlong2/latlong.dart'; import '../models/contact.dart'; import '../models/contact_group.dart'; import '../models/message_contact_location.dart'; import '../services/cayenne_lpp_parser.dart'; import '../services/contact_storage_service.dart'; import '../services/profiles_feature_service.dart'; import '../utils/fast_gps_packet.dart'; import '../utils/rssi_location_estimator.dart'; import '../utils/key_comparison.dart'; class PendingAdvert { final Uint8List publicKey; final DateTime receivedAt; final String? advName; final int? typeValue; final int? flags; final int? lastAdvert; final int? advLat; final int? advLon; final int? signedEncodedPathLen; final Uint8List? paddedPathBytes; final int? rxRssiDbm; final int? rxSnrRaw; final int? repeaterBatteryMv; final int? repeaterQueueLen; final int? repeaterLastRssi; final int? repeaterLastSnrRaw; final int? repeaterUptimeSecs; const PendingAdvert({ required this.publicKey, required this.receivedAt, this.advName, this.typeValue, this.flags, this.lastAdvert, this.advLat, this.advLon, this.signedEncodedPathLen, this.paddedPathBytes, this.rxRssiDbm, this.rxSnrRaw, this.repeaterBatteryMv, this.repeaterQueueLen, this.repeaterLastRssi, this.repeaterLastSnrRaw, this.repeaterUptimeSecs, }); String get publicKeyHex => publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); String get shortDisplayKey { final prefix = publicKey.length >= 6 ? publicKey.sublist(0, 6) : publicKey; return prefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':'); } PendingAdvert copyWith({ Uint8List? publicKey, DateTime? receivedAt, String? advName, int? typeValue, int? flags, int? lastAdvert, int? advLat, int? advLon, int? signedEncodedPathLen, Uint8List? paddedPathBytes, int? rxRssiDbm, int? rxSnrRaw, int? repeaterBatteryMv, int? repeaterQueueLen, int? repeaterLastRssi, int? repeaterLastSnrRaw, int? repeaterUptimeSecs, }) { return PendingAdvert( publicKey: publicKey ?? this.publicKey, receivedAt: receivedAt ?? this.receivedAt, advName: advName ?? this.advName, typeValue: typeValue ?? this.typeValue, flags: flags ?? this.flags, lastAdvert: lastAdvert ?? this.lastAdvert, advLat: advLat ?? this.advLat, advLon: advLon ?? this.advLon, signedEncodedPathLen: signedEncodedPathLen ?? this.signedEncodedPathLen, paddedPathBytes: paddedPathBytes ?? this.paddedPathBytes, rxRssiDbm: rxRssiDbm ?? this.rxRssiDbm, rxSnrRaw: rxSnrRaw ?? this.rxSnrRaw, repeaterBatteryMv: repeaterBatteryMv ?? this.repeaterBatteryMv, repeaterQueueLen: repeaterQueueLen ?? this.repeaterQueueLen, repeaterLastRssi: repeaterLastRssi ?? this.repeaterLastRssi, repeaterLastSnrRaw: repeaterLastSnrRaw ?? this.repeaterLastSnrRaw, repeaterUptimeSecs: repeaterUptimeSecs ?? this.repeaterUptimeSecs, ); } double? get repeaterBatteryPercent { if (repeaterBatteryMv == null) return null; final voltage = repeaterBatteryMv! / 1000.0; if (voltage <= 3.0) return 0.0; if (voltage >= 4.2) return 100.0; return ((voltage - 3.0) / 1.2) * 100.0; } double? get repeaterLastSnr => repeaterLastSnrRaw == null ? null : repeaterLastSnrRaw! / 4.0; double? get rxSnr => rxSnrRaw == null ? null : rxSnrRaw! / 4.0; } class _RetainedRoute { final int signedEncodedPathLen; final Uint8List paddedPathBytes; const _RetainedRoute({ required this.signedEncodedPathLen, required this.paddedPathBytes, }); } /// Contacts Provider - manages contact list and telemetry class ContactsProvider with ChangeNotifier { static const double _firstHopFallbackOffsetMeters = 100.0; static const String autoGroupIdPrefix = 'auto_group_'; final Map _contacts = {}; final List _savedContactGroups = []; final Map _pendingAdverts = {}; final Map _estimatedLocations = {}; final Map> _rssiObservations = {}; final ContactStorageService _storageService = ContactStorageService(); bool _isInitialized = false; bool _isPersisting = false; bool _persistRequested = false; bool _isPersistingPendingAdverts = false; bool _persistPendingAdvertsRequested = false; String? _storageNamespace; // Add default public channel on initialization ContactsProvider() : _storageNamespace = ProfileStorageScope.effectiveNamespace { _ensurePublicChannelExists(); } bool get isInitialized => _isInitialized; String? get storageNamespace => _storageNamespace; /// Initialize and load persisted contacts at app startup /// This loads contacts without filtering, allowing offline viewing Future initializeEarly() async { if (_isInitialized) return; await _loadFromStorage(); } Future reloadFromStorage({ String? namespace, Uint8List? devicePublicKey, }) async { _storageNamespace = namespace; await _loadFromStorage(force: true, devicePublicKey: devicePublicKey); } Future persistNow() async { await _storageService.saveContacts( _contactsForStorage(), namespace: _storageNamespace, ); await _storageService.saveContactGroups( _savedContactGroups, namespace: _storageNamespace, ); await _storageService.savePendingAdverts( _pendingAdverts.values.map(_pendingAdvertToJson).toList(), namespace: _storageNamespace, ); } Future cloneCurrentStorageTo(String? namespace) async { await _storageService.saveContacts( _contactsForStorage(), namespace: namespace, ); await _storageService.saveContactGroups( _savedContactGroups, namespace: namespace, ); await _storageService.savePendingAdverts( _pendingAdverts.values.map(_pendingAdvertToJson).toList(), namespace: namespace, ); } Future _loadFromStorage({ bool force = false, Uint8List? devicePublicKey, }) async { if (_isInitialized && !force) return; try { _resetInMemoryState(); debugPrint( 'đŸ“Ļ [ContactsProvider] Early loading persisted contacts (no filtering)...', ); final storedContacts = await _storageService.loadContacts( namespace: _storageNamespace, ); final storedGroups = await _storageService.loadContactGroups( namespace: _storageNamespace, ); final storedPendingAdverts = await _storageService.loadPendingAdverts( namespace: _storageNamespace, ); // Add stored contacts (excluding any with all-zeros public key) const publicChannelKey = '0000000000000000000000000000000000000000000000000000000000000000'; for (final contact in storedContacts) { // Skip any contacts with all-zeros public key (shouldn't happen, but safety check) if (contact.publicKeyHex == publicChannelKey) { continue; } _contacts[contact.publicKeyHex] = contact; } _isInitialized = true; _savedContactGroups ..clear() ..addAll(storedGroups); _restorePendingAdverts( storedPendingAdverts, devicePublicKey: devicePublicKey, ); debugPrint( '✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups', ); // Ensure public channel exists after loading _ensurePublicChannelExists(); notifyListeners(); } catch (e) { debugPrint('❌ [ContactsProvider] Error in early initialization: $e'); _isInitialized = true; // Mark as initialized even on error _ensurePublicChannelExists(); } } /// Initialize and load persisted contacts /// [devicePublicKey] - device's own public key to exclude from loaded contacts Future initialize({Uint8List? devicePublicKey}) async { if (_isInitialized) { // If already initialized (from early load), just filter out self-contact if (devicePublicKey != null) { _removeSelfContact(devicePublicKey); } return; } await _loadFromStorage(force: true, devicePublicKey: devicePublicKey); if (devicePublicKey != null) { _removeSelfContact(devicePublicKey); } } /// Remove self-contact from loaded contacts (called after BLE connection established) void _removeSelfContact(Uint8List devicePublicKey) { final selfKeyHex = devicePublicKey .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(''); if (_contacts.containsKey(selfKeyHex)) { final selfContact = _contacts[selfKeyHex]!; debugPrint( 'đŸ—‘ī¸ [ContactsProvider] Removing self-contact: ${selfContact.advName}', ); _contacts.remove(selfKeyHex); _persistContacts(); notifyListeners(); } } /// Ensure public channel always exists in the list void _ensurePublicChannelExists() { // Public channel has all-zeros public key (32 bytes = 64 hex chars) const publicChannelKey = '0000000000000000000000000000000000000000000000000000000000000000'; if (!_contacts.containsKey(publicChannelKey)) { // Create a pseudo-contact for the public channel (ephemeral broadcast) _contacts[publicChannelKey] = Contact( publicKey: Uint8List.fromList( List.filled(32, 0), ), // Zero key for public type: ContactType.channel, // Channel type (not room!) flags: 0, outPathLen: 0, outPath: Uint8List(64), advName: 'Public Channel', lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, advLat: 0, advLon: 0, lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, ); } } /// Persist contacts to storage (async, non-blocking, coalescing). Future _persistContacts() async { _persistRequested = true; if (_isPersisting) return; _isPersisting = true; try { while (_persistRequested) { _persistRequested = false; await _storageService.saveContacts( _contactsForStorage(), namespace: _storageNamespace, ); } } catch (e) { debugPrint('❌ [ContactsProvider] Error persisting contacts: $e'); } finally { _isPersisting = false; } } Future _persistPendingAdverts() async { _persistPendingAdvertsRequested = true; if (_isPersistingPendingAdverts) return; _isPersistingPendingAdverts = true; try { while (_persistPendingAdvertsRequested) { _persistPendingAdvertsRequested = false; await _storageService.savePendingAdverts( _pendingAdverts.values.map(_pendingAdvertToJson).toList(), namespace: _storageNamespace, ); } } catch (e) { debugPrint('❌ [ContactsProvider] Error persisting pending adverts: $e'); } finally { _isPersistingPendingAdverts = false; } } List get contacts => _contacts.values.toList(); List get favouriteContacts => _contacts.values.where((c) => c.isFavourite).toList(); List get savedContactGroups => List.from(_savedContactGroups) ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); List get pendingAdverts => _pendingAdverts.values.toList() ..sort((a, b) => b.receivedAt.compareTo(a.receivedAt)); PendingAdvert? pendingAdvertByKey(Uint8List publicKey) { final keyHex = publicKey .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(''); return _pendingAdverts[keyHex]; } bool shouldEnrichPendingAdvert(Uint8List publicKey) { final advert = pendingAdvertByKey(publicKey); if (advert == null) { return false; } final hasName = advert.advName?.trim().isNotEmpty ?? false; final hasType = advert.typeValue != null && advert.typeValue != 0; final hasLocation = advert.advLat != null && advert.advLon != null && (advert.advLat != 0 || advert.advLon != 0); return !(hasName && hasType && hasLocation); } List savedGroupsForSection(String sectionKey) { return savedContactGroups .where((group) => group.sectionKey == sectionKey) .toList(); } bool hasSavedGroupForFilter(String sectionKey, String query) { final normalizedQuery = _normalizeGroupQuery(query); if (normalizedQuery.isEmpty) { return false; } return _savedContactGroups.any( (group) => group.sectionKey == sectionKey && _normalizeGroupQuery(group.query) == normalizedQuery, ); } Future addSavedGroupForFilter( String sectionKey, String query, { String? label, List? matchPrefixes, bool isAutoGroup = false, }) async { final normalizedQuery = _normalizeGroupQuery(query); if (normalizedQuery.isEmpty || hasSavedGroupForFilter(sectionKey, normalizedQuery)) { return; } _savedContactGroups.add( SavedContactGroup( id: '${sectionKey}_${DateTime.now().microsecondsSinceEpoch}', sectionKey: sectionKey, label: (label ?? query).trim(), query: query.trim(), createdAt: DateTime.now(), matchPrefixes: matchPrefixes, isAutoGroup: isAutoGroup, ), ); await _persistSavedGroups(); notifyListeners(); } Future removeSavedGroupById(String id) async { final beforeCount = _savedContactGroups.length; _savedContactGroups.removeWhere((group) => group.id == id); if (_savedContactGroups.length == beforeCount) { return; } await _persistSavedGroups(); notifyListeners(); } Future removeSavedGroupForFilter( String sectionKey, String query, ) async { final normalizedQuery = _normalizeGroupQuery(query); final beforeCount = _savedContactGroups.length; _savedContactGroups.removeWhere( (group) => group.sectionKey == sectionKey && _normalizeGroupQuery(group.query) == normalizedQuery, ); if (_savedContactGroups.length == beforeCount) { return; } await _persistSavedGroups(); notifyListeners(); } Future replaceAutoGroupsForSection( String sectionKey, List groups, ) async { _savedContactGroups.removeWhere( (group) => group.sectionKey == sectionKey && group.isAutoGroup, ); _savedContactGroups.addAll(groups); await _persistSavedGroups(); notifyListeners(); } Future _persistSavedGroups() async { try { await _storageService.saveContactGroups( _savedContactGroups, namespace: _storageNamespace, ); } catch (e) { debugPrint('❌ [ContactsProvider] Error persisting contact groups: $e'); } } String _normalizeGroupQuery(String query) => query.trim().toLowerCase(); List get chatContacts => contacts.where((c) => c.isChat).toList()..sort(_sortByLastSeen); List get repeaters => contacts.where((c) => c.isRepeater).toList()..sort(_sortByLastSeen); List get sensorContacts => contacts.where((c) => c.isSensor).toList()..sort(_sortByLastSeen); List get rooms => contacts.where((c) => c.isRoom).toList()..sort(_sortByLastSeen); List get channels { // Always ensure public channel exists when getting channels _ensurePublicChannelExists(); return contacts.where((c) => c.isChannel).toList()..sort(_sortByLastSeen); } /// Get both rooms and channels (destinations for SAR markers) List get roomsAndChannels { _ensurePublicChannelExists(); return contacts.where((c) => c.isRoom || c.isChannel).toList() ..sort(_sortByLastSeen); } /// All estimated locations (for passing to map marker service). Map get estimatedLocations => Map.unmodifiable(_estimatedLocations); /// Get estimated location for a contact (RSSI-based, near last-hop repeater). LatLng? estimatedLocationFor(String publicKeyHex) => _estimatedLocations[publicKeyHex]; /// Set an estimated location for a contact that has no GPS. void setEstimatedLocation(String publicKeyHex, LatLng location) { _estimatedLocations[publicKeyHex] = location; notifyListeners(); } /// Record an RSSI observation and re-trilaterate the contact's position. /// /// Keeps up to 5 observations per unique repeater (latest wins). /// With multiple repeaters, trilateration produces a better estimate. void addRssiObservation({ required String contactPublicKeyHex, required List contactPublicKey, required RssiObservation observation, }) { final observations = _rssiObservations.putIfAbsent( contactPublicKeyHex, () => [], ); // Replace existing observation from the same repeater, or add new final repeaterKey = '${observation.repeaterLocation.latitude},${observation.repeaterLocation.longitude}'; observations.removeWhere((o) => '${o.repeaterLocation.latitude},${o.repeaterLocation.longitude}' == repeaterKey); observations.add(observation); // Keep at most 8 observations (most recent per repeater) if (observations.length > 8) { observations.sort((a, b) => b.observedAt.compareTo(a.observedAt)); observations.removeRange(8, observations.length); } final estimated = RssiLocationEstimator.trilaterate( observations: observations, contactPublicKey: contactPublicKey, ); if (estimated != null) { _estimatedLocations[contactPublicKeyHex] = estimated; notifyListeners(); } } /// Effective location: own GPS/advert > estimated from RSSI. LatLng? effectiveLocationFor(Contact contact) { return contact.displayLocation ?? _estimatedLocations[contact.publicKeyHex]; } /// Get contacts with location (for map display) /// Includes contacts with estimated locations from RSSI. List get contactsWithLocation => contacts.where((c) => effectiveLocationFor(c) != null).toList(); /// Get chat contacts with location (team members on map) List get chatContactsWithLocation => chatContacts.where((c) => effectiveLocationFor(c) != null).toList(); MessageContactLocation? buildMessageContactLocationSnapshot( Contact contact, { DateTime? capturedAt, }) { final snapshotTime = capturedAt ?? DateTime.now(); final telemetryGps = _getValidGpsOrNull(contact.telemetry?.gpsLocation); final telemetryTimestamp = contact.telemetry?.timestamp; AdvertLocation? advertLocation; for (final point in contact.advertHistory) { if (!point.timestamp.isAfter(snapshotTime)) { advertLocation = point; break; } } advertLocation ??= contact.advertHistory.isNotEmpty ? contact.advertHistory.first : null; if (telemetryGps != null) { final shouldUseTelemetry = telemetryTimestamp == null || advertLocation == null || !telemetryTimestamp.isBefore(advertLocation.timestamp); if (shouldUseTelemetry) { return MessageContactLocation( location: telemetryGps, source: 'telemetry', capturedAt: snapshotTime, sourceTimestamp: telemetryTimestamp, ); } } if (advertLocation != null) { return MessageContactLocation( location: advertLocation.location, source: 'advert', capturedAt: snapshotTime, sourceTimestamp: advertLocation.timestamp, ); } return null; } /// Sort contacts by last seen (most recent first) int _sortByLastSeen(Contact a, Contact b) { return b.lastSeenTime.compareTo(a.lastSeenTime); } /// Add or update a contact /// Excludes contacts that match the device's own public key void addOrUpdateContact(Contact contact, {Uint8List? devicePublicKey}) { debugPrint( '📝 [ContactsProvider] addOrUpdateContact called: ${contact.advName} (type: ${contact.type.displayName}, key: ${contact.publicKeyHex.substring(0, 8)}...)', ); // Don't add contacts that match our device's public key if (devicePublicKey != null && contact.publicKey.matches(devicePublicKey)) { debugPrint( 'â„šī¸ [ContactsProvider] Ignoring contact with device\'s own public key: ${contact.advName}', ); return; } // Check if this is a new contact final existingContact = _contacts[contact.publicKeyHex]; final isNewContact = existingContact == null; debugPrint( ' isNew: $isNewContact, total contacts before: ${_contacts.length}', ); final updatedContact = _mergeIncomingContact( incomingContact: contact, existingContact: existingContact, ); _contacts[contact.publicKeyHex] = updatedContact; // Keep repeaters and sensors in pending adverts so they remain visible // in the discovery list (with a checkmark). Remove others. if (contact.type != ContactType.repeater && contact.type != ContactType.sensor) { _pendingAdverts.remove(contact.publicKeyHex); } debugPrint( ' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}', ); _persistContacts(); _persistPendingAdverts(); notifyListeners(); debugPrint(' 🔔 notifyListeners() called'); } /// Add multiple contacts /// Excludes contacts that match the device's own public key void addContacts(List contacts, {Uint8List? devicePublicKey}) { int excluded = 0; for (final contact in contacts) { // Don't add contacts that match our device's public key if (devicePublicKey != null && contact.publicKey.matches(devicePublicKey)) { debugPrint( 'â„šī¸ [ContactsProvider] Ignoring contact with device\'s own public key: ${contact.advName}', ); excluded++; continue; } final existingContact = _contacts[contact.publicKeyHex]; _contacts[contact.publicKeyHex] = _mergeIncomingContact( incomingContact: contact, existingContact: existingContact, ); if (contact.type != ContactType.repeater && contact.type != ContactType.sensor) { _pendingAdverts.remove(contact.publicKeyHex); } } if (excluded > 0) { debugPrint( 'â„šī¸ [ContactsProvider] Excluded $excluded contact(s) matching device public key', ); } _persistContacts(); _persistPendingAdverts(); notifyListeners(); } Contact _mergeIncomingContact({ required Contact incomingContact, Contact? existingContact, }) { final retainedRoute = _retainedRouteForContact( keyHex: incomingContact.publicKeyHex, incomingContact: incomingContact, existingContact: existingContact, ); final mergedTelemetry = _mergeTelemetryForContact( existingTelemetry: existingContact?.telemetry, incomingTelemetry: incomingContact.telemetry, ); final inferredFallbackLocation = _inferFirstHopFallbackLocation( incomingContact: incomingContact, existingContact: existingContact, retainedRoute: retainedRoute, mergedTelemetry: mergedTelemetry, ); final inferredFallbackAdvLat = inferredFallbackLocation != null ? _coordinateToAdvertMicrodegrees(inferredFallbackLocation.latitude) : null; final inferredFallbackAdvLon = inferredFallbackLocation != null ? _coordinateToAdvertMicrodegrees(inferredFallbackLocation.longitude) : null; if (existingContact == null) { var newContact = incomingContact.copyWith( isNew: true, telemetry: mergedTelemetry, outPathLen: retainedRoute?.signedEncodedPathLen ?? incomingContact.outPathLen, outPath: retainedRoute?.paddedPathBytes ?? incomingContact.outPath, advLat: incomingContact.advertLocation != null ? incomingContact.advLat : inferredFallbackAdvLat ?? incomingContact.advLat, advLon: incomingContact.advertLocation != null ? incomingContact.advLon : inferredFallbackAdvLon ?? incomingContact.advLon, ); if (incomingContact.advertLocation != null) { final timestamp = DateTime.fromMillisecondsSinceEpoch( incomingContact.lastAdvert * 1000, ); newContact = newContact.addAdvertLocation( incomingContact.advertLocation!, timestamp, ); } return newContact; } final incomingAdvertLocation = incomingContact.advertLocation; final existingAdvertLocation = existingContact.advertLocation; var updatedContact = incomingContact.copyWith( isNew: false, nameOverride: existingContact.nameOverride, advertHistory: existingContact.advertHistory, telemetry: mergedTelemetry, outPathLen: retainedRoute?.signedEncodedPathLen ?? incomingContact.outPathLen, outPath: retainedRoute?.paddedPathBytes ?? incomingContact.outPath, advLat: incomingAdvertLocation != null ? incomingContact.advLat : existingAdvertLocation != null ? existingContact.advLat : inferredFallbackAdvLat ?? incomingContact.advLat, advLon: incomingAdvertLocation != null ? incomingContact.advLon : existingAdvertLocation != null ? existingContact.advLon : inferredFallbackAdvLon ?? incomingContact.advLon, ); if (incomingAdvertLocation != null) { final timestamp = DateTime.fromMillisecondsSinceEpoch( incomingContact.lastAdvert * 1000, ); updatedContact = updatedContact.addAdvertLocation( incomingAdvertLocation, timestamp, ); } return updatedContact; } _RetainedRoute? _retainedRouteForContact({ required String keyHex, required Contact incomingContact, required Contact? existingContact, }) { if (incomingContact.routeHasPath) { return null; } final pendingAdvert = _pendingAdverts[keyHex]; final pendingPathBytes = pendingAdvert?.paddedPathBytes; final pendingPathLen = pendingAdvert?.signedEncodedPathLen; if (pendingPathLen != null && pendingPathBytes != null && pendingPathBytes.isNotEmpty) { return _RetainedRoute( signedEncodedPathLen: pendingPathLen, paddedPathBytes: Uint8List.fromList(pendingPathBytes), ); } if (existingContact != null && existingContact.routeHasPath) { return _RetainedRoute( signedEncodedPathLen: existingContact.outPathLen, paddedPathBytes: Uint8List.fromList(existingContact.outPath), ); } return null; } LatLng? _inferFirstHopFallbackLocation({ required Contact incomingContact, required Contact? existingContact, required _RetainedRoute? retainedRoute, required ContactTelemetry? mergedTelemetry, }) { if (_getValidGpsOrNull(mergedTelemetry?.gpsLocation) != null) { return null; } if (incomingContact.advertLocation != null || existingContact?.advertLocation != null) { return null; } final routeBytes = retainedRoute?.paddedPathBytes ?? (incomingContact.routeHasPath ? incomingContact.routePathBytes : existingContact?.routeHasPath == true ? existingContact!.routePathBytes : null); final routeHashSize = retainedRoute != null ? ((ContactRouteCodec.toUnsignedDescriptor( retainedRoute.signedEncodedPathLen, ) >> 6) + 1) : incomingContact.routeHasPath ? incomingContact.routeHashSize : existingContact?.routeHasPath == true ? existingContact!.routeHashSize : 0; if (routeBytes == null || routeHashSize <= 0 || routeBytes.length < routeHashSize) { return null; } final lastHopHex = routeBytes .sublist(routeBytes.length - routeHashSize) .map((byte) => byte.toRadixString(16).padLeft(2, '0')) .join(); final repeaterCandidates = _contacts.values.where((candidate) { if (!candidate.isRepeater || candidate.publicKeyHex == incomingContact.publicKeyHex) { return false; } final location = candidate.displayLocation; return location != null && candidate.publicKeyHex.startsWith(lastHopHex); }).toList()..sort((a, b) => b.lastAdvert.compareTo(a.lastAdvert)); if (repeaterCandidates.isEmpty) { return null; } final repeaterLocation = repeaterCandidates.first.displayLocation; if (repeaterLocation == null) { return null; } final bearingDegrees = _stableFallbackBearingDegrees(incomingContact); return _offsetFromLocation( repeaterLocation, distanceMeters: _firstHopFallbackOffsetMeters, bearingDegrees: bearingDegrees, ); } double _stableFallbackBearingDegrees(Contact contact) { if (contact.publicKey.length < 2) { return 90.0; } final seed = (contact.publicKey[0] << 8) | contact.publicKey[1]; return (seed % 360).toDouble(); } LatLng _offsetFromLocation( LatLng origin, { required double distanceMeters, required double bearingDegrees, }) { const earthRadiusMeters = 6371000.0; final angularDistance = distanceMeters / earthRadiusMeters; final bearingRadians = bearingDegrees * 3.1415926535897932 / 180.0; final lat1 = origin.latitude * 3.1415926535897932 / 180.0; final lon1 = origin.longitude * 3.1415926535897932 / 180.0; final sinLat1 = sin(lat1); final cosLat1 = cos(lat1); final sinAngularDistance = sin(angularDistance); final cosAngularDistance = cos(angularDistance); final lat2 = asin( sinLat1 * cosAngularDistance + cosLat1 * sinAngularDistance * cos(bearingRadians), ); final lon2 = lon1 + atan2( sin(bearingRadians) * sinAngularDistance * cosLat1, cosAngularDistance - sinLat1 * sin(lat2), ); return LatLng( lat2 * 180.0 / 3.1415926535897932, ((lon2 * 180.0 / 3.1415926535897932 + 540.0) % 360.0) - 180.0, ); } /// Update contact telemetry // Dedup: track last telemetry data hash per contact to avoid processing // the same payload twice (0x8B + 0x8C can both fire for the same data). final Map _lastTelemetryHash = {}; void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) { debugPrint('📊 [ContactsProvider] updateTelemetry() called'); debugPrint( ' Public key prefix (hex): ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}', ); debugPrint(' LPP data size: ${lppData.length} bytes'); // Deduplicate: same data for same contact within 2 seconds = skip final prefixHex = publicKeyPrefix .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(); final dataHash = lppData.fold(0, (h, b) => h * 31 + b); final now = DateTime.now(); final last = _lastTelemetryHash[prefixHex]; if (last != null && last.$1 == dataHash && now.difference(last.$2).inSeconds < 2) { debugPrint(' â­ī¸ Duplicate telemetry payload, skipping'); return; } _lastTelemetryHash[prefixHex] = (dataHash, now); // Find contact by public key prefix final contact = _findContactByPrefix(publicKeyPrefix); if (contact == null) { debugPrint(' ❌ Contact not found for this prefix'); return; } debugPrint(' ✅ Found contact: ${contact.advName}'); debugPrint(' Old telemetry timestamp: ${contact.telemetry?.timestamp}'); try { // Parse Cayenne LPP data var telemetry = CayenneLppParser.parse(lppData); debugPrint(' ✅ Parsed new telemetry'); debugPrint(' New telemetry timestamp: ${telemetry.timestamp}'); final incomingGps = telemetry.gpsLocation; if (_isInvalidTelemetryGps(incomingGps)) { // Always sanitize invalid placeholder GPS to null first. telemetry = ContactTelemetry( gpsLocation: null, batteryPercentage: telemetry.batteryPercentage, batteryMilliVolts: telemetry.batteryMilliVolts, temperature: telemetry.temperature, timestamp: telemetry.timestamp, humidity: telemetry.humidity, pressure: telemetry.pressure, extraSensorData: telemetry.extraSensorData, ); } final previousTelemetry = contact.telemetry; final mergedTelemetry = _mergeTelemetryForContact( existingTelemetry: previousTelemetry, incomingTelemetry: telemetry, ); if (mergedTelemetry != null) { if (_shouldRetainLastValidGps( previousTelemetry, telemetry.gpsLocation, )) { debugPrint( ' âš ī¸ Incoming telemetry GPS is invalid/missing; keeping last known contact location as fallback: $incomingGps', ); } telemetry = ContactTelemetry( gpsLocation: telemetry.gpsLocation, batteryPercentage: mergedTelemetry.batteryPercentage, batteryMilliVolts: mergedTelemetry.batteryMilliVolts, temperature: mergedTelemetry.temperature, timestamp: mergedTelemetry.timestamp, humidity: mergedTelemetry.humidity, pressure: mergedTelemetry.pressure, extraSensorData: telemetry.extraSensorData, ); } // Update contact with new telemetry AND last seen time // lastAdvert is Unix timestamp in seconds final currentTimestamp = (DateTime.now().millisecondsSinceEpoch / 1000) .round(); debugPrint(' Old lastAdvert: ${contact.lastAdvert}'); debugPrint(' New lastAdvert: $currentTimestamp'); final persistedGps = _getValidGpsOrNull(telemetry.gpsLocation); final updatedContact = contact.copyWith( telemetry: telemetry, lastAdvert: currentTimestamp, // Update last seen time advLat: persistedGps != null ? _coordinateToAdvertMicrodegrees(persistedGps.latitude) : contact.advLat, advLon: persistedGps != null ? _coordinateToAdvertMicrodegrees(persistedGps.longitude) : contact.advLon, ); _contacts[contact.publicKeyHex] = updatedContact; debugPrint(' ✅ Updated contact in map (with new lastAdvert)'); _persistContacts(); debugPrint(' ✅ Persisted contacts to storage'); notifyListeners(); debugPrint(' ✅ Notified listeners - UI should update'); } catch (e) { debugPrint(' ❌ Failed to parse telemetry: $e'); debugPrint('Failed to parse telemetry: $e'); } } void updateFastGps(Uint8List publicKeyPrefix, FastGpsPacket packet) { final contact = _findContactByPrefix(publicKeyPrefix); if (contact == null) { debugPrint( 'âš ī¸ [ContactsProvider] Fast GPS sender not found: ${packet.senderKey6}', ); return; } final updatedTelemetry = _mergeTelemetryForContact( existingTelemetry: contact.telemetry, incomingTelemetry: ContactTelemetry( gpsLocation: LatLng(packet.latitude, packet.longitude), batteryPercentage: null, batteryMilliVolts: null, temperature: null, timestamp: DateTime.fromMillisecondsSinceEpoch( packet.timestampSeconds * 1000, ), humidity: null, pressure: null, extraSensorData: null, ), ); final updatedContact = contact.copyWith( telemetry: updatedTelemetry, lastAdvert: packet.timestampSeconds, advLat: _coordinateToAdvertMicrodegrees(packet.latitude), advLon: _coordinateToAdvertMicrodegrees(packet.longitude), ); _contacts[contact.publicKeyHex] = updatedContact; _persistContacts(); notifyListeners(); } bool _isInvalidTelemetryGps(LatLng? location) { if (location == null) return false; final lat = location.latitude; final lon = location.longitude; if (!lat.isFinite || !lon.isFinite) return true; // Many devices report "0000" placeholder GPS as (0.0, 0.0). const epsilon = 1e-7; return lat.abs() < epsilon && lon.abs() < epsilon; } LatLng? _getValidGpsOrNull(LatLng? location) { if (location == null || _isInvalidTelemetryGps(location)) { return null; } return location; } bool _shouldRetainLastValidGps( ContactTelemetry? existingTelemetry, LatLng? incomingGps, ) { final hasPreviousValidGps = _getValidGpsOrNull(existingTelemetry?.gpsLocation) != null; if (!hasPreviousValidGps) { return false; } return incomingGps == null; } ContactTelemetry? _mergeTelemetryForContact({ ContactTelemetry? existingTelemetry, ContactTelemetry? incomingTelemetry, }) { if (incomingTelemetry == null) { return existingTelemetry; } // Telemetry packets and contact refreshes are often sparse. Preserve the // last known reading for any field that is omitted in the incoming update. final incomingGps = _getValidGpsOrNull(incomingTelemetry.gpsLocation); final previousGps = _getValidGpsOrNull(existingTelemetry?.gpsLocation); final mergedExtraSensorData = _mergeExtraSensorData( existingTelemetry?.extraSensorData, incomingTelemetry.extraSensorData, ); return ContactTelemetry( gpsLocation: incomingGps ?? previousGps, batteryPercentage: incomingTelemetry.batteryPercentage ?? existingTelemetry?.batteryPercentage, batteryMilliVolts: incomingTelemetry.batteryMilliVolts ?? existingTelemetry?.batteryMilliVolts, temperature: incomingTelemetry.temperature ?? existingTelemetry?.temperature, timestamp: incomingTelemetry.timestamp, humidity: incomingTelemetry.humidity ?? existingTelemetry?.humidity, pressure: incomingTelemetry.pressure ?? existingTelemetry?.pressure, extraSensorData: mergedExtraSensorData.isEmpty ? null : mergedExtraSensorData, ); } Map _mergeExtraSensorData( Map? existingExtraSensorData, Map? incomingExtraSensorData, ) { final merged = {...?existingExtraSensorData}; if (incomingExtraSensorData == null || incomingExtraSensorData.isEmpty) { return merged; } final incomingMetricFamilies = incomingExtraSensorData.keys .map(_telemetryMetricFamilyForKey) .whereType() .toSet(); if (incomingMetricFamilies.isNotEmpty) { merged.removeWhere((key, _) { final family = _telemetryMetricFamilyForKey(key); return family != null && incomingMetricFamilies.contains(family); }); } merged.addAll(incomingExtraSensorData); return merged; } String? _telemetryMetricFamilyForKey(String key) { const sourcePrefix = '__source_channel:'; if (key.startsWith(sourcePrefix)) { return key.substring(sourcePrefix.length); } final separatorIndex = key.lastIndexOf('_'); if (separatorIndex <= 0 || separatorIndex == key.length - 1) { return key; } final suffix = key.substring(separatorIndex + 1); if (int.tryParse(suffix) == null) { return key; } return key.substring(0, separatorIndex); } int _coordinateToAdvertMicrodegrees(double coordinate) { return (coordinate * 1e6).round(); } /// Find contact by public key prefix (6 bytes) Contact? _findContactByPrefix(Uint8List prefix) { if (prefix.isEmpty) return null; final takeLen = prefix.length < 6 ? prefix.length : 6; final prefixHex = prefix .sublist(0, takeLen) .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(''); for (final contact in _contacts.values) { if (contact.publicKeyHex.startsWith(prefixHex)) { return contact; } } return null; } /// Find contact by first 6-byte public key prefix. Contact? findContactByPrefix(Uint8List prefix) { return _findContactByPrefix(prefix); } /// Find contact by 12-hex-char public key prefix. Contact? findContactByPrefixHex(String prefixHex) { if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(prefixHex)) return null; final bytes = Uint8List(6); for (var i = 0; i < 6; i++) { final start = i * 2; bytes[i] = int.parse(prefixHex.substring(start, start + 2), radix: 16); } return _findContactByPrefix(bytes); } /// Find contact by public key Contact? findContactByKey(Uint8List publicKey) { final keyHex = publicKey .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(''); return _contacts[keyHex]; } /// Clear a contact's learned path locally so the UI and next send both /// prefer flood routing until the radio reports a fresh route. void markPathUnhealthy(Uint8List publicKey) { final contact = findContactByKey(publicKey); if (contact == null || !contact.routeHasPath) { return; } _contacts[contact.publicKeyHex] = contact.copyWith( outPathLen: -1, outPath: Uint8List(0), ); _persistContacts(); notifyListeners(); } void setContactRouteLocal( Uint8List publicKey, { required int signedEncodedPathLen, required Uint8List paddedPathBytes, LatLng? inferredFallbackLocation, }) { final contact = findContactByKey(publicKey); if (contact == null) { return; } var updatedContact = contact.copyWith( outPathLen: signedEncodedPathLen, outPath: Uint8List.fromList(paddedPathBytes), ); if (inferredFallbackLocation != null) { updatedContact = updatedContact .copyWith( advLat: _coordinateToAdvertMicrodegrees( inferredFallbackLocation.latitude, ), advLon: _coordinateToAdvertMicrodegrees( inferredFallbackLocation.longitude, ), ) .addAdvertLocation(inferredFallbackLocation, DateTime.now()); } _contacts[contact.publicKeyHex] = updatedContact; _persistContacts(); notifyListeners(); } void retainReceivedRoute( Uint8List publicKey, { required int signedEncodedPathLen, required Uint8List paddedPathBytes, Uint8List? devicePublicKey, }) { if (devicePublicKey != null && publicKey.matches(devicePublicKey)) { return; } final keyHex = publicKey .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(''); final contact = _contacts[keyHex]; if (contact != null) { _contacts[keyHex] = contact.copyWith( outPathLen: signedEncodedPathLen, outPath: Uint8List.fromList(paddedPathBytes), ); _persistContacts(); notifyListeners(); return; } final now = DateTime.now(); final existing = _pendingAdverts[keyHex]; _pendingAdverts[keyHex] = (existing ?? PendingAdvert( publicKey: Uint8List.fromList(publicKey), receivedAt: now, )) .copyWith( receivedAt: now, signedEncodedPathLen: signedEncodedPathLen, paddedPathBytes: Uint8List.fromList(paddedPathBytes), ); notifyListeners(); } void resetContactRouteLocal(Uint8List publicKey) { final contact = findContactByKey(publicKey); if (contact == null) { return; } _contacts[contact.publicKeyHex] = contact.copyWith( outPathLen: -1, outPath: Uint8List(0), ); _persistContacts(); notifyListeners(); } void setContactNameOverride(String publicKeyHex, String? overrideName) { final contact = _contacts[publicKeyHex]; if (contact == null) { return; } final normalizedOverride = overrideName?.trim(); final nextOverride = (normalizedOverride == null || normalizedOverride.isEmpty) ? null : normalizedOverride; if (contact.nameOverride == nextOverride) { return; } _contacts[publicKeyHex] = contact.copyWith(nameOverride: nextOverride); _persistContacts(); notifyListeners(); } /// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80). /// Excludes only self key; known contacts still keep a discovery entry. bool addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) { if (devicePublicKey != null && publicKey.matches(devicePublicKey)) { return false; } final keyHex = publicKey .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(''); final existing = _pendingAdverts[keyHex]; final now = DateTime.now(); if (existing != null) { _pendingAdverts[keyHex] = existing.copyWith(receivedAt: now); _persistPendingAdverts(); notifyListeners(); return false; } else { _pendingAdverts[keyHex] = PendingAdvert( publicKey: Uint8List.fromList(publicKey), receivedAt: now, ); _persistPendingAdverts(); notifyListeners(); return true; } } bool addOrUpdatePendingAdvertContact( Contact contact, { Uint8List? devicePublicKey, }) { if (devicePublicKey != null && contact.publicKey.matches(devicePublicKey)) { return false; } final keyHex = contact.publicKeyHex; final route = ContactRouteCodec.fromContact(contact); final existing = _pendingAdverts[keyHex]; final now = DateTime.now(); final updated = (existing ?? PendingAdvert( publicKey: Uint8List.fromList(contact.publicKey), receivedAt: now, )) .copyWith( receivedAt: now, advName: contact.advName.trim().isEmpty ? null : contact.advName, typeValue: contact.type.value, flags: contact.flags, lastAdvert: contact.lastAdvert, advLat: contact.advLat, advLon: contact.advLon, signedEncodedPathLen: route?.signedEncodedPathLen ?? existing?.signedEncodedPathLen, paddedPathBytes: route?.paddedPathBytes == null ? existing?.paddedPathBytes : Uint8List.fromList(route!.paddedPathBytes), ); _pendingAdverts[keyHex] = updated; _persistPendingAdverts(); notifyListeners(); return existing == null; } bool addOrUpdatePendingAdvertMetadata({ required Uint8List publicKey, required int typeValue, Uint8List? devicePublicKey, int? flags, String? advName, int? lastAdvert, int? advLat, int? advLon, int? signedEncodedPathLen, Uint8List? paddedPathBytes, int? rxRssiDbm, int? rxSnrRaw, DateTime? receivedAt, }) { if (devicePublicKey != null && publicKey.matches(devicePublicKey)) { return false; } final keyHex = publicKey .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(''); final existing = _pendingAdverts[keyHex]; final nextReceivedAt = receivedAt ?? DateTime.now(); _pendingAdverts[keyHex] = (existing ?? PendingAdvert( publicKey: Uint8List.fromList(publicKey), receivedAt: nextReceivedAt, )) .copyWith( receivedAt: nextReceivedAt, typeValue: typeValue, advName: advName?.trim().isNotEmpty == true ? advName!.trim() : existing?.advName, flags: flags ?? existing?.flags, lastAdvert: lastAdvert ?? existing?.lastAdvert, advLat: advLat ?? existing?.advLat, advLon: advLon ?? existing?.advLon, signedEncodedPathLen: signedEncodedPathLen ?? existing?.signedEncodedPathLen, paddedPathBytes: paddedPathBytes == null ? existing?.paddedPathBytes : Uint8List.fromList(paddedPathBytes), rxRssiDbm: rxRssiDbm ?? existing?.rxRssiDbm, rxSnrRaw: rxSnrRaw ?? existing?.rxSnrRaw, ); _persistPendingAdverts(); notifyListeners(); return existing == null; } /// Find contact by name Contact? findContactByName(String name) { for (final contact in _contacts.values) { if (contact.advName == name) return contact; } return null; } /// Get contacts with low battery List get lowBatteryContacts { return contacts.where((c) { final battery = c.displayBattery; return battery != null && battery < 20.0; }).toList(); } /// Get recently seen contacts (within last 10 minutes) List get recentlySeenContacts { return contacts.where((c) => c.isRecentlySeen).toList(); } /// Get count of new contacts (not yet viewed) int get newContactsCount => contacts.where((c) => c.isNew && !c.isChannel).length; /// Mark all contacts as viewed (not new) void markAllAsViewed() { bool hasChanges = false; _contacts.forEach((key, contact) { if (contact.isNew && !contact.isChannel) { _contacts[key] = contact.copyWith(isNew: false); hasChanges = true; } }); if (hasChanges) { _persistContacts(); notifyListeners(); } } /// Mark a specific contact as viewed (not new) void markAsViewed(String publicKeyHex) { final contact = _contacts[publicKeyHex]; if (contact != null && contact.isNew) { _contacts[publicKeyHex] = contact.copyWith(isNew: false); _persistContacts(); notifyListeners(); } } /// Clear all contacts void clearContacts() { _contacts.clear(); _pendingAdverts.clear(); _ensurePublicChannelExists(); _persistContacts(); _persistPendingAdverts(); notifyListeners(); } /// Remove a contact /// [onRemoveFromDevice] - Optional callback to remove contact from BLE device Future removeContact( String publicKeyHex, { Future Function(Uint8List)? onRemoveFromDevice, }) async { // Get the contact before removing final contact = _contacts[publicKeyHex]; if (contact == null) return; // Remove from device first if callback provided if (onRemoveFromDevice != null) { await onRemoveFromDevice(contact.publicKey); } // Then remove from local storage _contacts.remove(publicKeyHex); _pendingAdverts.remove(publicKeyHex); _persistContacts(); _persistPendingAdverts(); notifyListeners(); } /// Get storage statistics Future> getStorageStats() async { return await _storageService.getStorageStats(namespace: _storageNamespace); } Future clearPendingAdverts() async { if (_pendingAdverts.isEmpty) { return; } _pendingAdverts.clear(); await _storageService.clearPendingAdverts(namespace: _storageNamespace); notifyListeners(); } void updatePendingAdvertStatusByPrefix( Uint8List publicKeyPrefix, { int? batteryMv, int? queueLen, int? lastRssi, int? lastSnrRaw, int? uptimeSecs, }) { final prefixHex = publicKeyPrefix .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(''); final match = _pendingAdverts.entries.where( (entry) => entry.key.startsWith(prefixHex), ); if (match.isEmpty) { return; } final key = match.first.key; final existing = _pendingAdverts[key]!; _pendingAdverts[key] = existing.copyWith( repeaterBatteryMv: batteryMv, repeaterQueueLen: queueLen, repeaterLastRssi: lastRssi, repeaterLastSnrRaw: lastSnrRaw, repeaterUptimeSecs: uptimeSecs, ); _persistPendingAdverts(); notifyListeners(); } List _contactsForStorage() { // Don't persist the public channel pseudo-contact (all zeros key) const publicChannelKey = '0000000000000000000000000000000000000000000000000000000000000000'; return _contacts.entries .where((entry) => entry.key != publicChannelKey) .map((entry) => entry.value) .toList(); } void _restorePendingAdverts( List> storedPendingAdverts, { Uint8List? devicePublicKey, }) { _pendingAdverts.clear(); for (final json in storedPendingAdverts) { final advert = _pendingAdvertFromJson(json); if (advert == null) continue; if (devicePublicKey != null && advert.publicKey.matches(devicePublicKey)) { continue; } if (_contacts.containsKey(advert.publicKeyHex)) { continue; } _pendingAdverts[advert.publicKeyHex] = advert; } } void _resetInMemoryState() { _contacts.clear(); _savedContactGroups.clear(); _pendingAdverts.clear(); _estimatedLocations.clear(); _rssiObservations.clear(); } Map _pendingAdvertToJson(PendingAdvert advert) { return { 'publicKey': base64Encode(advert.publicKey), 'receivedAtMillis': advert.receivedAt.millisecondsSinceEpoch, 'advName': advert.advName, 'typeValue': advert.typeValue, 'flags': advert.flags, 'lastAdvert': advert.lastAdvert, 'advLat': advert.advLat, 'advLon': advert.advLon, 'signedEncodedPathLen': advert.signedEncodedPathLen, 'paddedPathBytes': advert.paddedPathBytes == null ? null : base64Encode(advert.paddedPathBytes!), 'rxRssiDbm': advert.rxRssiDbm, 'rxSnrRaw': advert.rxSnrRaw, 'repeaterBatteryMv': advert.repeaterBatteryMv, 'repeaterQueueLen': advert.repeaterQueueLen, 'repeaterLastRssi': advert.repeaterLastRssi, 'repeaterLastSnrRaw': advert.repeaterLastSnrRaw, 'repeaterUptimeSecs': advert.repeaterUptimeSecs, }; } PendingAdvert? _pendingAdvertFromJson(Map json) { try { return PendingAdvert( publicKey: Uint8List.fromList( base64Decode(json['publicKey'] as String), ), receivedAt: DateTime.fromMillisecondsSinceEpoch( json['receivedAtMillis'] as int, ), advName: json['advName'] as String?, typeValue: json['typeValue'] as int?, flags: json['flags'] as int?, lastAdvert: json['lastAdvert'] as int?, advLat: json['advLat'] as int?, advLon: json['advLon'] as int?, signedEncodedPathLen: json['signedEncodedPathLen'] as int?, paddedPathBytes: json['paddedPathBytes'] == null ? null : Uint8List.fromList( base64Decode(json['paddedPathBytes'] as String), ), rxRssiDbm: json['rxRssiDbm'] as int?, rxSnrRaw: json['rxSnrRaw'] as int?, repeaterBatteryMv: json['repeaterBatteryMv'] as int?, repeaterQueueLen: json['repeaterQueueLen'] as int?, repeaterLastRssi: json['repeaterLastRssi'] as int?, repeaterLastSnrRaw: json['repeaterLastSnrRaw'] as int?, repeaterUptimeSecs: json['repeaterUptimeSecs'] as int?, ); } catch (e) { debugPrint( '❌ [ContactsProvider] Error parsing pending advert from JSON: $e', ); return null; } } /// Get contact count by type Map get contactCounts { return { 'chat': chatContacts.length, 'repeater': repeaters.length, 'sensor': sensorContacts.length, 'room': rooms.length, 'total': contacts.length, }; } }