feat(#430): client caplog 0xC4 download protocol + reassembler

Slice 1 of the client half of serial-capture (#430): the protocol layer
to download the device's serial-capture buffer over the companion link.

- meshcore_protocol.dart: cmdOffbandCaplog / respCodeOffbandCaplog = 0xC4
  (NOT 0xC3, which collides with cmdOffbandFemLna; see firmware #406),
  START/CHUNK/END sub-codes + request builder.
- caplog_reassembler.dart: pure START/CHUNK*/END reassembly state machine
  with truncation detection, unit-tested in isolation.
- meshcore_connector.dart: downloadCaplog() + 0xC4 frame dispatch + fast
  busy-reject on RESP_CODE_ERR while awaiting START.
- 9 unit tests passing; flutter analyze clean.

Integration test gated on the firmware 0xC4 fix merging. Not pushed
(human-test gate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix/395-dm-channel-send-wedge
Strycher 2 months ago
parent 26e0cb3211
commit 67eae2feb4

@ -0,0 +1,86 @@
import 'dart:typed_data';
import 'meshcore_protocol.dart';
/// Outcome of feeding one `0xC4` frame to [CaplogReassembler.accept].
enum CaplogStatus { started, chunk, completed, truncated, ignored }
/// Result of [CaplogReassembler.accept]. [bytes] is set on [CaplogStatus.completed]
/// and [CaplogStatus.truncated]; [expected] is set on [CaplogStatus.truncated].
class CaplogEvent {
const CaplogEvent(this.status, {this.bytes, this.expected});
final CaplogStatus status;
final Uint8List? bytes;
final int? expected;
}
/// Pure state machine that reassembles an Offband caplog (`0xC4`) streamed dump:
/// START `[0xC4, 0x01, total_len(uint32 LE)]` → CHUNK `[0xC4, 0x02, <bytes>]*`
/// → END `[0xC4, 0x03]`.
///
/// A CHUNK or END arriving without a preceding START, and any non-`0xC4` frame,
/// is [CaplogStatus.ignored]. Kept free of connector/transport concerns so the
/// reassembly is unit-testable in isolation. (#430)
class CaplogReassembler {
final BytesBuilder _buffer = BytesBuilder();
int? _expected;
bool _active = false;
CaplogEvent accept(Uint8List frame) {
if (frame.length < 2 || frame[0] != respCodeOffbandCaplog) {
return const CaplogEvent(CaplogStatus.ignored);
}
switch (frame[1]) {
case caplogSubStart:
_buffer.clear();
// total_len is optional/defensive: an older device might omit it. When
// absent we can't detect truncation, but still reassemble what arrives.
_expected = frame.length >= 6 ? readUint32LE(frame, 2) : null;
_active = true;
return const CaplogEvent(CaplogStatus.started);
case caplogSubChunk:
if (!_active) return const CaplogEvent(CaplogStatus.ignored);
if (frame.length > 2) _buffer.add(frame.sublist(2));
return const CaplogEvent(CaplogStatus.chunk);
case caplogSubEnd:
if (!_active) return const CaplogEvent(CaplogStatus.ignored);
_active = false;
final bytes = _buffer.takeBytes();
final expected = _expected;
_expected = null;
if (expected != null && bytes.length != expected) {
return CaplogEvent(
CaplogStatus.truncated,
bytes: bytes,
expected: expected,
);
}
return CaplogEvent(CaplogStatus.completed, bytes: bytes);
default:
return const CaplogEvent(CaplogStatus.ignored);
}
}
}
/// Thrown when a caplog download is rejected because another streamed response
/// is already in flight on the device (firmware answers `RESP_CODE_ERR`). (#430)
class CaplogBusyException implements Exception {
const CaplogBusyException();
@override
String toString() =>
'CaplogBusyException: device busy — another stream is in flight';
}
/// Thrown when the reassembled caplog byte count doesn't match the length the
/// device announced in its START frame. (#430)
class CaplogTruncatedException implements Exception {
const CaplogTruncatedException({
required this.received,
required this.expected,
});
final int received;
final int expected;
@override
String toString() =>
'CaplogTruncatedException: received $received of $expected bytes';
}

@ -58,6 +58,7 @@ import '../utils/battery_utils.dart';
import '../utils/platform_info.dart';
import 'meshcore_uuids.dart';
import 'meshcore_protocol.dart';
import 'caplog_reassembler.dart';
class DirectRepeater {
static const int maxAgeMinutes = 30; // Max age for direct repeater info
@ -283,6 +284,10 @@ class MeshCoreConnector extends ChangeNotifier {
bool _blockOffloadStoreFull = false;
bool _blockDumpInFlight = false;
final Set<String> _blockKeysTouchedDuringDump = {};
// Caplog serial-capture download (0xC4) streamed reassembly (#430).
Completer<Uint8List>? _caplogCompleter;
CaplogReassembler? _caplogReassembler;
bool _caplogAwaitingStart = false;
String? _firmwareVersion;
String? _deviceModel;
int? _offbandCaps;
@ -4277,6 +4282,83 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
/// Download the device's serial-capture buffer over the companion protocol
/// (0xC4). Sends the request and reassembles the START/CHUNK*/END stream into
/// the raw captured bytes.
///
/// Throws [StateError] if a download is already in flight,
/// [CaplogBusyException] if the device rejects because another stream is
/// already in flight, [CaplogTruncatedException] if the byte count doesn't
/// match the announced length, or [TimeoutException] if it never finishes.
/// (#430)
Future<Uint8List> downloadCaplog({
Duration timeout = const Duration(seconds: 30),
}) async {
if (_caplogCompleter != null) {
throw StateError('A caplog download is already in progress');
}
final completer = Completer<Uint8List>();
_caplogCompleter = completer;
_caplogReassembler = CaplogReassembler();
_caplogAwaitingStart = true;
final timer = Timer(timeout, () {
if (!completer.isCompleted) {
completer.completeError(
TimeoutException('caplog download timed out', timeout),
);
}
});
try {
await sendFrame(buildOffbandCaplogRequestFrame());
return await completer.future;
} finally {
timer.cancel();
_caplogCompleter = null;
_caplogReassembler = null;
_caplogAwaitingStart = false;
}
}
/// Route an incoming 0xC4 caplog frame into the in-flight download, if any.
/// START/CHUNK/END are reassembled by [CaplogReassembler]; frames arriving
/// with no download in flight are ignored. (#430)
void _handleOffbandCaplogFrame(Uint8List frame) {
final completer = _caplogCompleter;
final reassembler = _caplogReassembler;
if (completer == null || reassembler == null || completer.isCompleted) {
return;
}
final event = reassembler.accept(frame);
switch (event.status) {
case CaplogStatus.started:
_caplogAwaitingStart = false;
break;
case CaplogStatus.completed:
_appDebugLogService?.info(
'Caplog download complete: ${event.bytes!.length} bytes',
tag: 'Caplog',
);
completer.complete(event.bytes);
break;
case CaplogStatus.truncated:
_appDebugLogService?.warn(
'Caplog download truncated: ${event.bytes!.length} of '
'${event.expected} bytes',
tag: 'Caplog',
);
completer.completeError(
CaplogTruncatedException(
received: event.bytes!.length,
expected: event.expected!,
),
);
break;
case CaplogStatus.chunk:
case CaplogStatus.ignored:
break;
}
}
/// Called when a BLOCK_LIST dump ends. A truncated dump (early-END) is
/// re-requested once the link settles; never derive removals from a partial
/// pull. The union reconcile against the local list is wired in B4.
@ -4466,6 +4548,9 @@ class MeshCoreConnector extends ChangeNotifier {
case cmdOffbandFemLna:
_handleOffbandFemLnaReply(frame);
break;
case respCodeOffbandCaplog:
_handleOffbandCaplogFrame(frame);
break;
case respCodeSelfInfo:
debugPrint('Got SELF_INFO');
_handleSelfInfo(frame);
@ -4607,6 +4692,19 @@ class MeshCoreConnector extends ChangeNotifier {
}) => isSyncingChannels && channelSyncInFlight && !hasPendingGenericAck;
void _handleErrorFrame(Uint8List frame) {
// A caplog download awaiting its START frame: the firmware answers the
// generic RESP_CODE_ERR when another stream (block-list / contacts /
// observer config) is already in flight. Fail the download fast with a
// typed error instead of waiting out the timeout. Falling through to the
// normal handling below is harmless — it no-ops unless a channel sync is
// active. (#430)
if (_caplogAwaitingStart) {
_caplogAwaitingStart = false;
final caplog = _caplogCompleter;
if (caplog != null && !caplog.isCompleted) {
caplog.completeError(const CaplogBusyException());
}
}
final errCode = frame.length > 1 ? frame[1] : -1;
_appDebugLogService?.warn(
'Firmware responded with error code: $errCode',

@ -312,6 +312,30 @@ OffbandFemLnaReply? parseOffbandFemLnaReply(Uint8List frame) {
return OffbandFemLnaReply(frame[1], frame[2]);
}
// --- Offband caplog serial-capture download (0xC4) — companion-API only; NEVER
// on the mesh. Firmware counterpart OffbandMesh/meshcore-firmware#406.
//
// 0xC4, NOT 0xC3: the firmware first merged this on 0xC3, which collides with
// cmdOffbandFemLna (0xC3, #298) — the caplog handler swallowed every 0xC3 frame
// before FEM/LNA dispatch. Reassigned to 0xC4, the next free code in the 0xC0+
// space (0xC0 config, 0xC1 GPS, 0xC2 block, 0xC3 FEM LNA).
//
// Request: bare [0xC4], no payload (mirrors cmdOffbandGps). Reply is a streamed
// dump: START [0xC4, 0x01, total_len(uint32 LE)] → CHUNK [0xC4, 0x02, <bytes>]*
// → END [0xC4, 0x03]. The firmware auto-stops capture for the duration (so
// offsets stay stable) and rejects with the generic [respCodeErr] when another
// stream (block-list / contacts / observer config) is already in flight. (#430)
const int cmdOffbandCaplog = 0xC4;
const int respCodeOffbandCaplog = 0xC4;
const int caplogSubStart = 0x01;
const int caplogSubChunk = 0x02;
const int caplogSubEnd = 0x03;
/// Request frame to download the device's serial-capture buffer — a bare 1-byte
/// command, no payload. (#430)
Uint8List buildOffbandCaplogRequestFrame() =>
Uint8List.fromList([cmdOffbandCaplog]);
// --- Offband block command (0xC2) — capability-gated; see
// docs/architecture/block-contract-as-built.md §8. Firmware as-built PR #247. ---
const int cmdOffbandBlock = 0xC2;

@ -0,0 +1,116 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/connector/caplog_reassembler.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart';
Uint8List _start(int total) => Uint8List.fromList([
respCodeOffbandCaplog,
caplogSubStart,
total & 0xFF,
(total >> 8) & 0xFF,
(total >> 16) & 0xFF,
(total >> 24) & 0xFF,
]);
Uint8List _startNoLen() =>
Uint8List.fromList([respCodeOffbandCaplog, caplogSubStart]);
Uint8List _chunk(List<int> bytes) =>
Uint8List.fromList([respCodeOffbandCaplog, caplogSubChunk, ...bytes]);
Uint8List _end() => Uint8List.fromList([respCodeOffbandCaplog, caplogSubEnd]);
void main() {
group('CaplogReassembler', () {
test('reassembles START/CHUNK*/END into the full payload', () {
final r = CaplogReassembler();
expect(r.accept(_start(5)).status, CaplogStatus.started);
expect(r.accept(_chunk([1, 2, 3])).status, CaplogStatus.chunk);
expect(r.accept(_chunk([4, 5])).status, CaplogStatus.chunk);
final end = r.accept(_end());
expect(end.status, CaplogStatus.completed);
expect(end.bytes, Uint8List.fromList([1, 2, 3, 4, 5]));
});
test('empty capture: START(0) then END completes with no bytes', () {
final r = CaplogReassembler();
r.accept(_start(0));
final end = r.accept(_end());
expect(end.status, CaplogStatus.completed);
expect(end.bytes, isEmpty);
});
test('flags truncation when reassembled bytes < announced total', () {
final r = CaplogReassembler();
r.accept(_start(10));
r.accept(_chunk([1, 2, 3]));
final end = r.accept(_end());
expect(end.status, CaplogStatus.truncated);
expect(end.expected, 10);
expect(end.bytes!.length, 3);
});
test('START without a length reassembles with no truncation check', () {
final r = CaplogReassembler();
r.accept(_startNoLen());
r.accept(_chunk([9, 9]));
final end = r.accept(_end());
expect(end.status, CaplogStatus.completed);
expect(end.bytes, Uint8List.fromList([9, 9]));
});
test('ignores CHUNK/END with no preceding START', () {
final r = CaplogReassembler();
expect(r.accept(_chunk([1])).status, CaplogStatus.ignored);
expect(r.accept(_end()).status, CaplogStatus.ignored);
});
test('ignores non-caplog frames', () {
final r = CaplogReassembler();
expect(
r.accept(Uint8List.fromList([respCodeOk])).status,
CaplogStatus.ignored,
);
expect(
r.accept(Uint8List.fromList([cmdOffbandFemLna, 0x01, 0x01])).status,
CaplogStatus.ignored,
);
});
test('a second download reuses the same reassembler cleanly', () {
final r = CaplogReassembler();
r.accept(_start(2));
r.accept(_chunk([1, 2]));
expect(r.accept(_end()).status, CaplogStatus.completed);
r.accept(_start(3));
r.accept(_chunk([7, 8, 9]));
final end = r.accept(_end());
expect(end.status, CaplogStatus.completed);
expect(end.bytes, Uint8List.fromList([7, 8, 9]));
});
test('a fresh START mid-stream discards the prior partial buffer', () {
final r = CaplogReassembler();
r.accept(_start(99));
r.accept(_chunk([1, 2, 3])); // abandoned
r.accept(_start(2)); // restart
r.accept(_chunk([4, 5]));
final end = r.accept(_end());
expect(end.status, CaplogStatus.completed);
expect(end.bytes, Uint8List.fromList([4, 5]));
});
test('large multi-chunk payload reassembles in order', () {
final r = CaplogReassembler();
final expected = List<int>.generate(500, (i) => i % 256);
r.accept(_start(expected.length));
for (var i = 0; i < expected.length; i += 50) {
r.accept(_chunk(expected.sublist(i, i + 50)));
}
final end = r.accept(_end());
expect(end.status, CaplogStatus.completed);
expect(end.bytes, Uint8List.fromList(expected));
});
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.