From b48139964a9f34cefeeba4a73ba333cbdd47f2cf Mon Sep 17 00:00:00 2001 From: Janez T Date: Tue, 17 Mar 2026 11:15:51 +0100 Subject: [PATCH] feat: RSSI trilateration from multiple repeaters When a contact has no GPS, estimate their position from RSSI observations through multiple repeaters: - 1 repeater: offset by RSSI distance in deterministic direction - 2 repeaters: weighted midpoint between circle intersections - 3+ repeaters: weighted centroid with inverse-square weighting (closer repeater observations dominate) Observations stored per-repeater (latest wins), max 8 per contact, expire after 30 minutes. Each incoming message updates the estimate. --- lib/providers/app_provider.dart | 19 ++-- lib/providers/contacts_provider.dart | 40 ++++++++ lib/utils/rssi_location_estimator.dart | 129 ++++++++++++++++++++----- 3 files changed, 153 insertions(+), 35 deletions(-) diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 5861c87..0daae79 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -2016,17 +2016,15 @@ 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. + /// Record RSSI observation from last-hop repeater for trilateration. 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 @@ -2035,7 +2033,6 @@ class AppProvider with ChangeNotifier { .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) && @@ -2046,14 +2043,16 @@ class AppProvider with ChangeNotifier { } if (lastHopRepeater == null) return; - final estimated = RssiLocationEstimator.estimateFromRepeater( - repeaterLocation: lastHopRepeater.displayLocation!, - rssiDbm: rssiDbm, + // Record observation for trilateration + contactsProvider.addRssiObservation( + contactPublicKeyHex: contact.publicKeyHex, contactPublicKey: contact.publicKey, + observation: RssiObservation( + repeaterLocation: lastHopRepeater.displayLocation!, + rssiDbm: rssiDbm, + observedAt: DateTime.now(), + ), ); - if (estimated != null) { - contactsProvider.setEstimatedLocation(contact.publicKeyHex, estimated); - } } Future _learnPathFromPublicMessage({ diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index 57d2044..15039f0 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -8,6 +8,7 @@ import '../models/message_contact_location.dart'; import '../services/cayenne_lpp_parser.dart'; import '../services/contact_storage_service.dart'; import '../utils/fast_gps_packet.dart'; +import '../utils/rssi_location_estimator.dart'; import '../utils/key_comparison.dart'; class PendingAdvert { @@ -129,6 +130,7 @@ class ContactsProvider with ChangeNotifier { final List _savedContactGroups = []; final Map _pendingAdverts = {}; final Map _estimatedLocations = {}; + final Map> _rssiObservations = {}; final ContactStorageService _storageService = ContactStorageService(); bool _isInitialized = false; bool _isPersisting = false; @@ -516,6 +518,44 @@ class ContactsProvider with ChangeNotifier { notifyListeners(); } + /// Record an RSSI observation and re-trilaterate the contact's position. + /// + /// Keeps up to 5 observations per unique repeater (latest wins). + /// With multiple repeaters, trilateration produces a better estimate. + void addRssiObservation({ + required String contactPublicKeyHex, + required List contactPublicKey, + required RssiObservation observation, + }) { + final observations = _rssiObservations.putIfAbsent( + contactPublicKeyHex, + () => [], + ); + + // Replace existing observation from the same repeater, or add new + final repeaterKey = + '${observation.repeaterLocation.latitude},${observation.repeaterLocation.longitude}'; + observations.removeWhere((o) => + '${o.repeaterLocation.latitude},${o.repeaterLocation.longitude}' == + repeaterKey); + observations.add(observation); + + // Keep at most 8 observations (most recent per repeater) + if (observations.length > 8) { + observations.sort((a, b) => b.observedAt.compareTo(a.observedAt)); + observations.removeRange(8, observations.length); + } + + final estimated = RssiLocationEstimator.trilaterate( + observations: observations, + contactPublicKey: contactPublicKey, + ); + if (estimated != null) { + _estimatedLocations[contactPublicKeyHex] = estimated; + notifyListeners(); + } + } + /// Effective location: own GPS/advert > estimated from RSSI. LatLng? effectiveLocationFor(Contact contact) { return contact.displayLocation ?? _estimatedLocations[contact.publicKeyHex]; diff --git a/lib/utils/rssi_location_estimator.dart b/lib/utils/rssi_location_estimator.dart index 90920ae..d2be4de 100644 --- a/lib/utils/rssi_location_estimator.dart +++ b/lib/utils/rssi_location_estimator.dart @@ -1,6 +1,22 @@ import 'dart:math' as math; import 'package:latlong2/latlong.dart'; +/// An RSSI observation from a single repeater. +class RssiObservation { + final LatLng repeaterLocation; + final int rssiDbm; + final DateTime observedAt; + + const RssiObservation({ + required this.repeaterLocation, + required this.rssiDbm, + required this.observedAt, + }); + + double? get estimatedDistanceMeters => + RssiLocationEstimator.estimateDistanceMeters(rssiDbm); +} + /// Estimate distance from RSSI using the log-distance path loss model. /// /// For LoRa at ~900 MHz: @@ -8,62 +24,48 @@ import 'package:latlong2/latlong.dart'; /// - 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 + const double referenceRssi = -30.0; + const double pathLossExponent = 3.0; 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. + /// Offset a point by distance and bearing (Haversine inverse). static LatLng offsetPoint( LatLng origin, double distanceMeters, double bearingDegrees, ) { - const double earthRadius = 6371000.0; // meters + const double earthRadius = 6371000.0; 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 angDist = distanceMeters / earthRadius; final lat2 = math.asin( - math.sin(lat1) * math.cos(angularDistance) + - math.cos(lat1) * math.sin(angularDistance) * math.cos(bearing), + math.sin(lat1) * math.cos(angDist) + + math.cos(lat1) * math.sin(angDist) * 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), + math.sin(bearing) * math.sin(angDist) * math.cos(lat1), + math.cos(angDist) - 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. + /// Single-repeater estimate: offset from repeater by RSSI distance. static LatLng? estimateFromRepeater({ required LatLng repeaterLocation, required int rssiDbm, @@ -72,10 +74,87 @@ class RssiLocationEstimator { 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); } + + /// Trilateration from multiple RSSI observations. + /// + /// With 1 observation: offset from repeater (bearing from key hash). + /// With 2 observations: weighted midpoint on the line between circles. + /// With 3+ observations: weighted centroid of circle intersection region. + /// + /// Each observation is weighted by 1/distance² (closer = more accurate). + static LatLng? trilaterate({ + required List observations, + required List contactPublicKey, + Duration maxAge = const Duration(minutes: 30), + }) { + final now = DateTime.now(); + final recent = observations + .where((o) => + now.difference(o.observedAt) <= maxAge && + o.estimatedDistanceMeters != null) + .toList(); + + if (recent.isEmpty) return null; + + if (recent.length == 1) { + return estimateFromRepeater( + repeaterLocation: recent.first.repeaterLocation, + rssiDbm: recent.first.rssiDbm, + contactPublicKey: contactPublicKey, + ); + } + + // Weighted centroid: each repeater contributes a candidate point + // on the circle towards the centroid of all repeaters. + // Weight = 1/distance² (inverse square — closer observations dominate). + + // Step 1: compute raw centroid of all repeater locations + double centroidLat = 0, centroidLon = 0; + for (final obs in recent) { + centroidLat += obs.repeaterLocation.latitude; + centroidLon += obs.repeaterLocation.longitude; + } + centroidLat /= recent.length; + centroidLon /= recent.length; + final centroid = LatLng(centroidLat, centroidLon); + + // Step 2: for each observation, compute a candidate point on the + // circle (at RSSI distance) in the direction of the centroid. + double weightedLat = 0, weightedLon = 0, totalWeight = 0; + + for (final obs in recent) { + final dist = obs.estimatedDistanceMeters!; + final weight = 1.0 / (dist * dist); + + // Bearing from this repeater towards the centroid + final bearing = _bearingDegrees(obs.repeaterLocation, centroid); + final candidate = offsetPoint(obs.repeaterLocation, dist, bearing); + + weightedLat += candidate.latitude * weight; + weightedLon += candidate.longitude * weight; + totalWeight += weight; + } + + if (totalWeight <= 0) return null; + + return LatLng(weightedLat / totalWeight, weightedLon / totalWeight); + } + + /// Bearing in degrees from point A to point B. + static double _bearingDegrees(LatLng a, LatLng b) { + final lat1 = a.latitude * math.pi / 180.0; + final lat2 = b.latitude * math.pi / 180.0; + final dLon = (b.longitude - a.longitude) * math.pi / 180.0; + + final y = math.sin(dLon) * math.cos(lat2); + final x = math.cos(lat1) * math.sin(lat2) - + math.sin(lat1) * math.cos(lat2) * math.cos(dLon); + + return (math.atan2(y, x) * 180.0 / math.pi + 360.0) % 360.0; + } }