diff --git a/CHANGELOG.md b/CHANGELOG.md index 3878c91..a0227c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog +## v1.8.0 — Serial Capability (Bluetooth + USB) + +> Requires Android 10+ (API 29) + +### New: Serial Communication (#21) + +- **Bluetooth Classic Serial** — Connect to HC-05, Micro:bit, and any SPP/RFCOMM device via `flutter_bluetooth_classic_serial` +- **USB Serial** — Connect to Arduino, Micro:bit, FTDI, CH340, CP210x via USB OTG using `flutter_serial_communication` +- **Unified API** — Single `serial` capability with `transport` parameter ("bluetooth" or "usb"); 6 commands: `scan`, `connect`, `write`, `read`, `disconnect`, `list` +- **64KB Read Buffer** — Incoming data is buffered per connection with delimiter-based and timeout-based reads +- **Smart Permission Gating** — Bluetooth permissions requested only for BT commands; USB requires no runtime permissions +- **Simultaneous Transports** — One USB and one BT connection can be active at the same time + +### Node Commands Added + +| Command | Description | +|---------|-------------| +| `serial.scan` | Discover USB devices or paired Bluetooth devices | +| `serial.connect` | Open a persistent serial connection | +| `serial.write` | Send data (utf8 or base64 encoded) | +| `serial.read` | Read buffered data with timeout and delimiter support | +| `serial.disconnect` | Close one or all connections | +| `serial.list` | List active connections with buffer sizes | + +### Android Permissions Added + +- `BLUETOOTH`, `BLUETOOTH_ADMIN` (Android 11 and below) +- `BLUETOOTH_CONNECT`, `BLUETOOTH_SCAN` (Android 12+) +- `android.hardware.usb.host` feature (optional) + +--- + ## v1.7.1 — Background Persistence & Camera Fix > Requires Android 10+ (API 29) diff --git a/README.md b/README.md index 85ada95..f2050d4 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ OpenClaw brings the [OpenClaw](https://github.com/anthropics/openclaw) AI gatewa - **One-Tap Setup** — Downloads Ubuntu rootfs, Node.js 22, and OpenClaw automatically - **Built-in Terminal** — Full terminal emulator with extra keys toolbar, copy/paste, clickable URLs - **Gateway Controls** — Start/stop gateway with status indicator and health checks -- **Node Device Capabilities** — 7 capabilities (15 commands) exposed to AI via WebSocket node protocol +- **Node Device Capabilities** — 9 capabilities (21 commands) exposed to AI via WebSocket node protocol - **Token URL Display** — Captures auth token from onboarding, shows it with a copy button - **Web Dashboard** — Embedded WebView loads the dashboard with authentication token - **View Logs** — Real-time gateway log viewer with search/filter @@ -93,8 +93,34 @@ The Flutter app connects to the gateway as a **node**, exposing Android hardware | **Screen** | `screen.record` | MediaProjection consent | | **Sensor** | `sensor.read`, `sensor.list` | Body Sensors | | **Haptic** | `haptic.vibrate` | None | +| **Serial** | `serial.scan`, `serial.connect`, `serial.write`, `serial.read`, `serial.disconnect`, `serial.list` | Bluetooth (BT only) | -The gateway's `openclaw.json` is automatically patched before startup to clear `denyCommands` and set `allowCommands` for all 15 commands. +The gateway's `openclaw.json` is automatically patched before startup to clear `denyCommands` and set `allowCommands` for all registered commands. + +#### Serial Capability + +The serial capability enables AI agents to communicate with physical hardware over **Bluetooth Classic** (RFCOMM/SPP) and **USB serial** (CDC-ACM, FTDI, CH340, CP210x) — perfect for controlling Arduino, Micro:bit, HC-05, and similar devices. + +**Supported transports:** +| Transport | Package | Supported Chips | +|---|---|---| +| USB Serial | `flutter_serial_communication` | CDC-ACM, FTDI, CH340, CP210x | +| Bluetooth Classic | `flutter_bluetooth_classic_serial` | Any SPP/RFCOMM device | + +**Usage flow:** +``` +serial.scan transport:"usb" # Discover USB devices +serial.connect transport:"usb" deviceId:3 baudRate:9600 # Connect +serial.write connectionId:"usb_3" data:"Hello\n" # Send data +serial.read connectionId:"usb_3" timeoutMs:2000 # Read response +serial.disconnect connectionId:"all" # Clean up +``` + +**Notes:** +- USB devices are detected automatically via USB OTG — no permissions required +- Bluetooth devices must be paired via Android Settings first, then `serial.scan transport:"bluetooth"` lists them +- One active connection per transport (USB and BT can be connected simultaneously) +- Incoming data is buffered (64KB per connection) and read on demand with optional delimiter matching ### Termux CLI - **One-Command Setup** — Installs proot-distro, Ubuntu, Node.js 22, and OpenClaw @@ -193,7 +219,7 @@ openclawx gateway --verbose │ ┌─────────────────┴────────────────────────────┐ │ │ │ Node Provider (WebSocket) │ │ │ │ Camera · Flash · Location · Screen │ │ -│ │ Sensor · Haptic · Canvas │ │ +│ │ Sensor · Haptic · Canvas · Serial │ │ │ └─────────────────┬────────────────────────────┘ │ └────────────────────┼──────────────────────────────┘ │ @@ -204,7 +230,7 @@ openclawx gateway --verbose │ │ ┌─────────────────────────────────────┐ │ │ │ │ │ OpenClaw AI Gateway │ │ │ │ │ │ http://localhost:18789 │ │ │ -│ │ │ ← Node WS: 15 device commands │ │ │ +│ │ │ ← Node WS: 21 device commands │ │ │ │ │ └─────────────────────────────────────┘ │ │ │ │ Optional: Go, Homebrew │ │ │ └────────────────────────────────────────────┘ │ @@ -256,6 +282,7 @@ flutter_app/lib/ │ ├── location_capability.dart # GPS with timeout + fallback │ ├── screen_capability.dart # Screen recording via MediaProjection │ ├── sensor_capability.dart # Accelerometer, gyroscope, etc. +│ ├── serial_capability.dart # Bluetooth Classic + USB serial │ └── vibration_capability.dart # Haptic feedback └── widgets/ ├── gateway_controls.dart # Start/stop, URL display, copy button diff --git a/docs/serial-capability.md b/docs/serial-capability.md new file mode 100644 index 0000000..0d3a952 --- /dev/null +++ b/docs/serial-capability.md @@ -0,0 +1,354 @@ +# Serial Capability + +Communicate with physical hardware over **Bluetooth Classic** and **USB Serial** from the AI agent. + +--- + +## Overview + +The `serial` capability lets AI agents talk to microcontrollers and peripherals — Arduino, Micro:bit, HC-05, FTDI adapters, and any device that speaks serial. It uses a single unified command set with a `transport` parameter to switch between Bluetooth and USB. + +**Transports:** + +| Transport | Protocol | Supported Hardware | +|---|---|---| +| `bluetooth` | RFCOMM / SPP | HC-05, HC-06, Micro:bit, any Bluetooth Classic serial device | +| `usb` | USB Serial | Arduino (CDC-ACM), FTDI, CH340, CP210x, Micro:bit via USB OTG | + +**Packages used:** + +| Transport | Package | +|---|---| +| USB | [`flutter_serial_communication`](https://pub.dev/packages/flutter_serial_communication) (wraps `usb-serial-for-android`) | +| Bluetooth | [`flutter_bluetooth_classic_serial`](https://pub.dev/packages/flutter_bluetooth_classic_serial) | + +--- + +## Commands + +### `serial.scan` — Discover devices + +Finds available devices for the given transport. + +| Parameter | Type | Required | Default | Description | +|---|---|---|---|---| +| `transport` | string | yes | — | `"bluetooth"` or `"usb"` | + +**USB response:** +```json +{ + "transport": "usb", + "devices": [ + { + "name": "Arduino Uno", + "deviceId": 3, + "vendorId": 9025, + "productId": 67, + "manufacturer": "Arduino (www.arduino.cc)", + "serialNumber": "55739323535351E0E0D1" + } + ] +} +``` + +**Bluetooth response:** +```json +{ + "transport": "bluetooth", + "devices": [ + { + "name": "HC-05", + "address": "00:21:13:02:AA:BB", + "paired": true + } + ] +} +``` + +> **Note:** Bluetooth scan returns **paired** devices only. Pair new devices via Android Settings > Bluetooth first. + +--- + +### `serial.connect` — Open a connection + +Opens a persistent serial connection to a device. + +| Parameter | Type | Required | Default | Description | +|---|---|---|---|---| +| `transport` | string | yes | — | `"bluetooth"` or `"usb"` | +| `address` | string | BT only | — | MAC address from scan (e.g. `"00:21:13:02:AA:BB"`) | +| `deviceId` | int | USB only | — | Device ID from scan | +| `baudRate` | int | no | `9600` | Baud rate (USB only). Common: 9600, 115200 | +| `name` | string | no | `"BT Device"` | Friendly name (BT only, for display) | + +**Response:** +```json +{ + "connectionId": "usb_3", + "transport": "usb", + "device": "Arduino Uno", + "vendorId": 9025, + "productId": 67, + "baudRate": 9600 +} +``` + +**Connection IDs:** +- USB: `usb_` (e.g. `usb_3`) +- Bluetooth: `bt_` (e.g. `bt_02AABB`) + +**Limits:** One connection per transport. USB and Bluetooth can be connected simultaneously. Attempting a second connection on the same transport returns `ALREADY_CONNECTED`. + +--- + +### `serial.write` — Send data + +Sends data to a connected device. + +| Parameter | Type | Required | Default | Description | +|---|---|---|---|---| +| `connectionId` | string | yes | — | Connection ID from `serial.connect` | +| `data` | string | yes | — | Data to send | +| `encoding` | string | no | `"utf8"` | `"utf8"` or `"base64"` | +| `appendNewline` | bool | no | `false` | Append `\n` after data | + +**Response:** +```json +{ + "connectionId": "usb_3", + "bytesWritten": 6 +} +``` + +**Examples:** +``` +# Send text with newline +serial.write connectionId:"usb_3" data:"Hello" appendNewline:true + +# Send raw bytes via base64 +serial.write connectionId:"usb_3" data:"uwAiAAAifg==" encoding:"base64" +``` + +--- + +### `serial.read` — Read buffered data + +Reads from the incoming data buffer. Data is continuously buffered in the background (up to 64KB per connection). + +| Parameter | Type | Required | Default | Description | +|---|---|---|---|---| +| `connectionId` | string | yes | — | Connection ID | +| `maxBytes` | int | no | `1024` | Maximum bytes to return | +| `timeoutMs` | int | no | `2000` | Max wait time in milliseconds | +| `encoding` | string | no | `"utf8"` | `"utf8"` or `"base64"` for response | +| `delimiter` | string | no | — | Wait for this string (e.g. `"\n"`) | + +**Response:** +```json +{ + "connectionId": "usb_3", + "data": "Echo: Hello\n", + "encoding": "utf8", + "bytesRead": 12, + "bufferRemaining": 0 +} +``` + +**Behavior:** +- Without `delimiter`: returns immediately if buffer has data, otherwise waits up to `timeoutMs` +- With `delimiter`: waits until delimiter is found in buffer or `timeoutMs` expires +- If timeout expires with no data: returns `bytesRead: 0` and empty `data` +- `bufferRemaining` shows bytes left in buffer after this read + +--- + +### `serial.disconnect` — Close a connection + +| Parameter | Type | Required | Default | Description | +|---|---|---|---|---| +| `connectionId` | string | yes | — | Connection ID, or `"all"` to close everything | + +**Response:** +```json +{ + "connectionId": "usb_3", + "disconnected": true +} +``` + +--- + +### `serial.list` — List active connections + +No parameters required. + +**Response:** +```json +{ + "connections": [ + { + "connectionId": "usb_3", + "transport": "usb", + "device": "Arduino Uno", + "bufferBytes": 42 + }, + { + "connectionId": "bt_02AABB", + "transport": "bluetooth", + "device": "HC-05", + "address": "00:21:13:02:AA:BB", + "bufferBytes": 0 + } + ] +} +``` + +--- + +## Permissions + +| Transport | Android Permissions | Runtime Prompt? | +|---|---|---| +| USB | None (uses USB host API) | No | +| Bluetooth | `BLUETOOTH_CONNECT`, `BLUETOOTH_SCAN` | Yes (Android 12+) | + +Legacy permissions (`BLUETOOTH`, `BLUETOOTH_ADMIN`) are declared with `maxSdkVersion="30"` for Android 11 and below. + +USB uses `` — optional so the app installs on devices without USB OTG. + +--- + +## Error Codes + +| Code | When | +|---|---| +| `PERMISSION_DENIED` | User denied Bluetooth permission | +| `PERMISSION_PERMANENTLY_DENIED` | BT permission permanently denied (must enable in Settings) | +| `MISSING_PARAM` | Required parameter missing | +| `INVALID_PARAM` | Invalid transport value | +| `BT_NOT_SUPPORTED` | Device has no Bluetooth hardware | +| `BT_DISABLED` | Bluetooth adapter is off | +| `DEVICE_NOT_FOUND` | Device ID not in scan results | +| `CONNECT_FAILED` | Connection attempt failed | +| `ALREADY_CONNECTED` | Transport already has an active connection | +| `NOT_CONNECTED` | Connection ID doesn't match any active connection | +| `WRITE_FAILED` | Write operation returned false | +| `SCAN_ERROR` | Scan threw an exception | +| `SERIAL_ERROR` | Unhandled exception in capability | + +--- + +## Examples + +### Arduino LED Control + +``` +# 1. Scan for the Arduino +serial.scan transport:"usb" + +# 2. Connect at 9600 baud +serial.connect transport:"usb" deviceId:3 baudRate:9600 + +# 3. Turn LED on +serial.write connectionId:"usb_3" data:"LED_ON" appendNewline:true + +# 4. Read Arduino's response +serial.read connectionId:"usb_3" timeoutMs:3000 delimiter:"\n" + +# 5. Done +serial.disconnect connectionId:"usb_3" +``` + +### Micro:bit Sensor Reading (Bluetooth) + +``` +# 1. Pair Micro:bit in Android Settings first, then scan +serial.scan transport:"bluetooth" + +# 2. Connect using MAC address from scan +serial.connect transport:"bluetooth" address:"E4:F0:42:1A:2B:3C" name:"micro:bit" + +# 3. Request sensor data +serial.write connectionId:"bt_1A2B3C" data:"READ_TEMP\n" + +# 4. Read the temperature value +serial.read connectionId:"bt_1A2B3C" timeoutMs:5000 delimiter:"\n" +# → { "data": "TEMP:23.5\n", "bytesRead": 10 } + +# 5. Disconnect +serial.disconnect connectionId:"bt_1A2B3C" +``` + +### Binary Protocol (base64) + +``` +# Send raw bytes: [0xBB, 0x00, 0x22, 0x00, 0x00, 0x22, 0x7E] +serial.write connectionId:"usb_3" data:"uwAiAAAifg==" encoding:"base64" + +# Read raw response as base64 +serial.read connectionId:"usb_3" encoding:"base64" timeoutMs:3000 +``` + +### Multi-Device (USB + BT simultaneously) + +``` +serial.connect transport:"usb" deviceId:3 baudRate:115200 +serial.connect transport:"bluetooth" address:"00:21:13:02:AA:BB" + +serial.list +# → 2 connections active + +serial.write connectionId:"usb_3" data:"MOTOR_FWD\n" +serial.write connectionId:"bt_02AABB" data:"SENSOR_READ\n" + +serial.disconnect connectionId:"all" +``` + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────┐ +│ SerialCapability │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ USB Transport │ │ BT Transport │ │ +│ │ │ │ │ │ +│ │ FlutterSerial- │ │ FlutterBluetooth │ │ +│ │ Communication │ │ Classic │ │ +│ │ │ │ │ │ +│ │ ┌──────────────┐ │ │ ┌──────────────┐ │ │ +│ │ │ _ReadBuffer │ │ │ │ _ReadBuffer │ │ │ +│ │ │ (64KB max) │ │ │ │ (64KB max) │ │ │ +│ │ └──────────────┘ │ │ └──────────────┘ │ │ +│ └──────────────────┘ └──────────────────┘ │ +│ │ +│ handleWithPermission() ← smart gate │ +│ BT commands → check BT permission │ +│ USB commands → skip permission check │ +└─────────────────────────────────────────────┘ +``` + +--- + +## Troubleshooting + +**USB device not found in scan:** +- Ensure USB OTG is supported by your Android device +- Try a different USB cable (some are charge-only) +- Android may show a permission dialog for USB device access — approve it + +**Bluetooth device not listed:** +- Pair the device first in Android Settings > Bluetooth +- `serial.scan transport:"bluetooth"` only returns paired devices +- Ensure the device is powered on and in range + +**Connect fails:** +- USB: Verify the baud rate matches your device firmware (default: 9600) +- BT: Ensure the device isn't already connected to another app +- Try `serial.disconnect connectionId:"all"` and reconnect + +**Read returns empty:** +- Increase `timeoutMs` — the device may be slow to respond +- Check that you sent data the device expects (including newline if needed) +- Use `serial.list` to verify the connection is still active and check `bufferBytes` 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/bootstrap_service.dart b/flutter_app/lib/services/bootstrap_service.dart index b3293cf..bb92322 100644 --- a/flutter_app/lib/services/bootstrap_service.dart +++ b/flutter_app/lib/services/bootstrap_service.dart @@ -152,11 +152,21 @@ class BootstrapService { )); // ca-certificates: HTTPS for npm/git // git: openclaw has git deps (@whiskeysockets/libsignal-node) + // python3, make, g++: node-gyp needs these to compile native addons + // (npm's bundled node-gyp runs as a JS module, not a spawned process, + // so proot-compat.js spawn mock can't intercept it) // dpkg extracts via tar inside proot — permissions are correct. // Post-install scripts (update-ca-certificates) run automatically. + // Pre-configure tzdata to avoid interactive continent/timezone prompt + // (tzdata is a dependency of python3 and ignores DEBIAN_FRONTEND on + // first install if no timezone is pre-set). + await NativeBridge.runInProot( + 'ln -sf /usr/share/zoneinfo/Etc/UTC /etc/localtime && ' + 'echo "Etc/UTC" > /etc/timezone', + ); await NativeBridge.runInProot( 'apt-get install -y --no-install-recommends ' - 'ca-certificates git', + 'ca-certificates git python3 make g++', ); // Git config (.gitconfig) is written by installBionicBypass() on the 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..e94749e --- /dev/null +++ b/flutter_app/lib/services/capabilities/serial_capability.dart @@ -0,0 +1,522 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:flutter_serial_communication/flutter_serial_communication.dart'; +import 'package:flutter_serial_communication/models/device_info.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.isNotEmpty ? d.deviceName : (d.productName.isNotEmpty ? 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.isNotEmpty ? device.deviceName : (device.productName.isNotEmpty ? 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) { + _btBuffer?.add(event.data); + }); + + 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/lib/services/gateway_service.dart b/flutter_app/lib/services/gateway_service.dart index d1dc841..0e5aabd 100644 --- a/flutter_app/lib/services/gateway_service.dart +++ b/flutter_app/lib/services/gateway_service.dart @@ -91,6 +91,8 @@ class GatewayService { 'screen.record', 'sensor.read', 'sensor.list', 'haptic.vibrate', + 'serial.scan', 'serial.connect', 'serial.write', + 'serial.read', 'serial.disconnect', 'serial.list', ]; // Use a Node.js one-liner to safely merge into existing openclaw.json // without clobbering other settings (API keys, onboarding config, etc.) 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: