feat(#123): GPS refresh — self-telemetry stock-fw fallback (firmware-aware poll)

pull/209/head
Strycher 2 weeks ago
parent 0b4796a707
commit dc20cc17f1

@ -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;
}
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<int> 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;

@ -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();
}

@ -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<Map<String, dynamic>> 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

@ -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 = <Map<String, dynamic>>[
<String, dynamic>{
'channel': 1,
'values': <String, dynamic>{
'voltage': 4.1,
'gps': <String, dynamic>{
'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 = <Map<String, dynamic>>[
<String, dynamic>{
'channel': 1,
'values': <String, dynamic>{'voltage': 4.1},
},
];
expect(CayenneLpp.extractGps(parsed), isNull);
});
test('returns null for empty telemetry', () {
expect(CayenneLpp.extractGps(<Map<String, dynamic>>[]), 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);
});
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.