2026年11月2日 星期一

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 的串流

沒有留言:

張貼留言