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

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 算誤報率,再決定門檻要不要調

ESP32-S3 WiFi CSI Sensing: First Real-Board Test, Serial Bottlenecks and Two-Board ESP-NOW | ESP32-S3 WiFi CSI 感測:第一次真板實測、Serial 瓶頸與雙機 ESP-NOW

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

WiFi 不只能上網。每個 WiFi 封包進來時,網卡都要先估計「這條無線通道長什麼樣子」才能正確解碼,這份估計就叫 CSI(Channel State Information,通道狀態資訊)。有人在房間裡走動,電波的反射路徑就會改變,CSI 跟著變,所以拿 CSI 就能做「有沒有人在動」的感測,不用攝影機。ESP32 系列正好開放了 CSI 給程式讀,這篇記錄用 ESP32-S3 做 CSI 感測的第一次真板實測。

圖說:第一次收到的 CSI:30 秒、3006 筆、每秒 100 筆。上:192 個子載波的振幅熱圖;中:各子載波平均振幅;下:動作指標

一、CSI 是什麼

WiFi(802.11n)把一個頻道切成幾十個子載波同時傳資料。每個子載波經過空間傳到接收端時,振幅和相位都會被環境改變:直射、牆壁反射、家具反射加在一起。CSI 就是接收端對每個子載波估出來的一個複數(振幅+相位)。

  • 環境靜止時,CSI 相對穩定
  • 有人走動時,人體會擋住或反射部分路徑,各子載波的振幅開始起伏
  • 所以最簡單的「動作指標」:一段時間內各子載波振幅的標準差,取平均

ESP32 的 Arduino 核心(3.3.12)預編譯好的設定已經開了 CONFIG_ESP_WIFI_CSI_ENABLED,直接用 esp_wifi_set_csi_rx_cb() 就能拿到每個封包的 CSI,不必改用 ESP-IDF。

二、硬體與兩種模式

板子是兩片 ESP32-S3 N16R8(16 MB flash、8 MB PSRAM),插的是板子上 CH343 那個 COM 口,在 Raspberry Pi 4 上出現成 /dev/ttyACM0、/dev/ttyACM1。編譯、燒錄、收資料都在 Pi 4 上用 arduino-cli 做。

圖說:兩片 ESP32-S3-N16R8:一片當發射端、一片當接收端。板子有兩個 Type-C,線插右邊那個(CH343 COM 口)

模式 做法 需要
路由器模式
csi_recv
S3 連上家裡 AP,每 10 ms ping 一次閘道器,AP 每個回應封包都產生一筆 CSI 一片 S3+一台 2.4 GHz AP
雙機模式
csi_tx+csi_rx_espnow
一片在固定頻道每 10 ms 用 ESP-NOW 廣播,另一片收包取 CSI,從封包內容拿發射端序號算漏收率 兩片 ESP32,不需要路由器

CSI 從 Serial 以一行一筆輸出:

CSI_DATA,seq,mac,rssi,rate,sig_mode,mcs,cwb,channel,secondary,noise_floor,timestamp_us,len,[d0 d1 ...]

中括號裡每個子載波兩個 int8:虛部、實部。主機端的 Python 工具負責解析、即時顯示每秒封包數/漏收率/RSSI/動作指標,再畫成熱圖。

三、第一次上板:只收到一半,還有壞行

路由器模式一燒進去就連上家裡的 2.4 GHz AP(ch11、RSSI -40),CSI 也開始輸出。但 logger 顯示只有約 54 筆/秒,漏收率接近 50%,還偶爾出現 -279、-361 這種 int8 不可能出現的數字,解析程式直接當掉。查下去是兩個問題疊在一起:

1. Serial 頻寬不夠

S3 收到的 HT 封包 CSI 長度是 384 bytes(LLTF+HT-LTF+STBC-HT-LTF,192 個子載波),轉成文字一行約 1350 字元。100 Hz 就是每秒 135 KB,但 921600 baud 每秒只能傳約 92 KB。CH343 支援更高速率,韌體和 logger 都改成 2,000,000 baud。

2. pyserial 的 readline() 太慢

改完 baud 還是有壞行。為了分清楚是板子還是電腦的問題,直接用 stty+cat 把 Serial 原始資料存 10 秒:1008 筆、100.8 筆/秒、序號連續、0 壞行。板子完全沒問題,問題在 Python。

pyserial 的 readline() 一次只讀 1 byte,在 Pi 4 上跟不上每秒 135 KB,系統緩衝區滿了就開始掉字元,相鄰數字黏在一起才出現 -279 這種值。改成一次讀完緩衝區、自己切行:

buf += ser.read(max(1, ser.in_waiting))
*lines, buf = buf.split(b"\n")
for line in lines:
    ...

解析函式也加了保護:任何欄位解析失敗或數值超出 int8 範圍,整行丟掉並計數,不再讓程式當掉。

修正前 修正後
每秒筆數 約 54 100
漏收率 約 48% 0%
壞行 有,會讓程式當掉 0

四、路由器模式的限制:AP 在高處,人擋不到

接著做對照實驗:60 秒內前半段靜止、後半段走動。把每筆封包先除以自己的平均振幅(去掉自動增益的影響)、每 0.1 秒平均一次,再算每 2 秒的變化量:

時段 變化量
0~17 秒 約 0.058
18~34 秒 約 0.083(最高)
36~58 秒 約 0.069

走動時大約只高了 4 成,不夠當可靠的門檻。原因是家裡的 AP 架在高處,人走動時擋不到 AP 到板子之間的直線路徑,只能靠反射路徑的變化,訊號太弱;而且相鄰兩筆封包本身就差了約 20%,小變化容易被雜訊蓋過。

CSI 感測最靈敏的位置,是人橫穿發射端與接收端之間的連線(第一菲涅耳區)。AP 在天花板附近時,這條線大半在人頭上,所以改用雙機模式:兩片都放在約 1 公尺高,讓連線剛好橫過走道。

五、雙機模式:兩片 S3 互傳

第二片 S3 燒 csi_tx 當發射端,第一片改燒 csi_rx_espnow 當接收端。頻道從 11 改成 6:家裡 AP 在 ch11、鄰居分佈在 1~3 和 10,ch6 掃起來是空的,干擾比較少。接收端的 Serial 也一樣拉到 2,000,000 baud。

燒完第一次就收到:接收端自動學到發射端的 MAC,每秒約 91 筆、漏收約 8%(空中傳輸正常範圍),每筆 128 個子載波。發射端之後只要插 USB 供電就會自己開始廣播,不用接電腦。

六、對照實驗:走動時是靜止的 7 倍

隔天早上用雙機模式重做對照實驗:兩片都放在桌面高度,90 秒內「0~30 秒人離開不動、30~60 秒來回橫穿兩片之間、60~90 秒再離開不動」。

動作指標比路由器那次多做了兩步處理:

  1. 每筆封包先除以自己的平均振幅,去掉自動增益(AGC)造成的整體忽大忽小
  2. 每 10 筆(約 0.1 秒)平均一次壓掉單筆雜訊,再算每 3 秒內各子載波的標準差,取平均

圖說:上:動作指標(紅虛線是門檻 0.06);下:RSSI。橘色區是走動的 30~60 秒,每穿過一次就一個尖峰

時段 動作指標 RSSI
0~30 秒 靜止 平均 0.031 -36 dBm,幾乎不跳
30~60 秒 走動 平均 0.196、最高 0.31 每次穿過掉到 -46~-50 dBm
60~90 秒 靜止 平均 0.025 -36 dBm
  • 走動時的指標是靜止時的約 7 倍;路由器模式(AP 在高處)只差 4 成,差別非常明顯
  • 30 秒內大約穿過 7 次,圖上就有 7 個左右的尖峰,RSSI 也同步下探,可以當輔助指標
  • 門檻暫定 0.06:靜止時幾乎都在 0.035 以下;唯一一次 0.062 出現在第 27 秒,是人準備開始走、靠近兩片時,等於提前抓到

重點跟理論一致:CSI 對「擋住或貼近發射端與接收端之間的連線」最敏感。把兩片放在人體高度、連線橫過走道,效果比靠高處的 AP 好一個數量級。

七、即時監看網頁(GUI 設計)

實驗確認指標有效之後,下一步是讓它「隨時看得到」。我寫了一支 csi_monitor.py,在 Pi 4 上一邊收 Serial 一邊算指標,同時當網頁伺服器。用手機或電腦開 http://<Pi 4>:8090 就能看。下面是現場實拍:有人剛走進兩片之間,狀態變成紅色的「有人」,瀑布圖右邊也出現大片色塊。


圖說:桌面版:有人剛走進來,動作指標衝過紅虛線門檻,背景塗成淡紅,事件紀錄多一筆「進行中」

GUI 設計和程式的完整說明另外寫成一篇:ESP32 WiFi CSI 即時監看網頁:GUI 設計與程式。

畫面由上而下

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

技術選擇

  • 不裝任何新套件:Pi 4 上只有 numpy 和 pyserial,伺服器用 Python 內建的 ThreadingHTTPServer,圖表用瀏覽器的 Canvas 自己畫,不靠外部函式庫,離線也能用
  • Server-Sent Events(SSE)推資料:伺服器每 0.1 秒推一格(指標、RSSI、128 個子載波的差值),瀏覽器用 EventSource 接,斷線會自己重連;比 WebSocket 簡單,單向推送剛好夠用
  • 判斷放在伺服器端:「有人/沒人」與事件紀錄由 Pi 4 算,不管開幾個網頁、甚至沒人開網頁,結果都一致,也都會記錄
  • 新開的網頁先補資料:連上時先送最近 60 秒的歷史和事件,不用等一分鐘才畫滿
  • 重播模式:--replay 收錄檔 會照原本的時間間隔重播,沒有板子也能開發測試;下一節的兩張圖就是這樣產生的
  • 手機與深色模式:窄螢幕自動改成單欄,系統是深色模式時整頁跟著換色

圖說:手機版(深色模式):人剛離開,狀態回到「沒人」,事件紀錄留下剛才的 4 次走動

八、有人/沒人的兩張圖

先看監看頁在手機上的實際畫面。這兩張是同一分鐘內拍的:左邊人離開兩片之間,右邊人剛走進來。

圖說:左:沒人(動作指標 0.025、RSSI -35 dBm);右:有人,已持續 5 秒(RSSI 掉到 -38 dBm)。右邊這時人正要離開,指標剛從高點掉到門檻下,還在 2 秒的保持時間內,所以仍顯示「有人」

下面再用實驗收錄檔,把同樣的指標畫成完整的 30 秒資料圖,刻度都一樣,可以直接對照。

判斷規則很簡單:指標超過門檻 0.06 就是「有人」,掉回門檻下再等 2 秒才改回「沒人」,避免在門檻附近閃爍。

這兩張是把同一份實驗收錄檔丟進這支程式重播的結果。瀑布圖畫的是每 0.1 秒各子載波振幅和最近 3 秒平均的差:灰色代表跟平常一樣,紅藍代表變大或變小(中間兩條灰帶是沒有資料的空子載波)。

圖說:沒人(實驗第 60~90 秒):瀑布圖只有細碎雜訊,動作指標平均 0.026、最高 0.040,一直在門檻下,判定「沒人」

圖說:有人走動(實驗第 30~60 秒):每次穿過兩片之間,所有子載波一起變色、RSSI 下探,動作指標平均 0.196、最高 0.315,整段判定「有人」

沒人 有人走動
瀑布圖 接近灰色,只有細碎雜訊 大片紅藍色塊,每穿過一次換一次色
動作指標 平均 0.026、最高 0.040 平均 0.196、最高 0.315
RSSI -34~-39 dBm 反覆掉到 -50 dBm 上下
判定 整段「沒人」 整段「有人」,結束後約 2 秒改回「沒人」

沒人時最高只到 0.040,離門檻還有 1.5 倍的空間;有人時連最低點都在門檻附近以上,所以 0.06 這個門檻在這個擺位下切得很乾淨。重播 90 秒的結果,第 29~59 秒被判成一次「有人」事件,和實際走動時間吻合。

九、目前進度與下一步

  • ✅ 路由器模式真板實測:100 筆/秒、0 遺失
  • ✅ 找出並修好 Serial 頻寬與 pyserial 讀取太慢兩個問題
  • ✅ 雙機 ESP-NOW 模式收發成功:約 91 筆/秒
  • ✅ 雙機「靜止/走動/靜止」對照實驗:走動時指標約為靜止的 7 倍,門檻暫定 0.06
  • ✅ 即時監看網頁 csi_monitor.py:每 0.1 秒更新「有人/沒人」,事件寫成 CSV
  • ⏳ 長時間跑,看一整天的誤報率
  • 之後:相位清理(線性擬合去掉 CFO/SFO 偏移)、改用 UDP 串流、多台收發做粗略定位

這次最大的收穫其實不是 CSI 本身,而是排查方法:先用最單純的工具(cat 存原始資料)確認資料源是好的,就能立刻把問題範圍縮到主機端的讀取程式,而不是在韌體裡亂猜。

ESP32-S3 Stepper Balancing Robot: Dual-Core OTA and Step-by-Step Tuning | ESP32-S3 步進平衡車:雙核 OTA 架構與調適步驟

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

手邊有一台步進馬達平衡車(HJ-R3 主板:兩顆 A4988、MPU6050、2 顆 18650),原本接的是傳統 ESP32。這次把控制板換成 ESP32-S3 N16R8,改成 core 0 跑 WiFi/OTA/調參網頁、core 1 跑 200 Hz 平衡控制 的雙核架構。這篇把從燒錄到第一次站穩一分鐘的調適步驟、每一步的實測數據和踩到的坑整理下來。

一、架構:兩顆核心各做各的

核心 負責
Core 1 MPU6050 data-ready 中斷每 5 ms 觸發 → 互補濾波算傾角 → 角度環(200 Hz)+速度環(40 Hz)→ FastAccelStepper 用 MCPWM 硬體產生步進脈衝
Core 0 WiFi、ArduinoOTA、調參網頁(即時看角度、改 PID 不用重燒、存 NVS)

OTA 的安全設計:寫 Flash 時兩顆核心都會停,所以平衡中不收 OTA;OTA 一開始先關 A4988 的 ENABLE;新韌體開機後 IMU 正常跑 10 秒才確認,讀不到 IMU 就自動回滾到舊版。實測 OTA 寫入期間控制迴圈最長被卡住 426 ms,證明這個設計是必要的。

二、腳位:S3 有一半不能照搬

ESP32-S3 沒有 GPIO 22~25,N16R8 模組的 26~32 接 Flash、33~37 接 octal PSRAM,原本的腳位幾乎都要換。主板是用杜邦線接到開發板,所以只要改線、不用動主板。

訊號 原 ESP32 ESP32-S3
MPU6050 SDA / SCL / INT 22 / 21 / 18 8 / 9 / 10
左輪 STEP / DIR 32 / 33 4 / 5
右輪 STEP / DIR 14 / 12 6 / 7
A4988 ENABLE 25 15
電池電壓(新加) — 1(ADC1)

三、程式架構

韌體是 Arduino sketch(esp32 core 3.3.12+FastAccelStepper 2.0.1),約 700 行,依「誰在哪顆核心跑」切檔案:

檔案 跑在哪 內容
config.h — 腳位、MPU 安裝方向、迴圈頻率、PID 預設值(Params 結構)
shared.h 兩核共用 狀態列舉、遙測(Telemetry)、指令(Command)、spinlock
imu.cpp core 1 MPU6050 直接讀暫存器:±4g、±500°/s、DLPF 42 Hz、取樣 200 Hz、data-ready 中斷;讀不到時印診斷
control.cpp core 1 平衡控制任務、狀態機、步進輸出、OTA 健康檢查
net.cpp core 0 WiFi、mDNS、ArduinoOTA、調參網頁與 REST、參數存 NVS
balance_car_s3.ino core 1(loop) 開機順序、每秒量電池+低電量保護、序列埠輸出

任務與優先權

核心 任務(優先權) 做什麼
Core 0 WiFi 協定堆疊(系統) ESP32 的 WiFi 本來就跑在 core 0
net(2) WebServer(/status、/set…)、ArduinoOTA.handle()、斷線重連,每 5 ms 一輪
Core 1 control(configMAX_PRIORITIES − 2) MPU6050 INT 每 5 ms 喚醒一次,跑完整個控制迴圈
FastAccelStepper 引擎 管理速度斜坡,實際脈衝由 MCPWM/PCNT 硬體產生
Arduino loop(最低) 每秒量電池、低電量保護、序列埠輸出

兩顆核心之間只透過 g_params/g_cmd/g_tel 三個結構交換資料,用 portMUX spinlock 保護。

重點有三個:

  • 控制迴圈由硬體中斷驅動:MPU6050 每 5 ms 拉一次 INT,ISR 只做 vTaskNotifyGiveFromISR(),控制任務用 ulTaskNotifyTake() 等它。這樣取樣和控制完全同步,不靠 delay()。中斷 10 ms 沒來會退回輪詢並記一筆 imuErr。
  • 步進脈衝不佔 CPU:FastAccelStepper 用 S3 的 MCPWM+PCNT 硬體產生脈衝,控制任務每 5 ms 只更新一次目標速度,WiFi 中斷再忙也不會讓步進抖。
  • 兩核之間只交換三個小結構,進出都用 portENTER_CRITICAL(&g_mux) 整份複製,臨界區只有幾十個 byte 的 memcpy:
Params    g_params;  // net 寫(/set、NVS),control 讀:kp kd ksp ksi trim alpha vmax amax
Command   g_cmd;     // net 寫(/cmd、/test),control 讀:speed turn enabled testL testR
Telemetry g_tel;     // control 寫,net/loop 讀:state angle gyro speed acc[3] loops overruns …
volatile bool g_otaRequested;  // OTA 開始時 net 設 true,control 看到就關馬達

控制迴圈(每 5 ms)

等 INT → 複製 Params/Command → 讀 MPU6050
→ 互補濾波:angle = α·(angle + gyro·dt) + (1−α)·atan2(accY, accZ),α = 0.98
→ 狀態機決定要不要出力
→ 速度環(每 5 次=40 Hz):targetAngle = −(ksp·(v − v設定) + ksi·∫)
→ 角度環(200 Hz):acc = kp·(angle − trim − targetAngle) + kd·gyro
                    speed += acc·dt(限制在 ±vmax,acc 限制在 ±amax)
→ 左輪 = speed + turn、右輪 = speed − turn → FastAccelStepper
→ 寫回遙測(含這圈花了幾 µs、有沒有超過 5 ms)

狀態機

狀態 進入條件 馬達
idle 開機、網頁按「停用馬達」、馬達測試結束 斷電(EN 高)
running 允許平衡,且扶正到 trim ±5° 內維持 0.5 秒 平衡控制
fallen 傾角超過 ±45° 立刻 forceStop+斷電;再扶正會自動回 running
test /test 指令,非平衡中才准 照指定速度轉到時間到
ota OTA 開始(g_otaRequested) 斷電,直到重開機
fault MPU6050 或步進引擎初始化失敗 斷電,網路照開方便看狀態、重新 OTA

網頁與 API(core 0)

路徑 用途
/ 調參頁:每 200 ms 更新狀態、改參數、允許/停用平衡、馬達測試按鈕
/status JSON:state、angle、gyro、target、speed、batt、battLow、acc[3]、gyro3[3]、loops、imuErr、overruns、maxLoopUs、rssi、reset(重開原因)、uptime、params
/set?kp=..&kd=.. 即時改參數(不寫 Flash,平衡中也能改)
/save 參數存 NVS;寫 Flash 會讓兩核暫停,所以平衡中回 409 拒絕
/cmd?en=1&speed=&turn= 允許平衡、前進速度、轉向差速
/test?l=400&r=0&ms=1000 馬達測試(最多 3 秒、±1600 steps/s)

OTA 流程

espota 送韌體
 → net:狀態是 running 就不呼叫 ArduinoOTA.handle()(上傳逾時,等於拒絕)
 → onStart:g_otaRequested = true,等 control 關馬達、進 ota 狀態(最多 0.5 秒)
 → 寫入另一個 app 分割區(app3M_fat9M_16MB:兩個 3 MB app 分割區)→ 重開機
 → 新韌體狀態是 PENDING_VERIFY(verifyRollbackLater() 回 true)
 → control:IMU 和中斷連續正常 10 秒 → esp_ota_mark_app_valid_cancel_rollback()
            IMU 讀不到              → esp_ota_mark_app_invalid_rollback_and_reboot()

參數調整的流程因此變成:網頁 /set 即時改 → 站穩後停用馬達 → /save 存 NVS;只有改程式才需要 OTA。

四、調適步驟

步驟 1:先確認 MPU6050 讀得到

第一次燒進去序列埠顯示「MPU6050 讀不到」。韌體裡加了診斷:讀 SDA/SCL 閒置電位、把其他腳開下拉找「被外部拉高的腳」、正反兩種接法各掃一次 I2C。結果兩條線都是 0、沒有任何腳被拉高——代表 MPU6050 模組根本沒電。原因是主板整個沒上電(S3 只吃 USB 的電)。上電後迴圈剛好每秒 200 次、單次最長 2.7 ms。

步驟 2:馬達測試(輪子架空)

網頁加了「左輪前/後、右輪前/後、兩輪前」按鈕,每次 400 steps/s 轉 1 秒。順便確認微步:1 秒轉 1/8 圈 → 400 ÷ (200 × 16) = 1/8,主板跳線是 16 微步。

步驟 3:確認傾角的軸和正負號

一開始前後傾只看到幾度的變化,懷疑軸設錯。在 /status 加上三軸原始加速度後,請人扶著車子前傾、後傾,錄到:

動作 acc X acc Y acc Z
直立 -0.03 +0.02 1.01
前傾 -0.01 -0.73~-0.89 0.48~0.69
後傾 -0.02 +0.85 0.53

前後傾是 Y 軸在變(原本讀的 X 其實是左右傾)。改成:傾角=atan2(acc Y, acc Z)、陀螺儀用繞 X 軸,兩者都乘 -1,讓前傾為正。

步驟 4:第一次平衡——控制方式要對步進馬達

Pi 4 上跑一支小程式每秒抓 20 次 /status,把每次測試錄下來。下圖是四個階段的傾角曲線:

① 角度 PD 直接給輪速:最長站了 38 秒,但每次都會慢慢偏向一邊,輪速一路加到上限 4000 steps/s 追不回來而倒下。原因是步進馬達本身是「照指定速度轉」,要把傾斜 θ 的車身撐回來需要約 g·tanθ 的加速度;只給速度,輪子只是跟著傾角跑。

改法:角度環輸出加速度,再積分成輪速:

acc = kp × (angle − trim − targetAngle) + kd × gyroRate   // steps/s²
speed += acc × dt                                          // steps/s

步驟 5:kd 調到阻尼夠

② kd=80:前後擺動約 1 秒一次、越擺越大,輪速在 ±4000 之間甩,矯枉過正倒向另一邊。kd/kp 只有 0.03 秒,阻尼太小。③ kd=250(kd/kp ≈ 0.1 秒):第一次站超過一分鐘。

步驟 6:一直往後跑 → trim + 速度環

kd=250 時平均傾角是 -3.9°:重心偏後,要往後傾才站得住,車子就一直往後開。處理方式:

  • 速度環 PI:車速越快,目標傾角越往反方向(ksp=0.002 → 1000 steps/s 往回傾 2°;ksi=0.0001 消除長時間偏移)
  • 開了 I 項之後實際平衡點穩定在約 -1.7°,trim 設 -1.8°

步驟 7:站 30 秒後「突然沒力」→ 失步

倒下那一刻的數據很清楚:程式在 0.15 秒內把輪速加到 4000,車身卻以 428°/s 自由倒下——指令有送出去,輪子沒跟上,是步進馬達失步。步進馬達轉越快扭力越小,電池只有 7~8V 更明顯。把輪速和加速度上限改成可調參數,降到 vmax=3000 steps/s、amax=30000 steps/s² 之後,④ 可以站到 68 秒。

步驟 8:加電池電壓監測

電池+(開關之後)─ 8.3kΩ ─ GPIO1 ─ 1.5kΩ ─ GND,滿電 8.4V 時 GPIO1 只有約 1.3V。和三用電表對照:電表 7.48V、韌體讀 6.90V,乘上 1.084 校正後讀 7.41~7.45V。一開始設低於 6.8V 自動停止平衡,後來發現平衡中馬達抽電會把電壓拉低、誤停車,現在改成只偵測、不切馬達(低於 6.0V 只在網頁標 battLow 警告)。

⚠️ 8.3k 一定接電池端;接反 GPIO1 會吃到約 7V,S3 直接燒掉。

量出來才發現電池只有 7.48V(每顆 3.74V,約半電),已經低於 A4988 馬達電源規格的 8V——這也是失步的主因之一。

步驟 9:充飽電後反而大幅前後擺 → 速度環減弱

電池充到 8.09V 再測,3 次最久只站 29.6 秒,前後擺動變成每秒約 0.45 次的大慢擺,速度環把目標傾角一路推到 ±10° 上限。把速度環 ksp 0.002→0.0012、ksi 0.0001→0.00004,同時 vmax 3000→3500、amax 30000→45000,擺動回到每秒約 1 次,角度標準差降到 3.6°。

回頭把 bal4~bal7 全部 16 次倒下對一遍,模式完全一樣:車身晃到 7~15° → 輪速頂到 vmax → 0.2~1.5 秒內倒下。「突然沒力」其實多半是輪子已經全速、沒有餘裕再追。

步驟 10:角度環加硬 → 站 59 秒、晃 ±2°

kp 2500→3000、kd 250→300、vmax 4000,這次站了 59 秒、角度標準差 2.0°(目前最好),而且不是倒下,是被當時 6.5V 的低電壓保護停掉——平衡中電壓被拉低。參數已經存進 NVS。

再往下測,倒下後 /status 的 reset 變成 brownout:電池出力時電壓垮到 ESP32 都撐不住而重開,接著程式叫輪子全速、車子卻不動。閒置時讀 7.7V 看起來還好,負載下已經沒力,電池是現在的瓶頸。

五、實測影片與調參網頁

kp 3000/kd 300 這組參數的實測:站穩後用手推一下,車子會往前追、擺幾下再回到原地。

調參網頁在 core 0 跑,手機連同一個 WiFi 打開 http://車子IP/ 就能看。上半部每 200 ms 更新一次 /status:

  • state/reset/uptime:目前狀態、上次重開原因(poweron、sw、brownout…)、開機秒數
  • angle/gyro/target/speed:傾角、傾倒速度、速度環要的目標傾角、輪速
  • batt/battLow:電池電壓和低電量警告
  • loops/imuErr/overruns/maxLoopUs:控制迴圈跑了幾次、IMU 讀取錯誤、逾時次數、單次最長耗時(200 Hz 的預算是 5000 µs,實測約 2300 µs)
  • params:目前生效的參數

下半部可以直接改參數按「套用」即時生效,「存進 NVS」寫進 Flash(平衡中會被拒絕,避免寫 Flash 時兩顆核心停住讓車倒),還有停用馬達、允許平衡和單輪馬達測試按鈕。

圖說:手機上的調參網頁

六、目前的參數

參數 值 意思
kp 3000 每 1° 傾角給多少加速度(steps/s²)
kd 300 每 1°/s 傾倒速度給多少加速度
ksp / ksi 0.0012 / 0.00004 速度環 PI,輸出目標傾角
trim -1.8° 機械平衡點
vmax / amax 4000 / 45000 輪速、加速度上限(16 微步:1.25 轉/s)

七、踩到的坑

  • OTA 回滾沒作用:Arduino core 的 verifyRollbackLater() 是 C 函式,在 .ino 覆寫要寫 extern "C",不然新韌體一開機就被標成有效,永遠不會回滾。
  • S3 開發板接 CH343 時,FQBN 不要加 CDCOnBoot=cdc,Serial 才會走 UART0 出到 CH343。
  • 開機預設不平衡:要在網頁按「允許平衡」才會動馬達,避免一接上車子剛好直立就自己衝出去。
  • 倒下後 S3 會重開機:reset reason 是 poweron 時懷疑電池座接點彈開,是 brownout 時就是電池出力時電壓垮掉。

八、下一步

  • 換能放大電流的動力型 18650(或確認電池內阻),避免出力時 brownout
  • 考慮 3S 電池讓 A4988 吃到 11V 以上(要先確認主板降壓電路吃得了 12.6V),或調高 A4988 電流
  • 站穩後再加前進/轉向遙控