シリアルポートの接続維持と送受信 (State管理)

開いたシリアルポートを State に持って開きっぱなしにし、受信は専用スレッドで読んで 1 行ごとにイベントで送る。USB を抜いたときの切断検知と自動再接続、1 回ずつ開く方式との使い分けも示す。

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

Arduino が 1 秒ごとに送ってくる計測値を表示し続ける、機器からの通知をいつでも受け取る、ボタンを押すたびに短いコマンドを送る。こうした使い方では、送受信のたびにポートを開き直す シリアルポート通信 (RS-232C) を行う の方式は向きません。機器によっては開くたびに再起動し、閉じている間に機器が送ったデータは受け取れません。このレシピでは、開いたポートを Tauri の State に持って開きっぱなしにし、受信は専用のスレッドで読み続けて 1 行ごとにイベントで画面へ送ります。USB ケーブルが抜けたときの検知と、挿し直したときの自動再接続も組み込みます。

前提条件

serialport クレートの追加と、Linux での準備(libudev-dev、dialout グループ)は hw-001 と同じです。自作のコマンドなので capability の権限追加は不要で、イベントを受け取る listen の権限は core:default に含まれます。

cd src-tauri
cargo add serialport
cargo add serde --features derive

2 つの方式は次のように使い分けます。

比べる点1 回ずつ開く(hw-001)開きっぱなし(この記事)
向いている使い方ときどき問い合わせる機器から勝手に届く、頻繁に送る
機器が自分から送ったデータ閉じている間は失われる受信スレッドが受け取る
開いたときに再起動する機器毎回再起動する最初の 1 回だけ
他のアプリとの共有使っていない間は他から開ける閉じるまで独占する
実装の量少ないState・スレッド・切断処理が要る

1. バックエンドから実装する (Rust)

ポートは try_clone() で 2 つのハンドルに分け、書き込み用を State に、読み取り用を受信スレッドに渡します。read() はタイムアウトまで待つブロッキング処理で、アプリの間ずっと回り続けるので、非同期タスクではなく std::thread で専用のスレッドを立てます(重い処理を別スレッド・非同期で実行する)。State の Mutex はロックしたまま .await しないので、標準ライブラリのもので足ります(State と Mutex でアプリの状態を管理する)。

切断は受信スレッドが見つけます。TimedOut は「何も届かなかった」だけなので読み続け、それ以外のエラーと 0 バイトの読み取りは、抜かれた・電源が切れたとみなします。そのときは両方のハンドルをすぐに閉じ、reconnecting を知らせてから 1 秒おきに開き直します。すぐ閉じるのは、Linux では古いハンドルを開いたまま挿し直すと、同じ機器が /dev/ttyUSB1 のような別の名前で現れることがあるためです。

use std::io::{Read, Write};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::Duration;
use serde::Serialize;
use serialport::SerialPort;
use tauri::{AppHandle, Emitter, Manager, State};

/// 開いている接続
struct Session {
    id: u64, // 接続ごとの番号
    port_name: String,
    writer: Option<Box<dyn SerialPort>>, // 書き込み用。再接続を待つ間は None
    stop: Arc<AtomicBool>,               // 受信スレッドを止める旗
    reader: Option<JoinHandle<()>>,
}

#[derive(Default)]
struct SerialState {
    session: Mutex<Option<Session>>,
    next_id: AtomicU64,
}

#[derive(Clone, Serialize)]
struct Status {
    state: &'static str, // "connected" / "reconnecting" / "closed"
    port: String,
    detail: Option<String>,
}

fn open_port(port_name: &str, baud_rate: u32) -> serialport::Result<Box<dyn SerialPort>> {
    serialport::new(port_name, baud_rate)
        .timeout(Duration::from_millis(100)) // 受信スレッドが旗を確かめる間隔にもなる
        .open()
}

fn emit_status(app: &AppHandle, state: &'static str, port: &str, detail: Option<String>) {
    let _ = app.emit("serial-status", Status { state, port: port.to_string(), detail });
}

/// 自分の接続がまだ State にあるときだけ、書き込み用のハンドルを差し替える
fn set_writer(app: &AppHandle, id: u64, writer: Option<Box<dyn SerialPort>>) -> bool {
    let state = app.state::<SerialState>();
    let Ok(mut guard) = state.session.lock() else { return false };
    match guard.as_mut() {
        Some(s) if s.id == id => {
            s.writer = writer;
            true
        }
        _ => false, // すでに閉じられた。writer はここで破棄され、ハンドルも閉じる
    }
}

/// 受信スレッド: 1 行ごとにイベントで送り、抜かれたら開き直す
fn reader_loop(
    app: AppHandle,
    id: u64,
    port_name: String,
    baud_rate: u32,
    stop: Arc<AtomicBool>,
    first: Box<dyn SerialPort>,
) {
    let mut port = Some(first);
    let mut buf = [0u8; 1024];
    let mut line = Vec::new();
    while !stop.load(Ordering::Relaxed) {
        let Some(reader) = port.as_mut() else {
            // 切断中: 1 秒おきに開き直す
            std::thread::sleep(Duration::from_secs(1));
            if stop.load(Ordering::Relaxed) {
                break;
            }
            if let Ok(p) = open_port(&port_name, baud_rate) {
                if let Ok(writer) = p.try_clone() {
                    if set_writer(&app, id, Some(writer)) {
                        port = Some(p);
                        emit_status(&app, "connected", &port_name, None);
                    }
                }
            }
            continue;
        };
        match reader.read(&mut buf) {
            Ok(n) if n > 0 => {
                for &b in &buf[..n] {
                    if b == b'\n' {
                        let text = String::from_utf8_lossy(&line).trim_end_matches('\r').to_string();
                        let _ = app.emit("serial-line", text);
                        line.clear();
                    } else {
                        line.push(b);
                    }
                }
            }
            Err(e) if e.kind() == std::io::ErrorKind::TimedOut => {} // 何も届かなかっただけ
            other => {
                // 0 バイトやそのほかのエラーは、抜かれた・電源が切れたとみなす
                let detail = other.err().map(|e| e.to_string());
                port = None; // 読み取り用のハンドルを閉じる
                set_writer(&app, id, None); // 書き込み用も閉じる
                line.clear();
                emit_status(&app, "reconnecting", &port_name, detail);
            }
        }
    }
}

#[tauri::command]
async fn serial_open(
    app: AppHandle,
    state: State<'_, SerialState>,
    port_name: String,
    baud_rate: u32,
) -> Result<(), String> {
    let mut guard = state.session.lock().map_err(|e| e.to_string())?;
    if let Some(s) = guard.as_ref() {
        return Err(format!("{} は開いています。先に serial_close を呼んでください", s.port_name));
    }
    let writer = open_port(&port_name, baud_rate).map_err(|e| format!("{port_name} を開けません: {e}"))?;
    let reader = writer.try_clone().map_err(|e| e.to_string())?;
    let id = state.next_id.fetch_add(1, Ordering::Relaxed);
    let stop = Arc::new(AtomicBool::new(false));
    let handle = {
        let (app, name, stop) = (app.clone(), port_name.clone(), stop.clone());
        std::thread::Builder::new()
            .name("serial-reader".into())
            .spawn(move || reader_loop(app, id, name, baud_rate, stop, reader))
            .map_err(|e| e.to_string())?
    };
    *guard = Some(Session { id, port_name: port_name.clone(), writer: Some(writer), stop, reader: Some(handle) });
    drop(guard); // イベントを送る前にロックを外す
    emit_status(&app, "connected", &port_name, None);
    Ok(())
}

#[tauri::command]
async fn serial_write(state: State<'_, SerialState>, text: String) -> Result<(), String> {
    let mut guard = state.session.lock().map_err(|e| e.to_string())?;
    let writer = guard
        .as_mut()
        .and_then(|s| s.writer.as_mut())
        .ok_or("接続していません(再接続を待っている可能性があります)")?;
    writer.write_all(text.as_bytes()).map_err(|e| e.to_string())?;
    writer.flush().map_err(|e| e.to_string())
}

#[tauri::command]
async fn serial_close(app: AppHandle, state: State<'_, SerialState>) -> Result<(), String> {
    let taken = state.session.lock().map_err(|e| e.to_string())?.take();
    let Some(mut session) = taken else { return Ok(()) };
    session.stop.store(true, Ordering::Relaxed);
    session.writer = None; // 書き込み用のハンドルを閉じる
    if let Some(reader) = session.reader.take() {
        // 受信スレッドが読み取り用のハンドルを閉じて終わるまで待つ(最大 1 秒ほど)
        let _ = tauri::async_runtime::spawn_blocking(move || reader.join()).await;
    }
    emit_status(&app, "closed", &session.port_name, None);
    Ok(())
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .manage(SerialState::default())
        .invoke_handler(tauri::generate_handler![serial_open, serial_write, serial_close])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

ポイントは 3 つです。

  • 閉じるときは受信スレッドの終了を待つ: ポートは開くと独占され、ほかからは開けません。serial_close の直後に開き直すと、受信スレッドがまだ持っている読み取り用ハンドルのせいで使用中として失敗するので、join() で終わるまで待ちます。
  • 接続ごとの番号で確かめる: 受信スレッドは閉じられた後に State を触ることがあるので、id が一致するときだけ書き込み用ハンドルを差し替えます。
  • コマンドは async にする: async でないコマンドはメインスレッドで動きます。ポートを開く処理や join() で画面が固まらないよう、どれも async にしています。

2. フロントエンドから呼び出す (TypeScript)

受信と状態の通知は、開く前に listen で登録します。ページを再読み込みしても Rust 側の接続は残るので、開く前に serial_close を呼んでおきます。

import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';

type Status = {
  state: 'connected' | 'reconnecting' | 'closed';
  port: string;
  detail: string | null;
};

const log = document.querySelector<HTMLPreElement>('#log');

await listen<string>('serial-line', ({ payload }) => {
  log?.append(payload + '\n');
});
await listen<Status>('serial-status', ({ payload }) => {
  console.log(`[serial] ${payload.state} ${payload.port}`, payload.detail ?? '');
});

export async function connect(portName: string, baudRate: number) {
  await invoke('serial_close'); // 再読み込み前の接続が残っていれば閉じる
  await invoke('serial_open', { portName, baudRate });
}

export const send = (text: string) => invoke('serial_write', { text: text + '\n' });
export const disconnect = () => invoke('serial_close');

await connect('COM3', 115200);

ポート名は hw-001 の list_ports で一覧から選ばせると、打ち間違いを防げます。

動作確認

npm run tauri dev で起動し、USB シリアル変換器の TX と RX を短絡(ループバック)して send('hello') を呼ぶと、#log に hello が表示されます。1 秒ごとに Serial.println() で値を送るスケッチを書き込んだ Arduino なら、何もしなくても値が 1 行ずつ増えていきます。

続けて USB ケーブルを抜き、挿し直すと、コンソールに次のように出て受信が再開します。reconnecting の後ろには抜いたときのエラー文が付き、文言は OS とドライバーによって異なります。

[serial] connected COM3
[serial] reconnecting COM3
[serial] connected COM3

よくあるエラーと対処法

  • 「COM3 は開いています。先に serial_close を呼んでください」: 開いたまま serial_open を呼んでいます。開発中の再読み込みで起きやすいので、例の connect() のように先に閉じます。
  • 「COM3 を開けません」の後ろにアクセス拒否や使用中の趣旨の文言: Arduino IDE のシリアルモニタや、同じアプリの別の起動(開発版とインストール版など)がポートを掴んでいます。そちらを閉じます。
  • 「接続していません(再接続を待っている可能性があります)」: 抜かれて再接続を待っている間に送っています。connected の通知を待ってから送り直します。
  • 「state not managed for field state on command serial_open. You must call .manage() before using this command」: .manage(SerialState::default()) を忘れています。
  • 行が届かない・文字化けする: ボーレートが機器と違うか、機器が改行(LF)を送っていません。CR だけで区切る機器なら、区切り文字を b'\r' に変えます。

OS ごとの違いと注意点

  • Linux: 挿し直しで番号が変わると、同じ名前で開き直せません。/dev/serial/by-id/ の下のリンクをポート名に使えば、番号が変わっても同じ名前で開けます。
  • Windows: シリアル番号を持たない変換器は、挿す USB 端子を変えると別の COM 番号になることがあります。その場合は再接続できないので、hw-001 の一覧から VID / PID で探し直します。
  • macOS: ポートは /dev/cu.* 側を使います(理由は hw-001)。
  • 開いた直後の再起動: Arduino Uno など、開いたときに再起動するボードでは、起動が終わるまで送ったデータが無視されます。スケッチの setup() で READY などを 1 行送らせ、それを受け取ってから送り始めると確実です。自動再接続のたびにも再起動します。
  • エラーにならない切断: 抜いても読み取りがエラーにならず、タイムアウトが続くだけのドライバーもあります。そうした機器では、数秒おきに serialport::available_ports() でポートが残っているかも確かめます。
  • 受信が多いとき: 1 行ごとのイベントはすべてのウィンドウに送られます。1 秒に何百行も届く機器では、数十ミリ秒分をまとめて Vec<String> で送るか、emit_to() で送り先を絞ります(受け取り側は Rust からのイベントを受信する (listen))。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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