ラズパイ OS におけるシリアルデバイス名の恒久的な固定化方法

Raspberry Pi や Linux 環境において、USB シリアルコンバータやモデムを接続すると、システムは自動的に /dev/ttyUSB*/dev/ttyACM* などのノードを作成します。しかし、これらの番号は起動時や再接続のタイミングで動的に再割り当てされるため、既存のスクリプトが破損したり、複数のデバイスを識別しにくくなったりする課題があります。各ハードウェアには一意の属性が存在するため、udev ルールを設定することで、デバイス名を固定化することが可能です。

アプローチ 1:ハードウェア固有属性に基づいたリンク設定

最も確実な方法は、ベンダ ID、プロダクト ID、またはシリアルナンバーなどのハードウェア固有情報を抽出し、それらをキーにしてシンボリックリンクを自動生成するルールを構築することです。

1. デバイス情報の収集

まず、対象となるシリアルデバイスの一意性を特定するために、現在の接続状態を確認します。単一のデバイスだけでなく、複数接続されている場合にも対応できるように以下のコマンドを実行してください。

#!/bin/bash
# 現在認識されているすべての ttyUSB および ttyACM デバイスを巡回して詳細を取得
TARGET_LIST=("/dev/ttyUSB"* "/dev/ttyACM"*)

for target in "${TARGET_LIST[@]}"; do
    [ -e "$target" ] || continue
    echo "--- Configuration for $target ---"
    sudo udevadm info --query=all --name="$target" | \
        grep -E "^(ATTRS{serial}|ATTRS{idVendor}|ATTRS{idProduct}|ID_PATH)="
    echo ""
done

2. udev ルールの定義

収集した情報を基に、udev 規則ファイルを生成します。/etc/udev/rules.d/ ディレクトリ内に任意の名前(例:99-custom-serial.rules)でファイルを作成し、以下のような形式で記述します。ここで重要なのは ATTRS でハードウェア情報を選び取り、SYMLINK で固定的なエイリアス名を付与することです。

# === FTDI ベースのマルチポートモジュール ===
# シリアル番号 FTB1W6T7 かつ特定のバスパスを持つ場合にリンク作成
SUBSYSTEM=="tty", ATTRS{"serial"}=="FTB1W6T7", ATTRS{"idVendor"}=="0403", SYMLINK+="fixed_ftdi_1"
SUBSYSTEM=="tty", ATTRS{"serial"}=="FTB1W6T7", ATTRS{"idVendor"}=="0403", ATTRS{"idProduct"}=="6001", SYMLINK+="fixed_ftdi_2"

# === WCH CH340 シリーズ対応 ===
# ID パスを使用して特定の物理ポートを特定する場合
SUBSYSTEM=="tty", ENV{"ID_PATH"}=="platform-fe9c0000.xhci-usb-0:1.1:1.0", SYMLINK+="com_wch_a"
SUBSYSTEM=="tty", ENV{"ID_PATH"}=="platform-fe9c0000.xhci-usb-0:1.1:1.1", SYMLINK+="com_wch_b"

3. 自動生成スクリプト(Python 実装例)

手動入力を省略し、検出されたデバイスに対して即座に udev ルールを作成・適用するための管理ツールを構成します。ここではオブジェクト指向のアプローチを採用し、権限確認から規則書き込みまでをクラス内でカプセル化しています。

#!/usr/bin/env python3
"""
Udev Serial Device Mapper
Automates the creation of persistent symlinks for USB serial devices.
"""

import os
import sys
import re
import subprocess
from pathlib import Path

class UdevManager:
    def __init__(self, rules_path="/etc/udev/rules.d/88-auto-serial.rules"):
        self.config_file = Path(rules_path)
        
    def _check_elevation(self):
        """Verify execution context."""
        if os.geteuid() != 0:
            print("Fatal: Root privileges are required for this operation.")
            sys.exit(1)

    def _probe_devices(self):
        """Scan available serial ports."""
        matches = list(Path('/dev').glob('ttyUSB*')) + list(Path('/dev').glob('ttyACM*'))
        return [str(m.name) for m in matches]

    def _get_device_identity(self, node_name):
        """Extract unique identifiers from udev database."""
        try:
            res = subprocess.check_output([
                "udevadm", "info", "--query=all", f"--name=/dev/{node_name}"
            ], stderr=subprocess.DEVNULL, encoding='utf-8')
            
            # Pattern extraction adjusted for flexibility
            vendor_match = re.search(r'ATTRS\{idVendor\}="([a-fA-F0-9]+)"', res)
            product_match = re.search(r'ATTRS\{idProduct\}="([a-fA-F0-9]+)"', res)
            serial_match = re.search(r'ATTRS\{serial\}="([^"]+)"', res)
            
            return {
                'vid': vendor_match.group(1) if vendor_match else None,
                'pid': product_match.group(1) if product_match else None,
                'sn': serial_match.group(1) if serial_match else None
            }
        except Exception as e:
            print(f"Failed to parse identity: {e}")
            return {}

    def _generate_directive(self, props, alias_name):
        """Construct a single udev rule string."""
        directive = 'SUBSYSTEM=="tty",'
        if props.get('vid'): directive += f' ATTRS{{idVendor}}=="{props["vid"]}",'
        if props.get('pid'): directive += f' ATTRS{{idProduct}}=="{props["pid"]}",'
        if props.get('sn'): directive += f' ATTRS{{serial}}=="{props["sn"]}",'
        
        directive += f'SYMLINK+="{alias_name}"'
        return directive

    def _apply_rule(self, rule_string):
        """Append configuration to udev directory."""
        if self.config_file.exists():
            with open(self.config_file, 'r+') as f:
                content = f.read()
                if rule_string not in content:
                    f.write(rule_string + "\n")
                    print("Rule updated.")
                else:
                    print("Duplicate rule detected. Skipping.")
        else:
            self.config_file.parent.mkdir(parents=True, exist_ok=True)
            with open(self.config_file, 'w') as f:
                f.write(rule_string + "\n")
            print("Configuration initialized.")

    def _reload_system(self):
        """Trigger daemon reload."""
        subprocess.call(["udevadm", "control", "--reload-rules"])
        subprocess.call(["udevadm", "trigger"])

def main():
    manager = UdevManager()
    manager._check_elevation()
    
    devices = manager._probe_devices()
    if not devices:
        print("No serial interfaces detected on system.")
        return

    print("\nDetected Interfaces:")
    for i, dev in enumerate(devices):
        print(f"[{i}] {dev}")
    
    idx = int(input("Select index: "))
    selected = devices[idx]
    
    info = manager._get_device_identity(selected)
    if not info['vid'] or not info['pid']:
        print("Critical: Hardware identification failed.")
        return

    link_name = input(f"Enter new link name for {selected}: ")
    raw_rule = manager._generate_directive(info, link_name)
    print(f"\nGenerated Rule: {raw_rule}")
    
    confirm = input("Apply to system config? (y/n): ")
    if confirm.lower() == 'y':
        manager._apply_rule(raw_rule)
        manager._reload_system()
        print("Process completed successfully.")

if __name__ == "__main__":
    main()

アプローチ 2:物理ポートパスを用いた直接バインディング

上記のハードウェア属性に加え、あるいは代わりに、USB ホストコントローラ上の物理的なバス位置(ID_PATH)を利用する方法です。これにより、同一型番の複数デバイスを接続した場合でも、どのポートに差し込まれたかを正確に区別できます。

手順概要

  • 認識確認: lsusb を用いて機器が正しく認識されていることをチェックします。
  • 経路特定: dmesg | grep ttyUSB または udevadm info /dev/ttyUSBx を実行し、出力中の ID_PATH フィールド値をメモします。これは platform-xxxx.pcie-... のような形式になります。

次に、ルールファイルを編集してそのパス情報を埋め込みます。

sudo nano /etc/udev/rules.d/98-usb-port-fix.rules

ファイル末尾に以下のサンプルを追記し、実際の測定値に置き換えます。

# セルラーモデム用のポート固定(ID_PATH ベース)
SUBSYSTEM=="tty", ENV{ID_PATH}=="platform-fd500000.pcie-pci-0000:01:00.0-usb-0:1.1:1.0", SYMLINK+="lte_modem_primary"
SUBSYSTEM=="tty", ENV{ID_PATH}=="platform-fd500000.pcie-pci-0000:01:00.0-usb-0:1.1:1.1", SYMLINK+="lte_modem_data"
# 追加ポートが必要な場合は同様に行います

反映および検証

設定変更後は、新しいルールを即時適用する必要があります。以下のコマンドを実行します。

sudo udevadm control --reload-rules
sudo udevadm trigger --action=add

最後に、ls -l /dev を確認し、指定したシンボリックリンク(例:lte_modem_primary)が実際に作成されており、本来のデバイス(例:ttyUSB2)への参照が正しく機能していることを確認すれば完了です。

タグ: linux-udev raspberrypi-os serial-port-management systemd-python bash-scripting

8月22日 04:09 投稿