From 095458ece4057429f80391289f487cd58d5b0e29 Mon Sep 17 00:00:00 2001 From: Janez T Date: Tue, 17 Mar 2026 13:46:50 +0100 Subject: [PATCH] feat: USB serial companion support (Android) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add MeshCoreSerialService to meshcore_client (0x3C/0x3E framing) - Add usb_serial package for Android USB OTG - Add UsbSerialTransport wrapper for Android - Add USB tab to connection dialog (scan, select, connect) - Add ConnectionMode.usb + connectSerial/disconnectSerial - Web Serial stub (not yet implemented) - Fix message limits: DM 156→150, Channel 127→160 (firmware MAX_TEXT_LEN=160) --- lib/models/device_info.dart | 7 + lib/providers/connection_provider.dart | 70 +++++- lib/services/serial/usb_serial_transport.dart | 101 +++++++++ lib/services/serial/web_serial_transport.dart | 22 ++ lib/widgets/connection_dialog.dart | 212 +++++++++++++++++- pubspec.lock | 10 +- pubspec.yaml | 1 + 7 files changed, 414 insertions(+), 9 deletions(-) create mode 100644 lib/services/serial/usb_serial_transport.dart create mode 100644 lib/services/serial/web_serial_transport.dart diff --git a/lib/models/device_info.dart b/lib/models/device_info.dart index 4a8c63c..d576cb3 100644 --- a/lib/models/device_info.dart +++ b/lib/models/device_info.dart @@ -16,6 +16,9 @@ enum ConnectionMode { /// Direct TCP/WiFi connection to MeshCore device (port 5000) tcp, + + /// USB serial connection (Android OTG or Web Serial API) + usb, } extension ConnectionModeExtension on ConnectionMode { @@ -25,6 +28,8 @@ extension ConnectionModeExtension on ConnectionMode { return 'Direct (BLE)'; case ConnectionMode.tcp: return 'Direct (WiFi)'; + case ConnectionMode.usb: + return 'Direct (USB)'; } } @@ -34,6 +39,8 @@ extension ConnectionModeExtension on ConnectionMode { return 'Direct BLE connection to MeshCore device'; case ConnectionMode.tcp: return 'Direct WiFi/TCP connection to MeshCore device'; + case ConnectionMode.usb: + return 'USB serial connection to MeshCore device'; } } } diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index 2a1c7bf..f4df5f3 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -72,15 +72,21 @@ class ConnectionProvider with ChangeNotifier { static const int _controlTypeNodeDiscoverReq = 0x80; final MeshCoreBleService _bleService = MeshCoreBleService(); MeshCoreTcpService? _tcpService; + MeshCoreSerialService? _serialService; /// Expose BLE service for background location tracking MeshCoreBleService get bleService => _bleService; - /// Active service — BLE or TCP depending on current mode - MeshCoreServiceBase get _activeService => - (_connectionMode == ConnectionMode.tcp && _tcpService != null) - ? _tcpService! - : _bleService; + /// Active service — BLE, TCP, or USB depending on current mode + MeshCoreServiceBase get _activeService { + if (_connectionMode == ConnectionMode.tcp && _tcpService != null) { + return _tcpService!; + } + if (_connectionMode == ConnectionMode.usb && _serialService != null) { + return _serialService!; + } + return _bleService; + } /// Current connection mode ConnectionMode _connectionMode = ConnectionMode.ble; @@ -710,6 +716,56 @@ class ConnectionProvider with ChangeNotifier { notifyListeners(); } + /// Connect via USB serial using a pre-configured [MeshCoreSerialService]. + /// + /// The caller is responsible for opening the serial port and wiring + /// [service.writeRaw] + [service.feedRawBytes] before calling this. + /// After this call succeeds, [service.markConnected()] has already run. + Future connectSerial(MeshCoreSerialService service) async { + debugPrint('🔌 [Provider] connectSerial()'); + + _deviceInfo = _deviceInfo.copyWith( + deviceId: 'usb', + deviceName: 'USB Companion', + connectionState: ConnectionState.connecting, + ); + _error = null; + _supportsAutoaddConfig = null; + notifyListeners(); + + _serialService?.dispose(); + _serialService = service; + _wireServiceCallbacks(_serialService!); + _connectionMode = ConnectionMode.usb; + + // markConnected() should already have been called by the transport. + // If it hasn't, the service won't be connected yet. + if (!service.isConnected) { + _deviceInfo = _deviceInfo.copyWith( + connectionState: ConnectionState.error, + ); + notifyListeners(); + return false; + } + return true; + } + + /// Disconnect from USB serial device. + Future disconnectSerial() async { + _serialService?.markDisconnected(); + _serialService?.dispose(); + _serialService = null; + _connectionMode = ConnectionMode.ble; + _supportsAutoaddConfig = null; + _resetSyncState(); + _deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected); + _roomLoginManager.clearRoomLoginStates(); + _pingTracker.clearAll(); + _pendingSendOperations.clear(); + _messageDeliveryTracker.clearTracking(); + notifyListeners(); + } + /// Disconnect from device Future disconnect() async { _deviceInfo = _deviceInfo.copyWith( @@ -721,6 +777,10 @@ class ConnectionProvider with ChangeNotifier { await disconnectTcp(); return; } + if (_connectionMode == ConnectionMode.usb) { + await disconnectSerial(); + return; + } await _bleService.disconnect(); diff --git a/lib/services/serial/usb_serial_transport.dart b/lib/services/serial/usb_serial_transport.dart new file mode 100644 index 0000000..ce4e162 --- /dev/null +++ b/lib/services/serial/usb_serial_transport.dart @@ -0,0 +1,101 @@ +import 'dart:async'; +import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; +import 'package:usb_serial/usb_serial.dart'; +import 'package:meshcore_client/meshcore_client.dart'; + +/// Android USB OTG transport for MeshCore serial connection. +/// +/// Wraps the `usb_serial` package to provide raw byte I/O to +/// [MeshCoreSerialService]. +class UsbSerialTransport { + static const int _baudRate = 115200; + + UsbPort? _port; + StreamSubscription? _inputSubscription; + final MeshCoreSerialService _service; + + UsbSerialTransport(this._service); + + MeshCoreSerialService get service => _service; + + bool get isConnected => _port != null; + + /// List available USB serial devices. + static Future> listDevices() async { + return UsbSerial.listDevices(); + } + + /// Connect to a USB serial device. + Future connect(UsbDevice device) async { + try { + _port = await device.create(); + if (_port == null) { + debugPrint('❌ [USB] Failed to create port for ${device.productName}'); + return false; + } + + final opened = await _port!.open(); + if (!opened) { + debugPrint('❌ [USB] Failed to open port'); + _port = null; + return false; + } + + await _port!.setDTR(true); + await _port!.setRTS(true); + await _port!.setPortParameters( + _baudRate, + UsbPort.DATABITS_8, + UsbPort.STOPBITS_1, + UsbPort.PARITY_NONE, + ); + + // Wire write callback + _service.writeRaw = _write; + + // Listen for incoming data + _inputSubscription = _port!.inputStream?.listen( + (data) => _service.feedRawBytes(data), + onError: (error) { + debugPrint('❌ [USB] Read error: $error'); + disconnect(); + }, + onDone: () { + debugPrint('⚠️ [USB] Port closed'); + disconnect(); + }, + ); + + debugPrint( + '✅ [USB] Connected to ${device.productName} at $_baudRate baud', + ); + + // Initialize MeshCore session + return _service.markConnected(); + } catch (e) { + debugPrint('❌ [USB] Connection failed: $e'); + await disconnect(); + return false; + } + } + + /// Disconnect from the USB device. + Future disconnect() async { + _service.markDisconnected(); + _service.writeRaw = null; + await _inputSubscription?.cancel(); + _inputSubscription = null; + await _port?.close(); + _port = null; + } + + Future _write(Uint8List data) async { + if (_port == null) throw Exception('USB not connected'); + await _port!.write(data); + } + + void dispose() { + disconnect(); + } +} diff --git a/lib/services/serial/web_serial_transport.dart b/lib/services/serial/web_serial_transport.dart new file mode 100644 index 0000000..d93aa20 --- /dev/null +++ b/lib/services/serial/web_serial_transport.dart @@ -0,0 +1,22 @@ +import 'dart:async'; +import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; +import 'package:meshcore_client/meshcore_client.dart'; + +// Web Serial API is only available on web platform. +// This file provides the interface; the actual JS interop is conditional. + +/// Web Serial transport stub for non-web platforms. +/// On web, use [WebSerialTransportImpl] from web_serial_transport_web.dart. +class WebSerialTransport { + final MeshCoreSerialService service; + + WebSerialTransport(this.service); + + bool get isConnected => false; + bool get isSupported => false; + + Future requestAndConnect() async => false; + Future disconnect() async {} + void dispose() {} +} diff --git a/lib/widgets/connection_dialog.dart b/lib/widgets/connection_dialog.dart index e88328d..d5790b3 100644 --- a/lib/widgets/connection_dialog.dart +++ b/lib/widgets/connection_dialog.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:provider/provider.dart'; +import 'package:meshcore_client/meshcore_client.dart' hide Contact; +import 'package:usb_serial/usb_serial.dart'; import '../providers/connection_provider.dart'; import '../providers/app_provider.dart'; import '../services/network_scanner_service.dart'; @@ -55,7 +58,7 @@ class _ConnectionDialogState extends State @override void initState() { super.initState(); - _tabController = TabController(length: 2, vsync: this); + _tabController = TabController(length: 3, vsync: this); _connectionProvider = Provider.of( context, listen: false, @@ -178,8 +181,9 @@ class _ConnectionDialogState extends State TabBar( controller: _tabController, tabs: const [ - Tab(text: 'BLE Devices', icon: Icon(Icons.bluetooth)), - Tab(text: 'Network Servers', icon: Icon(Icons.wifi)), + Tab(text: 'BLE', icon: Icon(Icons.bluetooth)), + Tab(text: 'Network', icon: Icon(Icons.wifi)), + Tab(text: 'USB', icon: Icon(Icons.usb)), ], ), ], @@ -196,6 +200,9 @@ class _ConnectionDialogState extends State // Network Servers Tab _buildNetworkServersTab(), + + // USB Serial Tab + _buildUsbTab(connectionProvider), ], ), ), @@ -630,4 +637,203 @@ class _ConnectionDialogState extends State ], ); } + + Widget _buildUsbTab(ConnectionProvider connectionProvider) { + return _UsbDeviceList( + onConnected: () { + if (mounted) Navigator.of(context).pop(); + }, + ); + } +} + +class _UsbDeviceList extends StatefulWidget { + final VoidCallback onConnected; + + const _UsbDeviceList({required this.onConnected}); + + @override + State<_UsbDeviceList> createState() => _UsbDeviceListState(); +} + +class _UsbDeviceListState extends State<_UsbDeviceList> { + List _devices = []; + bool _isScanning = false; + bool _isConnecting = false; + + @override + void initState() { + super.initState(); + if (defaultTargetPlatform == TargetPlatform.android) { + _scanDevices(); + } + } + + Future _scanDevices() async { + setState(() => _isScanning = true); + try { + final devices = await UsbSerial.listDevices(); + if (!mounted) return; + setState(() { + _devices = devices; + _isScanning = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _devices = []; + _isScanning = false; + }); + } + } + + Future _connectToDevice(UsbDevice device) async { + setState(() => _isConnecting = true); + try { + final connectionProvider = context.read(); + final service = MeshCoreSerialService(appName: 'MeshCore SAR'); + + final port = await device.create(); + if (port == null) { + if (mounted) { + setState(() => _isConnecting = false); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to create USB port')), + ); + } + return; + } + + final opened = await port.open(); + if (!opened) { + if (mounted) { + setState(() => _isConnecting = false); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to open USB port')), + ); + } + return; + } + + await port.setDTR(true); + await port.setRTS(true); + await port.setPortParameters( + 115200, + UsbPort.DATABITS_8, + UsbPort.STOPBITS_1, + UsbPort.PARITY_NONE, + ); + + service.writeRaw = (data) async { + await port.write(data); + }; + + port.inputStream?.listen( + (data) => service.feedRawBytes(data), + onError: (_) { + service.markDisconnected(); + port.close(); + }, + onDone: () { + service.markDisconnected(); + }, + ); + + final sessionOk = await service.markConnected(); + if (!sessionOk) { + await port.close(); + if (mounted) { + setState(() => _isConnecting = false); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('USB session initialization failed')), + ); + } + return; + } + + final success = await connectionProvider.connectSerial(service); + if (!mounted) return; + + if (success) { + widget.onConnected(); + } else { + await port.close(); + setState(() => _isConnecting = false); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to connect via USB')), + ); + } + } catch (e) { + if (!mounted) return; + setState(() => _isConnecting = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('USB error: $e')), + ); + } + } + + @override + Widget build(BuildContext context) { + if (defaultTargetPlatform != TargetPlatform.android) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + kIsWeb + ? 'Web Serial is not yet supported.\nUse BLE or Network instead.' + : 'USB serial is available on Android only.\nConnect via OTG cable.', + textAlign: TextAlign.center, + ), + ), + ); + } + + if (_isScanning) { + return const Center(child: CircularProgressIndicator()); + } + + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: OutlinedButton.icon( + onPressed: _isConnecting ? null : _scanDevices, + icon: const Icon(Icons.refresh), + label: const Text('Scan USB devices'), + ), + ), + if (_devices.isEmpty) + const Expanded( + child: Center( + child: Text( + 'No USB serial devices found.\nConnect a MeshCore device via OTG cable.', + textAlign: TextAlign.center, + ), + ), + ) + else + Expanded( + child: ListView.builder( + itemCount: _devices.length, + itemBuilder: (context, index) { + final device = _devices[index]; + return ListTile( + leading: const Icon(Icons.usb), + title: Text(device.productName ?? 'USB Device'), + subtitle: Text(device.manufacturerName ?? ''), + trailing: _isConnecting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.chevron_right), + onTap: _isConnecting ? null : () => _connectToDevice(device), + ); + }, + ), + ), + ], + ); + } } diff --git a/pubspec.lock b/pubspec.lock index c084788..c47b29d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -788,7 +788,7 @@ packages: description: path: "." ref: main - resolved-ref: b36f726c3e29fd2dd992abffa2b575d4058d721f + resolved-ref: abff892fac0dd0e8004bfa1f415df74b44b8eb52 url: "https://github.com/dz0ny/meshcore_client.git" source: git version: "0.1.0" @@ -1405,6 +1405,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.5" + usb_serial: + dependency: "direct main" + description: + name: usb_serial + sha256: a605a600e34e7f28d4e80851ca3999ef747e42e406138887b8a88b8c382a8b07 + url: "https://pub.dev" + source: hosted + version: "0.5.2" uuid: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e012e71..075bb43 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -121,6 +121,7 @@ dependencies: # Network Service Discovery (Bonjour/mDNS) nsd: ^4.0.3 + usb_serial: ^0.5.2 dev_dependencies: flutter_test: