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
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';
|
||||
}
|
||||
@ -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…
Reference in new issue