Badge firmware
This is the firmware that actually ships on the badge in your hand — not a reference sketch, the repository. Source is private to the Onetap Labs team; MIT licensed once you're in.
Request access
The repository is private. Email support@onetaplabs.com with your GitHub username and what you're building — once you're added as a collaborator it's a normal clone and flash, shown to the right.
git clone https://github.com/Austin-beep-beep/onetap-id-cardcd onetap-id-cardpio run -e esp32s3_n4r8 -t uploadpio device monitorRepository
github.com/Austin-beep-beep/onetap-id-card — private, MIT licensed. One firmware image serves every badge; identity, task list and firmware version are pulled from the paired dashboard account rather than compiled in, so nothing about a specific person’s badge lives in this repository — that’s a property of the design, not a reason it’s gated. Access is by request; see the box above.
src/main.cpp is the whole application: state machine, drawing, the sync client and the OTA installer, in one file by design — this runs with PSRAM disabled on a marginal power rail, and keeping the hot path in one translation unit has made the budget easier to reason about than a header-per-feature split would.
| Path | What it is |
|---|---|
| src/main.cpp | Everything — state machine, drawing, sync, OTA. |
| include/pins.h | GPIO map, confirmed against hardware. |
| include/secrets.example.h | Wi-Fi credential template. Copy to secrets.h; never committed. |
| include/onetap_root_ca.h | Pinned TLS roots for onetaplabs.com — both ISRG X1 and X2. |
| lib/OneTapEPD/ | GxEPD2 panel driver, adapted for the GDEY0213B74. |
| test/host/ | Host-side parser tests and the on-panel text-fit checker. |
Toolchain and build environments
PlatformIO with the espressif32 platform and the Arduino framework. The display is driven by a fork of GxEPD2 checked into lib/OneTapEPD, adapted for the SSD1680 controller on the badge’s panel.
Three build environments, all defined in platformio.ini:
| Environment | What it's for |
|---|---|
| esp32s3_n4r8 | Default. Build and flash this for day-to-day development. |
| esp32s3_n4r8_insecure | TLS bring-up only — skips certificate validation so a TLS failure can be told apart from a protocol failure. Never ship a badge flashed with this. |
| esp32s3_n4r8_release | What gets published for over-the-air updates. Identical to the default env, except include/secrets.h is excluded so the published image never carries a Wi-Fi password. |
A publish-time script on the dashboard side refuses to accept an image that still contains the password from secrets.h — it greps the compiled binary for the literal string. Building the release env is what keeps that check from ever firing.
Flash and partition configuration
The board this firmware is built and tested against carries an ESP32-S3-WROOM-1U-N4R8: 4 MB of flash and 8 MB of octal PSRAM. Confirmed against the silicon on 5 August 2026 — esptool.py flash_id reports Embedded PSRAM 8MB (AP_3v3) and Detected flash size: 4MB.
board_build.flash_size = 4MB
board_build.flash_mode = dio ; ROM runs flash in DIO on this module
board_build.arduino.memory_type = dio_qspi
board_build.partitions = min_spiffs.csv ; 4MB-safe layoutThe bill of materials lists a different part, and the BOM is the one that is wrong. It names ESP32-S3-WROOM-1-N16R2 — 16 MB flash, 2 MB quad PSRAM. That disagreement was carried as unresolved here for a while; reading the chip settled it in favour of these build files. The Hardware page has the command and what to look for.
The flash is run in dio and memory_type is dio_qspi, so PSRAM is off in every build even though the part has 8 MB of it. That is a deliberate choice rather than a leftover from the module confusion: nothing in the firmware allocates from PSRAM, internal RAM sits at about 20% of 320 KB, and PSRAM draws current continuously on a 50 mAh cell. Enabling it would mean qio_opi and -DBOARD_HAS_PSRAM; the flags are commented out in platformio.ini with the reasoning beside them.
GPIO map
Confirmed on hardware. From include/pins.h:
| Signal | GPIO | Note |
|---|---|---|
| EPD_MOSI | 11 | Panel SDA/SDI |
| EPD_SCLK | 12 | Panel SCL |
| EPD_CS | 7 | |
| EPD_DC | 6 | |
| EPD_RST | 5 | |
| EPD_BUSY | 4 | |
| PIN_BUZZER | 40 | Buzzer8530SMD — needs a square-wave drive |
| BTN_HOME | 1 | |
| BTN_LEFT | 41 | |
| BTN_RIGHT | 2 | |
| BTN_UP | 45 | Strapping pin (VDD_SPI select) — must read LOW at boot |
| BTN_DOWN | 42 | |
| QWIIC_SDA | 8 | |
| QWIIC_SCL | 9 | |
| PIN_VBAT_ADC | 18 | ADC2_CH7 — ADC2 is unavailable while Wi-Fi is on |
Never hold the UP button through a reset, including the reboot that follows an OTA install. GPIO45 is read at boot to select the flash voltage; holding it selects 1.8 V and the board will not come back up.
Panel pin 8 (BS1) is tied to ground on the PCB for 4-wire SPI — it is not a GPIO and does not appear above.
Driving the display
The panel is a GDEY0213B74 — 250 × 122, SSD1680 — driven over 4-wire SPI through GxEPD2_213_OneTap in lib/OneTapEPD, a fork of the stock GxEPD2 driver carrying this project’s own full-refresh waveform tuning.
#include "GxEPD2_213_OneTap.h"
GxEPD2_BW<GxEPD2_213_OneTap, GxEPD2_213_OneTap::HEIGHT> display(
GxEPD2_213_OneTap(EPD_CS, EPD_DC, EPD_RST, EPD_BUSY));
void setup() {
display.init(115200);
display.setRotation(3);
display.setFullWindow();
display.firstPage();
do {
display.fillScreen(GxEPD_WHITE);
display.setCursor(10, 30);
display.print("Onetap Labs");
} while (display.nextPage());
display.hibernate(); // hold the image, drop the power
}Call hibernate() when you are done drawing. The image stays on the panel with the controller powered down, which is the whole point of e-paper on a badge that spends its day on a lanyard and most of that day asleep.
Flashing a board
Over USB-C, using the two strapping buttons:
- Hold
BOOT, tapEN, then releaseBOOT. The board is now in the ROM serial bootloader. - Run
pio run -e esp32s3_n4r8 -t upload. The port appears as a USB CDC device once the badge’s own firmware is running — the ROM bootloader enumerates as a different interface. - Tap
ENto run the new firmware, thenpio device monitorto watch it boot.
Host-side tests
The wire parser is the one part of this firmware that can fail silently — a bad parse doesn’t crash, it puts wrong text on a panel with no debugger attached. It runs on your machine, not the device:
python3 test/host/mkharness.py /tmp/harness.cpp
c++ -std=c++17 -O1 -g -fsanitize=undefined -fno-sanitize-recover=all \
-o /tmp/harness /tmp/harness.cpp
/tmp/harness
python3 test/host/measure.py # every on-screen string still fits the 250px panelmkharness.py extracts the parser functions verbatim out of main.cpp at generation time, so the tests are exercising the real code, not a re-typed copy that can drift out of sync with it. measure.py parses the actual glyph advance widths out of the panel fonts and checks every string the firmware draws against the 250-pixel panel width — it is what caught, and fixed, a batch of on-screen text that ran off the edge of the display.
When it boot-loops
A reset loop that prints rst:0x3 (RTC_SW_SYS_RST) straight after entry 0x..., with no application output at all, is a bootloader-stage failure. Your code is not running and never did.
- Check the partition table against the real flash size first. This is the most common cause by a wide margin.
- Check the module printed on your board against
platformio.ini— see the note above. A mismatched flash size or PSRAM mode is a silent boot failure, not an error message. - Disconnect anything attached to the Qwiic port or the breakout pads and reset. A peripheral holding a strapping pin at the wrong level at boot produces the same silence.
If the board boots but resets specifically while bringing up Wi-Fi — prev reset = BROWNOUT in the boot banner — that is a power rail problem, not a firmware one. A 500 mA transmit burst on a marginal supply is enough to brown out the 3.3 V rail; see Hardware for the charge-current and decoupling notes.