From 937f91e496e7716f9b11c9264d638d712df7f815 Mon Sep 17 00:00:00 2001 From: Janez T Date: Sun, 8 Mar 2026 09:23:07 +0100 Subject: [PATCH] Preserve contact telemetry data --- lib/providers/contacts_provider.dart | 120 +++++++++++--------- lib/providers/sensors_provider.dart | 61 ++++++---- lib/screens/sensors_tab.dart | 23 +++- test/providers/contacts_provider_test.dart | 124 +++++++++++++++++++++ 4 files changed, 247 insertions(+), 81 deletions(-) diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index f8692e6..5f9c366 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -283,62 +283,16 @@ class ContactsProvider with ChangeNotifier { } // Check if this is a new contact - final isNewContact = !_contacts.containsKey(contact.publicKeyHex); + final existingContact = _contacts[contact.publicKeyHex]; + final isNewContact = existingContact == null; debugPrint( ' isNew: $isNewContact, total contacts before: ${_contacts.length}', ); - Contact updatedContact; - if (isNewContact) { - // New contact - add initial location to history if available - updatedContact = contact.copyWith(isNew: true); - if (contact.advertLocation != null) { - final timestamp = DateTime.fromMillisecondsSinceEpoch( - contact.lastAdvert * 1000, - ); - updatedContact = updatedContact.addAdvertLocation( - contact.advertLocation!, - timestamp, - ); - } - } else { - // Existing contact - preserve history and isNew status - final existingContact = _contacts[contact.publicKeyHex]!; - final mergedTelemetry = _mergeTelemetryForContact( - existingTelemetry: existingContact.telemetry, - incomingTelemetry: contact.telemetry, - ); - final incomingAdvertLocation = contact.advertLocation; - final existingAdvertLocation = existingContact.advertLocation; - - // Start with existing contact - updatedContact = contact.copyWith( - isNew: existingContact.isNew, - advertHistory: existingContact.advertHistory, - telemetry: mergedTelemetry, - advLat: incomingAdvertLocation != null - ? contact.advLat - : existingAdvertLocation != null - ? existingContact.advLat - : contact.advLat, - advLon: incomingAdvertLocation != null - ? contact.advLon - : existingAdvertLocation != null - ? existingContact.advLon - : contact.advLon, - ); - - // Add new location to history if location has changed - if (contact.advertLocation != null) { - final timestamp = DateTime.fromMillisecondsSinceEpoch( - contact.lastAdvert * 1000, - ); - updatedContact = updatedContact.addAdvertLocation( - contact.advertLocation!, - timestamp, - ); - } - } + final updatedContact = _mergeIncomingContact( + incomingContact: contact, + existingContact: existingContact, + ); _contacts[contact.publicKeyHex] = updatedContact; _pendingAdverts.remove(contact.publicKeyHex); @@ -364,7 +318,11 @@ class ContactsProvider with ChangeNotifier { excluded++; continue; } - _contacts[contact.publicKeyHex] = contact; + final existingContact = _contacts[contact.publicKeyHex]; + _contacts[contact.publicKeyHex] = _mergeIncomingContact( + incomingContact: contact, + existingContact: existingContact, + ); _pendingAdverts.remove(contact.publicKeyHex); } if (excluded > 0) { @@ -376,6 +334,60 @@ class ContactsProvider with ChangeNotifier { notifyListeners(); } + Contact _mergeIncomingContact({ + required Contact incomingContact, + Contact? existingContact, + }) { + if (existingContact == null) { + var newContact = incomingContact.copyWith(isNew: true); + if (incomingContact.advertLocation != null) { + final timestamp = DateTime.fromMillisecondsSinceEpoch( + incomingContact.lastAdvert * 1000, + ); + newContact = newContact.addAdvertLocation( + incomingContact.advertLocation!, + timestamp, + ); + } + return newContact; + } + + final mergedTelemetry = _mergeTelemetryForContact( + existingTelemetry: existingContact.telemetry, + incomingTelemetry: incomingContact.telemetry, + ); + final incomingAdvertLocation = incomingContact.advertLocation; + final existingAdvertLocation = existingContact.advertLocation; + + var updatedContact = incomingContact.copyWith( + isNew: existingContact.isNew, + advertHistory: existingContact.advertHistory, + telemetry: mergedTelemetry, + advLat: incomingAdvertLocation != null + ? incomingContact.advLat + : existingAdvertLocation != null + ? existingContact.advLat + : incomingContact.advLat, + advLon: incomingAdvertLocation != null + ? incomingContact.advLon + : existingAdvertLocation != null + ? existingContact.advLon + : incomingContact.advLon, + ); + + if (incomingAdvertLocation != null) { + final timestamp = DateTime.fromMillisecondsSinceEpoch( + incomingContact.lastAdvert * 1000, + ); + updatedContact = updatedContact.addAdvertLocation( + incomingAdvertLocation, + timestamp, + ); + } + + return updatedContact; + } + /// Update contact telemetry void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) { debugPrint('📊 [ContactsProvider] updateTelemetry() called'); @@ -541,6 +553,8 @@ class ContactsProvider with ChangeNotifier { 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 = { diff --git a/lib/providers/sensors_provider.dart b/lib/providers/sensors_provider.dart index 87ffa87..a558bdf 100644 --- a/lib/providers/sensors_provider.dart +++ b/lib/providers/sensors_provider.dart @@ -220,35 +220,48 @@ class SensorsProvider with ChangeNotifier { try { for (final key in _watchedSensorKeys) { - Contact? contact; - for (final entry in contactsProvider.contacts) { - if (entry.publicKeyHex == key) { - contact = entry; - break; - } - } - if (contact == null) { - _refreshStates[key] = SensorRefreshState.unavailable; - notifyListeners(); - continue; - } - - _refreshStates[key] = SensorRefreshState.refreshing; - notifyListeners(); - - final result = await connectionProvider.smartPing( - contactPublicKey: contact.publicKey, - hasPath: contact.hasPath, + await refreshSensor( + publicKeyHex: key, + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, ); - - _refreshStates[key] = result.success - ? SensorRefreshState.success - : SensorRefreshState.timeout; - notifyListeners(); } } finally { _isRefreshingAll = false; notifyListeners(); } } + + Future refreshSensor({ + required String publicKeyHex, + required ContactsProvider contactsProvider, + required ConnectionProvider connectionProvider, + }) async { + Contact? contact; + for (final entry in contactsProvider.contacts) { + if (entry.publicKeyHex == publicKeyHex) { + contact = entry; + break; + } + } + + if (contact == null) { + _refreshStates[publicKeyHex] = SensorRefreshState.unavailable; + notifyListeners(); + return; + } + + _refreshStates[publicKeyHex] = SensorRefreshState.refreshing; + notifyListeners(); + + final result = await connectionProvider.smartPing( + contactPublicKey: contact.publicKey, + hasPath: contact.hasPath, + ); + + _refreshStates[publicKeyHex] = result.success + ? SensorRefreshState.success + : SensorRefreshState.timeout; + notifyListeners(); + } } diff --git a/lib/screens/sensors_tab.dart b/lib/screens/sensors_tab.dart index 0dc32b9..c674ad6 100644 --- a/lib/screens/sensors_tab.dart +++ b/lib/screens/sensors_tab.dart @@ -209,6 +209,11 @@ class SensorsTab extends StatelessWidget { }, onCustomize: () => _showMetricSelector(context, key, contact), + onRefresh: () => sensorsProvider.refreshSensor( + publicKeyHex: key, + contactsProvider: contactsProvider, + connectionProvider: context.read(), + ), ); }), ], @@ -302,6 +307,7 @@ class _SensorCard extends StatelessWidget { final Set visibleFields; final Map fieldSpans; final Future Function() onRemove; + final Future Function() onRefresh; final VoidCallback onCustomize; const _SensorCard({ @@ -310,6 +316,7 @@ class _SensorCard extends StatelessWidget { required this.visibleFields, required this.fieldSpans, required this.onRemove, + required this.onRefresh, required this.onCustomize, }); @@ -404,18 +411,24 @@ class _SensorCard extends StatelessWidget { ), PopupMenuButton( onSelected: (value) async { - if (value == 'remove') { + if (value == 'refresh') { + await onRefresh(); + } else if (value == 'remove') { await onRemove(); } else if (value == 'customize') { onCustomize(); } }, - itemBuilder: (context) => const [ + itemBuilder: (context) => [ PopupMenuItem( + value: 'refresh', + child: Text(l10n.refresh), + ), + const PopupMenuItem( value: 'customize', child: Text('Customize fields'), ), - PopupMenuItem( + const PopupMenuItem( value: 'remove', child: Text('Remove'), ), @@ -429,7 +442,9 @@ class _SensorCard extends StatelessWidget { 'This node is no longer available in the contact list.', ) else if (telemetry == null) - const Text('No telemetry received yet. Pull down to fetch it.') + const Text( + 'No telemetry received yet. Use Refresh from the menu or pull down to fetch it.', + ) else if (metrics.isEmpty) const Text( 'All fields are hidden. Use Visible fields to choose what to show.', diff --git a/test/providers/contacts_provider_test.dart b/test/providers/contacts_provider_test.dart index f806e3c..08e6f53 100644 --- a/test/providers/contacts_provider_test.dart +++ b/test/providers/contacts_provider_test.dart @@ -1,6 +1,7 @@ import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; import 'package:meshcore_sar_app/models/contact.dart'; import 'package:meshcore_sar_app/providers/contacts_provider.dart'; import 'package:meshcore_sar_app/services/cayenne_lpp_parser.dart'; @@ -242,6 +243,129 @@ void main() { }, ); + test('retains existing telemetry when contact refresh omits telemetry', () { + final initialTelemetry = ContactTelemetry( + gpsLocation: const LatLng(45.1234, 13.8765), + batteryPercentage: 76.5, + batteryMilliVolts: 3890, + temperature: 21.5, + timestamp: DateTime.now().subtract(const Duration(minutes: 5)), + humidity: 62.0, + pressure: 1008.4, + extraSensorData: const {'co2': 415.0}, + ); + + provider.addOrUpdateContact( + createContact( + key: publicKey, + type: ContactType.chat, + ).copyWith(telemetry: initialTelemetry), + ); + + provider.addOrUpdateContact( + Contact( + publicKey: publicKey, + type: ContactType.chat, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: 'Test Contact Refreshed', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ), + ); + + final updated = provider.findContactByKey(publicKey)!; + expect(updated.telemetry, isNotNull); + expect(updated.telemetry!.gpsLocation, const LatLng(45.1234, 13.8765)); + expect(updated.telemetry!.batteryPercentage, equals(76.5)); + expect(updated.telemetry!.batteryMilliVolts, equals(3890)); + expect(updated.telemetry!.temperature, equals(21.5)); + expect(updated.telemetry!.humidity, equals(62.0)); + expect(updated.telemetry!.pressure, equals(1008.4)); + expect(updated.telemetry!.extraSensorData, containsPair('co2', 415.0)); + }); + + test('retains existing telemetry during bulk contacts sync', () { + final initialTelemetry = ContactTelemetry( + gpsLocation: const LatLng(45.1234, 13.8765), + batteryPercentage: 76.5, + batteryMilliVolts: 3890, + temperature: 21.5, + timestamp: DateTime.now().subtract(const Duration(minutes: 5)), + humidity: 62.0, + pressure: 1008.4, + extraSensorData: const {'co2': 415.0}, + ); + + provider.addOrUpdateContact( + createContact( + key: publicKey, + type: ContactType.chat, + ).copyWith(telemetry: initialTelemetry), + ); + + provider.addContacts([ + Contact( + publicKey: publicKey, + type: ContactType.chat, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: 'Synced Contact', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ), + ]); + + final updated = provider.findContactByKey(publicKey)!; + expect(updated.telemetry, isNotNull); + expect(updated.telemetry!.gpsLocation, const LatLng(45.1234, 13.8765)); + expect(updated.telemetry!.batteryPercentage, equals(76.5)); + expect(updated.telemetry!.batteryMilliVolts, equals(3890)); + expect(updated.telemetry!.temperature, equals(21.5)); + expect(updated.telemetry!.humidity, equals(62.0)); + expect(updated.telemetry!.pressure, equals(1008.4)); + expect(updated.telemetry!.extraSensorData, containsPair('co2', 415.0)); + }); + + test('retains prior telemetry fields across sparse telemetry updates', () { + final fullTelemetry = ContactTelemetry( + gpsLocation: const LatLng(46.0569, 14.5058), + batteryPercentage: 54.0, + batteryMilliVolts: 3780, + temperature: 19.5, + timestamp: DateTime.now().subtract(const Duration(minutes: 2)), + humidity: 58.0, + pressure: 1011.2, + extraSensorData: const {'pm25': 8.0}, + ); + + provider.addOrUpdateContact( + createContact( + key: publicKey, + type: ContactType.chat, + ).copyWith(telemetry: fullTelemetry), + ); + + final batteryOnly = CayenneLppParser.createBatteryData(3.95); + provider.updateTelemetry(publicKey.sublist(0, 6), batteryOnly); + + final updated = provider.findContactByKey(publicKey)!; + expect(updated.telemetry, isNotNull); + expect(updated.telemetry!.gpsLocation, const LatLng(46.0569, 14.5058)); + expect(updated.telemetry!.batteryMilliVolts, isNotNull); + expect(updated.telemetry!.batteryPercentage, isNotNull); + expect(updated.telemetry!.temperature, equals(19.5)); + expect(updated.telemetry!.humidity, equals(58.0)); + expect(updated.telemetry!.pressure, equals(1011.2)); + expect(updated.telemetry!.extraSensorData, containsPair('pm25', 8.0)); + }); + test('builds message snapshot from latest valid telemetry', () { final telemetryData = CayenneLppParser.createGpsData( latitude: 45.0001,