---
# System prepended metadata

title: 用 Cynthion + MCP 讓 LLM 逆向 USB 裝置 — 從零到端對端實作
tags: [逆向工程, ClaudeCode, USB, Cynthion, FPGA, MCP, LLM, Anthropic, Facedancer]

---

---
title: 用 Cynthion + MCP 讓 LLM 逆向 USB 裝置 — 從零到端對端實作
date: 2026-05-11
tags: [USB, Cynthion, Facedancer, MCP, LLM, 逆向工程, Anthropic, ClaudeCode, FPGA]
status: published
description: 一個下午把開源 USB 測試儀 Cynthion 接上 Model Context Protocol，讓 Claude Code 用自然語言抓 USB 封包、解碼、從裝置 descriptor 反推身分、再用 Facedancer 模擬一個假裝置。17 個 MCP tool、3 個 Cynthion frame 格式陷阱加 6 個軟體層 bug（合計 9 個）、1 顆從 wire 還原指紋的藍牙 dongle 的完整實作筆記。
---

# 用 Cynthion + MCP 讓 LLM 逆向 USB 裝置 — 從零到端對端實作

![cynthion-mcp-hero](https://hackmd.io/_uploads/ByS5gN11zl.jpg)

> **TL;DR**：把 [Cynthion](https://greatscottgadgets.com/cynthion/) 這片 FPGA-based 開源 USB 測試儀包裝成 Model Context Protocol server，給 Claude Code 提供 17 個動詞 tool（capture、dissect、emulate、…），讓 LLM 能用自然語言跑完整的 USB 逆向工程流程。中間踩到 3 個 Cynthion 自家 frame 格式的解碼 bug，加上 6 個軟體層 bug（合計 9 個），最後成功從 passive wire capture 還原一顆未知藍牙 dongle 的完整指紋（含 MAC），並 clone 出一個 FTDI 裝置給 macOS IOKit 認到。完整 source code 在 [Oliver0804/cynthion-mcp](https://github.com/Oliver0804/cynthion-mcp) 。

---

## 起點：一個 7 個字的 prompt

> 「TARA 是什麼裝置？」

我把一顆陌生的 USB 裝置插到 Cynthion 的 TARGET-A 埠，然後在 Claude Code 對話框打了這 7 個字。三十秒後 Claude 回我：

> Edimax Bluetooth Adapter（Realtek RTL chip），VID `7392:C611`，藍牙 MAC `00:E0:4C:23:99:87`，Full-Speed，標準 USB Bluetooth HCI class，支援 ACL data 與 SCO 語音。

它沒裝任何 driver、沒去問裝置本身、沒用任何 OS 端的 USB 列舉資訊 —— 只是抓了 28 秒的 USB wire-level 流量，把裡面的 device descriptor 18 個位元組逐欄解出來，順便 cross-check 了 string descriptor 跟 OUI 廠商編碼。

這篇文章是這個能力背後一個下午的工程實作筆記：從讀 [LUNA](https://github.com/greatscottgadgets/luna) 跟 [Cynthion](https://github.com/greatscottgadgets/cynthion) 兩個 repo 搞清楚誰幹什麼，到設計一個能讓 LLM 操作硬體的 MCP server，到撞到 3 個 frame 格式的 bug、9 個軟體層的 bug，最後跑通 sniffer → decoder → 逆向 → emulator 全鏈路。

如果你對嵌入式 + USB 協定 + LLM tooling 三者的交叉感興趣，這篇是寫給你的。

### 前置條件

| 項目 | 版本 / 說明 |
|---|---|
| 硬體 | [Cynthion r1.4](https://greatscottgadgets.com/cynthion/) （FPGA：Lattice LFE5U-12F，ECP5 系列 12K LUT；輔助 MCU：SAMD11） |
| Host | macOS（Linux 應該也行，沒在這篇實測） |
| Python | 3.12 |
| 關鍵套件 | `cynthion==0.2.4`、`facedancer==3.1.1`（**必須 pin**，下面會解釋）、`mcp==1.27.1` |
| 解碼工具 | `tshark` from Wireshark 4.x（`brew install wireshark`） |
| LLM 客戶端 | Claude Code（任何 MCP-aware 客戶端皆可） |
| 本文 source | [Oliver0804/cynthion-mcp](https://github.com/Oliver0804/cynthion-mcp) |

---

## 工具拆解：LUNA、Cynthion、Facedancer、Packetry 誰幹什麼

開始實作之前先花 30 分鐘讀 GitHub 上四個 repo 的 README，搞清楚這個生態裡誰是引擎、誰是車、誰是儀表板。沒搞清楚會把資源放錯位置。

![cynthion-mcp-architecture](https://hackmd.io/_uploads/SJxheEk1Ml.jpg)

| 專案 | 角色 | 一句話 |
|---|---|---|
| [LUNA](https://github.com/greatscottgadgets/luna) | 引擎 | Amaranth HDL 寫的 USB gateware building blocks（USB 2.0 / 3.0 device/host）。是函式庫，不是產品。 |
| [Cynthion](https://github.com/greatscottgadgets/cynthion) | 車 | 用 LUNA 寫出來的成品 applet（`analyzer.bit` / `facedancer.bit` / `selftest.bit`）+ 硬體設計 + host 端 Python CLI。 |
| [Apollo](https://github.com/greatscottgadgets/apollo) | 引擎室 | Cynthion 板上的 debug MCU 韌體；負責 JTAG configure FPGA、SPI flash 上傳 bitstream。 |
| [Facedancer](https://github.com/greatscottgadgets/facedancer) | 駕駛艙 | Python 框架，讓你寫 `class MyUSBDevice(USBDevice)` 來模擬 USB 裝置；後端可用 Cynthion 或舊版 GreatFET。3.0 是 [ground-up rewrite](https://github.com/greatscottgadgets/facedancer/blob/main/README.md) 。 |
| [Packetry](https://github.com/greatscottgadgets/packetry) | 儀表板 | Rust 寫的 GUI 應用，讀 LINKTYPE_USB_2_0 格式的 `.pcap` 顯示 USB 流量。**只是 viewer**，沒有 CLI 模式。 |
| [Moondancer](https://github.com/greatscottgadgets/cynthion/tree/main/firmware/moondancer) | 啟動引擎 | Rust 寫的 RISC-V SoC 韌體，跑在 `facedancer.bit` 內部的 soft core 上，透過 libgreat-RPC 接受 Facedancer 指令。 |

關鍵理解：**Cynthion 是硬體 + bitstream + host CLI 的組合包，它的 FPGA 一次只能跑一個 applet**。MCP server 要做的事就是把「切 applet + 對應的 host 端操作」包裝成 LLM 可呼叫的 tool。

LUNA 本身對 MCP 沒貢獻 —— 它是 design-time 函式庫，沒有 runtime state。Packetry 同理：它是 GUI viewer，不是 library，[沒有 CLI export 模式](https://github.com/greatscottgadgets/packetry/blob/main/README.md) 。所以 MCP server 不能呼叫 Packetry，只能跟它共用一個 pcap 格式輸出。

---

## 架構選擇：一個 MCP server，三種能力，動態切換 bitstream

逆向 USB 裝置的工作流是三段式的：抓 → 看懂 → 複製或竄改。三段對應三種能力：

```
                  ┌─ switch_mode('analyzer') ──────┐
                  │  capture_start                  │
plug in target ───┤  (target enumerates)            │
                  │  capture_stop                   │
                  └─ convert_to_pcap ───────────────┘
                                │
                                ▼
                  ┌─ transaction_summary ──────────┐
                  │  dissect_packets(filter, …)    │  ← LLM 解析，
                  │  find_vendor_requests          │    抽 descriptor，
                  └────────────────────────────────┘    找協定 pattern
                                │
                                ▼
                  ┌─ switch_mode('facedancer') ────┐
                  │  emulator_diagnose             │
                  │  emulate_from_descriptor(…)    │  ← clone 裝置，
                  │  emulate_device('ftdi') etc    │    fuzz 回應，
                  │  disconnect_device             │    重放 vendor req
                  └────────────────────────────────┘
```

`switch_mode` 是這條鏈的關節 —— 它呼叫 `cynthion run <applet>` 把 `.bit` 燒進 FPGA SRAM。Cynthion 也支援持久燒到 flash（`cynthion flash`），但 MCP 場景下用 SRAM 即可，反正每次測試都不一樣。

Tool 表面總計 **17 個**：

| 群組 | tools |
|---|---|
| Hardware (3) | `get_status` / `switch_mode` / `recover` |
| Sniffer (5) | `capture_start` / `capture_stop` / `capture_status` / `list_captures` / `read_capture` |
| Decoder (4) | `convert_to_pcap` / `dissect_packets` / `transaction_summary` / `find_vendor_requests` |
| Emulator (5) | `emulator_diagnose` / `emulate_device` / `emulate_from_descriptor` / `disconnect_device` / `inject_serial` |

設計原則只有一條：**給 LLM 的工具要是動詞，不是畫面**。tshark 之所以比 Wireshark GUI 更適合給 LLM 用，是因為它接受 display filter 這種**可組合的查詢語法** —— `usbll.pid == 0x2d` 比「按那個 SETUP 顏色的按鈕」對 LLM 友善太多。

### MCP server 怎麼註冊到 Claude Code

```sh
claude mcp add -s user cynthion /absolute/path/to/.venv/bin/cynthion-mcp
claude mcp list | grep cynthion
# → cynthion: …/.venv/bin/cynthion-mcp  - ✓ Connected
```

之後重啟 Claude Code，17 個 tool 會以 `mcp__cynthion__*` 命名出現在對話的 deferred tool list 裡。Anthropic 對 Model Context Protocol 的官方介紹見 [Introducing the Model Context Protocol](https://www.anthropic.com/news/model-context-protocol) ；協定本身的規格在 [modelcontextprotocol.io](https://modelcontextprotocol.io/specification/2025-11-25) 。

---

## 為什麼要用 tshark，而不是自己寫 USB 解碼器

研究階段曾經考慮過自己寫一個 Cynthion native frame → human-readable transaction 的解碼器，後來決定不寫。

USB 不只是底層 packet framing；它是個堆疊：

```
┌────────────────────────────────────┐
│ Class-specific (HID / MSC / UVC …)│
├────────────────────────────────────┤
│ Standard requests (descriptors …) │
├────────────────────────────────────┤
│ Transfers (control / bulk / int)   │
├────────────────────────────────────┤
│ Transactions (token-data-handshake)│
├────────────────────────────────────┤
│ Packets (PID + payload + CRC)      │ ← Cynthion analyzer 抓在這層
└────────────────────────────────────┘
```

要把 raw byte 解到 LLM 能直接讀的層級（「Mass Storage SCSI READ(10) LBA=0x1000 length=8 sectors」），上面這五層都要寫。USB-IF [文件庫](https://www.usb.org/documents) 裡 base spec + 各 class 規格加起來幾千頁，是業界二十年的累積。

我選的捷徑是：**自己只負責 Cynthion 自家 framed binary → 標準 pcap 的轉換，剩下的解析全部丟給 tshark**。

tshark 是 Wireshark 的 CLI 模式，包含完整的 USB protocol dissector（HID、MSC、UVC、Audio、CDC 等）。從 4.x 開始它的輸出 `-T json` 是 LLM 友善的結構化 record。Display filter 語法是固定 DSL，LLM 可以自己拼 query。

```python
# cynthion_mcp/tshark.py 的核心就一行
proc = subprocess.run(
    [tshark, "-r", str(pcap_path), "-T", "json", "-Y", display_filter or ""],
    capture_output=True, text=True, timeout=60,
)
return json.loads(proc.stdout)
```

整個 decoder layer 含 PID 命名表跟 summariser 加起來 175 行。如果自己寫完整 dissector 至少要兩個月。

---

## 撞牆三連發：Cynthion native frame 的格式陷阱

![cynthion-mcp-frame-bugs](https://hackmd.io/_uploads/rJzaxVkyMe.jpg)

寫 `decoder.py` 把 `.bin` 轉 pcap 的時候，前三次嘗試全錯，三個 bug 互相疊在一起：

### Bug 1：speed enum 對錯位

第一次 `capture_start(speed="auto")` 抓 5 秒得到 11 KB，decoder 解出 0 個 packet。所有的 byte 都是「事件」，沒有「封包」。

原因：[`cynthion.gateware.analyzer.speeds.USBAnalyzerSpeed`](https://github.com/greatscottgadgets/cynthion/blob/main/cynthion/python/src/gateware/analyzer/speeds.py) 的編碼是（其中 `USBSpeed.HIGH = 0`）：

```python
class USBAnalyzerSpeed(IntEnum):
    HIGH = 0b00      # ← USBSpeed.HIGH 是 0
    FULL = 0b01
    LOW  = 0b10
    AUTO = 0b11      # r0.6+ 才有 auto
```

但我寫 `CaptureSpeed.AUTO = 0b00` —— 等於用 HS 模式試圖抓 Full-Speed Logitech 接收器，analyzer 在 HS 線根本看不到 FS 信號，所以只收到 timer wrap 事件，0 個 packet。

修法（在 [`capture.py`](https://github.com/Oliver0804/cynthion-mcp/blob/main/src/cynthion_mcp/capture.py) ）：把 enum 跟 gateware 對齊。`"auto"` map 到 `0b11`，HS / FS / LS 各自對應 `0b00 / 0b01 / 0b10`。

### Bug 2：endianness 寫反

修好 speed 後抓 5 秒得到 26,791 packet，tshark 卻顯示 `USBLL 768 SOF`。一個 SOF 應該是 3 bytes（PID + 11-bit frame number + 5-bit CRC5），怎麼會 768 bytes？

讀 gateware：[`cynthion.gateware.analyzer.fifo.Stream16to8`](https://github.com/greatscottgadgets/cynthion/blob/main/cynthion/python/src/gateware/analyzer/fifo.py) 的建構子預設 `msb_first=True`。每個 16-bit 值在 wire 上是 high byte 先送 —— **big-endian**。我的 decoder 讀的是 little-endian：

```python
# 錯
size = data[pos] | (data[pos + 1] << 8)
# 對
size = (data[pos] << 8) | data[pos + 1]
```

SOF 真實 size = `0x0003`。Big-endian 是 `00 03`，little-endian 讀成 `0x0300 = 768`。修法：所有 16-bit 欄位（packet size、timestamp）改用 big-endian。

### Bug 3：16-bit 對齊的 padding

修好 endian 後 SOF 變 3 bytes ✓，但每兩個 packet 就有一個變成「Invalid Packet ID (0x14) 797 bytes」的鬼東西。

從 `.bin` 直接 dump bytes 看：

```
offset  bytes                        解讀
4-7    00 03 ae 26                  header: size=3, time=0xae26
8-10   a5 ac ed                     payload: SOF (PID 0xa5)
11     00                           ← padding!（size 是 3，奇數）
12-15  00 03 1d 64                  下一個 header
```

Cynthion gateware 把所有記錄 16-bit 對齊；奇數長度的 packet 後面會 padding 一個位元組。我的 decoder `pos += 4 + size` 之後剛好停在 padding，下一輪讀錯位然後整串都歪。

修法：

```python
# Gateware writes everything 16-bit-aligned, so odd-size packets
# are followed by a single byte of padding. Advance past it.
pos += 4 + size + (size & 1)
```

### 三個 bug 修完之後

```python
$ python -c "from cynthion_mcp.decoder import cynthion_bin_to_pcap; ..."
packets=26791  events=1  duration=5.003s  speed=full
CAPTURE_START_FULL: 1
```

tshark 對轉出來的 pcap 完全認得：

```
   1   0.000000   host → broadcast    USBLL 3 SOF
   2   0.000125   host → 20.4         USBLL 3 IN
   3   0.000130   host → 20.2         USBLL 3 IN
   4   0.000136   host → 16.3         USBLL 3 IN
   5   0.000139   16.3 → host         USBLL 1 NAK   ← Logitech 回 NAK
   6   0.000250   host → 16.2         USBLL 3 IN
   7   0.000253   16.2 → host         USBLL 1 NAK
   8   0.000999   host → broadcast    USBLL 3 SOF
```

SOF 每 1 ms 一次 —— [Full-Speed USB 的標準 frame interval](https://www.beyondlogic.org/usbnutshell/usb3.shtml) 。device 16 跟 20 是 host 在 poll 的兩個 HID 裝置，全部回 NAK 表示「我沒事報告」—— 對應「滑鼠沒在動、鍵盤沒在敲」的真實 idle 狀態。

---

## 實戰：從 wire 還原一顆 Bluetooth dongle 的完整指紋

![cynthion-mcp-reverse-engineering](https://hackmd.io/_uploads/S1bCxN1yMl.jpg)

接下來把一顆陌生的 USB-A 裝置插到 TARGET-A，並在 capture 中段拔插一次觸發完整 enumeration：

```python
mcp__cynthion__capture_start(speed="auto")
# (使用者拔插 TARGET-A 上的目標裝置)
mcp__cynthion__capture_stop()
# → 647,660 bytes / 28.5 s
```

`convert_to_pcap` 之後跑 `transaction_summary`：

```json
{
  "total_packets": 79350,
  "pid_counts":   {"SOF": 25815, "IN": 53276, "SETUP": 28,
                   "DATA0": 30, "DATA1": 56, "ACK": 85, "OUT": 26},
  "device_counts": {"device_20": 51630, "device_16": 41,
                    "device_17": 1614, "device_23": 41, "device_0": 4}
}
```

`device_0` 出現 4 次 —— USB 規格規定[新 device 一開始都掛在 address 0](https://www.beyondlogic.org/usbnutshell/usb6.shtml) ，host 用 SET_ADDRESS 把它移到一個未占用的地址。4 個 packet 對應**兩次完整的「address 0 → SET_ADDRESS → 新地址」儀式**：第一次跑到 address 23、第二次跑到 address 16。

剩下的 device address 來歷：

- `device_20`（51,630 packet）跟 `device_17`（1,614 packet）是 capture 啟動**之前**就已經接在 bus 上的既有裝置（HID 主控制器與其他常駐 HID），整個 capture 期間 host 持續 poll 它們，所以 packet 數很高
- `device_23` 跟 `device_16`（各 41 packet）就是這次拔插觸發 enumeration 的兩個地址 —— 配上「兩次 address 0 儀式」可以推斷這顆 dongle 是 **複合結構**：可能是內含 hub 並向 host 暴露多個子裝置，也可能是裝置自己 re-enumerate 出第二個 logical entry

抓 SETUP token 跟其後的 DATA1（descriptor 通常用 DATA1 回送）：

```sh
tshark -r capture.pcap -Y 'usbll.pid == DATA1' \
  -T fields -e frame.number -e usbll.addr -e usbll.data
```

開頭就是 device descriptor 18 個位元組：

```
38737  23.0,host  12011001e0010140927311c6000201020301
```

逐欄解析（[device descriptor 結構](https://www.beyondlogic.org/usbnutshell/usb5.shtml) ）：

| Offset | Bytes | Field | 解碼 |
|---|---|---|---|
| 0 | `12` | bLength | 18 |
| 1 | `01` | bDescriptorType | DEVICE |
| 2–3 | `10 01` | bcdUSB | 0x0110 = USB 1.1 |
| **4** | **`e0`** | **bDeviceClass** | **Wireless Controller** — 對應 [USB-IF Class 0xE0](https://www.usb.org/defined-class-codes) |
| **5** | **`01`** | **bDeviceSubClass** | **RF Controller** |
| **6** | **`01`** | **bDeviceProtocol** | **Bluetooth Programming Interface** ([Bluetooth Core spec §3](https://www.bluetooth.com/wp-content/uploads/Files/Specification/HTML/Core-54/out/en/host-controller-interface/usb-transport-layer.html) ) |
| 7 | `40` | bMaxPacketSize0 | 64 |
| **8–9** | **`92 73`** | **idVendor (LE)** | **0x7392 = Edimax Technology** |
| **10–11** | **`11 c6`** | **idProduct (LE)** | **0xC611** |
| 12–13 | `00 02` | bcdDevice | 2.00 |
| 14–16 | `01 02 03` | iMfg / iProd / iSer | 1 / 2 / 3 |
| 17 | `01` | bNumConfigurations | 1 |

接著抓三個 string descriptor。USB string descriptor 是 [UTF-16LE 編碼](https://www.beyondlogic.org/usbnutshell/usb5.shtml#StringDescriptors) ，前兩個 byte 是長度跟 type（`0x03`），後面是字元：

```
38782  10 03 52 00 65 00 61 00 6c 00 74 00 65 00 6b 00     → "Realtek"
38760  32 03 45 00 64 00 69 00 6d 00 61 00 78 00 20 00 ... → "Edimax Bluetooth Adapter"
38806  1a 03 30 00 30 00 45 00 30 00 34 00 43 00 ...       → "00E04C239987"
```

最後一個字串看起來是 serial number，但**結構完全是 Bluetooth MAC**：`00:E0:4C:23:99:87`。OUI `00:E0:4C` 在 [IEEE OUI 資料庫](https://standards-oui.ieee.org/) 對應 **REALTEK SEMICONDUCTOR CORP** —— 跟製造商字串完全吻合。

繼續解 configuration descriptor（從 frame 38828 開始的 DATA1）：

```
09 02 b1 00 02 01 00 e0 fa   ← config: total 177 B, 2 interfaces, 500 mA
09 04 00 00 03 e0 01 01 04   ← iface 0: BT HCI class, 3 endpoints
07 05 81 03 10 00 01         ← EP 0x81 INT IN  16 B (HCI events)
07 05 02 02 40 00 00         ← EP 0x02 BULK OUT 64 B (ACL out)
07 05 82 02 40 00 00         ← EP 0x82 BULK IN  64 B (ACL in)
09 04 01 00 02 e0 01 01 04   ← iface 1 alt 0: SCO audio (zero bandwidth)
... (multiple alt settings for SCO voice payload sizes)
```

3 個 endpoint 的配置剛好對應 [Bluetooth Core spec USB Transport](https://www.bluetooth.com/wp-content/uploads/Files/Specification/HTML/Core-54/out/en/host-controller-interface/usb-transport-layer.html) 規定的：interrupt IN 給 HCI events、bulk OUT/IN 給 ACL data。第二個介面是 SCO audio，有多個 alt setting 對應不同 voice codec payload size。

把所有線索拼起來：

> **TARGET-A 上插的是一顆 Edimax Bluetooth dongle**：USB VID/PID `7392:C611`、Realtek RTL 系列藍牙 IC、Full-Speed、藍牙 MAC `00:E0:4C:23:99:87`、支援 HCI command + ACL data + SCO 語音的標準 USB Bluetooth HCI 裝置。

我沒裝任何 driver、沒對它送任何 command、host 端的 USB 列舉資訊全部沒參考 —— **這套指紋從 wire 上 79350 個 packet 純被動推理出來**。這就是 USB 逆向工程的本質。

---

## Clone 的限制：FTDI 通了，Bluetooth 還沒

逆向出 descriptor 之後，最自然的下一步就是 clone 給 Cynthion 變成那個裝置。對應的 tool 是 `emulate_from_descriptor`，吃 hex 字串、丟給 facedancer 的 `USBBaseDevice.from_binary_descriptor()`、Moondancer SoC 把它送到 TARGET-C 上。

![cynthion-mcp-device-clone](https://hackmd.io/_uploads/HyUgW4y1zg.jpg)

但這次的實驗結果是：**只給 descriptor 不夠**。我送了 Edimax 的 device descriptor + 一個自己手刻的 39 bytes config（保留 Bluetooth HCI interface 跟 3 個 endpoint）給 facedancer，emulation 啟動成功、Moondancer SoC 回 `ok`、但 macOS 始終沒列舉出來。

原因是 Bluetooth Class 0xE0 的 host driver 在 enumeration 之後還會送 **class-specific HCI command**（`HCI_RESET`、`HCI_READ_LOCAL_VERSION` 等），需要一個有 in-band 行為的假控制器。Passive descriptor responder 只能讓 USB layer 認識它，driver layer 一進來就翻臉。

要真的 clone Bluetooth dongle，得寫一個專屬 `EdimaxBluetoothDevice` 子類別，實作 HCI command/event handler + bidirectional ACL relay。這已經是寫一個 fake controller，不再是 descriptor replay 的範圍。

不過用 facedancer 內建的 `FTDIDevice` 模板測就直接成功：

```python
mcp__cynthion__emulate_device(device_type="ftdi")
```

macOS `ioreg` 立即抓到：

```
+-o FTDI emulation@00112400  <class IOUSBHostDevice, registered, matched, active>
    "USB Product Name" = "FTDI emulation"
    "USB Vendor Name"  = "not-FTDI"
    "idVendor"  = 1027    (0x0403)
    "idProduct" = 24577   (0x6001)
```

Location ID `00112400` 跟前面 TARGET-A 上 Logitech 接收器穿透到 host 時出現的位置是同一個 USB hub 分支 —— 證實 emulated 裝置確實透過 Cynthion TARGET-C 抵達 macOS USB stack。

### 為什麼要 pin facedancer 3.1.1

`facedancer==3.1.2` 是當前 PyPI 上的最新版（2025-12-05 釋出），但 `cynthion==0.2.4` wheel 內附的 Moondancer SoC 韌體是 2025-10 編的。兩者之間 libgreat-RPC protocol 有 silent break：connect verb 的 argument 配置改了，舊韌體不認新 protocol。

症狀：facedancer 3.1.2 + Moondancer 0.2.4 → 第一個 RPC 就 `LIBUSB_ERROR_TIMEOUT`，整個 SoC 看起來像死了。

解：[`pip install 'facedancer==3.1.1'`](https://github.com/greatscottgadgets/facedancer/releases) —— 這是 2025-08-01 跟 cynthion 0.2.3 同步釋出的版本，protocol 跟 0.2.4 內附韌體匹配。我把這個 pin 寫進 `cynthion-mcp` 的 `pyproject.toml`，加上 README 警告：

```toml
# Pinned: 3.1.2 (latest) ships a libgreat-RPC protocol that the Moondancer
# firmware bundled in cynthion 0.2.4 doesn't speak. 3.1.1 is the last
# version that round-trips with the shipped firmware.
"facedancer==3.1.1",
```

未來 cynthion 出新 release 才能 unpin。

---

## 整個下午抓到的 9 個 bug

寫 cynthion-mcp 的過程中總共抓到 9 個自己程式碼的 bug。除了上面三個 frame format 的，還有：

| 檔案 | bug | 修法 |
|---|---|---|
| `hardware.py` | `_find_gsg_device` 只篩 VID 0x1d50 → 撞到旁邊 HackRF One（也是 GSG VID）被優先抓 | 改篩 `(VID, PID)` pair，只認 Cynthion 的 `615b/615c` |
| `hardware.py` | `_open_apollo` 在 stub mode 遞迴呼叫 recover → 無限 log loop | 限制單次 recovery pass |
| `hardware.py` | `_safe_soft_reset` 預設不帶 `force_offline`，stub 模式下 Apollo 會拒絕開啟 → soft_reset 從來沒真的 fire | 加 fallback 鏈：先試 default，失敗再用 `force_offline=True` |
| `emulator.py` | `disconnect_device` 用 `asyncio.run_coroutine_threadsafe(raise EndEmulation)` 從外部 inject 例外 → **沒進 device.run() 的 try block** → Moondancer SoC 每次都 wedge | 改用 watcher coroutine 在 asyncio loop 內 polling `threading.Event`，set 時才 raise；事後 facedancer 自己的 finally block 會正確 disconnect |
| `emulator.py` | `inject_serial` 呼叫不存在的 `send_data` 方法 | 改用 facedancer 3.1.x 真正存在的 `transmit` / `send` |
| `emulator.py` | `emulate_from_descriptor` 沒包 `bytes.fromhex` 例外 → 壞 hex 直接 crash MCP server | 加 try/except，回友善訊息 |
| `server.py` | 17 個 tool 全部沒包 try/except，一次 USB timeout 整個 stdio server 死掉 | 寫 `@_safe` decorator 包在 `@mcp.tool()` 下層，回 `{"error": ...}` 而非 raise；保留 `__signature__` 讓 pydantic 還能解析 enum 型別 |
| `capture.py` | `stop_capture` 開第二把 USB handle 跟 drainer thread 競爭 | drainer thread 全程獨佔 handle，finally 內 `usb.util.dispose_resources(dev)` |

每個 bug 都不是設計問題，是「真實硬體下才暴露」的 edge case。例如「兩台 GSG 裝置同時插」這種狀況單元測試完全測不到 —— 我手上同時插了 HackRF One 跟 Cynthion 才看見 `_find_gsg_device` 的 VID-only 篩選會吃錯。

### 給 LLM 用的 MCP server 跟給人用的 CLI 有什麼不一樣

最關鍵的差異是 **error handling 的彈性**。CLI 工具發生錯誤，使用者看到 stack trace 會自己決定重試或改參數。MCP server 不能這樣 —— 一次 unhandled exception 整個 stdio session 死掉，**所有 tool 同時失效**，使用者得 `/exit` 重開 Claude Code 才能繼續。

`@_safe` decorator 是這個故事的主角：

```python
def _safe(fn):
    sig = inspect.signature(fn)  # 保留 pydantic 需要的 type info
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        try:
            return fn(*args, **kwargs)
        except Exception as e:
            log.warning("tool %s failed: %s", fn.__name__, e)
            return {
                "error": f"{type(e).__name__}: {e}",
                "tool": fn.__name__,
                "traceback_tail": traceback.format_exc().strip().splitlines()[-3:],
            }
    wrapper.__signature__ = sig
    return wrapper
```

關鍵細節：`from __future__ import annotations` 要拿掉，否則 pydantic 沒辦法解 `Literal[…]` 這種 forward reference（FastMCP 用 pydantic 建每個 tool 的 argument model）。

修完之後跑一個錯誤 path 驗證：

```python
>>> emulate_from_descriptor(device_descriptor_hex="not valid hex!!")
{
  "error": "ValueError: device_descriptor_hex is not valid hex: …",
  "tool": "emulate_from_descriptor",
  "traceback_tail": [...]
}
# server 還活著，其他 16 個 tool 都還能用 ✓
```

---

## 給 LLM 的工具長什麼樣，怎麼用

![cynthion-mcp-lessons](https://hackmd.io/_uploads/HkE--E1Jzx.jpg)

註冊到 Claude Code 之後，這段對話直接就能跑：

> 「切到 analyzer 模式，抓 5 秒，我會在中途拔插一顆裝置。停掉後告訴我看到的所有 device address，並用 SETUP/DATA1 配對抽出每個裝置的 device descriptor 跟 string descriptor。」

Claude 會自己 chain 起來：

```
mcp__cynthion__switch_mode(applet="analyzer")
  → bitstream_name: "USB Analyzer"
mcp__cynthion__capture_start(speed="auto")
  → id: "20260511-163801-315aec"
(等使用者拔插)
mcp__cynthion__capture_stop()
mcp__cynthion__convert_to_pcap(capture_id="...")
mcp__cynthion__transaction_summary(capture_id="...")
mcp__cynthion__dissect_packets(
  capture_id="...",
  display_filter="usbll.pid == DATA1 and usbll.addr matches \"0\\.0|23\\.0|16\\.0\"",
  limit=20
)
```

接著它自己讀 hex、查 [USB-IF class code 表](https://www.usb.org/defined-class-codes) 、查 OUI、把結果交叉驗證。LLM 在這條工作流裡的角色是「會看 protocol spec 的工程師」，不是「會操作 GUI 的人」—— 後者它做不來，前者它做得比人快。

幾個觀察：

1. **LLM 是天然的 protocol 解碼器**。USB descriptor 規格幾百頁但結構固定（length-type-value），一次就能把 18 個 byte 解出 vendor / product / class 並 cross-check 字串 cross-check OUI。真正花時間的部分是「讀文件、對照」，這部分 LLM 做得比人快。
2. **「動詞」比「畫面」適合 LLM**。Packetry 是好工具但畫面是給人看的；tshark + display filter 提供的是「找某類 packet」這種動詞接口，LLM 可以自己拼 query 探索資料，不需要被 GUI 限制。
3. **MCP 把 expert tool 變大眾化**。原本要會用 Cynthion 你得熟 Apollo / Facedancer / Packetry / Wireshark / Amaranth 全套；現在一個非硬體背景的 user 只要會問問題就能用。
4. **硬體比預期耐操**。整個下午燒了 N 次 bitstream、JTAG TAP wedge 好幾次、SoC 死掉幾次 —— 每次都靠 Apollo soft_reset 或物理 replug 救回來，硬體本身完全沒事。

### 安裝步驟

```sh
# 1. 三個 repo side-by-side
git clone https://github.com/greatscottgadgets/luna.git
git clone https://github.com/greatscottgadgets/cynthion.git
git clone https://github.com/Oliver0804/cynthion-mcp.git

# 2. 共用一個 venv
python3 -m venv .venv
./.venv/bin/pip install -e ./luna
./.venv/bin/pip install -e ./cynthion/cynthion/python
./.venv/bin/pip install -e ./cynthion-mcp

# 3. 從 PyPI wheel 把預編 bitstream + Moondancer 韌體解到 source 樹（source clone 沒附二進位）
./.venv/bin/pip download cynthion --no-deps -d /tmp/cw
unzip /tmp/cw/cynthion-*.whl 'cynthion/assets/*' -d /tmp/ext
cp -r /tmp/ext/cynthion/assets/* ./cynthion/cynthion/python/assets/

# 4. 註冊 MCP server 到 Claude Code
claude mcp add -s user cynthion ./.venv/bin/cynthion-mcp
```

之後重啟 Claude Code，17 個 tool 出現在 `mcp__cynthion__*` namespace。完整安裝步驟跟 troubleshooting 在 [Oliver0804/cynthion-mcp 的 README](https://github.com/Oliver0804/cynthion-mcp/blob/main/README.md) 。

### Troubleshooting

| 症狀 | 可能原因 | 解法 |
|---|---|---|
| `emulator_diagnose` 回 `LIBUSB_ERROR_TIMEOUT` | facedancer 版本太新 / 太舊 | `pip install 'facedancer==3.1.1'` |
| 燒 bitstream 時 `bitstream provides data past the device's SRAM array (fffffff8)` | JTAG TAP stuck | 先 `cynthion run analyzer`（小 bitstream 重設 TAP），再 Apollo soft_reset，再燒目標 applet |
| `Apollo stub interface found but not requested to be forced offline` | 嘗試在 stub mode 開啟 Apollo 不帶 force_offline | 開 `ApolloDebugger(force_offline=True)` 或先實體 replug |
| `MCP error -32000: Connection closed` | 早期版本沒包 try/except 的 bug，現在 0.0.2 之後應該不會了 | 升級 cynthion-mcp，重啟 Claude Code 把 server 拉起來 |
| 抓不到任何 packet 但 `bytes_written` 一直長 | speed 選錯了（HS 抓 FS 等等） | 用 `"auto"`，或對應目標裝置的真實速度 |

---

## 結語：還沒解的問題，跟下一步

整套 cynthion-mcp 在一個下午從零到 production，最關鍵的兩個架構決定是：

1. **不重新發明 USB decoder** —— 用 Cynthion → pcap → tshark 鏈，把專業領域知識外包給已存在的二十年累積工具
2. **MCP tool 設計為動詞**，給 LLM 可組合的查詢能力，而不是把 GUI 用文字描述出來

還沒解的問題：

- **完整 Bluetooth dongle clone**：需要實作 HCI command/event 處理。目前 `emulate_from_descriptor` 對 vendor-specific 簡單裝置 OK，對 class-driver 嚴格的裝置（BT、UVC、MSC 部分情境）需要寫 class-specific handler。
- **Packetry 相容性**：我吐的 pcap tshark 吃得很好，但 Packetry 對 [`LINKTYPE_USB_2_0`](https://www.tcpdump.org/linktypes.html) header 有比較嚴格的要求，還沒完全對齊；目前 Packetry 開不開得了我的 capture 是時好時壞。
- **USB 3.x SuperSpeed**：Cynthion analyzer 不 tee SS 線對，這是上游硬體限制。要做 SS 得換另一套硬體。
- **連續多次 emulate**：原本「single-shot per applet」的著名 bug 已經透過 watcher coroutine 修了，但還沒回歸測試「同一個 applet load 後做 N 次 emulate→disconnect 循環不需要 reset」這個 case。下次 session 補。

如果你也有 Cynthion 想玩，repo 在 [Oliver0804/cynthion-mcp](https://github.com/Oliver0804/cynthion-mcp) ，硬體測試完整紀錄在 [`docs/HARDWARE-TEST-LOG.md`](https://github.com/Oliver0804/cynthion-mcp/blob/main/docs/HARDWARE-TEST-LOG.md) 。歡迎 issue / PR / 偷我的點子做自己版本的硬體 MCP server。

---

## 參考資料

- **硬體與韌體**
  - [Cynthion · Great Scott Gadgets](https://greatscottgadgets.com/cynthion/) — 官方產品頁
  - [greatscottgadgets/cynthion](https://github.com/greatscottgadgets/cynthion) — Cynthion source + Moondancer 韌體
  - [greatscottgadgets/luna](https://github.com/greatscottgadgets/luna) — Amaranth HDL USB gateware
  - [greatscottgadgets/facedancer](https://github.com/greatscottgadgets/facedancer) — USB 裝置模擬框架（v3 是 ground-up rewrite）
  - [greatscottgadgets/packetry](https://github.com/greatscottgadgets/packetry) — Cynthion 的 GUI viewer
  - [Cynthion on Crowd Supply](https://www.crowdsupply.com/great-scott-gadgets/cynthion) — 完整產品描述跟特性說明
- **Model Context Protocol**
  - [Introducing the Model Context Protocol](https://www.anthropic.com/news/model-context-protocol) — Anthropic 官方介紹
  - [Specification - Model Context Protocol](https://modelcontextprotocol.io/specification/2025-11-25) — 協定規格
  - [Model Context Protocol — Wikipedia](https://en.wikipedia.org/wiki/Model_Context_Protocol) — 歷史與生態概覽
- **USB 協定**
  - [USB Defined Class Codes - USB-IF](https://www.usb.org/defined-class-codes) — 官方 class code 列表
  - [USB in a NutShell](https://www.beyondlogic.org/usbnutshell/usb1.shtml) — Beyond Logic 經典 USB 教學
  - [Bluetooth Core Specification - USB Transport Layer](https://www.bluetooth.com/wp-content/uploads/Files/Specification/HTML/Core-54/out/en/host-controller-interface/usb-transport-layer.html) — Class 0xE0/01/01 規格依據
  - [IEEE OUI Database](https://standards-oui.ieee.org/) — MAC OUI 反查
- **本文 source code**
  - [Oliver0804/cynthion-mcp](https://github.com/Oliver0804/cynthion-mcp) — 完整 MCP server source
  - [Hardware test log](https://github.com/Oliver0804/cynthion-mcp/blob/main/docs/HARDWARE-TEST-LOG.md) — 端到端硬體驗證紀錄
