feat: Estimate contact location from RSSI + last-hop repeater

For contacts without GPS, estimate their position by:
1. Finding the first-hop repeater from their routing path
2. Estimating distance from RSSI using log-distance path loss model
3. Placing them at a deterministic offset from the repeater

Uses LoRa outdoor path loss exponent (n=3.0) with reference RSSI
-30dBm at 1m. Distance clamped to 10m-50km range. Bearing derived
from contact public key hash for stable positioning.
This commit is contained in:
Janez T
2026-03-17 11:08:56 +01:00
parent c01ef7a059
commit d29b85feb8
5 changed files with 161 additions and 3 deletions

View File

@@ -18,6 +18,7 @@ import '../services/messaging_route_preferences.dart';
import '../services/nearest_router_selector.dart';
import '../services/packet_capture_storage_service.dart';
import '../services/path_history_service.dart';
import '../utils/rssi_location_estimator.dart';
import '../services/profiles_feature_service.dart';
import '../services/route_hash_preferences.dart';
import '../services/notification_service.dart';
@@ -1175,6 +1176,18 @@ class AppProvider with ChangeNotifier {
);
}
// Estimate location for contacts without GPS using RSSI + last-hop repeater
if (senderContact != null &&
senderContact.displayLocation == null &&
receptionDetailsSnapshot?.rssiDbm != null &&
senderContact.routeHasPath &&
senderContact.routeHopCount > 0) {
_estimateContactLocationFromRssi(
contact: senderContact,
rssiDbm: receptionDetailsSnapshot!.rssiDbm!,
);
}
// Check if message is a drawing broadcast
if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) {
debugPrint('🎨 [AppProvider] Drawing message received, parsing...');
@@ -2003,6 +2016,46 @@ class AppProvider with ChangeNotifier {
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
}
/// Estimate a contact's location from the last-hop repeater + RSSI distance.
void _estimateContactLocationFromRssi({
required Contact contact,
required int rssiDbm,
}) {
// Find the first-hop repeater from the contact's path
final pathHopCount = contact.routeHopCount;
final pathHashSize = contact.routeHashSize;
if (pathHopCount <= 0 || pathHashSize <= 0) return;
// Get first hop hash from path
final pathBytes = contact.routePathBytes;
if (pathBytes.length < pathHashSize) return;
final firstHopHash = pathBytes
.sublist(0, pathHashSize)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
// Find a repeater whose public key starts with this hash
Contact? lastHopRepeater;
for (final c in contactsProvider.contacts) {
if (c.publicKeyHex.toLowerCase().startsWith(firstHopHash) &&
c.displayLocation != null) {
lastHopRepeater = c;
break;
}
}
if (lastHopRepeater == null) return;
final estimated = RssiLocationEstimator.estimateFromRepeater(
repeaterLocation: lastHopRepeater.displayLocation!,
rssiDbm: rssiDbm,
contactPublicKey: contact.publicKey,
);
if (estimated != null) {
contactsProvider.setEstimatedLocation(contact.publicKeyHex, estimated);
}
}
Future<void> _learnPathFromPublicMessage({
required Contact contact,
required List<int> pathBytes,

View File

@@ -128,6 +128,7 @@ class ContactsProvider with ChangeNotifier {
final Map<String, Contact> _contacts = {};
final List<SavedContactGroup> _savedContactGroups = <SavedContactGroup>[];
final Map<String, PendingAdvert> _pendingAdverts = {};
final Map<String, LatLng> _estimatedLocations = {};
final ContactStorageService _storageService = ContactStorageService();
bool _isInitialized = false;
bool _isPersisting = false;
@@ -501,13 +502,33 @@ class ContactsProvider with ChangeNotifier {
..sort(_sortByLastSeen);
}
/// All estimated locations (for passing to map marker service).
Map<String, LatLng> get estimatedLocations =>
Map.unmodifiable(_estimatedLocations);
/// Get estimated location for a contact (RSSI-based, near last-hop repeater).
LatLng? estimatedLocationFor(String publicKeyHex) =>
_estimatedLocations[publicKeyHex];
/// Set an estimated location for a contact that has no GPS.
void setEstimatedLocation(String publicKeyHex, LatLng location) {
_estimatedLocations[publicKeyHex] = location;
notifyListeners();
}
/// Effective location: own GPS/advert > estimated from RSSI.
LatLng? effectiveLocationFor(Contact contact) {
return contact.displayLocation ?? _estimatedLocations[contact.publicKeyHex];
}
/// Get contacts with location (for map display)
/// Includes contacts with estimated locations from RSSI.
List<Contact> get contactsWithLocation =>
contacts.where((c) => c.displayLocation != null).toList();
contacts.where((c) => effectiveLocationFor(c) != null).toList();
/// Get chat contacts with location (team members on map)
List<Contact> get chatContactsWithLocation =>
chatContacts.where((c) => c.displayLocation != null).toList();
chatContacts.where((c) => effectiveLocationFor(c) != null).toList();
MessageContactLocation? buildMessageContactLocationSnapshot(
Contact contact, {