Python

Pythonで作る!HEX表示対応のシリアル通信モニタGUIアプリ徹底解説(App004)

tyamada

HEX表示対応のシリアルモニタGUIアプリとは?

シリアル通信は、マイコンや組み込み機器の開発で欠かせない通信手段です。
しかし、受信データをリアルタイムに確認したいとき、ターミナルソフトだけでは物足りないこともあります。

そこで今回は、Pythonとtkinter、そしてpyserialを使って作る HEX表示対応のシリアル通信モニタGUIアプリ を紹介します。
このアプリを使えば、文字列だけでなく、バイナリデータも16進数で見やすく表示できるため、組み込み開発やデバッグに非常に役立ちます。

特徴まとめ:

  • COMポートの自動検出
  • ボーレート、パリティ設定
  • 受信データをリアルタイム表示(テキスト / HEX切替可能)
  • 任意の文字列送信
  • 受信ログの保存
  • UIがフリーズしないスレッド設計(queue利用)
ソースコードを全て表示
#!/usr/bin/env python3
# serial_monitor_hex.py
"""
Serial Monitor GUI (tkinter + pyserial)
Now includes HEX (binary) view mode.

Features:
- Auto-detect COM ports
- Baudrate and parity selection
- Real-time received data display (Text / HEX)
- Threaded reader using queue
- Arbitrary text send
- Log save
Requires:
    pip install pyserial
"""

import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import threading
import queue
import time

try:
    import serial
    import serial.tools.list_ports
except Exception as e:
    raise SystemExit("pyserial is required. Install with: pip install pyserial\n" + str(e))


class SerialMonitorHex(ttk.Frame):

    def __init__(self, parent):
        super().__init__(parent)
        self.parent = parent
        self.parent.title("Serial Monitor + HEX Mode")
        self.pack(fill=tk.BOTH, expand=True)

        # serial objects
        self.ser = None
        self.read_thread = None
        self.stop_event = threading.Event()
        self.rx_queue = queue.Queue()

        # UI states
        self.autoscroll = tk.BooleanVar(value=True)
        self.hex_mode = tk.BooleanVar(value=False)   # ★ HEXモード追加

        self._build_ui()
        self._populate_baud_parity()
        self.refresh_ports()

        self.parent.after(100, self._process_rx_queue)
        self.parent.protocol("WM_DELETE_WINDOW", self._on_close)

    # ---- UI Building --------------------------------------------------

    def _build_ui(self):
        top = ttk.Frame(self)
        top.pack(side=tk.TOP, fill=tk.X, padx=6, pady=6)

        # COM Port
        ttk.Label(top, text="COM Port:").grid(row=0, column=0, sticky=tk.W)
        self.port_cb = ttk.Combobox(top, width=18, state="readonly")
        self.port_cb.grid(row=0, column=1, padx=3)
        ttk.Button(top, text="Refresh", command=self.refresh_ports).grid(row=0, column=2, padx=3)

        # Baudrate
        ttk.Label(top, text="Baud:").grid(row=0, column=3, padx=(12,3))
        self.baud_cb = ttk.Combobox(top, width=10)
        self.baud_cb.grid(row=0, column=4, padx=3)

        # Parity
        ttk.Label(top, text="Parity:").grid(row=0, column=5, padx=(12,3))
        self.parity_cb = ttk.Combobox(top, width=10)
        self.parity_cb.grid(row=0, column=6, padx=3)

        self.open_btn = ttk.Button(top, text="Open", command=self.open_port)
        self.open_btn.grid(row=0, column=7, padx=(12,3))
        self.close_btn = ttk.Button(top, text="Close", command=self.close_port, state=tk.DISABLED)
        self.close_btn.grid(row=0, column=8, padx=3)

        # 2-pane layout
        pw = ttk.Panedwindow(self, orient=tk.HORIZONTAL)
        pw.pack(fill=tk.BOTH, expand=True, padx=6, pady=(0,6))

        # left panel
        left = ttk.Frame(pw, width=300)
        pw.add(left, weight=0)

        ttk.Label(left, text="Send Data:").pack(anchor=tk.W, padx=4, pady=(6,0))
        self.send_entry = ttk.Entry(left)
        self.send_entry.pack(fill=tk.X, padx=4)

        send_row = ttk.Frame(left)
        send_row.pack(fill=tk.X, padx=4, pady=4)

        ttk.Label(send_row, text="EOL:").grid(row=0, column=0)
        self.eol_var = tk.StringVar(value="None")
        eol_opts = ["None", "\\n", "\\r", "\\r\\n"]
        self.eol_cb = ttk.Combobox(send_row, values=eol_opts, state="readonly", width=8)
        self.eol_cb.grid(row=0, column=1, padx=4)

        ttk.Button(send_row, text="Send", command=self.send_text).grid(row=0, column=2, padx=2)

        # buttons
        btn_row = ttk.Frame(left)
        btn_row.pack(fill=tk.X, padx=4, pady=8)
        ttk.Button(btn_row, text="Clear", command=self.clear_log).grid(row=0, column=0, padx=2)
        ttk.Button(btn_row, text="Save Log...", command=self.save_log).grid(row=0, column=1, padx=2)

        # autoscroll + hex mode
        ttk.Checkbutton(left, text="Autoscroll", variable=self.autoscroll).pack(anchor=tk.W, padx=4)
        ttk.Checkbutton(left, text="HEX表示", variable=self.hex_mode).pack(anchor=tk.W, padx=4, pady=(4,0))

        # right panel (text output)
        right = ttk.Frame(pw)
        pw.add(right, weight=1)

        ttk.Label(right, text="Received:").pack(anchor=tk.W, padx=4, pady=(6,0))
        self.text = tk.Text(right, wrap="none")
        self.text.pack(fill=tk.BOTH, expand=True, padx=4, pady=(0,4))

        # scrollbars
        vs = ttk.Scrollbar(right, orient="vertical", command=self.text.yview)
        vs.place(in_=self.text, relx=1, rely=0, relheight=1, anchor="ne")
        self.text.configure(yscrollcommand=vs.set)

    # ---- Config -------------------------------------------------------

    def _populate_baud_parity(self):
        self.baud_cb["values"] = ["1200","2400","4800","9600","19200","38400","57600","115200"]
        self.baud_cb.set("115200")
        opts = ["N (None)", "E (Even)", "O (Odd)", "M (Mark)", "S (Space)"]
        self.parity_cb["values"] = opts
        self.parity_cb.set("N (None)")

    def refresh_ports(self):
        ports = serial.tools.list_ports.comports()
        lst = [p.device for p in ports]
        self.port_cb["values"] = lst
        if lst and not self.port_cb.get():
            self.port_cb.set(lst[0])

    # ---- Serial Port -------------------------------------------------

    def open_port(self):
        port = self.port_cb.get()
        if not port:
            messagebox.showwarning("No port", "Select COM port")
            return

        try:
            baud = int(self.baud_cb.get())
        except:
            messagebox.showwarning("Bad Baud", "Invalid baud")
            return

        parity_chr = self.parity_cb.get()[0]
        parity_map = {
            "N": serial.PARITY_NONE,
            "E": serial.PARITY_EVEN,
            "O": serial.PARITY_ODD,
            "M": serial.PARITY_MARK,
            "S": serial.PARITY_SPACE,
        }
        try:
            self.ser = serial.Serial(port=port, baudrate=baud,
                                     parity=parity_map.get(parity_chr, serial.PARITY_NONE),
                                     timeout=0.1)
        except Exception as e:
            messagebox.showerror("Open Failed", str(e))
            return

        # start thread
        self.stop_event.clear()
        self.read_thread = threading.Thread(target=self._reader_worker, daemon=True)
        self.read_thread.start()

        self.open_btn.config(state=tk.DISABLED)
        self.close_btn.config(state=tk.NORMAL)
        self._append(f"--- Opened {port} ---\n")

    def close_port(self):
        self.stop_event.set()
        if self.read_thread:
            self.read_thread.join(timeout=1)

        if self.ser:
            try: self.ser.close()
            except: pass
            self.ser = None

        self.open_btn.config(state=tk.NORMAL)
        self.close_btn.config(state=tk.DISABLED)
        self._append("--- Closed ---\n")

    # ---- Reader Thread ----------------------------------------------

    def _reader_worker(self):
        while not self.stop_event.is_set():
            if not (self.ser and self.ser.is_open):
                break
            try:
                data = self.ser.read(1024)  # raw bytes
                if data:
                    ts = time.strftime("%Y-%m-%d %H:%M:%S")
                    self.rx_queue.put((ts, data))
            except Exception as e:
                self.rx_queue.put(("ERROR", str(e).encode()))
                break
            time.sleep(0.01)

    # ---- Queue Processing -------------------------------------------

    def _process_rx_queue(self):
        try:
            while True:
                ts, data = self.rx_queue.get_nowait()

                if ts == "ERROR":
                    self._append(f"[ERROR] {data}\n")
                    continue

                # ★ HEX表示モード
                if self.hex_mode.get():
                    hex_str = self._to_hex_lines(data)
                    self._append(f"[{ts}] HEX:\n{hex_str}\n")
                else:
                    # 通常テキスト表示
                    try:
                        s = data.decode("utf-8", errors="replace")
                    except:
                        s = str(data)
                    self._append(f"[{ts}] {s}")
        except queue.Empty:
            pass

        self.parent.after(100, self._process_rx_queue)

    # ---- HEX format --------------------------------------------------

    def _to_hex_lines(self, b: bytes) -> str:
        """
        Convert bytes to 16-byte hex dump.
        e.g. "48 65 6C 6C 6F ..."
        """
        lines = []
        for i in range(0, len(b), 16):
            chunk = b[i:i+16]
            hex_part = " ".join(f"{x:02X}" for x in chunk)
            lines.append(hex_part)
        return "\n".join(lines)

    # ---- UI Helpers --------------------------------------------------

    def _append(self, text):
        self.text.insert(tk.END, text)
        if self.autoscroll.get():
            self.text.see(tk.END)

    def send_text(self):
        if not (self.ser and self.ser.is_open):
            return
        msg = self.send_entry.get()
        eol = self.eol_var.get()
        if eol == "\\n":
            msg += "\n"
        elif eol == "\\r":
            msg += "\r"
        elif eol == "\\r\\n":
            msg += "\r\n"

        try:
            self.ser.write(msg.encode("utf-8"))
            ts = time.strftime("%Y-%m-%d %H:%M:%S")
            self._append(f"[{ts}] SENT: {msg}\n")
        except Exception as e:
            messagebox.showerror("Send Error", str(e))

    def clear_log(self):
        self.text.delete("1.0", tk.END)

    def save_log(self):
        content = self.text.get("1.0", tk.END)
        path = filedialog.asksaveasfilename(defaultextension=".txt")
        if not path:
            return
        with open(path, "w", encoding="utf-8") as f:
            f.write(content)
        messagebox.showinfo("Saved", path)

    def _on_close(self):
        self.close_port()
        self.parent.destroy()


def main():
    root = tk.Tk()
    app = SerialMonitorHex(root)
    root.geometry("900x500")
    root.mainloop()


if __name__ == "__main__":
    main()

なぜ自作GUIが役立つのか?

市販のターミナルソフトでも同様の機能はありますが、自作GUIなら以下のメリットがあります。

  1. 学習目的に最適
    • Python、tkinter、pyserialの知識が身につきます。
    • スレッドやqueueの使い方など、中級者向けのテクニックも学べます。
  2. 自由な機能追加が可能
    • HEX表示モードや送信フォーマット変更など、自分の開発環境に合わせてカスタマイズ可能です。
  3. 視覚的にデータを確認できる
    • ログ表示をスクロールして確認でき、送信内容も時系列で追えます。
    • データの異常やバグを早期発見できます。

アプリの使い方

  1. COMポートと通信設定を選択
    • COMポートは自動検出されます。
    • ボーレートやパリティもプルダウンで選択可能です。
  2. 接続を開く
    • 「Open」ボタンでシリアルポートに接続します。
    • 接続状態はステータスバーで確認できます。
  3. 受信データの確認
    • 受信したデータはテキストで表示されます。
    • HEX表示モードをONにすると、バイナリデータが16進数で見やすく表示されます。
  1. 任意文字列を送信
    • EOL(改行コード)も設定可能です。
    • 「Send」ボタンで文字列を送信できます。
  2. ログの保存
    • 受信データは「Save Log」でテキストファイルとして保存できます。
    • 開発中のデバッグ記録として便利です。

まとめ

このアプリは 学習と開発効率の向上 の両方に役立ちます。
さらに自作アプリなので、今後機能拡張も自由自在です。

ちなみに、PythonでGUI開発やシリアル通信を快適に行うには、性能の良いPCがあると作業効率が格段に上がります。
小型ノートでも動きますが、リアルタイム通信や複数ウィンドウの操作を考えると 高性能PCを用意しておくと開発がスムーズ です。
(ここでさりげなくパソコン購入の誘導)

画像:HEX表示+送信操作のスクリーンショットを挿入


コード解説パート


1. Point:コード全体像

今回作成したアプリは tkinter GUI + pyserial + threading + queue で構成されています。
主な構造は以下の通りです。

SerialMonitorHex (tk.Frame)
│
├─ UI構築
│   ├─ COMポート選択
│   ├─ ボーレート/パリティ設定
│   ├─ 送信テキスト入力
│   ├─ HEX表示モード/Autoscroll
│   └─ 受信テキスト表示(Text widget)
│
├─ シリアル通信
│   ├─ open_port / close_port
│   └─ read_thread -> queue
│
├─ 受信処理
│   ├─ _process_rx_queue
│   └─ HEX表示 or テキスト表示
│
└─ 送信処理 / ログ保存

なぜこの設計にしたか

  1. スレッド + queue を使う理由
    • tkinterはシングルスレッドで動作するため、直接シリアルを読み込むとUIがフリーズします。
    • 背景スレッドで受信 → queue に入れる → メインスレッドで Text に表示、という設計で解決しています。
  2. HEX表示モードの必要性
    • 組み込み機器は文字列だけでなくバイナリデータを送受信することが多いです。
    • そのままテキスト表示すると文字化けするため、16進数で表示する機能を追加しました。

コードの主要部分解説

3-1. COMポートと通信設定

ports = serial.tools.list_ports.comports()
self.port_cb["values"] = [p.device for p in ports]
  • pyseriallist_ports で接続可能なCOMポートを自動取得します。
  • GUIのコンボボックスに反映されるため、選択ミスを防げます。

3-2. シリアルポートオープンとスレッド起動

self.ser = serial.Serial(port=port, baudrate=baud, parity=parity_map.get(parity_chr))
self.stop_event.clear()
self.read_thread = threading.Thread(target=self._reader_worker, daemon=True)
self.read_thread.start()
  • serial.Serial でポートを開きます。
  • 別スレッドで _reader_worker を動かすことで、UIがフリーズしない受信処理を実現しています。

3-3. 受信スレッド

def _reader_worker(self):
    while not self.stop_event.is_set():
        data = self.ser.read(1024)
        if data:
            ts = time.strftime("%Y-%m-%d %H:%M:%S")
            self.rx_queue.put((ts, data))
  • 受信したバイト列はそのまま queue に渡します。
  • メインスレッドで Text ウィジェットに表示する際に HEX表示 or 通常文字列を選択できます。

3-4. HEX表示モード

def _to_hex_lines(self, b: bytes) -> str:
    lines = []
    for i in range(0, len(b), 16):
        chunk = b[i:i+16]
        hex_part = " ".join(f"{x:02X}" for x in chunk)
        lines.append(hex_part)
    return "\n".join(lines)
  • 1行16バイトで分割して16進数表記に変換します。
  • デバッグ中にバイナリ通信内容を直感的に確認できます。

3-5. 送信機能

msg = self.send_entry.get()
if eol == "\\n": msg += "\n"
self.ser.write(msg.encode("utf-8"))
  • 任意の文字列を送信可能
  • 改行コードの種類も設定できるので、マイコン側の仕様に合わせて送信できます。

3-6. ログ保存

content = self.text.get("1.0", tk.END)
with open(path, "w", encoding="utf-8") as f:
    f.write(content)
  • 受信したデータはテキストファイルとして保存可能
  • デバッグ履歴として後から確認できます。

まとめ

今回のアプリは、実用性と学習の両立を意識した設計です。

  • TkinterのGUI設計
  • pyserialを使ったシリアル通信
  • バックグラウンドスレッドとqueueでUIフリーズ回避
  • HEX表示モードでバイナリ確認

このアプリを使えば、マイコン開発のデバッグやデータ解析が格段に効率化されます。

さらに、高性能PCを用意すれば、複数ウィンドウや大量ログでもサクサク動作するため、開発作業のストレスも大幅に減ります。


まとめ

  • PythonでGUIシリアルモニタを作ると、学習と開発の両方に役立ちます。
  • HEX表示モードを追加することで、バイナリデータも直感的に確認可能。
  • 送信機能やログ保存もあり、開発効率が大幅に向上します。
  • 開発環境に余裕を持たせるために、性能の良いPCを用意するとより快適です。

コメントを残す

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

ABOUT ME
tyamada
tyamada
普通の会社員(平)
記事URLをコピーしました