From 3c08e150ef75f41c4e05d754f4b587e471e0caf4 Mon Sep 17 00:00:00 2001 From: Janez T Date: Wed, 24 Jun 2026 13:17:02 +0200 Subject: [PATCH] feat: align fast-GPS beacons with MeshUI firmware (16-byte format + cadence parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch the fast-GPS beacon to the firmware's canonical 16-byte wire format (magic, key6, lat/lon microdegrees, speed km/h) — no on-wire timestamp; the receiver stamps its own RX time. Drops compatibility with the older 19-byte app format. Mirror the firmware's adaptive send cadence in the app-fallback path (LocationTrackingService): anchor + dwell motion hysteresis, EMA ground speed, 9-min stationary keepalive, fix-quality (accuracy) gate, and a post-RX hold-off to avoid channel collisions. Parked nodes now beacon the stable anchor instead of GPS wander. Add the fast_gps_region config: region-scope picker in settings backed by the firmware's fast_gps_region / fast_gps_regions custom vars. Co-Authored-By: Claude Opus 4.8 --- lib/providers/app_provider.dart | 95 +++++- lib/providers/contacts_provider.dart | 14 +- lib/screens/settings_screen.dart | 62 ++++ lib/services/location_tracking_service.dart | 352 +++++++++++++++----- lib/utils/fast_gps_packet.dart | 29 +- test/providers/contacts_provider_test.dart | 10 +- test/utils/fast_gps_packet_test.dart | 41 ++- 7 files changed, 490 insertions(+), 113 deletions(-) diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 07440dc..ade792b 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -147,6 +147,22 @@ class ChannelLocationSharingResult { }); } +/// Radio fast-GPS region scope, mirroring the firmware's `fast_gps_region` +/// custom var. Index 0 is unscoped (flood everywhere); higher indices restrict +/// beacons to a named region. `labels` comes from the firmware so the picker +/// always matches the regions that build supports. +class FastGpsRegionState { + final List labels; + final int selectedIndex; + + const FastGpsRegionState({required this.labels, required this.selectedIndex}); + + String get selectedLabel => + (selectedIndex >= 0 && selectedIndex < labels.length) + ? labels[selectedIndex] + : (labels.isNotEmpty ? labels.first : 'Unscoped'); +} + /// Main App Provider - coordinates all other providers class AppProvider with ChangeNotifier { static const int _maxDirectPayloadHops = 3; @@ -981,9 +997,17 @@ class AppProvider with ChangeNotifier { ); }; - locationTrackingService.onFastLocationUpdate = (position, reason) { - unawaited(_sendFastLocationUpdate(position, reason: reason)); - }; + locationTrackingService.onFastLocationUpdate = + (latitude, longitude, speedKmh, reason) { + unawaited( + _sendFastLocationUpdate( + latitude: latitude, + longitude: longitude, + speedKmh: speedKmh, + reason: reason, + ), + ); + }; debugPrint('✅ [AppProvider] Location tracking service initialized'); } catch (e) { @@ -2903,6 +2927,34 @@ class AppProvider with ChangeNotifier { } } + /// Reads the radio's fast-GPS region scope, or `null` when the firmware does + /// not expose region scoping (older builds only know `fast_gps_channel`). + Future getFastGpsRegionState() async { + if (!connectionProvider.deviceInfo.isConnected) { + return null; + } + final vars = await connectionProvider.getCustomVars(); + final labelsRaw = vars['fast_gps_regions']; + if (labelsRaw == null || labelsRaw.isEmpty) { + return null; + } + final labels = labelsRaw.split(','); + final index = int.tryParse(vars['fast_gps_region'] ?? '0') ?? 0; + return FastGpsRegionState( + labels: labels, + selectedIndex: (index >= 0 && index < labels.length) ? index : 0, + ); + } + + /// Sets the radio's fast-GPS region scope by index (0 = unscoped). + Future setFastGpsRegion(int index) async { + if (!connectionProvider.deviceInfo.isConnected) { + throw StateError('Connect to a device first'); + } + await _setDeviceCustomVarOrThrow('fast_gps_region', index.toString()); + notifyListeners(); + } + Future getChannelLocationSharingState( int channelIdx, ) async { @@ -3074,8 +3126,10 @@ class AppProvider with ChangeNotifier { ); } - Future _sendFastLocationUpdate( - dynamic position, { + Future _sendFastLocationUpdate({ + required double latitude, + required double longitude, + required int speedKmh, required String reason, }) async { if (!connectionProvider.deviceInfo.isConnected) { @@ -3108,20 +3162,21 @@ class AppProvider with ChangeNotifier { .sublist(0, 6) .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(); + final clampedSpeedKmh = speedKmh.clamp(0, 255); final packet = FastGpsPacket( senderKey6: senderKey6, - latitude: position.latitude as double, - longitude: position.longitude as double, - timestampSeconds: DateTime.now().millisecondsSinceEpoch ~/ 1000, + latitude: latitude, + longitude: longitude, + speedKmh: clampedSpeedKmh, ); debugPrint( '📤 [AppProvider] Fast GPS send ' 'reason=$reason ' 'sender=$senderKey6 ' 'channel=$channelIdx ' - 'lat=${position.latitude} ' - 'lon=${position.longitude} ' - 'ts=${packet.timestampSeconds}', + 'lat=$latitude ' + 'lon=$longitude ' + 'speed=${clampedSpeedKmh}km/h', ); try { await connectionProvider.sendChannelData( @@ -3131,7 +3186,7 @@ class AppProvider with ChangeNotifier { ); debugPrint( '✅ [AppProvider] Fast GPS sent ' - 'sender=$senderKey6 channel=$channelIdx ts=${packet.timestampSeconds}', + 'sender=$senderKey6 channel=$channelIdx speed=${speedKmh}km/h', ); } catch (e) { debugPrint('⚠️ [AppProvider] Fast GPS send failed: $e'); @@ -3151,7 +3206,17 @@ class AppProvider with ChangeNotifier { return false; } - await _sendFastLocationUpdate(position, reason: 'test'); + // Geolocator reports ground speed in m/s; the beacon carries km/h (0..255). + final speedMs = position.speed; + final speedKmh = (speedMs.isFinite && speedMs > 0) + ? (speedMs * 3.6).round().clamp(0, 255) + : 0; + await _sendFastLocationUpdate( + latitude: position.latitude, + longitude: position.longitude, + speedKmh: speedKmh, + reason: 'test', + ); return true; } @@ -3693,6 +3758,10 @@ class AppProvider with ChangeNotifier { return false; } + // Heard a peer beacon — yield the channel briefly before our own send, to + // avoid collisions (mirrors the firmware's post-RX hold-off). + locationTrackingService.noteFastGpsBeaconHeard(); + final sender = _resolveContactByPrefixHex(fastGpsPacket.senderKey6); if (sender != null) { contactsProvider.updateFastGps( diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index 6875da3..bef1adf 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -1164,6 +1164,10 @@ class ContactsProvider with ChangeNotifier { return; } + // The fast-GPS beacon carries no timestamp — stamp our own receive time. + final receivedAt = DateTime.now(); + final receivedAtSeconds = receivedAt.millisecondsSinceEpoch ~/ 1000; + final updatedTelemetry = _mergeTelemetryForContact( existingTelemetry: contact.telemetry, incomingTelemetry: ContactTelemetry( @@ -1171,9 +1175,7 @@ class ContactsProvider with ChangeNotifier { batteryPercentage: null, batteryMilliVolts: null, temperature: null, - timestamp: DateTime.fromMillisecondsSinceEpoch( - packet.timestampSeconds * 1000, - ), + timestamp: receivedAt, humidity: null, pressure: null, extraSensorData: null, @@ -1187,13 +1189,13 @@ class ContactsProvider with ChangeNotifier { 'contactKey=${contact.publicKeyHex} ' 'lat=${packet.latitude} ' 'lon=${packet.longitude} ' - 'ts=${packet.timestampSeconds}', + 'speed=${packet.speedKmh}km/h', ); final updatedContact = contact.copyWith( telemetry: updatedTelemetry, - lastAdvert: packet.timestampSeconds, - lastMod: packet.timestampSeconds, + lastAdvert: receivedAtSeconds, + lastMod: receivedAtSeconds, advLat: _coordinateToAdvertMicrodegrees(packet.latitude), advLon: _coordinateToAdvertMicrodegrees(packet.longitude), ); diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 39c8cea..75f3144 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -85,6 +85,7 @@ class _SettingsScreenState extends State { double _fastLocationMovementThresholdMeters = 10.0; int _fastLocationActiveCadenceSeconds = 10; int? _fastLocationChannelIdx; + FastGpsRegionState? _fastGpsRegionState; bool _rotateMapWithHeading = false; bool _showMapDebugInfo = false; bool _openMapInFullscreen = false; @@ -410,6 +411,58 @@ class _SettingsScreenState extends State { _locationService.fastLocationActiveCadenceSeconds; _fastLocationChannelIdx = _locationService.fastLocationChannelIdx; }); + await _loadFastGpsRegionState(); + } + + Future _loadFastGpsRegionState() async { + final appProvider = context.read(); + FastGpsRegionState? regionState; + try { + regionState = await appProvider.getFastGpsRegionState(); + } catch (_) { + regionState = null; + } + if (!mounted) return; + setState(() => _fastGpsRegionState = regionState); + } + + Future _editFastGpsRegion() async { + final regionState = _fastGpsRegionState; + if (regionState == null) return; + + final selected = await showModalBottomSheet( + context: context, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < regionState.labels.length; i++) + ListTile( + leading: Icon(i == 0 ? Icons.public : Icons.travel_explore), + title: Text(regionState.labels[i]), + trailing: regionState.selectedIndex == i + ? const Icon(Icons.check) + : null, + onTap: () => Navigator.pop(sheetContext, i), + ), + ], + ), + ), + ); + + if (selected == null || selected == regionState.selectedIndex) return; + try { + await context.read().setFastGpsRegion(selected); + await _loadFastGpsRegionState(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to set region: $e'), + backgroundColor: Colors.orange, + ), + ); + } } Future _setFastLocationUpdatesEnabled(bool enabled) async { @@ -1766,6 +1819,15 @@ class _SettingsScreenState extends State { trailing: const Icon(Icons.chevron_right), onTap: _editFastLocationChannel, ), + if (_fastGpsRegionState != null) + ListTile( + dense: true, + leading: const Icon(Icons.travel_explore, size: 20), + title: const Text('Region scope'), + subtitle: Text(_fastGpsRegionState!.selectedLabel), + trailing: const Icon(Icons.chevron_right), + onTap: _editFastGpsRegion, + ), ListTile( dense: true, leading: const Icon(Icons.send, size: 20), diff --git a/lib/services/location_tracking_service.dart b/lib/services/location_tracking_service.dart index e593a86..e834be7 100644 --- a/lib/services/location_tracking_service.dart +++ b/lib/services/location_tracking_service.dart @@ -25,15 +25,43 @@ class LocationTrackingService { static const int _defaultFastLocationActiveCadenceSeconds = 60; static const int _minFastLocationActiveCadenceSeconds = 60; static const int _maxFastLocationActiveCadenceSeconds = 60; - static const Duration _fastLocationSlowInterval = Duration( - seconds: 60, - ); + // Moving cadence tiers — identical to the MeshUI firmware + // (FAST_GPS_*_INTERVAL_MS / FAST_GPS_SPEED_*_MAX_MPS in MyMesh.cpp). static const Duration _fastLocationWalkingInterval = Duration(seconds: 30); static const Duration _fastLocationFastInterval = Duration(seconds: 15); static const Duration _fastLocationVeryFastInterval = Duration(seconds: 5); static const double _fastLocationIdleSpeedMaxMetersPerSecond = 0.75; static const double _fastLocationWalkingSpeedMaxMetersPerSecond = 1.8; static const double _fastLocationFastSpeedMaxMetersPerSecond = 4.0; + + // Stationary keepalive — a parked node still beacons, but only every 9 min + // (FAST_GPS_STATIONARY_*_INTERVAL_MS), not on GPS jitter. + static const Duration _fastLocationStationaryInterval = Duration(minutes: 9); + + // Moving/stationary detection: anchor + dwell hysteresis (position-only, no + // Doppler), mirroring FAST_GPS_MOVE_* in MyMesh.cpp. A parked node holds an + // anchor; jitter inside the radius is absorbed and the stable anchor is + // reported, so multipath wander can't paint a fake track. + static const double _fastLocationMoveRadiusMeters = 25.0; + static const Duration _fastLocationMoveDwell = Duration(seconds: 15); + static const Duration _fastLocationStopDwell = Duration(seconds: 60); + static const Duration _fastLocationMoveEval = Duration(seconds: 1); + static const double _fastLocationSpeedGateMeters = 5.0; + static const Duration _fastLocationSpeedRebaseline = Duration(seconds: 8); + static const double _fastLocationSpeedGlitchMaxKmh = 300.0; + + // Fix-quality gate. The firmware requires >= 5 satellites (FAST_GPS_MIN_SATS); + // Geolocator does not expose a satellite count on all platforms, so we gate on + // horizontal accuracy as the closest available proxy. + static const double _fastLocationMaxAccuracyMeters = 50.0; + + // Yield ~5s after hearing a peer beacon, to avoid channel collisions + // (FAST_GPS_CHANNEL_RX_HOLDOFF_MS). + static const Duration _fastLocationRxHoldoff = Duration(seconds: 5); + + // Background keepalive tick when not in active use — cheap, just enough to fire + // the 9-min stationary beacon while parked. + static const Duration _fastLocationActiveTick = Duration(seconds: 15); // ============================================================================ // Singleton Pattern // ============================================================================ @@ -128,7 +156,22 @@ class LocationTrackingService { Timer? _fastLocationTimer; bool _isFastLocationActiveUse = false; DateTime? _lastFastLocationSentAt; - Position? _lastFastLocationSentPosition; + int? _lastFastLocationSentLatE6; + int? _lastFastLocationSentLonE6; + + // Firmware-mirrored fast-GPS motion state (app-fallback path). See + // MyMesh::updateGpsStatusCache / maybeSendFastGpsUpdate. + int? _fastGpsAnchorLatE6; + int? _fastGpsAnchorLonE6; + bool _fastGpsRefValid = false; + bool _fastGpsIsMoving = false; + DateTime? _fastGpsMoveStateSince; + DateTime? _fastGpsMoveEvalAt; + double _fastGpsSpeedKmh = 0.0; + int? _fastGpsSpeedPrevLatE6; + int? _fastGpsSpeedPrevLonE6; + DateTime? _fastGpsSpeedPrevAt; + DateTime? _fastGpsRxHoldoffUntil; // ============================================================================ // Callback Properties @@ -146,8 +189,11 @@ class LocationTrackingService { /// Called when tracking state changes void Function(bool isTracking)? onTrackingStateChanged; - /// Called when a fast private GPS update should be sent - void Function(Position position, String reason)? onFastLocationUpdate; + /// Called when a fast private GPS update should be sent. Carries the resolved + /// beacon coordinates (de-jittered anchor when parked) and EMA ground speed in + /// km/h, so the caller just encodes and transmits. + void Function(double latitude, double longitude, int speedKmh, String reason)? + onFastLocationUpdate; // ============================================================================ // Initialization @@ -418,6 +464,7 @@ class LocationTrackingService { isTracking = false; onTrackingStateChanged?.call(false); _refreshFastLocationTimer(); + _resetFastGpsMotionState(); // Reset first position flag so next connection starts fresh _firstPositionSet = false; @@ -533,6 +580,9 @@ class LocationTrackingService { Future setFastLocationUpdatesEnabled(bool enabled) async { fastLocationUpdatesEnabled = enabled; + if (!enabled) { + _resetFastGpsMotionState(); + } await saveSettings(); _refreshFastLocationTimer(); } @@ -560,23 +610,35 @@ class LocationTrackingService { _refreshFastLocationTimer(); } + /// Arms the post-RX send hold-off after hearing a peer's fast-GPS beacon, so + /// the phone yields the channel briefly to avoid collisions + /// (FAST_GPS_CHANNEL_RX_HOLDOFF_MS). Called by the app's incoming-beacon path. + void noteFastGpsBeaconHeard() { + _fastGpsRxHoldoffUntil = DateTime.now().add(_fastLocationRxHoldoff); + } + + /// Clears all fast-GPS motion/anchor state. Mirrors resetFastGpsShareState. + void _resetFastGpsMotionState() { + _lastFastLocationSentAt = null; + _lastFastLocationSentLatE6 = null; + _lastFastLocationSentLonE6 = null; + _fastGpsAnchorLatE6 = null; + _fastGpsAnchorLonE6 = null; + _fastGpsRefValid = false; + _fastGpsIsMoving = false; + _fastGpsMoveStateSince = null; + _fastGpsMoveEvalAt = null; + _fastGpsSpeedKmh = 0.0; + _fastGpsSpeedPrevLatE6 = null; + _fastGpsSpeedPrevLonE6 = null; + _fastGpsSpeedPrevAt = null; + _fastGpsRxHoldoffUntil = null; + } + void _evaluateFastLocationMovement(Position position) { if (!fastLocationUpdatesEnabled || fastLocationChannelIdx == null) return; - final previous = _lastFastLocationSentPosition; - if (previous == null) { - _emitFastLocationUpdate(position, reason: 'initial'); - return; - } - - final distance = Geolocator.distanceBetween( - previous.latitude, - previous.longitude, - position.latitude, - position.longitude, - ); - if (distance >= fastLocationMovementThresholdMeters) { - _emitFastLocationUpdate(position, reason: 'movement'); - } + _updateFastGpsMotionState(position); + _maybeSendFastLocation(); } void _refreshFastLocationTimer() { @@ -584,75 +646,213 @@ class LocationTrackingService { _fastLocationTimer = null; if (!isTracking || !fastLocationUpdatesEnabled || - fastLocationChannelIdx == null || - !_isFastLocationActiveUse) { + fastLocationChannelIdx == null) { return; } - _fastLocationTimer = Timer.periodic( - Duration(seconds: fastLocationActiveCadenceSeconds), - (_) { - final position = currentPosition; - if (position == null) return; - _emitFastLocationUpdate(position, reason: 'active_use'); - }, + // Always tick (even when parked) so the stationary keepalive fires without a + // position-stream event. Tick fast while in active use; otherwise just often + // enough to drive the 9-min keepalive cheaply. + final period = _isFastLocationActiveUse + ? _fastLocationActiveTick + : _fastLocationStationaryInterval; + _fastLocationTimer = Timer.periodic(period, (_) => _maybeSendFastLocation()); + } + + /// Updates the EMA ground speed and the moving/stationary anchor state machine + /// from a fresh fix. Faithful port of MyMesh::updateGpsStatusCache. + void _updateFastGpsMotionState(Position position) { + // Fix-quality gate (firmware: satellitesCount >= FAST_GPS_MIN_SATS). + final accuracy = position.accuracy; + if (accuracy.isFinite && + accuracy > 0 && + accuracy > _fastLocationMaxAccuracyMeters) { + return; + } + + final now = DateTime.now(); + final latE6 = (position.latitude * 1e6).round(); + final lonE6 = (position.longitude * 1e6).round(); + + // Ground speed from successive fixes, EMA-smoothed (0.4 prev / 0.6 inst). + if (_fastGpsSpeedPrevLatE6 == null || _fastGpsSpeedPrevAt == null) { + _fastGpsSpeedPrevLatE6 = latE6; + _fastGpsSpeedPrevLonE6 = lonE6; + _fastGpsSpeedPrevAt = now; + } else { + final distM = _distanceE6( + _fastGpsSpeedPrevLatE6!, + _fastGpsSpeedPrevLonE6!, + latE6, + lonE6, + ); + final dtMs = now.difference(_fastGpsSpeedPrevAt!).inMilliseconds; + if (distM >= _fastLocationSpeedGateMeters && dtMs >= 1000) { + final instKmh = (distM / 1000.0) / (dtMs / 3600000.0); + if (instKmh <= _fastLocationSpeedGlitchMaxKmh) { + _fastGpsSpeedKmh = _fastGpsSpeedKmh * 0.4 + instKmh * 0.6; + } + _fastGpsSpeedPrevLatE6 = latE6; + _fastGpsSpeedPrevLonE6 = lonE6; + _fastGpsSpeedPrevAt = now; + } else if (dtMs >= _fastLocationSpeedRebaseline.inMilliseconds) { + _fastGpsSpeedKmh = 0.0; + _fastGpsSpeedPrevLatE6 = latE6; + _fastGpsSpeedPrevLonE6 = lonE6; + _fastGpsSpeedPrevAt = now; + } + } + + // Moving/stationary state machine (anchor + dwell hysteresis), ~1 Hz. + final evalAt = _fastGpsMoveEvalAt; + if (evalAt != null && now.difference(evalAt) < _fastLocationMoveEval) { + return; + } + _fastGpsMoveEvalAt = now; + + if (!_fastGpsRefValid) { + _fastGpsAnchorLatE6 = latE6; + _fastGpsAnchorLonE6 = lonE6; + _fastGpsRefValid = true; + _fastGpsMoveStateSince = null; + _fastGpsIsMoving = false; + return; + } + + final refDist = _distanceE6( + _fastGpsAnchorLatE6!, + _fastGpsAnchorLonE6!, + latE6, + lonE6, + ); + if (!_fastGpsIsMoving) { + if (refDist > _fastLocationMoveRadiusMeters) { + if (_fastGpsMoveStateSince == null) { + _fastGpsMoveStateSince = now; + } else if (now.difference(_fastGpsMoveStateSince!) >= + _fastLocationMoveDwell) { + _fastGpsIsMoving = true; + _fastGpsAnchorLatE6 = latE6; + _fastGpsAnchorLonE6 = lonE6; + _fastGpsMoveStateSince = null; + } + } else { + _fastGpsMoveStateSince = null; + // Track slow GPS bias so cumulative drift never reaches the radius. + _fastGpsAnchorLatE6 = + _fastGpsAnchorLatE6! + ((latE6 - _fastGpsAnchorLatE6!) ~/ 8); + _fastGpsAnchorLonE6 = + _fastGpsAnchorLonE6! + ((lonE6 - _fastGpsAnchorLonE6!) ~/ 8); + } + } else { + if (refDist > _fastLocationMoveRadiusMeters) { + _fastGpsAnchorLatE6 = latE6; + _fastGpsAnchorLonE6 = lonE6; + _fastGpsMoveStateSince = null; + } else if (_fastGpsMoveStateSince == null) { + _fastGpsMoveStateSince = now; + } else if (now.difference(_fastGpsMoveStateSince!) >= + _fastLocationStopDwell) { + _fastGpsIsMoving = false; + _fastGpsAnchorLatE6 = latE6; + _fastGpsAnchorLonE6 = lonE6; + _fastGpsMoveStateSince = null; + } + } + } + + /// Decides whether to emit a beacon now, mirroring maybeSendFastGpsUpdate: + /// moving → speed-tier cadence once past the movement threshold; parked → + /// flat 9-min keepalive of the stable anchor. Honours the RX hold-off. + void _maybeSendFastLocation() { + if (!fastLocationUpdatesEnabled || fastLocationChannelIdx == null) return; + final position = currentPosition; + if (position == null) return; + // No valid anchor yet means no good fix has landed — don't beacon (the + // firmware likewise bails until it has a usable fix). + if (!_fastGpsRefValid) return; + + final int reportLatE6; + final int reportLonE6; + if (_fastGpsIsMoving) { + reportLatE6 = (position.latitude * 1e6).round(); + reportLonE6 = (position.longitude * 1e6).round(); + } else { + // Parked: report the stable anchor, not the wander. + reportLatE6 = _fastGpsAnchorLatE6!; + reportLonE6 = _fastGpsAnchorLonE6!; + } + + final now = DateTime.now(); + final lastAt = _lastFastLocationSentAt; + bool shouldSend = lastAt == null || _lastFastLocationSentLatE6 == null; + String reason = 'initial'; + if (!shouldSend) { + if (_fastGpsIsMoving) { + final distM = _distanceE6( + _lastFastLocationSentLatE6!, + _lastFastLocationSentLonE6!, + reportLatE6, + reportLonE6, + ); + if (distM > fastLocationMovementThresholdMeters) { + final elapsed = now.difference(lastAt!); + final interval = _movingIntervalForSpeedKmh(_fastGpsSpeedKmh); + shouldSend = elapsed >= interval; + reason = 'movement'; + } + } else if (now.difference(lastAt!) >= _fastLocationStationaryInterval) { + shouldSend = true; + reason = 'stationary'; + } + } + if (!shouldSend) return; + + // Yield the channel briefly after hearing a peer beacon. + final holdoff = _fastGpsRxHoldoffUntil; + if (holdoff != null) { + if (now.isBefore(holdoff)) return; + _fastGpsRxHoldoffUntil = null; + } + + _lastFastLocationSentLatE6 = reportLatE6; + _lastFastLocationSentLonE6 = reportLonE6; + _lastFastLocationSentAt = now; + + final speedKmh = _fastGpsSpeedKmh < 0 + ? 0 + : (_fastGpsSpeedKmh > 255.0 ? 255 : (_fastGpsSpeedKmh + 0.5).floor()); + onFastLocationUpdate?.call( + reportLatE6 / 1e6, + reportLonE6 / 1e6, + speedKmh, + reason, ); } - void _emitFastLocationUpdate(Position position, {required String reason}) { - if (!fastLocationUpdatesEnabled || fastLocationChannelIdx == null) return; - - final now = DateTime.now(); - final previous = _lastFastLocationSentPosition; - final previousTime = _lastFastLocationSentAt; - if (previous != null && previousTime != null) { - final distance = Geolocator.distanceBetween( - previous.latitude, - previous.longitude, - position.latitude, - position.longitude, - ); - final elapsedMs = now.difference(previousTime).inMilliseconds; - final minimumInterval = _fastLocationMinimumIntervalFor( - distanceMeters: distance, - elapsedMs: elapsedMs, - ); - if (elapsedMs < minimumInterval.inMilliseconds) { - return; - } - if (distance < 1.0 && elapsedMs < 3000) { - return; - } - } else if (previousTime != null && - now.difference(previousTime) < _fastLocationSlowInterval) { - return; + Duration _movingIntervalForSpeedKmh(double speedKmh) { + final speedMps = speedKmh / 3.6; + if (speedMps < _fastLocationIdleSpeedMaxMetersPerSecond) { + return _fastLocationStationaryInterval; } - - _lastFastLocationSentPosition = position; - _lastFastLocationSentAt = now; - onFastLocationUpdate?.call(position, reason); - } - - Duration _fastLocationMinimumIntervalFor({ - required double distanceMeters, - required int elapsedMs, - }) { - if (elapsedMs <= 0) { - return _fastLocationSlowInterval; - } - final speedMetersPerSecond = distanceMeters / (elapsedMs / 1000.0); - if (speedMetersPerSecond < _fastLocationIdleSpeedMaxMetersPerSecond) { - return _fastLocationSlowInterval; - } - if (speedMetersPerSecond < _fastLocationWalkingSpeedMaxMetersPerSecond) { + if (speedMps < _fastLocationWalkingSpeedMaxMetersPerSecond) { return _fastLocationWalkingInterval; } - if (speedMetersPerSecond < _fastLocationFastSpeedMaxMetersPerSecond) { + if (speedMps < _fastLocationFastSpeedMaxMetersPerSecond) { return _fastLocationFastInterval; } return _fastLocationVeryFastInterval; } + double _distanceE6(int latA, int lonA, int latB, int lonB) { + return Geolocator.distanceBetween( + latA / 1e6, + lonA / 1e6, + latB / 1e6, + lonB / 1e6, + ); + } + // ============================================================================ // Mesh Network Broadcasting // ============================================================================ diff --git a/lib/utils/fast_gps_packet.dart b/lib/utils/fast_gps_packet.dart index c1c550a..0060036 100644 --- a/lib/utils/fast_gps_packet.dart +++ b/lib/utils/fast_gps_packet.dart @@ -1,8 +1,23 @@ import 'dart:typed_data'; +/// Fast-GPS position beacon, wire-compatible with the MeshUI firmware +/// (`maybeSendFastGpsUpdate` in MyMesh.cpp). +/// +/// 16-byte binary payload sent as a `DATA_TYPE_DEV` group datagram on a +/// non-public channel: +/// +/// ``` +/// [0] magic 0x47 ('G') +/// [1..6] sender public-key prefix (6 bytes) +/// [7..10] latitude in microdegrees (int32, little-endian) +/// [11..14] longitude in microdegrees (int32, little-endian) +/// [15] ground speed in km/h (uint8, clamped 0..255) +/// ``` +/// +/// The beacon carries no timestamp — receivers stamp their own RX time. class FastGpsPacket { static const int magic = 0x47; // 'G' - static const int _payloadLength = 19; + static const int _payloadLength = 16; // Store coordinates in microdegrees. This preserves sub-meter precision, // which comfortably satisfies the meter-accuracy requirement. static const double coordinateScale = 1e6; @@ -10,13 +25,15 @@ class FastGpsPacket { final String senderKey6; final double latitude; final double longitude; - final int timestampSeconds; + + /// Ground speed in km/h (0..255), as carried in the beacon's trailing byte. + final int speedKmh; const FastGpsPacket({ required this.senderKey6, required this.latitude, required this.longitude, - required this.timestampSeconds, + this.speedKmh = 0, }); static bool isFastGpsBinary(Uint8List payload) => @@ -32,7 +49,7 @@ class FastGpsPacket { final data = ByteData.sublistView(payload); final latitude = data.getInt32(7, Endian.little) / coordinateScale; final longitude = data.getInt32(11, Endian.little) / coordinateScale; - final timestampSeconds = data.getUint32(15, Endian.little); + final speedKmh = data.getUint8(15); if (!_isValidCoordinate(latitude, longitude)) { return null; @@ -42,7 +59,7 @@ class FastGpsPacket { senderKey6: key6, latitude: latitude, longitude: longitude, - timestampSeconds: timestampSeconds, + speedKmh: speedKmh, ); } @@ -55,7 +72,7 @@ class FastGpsPacket { } data.setInt32(7, (latitude * coordinateScale).round(), Endian.little); data.setInt32(11, (longitude * coordinateScale).round(), Endian.little); - data.setUint32(15, timestampSeconds, Endian.little); + data.setUint8(15, speedKmh.clamp(0, 255)); return out; } diff --git a/test/providers/contacts_provider_test.dart b/test/providers/contacts_provider_test.dart index 058e13d..3a9e81b 100644 --- a/test/providers/contacts_provider_test.dart +++ b/test/providers/contacts_provider_test.dart @@ -762,10 +762,11 @@ void main() { senderKey6: '323334353637', latitude: 44.123456, longitude: 13.654321, - timestampSeconds: 1700001234, + speedKmh: 12, ), ); + final nowSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000; final updated = provider.findContactByKey(publicKey)!; expect(updated.telemetry, isNotNull); expect(updated.telemetry!.gpsLocation, isNotNull); @@ -780,8 +781,9 @@ void main() { expect(updated.telemetry!.batteryMilliVolts, isNotNull); expect(updated.advLat, equals((44.123456 * 1e6).round())); expect(updated.advLon, equals((13.654321 * 1e6).round())); - expect(updated.lastAdvert, equals(1700001234)); - expect(updated.lastMod, equals(1700001234)); + // The beacon carries no timestamp; the receiver stamps its own RX time. + expect(updated.lastAdvert, closeTo(nowSeconds, 5)); + expect(updated.lastMod, closeTo(nowSeconds, 5)); expect( provider.estimatedLocationFor(updated.publicKeyHex), isNotNull, @@ -804,7 +806,7 @@ void main() { senderKey6: '010203040506', latitude: 10, longitude: 20, - timestampSeconds: 99, + speedKmh: 0, ), ); final after = provider.findContactByKey(publicKey)!; diff --git a/test/utils/fast_gps_packet_test.dart b/test/utils/fast_gps_packet_test.dart index 92bbdaa..189c557 100644 --- a/test/utils/fast_gps_packet_test.dart +++ b/test/utils/fast_gps_packet_test.dart @@ -6,22 +6,38 @@ import 'package:meshcore_sar_app/utils/fast_gps_packet.dart'; void main() { group('FastGpsPacket', () { - test('encodes and parses a valid packet', () { + test('encodes and parses a valid 16-byte packet', () { final packet = FastGpsPacket( senderKey6: 'aabbccddeeff', latitude: 46.0569, longitude: 14.5058, - timestampSeconds: 1700000000, + speedKmh: 37, ); final encoded = packet.encodeBinary(); + expect(encoded.length, equals(16)); + expect(encoded[0], equals(FastGpsPacket.magic)); + final parsed = FastGpsPacket.tryParseBinary(encoded); expect(parsed, isNotNull); expect(parsed!.senderKey6, equals('aabbccddeeff')); expect(parsed.latitude, closeTo(46.0569, 0.000001)); expect(parsed.longitude, closeTo(14.5058, 0.000001)); - expect(parsed.timestampSeconds, equals(1700000000)); + expect(parsed.speedKmh, equals(37)); + }); + + test('clamps speed to a single unsigned byte', () { + final packet = FastGpsPacket( + senderKey6: '001122334455', + latitude: 1.0, + longitude: 2.0, + speedKmh: 999, + ); + + final parsed = FastGpsPacket.tryParseBinary(packet.encodeBinary()); + expect(parsed, isNotNull); + expect(parsed!.speedKmh, equals(255)); }); test('supports negative coordinates', () { @@ -29,7 +45,7 @@ void main() { senderKey6: '001122334455', latitude: -33.8688, longitude: -151.2093, - timestampSeconds: 42, + speedKmh: 0, ); final parsed = FastGpsPacket.tryParseBinary(packet.encodeBinary()); @@ -39,20 +55,29 @@ void main() { }); test('rejects malformed payloads', () { + // Too short. expect( FastGpsPacket.tryParseBinary(Uint8List.fromList([0x47, 0x01])), isNull, ); + // Wrong magic at the correct length. expect( FastGpsPacket.tryParseBinary( - Uint8List.fromList(List.filled(19, 0)..[0] = 0x48), + Uint8List.fromList(List.filled(16, 0)..[0] = 0x48), + ), + isNull, + ); + // Legacy 19-byte format is no longer accepted. + expect( + FastGpsPacket.tryParseBinary( + Uint8List.fromList(List.filled(19, 0)..[0] = 0x47), ), isNull, ); }); test('rejects invalid coordinate ranges', () { - final payload = Uint8List(19); + final payload = Uint8List(16); payload[0] = FastGpsPacket.magic; payload.setRange(1, 7, [0, 1, 2, 3, 4, 5]); final data = ByteData.sublistView(payload); @@ -66,7 +91,7 @@ void main() { (14.5 * FastGpsPacket.coordinateScale).round(), Endian.little, ); - data.setUint32(15, 1, Endian.little); + data.setUint8(15, 1); expect(FastGpsPacket.tryParseBinary(payload), isNull); }); @@ -78,7 +103,7 @@ void main() { senderKey6: 'aabbccddeeff', latitude: latitude, longitude: longitude, - timestampSeconds: 1700000000, + speedKmh: 5, ); final parsed = FastGpsPacket.tryParseBinary(packet.encodeBinary());