Composite Debug Tool: GUI test tổng hợp cho STM32 USB

GUI Python gộp CDC log, vendor command và RAM dump vào một màn hình: pattern queue-poll thống nhất trong tkinter, và vì sao viết GUI mới không đụng vào phần USB đã có.

6 phút đọc
STM32 / Firmware cover

Bài 8/8 trong series USB Device trên STM32.

Trước khi đọc bài này

Bài 7 đã có CLI tool (vendor_test.py) test được 4 vendor command. Bài này thêm GUI gộp CDC log realtime và toàn bộ vendor panel vào một cửa sổ. Milestone: lab-14-composite-debug-tool-gui.

Ý chính bài này

  • GUI viết mới ở lab-14, nhưng không đụng vào code giao tiếp USB - tận dụng vendor_usb.py đã test xong từ lab-13.
  • Một queue, một vòng poll duy nhất xử lý cả CDC log lẫn dump progress - không phải hai cơ chế khác nhau.
  • CDC và USB vendor là hai kết nối độc lập, mất một vẫn dùng được cái kia.
  • tkinter không thread-safe: mọi cập nhật UI phải quay về main thread qua queue.

Thiết kế tổng quan

01 COM port

CdcLogger thread

Đọc dòng log liên tục, đẩy vào _result_queue.

02 USB

Dump worker thread

Đọc Bulk IN, đẩy progress vào cùng _result_queue.

03 Queue

_result_queue

Điểm gặp duy nhất giữa các thread và main thread.

04 Poll

self.after(_poll)

main thread, chạy định kỳ, lấy hết item ra khỏi queue.

05 UI

Widget cập nhật

Text log, progress bar - chỉ sửa từ đây.

Hai worker thread, một queue, một vòng poll cập nhật UI.

Hai nguồn dữ liệu khác hẳn nhau (log CDC liên tục, tiến độ dump rời rạc) nhưng dùng chung đúng một cơ chế đưa dữ liệu về main thread - không phải hai pattern riêng cho hai việc.

Source map

tools/
├─ vendor_usb.py           // protocol layer, dùng chung CLI và GUI (lab-13)
├─ vendor_test.py          // CLI smoke test (lab-13)
├─ requirements.txt        // + pyserial (GUI cần đọc CDC qua COM port)
└─ composite_debug_tool/
   ├─ main.py               // entry point, trỏ về vendor_usb.py ở thư mục cha
   ├─ gui.py                // layout, _result_queue, _poll (lab-14)
   ├─ cdc_logger.py         // thread đọc COM port, đẩy vào queue
   └─ hex_view.py           // hex preview cho RAM dump

main.py dùng sys.path.insert trỏ về thư mục tools/ cha thay vì giữ bản copy vendor_usb.py riêng - một bản duy nhất, không lo hai bản lệch nhau.

Vì sao viết GUI mới không đụng vào phần USB

gui.py là code mới hoàn toàn, viết ở lab-14. Nhưng viết mới không có nghĩa viết lại từ đầu: vendor_usb.py (protocol layer) đã tồn tại và được kiểm chứng từ lab-13, qua vendor_test.py chạy CLI thành công cả 4 lệnh trước khi GUI tồn tại.

# gui.py - chỉ gọi lại API đã có, không viết thêm logic giao tiếpinfo = self._usb.get_firmware_info()ok = self._usb.set_led_mode(mode)result = self._usb.start_ram_dump(progress_cb=progress_cb)

GUI không viết thêm ctrl_transfer nào, không tự parse lại FirmwareInfo_t theo cách khác. Toàn bộ phần khó (định dạng request, đọc response, đọc Bulk IN theo acceptedLength) đã giải quyết xong ở lab-13. Việc của lab-14 chỉ là lớp trình bày: layout, thread, queue, progress bar, hex view - không chạm vào code giao tiếp USB.

Quyết định 1: một queue cho mọi thứ cần về main thread

tkinter không thread-safe - mọi thao tác lên widget phải chạy trên main thread. Thay vì có nhiều pattern khác nhau cho từng loại dữ liệu, GUI dùng đúng một _result_queue, gắn tag ở phần tử đầu tiên để _poll biết xử lý ra sao:

# Từ thread đọc CDC (cdc_logger.py) và thread dump (gui.py) - khác nhau về# nguồn nhưng cùng đẩy vào một queue duy nhấtself._result_queue.put(("progress", received, total))self._result_queue.put(("dump_done", result))
# _poll - main thread, gọi lại chính nó qua self.after mỗi POLL_MSdef _poll(self):    while not self._result_queue.empty():        item = self._result_queue.get_nowait()        # ... rẽ nhánh theo item[0] ...    self.after(self.POLL_MS, self._poll)

CDC log dùng queue riêng của nó (cdc_logger.py tự có queue.Queue), nhưng theo đúng nguyên lý giống hệt: thread nền chỉ đẩy dữ liệu thô vào queue, không bao giờ chạm widget trực tiếp - main thread mới là nơi duy nhất được sửa UI.

Quyết định 2: CdcLogger tự quản lý vòng đời thread

class CdcLogger:    def connect(self, port: str, baudrate: int = 115200) -> None:        self._stop_event.clear()        self._port = serial.Serial(port, baudrate, timeout=0.1)        self._thread = threading.Thread(target=self._reader, daemon=True)        self._thread.start()    def disconnect(self) -> None:        self._stop_event.set()        self._thread.join(timeout=2.0)        self._port.close()

timeout=0.1 cho Serial() là lý do vòng đọc dừng được: không có timeout, readline() block vô hạn khi chưa có dòng mới, thread không bao giờ quay lại kiểm tra _stop_event. daemon=True nghĩa là Python tự kill thread này khi chương trình chính thoát - không cần chờ đúng quy trình nếu người dùng đóng cửa sổ giữa chừng.

Hai kết nối độc lập

GUI có hai nút Connect riêng: một cho CDC (qua COM port, pyserial), một cho USB vendor (qua pyusb/libusbK). Mất kết nối này không ảnh hưởng kết nối kia

  • chỉ cần xem log thì không cần cài Zadig, chỉ cần test vendor command thì không cần mở CDC.

RAM dump: worker thread, progress qua queue

def _cmd_start_dump(self):    def progress_cb(received, total):        self._result_queue.put(("progress", received, total))    def worker():        result = self._usb.start_ram_dump(progress_cb=progress_cb)        self._result_queue.put(("dump_done", result))    threading.Thread(target=worker, daemon=True).start()

progress_cb chạy trong worker thread (được vendor_usb.py gọi lại mỗi khi nhận thêm dữ liệu), chỉ làm đúng một việc: đẩy vào queue. _poll ở main thread đọc queue và cập nhật thanh tiến độ - đúng pattern đã nói ở Quyết định 1, không phải cơ chế riêng cho dump.

Sau khi dump xong, hex_view.py hiển thị preview đầu file dạng hex + ASCII để xác nhận có nội dung thật. Nút Save lưu toàn bộ 147456 byte ra file .bin.

Video demo

Video demo

Thứ tự demo: Connect → Get Info → Repeat Toggle → LED Control → RAM Dump.

Tóm tắt series

Bài 8 là điểm kết của series, nhưng không phải điểm kết của repo. lab-15 (USB lifecycle) merge sau đó, xử lý reset/suspend/resume cho dump dở dang và TX flag kẹt - chi tiết ở mục “USB lifecycle” trong trang project Composite Device.

Nhìn lại toàn bộ series, hai điều đáng nhớ nhất không phải chi tiết giao thức USB:

  1. Build OK không đủ. Phần lớn bug composite class (lab-09) không gây lỗi build. USBView và Wireshark mới là nơi phát hiện ra.
  2. Tách layer trả giá trị thật, không phải nguyên tắc suông. GUI viết mới ở lab-14 gần như không chạm vào code USB - vì vendor_usb.py đã tách riêng và test xong từ lab-13.

Tài liệu tham khảo

Bài viết này hữu ích với bạn?

Chia sẻ, góp ý, hoặc ủng hộ nếu bạn thấy nội dung này có giá trị.

Góp ý