mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -2604,6 +2604,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
||||
context: context,
|
||||
mapRotation: _getMapRotation(),
|
||||
userPosition: _locationService.currentPosition,
|
||||
estimatedLocations: contactsProvider.estimatedLocations,
|
||||
onTap: (contact) {
|
||||
_showDetailedCompassWithContact(
|
||||
context,
|
||||
|
||||
@@ -41,10 +41,12 @@ class MapMarkerService {
|
||||
Function(Contact)? onTap,
|
||||
double mapRotation = 0,
|
||||
Position? userPosition,
|
||||
Map<String, LatLng>? estimatedLocations,
|
||||
}) {
|
||||
return contacts
|
||||
.map((contact) {
|
||||
final location = contact.displayLocation;
|
||||
final location = contact.displayLocation ??
|
||||
estimatedLocations?[contact.publicKeyHex];
|
||||
if (location == null) return null;
|
||||
|
||||
return Marker(
|
||||
|
||||
81
lib/utils/rssi_location_estimator.dart
Normal file
81
lib/utils/rssi_location_estimator.dart
Normal file
@@ -0,0 +1,81 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
/// Estimate distance from RSSI using the log-distance path loss model.
|
||||
///
|
||||
/// For LoRa at ~900 MHz:
|
||||
/// - Path loss exponent (n) ≈ 2.7-3.5 for outdoor environments
|
||||
/// - Reference distance: 1m, reference RSSI: -30 dBm (typical LoRa at 1m)
|
||||
///
|
||||
/// Formula: distance = 10 ^ ((txPower - rssi) / (10 * n))
|
||||
/// We use a simplified form with empirical constants for LoRa mesh.
|
||||
class RssiLocationEstimator {
|
||||
/// Estimate distance in meters from RSSI value.
|
||||
///
|
||||
/// Returns null if RSSI is not usable.
|
||||
static double? estimateDistanceMeters(int rssiDbm) {
|
||||
// RSSI values above -30 are unrealistic for LoRa
|
||||
if (rssiDbm > -20 || rssiDbm < -140) return null;
|
||||
|
||||
// Log-distance path loss model parameters for LoRa outdoor
|
||||
const double referenceRssi = -30.0; // RSSI at 1 meter
|
||||
const double pathLossExponent = 3.0; // outdoor mixed terrain
|
||||
|
||||
final distance = math.pow(
|
||||
10.0,
|
||||
(referenceRssi - rssiDbm) / (10.0 * pathLossExponent),
|
||||
).toDouble();
|
||||
|
||||
// Clamp to reasonable range (10m - 50km)
|
||||
return distance.clamp(10.0, 50000.0);
|
||||
}
|
||||
|
||||
/// Offset a point by a distance and bearing.
|
||||
///
|
||||
/// Uses Haversine inverse to compute the destination point.
|
||||
static LatLng offsetPoint(
|
||||
LatLng origin,
|
||||
double distanceMeters,
|
||||
double bearingDegrees,
|
||||
) {
|
||||
const double earthRadius = 6371000.0; // meters
|
||||
final lat1 = origin.latitude * math.pi / 180.0;
|
||||
final lon1 = origin.longitude * math.pi / 180.0;
|
||||
final bearing = bearingDegrees * math.pi / 180.0;
|
||||
final angularDistance = distanceMeters / earthRadius;
|
||||
|
||||
final lat2 = math.asin(
|
||||
math.sin(lat1) * math.cos(angularDistance) +
|
||||
math.cos(lat1) * math.sin(angularDistance) * math.cos(bearing),
|
||||
);
|
||||
final lon2 = lon1 +
|
||||
math.atan2(
|
||||
math.sin(bearing) * math.sin(angularDistance) * math.cos(lat1),
|
||||
math.cos(angularDistance) - math.sin(lat1) * math.sin(lat2),
|
||||
);
|
||||
|
||||
return LatLng(lat2 * 180.0 / math.pi, lon2 * 180.0 / math.pi);
|
||||
}
|
||||
|
||||
/// Estimate a contact's location based on the last-hop repeater position
|
||||
/// and the received signal strength.
|
||||
///
|
||||
/// Returns null if estimation is not possible (no repeater location or RSSI).
|
||||
///
|
||||
/// Uses a deterministic bearing derived from the contact's public key hash
|
||||
/// so the same contact always appears in the same direction from the repeater.
|
||||
static LatLng? estimateFromRepeater({
|
||||
required LatLng repeaterLocation,
|
||||
required int rssiDbm,
|
||||
required List<int> contactPublicKey,
|
||||
}) {
|
||||
final distance = estimateDistanceMeters(rssiDbm);
|
||||
if (distance == null) return null;
|
||||
|
||||
// Deterministic bearing from contact key so position is stable
|
||||
final keyHash = contactPublicKey.fold<int>(0, (a, b) => a ^ b);
|
||||
final bearing = (keyHash % 360).toDouble();
|
||||
|
||||
return offsetPoint(repeaterLocation, distance, bearing);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user