diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 988abc8..9ff5c26 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -19,6 +19,7 @@ import '../models/translation_support.dart'; import '../helpers/reaction_helper.dart'; import '../helpers/cyr2lat.dart'; import '../helpers/smaz.dart'; +import '../helpers/cayenne_lpp.dart'; import '../services/app_debug_log_service.dart'; import '../services/ble_debug_log_service.dart'; import '../services/linux_ble_error_classifier.dart'; @@ -2718,11 +2719,14 @@ class MeshCoreConnector extends ChangeNotifier { _batteryPollTimer = null; } - /// Poll the radio's GPS position every minute via the lightweight Offband - /// `0xC1` query — NOT `CMD_APP_START`, which re-inits the whole device - /// (re-walks channels, re-drains messages) and floods slow BLE links. (#140) - /// No-op if already running. Triggered when the radio reports `gps=1`; the - /// reply updates self-lat/lon via `_handleOffbandGps`. (#135) + /// Poll the radio's own GPS position every [_gpsLocationPollInterval] without + /// resetting the session (the old `CMD_APP_START` poll re-inited the device + /// and broke contact/channel sync on gps=1 radios — #110/#140). Mechanism is + /// firmware-aware: the Offband `0xC1` query where supported (#135, richer + /// state), else standard MeshCore self-telemetry (`CMD_SEND_TELEMETRY_REQ` + /// len==4 → `PUSH_CODE_TELEMETRY_RESPONSE`, decoded in [_handleSelfTelemetry]) + /// as the stock-firmware fallback (#123). No-op if already running. Triggered + /// when the radio reports `gps=1`. void _startGpsLocationPolling() { if (_gpsLocationPollTimer != null) return; _gpsLocationPollTimer = Timer.periodic(_gpsLocationPollInterval, (timer) { @@ -2731,7 +2735,19 @@ class MeshCoreConnector extends ChangeNotifier { _gpsLocationPollTimer = null; return; } - unawaited(requestOffbandGps()); + if (supportsOffbandGps) { + unawaited(requestOffbandGps()); + } else { + // Stock firmware (no 0xC1): non-destructive self-telemetry read. (#123) + unawaited( + sendFrame(buildSendTelemetryReq(null)).catchError((Object e) { + _appDebugLogService?.warn( + 'GPS self-telemetry poll send failed: $e', + tag: 'GPS', + ); + }), + ); + } }); } @@ -2740,19 +2756,60 @@ 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) + /// Start or stop the GPS poll based on state: run whenever the radio reports + /// `gps=1` — the poll itself picks 0xC1 vs self-telemetry by firmware, so + /// stock-fw radios get the non-destructive self-telemetry refresh too (#123). + /// Called from every input that can change `gps` or `offband_caps` (custom-var + /// frames, the enable toggle, the device-info reply that delivers + /// `offband_caps`) so frame-arrival order doesn't matter. (#144) void _reconcileGpsPolling() { - if (supportsOffbandGps && _currentCustomVars?['gps'] == '1') { + if (_currentCustomVars?['gps'] == '1') { _startGpsLocationPolling(); } else { _stopGpsLocationPolling(); } } + /// Decode a self-telemetry reply + /// (`[0x8B][reserved][self-pubkey 6][CayenneLPP]`) and update the radio's own + /// position from its GPS — the non-destructive replacement for the APP_START + /// GPS poll (#110). Contact telemetry (a different pubkey) is ignored here; + /// the telemetry screen handles those. + void _handleSelfTelemetry(List frame) { + final selfKey = _selfPublicKey; + if (selfKey == null || selfKey.length < 6) return; + if (frame.length < 8) return; // code + reserved + 6-byte pubkey prefix + if (!listEquals(frame.sublist(2, 8), selfKey.sublist(0, 6))) { + return; // not the self node — a contact telemetry reply + } + // Guard the parse: a malformed CayenneLPP payload from an external radio + // must not throw out of the RX dispatch and stall the receive loop. (#123) + try { + final gps = CayenneLpp.extractGps( + CayenneLpp.parseByChannel(Uint8List.fromList(frame.sublist(8))), + ); + if (gps == null || !hasValidLocation(gps.latitude, gps.longitude)) { + _appDebugLogService?.info( + 'Self-telemetry reply received, no valid GPS in payload', + tag: 'GPS', + ); + return; + } + _appDebugLogService?.info( + 'Self GPS via telemetry: ${gps.latitude}, ${gps.longitude}', + tag: 'GPS', + ); + _selfLatitude = gps.latitude; + _selfLongitude = gps.longitude; + notifyListeners(); + } catch (e) { + _appDebugLogService?.warn( + 'Self-telemetry CayenneLPP parse failed: $e', + tag: 'GPS', + ); + } + } + void setPollingInterval(int i) { _pollingInterval = i.clamp(1, 60); if (isConnected) { @@ -4112,6 +4169,9 @@ class MeshCoreConnector extends ChangeNotifier { _handleRxData(frame); _handleLogRxData(frame); break; + case pushCodeTelemetryResponse: + _handleSelfTelemetry(frame); + break; case respCodeChannelInfo: _handleChannelInfo(frame); break; diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index 40391a2..e9e6c83 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -1026,7 +1026,7 @@ Uint8List buildSendTelemetryReq(Uint8List? pubKey) { writer.writeBytes(Uint8List(3)); // reserved bytes writer.writeBytes(pubKey); } else { - writer.writeBytes(Uint8List(4)); // reserved bytes + writer.writeBytes(Uint8List(3)); // self: [cmd]+3 reserved => len==4 (#110) } return writer.toBytes(); } diff --git a/lib/helpers/cayenne_lpp.dart b/lib/helpers/cayenne_lpp.dart index 021e9cc..7c3e1ed 100644 --- a/lib/helpers/cayenne_lpp.dart +++ b/lib/helpers/cayenne_lpp.dart @@ -4,6 +4,26 @@ import 'package:meshcore_open/utils/app_logger.dart'; import '../connector/meshcore_protocol.dart'; class CayenneLpp { + /// The first GPS fix in a parsed CayenneLPP set, or null if none carries one. + /// Self-telemetry returns the radio's own position on `TELEM_CHANNEL_SELF`, + /// so the first GPS reading is the device's own location (#110). + static ({double latitude, double longitude})? extractGps( + List> parsed, + ) { + for (final entry in parsed) { + final values = entry['values']; + if (values is! Map) continue; + final gps = values['gps']; + if (gps is Map && gps['latitude'] is num && gps['longitude'] is num) { + return ( + latitude: (gps['latitude'] as num).toDouble(), + longitude: (gps['longitude'] as num).toDouble(), + ); + } + } + return null; + } + static const int lppDigitalInput = 0; // 1 byte static const int lppDigitalOutput = 1; // 1 byte static const int lppAnalogInput = 2; // 2 bytes, 0.01 signed diff --git a/test/connector/gps_self_telemetry_test.dart b/test/connector/gps_self_telemetry_test.dart new file mode 100644 index 0000000..89277c0 --- /dev/null +++ b/test/connector/gps_self_telemetry_test.dart @@ -0,0 +1,79 @@ +// Self-telemetry GPS read (#110). The client refreshes the radio's own +// position via the self telemetry request (CMD_SEND_TELEMETRY_REQ, len==4) +// instead of APP_START (which reset the session), then extracts GPS from the +// CayenneLPP reply (firmware self-reply: [0x8B][reserved][self-pubkey 6][LPP]). + +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/connector/meshcore_protocol.dart'; +import 'package:meshcore_open/helpers/cayenne_lpp.dart'; + +void main() { + group('buildSendTelemetryReq(null) — the self request', () { + test( + 'is CMD_SEND_TELEMETRY_REQ + 3 reserved = 4-byte frame (firmware len==4)', + () { + // Firmware MyMesh.cpp:2030 matches the self request on `len == 4` (total + // frame). 5 bytes ([39,0,0,0,0]) misses it and gets RESP_CODE_ERR (#110). + expect(buildSendTelemetryReq(null), [cmdSendTelemetryReq, 0, 0, 0]); + }, + ); + }); + + group('CayenneLpp.extractGps', () { + test('returns lat/lon from a parsed GPS reading', () { + final parsed = >[ + { + 'channel': 1, + 'values': { + 'voltage': 4.1, + 'gps': { + 'latitude': 12.34, + 'longitude': 56.78, + 'altitude': 100.0, + }, + }, + }, + ]; + final gps = CayenneLpp.extractGps(parsed); + expect(gps, isNotNull); + expect(gps!.latitude, closeTo(12.34, 1e-9)); + expect(gps.longitude, closeTo(56.78, 1e-9)); + }); + + test('returns null when no channel carries GPS', () { + final parsed = >[ + { + 'channel': 1, + 'values': {'voltage': 4.1}, + }, + ]; + expect(CayenneLpp.extractGps(parsed), isNull); + }); + + test('returns null for empty telemetry', () { + expect(CayenneLpp.extractGps(>[]), isNull); + }); + }); + + group('parseDeviceInfoStrings', () { + test('extracts build date, model, and firmware version', () { + final frame = Uint8List.fromList([ + 0x0d, 0x0e, 0xaf, 0x28, 0, 0, 0, 0, // header (code, ver, counts, rsvd) + ...'24-Jun-2026'.codeUnits, 0, + ...'RAK 3401'.codeUnits, 0, + ...'1.1.0-1.1.0'.codeUnits, 0, + ]); + expect(parseDeviceInfoStrings(frame), [ + '24-Jun-2026', + 'RAK 3401', + '1.1.0-1.1.0', + ]); + }); + + test('returns empty for a short frame', () { + expect(parseDeviceInfoStrings(Uint8List.fromList([0x0d, 0x0e])), isEmpty); + }); + }); +}