diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 27fec2e..5861c87 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -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 _learnPathFromPublicMessage({ required Contact contact, required List pathBytes, diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index 1873964..57d2044 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -128,6 +128,7 @@ class ContactsProvider with ChangeNotifier { final Map _contacts = {}; final List _savedContactGroups = []; final Map _pendingAdverts = {}; + final Map _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 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 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 get chatContactsWithLocation => - chatContacts.where((c) => c.displayLocation != null).toList(); + chatContacts.where((c) => effectiveLocationFor(c) != null).toList(); MessageContactLocation? buildMessageContactLocationSnapshot( Contact contact, { diff --git a/lib/screens/map_tab.dart b/lib/screens/map_tab.dart index 83c8413..6be52be 100644 --- a/lib/screens/map_tab.dart +++ b/lib/screens/map_tab.dart @@ -2604,6 +2604,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { context: context, mapRotation: _getMapRotation(), userPosition: _locationService.currentPosition, + estimatedLocations: contactsProvider.estimatedLocations, onTap: (contact) { _showDetailedCompassWithContact( context, diff --git a/lib/services/map_marker_service.dart b/lib/services/map_marker_service.dart index 0f319ab..3e5bafe 100644 --- a/lib/services/map_marker_service.dart +++ b/lib/services/map_marker_service.dart @@ -41,10 +41,12 @@ class MapMarkerService { Function(Contact)? onTap, double mapRotation = 0, Position? userPosition, + Map? estimatedLocations, }) { return contacts .map((contact) { - final location = contact.displayLocation; + final location = contact.displayLocation ?? + estimatedLocations?[contact.publicKeyHex]; if (location == null) return null; return Marker( diff --git a/lib/utils/rssi_location_estimator.dart b/lib/utils/rssi_location_estimator.dart new file mode 100644 index 0000000..90920ae --- /dev/null +++ b/lib/utils/rssi_location_estimator.dart @@ -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 contactPublicKey, + }) { + final distance = estimateDistanceMeters(rssiDbm); + if (distance == null) return null; + + // Deterministic bearing from contact key so position is stable + final keyHash = contactPublicKey.fold(0, (a, b) => a ^ b); + final bearing = (keyHash % 360).toDouble(); + + return offsetPoint(repeaterLocation, distance, bearing); + } +}