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
  • 時間戳記:每台畫面左上角都有相機名稱和時間

圖說:四格監看頁(2 欄版面):cam4 正在錄影,有紅框和「■ 結束 0:03」;cam2 剛好在重連,顯示「連線中…」

一、架構

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@ 那樣的範本服務。

ESP32-CAM on Raspberry Pi 5 Hotspot: Live MJPEG View | ESP32-CAM × Pi 5 雙 WiFi 熱點:即時影像設定步驟與注意事項

延續上一篇 Pi 5 雙 WiFi × ESP32:Pi 5 的內建網卡照常上網、跑 Tailscale,USB 網卡另外開一個 2.4GHz 熱點 pi5-esp32 專門給 ESP32 用。這次把一片 ESP32-CAM 接進這個熱點,讓手機或筆電直接看即時影像,並整理從燒錄、設定到實測的完整步驟和注意事項。

一、架構

ESP32-CAM(AI-Thinker,OV2640)
   │ WiFi 2.4GHz,連 Pi 5 熱點 pi5-esp32,固定 IP 10.42.0.50
   │   http://10.42.0.50/            觀看頁(即時畫面、拍照、解析度/畫質)
   │   http://10.42.0.50:81/stream   MJPEG 串流
   ▼
Pi 5(wlan1 = USB 網卡熱點 10.42.0.1;wlan0 = 內建網卡上網+Tailscale)
   ├─ 方式 B1:手機/筆電直接連 pi5-esp32 熱點看(這篇的重點)
   ├─ 方式 A :Pi 5 用 ffmpeg 轉 H.264,MediaMTX 提供 WebRTC
   └─ Tailscale:Pi 5 當子網路路由器,遠端裝置直接開 http://10.42.0.50/(第七節)

二、硬體與環境

項目 內容
相機板 ESP32-CAM(ESP32-D0WD-V3、4MB Flash、4MB PSRAM、OV2640)+ ESP32-CAM-MB 底板(CH340,有自動下載電路)
熱點主機 Raspberry Pi 5,USB 網卡 D-Link DWA-135(RTL8192CU,2.4GHz only,支援 AP 模式)
熱點 SSID pi5-esp32、頻道 1、網段 10.42.0.0/24(NetworkManager shared 模式)
編譯環境


arduino-cli,esp32 core 3.3.12(在 Pi 5 上編譯)


圖說:實體照片:中間是插在 ESP32-CAM-MB 底板上、接著 USB 線的 ESP32-CAM(OV2640 鏡頭在下方),兩邊是另外兩片裝進透明壓克力外殼的 ESP32-CAM


圖說:Pi 5 桌面右上角的網路狀態:USB 網卡開的熱點 pi5-esp32(10.42.0.1)和內建網卡的 WiFi 同時在使用中

三、設定步驟

1. 先備份原本的韌體

手上這片原本燒的是別的程式,先把整顆 4MB Flash 讀出來,之後隨時能燒回去:

ESPTOOL=~/.arduino15/packages/esp32/tools/esptool_py/5.3.1/esptool
$ESPTOOL --port /dev/ttyUSB0 --baud 460800 read-flash 0 0x400000 backup.bin
sha256sum backup.bin

注意:CH340 用 921600 讀會出現 Corrupt data, expected 0x1000 bytes,降到 460800 就正常。

2. 填 WiFi 與 OTA 密碼

韌體把密碼放在獨立的 secrets.h(不進 git),複製範本後填入熱點密碼與 OTA 密碼:

#pragma once
/* 複製成 secrets.h 再填值;secrets.h 在 .gitignore,不會進 git */

/* Pi 5 熱點 pi5-esp32 的密碼:在 Pi 上 sudo cat /etc/pi5-esp32/ap.psk */
#define SECRET_AP_SSID "pi5-esp32"
#define SECRET_AP_PASS ""

/* OTA 密碼:沿用 pi5-dual-wifi-esp32 的,在 Pi 上 sudo cat /etc/pi5-esp32/ota.pass */
#define SECRET_OTA_PASS ""

3. 編譯

arduino-cli compile --fqbn esp32:esp32:esp32:PSRAM=enabled,PartitionScheme=min_spiffs firmware/esp32cam_stream

Arduino IDE 的設定對應如下:

選項 設定 原因
開發板 ESP32 Dev Module 「AI Thinker ESP32-CAM」板型預設 Huge APP 分區,沒有 OTA
PSRAM Enabled 沒開的話相機只能用 QVGA、單張緩衝
Partition Scheme Minimal SPIFFS (1.9MB APP with OTA) 程式約 1.09MB(55%),留雙分區給 OTA
Upload Speed 460800 CH340 用 921600 不穩

4. 燒錄

arduino-cli upload -p /dev/ttyUSB0 --fqbn esp32:esp32:esp32:PSRAM=enabled,PartitionScheme=min_spiffs,UploadSpeed=460800 firmware/esp32cam_stream

ESP32-CAM-MB 底板有自動下載電路,從 Pi 5 用 arduino-cli 燒不用按任何鍵。如果你的底板或 USB-TTL 沒有這個電路,在出現 Connecting..... 時:按住 IO0(BOOT)→ 按一下 RST → 放開 IO0。

5. 確認開機

序列埠(115200)按 RST 應該看到:

esp32cam_stream 1.1.0, psram=1
connecting to pi5-esp32......
connected 10.42.0.50 rssi=-35
view: http://10.42.0.50/   stream: http://10.42.0.50:81/stream

在 Pi 5 上驗證:

ping -c3 10.42.0.50
curl -s http://10.42.0.50/status
# {"fw":"1.1.0","ip":"10.42.0.50","rssi":-34,"framesize":"VGA","quality":12,"psram":true,...}
curl -s -o cap.jpg http://10.42.0.50/capture   # 640×480 JPEG

6. 手機或筆電觀看(B1)

  1. 手機/筆電的 WiFi 連 pi5-esp32(連上時會暫時斷開原本的 WiFi)
  2. 瀏覽器開 http://10.42.0.50/:上方即時畫面,下方可切解析度(QVGA~HD)、畫質、上下翻轉、補光燈、拍照下載,最下面是狀態列(RSSI、已送張數、運行時間)

圖說:實際畫面:在 Pi 5 上用 Chromium 開 http://10.42.0.50/,下方是解析度、畫質、翻轉、拍照、補光按鈕,最下面是狀態列(FW 1.1.0、VGA、RSSI -60 dBm)

四、程式重點

  • 固定 IP 10.42.0.50:Pi 端 ffmpeg、書籤都要固定網址;NetworkManager 熱點的 DHCP 從 .2 開始發,.50 不太會撞到。
  • 串流和控制分兩個 HTTP server(port 81 / 80):MJPEG 是一直不斷的長連線,放同一個 server 會讓 /status、/capture 卡住。
  • CAMERA_GRAB_LATEST + 雙緩衝:串流永遠送最新一張,不會越看越延遲。
  • WiFi.setSleep(false):預設的省電模式會讓串流斷斷續續。
  • 每 10 秒檢查 WiFi,熱點重開或斷線就自動重連;ArduinoOTA 用 port 3333,跟同一個熱點上其他 ESP32 的 OTA 流程一致。

五、完整程式

/*
 * ESP32-CAM(AI-Thinker,OV2640)→ 連 Pi 5 熱點 pi5-esp32,提供 MJPEG 串流給 Pi 5 的 MediaMTX 轉 WebRTC。
 *   http://10.42.0.50/            觀看頁(手機/筆電連 pi5-esp32 熱點後直接開):即時畫面+拍照+解析度/畫質
 *   http://10.42.0.50:81/stream   MJPEG 串流(Pi 5 的 ffmpeg 也從這裡拉)
 *   http://10.42.0.50/capture     單張 JPEG
 *   http://10.42.0.50/status      JSON 狀態(解析度、畫質、RSSI、heap、運行時間)
 *   http://10.42.0.50/control?framesize=VGA&quality=12&vflip=1&hmirror=0
 * OTA:ArduinoOTA port 3333(同 pi5_link),密碼見 secrets.h
 * 編譯:arduino-cli compile --fqbn esp32:esp32:esp32:PSRAM=enabled,PartitionScheme=min_spiffs
 *   (min_spiffs 才有 OTA 雙分區;esp32cam 板型預設 huge_app 沒有 OTA)
 */
#include <WiFi.h>
#include <ArduinoOTA.h>
#include "esp_camera.h"
#include "esp_http_server.h"
#include "secrets.h"

#define FW_VERSION "1.1.0"
// 固定 IP:Pi 端 ffmpeg 要用固定網址拉流(熱點 DHCP 從 .2 開始發,.50 不太會撞到)
static const IPAddress LOCAL_IP(10, 42, 0, 50), GATEWAY(10, 42, 0, 1), SUBNET(255, 255, 255, 0);

// AI-Thinker ESP32-CAM 腳位
#define PWDN_GPIO_NUM 32
#define RESET_GPIO_NUM -1
#define XCLK_GPIO_NUM 0
#define SIOD_GPIO_NUM 26
#define SIOC_GPIO_NUM 27
#define Y9_GPIO_NUM 35
#define Y8_GPIO_NUM 34
#define Y7_GPIO_NUM 39
#define Y6_GPIO_NUM 36
#define Y5_GPIO_NUM 21
#define Y4_GPIO_NUM 19
#define Y3_GPIO_NUM 18
#define Y2_GPIO_NUM 5
#define VSYNC_GPIO_NUM 25
#define HREF_GPIO_NUM 23
#define PCLK_GPIO_NUM 22
#define FLASH_LED_GPIO 4

static httpd_handle_t web_httpd = NULL, stream_httpd = NULL;
static volatile uint32_t frames_sent = 0;

#define BOUNDARY "frame"
static const char *STREAM_TYPE = "multipart/x-mixed-replace;boundary=" BOUNDARY;
static const char *STREAM_BOUNDARY = "\r\n--" BOUNDARY "\r\n";
static const char *STREAM_PART = "Content-Type: image/jpeg\r\nContent-Length: %u\r\n\r\n";

static bool camera_init() {
  camera_config_t c = {};
  c.ledc_channel = LEDC_CHANNEL_0;
  c.ledc_timer = LEDC_TIMER_0;
  c.pin_d0 = Y2_GPIO_NUM; c.pin_d1 = Y3_GPIO_NUM; c.pin_d2 = Y4_GPIO_NUM; c.pin_d3 = Y5_GPIO_NUM;
  c.pin_d4 = Y6_GPIO_NUM; c.pin_d5 = Y7_GPIO_NUM; c.pin_d6 = Y8_GPIO_NUM; c.pin_d7 = Y9_GPIO_NUM;
  c.pin_xclk = XCLK_GPIO_NUM; c.pin_pclk = PCLK_GPIO_NUM; c.pin_vsync = VSYNC_GPIO_NUM; c.pin_href = HREF_GPIO_NUM;
  c.pin_sccb_sda = SIOD_GPIO_NUM; c.pin_sccb_scl = SIOC_GPIO_NUM;
  c.pin_pwdn = PWDN_GPIO_NUM; c.pin_reset = RESET_GPIO_NUM;
  c.xclk_freq_hz = 20000000;
  c.pixel_format = PIXFORMAT_JPEG;
  c.grab_mode = CAMERA_GRAB_LATEST;  // 串流只要最新一張,避免延遲累積
  if (psramFound()) {
    c.frame_size = FRAMESIZE_VGA;  // 640x480:Pi 5 沒有硬體 H.264 編碼器,VGA 用軟體編碼最穩
    c.jpeg_quality = 12;
    c.fb_count = 2;
    c.fb_location = CAMERA_FB_IN_PSRAM;
  } else {
    c.frame_size = FRAMESIZE_QVGA;
    c.jpeg_quality = 15;
    c.fb_count = 1;
    c.fb_location = CAMERA_FB_IN_DRAM;
  }
  esp_err_t err = esp_camera_init(&c);
  if (err != ESP_OK) {
    Serial.printf("camera init failed 0x%x\n", err);
    return false;
  }
  return true;
}

static esp_err_t stream_handler(httpd_req_t *req) {
  char part[64];
  esp_err_t res = httpd_resp_set_type(req, STREAM_TYPE);
  httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
  while (res == ESP_OK) {
    camera_fb_t *fb = esp_camera_fb_get();
    if (!fb) {
      res = ESP_FAIL;
      break;
    }
    size_t hlen = snprintf(part, sizeof(part), STREAM_PART, fb->len);
    res = httpd_resp_send_chunk(req, STREAM_BOUNDARY, strlen(STREAM_BOUNDARY));
    if (res == ESP_OK) res = httpd_resp_send_chunk(req, part, hlen);
    if (res == ESP_OK) res = httpd_resp_send_chunk(req, (const char *)fb->buf, fb->len);
    esp_camera_fb_return(fb);
    frames_sent++;
  }
  return res;
}

static esp_err_t capture_handler(httpd_req_t *req) {
  camera_fb_t *fb = esp_camera_fb_get();
  if (!fb) return httpd_resp_send_500(req);
  httpd_resp_set_type(req, "image/jpeg");
  esp_err_t res = httpd_resp_send(req, (const char *)fb->buf, fb->len);
  esp_camera_fb_return(fb);
  return res;
}

// 觀看頁:串流在 :81,用 location.hostname 組網址,換 IP 也不用改
static const char INDEX_HTML[] = R"HTML(<!doctype html><html lang="zh-TW"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>ESP32-CAM</title>
<style>body{margin:0;background:#111;color:#eee;font-family:sans-serif;text-align:center}
img{width:100%;max-width:800px;display:block;margin:0 auto;background:#000}
.bar{padding:10px;display:flex;flex-wrap:wrap;gap:8px;justify-content:center}
button,select{font-size:16px;padding:8px 12px;border-radius:8px;border:0;background:#2a6;color:#fff}
select{background:#333}#st{font-size:13px;color:#999;padding:6px}</style></head><body>
<img id="v" alt="stream">
<div class="bar">
<select id="fs"><option>QVGA</option><option selected>VGA</option><option>SVGA</option><option>XGA</option><option>HD</option></select>
<select id="q"><option value="10">畫質 高</option><option value="12" selected>畫質 中</option><option value="20">畫質 低</option></select>
<button onclick="flip()">上下翻轉</button><button onclick="snap()">拍照</button><button id="fl" onclick="flash()">補光 關</button>
</div><div id="st"></div>
<script>
const h=location.hostname,v=document.getElementById('v');let vf=0,fon=0;
function play(){v.src='http://'+h+':81/stream?t='+Date.now()}
function ctl(q){return fetch('/control?'+q).then(r=>r.json()).then(show)}
function show(j){document.getElementById('st').textContent='FW '+j.fw+'|'+j.framesize+'|畫質 '+j.quality+'|RSSI '+j.rssi+' dBm|已送 '+j.frames_sent+' 張|運行 '+j.uptime_s+' 秒';vf=j.vflip}
document.getElementById('fs').onchange=e=>ctl('framesize='+e.target.value).then(play);
document.getElementById('q').onchange=e=>ctl('quality='+e.target.value);
function flip(){ctl('vflip='+(vf?0:1))}
function flash(){fon=fon?0:1;document.getElementById('fl').textContent='補光 '+(fon?'開':'關');ctl('flash='+fon)}
function snap(){const a=document.createElement('a');a.href='/capture';a.download='esp32cam-'+Date.now()+'.jpg';a.click()}
v.onerror=()=>setTimeout(play,2000);play();fetch('/status').then(r=>r.json()).then(show);setInterval(()=>fetch('/status').then(r=>r.json()).then(show),5000);
</script></body></html>)HTML";

static esp_err_t index_handler(httpd_req_t *req) {
  httpd_resp_set_type(req, "text/html; charset=utf-8");
  return httpd_resp_send(req, INDEX_HTML, sizeof(INDEX_HTML) - 1);
}

static const char *framesize_name(framesize_t f) {
  switch (f) {
    case FRAMESIZE_QVGA: return "QVGA";
    case FRAMESIZE_VGA: return "VGA";
    case FRAMESIZE_SVGA: return "SVGA";
    case FRAMESIZE_XGA: return "XGA";
    case FRAMESIZE_HD: return "HD";
    default: return "other";
  }
}

static esp_err_t status_handler(httpd_req_t *req) {
  sensor_t *s = esp_camera_sensor_get();
  char json[320];
  snprintf(json, sizeof(json),
           "{\"fw\":\"%s\",\"ip\":\"%s\",\"rssi\":%d,\"framesize\":\"%s\",\"quality\":%d,"
           "\"vflip\":%d,\"hmirror\":%d,\"psram\":%s,\"free_heap\":%u,\"frames_sent\":%u,\"uptime_s\":%lu}",
           FW_VERSION, WiFi.localIP().toString().c_str(), WiFi.RSSI(), framesize_name(s->status.framesize),
           s->status.quality, s->status.vflip, s->status.hmirror, psramFound() ? "true" : "false",
           ESP.getFreeHeap(), frames_sent, millis() / 1000);
  httpd_resp_set_type(req, "application/json");
  return httpd_resp_send(req, json, HTTPD_RESP_USE_STRLEN);
}

static esp_err_t control_handler(httpd_req_t *req) {
  char q[128], v[16];
  sensor_t *s = esp_camera_sensor_get();
  if (httpd_req_get_url_query_str(req, q, sizeof(q)) == ESP_OK) {
    if (httpd_query_key_value(q, "framesize", v, sizeof(v)) == ESP_OK) {
      framesize_t f = !strcmp(v, "QVGA") ? FRAMESIZE_QVGA : !strcmp(v, "SVGA") ? FRAMESIZE_SVGA
                    : !strcmp(v, "XGA") ? FRAMESIZE_XGA : !strcmp(v, "HD") ? FRAMESIZE_HD : FRAMESIZE_VGA;
      s->set_framesize(s, f);
    }
    if (httpd_query_key_value(q, "quality", v, sizeof(v)) == ESP_OK) s->set_quality(s, constrain(atoi(v), 4, 63));
    if (httpd_query_key_value(q, "vflip", v, sizeof(v)) == ESP_OK) s->set_vflip(s, atoi(v));
    if (httpd_query_key_value(q, "hmirror", v, sizeof(v)) == ESP_OK) s->set_hmirror(s, atoi(v));
    if (httpd_query_key_value(q, "flash", v, sizeof(v)) == ESP_OK) digitalWrite(FLASH_LED_GPIO, atoi(v) ? HIGH : LOW);
  }
  return status_handler(req);
}

static void start_servers() {
  httpd_config_t cfg = HTTPD_DEFAULT_CONFIG();
  cfg.server_port = 80;
  cfg.ctrl_port = 32768;
  httpd_uri_t index = {"/", HTTP_GET, index_handler, NULL};
  httpd_uri_t capture = {"/capture", HTTP_GET, capture_handler, NULL};
  httpd_uri_t status = {"/status", HTTP_GET, status_handler, NULL};
  httpd_uri_t control = {"/control", HTTP_GET, control_handler, NULL};
  if (httpd_start(&web_httpd, &cfg) == ESP_OK) {
    httpd_register_uri_handler(web_httpd, &index);
    httpd_register_uri_handler(web_httpd, &capture);
    httpd_register_uri_handler(web_httpd, &status);
    httpd_register_uri_handler(web_httpd, &control);
  }
  // 串流放在另一個 server(port 81),長連線才不會卡住 /status、/capture
  cfg.server_port = 81;
  cfg.ctrl_port = 32769;
  httpd_uri_t stream = {"/stream", HTTP_GET, stream_handler, NULL};
  if (httpd_start(&stream_httpd, &cfg) == ESP_OK) httpd_register_uri_handler(stream_httpd, &stream);
}

static void wifi_connect() {
  WiFi.mode(WIFI_STA);
  WiFi.setSleep(false);  // 省電模式會讓串流斷斷續續
  WiFi.config(LOCAL_IP, GATEWAY, SUBNET, GATEWAY);
  WiFi.begin(SECRET_AP_SSID, SECRET_AP_PASS);
  Serial.printf("connecting to %s", SECRET_AP_SSID);
  for (int i = 0; i < 60 && WiFi.status() != WL_CONNECTED; i++) {
    delay(500);
    Serial.print(".");
  }
  Serial.printf("\n%s %s rssi=%d\n", WiFi.status() == WL_CONNECTED ? "connected" : "FAILED",
                WiFi.localIP().toString().c_str(), WiFi.RSSI());
}

void setup() {
  Serial.begin(115200);
  pinMode(FLASH_LED_GPIO, OUTPUT);
  digitalWrite(FLASH_LED_GPIO, LOW);
  Serial.printf("\nesp32cam_stream %s, psram=%d\n", FW_VERSION, psramFound());
  if (!camera_init()) {
    delay(3000);
    ESP.restart();
  }
  wifi_connect();
  ArduinoOTA.setHostname("esp32cam");
  ArduinoOTA.setPort(3333);
  if (strlen(SECRET_OTA_PASS)) ArduinoOTA.setPassword(SECRET_OTA_PASS);
  ArduinoOTA.begin();
  start_servers();
  Serial.println("view: http://10.42.0.50/   stream: http://10.42.0.50:81/stream");
}

void loop() {
  ArduinoOTA.handle();
  static uint32_t last = 0;
  if (millis() - last > 10000) {  // 熱點重開或訊號掉了就重連
    last = millis();
    if (WiFi.status() != WL_CONNECTED) {
      Serial.println("wifi lost, reconnecting");
      WiFi.disconnect();
      wifi_connect();
    }
  }
  delay(10);
}

六、實測結果與注意事項

項目 結果
連線 RSSI -35 dBm,/status、觀看頁、拍照都正常
串流(VGA 640×480) 約 2~3 fps、20~25 KB/s
串流(QVGA 320×240) 約 3 fps
1400 bytes ping 掉包 20%、平均 450 ms

訊號很強、2.4GHz 頻道也不擁擠,fps 卻上不去,瓶頸在熱點那張 USB 網卡:DWA-135 用的是 Linux 內建 rtl8192cu 驅動,AP 模式的效能和穩定度出了名地差。上一篇只跑 MQTT、OTA 這種小流量沒感覺,換成影像就現形了。

  • 熱點網卡要挑 AP 模式穩的晶片:要跑影像,建議換 MediaTek(例如 MT7601U、MT7612U)這類 Linux 內建支援較好的 USB 網卡。
  • 不要短時間內反覆重開熱點:實測連續切換頻道、重啟熱點幾次後,RTL8192CU 會進入 ESP32 怎麼都連不上的狀態,熱點本身看起來卻正常;重插網卡才會恢復。
  • Pi 5 沒有硬體 H.264 編碼器(Pi 4 有、Pi 5 拿掉了),要轉 WebRTC 只能靠 CPU 用 libx264,解析度先用 VGA。
  • Pi 上若已經有 MediaMTX,不要再裝第二個(會搶 8554/8889/8189),用 ffmpeg 推到既有的就好。
  • Pi 轉 WebRTC 和手機直連不要同時開:ffmpeg 會一直佔一條串流,直連的人幀率更低。
  • 供電:ESP32-CAM 開 WiFi 加相機很吃電流,用 5V 2A 等級電源,不然會 brownout 重開或畫面卡住。
  • ESP32-CAM 做不了 WebRTC:OV2640 只能出 JPEG,原始款 ESP32 也跑不動 H.264 加 DTLS;要板子自己出 WebRTC,要換 ESP32-S3 或 ESP32-P4。

七、從 Tailscale 遠端看(不用連熱點)

B1 要在 Pi 5 旁邊連熱點才看得到。想在外面、用平常的 WiFi 就能看,可以讓 Pi 5 當 Tailscale 子網路路由器,把熱點網段 10.42.0.0/24 分享給整個 tailnet:有裝 Tailscale 的手機、筆電直接開 http://10.42.0.50/,觀看頁的按鈕全部能用。

1. Pi 5 分享熱點網段

sudo tailscale set --advertise-routes=10.42.0.0/24

用 tailscale set 只改這一項;不要用 tailscale up,那會把沒寫到的其他設定重設。Pi 5 本來就開了 IP 轉發(熱點 NAT 需要),不用另外改 sysctl。

2. 到管理後台核准

login.tailscale.com → Machines → 找到 Pi 5 那台(會出現「Subnets」標籤)→ ⋯ → Edit route settings → 勾 10.42.0.0/24 → Save。核准後會從「Awaiting Approval」移到「Approved」:

圖說:Tailscale 管理後台:10.42.0.0/24 已核准

建議順便在同一台的 ⋯ 選單按 Disable key expiry:當路由器的機器金鑰過期,整個子網路都會斷。

3. 坑:NetworkManager 熱點會擋掉從 Tailscale 連進來的連線

核准後,筆電的路由表已經有 10.42.0.0/24 → Tailscale,tailscale ping 10.42.0.50 也通,可是開網頁一直逾時。查了一圈:Tailscale ACL 是全開的、Docker 的 FORWARD 也不是兇手,真正擋住的是 NetworkManager 熱點(shared 模式)自己建的 nft 表:

table ip nm-shared-wlan1 {
  chain filter_forward {
    ip daddr 10.42.0.0/24 oifname "wlan1" ct state { established, related } accept
    ip saddr 10.42.0.0/24 iifname "wlan1" accept
    iifname "wlan1" oifname "wlan1" accept
    iifname "wlan1" reject
    oifname "wlan1" reject      ← 從熱點外面「主動」連進來的一律拒絕
  }
}

熱點裡的裝置往外連沒問題(回程封包是 established),但從外面主動連進熱點就會被最後一條擋掉。解法是在最前面補一條「只放行 tailscale0 → wlan1」。NetworkManager 每次啟用熱點都會重建這張表,所以用 NetworkManager dispatcher 腳本,每次熱點起來時自動補上:

#!/bin/sh
# NetworkManager dispatcher:熱點 wlan1 起來後,允許 Tailscale(tailscale0)連進熱點網段 10.42.0.0/24。
# 原因:NM shared 模式的 nft 表 nm-shared-wlan1 最後有 `oifname "wlan1" reject`,
#       從熱點外面「主動」連進來的都會被拒;Tailscale 子網路路由(Pi advertise 10.42.0.0/24)因此不通。
# NM 每次啟用熱點都會重建這張表,所以要在每次 up 時補規則。
# 安裝:sudo install -m 755 -o root -g root pi5/90-pi5-esp32-tailscale /etc/NetworkManager/dispatcher.d/
[ "$1" = "wlan1" ] || exit 0
case "$2" in up|dhcp4-change) ;; *) exit 0 ;; esac
for i in 1 2 3 4 5; do
  nft list chain ip nm-shared-wlan1 filter_forward >/dev/null 2>&1 && break
  sleep 1
done
nft list chain ip nm-shared-wlan1 filter_forward 2>/dev/null | grep -q 'iifname "tailscale0" oifname "wlan1"' && exit 0
nft insert rule ip nm-shared-wlan1 filter_forward iifname "tailscale0" oifname "wlan1" accept
logger -t pi5-esp32-tailscale "allowed tailscale0 -> wlan1 in nm-shared-wlan1"

安裝並立刻套用一次:

sudo install -m 755 -o root -g root 90-pi5-esp32-tailscale /etc/NetworkManager/dispatcher.d/
sudo /etc/NetworkManager/dispatcher.d/90-pi5-esp32-tailscale wlan1 up
sudo nft list chain ip nm-shared-wlan1 filter_forward

圖說:執行後 filter_forward 第一條變成 iifname “tailscale0” oifname “wlan1” accept

只放行 tailscale0,公司或家裡區網一樣連不進熱點;但整個 tailnet 的裝置都能連到熱點上所有 ESP32,tailnet 有別人共用的話,記得用 ACL 限制誰能存取 10.42.0.0/24。

4. 用戶端

  • Windows、iPhone、Android:Tailscale 預設就會接收子網路路由(Windows 可在工作列圖示確認「Use Tailscale subnets」有勾)
  • Linux:要執行一次 sudo tailscale set --accept-routes

5. 實測

從另一台 Windows 筆電(msi)經 Tailscale 開 http://10.42.0.50/:/status、觀看頁都正常,拍一張 640×480 約 27 KB、5 秒;畫面一樣約 1 fps,瓶頸還是熱點網卡,Tailscale 本身只多一點延遲。

圖說:在 msi 筆電經 Tailscale 開 http://10.42.0.50/,狀態列:QVGA、畫質 20、RSSI -32 dBm、已送 4197 張、運行 3055 秒

小 bug:圖中下拉選單顯示 VGA/畫質中,狀態列卻是 QVGA/畫質 20,因為觀看頁打開時選單用的是預設值,沒有讀板子現在的設定;以狀態列為準,下一版韌體會修。

八、改看 WebRTC:兩個讓畫面出不來的坑

前面都是直接看 ESP32-CAM 的 MJPEG。接著把 Pi 5 上的 esp32cam-ffmpeg 服務打開,讓它從 http://10.42.0.50:81/stream 拉 MJPEG、用 libx264 轉成 H.264,推給既有的 MediaMTX,用瀏覽器開 http://100.108.160.111:8889/esp32cam/(Pi 5 的 Tailscale IP)看 WebRTC。服務起來了,畫面卻一直出不來,查下來是兩個不相干的問題疊在一起。

1. ESP32-CAM 一次只能給一個人看

MediaMTX 的 log 每兩秒一行:

[WebRTC] [session 1566038d] closed: no stream is available on path 'esp32cam'

ffmpeg 有在跑,可是 MediaMTX 一直說這個路徑沒有串流。原因是 Pi 5 桌面上的 Chromium 還開著 http://10.42.0.50/ 觀看頁:韌體的串流 server(port 81)一次只服務一條連線,Chromium 佔著,ffmpeg 就一直排不到。把 Pi 5 上的瀏覽器關掉,同一秒 ffmpeg 就接上了:

16:34:06 mediamtx: [path esp32cam] stream is available and online, 1 track (H264)
16:34:06 mediamtx: [RTSP] [session fbdaeac0] is publishing to path 'esp32cam'
16:34:06 systemd:  app-org.chromium.Chromium-4265.scope: Consumed 24.639s CPU time

結論:要看 WebRTC 時,任何地方都不要直接開 ESP32-CAM 的觀看頁或 :81/stream,所有人都改看 MediaMTX,讓 ffmpeg 當唯一的那一條連線。

2. MediaMTX 只回 127.0.0.1,Windows 瀏覽器連不上

串流有了,msi 筆電的瀏覽器還是黑畫面,log 換成每 10 秒一行:

[WebRTC] [session de194a66] closed: deadline exceeded while waiting connection

防火牆沒擋(tailscale0 進來的都放行),從 gx10 用 Python 的 aiortc 寫一支小程式跑 WHEP 測試,反而連得上。差別在 MediaMTX 回的 SDP answer,ICE candidate 只有一個:

a=candidate:2878742611 1 udp 2130706431 127.0.0.1 8189 typ host

aiortc 會在自己的 candidate 裡附上真實 IP,Pi 照著反連就通了(peer-reflexive);但 Chrome、Edge 這類瀏覽器預設會把本機 IP 藏成 xxxx.local 的 mDNS 名稱,Pi 解析不了,自己又只告訴對方 127.0.0.1,兩邊都找不到對方,ICE 等到 timeout 就關掉。

解法是在 MediaMTX 設定裡明確補上 Pi 5 對外的位址(Tailscale IP 和區網 IP):

# ~/mediamtx/mediamtx.yml(先 cp 一份備份)
webrtcAdditionalHosts: [100.108.160.111, 192.168.1.238]

MediaMTX 偵測到設定檔變動會自己重新載入(log 出現 reloading configuration (file changed)),不用重啟服務。改完 answer 裡就多了 100.108.160.111、192.168.1.238 等 candidate,msi 重新整理頁面就有畫面了。

圖說:WebRTC 通了之後,從 MediaMTX 的 esp32cam 路徑擷取的一張畫面(目前是 QVGA 320×240)

3. 多人同時看

改走 MediaMTX 之後,ESP32-CAM 那端永遠只有 ffmpeg 一條連線,要幾個人看都由 Pi 5 轉發。實測 msi 筆電的瀏覽器還開著,iPhone 的 Safari 再經 Tailscale 開同一個網址,兩邊同時有畫面,MediaMTX 的 log 也看得到兩個 WebRTC session 一起在讀:

16:37:01 [WebRTC] [session 92e80112] is reading from path 'esp32cam', 1 track (H264)   ← msi
16:42:57 [WebRTC] [session 23532a86] created by 100.97.225.118:57306                  ← iPhone
16:42:57 [WebRTC] [session 23532a86] is reading from path 'esp32cam', 1 track (H264)

圖說:iPhone 經 Tailscale 開 http://100.108.160.111:8889/esp32cam/,和 msi 筆電同時在看

這就是 WebRTC 方式的主要好處:直接看 MJPEG 時第二個人根本排不進去,換成 MediaMTX 轉發後,多一個觀看者只是 Pi 5 多送一份封包,不會再去搶 ESP32-CAM 那條串流。

  • 區網 IP 是 DHCP 拿的,換了就要跟著改;Tailscale IP 不會變,遠端看主要靠它。
  • ffmpeg 輸出是固定幀率,WebRTC 那端收到的 frame 數看起來很多,但畫面實際更新的速度還是被熱點網卡限制住,跟直接看 MJPEG 差不多。

九、下一步

  • 換一張 AP 模式穩定的 USB 網卡,重測 fps
  • 觀看頁的選單同步板子目前的設定(韌體 1.1.1,用 OTA 更新)
  • WebRTC 已經打通,之後多人看都走 MediaMTX;觀看頁可以加一個提示,避免有人直接佔走 ESP32-CAM 的串流

A Live Web Monitor for ESP32 WiFi CSI: GUI Design and Code | ESP32 WiFi CSI 即時監看網頁:GUI 設計與程式

延續上一篇 ESP32-S3 WiFi CSI 感測:第一次真板實測、Serial 瓶頸與雙機 ESP-NOW。實驗證明「動作指標」可以分辨有人和沒人:靜止約 0.03,走動約 0.2。但每次都要收錄、跑分析、看圖,太麻煩了。這篇記錄怎麼把它做成一個即時監看網頁:Pi 4 一邊收 ESP32 的 CSI 一邊判斷,手機或電腦開瀏覽器就能看到現在有沒有人。整支程式只有一個 Python 檔加一個 HTML 檔,不裝任何新套件。

圖說:完成的監看頁(桌面版):有人剛走進兩片 ESP32-S3 之間,狀態轉紅、指標衝過門檻、瀑布圖右邊出現色塊

一、整體架構

ESP32-S3 (TX) ── ESP-NOW 每 10 ms 廣播 ──▶ ESP32-S3 (RX)
                                              │ USB Serial 2 Mbaud,一行一筆 CSI
                                              ▼
Pi 4  csi_monitor.py
  ├─ Serial 執行緒:讀行 → 解析 → 算指標 → 判斷有人/沒人
  │                        └─ 事件寫 data/events_*.csv
  └─ HTTP 伺服器(port 8090)
       ├─ GET  /           → monitor.html
       ├─ GET  /stream     → SSE,每 0.1 秒推一格資料
       └─ POST /threshold  → 改門檻
                                              │
                                              ▼
手機/電腦瀏覽器:EventSource 接資料 → Canvas 畫圖
檔案 做什麼
host/csi_parse.py 解析 CSI_DATA,... 行(上一篇就有,直接沿用)
host/csi_monitor.py 收資料、算指標、判斷、HTTP/SSE 伺服器,約 230 行
host/monitor.html 網頁本身:版面、Canvas 畫圖、互動,約 300 行,不引用外部函式庫

Pi 4 上原本只有 numpy 和 pyserial,所以刻意只用 Python 標準函式庫:http.server 的 ThreadingHTTPServer 當伺服器,前端也不用圖表函式庫,全部用 Canvas 自己畫。好處是不必 pip install,家裡網路斷了也能用。

二、GUI 設計:畫面由上而下

設計原則是「最常看的放最大」。大部分時候只想知道一件事:現在有沒有人?其他區塊用來回答第二層的問題:判斷可不可靠、為什麼這樣判。

區塊 內容 設計考量
狀態 大字「有人」(紅框)/「沒人」(綠字),有人時顯示已持續幾秒 放最大、最左上。除了紅綠色,還有 ● ○ 圖示和文字,色盲也分得出來
數值方塊 動作指標、RSSI、封包率、漏收、子載波數 用來確認資料源正常。如果封包率掉、漏收升,代表發射端沒電或太遠,不是「沒人」
動作指標曲線 最近 60 秒;紅虛線是門檻,判定有人的時段塗淡紅底;下方有門檻滑桿 看指標離門檻多遠。換擺位時直接拖滑桿調門檻,不用改程式、不用重啟
子載波瀑布圖 最近 30 秒 × 128 個子載波,顏色是每格和 3 秒平均的差 只畫「變化」,不畫原始振幅:靜止時一片灰,有人時色塊非常明顯。用藍-灰-紅,不用彩虹色階
RSSI 曲線 最近 60 秒 輔助指標,有人擋住直線路徑會往下掉。和動作指標分開畫,不做雙 Y 軸
事件紀錄 每次「有人」的開始、結束、秒數 離開一陣子回來也知道剛才發生什麼;同時寫進 CSV,之後拿來算誤報率

手機上是窄螢幕,自動改成單欄排列;系統設成深色模式時,整頁也跟著換成深色。所有顏色都定義成 CSS 變數,淺色和深色各一組。下面是手機實拍,同一分鐘內的「沒人」和「有人」:

圖說:手機實拍:左邊沒人(指標 0.025),右邊有人且已持續 5 秒。右邊的指標 0.055 已經低於門檻,但還在 2 秒的保持時間內,所以仍顯示有人

電腦版實際跑起來的樣子:人已經離開一陣子,狀態是「沒人」,右下角事件紀錄留下剛才的 4 次走動。

圖說:電腦版實拍(深色模式):沒人時動作指標穩定在 0.03~0.05,離 0.06 門檻還有一段距離;封包率 93/秒、漏收 7.2%,資料源正常。事件紀錄最近一次是 11:12:21~11:12:26,持續 5.2 秒

三、後端:csi_monitor.py

1. 讀 Serial:整塊讀,自己切行

上一篇踩過的坑:pyserial 的 readline() 一次讀 1 byte,在 Pi 4 上跟不上每秒 135 KB 的 CSI,會掉字元。所以這裡沿用整塊讀的寫法,並且包一層 try/except。板子拔掉或重燒時不會整支程式掛掉,而是 3 秒後自動重開:

while True:
    try:
        ser = serial.Serial(args.port, args.baud, timeout=0.2)
        buf = b""
        while True:
            buf += ser.read(max(1, ser.in_waiting))
            *lines, buf = buf.split(b"\n")
            for line in lines:
                r = parse_line(line.decode(errors="replace"))
                if r:
                    mon.feed(*r, time.time())
    except Exception as e:  # 板子拔掉、重燒時等它回來
        print(f"Serial 錯誤:{e},3 秒後重試")
        time.sleep(3)

Serial 埠用 /dev/serial/by-id/usb-1a86_..._5D01087713-if00,不用 /dev/ttyACM0。兩片板子都插在同一台 Pi 4 上,ttyACM0 和 ttyACM1 的順序會隨插拔改變,by-id 則固定對應到同一片板子。

2. 算指標:和實驗完全一樣

每收到一包 CSI 就呼叫 feed(),步驟和上一篇的對照實驗相同:

  1. 振幅除以這一包自己的平均值,去掉自動增益(AGC)造成的整體忽大忽小
  2. 累積 10 包平均成一「格」(約 0.1 秒),壓掉單包雜訊
  3. 最近 30 格(3 秒)每個子載波算標準差,再取平均,就是動作指標
def feed(self, meta, csi, now):
    amp = np.abs(csi)
    if self.n_sub != len(amp):           # 換封包長度就重來
        self.n_sub, self.acc, self.frames = len(amp), [], deque(maxlen=WIN_FRAMES)
    self.acc.append(amp / amp.mean())    # ① 去 AGC
    if len(self.acc) < AVG_N:
        return
    frame = np.mean(self.acc, axis=0)    # ② 10 包平均成一格
    self.acc = []
    self.frames.append(frame)
    win = np.array(self.frames)
    motion = float(win.std(axis=0).mean())   # ③ 3 秒窗標準差取平均
    dev = frame - win.mean(axis=0)           # 瀑布圖用:這格偏離平均多少
    self.judge(motion, now)
    ...

瀑布圖畫的是 dev,也就是「這一格和最近 3 秒平均的差」,所以靜止時接近 0(灰色),有人時才出現紅藍。

為了確認即時版的算法沒寫錯,我把早上的實驗收錄檔丟進同一個 feed() 重播。結果靜止 0.030、走動 0.196、靜止 0.026,和實驗時另外寫的分析程式一致。

3. 判斷:門檻+2 秒保持

def judge(self, motion, now):
    if motion > self.threshold:
        self.last_hit = now
        if not self.present:
            self.present, self.event_start = True, now      # 事件開始
    elif self.present and now - self.last_hit > HOLD_S:
        self.present = False                               # 事件結束,寫 CSV
        ...

進入「有人」很快,只要一格超過門檻就算。退出則要指標持續低於門檻 2 秒。人走路時兩步之間指標會短暫下降,如果沒有這段保持時間,狀態會在門檻附近一直閃,事件紀錄也會被切成一堆很短的片段。

判斷放在伺服器端,不放在網頁裡。所以不管開了幾個網頁、甚至都沒人開,判斷結果都一致,事件也都會寫進 CSV。

4. 推資料:Server-Sent Events

瀏覽器要每 0.1 秒拿到新資料。我選 SSE(Server-Sent Events)而不是 WebSocket,原因是:

  • 資料只有伺服器推往瀏覽器一個方向,SSE 剛好夠用;改門檻這種少量操作,用一般的 POST 就好
  • SSE 就是一個不結束的 HTTP 回應,用內建 http.server 就能做,不用另外裝 websocket 套件
  • 瀏覽器的 EventSource 斷線會自己重連,不用另外寫重連邏輯

每個連上來的網頁有自己的一個 queue。Serial 執行緒算完一格就丟進所有 queue,各自的 HTTP 執行緒再寫出去:

def broadcast(self, kind, data):
    payload = f"event: {kind}\ndata: {json.dumps(data)}\n\n".encode()
    with self.lock:
        for q in list(self.clients):
            try:
                q.put_nowait(payload)
            except queue.Full:
                pass  # 網頁跟不上就丟格,不能卡住收資料的執行緒

這裡有兩個細節:

  • queue 有上限(50 格),滿了就丟:手機切到背景時瀏覽器會暫停讀資料。如果這時候用會等待的 put(),連 Serial 執行緒都會被卡住,CSI 就漏收了
  • 新連上的網頁先補資料:伺服器保留最近 600 格(60 秒)。網頁一連上,先送一個 init 事件,內含歷史資料、事件紀錄和目前門檻,畫面一打開就是滿的

每一格的資料長這樣,128 個子載波的差值取到小數 3 位,一格約 1 KB,每秒約 10 KB,手機用行動網路看也沒問題:

{"t": 1793414664.94, "motion": 0.0254, "rssi": -36, "pps": 95.0, "loss": 0.0494,
 "len": 256, "bad": 0, "present": false, "th": 0.06, "dev": [0.0, -0.024, -0.031, ...]}

5. 重播模式:沒有板子也能開發

--replay 收錄檔 會照封包原本的時間戳記間隔重播,網頁看起來就跟現場一樣。上面「指標和實驗一致」的驗證就是在 gx10 上用重播模式做的,調畫面時也不必每次都到 Pi 4 旁邊走來走去。

python3 csi_monitor.py --replay ../data/csi_20261031_092328.log

四、前端:monitor.html

1. 接資料

const es = new EventSource('stream');
es.addEventListener('init',  e => { /* 歷史、事件、門檻 */ });
es.addEventListener('frame', e => {
  frames.push(JSON.parse(e.data));
  setStatus(...);                       // 數值方塊與狀態立刻更新
  if (!pending) { pending = true; requestAnimationFrame(() => { pending = false; draw(); }); }
});
es.addEventListener('event', e => { /* 事件紀錄表 */ });

重畫用 requestAnimationFrame 合併:一次畫面更新裡收到再多格,也只重畫一次。手機切到背景時,瀏覽器也會自動暫停重畫。

2. 折線圖:Canvas 自己畫

動作指標和 RSSI 用同一個 lineChart(),依序畫這幾層:

  1. 「有人」時段的淡紅底
  2. 格線與刻度
  3. 門檻虛線
  4. 2 px 的資料線
  5. 滑鼠或手指停留時的十字線和提示框

動作指標的 Y 軸上限取「門檻 2 倍」和「資料最大值」較大的那個,所以沒人時門檻線也不會跑出畫面。

3. 瀑布圖:ImageData 一格一像素

30 秒 × 128 個子載波,大約 300 × 128 個點。不需要一個一個畫方塊,而是先建一張 300×128 像素的小圖,每個像素填顏色,再用 drawImage 放大貼上去,並關掉平滑處理(程式碼略為簡化):

const img = g.createImageData(data.length, n);
data.forEach((f, x) => f.dev.forEach((v, y) => {
  const [r, gg, b] = divColor(v), i = ((n - 1 - y) * data.length + x) * 4;
  img.data.set([r, gg, b, 255], i);
}));
off.getContext('2d').putImageData(img, 0, 0);
g.imageSmoothingEnabled = false;
g.drawImage(off, x0, top, width, height);

色階 divColor() 是三段漸層:-0.25 藍、0 灰、+0.25 紅。三個端點的顏色也從 CSS 變數讀,深色模式的灰會換成接近背景的深灰,靜止時整片就融進背景裡。

4. 門檻滑桿

拖滑桿時畫面上的紅虛線會立刻移動,但要停手 0.3 秒後才送一次 POST /threshold 給伺服器,避免拖一下就送幾十個請求。伺服器改了門檻後,下一格資料裡的 th 會帶新值。其他開著的網頁收到後,也會把自己的滑桿同步過去。

五、踩到的坑:手機上曲線越拉越高

電腦上一切正常,手機一打開,動作指標那張圖卻只剩上面 0.099~0.132 一小段,曲線大半跑到畫面外。

原因是高解析度螢幕的處理。為了讓 Canvas 在 3 倍螢幕上不糊,程式會把 Canvas 的像素尺寸設成「顯示尺寸 × devicePixelRatio」。但我把顯示高度存在 HTML 的 height 屬性裡,而設定 canvas.height 正好會改寫這個屬性:

第 1 次:讀 height=180 → 設 canvas.height = 540(× 3)
第 2 次:讀 height=540 → 設 canvas.height = 1620 ……

每重畫一次就變高 3 倍。電腦螢幕的 devicePixelRatio 是 1,乘完不變,所以在電腦上完全看不出來。修法是第一次就把原始高度另外存起來:

if (!c.dataset.h) c.dataset.h = c.getAttribute('height');
const h = +c.dataset.h;

之後我截圖驗證時,都把無頭瀏覽器的 deviceScaleFactor 設成 3,模擬手機螢幕。

六、怎麼跑、資料量多大

cd ~/esp32-csi/host
python3 csi_monitor.py --port /dev/serial/by-id/usb-1a86_USB_Single_Serial_5D01087713-if00
# 瀏覽器開 http://<Pi 4 的 IP>:8090
參數 用途
--threshold 0.06 初始門檻(網頁上也能調)
--replay 檔案 重播收錄檔,不讀 Serial
--record 同時存原始 CSI 到 data/
--http 8090 網頁 port

平常不開 --record,這時會一直長大的只有事件 CSV,每筆約 25 bytes,一天下來也才幾十 KB。曲線和瀑布圖都只存在記憶體裡,最多 60 秒。如果開 --record 存原始 CSI,每秒約 75 KB、每天約 6.5 GB,只適合做實驗時錄幾分鐘。

從外面看的話,Pi 4 有裝 Tailscale,手機開 Tailscale App 後用 Pi 4 的 Tailscale IP 連就可以。要用 IP,不要用主機簡稱,iPhone 上簡稱常常解析不到。

七、小結與下一步

  • ✅ 一個 Python 檔加一個 HTML 檔,不裝新套件,Pi 4 即時判斷有人/沒人,手機、電腦都能看
  • ✅ 指標和實驗分析程式一致;判斷放在伺服器端,用門檻加 2 秒保持,事件寫成 CSV
  • ✅ SSE 推資料、新網頁先補 60 秒歷史、queue 滿了就丟格,不拖慢收資料
  • ✅ 有重播模式,沒有板子也能開發和測試
  • ⏳ 做成開機自動啟動的 systemd 服務
  • ⏳ 放著跑一整天,用事件 CSV 算誤報率,再決定門檻要不要調