2026年11月10日 星期二

CAN Bus on the ATS Console: PCAN FD, Error Frames, Scripts and UWB Ranging with NXP NCJ29D6 | ATS 操作台的 CAN 介面:PCAN FD、錯誤框處理、腳本與 NXP NCJ29D6 UWB 測距

ATS 網頁操作台除了 UART、GPIB、Ethernet,還有一個 CAN 分頁:插 PCAN(PEAK)、Kvaser 或 CANable 都能用,支援 CAN FD,可以手動送框、依 ID 彙整、寫腳本。這篇整理 CAN 介面的用法和實機踩到的坑,後半段是這次的重點:把 NXP NCJ29D6 UWB 模組的測距(ranging) 整合進 ATS。原廠的測試工具是加密的 exe,這次照它的 CAN 收發方式,在 ATS 裡用腳本就能讓兩片模組開始測距,即時看到距離、AoA,還有一個相對位置圖。

說明:本文截圖是在開發機上用模擬模式拍的(virtual CAN 上有一顆假的 ECU 和一片假的 NCJ29D6,回應格式照原廠 log),數值只是示意;實機結果是 11/06 在筆電上用兩支 PCAN-USB FD 對測的紀錄。

一、連線:掃描轉接器、鮑率、CAN FD

切到 CAN 分頁會自動掃描插著的轉接器(python-can 的 detect),下拉選單列出全部,正在使用的會反灰。選鮑率,要 CAN FD 就再選資料段速率(1M/2M/5M),別名可以取「UWB 模組」「車身 CAN」方便分辨。開了 FD 的連線名稱會帶「500k/2M FD」,跟不開 FD 視為不同裝置,log 檔也分開。

第一個坑在 PCAN 的 FD 模式:python-can 的 pcan 介面開 FD 時不吃 bitrate/data_bitrate,直接傳會得到「A parameter contains an invalid value」。要給完整的位元時序,ATS 用 80 MHz 時脈、取樣點 80% 算出來,跟 PCAN-View 的 SAE J2284-4 預設(500k/2M)一致:

# ats/ifaces.py CanBus
if c["interface"] == "pcan":
    # PCAN 的 FD 模式不吃 bitrate/data_bitrate,要完整位元時序
    kw = {"interface": "pcan", "channel": c["channel"], "timing": can.BitTimingFd.from_sample_point(
        f_clock=80_000_000, nom_bitrate=kw["bitrate"], nom_sample_point=80.0,
        data_bitrate=kw["data_bitrate"], data_sample_point=80.0)}

二、收發:手動送框、快捷、依 ID 彙整

下方輸入 ID(hex)和資料,勾 EXT(29 bit)或 FD 就能送;可以每 N ms 重複送,常用的框存成快捷(「ID#資料」格式),點一下就送。收發紀錄 → 是送出、← 是收到,FD 框會標 FD 和長度。下圖是模擬 ECU:0x100 每秒心跳,送 OBD 請求 7E0 會從 7E8 回應。

匯流排上框很多的時候,勾「依 ID 彙整」改成每個 ID 一行,顯示方向、DLC、最新資料、次數和實際週期,一眼看出哪個 ID 有沒有在送、週期對不對:

所有收發都記到 CSV(utf-8 BOM,Excel 直接開):一份總表,另外每個連線一份 logs/can/<別名+名稱>_<時間>.csv。

三、錯誤框:只顯示一次、看懂原因、2 秒自動中斷

實機遇到的狀況:接上 DUT 還沒送任何框,畫面就被錯誤框洗版,每秒約 7700 個。一開始以為是沒有 ACK,後來把 PCAN 的錯誤框解開才知道是「接收時填充錯誤、REC 127」:對方送的是 CAN FD,我們這邊沒開 FD。PCAN 錯誤框的格式是 ID=錯誤種類(1 位元/2 格式/4 填充/8 其他),data[0] 0=送出時、1=接收時,data[2]/[3] 是接收/送出錯誤計數:

ERR_KIND = {1: "位元錯誤", 2: "格式錯誤", 4: "填充錯誤", 8: "其他錯誤"}
# 例:ID 4 [4] 01 00 7f 00 → 接收時填充錯誤、REC 127
#   → 提示「鮑率不同或對方送 CAN FD(沒開 FD 時建議 500k/2M)」
# 送出端的錯誤才提示 ACK(沒接東西、只有自己一個節點)

現在的處理方式:同一波錯誤框只顯示第一個,附一行中文原因和建議;之後每秒(10 秒後每 10 秒)一行「已 N 個」。錯誤持續超過 2 秒就自動中斷這個連線,視窗留著標「已中斷」看原因,改好設定再連。另外,沒接東西時送框會因為沒有 ACK 讓控制器無限重送,PCAN 實機證實 bus.reset() 停不了,所以「剛送過框又一直錯」時會把通道關掉重開。

四、CAN 腳本

腳本一行一個框,格式跟快捷一樣是 ID#資料,前面可以加 FD、EXT;@wait 2 等 2 秒,# 開頭 是註解。CAN 沒有「查詢」,所以另外加了等回應的語法:

語法意思
7E0#02 01 0D 00 00 00 00 00送一個框
@expect 011#41 03 XX XX 00 3s等收到開頭符合的框(ID 寫 * 不限、XX 不比對),沒等到腳本就停
UCI 012#20 02 00 00送一則 UCI 訊息,一律 FD,超過 64 bytes 自動分段(下面說明)
@uci */@uci off把收到的框當 UCI 解讀(每則翻成一行中文);off 關掉並印統計
@to COM11 dio17_on => ok> 3s送到另一個開著的連線(例如 UART 控制板讓 relay 上電),可等回應

@expect 從「上一個送出的框之後」收到的開始找,而且依序消耗:同一個回應不會被兩行 @expect 用到,所以「先等 RSP、再等 NTF」這種順序可以照寫。每個 CAN 裝置各自存一份腳本(macros\can\<介面>_<通道>.yaml)。

五、UWB 測距:NXP NCJ29D6 的 UCI over CAN

NCJ29D6 是 NXP 的車用 UWB 晶片,公司的模組透過 CAN FD 跟 PC 溝通,原廠有一支 Ranging Test Tool(Python 包成 exe、Pyarmor 加密)。從它的 log、PCAN-View 的紀錄,以及另一版沒加密的原始碼,把收發方式整理出來:

項目內容
匯流排CAN FD+BRS,500k/2M(80 MHz,取樣點約 80%)
PC → 模組ID 依板子不同(原廠 Setting.ini:0x12/0x10/0x06),ATS 預設 012
模組 → PC實機是 0x011(原廠收的時候不分 ID,所以腳本的 @expect 用 *)
資料就是原始 UCI 訊息:標頭 4 bytes [MT|PBF|GID][OID][長度高][長度低];MT 1=指令 0x2X、2=回應 0x4X、3=通知 0x6X;回應後面補 00 到 FD 長度
長訊息超過 64 bytes 用 UCI 的 PBF 分段:每段 payload 最多 60、各自帶標頭,中間段 byte0 加 0x10,送的時候段間隔 20 ms

腳本的 UCI ID#訊息 會自動切段;收的方向開 @uci 時自動接回完整訊息再解讀。切段的程式照原廠 transmit_uci_message 的做法:

# ats/uci.py
def fragments(msg, size=64):
    """完整 UCI 訊息 → 一個個 CAN FD 框的資料(每段 payload 最多 60,標頭 byte2/3 是這段長度)"""
    msg = bytes(msg)
    if len(msg) <= size:
        return [msg]
    body, room, out = msg[4:], size - 4, []
    for k in range(0, len(body), room):
        seg = body[k:k + room]
        last = k + room >= len(body)
        out.append(bytes([msg[0] & 0xEF if last else msg[0] | 0x10, msg[1], len(seg) >> 8, len(seg) & 0xFF]) + seg)
    return out

內建四個腳本

這些腳本寫在 instruments/NCJ29D6/commands.yaml,標了 iface: can,所以會成為所有 CAN 連線的預設腳本:

  • 讀版本:CORE_GET_DEVICE_INFO(回應 200 多 bytes、分 4 段,接回來後顯示韌體/MAC 版本)+ DEVICE_GET_REBOOT_REASON。
  • FiRa Responder 開始測距:重設 → 低功耗關 → 天線延遲(0x2A)/天線間距 17 mm → SESSION_INIT(session ID 0xCEFAFECA)→ 共同參數(DS-TWR、channel 9、間隔 1000 ms)→ responder 參數(自己 MAC DDCC、對方 BBAA)→ START。
  • FiRa Initiator 開始測距:一樣的流程,改成 controller/initiator、自己 BBAA、對方 DDCC。
  • 停止測距:原廠結束時只送 CORE_DEVICE_RESET,沒有 SESSION_STOP;ATS 照做,並關掉 UCI 解讀、印出統計。

每一步都用 @expect 確認回應狀態 00(OK),SESSION_INIT 還會確認 handle 是 00 00 01 00(原廠後面的指令是固定照這個值寫的)。腳本開頭先 reset 是原廠沒有的,免得上一次的 session 還在,SESSION_INIT 回 SESSION_DUPLICATE。

解讀 RANGE_DATA_NTF:距離與兩個 AoA

開始測距後,模組每秒送一筆 RANGE_DATA_NTF(68 bytes,分兩個 FD 框)。開了 @uci,ATS 會把它組回來翻成一行:距離(cm)、自己量的 AoA,以及對方量的 AoA:

# ats/uci.py Decoder.range_data():每筆 TWR 量測 31 bytes(短 MAC)
mac, st, d = m[1::-1].hex().upper(), m[2], int.from_bytes(m[4:6], "little")      # MAC、狀態、距離 cm
if st == 0 and d != 0xFFFF:
    a = int.from_bytes(m[9:11], "little", signed=True) / 128                      # 自己量的 AoA(度)
    if len(m) >= 18 and m[17]:                                                    # FOM 不是 0 才有
        b = int.from_bytes(m[15:17], "little", signed=True) / 128                 # 對方量的 AoA

這個「對方量的 AoA」是實機對測才發現的:Initiator 收到的 RANGE_DATA 裡,每筆第 15~16 byte 還帶著 Responder 量到的角度,Responder 那邊這個欄位則全是 0。所以 ATS 對 Initiator 顯示「自己量/對方量」兩個 AoA,統計也分開算。

實機對測結果

11/06 在筆電上插兩支 PCAN-USB FD,各接一片模組,一邊跑 Responder、一邊跑 Initiator:CAN ID 用預設 012 就通,模組從 0x011 回。

項目結果
距離約 106 cm
Responder 自己量的 AoA-1.33°
Initiator 收到的「Responder 量的 AoA」-0.88°(跟上一列是同一個角度,兩邊差 0.45°)
Initiator 自己量的 AoA-17.73°(擺放角度造成,確認過不用處理)

停止腳本送 CORE_DEVICE_RESET 後 @uci off,會印出這次的統計:筆數、距離平均/最小/最大/差,兩個 AoA 各自的平均和範圍:


UWB 相對位置圖

只看數字不直觀,所以 CAN 視窗工具列多了「UWB 圖」,開一個彈出視窗:Responder 固定在下方朝上,Initiator 依「距離+Responder 量的 AoA」定位,箭頭是 Initiator 的正前方(由 Initiator 自己量的 AoA 推回來),還有軌跡、平滑(1/3/5/10 筆平均)、右側的距離與兩個 AoA 統計,下方是 2 分鐘曲線,滑鼠移上去看每一點的數值。伺服器解到 RANGE_DATA_NTF 時另外廣播一個 uwb 事件給這個頁面,不進收發紀錄。

還沒確認的

  • AoA 的正負方向(左正還是右正)實機還沒確認,UWB 圖先留了「左右反轉」開關。
  • PCAN-View 有設 SSP Offset 32,python-can 沒有這個參數;如果資料段不穩,先查這裡。
  • 每片板子 PC → 模組的 CAN ID 可能不同,不是 012 時要把腳本裡的 UCI 012# 改掉。

2026年11月9日 星期一

Driving an Anritsu MT8870A from the ATS Console: VSG Packets and VSA Measurements over GPIB | 用 ATS 操作台控制 Anritsu MT8870A:GPIB 送封包與量測

公司有一台 Anritsu MT8870A Universal Wireless Test Set,機箱裡插了兩片 MU887001A TRX 測試模組(Slot 1、Slot 4),可以量 WLAN、藍牙、GPS 等無線訊號。原本要用原廠的 CombiView 操作,這次把它接進自己寫的 ATS 網頁操作台,用 GPIB 控制:一片當訊號產生器(VSG)送出 802.11b 封包,另一片當分析儀(VSA)量功率、EVM 和頻率誤差,結果直接畫成曲線。這篇記錄怎麼接、怎麼操作,還有實測時踩到的坑。

先搞清楚:每一片模組都是一台獨立的儀器

MT8870A 的機箱只是電源和外殼,真正做事的是裡面的 TRX 模組。每一片模組有自己的 IP、自己的 GPIB 位址,要分開連線、分開下指令。機箱背面有 GPIB 1~4 四個接頭(選配),問 *IDN? 會回模組的型號和序號:

*IDN?    →  ANRITSU,MU887001A,6272359750,04.01.00   (模組)
*IDN? 1  →  ANRITSU,MT8870A,…                       (機箱)

兩片模組出廠的 GPIB 位址都是 1。串在同一條 GPIB 線上時,問一句會兩片同時回答,第一次回的序號會變來變去,接著就整個逾時。用下面這條指令一次設定四個 Slot 的位址,送給任一片都可以,設完重啟網路才會生效(大約 80 秒):

SYST:LANG SCPI
:SYST:COMM:GPIB:ADDR:ALL 1,1,1,2    ← Slot 1=1、Slot 4=2
:SYST:COMM:NET:REST

之後 PC 上就有兩台儀器:GPIB0::1::INSTR(Slot 1)和 GPIB0::2::INSTR(Slot 4)。開機時按住面板的 IP Reset,位址會還原成全部 1。

在 ATS 連線:自動辨識、自動標 Slot

  1. 左邊「新增連線」選 GPIB 分頁,按重新掃描,選 GPIB0::1::INSTR 按連線,另一片同樣做一次。
  2. ATS 連上後自動問 *IDN?,型號對上 MT8870A 指令集,類型顯示「頻譜」,工具列多出「RF 輸出」「腳本」「指令集」。
  3. 接著再問 :SYST:INF:SLOT?,連線名稱後面自動加上 Slot 1/Slot 4。兩片模組型號一樣,沒有這個很容易搞混哪一片在發射。
  4. 別名可以自己取,例如 VSG、VSA。

每片模組連上後,記得先切 SCPI 模式並選應用程式,不然其他指令都會回 -113 Undefined header:

SYST:LANG SCPI
INST SRW        ← Short Range Wireless:WLAN/藍牙

VSG:用「腳本」一鍵送出 11b 封包

讓模組播放標準封包波形要十幾條指令,還要等波形載入(第一次大約 40 秒)。ATS 加了「腳本」功能,把一串指令包成一個名字,選了按執行,伺服器就照順序送完,執行中按鈕會顯示目前第幾行,也可以按停止中斷。

MT8870A 預設有 8 個腳本:VSG 送 11b/11g/11n MCS7/藍牙 DH1 封包、VSG 停止發送、VSA 從 Port 1 收、VSA 從 Port 3 收、VSA 11b 量一次。按「編輯」可以直接改內容,例如把頻率改成別的頻道,存在 ATS 資料夾的 macros\,更新程式也不會被蓋掉。

「VSG:11b 11 Mbps 封包」腳本的內容:

SYST:LANG SCPI
INST SRW
ROUT:PORT:CONN:DIR PORT1,PORT1          ← 輸入埠,輸出埠(一定兩個一起給)
SOUR:GPRF:GEN:MODE NORMAL
SOUR:GPRF:GEN:ARB:FILE:LOAD 'MV887030A_b_11_1024L'
@until SOUR:GPRF:GEN:ARB:FILE:LOAD? 'MV887030A_b_11_1024L' == 0 90
SOUR:GPRF:GEN:RFS:FREQ 2412MHz
SOUR:GPRF:GEN:RFS:LEV -10               ← Port 1/2 最大 -10 dBm,Port 3/4 最大 0 dBm
SOUR:GPRF:GEN:ARB:WAV:PATT:SEL 'MV887030A_b_11_1024L',1
SOUR:GPRF:GEN:BBM ARB
SOUR:GPRF:GEN:STAT 1

工具列的「RF 輸出」按鈕對應 SOUR:GPRF:GEN:STAT 1/0/?:紅色=正在發射,按一下停止;開啟前會跳確認框。不管是按鈕、腳本還是手動送指令開關,ATS 都會再問一次狀態,按鈕跟實際一致。

VSA:量測項目畫成曲線

VSA 那格勾「曲線」,量測項目選 11b 平均功率、峰值功率、EVM、頻率誤差(可以複選),按開始量測。開始時先送一次設定(segment、觸發、濾波器、頻率、自動調準位),之後每一輪送一次「擷取+讀結果」。

MT8870A 的結果是一長串逗號分隔的數字,所以指令集的量測項目多了 field(取第幾欄)和 timeout。例如功率:

:INIT:SRW;*WAI;:FETC:SRW:SUMM:WLAN:POW? 1,1
→ 1,0,1,1,1,-9.25,-10.97,1.70,1,0,-10.97,-10.97,6,7
            第 6 欄峰值 ↑   ↑ 第 7 欄平均(dBm)

:INIT:SRW;*WAI;:FETC:SRW:SUMM:WLAN:DSSS:EVM? 1,1
→ 第 5 欄 EVM(%)、第 10 欄頻率誤差(ppm)

量測項目不指定接收埠,照模組目前的設定收;開始量測前先執行腳本「VSA:從 Port 1 收」或「從 Port 3 收」。沒抓到封包時回 1,0,1,0(只有 4 欄),那一輪曲線就不畫點。

指令集面板

按「指令集」會列出整理好的 SCPI 指令:系統、埠與線損補償、擷取設定、量測項目、讀結果、訊號產生器、RX 封包序列、FM/音訊。點一條就填進輸入框,危險的指令(例如 *RST、改 GPIB 位址、開 RF 輸出)前面有 ⚠,送出前要確認。指令來源是原廠的 MU887000A 操作手冊和 Remote Command List Examples。

面板燈號怎麼看

  • Module Status 綠:模組正常;Remote 綠:正被遠端控制。
  • 燈 2 綠:RF 輸出開著;紅:波形檔載入失敗。
  • 燈 3 綠:正在量測;紅:上一次量測失敗(下一次成功才會熄,當 VSG 不量測時會一直紅著,不代表現在有問題)。
  • 各埠的 Input/Output 燈:訊號從這個埠進/出。ROUT:PORT:CONN:DIR PORT1,PORT1 會讓 Port 1 的 Input、Output 都亮。

實測踩到的坑

  • GPIB 位址衝突:兩片模組出廠都是位址 1,串同一條線會互相干擾,要先改位址(見上面)。
  • 觸發準位:原廠範例用 :CONF:SRW:TLEV -20(相對參考準位往下 20 dB)。訊號弱時(-47 dBm、雜訊底 -59)觸發點掉進雜訊,一直亂觸發、抓不到封包;改成 -10 就穩定。
  • 11b EVM 要設濾波器:沒送 :CONF:SRW:WLB:FTYP GAUSSIAN 時 EVM 是 3.8 %,設了是 0.7 %。
  • 線要真的接通:一開始兩片之間少了 36 dB、讀數亂跳、EVM 到 50 %,最後發現兩條線各自通到別處,只是靠漏出去的訊號互相收到,而且開路的線像天線一樣收到辦公室的 2.4 GHz 訊號。判斷方法很簡單:把 VSG 關掉,VSA 還收得到東西就不是你的訊號。
  • 沒有線也能先驗證:Port 1/2 可以同時送和收,同一片模組 ROUT:PORT:CONN:DIR PORT1,PORT1 開產生器、分析也從 Port 1 收,訊號在內部繞回來,實測 -10.16 dBm、EVM 0.7 %,可以快速確認模組本身正常。
  • 查詢後面帶參數:FETC:SRW:SUMM:WLAN:POW? 1,1、*IDN? 1 這種問號後面還有參數的查詢,ATS 原本當成寫入不讀回應,回應被下一條讀走;改成看最後一段指令的標頭。

小結

現在 MT8870A 在 ATS 裡跟電源、電錶、示波器一樣,連上就自動辨識,有指令集、輸出按鈕、腳本和量測曲線,兩片模組對打的讀數也正確(-10.98 dBm、EVM 0.6 %)。下一步是接真的 Wi-Fi/藍牙 DUT 從 Port 3 量,並核對藍牙、BLE 結果的欄位,再把這些步驟串成自動測試流程。

GW Instek PPT-3615 Triple-Output Supply on the ATS Console: Channel Select, Output Button and Multi-Trace Charts | 用 ATS 操作台控制固緯 PPT-3615 三通道電源

GW Instek(固緯)PPT-3615 是三通道可程式線性電源:CH1、CH2 各 0~36 V/1.5 A,CH3 0~6 V/3 A,只有 GPIB 介面。它跟 Tektronix PS2520G 是同一台機器(OEM),所以指令集參考 Tek 的程式手冊,但實際接上去才發現 GW 韌體有不少差異。這篇記錄怎麼在 ATS 網頁操作台上控制它,以及那些手冊上沒寫的地方。

說明:本文截圖是在開發機上用模擬儀器拍的(回應格式照實機),數值只是示意;指令差異都是公司實機測出來的。

連線與辨識:*IDN? 是用分號分隔的

一般 SCPI 儀器的 *IDN? 是用逗號分隔「廠牌,型號,序號,韌體」,PPT-3615 回的卻是:

GOOD WILL;PPT-3615;0870154;SCPI:93.0 FW:1.15

ATS 原本只切逗號,結果判成「通用裝置」,後來改成逗號、分號都能切,現在可以正確辨識成「電源」,工具列也會多出「輸出」按鈕。

輸出按鈕與選通道

實機測出來跟 Tek 手冊不一樣的地方:

  • 選通道要用 :CHAN 1(CHAN 和數字中間要空格),查目前通道用 :CHAN?。手冊上的 INST:NSEL、INST:SEL 全部回 -100;寫成 :CHAN1 會 -108。之後的 VOLT、CURR、MEAS 都是對目前選的通道。
  • 輸出開關要寫完整的 OUTP:STAT 1/OUTP:STAT 0,簡寫 OUTP ON 不認得、OUTP? 會直接逾時。一開始「開始量測一直讀到 0 V」就是因為輸出根本沒開,所以 ATS 才加了輸出按鈕。三個通道是一起開關的,開之前 ATS 會跳確認框提醒先檢查電壓、限流和 OVP。
  • 不認得的查詢不回應:不會回錯誤字串,VISA 等到逾時(2 秒),之後 SYST:ERR? 會多出 -100 和 -420。
  • 查詢結尾加不加分號都可以,例如 MEAS:VOLT?;,跟以前 C# 程式的寫法相容。
  • VOLT?/CURR? 回的是設定值(限制值),實際輸出要用 MEAS:VOLT?/MEAS:CURR?。

曲線:三個通道一起看

量測功能是可以複選的清單:CH1~CH3 的電壓、電流共 6 項,勾幾項就畫幾條線。

每一項都有自己的前置指令(:CHAN n),伺服器只在通道跟目前不同時才送,所以一輪會是 :CHAN 1 → MEAS:VOLT? → MEAS:CURR? → :CHAN 2 → MEAS:CURR? → :CHAN 3 → MEAS:CURR?。第一個單位(V)畫在左軸、第二個單位(A)畫在右軸,刻度用該線的顏色。

要注意:切通道會改到面板上目前選的通道,量測停止後面板會停在最後一個通道。

指令集

指令集共 6 類 40 條、18 個錯誤碼,另外整理了保護功能:OVP 動作會關輸出並報 -300(面板 Err -013),排除後送 OUTP:PROT:CLE;OCP 一到限流就關輸出(Err -012),要 CURR:PROT:STAT OFF 再 ON 才清得掉。

2026年11月7日 星期六

Tektronix TDS3054B over Ethernet on the ATS Console: Live Scope Screen and Measurement Trends | 用 ATS 操作台控制 Tektronix TDS3054B 示波器:網路連線、畫面與量測曲線

Tektronix TDS3054B 是四通道、500 MHz 的數位示波器(TDS3000B 系列)。公司這台背後的通訊模組槽是空的,所以沒有 GPIB、也沒有 RS-232,只有內建的 Ethernet。這篇記錄怎麼把它接進 ATS 網頁操作台:網頁上照示波器螢幕畫出波形,也可以把頻率、Vpp 這些量測值畫成隨時間變化的曲線,最後是幾個讓示波器卡死的踩坑。

說明:本文截圖是在開發機上用模擬儀器拍的(回應格式照實機,CH1 是 1 kHz 方波、CH2 是正弦),數值只是示意;連線方式和踩坑都是實機經驗。

連線:Ethernet,但要走 VISA(VXI-11)

TDS3000B 的網路是 VXI-11 協定,不是常見的 raw socket(TCP 5025),所以 ATS 的「Ethernet」分頁連不上它。要走「GPIB」分頁(其實是 VISA),位址選「手動輸入」,填:

TCPIP0::192.168.10.2::INSTR
(不通再試 TCPIP0::192.168.10.2::gpib0,1::INSTR)

示波器的 IP 在面板 UTILITY → System: I/O → Ethernet Network Settings 設定,建議用固定 IP。公司這台直接用網路線接在測試桌機的第二張網卡上。手動輸入的位址連線成功一次後,ATS 會記進 visa_addresses.txt,之後就會出現在下拉選單裡。

網路儀器的位址 ATS 掃描時不會去問 *IDN?,因為這台示波器一次只接受一個 VXI-11 連線,掃描時開連線會跟正在用的程式搶。另外,瀏覽器打 http://示波器IP/ 是原廠的 e*Scope 網頁,可以看螢幕截圖、下指令,不用裝任何東西。

示波器畫面:網頁上照螢幕畫波形

連上後辨識成「示波器」類型,勾「曲線」會多一個下拉選單「示波器畫面/量測值曲線」,預設是示波器畫面。按「開始量測」,伺服器每一輪:

  1. 讀時基、觸發來源/準位/斜率、執行狀態::HORizontal:MAIn:SCAle?;:TRIGger:A:EDGe:SOUrce?;…
  2. :SELect? 看哪些通道開著,:HORizontal:RECOrdlength? 拿點數(10000)。
  3. 每個開著的通道讀 SCAle/POSition/OFFSet,設 DATa:SOUrce CHx;ENCdg ASCIi;…,讀換算係數 WFMPre:XINcr?;XZEro?;YMUlt?;YZEro?;YOFf?,最後 CURVe? 拿波形。
  4. 換算成「格」(電壓 =(原始值 − YOFf)× YMUlt + YZEro,格數 =(電壓 − OFFSet)/ SCAle + POSition),網頁照 10×8 格的螢幕畫出來。

波形資料一次一萬點,所以不寫進收發紀錄,也不進 CSV;「匯出 CSV」會存目前這一張。示波器本身的設定(時基、垂直刻度、觸發)還是在面板上調,ATS 只負責讀。

量測值曲線:長時間觀察頻率、Vpp

切到「量測值曲線」,可以從 44 個量測項目(CH1~4 × 平均、Vpp、最大、最小、RMS、頻率、週期、上升/下降時間、正脈寬、正工作週期)複選:

用的是 MEASUrement:IMMed(不顯示在螢幕上的量測),每項先送 HEADer OFF、切來源和類型,再讀 MEASUrement:IMMed:VALue?,不會動到螢幕上的四格量測。幾個要注意的地方:

  • Tek 預設回應帶標頭(:MEASUREMENT:IMMED:VALUE 1.2E0),不先送 HEADer OFF 曲線會讀不到數字。
  • 量不到(沒觸發、訊號不到一個週期、超出畫面)回 9.9E37,ATS 不畫這種點。
  • 時基 40 ms/div 以上是 Roll 模式,波形還沒成形前頻率、上升時間可能一直量不到。
  • 錯誤不會自己回傳,要用 EVMsg? 或 ALLEv? 讀事件,ATS 會附上中文說明(指令集收錄 124 個事件碼)。

踩坑:沒讀完的波形讓示波器卡死

有一次 ATS 在讀 CURVe? 的途中被強制結束,波形沒讀完、留在示波器的輸出緩衝。下次 ATS 連上問 *IDN?,先讀到的是那一大串舊波形,辨識就錯了。更糟的是嘗試「連上就讀到逾時,把殘留清掉」時,TDS3054B 的 VXI-11 直接卡死:開得了連線但什麼都不回,只能關機重開。

現在的做法是:網路儀器開連線時先 Device Clear,再送 *OPC?,一直讀到回「1」為止,前面讀到的都丟掉。絕對不要在沒送查詢的情況下去讀。正常按「停止量測」不會有這個問題,伺服器會把讀到一半的 CURVe? 讀完。

另外兩個小地方:示波器一次只收一個連線,ATS 連著時其他程式(例如另一個 ATS 視窗)會開不了,ATS 會提示「可能有別的程式正連著它」;被強制中斷後示波器大約要 30 秒才會釋放連線。如果是儀器先關機,ATS 的連線按 ✕ 也能直接移除,不會卡在清單裡。

Pi 5 Mecanum Robot Car: Web Control, Gyro Straight-Line Hold, Distance & Route Maps, Obstacle Avoidance | Pi 5 麥克納姆輪自走車:網頁遙控、陀螺儀走直線、距離圖與路線圖、自動避障實車調校

📺 影片版(約 8 分鐘):想先看實際操作的話可以直接看這支影片,完整步驟、設定與程式碼在下方文字版。
YouTube:https://youtu.be/Imtc1hFkkwQ(winson-DIY 頻道)
⚡ Shorts 精華版(35 秒):https://youtube.com/shorts/PoqKt63YW88

這台麥克納姆輪車之前是 Arduino UNO/Mega2560+ESP32-CAM 的取物機器人。這次把 Arduino 拿掉,改成 Raspberry Pi 5 直接用 GPIO 驅動兩片 L298N,加上 Logitech C310 攝影機、HC-SR04 超聲波(裝在 MG90S 伺服上可以轉頭)、MPU6050 陀螺儀,做成一台用手機網頁遙控、會自己避障的自走車。這篇記錄硬體接法、網頁介面,以及實車調校時一個一個踩到的坑。

硬體與接線

零件用途接到 Pi 5
L298N ×2四顆 TT 馬達(每片管一邊兩顆)EN 用 GPIO12/13/18/19(PWM 調速,板上 ENA/ENB 跳帽要拔),IN 用 8 支 GPIO
HC-SR04前方距離Trig GPIO17;Echo 是 5V,經 1kΩ/2kΩ 分壓才接 GPIO27
MG90S超聲波轉頭(±80°)GPIO22,電源吃 L298N 的 5V(不吃 Pi 的 5V)
MPU6050(GY-521)陀螺儀:車頭方向、撞擊偵測I2C:SDA 3 腳、SCL 5 腳、VCC 3.3V
Logitech C310影像+YOLO 物件偵測USB(640×480 YUYV,MJPG 會一直噴壞 JPEG)
18650 ×3 串聯馬達電源(12.6V 滿)→ 保護板 → 開關 → 兩片 L298N,GND 跟 Pi 共地
行動電源Pi 5 電源USB-C,跟馬達電源分開




三顆 18650 滿電 12.6V,扣掉 L298N 約 2V 壓降,馬達還是吃到 9~10V,而 TT 馬達額定 3~6V,所以程式把 PWM 上限壓在 60%。Pi 5 用行動電源供電,網頁上會顯示 Pi 的 5V 電壓;手邊這顆只有 4.75~4.89V,常跳「曾電壓不足/曾降頻」,之後要換 PD 的行動電源。

網頁操作

程式是 Python(aiohttp+lgpio+OpenCV+onnxruntime),開機自動跑成 systemd 服務,手機連 http://pi5-2:8080/:左邊搖桿前後+左右平移,右邊橢圓搖桿原地旋轉,影像上疊距離,YOLO(yolo26n,CPU 每張約 32 ms、限每秒 10 次)把看到的東西框出來。網頁每 0.1 秒送一次搖桿,超過 0.5 秒沒收到就停車(斷線、手機鎖螢幕)。


實車調校一:輪子方向與超聲波

  • 輪子方向:網頁有「單輪測試」,每顆按「正轉」都應該讓車往前推。實車 RL(左後)和 FR(右前)是反的,不用改線,在 config.yaml 把那兩顆的 invert 改成 true。
  • 超聲波第一次完全沒回音:Pi 送了 10 次 Trig,GPIO27 一次都沒變高——是接線問題(實體腳號和 GPIO 編號很容易搞混,第 13 腳是 GPIO27,GPIO13 是第 33 腳),重接後馬上量到 40 cm。
  • 轉頭後距離不會變:原本程式只在伺服朝正前方時才量,轉到 -10° 就凍結在最後一個值。改成任何角度都量,網頁標出方向(例如「63 cm 右 10°」);「太近不准往前」只看 ±15° 內的讀值。

實車調校二:陀螺儀讓車走直線

TT 馬達左右快慢不一樣,速度 50% 直走 3 秒,車頭就往左偏了 10°。加上 MPU6050 之後,搖桿沒有旋轉時程式會鎖住起步那一刻的車頭方向,偏了就讓兩邊輪子一快一慢拉回來(PD 控制),另外再學一個「這台車直走本來就要往右補多少」的固定量(積分,前/後/左/右分開學)。

版本3 秒直走的車頭偏移
沒修正10°
kp 0.037°
kp 0.1、ki 0.05、上限 0.5最多 2°,之後自己拉回,停下時 1.3°;實際走 115 cm,眼睛看是直的

兩個坑:

  • 兩個網頁互相蓋掉:我從遠端送「前進」做測試時,使用者手機的網頁還開著、每 0.1 秒送一次「搖桿放著=0」,車子其實是走走停停,第一次量到的「3 秒 80 cm」是錯的(真的是 115 cm)。改成每個網頁的搖桿分開記,有人在推就用最新推的那個,全部放開才停。
  • 積分飽和:自動模式卡在櫃子前推不動時,修正量一路累積到上限 -0.5,右輪只剩 14% 出力,車子一直往右繞。改成偏差小於 10° 才學、修正量上限 0.2、偏超過 20°(被撞歪、手轉、卡住)就改用現在的方向當目標。

距離圖與路線圖

影像下面多了兩張圖:

  • 距離圖(車身座標,車頭朝上):超聲波每 5° 一格、15 秒內的讀值畫成綠點連成輪廓;按「掃描一圈」伺服從右 80° 轉到左 80°,每 10° 量一次。黃色扇形是攝影機視角,YOLO 認得的東西畫成黃點並估距離。
  • 路線圖(俯視,出發時車頭朝上):沒有編碼器,位置是「送給四輪的速度 → 麥克納姆反運算 → 乘實測 77 cm/s(全速)」推算,方向用陀螺儀;超聲波打到的點換成世界座標畫成紅點,跑久了房間的輪廓就出來了。

攝影機估距離用「地面法」:鏡頭離地 8 cm,東西放在地上時,它的底邊在畫面越下面就越近,距離=鏡頭高度 ÷ tan(底邊在水平線下的角度);底邊碰到畫面下緣(看不到落地點)時,改用人、椅子這類已知高度的東西用框的高度估。一開始櫃子被估成 28 cm,因為鏡頭被夾子夾得往上仰——用一個放在 100 cm 的箱子校正:箱子底邊在畫面第 350 列、遠處地面的水平線在第 290 列左右,算出鏡頭往上仰約 6°,校正後估成 87 cm(從車頭算),跟超聲波的 86 cm 對得上。


自動避障:一路改了好幾版

最初的邏輯很簡單:前方 25 cm 內有東西就停、伺服左右各看 60°、往比較空的一邊轉。實車跑起來的問題和改法:

實車問題原因改法
斜面的路障偵測不到聲波被斜面彈走,收不到回音,原本當成「前方沒東西」前進時伺服在 0/左 20°/0/右 20° 輪流量,取擋在車寬內最近的;±25° 都沒回音超過 1 秒就停下來看;沒回音或接近時半速
撞到東西還一直頂沒有感測MPU6050 的加速度:水平加速度突然超過 0.9 g 當撞擊,退一點再找路
一直誤判撞到(前方還有 124 cm)起步、急停的加速度也超過門檻穩定前進 0.8 秒之後才算撞擊
跑不出房間只看左右兩個角度、固定轉一個角度;轉完還用轉之前的前方距離看 5 個角度(右 80、右 40、正前、左 40、左 80),往最空的方向轉、每次最多 30°;轉完清掉舊讀值
左右來回換、轉不出去每次重新選邊記住閃避方向:沒走出 50 cm 又被擋就繼續往同一邊;那邊累積轉 120° 還不行就記住、改另一邊;兩邊都不行就後退重來
頂著抽屜櫃推 45 秒超聲波裝得低,從櫃子下面看出去一直量到 90 cm卡住偵測:輪子推算走了 25 cm 以上,正前方距離卻變不到 1/4 → 當成卡住,後退換方向
太快自動模式不吃網頁的「速度上限」自動前進、轉彎、後退都乘上速度上限;轉彎改用陀螺儀轉到角度就停

最嚴重的坑:重開服務時暴衝

有一次車子突然全速衝進躺椅腳靠底下。查了才發現兩件事疊在一起:

  1. lgpio 的 tx_pwm(…, 0) 不會把腳位拉低:它只是停掉 PWM,腳位停在最後的電位。用 pinctrl get 一看,四支 L298N 的 EN 一直都是 high。
  2. 我用 SIGKILL 重開服務(程式收到 SIGTERM 不會自己結束,之前都用強制結束),當時車子正在自動模式轉動:方向腳停在「轉」、EN 又是 high,新程式起來前那 2~3 秒就是 100% 全速。

修法:停車時一定 gpio_write(EN, 0);systemd 服務加 ExecStartPre/ExecStopPost 用 pinctrl set … op dl 把 12 支馬達腳全部拉低、TimeoutStopSec=3;收到關閉訊號第一件事先停車。之後就算程式當掉或被砍,馬達也不會停在轉動狀態。接馬達的專案,「程式死掉時腳位會停在哪」一定要先想好。

還沒解決的

  • 超聲波看不到比它高或低的東西(躺椅腳靠、櫃子下緣、門檻),打算車頂加一顆 VL53L1X 雷射測距,跟 MPU6050 共用 I2C。
  • Pi 5 的行動電源電壓偏低(4.75~4.89V),要換 PD 5V 3A。
  • 路線圖的平移效率(假設直走的 80%)還沒實測;跑久了會飄。

2026年11月6日 星期五

HP 34401A on the ATS Console: GPIB Readings, Command Set and Live Charts | 用 ATS 操作台控制 HP 34401A 電表:GPIB 讀值、指令集與即時曲線

實驗室最常用的儀器大概就是電表。公司這台 HP 34401A 是六位半的桌上型數位電表,雖然是 1990 年代的機種,GPIB 和 SCPI 指令都很完整,到現在還是很好用。這篇介紹怎麼用自己寫的 ATS 網頁操作台控制它:自動找到儀器、查中文指令集、把讀值畫成即時曲線。

說明:本文截圖是在開發機上用模擬儀器拍的(回應格式照實機),畫面上的數值只是示意;指令和注意事項則來自公司實機測試。同系列還有 MT8870A 無線綜測儀那篇。

連線:GPIB 分頁自動找到儀器

左邊「新增連線」切到 GPIB,ATS 會透過 VISA(NI-VISA 或 Keysight IO Libraries)列出匯流排上的所有儀器,並逐台問 *IDN?,下拉選單直接顯示「位址+型號」,不用再去記哪台是幾號。34401A 出廠的 GPIB 位址是 22,公司這台改成了 24。

連上之後 ATS 會自動辨識成「電錶」類型,並載入 34401A 的指令集。別名會出現在連線清單和 log 檔名上,同時接好幾台儀器時比較好分。

指令集:98 條指令中文說明

送出列旁邊的「指令集」按鈕會打開這台儀器的指令清單。內容是從原廠 User’s Guide(34401-90004)第 4 章整理出來的,共 9 類 98 條,每條都有中文說明和手冊頁碼,可以搜尋、點一下就填進輸入框,<參數> 會自動反白方便直接改。錯誤碼(34 個)也有中文對照:送 SYST:ERR? 回來的錯誤會直接附上說明。

曲線:選量測功能,按開始

勾工具列的「曲線」,選量測功能(DC 電壓、DC 電流、AC 電壓、2 線/4 線電阻、頻率、週期等 9 種),設定間隔後按「開始量測」。ATS 伺服器會先送一次設定指令(例如 CONF:VOLT:DC),之後每一輪送 READ?,一筆回來才問下一筆,不會因為儀器慢而排隊。上面顯示最新值、最小、最大、平均、標準差,所有資料同時寫進 CSV log。

實用細節

  • NPLC 和逾時:*RST 之後是 NPLC 10(5½ 位)。改成 NPLC 100 時一筆要 2 秒以上,READ? 會超過 VISA 預設的 2 秒逾時,量測前先把連線的「逾時 ms」調大。想要快就選「DC 電壓(快速 4½ 位)」,會送 CONF:VOLT:DC 10,0.001 加 VOLT:DC:NPLC 0.2。
  • 超量程:回 ±9.90000000E+37,ATS 的曲線不畫這種點,統計也不算進去。
  • 同一條 GPIB 上的其他儀器:34401A 在跑曲線時,每次 READ? 會佔住匯流排一段積分時間。我們就碰過同一條 GPIB 上寫 PBZ 電源序列慢了十幾倍,後來 ATS 在寫入序列時會自動暫停同一塊 GPIB 卡上的量測。
  • RS-232:沒有 GPIB 卡也能用 UART 接,出廠是 9600 baud、偶同位、7 bits,要先送 SYST:REM 才能遠端控制(不然錯誤 550),用完送 SYST:LOC 還給面板。
  • 面板文字:DISP:TEXT "HELLO" 可以在電表螢幕上顯示字,最多 12 個字元,只能英數。

指令集是一個 YAML 檔(instruments/34401A/commands.yaml),量測功能、錯誤碼、說明都在裡面,之後要加常用指令或改說明,直接編輯這個檔就好,不用改程式。

A Stepper Motor Turntable over RS-232 on the ATS Console: Live Angle Dial and Angle Sweep Scripts | 用 ATS 操作台控制步進馬達轉台:UART 指定型號、即時角度錶盤與掃角腳本

量 UWB 的 AoA(到達角)要把待測物放在轉台上,一格一格轉角度量。我們手邊的轉台是步進馬達加一台 3 軸微步進馬達驅動控制器(開機訊息 CNC3D-3X),走 RS-232。以前都是開超級終端機手打 :U36000,這次把它接進 ATS 網頁操作台:選好型號就有指令集、工具列即時顯示角度,掃角度變成一個腳本按一下就跑完。

說明:本文截圖是在開發機上用模擬轉台拍的(回應格式照實機),數值只是示意;接線、速度和踩坑紀錄都是實機(筆電 COM27)測出來的。

控制器指令速查

RS-232 9600 8-N-1,指令以冒號開頭、數字直接接在後面、結尾送 CR。數字一律是「步」。

指令意思實機回應
:X36000X 軸轉到絕對座標 36000 步(不回)
:U-400X 軸相對轉 -400 步(不回)
:RX/:RP讀 X/XYZ 座標X0/X0,Y0,Z0
:RL極限開關狀態(X+ X- Y+ Y- Z+ Z-)L001111
:SX0不轉,把目前座標設成 0(不回)
:SI500,2000,800速度:初速、高速(PPS)、加減速步數(不回)
:SM63/:SM0極限保護開/關(不回)
r即時:是否在動(不用 Enter,轉動中也能問). 移動中、> 就緒、L 極限
a即時:讀 XYZ 座標X0,Y0,Z0
b緊急停止

換算:馬達 1600 步/圈(1/8 微步),轉台齒輪比 1:90,所以 400 步 = 1 度,90 度是 36000 步、一整圈 144000 步(實機送 :X400 刻度剛好走 1 度確認過)。

跟說明書不一樣的地方

  • 不回顯:說明書畫面上的 :RXX0 其實是終端機自己回顯加上回應;控制器只回 X0,移動指令什麼都不回。
  • r 只回一個字、沒有換行:程式如果等「一行」會永遠等不到,要直接讀一個字元。
  • :RL 前面多一個 L,而且沒接的 Y、Z 軸讀 1,所以正常是 L001111。也因為這樣,停下來時 r 常回 L 而不是 >,等它停要兩個都接受。
  • 旋轉台只有一個負向極限開關,移動台才有正負兩個。

UART 連線+手動指定型號

GPIB 儀器連上會自動問 *IDN? 辨識型號,但這台控制器不會回答。所以 ATS 這次加了「UART 手動指定型號」:連線後在工具列類型選單選「裝置:轉台 CNC3D-3X」,就會載入這台的指令集和腳本,送出列的行尾也自動切成 CR。選擇記在瀏覽器,下次連同一個 COM 埠會自動套用。


指令集照說明書整理成分類清單(移動、相對移動、讀取、停止、設定、手動模式),可以搜尋,點一下就填進輸入框。實機測出來的兩組速度也收進去了:

即時角度錶盤

指令集的 yaml 裡多了一段 position 設定:每 0.5 秒送一次即時指令 a,用正規表示式 X(-?\d+) 抓出步數,乘 0.0025 換成度數,畫在工具列的小錶盤上(轉動中變藍色)。這些查詢不會進收發紀錄,剛送過指令的 1 秒內也會先跳過,避免搶到別的指令的回應。

position:
  label: 轉台
  query: a          # 即時指令,轉動中也能讀
  eol: none
  regex: 'X(-?\d+)'
  scale: 0.0025     # 1:90 → 400 步 = 1 度
  unit: °
  period: 0.5
  wrap: 360

腳本:轉 90 度、掃 -90~+90 度

控制器的移動指令不回應,所以腳本要自己等它轉完。ATS 腳本的 @until 原本只能比對「等於」,這次加了正規表示式:

:SM63                  # 極限保護開著
:SI500,2000,800        # 降速(原因見下面踩坑)
:U36000                # 正轉 90 度
@until r ~ [L>] 60     # 每秒問 r,回 L 或 > 就往下,最多等 60 秒
:RX

轉動中控制器忙著發脈衝,偶爾來不及回 r,所以 @until 碰到沒回應不會中斷,而是繼續問到時限為止。

量 AoA 用的是「-90~+90 度每 15 度停 3 秒」:先轉到 -90 度(:X-36000),之後每次走 6000 步、停 3 秒,13 個點,最後回 0 度。開始前先把轉台對準正前方,跑一次「目前位置設成 0 度」(:SX0)。另外還有讀位置、反轉 90 度、回到 0 度、緊急停止等腳本,都可以在 ATS 裡直接改。

刻度校正

控制器的座標每次開機都歸零,跟轉台盤面上的刻度沒有關係。實機對照::X-400 時刻度約 276.6°、:X400 約 278.5°,步數變大刻度也變大,800 步剛好 2 度。所以錶盤做成可以點:點一下輸入「現在刻度的讀數」,之後就顯示刻度角度(0~360 度循環),旁邊小字保留控制器步數。控制器關電重開或用 :SX 改座標後要重新校正;輸入空白就取消。

實機踩坑:高速轉大角度會失步卡死

第一版腳本用控制器預設速度(高速 4000 PPS)直接 :X-36000 轉 -90 度,結果馬達發出「卡卡」聲、不轉,r 5 秒都沒回應,腳本逾時停下。之後不管送什麼都不動::RL 從 L001111 變成 L111111,關掉極限保護 :SM0 也沒用。

一開始以為是極限開關擋住,其實是失步:轉台負載加上 1:90 減速,馬達跟不上 4000 PPS 的脈衝,控制器就進入異常。處理方法:

  1. 先送 b 緊急停止,然後關掉控制器電源重開,:RL 回到 L001111 才正常。
  2. 降速::SI500,2000,800(初速 1.25 度/秒、高速 5 度/秒),正反轉 90 度都正常,90 度約 18 秒。中速 :SI1000,3000,1000(90 度約 12.5 秒)也收進指令集,大角度還沒長時間驗證。
  3. 所有會轉動的腳本開頭都先送 :SM63(極限保護開著)和 :SI500,2000,800,不依賴控制器開機時的預設速度。

另外提醒:插拔 RS-232 的 9 pin 接頭前先關控制器電源;如果是 12 軸版控制器,把指令開頭的冒號換成 @0~@3 選控制器,讀位置/是否在動分別用 a/c/d/e 和 r/s/t/u。

2026年11月2日 星期一

Three ESP32-CAMs on One Pi 5 Hotspot: MAC-Based IDs and a Multi-View Page | 三台 ESP32-CAM 同時連 Pi 5 熱點:MAC 自動編號+多畫面監看頁

延續上一篇 ESP32-CAM × Pi 5 雙 WiFi 熱點:那篇只接一台 ESP32-CAM,Pi 5 用 MediaMTX 轉成 WebRTC 給手機、筆電看。手上其實有三台 ESP32-CAM,這次的目標是三台燒同一份韌體,上電後同時連上 Pi 5 的熱點 pi5-esp32,每台各自一個觀看網址,最後再做一個一頁看三台、可以擴充的監看網頁。

圖說:三片 ESP32-CAM:中間是插在 ESP32-CAM-MB 底板上的 cam1,兩邊是裝進透明外殼的 cam2、cam3

一、架構

cam1 10.42.0.50 ─┐
cam2 10.42.0.51 ─┼─ WiFi 2.4GHz ─→ Pi 5 熱點 pi5-esp32(wlan1,10.42.0.1)
cam3 10.42.0.52 ─┘                    │
                                      ├─ esp32cam-ffmpeg@1 → MediaMTX 路徑 esp32cam1
                                      ├─ esp32cam-ffmpeg@2 → MediaMTX 路徑 esp32cam2
                                      └─ esp32cam-ffmpeg@3 → MediaMTX 路徑 esp32cam3
瀏覽器(區網或 Tailscale):http://<Pi 5>:8889/esp32camN/

編號 N 串起所有東西:ESP32-CAM 的固定 IP 是 10.42.0.(49+N)、主機名和 OTA 名稱是 esp32camN,Pi 5 上的服務是 esp32cam-ffmpeg@N,觀看路徑是 esp32camN。

二、問題:同一份韌體會搶同一個 IP

上一篇的韌體把 IP 寫死成 10.42.0.50,OTA 名稱也固定是 esp32cam。三台燒同一份,上電後三台都會用同一個 IP,結果是互相打架、時通時斷,Pi 端的 ffmpeg 抓到哪一台也說不準。

每台各編一份韌體(改一個數字再編譯)也行,但很容易燒錯。這次的做法是讓韌體開機時讀晶片的 MAC,自己查表決定是第幾台,三台燒的是完全相同的 bin。

三、韌體:MAC 查表(1.2.0)

// MAC → 編號;新板子燒錄時 esptool 會印出 MAC,補進來就好(Pi 端服務 esp32cam-ffmpeg@<編號>)
struct CamEntry {
  uint8_t mac[6];
  uint8_t id;
};
static const CamEntry CAMS[] = {
  {{0xa8, 0x42, 0xe3, 0x4a, 0x1e, 0x90}, 1},  // 原本那台(ESP32-CAM-MB 底板)
  {{0xa8, 0x42, 0xe3, 0x4b, 0x79, 0x3c}, 2},  // 第二片(透明外殼)
  {{0xa8, 0x42, 0xe3, 0x4a, 0x17, 0xe0}, 3},  // 第三片(透明外殼)
};
static uint8_t cam_id = 0;  // 0 = 不在表裡,走 DHCP
static char cam_mac[18], cam_name[24];

static void identify() {
  uint8_t mac[6];
  esp_read_mac(mac, ESP_MAC_WIFI_STA);
  snprintf(cam_mac, sizeof(cam_mac), "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
  for (const CamEntry &e : CAMS)
    if (!memcmp(e.mac, mac, 6)) cam_id = e.id;
  if (cam_id) snprintf(cam_name, sizeof(cam_name), "esp32cam%u", cam_id);
  else snprintf(cam_name, sizeof(cam_name), "esp32cam-%02x%02x%02x", mac[3], mac[4], mac[5]);
}
  • esp_read_mac(mac, ESP_MAC_WIFI_STA) 讀的是 WiFi STA 的 MAC,不用先開 WiFi;原始款 ESP32 的 STA MAC 和 esptool 印出來的 MAC 相同,可以直接照抄進表。
  • 連線時依編號設固定 IP:WiFi.config(IPAddress(10, 42, 0, 49 + cam_id), ...),同時 WiFi.setHostname(cam_name)、ArduinoOTA.setHostname(cam_name)。
  • 不在表裡的板子改走 DHCP,名稱用 MAC 後三碼(例如 esp32cam-4a17e0)。忘了加表也只是拿到一個 DHCP 位址,不會搶到別台的 IP。
  • /status 多了 cam、name、mac 三個欄位,觀看頁狀態列最前面也會顯示是哪一台。
{"fw":"1.2.0","cam":2,"name":"esp32cam2","mac":"a8:42:e3:4b:79:3c","ip":"10.42.0.51","rssi":-44,...}

四、燒錄流程(每片重複一次)

三片輪流插到 ESP32-CAM-MB 底板,接上 Pi 5 的 USB(/dev/ttyUSB0),在 Pi 5 上:

1. 讀 MAC

ESPTOOL=~/.arduino15/packages/esp32/tools/esptool_py/5.3.1/esptool
$ESPTOOL --port /dev/ttyUSB0 --baud 460800 read-mac
# Chip type: ESP32-D0WD-V3 (revision v3.0)
# MAC:       a8:42:e3:4b:79:3c

2. 先備份原本的韌體

透明外殼那兩片原本有燒別的程式,一樣先整顆 4MB 讀出來,用 MAC 當檔名:

$ESPTOOL --port /dev/ttyUSB0 --baud 460800 read-flash 0 0x400000 a842e34b793c-20261029.bin
sha256sum a842e34b793c-20261029.bin

3. 加進 CAMS 表、編譯、燒錄

F=esp32:esp32:esp32:PSRAM=enabled,PartitionScheme=min_spiffs
arduino-cli compile --fqbn $F --output-dir build firmware/esp32cam_stream
arduino-cli upload -p /dev/ttyUSB0 --fqbn $F,UploadSpeed=460800 --input-dir build firmware/esp32cam_stream
curl -s http://10.42.0.51/status    # 開機約 15 秒後

MB 底板有自動下載電路,從 Pi 5 燒不用按鍵。這次實際燒出來:

台 MAC IP RSSI
cam1(MB 底板) a8:42:e3:4a:1e:90 10.42.0.50 -34 dBm
cam2(透明外殼) a8:42:e3:4b:79:3c 10.42.0.51 -44 dBm
cam3(透明外殼) a8:42:e3:4a:17:e0 10.42.0.52 -50~-58 dBm

小技巧:表是累加的。燒 cam3 時表裡已經有三筆,之後任何一台要重燒都用同一個 bin,第一次 USB 燒好之後也可以全部走 OTA(port 3333),不用再拆外殼。

五、Pi 5:systemd 範本服務 esp32cam-ffmpeg@N

上一篇是一個固定的 esp32cam-ffmpeg.service。改成 systemd 的範本服務(unit 名稱帶 @),%i 就是編號,一份設定開幾台都行:

[Unit]
Description=ESP32-CAM %i MJPEG -> H.264 -> MediaMTX (esp32cam%i)
After=network-online.target mediamtx.service pi5-esp32-forward.service
Wants=network-online.target mediamtx.service

[Service]
User=pi
# Pi 5 沒有硬體 H.264 編碼器 → libx264;ultrafast + zerolatency + baseline(無 B 幀)瀏覽器 WebRTC 才吃得下
# 推流用 TCP;-rw_timeout 5 秒:ESP32-CAM 斷電時 HTTP 讀取會卡住而不是斷線,沒設的話 ffmpeg 不會結束、相機回來也不會重連
# ffmpeg 結束後 5 秒由 systemd 重啟
ExecStart=/bin/sh -c 'exec /usr/bin/ffmpeg -hide_banner -loglevel warning -fflags nobuffer -use_wallclock_as_timestamps 1 \
  -rw_timeout 5000000 -i http://10.42.0.$((49 + %i)):81/stream \
  -vf format=yuv420p -c:v libx264 -preset ultrafast -tune zerolatency -profile:v baseline \
  -g 30 -b:v 1500k -maxrate 2000k -bufsize 1000k \
  -f rtsp -rtsp_transport tcp rtsp://127.0.0.1:8554/esp32cam%i'
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
  • ExecStart 包一層 /bin/sh -c,才能用 $((49 + %i)) 從編號算出 IP。
  • 啟用:sudo systemctl enable --now esp32cam-ffmpeg@1 esp32cam-ffmpeg@2 esp32cam-ffmpeg@3(專案的 install.sh 1 2 3 就是做這件事,順便移除舊的單台服務)。
  • Pi 5 上既有的 MediaMTX 不用改:預設的 all_others 路徑收任何名稱的推流,esp32cam1~esp32cam3 推上去就有。上一篇補的 webrtcAdditionalHosts 照樣要有,Windows/手機瀏覽器才連得上。

六、坑:相機斷電,ffmpeg 卻不會結束

燒 cam2、cam3 時,cam1 從底板拔下來、斷電了一段時間。再把 cam1 插回去,它很快就連回熱點,/status 也正常,可是瀏覽器開 esp32cam1 一直顯示:

Error: stream not found, retrying in some seconds

systemctl status esp32cam-ffmpeg@1 卻顯示 active,重啟次數 0。原因是 ESP32-CAM 突然斷電時不會送 TCP 的 FIN/RST,Pi 5 這端的 HTTP 連線只是「沒有資料進來」,ffmpeg 就一直卡在讀取,既不結束、也不重連,systemd 的 Restart=always 根本沒機會出手。

解法是在輸入加讀取逾時,5 秒沒資料就讓 ffmpeg 結束,再由 systemd 在 5 秒後重啟:

-rw_timeout 5000000 -i http://10.42.0.$((49 + %i)):81/stream     # 單位是微秒

改完之後,沒上電的相機服務會在 activating ↔ 重試之間循環,相機一上電、下一輪重試就自動接上;cam1 插回後就是這樣自己恢復畫面的。

另一個小坑:systemctl enable --now 對已經在跑的服務不會重啟,改了 unit 檔、daemon-reload 之後還要手動 sudo systemctl restart esp32cam-ffmpeg@1 ...,新參數才會生效。

七、斷電重上:要多久才會回來?

相機裝在外殼裡,難免會斷電、重開。想確認「斷電 → 重新上電 → 連回熱點 → Pi 的服務重新接上」整條流程不用人工介入,用 cam1 實測:Pi 5 透過序列埠的 RTS 腳把 ESP32 的 EN 拉低,讓晶片硬重開。效果和突然拔電一樣,板子不會先送 TCP 斷線通知,正好是上一節 ffmpeg 卡住的那種情況。

時間 發生的事
T+0 晶片重開
T+2.4s 韌體開機,開始連 pi5-esp32
T+4.6s WiFi 連上(熱點端 EAPOL 4-way handshake 完成),HTTP server 起來
T+5.3s Pi 上舊的 ffmpeg 5 秒讀不到資料,因 -rw_timeout 結束
T+6.8s Pi ping 得到 10.42.0.50
T+9.2s /status 回應 1.2.0 / cam1
T+10.4s systemd 等 5 秒(RestartSec=5)後重啟 ffmpeg
T+15.8s MediaMTX 的 esp32cam1 恢復串流,瀏覽器重新有畫面

總共約 16 秒。板子這端不到 5 秒就連上,大部分時間花在 Pi 這端:ffmpeg 重啟要等 5 秒,啟動到出畫面又要 5 秒。想再快一點,可以把 RestartSec 改成 2,總時間大約降到 13 秒。相機離線十幾分鐘再插回去,也是同樣自己接上。

八、同時連線:ESP32 一條,Pi 很多條

1. 同一台 ESP32-CAM:一次只能一條串流

  • ffmpeg 佔著 cam1 時,再用 curl 開第二條連線拉 5 秒:0 bytes。/status 這類控制網址不受影響,因為它在另一個 port 80 的 server。
  • 把 ffmpeg 停掉、同時開兩條:A 馬上有資料,B 要等 A 斷掉(5.96 秒後)才收到第一個 byte。

原因是韌體的串流 server(port 81)只有一個 task,stream_handler 是一個不會結束的迴圈,第一條連線佔住之後,後來的只能排隊。所以多人看一律走 MediaMTX:ESP32 只給 ffmpeg 一條,MediaMTX 再分給所有觀看者。

2. Pi 5 同時跑多條 ffmpeg:很輕鬆

實際的 esp32cam-ffmpeg@1 只吃約 3% CPU(相機幀率很低)。另外用 testsrc 模擬三台:VGA、25 fps、參數和正式服務相同,三條各跑 20 秒:

項目 結果
四條 ffmpeg 合計 CPU 約 60%(Pi 5 四核滿載 400%)
每條速度 26 fps、speed 1.02x(即時)
CPU 溫度 45°C

三台同時轉碼 Pi 5 完全撐得住,瓶頸不在這裡。

3. 三台真的同時上線:瓶頸在熱點網卡

圖說:cam2、cam3 的電池充過電後,三台同時直播(直播 3 / 3 台)

三台都上線後,用韌體 /status 的 frames_sent 計數 10 秒,算出每台實際送出的幀率:

項目 結果
cam1 / cam2 / cam3 1.0 / 1.0 / 0.9 fps(VGA、quality 12)
熱點 wlan1 總接收 約 40 KB/s
只留 cam1 一台(停掉 @2、@3) 仍然約 1 fps、30 KB/s
三條 ffmpeg CPU 各約 9~10%,整機 92% 閒置,47°C,沒有降頻
訊號 -32~-40 dBm,頻道 1 只有兩顆 -75 dBm 的鄰居

只剩一台也只有 1 fps,表示不是三台互搶,而是每條上傳本來就這麼慢;訊號、頻道、CPU 都沒問題,剩下的就是熱點那張 DWA-135(RTL8192CU)在 AP 模式的吞吐量。iw station dump 也看得到,ESP32 送過來的速率只有 1~19.5 Mbit/s,Pi 送出去卻是 144 Mbit/s。這時候監看頁看起來會比較像「每秒更新一次」的監視畫面,而不是流暢的影片。

九、多畫面監看頁:一頁看三台,加相機只改一行

MediaMTX 內建的頁面一次只能看一個路徑。另外寫了一個靜態網頁放在 Pi 5 上,一頁並排所有相機:

圖說:監看頁實測:cam1 直播中(綠燈),cam2、cam3 沒上電,顯示「相機離線」並自動重試(紅燈)

功能

  • 每台一格,各自用 WHEP 直接跟同一台主機 :8889 的 MediaMTX 拿 WebRTC;網址用 location.hostname 組,所以從區網 IP 或 Tailscale IP 開都可以。
  • 燈號:綠=直播中、黃=連線中、紅=離線。最上面顯示「直播 1 / 3 台」。
  • 相機離線(MediaMTX 回 404)、WebRTC 連線斷掉、或畫面停住超過 10 秒,都會 5 秒後自動重連;相機重新上電後畫面會自己回來。
  • 版面可切自動/1~3 欄(記在 localStorage),手機上自動排一欄;點畫面全螢幕,每格有手動重連按鈕。

擴充:cams.json

相機清單不寫在程式裡,放在同目錄的 cams.json,網頁載入時讀取,加一台就多一行:

{
  "title": "ESP32-CAM 監看",
  "cams": [
    { "name": "cam1", "path": "esp32cam1", "note": "MB 底板" },
    { "name": "cam2", "path": "esp32cam2", "note": "透明外殼" },
    { "name": "cam3", "path": "esp32cam3", "note": "透明外殼" }
  ]
}

存檔後重新整理網頁就多一格,服務不用重啟。完整加一台的流程是:韌體 CAMS 表加 MAC → Pi 開 esp32cam-ffmpeg@4 → cams.json 加一行。

程式重點:WHEP 播放與自動重連

不用任何函式庫,瀏覽器原生的 RTCPeerConnection 就能做 WHEP:建一個只收不送的 video transceiver,把 offer POST 給 /esp32camN/whep,拿回 answer 設進去就好。為了簡單,不做 trickle ICE,等 candidate 收集完(最多 2 秒)才送 offer。

  async start() {
    clearTimeout(this.timer);
    this.close();
    this.setState('wait', '連線中…');
    const pc = new RTCPeerConnection();
    this.pc = pc;
    pc.addTransceiver('video', { direction: 'recvonly' });
    pc.ontrack = e => { this.video.srcObject = e.streams[0] || new MediaStream([e.track]); };
    pc.onconnectionstatechange = () => {
      if (pc !== this.pc) return;
      if (pc.connectionState === 'connected') {
        this.lastProgress = Date.now();
        this.setState('live', '直播中');
      } else if (['failed', 'disconnected', 'closed'].includes(pc.connectionState)) {
        this.retry('連線中斷,重試中…');
      }
    };
    try {
      await pc.setLocalDescription(await pc.createOffer());
      await iceGathered(pc);
      const res = await fetch(whepUrl(this.cam.path), {
        method: 'POST',
        headers: { 'Content-Type': 'application/sdp' },
        body: pc.localDescription.sdp,
      });
      if (pc !== this.pc) return;
      if (res.status === 404) return this.retry('相機離線', 'off');
      if (!res.ok) {
        const body = await res.text().catch(() => '');
        // ESP32-CAM 推的是 H.264;瀏覽器沒有 H.264 解碼器(例如開源版 Chromium)時 MediaMTX 會回這個
        if (body.includes('codecs not supported')) return this.retry('這個瀏覽器不支援 H.264', 'off');
        return this.retry(`伺服器回應 ${res.status}`, 'off');
      }
      await pc.setRemoteDescription({ type: 'answer', sdp: await res.text() });
    } catch (err) {
      if (pc === this.pc) this.retry('連不到伺服器', 'off');
    }
  }

ffmpeg 重啟時,WebRTC 連線不一定會報錯,畫面可能只是停住。所以另外每 2 秒看一次 video.currentTime,10 秒沒前進就當成斷線重連:

  // 畫面時間軸一直沒前進 → 當成斷線重連
  checkStall() {
    if (this.el.dataset.state !== 'live') return;
    const t = this.video.currentTime;
    if (t !== this.lastTime) { this.lastTime = t; this.lastProgress = Date.now(); return; }
    if (Date.now() - this.lastProgress > STALL_MS) this.retry('畫面停住,重新連線');
  }

部署

頁面只有 index.html 和 cams.json 兩個靜態檔,用 Python 內建的 http.server 就夠了,包成 systemd 服務開機自動跑(port 8088):

[Unit]
Description=ESP32-CAM 多畫面監看頁 (port 8088)
After=network-online.target mediamtx.service

[Service]
User=pi
ExecStart=/usr/bin/python3 -m http.server 8088 --bind 0.0.0.0 --directory /home/pi/esp32-cam-multi/web
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

MediaMTX 預設 webrtcAllowOrigins: ['*'],從 :8088 的頁面跨來源 POST 到 :8889 不用另外設 CORS。

坑:測試時用 Playwright 內建的 Chromium 開,cam1 一直拿到 HTTP 400,MediaMTX 的 log 是 codecs not supported by client。原因是開源版 Chromium 沒有 H.264 解碼器,而 ESP32-CAM 這條串流是 H.264。Chrome、Edge、Safari 都沒問題(換用 Raspberry Pi OS 的 Chromium 測就正常了)。頁面遇到這個錯誤會直接顯示「這個瀏覽器不支援 H.264」。

十、坑:鋰電池沒電,WiFi 連上了 IP 卻不通

cam2、cam3 燒錄時插在 MB 底板上、USB 供電,一切正常;裝回透明外殼、改用電池之後,就再也連不到了。外殼裡是一顆 3.7V 560mAh 鋰電池,加一片鋰電池充電模組,紅黑線接到 ESP32-CAM:

圖說:外殼內部:3.7V 560mAh 鋰電池+充電模組(紅燈亮),紅黑線接到 ESP32-CAM

熱點端看到的現象很微妙:

  • cam2 換電池後有重新連上熱點,WPA 4-way handshake 也成功(AP-STA-CONNECTED、EAPOL-4WAY-HS-COMPLETED)。
  • 熱點每 5 分鐘 poll 一次,它都有回應(AP-STA-POLL-OK)。
  • 但 Pi 對 10.42.0.51 的 ARP 一直是 FAILED,ping 不通,5 個多小時只收到 26 個封包。

先懷疑熱點(RTL8192CU)留著舊的連線狀態,用 iw dev wlan1 station del 把 cam2、cam3 踢掉讓它們重連,結果等了 40 秒都沒有再來連,排除了熱點的問題。

結果:把電池充一下電,兩台就馬上連上、出畫面了(上面第八節的三格同時直播就是充電之後拍的)。原因就是電池電壓太低:

  • WiFi 握手、回 poll 只是偶爾送一點點資料,低電壓還撐得住;一要正常收發封包、拍照串流,發射電流一拉高電壓就掉下去,板子處在「看起來連著、實際不通」的半當機狀態。
  • ESP32-CAM 的 5V 腳後面是 AMS1117-3.3 穩壓 IC,要約 4.4V 以上的輸入才穩得住 3.3V。鋰電池電壓 3.0~4.2V,充飽時勉強可以,越用越低,很快就掉到不夠的範圍。

所以「充飽就好」只是暫時的,要穩定還是建議:

  • 中間加 5V 升壓模組(例如 MT3608),或換成「充電+升壓」二合一的行動電源板(例如 IP5306 類),輸出 5V、至少 1A,接 5V 腳。這樣電池從 4.2V 用到 3.3V 都能穩定供電。
  • 電源腳旁並一顆 470~1000µF 電容,擋 WiFi 發射時的瞬間電流。
  • 續航:ESP32-CAM 開著串流約 200~300 mA,560mAh 大概只撐 1.5~2 小時;要長時間跑還是插 USB 電源。
  • 看到「熱點顯示有連、ping 不通」,第一個先量電池電壓。

十一、觀看網址

頁面 網址(Tailscale)
多畫面監看頁 http://100.108.160.111:8088/
cam1 http://100.108.160.111:8889/esp32cam1/
cam2 http://100.108.160.111:8889/esp32cam2/
cam3 http://100.108.160.111:8889/esp32cam3/

三台燒好後都各自驗證過:/status 正常、MediaMTX 收到 H.264 640×480,從 gx10 用 aiortc 跑 WHEP 測試,每台都是 ICE completed、12 秒約 280~295 個 frame(這是 ffmpeg 固定幀率的輸出,不等於相機實際的更新速度)。和上一篇一樣,不要直接開 ESP32-CAM 自己的觀看頁,會把 ffmpeg 那條串流佔走。

十二、注意事項

  • 頻寬:熱點用的 USB 網卡 DWA-135(RTL8192CU)在 AP 模式很弱,實測三台同時跑每台約 1 fps、合計約 40 KB/s(見第八節)。要更順,建議換 MediaTek 晶片的 USB 網卡;降到 QVGA 也可以多擠一點幀率。
  • 供電:見第十節,電池沒電會變成「有連、不通」;最好 5V 升壓、每台 1A 以上;三台不要從同一個小 USB hub 取電。
  • 改 unit 檔後要 restart:systemctl enable --now 對已經在跑的服務不會重啟。
  • 加第四台:MAC 補進 CAMS 表(編號 4 → 10.42.0.53)、OTA 更新全部板子,Pi 上 sudo systemctl enable --now esp32cam-ffmpeg@4,cams.json 加一行。

十三、下一步

  • cam2、cam3 加 5V 升壓模組,量實際續航
  • 換 AP 模式穩定的 USB 網卡(MediaTek),重測三台 fps
  • 監看頁加拍照(從 video 畫到 canvas 下載)

Pi 5 Multi-Camera Monitor Page: USB Webcam, Start/Stop, Snapshot, Recording and Timestamps | Pi 5 四格監看頁:USB webcam、開關、截圖錄影與時間戳記

延續上一篇 三台 ESP32-CAM 同時連 Pi 5 熱點:那篇做了一個一頁看三台 ESP32-CAM 的監看網頁。這次再往上加四樣東西:
  • 第 4 格:直接插在 Pi 5 USB 上的 Logitech C922 webcam,720p 30fps
  • 開啟/停止鍵:從網頁開關每一台的串流服務
  • 截圖、錄影鍵:在瀏覽器裡直接存 PNG、MP4
  • 時間戳記:每台畫面左上角都有相機名稱和時間

一、架構

ESP32-CAM ×3 ──WiFi 熱點 pi5-esp32──▶ esp32cam-ffmpeg@1..3 ─┐
(MJPEG :81/stream)                                         │  H.264 + 時間戳記
                                                             ├──▶ MediaMTX ──WebRTC(WHEP :8889)──▶ 瀏覽器
Logitech C922 ──USB──▶ usbcam-ffmpeg.service ────────────────┘    (路徑 esp32cam1..3、usbcam)

瀏覽器 ──HTTP :8088──▶ esp32cam-web.service(server.py)
                        ├─ 靜態檔:index.html、cams.json
                        └─ /api/svc/<service>:查狀態、start、stop

影像一律走 MediaMTX 的 WebRTC,網頁伺服器只負責頁面和開關服務。截圖和錄影都在瀏覽器裡完成,不經過 Pi。

這部分從原本的 esp32-cam-multi 專案獨立出來,變成 esp32-cam-monitor-web:

檔案 作用
web/index.html 監看頁:讀 cams.json 產生格子,每格 WHEP 播放、自動重連、四顆按鍵
web/cams.json 相機清單,加一台就多一行
web/server.py 靜態檔加上開關服務的 API
pi/usbcam-stream.sh C922 的 ffmpeg(MJPEG → 時間戳記 → H.264 → RTSP)
pi/usbcam-install.sh、pi/web-install.sh 裝兩個 systemd 服務

Pi 5 上有哪些伺服器、開了哪些 port

Pi 5 上沒有 Apache、Nginx 這類一般的網站伺服器。從筆電看監看頁時,是下面兩個在分工(用 sudo ss -tulpn 查的):

Port 程式 用途 有用到嗎
8088/tcp server.py 監看頁本身(index.html、cams.json)+開關 API /api/svc/… 有
8889/tcp MediaMTX WebRTC 的 HTTP 端:WHEP 連線請求,也有內建的單台觀看頁 :8889/<path>/ 有
8189/udp MediaMTX WebRTC 實際傳影像的通道 有
8554/tcp MediaMTX RTSP,Pi 上的 ffmpeg 把轉好的 H.264 推進來 有(Pi 內部)
8888/tcp MediaMTX HLS 預設開著,沒用到
1935/tcp MediaMTX RTMP 預設開著,沒用到
8890/udp MediaMTX SRT 預設開著,沒用到
筆電瀏覽器 ──HTTP :8088──▶ server.py        (拿網頁、按開關鍵)
     │
     └──WebRTC :8889 / :8189──▶ MediaMTX ◀── RTSP :8554 ◀── ffmpeg ×4 ◀── 相機
  • 影像不經過 server.py:網頁載入後,每一格自己用 WHEP 連 :8889,影像直接從 MediaMTX 送到瀏覽器。server.py 只送網頁和按鍵指令,所以它用 Python 內建的 HTTP 伺服器就夠了。
  • 可以少開幾個 port:HLS、RTMP、SRT 這個專案沒用到,可以在 mediamtx.yml 把 hls、rtmp、srt 設成 no。

二、第 4 格:Pi 5 上的 USB webcam

先確認攝影機和它支援的格式:

$ lsusb | grep -i logitech
Bus 003 Device 008: ID 046d:085c Logitech, Inc. C922 Pro Stream Webcam
$ v4l2-ctl -d /dev/video0 --list-formats-ext
[0]: 'YUYV'  最大 640x480 30fps
[1]: 'MJPG'  到 1280x720 30fps

重點是 YUYV 最大只有 640×480,要 720p 30fps 一定要用 MJPG,所以 ffmpeg 要加 -input_format mjpeg。

ffmpeg 寫成一支腳本,由 systemd 呼叫:

exec /usr/bin/ffmpeg -hide_banner -loglevel warning -fflags nobuffer \
  -f v4l2 -input_format mjpeg -video_size "$SIZE" -framerate "$FPS" -i "$DEV" \
  -vf "format=yuv420p,$STAMP" -c:v libx264 -preset ultrafast -tune zerolatency -profile:v baseline \
  -g $((FPS * 2)) -b:v 2500k -maxrate 3000k -bufsize 1500k \
  -f rtsp -rtsp_transport tcp "rtsp://127.0.0.1:8554/$NAME"
  • 裝置用固定路徑:/dev/v4l/by-id/usb-046d_C922_Pro_Stream_Webcam_…-video-index0。用 /dev/video0 的話,拔插或多接一支攝影機時編號會變。
  • libx264:Pi 5 沒有硬體 H.264 編碼器,只能軟體編碼。baseline 沒有 B 幀,瀏覽器的 WebRTC 才播得了。
  • -g 60:2 秒一個關鍵幀,新打開頁面的人最多等 2 秒就有畫面。

裝好之後在 cams.json 加一行,重新整理頁面就多一格:

{ "name": "cam4", "path": "usbcam", "note": "C922 USB(Pi 5)", "service": "usbcam-ffmpeg.service" }

實測:

項目 結果
RTSP H.264 Constrained Baseline 1280×720,約 30 fps
WebRTC 從 GX10 用 aiortc 拉 12 秒,收到 331 幀(約 28 fps)
CPU 這支 ffmpeg 約佔一顆核心的 55%,整機約 75% 閒置
溫度 46~53°C,沒有降頻

C922 直接插在 Pi 上,不走 ESP32 的 WiFi 熱點,所以不受熱點網卡 DWA-135 的頻寬限制。同一頁裡的 ESP32-CAM 每台只有零點幾 fps,兩者的流暢度差很多。

三、開啟/停止鍵

圖說:每格下方的控制列:截圖、錄影(錄影中顯示「■ 結束 m:ss」)、↻ 重新連線、■ 停止

原本的網頁是用 python3 -m http.server 提供,只能給靜態檔。要從網頁開關服務,就改成自己寫一支 server.py,多三個 API:

API 作用
GET /api/svc/<service> 回傳 {"service": ..., "active": true/false}
POST /api/svc/<service>/start sudo -n systemctl start
POST /api/svc/<service>/stop sudo -n systemctl stop

能開關的服務只限 cams.json 裡有寫 service 的,每次請求都重讀一次檔案:

def allowed():
    try:
        with open(os.path.join(WEB, "cams.json")) as f:
            return {c["service"] for c in json.load(f)["cams"] if c.get("service")}
    except (OSError, ValueError, KeyError):
        return set()

def do_POST(self):
    svc, action = self.svc_args()
    if svc not in allowed() or action not in ("start", "stop"):
        return self.reply(404, {"error": "unknown service or action"})
    r = subprocess.run(["sudo", "-n", "systemctl", action, svc], capture_output=True, text=True, timeout=30)

實測拿這個 API 去停 mediamtx.service,或用 ..%2fx 這種路徑亂試,都會回 404。sudo -n 是為了萬一需要密碼時直接失敗,不會卡住。

網頁這邊:

  • 有 service 的格子才有按鍵:紅色「■ 停止」,停止後變綠色「▶ 開啟」。
  • 停止後格子變灰點加「已停止」,不會自動重試,否則它會每 5 秒一直顯示「相機離線」重連。
  • 每 10 秒同步一次服務狀態,所以在手機上按停止,電腦上的頁面也會跟著變。
  • 按開啟之後等 3 秒,讓 ffmpeg 先推流到 MediaMTX,再連 WebRTC。

ESP32-CAM 和 C922 按停止的效果不一樣:

格子 服務 按停止會怎樣
cam1~cam3 esp32cam-ffmpeg@N 停的是 Pi 上拉影像、轉碼的 ffmpeg;ESP32-CAM 本身還是開著、連著 WiFi,照樣耗電
cam4 usbcam-ffmpeg 攝影機真的被釋放,fuser /dev/video0 沒有任何程式在用

踩到的兩個坑:

  • 服務名稱裡的 @:esp32cam-ffmpeg@1.service 經過 encodeURIComponent 會變成 %40,伺服器要先 urllib.parse.unquote 再比對,不然永遠對不上。
  • 改版後看不到新按鍵:http.server 只送 Last-Modified,Chrome 會自己決定快取多久,一般重新整理拿到的還是舊頁面。server.py 對靜態檔加了 Cache-Control: no-cache:每次都跟伺服器確認,沒改就回 304,所以也不會浪費流量。

四、截圖、錄影鍵

這兩個功能完全在瀏覽器裡做,檔案直接下載到正在看的那台電腦或手機,Pi 不用多做事。

snapshot() {
  const v = this.video;
  const c = document.createElement('canvas');
  c.width = v.videoWidth; c.height = v.videoHeight;
  c.getContext('2d').drawImage(v, 0, 0);
  c.toBlob(b => download(b, this.fileName('png')), 'image/png');
}

startRec() {
  // 先試 MP4(Safari、新版 Chrome),不行再用 WebM
  const type = ['video/mp4;codecs=avc1', 'video/mp4', 'video/webm;codecs=vp9', 'video/webm']
    .find(t => MediaRecorder.isTypeSupported(t)) || '';
  const rec = new MediaRecorder(this.video.srcObject, type ? { mimeType: type } : {});
  rec.ondataavailable = e => { if (e.data.size) chunks.push(e.data); };
  rec.onstop = () => download(new Blob(chunks, { type: rec.mimeType }), this.fileName(ext));
  rec.start(1000);  // 每秒切一段,中途斷線也保得住已錄的部分
}
  • 截圖用影像的原始解析度,存成 PNG。
  • 錄影直接錄 WebRTC 收到的 MediaStream。按下去之後按鍵變紅色「■ 結束 m:ss」,畫面周圍有紅框;再按一次就結束並下載。
  • 斷線、畫面停住要重連、或按了停止時,會先把已錄的部分存下來,不會白錄。
  • 檔名帶相機和時間,例如 cam4-20261030-104508.mp4。

用 Pi 上的 Chromium 測 cam4:截圖是 1280×720 PNG;錄 6 秒得到 H.264 1280×720 MP4,長 5.7 秒,可以正常播放。

排版的兩個小坑:

  • 按鍵不要用 emoji:一開始用 📷 ⏺,結果 Pi 的 Chromium 沒有 emoji 字型,顯示成方框,所以改成文字「截圖」「● 錄影」。
  • 四欄時控制列太擠:相機名稱、狀態、四顆按鍵擠在一行,文字被壓成好幾行。改成 flex-wrap,按鍵包成一組靠右,擠不下就整組換到第二行。

五、時間戳記

圖說:四台各抓一幀:左上角是相機名稱和 Pi 的本地時間,cam1~cam3 是 640×480,cam4 是 1280×720

時間戳記用 ffmpeg 的 drawtext 燒進轉碼後的影像,所以每個觀看的人、截圖、錄影都會帶著時間,不用另外處理:

STAMP="drawtext=fontfile=$FONT:text='$LABEL  %{localtime\\:%F %T}':x=10:y=10:fontsize=h/24:fontcolor=white:box=1:boxcolor=black@0.55:boxborderw=6"
  • 時間來源:Pi 的本地時間,timedatectl 顯示時區 Asia/Taipei,有 NTP 對時。
  • fontsize=h/24:字高跟著畫面高度調整,VGA 和 720p 看起來比例一樣。
  • 冒號跳脫:drawtext 的參數用冒號分隔,時間格式裡的冒號都要跳脫。改用 %F %T(等於 %Y-%m-%d %H:%M:%S)的話,格式字串本身沒有冒號,只剩 localtime 後面那一個要寫成 \:。
  • 不要直接寫進 systemd 的 ExecStart:systemd 會把 % 當成自己的變數(要寫成 %%),再加上 sh -c 的引號,跳脫很難寫對。所以把 ffmpeg 抽成 bash 腳本,unit 只寫一行:
    ExecStart=/bin/bash /home/pi/esp32-cam-monitor-web/pi/usbcam-stream.sh usbcam cam4 /dev/v4l/by-id/… 1280x720 30

ESP32-CAM 每秒只有零點幾幀,時間大約 1~4 秒才跳一次,代表那一幀實際送到 Pi 的時間。

補充:ESP32 串流不要補重複幀

加完第 4 格和時間戳記後,畫面變得不太穩,MediaMTX 記錄一直出現 [WebRTC] reader is too slow, discarding 300 frames,30 分鐘內 WebRTC 連線關了 144 次。

原因在 ESP32 那三路的 ffmpeg:相機不到 1 fps,ffmpeg 預設會補重複幀湊成固定的每秒 25 幀,但它是等下一幀真的到了,才一口氣把中間缺的 25~60 幀送出去。MediaMTX 要在一瞬間推這麼多幀給瀏覽器,送不及就丟幀,畫面卡住,監看頁的卡住偵測又會重連。加了第 4 格、又同時開好幾個頁面看之後,丟幀從每 10 分鐘 14 次暴增到 600~900 次。

解法是 ESP32 那三路改成相機來幾幀就送幾幀,關鍵幀改用時間算:

-fps_mode passthrough -force_key_frames "expr:gte(t,n_forced*2)"
  • 原本的 -g 30 是每 30 幀一個關鍵幀,幀率這麼低時會變成 30 多秒才一個,新開的頁面要等很久才有畫面;改成每 2 秒一個。
  • 網頁判斷「畫面卡住」的時間從 10 秒放寬到 20 秒,因為一幀可能隔十幾秒才來。
  • 改完之後,最近一分鐘 too slow 是 0 次。C922 是穩定的 30 fps,不需要改。

加上時間戳記後 CPU 幾乎沒變:C922 那支約 55%,ESP32 每支約 8%。

偵錯步驟:怎麼找到是補幀爆量

以下是這次實際查問題的順序,指令都在 Pi 5 上跑。

1. 先看 MediaMTX 的記錄,找出是哪一段在出錯

journalctl -u mediamtx --since "-30min" --no-pager -o cat | grep -iE "too slow|closed|error" | tail -20
WAR [WebRTC] [session 532f2fe0] reader is too slow, discarding 325 frames
WAR [WebRTC] [session ad56c52e] reader is too slow, discarding 266 frames
INF [WebRTC] [session 929bf77e] closed: peer connection closed

ffmpeg 這邊沒有重啟(systemctl show esp32cam-ffmpeg@1 -p NRestarts 是 0),相機也都連得上,所以問題不在相機到 Pi 這段,而在 MediaMTX 送給瀏覽器這段:MediaMTX 送不完,只好丟幀。

2. 每 10 分鐘統計一次,看是從什麼時候開始變多

journalctl -u mediamtx --since "-60min" --no-pager -o short-iso | grep "too slow" | cut -c1-15 | sort | uniq -c
     14 2026-10-30T10:0
     14 2026-10-30T10:1
    296 2026-10-30T10:3
     48 2026-10-30T10:4
    895 2026-10-30T10:5
    652 2026-10-30T11:0

10:30 以前就有,只是很少;10:30 加了第 4 格之後暴增。所以這不是新加的東西造成的錯誤,而是原本就有的問題被放大了。

3. 看是誰在看、看哪一路

journalctl -u mediamtx --since "-60min" --no-pager -o cat | grep -E "created by|is reading from path"

整理後,一小時內同一台筆電對 esp32cam1~3 各重連了 23~33 次,usbcam 19 次。四路都在重連,表示不是某一台相機的問題,而是網頁的「畫面卡住 → 重連」一直被觸發。

4. 直接讀 RTSP,看影格是怎麼送出來的

timeout 12 ffmpeg -v error -rtsp_transport tcp -i rtsp://127.0.0.1:8554/esp32cam1 \
  -t 8 -c copy -f null - -progress pipe:1 | grep "^frame="
frame=37  frame=37  frame=37  frame=37  frame=40
frame=100 frame=100
frame=133 frame=133 frame=133 frame=133 frame=133 frame=133
frame=173

關鍵就在這裡:frame 卡在 37 好一陣子,然後一次跳到 100。ESP32 不到 1 fps,ffmpeg 為了湊每秒 25 幀會補重複幀,但它要等下一幀真的到了,才把中間缺的幾十幀一口氣送出去。同樣的指令讀 usbcam,frame 是平穩地每 0.5 秒加 15,所以 C922 沒有這個問題。

ffmpeg 自己的記錄也有印證:journalctl -u esp32cam-ffmpeg@1 一直出現 More than 1000000 frames duplicated。

5. 改完之後驗證

改成 -fps_mode passthrough 後,用 framecrc 數每一路實際送出的幀數:

for p in esp32cam1 esp32cam2 esp32cam3 usbcam; do
  n=$(timeout 13 ffmpeg -v error -rtsp_transport tcp -i rtsp://127.0.0.1:8554/$p \
    -map 0:v -c copy -f framecrc - | grep -vc "^#")
  echo "$p frames_in_~12s=$n"
done
esp32cam1 frames_in_~12s=7
esp32cam2 frames_in_~12s=7
esp32cam3 frames_in_~12s=6
usbcam    frames_in_~12s=388

ESP32 每路只送真正收到的 6~7 幀,不會再一次爆出幾十幀。再看 MediaMTX 的記錄:

journalctl -u mediamtx --since "-2min" --no-pager -o cat | grep -c "too slow"     → 0
journalctl -u mediamtx --since "-2min" --no-pager -o cat | grep -c "closed:"      → 8

6. 剩下的連線關閉是什麼?

2026-10-30T11:11:14 [WebRTC] [session aa910c8b] closed: peer connection closed
2026-10-30T11:11:15 [WebRTC] [session 8c2a302a] closed: peer connection closed
2026-10-30T11:11:16 [WebRTC] [session e68739cc] closed: peer connection closed
2026-10-30T11:11:16 [WebRTC] [session deadaa50] closed: peer connection closed
2026-10-30T11:11:22 [WebRTC] [session b1107bde] created by 192.168.1.104:51524
...(同一秒建立四條,分別讀 usbcam、esp32cam1~3)

四條在同一秒關掉,6 秒後同一台電腦又同時開了四條,這是有人重新整理頁面,不是不穩。之後就沒有再斷過。

小結:看到「畫面不穩」,先分清楚是哪一段斷:ffmpeg 有沒有重啟(相機 → Pi)、MediaMTX 有沒有 too slow(Pi → 瀏覽器)、連線關閉是零星發生還是全部集中在同一秒(有人重新整理)。這次的線索是「too slow 很多,但 ffmpeg 沒重啟」,再用 RTSP 讀取看出影格是一陣一陣爆出來的。

六、部署

rsync -a --exclude .git ~/esp32-cam-monitor-web/ pi@<pi5>:esp32-cam-monitor-web/
ssh pi@<pi5> 'cd esp32-cam-monitor-web && bash pi/web-install.sh && bash pi/usbcam-install.sh'
  • 只改 web/ 裡的頁面或 cams.json:rsync 過去,重新整理頁面就生效,不用重啟服務。
  • 改 server.py:sudo systemctl restart esp32cam-web
  • 改 usbcam-stream.sh:sudo systemctl restart usbcam-ffmpeg

七、觀看網址

頁面 網址(Tailscale)
四格監看頁 http://100.108.160.111:8088/
單看 C922 http://100.108.160.111:8889/usbcam/
單看 ESP32-CAM N http://100.108.160.111:8889/esp32camN/

八、注意事項

  • 開關鍵誰都能按:8088 在公司網段也連得到,同網段的人都能按停止。要限制的話,可以讓 server.py 只聽 Tailscale 的 IP。
  • 停止只是暫時的:按停止是 systemctl stop,服務仍是開機自動啟動,Pi 重開後會自己開回來。
  • ESP32-CAM 按停止不省電:要讓它真的省電,得改韌體,讓它收到指令後進入深度睡眠。
  • ESP32 的錄影會像幻燈片:熱點網卡頻寬有限,每台只有零點幾 fps;換成 MediaTek 晶片的 USB 網卡才會改善。
  • 同時只能接一支 USB webcam:目前服務名稱是固定的 usbcam-ffmpeg,要兩支以上得改成像 esp32cam-ffmpeg@ 那樣的範本服務。