延續上一篇 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 下載)
















