import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; import '../connector/caplog_reassembler.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; import '../helpers/snack_bar_builder.dart'; import '../utils/log_export.dart'; /// Serial-capture diagnostics screen (#430). /// /// Controls the connected radio's serial-capture buffer over the 0xC4 companion /// command (enable / disable / erase / status) and downloads it as a shareable /// file. Support is gated on the device-info **capability bit** (`0x20` + /// `FIRMWARE_VER_CODE >= 17`), which is static and refreshes on reconnect, so /// the feature never latches "unsupported" after a reboot. Capture state is /// derived from the device STATUS, so an auto-resumed capture (post-reboot) /// shows STOP, not START. /// /// Strings are English-only for now; localization is a follow-up (#427). class SerialCaptureScreen extends StatefulWidget { const SerialCaptureScreen({super.key}); @override State createState() => _SerialCaptureScreenState(); } class _SerialCaptureScreenState extends State { /// Capture-window options in minutes for the timed flow; 0 = until stopped. static const List _durations = [1, 5, 15, 30, 0]; MeshCoreConnector? _connector; CaplogDeviceStatus? _status; int _durationMinutes = 5; bool _busy = false; String? _error; String? _warning; bool _wasConnected = false; DateTime? _startedAt; int? _timedWindowMinutes; // set while a timed capture is running (for the UI) Timer? _tick; Timer? _autoStop; Timer? _statusPoll; @override void initState() { super.initState(); final c = context.read(); _connector = c; c.addListener(_onConnectorChanged); _wasConnected = c.isConnected; if (c.isConnected && c.supportsOffbandCaplog) { WidgetsBinding.instance.addPostFrameCallback((_) { _ensureStatusPolling(); _refreshStatus(); }); } } @override void dispose() { _connector?.removeListener(_onConnectorChanged); _stopTimers(); super.dispose(); } /// React to connection transitions: on reconnect re-derive device state (no /// latch); on disconnect stop polling a dead link. void _onConnectorChanged() { final c = _connector; if (c == null || !mounted) return; final connected = c.isConnected; if (connected && !_wasConnected) { _wasConnected = true; if (c.supportsOffbandCaplog) { _ensureStatusPolling(); _refreshStatus(); } } else if (!connected && _wasConnected) { _wasConnected = false; _stopTimers(); setState(() {}); } } Future _refreshStatus() async { final c = _connector; if (c == null || !c.isConnected) return; try { final status = await c.getDeviceCaplogStatus(); if (!mounted) return; setState(() => _status = status); // Keep local timers/elapsed in sync with the device's actual state, so an // auto-resumed capture after a reboot is reflected as running. if (status.enabled) { _startedAt ??= DateTime.now(); _startTick(); } else { _startedAt = null; _timedWindowMinutes = null; _stopTick(); } } catch (_) { // Transient (e.g. mid-reconnect); keep last-known state and retry on the // next poll / reconnect rather than latching. } } /// STATUS poll runs whenever the screen is on a connected, caplog-capable /// device, so the buffer readout stays live even while idle (not just during /// capture). Stopped only on disconnect / dispose. void _ensureStatusPolling() { _statusPoll ??= Timer.periodic( const Duration(seconds: 3), (_) => _refreshStatus(), ); } /// 1s elapsed-time tick; runs only while capturing. void _startTick() { _tick ??= Timer.periodic(const Duration(seconds: 1), (_) { if (mounted) setState(() {}); }); } void _stopTick() { _tick?.cancel(); _tick = null; } void _stopTimers() { _tick?.cancel(); _autoStop?.cancel(); _statusPoll?.cancel(); _tick = _autoStop = _statusPoll = null; } Future _setEnabled(bool enabled) async { final c = _connector; if (c == null) return false; final ok = await c.setDeviceCaplogEnabled(enabled); if (!ok) { throw Exception('device rejected ${enabled ? 'enable' : 'disable'}'); } return ok; } Future _startTimed() async { setState(() { _busy = true; _error = null; }); try { await _connector?.eraseDeviceCaplog(); // clean session: start from 0 await _setEnabled(true); if (!mounted) return; _startedAt = DateTime.now(); _timedWindowMinutes = _durationMinutes > 0 ? _durationMinutes : null; _startTick(); if (_durationMinutes > 0) { _autoStop?.cancel(); _autoStop = Timer(Duration(minutes: _durationMinutes), _stop); } await _refreshStatus(); } catch (e) { if (mounted) setState(() => _error = 'Could not start capture: $e'); } finally { if (mounted) setState(() => _busy = false); } } Future _stop() async { if (!mounted) return; setState(() => _busy = true); try { await _setEnabled(false); if (mounted) { _startedAt = null; _timedWindowMinutes = null; } _autoStop?.cancel(); _autoStop = null; _stopTick(); await _refreshStatus(); } catch (e) { if (mounted) setState(() => _error = 'Could not stop capture: $e'); } finally { if (mounted) setState(() => _busy = false); } } /// Boot-log flow (#428): enable capture with no timer, then reboot so the /// radio records the boot sequence from power-on. The connection drops during /// reboot; on reconnect `_onConnectorChanged` re-derives STATUS and (once /// firmware #428 persists the flag) the capture shows as running → Stop here. Future _startAndReboot() async { final c = _connector; if (c == null) return; final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Start capture & reboot?'), content: const Text( 'Enables serial capture, then reboots the radio so the boot log is ' 'captured from power-on. There is no timer. Capture runs until you ' 'Stop it. The connection drops during the reboot; when it reconnects, ' 'capture is still running and you can Stop and download here.', ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel'), ), FilledButton( style: FilledButton.styleFrom(backgroundColor: Colors.red[700]), onPressed: () => Navigator.pop(ctx, true), child: const Text('Start & Reboot'), ), ], ), ); if (confirmed != true || !mounted) return; setState(() { _busy = true; _error = null; }); try { await c.eraseDeviceCaplog(); // fresh boot log from power-on await _setEnabled(true); // no timer: runs until Stop _startedAt = DateTime.now(); _timedWindowMinutes = null; await c.rebootDevice(); // Connection drops now; reconnect handling re-derives STATUS. } catch (e) { if (mounted) setState(() => _error = 'Start & reboot failed: $e'); } finally { if (mounted) setState(() => _busy = false); } } Future _download() async { final c = _connector; if (c == null) return; setState(() { _busy = true; _error = null; _warning = null; }); try { final result = await c.downloadCaplog(); if (!mounted) return; if (result.bytes.isEmpty) { showDismissibleSnackBar( context, content: const Text('Buffer is empty, nothing to download.'), ); 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)}' '${result.truncated ? '-partial' : ''}.txt'; final file = File('${dir.path}${Platform.pathSeparator}$name'); 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, subject: 'Offband serial capture', ); } on CaplogBusyException { if (mounted) { setState( () => _error = 'Device busy (another transfer in progress). Retry.', ); } } on TimeoutException { if (mounted) setState(() => _error = 'No response from device.'); } catch (e) { if (mounted) setState(() => _error = 'Download failed: $e'); } finally { if (mounted) setState(() => _busy = false); } } Future _erase() async { final c = _connector; if (c == null) return; setState(() { _busy = true; _error = null; }); try { await c.eraseDeviceCaplog(); await _refreshStatus(); } catch (e) { if (mounted) setState(() => _error = 'Erase failed: $e'); } finally { if (mounted) setState(() => _busy = false); } } static String _pad2(int n) => n.toString().padLeft(2, '0'); String _elapsed() { final s = _startedAt == null ? 0 : DateTime.now().difference(_startedAt!).inSeconds; return '${_pad2(s ~/ 60)}:${_pad2(s % 60)}'; } String _remaining() { final window = _timedWindowMinutes; if (_startedAt == null || window == null) return ''; final total = window * 60; final left = (total - DateTime.now().difference(_startedAt!).inSeconds) .clamp(0, total); return '${_pad2(left ~/ 60)}:${_pad2(left % 60)}'; } @override Widget build(BuildContext context) { final connector = context.watch(); return Scaffold( appBar: AppBar(title: const Text('Serial capture'), centerTitle: true), body: _body(connector), ); } Widget _body(MeshCoreConnector connector) { if (!connector.isConnected) { return const Padding( padding: EdgeInsets.all(24), child: Center( child: Text( 'Connect to a device to use serial capture.', textAlign: TextAlign.center, ), ), ); } if (!connector.supportsOffbandCaplog) { return const Padding( padding: EdgeInsets.all(24), child: Center( child: Text( "This device's firmware doesn't support serial capture.", textAlign: TextAlign.center, ), ), ); } final capturing = _status?.enabled ?? (_startedAt != null); final timed = _timedWindowMinutes != null; return ListView( padding: const EdgeInsets.all(16), children: [ if (_error != null) Card( color: Theme.of(context).colorScheme.errorContainer, child: Padding( padding: const EdgeInsets.all(12), child: Row( children: [ const Icon(Icons.error_outline), const SizedBox(width: 8), Expanded(child: Text(_error!)), IconButton( icon: const Icon(Icons.close), onPressed: () => setState(() => _error = null), ), ], ), ), ), 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), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( capturing ? 'Capturing…' : 'Idle', style: Theme.of(context).textTheme.titleMedium, ), if (capturing) Padding( padding: const EdgeInsets.only(top: 4), child: Text( 'Elapsed ${_elapsed()}' '${timed ? ' · auto-stops in ${_remaining()}' : ''}', ), ), if (_status != null) ...[ const SizedBox(height: 12), LinearProgressIndicator( value: _status!.capacityBytes > 0 ? (_status!.usedBytes / _status!.capacityBytes).clamp( 0.0, 1.0, ) : null, ), const SizedBox(height: 4), Text( 'Buffer ${_status!.usedBytes} / ${_status!.capacityBytes} bytes', ), ], ], ), ), ), const SizedBox(height: 16), Row( children: [ const Text('Capture duration'), const Spacer(), DropdownButton( value: _durationMinutes, onChanged: capturing ? null : (v) { if (v != null) setState(() => _durationMinutes = v); }, items: _durations .map( (m) => DropdownMenuItem( value: m, child: Text(m == 0 ? 'Until I stop' : '$m min'), ), ) .toList(), ), ], ), const SizedBox(height: 16), FilledButton.icon( onPressed: _busy ? null : (capturing ? _stop : _startTimed), icon: Icon(capturing ? Icons.stop : Icons.fiber_manual_record), label: Text(capturing ? 'Stop capture' : 'Start capture'), ), const SizedBox(height: 8), OutlinedButton.icon( onPressed: _busy || capturing ? null : _download, icon: Icon(LogExport.icon), label: Text('Download & ${LogExport.actionVerb}'), ), const SizedBox(height: 8), TextButton.icon( onPressed: _busy || capturing ? null : _erase, icon: const Icon(Icons.delete_outline), label: const Text('Erase buffer'), ), const Divider(height: 24), // Boot-log flow (#428): enable capture (no timer) + reboot, styled as a // dangerous action (it reboots the radio and drops the connection). FilledButton.icon( style: FilledButton.styleFrom( backgroundColor: Colors.red[700], foregroundColor: Colors.white, ), onPressed: _busy || capturing ? null : _startAndReboot, icon: const Icon(Icons.restart_alt), label: const Text('Start & Reboot (capture boot log)'), ), ], ); } }