2026年11月2日 星期一

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

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 架構與調適步驟

手邊有一台步進馬達平衡車(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 電流
  • 站穩後再加前進/轉向遙控

2026年10月27日 星期二

追一個封包 : ESP32 經 Pi 5 NAT 走 Tailscale 連到 MQTT broker 的完整路徑

📺 影片版(約 9 分鐘):想先看實際操作的話可以直接看這支影片,完整步驟、設定與程式碼在下方文字版。
YouTube:https://youtu.be/7rlWNReOv-8(winson-DIY 頻道)

上一篇〈Pi 5 雙 WiFi × ESP32〉裡,ESP32 連的是 Pi 5 開的熱點(10.42.0.x),MQTT broker 卻在另一台主機 gx10 上,而且 broker 位址填的是 gx10 的 Tailscale IP。ESP32 根本沒裝 Tailscale,這條路是怎麼通的?

這篇把那一行「經 Pi NAT → Tailscale」拆開,用 Pi 5 上實際抓下來的路由表、防火牆規則,一步一步追一個 MQTT 封包從 ESP32 走到 gx10 再走回來的完整路徑。順便解釋當初那個怪現象:為什麼 MQTT 通、但 ESP32 上不了網。

一、角色與位址

角色 介面 位址
ESP32 WiFi(連 Pi 熱點) 10.42.0.170,閘道 10.42.0.1
Pi 5 wlan1(USB 網卡,熱點) 10.42.0.1/24
wlan0(內建,公司 WiFi) 192.168.1.236
tailscale0 100.108.160.111
gx10 LAN 192.168.1.25
tailscale0 100.69.83.97(mosquitto :1883 在 Docker 裡)

(100.x 是 Tailscale 的 CGNAT 位址,只有同一個 tailnet 的裝置才連得到,公開出來沒有風險。)

二、全貌

ESP32 10.42.0.170:5xxxx ──WiFi──▶ Pi5 wlan1 (10.42.0.1)
   目的 100.69.83.97:1883              │
                                       │ ① 路由:查 table 52 → 走 tailscale0
                                       │ ② 轉發過濾:ts-forward 放行
                                       │ ③ 來源 NAT:10.42.0.170 → 100.108.160.111
                                       ▼
                              tailscale0 → tailscaled(WireGuard 加密)
                                       │ 包成 UDP,從 wlan0 送出(同區網直連)
                                       ▼
                     gx10 tailscale0 → docker-proxy → mosquitto 容器

三、第 0 步:ESP32 把封包丟給閘道

ESP32 從熱點的 DHCP(NetworkManager 內建的 dnsmasq)拿到 10.42.0.170,預設閘道 10.42.0.1。它要連 100.69.83.97:1883,這不在自己網段,於是交給閘道 Pi 5。

重點:ESP32 完全不知道 Tailscale 存在,對它來說 100.69.83.97 就只是一個「外面的 IP」。

四、第 1 步:Pi 5 決定往哪送(路由)

NetworkManager 的 ipv4.method shared 會打開 ip_forward,Pi 5 開始幫別人轉送封包。接著查 policy routing 規則:

$ ip rule
0:      from all lookup local
5210:   from all fwmark 0x80000/0xff0000 lookup main
5230:   from all fwmark 0x80000/0xff0000 lookup default
5250:   from all fwmark 0x80000/0xff0000 unreachable
5270:   from all lookup 52        ← Tailscale 的路由表,優先查
32766:  from all lookup main      ← 一般路由表(default 走 wlan0)
32767:  from all lookup default
$ ip route show table 52 | grep 100.69.83.97 100.69.83.97 dev tailscale0

Tailscale 會把 tailnet 裡每一台節點都寫進 table 52,並用優先序 5270 的規則讓它比 main 表先被查到。(5210–5250 那幾條帶 fwmark 0x80000 的,是給 tailscaled 自己送出去的 WireGuard UDP 用的,避免它被繞回 tailscale0 形成迴圈。)

用 ip route get 模擬「從 wlan1 進來、來源是 ESP32」的封包,差異一目了然:

$ ip route get 100.69.83.97 from 10.42.0.170 iif wlan1
100.69.83.97 from 10.42.0.170 dev tailscale0 table 52     ← 走 Tailscale

$ ip route get 1.1.1.1 from 10.42.0.170 iif wlan1
1.1.1.1 from 10.42.0.170 via 192.168.1.1 dev wlan0       ← 一般上網走 wlan0

目的地是 tailnet 節點 → tailscale0;其他 → wlan0。這一步是自動的,不需要任何設定。

五、第 2 步:轉發過濾(兩張表都要放行)

這台 Pi 上同時有兩套防火牆在看 FORWARD:NetworkManager 自己建的 nftables 表,以及 Docker/Tailscale 透過 iptables-nft 管的 filter 表。封包必須通過每一個 forward hook,任何一個 DROP 就沒了。

NetworkManager 的表(shared 模式自動建立):

table ip nm-shared-wlan1 {
    chain filter_forward {
        type filter hook forward priority filter; policy accept;
        ip daddr 10.42.0.0/24 oifname "wlan1" ct state { established, related } accept
        ip saddr 10.42.0.0/24 iifname "wlan1" accept      ← ESP32 出去的封包:✅
        iifname "wlan1" oifname "wlan1" accept
    }
}

Docker + Tailscale 的 iptables FORWARD:

$ sudo iptables -S FORWARD
-P FORWARD DROP                   ← Docker 把預設政策改成 DROP
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER-ISOLATION-STAGE-1
...(docker0 相關)
-A FORWARD -j ts-forward

$ sudo iptables -S ts-forward
-A ts-forward -i tailscale0 -j MARK --set-xmark 0x40000/0xff0000
-A ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT
-A ts-forward -s 100.64.0.0/10 -o tailscale0 -j DROP
-A ts-forward -o tailscale0 -j ACCEPT    ← 往 tailscale0 的封包:✅

ESP32 的封包要出 tailscale0,在 ts-forward 最後一條被 ACCEPT。前一條 -s 100.64.0.0/10 -o tailscale0 -j DROP 是防偽造 Tailscale 來源用的,我們的來源是 10.42.x,不受影響。

解謎:為什麼「MQTT 通、上網不通」

目的地 出口 NM 表 iptables FORWARD 結果
gx10 的 100.x(MQTT) tailscale0 ✅ ✅ ts-forward 放行 通
NTP / 外網 wlan0 ✅ ❌ 沒有規則認領,掉進 -P DROP 不通

MQTT 是「剛好」被 Tailscale 的規則救了。解法是在 Docker 保留給使用者的 DOCKER-USER 鏈補上 wlan1 ↔ wlan0 的放行(細節見上一篇第六節)。

六、第 3 步:來源 NAT(masquerade)——整條路的關鍵

封包要離開 Pi 5 之前,經過 NAT 的 postrouting:

table ip nm-shared-wlan1 {
    chain nat_postrouting {
        type nat hook postrouting priority srcnat; policy accept;
        ip saddr 10.42.0.0/24 ip daddr != 10.42.0.0/24 masquerade
    }
}

這條規則不管出口是哪張網卡,只要來源是熱點網段、目的不是熱點網段就 masquerade。出口是 tailscale0,所以來源被改成 tailscale0 的位址:

改寫前:10.42.0.170:5xxxx    → 100.69.83.97:1883
改寫後:100.108.160.111:yyyy → 100.69.83.97:1883

為什麼一定要這步?

  • gx10 不知道 10.42.0.0/24 在哪。來源若保持 10.42.0.170,gx10 回封包時查不到路由。
  • Tailscale 本身也會擋:WireGuard 的 AllowedIPs 機制下,gx10 只接受「來自 Pi 5 這個 peer、來源位址是 Pi 5 自己 100.x(或它有公告的子網段)」的封包。10.42.0.170 兩者都不是,會直接被丟掉。

改成 100.108.160.111 之後,在 gx10 眼中這就是 Pi 5 自己發起的連線,一切合法。Linux 的 conntrack 記下對應 100.108.160.111:yyyy ⇄ 10.42.0.170:5xxxx,回程時用得到。

附帶一提,Tailscale 自己也有一條 masquerade(ts-postrouting),但它只處理帶 0x40000 標記、也就是「從 tailscale0 進來再轉出去」的封包,跟我們這個方向無關;真正做事的是 NetworkManager 那條。

七、第 4 步:WireGuard 加密,從 wlan0 出門

tailscale0 是 TUN 虛擬網卡,封包寫進去後由 tailscaled 用 WireGuard 加密,包成 UDP(預設 41641 埠),再從實體網卡送出。這個 UDP 封包本身帶著 fwmark 0x80000,所以會命中前面 5210 那條規則、查 main 表從 wlan0 出去,而不是又繞回 tailscale0。

因為 Pi 5 和 gx10 剛好在同一個公司區網,Tailscale 會打通區網直連(192.168.1.236 → 192.168.1.25:41641),不經過外部 DERP 中繼,延遲只有幾 ms。確認方式:

$ tailscale ping gx10-6679
pong from gx10-6679 (100.69.83.97) via 192.168.1.25:41641 in 5ms   ← "via 區網IP" 就是直連

八、第 5 步:gx10 收件

gx10 的 tailscaled 解密後,從 tailscale0 吐出 100.108.160.111 → 100.69.83.97:1883。1883 是 Docker 發佈的埠(0.0.0.0:1883->1883/tcp),經 docker-proxy 轉進 mosquitto 容器。所以在容器裡看,連線來源是 Docker 閘道 172.17.0.1:

$ docker exec mosquitto netstat -tn | grep 1883
tcp   0   0 172.17.0.2:1883   172.17.0.1:60584   ESTABLISHED

經過兩層轉換(Pi 的 masquerade + docker-proxy),broker 已經看不出這是 ESP32 了;要辨識裝置,得靠 MQTT 的 client ID(esp32-48cfe4)。

九、回程

  1. mosquitto 回覆 → docker-proxy → gx10 tailscale0,目的 100.108.160.111。
  2. WireGuard 加密,UDP 直連回 Pi 5 的 wlan0,tailscaled 解密後從 Pi 的 tailscale0 吐出。
  3. conntrack 查到對應,把目的地還原成 10.42.0.170:5xxxx。注意 NAT 規則只在連線第一個封包時評估,回程不會再跑一次 masquerade。
  4. 路由:10.42.0.0/24 是 wlan1 直連網段 → 從 wlan1 送出。
  5. 轉發過濾:NM 表的 ct state established 放行;iptables 那邊,ts-forward 對「從 tailscale0 進來」的封包打上 0x40000 標記後 ACCEPT。
  6. ESP32 收到 MQTT 回應。

十、這個設計帶來的三個特性

1. 單向:ESP32 能找 gx10,gx10 找不到 ESP32

gx10(以及 tailnet 裡任何機器)的路由表裡沒有 10.42.0.0/24,無法主動連 ESP32。所以 ESP32 的 HTTP API(:8080)、WebSocket(:81)、OTA(:3333)都只能在 Pi 5 上操作。MQTT 剛好是「裝置主動連 broker」的模型,天生適合這種單向架構——指令也是 ESP32 自己去 broker 訂閱拿回來的。

2. 熱點裡的裝置等於能碰到整個 tailnet

table 52 裡的每個 100.x 都會被轉進 tailscale0,而且來源被改成 Pi 5 的身分。換句話說,任何連上這個熱點的裝置,都能以 Pi 5 的名義連到你所有 Tailscale 機器。目前熱點只有自己的 ESP32、密碼是隨機 16 字元的 WPA2,風險可控;但如果要給不信任的裝置用,應該在 forward 加規則,限制熱點只能連特定的 IP:port(例如只放行 gx10:1883)。

3. 想要雙向:用 Subnet Router

如果希望 gx10 或筆電能直接打 ESP32 的 HTTP API、甚至用 Arduino IDE 直接 OTA,正規做法是讓 Pi 5 把熱點網段公告進 tailnet:

# Pi 5 上
sudo tailscale set --advertise-routes=10.42.0.0/24
# 然後到 Tailscale 管理後台核准這條路由;
# Linux 用戶端要 tailscale set --accept-routes,Windows/macOS 預設接受

這樣其他 tailnet 節點會在自己的 table 52 裡多一條 10.42.0.0/24 → Pi 5,連線就能雙向。比起在筆電手動加靜態路由、把熱點開放給整個公司區網,這個做法只對自己的 Tailscale 裝置開放,安全很多。

十一、小結

  • 路由:Tailscale 的 table 52(優先序 5270)讓 100.x 目的地自動走 tailscale0,不用設定。
  • 過濾:同時有 NM 的 nftables 表和 Docker/Tailscale 的 iptables 表,兩邊都要放行;Docker 的 FORWARD DROP 只會讓走 wlan0 的流量出事。
  • NAT:NM shared 的 masquerade 不挑出口,把 10.42.x 換成 Pi 5 的 100.x 身分,這是 Tailscale 願意收這個封包的前提。
  • 傳輸:WireGuard over UDP,同區網會直連,延遲幾 ms。
  • 代價:單向,而且熱點裝置繼承了 Pi 5 在 tailnet 裡的存取範圍。

排查類似問題時,最好用的三個指令:ip route get <目的> from <來源> iif <入口> 看路由、iptables -S + nft list ruleset 看過濾和 NAT、tailscale ping 看是不是直連。