End-to-end embedded ML pipeline on ESP32-WROOM-32U + NRF24L01.
The ESP32 receives packets via NRF24L01, extracts real-time wireless link features, and runs a lightweight decision tree to classify the link into one of three states:
| State | Meaning |
|---|---|
| NORMAL | Stable, low-loss transmission |
| WEAK | High packet loss or long delays |
| INTERFERENCE | Bursty / irregular arrival pattern |
This project was built using AEL (AI Embedded Lab) with Claude Code driving the ESP-IDF build/flash/verify loop. See DEVELOPMENT.md for a walkthrough of how the AI iterated on the firmware.
Option A: Python simulation (no ESP32, no NRF24)
pip install numpy scikit-learn
python3 tools/simulate.pyTrains a fresh model on data/synthetic_link_data.csv, simulates all three link
conditions, and shows a live ANSI dashboard in your terminal.
Option B: Firmware simulation (ESP32 only, no NRF24 transmitter)
cd firmware
. $IDF_PATH/export.sh
idf.py -DCONFIG_NRF24_SIM_MODE=y -p /dev/cu.usbserial-110 build flash monitorThe ESP32 generates synthetic packets internally — cycles NORMAL → WEAK → INTERFERENCE every 20 s. No second device required.
| Mode | Hardware needed | How to run |
|---|---|---|
| Python simulation | None | python3 tools/simulate.py |
| Firmware simulation | ESP32 only | idf.py -DCONFIG_NRF24_SIM_MODE=y build flash monitor |
| Real hardware | ESP32 + NRF24L01 + transmitter | idf.py build flash monitor |
| Component | Notes |
|---|---|
| ESP32-WROOM-32U | Any ESP32-WROOM variant works |
| NRF24L01 | 2.4 GHz transceiver — 3.3 V only |
| SSD1306 OLED | 128×64 I2C display (optional) |
| Second NRF24L01 | On any Arduino/ESP32 as the transmitter |
NRF24L01 → ESP32
| NRF24L01 | ESP32 GPIO |
|---|---|
| VCC | 3.3 V |
| GND | GND |
| CE | 4 |
| CSN | 5 |
| SCK | 18 |
| MOSI | 23 |
| MISO | 19 |
| IRQ | NC |
SSD1306 OLED → ESP32 (optional)
| OLED | ESP32 GPIO |
|---|---|
| VCC | 3.3 V |
| GND | GND |
| SDA | 21 |
| SCL | 22 |
nrf24-link-monitor/
├── firmware/ ESP-IDF project (ESP32 target)
│ ├── CMakeLists.txt
│ ├── sdkconfig.defaults
│ ├── sdkconfig.sim.defaults SIM_MODE config override
│ ├── partitions.csv
│ ├── components/
│ │ └── board/ Minimal board init (NVS flash)
│ └── main/
│ ├── main.c App: RX task + feature task + serial CLI
│ ├── Kconfig.projbuild menuconfig option for SIM_MODE
│ ├── nrf24l01.h/c SPI driver for NRF24L01 RX mode
│ ├── link_features.h/c Feature extractor (rate, loss, IAT, burst)
│ ├── link_model.h Decision tree — generated by training/train.py
│ └── ssd1306.h/c Minimal I2C OLED driver with 5×7 font
├── training/
│ ├── train.py Train model → writes firmware/main/link_model.h
│ └── requirements.txt
├── experiments/
│ └── link_monitor_sim_test.py Offline simulation test script
├── tools/
│ ├── collect.py Serial CSV harvester for data collection
│ ├── simulate.py Full pipeline simulation (no hardware needed)
│ └── requirements.txt
└── data/
└── synthetic_link_data.csv 450-sample dataset (150/class) for offline training
Follow the ESP-IDF getting started guide. This project requires ESP-IDF v5.x or v6.x.
. $IDF_PATH/export.shFind your port first:
# macOS
ls /dev/cu.usbserial-* /dev/cu.SLAB_USBtoUART 2>/dev/null
# Linux
ls /dev/ttyUSB*Then flash:
cd firmware
# macOS:
idf.py -p /dev/cu.usbserial-110 build flash monitor
# Linux:
idf.py -p /dev/ttyUSB0 build flash monitorYou need a second device with NRF24L01 transmitting on channel 76 with an 8-byte payload where byte 0 is a rolling sequence number.
pip install pyserial
python3 tools/collect.py --port /dev/cu.usbserial-110In the serial monitor, send:
c→ collect mode0→ label NORMAL (stable link)1→ label WEAK (move transmitter far away)2→ label INTERFERENCE (run a microwave / add BT traffic)
Collect ≥100 samples per class, then Ctrl+C.
pip install numpy scikit-learn
# On your real collected data:
python3 training/train.py --data data/nrf24_link_data.csv
# Or use the included synthetic dataset to test the pipeline first:
python3 training/train.py --data data/synthetic_link_data.csvThis overwrites firmware/main/link_model.h with a freshly trained decision tree
exported as a C static inline function — zero runtime dependencies on the ESP32.
cd firmware
idf.py -p /dev/cu.usbserial-110 build flash monitor| Key | Action |
|---|---|
i |
Inference mode (default) |
c |
Collect mode |
0 / 1 / 2 |
Set label (collect mode) |
s |
Print status |
| Feature | Description |
|---|---|
pkt_rate |
Packets received per second |
loss_rate |
Fraction of expected packets missed (via sequence gaps) |
avg_iat_ms |
Mean inter-arrival time between consecutive packets |
burst_score |
σ/µ of IAT — high = bursty/erratic, low = regular |
Features are computed over a 2-second sliding window at 1 Hz.
A scikit-learn DecisionTreeClassifier (max depth 5) is trained on the collected
features and exported as a C static inline function — zero runtime dependencies,
no library required on the ESP32.
firmware/main/link_model.h is pre-generated from data/synthetic_link_data.csv
so the firmware compiles and runs immediately without any extra steps.
To retrain on real data, run training/train.py --data <your_csv>.