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.

clone and flash (once you have access)
git clone https://github.com/Austin-beep-beep/onetap-id-cardcd onetap-id-cardpio run -e esp32s3_n4r8 -t uploadpio device monitor

Repository

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.

PathWhat it is
src/main.cppEverything — state machine, drawing, sync, OTA.
include/pins.hGPIO map, confirmed against hardware.
include/secrets.example.hWi-Fi credential template. Copy to secrets.h; never committed.
include/onetap_root_ca.hPinned 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:

EnvironmentWhat it's for
esp32s3_n4r8Default. Build and flash this for day-to-day development.
esp32s3_n4r8_insecureTLS 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_releaseWhat 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 layout

The 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:

SignalGPIONote
EPD_MOSI11Panel SDA/SDI
EPD_SCLK12Panel SCL
EPD_CS7
EPD_DC6
EPD_RST5
EPD_BUSY4
PIN_BUZZER40Buzzer8530SMD — needs a square-wave drive
BTN_HOME1
BTN_LEFT41
BTN_RIGHT2
BTN_UP45Strapping pin (VDD_SPI select) — must read LOW at boot
BTN_DOWN42
QWIIC_SDA8
QWIIC_SCL9
PIN_VBAT_ADC18ADC2_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.

The button model

One model, and it holds on every screen in the firmware — the menus, the games, the sync flow, all of it:

ButtonShort pressLong press
HOMEBack one levelJump straight to the home card
LEFTOpen Pipeline (from the home card)Sync — time, identity, projects, tasks
RIGHTSelect
UP / DOWNScroll a list; DOWN opens the menu from home

The long-LEFT sync is intentionally not bound everywhere: it is disabled in every game (LEFT is a move there) and on any screen that already owns the radio — the Wi-Fi Setup portal, pairing, and the firmware-update flow — because a second sync session would tear down the one already running.

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, tap EN, then release BOOT. 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 EN to run the new firmware, then pio device monitor to 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 panel

mkharness.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.