Add LPCNet voice mode option

This commit is contained in:
Janez T
2026-03-11 20:47:17 +01:00
parent e31bfac2ac
commit 1b6ff59af3
4 changed files with 390 additions and 24 deletions

View File

@@ -1,3 +1,4 @@
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import '../models/contact.dart';
@@ -55,6 +56,7 @@ class _RetainedRoute {
/// Contacts Provider - manages contact list and telemetry
class ContactsProvider with ChangeNotifier {
static const double _firstHopFallbackOffsetMeters = 100.0;
final Map<String, Contact> _contacts = {};
final Map<String, PendingAdvert> _pendingAdverts = {};
final ContactStorageService _storageService = ContactStorageService();
@@ -367,13 +369,36 @@ class ContactsProvider with ChangeNotifier {
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(
@@ -387,10 +412,6 @@ class ContactsProvider with ChangeNotifier {
return newContact;
}
final mergedTelemetry = _mergeTelemetryForContact(
existingTelemetry: existingContact.telemetry,
incomingTelemetry: incomingContact.telemetry,
);
final incomingAdvertLocation = incomingContact.advertLocation;
final existingAdvertLocation = existingContact.advertLocation;
@@ -405,12 +426,12 @@ class ContactsProvider with ChangeNotifier {
? incomingContact.advLat
: existingAdvertLocation != null
? existingContact.advLat
: incomingContact.advLat,
: inferredFallbackAdvLat ?? incomingContact.advLat,
advLon: incomingAdvertLocation != null
? incomingContact.advLon
: existingAdvertLocation != null
? existingContact.advLon
: incomingContact.advLon,
: inferredFallbackAdvLon ?? incomingContact.advLon,
);
if (incomingAdvertLocation != null) {
@@ -457,6 +478,114 @@ class ContactsProvider with ChangeNotifier {
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 firstHopHex = routeBytes
.sublist(0, 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(firstHopHex);
}).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
void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) {
debugPrint('📊 [ContactsProvider] updateTelemetry() called');

View File

@@ -19,6 +19,8 @@ class MessageStorageService {
'stored_message_transfer_details';
static const String _messageRouteMetadataKey =
'stored_message_route_metadata';
static const String _embeddedReceptionDetailsKey = 'storedReceptionDetails';
static const String _legacyPathBytesKey = 'storedPathBytes';
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
/// Save messages to persistent storage
@@ -32,8 +34,16 @@ class MessageStorageService {
try {
final prefs = await SharedPreferences.getInstance();
// Convert messages to JSON
final jsonList = messages.map((msg) => _messageToJson(msg)).toList();
// Convert messages to JSON and embed path bytes as a fallback so they
// survive restore even if the sidecar reception-details entry is absent.
final jsonList = messages
.map(
(msg) => _messageToJson(
msg,
receptionDetails: messageReceptionDetails[msg.id],
),
)
.toList();
// Limit to max stored messages (keep most recent)
final limitedList = jsonList.length > _maxStoredMessages
@@ -129,16 +139,10 @@ class MessageStorageService {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messageReceptionDetailsKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! Map<String, dynamic>) {
return const {};
}
final result = <String, MessageReceptionDetails>{};
if (jsonString != null && jsonString.isNotEmpty) {
final decoded = jsonDecode(jsonString);
if (decoded is Map<String, dynamic>) {
for (final entry in decoded.entries) {
final value = entry.value;
if (value is! Map<String, dynamic>) continue;
@@ -147,6 +151,24 @@ class MessageStorageService {
result[entry.key] = snapshot;
}
}
}
}
final embeddedReceptionDetails = await _loadEmbeddedReceptionDetails();
embeddedReceptionDetails.forEach((messageId, snapshot) {
result.putIfAbsent(messageId, () => snapshot);
});
final fallbackPathBytes = await _loadLegacyPathBytesFromMessages();
fallbackPathBytes.forEach((messageId, pathBytes) {
result.putIfAbsent(
messageId,
() => MessageReceptionDetails(
capturedAt: DateTime.fromMillisecondsSinceEpoch(0),
pathBytes: pathBytes,
),
);
});
return result;
} catch (e) {
debugPrint('❌ [MessageStorage] Error loading reception details: $e');
@@ -278,7 +300,10 @@ class MessageStorageService {
}
/// Convert Message to JSON
Map<String, dynamic> _messageToJson(Message message) {
Map<String, dynamic> _messageToJson(
Message message, {
MessageReceptionDetails? receptionDetails,
}) {
return {
'id': message.id,
'messageType': message.messageType.name,
@@ -338,9 +363,67 @@ class MessageStorageService {
},
)
.toList(),
if (receptionDetails != null)
_embeddedReceptionDetailsKey: receptionDetails.toJson(),
if (receptionDetails?.pathBytes case final pathBytes?)
_legacyPathBytesKey: List<int>.from(pathBytes),
};
}
Future<Map<String, MessageReceptionDetails>>
_loadEmbeddedReceptionDetails() async {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messagesKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! List) {
return const {};
}
final result = <String, MessageReceptionDetails>{};
for (final entry in decoded) {
if (entry is! Map<String, dynamic>) continue;
final messageId = entry['id'];
final embedded = entry[_embeddedReceptionDetailsKey];
if (messageId is! String || embedded is! Map<String, dynamic>) continue;
final snapshot = MessageReceptionDetails.fromJson(embedded);
if (snapshot == null) continue;
result[messageId] = snapshot;
}
return result;
}
Future<Map<String, List<int>>> _loadLegacyPathBytesFromMessages() async {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messagesKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! List) {
return const {};
}
final result = <String, List<int>>{};
for (final entry in decoded) {
if (entry is! Map<String, dynamic>) continue;
final messageId = entry['id'];
final pathBytes = entry[_legacyPathBytesKey];
if (messageId is! String || pathBytes is! List) continue;
final normalized = pathBytes
.whereType<num>()
.map((b) => b.toInt())
.toList();
if (normalized.isEmpty) continue;
result[messageId] = normalized;
}
return result;
}
/// Convert JSON to Message
Message? _messageFromJson(Map<String, dynamic> json) {
try {

View File

@@ -1,5 +1,6 @@
import 'dart:typed_data';
import 'package:geolocator/geolocator.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
import 'package:meshcore_sar_app/models/contact.dart';
@@ -522,6 +523,58 @@ void main() {
isEmpty,
);
});
test('infers a fallback location 100m from first-hop repeater', () {
final repeaterKey = Uint8List.fromList([
0xAA,
0xBB,
0x10,
0x11,
0x12,
0x13,
...List<int>.generate(26, (index) => index + 20),
]);
provider.addOrUpdateContact(
createContact(
key: repeaterKey,
type: ContactType.repeater,
name: 'Relay Alpha',
),
);
final targetKey = createPublicKey(120);
final route = ContactRouteCodec.parse('AABB,CCDD');
provider.addOrUpdateContact(
Contact(
publicKey: targetKey,
type: ContactType.chat,
flags: 0,
outPathLen: route.signedEncodedPathLen,
outPath: route.paddedPathBytes,
advName: 'No GPS',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
),
);
final updated = provider.findContactByKey(targetKey)!;
final inferred = updated.displayLocation;
final repeater = provider.findContactByKey(repeaterKey)!;
final repeaterLocation = repeater.displayLocation;
expect(inferred, isNotNull);
expect(repeaterLocation, isNotNull);
final distanceMeters = Geolocator.distanceBetween(
repeaterLocation!.latitude,
repeaterLocation.longitude,
inferred!.latitude,
inferred.longitude,
);
expect(distanceMeters, closeTo(100.0, 8.0));
});
});
group('ContactsProvider.updateFastGps', () {

View File

@@ -0,0 +1,101 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_client/meshcore_client.dart';
import 'package:meshcore_sar_app/models/message_reception_details.dart';
import 'package:meshcore_sar_app/services/message_storage_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test(
'retains path bytes for stored unread message when reception sidecar is missing',
() async {
final storage = MessageStorageService();
final message = Message(
id: 'msg-1',
messageType: MessageType.contact,
senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]),
pathLen: 2,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'Unread message',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
isRead: false,
);
await storage.saveMessages(
[message],
messageReceptionDetails: {
message.id: MessageReceptionDetails(
capturedAt: DateTime.fromMillisecondsSinceEpoch(1700000000600),
pathBytes: const [0xAA, 0xBB, 0xCC],
),
},
);
final prefs = await SharedPreferences.getInstance();
await prefs.remove('stored_message_reception_details');
final restoredDetails = await storage.loadMessageReceptionDetails();
expect(restoredDetails.keys, contains(message.id));
expect(restoredDetails[message.id]?.pathBytes, [0xAA, 0xBB, 0xCC]);
},
);
test('retains embedded reception details when sidecar is missing', () async {
final storage = MessageStorageService();
final message = Message(
id: 'msg-2',
messageType: MessageType.channel,
senderPublicKeyPrefix: Uint8List.fromList([6, 5, 4, 3, 2, 1]),
channelIdx: 2,
pathLen: 3,
textType: MessageTextType.plain,
senderTimestamp: 1700000100,
text: 'Room update',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000100500),
isRead: false,
);
await storage.saveMessages(
[message],
messageReceptionDetails: {
message.id: MessageReceptionDetails(
capturedAt: DateTime.fromMillisecondsSinceEpoch(1700000100600),
packetLoggedAt: DateTime.fromMillisecondsSinceEpoch(1700000100400),
rssiDbm: -91,
snrDb: 7.25,
pathBytes: const [0xAA, 0xBB, 0xCC, 0xDD],
senderToReceiptMs: 1200,
estimatedTransmitMs: 800,
postTransmitDelayMs: 400,
),
},
);
final prefs = await SharedPreferences.getInstance();
await prefs.remove('stored_message_reception_details');
final restoredDetails = await storage.loadMessageReceptionDetails();
final restored = restoredDetails[message.id];
expect(restored, isNotNull);
expect(restored!.pathBytes, [0xAA, 0xBB, 0xCC, 0xDD]);
expect(restored.rssiDbm, -91);
expect(restored.snrDb, 7.25);
expect(restored.senderToReceiptMs, 1200);
expect(restored.estimatedTransmitMs, 800);
expect(restored.postTransmitDelayMs, 400);
expect(
restored.packetLoggedAt,
DateTime.fromMillisecondsSinceEpoch(1700000100400),
);
});
}