序列埠監看之左右護法:CoolTerm Remote Control Socket 與自製 pty 轉發
本地端接了 4 支 ESP8266 跑 ESPNOW 同步實驗,需要長時間盯著多個序列埠的輸出。Ken 推薦了 CoolTerm(Roger Meier 的免費序列埠終端機軟體)——原話是這是他用過唯一能同時開多個序列埠連線視窗的終端機軟體,功能之強大因此推薦給我用。
真正想解決的問題不是「讓程式也能讀」這麼單薄——而是同一份即時資料,Ken 用眼睛看著 CoolTerm 畫面,同一時間 Claude 也在用 socket 監看、甚至能反過來控制(送指令、改參數、啟停連線),兩邊互不干擾、互不知情也沒關係。這篇記錄兩種做到「一份資料、多方導流」的做法:先是 CoolTerm 官方自帶的 Remote Control Socket API(甚至可以再用 Data Forwarding 分流出第三、第四個消費端),最後壓軸的是我自己另外刻的一套 pty 轉發方案。
(Ken 補述:底下 Coolterm_API 是 Linux 版的同 另一包 Coolterm_ContextDelivery[來源],並加附 pty 轉發;
CoolTerm_ContextDelivery 則是 Windows 版,並已做過大量的測試;其來源是他處合法取得)
事前準備:兩個方法各自要先設定好的事
下面提到的前提,不是「這篇文章全部流程」共用的,而是分屬第一節(CoolTerm API)跟第二節(自製 pty)各自的方法——缺一個都會卡住、而且往往不會有清楚的錯誤訊息告訴你少了什麼,先列出來、標明各自適用的範圍:
- 兩個方法都要:執行的帳號要能開序列埠,也就是要在
dialout群組裡(`/dev/ttyUSBn` 預設是 `crw-rw—- root dialout`)。沒加的話 `sudo usermod -aG dialout <user>` 之後要重新登入一次 session 才會生效。這是唯一兩個方法都需要的前提——第二節的自製 pty 方案本身是純 Python 腳本,不需要 GUI、不需要 CoolTerm,只有這一條跟 CoolTerm API 共用。 - 只有第一節(CoolTerm API)需要:要有一個實際在跑的圖形桌面 session 可以連過去。CoolTerm 是 GUI 應用程式,本文示範的「SSH 進去、背景啟動、全自動連線」不是真的無頭運行,而是這台機器本來就有一個已登入、長期在線的 GNOME/Wayland 桌面 session(本文情境下是特地留給自動化用的一個帳號 session),SSH 進去後把對應的環境變數指過去、借用那個已存在的桌面而已。若目標機器上沒有這樣一個持續在線的 session,得先想辦法生出一個(例如一個永遠登入著的帳號),這篇不涉及怎麼建這個 session 本身。要借用的環境變數:
XDG_RUNTIME_DIR、DBUS_SESSION_BUS_ADDRESS、WAYLAND_DISPLAY、DISPLAY、XAUTHORITY(檔名每次該 session 重啟會變,要重新查一次)、QT_QPA_PLATFORM=wayland。查法:ps aux | grep gnome-session找到對應 PID 確認是哪個使用者的 session,環境變數的值可以從/run/user/<uid>/底下的檔名反推,或用loginctl/who核對。少了這一步,`CoolTerm …` 這行指令會直接因為連不上任何 display 而啟動失敗。 - 只有第一節(CoolTerm API)需要:Remote Control Socket 這個開關要先設定好。設定存在
~/.config/CoolTerm/CoolTerm_Prefs.plist(純文字 XML plist 格式),關鍵 key 是Pref_RemoteSocketEnable(設成True)與Pref_RemoteSocketPort(設成51413)。這裡更正一個原本沒實測就寫的錯誤說法:先前以為第一次一定要手動開一次 CoolTerm、勾一次 Preferences 才能生出這份設定檔——實際測試(連 CoolTerm 都從沒啟動過一次的全新環境)發現完全不需要:只要這兩個 key 存在於這份設定檔裡(哪怕是從空白手刻出一份最小化的 plist 檔),CoolTerm 第一次啟動就會直接讀到、Remote Control Socket 立刻生效,全程不用碰 GUI。第二節的自製 pty 方案完全用不到這個開關,跟 CoolTerm 的 Preferences 一點關係都沒有。
一、CoolTerm 自帶的 API:Remote Control Socket
CoolTerm 從 2.x 版起就附帶一組正式的 Remote Control Socket 協定(TCP,預設 port 51413,隨附完整 PDF 規格書與一支 CoolTerm.py client 模組),可以在「使用者眼前的終端機視窗完全不受干擾」的前提下,讓外部程式讀出目前收到的資料、甚至反過來遙控整個連線(連線/斷線/送資料/改參數)。這套機制本身跨平台,在此之前其實已經在 Windows 情境下評估、實測過一輪(COM port、逐位元組驗證、921600 baud 壓力測試等),文首附的 CoolTerm_ContextDelivery.7z 就是那份評估的完整記錄(個資已清);以下記錄的是怎麼在 Linux 上把同一套 API 用到全自動、完全不用滑鼠點擊的程度,並在多處實測出比 Windows 版當初做法更進一步的結果(見後續段落)。
關鍵:`LookAhead()` 而不是 `Read()`
Remote Control Socket 有兩種讀法:Read()/ReadAll() 會把資料從接收緩衝區移除——而 CoolTerm 畫面顯示的正是這個緩衝區的投影,用這個會把使用者眼前的內容吃掉;LookAhead() 則是「回傳緩衝區內容但不移除」,這才是「旁觀不干擾」的正解。唯一要注意的協定限制:封包長度欄位只有 2 bytes,單次回應最多 65,535 bytes,讀取的視窗 RXBufferSize 得設在這個上限以下(本文設 32,768),否則資料會被靜默截斷。
踩到的一個 client 端 bug:`recv()` 沒收滿
隨附的 CoolTerm.py(v1.8, 2025-01)送出指令後,只做一次 self.skt.recv(65535),沒有收滿迴圈。TCP 本來就可能把較大的回應拆成好幾段送達,沒收滿就直接回傳,會靜默回傳一個殘缺的前綴、不報任何錯誤——資料量小時不會發生,但視窗累積的資料一多,就會不知不覺讀到假資料。修法是先讀 6-byte 表頭取得長度欄位,再收滿剩下的 payload:
class CTSocket(CoolTerm.CoolTermSocket):
def _recv_exact(self, n):
buf = b""
while len(buf) < n:
chunk = self.skt.recv(n - len(buf))
if not chunk:
raise ConnectionError("CoolTerm closed the remote control socket")
buf += chunk
return buf
def _SendPacket(self, packet):
self.skt.sendall(packet)
head = self._recv_exact(6)
length = int.from_bytes(head[1:3], "little")
return head + self._recv_exact(length)
不點滑鼠也能開窗連線:CLI 帶設定檔 + AutoConnect
CoolTerm 的連線設定可以存成一份純文字的 .CoolTermSettings 檔(key = value 格式),CoolTerm 支援「用命令列帶一份設定檔啟動」,設定檔裡若 AutoConnect = true,開起來就直接連線,完全不需要人在畫面前點任何東西:
# source_ttyUSB0.CoolTermSettings(節錄)
Port = /dev/ttyUSB0
BaudRate = 115200
RXBufferSize = 32768
AutoConnect = true
# 啟動(headless,透過 SSH 對著既有的 GNOME/Wayland session 跑)
CoolTerm ~/MyPrjs/CoolTerm_API/source_ttyUSB0.CoolTermSettings
Remote Control Socket 這個開關本身也不需要進 Preferences 點——設定存在 ~/.config/CoolTerm/CoolTerm_Prefs.plist(純文字 XML),Pref_RemoteSocketEnable 這個 key 直接編輯或核對即可。踩過的一個坑:**同一時間只能有一個 CoolTerm process**(Remote Control Socket 是單一 listener),不小心啟動第二個會連不上 51413、查起來會查到前一個殘留的視窗,啟動前務必先確認乾淨。
實測結果
對著一支正在真實硬體上跑 ESP8266 壓力測試(見〈MultiTimers/MultiPWMs 系列大一統〉的硬體驗證段落)的 /dev/ttyUSB0,全程未做任何 GUI 操作:
$ python3 ct_port.py status
source_ttyUSB0.CoolTermSettings port=/dev/ttyUSB0 connected=True
$ python3 mirror_tail.py --port /dev/ttyUSB0 --seconds 5
[following source_ttyUSB0.CoolTermSettings | port /dev/ttyUSB0 | peek mode]
STRESS_PROGRESS iter=20183694 syncFail=0 freeHeap=52304 millis=142198253
$ python3 mirror_tail.py --port /dev/ttyUSB0 --seconds 10
[following source_ttyUSB0.CoolTermSettings | port /dev/ttyUSB0 | peek mode]
STRESS_PROGRESS iter=20185829 syncFail=0 freeHeap=52304 millis=142213262
STRESS_PROGRESS iter=20186540 syncFail=0 freeHeap=52304 millis=142218268
兩次執行間隔 iter 數持續遞增,確認讀到的是真正即時的資料流,不是巧合的一次快照——從開窗、連線、到讀取,全程零 GUI 互動。
更進一步:Data Forwarding → NULL Device 鏡像,一樣全自動
CoolTerm 另有一個更進階的機制:把一個視窗收到的資料即時複製(Data Forwarding)到第二個「NULL Device」視窗——一個沒有實體連線的假裝置。程式端去讀這第二個視窗即可,完全不去碰第一個視窗的緩衝區,等於多開一個分流的水龍頭。之前查到的資料(含 Windows 版的評估)都寫「NULL Device 必須從 GUI 的 Port 選單建立,scripting API 做不到」——**但這在 Linux 這邊實測發現不成立**:`CoolTerm.py` 有一個叫 `LoadSetting(FilePath)` 的指令,可以把一份 .CoolTermSettings 檔(裡面 Port = NULL)直接載入成一個新視窗,而且載入後 Connect() 也順利回傳 True——**全程沒有碰任何 GUI**:
>>> s.LoadSetting('mirror_NULL.CoolTermSettings')
True
>>> wid = s.GetWindowIDfromName('mirror_NULL.CoolTermSettings')
>>> s.Connect(wid)
True
>>> s.IsConnected(wid)
True
接著在來源視窗上設定轉送關係(同樣純腳本、不需要 GUI):
s.SetParameter(src, "ForwardTerminals", "mirror_NULL.CoolTermSettings")
s.SetParameter(src, "ForwardSources", "R")
s.SetParameter(src, "ForwardDestinations", "R")
s.SetParameter(src, "OpenForwardPorts", "true")
設好之後,對 mirror 視窗用 Read()(drain 模式,因為它是機器專用的資料出口,不是給人看的畫面)就能拿到跟來源一致的即時資料,同時完全不去動來源視窗的緩衝區:
$ python3 mirror_tail.py --window mirror_NULL.CoolTermSettings --seconds 10
[following mirror_NULL.CoolTermSettings | port NULL | drain mode]
STRESS_PROGRESS iter=20448940 syncFail=0 freeHeap=52304 millis=144064523
STRESS_PROGRESS iter=20449653 syncFail=0 freeHeap=52304 millis=144069525
STRESS_PROGRESS iter=20450364 syncFail=0 freeHeap=52304 millis=144074526
至此,一份序列埠資料可以同時有三方在看:Ken 盯著 source_ttyUSB0 那個視窗的畫面、Claude 用 LookAhead 旁觀同一個視窗、Claude 再開一條 mirror_NULL 分流專門餵給程式消化——三邊互不干擾,而且全程一次 GUI 都沒點。
高速率下 Forwarding 會不會掉字?——實測,而且答案跟預期不一樣
Windows 版評估在 921600 baud(92.5 kB/s)跑出來源與鏡像兩份 capture 檔 SHA-256 完全相同、零遺失的結果。這裡原本想直接比照,但**先被問了一個很實際的問題:ESP8266 這端真的支援 921600 嗎?** CH340(這裡用的 USB 轉序列晶片,`idVendor=1a86 idProduct=7523`)規格表上 921600 確實列在標準支援清單裡,理論上沒問題——但理論不能取代實測,所以老實地做了一輪分級測試,而不是假設它行。
寫了一支 blast_test.ino(仿 Windows 版 blast.ps1 的做法:固定 64-byte 記錄、8-byte 序號前綴、全速連續發送),燒進 ttyUSB0,在四個速率下各跑 20 秒,來源與鏡像視窗各自 CaptureStart() 落檔,事後逐筆比對序號。這裡有個容易漏掉、但決定比對結果能不能信的關鍵參數:CaptureFormat = Raw——確保落檔的內容就是原始位元組,所dump 即所印,跟畫面上(或 LookAhead 讀到的)內容逐位元組一致,不是另外格式化過的版本(例如 CaptureFormatHexData 開啟時會存成十六進位字串)。這個設定沒開對,事後比對序號就毫無意義。
| Baud | 序列埠層資料 | Forwarding 遺失 |
|---|---|---|
| 115200 | 乾淨 | 0% |
| 230400 | 乾淨(7,427 筆全數收到) | 0% |
| 460800 | 亂碼 | —(資料本身已錯,無法比對序號) |
| 921600 | 亂碼 | —(同上) |
460800 起的亂碼不是「遺失」(缺幾筆記錄),是收到的位元組本身就不對——這是典型的訊號層問題,不是 Forwarding 機制的鍋(前面 115200/230400 兩輪已經證明 Forwarding 本身零遺失)。換句話說:**Windows 版評估用的那組硬體撐得住 921600,但這裡這組 ESP8266+CH340 撐不住,可靠上限落在 230400~460800 之間**。這正好印證了一開始那個疑問是對的:跨硬體套用高速率結論之前,該實測,不該只憑晶片規格表或別人成功過就假設一定行。之後若要在這組硬體上做高速率序列埠應用,230400 是目前實測確認可靠的上限。
安全提醒:Remote Control Socket 預設 listen 在 0.0.0.0:51413,同網段任何機器都能連上並完整遙控 CoolTerm(含對序列埠送出資料),不用時建議關掉或加防火牆規則。
二、壓軸:自己刻的版本——pty 多埠轉發
上面那套是借用 CoolTerm 官方自帶的能力。但其實在認識這組 API 之前,我已經自己寫了一套完全獨立的方案,用來解決同一個根本問題:Linux 序列埠是獨佔式存取,同一個 /dev/ttyUSBn 同時間只能被一支程式打開。CoolTerm 的答案是把控制面/資料面都包成一套 socket 協定;我的答案更直接——自己在中間插一層。
原理:pty 當中間人
uart_bridge_multi.py 對每一個要監看的序列埠做同樣的事:打開真正的 /dev/ttyUSBn,同時用 os.openpty() 建一組 pty(虛擬終端機)配對,把真實序列埠收到的每一個位元組即時轉發到 pty 的 slave 端(對外曝露成一個符號連結,例如 ~/uart_pty_ttyUSB1)。這樣一來,CoolTerm(或任何其他終端機程式)可以直接接上這個 pty,完全不需要去搶真正的裝置節點——真裝置永遠只被我這支 bridge 獨占打開,其餘所有消費端都接 pty,愛開幾個都可以。
同一個迴圈裡,bridge 也把每個收到的 chunk 同時寫成兩份 log:原始位元組版本,以及每行前綴 unix epoch 時間戳的版本(<epoch>\t<bytes repr>),供事後精確的時間比對分析用——這部分完全不需要 CoolTerm 介入,是純 Python 這端自己做的。
寫這套的過程中踩到、修掉的兩個坑
- 邊寫邊 truncate 會讓整個 bridge 卡死:定期把 log 封存、清空重來時,若對「還開著、正在被同一個 process 以 append 模式寫入」的檔案做 truncate,會讓整個單執行緒的 bridge 迴圈卡住——不只是被清空的那個 port,連其他完全沒被動到的 port 也會一起被拖累到下個週期才會被強制重開。修法:清空前先明確停掉 bridge,清空完再重啟。
- pty 沒人接、寫入會整個卡住:
os.write(master_fd, data)若 pty 的 slave 端一直沒有任何程式接上去讀,緩衝區會被塞滿,接下來的寫入會無限期阻塞,一樣拖垮整個單執行緒迴圈。修法:把 master fd 設成O_NONBLOCK,寫入時包一層try/except BlockingIOError,沒人接就直接丟棄那筆資料,不要卡住。
一個已修掉的限制:韌體的一行,log 裡曾經會變成好幾筆
bridge 讀取序列埠的方式是 select() 偵測到有資料可讀,就呼叫 os.read(ser_fd, 4096) 撈一次——**這次撈到多少 bytes,純粹取決於呼叫當下核心的序列埠 buffer 裡累積了多少資料,跟韌體端 `Serial.println()` 印出的「一行」完全是兩回事**。序列埠底層就是一個位元組串流,「一行」只是韌體端用 \r\n 標記出來的邏輯概念,並不保證會被底層驅動、`select()` 的喚醒時機、或這次 os.read() 撈到的份量完整涵蓋。若韌體剛印到一半(例如字串還沒送完)核心就先把已經到的部分交出來,這次 os.read() 就只拿到半行,剩下的下一次 select() 醒來時才補上——舊版 log 檔(uart_ts_ttyUSBn.log)是**每次 os.read() 各自獨立寫一筆、各自帶自己的時間戳**,於是韌體端原本一行的輸出,就可能在 log 裡被拆成兩筆以上、時間戳略有差異的紀錄。事後分析 log 時,若逐行搜尋關鍵字,剛好卡在切割點上的關鍵字會搜尋不到,得額外把相鄰兩筆紀錄的內容接起來再搜尋一次才保險(這次分析 ESPNOW 同步問題時就因此吃過幾次虧)。
**這個限制已經修掉**:加一個逐 port 的緩衝區,收到的資料先累積,只有真的遇到 \n 才把湊齊的完整一行連同時間戳寫進 ts_f;PTY 轉發(CoolTerm 或人眼即時看到的那份)仍然用原始未緩衝的 chunk,不等湊齊整行才轉發,維持即時性,只有事後分析用的 log 這條路徑做整行重組。程式關閉時緩衝區裡若還留著沒遇到 \n 的殘餘內容,也會照樣寫出去(標記可能不完整),不會默默遺失最後一段:
self.line_buf.extend(data)
while True:
idx = self.line_buf.find(b"\n")
if idx < 0:
break
complete = bytes(self.line_buf[:idx + 1])
del self.line_buf[:idx + 1]
self._emit_line(now, complete)
兩種做法的取捨
| CoolTerm Remote Control Socket | 自製 pty 轉發 | |
|---|---|---|
| 依賴 | CoolTerm 本身(closed-source freeware) | 純 Python 標準庫,零外部依賴 |
| 讀取方式 | TCP socket、有 65,535 bytes 單次上限 | 直接讀 pty,無此限制 |
| 對顯示畫面的影響 | 用 LookAhead 可做到零影響 | 本來就是完全獨立的兩個消費端,天生零影響 |
| 可控性 | 能反向遙控整個連線(連線/斷線/送資料/改參數) | 純被動旁聽,不介入控制 |
| 建置門檻 | 要研究協定、修 client bug | 要自己處理 truncate/pty 阻塞這類底層坑 |
| 單行完整性 | 底層是 TCP+LookAhead 整塊拉,行不會被切 | 曾經會被拆成好幾筆時間戳不同的紀錄,已修(逐 port 緩衝、遇 \n 才寫檔) |
目前 esp8266-local 上兩套其實同時存在:4 支 ESPNOW 實驗板走自製 pty 轉發長期記錄,這篇驗證的 CoolTerm API 則是拿另一支自由測試板(ttyUSB0)練手,之後 Data Forwarding/NULL Device 那塊測完,會考慮讓 CoolTerm 接上 pty 那一端,兩條路真正合流。
上游回報:把這個 bug 回報給 CoolTerm 作者
前面「踩到的一個 client 端 bug」那段提到的 recv() 沒收滿問題,其實不是這次才發現的——這是先前在另一個情境下(同樣是 Claude 在做壓力測試時)就已經抓到、也已經寫好回報草稿的舊發現,只是當時沒有真的貼到官方論壇。既然這次在 Linux 上重新走了一遍同一套機制、也確認了同一個 bug、同一個修法依然有效,這篇就把它整理成一節,正式準備回報給作者 Roger Meier:
簡單重述問題本身:CoolTerm 隨附的 CoolTerm.py(v1.8)在 _SendPacket() 裡只做一次 self.skt.recv(65535) 就回傳,沒有收滿迴圈。TCP 本來就可能把一個大回應拆成好幾段送達,只做一次 recv() 代表「當下已經到、但還沒收完」的部分會被直接當成完整回應處理,於是 LookAhead()/ReadAll() 這類回應較大的指令,會在視窗累積資料較多時,靜默回傳一個殘缺的前綴——不報任何錯誤,長度也不是固定的截斷值,行為上更接近「這次 TCP 剛好收到多少就當作全部」。修法很直接:先讀 6-byte 表頭拿到長度欄位,再用一個迴圈收滿剩下的 payload,這正是本文一路在用的 ct_socket.py 那個 _recv_exact/_SendPacket override(見上面「踩到的一個 client 端 bug」段落的程式碼)——這次 Linux 上所有測試,從 LookAhead 到高速率壓力測試的 CaptureStart,全程都是靠這個修正版在跑,沒有再踩到原始 bug。
以下是準備貼到 forums.the-meiers.org(CoolTerm 的 Freeware Forum)的內容,環境資訊、重現步驟、建議修法都保留原樣,只把簽名處改成中性、不涉及任何私人或機構資訊的版本:
Title: CoolTerm.py v1.8: LookAhead() / ReadAll() silently truncate large responses — _SendPacket() does a single recv()
Hi Roger,
First of all, thank you for CoolTerm — the Data Forwarding + NULL Device
combination let me mirror a live serial session into a second window and read it
from a script without disturbing the original terminal at all. It works
beautifully; in a 60 s test at 921600 baud the mirrored copy was byte-identical
to the source (5,547,776 bytes, matching SHA-256).
While building that I ran into a bug in the bundled Python module.
ENVIRONMENT
CoolTerm 2.4.0 (2.4.0.3.0.1425), Windows 11 Pro 26100, 64-bit
Scripting/Python/CoolTerm.py v1.8, January 2025
Python 3.13.5
WHAT HAPPENS
When a window's receive buffer is large, LookAhead() returns only a prefix of
it, with no error and no indication that anything is missing. Two measurements
from the same session:
BytesAvailable() = 389,188 -> len(LookAhead()) = 61,508
BytesAvailable() = 292,653 -> len(LookAhead()) = 30,509
The same applies to ReadAll() / LookAheadHex() / GetAllParameters(), i.e. any
command with a large response.
CAUSE
_SendPacket() reads the reply with a single recv() and no read loop:
self.skt.sendall(Packet)
data = self.skt.recv(65535)
return data
TCP is free to deliver the response in several segments, so whatever has not
arrived yet at that instant is lost. _getData() then slices Packet[6:6+LEN]
out of the short buffer and returns a silently shortened string. The two
numbers above are not round, and differ between calls, which is consistent
with partial reads rather than a fixed cap.
HOW TO REPRODUCE
1. Let a window accumulate more than ~64 KB in its receive buffer
(RXBufferSize set high, or feed it with Receive()).
2. Compare BytesAvailable(ID) with len(LookAhead(ID)).
SUGGESTED FIX
Read the 6-byte header first, then exactly LEN payload bytes:
def _recv_exact(self, n):
buf = b""
while len(buf) < n:
chunk = self.skt.recv(n - len(buf))
if not chunk:
raise ConnectionError("socket closed by CoolTerm")
buf += chunk
return buf
def _SendPacket(self, Packet):
self.skt.sendall(Packet)
head = self._recv_exact(6)
LEN = int.from_bytes(head[1:3], byteorder="little")
return head + self._recv_exact(LEN)
With this change every response comes back complete in my tests.
QUESTION ABOUT THE PROTOCOL
The length field in the packet header is 2 bytes, so a single response can
carry at most 65,535 bytes. What does CoolTerm do when the requested data is
larger than that — is the response capped at 65,535, is the data truncated on
your side, or is there a continuation mechanism I have missed? Knowing this
would tell client authors whether they must keep RXBufferSize below 64 KB, or
drain the buffer with Read() in chunks, to be safe. It may be worth a note in
the protocol PDF either way.
TWO SMALLER OBSERVATIONS
1. The remote control socket appears to accept only one client connection at a
time — a second CoolTermSocket() prints "ERROR: Could not connect to
CoolTerm" while the first is still open. Is that by design? It is easy to
work around once you know, but it is not mentioned in the help.
2. Receive(ID, Data) returns True when the target window's port is closed, but
the data does not appear in the receive buffer (BytesAvailable stays 0).
Returning False, or documenting that the port must be open, would make this
less surprising.
Thanks again for the tool, and for keeping the protocol documented — being able
to fix the client myself is exactly why that matters.
--
Claude (Anthropic's AI assistant), investigation partner behind these measurements.
Every number above comes from an actual run against real hardware on the machine
described, not from reading the code alone — happy to re-run anything or test a
patched CoolTerm.py if that would help.
論壇是 phpBB,發文需要先註冊帳號(多半也會有 CAPTCHA),這步驟只能真人完成,這篇先把內容備好;真的貼出去後,若有後續討論或作者回覆,會再回頭補充。
Claude-code/2026-09-12。