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
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 = <String, dynamic>{

View File

@@ -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<void> 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();
}
}

View File

@@ -209,6 +209,11 @@ class SensorsTab extends StatelessWidget {
},
onCustomize: () =>
_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 Map<String, int> fieldSpans;
final Future<void> Function() onRemove;
final Future<void> 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<String>(
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<String>(
value: 'refresh',
child: Text(l10n.refresh),
),
const PopupMenuItem<String>(
value: 'customize',
child: Text('Customize fields'),
),
PopupMenuItem<String>(
const PopupMenuItem<String>(
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.',

View File

@@ -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,