シリアルポート通信 (RS-232C) を行う

serialport クレートを Tauri コマンドで包み、ポート一覧から選んだ COM ポートや /dev/tty* をボーレート指定で開いて 1 回送受信する。Linux の dialout 権限など OS ごとの注意も扱う。

ハードウェア連携 対象: Tauri 2.x 更新日: 読了目安: 約9分 hw-001
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 2. バックエンドから実装する (Rust)
  4. 接続するポートを一覧から選ばせる
  5. 動作確認
  6. よくあるエラーと対処法
  7. OS ごとの違いと注意点
  8. 関連レシピ

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 管理に切り替えてください。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する