Enhance USB serial services with debug logging and reset functionality

- Introduced debug logging in USB serial services for better traceability.
- Added reset method to UsbSerialFrameDecoder to clear buffered data.
- Updated tests to verify the reset functionality of the decoder.
This commit is contained in:
just_stuff_tm 2026-03-02 18:54:12 -05:00 committed by just-stuff-tm
parent c2f544eeba
commit 4c7ee3b3b0
5 changed files with 153 additions and 30 deletions

View file

@ -350,11 +350,38 @@ class MeshCoreConnector extends ChangeNotifier {
? allMessages.sublist(allMessages.length - _messageWindowSize)
: allMessages;
_conversations[contactKeyHex] = windowedMessages;
final currentMessages =
_conversations[contactKeyHex] ?? const <Message>[];
final mergedMessages = <Message>[...windowedMessages];
final existingKeys = <String>{
for (final message in windowedMessages) _messageMergeKey(message),
};
for (final message in currentMessages) {
final key = _messageMergeKey(message);
if (existingKeys.add(key)) {
mergedMessages.add(message);
}
}
mergedMessages.sort((a, b) => a.timestamp.compareTo(b.timestamp));
final windowedMergedMessages = mergedMessages.length > _messageWindowSize
? mergedMessages.sublist(mergedMessages.length - _messageWindowSize)
: mergedMessages;
_conversations[contactKeyHex] = windowedMergedMessages;
notifyListeners();
}
}
String _messageMergeKey(Message message) {
final messageId = message.messageId;
if (messageId != null && messageId.isNotEmpty) {
return 'id:$messageId';
}
return 'fallback:${message.isOutgoing}:${message.timestamp.millisecondsSinceEpoch}:${message.text}';
}
/// Load older messages for a contact (pagination)
Future<List<Message>> loadOlderMessages(
String contactKeyHex, {
@ -590,6 +617,7 @@ class MeshCoreConnector extends ChangeNotifier {
_bleDebugLogService = bleDebugLogService;
_appDebugLogService = appDebugLogService;
_backgroundService = backgroundService;
_usbSerialService.setDebugLogService(_appDebugLogService);
// Initialize notification service
_notificationService.initialize();
@ -749,7 +777,7 @@ class MeshCoreConnector extends ChangeNotifier {
androidScanMode: AndroidScanMode.lowLatency,
);
} catch (error) {
debugPrint('[BLE Scan] Scan/picker failure: $error');
_appDebugLogService?.warn('Scan/picker failure: $error', tag: 'BLE Scan');
_setState(MeshCoreConnectionState.disconnected);
rethrow;
}
@ -806,7 +834,10 @@ class MeshCoreConnector extends ChangeNotifier {
try {
final connectLabel = _deviceDisplayName ?? _deviceId;
debugPrint('[BLE Connect] Starting connect to $connectLabel');
_appDebugLogService?.info(
'Starting connect to $connectLabel',
tag: 'BLE Connect',
);
_connectionSubscription = device.connectionState.listen((state) {
if (state == BluetoothConnectionState.disconnected && isConnected) {
_handleDisconnection();
@ -820,7 +851,10 @@ class MeshCoreConnector extends ChangeNotifier {
license: License.free,
);
} catch (error) {
debugPrint('[BLE Connect] device.connect() failure: $error');
_appDebugLogService?.error(
'device.connect() failure: $error',
tag: 'BLE Connect',
);
rethrow;
}
@ -828,9 +862,12 @@ class MeshCoreConnector extends ChangeNotifier {
if (!PlatformInfo.isWeb) {
try {
final mtu = await device.requestMtu(185);
debugPrint('MTU set to: $mtu');
_appDebugLogService?.info('MTU set to: $mtu', tag: 'BLE Connect');
} catch (e) {
debugPrint('MTU request failed: $e, using default');
_appDebugLogService?.warn(
'MTU request failed: $e, using default',
tag: 'BLE Connect',
);
}
}
@ -838,11 +875,15 @@ class MeshCoreConnector extends ChangeNotifier {
try {
services = await device.discoverServices();
} catch (error) {
debugPrint('[BLE Connect] service discovery failure: $error');
_appDebugLogService?.error(
'service discovery failure: $error',
tag: 'BLE Connect',
);
if (PlatformInfo.isWeb &&
error.toString().contains('GATT Server is disconnected')) {
debugPrint(
'[BLE Connect] retrying service discovery after transient web disconnect',
_appDebugLogService?.warn(
'retrying service discovery after transient web disconnect',
tag: 'BLE Connect',
);
await Future<void>.delayed(const Duration(milliseconds: 300));
await device.connect(
@ -882,17 +923,32 @@ class MeshCoreConnector extends ChangeNotifier {
}
if (PlatformInfo.isWeb) {
debugPrint('Starting setNotifyValue(true)');
debugPrint('Web: Calling setNotifyValue(true) without awaiting');
_appDebugLogService?.info(
'Starting setNotifyValue(true)',
tag: 'BLE Connect',
);
_appDebugLogService?.info(
'Web: Calling setNotifyValue(true) without awaiting',
tag: 'BLE Connect',
);
unawaited(() async {
try {
await _txCharacteristic!.setNotifyValue(true);
} catch (error) {
debugPrint('[BLE Connect] notify failure (web, ignored): $error');
debugPrint('Web setNotifyValue error (ignoring): $error');
_appDebugLogService?.warn(
'notify failure (web, ignored): $error',
tag: 'BLE Connect',
);
_appDebugLogService?.warn(
'Web setNotifyValue error (ignoring): $error',
tag: 'BLE Connect',
);
}
}());
debugPrint('setNotifyValue(true) configuration completed');
_appDebugLogService?.info(
'setNotifyValue(true) configuration completed',
tag: 'BLE Connect',
);
} else {
bool notifySet = false;
for (int attempt = 0; attempt < 3 && !notifySet; attempt++) {
@ -903,8 +959,11 @@ class MeshCoreConnector extends ChangeNotifier {
await _txCharacteristic!.setNotifyValue(true);
notifySet = true;
} catch (e) {
debugPrint('[BLE Connect] notify failure: $e');
debugPrint('setNotifyValue attempt ${attempt + 1}/3 failed: $e');
_appDebugLogService?.warn('notify failure: $e', tag: 'BLE Connect');
_appDebugLogService?.warn(
'setNotifyValue attempt ${attempt + 1}/3 failed: $e',
tag: 'BLE Connect',
);
if (attempt == 2) rethrow;
}
}
@ -925,7 +984,19 @@ class MeshCoreConnector extends ChangeNotifier {
_activeTransport == MeshCoreTransportType.bluetooth) {
// Chrome's Web Bluetooth stack commonly delays incoming notifications
// until the non-blocking notify setup settles. Avoid stacking extra
// startup writes while that is happening.
// startup writes while that is happening. Defer the clock sync until
// the connection has had time to settle.
unawaited(
Future<void>(() async {
await Future<void>.delayed(const Duration(seconds: 5));
if (!isConnected ||
!PlatformInfo.isWeb ||
_activeTransport != MeshCoreTransportType.bluetooth) {
return;
}
await syncTime();
}),
);
} else {
final gotSelfInfo = await _waitForSelfInfo(
timeout: const Duration(seconds: 3),
@ -943,7 +1014,7 @@ class MeshCoreConnector extends ChangeNotifier {
unawaited(getChannels());
}
} catch (e) {
debugPrint("Connection error: $e");
_appDebugLogService?.error('Connection error: $e', tag: 'BLE Connect');
await disconnect(manual: false);
rethrow;
}
@ -986,7 +1057,7 @@ class MeshCoreConnector extends ChangeNotifier {
_usbFrameSubscription = _usbSerialService.frameStream.listen(
_handleFrame,
onError: (error, stackTrace) {
debugPrint('USB transport error: $error');
_appDebugLogService?.error('USB transport error: $error', tag: 'USB');
unawaited(disconnect(manual: false));
},
onDone: () {
@ -1013,7 +1084,7 @@ class MeshCoreConnector extends ChangeNotifier {
await syncTime();
} catch (error) {
debugPrint('USB connection error: $error');
_appDebugLogService?.error('USB connection error: $error', tag: 'USB');
await disconnect(manual: false);
rethrow;
}
@ -1149,7 +1220,7 @@ class MeshCoreConnector extends ChangeNotifier {
// Skip queued BLE operations so disconnect doesn't get stuck behind them.
await _device?.disconnect(queue: false);
} catch (e) {
debugPrint("Disconnect error: $e");
_appDebugLogService?.warn('Disconnect error: $e', tag: 'BLE Connect');
}
_device = null;

View file

@ -37,6 +37,11 @@ class UsbSerialFrameDecoder {
final List<int> _rxBuffer = <int>[];
int _startIndex = 0;
void reset() {
_rxBuffer.clear();
_startIndex = 0;
}
List<UsbSerialDecodedPacket> ingest(Uint8List bytes) {
if (bytes.isEmpty) {
return const <UsbSerialDecodedPacket>[];

View file

@ -5,6 +5,7 @@ import 'package:flserial/flserial_exception.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'app_debug_log_service.dart';
import '../utils/platform_info.dart';
import '../utils/usb_port_labels.dart';
import 'usb_serial_frame_codec.dart';
@ -29,6 +30,7 @@ class UsbSerialService {
String? _connectedPortKey;
String? _connectedPortLabel;
FlSerial? _serial;
AppDebugLogService? _debugLogService;
UsbSerialStatus get status => _status;
String? get activePortKey => _connectedPortKey;
@ -66,6 +68,10 @@ class UsbSerialService {
return Future.value(FlSerial.listPorts());
}
void setDebugLogService(AppDebugLogService? service) {
_debugLogService = service;
}
Future<void> connect({
required String portName,
int baudRate = 115200,
@ -80,6 +86,7 @@ class UsbSerialService {
_status = UsbSerialStatus.connecting;
final normalizedPortName = normalizeUsbPortName(portName);
_frameDecoder.reset();
if (_useAndroidUsbHost) {
try {
@ -87,8 +94,9 @@ class UsbSerialService {
'portName': normalizedPortName,
'baudRate': baudRate,
});
debugPrint(
_debugLogService?.info(
'USB serial opened port=$normalizedPortName on Android via USB host bridge',
tag: 'USB Serial',
);
} on PlatformException catch (error) {
_status = UsbSerialStatus.disconnected;
@ -111,8 +119,9 @@ class UsbSerialService {
serial.setFlowControlNone();
serial.setRTS(false);
serial.setDTR(true);
debugPrint(
_debugLogService?.info(
'USB serial opened port=$normalizedPortName cts=${serial.getCTS()} dsr=${serial.getDSR()} dtr=true rts=false',
tag: 'USB Serial',
);
} on FlSerialException catch (error) {
_serial?.free();
@ -174,6 +183,7 @@ class UsbSerialService {
_status = UsbSerialStatus.disconnecting;
_connectedPortKey = null;
_connectedPortLabel = null;
_frameDecoder.reset();
await _androidDataSubscription?.cancel();
_androidDataSubscription = null;
await _dataSubscription?.cancel();
@ -255,8 +265,9 @@ class UsbSerialService {
void _ingestRawBytes(Uint8List bytes) {
for (final packet in _frameDecoder.ingest(bytes)) {
if (!packet.isRxFrame) {
debugPrint(
_debugLogService?.info(
'USB ignored packet start=0x${packet.frameStart.toRadixString(16).padLeft(2, '0')} len=${packet.payload.length}',
tag: 'USB Serial',
);
continue;
}
@ -287,10 +298,13 @@ class UsbSerialService {
void _logFrameSummary(String prefix, Uint8List bytes) {
if (bytes.isEmpty) {
debugPrint('$prefix len=0');
_debugLogService?.info('$prefix len=0', tag: 'USB Serial');
return;
}
debugPrint('$prefix code=${bytes[0]} len=${bytes.length}');
_debugLogService?.info(
'$prefix code=${bytes[0]} len=${bytes.length}',
tag: 'USB Serial',
);
}
}

View file

@ -5,6 +5,7 @@ import 'dart:js_interop_unsafe';
import 'package:flutter/foundation.dart';
import 'package:web/web.dart' as web;
import 'app_debug_log_service.dart';
import '../utils/usb_port_labels.dart';
import 'usb_serial_frame_codec.dart';
@ -27,6 +28,7 @@ class UsbSerialService {
String? _connectedPortName;
String? _connectedPortKey;
String _requestPortLabel = 'Choose USB Device';
AppDebugLogService? _debugLogService;
UsbSerialStatus get status => _status;
String? get activePortKey => _connectedPortKey;
@ -69,6 +71,7 @@ class UsbSerialService {
}
_status = UsbSerialStatus.connecting;
_frameDecoder.reset();
try {
final requestedPortName = normalizeUsbPortName(portName);
@ -88,7 +91,10 @@ class UsbSerialService {
_status = UsbSerialStatus.connected;
unawaited(_pumpReads());
debugPrint('USB serial opened port=$_connectedPortName via Web Serial');
_debugLogService?.info(
'USB serial opened port=$_connectedPortName via Web Serial',
tag: 'USB Serial',
);
} catch (error) {
await _cleanupFailedConnect();
_status = UsbSerialStatus.disconnected;
@ -126,6 +132,7 @@ class UsbSerialService {
_port = null;
_connectedPortName = null;
_connectedPortKey = null;
_frameDecoder.reset();
if (reader != null) {
try {
@ -169,6 +176,10 @@ class UsbSerialService {
_requestPortLabel = trimmed;
}
void setDebugLogService(AppDebugLogService? service) {
_debugLogService = service;
}
void dispose() {
unawaited(disconnect().whenComplete(_closeFrameController));
}
@ -407,8 +418,9 @@ class UsbSerialService {
void _ingestRawBytes(Uint8List bytes) {
for (final packet in _frameDecoder.ingest(bytes)) {
if (!packet.isRxFrame) {
debugPrint(
_debugLogService?.info(
'USB ignored packet start=0x${packet.frameStart.toRadixString(16).padLeft(2, '0')} len=${packet.payload.length}',
tag: 'USB Serial',
);
continue;
}
@ -439,10 +451,13 @@ class UsbSerialService {
void _logFrameSummary(String prefix, Uint8List bytes) {
if (bytes.isEmpty) {
debugPrint('$prefix len=0');
_debugLogService?.info('$prefix len=0', tag: 'USB Serial');
return;
}
debugPrint('$prefix code=${bytes[0]} len=${bytes.length}');
_debugLogService?.info(
'$prefix code=${bytes[0]} len=${bytes.length}',
tag: 'USB Serial',
);
}
}

View file

@ -120,4 +120,22 @@ void main() {
expect(packets.single.payload, orderedEquals(<int>[0x44]));
},
);
test('UsbSerialFrameDecoder reset clears buffered partial data', () {
final decoder = UsbSerialFrameDecoder();
expect(
decoder.ingest(Uint8List.fromList(<int>[usbSerialRxFrameStart, 0x02])),
isEmpty,
);
decoder.reset();
final packets = decoder.ingest(
Uint8List.fromList(<int>[usbSerialRxFrameStart, 0x01, 0x00, 0x55]),
);
expect(packets, hasLength(1));
expect(packets.single.payload, orderedEquals(<int>[0x55]));
});
}