Arduino や計測器、レシート印字機などは今でも RS-232C / UART(多くは USB シリアル変換経由)で「コマンドを送り応答を 1 行返す」形で動きます。このレシピでは Rust の serialport クレート (4.x) を #[tauri::command] で包み、開く → 送信 → 受信 → 閉じる、の 1 往復を invoke で実行します。接続するポートを一覧から選ばせる方法も 2 章で扱います。接続を保ったまま連続して送受信する方法は シリアルポートの接続維持と送受信 (State管理) を参照してください。
コミュニティプラグイン tauri-plugin-serialplugin(npm run tauri add serialplugin、権限 serialplugin:default)なら JS だけで完結しますが、プロトコル処理を Rust に閉じ込められる点と依存の少なさから、ここでは自前コマンド方式を主軸にします。
前提条件
src-tauri で serialport を追加します。自作コマンドは ACL の対象外なので capabilities の権限追加は不要です。
cd src-tauri
cargo add serialport
cargo add serde --features derive
Linux では serialport の既定機能 libudev が libudev-dev を要求するため、sudo apt install libudev-dev pkg-config を先に実行します。
1. フロントエンドから実装する (TypeScript)
接続設定と送信文字列を渡し、応答文字列を受け取ります。Rust 側のスネークケース引数はキャメルケースで渡します。
import { invoke } from '@tauri-apps/api/core';
interface SerialConfig {
portName: string; // Windows: "COM3" / macOS: "/dev/cu.usbserial-1420" / Linux: "/dev/ttyUSB0"
baudRate: number; // 機器側と一致させる (9600, 115200 など)
timeoutMs: number; // 応答待ちの上限
}
export function query(config: SerialConfig, command: string): Promise<string> {
// 多くの機器は CR+LF 終端でコマンドを解釈する
return invoke<string>('serial_transceive', { config, message: command + '\r\n' });
}
try {
const reply = await query({ portName: 'COM3', baudRate: 9600, timeoutMs: 1000 }, '*IDN?');
console.log('reply:', reply);
} catch (e) {
console.error('serial error:', e);
}
2. バックエンドから実装する (Rust)
serialport::new(名前, ボーレート) のビルダーにデータビット・パリティ・ストップビット・フロー制御・タイムアウトを指定して open() します。既定は 8N1 ですが、仕様書と突き合わせるため明示しておきます。読み書きは std::io::Read / Write をそのまま使います。read() はタイムアウトまでブロックするので、async fn + spawn_blocking で UI スレッドから逃がします(重い処理を別スレッド・非同期で実行する)。
// src-tauri/src/lib.rs
use serialport::{DataBits, FlowControl, Parity, StopBits};
use std::io::{Read, Write};
use std::time::Duration;
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct SerialConfig {
port_name: String,
baud_rate: u32,
timeout_ms: u64,
}
fn transceive_blocking(config: SerialConfig, message: String) -> Result<String, String> {
let mut port = serialport::new(&config.port_name, config.baud_rate)
.data_bits(DataBits::Eight)
.parity(Parity::None)
.stop_bits(StopBits::One)
.flow_control(FlowControl::None)
.timeout(Duration::from_millis(config.timeout_ms))
.open()
.map_err(|e| format!("{} を開けません: {}", config.port_name, e))?;
port.write_all(message.as_bytes()).map_err(|e| format!("送信失敗: {}", e))?;
port.flush().map_err(|e| e.to_string())?;
// LF が来るかタイムアウトするまで読む
let mut buf = [0u8; 256];
let mut received: Vec<u8> = Vec::new();
loop {
match port.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
received.extend_from_slice(&buf[..n]);
if received.ends_with(b"\n") { break; }
}
Err(e) if e.kind() == std::io::ErrorKind::TimedOut => break,
Err(e) => return Err(format!("受信失敗: {}", e)),
}
}
// port はここで drop され、OS のハンドルも閉じる
Ok(String::from_utf8_lossy(&received).trim_end().to_string())
}
#[tauri::command]
async fn serial_transceive(config: SerialConfig, message: String) -> Result<String, String> {
tauri::async_runtime::spawn_blocking(move || transceive_blocking(config, message))
.await
.map_err(|e| e.to_string())?
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![serial_transceive, list_ports]) // list_ports は次の節
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
バイナリプロトコルなら message: Vec<u8> にして Vec<u8> を返し、JS 側は number[] / Uint8Array で扱います。
接続するポートを一覧から選ばせる
ポート名を手入力させると、COM 番号の取り違えや長いデバイス名の打ち間違いが起きます。serialport::available_ports() で今つながっているポートを列挙し、プルダウンで選ばせるのが定番です。USB 接続のポートには VID / PID(メーカーと製品の番号)と製品名が付くので、表示に添えたり、特定の機器を自動で選んだりできます。製品名は OS とドライバーによっては取れません。
use serialport::SerialPortType;
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct PortInfo {
port_name: String,
kind: &'static str, // "USB" / "PCI" / "Bluetooth" / "Unknown"
vid: Option<u16>,
pid: Option<u16>,
product: Option<String>,
}
#[tauri::command]
fn list_ports() -> Result<Vec<PortInfo>, String> {
let ports = serialport::available_ports().map_err(|e| e.to_string())?;
Ok(ports
.into_iter()
// macOS は 1 台につき /dev/tty.* と /dev/cu.* の両方が返るので cu 側だけ残す
.filter(|p| !p.port_name.starts_with("/dev/tty."))
.map(|p| {
let (kind, vid, pid, product) = match p.port_type {
SerialPortType::UsbPort(u) => ("USB", Some(u.vid), Some(u.pid), u.product),
SerialPortType::PciPort => ("PCI", None, None, None),
SerialPortType::BluetoothPort => ("Bluetooth", None, None, None),
SerialPortType::Unknown => ("Unknown", None, None, None),
};
PortInfo { port_name: p.port_name, kind, vid, pid, product }
})
.collect())
}
抜き差しは通知されないので、画面を開いたときと「再検索」ボタンで取り直します。VID の例は Arduino が 2341、FTDI が 0403、CH340 が 1a86、CP210x が 10c4 です。Linux で既定の libudev 機能を外しても、VID / PID は取れます。
import { invoke } from '@tauri-apps/api/core';
type PortInfo = {
portName: string;
kind: 'USB' | 'PCI' | 'Bluetooth' | 'Unknown';
vid: number | null;
pid: number | null;
product: string | null;
};
const hex4 = (n: number | null) => (n === null ? '----' : n.toString(16).padStart(4, '0'));
export async function refreshPorts(select: HTMLSelectElement) {
const previous = select.value;
const ports = await invoke<PortInfo[]>('list_ports');
select.replaceChildren(
...ports.map((p) => {
const label = p.kind === 'USB'
? `${p.portName} - ${p.product ?? 'USB'} (${hex4(p.vid)}:${hex4(p.pid)})`
: `${p.portName} - ${p.kind}`;
return new Option(label, p.portName);
}),
);
// 選んでいたポートが残っていれば維持し、なければ Arduino(VID 2341)を選ぶ
const keep = ports.find((p) => p.portName === previous) ?? ports.find((p) => p.vid === 0x2341);
if (keep) select.value = keep.portName;
}
const select = document.querySelector<HTMLSelectElement>('#port');
if (select) {
void refreshPorts(select);
document.querySelector('#rescan')?.addEventListener('click', () => void refreshPorts(select));
}
動作確認
npm run tauri dev を起動し、USB シリアル変換器の TX と RX を短絡(ループバック)して query(...) を呼ぶと、送った文字列がそのまま返ります。
reply: *IDN?
存在しないポート名では serialport のエラー文言を含む文字列で reject されます。Windows で Arduino IDE のシリアルモニタなど他アプリがポートを掴んでいると「アクセスが拒否されました」という趣旨のエラーになるので先に閉じてください。
よくあるエラーと対処法
No such file or directory/The system cannot find the file specifiedという趣旨で開けない: ポート名の誤りです。Windows はデバイスマネージャー、macOS はls /dev/cu.*、Linux はls /dev/ttyUSB* /dev/ttyACM*で確認するか、2 章のlist_portsで一覧から選ばせます。- Linux で
Permission denied:/dev/ttyUSB0はdialoutグループ(ディストリビューションによってはuucp)所有です。sudo usermod -aG dialout $USERの後、再ログインしないと反映されません。 libudevが見つからずビルド失敗 (Linux):libudev-devとpkg-configを入れるか、serialport = { version = "4", default-features = false }で udev 列挙を外します。readが常にタイムアウトして空文字: ボーレート不一致か終端文字の不一致が大半です。機器が CR のみを期待している、応答に LF が付かずends_with(b"\n")で抜けていない、などを疑ってください。
OS ごとの違いと注意点
- Windows: ポート名は
COM3形式。COM10以上は Win32 的に\\.\COM10と書く必要がありますが、serialportクレートが内部で付与するのでCOM10のままで構いません。CH340 / CP210x / FTDI のドライバーがないとポート自体が現れません。 - macOS: 同じ機器に
/dev/tty.usbserial-XXXXと/dev/cu.usbserial-XXXXが現れ、available_ports()も両方を返します。アプリから能動的に接続するならcu.側を使うのが慣例で、tty.側は DCD 待ちでオープンがブロックすることがあります。 - Linux: USB シリアルは
/dev/ttyUSB0、Arduino など CDC-ACM は/dev/ttyACM0。抜き差しで番号が変わるので、固定したいなら/dev/serial/by-id/のリンクを使います。 - 1 回ごとに開閉する方式は、DTR 変化でリセットがかかる Arduino では「開くたびに再起動して最初の応答が遅れる」ことがあります。連続通信は hw-003 の State 管理に切り替えてください。
