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 秒再離開不動」。
動作指標比路由器那次多做了兩步處理:
- 每筆封包先除以自己的平均振幅,去掉自動增益(AGC)造成的整體忽大忽小
- 每 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 存原始資料)確認資料源是好的,就能立刻把問題範圍縮到主機端的讀取程式,而不是在韌體裡亂猜。








沒有留言:
張貼留言