Rust
The JavaScript API is a thin layer over Rust; the same operations are available in your Tauri backend. Full item docs: docs.rs.
Setup
// src-tauri/src/lib.rs
tauri::Builder::default()
.plugin(tauri_plugin_serialplugin::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");
The SerialPort state (recommended)
The plugin manages a tauri_plugin_serialplugin::api::SerialPort<R>. Its methods
are synchronous and may block (open, read with timeout), so call them from a
blocking context, for example an async command plus spawn_blocking:
use tauri::State;
use tauri_plugin_serialplugin::api::SerialPort;
#[tauri::command]
async fn modem_signal(serial: State<'_, SerialPort<tauri::Wry>>) -> Result<String, String> {
let serial = serial.inner().clone();
tauri::async_runtime::spawn_blocking(move || {
let path = "/dev/ttyUSB0".to_string();
serial.open(path.clone(), 115_200, None, None, None, None, Some(1000))?;
let reply = serial.at(path.clone(), "AT+CSQ".into(), None)?;
serial.close(path)?;
Ok::<_, tauri_plugin_serialplugin::error::Error>(reply.lines.join("\n"))
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())
}
Main methods (same names as the JS API in snake_case):
| Group | Methods |
|---|---|
| Discovery | available_ports(single_port_per_device), managed_ports(), watch_ports(options, channel), unwatch_ports(id) |
| Lifecycle | open(path, baud, data_bits, flow, parity, stop_bits, timeout_ms), open_with_lines(…, ModemLinesOnOpen { dtr, rts }), close(path), close_all(), force_close(path) |
| I/O | write(path, text), write_binary(path, bytes), read(path, timeout, size), read_binary(…), cancel_read(path), watch(path, options, channel), unwatch(id) |
| AT / exchange | exchange(path, text, options), exchange_binary(…), at(path, command, options), at_phases(path, phases), send_sms_pdu(path, len, pdu, options), configure_at_session(path, session), cancel_exchange(path) |
| CMUX | enable_mux(path, command, timeout_ms), open_mux_channel(path, dlci), disable_mux(path) |
| Settings / lines | set_baud_rate, set_data_bits, set_flow_control, set_parity, set_stop_bits, set_timeout, write_request_to_send, write_data_terminal_ready, read_clear_to_send, read_data_set_ready, read_ring_indicator, read_carrier_detect, bytes_to_read, bytes_to_write, clear_buffer, set_break, clear_break |
Types live in tauri_plugin_serialplugin::state (DataBits, Parity,
StopBits, FlowControl, ClearBuffer, LogLevel, ModemLinesOnOpen) and
tauri_plugin_serialplugin::events (WatchOptions, ExchangeOptions,
SerialEvent, …). Errors are tauri_plugin_serialplugin::error::Error, which
serializes to its message string.
SerialPort<R> derefs to serialplugin_core::SerialManager (see below), so every method
above is the core's; only watch / watch_ports take a Tauri Channel here.
serial.core() hands out the Tauri-free manager itself.
Using the core without Tauri
Everything except the Tauri glue lives in the
serialplugin-core
crate (also re-exported as tauri_plugin_serialplugin::serialplugin_core). It has no Tauri
dependency, so a headless service (a gateway driving many modems, a CLI) can use the
same hub, parsers, AT queue and CMUX directly:
[dependencies]
serialplugin-core = { git = "https://github.com/s00d/tauri-plugin-serialplugin" }
# features = ["ble"] for ble:// on desktop, iOS and Android
use serialplugin_core::events::{EventSink, SerialEvent, WatchOptions};
use serialplugin_core::SerialManager;
use std::sync::Arc;
let serial = SerialManager::new();
let path = serial.open("/dev/ttyUSB0".into(), 115_200, None, None, None, None, None)?;
// Runs on the port's RX thread (the last remainder on unwatch / close may come on the
// caller's thread), never under a manager lock: keep it short, e.g. forward into a tokio channel.
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let sink: EventSink = Arc::new(move |event| tx.send(event).is_ok());
let parser = serde_json::from_str(r#"{ "type": "readline", "delimiter": "\r\n" }"#)?;
let watch_id = serial.watch_framed(path.clone(), WatchOptions::default(), Some(parser), sink)?;
let reply = tokio::task::spawn_blocking({
let (serial, path) = (serial.clone(), path.clone());
move || serial.at(path, "AT+CSQ".into(), None)
})
.await??;
while let Some(event) = rx.recv().await {
match event {
SerialEvent::Data { data, .. } => println!("{}", String::from_utf8_lossy(&data)),
SerialEvent::Urc { line, .. } => println!("URC {line}"),
SerialEvent::Disconnect { .. } => break,
SerialEvent::Error { message, .. } => eprintln!("{message}"),
}
}
serial.unwatch(watch_id)?;
The method set is the table above. Differences from the plugin: watch(path, options, sink) and watch_ports(options, sink) take closures (EventSink, PortListSink;
return false once the receiver is gone) and return their own id for
unwatch / unwatch_ports. Methods block, so call them from spawn_blocking or a
plain thread. Example: cargo run -p serialplugin-core --example headless -- /dev/ttyUSB0.
Core API (4.2)
The methods above keep their 4.x signatures, errors and behaviour. New code can use
these core-only additions (reachable from the plugin's SerialPort<R> through Deref):
| Method | |
|---|---|
open_core, close_core, write_core, exchange_core, at_core | Same as open_with_lines / close / write_binary / exchange_binary / at, but return CoreError |
watch_core(path, options, parser, sink) -> SubscriptionId, unwatch_core(id) | Any number of streams per port, each with its own parser / routeUrc state |
subscribe_core(sink) -> SubscriptionId, unsubscribe_core(id) | Port states of every port: opened, disconnected (kind + reason), closed |
set_core_queue_capacity(bytes) | Queue limit of new core receivers (default 4 MiB) |
insert_port(path, Box<dyn SerialPort>) | Manage your own SerialPort (a fake device in tests) as if open had opened it |
CoreError has a kind() (NotFound, PermissionDenied, Busy, Disconnected,
Timeout, Cancelled, Other), the 4.x message as Display and the platform error as
source(); Error::from(core_error) gives the old error with the same text.
use serialplugin_core::events::{CoreEvent, CoreSink, PortStatus, SerialEvent, WatchOptions};
use serialplugin_core::ErrorKind;
use std::sync::Arc;
let states: CoreSink = Arc::new(|event| {
if let CoreEvent::PortState { path, state: PortStatus::Disconnected { kind, reason } } = event {
eprintln!("{path} lost ({kind:?}): {reason}");
}
true
});
serial.subscribe_core(states);
let path = "/dev/ttyUSB1".to_string();
match serial.open_core(path.clone(), 115_200, None, None, None, None, None, Default::default()) {
Err(e) if e.kind() == ErrorKind::Busy => { /* opened elsewhere: retry later */ }
other => { other?; }
}
let (line_tx, line_rx) = std::sync::mpsc::channel::<Vec<u8>>();
let lines: CoreSink = Arc::new(move |event| match event {
CoreEvent::Event(SerialEvent::Data { data, .. }) => line_tx.send(data).is_ok(),
CoreEvent::Overflow { dropped_from } => { eprintln!("lost data from byte {dropped_from}"); false }
_ => true,
});
let parser = serde_json::from_str(r#"{ "type": "readline" }"#)?;
let id = serial.watch_core(path.clone(), WatchOptions::default(), Some(parser), lines)?;
Busy means the port exists but is in use (another process holds it; on Windows
"Access is denied" in any system language, since a port that is listed but cannot be
opened counts as busy).
Delivery: watch_core and subscribe_core sinks run on their own delivery thread, so
a slow one never stalls RX, at() / exchange or close(), and it may call at() on
the same port. Its queue is bounded: when it is full the sink gets
CoreEvent::Overflow { dropped_from } (byte offset of its data stream) and is
unsubscribed; the port and the other subscribers carry on. A core sink returning false
is unsubscribed too (unwatch_core on it is then NotFound). The old watch (and the
plugin's Tauri Channel) keeps the 4.x timing: its sink runs on the RX side, loses
nothing, and gets nothing after unwatch() / close() has returned; a slow one holds
up RX, so keep it short (a Tauri Channel send is).
Lifecycle: on a real disconnect (EOF, I/O error, unplug) every stream gets Disconnect
once, subscribe_core gets PortState { Disconnected { kind: Disconnected, reason } }
and the core releases the port (both handles, its CMUX channels). Until close() the
path behaves as in 4.x: managed_ports() still lists it, a watch / watch_core
started now gets that Disconnect at once, and other calls fail with the disconnect
reason ("Serial port disconnected: …", kind Disconnected). close() then removes
it and succeeds once; open() on the path also starts afresh. An intentional
close() delivers what the hub still holds (batch, parser remainder) and sends no
Disconnect. "One watch per port" applies to the old watch only, per
SerialManager; a watch id is unique across managers (the 4.x global registry).
insert_port on Unix splits a port with a working try_clone() into a read and a
write handle: the clone must share the device (like a dup'ed fd), or return Err.
On Unix, local serial ports get a second (cloned) handle for writes and control calls:
writes never wait for the RX hub, and the port timeout (open(timeout) /
set_timeout) applies to them as set. TCP, BLE, Android and Windows keep one handle.
The log cargo feature sends the core's logs through the log crate instead of
stdout / stderr; test-util keeps mock_serial in every build.
Calling the Tauri commands directly
tauri_plugin_serialplugin::commands holds the functions behind the JS calls. The
port I/O ones are async (they run on Tauri's blocking pool) and must be awaited;
managed_ports, set_log_level, get_log_level and capabilities are
synchronous.
use tauri::{AppHandle, State};
use tauri_plugin_serialplugin::{api::SerialPort, commands};
#[tauri::command]
async fn open_default(
app: AppHandle<tauri::Wry>,
serial: State<'_, SerialPort<tauri::Wry>>,
) -> Result<String, String> {
commands::open(app, serial, "COM3".into(), 9600, None, None, None, None, None, None, None)
.await
.map_err(|e| e.to_string())
}
commands::open takes, after app and serial: path, baud_rate,
data_bits, flow_control, parity, stop_bits, timeout, dtr, rts
(all but the first two are Option).
Log level
use tauri_plugin_serialplugin::state::{set_log_level, LogLevel};
set_log_level(LogLevel::Error); // before or after init(); JS can change it later
Cargo features
| Feature | |
|---|---|
ble | Serial over Bluetooth LE (ble://), desktop, iOS and Android. Needs libdbus-1-dev on Linux. See platforms. |
android-test-harness | Fake USB transport for the JNI integration tests. Not for apps. |
Core crate (serialplugin-core) only: log (logs through the log crate), test-util
(mock_serial in every build), usb-port-details (USB interface number and location
in available_ports_detailed(); off by default and not enabled by the plugin, see
below).
Port details
available_ports() keeps the 4.x string map. For typed values — and to tell the ports
of one composite device (a modem's AT / NMEA / diag ports share vid, pid and serial)
apart — use available_ports_detailed() on the core SerialManager:
for p in serial.available_ports_detailed()? {
println!("{} {:04x?}:{:04x?} if={:?} at {:?}", p.path, p.vid, p.pid, p.interface, p.location);
}
| Field | Linux | macOS | Windows | Android |
|---|---|---|---|---|
vid, pid (u16), serial_number, manufacturer, product | yes | yes | yes (full serial) | yes |
interface (USB interface number) ¹ | yes | yes (may be the data interface) | yes | no |
location (bus-port.port…) ¹ | sysfs busnum/devpath | IOKit locationID | LocationPaths | no |
by_id, by_path | /dev/serial/by-id, by-path links | empty | empty | empty |
Unknown values are None. Both macOS nodes (cu. and tty.) are listed. iOS returns
an empty list.
¹ Only with the core feature usb-port-details (add
serialplugin-core = { version = "0.2", features = ["usb-port-details"] }), else
None. It turns on serialport's usbportinfo-interface / usbportinfo-location,
which add fields to serialport::UsbPortInfo for every crate in your build, and on
Linux without libudev (musl) make serialport skip a USB port whose sysfs location
cannot be read. With it the port-list monitor also reports a changed USB location on
one path as a re-plug.