diff --git a/flutter_app/android/app/src/main/AndroidManifest.xml b/flutter_app/android/app/src/main/AndroidManifest.xml
index 3c34781..162f0ed 100644
--- a/flutter_app/android/app/src/main/AndroidManifest.xml
+++ b/flutter_app/android/app/src/main/AndroidManifest.xml
@@ -15,6 +15,11 @@
+
+
+
+
+
_state;
@@ -156,6 +158,11 @@ class NodeProvider extends ChangeNotifier with WidgetsBindingObserver {
_sensorCapability.commands.map((c) => '${_sensorCapability.name}.$c').toList(),
(cmd, params) => _sensorCapability.handleWithPermission(cmd, params),
);
+ _nodeService.registerCapability(
+ _serialCapability.name,
+ _serialCapability.commands.map((c) => '${_serialCapability.name}.$c').toList(),
+ (cmd, params) => _serialCapability.handleWithPermission(cmd, params),
+ );
}
Future _init() async {
@@ -306,6 +313,7 @@ class NodeProvider extends ChangeNotifier with WidgetsBindingObserver {
_nodeService.dispose();
_cameraCapability.dispose();
_flashCapability.dispose();
+ _serialCapability.dispose();
NativeBridge.stopNodeService();
super.dispose();
}
diff --git a/flutter_app/lib/screens/node_screen.dart b/flutter_app/lib/screens/node_screen.dart
index 44fca9c..1b407d6 100644
--- a/flutter_app/lib/screens/node_screen.dart
+++ b/flutter_app/lib/screens/node_screen.dart
@@ -216,6 +216,18 @@ class _NodeScreenState extends State {
'Read accelerometer, gyroscope, magnetometer, barometer',
Icons.sensors,
),
+ _capabilityTile(
+ theme,
+ 'Bluetooth Serial',
+ 'Connect to Micro:bit, HC-05 and classic BT serial devices',
+ Icons.bluetooth,
+ ),
+ _capabilityTile(
+ theme,
+ 'USB Serial',
+ 'Wired serial via USB OTG (Arduino, Micro:bit, FTDI)',
+ Icons.usb,
+ ),
const SizedBox(height: 16),
// Device Info
diff --git a/flutter_app/lib/services/capabilities/serial_capability.dart b/flutter_app/lib/services/capabilities/serial_capability.dart
new file mode 100644
index 0000000..62201d3
--- /dev/null
+++ b/flutter_app/lib/services/capabilities/serial_capability.dart
@@ -0,0 +1,530 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:typed_data';
+import 'package:flutter_serial_communication/flutter_serial_communication.dart';
+import 'package:flutter_bluetooth_classic_serial/flutter_bluetooth_classic.dart';
+import 'package:permission_handler/permission_handler.dart';
+import '../../models/node_frame.dart';
+import 'capability_handler.dart';
+
+/// Circular read buffer for incoming serial data (capped at 64 KB).
+class _ReadBuffer {
+ final List _bytes = [];
+ static const int maxSize = 65536;
+
+ int get length => _bytes.length;
+
+ void add(List data) {
+ _bytes.addAll(data);
+ if (_bytes.length > maxSize) {
+ _bytes.removeRange(0, _bytes.length - maxSize);
+ }
+ }
+
+ List take(int maxBytes) {
+ final count = maxBytes.clamp(0, _bytes.length);
+ final chunk = _bytes.sublist(0, count);
+ _bytes.removeRange(0, count);
+ return chunk;
+ }
+
+ int findDelimiter(List delimiter) {
+ if (delimiter.isEmpty || _bytes.length < delimiter.length) return -1;
+ outer:
+ for (int i = 0; i <= _bytes.length - delimiter.length; i++) {
+ for (int j = 0; j < delimiter.length; j++) {
+ if (_bytes[i + j] != delimiter[j]) continue outer;
+ }
+ return i;
+ }
+ return -1;
+ }
+
+ List takeUntilDelimiter(List delimiter) {
+ final idx = findDelimiter(delimiter);
+ if (idx < 0) return [];
+ final end = idx + delimiter.length;
+ final chunk = _bytes.sublist(0, end);
+ _bytes.removeRange(0, end);
+ return chunk;
+ }
+}
+
+/// Serial capability supporting both Bluetooth Classic and USB serial.
+///
+/// One active connection per transport (plugin limitation).
+/// Connection IDs: `usb_` for USB, `bt_` for Bluetooth.
+class SerialCapability extends CapabilityHandler {
+ // ── USB state ──────────────────────────────────────────────────────────
+ final FlutterSerialCommunication _usbSerial = FlutterSerialCommunication();
+ String? _usbConnectionId;
+ String? _usbDeviceName;
+ _ReadBuffer? _usbBuffer;
+ StreamSubscription? _usbDataSub;
+ List? _lastUsbDevices;
+
+ // ── Bluetooth state ────────────────────────────────────────────────────
+ final FlutterBluetoothClassic _btSerial = FlutterBluetoothClassic();
+ String? _btConnectionId;
+ String? _btDeviceName;
+ String? _btAddress;
+ _ReadBuffer? _btBuffer;
+ StreamSubscription? _btDataSub;
+
+ @override
+ String get name => 'serial';
+
+ @override
+ List get commands =>
+ ['scan', 'connect', 'write', 'read', 'disconnect', 'list'];
+
+ @override
+ List get requiredPermissions => [
+ Permission.bluetoothConnect,
+ Permission.bluetoothScan,
+ ];
+
+ @override
+ Future checkPermission() async {
+ return await Permission.bluetoothConnect.isGranted &&
+ await Permission.bluetoothScan.isGranted;
+ }
+
+ @override
+ Future requestPermission() async {
+ final statuses = await [
+ Permission.bluetoothConnect,
+ Permission.bluetoothScan,
+ ].request();
+ return statuses.values.every((s) => s.isGranted);
+ }
+
+ /// Only require Bluetooth permissions when the command targets BT transport.
+ @override
+ Future handleWithPermission(
+ String command, Map params) async {
+ final transport = params['transport'] as String?;
+ final connectionId = params['connectionId'] as String?;
+
+ bool needsBt = false;
+ if (transport == 'bluetooth') needsBt = true;
+ if (connectionId != null && connectionId.startsWith('bt_')) needsBt = true;
+
+ if (needsBt) {
+ return super.handleWithPermission(command, params);
+ }
+ return handle(command, params);
+ }
+
+ @override
+ Future handle(
+ String command, Map params) async {
+ try {
+ switch (command) {
+ case 'serial.scan':
+ return await _scan(params);
+ case 'serial.connect':
+ return await _connect(params);
+ case 'serial.write':
+ return await _write(params);
+ case 'serial.read':
+ return await _read(params);
+ case 'serial.disconnect':
+ return await _disconnect(params);
+ case 'serial.list':
+ return _list();
+ default:
+ return _error('UNKNOWN_COMMAND', 'Unknown serial command: $command');
+ }
+ } catch (e) {
+ return _error('SERIAL_ERROR', '$e');
+ }
+ }
+
+ // ── scan ────────────────────────────────────────────────────────────────
+
+ Future _scan(Map params) async {
+ final transport = params['transport'] as String?;
+ if (transport == null) {
+ return _error(
+ 'MISSING_PARAM', 'transport required ("bluetooth" or "usb")');
+ }
+ if (transport == 'usb') return _scanUsb();
+ if (transport == 'bluetooth') return _scanBluetooth();
+ return _error('INVALID_PARAM', 'transport must be "bluetooth" or "usb"');
+ }
+
+ Future _scanUsb() async {
+ try {
+ final devices = await _usbSerial.getAvailableDevices();
+ _lastUsbDevices = devices;
+ return NodeFrame.response('', payload: {
+ 'transport': 'usb',
+ 'devices': devices
+ .map((d) => {
+ 'name': d.deviceName ?? d.productName ?? 'Unknown USB Device',
+ 'deviceId': d.deviceId,
+ 'vendorId': d.vendorId,
+ 'productId': d.productId,
+ 'manufacturer': d.manufacturerName,
+ 'serialNumber': d.serialNumber,
+ })
+ .toList(),
+ });
+ } catch (e) {
+ return _error('SCAN_ERROR', 'USB scan failed: $e');
+ }
+ }
+
+ Future _scanBluetooth() async {
+ try {
+ final supported = await _btSerial.isBluetoothSupported();
+ if (!supported) {
+ return _error(
+ 'BT_NOT_SUPPORTED', 'Bluetooth not supported on this device');
+ }
+ final enabled = await _btSerial.isBluetoothEnabled();
+ if (!enabled) {
+ return _error(
+ 'BT_DISABLED', 'Bluetooth is disabled. Enable it in settings.');
+ }
+ final devices = await _btSerial.getPairedDevices();
+ return NodeFrame.response('', payload: {
+ 'transport': 'bluetooth',
+ 'devices': devices
+ .map((d) => {
+ 'name': d.name,
+ 'address': d.address,
+ 'paired': true,
+ })
+ .toList(),
+ });
+ } catch (e) {
+ return _error('SCAN_ERROR', 'Bluetooth scan failed: $e');
+ }
+ }
+
+ // ── connect ─────────────────────────────────────────────────────────────
+
+ Future _connect(Map params) async {
+ final transport = params['transport'] as String?;
+ if (transport == null) {
+ return _error('MISSING_PARAM', 'transport required');
+ }
+ if (transport == 'usb') return _connectUsb(params);
+ if (transport == 'bluetooth') return _connectBluetooth(params);
+ return _error('INVALID_PARAM', 'transport must be "bluetooth" or "usb"');
+ }
+
+ Future _connectUsb(Map params) async {
+ if (_usbConnectionId != null) {
+ return _error('ALREADY_CONNECTED',
+ 'USB already connected ($_usbConnectionId). Disconnect first.');
+ }
+
+ final deviceId = params['deviceId'];
+ final baudRate = params['baudRate'] as int? ?? 9600;
+ if (deviceId == null) {
+ return _error('MISSING_PARAM', 'deviceId required for USB');
+ }
+
+ _lastUsbDevices ??= await _usbSerial.getAvailableDevices();
+ DeviceInfo? device;
+ for (final d in _lastUsbDevices!) {
+ if (d.deviceId.toString() == deviceId.toString()) {
+ device = d;
+ break;
+ }
+ }
+ if (device == null) {
+ return _error(
+ 'DEVICE_NOT_FOUND', 'Device $deviceId not found. Run serial.scan.');
+ }
+
+ final ok = await _usbSerial.connect(device, baudRate);
+ if (!ok) {
+ return _error('CONNECT_FAILED', 'USB connect failed');
+ }
+
+ final connId = 'usb_${device.deviceId}';
+ _usbConnectionId = connId;
+ _usbDeviceName = device.deviceName ?? device.productName ?? 'USB Device';
+ _usbBuffer = _ReadBuffer();
+
+ _usbDataSub?.cancel();
+ _usbDataSub = _usbSerial
+ .getSerialMessageListener()
+ .receiveBroadcastStream()
+ .listen((event) {
+ if (event is Uint8List) {
+ _usbBuffer?.add(event);
+ } else if (event is List) {
+ _usbBuffer?.add(List.from(event));
+ } else if (event is String) {
+ _usbBuffer?.add(utf8.encode(event));
+ }
+ });
+
+ return NodeFrame.response('', payload: {
+ 'connectionId': connId,
+ 'transport': 'usb',
+ 'device': _usbDeviceName,
+ 'vendorId': device.vendorId,
+ 'productId': device.productId,
+ 'baudRate': baudRate,
+ });
+ }
+
+ Future _connectBluetooth(Map params) async {
+ if (_btConnectionId != null) {
+ return _error('ALREADY_CONNECTED',
+ 'Bluetooth already connected ($_btConnectionId). Disconnect first.');
+ }
+
+ final address = params['address'] as String?;
+ if (address == null || address.isEmpty) {
+ return _error('MISSING_PARAM', 'address required for Bluetooth');
+ }
+
+ final ok = await _btSerial.connect(address);
+ if (!ok) {
+ return _error('CONNECT_FAILED', 'Bluetooth connect failed at $address');
+ }
+
+ final clean = address.replaceAll(':', '');
+ final suffix =
+ clean.length >= 6 ? clean.substring(clean.length - 6) : clean;
+ final connId = 'bt_$suffix';
+ _btConnectionId = connId;
+ _btAddress = address;
+ _btDeviceName = params['name'] as String? ?? 'BT Device';
+ _btBuffer = _ReadBuffer();
+
+ _btDataSub?.cancel();
+ _btDataSub = _btSerial.onDataReceived.listen((event) {
+ if (event is List) {
+ _btBuffer?.add(event);
+ } else {
+ try {
+ final data = (event as dynamic).data;
+ if (data is List) {
+ _btBuffer?.add(data);
+ }
+ } catch (_) {}
+ }
+ });
+
+ return NodeFrame.response('', payload: {
+ 'connectionId': connId,
+ 'transport': 'bluetooth',
+ 'device': _btDeviceName,
+ 'address': address,
+ });
+ }
+
+ // ── write ───────────────────────────────────────────────────────────────
+
+ Future _write(Map params) async {
+ final connectionId = params['connectionId'] as String?;
+ if (connectionId == null) {
+ return _error('MISSING_PARAM', 'connectionId required');
+ }
+ final data = params['data'] as String?;
+ if (data == null) {
+ return _error('MISSING_PARAM', 'data required');
+ }
+
+ final encoding = params['encoding'] as String? ?? 'utf8';
+ final appendNewline = params['appendNewline'] as bool? ?? false;
+
+ List bytes;
+ if (encoding == 'base64') {
+ bytes = base64Decode(data);
+ } else {
+ bytes = utf8.encode(data);
+ }
+ if (appendNewline) {
+ bytes = [...bytes, 0x0A];
+ }
+
+ if (connectionId == _usbConnectionId && _usbConnectionId != null) {
+ final ok = await _usbSerial.write(Uint8List.fromList(bytes));
+ if (!ok) return _error('WRITE_FAILED', 'USB write failed');
+ return NodeFrame.response('', payload: {
+ 'connectionId': connectionId,
+ 'bytesWritten': bytes.length,
+ });
+ }
+
+ if (connectionId == _btConnectionId && _btConnectionId != null) {
+ final ok = await _btSerial.sendData(bytes);
+ if (!ok) return _error('WRITE_FAILED', 'Bluetooth write failed');
+ return NodeFrame.response('', payload: {
+ 'connectionId': connectionId,
+ 'bytesWritten': bytes.length,
+ });
+ }
+
+ return _error('NOT_CONNECTED', 'No connection: $connectionId');
+ }
+
+ // ── read ────────────────────────────────────────────────────────────────
+
+ Future _read(Map params) async {
+ final connectionId = params['connectionId'] as String?;
+ if (connectionId == null) {
+ return _error('MISSING_PARAM', 'connectionId required');
+ }
+
+ final maxBytes = params['maxBytes'] as int? ?? 1024;
+ final timeoutMs = params['timeoutMs'] as int? ?? 2000;
+ final encoding = params['encoding'] as String? ?? 'utf8';
+ final delimiter = params['delimiter'] as String?;
+
+ _ReadBuffer? buffer;
+ if (connectionId == _usbConnectionId) {
+ buffer = _usbBuffer;
+ } else if (connectionId == _btConnectionId) {
+ buffer = _btBuffer;
+ }
+ if (buffer == null) {
+ return _error('NOT_CONNECTED', 'No connection: $connectionId');
+ }
+
+ final delimBytes = delimiter != null ? utf8.encode(delimiter) : null;
+ final deadline = DateTime.now().add(Duration(milliseconds: timeoutMs));
+
+ while (DateTime.now().isBefore(deadline)) {
+ if (delimBytes != null) {
+ if (buffer.findDelimiter(delimBytes) >= 0) {
+ final chunk = buffer.takeUntilDelimiter(delimBytes);
+ return _readResponse(connectionId, chunk, encoding, buffer.length);
+ }
+ } else if (buffer.length > 0) {
+ break;
+ }
+ await Future.delayed(const Duration(milliseconds: 50));
+ }
+
+ final chunk = buffer.take(maxBytes);
+ return _readResponse(connectionId, chunk, encoding, buffer.length);
+ }
+
+ NodeFrame _readResponse(
+ String connectionId, List bytes, String encoding, int remaining) {
+ final data = encoding == 'base64'
+ ? base64Encode(bytes)
+ : utf8.decode(bytes, allowMalformed: true);
+ return NodeFrame.response('', payload: {
+ 'connectionId': connectionId,
+ 'data': data,
+ 'encoding': encoding,
+ 'bytesRead': bytes.length,
+ 'bufferRemaining': remaining,
+ });
+ }
+
+ // ── disconnect ──────────────────────────────────────────────────────────
+
+ Future _disconnect(Map params) async {
+ final connectionId = params['connectionId'] as String?;
+ if (connectionId == null) {
+ return _error('MISSING_PARAM', 'connectionId required');
+ }
+
+ if (connectionId == 'all') {
+ await _disconnectUsb();
+ await _disconnectBt();
+ return NodeFrame.response('', payload: {
+ 'connectionId': 'all',
+ 'disconnected': true,
+ });
+ }
+
+ if (connectionId == _usbConnectionId) {
+ await _disconnectUsb();
+ return NodeFrame.response('', payload: {
+ 'connectionId': connectionId,
+ 'disconnected': true,
+ });
+ }
+
+ if (connectionId == _btConnectionId) {
+ await _disconnectBt();
+ return NodeFrame.response('', payload: {
+ 'connectionId': connectionId,
+ 'disconnected': true,
+ });
+ }
+
+ return _error('NOT_CONNECTED', 'No connection: $connectionId');
+ }
+
+ Future _disconnectUsb() async {
+ _usbDataSub?.cancel();
+ _usbDataSub = null;
+ try {
+ await _usbSerial.disconnect();
+ } catch (_) {}
+ _usbConnectionId = null;
+ _usbDeviceName = null;
+ _usbBuffer = null;
+ }
+
+ Future _disconnectBt() async {
+ _btDataSub?.cancel();
+ _btDataSub = null;
+ try {
+ await _btSerial.disconnect();
+ } catch (_) {}
+ _btConnectionId = null;
+ _btDeviceName = null;
+ _btAddress = null;
+ _btBuffer = null;
+ }
+
+ // ── list ────────────────────────────────────────────────────────────────
+
+ NodeFrame _list() {
+ final connections =