Guides

Rust

Using the plugin from your Tauri backend.

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 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):

GroupMethods
Discoveryavailable_ports(single_port_per_device), managed_ports(), watch_ports(options, channel), unwatch_ports(id)
Lifecycleopen(path, baud, data_bits, flow, parity, stop_bits, timeout_ms), open_with_lines(…, ModemLinesOnOpen { dtr, rts }), close(path), close_all(), force_close(path)
I/Owrite(path, text), write_binary(path, bytes), read(path, timeout, size), read_binary(…), cancel_read(path), watch(path, options, channel), unwatch(id)
AT / exchangeexchange(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)
CMUXenable_mux(path, command, timeout_ms), open_mux_channel(path, dlci), disable_mux(path)
Settings / linesset_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_coreSame 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
bleSerial over Bluetooth LE (ble://), desktop, iOS and Android. Needs libdbus-1-dev on Linux. See platforms.
android-test-harnessFake 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);
}
FieldLinuxmacOSWindowsAndroid
vid, pid (u16), serial_number, manufacturer, productyesyesyes (full serial)yes
interface (USB interface number) ¹yesyes (may be the data interface)yesno
location (bus-port.port…) ¹sysfs busnum/devpathIOKit locationIDLocationPathsno
by_id, by_path/dev/serial/by-id, by-path linksemptyemptyempty

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.