mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 08:20:36 +00:00
feat: align fast-GPS beacons with MeshUI firmware (16-byte format + cadence parity)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<String> 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
|
/// Main App Provider - coordinates all other providers
|
||||||
class AppProvider with ChangeNotifier {
|
class AppProvider with ChangeNotifier {
|
||||||
static const int _maxDirectPayloadHops = 3;
|
static const int _maxDirectPayloadHops = 3;
|
||||||
@@ -981,9 +997,17 @@ class AppProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
locationTrackingService.onFastLocationUpdate = (position, reason) {
|
locationTrackingService.onFastLocationUpdate =
|
||||||
unawaited(_sendFastLocationUpdate(position, reason: reason));
|
(latitude, longitude, speedKmh, reason) {
|
||||||
};
|
unawaited(
|
||||||
|
_sendFastLocationUpdate(
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
speedKmh: speedKmh,
|
||||||
|
reason: reason,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
debugPrint('✅ [AppProvider] Location tracking service initialized');
|
debugPrint('✅ [AppProvider] Location tracking service initialized');
|
||||||
} catch (e) {
|
} 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<FastGpsRegionState?> 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<void> setFastGpsRegion(int index) async {
|
||||||
|
if (!connectionProvider.deviceInfo.isConnected) {
|
||||||
|
throw StateError('Connect to a device first');
|
||||||
|
}
|
||||||
|
await _setDeviceCustomVarOrThrow('fast_gps_region', index.toString());
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
Future<ChannelLocationSharingState> getChannelLocationSharingState(
|
Future<ChannelLocationSharingState> getChannelLocationSharingState(
|
||||||
int channelIdx,
|
int channelIdx,
|
||||||
) async {
|
) async {
|
||||||
@@ -3074,8 +3126,10 @@ class AppProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _sendFastLocationUpdate(
|
Future<void> _sendFastLocationUpdate({
|
||||||
dynamic position, {
|
required double latitude,
|
||||||
|
required double longitude,
|
||||||
|
required int speedKmh,
|
||||||
required String reason,
|
required String reason,
|
||||||
}) async {
|
}) async {
|
||||||
if (!connectionProvider.deviceInfo.isConnected) {
|
if (!connectionProvider.deviceInfo.isConnected) {
|
||||||
@@ -3108,20 +3162,21 @@ class AppProvider with ChangeNotifier {
|
|||||||
.sublist(0, 6)
|
.sublist(0, 6)
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
.join();
|
.join();
|
||||||
|
final clampedSpeedKmh = speedKmh.clamp(0, 255);
|
||||||
final packet = FastGpsPacket(
|
final packet = FastGpsPacket(
|
||||||
senderKey6: senderKey6,
|
senderKey6: senderKey6,
|
||||||
latitude: position.latitude as double,
|
latitude: latitude,
|
||||||
longitude: position.longitude as double,
|
longitude: longitude,
|
||||||
timestampSeconds: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
speedKmh: clampedSpeedKmh,
|
||||||
);
|
);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'📤 [AppProvider] Fast GPS send '
|
'📤 [AppProvider] Fast GPS send '
|
||||||
'reason=$reason '
|
'reason=$reason '
|
||||||
'sender=$senderKey6 '
|
'sender=$senderKey6 '
|
||||||
'channel=$channelIdx '
|
'channel=$channelIdx '
|
||||||
'lat=${position.latitude} '
|
'lat=$latitude '
|
||||||
'lon=${position.longitude} '
|
'lon=$longitude '
|
||||||
'ts=${packet.timestampSeconds}',
|
'speed=${clampedSpeedKmh}km/h',
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await connectionProvider.sendChannelData(
|
await connectionProvider.sendChannelData(
|
||||||
@@ -3131,7 +3186,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'✅ [AppProvider] Fast GPS sent '
|
'✅ [AppProvider] Fast GPS sent '
|
||||||
'sender=$senderKey6 channel=$channelIdx ts=${packet.timestampSeconds}',
|
'sender=$senderKey6 channel=$channelIdx speed=${speedKmh}km/h',
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('⚠️ [AppProvider] Fast GPS send failed: $e');
|
debugPrint('⚠️ [AppProvider] Fast GPS send failed: $e');
|
||||||
@@ -3151,7 +3206,17 @@ class AppProvider with ChangeNotifier {
|
|||||||
return false;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3693,6 +3758,10 @@ class AppProvider with ChangeNotifier {
|
|||||||
return false;
|
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);
|
final sender = _resolveContactByPrefixHex(fastGpsPacket.senderKey6);
|
||||||
if (sender != null) {
|
if (sender != null) {
|
||||||
contactsProvider.updateFastGps(
|
contactsProvider.updateFastGps(
|
||||||
|
|||||||
@@ -1164,6 +1164,10 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
return;
|
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(
|
final updatedTelemetry = _mergeTelemetryForContact(
|
||||||
existingTelemetry: contact.telemetry,
|
existingTelemetry: contact.telemetry,
|
||||||
incomingTelemetry: ContactTelemetry(
|
incomingTelemetry: ContactTelemetry(
|
||||||
@@ -1171,9 +1175,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
batteryPercentage: null,
|
batteryPercentage: null,
|
||||||
batteryMilliVolts: null,
|
batteryMilliVolts: null,
|
||||||
temperature: null,
|
temperature: null,
|
||||||
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
timestamp: receivedAt,
|
||||||
packet.timestampSeconds * 1000,
|
|
||||||
),
|
|
||||||
humidity: null,
|
humidity: null,
|
||||||
pressure: null,
|
pressure: null,
|
||||||
extraSensorData: null,
|
extraSensorData: null,
|
||||||
@@ -1187,13 +1189,13 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
'contactKey=${contact.publicKeyHex} '
|
'contactKey=${contact.publicKeyHex} '
|
||||||
'lat=${packet.latitude} '
|
'lat=${packet.latitude} '
|
||||||
'lon=${packet.longitude} '
|
'lon=${packet.longitude} '
|
||||||
'ts=${packet.timestampSeconds}',
|
'speed=${packet.speedKmh}km/h',
|
||||||
);
|
);
|
||||||
|
|
||||||
final updatedContact = contact.copyWith(
|
final updatedContact = contact.copyWith(
|
||||||
telemetry: updatedTelemetry,
|
telemetry: updatedTelemetry,
|
||||||
lastAdvert: packet.timestampSeconds,
|
lastAdvert: receivedAtSeconds,
|
||||||
lastMod: packet.timestampSeconds,
|
lastMod: receivedAtSeconds,
|
||||||
advLat: _coordinateToAdvertMicrodegrees(packet.latitude),
|
advLat: _coordinateToAdvertMicrodegrees(packet.latitude),
|
||||||
advLon: _coordinateToAdvertMicrodegrees(packet.longitude),
|
advLon: _coordinateToAdvertMicrodegrees(packet.longitude),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
double _fastLocationMovementThresholdMeters = 10.0;
|
double _fastLocationMovementThresholdMeters = 10.0;
|
||||||
int _fastLocationActiveCadenceSeconds = 10;
|
int _fastLocationActiveCadenceSeconds = 10;
|
||||||
int? _fastLocationChannelIdx;
|
int? _fastLocationChannelIdx;
|
||||||
|
FastGpsRegionState? _fastGpsRegionState;
|
||||||
bool _rotateMapWithHeading = false;
|
bool _rotateMapWithHeading = false;
|
||||||
bool _showMapDebugInfo = false;
|
bool _showMapDebugInfo = false;
|
||||||
bool _openMapInFullscreen = false;
|
bool _openMapInFullscreen = false;
|
||||||
@@ -410,6 +411,58 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_locationService.fastLocationActiveCadenceSeconds;
|
_locationService.fastLocationActiveCadenceSeconds;
|
||||||
_fastLocationChannelIdx = _locationService.fastLocationChannelIdx;
|
_fastLocationChannelIdx = _locationService.fastLocationChannelIdx;
|
||||||
});
|
});
|
||||||
|
await _loadFastGpsRegionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadFastGpsRegionState() async {
|
||||||
|
final appProvider = context.read<AppProvider>();
|
||||||
|
FastGpsRegionState? regionState;
|
||||||
|
try {
|
||||||
|
regionState = await appProvider.getFastGpsRegionState();
|
||||||
|
} catch (_) {
|
||||||
|
regionState = null;
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _fastGpsRegionState = regionState);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _editFastGpsRegion() async {
|
||||||
|
final regionState = _fastGpsRegionState;
|
||||||
|
if (regionState == null) return;
|
||||||
|
|
||||||
|
final selected = await showModalBottomSheet<int>(
|
||||||
|
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<AppProvider>().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<void> _setFastLocationUpdatesEnabled(bool enabled) async {
|
Future<void> _setFastLocationUpdatesEnabled(bool enabled) async {
|
||||||
@@ -1766,6 +1819,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: _editFastLocationChannel,
|
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(
|
ListTile(
|
||||||
dense: true,
|
dense: true,
|
||||||
leading: const Icon(Icons.send, size: 20),
|
leading: const Icon(Icons.send, size: 20),
|
||||||
|
|||||||
@@ -25,15 +25,43 @@ class LocationTrackingService {
|
|||||||
static const int _defaultFastLocationActiveCadenceSeconds = 60;
|
static const int _defaultFastLocationActiveCadenceSeconds = 60;
|
||||||
static const int _minFastLocationActiveCadenceSeconds = 60;
|
static const int _minFastLocationActiveCadenceSeconds = 60;
|
||||||
static const int _maxFastLocationActiveCadenceSeconds = 60;
|
static const int _maxFastLocationActiveCadenceSeconds = 60;
|
||||||
static const Duration _fastLocationSlowInterval = Duration(
|
// Moving cadence tiers — identical to the MeshUI firmware
|
||||||
seconds: 60,
|
// (FAST_GPS_*_INTERVAL_MS / FAST_GPS_SPEED_*_MAX_MPS in MyMesh.cpp).
|
||||||
);
|
|
||||||
static const Duration _fastLocationWalkingInterval = Duration(seconds: 30);
|
static const Duration _fastLocationWalkingInterval = Duration(seconds: 30);
|
||||||
static const Duration _fastLocationFastInterval = Duration(seconds: 15);
|
static const Duration _fastLocationFastInterval = Duration(seconds: 15);
|
||||||
static const Duration _fastLocationVeryFastInterval = Duration(seconds: 5);
|
static const Duration _fastLocationVeryFastInterval = Duration(seconds: 5);
|
||||||
static const double _fastLocationIdleSpeedMaxMetersPerSecond = 0.75;
|
static const double _fastLocationIdleSpeedMaxMetersPerSecond = 0.75;
|
||||||
static const double _fastLocationWalkingSpeedMaxMetersPerSecond = 1.8;
|
static const double _fastLocationWalkingSpeedMaxMetersPerSecond = 1.8;
|
||||||
static const double _fastLocationFastSpeedMaxMetersPerSecond = 4.0;
|
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
|
// Singleton Pattern
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -128,7 +156,22 @@ class LocationTrackingService {
|
|||||||
Timer? _fastLocationTimer;
|
Timer? _fastLocationTimer;
|
||||||
bool _isFastLocationActiveUse = false;
|
bool _isFastLocationActiveUse = false;
|
||||||
DateTime? _lastFastLocationSentAt;
|
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
|
// Callback Properties
|
||||||
@@ -146,8 +189,11 @@ class LocationTrackingService {
|
|||||||
/// Called when tracking state changes
|
/// Called when tracking state changes
|
||||||
void Function(bool isTracking)? onTrackingStateChanged;
|
void Function(bool isTracking)? onTrackingStateChanged;
|
||||||
|
|
||||||
/// Called when a fast private GPS update should be sent
|
/// Called when a fast private GPS update should be sent. Carries the resolved
|
||||||
void Function(Position position, String reason)? onFastLocationUpdate;
|
/// 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
|
// Initialization
|
||||||
@@ -418,6 +464,7 @@ class LocationTrackingService {
|
|||||||
isTracking = false;
|
isTracking = false;
|
||||||
onTrackingStateChanged?.call(false);
|
onTrackingStateChanged?.call(false);
|
||||||
_refreshFastLocationTimer();
|
_refreshFastLocationTimer();
|
||||||
|
_resetFastGpsMotionState();
|
||||||
|
|
||||||
// Reset first position flag so next connection starts fresh
|
// Reset first position flag so next connection starts fresh
|
||||||
_firstPositionSet = false;
|
_firstPositionSet = false;
|
||||||
@@ -533,6 +580,9 @@ class LocationTrackingService {
|
|||||||
|
|
||||||
Future<void> setFastLocationUpdatesEnabled(bool enabled) async {
|
Future<void> setFastLocationUpdatesEnabled(bool enabled) async {
|
||||||
fastLocationUpdatesEnabled = enabled;
|
fastLocationUpdatesEnabled = enabled;
|
||||||
|
if (!enabled) {
|
||||||
|
_resetFastGpsMotionState();
|
||||||
|
}
|
||||||
await saveSettings();
|
await saveSettings();
|
||||||
_refreshFastLocationTimer();
|
_refreshFastLocationTimer();
|
||||||
}
|
}
|
||||||
@@ -560,23 +610,35 @@ class LocationTrackingService {
|
|||||||
_refreshFastLocationTimer();
|
_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) {
|
void _evaluateFastLocationMovement(Position position) {
|
||||||
if (!fastLocationUpdatesEnabled || fastLocationChannelIdx == null) return;
|
if (!fastLocationUpdatesEnabled || fastLocationChannelIdx == null) return;
|
||||||
final previous = _lastFastLocationSentPosition;
|
_updateFastGpsMotionState(position);
|
||||||
if (previous == null) {
|
_maybeSendFastLocation();
|
||||||
_emitFastLocationUpdate(position, reason: 'initial');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final distance = Geolocator.distanceBetween(
|
|
||||||
previous.latitude,
|
|
||||||
previous.longitude,
|
|
||||||
position.latitude,
|
|
||||||
position.longitude,
|
|
||||||
);
|
|
||||||
if (distance >= fastLocationMovementThresholdMeters) {
|
|
||||||
_emitFastLocationUpdate(position, reason: 'movement');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _refreshFastLocationTimer() {
|
void _refreshFastLocationTimer() {
|
||||||
@@ -584,75 +646,213 @@ class LocationTrackingService {
|
|||||||
_fastLocationTimer = null;
|
_fastLocationTimer = null;
|
||||||
if (!isTracking ||
|
if (!isTracking ||
|
||||||
!fastLocationUpdatesEnabled ||
|
!fastLocationUpdatesEnabled ||
|
||||||
fastLocationChannelIdx == null ||
|
fastLocationChannelIdx == null) {
|
||||||
!_isFastLocationActiveUse) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_fastLocationTimer = Timer.periodic(
|
// Always tick (even when parked) so the stationary keepalive fires without a
|
||||||
Duration(seconds: fastLocationActiveCadenceSeconds),
|
// position-stream event. Tick fast while in active use; otherwise just often
|
||||||
(_) {
|
// enough to drive the 9-min keepalive cheaply.
|
||||||
final position = currentPosition;
|
final period = _isFastLocationActiveUse
|
||||||
if (position == null) return;
|
? _fastLocationActiveTick
|
||||||
_emitFastLocationUpdate(position, reason: 'active_use');
|
: _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}) {
|
Duration _movingIntervalForSpeedKmh(double speedKmh) {
|
||||||
if (!fastLocationUpdatesEnabled || fastLocationChannelIdx == null) return;
|
final speedMps = speedKmh / 3.6;
|
||||||
|
if (speedMps < _fastLocationIdleSpeedMaxMetersPerSecond) {
|
||||||
final now = DateTime.now();
|
return _fastLocationStationaryInterval;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
if (speedMps < _fastLocationWalkingSpeedMaxMetersPerSecond) {
|
||||||
_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) {
|
|
||||||
return _fastLocationWalkingInterval;
|
return _fastLocationWalkingInterval;
|
||||||
}
|
}
|
||||||
if (speedMetersPerSecond < _fastLocationFastSpeedMaxMetersPerSecond) {
|
if (speedMps < _fastLocationFastSpeedMaxMetersPerSecond) {
|
||||||
return _fastLocationFastInterval;
|
return _fastLocationFastInterval;
|
||||||
}
|
}
|
||||||
return _fastLocationVeryFastInterval;
|
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
|
// Mesh Network Broadcasting
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -1,8 +1,23 @@
|
|||||||
import 'dart:typed_data';
|
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 {
|
class FastGpsPacket {
|
||||||
static const int magic = 0x47; // 'G'
|
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,
|
// Store coordinates in microdegrees. This preserves sub-meter precision,
|
||||||
// which comfortably satisfies the meter-accuracy requirement.
|
// which comfortably satisfies the meter-accuracy requirement.
|
||||||
static const double coordinateScale = 1e6;
|
static const double coordinateScale = 1e6;
|
||||||
@@ -10,13 +25,15 @@ class FastGpsPacket {
|
|||||||
final String senderKey6;
|
final String senderKey6;
|
||||||
final double latitude;
|
final double latitude;
|
||||||
final double longitude;
|
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({
|
const FastGpsPacket({
|
||||||
required this.senderKey6,
|
required this.senderKey6,
|
||||||
required this.latitude,
|
required this.latitude,
|
||||||
required this.longitude,
|
required this.longitude,
|
||||||
required this.timestampSeconds,
|
this.speedKmh = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
static bool isFastGpsBinary(Uint8List payload) =>
|
static bool isFastGpsBinary(Uint8List payload) =>
|
||||||
@@ -32,7 +49,7 @@ class FastGpsPacket {
|
|||||||
final data = ByteData.sublistView(payload);
|
final data = ByteData.sublistView(payload);
|
||||||
final latitude = data.getInt32(7, Endian.little) / coordinateScale;
|
final latitude = data.getInt32(7, Endian.little) / coordinateScale;
|
||||||
final longitude = data.getInt32(11, 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)) {
|
if (!_isValidCoordinate(latitude, longitude)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -42,7 +59,7 @@ class FastGpsPacket {
|
|||||||
senderKey6: key6,
|
senderKey6: key6,
|
||||||
latitude: latitude,
|
latitude: latitude,
|
||||||
longitude: longitude,
|
longitude: longitude,
|
||||||
timestampSeconds: timestampSeconds,
|
speedKmh: speedKmh,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +72,7 @@ class FastGpsPacket {
|
|||||||
}
|
}
|
||||||
data.setInt32(7, (latitude * coordinateScale).round(), Endian.little);
|
data.setInt32(7, (latitude * coordinateScale).round(), Endian.little);
|
||||||
data.setInt32(11, (longitude * 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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -762,10 +762,11 @@ void main() {
|
|||||||
senderKey6: '323334353637',
|
senderKey6: '323334353637',
|
||||||
latitude: 44.123456,
|
latitude: 44.123456,
|
||||||
longitude: 13.654321,
|
longitude: 13.654321,
|
||||||
timestampSeconds: 1700001234,
|
speedKmh: 12,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final nowSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||||
final updated = provider.findContactByKey(publicKey)!;
|
final updated = provider.findContactByKey(publicKey)!;
|
||||||
expect(updated.telemetry, isNotNull);
|
expect(updated.telemetry, isNotNull);
|
||||||
expect(updated.telemetry!.gpsLocation, isNotNull);
|
expect(updated.telemetry!.gpsLocation, isNotNull);
|
||||||
@@ -780,8 +781,9 @@ void main() {
|
|||||||
expect(updated.telemetry!.batteryMilliVolts, isNotNull);
|
expect(updated.telemetry!.batteryMilliVolts, isNotNull);
|
||||||
expect(updated.advLat, equals((44.123456 * 1e6).round()));
|
expect(updated.advLat, equals((44.123456 * 1e6).round()));
|
||||||
expect(updated.advLon, equals((13.654321 * 1e6).round()));
|
expect(updated.advLon, equals((13.654321 * 1e6).round()));
|
||||||
expect(updated.lastAdvert, equals(1700001234));
|
// The beacon carries no timestamp; the receiver stamps its own RX time.
|
||||||
expect(updated.lastMod, equals(1700001234));
|
expect(updated.lastAdvert, closeTo(nowSeconds, 5));
|
||||||
|
expect(updated.lastMod, closeTo(nowSeconds, 5));
|
||||||
expect(
|
expect(
|
||||||
provider.estimatedLocationFor(updated.publicKeyHex),
|
provider.estimatedLocationFor(updated.publicKeyHex),
|
||||||
isNotNull,
|
isNotNull,
|
||||||
@@ -804,7 +806,7 @@ void main() {
|
|||||||
senderKey6: '010203040506',
|
senderKey6: '010203040506',
|
||||||
latitude: 10,
|
latitude: 10,
|
||||||
longitude: 20,
|
longitude: 20,
|
||||||
timestampSeconds: 99,
|
speedKmh: 0,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final after = provider.findContactByKey(publicKey)!;
|
final after = provider.findContactByKey(publicKey)!;
|
||||||
|
|||||||
@@ -6,22 +6,38 @@ import 'package:meshcore_sar_app/utils/fast_gps_packet.dart';
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('FastGpsPacket', () {
|
group('FastGpsPacket', () {
|
||||||
test('encodes and parses a valid packet', () {
|
test('encodes and parses a valid 16-byte packet', () {
|
||||||
final packet = FastGpsPacket(
|
final packet = FastGpsPacket(
|
||||||
senderKey6: 'aabbccddeeff',
|
senderKey6: 'aabbccddeeff',
|
||||||
latitude: 46.0569,
|
latitude: 46.0569,
|
||||||
longitude: 14.5058,
|
longitude: 14.5058,
|
||||||
timestampSeconds: 1700000000,
|
speedKmh: 37,
|
||||||
);
|
);
|
||||||
|
|
||||||
final encoded = packet.encodeBinary();
|
final encoded = packet.encodeBinary();
|
||||||
|
expect(encoded.length, equals(16));
|
||||||
|
expect(encoded[0], equals(FastGpsPacket.magic));
|
||||||
|
|
||||||
final parsed = FastGpsPacket.tryParseBinary(encoded);
|
final parsed = FastGpsPacket.tryParseBinary(encoded);
|
||||||
|
|
||||||
expect(parsed, isNotNull);
|
expect(parsed, isNotNull);
|
||||||
expect(parsed!.senderKey6, equals('aabbccddeeff'));
|
expect(parsed!.senderKey6, equals('aabbccddeeff'));
|
||||||
expect(parsed.latitude, closeTo(46.0569, 0.000001));
|
expect(parsed.latitude, closeTo(46.0569, 0.000001));
|
||||||
expect(parsed.longitude, closeTo(14.5058, 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', () {
|
test('supports negative coordinates', () {
|
||||||
@@ -29,7 +45,7 @@ void main() {
|
|||||||
senderKey6: '001122334455',
|
senderKey6: '001122334455',
|
||||||
latitude: -33.8688,
|
latitude: -33.8688,
|
||||||
longitude: -151.2093,
|
longitude: -151.2093,
|
||||||
timestampSeconds: 42,
|
speedKmh: 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
final parsed = FastGpsPacket.tryParseBinary(packet.encodeBinary());
|
final parsed = FastGpsPacket.tryParseBinary(packet.encodeBinary());
|
||||||
@@ -39,20 +55,29 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('rejects malformed payloads', () {
|
test('rejects malformed payloads', () {
|
||||||
|
// Too short.
|
||||||
expect(
|
expect(
|
||||||
FastGpsPacket.tryParseBinary(Uint8List.fromList([0x47, 0x01])),
|
FastGpsPacket.tryParseBinary(Uint8List.fromList([0x47, 0x01])),
|
||||||
isNull,
|
isNull,
|
||||||
);
|
);
|
||||||
|
// Wrong magic at the correct length.
|
||||||
expect(
|
expect(
|
||||||
FastGpsPacket.tryParseBinary(
|
FastGpsPacket.tryParseBinary(
|
||||||
Uint8List.fromList(List<int>.filled(19, 0)..[0] = 0x48),
|
Uint8List.fromList(List<int>.filled(16, 0)..[0] = 0x48),
|
||||||
|
),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
// Legacy 19-byte format is no longer accepted.
|
||||||
|
expect(
|
||||||
|
FastGpsPacket.tryParseBinary(
|
||||||
|
Uint8List.fromList(List<int>.filled(19, 0)..[0] = 0x47),
|
||||||
),
|
),
|
||||||
isNull,
|
isNull,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects invalid coordinate ranges', () {
|
test('rejects invalid coordinate ranges', () {
|
||||||
final payload = Uint8List(19);
|
final payload = Uint8List(16);
|
||||||
payload[0] = FastGpsPacket.magic;
|
payload[0] = FastGpsPacket.magic;
|
||||||
payload.setRange(1, 7, [0, 1, 2, 3, 4, 5]);
|
payload.setRange(1, 7, [0, 1, 2, 3, 4, 5]);
|
||||||
final data = ByteData.sublistView(payload);
|
final data = ByteData.sublistView(payload);
|
||||||
@@ -66,7 +91,7 @@ void main() {
|
|||||||
(14.5 * FastGpsPacket.coordinateScale).round(),
|
(14.5 * FastGpsPacket.coordinateScale).round(),
|
||||||
Endian.little,
|
Endian.little,
|
||||||
);
|
);
|
||||||
data.setUint32(15, 1, Endian.little);
|
data.setUint8(15, 1);
|
||||||
|
|
||||||
expect(FastGpsPacket.tryParseBinary(payload), isNull);
|
expect(FastGpsPacket.tryParseBinary(payload), isNull);
|
||||||
});
|
});
|
||||||
@@ -78,7 +103,7 @@ void main() {
|
|||||||
senderKey6: 'aabbccddeeff',
|
senderKey6: 'aabbccddeeff',
|
||||||
latitude: latitude,
|
latitude: latitude,
|
||||||
longitude: longitude,
|
longitude: longitude,
|
||||||
timestampSeconds: 1700000000,
|
speedKmh: 5,
|
||||||
);
|
);
|
||||||
|
|
||||||
final parsed = FastGpsPacket.tryParseBinary(packet.encodeBinary());
|
final parsed = FastGpsPacket.tryParseBinary(packet.encodeBinary());
|
||||||
|
|||||||
Reference in New Issue
Block a user