Heart rate and SpO₂ from a MAX30102, on either a RP2040-Zero or an ESP32-S3-Zero, off one portable C++17 driver core.
OxiNode is a deliberately small, professional-grade firmware showcase. The same lib/max3010x/ driver runs on two boards behind a 4-method HAL interface. Pick your platform; the MCU-specific code is < 300 LoC.
| Path | Board | Link to host | Use case |
|---|---|---|---|
| RP2040 (prototype, default) | Waveshare RP2040-Zero | USB-CDC, JSON-Lines or binary CRC frames | Plug into a laptop, watch numbers in picocom or the Python TUI |
| ESP32-S3 (phase 2) | Waveshare ESP32-S3-Zero | Wi-Fi STA + WebSocket, embedded HTML | Open the IP in your phone browser, watch a live chart |
Both paths are interrupt-driven: the MAX30102 drives an active-low INT line, an ISR posts to a task, the task drains the FIFO over I²C. No polling.
You only need one of the two dev environments. Both reproduce the toolchain bit-for-bit; pick whichever you already have.
git clone <this-repo> oxinode && cd oxinode
nix develop # drops you in a shell with both toolchains
./scripts/build.sh rp2040 # → build/rp2040/oxinode.uf2
./scripts/build.sh esp32s3 # → build/esp32s3/oxinode.bin (phase 2)The flake pins pico-sdk 2.2.0 (with submodules — required for tinyusb) and pulls gcc-arm-embedded, picotool, openocd-rp2040, picocom, plus cmake/ninja/python3. ESP-IDF v5.5 is fetched on first use of the ESP32 shell because Espressif's installer stages ~400 MB of toolchain that doesn't belong in the Nix store.
./docker/docker-build.sh rp2040 # builds firmware/rp2040 inside a pico-sdk image
./docker/docker-build.sh esp32s3 # builds firmware/esp32s3 inside espressif/idf:release-v5.5The Docker images are immutable — your host stays clean, but you'll need picotool / idf.py flash on the host to actually flash, since USB pass-through to a container is fiddly.
RP2040-Zero — hold BOOT, plug USB, release. Drag-drop the .uf2, or:
./scripts/flash.sh rp2040 # picotool load + reboot to app
./scripts/monitor.sh # picocom /dev/ttyACM* @ 115200ESP32-S3-Zero — over the on-chip USB-JTAG (no DTR/RTS dance required):
./scripts/flash.sh esp32s3 /dev/ttyACM0 # idf.py -p ... flash monitorSTOP — do not solder yet. First confirm the silkscreen on your MAX30102 breakout matches one of the variants in docs/HARDWARE.md. Once confirmed, the wiring is:
RP2040-Zero MAX30102 breakout (GY-style 7-pin)
───────────── ─────────────────────────────────────
3V3 (OUT) ────────▶ VIN (sensor accepts 3.3–5 V)
GND ────────▶ GND
GP4 / SDA ◀──────▶ SDA (I²C0, 4.7 kΩ pull-up on breakout)
GP5 / SCL ────────▶ SCL (I²C0, 4.7 kΩ pull-up on breakout)
GP6 ◀────── INT (open-drain, active low; internal pull-up enabled)
── ── IRD (leave floating — LED-drive monitor pin)
── ── RD (leave floating — LED-drive monitor pin)
Optional 0.96″ SSD1306 OLED for a standalone HR / SpO₂ dashboard:
RP2040-Zero SSD1306 OLED (128×64, I²C, 0x3C)
───────────── ─────────────────────────────────────
3V3 (OUT) ────────▶ VDD (3.3–5 V, on-module charge-pump)
GND ────────▶ GND
GP14 ◀──────▶ SDA (I²C1, 400 kHz)
GP15 ────────▶ SCL (I²C1, 400 kHz)
The OLED is fully optional — if it's unplugged the firmware logs oled_init_failed once and the USB-CDC link keeps working normally.
Full pin tables for both boards plus a fallback wiring for ESP32-S3 are in docs/HARDWARE.md.
┌────────────────────────────────────────┐
│ lib/max3010x (portable C++17) │
│ │
│ Max30102 ──▶ HrDetector ──▶ Sample │
│ │ │ │
│ ▼ ▼ │
│ Spo2Algo ────────────────▶ Observer │
└─────▲──────────────────────────────┬───┘
│ IHal (i2c, gpio-int, time) │
┌──────────────────┴──────────┐ ┌──────────────┴────────────┐
│ firmware/rp2040 PicoI2cHal │ │ firmware/esp32s3 (phase 2)│
│ PicoIntPin → multicore FIFO│ │ Esp32I2cHal │
│ UsbCdcLink → JSON / binary│ │ WifiManager + WS server │
└─────────────────────────────┘ └───────────────────────────┘
│ │
▼ ▼
USB-CDC Wi-Fi → phone
picocom / Python TUI browser chart
See docs/ARCHITECTURE.md for the long form, and docs/DESIGN.md for the why behind every nontrivial choice.
Boots in JSON-Lines mode — eyeballable in any serial terminal:
{"t":12345,"ir":123456,"red":98765,"hr":72,"spo2":98}
{"t":12365,"ir":123890,"red":98910,"hr":72,"spo2":98}Send MODE BIN\n to switch to length-prefixed CRC-16/CCITT frames for the Python desktop client. Full spec, frame layout, and CRC reference in docs/PROTOCOL.md.
oxinode/
├── datasheets/ ← Vendor PDFs (committed; fetcher in datasheets/fetch.sh)
├── docs/ ← ARCHITECTURE / DESIGN / HARDWARE / PROTOCOL
├── docker/ ← Dockerfile.rp2040, Dockerfile.esp32, wrapper script
├── flake.nix ← Nix dev shell (rp2040 + esp32 in one shell)
├── lib/max3010x/ ← Portable driver core (host-tested)
├── firmware/rp2040/ ← Pico-SDK firmware (priority)
├── firmware/esp32s3/ ← ESP-IDF firmware (phase 2)
├── host/tests/ ← Google Test, FakeI2cHal — runs on x86
├── host/desktop_client/ ← Python TUI for binary mode
└── scripts/ ← build.sh, flash.sh, monitor.sh
| Component | State |
|---|---|
Portable driver core (lib/max3010x/) |
Working — driver verified byte-for-byte against MAX30102 datasheet pages 10–15 (docs/DATASHEETS.md); HR + SpO2 paths follow Maxim UG6409 + AN6845 references (see docs/DESIGN.md D-13) |
| Host gtest suite | 30/30 passing |
| RP2040 firmware | HR + SpO2 working — locks 78 BPM resting and 97 % SpO2 off finger, JSON-Lines streaming over USB-CDC at /dev/ttyACM0 |
| Python desktop client | Working — 22/22 pytest, mypy --strict, ruff clean. Untested against live RP2040. |
| ESP32-S3 firmware | Skeleton only — phase 2 (untested in Docker) |
Open: MODE BIN host command parsing |
Disabled in current RP2040 firmware (pico_stdio_usb owns the USB descriptor; tud_cdc_n_* are gated). JSON-Lines path covers v1. |
MIT. See LICENSE.