Offband 0xC1 GPS query: 1-byte request -> RESP 0xC1 ASCII reply (enabled/detected/active/fix/baud/lat/lon/alt_cm/sats/time), parsed into OffbandGpsStatus (lat/lon /1e6). New GPS-status section in the Location dialog (live-fix vs stored banner + coords/sats/alt/time). Validated against RedCreek's feat/gps-state-query test firmware. #140: the per-minute GPS poll now uses the lightweight 0xC1 query instead of CMD_APP_START (which re-walked channels + re-drained messages = the BLE re-init flood); a live fix updates self-lat/lon. Validated on b42 (APP_START ~1/min -> ~0, 0xC1 polls clean every 60s, channel re-walk gone). Gemini (fix-then-ship): atomic completer-claim in _handleOffbandGps to shrink the stale-reply race; full elimination needs a protocol sequence id, but the poll shares in-flight via the re-entrancy guard and the query button is disabled during a request, so concurrent requests don't occur in practice. The hardcoded snackbars Gemini flagged in _editLocation are pre-existing l10n debt, not introduced here; all new strings are localized. Closes #135 Closes #140 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>pull/145/head
parent
13e38230c6
commit
7f2548ca95
@ -0,0 +1,64 @@
|
||||
/// Parsed payload of a `RESP_CODE_OFFBAND_GPS` (0xC1) reply — the on-demand GPS
|
||||
/// state from an Offband-fork companion radio. (#135)
|
||||
///
|
||||
/// Wire format is one ASCII string of space-separated `key=value` tokens, e.g.
|
||||
/// `enabled=1 detected=1 active=1 fix=1 lat=39421530 lon=-84449000 alt_cm=23390 sats=12 time=1782531085`.
|
||||
/// `lat`/`lon` are 1e-6 degrees (same units as the SELF_INFO frame).
|
||||
class OffbandGpsStatus {
|
||||
final bool enabled;
|
||||
final bool detected;
|
||||
final bool active;
|
||||
final bool fix;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final int? altitudeCm;
|
||||
final int? satellites;
|
||||
final DateTime? timestampUtc;
|
||||
|
||||
const OffbandGpsStatus({
|
||||
required this.enabled,
|
||||
required this.detected,
|
||||
required this.active,
|
||||
required this.fix,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.altitudeCm,
|
||||
this.satellites,
|
||||
this.timestampUtc,
|
||||
});
|
||||
|
||||
/// True when the radio reports a live satellite fix with usable coordinates —
|
||||
/// i.e. position is GPS-driven, not a stored/manual location. (#135)
|
||||
bool get hasLiveFix => fix && latitude != null && longitude != null;
|
||||
|
||||
/// Parses the ASCII `key=value` reply string. Unknown/missing keys are
|
||||
/// tolerated (null); `lat`/`lon` are scaled from 1e-6 degrees.
|
||||
static OffbandGpsStatus parse(String raw) {
|
||||
final fields = <String, String>{};
|
||||
for (final token in raw.trim().split(RegExp(r'\s+'))) {
|
||||
final eq = token.indexOf('=');
|
||||
if (eq > 0) fields[token.substring(0, eq)] = token.substring(eq + 1);
|
||||
}
|
||||
int? asInt(String key) {
|
||||
final v = fields[key];
|
||||
return v == null ? null : int.tryParse(v);
|
||||
}
|
||||
|
||||
final lat = asInt('lat');
|
||||
final lon = asInt('lon');
|
||||
final time = asInt('time');
|
||||
return OffbandGpsStatus(
|
||||
enabled: fields['enabled'] == '1',
|
||||
detected: fields['detected'] == '1',
|
||||
active: fields['active'] == '1',
|
||||
fix: fields['fix'] == '1',
|
||||
latitude: lat == null ? null : lat / 1e6,
|
||||
longitude: lon == null ? null : lon / 1e6,
|
||||
altitudeCm: asInt('alt_cm'),
|
||||
satellites: asInt('sats'),
|
||||
timestampUtc: (time == null || time <= 0)
|
||||
? null
|
||||
: DateTime.fromMillisecondsSinceEpoch(time * 1000, isUtc: true),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_open/models/offband_gps_status.dart';
|
||||
|
||||
void main() {
|
||||
group('OffbandGpsStatus.parse', () {
|
||||
test('parses a full reply with a live fix', () {
|
||||
final s = OffbandGpsStatus.parse(
|
||||
'enabled=1 detected=1 active=1 fix=1 lat=39421530 lon=-84449000 '
|
||||
'alt_cm=23390 sats=12 time=1782531085',
|
||||
);
|
||||
expect(s.enabled, isTrue);
|
||||
expect(s.detected, isTrue);
|
||||
expect(s.active, isTrue);
|
||||
expect(s.fix, isTrue);
|
||||
expect(s.latitude, closeTo(39.42153, 1e-9));
|
||||
expect(s.longitude, closeTo(-84.449, 1e-9));
|
||||
expect(s.altitudeCm, 23390);
|
||||
expect(s.satellites, 12);
|
||||
expect(
|
||||
s.timestampUtc,
|
||||
DateTime.fromMillisecondsSinceEpoch(1782531085 * 1000, isUtc: true),
|
||||
);
|
||||
expect(s.hasLiveFix, isTrue);
|
||||
});
|
||||
|
||||
test('no fix → flags false, hasLiveFix false even with stale coords', () {
|
||||
final s = OffbandGpsStatus.parse(
|
||||
'enabled=1 detected=1 active=0 fix=0 lat=39421530 lon=-84449000 '
|
||||
'sats=0 time=0',
|
||||
);
|
||||
expect(s.fix, isFalse);
|
||||
expect(s.active, isFalse);
|
||||
expect(s.hasLiveFix, isFalse);
|
||||
expect(s.timestampUtc, isNull); // time=0 → null
|
||||
});
|
||||
|
||||
test('missing optional keys are tolerated', () {
|
||||
final s = OffbandGpsStatus.parse('enabled=0 detected=0 active=0 fix=0');
|
||||
expect(s.enabled, isFalse);
|
||||
expect(s.latitude, isNull);
|
||||
expect(s.longitude, isNull);
|
||||
expect(s.altitudeCm, isNull);
|
||||
expect(s.satellites, isNull);
|
||||
expect(s.timestampUtc, isNull);
|
||||
expect(s.hasLiveFix, isFalse);
|
||||
});
|
||||
|
||||
test('extra whitespace and unknown keys are ignored', () {
|
||||
final s = OffbandGpsStatus.parse(
|
||||
' enabled=1 fix=1 foo=bar lat=1000000 lon=2000000 ',
|
||||
);
|
||||
expect(s.enabled, isTrue);
|
||||
expect(s.fix, isTrue);
|
||||
expect(s.latitude, closeTo(1.0, 1e-9));
|
||||
expect(s.longitude, closeTo(2.0, 1e-9));
|
||||
expect(s.hasLiveFix, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in new issue