プログラミング実践

【Python】PySerial のコードを今風に書き直す(with文・型ヒント・readline)

記事内に商品プロモーションを含む場合があります

どうも、コンです。

以前 【シリアル通信】PySerialの基本的な使い方【Python】 という記事を書いたのですが、改めて読み直してみるとコードが少し古い書き方になっていました。動きはするものの、PEP 8 の慣習や Python 3.6 以降で標準になった書き方を踏まえて整理すると、もっと短く・意図が伝わりやすい書き方ができます。

この記事では、元記事のサンプルコードをベースに、PySerial のコードを今風に書き直す4つのポイントを「動くフルコード」で紹介します。各サンプルはそのままコピペして実行できる形です。

結論(先出し)

以下の4つを押さえると、PySerial のコードが短く、現代の Python らしい見た目になります。

  1. with 文でポートのオープン/クローズを自動化する
  2. 関数に型ヒントを付けて引数と戻り値の意図を明示する
  3. 固定バイトの read(n) ではなく readline()read_until() を使う
  4. バイト列リテラル b'...' と f-string で文字列処理を簡潔にする

元記事のコード(おさらい)

まず、元記事に出てきたサンプルを「ポート検知 → 開く → 送信 → 受信 → 閉じる」までひと続きにつなげたフルバージョンを示します。これがビフォアの基準になります。

import serial
import serial.tools.list_ports
# シリアルポートを自動検出する関数
def auto_detect_serial_port():
    ports = list(serial.tools.list_ports.comports())
    for p in ports:
        print(p)
        if 'USB Serial Port' in p.description:
            return p.device
    return None
# シリアルポートをオープンする
ser = serial.Serial(auto_detect_serial_port(), 9600, timeout=1)
# 送信
data = 'Hello, World!'
ser.write(data.encode())
# 受信
data = ser.read(10)
print(data.decode())
# シリアルポートをクローズする
ser.close()

今風に書き直したコード(完成版)

4つのポイントを反映すると、同じ処理が以下のフルコードになります。これがアフターの最終形です。

import serial
import serial.tools.list_ports
def find_port(keyword: str = 'USB Serial') -> str:
    for port in serial.tools.list_ports.comports():
        if keyword in port.description:
            return port.device
    raise RuntimeError(f'シリアルポートが見つかりません: {keyword!r}')
def main() -> None:
    with serial.Serial(find_port(), 9600, timeout=1) as ser:
        ser.write(b'Hello, World!\n')
        line = ser.readline().decode().rstrip()
        print(f'受信: {line}')
if __name__ == '__main__':
    main()

元記事の同等処理が20行・クローズ忘れリスク付きだったのに対し、関数1つ・with ブロック1つで完結します。ここから何をどう変えたのか、4つのポイントに分けて順に見ていきます。

1. with 文で close() を自動化する

元記事は serial.Serial(...) でポートを開き、最後に ser.close() を呼ぶ書き方でした。送受信だけを抜き出すと以下のような流れになります。

import serial
ser = serial.Serial('COM3', 9600, timeout=1)
ser.write('Hello, World!'.encode())
data = ser.read(10)
print(data.decode())
ser.close()

この書き方の弱点は、途中で例外が発生すると ser.close() まで到達せず、ポートが開きっぱなしになることです。try/finally で囲む手もありますが、PySerial の Serial クラスはコンテキストマネージャに対応しているので、with 文を使うのが一番素直な書き方です。

import serial
with serial.Serial('COM3', 9600, timeout=1) as ser:
    ser.write('Hello, World!'.encode())
    data = ser.read(10)
    print(data.decode())

with ブロックを抜けるときに、例外があってもなくても close() が呼ばれます。open() でファイルを扱うときと同じ感覚です。詳細は PySerial 公式 – Serial クラス のリファレンスを参照してください。

2. 関数に型ヒントを付ける

元記事ではシリアルポートを自動検知する関数を以下のように書いていました。

import serial
import serial.tools.list_ports
# シリアルポートを自動検出する関数
def auto_detect_serial_port():
    ports = list(serial.tools.list_ports.comports())
    for p in ports:
        print(p)
        if 'USB Serial Port' in p.description:
            return p.device
    return None
# シリアルポートをオープンする
ser = serial.Serial(auto_detect_serial_port(), 9600, timeout=1)
# シリアルポートをクローズする
ser.close()

気になる点は2つあります。

  • 引数も戻り値も型がわからず、IDE の補完が効きづらい
  • 見つからないと None を返すので、serial.Serial(None, ...) となり挙動が分かりにくい

型ヒントを付けて、検索ワードを引数化し、見つからない場合は例外を投げる形に書き直します。

import serial
import serial.tools.list_ports
def find_port(keyword: str = 'USB Serial') -> str:
    for port in serial.tools.list_ports.comports():
        if keyword in port.description:
            return port.device
    raise RuntimeError(f'シリアルポートが見つかりません: {keyword!r}')
with serial.Serial(find_port(), 9600, timeout=1) as ser:
    pass  # ここでシリアル通信を行う

keyword: str = 'USB Serial' と書くことで、Arduino を狙うなら 'Arduino'、Silicon Labs の USB-UART なら 'CP210x' のように呼び出し側で切り替えられます。戻り値が必ず str と決まっているので、serial.Serial() に渡す側も None チェックなしで扱えます。

3. read(n) より readline() / read_until() を使う

元記事では受信を ser.read(10) で書いていました。送信からの一連の流れは以下です。

import serial
with serial.Serial('COM3', 9600, timeout=1) as ser:
    ser.write('PING\n'.encode())
    data = ser.read(10)
    print(data.decode())

read(n) は最大 n バイトを読む API なので、相手が「9バイトしか送ってこなかった」「11バイト目以降を次の読み取りで処理したい」という場面で扱いづらくなります。多くのマイコン側プロトコルは改行区切り(\n)でメッセージを送ってくるので、readline() で行単位に読む方が素直です。

import serial
with serial.Serial('COM3', 9600, timeout=1) as ser:
    ser.write(b'PING\n')
    line = ser.readline().decode().rstrip()
    print(f'受信: {line}')

終端文字が \n 以外なら read_until(b'>') のように区切りを指定できます。固定バイトを読みたい用途も残るので read(n) 自体が悪いわけではなく、用途に応じて使い分けるイメージです。

4. b’…’ リテラルと f-string で文字列処理を簡潔に

元記事の送信コードでは、文字列を変数に入れてから encode() していました。

import serial
with serial.Serial('COM3', 9600, timeout=1) as ser:
    data = 'Hello, World!'
    ser.write(data.encode())

定数の文字列を送るだけなら、最初からバイト列リテラル b'...' で書いておく方がシンプルです。動的な値を組み立てる場合は f-string と .encode() を組み合わせます。

import serial
with serial.Serial('COM3', 9600, timeout=1) as ser:
    # 定数: バイト列リテラルそのまま
    ser.write(b'Hello, World!\n')
    # 動的: f-string で組み立てて encode
    led_id = 42
    brightness = 100
    ser.write(f'LED{led_id}:{brightness}\n'.encode())

受信したデータをログ出力する箇所も、% 記法や str.format() ではなく f-string で書くのが今は標準的です(PEP 498、Python 3.6 以降)。

まとめ

  • with 文を使うとクローズ忘れと例外時のリーク両方を一度に防げる
  • 関数に型ヒントを付けると IDE 補完が効き、戻り値の契約が明確になる
  • 受信は固定バイトの read(n) より readline() / read_until() の方がプロトコルに合わせやすい
  • 定数バイト列は b'...'、動的なら f-string + .encode() を使う

PySerial の API 自体はここ数年大きく変わっていないので、書き方を整えるだけで読みやすさはかなり改善できます。元記事のコードでも動作はするのですが、これから書く方には今回紹介した4ポイントを反映した形をおすすめします。

ここまで読んでいただきありがとうございました。参考になれば幸いです。

COMMENT

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA