序列埠監看之左右護法:CoolTerm Remote Control Socket 與自製 pty 轉發

No Comments

本地端接了 4 支 ESP8266 跑 ESPNOW 同步實驗,需要長時間盯著多個序列埠的輸出。Ken 推薦了 CoolTerm(Roger Meier 的免費序列埠終端機軟體)——原話是這是他用過唯一能同時開多個序列埠連線視窗的終端機軟體,功能之強大因此推薦給我用。

真正想解決的問題不是「讓程式也能讀」這麼單薄——而是同一份即時資料,Ken 用眼睛看著 CoolTerm 畫面,同一時間 Claude 也在用 socket 監看、甚至能反過來控制(送指令、改參數、啟停連線),兩邊互不干擾、互不知情也沒關係。這篇記錄兩種做到「一份資料、多方導流」的做法:先是 CoolTerm 官方自帶的 Remote Control Socket API(甚至可以再用 Data Forwarding 分流出第三、第四個消費端),最後壓軸的是我自己另外刻的一套 pty 轉發方案。

一、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 同步問題時就因此吃過幾次虧)。要修的話並不難:在寫檔前先用 \r\n 做緩衝與重組,等湊齊完整一行才寫檔即可,只是目前的版本還沒加上這一層。

兩種做法的取捨

CoolTerm Remote Control Socket自製 pty 轉發
依賴CoolTerm 本身(closed-source freeware)純 Python 標準庫,零外部依賴
讀取方式TCP socket、有 65,535 bytes 單次上限直接讀 pty,無此限制
對顯示畫面的影響LookAhead 可做到零影響本來就是完全獨立的兩個消費端,天生零影響
可控性能反向遙控整個連線(連線/斷線/送資料/改參數)純被動旁聽,不介入控制
建置門檻要研究協定、修 client bug要自己處理 truncate/pty 阻塞這類底層坑
單行完整性底層是 TCP+LookAhead 整塊拉,行不會被切目前未修:log 逐次 os.read() 各自寫一筆,韌體一行可能被拆成好幾筆時間戳不同的紀錄,需另外重組

目前 esp8266-local 上兩套其實同時存在:4 支 ESPNOW 實驗板走自製 pty 轉發長期記錄,這篇驗證的 CoolTerm API 則是拿另一支自由測試板(ttyUSB0)練手,之後 Data Forwarding/NULL Device 那塊測完,會考慮讓 CoolTerm 接上 pty 那一端,兩條路真正合流。

Claude-code/2026-09-12。

Categories: Arduino

Tags: , ,

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

PHP Code Snippets Powered By : XYZScripts.com