# expo-usb-serial

**Android-only** Expo native module for USB serial communication over USB OTG.  
Wraps mik3y's [usb-serial-for-android](https://github.com/mik3y/usb-serial-for-android) Java library.

> **Why this exists:** Other community packages are unmaintained or require manual configuration of build dependencies. This module wraps the underlying Java library using the modern Expo Modules API.

---

> [!WARNING]
> **Expo Go Compatibility**
> Because this package utilizes custom native code (and compiles mik3y's `usb-serial-for-android` Java library under the hood) to talk to physical USB OTG serial devices, it **will not work** in the standard pre-compiled **Expo Go** application.
> 
> To test and run this library, you must compile it into an Android **Development Build** (`expo-dev-client`) by running `npx expo run:android` or running it in a vanilla React Native project.

## Supported chips

| Silicon                 | Examples                                                      |
| ----------------------- | ------------------------------------------------------------- |
| **FTDI**                | FT232R, FT2232H, FT231X                                       |
| **Silicon Labs CP210x** | CP2102, CP2104, CP2109                                        |
| **WCH CH340/CH341**     | CH340G, CH341A                                                |
| **Prolific PL2303**     | PL2303HX, PL2303TA                                            |
| **CDC-ACM**             | Arduino Uno/Mega/Leonardo, Teensy, STM32, ESP32-S3 native USB |

---

## Installation

```sh
npx expo install expo-usb-serial
```

### Add the Config Plugin

In your `app.json` / `app.config.js`:

```json
{
  "plugins": [["expo-usb-serial"]]
}
```

Then run a new build:

```sh
npx expo prebuild --platform android
# or
eas build --platform android
```

The config plugin configures the Android USB host feature, adds the necessary intent-filters for hardware detection, and generates the device filters for the supported silicon chips.

### Custom device filter

To restrict which devices trigger your app, or to add exotic chips, define them in the config plugin.

> [!IMPORTANT]
> **Decimal Format Required:** Standard JSON does not support hexadecimal literal notation (like `0x0403`) or inline comments. Therefore, **Vendor IDs and Product IDs must be specified as base-10 (decimal) integers** in your `app.json` config.
>
> * **Converting Hex to Decimal:** Most hardware datasheets list IDs in hex format (e.g. `0x0403` for FTDI, `0x10C4` for CP210x). You **must** convert these to decimal:
>   * `0x0403` (hex) ➔ `1027` (decimal)
>   * `0x10C4` (hex) ➔ `4292` (decimal)
>   * `0x1A86` (hex) ➔ `6790` (decimal)
>   * `0x067B` (hex) ➔ `1659` (decimal)
> * You can easily perform this conversion in JavaScript/Node using `parseInt("0403", 16)`, by looking up the vendor ID in the `VendorIds` constant, or by using a developer calculator.

```json
{
  "plugins": [
    [
      "expo-usb-serial",
      {
        "devices": [
          { "vendorId": 1027, "label": "FTDI" },
          { "vendorId": 4292, "productId": 60000, "label": "My CP2102 dongle" }
        ]
      }
    ]
  ]
}
```

To disable auto-launch on device plug-in:

```json
["expo-usb-serial", { "autoLaunchOnConnect": false }]
```

---

## API

```typescript
import {
  listDevices,
  requestPermission,
  connect,
  isConnected,
  listConnected,
  isAvailable,
  VendorIds,
  UsbSerialConnection,
  type UsbSerialDevice,
  type ConnectOptions,
  type UsbSerialUnavailableError,
} from "expo-usb-serial";
```

### `listDevices({ customDevices }?): Promise<UsbSerialDevice[]>`

Returns all attached USB devices that have a supported driver. You can optionally pass a list of custom devices to map proprietary or unrecognized USB Vendor/Product IDs to standard chip drivers:

```typescript
// Standard discovery
const devices = await listDevices();

// Discovery with custom vendor chip mappings
const devices = await listDevices({
  customDevices: [
    { vendorId: 0x1234, productId: 0x5678, driverName: "FtdiSerial" }
  ]
});
// [{ deviceId: 3, vendorId: 4660, productId: 22136, driverName: "FtdiSerial", portCount: 1 }]
```

### `requestPermission({ deviceId }): Promise<boolean>`

Shows the Android system dialog asking the user to grant access to the USB device. Returns `true` if granted, or `false` if denied.

> **Note:** The permission request will automatically time out and reject the promise after **30 seconds** if the user ignores or backgrounds the dialog, cleanly releasing background receiver resources.

```typescript
const granted = await requestPermission({ deviceId: devices[0].deviceId });
```

### `connect({ deviceId, options }?): Promise<UsbSerialConnection>`

Opens a serial connection and returns a JSI-backed `UsbSerialConnection` instance representing the active port. Starts the background read loop automatically.

```typescript
const connection = await connect({
  deviceId,
  options: {
    baudRate: 115200, // default: 9600
    dataBits: 8, // default: 8
    stopBits: 1, // default: 1
    parity: "none", // "none" | "odd" | "even" | "mark" | "space"
    portIndex: 0, // for multi-port FTDI devices
  }
});
```

### `UsbSerialConnection` Class

A JSI-backed native connection instance. Exposes synchronous direct methods directly on the JavaScript thread:

- **`connection.write(data: Uint8Array, timeoutMs?: number): number`** (JSI Sync)
  Sends raw bytes directly to the hardware using direct JSI memory mapping. Runs synchronously on the JavaScript execution thread.
  *Best for ultra-low latency high-priority write cycles where blocking for <1ms is acceptable.*
  ```typescript
  const data = new Uint8Array([0x01, 0xab, 0x03]);
  const written = connection.write(data);
  ```

- **`connection.writeAsync(data: Uint8Array, timeoutMs?: number): Promise<number>`** (JSI Async)
  Sends raw bytes asynchronously by delegating the blocking OS I/O operation to a native background thread pool.
  *Best for large payloads or standard writes to fully safeguard the JS UI thread from blocking freezes.*
  ```typescript
  const data = new Uint8Array([0x01, 0xab, 0x03]);
  const written = await connection.writeAsync(data);
  ```

- **`connection.disconnect(): void`**
  Closes the port and terminates the background read loop synchronously.
  ```typescript
  connection.disconnect();
  ```

- **`connection.setDtr(enabled: boolean): void`**
  Toggles the DTR pin signal state synchronously (commonly used to reset microcontrollers like Arduino).
  ```typescript
  connection.setDtr(false);
  ```

- **`connection.setRts(enabled: boolean): void`**
  Toggles the RTS pin signal state synchronously.
  ```typescript
  connection.setRts(true);
  ```

- **`connection.isConnected(): boolean`**
  Synchronous state check to see if the port is currently open.
  ```typescript
  const open = connection.isConnected();
  ```

- **`connection.addDataListener(listener: (data: Uint8Array) => void): { remove(): void }`**
  Subscribe to incoming data from this connection instance. Callback fires for every read chunk and receives raw bytes directly as a JS `Uint8Array` via JSI:
  ```typescript
  const sub = connection.addDataListener((data) => {
    const text = Array.from(data).map(b => String.fromCharCode(b)).join("");
    console.log("Received data:", text);
  });
  
  // When done:
  sub.remove();
  ```

- **`connection.addErrorListener(listener: (error: { code: string; message: string }) => void): { remove(): void }`**
  Subscribe to connection-specific error events. The most important error code is `"ERR_USB_DISCONNECTED"` — emitted when the USB cable is physically unplugged.
  ```typescript
  const sub = connection.addErrorListener((e) => {
    console.warn(`[${e.code}] ${e.message}`);
    if (e.code === "ERR_USB_DISCONNECTED") {
      console.log("Device disconnected!");
    }
  });
  
  // When done:
  sub.remove();
  ```

### `isConnected({ deviceId }): boolean`

Synchronous module-level check — returns `true` if the device ID currently has any active open connections.

```typescript
const active = isConnected({ deviceId });
```

### `listConnected(): number[]`

Synchronous check — returns a list of device IDs for all USB serial devices currently connected (with active open connections). No I/O is performed.

```typescript
const activeDeviceIds = listConnected();
console.log("Active connection IDs:", activeDeviceIds);
```



### `isAvailable: boolean`

`true` on Android with a native build. `false` on iOS, web, or Expo Go.

### `VendorIds`

Convenience constants for common vendor IDs (decimal):

```typescript
VendorIds.FTDI;     // 1027   (0x0403)
VendorIds.SILABS;   // 4292   (0x10C4)
VendorIds.WCH;      // 6790   (0x1A86)
VendorIds.PROLIFIC; // 1659   (0x067B)
VendorIds.ARDUINO;  // 9025   (0x2341)
```

---

## Native Error Codes Reference

When the native layer rejects a promise or emits an `onUsbSerialError` event, it includes a short `code` string. Here is a reference of all possible error codes:

| Error Code | Occurs When |
|------------|-------------|
| `"ERR_USB_DISCONNECTED"` | Emitted dynamically when the USB cable is physically unplugged. |
| `"ERR_USB_READ"` | The background read thread fails to read from the hardware. |
| `"ERR_USB_WRITE"` | A write operation to the hardware fails or times out. |
| `"ERR_USB_PERMISSION_TIMEOUT"` | The user ignores the Android system USB permission dialog for more than 30 seconds. |
| `"ERR_USB_PERMISSION"` | An OS exception occurs while requesting permissions. |
| `"ERR_USB_NOT_FOUND"` | The specified `deviceId` is not currently attached. |
| `"ERR_USB_ALREADY_OPEN"` | Attempting to `connect()` to a `deviceId` that already has an open connection. |
| `"ERR_USB_NO_DRIVER"` | No standard or custom driver is matching the USB chipset. |
| `"ERR_USB_INVALID_PORT"` | The `portIndex` exceeds the number of available physical ports on the device. |
| `"ERR_USB_OPEN"` | The OS fails to open the device (most commonly due to missing USB permission). |
| `"ERR_USB_CONNECT"` | The serial port fails to configure or open at the hardware level. |
| `"ERR_USB_DISCONNECT"` | Failed to cleanly close the connection. |
| `"ERR_USB_LINE_CONTROL"` | Failed to toggle the DTR or RTS hardware control line state. |
| `"ERR_USB_LIST"` | An exception occurs while probing physical USB interfaces. |

---

## Full Example

```typescript
import React, { useEffect, useRef, useState } from "react";
import { Button, FlatList, Text, View } from "react-native";
import {
  listDevices,
  requestPermission,
  connect,
  isAvailable,
  UsbSerialConnection,
  type UsbSerialDevice,
} from "expo-usb-serial";

export default function App() {
  const [devices, setDevices] = useState<UsbSerialDevice[]>([]);
  const [activeConnection, setActiveConnection] = useState<UsbSerialConnection | null>(null);
  const [log, setLog] = useState<string[]>([]);
  const subs = useRef<Array<{ remove(): void }>>([]);

  const appendLog = (msg: string) =>
    setLog((prev) => [`${new Date().toISOString().slice(11, 19)} ${msg}`, ...prev.slice(0, 99)]);

  const scan = async () => {
    if (!isAvailable) return appendLog("USB serial not available on this platform");
    const found = await listDevices();
    setDevices(found);
    appendLog(`Found ${found.length} device(s)`);
  };

  const openDevice = async (device: UsbSerialDevice) => {
    const granted = await requestPermission({ deviceId: device.deviceId });
    if (!granted) return appendLog("Permission denied");

    const connection = await connect({ deviceId: device.deviceId, options: { baudRate: 115200 } });
    setActiveConnection(connection);
    appendLog(`Connected to ${device.productName ?? `device ${device.deviceId}`}`);

    subs.current.push(
      connection.addDataListener((data) => {
        const text = Array.from(data).map(b => String.fromCharCode(b)).join("").trim();
        if (text) appendLog(`← ${text}`);
      }),
      connection.addErrorListener((e) => {
        appendLog(`⚠ ${e.code}: ${e.message}`);
        if (e.code === "ERR_USB_DISCONNECTED") setActiveConnection(null);
      })
    );
  };

  const closeDevice = async () => {
    if (activeConnection !== null) {
      subs.current.forEach((s) => s.remove());
      subs.current = [];
      activeConnection.disconnect();
      setActiveConnection(null);
      appendLog("Disconnected");
    }
  };

  const sendPing = async () => {
    if (activeConnection === null) return;
    const data = new Uint8Array([112, 105, 110, 103, 10]); // "ping\n"
    const n = activeConnection.write(data);
    appendLog(`→ sent ${n} bytes`);
  };

  useEffect(() => () => { subs.current.forEach((s) => s.remove()); }, []);

  return (
    <View style={{ flex: 1, padding: 24, gap: 12 }}>
      <Button title="Scan for devices" onPress={scan} />
      {devices.map((d) => (
        <Button
          key={d.deviceId}
          title={`Connect: ${d.productName ?? d.driverName} (${d.vendorId}:${d.productId})`}
          onPress={() => openDevice(d)}
          disabled={activeConnection !== null}
        />
      ))}
      {activeConnection !== null && (
        <>
          <Button title="Send ping" onPress={sendPing} />
          <Button title="Disconnect" onPress={closeDevice} color="red" />
        </>
      )}
      <FlatList
        data={log}
        keyExtractor={(_, i) => String(i)}
        renderItem={({ item }) => (
          <Text style={{ fontFamily: "monospace", fontSize: 12 }}>{item}</Text>
        )}
      />
    </View>
  );
}
```

---

## Platform Support

| Platform | Status                                                                                          |
| -------- | ----------------------------------------------------------------------------------------------- |
| Android  | ✅ Full support                                                                                 |
| iOS      | ❌ Not supported (Apple restricts generic USB serial; use BLE or Lightning accessories instead) |
| Web      | ❌ Not supported                                                                                |

---

## JitPack Dependency (Zero-Config)

The `usb-serial-for-android` library is hosted on JitPack.

**No manual setup is required!** The config plugin handles this during prebuild.

---

## Hardware Testing & Troubleshooting Tips

When working with low-level USB OTG serial devices on Android, keep these hardware and OS constraints in mind:

### Bus Power Limits

Many development boards and 3D printer controllers (like the CH340 chip on the Snapmaker U1) pull power directly from the USB host line to stay discoverable. If your phone aggressively restricts power output, plugging into a turned-off printer can cause connection drops. Always power on the printer or peripheral before connecting the USB cable to your phone.

### System Intent Prompts

The moment you connect your serial device via a USB OTG cable, the Android OS broadcasts a `UsbManager.ACTION_USB_DEVICE_ATTACHED` intent. Because this module's Config Plugin automatically wires the intent-filter onto your main activity, Android will instantly display a system dialog asking: _"Allow [Your App] to access [Device Name]?"_ and can automatically launch your application.

---

## Contributing

Issues and PRs welcome at [github.com/bc-bane/expo-usb-serial](https://github.com/bc-bane/expo-usb-serial).

## License

MIT
