Pythonで作るリアルタイムIoTダッシュボード「IoTDashboardApp」完全解説|MQTT対応の可視化アプリをTkinterで構築する方法(App006)
tyamada
Pythonランド
シリアル通信は、マイコンや組み込み機器の開発で欠かせない通信手段です。
しかし、受信データをリアルタイムに確認したいとき、ターミナルソフトだけでは物足りないこともあります。
そこで今回は、Pythonとtkinter、そしてpyserialを使って作る HEX表示対応のシリアル通信モニタGUIアプリ を紹介します。
このアプリを使えば、文字列だけでなく、バイナリデータも16進数で見やすく表示できるため、組み込み開発やデバッグに非常に役立ちます。
特徴まとめ:

#!/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なら以下のメリットがあります。

このアプリは 学習と開発効率の向上 の両方に役立ちます。
さらに自作アプリなので、今後機能拡張も自由自在です。
ちなみに、PythonでGUI開発やシリアル通信を快適に行うには、性能の良いPCがあると作業効率が格段に上がります。
小型ノートでも動きますが、リアルタイム通信や複数ウィンドウの操作を考えると 高性能PCを用意しておくと開発がスムーズ です。
(ここでさりげなくパソコン購入の誘導)
画像:HEX表示+送信操作のスクリーンショットを挿入
今回作成したアプリは 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 テキスト表示
│
└─ 送信処理 / ログ保存
ports = serial.tools.list_ports.comports()
self.port_cb["values"] = [p.device for p in ports]
pyserial の list_ports で接続可能なCOMポートを自動取得します。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がフリーズしない受信処理を実現しています。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 通常文字列を選択できます。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)

msg = self.send_entry.get()
if eol == "\\n": msg += "\n"
self.ser.write(msg.encode("utf-8"))
content = self.text.get("1.0", tk.END)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
今回のアプリは、実用性と学習の両立を意識した設計です。
このアプリを使えば、マイコン開発のデバッグやデータ解析が格段に効率化されます。
さらに、高性能PCを用意すれば、複数ウィンドウや大量ログでもサクサク動作するため、開発作業のストレスも大幅に減ります。
コメントを残す