Reference firmware for the edge tier of a cloud-connected IoT system. Sensors → preprocessing → on-device anomaly detection (Z-score TinyML) → cloud alert via a single pluggable hook. Targets Seeed XIAO ESP32-S3 and Raspberry Pi Pico 2W.
The default build is a dry run (serial output), so you can verify payload shape and bandwidth before writing any cloud code.
┌──────────────────┐ I2C: BME280, MPU6050
│ Sensors │ Sensor list defined in embedded_configs.h
└────────┬─────────┘
▼
┌──────────────────┐ highpass / lowpass / magnitude / delta /
│ Preprocessing │ abs / moving_variance — chained via JSON config
└────────┬─────────┘
▼
┌──────────────────┐ Z-score model, learned online and persisted
│ AnomalyEngine │ to NVS (ESP32) / EEPROM (Pico 2W).
│ │ M-of-N voting + per-device alert cooldown.
└────────┬─────────┘
▼
┌──────────────────┐ ┌─────────────────────────────────────┐
│ uplinkOnAlert() │ → │ User-implemented cloud transport │
│ (only hook) │ │ HTTPS, MQTT, BLE Advertise, ... │
└──────────────────┘ │ Default: dry run (serial output) │
└─────────────────────────────────────┘
| Board | TFT (MSP2807) | I2C SDA / SCL |
|---|---|---|
| Seeed XIAO ESP32-S3 | ✓ included | GPIO 5 / 6 |
| Raspberry Pi Pico 2W | ✓ included | GPIO 4 / 5 |
src/board_config.h reads the Arduino board macro and picks I2C pins
automatically. TFT pin configuration requires a one-time setup — see TFT_eSPI setup.
- Arduino IDE 2.x
- Board packages:
esp32by Espressif Systems (3.x) and/orRaspberry Pi Pico/RP2040by Earle F. Philhower III (4.x). - Libraries:
- ArduinoJson (Benoit Blanchon) ≥ 7.0
- TFT_eSPI (Bodmer) ≥ 2.5.44 GitHub master branch — see Troubleshooting.
| Function | XIAO GPIO | MSP2807 pin |
|---|---|---|
| TFT MOSI | 9 | SDI |
| TFT MISO | 8 | SDO |
| TFT SCLK | 7 | SCK |
| TFT CS | 4 | CS |
| TFT DC | 3 | DC |
| TFT RST | 2 | RESET |
| TFT BL | 1 | LED |
| I2C SDA | 5 | (sensors) |
| I2C SCL | 6 | (sensors) |
| Function | Pico GPIO | MSP2807 pin |
|---|---|---|
| TFT MOSI | 19 | SDI |
| TFT MISO | 16 | SDO |
| TFT SCLK | 18 | SCK |
| TFT CS | 17 | CS |
| TFT DC | 14 | DC |
| TFT RST | 15 | RESET |
| TFT BL | 20 | LED |
| I2C SDA | 4 | (sensors) |
| I2C SCL | 5 | (sensors) |
Copy all three files from src/tft/setups/ into the TFT_eSPI library:
src/tft/setups/EdgeAI_AutoSelect.h → Arduino/libraries/TFT_eSPI/User_Setups/
src/tft/setups/Setup_XIAO_ESP32S3_MSP2807.h → Arduino/libraries/TFT_eSPI/User_Setups/
src/tft/setups/Setup_RPI_PICO2W_ILI9341.h → Arduino/libraries/TFT_eSPI/User_Setups/
Then edit Arduino/libraries/TFT_eSPI/User_Setup_Select.h:
- Comment out
#include <User_Setup.h>. - Add before the closing
#endif:#include <User_Setups/EdgeAI_AutoSelect.h>
EdgeAI_AutoSelect.h selects the correct board setup automatically at compile time.
If your hardware uses different TFT pins, edit the matching
Setup_*.h and re-copy it to libraries/TFT_eSPI/User_Setups/.
| Setting | Value |
|---|---|
| USB Mode | Hardware CDC and JTAG |
| Flash Size | match your module (4MB or 8MB) |
| PSRAM | OPI PSRAM (recommended) |
Open narrative-aI-edge.ino, select your board and port, then Sketch → Upload.
Serial Monitor at 115200 baud:
=== Narrative IoT Edge 1 ===
[Board] xiao-esp32s3
[Storage] NVS Preferences ready
[Storage] ready
[AnomalyEngine] threshold=0.62 buf=300 retrain=300 drift=0.30 mofn=7/10 online=on
[I2C] SDA=5 SCL=6
[BME280] Initialized at 0x76
[MPU6050] Initialized at 0x68 (8g, 500deg/s, 21Hz LPF)
...
[AnomalyEngine] No model at /model.json -- edge learning starts
[ML] no model -> learning phase
[Setup] Done
[CSV] time,temperature,vibration_rms,vibration_volatility,score,mofn
[CSV] 3254,27.592,0.033,0.001,
...
[AnomalyEngine] Collecting: 300 / 300
[AnomalyEngine] Model saved
[AnomalyEngine] Model ready. Inference enabled.
[AnomalyEngine] Edge model built: version=edge-302254 samples=300 threshold=0.62
Trigger an anomaly (e.g., shake the sensor):
[ALERT] score=0.640 mofn=7 type=volatility driver=vibration_rms
[Uplink/Alert] size=352B {"device_id":"node-001","device_uptime_ms":530262,"payload_version":1,"anomaly_signature":{"score":0.64036,"type":"volatility","primary_driver":"vibration_rms","duration_seconds":0,"sensor_snapshot":{"temperature":27.89,"vibration_rms":0.158052,"vibration_volatility":0.003896}},"meta":{"mofn_count":7,"model_version":"edge-302254"}}
[CSV] 530265,27.890,0.158,0.004,0.640,7 <-- ALERT
Implement uplinkOnAlert(...) in src/cloud_uplink.cpp to send alerts over any transport.
The default implementation is a dry run that prints the payload to serial.
Edit src/embedded_configs.h → ML_JSON and rebuild.
| Key | Default | Description |
|---|---|---|
ml.anomaly_threshold |
0.62 | Alert threshold (sigmoid of max Z-score). |
ml.buffer_target |
300 | Samples before the first model is built. |
ml.retrain_interval |
300 | Online-retrain cadence (samples). |
ml.alert_cooldown_minutes |
10 | Cooldown between alerts. |
ml.mofn_n / ml.mofn_m |
10 / 7 | M-of-N voting window and threshold. |
ml.max_drift_ratio |
0.3 | Drift guard tolerance for online retraining. |
ml.discard_saved_model |
false | true = re-learn from scratch on every boot. |
ml.online_learning |
true | false = freeze model after initial learning. |
Per-field guardrails (min_std, max_std, hard limits) and the
preprocessing pipeline are in SENSORS_JSON in the same file.
narrative-ai-edge/
├── narrative-ai-edge.ino Main sketch
├── README.md This file
└── src/
├── board_config.h Per-board pin & TFT macros
├── embedded_configs.h Sensor / ML config (in-source JSON)
├── cloud_uplink.{h,cpp} Cloud integration boundary
├── logger.h Serial + TFT unified logging
├── hal/ihal_storage.h Storage abstraction
├── arduino_hal/
│ ├── hal_wdt.h Watchdog wrapper
│ └── hybrid_storage.{h,cpp} Embedded configs + NVS / EEPROM
├── sensors/i2c_bus.h I2C bus init
├── tft/
│ ├── tft_display.{h,cpp} Dashboard + scroll log
│ └── setups/ Copy these to TFT_eSPI/User_Setups/
│ ├── EdgeAI_AutoSelect.h Board-dispatch wrapper
│ ├── Setup_XIAO_ESP32S3_MSP2807.h
│ └── Setup_RPI_PICO2W_ILI9341.h
├── app/
│ └── sensor_manager.{h,cpp} Driver list, averaging, pipeline
└── shared/ Portable, transport-agnostic code
├── serial_log.h LOGP() macro
├── time_compat.h millis() / sleep_ms() shim
├── ml/anomaly_engine.{h,cpp} Z-score learner + M-of-N
├── pipeline/preprocessing.{h,cpp}
└── sensors/
├── i2c_sensor.h
├── bme280_driver.{h,cpp}
└── mpu6050_driver.{h,cpp}
Boot crash, Guru Meditation Error: StoreProhibited — TFT_eSPI
2.5.43 is incompatible with Arduino-ESP32 3.x. Install TFT_eSPI from
GitHub master:
- Delete
Arduino/libraries/TFT_eSPI. (if installed) - Download
master.zipfromhttps://github.com/Bodmer/TFT_eSPI. - Arduino IDE:
Sketch → Include Library → Add .ZIP Library...→ select the ZIP. - Re-apply the TFT_eSPI setup step (copy the three files under
src/tft/setups/toUser_Setups/and add the one-line include toUser_Setup_Select.h).
TFT stays black — User_Setup_Select.h not edited, the three
setup files not copied, or TFT_eSPI not the GitHub master build.
Re-check the "TFT_eSPI setup" section above.
MIT
- Sensor driver: add a file under
src/shared/sensors/implementingI2CSensor. - Cloud uplink: reference implementations for AWS / Azure / Google Cloud.
- Tuning recipes: per-use-case parameter sets (sensor type, environment,
min_std/anomaly_threshold/ M-of-N values) with notes on the reasoning.
