mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
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:
@@ -2016,17 +2016,15 @@ class AppProvider with ChangeNotifier {
|
|||||||
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
|
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({
|
void _estimateContactLocationFromRssi({
|
||||||
required Contact contact,
|
required Contact contact,
|
||||||
required int rssiDbm,
|
required int rssiDbm,
|
||||||
}) {
|
}) {
|
||||||
// Find the first-hop repeater from the contact's path
|
|
||||||
final pathHopCount = contact.routeHopCount;
|
final pathHopCount = contact.routeHopCount;
|
||||||
final pathHashSize = contact.routeHashSize;
|
final pathHashSize = contact.routeHashSize;
|
||||||
if (pathHopCount <= 0 || pathHashSize <= 0) return;
|
if (pathHopCount <= 0 || pathHashSize <= 0) return;
|
||||||
|
|
||||||
// Get first hop hash from path
|
|
||||||
final pathBytes = contact.routePathBytes;
|
final pathBytes = contact.routePathBytes;
|
||||||
if (pathBytes.length < pathHashSize) return;
|
if (pathBytes.length < pathHashSize) return;
|
||||||
final firstHopHash = pathBytes
|
final firstHopHash = pathBytes
|
||||||
@@ -2035,7 +2033,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
.join()
|
.join()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
|
|
||||||
// Find a repeater whose public key starts with this hash
|
|
||||||
Contact? lastHopRepeater;
|
Contact? lastHopRepeater;
|
||||||
for (final c in contactsProvider.contacts) {
|
for (final c in contactsProvider.contacts) {
|
||||||
if (c.publicKeyHex.toLowerCase().startsWith(firstHopHash) &&
|
if (c.publicKeyHex.toLowerCase().startsWith(firstHopHash) &&
|
||||||
@@ -2046,14 +2043,16 @@ class AppProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
if (lastHopRepeater == null) return;
|
if (lastHopRepeater == null) return;
|
||||||
|
|
||||||
final estimated = RssiLocationEstimator.estimateFromRepeater(
|
// Record observation for trilateration
|
||||||
repeaterLocation: lastHopRepeater.displayLocation!,
|
contactsProvider.addRssiObservation(
|
||||||
rssiDbm: rssiDbm,
|
contactPublicKeyHex: contact.publicKeyHex,
|
||||||
contactPublicKey: contact.publicKey,
|
contactPublicKey: contact.publicKey,
|
||||||
|
observation: RssiObservation(
|
||||||
|
repeaterLocation: lastHopRepeater.displayLocation!,
|
||||||
|
rssiDbm: rssiDbm,
|
||||||
|
observedAt: DateTime.now(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (estimated != null) {
|
|
||||||
contactsProvider.setEstimatedLocation(contact.publicKeyHex, estimated);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _learnPathFromPublicMessage({
|
Future<void> _learnPathFromPublicMessage({
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import '../models/message_contact_location.dart';
|
|||||||
import '../services/cayenne_lpp_parser.dart';
|
import '../services/cayenne_lpp_parser.dart';
|
||||||
import '../services/contact_storage_service.dart';
|
import '../services/contact_storage_service.dart';
|
||||||
import '../utils/fast_gps_packet.dart';
|
import '../utils/fast_gps_packet.dart';
|
||||||
|
import '../utils/rssi_location_estimator.dart';
|
||||||
import '../utils/key_comparison.dart';
|
import '../utils/key_comparison.dart';
|
||||||
|
|
||||||
class PendingAdvert {
|
class PendingAdvert {
|
||||||
@@ -129,6 +130,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
final List<SavedContactGroup> _savedContactGroups = <SavedContactGroup>[];
|
final List<SavedContactGroup> _savedContactGroups = <SavedContactGroup>[];
|
||||||
final Map<String, PendingAdvert> _pendingAdverts = {};
|
final Map<String, PendingAdvert> _pendingAdverts = {};
|
||||||
final Map<String, LatLng> _estimatedLocations = {};
|
final Map<String, LatLng> _estimatedLocations = {};
|
||||||
|
final Map<String, List<RssiObservation>> _rssiObservations = {};
|
||||||
final ContactStorageService _storageService = ContactStorageService();
|
final ContactStorageService _storageService = ContactStorageService();
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
bool _isPersisting = false;
|
bool _isPersisting = false;
|
||||||
@@ -516,6 +518,44 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
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.
|
/// Effective location: own GPS/advert > estimated from RSSI.
|
||||||
LatLng? effectiveLocationFor(Contact contact) {
|
LatLng? effectiveLocationFor(Contact contact) {
|
||||||
return contact.displayLocation ?? _estimatedLocations[contact.publicKeyHex];
|
return contact.displayLocation ?? _estimatedLocations[contact.publicKeyHex];
|
||||||
|
|||||||
@@ -1,6 +1,22 @@
|
|||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'package:latlong2/latlong.dart';
|
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.
|
/// Estimate distance from RSSI using the log-distance path loss model.
|
||||||
///
|
///
|
||||||
/// For LoRa at ~900 MHz:
|
/// 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)
|
/// - Reference distance: 1m, reference RSSI: -30 dBm (typical LoRa at 1m)
|
||||||
///
|
///
|
||||||
/// Formula: distance = 10 ^ ((txPower - rssi) / (10 * n))
|
/// Formula: distance = 10 ^ ((txPower - rssi) / (10 * n))
|
||||||
/// We use a simplified form with empirical constants for LoRa mesh.
|
|
||||||
class RssiLocationEstimator {
|
class RssiLocationEstimator {
|
||||||
/// Estimate distance in meters from RSSI value.
|
/// Estimate distance in meters from RSSI value.
|
||||||
///
|
|
||||||
/// Returns null if RSSI is not usable.
|
|
||||||
static double? estimateDistanceMeters(int rssiDbm) {
|
static double? estimateDistanceMeters(int rssiDbm) {
|
||||||
// RSSI values above -30 are unrealistic for LoRa
|
|
||||||
if (rssiDbm > -20 || rssiDbm < -140) return null;
|
if (rssiDbm > -20 || rssiDbm < -140) return null;
|
||||||
|
|
||||||
// Log-distance path loss model parameters for LoRa outdoor
|
const double referenceRssi = -30.0;
|
||||||
const double referenceRssi = -30.0; // RSSI at 1 meter
|
const double pathLossExponent = 3.0;
|
||||||
const double pathLossExponent = 3.0; // outdoor mixed terrain
|
|
||||||
|
|
||||||
final distance = math.pow(
|
final distance = math.pow(
|
||||||
10.0,
|
10.0,
|
||||||
(referenceRssi - rssiDbm) / (10.0 * pathLossExponent),
|
(referenceRssi - rssiDbm) / (10.0 * pathLossExponent),
|
||||||
).toDouble();
|
).toDouble();
|
||||||
|
|
||||||
// Clamp to reasonable range (10m - 50km)
|
|
||||||
return distance.clamp(10.0, 50000.0);
|
return distance.clamp(10.0, 50000.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Offset a point by a distance and bearing.
|
/// Offset a point by distance and bearing (Haversine inverse).
|
||||||
///
|
|
||||||
/// Uses Haversine inverse to compute the destination point.
|
|
||||||
static LatLng offsetPoint(
|
static LatLng offsetPoint(
|
||||||
LatLng origin,
|
LatLng origin,
|
||||||
double distanceMeters,
|
double distanceMeters,
|
||||||
double bearingDegrees,
|
double bearingDegrees,
|
||||||
) {
|
) {
|
||||||
const double earthRadius = 6371000.0; // meters
|
const double earthRadius = 6371000.0;
|
||||||
final lat1 = origin.latitude * math.pi / 180.0;
|
final lat1 = origin.latitude * math.pi / 180.0;
|
||||||
final lon1 = origin.longitude * math.pi / 180.0;
|
final lon1 = origin.longitude * math.pi / 180.0;
|
||||||
final bearing = bearingDegrees * math.pi / 180.0;
|
final bearing = bearingDegrees * math.pi / 180.0;
|
||||||
final angularDistance = distanceMeters / earthRadius;
|
final angDist = distanceMeters / earthRadius;
|
||||||
|
|
||||||
final lat2 = math.asin(
|
final lat2 = math.asin(
|
||||||
math.sin(lat1) * math.cos(angularDistance) +
|
math.sin(lat1) * math.cos(angDist) +
|
||||||
math.cos(lat1) * math.sin(angularDistance) * math.cos(bearing),
|
math.cos(lat1) * math.sin(angDist) * math.cos(bearing),
|
||||||
);
|
);
|
||||||
final lon2 = lon1 +
|
final lon2 = lon1 +
|
||||||
math.atan2(
|
math.atan2(
|
||||||
math.sin(bearing) * math.sin(angularDistance) * math.cos(lat1),
|
math.sin(bearing) * math.sin(angDist) * math.cos(lat1),
|
||||||
math.cos(angularDistance) - math.sin(lat1) * math.sin(lat2),
|
math.cos(angDist) - math.sin(lat1) * math.sin(lat2),
|
||||||
);
|
);
|
||||||
|
|
||||||
return LatLng(lat2 * 180.0 / math.pi, lon2 * 180.0 / math.pi);
|
return LatLng(lat2 * 180.0 / math.pi, lon2 * 180.0 / math.pi);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Estimate a contact's location based on the last-hop repeater position
|
/// Single-repeater estimate: offset from repeater by RSSI distance.
|
||||||
/// 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({
|
static LatLng? estimateFromRepeater({
|
||||||
required LatLng repeaterLocation,
|
required LatLng repeaterLocation,
|
||||||
required int rssiDbm,
|
required int rssiDbm,
|
||||||
@@ -72,10 +74,87 @@ class RssiLocationEstimator {
|
|||||||
final distance = estimateDistanceMeters(rssiDbm);
|
final distance = estimateDistanceMeters(rssiDbm);
|
||||||
if (distance == null) return null;
|
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 keyHash = contactPublicKey.fold<int>(0, (a, b) => a ^ b);
|
||||||
final bearing = (keyHash % 360).toDouble();
|
final bearing = (keyHash % 360).toDouble();
|
||||||
|
|
||||||
return offsetPoint(repeaterLocation, distance, bearing);
|
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<RssiObservation> observations,
|
||||||
|
required List<int> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user