Battery-powered ESP32-S3 firmware that's the embedded client for the Tesserae server, ported to the Waveshare ESP32-S3 PhotoPainter (7.3" 800×480 6-colour Spectra E6 e-paper panel on an ESP32-S3-WROOM-1-N16R8 module, AXP2101 PMIC, microSD slot).
The wake state machine, MQTT contract, captive-portal provisioning, and NVS schema are the same as the 13.3" client (dmellok/tesserae-esp32-bin-client). What's different is the panel (single-CS, smaller, different init sequence), power management (AXP2101 over I2C instead of a GPIO panel-power gate and an ADC battery divider), and the heartbeat kind so the server can route the right frame format.
| Component | Source firmware (13.3" E6) | This port (7.3" PhotoPainter) |
|---|---|---|
| Module | ESP32-S3-WROOM-2-N32R16V | ESP32-S3-WROOM-1-N16R8 |
| Panel | 13.3" 1200×1600, dual-CS | 7.3" 800×480, single-CS |
| Frame size | 960 000 bytes | 192 000 bytes |
| Panel power | GPIO1 (active-high) | AXP2101 ALDOs (via I²C) |
| Battery sense | ADC1 ch7 (GPIO8) + divider | AXP2101 fuel gauge (via I²C) |
| User button | RESET (double-tap) | BOOT (hold) + RESET (double-tap) |
| Status LED | none | Red GPIO45, Green GPIO42 |
| Extras | — | microSD slot, SHTC3, ES8311+ES7210 (all unused in v1) |
Pin map lives in include/app_config.h; sourced from Waveshare's reference repo waveshareteam/ESP32-S3-PhotoPainter.
Since v0.3.0 the firmware speaks two transports to Tesserae and picks between them at boot via NVS. The selection lives in transport_mode under the rest namespace; the captive portal writes it from a top-of-form radio.
| Mode | When | Pros | Cons |
|---|---|---|---|
0 MQTT (default for existing installs) |
a broker is already on your LAN | retained-message model; instant push on URL change | requires a broker, captive portal needs broker creds |
1 REST (recommended for new installs) |
no broker on hand | zero broker dependency; device picks its own poll cadence; works through any reverse proxy that handles HTTP/JSON | per-wake polling cost; longer first-pair flow |
Backward compat: firmware upgrades from <0.3.0 see transport_mode absent in NVS and default to MQTT. No re-pair needed. New first-boots get the captive-portal radio.
Same three topics as the 13.3" client under tesserae/<device_id>/:
| Topic | Direction | Retained | Purpose |
|---|---|---|---|
frame/bin |
server → device | yes | URL of the next .bin frame |
config |
server → device | yes | Runtime device settings |
status |
device → broker | yes | Wake-time heartbeat + LWT |
Default device_id is photopainter-73 (not esp32) so the two panel kinds don't collide on a shared broker.
Raw, headerless, exactly 192 000 bytes (800 × 480 ÷ 2), scanline order, two pixels per byte, high nibble = even column. Palette nibbles: 0x0=Black, 0x1=White, 0x2=Yellow, 0x3=Red, 0x5=Blue, 0x6=Green.
The heartbeat publishes kind: "esp32_client" and panel_w: 800, panel_h: 480 — same kind as the 13.3" client. Tesserae's esp32_bin renderer dispatches on the panel dimensions, so no server-side change is required for the 7.3" panel.
Published at the end of every wake (after the paint, if any) to tesserae/<device_id>/status, retained QoS 1. Wire-contract matches dmellok/tesserae-esp32-bin-client v0.6.0 byte-for-byte so server-side handlers don't need to branch on panel type.
{
"battery_mv": 4180,
"battery_pct": 92,
"rssi": -41,
"ip": "192.168.50.137",
"fw_version": "0.2.0",
"kind": "esp32_client",
"panel_w": 800,
"panel_h": 480,
"sleep_interval_s": 300,
"next_sleep_s": 300,
"wake_reason": "timer",
"sleep_until": 1780887503
}Two of these feed Tesserae's smart-sync scheduler and are worth calling out:
next_sleep_s— duration of the deep sleep the device is about to enter (vs.sleep_interval_swhich is the configured cadence). Same value for this firmware since it always sleeps for the configured interval; a future firmware that extends sleep on low battery could differ.sleep_until— absolute unix timestamp (UTC seconds) of the intended wake. Omitted entirely when NTP hasn't synced yet (first cold boot before the 5 s SNTP window completes). The server treats absent and0differently — absent triggers a tolerance-window fallback rather than getting recorded as "device claims to wake at 1970-01-01".
The remaining fields are unchanged from the v1 wire contract and present on every heartbeat.
When transport_mode = 1, every wake hits the Tesserae server's /api/v1/device/<id>/... endpoints over plain HTTP:
| Method + path | Purpose |
|---|---|
POST /device/discover |
Unauthenticated. Posts identity (device_id, kind, panel_w/h, fw_version, mac). Server's two responses: (a) registered: false — the admin hasn't clicked Register yet; firmware deep-sleeps retry_after_s and retries on next wake; (b) registered: true — server matched by MAC; response carries device_token for subsequent requests. Default first-boot path. |
POST /device/register |
Strict opt-in alternative. Sends the same identity body plus a user-entered X-Pairing-Code: <6-digit> header. Server returns device_token (201 fresh / 200 reused). Used when the captive portal's Pairing code field is filled. |
GET /device/<id>/frame |
Auth: Authorization: Bearer <device_token>. Optional If-None-Match: "<last_etag>" header. Returns 200 + {url, format, panel_w, panel_h, render_id, renderer_id} + ETag header (firmware then fetches the url and paints), 304 (skip — panel still current), or 204 (server hasn't rendered yet). |
POST /device/<id>/status |
Auth: Authorization: Bearer <device_token>. Body: same heartbeat JSON as the MQTT path. Response carries {status, config, next_poll_s, server_time} — the firmware applies config.sleep_interval_s to NVS and uses next_poll_s for this cycle's deep-sleep duration. server_time (float seconds) is used to seed the RTC when NTP is unavailable. |
The device_token is persisted to NVS the first time it's obtained; subsequent wakes go straight to the frame GET. A 401 on any later request wipes the token and forces re-pairing through the captive portal.
Requires PlatformIO. ESP-IDF 5.x and the Xtensa toolchain are pulled automatically on first build.
pio run # build
pio run -e tesserae-photopainter-73-bin-client -t upload # flash via USB-C
pio device monitor # 115200 baudFor the dev shortcut, copy include/secrets.example.h to include/secrets.h and bake in WiFi/MQTT defaults. secrets.h is git-ignored.
Two ways to enter the setup form (same form for both):
- First boot / no creds. SoftAP
Tesserae-Setup(passwordtesserae) comes up; phone's captive-portal prompt opens the form. - Always-on editor. Either hold BOOT while pressing RESET or double-tap RESET within one wake window. The device serves the form on its STA IP and advertises it over mDNS at
http://tesserae-<device_id>.local/.
The double-tap path relies on the AXP2101 preserving RTC slow-memory across a PEK-triggered reset; if your unit clears it, the BOOT-hold path always works.
- WiFi off before paint —
wifi_sta_stop()runs before the ~25 s panel refresh so the radio isn't holding ~80 mA during the silent stretch. Same approach as the 13.3" client; biggest single battery saving in the render path. - Panel power via PMIC —
pmic_rails_set(false)is what gets the average current down before deep sleep, since the PhotoPainter has no dedicated GPIO panel-power gate. All four ALDO rails get dropped together (the Waveshare schematic doesn't publish the per-ALDO mapping; this mirrors their factory firmware behaviour). - Battery via I²C —
pmic_battery_mv()andpmic_battery_pct()come straight from the AXP2101 fuel gauge; no curve-fit ADC calibration, no Li-Po SoC table to maintain.
Since v0.2.0 the heartbeat publishes at the end of every wake so sleep_until is wall-clock-accurate. Two PhotoPainter-specific quirks make this firmware's cost a bit higher than the sibling's:
- Always-resync NTP. On the sibling,
time(NULL)advances naturally across deep sleep and a cached SNTP sync is reused. On the PhotoPainter, empiricallytime(NULL)reads ~26 s behind wall-clock on every timer-wake from deep sleep — almost certainly because the AXP2101 PMIC sleep state interferes with the SoC's RTC time-of-day maintenance. So this firmware does a fresh SNTP exchange on every wake, not just cold boot. Without it, Tesserae's smart-sync scheduler would mark the device "untrusted" because predicted-wake-time would disagree with observed-wake-time by tens of seconds. Seeensure_time_syncedinsrc/main.cfor the long comment. - Render-wake WiFi reconnect. On render wakes we drop WiFi before the ~25 s paint (single biggest battery saving) and reconnect afterwards to publish the heartbeat — costs ~3-5 s of WiFi-on time.
Per-wake cost:
| Wake type | Δ per wake |
|---|---|
| Hash-skip (URL unchanged) | +0.02–0.07 mAh (1-3 s NTP sync at ~80 mA; heartbeat shares the existing MQTT session) |
| Render wake (new URL or BOOT tap) | +0.10–0.18 mAh (NTP sync + WiFi reconnect + one-shot MQTT publish) |
| First wake after a flash/cold-boot | sometimes drops sleep_until if SNTP misses the 5 s window — the graceful-omit path. Subsequent wakes are clean. |
Impact on the documented use cases (15-min default sleep interval, 96 wakes/day):
| Use case | Render wakes/day | Pre-v0.2.0 daily draw | Post-v0.2.1 | Δ |
|---|---|---|---|---|
| Photo frame (1 update/day) | 1 | ~14 mAh | ~18 mAh | +28% |
| Dashboard (hourly updates) | 24 | ~30 mAh | ~37 mAh | +24% |
| Frequent (every wake renders) | 96 | ~75 mAh | ~89 mAh | +19% |
Worth-it trade: the alternative (keep WiFi up through the 25 s paint, or skip smart-sync entirely) costs ~2 mAh per render wake — 20× worse than what we ship. What you get for the overhead: Tesserae JIT-renders the next frame to land in the retained slot right before each wake, instead of pre-staging and risking staleness.
If a deployment ever finds the always-resync NTP cost unacceptable, the natural fix is a future PhotoPainter hardware revision with a 32 K crystal on the RTC slow clock — the force_resync parameter is already plumbed for that case (set !force_resync to re-enable the cached-time skip).
tesserae-photopainter-7.3-bin-client/
├── platformio.ini # board, partitions, monitor, FW_VERSION
├── partitions.csv # 14 MB factory app + NVS
├── sdkconfig.defaults # PSRAM-octal, mbedTLS bundle, MQTT 3.1.1
├── include/
│ ├── app_config.h # pinout + behaviour tunables
│ └── secrets.example.h # template for credential overrides
└── src/
├── main.c # boot -> settings? -> splash -> connect -> ntp -> mqtt fetch -> render -> publish heartbeat -> sleep
├── idf_component.yml # managed deps (espressif/mdns)
├── epd_driver.{c,h} # 7.3" Spectra E6 panel driver
├── pmic.{c,h} # AXP2101 wrapper (battery + LDO rails)
├── heartbeat.{c,h} # battery / RSSI / IP / panel size JSON
├── wifi_manager.{c,h} # NVS-backed STA connect
├── provisioning.{c,h} # captive portal + always-on settings server
├── mqtt_config.{c,h} # NVS-backed broker URI / device_id
├── mqtt_handler.{c,h} # single-shot subscribe + dispatch (transport=mqtt)
├── rest_handler.{c,h} # discover/register + frame GET + status POST (transport=rest)
├── image_fetcher.{c,h} # HTTP download into PSRAM
└── image_decoder.{c,h} # strict 192000-byte panel-native validation
No tests -- smoke-test on real hardware. Recommended validation after any change to the wake state machine:
- Flash a fresh board, walk it through the captive portal.
mosquitto_pub -t tesserae/photopainter-73/frame/bin -r -m '{"url":"http://.../test.bin"}'and confirm the panel paints.- Hold BOOT, press RESET, confirm
http://tesserae-photopainter-73.local/(or the device IP) serves the settings form pre-filled with live values.
tools/release.sh cuts a leak-safe GitHub release for the version in platformio.ini. Do not pio run straight into gh release create — include/secrets.h is #included at compile time and its #define values land in .rodata. A 3-second strings firmware.bin | grep would walk away with your WiFi password, MQTT credentials, and broker URI.
The script defends in two layers, both required:
- Move-aside with restore trap. Before building,
include/secrets.his renamed to a unique.secrets.h.release-backup.<pid>sibling and anEXITtrap is installed to restore it. The build then sees nosecrets.h, the__has_includeguard ininclude/app_config.hskips the include, andWIFI_DEFAULT_*/MQTT_DEFAULT_*all fall back to"". - Post-build leak scan. Every quoted string literal of ≥ 4 chars is parsed out of the stashed backup and grep'd against each built
.bin. Any match aborts the release with aLEAK:line naming the value. This is the actual safety net — layer 1 is cheap prevention.
tools/release.sh # build, scan, tag, release
tools/release.sh --notes-only # print suggested release notes and exitThe script refuses to run on a dirty tree or when origin/main is ahead of HEAD. Release artifacts are staged to release/<version>/ (gitignored) and uploaded as GitHub release assets alongside a SHA256SUMS file.
If you change tools/release.sh, validate it by temporarily commenting out the mv "$SECRETS" "$BAK" line and re-running — the leak scan must catch the leak and abort with a LEAK: line before the upload step. Restore the mv after the test.
The wake state machine, captive-portal provisioning, NVS schema, and MQTT contract are forked verbatim from dmellok/tesserae-esp32-bin-client (the 13.3" Spectra E6 sibling).
The PhotoPainter-specific code owes nearly everything that works to aitjcize/esp32-photoframe, whose mature open-source firmware for this exact device was the only reference that actually drove the panel cleanly on our hardware. Three load-bearing pieces are ported from it:
- AXP2101 wake pulse in
src/pmic.c— pulsing the PMIC's IRQ pin (GPIO21) LOW for >16 ms to wake its I²C interface after a sleep state. Without this, every I²C transaction returnsESP_ERR_INVALID_STATEand battery telemetry / rail control are dead. Ported fromcomponents/pmic_driver_axp2101/src/axp2101.cpp. - I²C bus rescue in
src/pmic.c— 9 SCL pulses + STOP condition via open-drain GPIO beforei2c_new_master_bus, in case a slave is mid-byte from a previous power cycle. Ported fromcomponents/board_hal/src/driver_waveshare_photopainter_73.c. - Panel driver protocol in
src/epd_driver.c— sending the command byte through the SPI peripheral's command phase (SPI_TRANS_VARIABLE_CMD), streaming the frame in 128-byte stack-buffered chunks (PSRAM is unreliable as a direct SPI DMA source on ESP32-S3), and ordering the refresh asreset → init → DTM → data → PON → DRF → POF → DSLP. Our first attempt — which followed Waveshare'sxiaozhi-esp32reference atwaveshareteam/ESP32-S3-PhotoPainter— issued PON during init and DMA'd directly from PSRAM, and the panel hung at every refresh. Aitjcize's pattern recovers reliably. Ported fromcomponents/epaper_driver_ed2208_gca/src/driver_ed2208_gca.c.
The panel command-byte values themselves still come from Waveshare's waveshareteam/ESP32-S3-PhotoPainter reference.
AGPL-3.0-or-later. See LICENSE.