先に結論 — ポート名を設定ファイルに書くのをやめ、機器の特徴で毎回探す形にします。完成版は 6 章、この断片だけでも動きます。

import serial
import serial.tools.list_ports

# 事前に  python -m pip install pyserial
# 自分の機器の値は  python -m serial.tools.list_ports -v  で確認(→ 5.1)
# 探したい機器の条件。VID/PID は変換チップの公開値、SERIAL は個体の値
VID, PID, SERIAL = 0x0403, 0x6001, "AB0123CD"

def find_port() -> str:
    for p in serial.tools.list_ports.comports():
        # serial_number は None ではなく空文字列 "" になることがある(5.3)
        if p.vid == VID and p.pid == PID and (p.serial_number or "") == SERIAL:
            return p.device            # "COM5" などが返る
    raise RuntimeError("機器が見つかりません")

ser = serial.Serial(find_port(), 9600, timeout=1.0)

シリアル番号を持たない変換器(8 章)では、この形は効きません。逃げ道は 9 章location と応答プローブです。

この記事の目次

1. 「昨日まで COM3」で止まるコードの正体

決め打ちが壊れるのは、変換器を別の差込口に移した/現場 PC を入れ替えた/同じ変換器を 2 本挿した——この 3 場面です。どれも設備の故障ではなく Windows が番号を付け替えただけなので、機器を疑うと半日溶けます。まず、画面に出る症状で現在地を確認してください。

症状・エラー原文起きていること対処
SerialException: could not open port 'COM99': FileNotFoundError(2, '指定されたファイルが見つかりません。', None, 2) そのポートが存在しない。番号が変わったか、変換器が抜けている まず一覧を取る(5 章)。恒久対策は 6 章
SerialException: could not open port 'COM3': PermissionError(13, 'アクセスが拒否されました。', None, 5) ポートは実在するが、他のプロセスが既に開いている。番号ずれとは別問題 ターミナルソフトや前回のプロセスの残りを確認(FAQ
例外は出ないが、応答が返らない/中身が違う機器だった 番号がずれて別の機器を開いてしまった。いちばん危ない 機器の特徴で特定する(6 章)。最後の砦は応答プローブ(9 章
comports()[0] が期待と違うポートを返す comports() の戻り値は並び順が保証されていません5.3 で実測) 添字で取らない(7 章

3 番目が最悪です。例外で止まるほうが、黙って別の機器を読み書きするよりはるかに安全——番号決め打ちは、この「黙って間違える」経路を常に開けたままにします。

出典は、上 2 行のエラー文言が自宅の検証機で採取したもの、4 行目の並び順が 5.3 の実測です。3 行目は文言を伴わない症状のため、当サイトでは未再現です(12 章)。

2. なぜ番号が変わるのか — Windows 側の 3 層で捉える(うち 2 層はレジストリの実測)

COM 番号を管理しているのは 1 か所ではなく、役割の違う 3 つの層です。その 3 層が「番号が戻る」と「番号が増える」をどこで分けているのかを先に 1 枚にまとめます。答えだけでいい人は 3 章へ飛んでかまいません

USB-シリアル変換器を挿し直したときに COM 番号が戻るか増えるかの分岐を示す図 変換器を USB に挿すと Windows がデバイスインスタンス ID を作る。シリアル番号を持つ機器は VID・PID・シリアル番号で ID が決まるため、別の差込口でも同じ ID になり、レジストリの PortName から同じ COM 番号が戻る。シリアル番号を持たない機器は VID・PID と差込口の位置で ID が決まるため、別の差込口に挿すと別の ID になり、ComDB から次の空き番号が払い出されて番号が増える。 同じ変換器を挿し直したのに、番号が戻る機器と増える機器がある 変換器を USB に挿す Windows がデバイスインスタンス ID を作る シリアル番号があればそれを、無ければ「場所」を使う シリアル番号を持つ(FTDI・CP2109 など) USB\VID_0403&PID_6001\AB0123CD ID は「モノ」で決まる 別の差込口でも ID は同じ レジストリの PortName を再利用 COM3 のまま戻る serial_number で機器を特定できる(6 章) シリアル番号を持たない(無印 CH340 など) USB\VID_1A86&PID_7523\5&xxxxxxx&0&2 ID は「差込口の場所」で決まる 別の差込口なら別 ID = 未知のデバイス ComDB から次の空き番号を claim COM5 に増える location か応答プローブで逃げる(9 章) Microsoft 公式ドキュメント 3 本(Instance ID / PortName / COM Port Database)からの整理。当サイトでは番号が変わる現象そのものは未実測

以下 2.1〜2.3 が、この図の裏取りです(図は公式ドキュメント 3 本を突き合わせた整理で、「シリアル番号が無いと番号が増える」と 1 文で書いた公式記述があるわけではありません12 章)。

2.1 ComDB — 空き番号の台帳。誰がどれかは覚えていない

番号の重複を防ぐ台帳(COM port database、通称 ComDB)について、Microsoft の公式ドキュメントはこう書いています。

COM ポートデータベースは要素の配列で構成され、各要素は COM ポート番号が使用中かどうかを示す。最初の要素が COM1、2 番目が COM2、以下同様。ただし、このデータベースは、どのデバイスにどのポート番号が割り当てられているかの情報を一切持たない(拙訳)

Microsoft Learn — COM Port Database(アーカイブ済み文書)

実体はレジストリ HKLM\SYSTEM\CurrentControlSet\Control\COM Name Arbiter\ComDB のバイナリ値です。自宅の検証機読み取りのみで見ました(業務端末ではありません。管理者権限も不要です)。

# PowerShell。読み取りだけで、値の書き換えは一切していない
$b = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\COM Name Arbiter' -Name ComDB).ComDB
'{0} bytes (= COM1..{1})' -f $b.Length, ($b.Length * 8)
($b[0..7] | ForEach-Object { '{0:X2}' -f $_ }) -join ' '
32 bytes (= COM1..256)
0C 00 00 00 00 00 00 00

先頭バイト 0x0C は 2 進で 00001100、立っているのは bit2 と bit3。bit n が COM(n+1) に対応するので claim 済みは COM3 と COM4——実在する 2 本と完全に一致しました。claim とは、台帳に「この番号は使用中」の印を立てることです。払い出しは ComDBClaimNextFreePort(最小の空き番号を claim)が担当します。

2.2 PortName — 番号を覚えているのはこちら

PortName(REG_SZ) デバイスの名前を指定する。名前は通常 COM<n> の形で、<n> はインストーラが COM ポートデータベースから取得したポート番号である。(中略)既定値は空文字列である(拙訳)

Microsoft Learn — Registry Settings for a Plug and Play Serial Device(アーカイブ済み文書)

重要なのは、この値がデバイスインスタンスごとのレジストリキー配下に保存されることです。検証機で読み出すと、実在する 2 ポートがそれぞれ自分の番号を持っていました(インスタンス ID の端末固有部分はマスク)。

PortName=COM4  <-  BTHENUM\{00001101-....-00805F9B34FB}_VID&xxxxxxxx_PID&xxxx\7&XXXXXXX&0&XXXXXXXXXXXX_C00000000
PortName=COM3  <-  BTHENUM\{00001101-....-00805F9B34FB}_LOCALMFG&0000\7&XXXXXXX&0&000000000000_00000000

同じインスタンスと認識されれば同じ番号が戻り、別インスタンスと判定されれば ComDB から新しい番号が claim される。ここが分岐点です。

2.3 デバイスインスタンス ID — シリアル番号か、それとも「場所」か

インスタンス ID は、下位のバスが対応していればシリアル番号の情報を、そうでなければ何らかのロケーション情報を含む。(中略)インスタンス ID はシステムの再起動をまたいで永続する(拙訳)

Microsoft Learn — Instance ID

この 1 文で全部つながります。シリアル番号を持つ変換器は「モノ」で識別されるため、どの差込口に挿しても ID が変わらず、2.2PortName がそのまま再利用されて同じ番号が戻ります。

一方、シリアル番号を持たない変換器の識別子は「場所」です。別の差込口に挿した瞬間に別の ID になり、Windows から見れば未知のデバイスです。そこで ComDB から次の空き番号が払い出される——これが「差すたびに番号が増える」現象の説明として整合します(図 1)。

3. 対策は 3 つ。どれを選ぶか

対策は「OS 側で番号を固定する」か「プログラム側で番号に依存しない」かの二択で、先に手を付けるべきはプログラム側です。

対策やること効く範囲現場での弱点
① デバイスマネージャーで固定 ポートのプロパティ → 詳細設定で番号を選ぶ その PC のその機器 1 台 PC を入れ替えると全部やり直し。台数分の手作業
② レジストリ IgnoreHWSerNum usbflags にキーを作りシリアル番号を無視させる その VID/PID/リビジョンの機器すべて 管理者権限が必要。同型機を複数台使う現場では逆効果4.2
③ Python 側で自動特定 VID/PID・シリアル番号・差込口位置で毎回探す コードを配った先すべて。Linux でも同じ発想が通る シリアル番号を持たない変換器では条件の工夫が要る(9 章

4. OS 側で固定する — できることと、その限界

4.1 デバイスマネージャーで番号を選ぶ

Windows 標準の手順です。

  1. デバイスマネージャーを開き、「ポート (COM と LPT)」を展開する
  2. 対象のポートを右クリック →「プロパティ」
  3. 「ポートの設定」タブ →「詳細設定」
  4. 「COM ポート番号」のドロップダウンで番号を選ぶ

使用中の番号も選べてしまうことがあり、重複させるとどちらか一方が開けなくなります。またこの操作は 2.2PortName を書き換えているだけなので、デバイスインスタンス ID ごと変わる挿し替え(シリアル番号を持たない機器を別の差込口へ)には無力です。

4.2 レジストリ IgnoreHWSerNum — 効く場面と、逆効果になる場面

IgnoreHWSerNum(REG_BINARY) USB ドライバスタックがデバイスのシリアル番号を無視すべきかどうかを示す。0x00: 無効。0x01: USB ドライバスタックにシリアル番号を無視させる。したがって、デバイスインスタンスは、デバイスが接続されているポートに紐づけられる(拙訳)

Microsoft Learn — USB Device Registry Entries

キーの場所は HKLM\SYSTEM\CurrentControlSet\Control\usbflags\<vvvvpppprrrr>vvvv がベンダー ID、pppp がプロダクト ID、rrrr がリビジョン番号で、いずれも 4 桁の 16 進数と公式に定義されています。自宅の検証機usbflags 配下を読み取りだけしたところ、8 個のサブキーはすべてキー名が 12 文字(4+4+4 と一致)でした。

この設定が有効なのは「1 台しか挿さない前提で、挿し替えのたびに番号が増えるのを止めたい」場合だけです。同型を 2 本以上使う現場では逆効果で、個体の区別が消え、6 章serial_number による特定も効かなくなります。

⚠️ 当サイトはこの設定を実際に作成しての動作確認を行っていません(公式記述の引用に留めています)。適用するなら、①該当キーをエクスポートしてバックアップ、②稼働設備につながった PC では計画停止のタイミングで、③情報システム部門・設備保全部門の承認を得てから。レジストリ編集は自己責任です。個人ブログでキー名の桁数が食い違う例も見かけるので、桁数は上の公式定義(4+4+4)で確認してください。

5. pyserial から見える情報 — 全属性を実測でダンプする

5.1 インストールと、まず打つ 1 行

パッケージ名は pyserial、import 名は serial です(pip install serial別の無関係なパッケージが入ります)。仮想環境への導入に管理者権限は不要です。

python -m venv .venv
.venv\Scripts\activate
python -m pip install pyserial

# 実行ポリシーで activate がブロックされる会社 PC なら、有効化せずに直接呼ぶ
.venv\Scripts\python.exe -m pip install pyserial

# 社内プロキシ経由なら(認証情報は環境変数へ逃がし、コマンド履歴に残さない)
python -m pip install --proxy http://proxy.example.local:8080 pyserial

# 外部に出られない閉域網なら、別 PC で取得した wheel を持ち込んで
python -m pip install --no-index --find-links .\wheels pyserial

入ったら、コードを書く前にこの 1 行です。

python -m serial.tools.list_ports -v
2 ports found
COM3
    desc: Bluetooth リンク経由の標準シリアル (COM3)
    hwid: BTHENUM\{00001101-....-00805F9B34FB}_LOCALMFG&0000\7&XXXXXXX&0&000000000000_00000000
COM4
    desc: Bluetooth リンク経由の標準シリアル (COM4)
    hwid: BTHENUM\{00001101-....-00805F9B34FB}_VID&xxxxxxxx_PID&xxxx\7&XXXXXXX&0&XXXXXXXXXXXX_C00000000

見えているのは Bluetooth 経由の仮想 COM ポート 2 本だけです(端末固有の識別子はマスク)。USB-シリアル変換器が無い環境は、かえって都合のいい実験台になりました

5.2 ListPortInfo の全属性

import serial.tools.list_ports

for p in serial.tools.list_ports.comports():
    for attr in ("device", "name", "description", "hwid", "vid", "pid",
                 "serial_number", "location", "manufacturer", "product", "interface"):
        print(f"  {attr:<14} = {getattr(p, attr)!r}")
    print("-" * 40)
  device         = 'COM4'
  name           = 'COM4'
  description    = 'Bluetooth リンク経由の標準シリアル (COM4)'
  hwid           = 'BTHENUM\\{00001101-....-00805F9B34FB}_VID&xxxxxxxx_PID&xxxx\\7&XXXXXXX&0&XXXXXXXXXXXX_C00000000'
  vid            = None
  pid            = None
  serial_number  = None
  location       = None
  manufacturer   = 'Microsoft'
  product        = None
  interface      = None

COM3 も同じ形なので、ここでは COM4 の分だけ載せています。注目してほしいのは hwidVID&... PID&... と入っているのに vid / pidNone なことです。Windows 版はハードウェア ID が USBFTDIBUS で始まるときだけ VID/PID を解析し、それ以外は生のまま入れて終わります(Bluetooth 経由は BTHENUM 始まり)。

# serial/tools/list_ports_windows.py(pyserial 3.5・venv 内の現物を確認)
if szHardwareID_str.startswith('USB'):
    m = re.search(r'VID_([0-9a-f]{4})(&PID_([0-9a-f]{4}))?(&MI_(\d{2}))?(\\(.*))?',
                  szHardwareID_str, re.I)
    ...
elif szHardwareID_str.startswith('FTDIBUS'):
    ...
else:
    info.hwid = szHardwareID_str          # ← 解析せず生のまま

教訓はひとつ。vidNone のポートは「USB でつながっていない」だけでなく「pyserial が解析できなかった」場合も含みます。仮想 COM ポートが相手なら、VID/PID での絞り込みは最初から使えません。

では、変換器が挿さっている PC ではどう出るのか。下は当サイトの実測値ではなく、どの属性に何が入るかを示す凡例です(値は 8 章のチップで変わります)。

  device         = 'COM5'
  vid            = 1027          # 0x0403(FTDI)が 10 進で入る
  pid            = 24577         # 0x6001
  serial_number  = 'AB0123CD'    # ← 6 章で使うのはこれ
  location       = '1-2'         # ← シリアル番号が空のときの逃げ道(9.1)

5.3 実測で分かった 4 つの落とし穴

comports() は並べ替えてくれない。5 回連続で実行した結果はすべて ['COM4', 'COM3']。CLI が番号順に見えるのは list_ports.pymain()sorted(comports(...)) を通しているからで、コードから呼ぶときは自分で sorted() が要りますListPortInfo は自然順の比較に対応済み)。

serial_numberNone ではなく空文字列 "" になることがある。Windows 版は、取り出した文字列が英数字とアンダースコアだけでない場合に親デバイスをたどりますが、その探索関数は見つからないと None ではなく '' を返しますif p.serial_number is None: は取りこぼします。

# serial/tools/list_ports_windows.py — シリアル番号の妥当性チェック(逐語)
# Check that the USB serial number only contains alpha-numeric characters. It
# may be a windows device ID (ephemeral ID) for composite devices.
if m.group(7) and re.match(r'^\w+$', m.group(7)):
    info.serial_number = m.group(7)
else:
    info.serial_number = get_parent_serial_number(devinfo.DevInst, info.vid, info.pid)

hwidSER= は「シリアル番号なし」の判定に使えない。組み立てを担当する usb_info()serial_number is not None のときだけ SER= を足すので、空文字列だと SER= だけが残ります。USB 機器が無くても、ListPortInfo を手で組み立てれば実証できます。

serial_number組み立てられた hwid"SER=" in hwidis Nonenot p.serial_number
'AB0123CD'USB VID:PID=0403:6001 SER=AB0123CD LOCATION=1-2TrueFalseFalse
NoneUSB VID:PID=1A86:7523 LOCATION=1-3FalseTrueTrue
''(空文字列)USB VID:PID=1A86:7523 SER= LOCATION=1-4TrueFalseTrue

3 行目が罠です。SER= があるからシリアル番号を持っている」と判定すると、空文字列の機器を取り違えます。最右列の書き方だけが 3 行すべてで正しく動きます。

grep() はジェネレータを返す。list_ports.grep(regexp)name / description / hwid を大文字小文字の区別なく検索できますが、戻り値がジェネレータなので if grep(...): は該当ゼロでも常に True です。実測でも bool(grep("該当しない文字列")) == Truelist() に入れて初めて [] になりました。

なお、シリアル番号だけで探す最小コードはpySerial の基本記事 11.1にあります。本記事はその先です。

6. pyserial でポートを自動検出する find_port() — 優先順位つき完成コード

設計の骨は「何を identity にするか」を明示的に決めることです。何で識別するかを混ぜて書くと、あとで「なぜこの条件なのか」が誰にも分からなくなります。

機器を特定する条件の優先順位を示す図 まず VID と PID で製品の型を絞り、次にシリアル番号で個体を確定する。シリアル番号が無い場合は location で差込口を固定し、それでも一意に決まらない場合は応答プローブで機器のふるまいを確認する。上の段ほど堅く、下の段ほど現場の運用ルールに依存する。 何で機器を特定するか — 上から順に試し、決まったところで止める 1. vid / pid 識別できるのは「製品の型」。同型が複数あれば全部ヒットする 確実 / 挿し替えに強い 2. serial_number 識別できるのは「モノ(個体)」。値があればこれが最有力 最優先。チップ次第で空(8 章) 3. location 識別できるのは「場所(差込口)」。挿し替えると値が変わる 運用ルールとセットで成立 4. 応答プローブ 識別できるのは「ふるまい」。読み出し専用コマンドに限る 最後の手段。相手を動かす危険 2 で決まれば 3・4 は不要。どこで決めたかをログに残しておくと、あとから切り分けができる

以下が完成版です。そのまま find_port.py として保存し、同じフォルダから from find_port import PortSpec, open_device で使えます

"""COM ポート番号に依存せず、機器そのものを特定して開く。"""
from __future__ import annotations

import logging
import time
from dataclasses import dataclass

import serial
import serial.tools.list_ports
from serial.tools.list_ports_common import ListPortInfo

logger = logging.getLogger(__name__)


class PortNotFound(Exception):
    """条件に合うポートが 1 つも無い。"""


class PortAmbiguous(Exception):
    """条件に合うポートが 2 つ以上あって、どれか決められない。"""


@dataclass(frozen=True)
class PortSpec:
    """探したい機器の条件。指定した項目だけが AND で効く。"""
    vid: int | None = None                   # 例: 0x0403(FTDI)
    pid: int | None = None                   # 例: 0x6001
    serial_number: str | None = None         # 個体を確定させる最有力の条件
    location: str | None = None              # 例: "1-2.3"(差込口を固定する運用とセット)
    description_contains: str | None = None  # 最後の手段。表示名は環境で変わる

    def __str__(self) -> str:
        parts = []
        if self.vid is not None:
            parts.append(f"vid=0x{self.vid:04X}")
        if self.pid is not None:
            parts.append(f"pid=0x{self.pid:04X}")
        for k in ("serial_number", "location", "description_contains"):
            v = getattr(self, k)
            if v is not None:
                parts.append(f"{k}={v!r}")
        return "PortSpec(" + ", ".join(parts) + ")"


def _describe(p: ListPortInfo) -> str:
    return (f"{p.device} vid={p.vid and f'0x{p.vid:04X}'} pid={p.pid and f'0x{p.pid:04X}'} "
            f"serial_number={p.serial_number!r} location={p.location!r} desc={p.description!r}")


def find_port(spec: PortSpec, *, on_multiple: str = "raise") -> str:
    """条件に合う COM ポート名("COM7" など)を 1 つ返す。

    on_multiple="raise"  複数該当したら例外(既定。取り違え事故を防ぐ)
    on_multiple="first"  複数該当したら device 名の自然順で先頭を返す
    """
    ports = serial.tools.list_ports.comports()
    cands = []
    for p in ports:
        if spec.vid is not None and p.vid != spec.vid:
            continue
        if spec.pid is not None and p.pid != spec.pid:
            continue
        # serial_number は None ではなく空文字列 "" になることがある(5.3 の②)
        if spec.serial_number is not None and (p.serial_number or "") != spec.serial_number:
            continue
        if spec.location is not None and p.location != spec.location:
            continue
        if (spec.description_contains is not None
                and spec.description_contains not in (p.description or "")):
            continue
        cands.append(p)

    if not cands:
        # 「見つからない」だけでは切り分けできないので、見えている全ポートを添える
        raise PortNotFound(
            f"条件に合うポートがありません: {spec}\n"
            + "現在見えているポート:\n"
            + ("\n".join("  " + _describe(p) for p in sorted(ports)) or "  (0 本)")
        )
    if len(cands) > 1:
        detail = "\n".join("  " + _describe(p) for p in sorted(cands))
        if on_multiple == "raise":
            raise PortAmbiguous(f"条件に {len(cands)} 本が該当します。条件を足してください:\n{detail}")
        logger.warning("複数該当のため先頭を採用します:\n%s", detail)

    chosen = sorted(cands)[0]        # comports() は並べ替えられていない(5.3 の①)
    logger.info("ポートを特定しました: %s", _describe(chosen))
    return chosen.device


def open_device(spec: PortSpec, *, baudrate: int = 9600, timeout: float = 1.0,
                retries: int | None = 5, wait: float = 2.0, **kwargs) -> serial.Serial:
    """機器を探して開く。見つからない・開けない場合は探し直しから再試行する。

    retries=None なら、見つかるまで待ち続ける(常駐アプリ向け。10 章)。
    """
    last: Exception | None = None
    attempt = 0
    while retries is None or attempt < retries:
        attempt += 1
        try:
            port = find_port(spec)
            return serial.Serial(port, baudrate, timeout=timeout, **kwargs)
        except (PortNotFound, serial.SerialException) as e:
            last = e
            logger.warning("接続失敗 (%s/%s): %s", attempt,
                           retries if retries is not None else "無制限", e)
            if retries is None or attempt < retries:
                time.sleep(wait)
    raise RuntimeError(f"機器に接続できませんでした({retries} 回試行)") from last


if __name__ == "__main__":
    # 単体で実行すると自己診断になる。vid/pid は自分の機器の値に書き換える
    # (値は「python -m serial.tools.list_ports -v」で調べる=5.1)
    logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
    print(find_port(PortSpec(vid=0x0403, pid=0x6001)))

logging.basicConfig() を入れておかないと logger.info() の行は画面に出ません。呼び出し側の main() で 1 回だけ呼ぶのが原則です(ライブラリ側で呼ぶと設定が二重になります)。

設計判断は 3 つです。

  • 複数該当は既定で例外 — 適当に 1 本目を選ぶと「黙って別の機器を操作する」経路ができます(1 章
  • 見つからないときは見えている全ポートを添える — 現場 PC に Python が無いと追加調査ができないので、1 回のエラーメッセージで切り分けが終わるようにします
  • (p.serial_number or "")sorted()5.3 の①②への対策です

自分の環境に合わせて書き換えるのは PortSpecVID/PID とシリアル番号の 2 か所だけです。次は、Bluetooth ポート 2 本だけの環境で条件を変えながら呼んだときの実行結果です(条件は表示名に読み替え)。

=== 1 本に絞れるケース ===
INFO ポートを特定しました: COM3 vid=None pid=None serial_number=None location=None desc='Bluetooth リンク経由の標準シリアル (COM3)'
COM3

=== 2 本該当してしまうケース(既定は例外) ===
PortAmbiguous: 条件に 2 本が該当します。条件を足してください:
  COM3 vid=None ... desc='Bluetooth リンク経由の標準シリアル (COM3)'
  COM4 vid=None ... desc='Bluetooth リンク経由の標準シリアル (COM4)'

=== 該当なし(診断つきで落ちる) ===
PortNotFound: 条件に合うポートがありません: PortSpec(vid=0x0403, pid=0x6001, serial_number='AB0123CD')
現在見えているポート:
  COM3 vid=None ... desc='Bluetooth リンク経由の標準シリアル (COM3)'
  COM4 vid=None ... desc='Bluetooth リンク経由の標準シリアル (COM4)'

=== open_device のリトライ(存在しない機器・2 回で打ち切り) ===
WARNING 接続失敗 (1/2): 条件に合うポートがありません: PortSpec(vid=0x1A86, pid=0x7523)
WARNING 接続失敗 (2/2): 条件に合うポートがありません: PortSpec(vid=0x1A86, pid=0x7523)
RuntimeError: 機器に接続できませんでした(2 回試行) | 原因: PortNotFound

末尾の | 原因: PortNotFoundテスト用ハーネスの出力です(素の traceback では raise ... from last による例外の連鎖として出ます)。リトライ 2 回分で繰り返される「現在見えているポート」の一覧も、上では省いています。

7. やってはいけない 5 つの書き方

書き方いつ壊れるか代わりに
serial.Serial("COM3", 9600) 挿し替え・PC 入れ替えで即。番号が別の機器に回っていると例外なしで誤動作 find_port() で毎回探す(6 章
comports()[0].device 常に危険。並び順は保証されない(実測で ['COM4', 'COM3'] 条件で絞る。順序が要るなら sorted()
if "USB Serial" in p.description ドライバのバージョン・OS 言語・製品で表示名が変わる。同型が 2 本あれば両方ヒット VID/PID と serial_number。表示名は最後の手段
if p.serial_number is None Windows では空文字列 "" になる経路がある(5.3②) if not p.serial_number
if list_ports.grep(pattern): 常に Trueジェネレータなので該当ゼロでも真 if list(list_ports.grep(pattern)):

8. チップ別 — シリアル番号がある機器・ない機器

6 章の設計が効くかは、変換器のチップが個体ごとのシリアル番号を持つかで決まります。買う前に確認すべき仕様です。以下はメーカーのデータシート(一次情報)の記述を突き合わせた表です(実測範囲は 12 章)。

チップ既定のシリアル番号実務上の意味
FTDI FT232R 個体ごとに一意 そのまま個体識別できる。業務用の第一候補
Silicon Labs CP2102 全個体が 0001 素の状態では個体識別に使えない。書き換えツールで固有値を入れる
Silicon Labs CP2109 個体ごとに一意 同じ CP21xx でも品種で挙動が真逆。型番まで確認して買う
WCH CH340 データシートに規定なし(実装依存) 実装によっては値が空になるため、9 章の逃げ道が要る
WCH CH340B 既定は全個体 12345678(EEPROM で書き換え可) 無印と混同しない。ただし買ってそのままでは CP2102 と同じ「全個体同じ値」で、書き換えて初めて個体識別に使える
Prolific PL2303GT 既定で個体ごとに一意 既定なら使える。無効設定の製品では値が毎回変わりうる

上の表の根拠にしたデータシートの記述(拙訳)は次のとおりです。

  • FT232R: 「一意の USB シリアル番号があらかじめプログラムされた状態で供給される」
  • CP2102 / CP2109: 既定ディスクリプタ値の同じ表に「CP2102 Serial Number 0001」「CP2109 Serial Number Unique 8 character ASCII string
  • CH340: v3B が EEPROM とシリアル番号に触れるのは CH340B の節(5.2)だけで、無印 CH340 のシリアル番号を規定した記述は無い(=実装依存)
  • CH340B: 「CH340B は EEPROM を内蔵し、シリアル番号などの設定に使う」「CFG レジスタの bit5 で有効/無効を設定する」「シリアル番号、ASCII 文字列の長さは 8。先頭バイトが ASCII 文字でない場合はシリアル番号を無効にする」(v3B の 5.2 の表。同じ表の既定値が 12345678
  • PL2303GT: 「各 IC は一意の ID を持つ」。3 択の既定は「Enable Unique Serial Number ID」で、「Disable Serial Number」ではOS がランダムなシリアル番号を割り当てる

最後の行に注意してください。「シリアル番号が取れている=個体識別に使える」とは限りません。買う前の確認は 2 点です。

  • チップの型番まで見るCP2102CP2109CH340CH340B のように 1 文字で挙動が変わります
  • 実物を 1 本買って確かめるpython -m serial.tools.list_ports -v を打ち、SER= の後ろに個体ごとに違う値が出るかを見ます

なお PL2303 の旧チップは Windows 11 の現行ドライバで動作しないという報告が多く(当サイトでは未確認)、この話以前の問題になります(pySerial の基本記事 11.2)。

9. 同じ VID/PID を 2 本挿したら — location と応答プローブ

現場でいちばん困る状況です。同じ型の変換器を 2 本、別々の設備につないでいる。VID/PID は同じで serial_number はどちらも空。4.2IgnoreHWSerNum を入れても解決しません(むしろ区別を消す設定です)。残る手は 2 つ。

9.1 location — 「モノ」ではなく「場所」を固定する

pyserial 公式は location"USB device location string (<bus>-<port>[-<port>]…)" と定義しています。バス番号とハブのポート番号の階層、つまり差込口の物理的な位置です。

# 断片です。6 章の PortSpec と組み合わせて使ってください
LINE_A = PortSpec(vid=0x1A86, pid=0x7523, location="1-3")   # 手前の差込口 = A ライン
LINE_B = PortSpec(vid=0x1A86, pid=0x7523, location="1-4")   # 奥の差込口   = B ライン

これは現場の運用ルールとセットでなければ成立しません。「手前の差込口には必ず A ラインの変換器を挿す」を、PC 筐体へのラベル貼りまで含めて決めてください。担当者が善意でケーブルを整理し直した瞬間、この方式は無言で入れ替わります。ラベル無しの location 運用は事故の予約です。

ソースを読んで分かった制約も 1 つ。FTDI の専用ドライバ(FTDIBUS)経由で見えるポートでは location が取得されません——ソースに # USB location is hidden by FDTI driver :( というコメントがそのまま残っています(綴りは原文ママ)。FTDI 機器はシリアル番号が使えるので実害は小さいものの、location はどの機器でも必ず取れる」前提で設計すると空振りします

9.2 応答プローブ — 最後の手段。相手を動かさないこと

location も固定できない場合の最後の手段が、軽いコマンドを送って応答で機器を確定する方法です。

import logging
import serial

logger = logging.getLogger(__name__)


def probe(port: str, request: bytes, expect_prefix: bytes, *,
          baudrate: int = 9600, timeout: float = 0.3, read_len: int = 32) -> bool:
    """読み出し専用の 1 コマンドを送り、期待する応答が返るかで機器を判定する。

    書き込み系・状態変更系のコマンドは絶対に使わない。
    """
    try:
        with serial.Serial(port, baudrate, timeout=timeout, write_timeout=timeout) as ser:
            ser.reset_input_buffer()      # 前回の残留データを捨ててから送る
            ser.write(request)
            ser.flush()
            resp = ser.read(read_len)
    except serial.SerialException as e:
        # 開けない・書けない(他プロセスが使用中、応答なしの write timeout など)
        logger.info("probe %s: 開けない/送れない (%s)", port, e)
        return False
    logger.info("probe %s: %r", port, resp)   # 送った先と応答を必ず残す
    return resp.startswith(expect_prefix)

相手機器がいない状態での実行結果です。

INFO probe COM3: 開けない/送れない (Write timeout)
COM3 -> False
INFO probe COM99: 開けない/送れない (could not open port 'COM99': FileNotFoundError(2, '指定されたファイルが見つかりません。', None, 2))
COM99 -> False
elapsed 0.41s

応答が無くても2 本で 0.41 秒(4 回実行して同じ値)で抜けました。timeoutwrite_timeout をどちらも 0.3 秒にしているためです。

⚠️ この方法は相手の機器を実際に動かします。使う前に必ず次を守ってください。

  • 読み出し専用のコマンドだけを使う — 設定変更・出力操作・リセットの類は送らない。通信仕様書で副作用が無いと明記されたものに限る
  • 通信条件が分かっている相手にだけ投げる — ボーレート・パリティ・ストップビットが違う相手に送ると、機器側はフレーミングエラーとして受け取ります。通信仕様が不明な機器を総当たりでプローブしないでください(probe()baudrate=9600 はあくまで既定値です)
  • 24 時間稼働中の設備には投げない — 未知のコマンドを受けた機器の挙動は仕様書に無いことがあります。計画停止のタイミングで
  • timeoutwrite_timeout を必ず設定する — 無いと相手のいないポートで止まります
  • どこに何を送ってどう返ったかをログに残す — 残さないと翌日の「なぜ B ラインを掴んだのか」に答えられません(ロギング設計の記事
  • 無応答とエラー応答の両方を想定する — 未知のコマンドを無視する機器とエラーを返す機器があり、startswith だけでは前者を取り違えます

順序としては最後です。予算が付くなら、シリアル番号を持つ変換器に替えるほうが速くて確実に終わります8 章)。

10. 見失ったあとに復帰する — 探し直しから始める再接続

常駐アプリでは「見失ったあと戻ってくる」ところまでが設計です。急所は再接続のたびにポート名を探し直すこと。最初に 1 回だけ解決して変数に持ち続けると、番号が変わったあと永久に古い番号を掴みにいきます。

# 断片です。6 章の find_port.py と同じフォルダに置いてください(handle() は自分の処理に置き換え)
import logging
import time

import serial

from find_port import PortSpec, open_device

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logger = logging.getLogger(__name__)

SPEC = PortSpec(vid=0x0403, pid=0x6001, serial_number="AB0123CD")
# retries=None = 見つかるまで待ち続ける。常駐アプリではこれが既定の選択
OPEN = dict(baudrate=9600, timeout=1.0, write_timeout=1.0, retries=None, wait=2.0)

ser = open_device(SPEC, **OPEN)
try:
    while True:
        try:
            ser.reset_input_buffer()
            ser.write(b"READ\r\n")
            resp = ser.read(32)
            if not resp:
                raise TimeoutError("no response")      # 読み取りタイムアウトは例外にならない
            handle(resp)
        except (serial.SerialException, TimeoutError) as e:
            logger.warning("通信断: %s — 探し直して再接続します", e)
            ser.close()
            ser = open_device(SPEC, **OPEN)      # 戻ってくるまで待つ
        time.sleep(1.0)
finally:
    ser.close()

補足を 3 点。retries は既定値 5 のまま常駐させないこと。既定では 5 回で打ち切って RuntimeErrorwhile の外へ抜けるので、機器が 30 分いなくなる段取り替えでは約 10 秒でプロセスが死にます。次に読み取りタイムアウトは例外になりません——read() は短い(または空の)バイト列を返すだけなので、長さを見て自分で例外に変換します。そして最後の例外を誰が受け取るかまで決めて 24 時間運用です。プロセスの生死は常駐化の仕組みに任せ、切断と再接続の履歴はロギング設計の記事の形で残します。

11. よくある質問(FAQ)

COM ポート番号が毎回変わるのはなぜですか?

Windows はデバイスインスタンス ID ごとに COM 番号を覚えており、この ID はシリアル番号を持たない機器では差込口の位置から作られます。別の差込口に挿すと別の機器とみなされ、空き番号台帳(ComDB)から新しい番号が払い出されます(2 章)。

COM ポート番号を固定するにはどうすればいいですか?

デバイスマネージャーで選べます(手順は 4.1)。ただしPC ごとの手作業なので、プログラムから使うなら find_port() で毎回探すほうが移植性で勝ります(3 章)。

「アクセスが拒否されました」と出るのはなぜですか?

番号が変わったのではなく、そのポートを他のプロセスが既に開いています(検証機で同じポートを 2 回開いて再現。エラー原文は 1 章の表)。ターミナルソフト・前回のスクリプト・常駐アプリの順に疑ってください。

serial_number が None や空になるのはなぜですか?

機器がシリアル番号を持っていない(無印 CH340 など・8 章)、②pyserial が値を取れず、None ではなく空文字列を返した——の 2 通りです。判定は if not p.serial_number: と書いてください(5.3)。

同じ型の変換器を 2 本挿したとき、どちらがどちらか区別できますか?

シリアル番号があれば区別できます。無ければ location(差込口の位置)ですが差込口のルールとラベル貼りが前提で、それも難しければ応答プローブが最後の手段です(9 章)。

COM 番号が増え続けて空きが無くなったらどうすればいいですか?

ComDB の claim は、機器を外しても解放されずに残ることがあります(公式には解放用の ComDBReleasePort というルーチンも存在します)。デバイスマネージャーの「表示」→「非表示のデバイスの表示」で過去のポートを削除する手順が一般に案内されますが、台帳のバイナリ値を手で書き換えるのは勧めません(壊すと番号の重複割り当てが起きます)。そもそも 6 章の形なら、番号が何番でもアプリは動き続けます(この項は当サイトでは未実施)。

Linux や macOS でも同じ問題は起きますか?

起きます。名前が COM3 ではなく /dev/ttyUSB0(Linux)や /dev/tty.usbserial-XXXX(macOS)になるだけで、挿す順番や差込口で末尾の番号が入れ替わる点は同じです。対策も同じで、6 章find_port()p.device の中身が変わるだけでそのまま使えます(OS 側で固定するなら Linux は /dev/serial/by-id/ か udev ルール)。Linux / macOS は当サイトでは未検証です(OS 差の解説は pySerial の基本記事 11.3)。

12. 自分の環境で確かめる — 追試 7 手順と、当サイトが測れなかったこと

どこまでが自宅の検証機で動かした結果で、どこからが一次資料からの整理なのかを並べます。

実測した(自宅の検証機で実行)実測していない(一次資料からの整理)
ComDB の中身とビット構造(0x0C → COM3・COM4) 差し直しで COM 番号が変わる現象そのもの(2 章は公式ドキュメント 3 本からの整理)
PortName の実値(インスタンスごとに番号を保持) IgnoreHWSerNum を作成したときの挙動(4.2 は公式記述の引用のみ)
usbflags のサブキー数とキー名 12 文字 チップ別のシリアル番号の実際の値(8 章はデータシートの突き合わせ)
comports() の全属性と並び順(5 回とも ['COM4', 'COM3'] 同一 VID/PID 2 本での location の差(9.1 は公式定義とソース読解に基づく設計)
hwid の組み立てと serial_numberNone/空文字列 実機相手の応答プローブ(9.2 は相手がいないときに False で抜ける形まで)
grep() がジェネレータを返すこと/エラー原文 2 種 find_port()probe()実機(応答を返す機器)相手の通し確認(6 章9.2 に載せたのは相手機器なしでの実行結果)
pyserial 3.5 のソース照合(venv 内の現物)/find_port()open_device()probe() の分岐(相手機器なしの検証機で実行)

レジストリは読み取りのみで、値の書き換えは一切していません(IgnoreHWSerNum を試さなかったのもこの方針のためです)。右の列は「当サイトでは確認できていない」という意味で、間違いという意味ではありません

変換器をお持ちなら、次の 7 手順で未測定部分を埋められます。結果が本記事と食い違ったら、それはあなたの環境での事実です

  1. 挿す前に python -m serial.tools.list_ports -v と ComDB のビットを記録(2.1
  2. 1 本を同じ差込口で 3 回、続けて別の差込口で 3 回(うち 1 回はハブ経由)抜き差しする。毎回 device / vid / pid / serial_number / location / hwid を CSV に残し、番号が戻るか増えるかを記録
  3. チップの違う 2 本を同時に挿し、VID/PID で区別できることを確認
  4. 同じ型の 2 本を同時に挿す。serial_number が両方空になるか、location の差だけで区別できるか(本記事の核心の未測定部分)
  5. 4. の状態で PC を再起動し、location が変わらないことを確認
  6. IgnoreHWSerNum を試すなら、該当キーのバックアップと、稼働設備につながっていない PC であることを先に確認
  7. 手元の機器に 9.2probe() を走らせ、応答で個体を確定できるかを試す

検証環境: 自宅の検証機(Windows 11 Pro ビルド 26200.9168 / Python 3.12.10 / pyserial 3.5。pyserial は 2020-11-23 リリースで、執筆時点の最新安定版)。会社支給の端末・業務用 PC は一切使用していません。pyserial は検証用の専用仮想環境に導入し、管理者権限は使っていません。COM ポートは Bluetooth 経由の仮想ポート 2 本のみです。実測はいずれも 2026 年 9 月 3 日に行いました。

実機に適用する前に確認すること

⚠️ 本記事のコードと数値は当サイトの検証機での確認に基づくもので、ポートの自動特定はアプリの動作を保証する仕組みではありません。実際の設備・現場 PC に適用するときは、次の 4 点が前提です。

  • 対象の PC とネットワークについて設備保全部門・情報システム部門の承認を得る
  • レジストリ変更は PC 全体に効くため他部署のスクリプトへの影響を事前に確認し、必ずバックアップを取ってから行う
  • 9.2 の応答プローブは相手機器を実際に動かすため、読み出し専用のコマンドに限り、計画停止のタイミングで検証する
  • 24 時間稼働中の PLC・制御機器につながったポートには試験的な書き込みを行わない

ポートの自動特定は、安全インターロック・安全 PLC・法定の警報装置の代替にはなりません。適用による設備の停止・データ欠損・機会損失について、筆者・GenbaPy は責任を負いません。

関連記事

参考文献・一次情報