fix(#580): keep a truncated caplog and offer to save/share it

A truncated caplog download used to completeError and discard every received
byte, so the user got nothing from a buffer still intact on the device (a tester
lost 8461/8608 bytes, 98.3%, deterministically per firmware#711 so retry can't
help). Now truncation is a soft outcome:

- downloadCaplog() returns a CaplogDownload (bytes + received/expected/chunks +
  truncated) instead of throwing; busy/timeout still throw.
- serial_capture_screen still writes + LogExport.shareFile the partial bytes,
  marks the file "# PARTIAL ..." with the counts, and shows a non-fatal warning
  instead of a red error.
- Button label is now platform-aware (Download & save on desktop, & share on
  mobile) via LogExport.actionVerb, matching the App/BLE log screens.

Replaces CaplogTruncatedException with CaplogDownload. Reassembler unchanged
(already keeps the bytes). Adds CaplogDownload contract tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix/580-caplog-partial
Strycher 1 month ago
parent a57ec8f902
commit d812bc38ed

@ -71,23 +71,26 @@ class CaplogBusyException implements Exception {
'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({
/// Result of a caplog download. On a clean transfer [received] == [expected]
/// and [truncated] is false; on a short transfer [bytes] still carry everything
/// that was reassembled and [truncated] is true. A partial capture is kept and
/// handed back rather than discarded, so the user can still save/share it. (#580)
class CaplogDownload {
const CaplogDownload({
required this.bytes,
required this.received,
required this.expected,
this.chunks,
required this.chunks,
});
/// The reassembled bytes (complete, or partial when [truncated]).
final Uint8List bytes;
final int received;
final int expected;
/// Number of CHUNK frames the client accumulated before END, a diagnostic to
/// tell client/transport frame loss apart from the firmware streaming short.
final int? chunks;
/// Number of CHUNK frames accumulated before END, a diagnostic to tell
/// client/transport frame loss apart from the firmware streaming short.
final int chunks;
@override
String toString() =>
'CaplogTruncatedException: received $received of $expected bytes'
'${chunks != null ? ' in $chunks chunks' : ''}';
bool get truncated => received != expected;
}

@ -292,7 +292,7 @@ class MeshCoreConnector extends ChangeNotifier {
bool _blockDumpInFlight = false;
final Set<String> _blockKeysTouchedDuringDump = {};
// Caplog serial-capture download (0xC4) streamed reassembly (#430).
Completer<Uint8List>? _caplogCompleter;
Completer<CaplogDownload>? _caplogCompleter;
CaplogReassembler? _caplogReassembler;
bool _caplogAwaitingStart = false;
Completer<CaplogAck>? _caplogAckCompleter;
@ -4638,18 +4638,19 @@ class MeshCoreConnector extends ChangeNotifier {
/// (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,
/// Returns a [CaplogDownload] carrying the reassembled bytes. A short transfer
/// is NOT an error: the result's [CaplogDownload.truncated] is set and the
/// partial [CaplogDownload.bytes] are kept so the caller can still save/share
/// them (#580). 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({
/// already in flight, or [TimeoutException] if it never finishes. (#430)
Future<CaplogDownload> downloadCaplog({
Duration timeout = const Duration(seconds: 30),
}) async {
if (_caplogCompleter != null) {
throw StateError('A caplog download is already in progress');
}
final completer = Completer<Uint8List>();
final completer = Completer<CaplogDownload>();
_caplogCompleter = completer;
_caplogReassembler = CaplogReassembler();
_caplogAwaitingStart = true;
@ -4712,7 +4713,14 @@ class MeshCoreConnector extends ChangeNotifier {
'$_caplogChunks chunks',
tag: 'Caplog',
);
completer.complete(event.bytes);
completer.complete(
CaplogDownload(
bytes: event.bytes!,
received: event.bytes!.length,
expected: event.bytes!.length,
chunks: _caplogChunks,
),
);
break;
case CaplogStatus.truncated:
_appDebugLogService?.warn(
@ -4720,8 +4728,11 @@ class MeshCoreConnector extends ChangeNotifier {
'${event.expected} bytes in $_caplogChunks chunks',
tag: 'Caplog',
);
completer.completeError(
CaplogTruncatedException(
// Not an error: hand back the partial bytes so the caller can still
// save/share them (#580).
completer.complete(
CaplogDownload(
bytes: event.bytes!,
received: event.bytes!.length,
expected: event.expected!,
chunks: _caplogChunks,

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
@ -37,6 +38,7 @@ class _SerialCaptureScreenState extends State<SerialCaptureScreen> {
int _durationMinutes = 5;
bool _busy = false;
String? _error;
String? _warning;
bool _wasConnected = false;
DateTime? _startedAt;
@ -247,11 +249,12 @@ class _SerialCaptureScreenState extends State<SerialCaptureScreen> {
setState(() {
_busy = true;
_error = null;
_warning = null;
});
try {
final bytes = await c.downloadCaplog();
final result = await c.downloadCaplog();
if (!mounted) return;
if (bytes.isEmpty) {
if (result.bytes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Buffer is empty, nothing to download.'),
@ -259,15 +262,36 @@ class _SerialCaptureScreenState extends State<SerialCaptureScreen> {
);
return;
}
// A truncated capture is kept, not discarded: mark the file partial (so a
// partial log is never mistaken for a complete one) and warn the user, but
// still write and offer to save/share it (#580).
final dir = await getTemporaryDirectory();
final ts = DateTime.now();
final name =
'serial-capture-'
'${ts.year}${_pad2(ts.month)}${_pad2(ts.day)}-'
'${_pad2(ts.hour)}${_pad2(ts.minute)}${_pad2(ts.second)}.txt';
'${_pad2(ts.hour)}${_pad2(ts.minute)}${_pad2(ts.second)}'
'${result.truncated ? '-partial' : ''}.txt';
final file = File('${dir.path}${Platform.pathSeparator}$name');
await file.writeAsBytes(bytes);
if (result.truncated) {
final header = utf8.encode(
'# PARTIAL Offband serial capture: '
'${result.received} of ${result.expected} bytes in '
'${result.chunks} chunks. Incomplete '
'(see OffbandMesh/meshcore-firmware#711).\n\n',
);
await file.writeAsBytes([...header, ...result.bytes]);
} else {
await file.writeAsBytes(result.bytes);
}
if (!mounted) return;
if (result.truncated) {
setState(
() => _warning =
'Partial capture: ${result.received} of ${result.expected} '
'bytes. Saving what was captured.',
);
}
await LogExport.shareFile(
context,
file,
@ -279,8 +303,6 @@ class _SerialCaptureScreenState extends State<SerialCaptureScreen> {
() => _error = 'Device busy (another transfer in progress). Retry.',
);
}
} on CaplogTruncatedException catch (e) {
if (mounted) setState(() => _error = 'Capture truncated: $e');
} on TimeoutException {
if (mounted) setState(() => _error = 'No response from device.');
} catch (e) {
@ -381,6 +403,24 @@ class _SerialCaptureScreenState extends State<SerialCaptureScreen> {
),
),
),
if (_warning != null)
Card(
color: Theme.of(context).colorScheme.tertiaryContainer,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
const Icon(Icons.warning_amber_outlined),
const SizedBox(width: 8),
Expanded(child: Text(_warning!)),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => setState(() => _warning = null),
),
],
),
),
),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
@ -451,7 +491,7 @@ class _SerialCaptureScreenState extends State<SerialCaptureScreen> {
OutlinedButton.icon(
onPressed: _busy || capturing ? null : _download,
icon: Icon(LogExport.icon),
label: const Text('Download & share'),
label: Text('Download & ${LogExport.actionVerb}'),
),
const SizedBox(height: 8),
TextButton.icon(

@ -43,6 +43,11 @@ class LogExport {
: context.l10n.debugLog_saveLog;
}
/// Short verb for a compound button label like "Download & save". Mobile
/// shares via the OS sheet; desktop and web save a file (web via a browser
/// download), so "save" fits both. (#580)
static String get actionVerb => (!kIsWeb && _isMobile) ? 'share' : 'save';
/// Export the combined on-disk app+BLE log (the App/BLE log screens). On web
/// there is no on-disk file, so [webContent] supplies the text to download.
static Future<void> shareLogs(

@ -113,4 +113,27 @@ void main() {
expect(end.bytes, Uint8List.fromList(expected));
});
});
group('CaplogDownload', () {
test('truncated when received < expected, and keeps the partial bytes', () {
final d = CaplogDownload(
bytes: Uint8List.fromList([1, 2, 3]),
received: 3,
expected: 10,
chunks: 2,
);
expect(d.truncated, isTrue);
expect(d.bytes, Uint8List.fromList([1, 2, 3]));
});
test('not truncated when received == expected', () {
final d = CaplogDownload(
bytes: Uint8List.fromList([1, 2, 3]),
received: 3,
expected: 3,
chunks: 1,
);
expect(d.truncated, isFalse);
});
});
}

Loading…
Cancel
Save

Powered by TurnKey Linux.