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

Recipe ID: hw-003

serialport::open() で開いたポートは、そのスコープを抜けると自動的に閉じられます。
接続を維持して読み書きを行うには、Tauri の StateMutex を使ってポートのインスタンスを管理する必要があります。

1. 状態管理用の構造体定義

src-tauri/src/lib.rs (または main.rs) で、ポートを保持する構造体を定義します。

use std::sync::Mutex;
use serialport::SerialPort;
use tauri::State;

// シリアルポートを保持する State
// Send + Sync である必要があるため Mutex で包みます
pub struct SerialConnection {
    pub port: Mutex<Option<Box<dyn SerialPort>>>,
}

run 関数で初期化します。

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .manage(SerialConnection { port: Mutex::new(None) }) // 初期値は None
        .invoke_handler(tauri::generate_handler![open_port, close_port, write_serial, write_serial_binary])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

2. コマンドの実装

接続 (Open)

use std::time::Duration;

#[tauri::command]
fn open_port(
    port_name: String, 
    baud_rate: u32, 
    state: State<'_, SerialConnection>
) -> Result<String, String> {
    let mut port_guard = state.port.lock().map_err(|e| e.to_string())?;
    
    // すでに開いている場合は閉じるか、エラーにする
    if port_guard.is_some() {
        return Err("Port is already open".to_string());
    }

    let port = serialport::new(&port_name, baud_rate)
        .timeout(Duration::from_millis(100))
        .open()
        .map_err(|e| e.to_string())?;

    *port_guard = Some(port);
    
    Ok(format!("{} connected", port_name))
}

切断 (Close)

#[tauri::command]
fn close_port(state: State<'_, SerialConnection>) -> Result<String, String> {
    let mut port_guard = state.port.lock().map_err(|e| e.to_string())?;
    
    if port_guard.is_some() {
        *port_guard = None; // Drop させることでポートが閉じる
        Ok("Disconnected".to_string())
    } else {
        Err("No connection".to_string())
    }
}

送信 (Write)

#[tauri::command]
fn write_serial(content: String, state: State<'_, SerialConnection>) -> Result<String, String> {
    let mut port_guard = state.port.lock().map_err(|e| e.to_string())?;

    if let Some(port) = port_guard.as_mut() {
        // 文字列をバイト列として送信
        port.write_all(content.as_bytes()).map_err(|e| e.to_string())?;
        Ok("Sent".to_string())
    } else {
        Err("Port not open".to_string())
    }
}

#[tauri::command]
fn write_serial_binary(content: Vec<u8>, state: State<'_, SerialConnection>) -> Result<String, String> {
    let mut port_guard = state.port.lock().map_err(|e| e.to_string())?;

    if let Some(port) = port_guard.as_mut() {
        port.write_all(&content).map_err(|e| e.to_string())?;
        Ok("Sent".to_string())
    } else {
        Err("Port not open".to_string())
    }
}

フロントエンドからの呼び出し例

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

// 接続
async function connect() {
  try {
    const res = await invoke('open_port', { portName: 'COM3', baudRate: 9600 });
    console.log(res);
  } catch (e) {
    console.error(e);
  }
}

// 送信
async function sendData() {
  try {
    await invoke('write_serial', { content: 'Hello World\n' });
  } catch (e) {
    console.error(e);
  }
}

// 送信 (バイナリ)
async function sendBinaryData() {
  try {
    // [0x01, 0x02, 0x03] を送信
    await invoke('write_serial_binary', { content: [1, 2, 3] });
  } catch (e) {
    console.error(e);
  }
}

// 切断
async function disconnect() {
  try {
    await invoke('close_port');
  } catch (e) {
    console.error(e);
  }
}

3. 受信 (Read) の実装

受信は独立したスレッドで常時監視し、データを受け取ったら Tauri の Event システムを使ってフロントエンドへ通知する方式が一般的です。

src-tauri/src/lib.rs に以下の変更を加えます。

1. open_port コマンドの引数に AppHandle を追加
2. ポートを開いた直後に try_clone() して、別スレッドに渡す
3. スレッド内でループして read() を行う

use std::thread;
use std::io::Read;
use tauri::Emitter; // v2 でイベントを送るために必要

#[tauri::command]
fn open_port(
    app: tauri::AppHandle, // 追加
    port_name: String, 
    baud_rate: u32, 
    state: State<'_, SerialConnection>
) -> Result<String, String> {
    let mut port_guard = state.port.lock().map_err(|e| e.to_string())?;
    
    if port_guard.is_some() {
        return Err("Port is already open".to_string());
    }

    let port = serialport::new(&port_name, baud_rate)
        .timeout(Duration::from_millis(100))
        .open()
        .map_err(|e| e.to_string())?;

    // --- 受信スレッドの開始 ---
    // ポートをクローン(複製)する
    let mut clone = port.try_clone().map_err(|e| e.to_string())?;
    
    thread::spawn(move || {
        let mut buffer: Vec<u8> = vec![0; 1024];
        loop {
            match clone.read(buffer.as_mut_slice()) {
                Ok(0) => break, // EOF
                Ok(n) => {
                    // 受信データを取得
                    let data = &buffer[..n];
                    // ここでは単純化のため String に変換して送信
                    // (実際にはバイナリ配列として送るか、プロトコルに合わせてパースします)
                    if let Ok(text) = String::from_utf8(data.to_vec()) {
                        // フロントエンドへ "serial-payload" イベントを発行
                        let _ = app.emit("serial-payload", text);
                    }
                }
                Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => {
                    // タイムアウト時はリトライ
                    continue;
                }
                Err(_) => {
                    // エラー時はスレッド終了
                    break;
                }
            }
        }
    });
    // ------------------------

    *port_guard = Some(port);
    
    Ok(format!("{} connected", port_name))
}

フロントエンド側での受信:

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

// イベントリスナーの登録
await listen<string>('serial-payload', (event) => {
    console.log('Received:', event.payload);
});