本記事は、MIXI DEVELOPERS Advent Calendar 2025の15日目の記事となっています。
株式会社MIXIでは、Romi事業部と呼ばれる部署で、ロボットの組み込みソフトを書いてみたり、ロボットが扱うサーバーのあれこれを触ってみたりしています。
本記事は、休みの日にふと、「ESP32上でRustを使ってWiFi CSIの収集ができるようになりたいな〜」と思ったので取り組んでみましたという話です。
WiFi CSI とは
WiFi Channel State Informationの略であり、日本語に訳すと「WiFiチャネル状態情報」というものを指す言葉。WiFiの電波がある地点へ到達するまでの振幅や位相の変化を活用することで、人物検知や姿勢検知などをカメラ情報なしに実現できるような技術である。
上記のような特徴から、WiFiモジュールという汎用的な機器を用いてセンサリングができることや、プライバシーに配慮した形でセンサリングを実現できることなどが魅力的な点として挙げられる。
ESP32S3の購入
開発に取り掛かる前に、まずは秋葉原にある秋月へ買い物に行った。今回は「Seeed Studio XIAO ESP32S3」を購入し、使用することにした。
理由は特になく、後述のライブラリがサポートしている点と、2年前に販売されたものなためある程度の安定性を担保しつつも比較的新しく、性能も問題なさそうな印象を受けたのでESP32S3にした。
環境構築
自身のPC (MacOS) に既にRust関連の開発ツールは入っていたが、コンパイラのバージョンが古かったため、コンパイラのアップデートから取り掛かった。ちなみに、rls-previewが1.92.0では配信されなくなったため、ローカルからこれを削除し、rustup updateを実行してアップデート完了。
加えて、espupをインストールしていく。espupはEspressif SoCの開発元であるEspressif Systemsによって提供されているツールである (後述のesp-generateやesp-rs関連crateも同様)。espupはRustによるESP用プログラムの開発におけるツールの管理をするためのツール。Rust + ESPはこれがないと始まらないので cargo install espup --locked でインストールし、さらにespup installを実行する。
$HOME下にexport-esp.shが自動的に作成されて、.bashrcなどに内容をコピーすることが求められるため、忘れずに実行しておく (そして、その場でもexport-esp.shを実行しておく)。
また、今回扱うesp-csi-rsでは、esp-generateを用いたプロジェクトの初期化がREADMEに書かれているため、下記のようにesp-generateをインストールし、プロジェクトのセットアップを実行しておく。
cargo install esp-generate --locked
esp-generate --chip=esp32s3 esp-csi-collector
ESP CSI in Rust
ということで、今回はesp-csi-rsを使用させていただく。こちらのcrateは、no_stdなRustを用いてESP32上でWiFi CSIを収集するために必要なモジュールを提供してくださっている。
esp-csi-rsは複数のCSI収集方法を提供しているが、今回は一番シンプルなSnifferタイプを使用する。他のタイプでは、WiFiルーターや他のESP32との通信が大前提であるが、Snifferタイプでは周囲のネットワーク上のパケットを活用してCSIを収集するため、用意するESP32は1台だけで良く、その他細かい設定などは特に必要ない。
ただ、esp-csi-rsをすんなりと使いこなせたかと言われると決してそうではなく、特に依存関係の解決に多くの時間を費やした。基本的には、公式レポジトリのCargo.tomlを参考にしながら、バージョン指定に問題がありそうなcrateを見つけて1つずつ修正していくことで対応した。
実際のコード
最終的に書いたコードは以下の通りである。
#![no_std]で標準ライブラリ(OS 前提の部分)を使わない、ベアメタル向けバイナリであることを示し、#![no_main]で通常のfn main()を使わず、HAL / Embassy 側が定義するエントリポイントマクロを使うことを示している。また、no_stdでもallocクレートを有効化してヒープ確保を使うことをextern crate allocで明示している。
#![no_std]
#![no_main]
extern crate alloc;
各crateのざっくりとした説明は以下の通り。
-
embassy_executor,embassy_time- async実行環境+タイマー
-
esp_hal- ESP32S3のHAL
- クロック設定,
TimerGroup,RNGなどを今回は使用している
-
esp_println- USB-Serial/JTAGやUARTに
println!するための簡易ログ
- USB-Serial/JTAGやUARTに
-
esp_wifi-
esp-idfなしでWiFiドライバを動かすためのcrate
-
-
esp_csi_rs- CSIを取得するためのcrate
-
esp_bootloader_esp_idf::esp_app_desc!()- ESP-IDF互換のアプリメタデータ(バージョン等)をバイナリに埋め込むマクロ
- ブートローダが参照できる
グローバルに一度だけ初期化されるstaticなストレージを用意するマクロを定義している。後ほど使用する。
macro_rules! mk_static {
($t:ty, $val:expr) => {{
static STATIC: static_cell::StaticCell<$t> = static_cell::StaticCell::new();
STATIC.uninit().write($val)
}};
}
クロックなどSoCの基本的な設定を実行する。
let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
let peripherals = esp_hal::init(config);
esp_alloc::heap_allocator!(size: 128 * 1024);
組み込みRust向けの非同期ランタイムとそれに対応したHAL群を扱うフレームワークであるEmbassyに、時間を知るためのハードウェアタイマを紐づける処理を行う。
let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
let peripherals = esp_hal::init(config);
esp_alloc::heap_allocator!(size: 128 * 1024);
こちらでもハードウェアタイマの設定を行い、WiFiを初期化する。
let tg0 = TimerGroup::new(peripherals.TIMG0);
let timer0 = tg0.timer0;
let rng = Rng::new(peripherals.RNG);
// esp-wifi の初期化
let init = wifi_init(timer0, rng).unwrap();
initnのライフタイムがローカルスコープなため、WiFiドライバ側で要求されている'static制約を満たせない。そこで、先ほど作成したマクロを使用して、initをstatic領域にムーブして、&'static mut EspWifiController<'static>を生成する。
let init_ref: &'static mut EspWifiController<'static> =
mk_static!(EspWifiController<'static>, init);
let wifi = peripherals.WIFI;
// WiFi controller + interfaces を取得
let (wifi_ctrl, interfaces): (WifiController<'static>, Interfaces<'static>) =
esp_wifi::wifi::new(init_ref, wifi).unwrap();
CSI Snifferの構築と初期化を行う。
let mut sniffer = CSISniffer::new(CSIConfig::default(), wifi_ctrl).await;
sniffer
.init(interfaces, &spawner)
.await
.expect("CSISniffer init failed");
Snifferの開始処理を行う。
sniffer.start_collection().await;
println!("CSI Sniffer started. Printing CSI to USB serial...");
開始後は無限ループにて定期的に収集したデータをシリアルに出力し続ける。
loop {
sniffer.print_csi_w_metadata().await;
Timer::after(Duration::from_millis(1)).await;
}
Type-Cケーブルで接続したESP32S3へ上記プログラムをcargo runでビルドして焼くことができる。
cargo run実行後、シリアルには下記のようなデータが出力され続ける。
mac: <MAC address>
rssi: -80
rate: 11
noise floor: 159
channel: 1
timestamp: 138097085
sig len: 275
rx state: 0
secondary channel: 0
sgi: 0
ant: 0
ampdu cnt: 0
sig_mode: 0
mcs: 0
cwb: 0
smoothing: 0
not sounding: 0
aggregation: 0
stbc: 0
fec coding: 0
sig_len: 275
data length: 128
csi raw data:
[0, 0, 4, 3, 4, 4, 5, 4, 7, 6, 7, 8, 7, 9, 8, 9, 8, 9, 9, 10, 9, 10, 8, 10, 9, 9, 10, 10, 10, 10, 10, 10, 9, 10, 9, 10, 7, 10, 7, 10, 7, 11, 6, 10, 6, 10, 6, 10, 5, 9, 4, 8, 3, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 6, -8, 5, -10, 6, -11, 7, -11, 7, -14, 7, -16, 7, -17, 7, -19, 7, -19, 9, -19, 9, -18, 8, -18, 8, -18, 7, -19, 5, -17, 4, -16, 3, -15, 2, -14, 1, -12, 1, -11, 1, -10, 1, -9, 2, -7, 3, -5, 3, -3, 3, -2]%
csi raw dataに記載されている配列は生のCSIバイト列であり、恐らくはチャンネル1で拾った非HT(11b/g)パケットの LLTF部分のCSIを64個のサブキャリア分だけ8bitの複素数で並べたものかと思われる。
最後に
今回自身が用意したレポジトリの構造を見てもらえればわかるように、本当はESP32で収集したWiFi CSIを受け取って、人の検知など行うつもりだったのだが (ローカルでのベクトル検索と組み合わせないか試していた) 、思いの外綺麗に成果が出なかったため、本記事ではESP32でno_stdなRustを用いてWiFi CSIを収集するというところまでに留まった内容となっている。
WiFiモジュールという汎用的なモジュールを使ってプライバシーに配慮したセンサリング機能が実現できることは魅力的なので、また面白い使い方を模索したい。
