parent
22a53439b1
commit
c23a1da430
@ -0,0 +1,70 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
const int usbSerialTxFrameStart = 0x3c;
|
||||||
|
const int usbSerialRxFrameStart = 0x3e;
|
||||||
|
const int usbSerialHeaderLength = 3;
|
||||||
|
|
||||||
|
Uint8List wrapUsbSerialTxFrame(Uint8List payload) {
|
||||||
|
final packet = Uint8List(usbSerialHeaderLength + payload.length);
|
||||||
|
packet[0] = usbSerialTxFrameStart;
|
||||||
|
packet[1] = payload.length & 0xff;
|
||||||
|
packet[2] = (payload.length >> 8) & 0xff;
|
||||||
|
packet.setRange(usbSerialHeaderLength, packet.length, payload);
|
||||||
|
return packet;
|
||||||
|
}
|
||||||
|
|
||||||
|
class UsbSerialDecodedPacket {
|
||||||
|
const UsbSerialDecodedPacket({
|
||||||
|
required this.frameStart,
|
||||||
|
required this.payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int frameStart;
|
||||||
|
final Uint8List payload;
|
||||||
|
|
||||||
|
bool get isRxFrame => frameStart == usbSerialRxFrameStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
class UsbSerialFrameDecoder {
|
||||||
|
final List<int> _rxBuffer = <int>[];
|
||||||
|
|
||||||
|
List<UsbSerialDecodedPacket> ingest(Uint8List bytes) {
|
||||||
|
if (bytes.isEmpty) {
|
||||||
|
return const <UsbSerialDecodedPacket>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
_rxBuffer.addAll(bytes);
|
||||||
|
final packets = <UsbSerialDecodedPacket>[];
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (_rxBuffer.isEmpty) {
|
||||||
|
return packets;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_rxBuffer.first != usbSerialRxFrameStart &&
|
||||||
|
_rxBuffer.first != usbSerialTxFrameStart) {
|
||||||
|
_rxBuffer.removeAt(0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_rxBuffer.length < usbSerialHeaderLength) {
|
||||||
|
return packets;
|
||||||
|
}
|
||||||
|
|
||||||
|
final payloadLength = _rxBuffer[1] | (_rxBuffer[2] << 8);
|
||||||
|
final packetLength = usbSerialHeaderLength + payloadLength;
|
||||||
|
if (_rxBuffer.length < packetLength) {
|
||||||
|
return packets;
|
||||||
|
}
|
||||||
|
|
||||||
|
final frameStart = _rxBuffer.first;
|
||||||
|
final payload = Uint8List.fromList(
|
||||||
|
_rxBuffer.sublist(usbSerialHeaderLength, packetLength),
|
||||||
|
);
|
||||||
|
_rxBuffer.removeRange(0, packetLength);
|
||||||
|
packets.add(
|
||||||
|
UsbSerialDecodedPacket(frameStart: frameStart, payload: payload),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,284 +1,2 @@
|
|||||||
import 'dart:async';
|
export 'usb_serial_service_native.dart'
|
||||||
|
if (dart.library.js_interop) 'usb_serial_service_web.dart';
|
||||||
import 'package:flserial/flserial.dart';
|
|
||||||
import 'package:flserial/flserial_exception.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
|
|
||||||
/// Wraps the native flserial plugin to expose a stream of raw bytes for the
|
|
||||||
/// MeshCore connector to consume.
|
|
||||||
class UsbSerialService {
|
|
||||||
UsbSerialService();
|
|
||||||
|
|
||||||
static const MethodChannel _androidMethodChannel = MethodChannel(
|
|
||||||
'meshcore_open/android_usb_serial',
|
|
||||||
);
|
|
||||||
static const EventChannel _androidEventChannel = EventChannel(
|
|
||||||
'meshcore_open/android_usb_serial_events',
|
|
||||||
);
|
|
||||||
static const int _serialTxFrameStart = 0x3c;
|
|
||||||
static const int _serialRxFrameStart = 0x3e;
|
|
||||||
static const int _serialHeaderLength = 3;
|
|
||||||
|
|
||||||
final StreamController<Uint8List> _frameController =
|
|
||||||
StreamController<Uint8List>.broadcast();
|
|
||||||
final FlSerial _serial = FlSerial();
|
|
||||||
final List<int> _rxBuffer = <int>[];
|
|
||||||
StreamSubscription<dynamic>? _androidDataSubscription;
|
|
||||||
StreamSubscription<FlSerialEventArgs>? _dataSubscription;
|
|
||||||
UsbSerialStatus _status = UsbSerialStatus.disconnected;
|
|
||||||
String? _connectedPortName;
|
|
||||||
|
|
||||||
UsbSerialStatus get status => _status;
|
|
||||||
String? get activePortName => _connectedPortName;
|
|
||||||
Stream<Uint8List> get frameStream => _frameController.stream;
|
|
||||||
bool get _useAndroidUsbHost =>
|
|
||||||
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
|
|
||||||
|
|
||||||
bool get isConnected {
|
|
||||||
if (_useAndroidUsbHost) {
|
|
||||||
return _status == UsbSerialStatus.connected;
|
|
||||||
}
|
|
||||||
return _status == UsbSerialStatus.connected &&
|
|
||||||
_serial.isOpen() == FlOpenStatus.open;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<String>> listPorts() async {
|
|
||||||
if (_useAndroidUsbHost) {
|
|
||||||
final ports = await _androidMethodChannel.invokeListMethod<String>(
|
|
||||||
'listPorts',
|
|
||||||
);
|
|
||||||
return ports ?? <String>[];
|
|
||||||
}
|
|
||||||
return Future.value(FlSerial.listPorts());
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> connect({
|
|
||||||
required String portName,
|
|
||||||
int baudRate = 115200,
|
|
||||||
}) async {
|
|
||||||
if (_status == UsbSerialStatus.connected ||
|
|
||||||
_status == UsbSerialStatus.connecting) {
|
|
||||||
throw StateError('USB serial transport is already active');
|
|
||||||
}
|
|
||||||
|
|
||||||
_status = UsbSerialStatus.connecting;
|
|
||||||
final normalizedPortName = _normalizePortName(portName);
|
|
||||||
|
|
||||||
if (_useAndroidUsbHost) {
|
|
||||||
try {
|
|
||||||
await _androidMethodChannel.invokeMethod<void>('connect', {
|
|
||||||
'portName': normalizedPortName,
|
|
||||||
'baudRate': baudRate,
|
|
||||||
});
|
|
||||||
debugPrint(
|
|
||||||
'USB serial opened port=$normalizedPortName on Android via USB host bridge',
|
|
||||||
);
|
|
||||||
} on PlatformException catch (error) {
|
|
||||||
_status = UsbSerialStatus.disconnected;
|
|
||||||
throw StateError(error.message ?? error.code);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_serial.init();
|
|
||||||
|
|
||||||
try {
|
|
||||||
final status = _serial.openPort(normalizedPortName, baudRate);
|
|
||||||
if (status != FlOpenStatus.open) {
|
|
||||||
throw StateError(
|
|
||||||
'Failed to open USB port $normalizedPortName ($status)',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
_serial.setByteSize8();
|
|
||||||
_serial.setBitParityNone();
|
|
||||||
_serial.setStopBits1();
|
|
||||||
_serial.setFlowControlNone();
|
|
||||||
_serial.setRTS(false);
|
|
||||||
_serial.setDTR(true);
|
|
||||||
debugPrint(
|
|
||||||
'USB serial opened port=$normalizedPortName cts=${_serial.getCTS()} dsr=${_serial.getDSR()} dtr=true rts=false',
|
|
||||||
);
|
|
||||||
} on FlSerialException catch (error) {
|
|
||||||
_serial.free();
|
|
||||||
_status = UsbSerialStatus.disconnected;
|
|
||||||
throw StateError(
|
|
||||||
'Failed to open USB port $normalizedPortName: ${error.msg} (${error.error})',
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
_serial.free();
|
|
||||||
_status = UsbSerialStatus.disconnected;
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_connectedPortName = normalizedPortName;
|
|
||||||
if (_useAndroidUsbHost) {
|
|
||||||
_androidDataSubscription = _androidEventChannel
|
|
||||||
.receiveBroadcastStream()
|
|
||||||
.listen(
|
|
||||||
_handleAndroidData,
|
|
||||||
onError: _handleSerialError,
|
|
||||||
onDone: _handleSerialDone,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
_dataSubscription = _serial.onSerialData.stream.listen(
|
|
||||||
_handleSerialData,
|
|
||||||
onError: _handleSerialError,
|
|
||||||
onDone: _handleSerialDone,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
_status = UsbSerialStatus.connected;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> write(Uint8List data) async {
|
|
||||||
if (!isConnected) {
|
|
||||||
throw StateError('USB serial port is not open');
|
|
||||||
}
|
|
||||||
final packet = Uint8List(_serialHeaderLength + data.length);
|
|
||||||
packet[0] = _serialTxFrameStart;
|
|
||||||
packet[1] = data.length & 0xff;
|
|
||||||
packet[2] = (data.length >> 8) & 0xff;
|
|
||||||
packet.setRange(_serialHeaderLength, packet.length, data);
|
|
||||||
_logFrameSummary('USB TX frame', data);
|
|
||||||
if (_useAndroidUsbHost) {
|
|
||||||
try {
|
|
||||||
await _androidMethodChannel.invokeMethod<void>('write', {
|
|
||||||
'data': packet,
|
|
||||||
});
|
|
||||||
} on PlatformException catch (error) {
|
|
||||||
throw StateError(error.message ?? error.code);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_serial.write(packet);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> disconnect() async {
|
|
||||||
if (_status == UsbSerialStatus.disconnected) return;
|
|
||||||
|
|
||||||
_status = UsbSerialStatus.disconnecting;
|
|
||||||
_connectedPortName = null;
|
|
||||||
await _androidDataSubscription?.cancel();
|
|
||||||
_androidDataSubscription = null;
|
|
||||||
await _dataSubscription?.cancel();
|
|
||||||
_dataSubscription = null;
|
|
||||||
|
|
||||||
if (_useAndroidUsbHost) {
|
|
||||||
try {
|
|
||||||
await _androidMethodChannel.invokeMethod<void>('disconnect');
|
|
||||||
} catch (_) {
|
|
||||||
// Ignore errors while closing.
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
if (_serial.isOpen() == FlOpenStatus.open) {
|
|
||||||
_serial.closePort();
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// Ignore errors while closing.
|
|
||||||
}
|
|
||||||
|
|
||||||
_serial.free();
|
|
||||||
}
|
|
||||||
_status = UsbSerialStatus.disconnected;
|
|
||||||
}
|
|
||||||
|
|
||||||
void dispose() {
|
|
||||||
unawaited(disconnect());
|
|
||||||
unawaited(_frameController.close());
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleSerialData(FlSerialEventArgs event) {
|
|
||||||
try {
|
|
||||||
final bytes = event.serial.readList();
|
|
||||||
if (bytes.isNotEmpty) {
|
|
||||||
_ingestRawBytes(Uint8List.fromList(bytes));
|
|
||||||
}
|
|
||||||
} catch (error, stack) {
|
|
||||||
_frameController.addError(error, stack);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleAndroidData(dynamic data) {
|
|
||||||
if (data is Uint8List) {
|
|
||||||
_ingestRawBytes(data);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (data is ByteData) {
|
|
||||||
_ingestRawBytes(data.buffer.asUint8List());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_frameController.addError(
|
|
||||||
StateError('Unexpected Android USB event payload: ${data.runtimeType}'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleSerialError(Object error, [StackTrace? stackTrace]) {
|
|
||||||
_frameController.addError(error, stackTrace);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleSerialDone() {
|
|
||||||
unawaited(disconnect());
|
|
||||||
}
|
|
||||||
|
|
||||||
String _normalizePortName(String portName) {
|
|
||||||
final separatorIndex = portName.indexOf(' - ');
|
|
||||||
final normalized = separatorIndex >= 0
|
|
||||||
? portName.substring(0, separatorIndex)
|
|
||||||
: portName;
|
|
||||||
return normalized.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _ingestRawBytes(Uint8List bytes) {
|
|
||||||
if (bytes.isEmpty) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_rxBuffer.addAll(bytes);
|
|
||||||
_drainRxBuffer();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _drainRxBuffer() {
|
|
||||||
while (true) {
|
|
||||||
if (_rxBuffer.isEmpty) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_rxBuffer.first != _serialRxFrameStart &&
|
|
||||||
_rxBuffer.first != _serialTxFrameStart) {
|
|
||||||
_rxBuffer.removeAt(0);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_rxBuffer.length < _serialHeaderLength) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final payloadLength = _rxBuffer[1] | (_rxBuffer[2] << 8);
|
|
||||||
final packetLength = _serialHeaderLength + payloadLength;
|
|
||||||
if (_rxBuffer.length < packetLength) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final frameStart = _rxBuffer.first;
|
|
||||||
final payload = Uint8List.fromList(
|
|
||||||
_rxBuffer.sublist(_serialHeaderLength, packetLength),
|
|
||||||
);
|
|
||||||
_rxBuffer.removeRange(0, packetLength);
|
|
||||||
if (frameStart != _serialRxFrameStart) {
|
|
||||||
debugPrint(
|
|
||||||
'USB ignored packet start=0x${frameStart.toRadixString(16).padLeft(2, '0')} len=${payload.length}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
_frameController.add(payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _logFrameSummary(String prefix, Uint8List bytes) {
|
|
||||||
if (bytes.isEmpty) {
|
|
||||||
debugPrint('$prefix len=0');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
debugPrint('$prefix code=${bytes[0]} len=${bytes.length}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum UsbSerialStatus { disconnected, connecting, connected, disconnecting }
|
|
||||||
|
|||||||
@ -0,0 +1,247 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flserial/flserial.dart';
|
||||||
|
import 'package:flserial/flserial_exception.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import '../utils/usb_port_labels.dart';
|
||||||
|
import 'usb_serial_frame_codec.dart';
|
||||||
|
|
||||||
|
/// Wraps the native flserial plugin to expose a stream of raw bytes for the
|
||||||
|
/// MeshCore connector to consume.
|
||||||
|
class UsbSerialService {
|
||||||
|
UsbSerialService();
|
||||||
|
|
||||||
|
static const MethodChannel _androidMethodChannel = MethodChannel(
|
||||||
|
'meshcore_open/android_usb_serial',
|
||||||
|
);
|
||||||
|
static const EventChannel _androidEventChannel = EventChannel(
|
||||||
|
'meshcore_open/android_usb_serial_events',
|
||||||
|
);
|
||||||
|
final StreamController<Uint8List> _frameController =
|
||||||
|
StreamController<Uint8List>.broadcast();
|
||||||
|
final FlSerial _serial = FlSerial();
|
||||||
|
final UsbSerialFrameDecoder _frameDecoder = UsbSerialFrameDecoder();
|
||||||
|
StreamSubscription<dynamic>? _androidDataSubscription;
|
||||||
|
StreamSubscription<FlSerialEventArgs>? _dataSubscription;
|
||||||
|
UsbSerialStatus _status = UsbSerialStatus.disconnected;
|
||||||
|
String? _connectedPortName;
|
||||||
|
|
||||||
|
UsbSerialStatus get status => _status;
|
||||||
|
String? get activePortName => _connectedPortName;
|
||||||
|
Stream<Uint8List> get frameStream => _frameController.stream;
|
||||||
|
bool get _useAndroidUsbHost =>
|
||||||
|
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
|
||||||
|
|
||||||
|
bool get isConnected {
|
||||||
|
if (_useAndroidUsbHost) {
|
||||||
|
return _status == UsbSerialStatus.connected;
|
||||||
|
}
|
||||||
|
return _status == UsbSerialStatus.connected &&
|
||||||
|
_serial.isOpen() == FlOpenStatus.open;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<String>> listPorts() async {
|
||||||
|
if (_useAndroidUsbHost) {
|
||||||
|
final ports = await _androidMethodChannel.invokeListMethod<String>(
|
||||||
|
'listPorts',
|
||||||
|
);
|
||||||
|
return ports ?? <String>[];
|
||||||
|
}
|
||||||
|
return Future.value(FlSerial.listPorts());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> connect({
|
||||||
|
required String portName,
|
||||||
|
int baudRate = 115200,
|
||||||
|
}) async {
|
||||||
|
if (_status == UsbSerialStatus.connected ||
|
||||||
|
_status == UsbSerialStatus.connecting) {
|
||||||
|
throw StateError('USB serial transport is already active');
|
||||||
|
}
|
||||||
|
|
||||||
|
_status = UsbSerialStatus.connecting;
|
||||||
|
final normalizedPortName = normalizeUsbPortName(portName);
|
||||||
|
|
||||||
|
if (_useAndroidUsbHost) {
|
||||||
|
try {
|
||||||
|
await _androidMethodChannel.invokeMethod<void>('connect', {
|
||||||
|
'portName': normalizedPortName,
|
||||||
|
'baudRate': baudRate,
|
||||||
|
});
|
||||||
|
debugPrint(
|
||||||
|
'USB serial opened port=$normalizedPortName on Android via USB host bridge',
|
||||||
|
);
|
||||||
|
} on PlatformException catch (error) {
|
||||||
|
_status = UsbSerialStatus.disconnected;
|
||||||
|
throw StateError(error.message ?? error.code);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_serial.init();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final status = _serial.openPort(normalizedPortName, baudRate);
|
||||||
|
if (status != FlOpenStatus.open) {
|
||||||
|
throw StateError(
|
||||||
|
'Failed to open USB port $normalizedPortName ($status)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_serial.setByteSize8();
|
||||||
|
_serial.setBitParityNone();
|
||||||
|
_serial.setStopBits1();
|
||||||
|
_serial.setFlowControlNone();
|
||||||
|
_serial.setRTS(false);
|
||||||
|
_serial.setDTR(true);
|
||||||
|
debugPrint(
|
||||||
|
'USB serial opened port=$normalizedPortName cts=${_serial.getCTS()} dsr=${_serial.getDSR()} dtr=true rts=false',
|
||||||
|
);
|
||||||
|
} on FlSerialException catch (error) {
|
||||||
|
_serial.free();
|
||||||
|
_status = UsbSerialStatus.disconnected;
|
||||||
|
throw StateError(
|
||||||
|
'Failed to open USB port $normalizedPortName: ${error.msg} (${error.error})',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
_serial.free();
|
||||||
|
_status = UsbSerialStatus.disconnected;
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_connectedPortName = normalizedPortName;
|
||||||
|
if (_useAndroidUsbHost) {
|
||||||
|
_androidDataSubscription = _androidEventChannel
|
||||||
|
.receiveBroadcastStream()
|
||||||
|
.listen(
|
||||||
|
_handleAndroidData,
|
||||||
|
onError: _handleSerialError,
|
||||||
|
onDone: _handleSerialDone,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_dataSubscription = _serial.onSerialData.stream.listen(
|
||||||
|
_handleSerialData,
|
||||||
|
onError: _handleSerialError,
|
||||||
|
onDone: _handleSerialDone,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_status = UsbSerialStatus.connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> write(Uint8List data) async {
|
||||||
|
if (!isConnected) {
|
||||||
|
throw StateError('USB serial port is not open');
|
||||||
|
}
|
||||||
|
final packet = wrapUsbSerialTxFrame(data);
|
||||||
|
_logFrameSummary('USB TX frame', data);
|
||||||
|
if (_useAndroidUsbHost) {
|
||||||
|
try {
|
||||||
|
await _androidMethodChannel.invokeMethod<void>('write', {
|
||||||
|
'data': packet,
|
||||||
|
});
|
||||||
|
} on PlatformException catch (error) {
|
||||||
|
throw StateError(error.message ?? error.code);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_serial.write(packet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> disconnect() async {
|
||||||
|
if (_status == UsbSerialStatus.disconnected) return;
|
||||||
|
|
||||||
|
_status = UsbSerialStatus.disconnecting;
|
||||||
|
_connectedPortName = null;
|
||||||
|
await _androidDataSubscription?.cancel();
|
||||||
|
_androidDataSubscription = null;
|
||||||
|
await _dataSubscription?.cancel();
|
||||||
|
_dataSubscription = null;
|
||||||
|
|
||||||
|
if (_useAndroidUsbHost) {
|
||||||
|
try {
|
||||||
|
await _androidMethodChannel.invokeMethod<void>('disconnect');
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore errors while closing.
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
if (_serial.isOpen() == FlOpenStatus.open) {
|
||||||
|
_serial.closePort();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore errors while closing.
|
||||||
|
}
|
||||||
|
|
||||||
|
_serial.free();
|
||||||
|
}
|
||||||
|
_status = UsbSerialStatus.disconnected;
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateConnectedLabel(String label) {
|
||||||
|
final trimmed = label.trim();
|
||||||
|
if (trimmed.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_connectedPortName = trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
unawaited(disconnect());
|
||||||
|
unawaited(_frameController.close());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSerialData(FlSerialEventArgs event) {
|
||||||
|
try {
|
||||||
|
final bytes = event.serial.readList();
|
||||||
|
if (bytes.isNotEmpty) {
|
||||||
|
_ingestRawBytes(Uint8List.fromList(bytes));
|
||||||
|
}
|
||||||
|
} catch (error, stack) {
|
||||||
|
_frameController.addError(error, stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleAndroidData(dynamic data) {
|
||||||
|
if (data is Uint8List) {
|
||||||
|
_ingestRawBytes(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data is ByteData) {
|
||||||
|
_ingestRawBytes(data.buffer.asUint8List());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_frameController.addError(
|
||||||
|
StateError('Unexpected Android USB event payload: ${data.runtimeType}'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSerialError(Object error, [StackTrace? stackTrace]) {
|
||||||
|
_frameController.addError(error, stackTrace);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSerialDone() {
|
||||||
|
unawaited(disconnect());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _ingestRawBytes(Uint8List bytes) {
|
||||||
|
for (final packet in _frameDecoder.ingest(bytes)) {
|
||||||
|
if (!packet.isRxFrame) {
|
||||||
|
debugPrint(
|
||||||
|
'USB ignored packet start=0x${packet.frameStart.toRadixString(16).padLeft(2, '0')} len=${packet.payload.length}',
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_frameController.add(packet.payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _logFrameSummary(String prefix, Uint8List bytes) {
|
||||||
|
if (bytes.isEmpty) {
|
||||||
|
debugPrint('$prefix len=0');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debugPrint('$prefix code=${bytes[0]} len=${bytes.length}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UsbSerialStatus { disconnected, connecting, connected, disconnecting }
|
||||||
@ -0,0 +1,385 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:js_interop';
|
||||||
|
import 'dart:js_interop_unsafe';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:web/web.dart' as web;
|
||||||
|
|
||||||
|
import '../utils/usb_port_labels.dart';
|
||||||
|
import 'usb_serial_frame_codec.dart';
|
||||||
|
|
||||||
|
class UsbSerialService {
|
||||||
|
UsbSerialService();
|
||||||
|
|
||||||
|
static const Map<String, String> _knownUsbNames = <String, String>{
|
||||||
|
'2886:1667': 'Seeed Wio Tracker L1',
|
||||||
|
};
|
||||||
|
static final Map<String, String> _deviceNamesByPortKey = <String, String>{};
|
||||||
|
|
||||||
|
final StreamController<Uint8List> _frameController =
|
||||||
|
StreamController<Uint8List>.broadcast();
|
||||||
|
final UsbSerialFrameDecoder _frameDecoder = UsbSerialFrameDecoder();
|
||||||
|
|
||||||
|
UsbSerialStatus _status = UsbSerialStatus.disconnected;
|
||||||
|
JSObject? _port;
|
||||||
|
JSObject? _reader;
|
||||||
|
JSObject? _writer;
|
||||||
|
String? _connectedPortName;
|
||||||
|
String? _connectedPortKey;
|
||||||
|
|
||||||
|
UsbSerialStatus get status => _status;
|
||||||
|
String? get activePortName => _connectedPortName;
|
||||||
|
Stream<Uint8List> get frameStream => _frameController.stream;
|
||||||
|
bool get isConnected => _status == UsbSerialStatus.connected;
|
||||||
|
|
||||||
|
JSObject get _navigator => JSObject.fromInteropObject(web.window.navigator);
|
||||||
|
bool get _isSupported => _navigator.has('serial');
|
||||||
|
JSObject? get _serial {
|
||||||
|
if (!_isSupported) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final serial = _navigator['serial'];
|
||||||
|
return serial == null ? null : serial as JSObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<String>> listPorts() async {
|
||||||
|
if (!_isSupported) {
|
||||||
|
return const <String>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
final ports = await _getAuthorizedPorts();
|
||||||
|
if (ports.isEmpty) {
|
||||||
|
return const <String>[usbRequestPortLabel];
|
||||||
|
}
|
||||||
|
return ports.map(_displayLabelForPort).toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> connect({
|
||||||
|
required String portName,
|
||||||
|
int baudRate = 115200,
|
||||||
|
}) async {
|
||||||
|
if (_status == UsbSerialStatus.connected ||
|
||||||
|
_status == UsbSerialStatus.connecting) {
|
||||||
|
throw StateError('USB serial transport is already active');
|
||||||
|
}
|
||||||
|
if (!_isSupported) {
|
||||||
|
throw UnsupportedError('Web Serial is not supported by this browser.');
|
||||||
|
}
|
||||||
|
|
||||||
|
_status = UsbSerialStatus.connecting;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final requestedPortName = normalizeUsbPortName(portName);
|
||||||
|
final authorizedPorts = await _getAuthorizedPorts();
|
||||||
|
_port = _selectPort(authorizedPorts, requestedPortName);
|
||||||
|
|
||||||
|
_port ??= await _requestPort();
|
||||||
|
if (_port == null) {
|
||||||
|
throw StateError('No USB serial device selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
await _openPort(_port!, baudRate);
|
||||||
|
_connectedPortKey = _portKeyFor(_port!);
|
||||||
|
_connectedPortName = _buildDisplayLabel(_connectedPortKey!);
|
||||||
|
_writer = _getWriter(_port!);
|
||||||
|
_reader = _getReader(_port!);
|
||||||
|
_status = UsbSerialStatus.connected;
|
||||||
|
unawaited(_pumpReads());
|
||||||
|
|
||||||
|
debugPrint('USB serial opened port=$_connectedPortName via Web Serial');
|
||||||
|
} catch (error) {
|
||||||
|
_status = UsbSerialStatus.disconnected;
|
||||||
|
_connectedPortName = null;
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> write(Uint8List data) async {
|
||||||
|
if (!isConnected || _writer == null) {
|
||||||
|
throw StateError('USB serial port is not open');
|
||||||
|
}
|
||||||
|
|
||||||
|
final packet = wrapUsbSerialTxFrame(data);
|
||||||
|
_logFrameSummary('USB TX frame', data);
|
||||||
|
|
||||||
|
final promise = _writer!.callMethod<JSPromise<JSAny?>>(
|
||||||
|
'write'.toJS,
|
||||||
|
packet.toJS,
|
||||||
|
);
|
||||||
|
await promise.toDart;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> disconnect() async {
|
||||||
|
if (_status == UsbSerialStatus.disconnected) return;
|
||||||
|
|
||||||
|
_status = UsbSerialStatus.disconnecting;
|
||||||
|
final reader = _reader;
|
||||||
|
final writer = _writer;
|
||||||
|
final port = _port;
|
||||||
|
|
||||||
|
_reader = null;
|
||||||
|
_writer = null;
|
||||||
|
_port = null;
|
||||||
|
_connectedPortName = null;
|
||||||
|
_connectedPortKey = null;
|
||||||
|
|
||||||
|
if (reader != null) {
|
||||||
|
try {
|
||||||
|
await reader.callMethod<JSPromise<JSAny?>>('cancel'.toJS).toDart;
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore errors while closing.
|
||||||
|
}
|
||||||
|
_releaseLock(reader);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (writer != null) {
|
||||||
|
_releaseLock(writer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (port != null) {
|
||||||
|
try {
|
||||||
|
await port.callMethod<JSPromise<JSAny?>>('close'.toJS).toDart;
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore errors while closing.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_status = UsbSerialStatus.disconnected;
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateConnectedLabel(String label) {
|
||||||
|
final trimmed = label.trim();
|
||||||
|
final portKey = _connectedPortKey;
|
||||||
|
if (trimmed.isEmpty || portKey == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_deviceNamesByPortKey[portKey] = trimmed;
|
||||||
|
_connectedPortName = _buildDisplayLabel(portKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
unawaited(disconnect());
|
||||||
|
unawaited(_frameController.close());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<JSObject>> _getAuthorizedPorts() async {
|
||||||
|
final serial = _serial;
|
||||||
|
if (serial == null) {
|
||||||
|
return const <JSObject>[];
|
||||||
|
}
|
||||||
|
final result = await serial
|
||||||
|
.callMethod<JSPromise<JSAny?>>('getPorts'.toJS)
|
||||||
|
.toDart;
|
||||||
|
return _toObjectList(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<JSObject?> _requestPort() async {
|
||||||
|
final serial = _serial;
|
||||||
|
if (serial == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final result = await serial
|
||||||
|
.callMethod<JSPromise<JSAny?>>('requestPort'.toJS)
|
||||||
|
.toDart;
|
||||||
|
return result == null ? null : result as JSObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
JSObject? _selectPort(List<JSObject> ports, String requestedPortName) {
|
||||||
|
if (ports.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (requestedPortName.isEmpty || requestedPortName == usbRequestPortLabel) {
|
||||||
|
return ports.first;
|
||||||
|
}
|
||||||
|
for (final port in ports) {
|
||||||
|
final description = _describePort(port);
|
||||||
|
if (description == requestedPortName) {
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openPort(JSObject port, int baudRate) {
|
||||||
|
final options = JSObject()..['baudRate'] = baudRate.toJS;
|
||||||
|
return port.callMethod<JSPromise<JSAny?>>('open'.toJS, options).toDart;
|
||||||
|
}
|
||||||
|
|
||||||
|
JSObject? _getReader(JSObject port) {
|
||||||
|
final readable = port.getProperty<JSAny?>('readable'.toJS);
|
||||||
|
if (readable == null) {
|
||||||
|
throw StateError('Web Serial port is not readable');
|
||||||
|
}
|
||||||
|
final readableObject = readable as JSObject;
|
||||||
|
return readableObject.callMethod<JSAny?>('getReader'.toJS) as JSObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
JSObject? _getWriter(JSObject port) {
|
||||||
|
final writable = port.getProperty<JSAny?>('writable'.toJS);
|
||||||
|
if (writable == null) {
|
||||||
|
throw StateError('Web Serial port is not writable');
|
||||||
|
}
|
||||||
|
final writableObject = writable as JSObject;
|
||||||
|
return writableObject.callMethod<JSAny?>('getWriter'.toJS) as JSObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pumpReads() async {
|
||||||
|
final reader = _reader;
|
||||||
|
if (reader == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (_status == UsbSerialStatus.connected &&
|
||||||
|
identical(reader, _reader)) {
|
||||||
|
final result = await reader
|
||||||
|
.callMethod<JSPromise<JSAny?>>('read'.toJS)
|
||||||
|
.toDart;
|
||||||
|
if (result == null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
final resultObject = result as JSObject;
|
||||||
|
|
||||||
|
final doneValue = resultObject.getProperty<JSAny?>('done'.toJS);
|
||||||
|
final done = doneValue != null && doneValue.dartify() == true;
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
final value = resultObject.getProperty<JSAny?>('value'.toJS);
|
||||||
|
final bytes = _coerceBytes(value);
|
||||||
|
if (bytes != null && bytes.isNotEmpty) {
|
||||||
|
_ingestRawBytes(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
if (_status == UsbSerialStatus.connected) {
|
||||||
|
_frameController.addError(error, stackTrace);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_releaseLock(reader);
|
||||||
|
if (_status == UsbSerialStatus.connected && identical(reader, _reader)) {
|
||||||
|
_frameController.addError(StateError('USB serial connection closed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List? _coerceBytes(JSAny? value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
try {
|
||||||
|
return (value as JSUint8Array).toDart;
|
||||||
|
} catch (_) {
|
||||||
|
// Fall back to array-like coercion below.
|
||||||
|
}
|
||||||
|
|
||||||
|
final object = value as JSObject;
|
||||||
|
if (object.has('length')) {
|
||||||
|
final lengthValue = object.getProperty<JSAny?>('length'.toJS)?.dartify();
|
||||||
|
if (lengthValue is num) {
|
||||||
|
final length = lengthValue.toInt();
|
||||||
|
final bytes = Uint8List(length);
|
||||||
|
for (var i = 0; i < length; i++) {
|
||||||
|
final item = object.getProperty<JSAny?>(i.toString().toJS)?.dartify();
|
||||||
|
if (item is num) {
|
||||||
|
bytes[i] = item.toInt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<JSObject> _toObjectList(JSAny? value) {
|
||||||
|
if (value == null) {
|
||||||
|
return const <JSObject>[];
|
||||||
|
}
|
||||||
|
final object = value as JSObject;
|
||||||
|
if (!object.has('length')) {
|
||||||
|
return const <JSObject>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
final lengthValue = object.getProperty<JSAny?>('length'.toJS)?.dartify();
|
||||||
|
if (lengthValue is! num) {
|
||||||
|
return const <JSObject>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
final length = lengthValue.toInt();
|
||||||
|
final items = <JSObject>[];
|
||||||
|
for (var i = 0; i < length; i++) {
|
||||||
|
final item = object.getProperty<JSAny?>(i.toString().toJS);
|
||||||
|
if (item != null) {
|
||||||
|
items.add(item as JSObject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _describePort(JSObject port) {
|
||||||
|
try {
|
||||||
|
final info = port.callMethod<JSAny?>('getInfo'.toJS);
|
||||||
|
if (info == null) {
|
||||||
|
return usbRequestPortLabel;
|
||||||
|
}
|
||||||
|
final infoObject = info as JSObject;
|
||||||
|
|
||||||
|
final vendorId = infoObject
|
||||||
|
.getProperty<JSAny?>('usbVendorId'.toJS)
|
||||||
|
?.dartify();
|
||||||
|
final productId = infoObject
|
||||||
|
.getProperty<JSAny?>('usbProductId'.toJS)
|
||||||
|
?.dartify();
|
||||||
|
final hasVendor = vendorId is num;
|
||||||
|
final hasProduct = productId is num;
|
||||||
|
|
||||||
|
return describeWebUsbPort(
|
||||||
|
vendorId: hasVendor ? vendorId.toInt() : null,
|
||||||
|
productId: hasProduct ? productId.toInt() : null,
|
||||||
|
knownUsbNames: _knownUsbNames,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return usbRequestPortLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _portKeyFor(JSObject port) => _describePort(port);
|
||||||
|
|
||||||
|
String _displayLabelForPort(JSObject port) =>
|
||||||
|
_buildDisplayLabel(_portKeyFor(port));
|
||||||
|
|
||||||
|
String _buildDisplayLabel(String portKey) {
|
||||||
|
return buildUsbDisplayLabel(
|
||||||
|
basePortLabel: portKey,
|
||||||
|
deviceName: _deviceNamesByPortKey[portKey],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _releaseLock(JSObject resource) {
|
||||||
|
try {
|
||||||
|
resource.callMethod<JSAny?>('releaseLock'.toJS);
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore lock release failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _ingestRawBytes(Uint8List bytes) {
|
||||||
|
for (final packet in _frameDecoder.ingest(bytes)) {
|
||||||
|
if (!packet.isRxFrame) {
|
||||||
|
debugPrint(
|
||||||
|
'USB ignored packet start=0x${packet.frameStart.toRadixString(16).padLeft(2, '0')} len=${packet.payload.length}',
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_frameController.add(packet.payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _logFrameSummary(String prefix, Uint8List bytes) {
|
||||||
|
if (bytes.isEmpty) {
|
||||||
|
debugPrint('$prefix len=0');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debugPrint('$prefix code=${bytes[0]} len=${bytes.length}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UsbSerialStatus { disconnected, connecting, connected, disconnecting }
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
const String usbRequestPortLabel = 'Choose USB Device';
|
||||||
|
|
||||||
|
String normalizeUsbPortName(String portLabel) {
|
||||||
|
final separatorIndex = portLabel.indexOf(' - ');
|
||||||
|
final normalized = separatorIndex >= 0
|
||||||
|
? portLabel.substring(0, separatorIndex)
|
||||||
|
: portLabel;
|
||||||
|
return normalized.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
String friendlyUsbPortName(String portLabel) {
|
||||||
|
final separatorIndex = portLabel.indexOf(' - ');
|
||||||
|
if (separatorIndex < 0) {
|
||||||
|
return portLabel.trim();
|
||||||
|
}
|
||||||
|
final friendlyName = portLabel.substring(separatorIndex + 3).trim();
|
||||||
|
if (friendlyName.isEmpty) {
|
||||||
|
return normalizeUsbPortName(portLabel);
|
||||||
|
}
|
||||||
|
return friendlyName;
|
||||||
|
}
|
||||||
|
|
||||||
|
String describeWebUsbPort({
|
||||||
|
required int? vendorId,
|
||||||
|
required int? productId,
|
||||||
|
Map<String, String> knownUsbNames = const <String, String>{},
|
||||||
|
}) {
|
||||||
|
if (vendorId == null && productId == null) {
|
||||||
|
return usbRequestPortLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
final vendorHex = vendorId?.toRadixString(16).padLeft(4, '0').toUpperCase();
|
||||||
|
final productHex = productId?.toRadixString(16).padLeft(4, '0').toUpperCase();
|
||||||
|
final knownName = (vendorHex != null && productHex != null)
|
||||||
|
? knownUsbNames['${vendorHex.toLowerCase()}:${productHex.toLowerCase()}']
|
||||||
|
: null;
|
||||||
|
|
||||||
|
final parts = <String>[knownName ?? 'Web Serial Device'];
|
||||||
|
if (vendorHex != null) {
|
||||||
|
parts.add('VID:$vendorHex');
|
||||||
|
}
|
||||||
|
if (productHex != null) {
|
||||||
|
parts.add('PID:$productHex');
|
||||||
|
}
|
||||||
|
return '${parts.first} (${parts.skip(1).join(' ')})';
|
||||||
|
}
|
||||||
|
|
||||||
|
String buildUsbDisplayLabel({
|
||||||
|
required String basePortLabel,
|
||||||
|
String? deviceName,
|
||||||
|
}) {
|
||||||
|
final trimmedName = deviceName?.trim() ?? '';
|
||||||
|
if (trimmedName.isEmpty) {
|
||||||
|
return basePortLabel;
|
||||||
|
}
|
||||||
|
return '$basePortLabel - $trimmedName';
|
||||||
|
}
|
||||||
@ -0,0 +1,84 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_open/services/usb_serial_frame_codec.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('wrapUsbSerialTxFrame prefixes tx header and payload length', () {
|
||||||
|
final packet = wrapUsbSerialTxFrame(Uint8List.fromList(<int>[0x16, 0x03]));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
packet,
|
||||||
|
orderedEquals(<int>[usbSerialTxFrameStart, 0x02, 0x00, 0x16, 0x03]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('UsbSerialFrameDecoder buffers partial frames until complete', () {
|
||||||
|
final decoder = UsbSerialFrameDecoder();
|
||||||
|
|
||||||
|
final firstChunk = decoder.ingest(
|
||||||
|
Uint8List.fromList(<int>[usbSerialRxFrameStart, 0x03]),
|
||||||
|
);
|
||||||
|
final secondChunk = decoder.ingest(
|
||||||
|
Uint8List.fromList(<int>[0x00, 0x05, 0x06, 0x07]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(firstChunk, isEmpty);
|
||||||
|
expect(secondChunk, hasLength(1));
|
||||||
|
expect(secondChunk.single.isRxFrame, isTrue);
|
||||||
|
expect(secondChunk.single.payload, orderedEquals(<int>[0x05, 0x06, 0x07]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'UsbSerialFrameDecoder drops leading noise and parses multiple frames',
|
||||||
|
() {
|
||||||
|
final decoder = UsbSerialFrameDecoder();
|
||||||
|
|
||||||
|
final packets = decoder.ingest(
|
||||||
|
Uint8List.fromList(<int>[
|
||||||
|
0x00,
|
||||||
|
0x01,
|
||||||
|
usbSerialRxFrameStart,
|
||||||
|
0x01,
|
||||||
|
0x00,
|
||||||
|
0x55,
|
||||||
|
usbSerialRxFrameStart,
|
||||||
|
0x02,
|
||||||
|
0x00,
|
||||||
|
0x66,
|
||||||
|
0x77,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(packets, hasLength(2));
|
||||||
|
expect(packets[0].payload, orderedEquals(<int>[0x55]));
|
||||||
|
expect(packets[1].payload, orderedEquals(<int>[0x66, 0x77]));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'UsbSerialFrameDecoder preserves tx packets so caller can ignore them',
|
||||||
|
() {
|
||||||
|
final decoder = UsbSerialFrameDecoder();
|
||||||
|
|
||||||
|
final packets = decoder.ingest(
|
||||||
|
Uint8List.fromList(<int>[
|
||||||
|
usbSerialTxFrameStart,
|
||||||
|
0x01,
|
||||||
|
0x00,
|
||||||
|
0x22,
|
||||||
|
usbSerialRxFrameStart,
|
||||||
|
0x01,
|
||||||
|
0x00,
|
||||||
|
0x33,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(packets, hasLength(2));
|
||||||
|
expect(packets[0].isRxFrame, isFalse);
|
||||||
|
expect(packets[0].payload, orderedEquals(<int>[0x22]));
|
||||||
|
expect(packets[1].isRxFrame, isTrue);
|
||||||
|
expect(packets[1].payload, orderedEquals(<int>[0x33]));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_open/utils/usb_port_labels.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('normalizeUsbPortName strips friendly suffix from composite label', () {
|
||||||
|
expect(
|
||||||
|
normalizeUsbPortName(
|
||||||
|
'COM6 - USB Serial Device (COM6) - USB\\VID_2886&PID_1667',
|
||||||
|
),
|
||||||
|
'COM6',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('friendlyUsbPortName prefers suffix when present', () {
|
||||||
|
expect(
|
||||||
|
friendlyUsbPortName(
|
||||||
|
'COM6 - USB Serial Device (COM6) - USB\\VID_2886&PID_1667',
|
||||||
|
),
|
||||||
|
'USB Serial Device (COM6) - USB\\VID_2886&PID_1667',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'friendlyUsbPortName falls back to normalized port when suffix is empty',
|
||||||
|
() {
|
||||||
|
expect(friendlyUsbPortName('COM6 - '), 'COM6');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('describeWebUsbPort uses known VID/PID names when available', () {
|
||||||
|
expect(
|
||||||
|
describeWebUsbPort(
|
||||||
|
vendorId: 0x2886,
|
||||||
|
productId: 0x1667,
|
||||||
|
knownUsbNames: const <String, String>{
|
||||||
|
'2886:1667': 'Seeed Wio Tracker L1',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
'Seeed Wio Tracker L1 (VID:2886 PID:1667)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('describeWebUsbPort falls back to generic label for unknown device', () {
|
||||||
|
expect(
|
||||||
|
describeWebUsbPort(vendorId: 0x1234, productId: 0x5678),
|
||||||
|
'Web Serial Device (VID:1234 PID:5678)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('describeWebUsbPort returns chooser label when no usb ids exist', () {
|
||||||
|
expect(
|
||||||
|
describeWebUsbPort(vendorId: null, productId: null),
|
||||||
|
usbRequestPortLabel,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildUsbDisplayLabel appends device-reported name when available', () {
|
||||||
|
expect(
|
||||||
|
buildUsbDisplayLabel(
|
||||||
|
basePortLabel: 'Seeed Wio Tracker L1 (VID:2886 PID:1667)',
|
||||||
|
deviceName: 'KD3CGK mesh-utility.org',
|
||||||
|
),
|
||||||
|
'Seeed Wio Tracker L1 (VID:2886 PID:1667) - KD3CGK mesh-utility.org',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildUsbDisplayLabel keeps base label when custom name is blank', () {
|
||||||
|
expect(
|
||||||
|
buildUsbDisplayLabel(
|
||||||
|
basePortLabel: 'Seeed Wio Tracker L1 (VID:2886 PID:1667)',
|
||||||
|
deviceName: ' ',
|
||||||
|
),
|
||||||
|
'Seeed Wio Tracker L1 (VID:2886 PID:1667)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in new issue