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 = >[]; + if (_usbConnectionId != null) { + connections.add({ + 'connectionId': _usbConnectionId, + 'transport': 'usb', + 'device': _usbDeviceName, + 'bufferBytes': _usbBuffer?.length ?? 0, + }); + } + if (_btConnectionId != null) { + connections.add({ + 'connectionId': _btConnectionId, + 'transport': 'bluetooth', + 'device': _btDeviceName, + 'address': _btAddress, + 'bufferBytes': _btBuffer?.length ?? 0, + }); + } + return NodeFrame.response('', payload: {'connections': connections}); + } + + // ── helpers ───────────────────────────────────────────────────────────── + + NodeFrame _error(String code, String message) { + return NodeFrame.response('', error: {'code': code, 'message': message}); + } + + void dispose() { + _usbDataSub?.cancel(); + _btDataSub?.cancel(); + try { + _usbSerial.disconnect(); + } catch (_) {} + try { + _btSerial.disconnect(); + } catch (_) {} + _usbConnectionId = null; + _btConnectionId = null; + _usbBuffer = null; + _btBuffer = null; + } +} diff --git a/flutter_app/pubspec.yaml b/flutter_app/pubspec.yaml index ce6bf72..a0ffd2f 100644 --- a/flutter_app/pubspec.yaml +++ b/flutter_app/pubspec.yaml @@ -25,6 +25,8 @@ dependencies: uuid: ^4.2.0 camera: ^0.11.0 geolocator: ^12.0.0 + flutter_serial_communication: ^0.2.8 + flutter_bluetooth_classic_serial: ^1.3.2 dev_dependencies: flutter_test: