延續上一篇 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)
- 手機/筆電的 WiFi 連
pi5-esp32(連上時會暫時斷開原本的 WiFi) - 瀏覽器開
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 的串流








沒有留言:
張貼留言