2026年11月2日 星期一

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 存原始資料)確認資料源是好的,就能立刻把問題範圍縮到主機端的讀取程式,而不是在韌體裡亂猜。

沒有留言:

張貼留言