mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Preserve contact telemetry data
This commit is contained in:
@@ -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>{
|
||||||
|
|||||||
@@ -220,35 +220,48 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
for (final key in _watchedSensorKeys) {
|
for (final key in _watchedSensorKeys) {
|
||||||
Contact? contact;
|
await refreshSensor(
|
||||||
for (final entry in contactsProvider.contacts) {
|
publicKeyHex: key,
|
||||||
if (entry.publicKeyHex == key) {
|
contactsProvider: contactsProvider,
|
||||||
contact = entry;
|
connectionProvider: connectionProvider,
|
||||||
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,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
_refreshStates[key] = result.success
|
|
||||||
? SensorRefreshState.success
|
|
||||||
: SensorRefreshState.timeout;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
_isRefreshingAll = false;
|
_isRefreshingAll = false;
|
||||||
notifyListeners();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user