expo-usb-serial
v0.0.2
Published
Android-only Expo module for USB serial communication over USB OTG. Wraps the rock-solid usb-serial-for-android (mik3y) Java library with support for FTDI, CH340, CP210x, and PL2303 chips.
Maintainers
Readme
expo-usb-serial
Android-only Expo native module for USB serial communication over USB OTG.
Wraps mik3y's 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-androidJava 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 runningnpx expo run:androidor 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
npx expo install expo-usb-serialAdd the Config Plugin
In your app.json / app.config.js:
{
"plugins": [["expo-usb-serial"]]
}Then run a new build:
npx expo prebuild --platform android
# or
eas build --platform androidThe 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 yourapp.jsonconfig.
- Converting Hex to Decimal: Most hardware datasheets list IDs in hex format (e.g.
0x0403for FTDI,0x10C4for 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 theVendorIdsconstant, or by using a developer calculator.
{
"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:
["expo-usb-serial", { "autoLaunchOnConnect": false }]API
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:
// 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.
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.
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.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.const data = new Uint8Array([0x01, 0xab, 0x03]); const written = await connection.writeAsync(data);connection.disconnect(): voidCloses the port and terminates the background read loop synchronously.connection.disconnect();connection.setDtr(enabled: boolean): voidToggles the DTR pin signal state synchronously (commonly used to reset microcontrollers like Arduino).connection.setDtr(false);connection.setRts(enabled: boolean): voidToggles the RTS pin signal state synchronously.connection.setRts(true);connection.isConnected(): booleanSynchronous state check to see if the port is currently open.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 JSUint8Arrayvia JSI: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.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.
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.
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):
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
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.
License
MIT
