diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index a5759cd..e4e0836 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -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? 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 ??= {})[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'); } diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index d30f878..40391a2 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -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; diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index fff0339..7820913 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -886,22 +886,23 @@ class _SettingsScreenState extends State { } }, ), - _buildGpsQuerySection( - context, - gpsStatus, - queryingGps, - queriedOnce, - () async { - setDialogState(() => queryingGps = true); - final s = await connector.requestOffbandGps(); - if (!dialogContext.mounted) return; - setDialogState(() { - queryingGps = false; - queriedOnce = true; - gpsStatus = s; - }); - }, - ), + if (connector.supportsOffbandGps) + _buildGpsQuerySection( + context, + gpsStatus, + queryingGps, + queriedOnce, + () async { + setDialogState(() => queryingGps = true); + final s = await connector.requestOffbandGps(); + if (!dialogContext.mounted) return; + setDialogState(() { + queryingGps = false; + queriedOnce = true; + gpsStatus = s; + }); + }, + ), ], ], ), diff --git a/pubspec.yaml b/pubspec.yaml index 0b5dbe2..f95ea40 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/offband_gps_capability_test.dart b/test/offband_gps_capability_test.dart new file mode 100644 index 0000000..dc130b9 --- /dev/null +++ b/test/offband_gps_capability_test.dart @@ -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); + }); + }); +}