feat: USB serial companion support (Android)

- 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)
This commit is contained in:
Janez T
2026-03-17 13:46:50 +01:00
parent 5659242672
commit 095458ece4
7 changed files with 414 additions and 9 deletions

View File

@@ -16,6 +16,9 @@ enum ConnectionMode {
/// Direct TCP/WiFi connection to MeshCore device (port 5000) /// Direct TCP/WiFi connection to MeshCore device (port 5000)
tcp, tcp,
/// USB serial connection (Android OTG or Web Serial API)
usb,
} }
extension ConnectionModeExtension on ConnectionMode { extension ConnectionModeExtension on ConnectionMode {
@@ -25,6 +28,8 @@ extension ConnectionModeExtension on ConnectionMode {
return 'Direct (BLE)'; return 'Direct (BLE)';
case ConnectionMode.tcp: case ConnectionMode.tcp:
return 'Direct (WiFi)'; return 'Direct (WiFi)';
case ConnectionMode.usb:
return 'Direct (USB)';
} }
} }
@@ -34,6 +39,8 @@ extension ConnectionModeExtension on ConnectionMode {
return 'Direct BLE connection to MeshCore device'; return 'Direct BLE connection to MeshCore device';
case ConnectionMode.tcp: case ConnectionMode.tcp:
return 'Direct WiFi/TCP connection to MeshCore device'; return 'Direct WiFi/TCP connection to MeshCore device';
case ConnectionMode.usb:
return 'USB serial connection to MeshCore device';
} }
} }
} }

View File

@@ -72,15 +72,21 @@ class ConnectionProvider with ChangeNotifier {
static const int _controlTypeNodeDiscoverReq = 0x80; static const int _controlTypeNodeDiscoverReq = 0x80;
final MeshCoreBleService _bleService = MeshCoreBleService(); final MeshCoreBleService _bleService = MeshCoreBleService();
MeshCoreTcpService? _tcpService; MeshCoreTcpService? _tcpService;
MeshCoreSerialService? _serialService;
/// Expose BLE service for background location tracking /// Expose BLE service for background location tracking
MeshCoreBleService get bleService => _bleService; MeshCoreBleService get bleService => _bleService;
/// Active service — BLE or TCP depending on current mode /// Active service — BLE, TCP, or USB depending on current mode
MeshCoreServiceBase get _activeService => MeshCoreServiceBase get _activeService {
(_connectionMode == ConnectionMode.tcp && _tcpService != null) if (_connectionMode == ConnectionMode.tcp && _tcpService != null) {
? _tcpService! return _tcpService!;
: _bleService; }
if (_connectionMode == ConnectionMode.usb && _serialService != null) {
return _serialService!;
}
return _bleService;
}
/// Current connection mode /// Current connection mode
ConnectionMode _connectionMode = ConnectionMode.ble; ConnectionMode _connectionMode = ConnectionMode.ble;
@@ -710,6 +716,56 @@ class ConnectionProvider with ChangeNotifier {
notifyListeners(); 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<bool> 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<void> 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 /// Disconnect from device
Future<void> disconnect() async { Future<void> disconnect() async {
_deviceInfo = _deviceInfo.copyWith( _deviceInfo = _deviceInfo.copyWith(
@@ -721,6 +777,10 @@ class ConnectionProvider with ChangeNotifier {
await disconnectTcp(); await disconnectTcp();
return; return;
} }
if (_connectionMode == ConnectionMode.usb) {
await disconnectSerial();
return;
}
await _bleService.disconnect(); await _bleService.disconnect();

View File

@@ -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<List<UsbDevice>> listDevices() async {
return UsbSerial.listDevices();
}
/// Connect to a USB serial device.
Future<bool> 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<void> disconnect() async {
_service.markDisconnected();
_service.writeRaw = null;
await _inputSubscription?.cancel();
_inputSubscription = null;
await _port?.close();
_port = null;
}
Future<void> _write(Uint8List data) async {
if (_port == null) throw Exception('USB not connected');
await _port!.write(data);
}
void dispose() {
disconnect();
}
}

View File

@@ -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<bool> requestAndConnect() async => false;
Future<void> disconnect() async {}
void dispose() {}
}

View File

@@ -1,5 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:provider/provider.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/connection_provider.dart';
import '../providers/app_provider.dart'; import '../providers/app_provider.dart';
import '../services/network_scanner_service.dart'; import '../services/network_scanner_service.dart';
@@ -55,7 +58,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_tabController = TabController(length: 2, vsync: this); _tabController = TabController(length: 3, vsync: this);
_connectionProvider = Provider.of<ConnectionProvider>( _connectionProvider = Provider.of<ConnectionProvider>(
context, context,
listen: false, listen: false,
@@ -178,8 +181,9 @@ class _ConnectionDialogState extends State<ConnectionDialog>
TabBar( TabBar(
controller: _tabController, controller: _tabController,
tabs: const [ tabs: const [
Tab(text: 'BLE Devices', icon: Icon(Icons.bluetooth)), Tab(text: 'BLE', icon: Icon(Icons.bluetooth)),
Tab(text: 'Network Servers', icon: Icon(Icons.wifi)), Tab(text: 'Network', icon: Icon(Icons.wifi)),
Tab(text: 'USB', icon: Icon(Icons.usb)),
], ],
), ),
], ],
@@ -196,6 +200,9 @@ class _ConnectionDialogState extends State<ConnectionDialog>
// Network Servers Tab // Network Servers Tab
_buildNetworkServersTab(), _buildNetworkServersTab(),
// USB Serial Tab
_buildUsbTab(connectionProvider),
], ],
), ),
), ),
@@ -630,4 +637,203 @@ class _ConnectionDialogState extends State<ConnectionDialog>
], ],
); );
} }
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<UsbDevice> _devices = [];
bool _isScanning = false;
bool _isConnecting = false;
@override
void initState() {
super.initState();
if (defaultTargetPlatform == TargetPlatform.android) {
_scanDevices();
}
}
Future<void> _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<void> _connectToDevice(UsbDevice device) async {
setState(() => _isConnecting = true);
try {
final connectionProvider = context.read<ConnectionProvider>();
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),
);
},
),
),
],
);
}
} }

View File

@@ -788,7 +788,7 @@ packages:
description: description:
path: "." path: "."
ref: main ref: main
resolved-ref: b36f726c3e29fd2dd992abffa2b575d4058d721f resolved-ref: abff892fac0dd0e8004bfa1f415df74b44b8eb52
url: "https://github.com/dz0ny/meshcore_client.git" url: "https://github.com/dz0ny/meshcore_client.git"
source: git source: git
version: "0.1.0" version: "0.1.0"
@@ -1405,6 +1405,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.5" 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: uuid:
dependency: transitive dependency: transitive
description: description:

View File

@@ -121,6 +121,7 @@ dependencies:
# Network Service Discovery (Bonjour/mDNS) # Network Service Discovery (Bonjour/mDNS)
nsd: ^4.0.3 nsd: ^4.0.3
usb_serial: ^0.5.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: