Repo 1 of 4 · 500 Hz deterministic multi-sensor data acquisition firmware for real-time navigation systems.
Streams fused IMU · magnetometer · barometer · GPS telemetry as timestamped CSV over UART at 921600 baud.
⚠️ This repository was namednavigation-system-firmwarebefore moving under themulti-sensor-navigation-systemorganization from @ShivtejG236.
Bare-metal firmware for ESP32-WROOM-32 (devkit) that acquires IMU, magnetometer, barometer & GPS data, assembles deterministic sensor frames and streams them over UART for downstream processing (repos 2–4).
The firmware runs two pinned FreeRTOS tasks:
| Task | Core | Priority | Responsibility |
|---|---|---|---|
task_sensors |
1 | 5 | IMU/mag/baro polling, ring-buffer producer |
task_serial |
0 | 3 | GPS UART drain, CSV output, ring-buffer consumer |
The split exists by design: task_sensors busy-waits at 500 Hz on Core 1 so it never misses an IMU deadline. GPS UART draining lives on Core 0 — if it ran on Core 1, the Core 1 UART ISR would starve and drop NMEA bytes.
graph TD
subgraph Core1["Core 1 — task_sensors (priority 5)"]
IMU["MPU-6050\n500 Hz"]
MAG["QMC5883L\n75 Hz"]
BARO["BMP280\n25 Hz"]
ROW["Assemble SensorRow\n+ fault_flag + jitter"]
RB["Ring buffer\n128 slots SPSC"]
IMU --> ROW
MAG --> ROW
BARO --> ROW
ROW --> RB
end
subgraph Core0["Core 0 — task_serial (priority 3)"]
GPS["NEO-6M GPS\n1 Hz NMEA drain"]
SNAP["Snapshot GPS globals\ninto SensorRow"]
POP["rb_pop"]
CSV["CSV → UART0\n921600 baud"]
GPS --> SNAP
SNAP --> POP
RB --> POP
POP --> CSV
end
CSV --> HOST["Host logger\n(Repo 2)"]
Model: task_sensors = producer · task_serial = consumer
Data flow is single-producer (Core 1) → single-consumer (Core 0) via a lock-free ring buffer.
|
![]() |
All three I²C devices share the same bus at 400 kHz fast-mode.
| Sensor | Rate | Period |
|---|---|---|
| MPU-6050 | 500 Hz | 2 000 µs |
| QMC5883L | 75 Hz | 13 333 µs |
| BMP280 | 25 Hz | 40 000 µs |
| NEO-6M | 1 Hz | 1 000 000 µs |
Lower-rate sensors are polled sub-sampled within the 500 Hz IMU loop — no separate timers, no RTOS overhead.
The firmware prints a single header line on boot, then one row per IMU tick:
Field Groups
Fields are grouped (only here) by sensor domain for clarity:| Group | Fields |
|---|---|
| [timing] | t_imu_us, t_read_start_us, t_proc_end_us, t_gps_us |
| [imu] | ax_g, ay_g, az_g, gx_dps, gy_dps, gz_dps |
| [mag] | mx_uT, my_uT, mz_uT |
| [baro] | pressure_hPa, bmp_temp_C |
| [gps] | lat_deg, lon_deg, alt_gps_m, hdop, satellites |
| [system] | fault_flag, jitter_us |
Field Reference
| Field | Unit | Notes |
|---|---|---|
| t_imu_us | µs | esp_timer_get_time() at IMU read start - monotonic, 1µs resolution |
| t_read_start_us | µs | Earliest read start timestamp in the row |
| t_proc_end_us | µs | After all sensor reads complete — subtract from `t_read_start_us` for processing budget |
| t_gps_us | µs | Timestamp of last parsed GPS sentence |
| ax/ay/az | g | Accelerometer — converted from m/s² |
| gx/gy/gz | °/s | Gyroscope — converted from rad/s |
| mx/my/mz | µT | Magnetometer — QMC5883L at 8G range, 3000 LSB/Gauss |
| pressure | hPa | BMP280 — sanity-checked to 800–1100 hPa |
| bmp_temp | °C | BMP280 temperature |
| lat/lon | degrees | 8 decimal places (~1.1 mm resolution) |
| alt_gps | m (MSL) | GPS altitude |
| hdop | — | Horizontal dilution of precision |
| satellites | — | Satellites in use |
| fault_flag | bitmask | See below |
| jitter_us | µs | How many µs the IMU tick overshot its absolute deadline; 0 = on time |
Fault Flag Bitmask
| bit 0 | FAULT_IMU | MPU-6050 read failed; stale values carried forward |
| bit 1 | FAULT_MAG | QMC5883L returned all-zero (I²C fault) |
| bit 2 | FAULT_BARO | BMP280 out-of-range or NaN |
| bit 3 | FAULT_GPS_HDOP | HDOP > 2.5 or satellites < 4 |
| bit 4 | FAULT_GPS_NOFIX | No valid GPS fix yet |
Fault flags describe the current sample only — they are not latched system faults. A 0x00 row means all sensors reported cleanly.
When a lower-rate sensor misses its sub-deadline within a 500 Hz tick, the last known-good value is carried forward into that row's fields. The corresponding fault-flag bit is set so downstream consumers can distinguish fresh from stale. This guarantees consumers always see a valid reading rather than zeros.
A 128-slot lock-free SPSC ring buffer (RING_SIZE must be a power of 2) decouples the sensor task from the serial task. rb_push / rb_pop use __sync_synchronize() memory barriers to prevent store/load reordering across the two cores — no mutex, no FreeRTOS synchronisation primitive. If the ring fills (serial output can't keep up), the producer drops the row and increments rb_drops. Drops are reported on Serial once per second:
# rb_drops=12
Designed for SPSC (single producer, single consumer); not safe for multi-writer scenarios.
firmware/
├── src/
│ └── main.cpp # All firmware logic
├── include/
│ ├── config.h # Pin assignments, sample rates, fault flags, ring size
│ └── sensor_row.h # SensorRow struct + lock-free ring buffer
├── platformio.ini # PlatformIO build config
├── sdkconfig.defaults # ESP-IDF SDK overrides (task WDT tuning)
└── docs/
└── setup.png # Schematic diagram
Managed by PlatformIO — no manual installation needed.
| Library | Version | Purpose |
|---|---|---|
adafruit/Adafruit MPU6050 |
^2.2.6 | IMU driver |
mprograms/QMC5883LCompass |
^1.0.2 | Magnetometer driver |
adafruit/Adafruit BMP280 Library |
^2.6.8 | Barometer driver |
mikalhart/TinyGPSPlus |
^1.0.3 | NMEA sentence parser |
adafruit/Adafruit Unified Sensor |
^1.1.14 | Sensor abstraction layer |
# Build
pio run
# Flash + open monitor
pio run --target upload && pio device monitor --baud 921600On boot you should see sensor init messages followed by the CSV header:
# MPU-6050 OK
# QMC5883L OK
# BMP280 OK
# GPS UART started — waiting for fix (HDOP reported in data)
t_imu_us,t_read_start_us,...
If a sensor is absent, firmware continues logging available sensors and annotates the header with a # WARN line. fault_flag will reflect which sensor is missing on every row.
Why the WDT is unregistered for idle tasks: task_sensors busy-waits at 500 Hz on Core 1, which starves the idle task. Without esp_task_wdt_delete() on both idle tasks (and on loopTask), the TWDT fires every ~5 s and hard-resets the chip. sdkconfig.defaults disables the idle-task TWDT at the SDK level; the runtime calls are an explicit belt-and-suspenders.
Why GPS UART draining is on Core 0: The UART receive ISR runs on whichever core processes the interrupt. If task_sensors owned the UART drain, the 500 Hz busy-wait would starve the ISR on Core 1 and drop bytes in NMEA sentences. Core 0 yields via vTaskDelay(1) each iteration, giving the ISR clean execution windows.
Double-write tearing on GPS globals: lat and lon are volatile double (64-bit) written on Core 0 and read on Core 1. Xtensa LX6 does not guarantee atomic 64-bit stores; a torn read is possible. This is acceptable for logging — the corruption is bounded to one sample and corrected on the next GPS sentence.
graph LR
FW["🔧 firmware\nESP32 sensor acquisition\nCSV @ 921600 baud"]
PL["📡 data-pipeline\nHost logger · serial → CSV\ncleaning & calibration"]
AN["📊 analysis\nEKF · Allan variance\nvisualisation · reports"]
EH["🧭 edgehard\nDead reckoning\nGPS dropout modelling"]
FW -->|"UART CSV stream"| PL
PL -->|"clean .csv files"| AN
PL -->|"clean .csv files"| EH
AN -. "future: model feedback" .-> EH
| Repo | Role | Status |
|---|---|---|
firmware ← (this repo, formerly navigation-system-firmware) |
ESP32 firmware — sensor acquisition & CSV streaming | ✅ Active |
data-pipeline |
Host-side logger & calibration — reads serial, writes timestamped CSV | ✅ Active |
analysis |
Offline processing — EKF, Allan variance, map visualisation, reports | 🚧 In progress |
edgehard |
Dead reckoning — GPS dropout modelling, edge inference | 🔜 Planned |
This project explores how sensor systems behave under high-frequency, tightly timed acquisition.
Instead of abstract simulation, it focuses on:
- capturing raw sensor imperfections (noise, drift, jitter)
- understanding timing effects in multi-rate systems
- building a reliable data foundation for downstream estimation (EKF, dead reckoning)
The goal is to bridge the gap between clean theoretical models and messy real-world sensor data.
