skio_usb_serial 0.2.1
skio_usb_serial: ^0.2.1 copied to clipboard
USB serial for Flutter on Android (USB OTG), macOS and web (Web Serial): talk to ESP32, Arduino, STM32 and CH340, CP210x, FTDI adapters. JNI and FFI, no platform channels.
skio_usb_serial #
Talk to USB serial devices from a Flutter app: Arduino and ESP32 boards, USB-to-serial adapters (CH340, CP210x, FTDI, PL2303), GPS modules, scales, barcode readers, lab instruments and anything else that shows up as a serial port. Send and receive bytes or text on Android (with a USB OTG cable), in macOS apps, and in the browser (Chrome and Edge, using Web Serial).
Part of the skio family of Flutter hardware plugins.
Contents #
- Platforms
- Install
- Quick start
- Features
- Errors and what to do
- Debug logging
- Troubleshooting
- Tested hardware
- Limitations
- Example app
- Testing your app without hardware
- FAQ
- Compared with other packages
- Migrating from usb_serial
Platforms #
| Feature | Android | macOS | Web (Chrome, Edge) |
|---|---|---|---|
| How it connects | USB host (OTG) cable or adapter | The /dev/cu.* port macOS creates for the adapter |
Web Serial API |
| Finding ports | Lists every attached adapter | Lists every attached USB serial port, with vendor, product and serial number | The user picks a port in a browser popup; picked ports are remembered |
| Permission | A USB dialog for each device | None at runtime; sandboxed apps need one entitlement | Picking the port in the popup is the permission |
| Send and receive | Yes | Yes | Yes |
| Baud rate, data bits, parity, stop bits | Yes | Yes, any baud rate (parity none, odd or even; 1 or 2 stop bits) | Yes (7 or 8 data bits; parity none, odd or even; 1 or 2 stop bits) |
| Hardware flow control (RTS/CTS) | Yes, if the chip supports it | Yes | Yes |
| DTR/DSR and XON/XOFF flow control | Yes, if the chip supports it | Yes | No |
| DTR and RTS lines | Yes | Yes | Yes |
| Plug and unplug events | Yes (checked once a second) | Yes (checked once a second) | Yes |
| Minimum version | Android 7.0 (API 24) | Same as Flutter | Desktop Chrome or Edge, page served over https or localhost |
Safari, Firefox, iOS, Windows apps and Linux apps are not supported; on
those platforms the package reports AccessStatus.unsupported instead of
failing. For Windows, Linux and the UARTs built into Android panels, use
skio_uart.
Supported chips on Android: CH340/CH341/CH9102, CP210x, FTDI (FT232, FT2232, ...), Prolific PL2303, and CDC-ACM devices such as Arduino boards and ESP32/RP2040 native USB. Adapters with several ports (for example FT2232) show one entry per port.
On macOS any adapter that shows up as /dev/cu.* works. Recent macOS
versions include drivers for CDC-ACM devices and the common FTDI, CH340,
CP210x and PL2303 chips.
Install #
flutter pub add skio_usb_serial
Android: nothing else to add. The package needs no runtime permission, declares USB host as optional (so your app still installs on phones without it), and includes the rules that keep release builds working with code shrinking. The Android library it uses (usb-serial-for-android) comes from JitPack; the package adds that repository for you.
macOS: apps are sandboxed by default, and a sandboxed app can only open
serial ports with the serial device entitlement. Add it to both
macos/Runner/DebugProfile.entitlements and
macos/Runner/Release.entitlements:
<key>com.apple.security.device.serial</key>
<true/>
Without it, open throws AccessDenied. The package uses dart:ffi only,
so there is no CocoaPods or Swift setup.
Web: serve the page over https (or http://localhost while
developing), and open it in desktop Chrome or Edge.
Quick start #
import 'dart:convert';
import 'package:skio_usb_serial/skio_usb_serial.dart';
Future<void> talkToBoard() async {
// On the web, first let the user pick the port (from a button press):
if (UsbSerialPort.requiresUserSelection) await UsbSerialPort.request();
// 1. Find the port.
final ports = await UsbSerialPort.list();
if (ports.isEmpty) return;
// 2. Ask for permission (Android shows a USB dialog).
final access = await UsbSerialPort.access.requestAccess(ports.first);
if (!access.isUsable) return;
// 3. Open it: 115200 baud, 8 data bits, no parity, 1 stop bit.
final port = await UsbSerialPort.open(
ports.first,
config: const SerialConfig(baudRate: 115200),
);
// 4. Print every line the device sends.
port.input.transform(const LineReader()).listen(print);
// 5. Send a line of text.
await port.write(utf8.encode('hello\n'));
// ...later
await port.close();
}
Features #
Find serial ports #
final ports = await UsbSerialPort.list();
for (final port in ports) {
print('${port.name} vendor ${port.vendorId} product ${port.productId}');
}
On Android this lists every USB serial adapter plugged into the phone.
On macOS it lists every USB serial port (built-in ports such as
Bluetooth-Incoming-Port are left out); device.id is the /dev/cu.*
path and device.serialNumber is filled in when the adapter has one.
On the web it lists the ports the user has already picked for this site.
To look for one kind of device, pass its USB vendor (and product) ID:
// Only CH340 adapters (vendor 0x1a86).
final ports = await UsbSerialPort.list(
filters: const [DeviceFilter(vendorId: 0x1a86)],
);
Let the user pick a port (web) #
Browsers only allow access to ports the user picks. Show a "Choose port"
button and call request() from it:
ElevatedButton(
onPressed: () async {
final port = await UsbSerialPort.request(); // opens the browser popup
if (port == null) return; // the user closed the popup
// port is ready to open
},
child: const Text('Choose port'),
)
UsbSerialPort.requiresUserSelectionistrueon the web andfalseon Android and macOS, so the same code can decide whether to show the button.- Pass
filterstorequest()to show only matching adapters in the popup. - The browser remembers picked ports for your site, so next time
list()returns them without the popup. request()must be called from a user action such as a button press; on Android and macOS it throwsUnsupported(uselist()there).
Ask for permission (Android) #
final access = await UsbSerialPort.access.requestAccess(device);
if (!access.isUsable) {
// The user tapped Cancel in the USB dialog.
}
Android asks the user once per device ("Allow the app to access USB
Serial?"). checkAccess(device) tells you whether permission is already
granted, without showing a dialog. On the web, picking the port is the
permission, so requestAccess() opens the port popup. On macOS no runtime
permission exists, so both return AccessStatus.notRequired.
Open a port with the right settings #
The settings must match what the device expects. Baud rate is the most common source of problems: wrong baud rate means garbled characters.
final port = await UsbSerialPort.open(
device,
config: const SerialConfig(
baudRate: 9600, // required: 9600, 115200, ...
dataBits: 8, // 5 to 8, default 8
parity: Parity.none, // none, odd, even, mark, space
stopBits: StopBits.one, // one, onePointFive, two
flowControl: FlowControl.none, // none, rtsCts, dtrDsr, xonXoff
),
);
The default (just SerialConfig(baudRate: ...)) is 8N1 without flow
control, which is what most devices use.
Two more options on open:
openDelay: waits after opening before returning, for boards that restart when the port opens.writeTimeout: how long a write may take before it fails (default 2 seconds).
Receive data #
port.input.listen((Uint8List bytes) {
print('Received ${bytes.length} bytes');
});
- Data arrives in chunks as USB delivers it, not as whole messages. A single line can be split across two chunks, or two lines can arrive in one. Use LineReader for text, or your own framing for binary protocols.
- Bytes that arrive before you start listening are kept, not lost (for example a board's startup message).
- The stream can be listened to once. For several listeners, use
port.input.asBroadcastStream(). - If the device is unplugged, the stream reports a
Disconnectederror and then ends.
Read text line by line #
port.input.transform(const LineReader()).listen((String line) {
print('Line: $line');
});
LineReader joins chunks back into lines. It:
- understands
\n,\r\nand\rline endings; - decodes UTF-8 correctly even when a character is split across chunks;
- replaces invalid bytes with
�instead of failing; - limits line length (default 64 KB) so a device that never sends a newline
can't use up memory:
LineReader(maxLineLength: 1024).
Send data #
await port.write(utf8.encode('READ\r\n')); // text
await port.write([0x01, 0x03, 0x00, 0x00]); // raw bytes
await port.write(data, timeout: const Duration(seconds: 5));
- Writes are sent in the order you call them, even without
await. - Writing never freezes your app's UI; on Android the data goes to a background thread, and on macOS writes never block.
- If the device doesn't accept the data in time,
writethrowsOperationTimeout.
Control DTR and RTS (and avoid board resets) #
DTR and RTS are two control lines of a serial port. Many boards use them: ESP32 and Arduino boards restart when these lines change, which is how upload tools reset them.
// Set the lines while the port is open.
await port.setSignals(dtr: false, rts: false);
// Or set them as part of opening, before any data flows:
final port = await UsbSerialPort.open(
device,
config: const SerialConfig(baudRate: 115200, dtr: false, rts: false),
);
If your board restarts every time you connect, open with dtr: false and
rts: false. Leave them out (null) to keep the adapter's defaults.
Know when a device is plugged in or out #
UsbSerialPort.events.listen((event) {
switch (event) {
case DeviceAttached(:final device):
print('Plugged in: ${device.name}');
case DeviceDetached(:final device):
print('Unplugged: ${device.name}');
}
});
For an open port, watch port.done: it completes when the port closes,
whether your app closed it or the device was unplugged. port.isOpen tells
you the current state.
Close the port #
await port.close();
This releases the device for other apps. Calling it more than once is safe.
Close the port in your widget's dispose().
Errors and what to do #
Every error is a HardwareException (from
skio_core), so one try/catch
covers them all:
try {
final port = await UsbSerialPort.open(device, config: config);
} on HardwareException catch (e) {
showMessage(e.message); // a short, readable message
}
| Error | What it means | What to do |
|---|---|---|
AccessDenied |
No permission for the device (Android), the app lacks the serial entitlement (macOS), or the port popup wasn't opened from a user action (web). | Call requestAccess(device); on macOS add the entitlement; on the web call request() from a button press. |
DeviceBusy |
Another app, browser tab or serial monitor is using the port, or this app already has it open. | Close the other program (Arduino IDE, serial monitor, other tab), then open again. |
DeviceNotFound |
The device was unplugged. | Plug it in and list the ports again. |
Disconnected |
The device went away while in use. | Check the cable, then open again. |
OperationTimeout |
The device didn't accept data in time. | Check the baud rate and flow control settings. |
Unsupported |
Not available on this platform, or a setting the chip or browser can't do. | 8N1 without flow control works with every adapter. |
ProtocolError |
Something unexpected happened. | Turn on debug logging and report it. |
Debug logging #
Logging is off by default. Turn it on to see exactly what happens, including every byte sent and received:
SkioLog.level = LogLevel.trace; // info, debug or trace (every byte in hex)
SkioLog.records.listen(print);
Example output:
2026-09-24T10:36:38.195 INFO skio_usb_serial [/dev/bus/usb/001/003]: USB permission granted
2026-09-24T10:36:38.326 INFO skio_usb_serial [/dev/bus/usb/001/003]: Opened with SerialConfig(115200 8N1, flow: none)
2026-09-24T10:36:38.358 TRACE skio_usb_serial [/dev/bus/usb/001/003]: RX 8: 7b 73 29 82 4a 42 23 ff
Please include this output in bug reports. Nothing is ever sent anywhere by the package.
Troubleshooting #
| Problem | Likely cause and fix |
|---|---|
| Garbled characters | Wrong baud rate. Try the device's documented rate; ESP32/ESP8266 boot messages use 74880. |
| The board restarts when you connect | DTR/RTS reset the board. Open with SerialConfig(..., dtr: false, rts: false). |
| Lines arrive cut in half | USB delivers chunks, not messages. Use LineReader or your own framing. |
| No ports on Android | The phone needs USB host (OTG) support, and some phones need OTG turned on in settings. Use a powered hub for boards that draw more current. |
DeviceBusy on the web |
The port is open in another tab or program. Close it there. |
AccessDenied on macOS |
The app is sandboxed without com.apple.security.device.serial. Add it to both entitlements files and rebuild. |
| No ports on macOS | Check the adapter appears with ls /dev/cu.*. If it doesn't, the chip needs a driver from its maker. |
| The web popup doesn't open | request() wasn't called from a button press, the page isn't https/localhost, or the browser isn't Chrome/Edge. |
| Nothing received | Check the device is actually sending, the baud rate, and that TX/RX wires aren't swapped. |
Tested hardware #
Boards
| Board | Connection | Result |
|---|---|---|
| ESP32 | Through its CH340 USB-serial chip (1a86:7523) | Listing, permission, open, receive and send work |
| STM32 microcontroller boards | USB serial | Open, receive and send work |
| Nordic (nRF) boards | USB serial | Open, receive and send work |
Phones, computers and browsers
| Device | Platform | Result |
|---|---|---|
| vivo | Android, USB OTG | Works |
| OPPO | Android, USB OTG | Works |
| Samsung | Android, USB OTG | Works |
| POCO M7 5G | Android 16, USB OTG | Works |
| Mac (with an ESP32 through its CH340 chip) | macOS app | Works |
| Chrome on macOS | Web Serial | Works |
Tried another adapter or phone? Please open an issue with the result and your debug log.
Limitations #
- Android, macOS and web only. For Windows, Linux and the UARTs built into
Android panels, use
skio_uart. - On Android and macOS, plug and unplug events are checked once a second.
- macOS has no mark or space parity and no 1.5 stop bits; these throw
Unsupported. - Web Serial doesn't support 5 or 6 data bits, mark/space parity, 1.5 stop
bits, or DTR/DSR and XON/XOFF flow control; these throw
Unsupported.
Example app #
The example folder is a complete serial terminal: pick a port,
choose settings, see data as text or hex, send text or hex, switch DTR/RTS,
and read the package's log. See its README for what
each button does.
cd example
flutter run # on an Android phone with a USB serial device attached
flutter run -d macos # as a Mac app
flutter run -d chrome # in the browser
Testing your app without hardware #
Replace the platform with a fake in your tests:
import 'package:skio_usb_serial/platform_interface.dart';
setUp(() => UsbSerialPlatform.instance = MyFakeSerialPlatform());
FAQ #
How do I connect an ESP32 (or Arduino) to a Flutter app over USB?
Plug the board into the phone with a USB OTG cable, then use
UsbSerialPort.list(), requestAccess() and open() as in the
quick start, with the baud rate your firmware uses (often
115200). If the board restarts when you connect, open with
SerialConfig(baudRate: 115200, dtr: false, rts: false).
Can I use USB serial in a Flutter web app?
Yes, in desktop Chrome and Edge. Call UsbSerialPort.request() from a
button press so the user can pick the port; everything else is the same code
as on Android.
Does it work on iOS? No. Apple doesn't let iPhone or iPad apps talk to USB serial adapters (only to Apple-certified accessories).
Does it work on macOS?
Yes, from 0.2.0, with the same code as on Android. Sandboxed apps need the
com.apple.security.device.serial entitlement (see Install).
Does it work on Windows or Linux?
Use skio_uart, which opens any
serial port by path on Windows, Linux, macOS and Android (including the
UARTs built into Android panels), with the same SerialConfig and errors.
How do I read text line by line?
port.input.transform(const LineReader()) gives a stream of lines, even when
lines arrive split across USB transfers. See
Read text line by line.
Why do I get garbled characters? The baud rate doesn't match the device. See Troubleshooting.
Do I need any Android permissions?
No runtime permission. Android shows a USB dialog per device, which
requestAccess(device) handles.
Does it collect data or use the network? No. The package has no network access and no telemetry.
Compared with other packages #
A fair summary to help you choose (versions as of September 2026):
| Package | Android | Web | macOS | Windows, Linux | Latest release |
|---|---|---|---|---|---|
| skio_usb_serial | Yes (USB OTG) | Yes (Web Serial) | Yes | Use skio_uart | 2026 |
| skio_uart | Built-in UARTs | No | Yes | Yes | 2026 |
| usb_serial | Yes | No | No | No | 0.5.2, July 2024 |
| flutter_libserialport | Yes | No | Yes | Yes | 0.6.0, August 2025 |
| serial_port_win32 | No | No | No | Windows only | 3.0.0, August 2026 |
Choose skio_usb_serial for Android, macOS and the web with one API, typed
errors, line reading and in-app logging. For Windows, Linux and Android
panels' built-in ports, use skio_uart.
Migrating from usb_serial #
The concepts map one to one:
| usb_serial | skio_usb_serial |
|---|---|
UsbSerial.listDevices() |
UsbSerialPort.list() |
device.create() + port.open() |
UsbSerialPort.open(device, config: ...) |
port.setPortParameters(115200, UsbPort.DATABITS_8, UsbPort.STOPBITS_1, UsbPort.PARITY_NONE) |
SerialConfig(baudRate: 115200) (8N1 is the default) |
port.setDTR(true) / port.setRTS(true) |
port.setSignals(dtr: true, rts: true), or dtr/rts in SerialConfig |
port.inputStream |
port.input |
Transaction.stringTerminated(...) |
port.input.transform(const LineReader()) |
port.write(Uint8List) |
port.write(bytes) |
port.close() |
port.close() |
UsbSerial.usbEventStream |
UsbSerialPort.events |
Before:
final devices = await UsbSerial.listDevices();
final port = await devices.first.create();
await port?.open();
await port?.setPortParameters(
115200, UsbPort.DATABITS_8, UsbPort.STOPBITS_1, UsbPort.PARITY_NONE);
port?.inputStream?.listen(print);
After:
final devices = await UsbSerialPort.list();
await UsbSerialPort.access.requestAccess(devices.first);
final port = await UsbSerialPort.open(
devices.first,
config: const SerialConfig(baudRate: 115200),
);
port.input.listen(print);
Then remove the usb_serial dependency. skio_usb_serial needs no extra
Android setup.
How it works #
On Android the package calls the phone's USB system and the
usb-serial-for-android
library (MIT) directly from Dart through JNI, with no platform channels and
no Java code of its own. On macOS it finds ports through IOKit and talks to
them with POSIX termios, both through dart:ffi, reading on a background
isolate so the UI never waits; there is no native code to compile. On the
web it uses the browser's Web Serial API.
All queueing, line reading and error handling is Dart code, covered by unit
tests.
The skio family #
| Package | What it does |
|---|---|
skio_core |
Shared types used by all skio packages: permissions, errors, device filters, serial settings, logging |
skio_usb_serial |
USB serial ports (this package) |
skio_uart |
Serial ports by path: built-in UARTs, COM ports, adapters on Windows and Linux |
skio_uvc_camera |
USB cameras: preview, photos, snapshot button |
License #
BSD 3-Clause. The Android part uses usb-serial-for-android (MIT).