feat(#144): gate 0xC1 (CMD_OFFBAND_GPS) to Offband-capable firmware

The fork-only 0xC1 GPS command was sent to any connected radio — both the
60s self-location poll (on gps=1) and the Settings GPS status query. Stock /
non-Offband MeshCore firmware can't answer it, so gate both behind a
capability check.

- firmwareSupportsOffbandGps(offbandCaps): presence of the Offband-fork
  offband_caps byte (device-info v14+), which stock MeshCore never emits.
- requestOffbandGps() hard-returns when unsupported — 0xC1 never goes out.
- _reconcileGpsPolling() centralizes the poll decision (support AND gps=1)
  across all three triggers + the device-info reply, so frame order is moot.
- Settings hides the GPS status section on unsupported firmware.

Interim presence-gate; a dedicated OFFBAND_CAP_GPS bit can follow once
firmware defines one. Unit-tested (3 new); Gemini review: ship.

Bumps build number to +43 (test build b43).

Closes #144

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pull/147/head
Strycher 3 weeks ago
parent 7f2548ca95
commit 87e8e62e6b

@ -460,6 +460,11 @@ class MeshCoreConnector extends ChangeNotifier {
/// `offband_caps` capability bitfield from the device-info reply (v14+);
/// null on older firmware that sends a shorter frame.
int? get offbandCaps => _offbandCaps;
/// Whether the connected firmware speaks the `0xC1` GPS extension i.e. it
/// advertised the Offband-fork `offband_caps` byte. Stock MeshCore omits it,
/// so 0xC1 is suppressed there rather than pinged blindly. (#144)
bool get supportsOffbandGps => firmwareSupportsOffbandGps(_offbandCaps);
Map<String, String>? get currentCustomVars => _currentCustomVars;
int? get batteryMillivolts => _batteryMillivolts;
int? get storageUsedKb => _storageUsedKb;
@ -2661,6 +2666,19 @@ class MeshCoreConnector extends ChangeNotifier {
_gpsLocationPollTimer = null;
}
/// Start or stop the 0xC1 GPS poll based on current state: only when the
/// radio reports `gps=1` AND the firmware advertises 0xC1 support. Called
/// from every input that can change either input (custom-var frames, the
/// enable toggle, and the device-info reply that delivers `offband_caps`) so
/// frame-arrival order doesn't matter. (#144)
void _reconcileGpsPolling() {
if (supportsOffbandGps && _currentCustomVars?['gps'] == '1') {
_startGpsLocationPolling();
} else {
_stopGpsLocationPolling();
}
}
void setPollingInterval(int i) {
_pollingInterval = i.clamp(1, 60);
if (isConnected) {
@ -3566,10 +3584,8 @@ class MeshCoreConnector extends ChangeNotifier {
(_currentCustomVars ??= <String, String>{})[key] = val;
notifyListeners();
}
if (value == 'gps:1') {
_startGpsLocationPolling();
} else if (value == 'gps:0') {
_stopGpsLocationPolling();
if (value == 'gps:1' || value == 'gps:0') {
_reconcileGpsPolling();
}
}
@ -3824,6 +3840,8 @@ class MeshCoreConnector extends ChangeNotifier {
Duration timeout = const Duration(seconds: 5),
}) async {
if (!isConnected) return null;
// Never emit the fork-only 0xC1 to firmware that can't answer it. (#144)
if (!supportsOffbandGps) return null;
final existing = _offbandGpsCompleter;
if (existing != null && !existing.isCompleted) {
// A query is already in flight share it rather than orphaning it.
@ -4213,6 +4231,9 @@ class MeshCoreConnector extends ChangeNotifier {
// Offband config capability v14+ (byte 82). Extracted + bounds-checked in a
// testable helper; offset verified against firmware (see parseOffbandCaps).
_offbandCaps = parseOffbandCaps(frame);
// Caps just landed; (re)evaluate GPS polling in case a `gps=1` custom-var
// frame arrived before this device-info reply set support. (#144)
_reconcileGpsPolling();
// Firmware reports MAX_CONTACTS / 2 for v3+ device info.
final reportedContacts = frame[2];
@ -6301,13 +6322,10 @@ class MeshCoreConnector extends ChangeNotifier {
final buf = BufferReader(frame.sublist(1));
try {
_currentCustomVars = _parseKeyValueString(buf.readCString());
// Reflect current GPS state in the polling timer (handles initial
// device state on connect as well as external CLI/USB toggles).
if (_currentCustomVars?['gps'] == '1') {
_startGpsLocationPolling();
} else {
_stopGpsLocationPolling();
}
// Reflect current GPS state in the polling timer (handles initial device
// state on connect as well as external CLI/USB toggles); gated on 0xC1
// support so stock firmware never gets polled. (#144)
_reconcileGpsPolling();
} catch (e) {
appLogger.warn('Malformed custom vars frame: $e', tag: 'Connector');
}

@ -263,6 +263,17 @@ const int respCodeOffbandGps = 0xC1;
/// Request frame for [cmdOffbandGps] a bare 1-byte command, no payload. (#135)
Uint8List buildOffbandGpsRequestFrame() => Uint8List.fromList([cmdOffbandGps]);
/// True iff the connected firmware understands the `0xC1` GPS extension. Gated
/// on the `offband_caps` byte being present: that byte is an Offband-fork
/// addition (device-info v14+) which stock/upstream MeshCore never emits, so a
/// null caps value means non-Offband firmware that couldn't answer `0xC1` — and
/// must not be pinged with it. (#144)
///
/// Interim presence-gate: a dedicated `OFFBAND_CAP_GPS` bit (firmware
/// follow-up) would let an Offband build without GPS opt out; until firmware
/// defines one, any Offband v14+ radio is assumed to speak `0xC1`.
bool firmwareSupportsOffbandGps(int? offbandCaps) => offbandCaps != null;
const int statsTypeCore = 0;
const int statsTypeRadio = 1;
const int statsTypePackets = 2;

@ -886,6 +886,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
},
),
if (connector.supportsOffbandGps)
_buildGpsQuerySection(
context,
gpsStatus,

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.1.2-beta.4+36
version: 1.1.2-beta.4+43
environment:
sdk: ^3.9.2

@ -0,0 +1,21 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart';
void main() {
group('firmwareSupportsOffbandGps', () {
test('null caps (stock/pre-v14 MeshCore omits the byte) → unsupported', () {
expect(firmwareSupportsOffbandGps(null), isFalse);
});
test('caps byte present with no bits set → supported', () {
// An Offband v14+ radio that simply isn't a wifi observer still emits the
// offband_caps byte (value 0), so it speaks the 0xC1 fork extension.
expect(firmwareSupportsOffbandGps(0x00), isTrue);
});
test('caps byte present with bits set → supported', () {
expect(firmwareSupportsOffbandGps(0x01), isTrue);
expect(firmwareSupportsOffbandGps(0xFF), isTrue);
});
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.