Preserve contact telemetry data

This commit is contained in:
Janez T
2026-03-08 09:23:07 +01:00
parent 1f826ae4c2
commit 937f91e496
4 changed files with 247 additions and 81 deletions

View File

@@ -283,62 +283,16 @@ class ContactsProvider with ChangeNotifier {
} }
// Check if this is a new contact // Check if this is a new contact
final isNewContact = !_contacts.containsKey(contact.publicKeyHex); final existingContact = _contacts[contact.publicKeyHex];
final isNewContact = existingContact == null;
debugPrint( debugPrint(
' isNew: $isNewContact, total contacts before: ${_contacts.length}', ' isNew: $isNewContact, total contacts before: ${_contacts.length}',
); );
Contact updatedContact; final updatedContact = _mergeIncomingContact(
if (isNewContact) { incomingContact: contact,
// New contact - add initial location to history if available existingContact: existingContact,
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,
);
}
}
_contacts[contact.publicKeyHex] = updatedContact; _contacts[contact.publicKeyHex] = updatedContact;
_pendingAdverts.remove(contact.publicKeyHex); _pendingAdverts.remove(contact.publicKeyHex);
@@ -364,7 +318,11 @@ class ContactsProvider with ChangeNotifier {
excluded++; excluded++;
continue; continue;
} }
_contacts[contact.publicKeyHex] = contact; final existingContact = _contacts[contact.publicKeyHex];
_contacts[contact.publicKeyHex] = _mergeIncomingContact(
incomingContact: contact,
existingContact: existingContact,
);
_pendingAdverts.remove(contact.publicKeyHex); _pendingAdverts.remove(contact.publicKeyHex);
} }
if (excluded > 0) { if (excluded > 0) {
@@ -376,6 +334,60 @@ class ContactsProvider with ChangeNotifier {
notifyListeners(); 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 /// Update contact telemetry
void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) { void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) {
debugPrint('📊 [ContactsProvider] updateTelemetry() called'); debugPrint('📊 [ContactsProvider] updateTelemetry() called');
@@ -541,6 +553,8 @@ class ContactsProvider with ChangeNotifier {
return existingTelemetry; 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 incomingGps = _getValidGpsOrNull(incomingTelemetry.gpsLocation);
final previousGps = _getValidGpsOrNull(existingTelemetry?.gpsLocation); final previousGps = _getValidGpsOrNull(existingTelemetry?.gpsLocation);
final mergedExtraSensorData = <String, dynamic>{ final mergedExtraSensorData = <String, dynamic>{

View File

@@ -220,20 +220,38 @@ class SensorsProvider with ChangeNotifier {
try { try {
for (final key in _watchedSensorKeys) { for (final key in _watchedSensorKeys) {
await refreshSensor(
publicKeyHex: key,
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
);
}
} finally {
_isRefreshingAll = false;
notifyListeners();
}
}
Future<void> refreshSensor({
required String publicKeyHex,
required ContactsProvider contactsProvider,
required ConnectionProvider connectionProvider,
}) async {
Contact? contact; Contact? contact;
for (final entry in contactsProvider.contacts) { for (final entry in contactsProvider.contacts) {
if (entry.publicKeyHex == key) { if (entry.publicKeyHex == publicKeyHex) {
contact = entry; contact = entry;
break; break;
} }
} }
if (contact == null) { if (contact == null) {
_refreshStates[key] = SensorRefreshState.unavailable; _refreshStates[publicKeyHex] = SensorRefreshState.unavailable;
notifyListeners(); notifyListeners();
continue; return;
} }
_refreshStates[key] = SensorRefreshState.refreshing; _refreshStates[publicKeyHex] = SensorRefreshState.refreshing;
notifyListeners(); notifyListeners();
final result = await connectionProvider.smartPing( final result = await connectionProvider.smartPing(
@@ -241,14 +259,9 @@ class SensorsProvider with ChangeNotifier {
hasPath: contact.hasPath, hasPath: contact.hasPath,
); );
_refreshStates[key] = result.success _refreshStates[publicKeyHex] = result.success
? SensorRefreshState.success ? SensorRefreshState.success
: SensorRefreshState.timeout; : SensorRefreshState.timeout;
notifyListeners(); notifyListeners();
} }
} finally {
_isRefreshingAll = false;
notifyListeners();
}
}
} }

View File

@@ -209,6 +209,11 @@ class SensorsTab extends StatelessWidget {
}, },
onCustomize: () => onCustomize: () =>
_showMetricSelector(context, key, contact), _showMetricSelector(context, key, contact),
onRefresh: () => sensorsProvider.refreshSensor(
publicKeyHex: key,
contactsProvider: contactsProvider,
connectionProvider: context.read<ConnectionProvider>(),
),
); );
}), }),
], ],
@@ -302,6 +307,7 @@ class _SensorCard extends StatelessWidget {
final Set<String> visibleFields; final Set<String> visibleFields;
final Map<String, int> fieldSpans; final Map<String, int> fieldSpans;
final Future<void> Function() onRemove; final Future<void> Function() onRemove;
final Future<void> Function() onRefresh;
final VoidCallback onCustomize; final VoidCallback onCustomize;
const _SensorCard({ const _SensorCard({
@@ -310,6 +316,7 @@ class _SensorCard extends StatelessWidget {
required this.visibleFields, required this.visibleFields,
required this.fieldSpans, required this.fieldSpans,
required this.onRemove, required this.onRemove,
required this.onRefresh,
required this.onCustomize, required this.onCustomize,
}); });
@@ -404,18 +411,24 @@ class _SensorCard extends StatelessWidget {
), ),
PopupMenuButton<String>( PopupMenuButton<String>(
onSelected: (value) async { onSelected: (value) async {
if (value == 'remove') { if (value == 'refresh') {
await onRefresh();
} else if (value == 'remove') {
await onRemove(); await onRemove();
} else if (value == 'customize') { } else if (value == 'customize') {
onCustomize(); onCustomize();
} }
}, },
itemBuilder: (context) => const [ itemBuilder: (context) => [
PopupMenuItem<String>( PopupMenuItem<String>(
value: 'refresh',
child: Text(l10n.refresh),
),
const PopupMenuItem<String>(
value: 'customize', value: 'customize',
child: Text('Customize fields'), child: Text('Customize fields'),
), ),
PopupMenuItem<String>( const PopupMenuItem<String>(
value: 'remove', value: 'remove',
child: Text('Remove'), child: Text('Remove'),
), ),
@@ -429,7 +442,9 @@ class _SensorCard extends StatelessWidget {
'This node is no longer available in the contact list.', 'This node is no longer available in the contact list.',
) )
else if (telemetry == null) 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) else if (metrics.isEmpty)
const Text( const Text(
'All fields are hidden. Use Visible fields to choose what to show.', 'All fields are hidden. Use Visible fields to choose what to show.',

View File

@@ -1,6 +1,7 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart'; 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/models/contact.dart';
import 'package:meshcore_sar_app/providers/contacts_provider.dart'; import 'package:meshcore_sar_app/providers/contacts_provider.dart';
import 'package:meshcore_sar_app/services/cayenne_lpp_parser.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', () { test('builds message snapshot from latest valid telemetry', () {
final telemetryData = CayenneLppParser.createGpsData( final telemetryData = CayenneLppParser.createGpsData(
latitude: 45.0001, latitude: 45.0001,