import { NativeModule, requireNativeModule, SharedObject } from "expo"; type Subscription = { remove: () => void }; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- /** * Describes a custom USB device VID/PID mapping to support proprietary or * unrecognized serial chips with standard drivers. */ export type CustomDevice = { /** USB Vendor ID in decimal (e.g. 0x0403 = 1027 for FTDI). */ vendorId: number; /** USB Product ID in decimal. */ productId: number; /** * Silicon driver family to bind this custom device to. * Supports both canonical class names and convenient short-aliases (case-insensitive): * - **FTDI**: `"FtdiSerial"`, `"ftdi"` * - **CDC-ACM**: `"CdcAcmSerial"`, `"cdc-acm"` (default) * - **CH34x/CH340**: `"Ch34xSerial"`, `"ch34x"`, `"ch340"` * - **CP210x/CP21xx**: `"Cp21xxSerial"`, `"cp21xx"`, `"cp210x"` * - **Prolific PL2303**: `"ProlificSerial"`, `"prolific"`, `"pl2303"` * @default "CdcAcmSerial" */ driverName?: | "FtdiSerial" | "CdcAcmSerial" | "Ch34xSerial" | "Cp21xxSerial" | "ProlificSerial" | "ftdi" | "cdc-acm" | "ch34x" | "ch340" | "cp21xx" | "cp210x" | "prolific" | "pl2303"; }; /** * Describes a USB serial device found via {@link listDevices}. */ export type UsbSerialDevice = { /** Android USB device ID (stable for the lifetime of the connection). */ deviceId: number; /** USB Vendor ID in decimal (e.g. 0x0403 = 1027 for FTDI). */ vendorId: number; /** USB Product ID in decimal. */ productId: number; /** * Human-readable driver name resolved by usb-serial-for-android. * One of: `"FtdiSerial"`, `"CdcAcmSerial"`, `"Ch34xSerial"`, * `"Cp21xxSerial"`, `"ProlificSerial"`, or `"Unknown"`. */ driverName: string; /** Number of available serial ports on the device (most have 1, some FTDI have 4). */ portCount: number; /** Manufacturer string from USB descriptor, if available. */ manufacturerName?: string; /** Product string from USB descriptor, if available. */ productName?: string; /** Serial number string from USB descriptor, if available. */ serialNumber?: string; }; /** * Options passed to {@link connect}. */ export type ConnectOptions = { /** * Baud rate in bits per second. * Common values: `9600`, `19200`, `38400`, `57600`, `115200`, `230400`, `460800`, `921600`. * @default 9600 */ baudRate?: number; /** * Number of data bits. * @default 8 */ dataBits?: 5 | 6 | 7 | 8; /** * Number of stop bits. * @default 1 */ stopBits?: 1 | 2; /** * Parity mode. * @default "none" */ parity?: "none" | "odd" | "even" | "mark" | "space"; /** * Port index to open on multi-port devices (e.g. FTDI FT2232 has 2 ports). * @default 0 */ portIndex?: number; }; /** * A data event emitted by {@link addDataListener} containing bytes received * from the USB serial device. */ export type UsbSerialDataEvent = { /** The USB device ID this data came from. */ deviceId: number; /** * The received bytes as a standard Uint8Array directly from JSI memory. */ data: Uint8Array; }; /** * An error event emitted by {@link addErrorListener} when the native layer * encounters a read/write failure or the device is unplugged unexpectedly. */ export type UsbSerialErrorEvent = { /** The USB device ID this error is associated with. */ deviceId: number; /** Short error code, e.g. `"ERR_USB_READ"` or `"ERR_USB_DISCONNECTED"`. */ code: "ERR_USB_READ" | "ERR_USB_WRITE" | "ERR_USB_DISCONNECTED" | "ERR_USB_PERMISSION_TIMEOUT" | (string & {}); /** Human-readable error message. */ message: string; }; // --------------------------------------------------------------------------- // Well-known Vendor/Product IDs for documentation purposes // --------------------------------------------------------------------------- /** * Vendor IDs (decimal) for common USB-to-serial silicon vendors. * Use these with {@link listDevices} results to identify chip family. */ export const VendorIds = { FTDI: 1027, // 0x0403 SILABS: 4292, // 0x10C4 (CP210x) WCH: 6790, // 0x1A86 (CH340/CH341) PROLIFIC: 1659, // 0x067B (PL2303) /** Arduino Uno R3 (8-bit AVR, CDC-ACM) */ ARDUINO: 9025, // 0x2341 } as const; // --------------------------------------------------------------------------- // Errors // --------------------------------------------------------------------------- /** * Thrown when the native USB serial module is not available on the current * platform (iOS, web, or missing native build). */ export class UsbSerialUnavailableError extends Error { constructor() { super( "[expo-usb-serial] Native module is not available. " + "This module is Android-only. Ensure you are running on an Android " + "device with a native build (not Expo Go)." ); this.name = "UsbSerialUnavailableError"; } } // --------------------------------------------------------------------------- // Native Module Binding // --------------------------------------------------------------------------- type ExpoUsbSerialModuleType = InstanceType & { listDevices(customDevices: Array<{ vendorId: number; productId: number; driverName: string }>): Promise; connect(deviceId: number, options: Required): Promise; isConnected(deviceId: number): boolean; listConnected(): number[]; requestPermission(deviceId: number): Promise; UsbSerialConnection: any; }; let NativeUsbSerial: ExpoUsbSerialModuleType | null = null; try { NativeUsbSerial = requireNativeModule("ExpoUsbSerial"); } catch { NativeUsbSerial = null; } /** Whether the native USB serial module is available on the current platform. */ export const isAvailable = NativeUsbSerial != null; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function normalizeConnectOptions(options: ConnectOptions): Required { return { baudRate: options.baudRate ?? 9600, dataBits: options.dataBits ?? 8, stopBits: options.stopBits ?? 1, parity: options.parity ?? "none", portIndex: options.portIndex ?? 0, }; } // --------------------------------------------------------------------------- // JSI Connection Instance Class // --------------------------------------------------------------------------- /** * A JSI-backed native connection instance representing an active open serial port. * Exposes synchronous direct methods directly on the JS thread without bridge overhead. */ export class UsbSerialConnection extends ((NativeUsbSerial?.UsbSerialConnection ?? SharedObject) as typeof SharedObject) { /** Android USB device ID associated with this connection. */ declare deviceId: number; /** * Writes bytes directly to the connected serial device using direct JSI memory transfer. * Runs synchronously on the JS thread. * @returns The number of bytes written. */ write(data: Uint8Array, timeoutMs?: number): number { // @ts-ignore return super.write(data, timeoutMs ?? 2000); } /** * Writes bytes asynchronously to the connected serial device on a background IO thread. * Protects the JS execution thread from blocking I/O freezes. * @returns A promise that resolves to the number of bytes written. */ async writeAsync(data: Uint8Array, timeoutMs?: number): Promise { // @ts-ignore return super.writeAsync(data, timeoutMs ?? 2000); } /** * Closes the serial connection synchronously. */ disconnect(): void { // @ts-ignore super.disconnect(); } /** * Sets the DTR (Data Terminal Ready) line signal state synchronously. */ setDtr(enabled: boolean): void { // @ts-ignore super.setDtr(enabled); } /** * Sets the RTS (Request to Send) line signal state synchronously. */ setRts(enabled: boolean): void { // @ts-ignore super.setRts(enabled); } /** * Synchronously checks if this connection port is currently open. */ isConnected(): boolean { // @ts-ignore return super.isConnected(); } /** * Subscribes to incoming data from this connection instance. * The callback is invoked for every read chunk from the native JSI read loop. * Data arrives directly as a raw Uint8Array. * * @returns A subscription object with a `remove()` method to unsubscribe. */ addDataListener( listener: (data: Uint8Array) => void ): Subscription { // @ts-ignore return super.addListener("data", (event: { data: Uint8Array }) => { listener(event.data); }); } /** * Subscribes to error events from this connection instance. * * @returns A subscription object with a `remove()` method to unsubscribe. */ addErrorListener( listener: (error: { code: string; message: string }) => void ): Subscription { // @ts-ignore return super.addListener("error", listener); } } // --------------------------------------------------------------------------- // Public API — Device Discovery // --------------------------------------------------------------------------- /** * Returns a list of all USB serial devices currently attached to the device * that are supported by the usb-serial-for-android library. * * Supported chip families: FTDI, CP210x, CH340/CH341, PL2303, CDC-ACM * (Arduino, Teensy, STM32 DFU, etc.). * * Pass custom devices to map proprietary or unrecognized Vendor/Product IDs * to standard drivers. * * @throws {UsbSerialUnavailableError} If the native module is not installed. */ export async function listDevices(params: { customDevices?: CustomDevice[]; } = {}): Promise { if (!NativeUsbSerial) throw new UsbSerialUnavailableError(); const customDevices = params.customDevices ?? []; const normalized = customDevices.map((cd) => ({ vendorId: cd.vendorId, productId: cd.productId, driverName: cd.driverName ?? "CdcAcmSerial", })); return NativeUsbSerial.listDevices(normalized); } // --------------------------------------------------------------------------- // Public API — Permission // --------------------------------------------------------------------------- /** * Requests the Android `USB_PERMISSION` for a specific device. This shows * the system dialog asking the user to allow the app to communicate with * the USB device. Most devices require this before calling {@link connect}. * * Returns `true` if the user granted permission, `false` if denied. * * @throws {UsbSerialUnavailableError} If the native module is not installed. */ export async function requestPermission(params: { deviceId: number }): Promise { if (!NativeUsbSerial) throw new UsbSerialUnavailableError(); return NativeUsbSerial.requestPermission(params.deviceId); } // --------------------------------------------------------------------------- // Public API — Connection Management (JSI Factory) // --------------------------------------------------------------------------- /** * Opens a serial connection to the USB device with the given `deviceId` and * returns a JSI-backed {@link UsbSerialConnection} instance. * Call {@link requestPermission} first if you haven't already. * * The connection remains open until you call `connection.disconnect()` or the device * is physically unplugged (which fires an `onUsbSerialError` event with code * `"ERR_USB_DISCONNECTED"`). * * @throws {UsbSerialUnavailableError} If the native module is not installed. * @throws If the device is not found, already open, or the driver fails to initialize. */ export async function connect(params: { deviceId: number; options?: ConnectOptions; }): Promise { if (!NativeUsbSerial) throw new UsbSerialUnavailableError(); const options = params.options ?? {}; return NativeUsbSerial.connect(params.deviceId, normalizeConnectOptions(options)); } /** * Synchronously checks if a device currently has any active connections. * * @throws {UsbSerialUnavailableError} If the native module is not installed. */ export function isConnected(params: { deviceId: number }): boolean { if (!NativeUsbSerial) throw new UsbSerialUnavailableError(); return NativeUsbSerial.isConnected(params.deviceId); } /** * Returns a list of device IDs for all USB serial devices currently connected * (with active open connections). * * @throws {UsbSerialUnavailableError} If the native module is not installed. */ export function listConnected(): number[] { if (!NativeUsbSerial) throw new UsbSerialUnavailableError(); return NativeUsbSerial.listConnected(); }