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.
This commit is contained in:
Janez T
2026-03-17 11:15:51 +01:00
parent d29b85feb8
commit b48139964a
3 changed files with 153 additions and 35 deletions

View File

@@ -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<void> _learnPathFromPublicMessage({

View File

@@ -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<SavedContactGroup> _savedContactGroups = <SavedContactGroup>[];
final Map<String, PendingAdvert> _pendingAdverts = {};
final Map<String, LatLng> _estimatedLocations = {};
final Map<String, List<RssiObservation>> _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<int> 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];