Server: Frame.panel_type (new column + migration) is auto-derived from
the device's reported board (X-Frame-Board), never user-set -- the
panel is a property of the hardware, not a picker in the UI.
image_pipeline's packing/render pipeline is parameterized by panel
geometry instead of hardcoded 800x480 globals, with the real confirmed
13.3in geometry (1600x1200) registered alongside the original 7.3in
panel. Existing 7.3in frames are unaffected (column default + board
mapping both resolve to the original panel).
Board identifiers are also renamed (devkit/xiao -> devkit_esp32c6/
xiao_esp32c6, plus new "ee02") since the EE02 board also carries a XIAO
module -- "xiao" alone stopped disambiguating hardware. The server
keeps accepting the legacy bare names indefinitely for already-flashed
devices.
Firmware: scaffolds a third build target (ee02, ESP32-S3 -- a real
chip-target change, not just a same-chip Kconfig variant like xiao) and
a new epd13in3e driver component skeleton. The actual panel init/LUT/
refresh register sequence isn't ported from vendor demo code yet (none
was available), so that component deliberately fails to compile
(#error) rather than risk sending unverified register values to real
hardware -- devkit/xiao are unaffected and build identically to before.
CI's ee02 build step is continue-on-error for the same reason.
Deletes manage_qr_overlay.c/.h; frame_client.c's manage-button flow is
now one fetch with &manage=1 instead of on-device QR/text generation
plus separate photo-info/face-labels requests.
- version.txt + esp_app_desc_t version reporting (X-Frame-Version header);
new ota_update.c checks the server's advertised version against the
running one and streams+applies an update via esp_https_ota, gated by
bootloader rollback (marks the image valid only after a full successful
cycle, so a bad update can't brick a wall-mounted frame).
- Dual-OTA partition tables: partitions.csv (8MB dev board, 2MB slots) and
new partitions_xiao.csv (4MB XIAO, 1.875MB slots -- the dev board's
table doesn't fit the XIAO's flash). New build_for_board.sh gives each
board its own build dir + generated sdkconfig via SDKCONFIG_DEFAULTS
layering, so switching boards never clobbers the other's config.
- fetch_photo_info()/fetch_face_labels() were using the short
reachability-check timeout even though the manage-menu path can be the
first (cold, TLS-handshake-paying) request of a wake cycle -- switched
to the longer fetch timeout to stop spurious ESP_ERR_HTTP_CONNECT
failures.
- XIAO: the RF switch that selects onboard vs. external antenna
(GPIO3/14) isn't initialized by plain ESP-IDF the way Seeed's Arduino
package does it, leaving WiFi unable to reliably reach the antenna at
all -- new board_antenna.c powers the switch and selects the onboard
antenna, gated behind FRAME_XIAO_ANTENNA_INIT (on by default in
sdkconfig.xiao). Also remaps the EPD DC/RST/BUSY pins, since the dev
board's defaults (GPIO9/10/11) aren't physically exposed on the XIAO.
Battery (firmware + server, disabled by default): new battery.c reads
a 2x200k voltage divider via ADC oneshot with curve-fitting calibration
(the ESP32-C6's scheme), maps through a piecewise LiPo discharge curve,
and restores the pin to button duty after each read -- the settled
XIAO ESP32-C6 design shares the back button's GPIO0/A0, time-shared per
wake. Skipped entirely when on mains (a 2x100k VBUS divider into a
spare digital pin -- the 5V pin is dead on battery power, so presence =
mains, where the charging voltage would read misleadingly full) or when
the reading is implausible. The manage overlay gains a battery region
(static outline glyph + "NN%", below the manage QR, all menu levels),
and the device POSTs to the new /frame/battery endpoint after a
successful fetch; the server stores percent + as-of timestamp, exposed
via /api/queue and shown in the web UI. FRAME_BATTERY_ADC_GPIO /
FRAME_VBUS_SENSE_GPIO default to -1 (fully inert on the dev board);
compile-verified both disabled and enabled, hardware bring-up deferred
until the ordered XIAO + batteries arrive.
Orientation (server-side only): new config setting + web UI dropdown
(landscape / portrait / landscape_flipped / portrait_flipped). Photos
are composed/cropped at the logical hanging shape (portrait crops at
480x800, so face-aware crops match how the frame actually hangs), then
rotated losslessly into the panel's native 800x480 byte layout after
dithering -- the device never knows. Face-label anchors are transformed
through the same rotation (logical_to_native()) so they stay attached
to faces on rotated frames. Known documented limitation: the on-device
manage overlay still renders in native orientation, so it appears
sideways on a portrait-hung frame (QRs scan at any rotation; text reads
sideways).
Root-caused the earlier "No matching trusted root certificate found"
failure properly this time by reading ESP-IDF's actual bundle-matching
code (esp_crt_bundle.c): it looks up a trusted root by the ISSUER name
of whatever certificate it can't otherwise validate, not by matching
the presented certificate itself. The live server's chain ends in a
GTS Root R4 certificate cross-signed by the old GlobalSign Root CA R1
(common Cloudflare/Google Trust Services practice, for compatibility
with older/embedded clients) -- and ESP-IDF's current bundle snapshot
has dropped that old GlobalSign root entirely, so the lookup came up
empty. This was a general gap, not something specific to this one
deployment's cert.
Fix: keep the standard public CA bundle (esp_crt_bundle_attach) as the
trust mechanism -- so any normal reverse-proxy cert (Let's Encrypt,
etc.) works out of the box -- and add the one missing root on top via
ESP-IDF's CONFIG_MBEDTLS_CUSTOM_CERTIFICATE_BUNDLE mechanism
(sdkconfig.defaults), which appends a project-supplied cert file to the
bundle at build time. Fetched GlobalSign's official Root CA R1 cert and
cryptographically verified (openssl verify) it actually validates the
live server's certificate before embedding it -- see
firmware/main/certs/additional_root_ca.pem (replaces the old
tools_server_ca.pem, which pinned one exact certificate directly and
would've broken for anyone else's reverse proxy). Confirmed working
against the real deployment on hardware.
Back button (new GPIO0, POST /frame/back): the server now tracks a
bounded history of previously-current photos (photo_queue.py), pushed
to on every advance (auto or forced) and popped by back_forced() --
symmetric with advance, so pressing next afterwards returns to right
where you were. frame_client.c's force_advance bool becomes a 3-way
fetch_action_t (NORMAL/ADVANCE/BACK) threaded through the whole fetch
path.
Also folds the separate reset and manage buttons onto one pin
(combo_button.c, replacing reset_button.c/manage_button.c entirely),
disambiguated by hold duration: quick press shows the management menu
(unchanged), ~3s hold-then-release soft-resets (esp_restart(), config
kept -- new), ~15s hold factory-resets (today's old reset behavior,
extended from 10s for clearer tier separation). Driven by a production
board (Seeed XIAO ESP32-C6) exposing only 3 of the ESP32-C6's 8
deep-sleep-wakeup-capable GPIOs -- next/back keep their own dedicated
pins where instant response matters most, everything else shares the
third pin via timing instead of needing its own. Same three-pin layout
now works on both the dev board and the production board.
Fixed a fast-tap bug in combo_button_check() before shipping: it only
did a live gpio_get_level() read to decide whether the button was
pressed at all, so a press fast enough to already be released by the
time boot reached that check was missed entirely (treated as "never
pressed" rather than "quick press"). Added the same latched
esp_sleep_get_gpio_wakeup_status() check the other buttons already use
for exactly this reason.
The Tools Server hostname turned out to be Cloudflare-proxied, not a
direct connection to nginx -- so the ESP32 (and any browser) sees
Cloudflare's own edge certificate (issued by Google Trust Services),
never the Origin CA cert, which only ever sits on the Cloudflare-to-
origin leg. Confirmed on hardware: ESP_ERR_HTTP_CONNECT.
Tried switching to ESP-IDF's built-in public CA bundle instead
(esp_crt_bundle_attach) as the more general fix, but that also failed
on hardware ("No matching trusted root certificate found") -- the
bundle's copy of the relevant Google root has the same name and public
key as the live one but a different serial/signature (a reissue), and
the bundle does an exact byte-level match, not a semantic one.
Simplest reliable fix: embed the exact certificate the proxy actually
presents (extracted live via openssl s_client, see
firmware/main/certs/tools_server_ca.pem) and trust that directly via
cert_pem, sidestepping bundle-matching semantics entirely. Documented
in firmware/README.md how to re-extract if the proxy's CA ever changes.
ESP32 side can now reach the tools server over HTTPS: the Tools Server
field accepts an https:// address for a TLS-terminating reverse proxy
in front of the server (which still only ever speaks plain HTTP
itself), trusting Cloudflare's Origin CA root (embedded at build time)
since that's the common way to get a real cert on a private origin.
Every URL the device builds -- image fetch, config check, manage-menu
data, the QR codes' own links -- goes through one build_url() helper
that picks the scheme from what's configured.
Also adds an optional MANAGEMENT_TOKEN (docker-compose.yml) that gates
the web UI (/, /api/*) behind a shared secret -- unset by default, so
existing trusted-LAN deployments are unaffected. The same token is
entered once during the ESP32's captive-portal setup and gets baked
into the manage-menu's QR code (?token=...), so scanning it just works;
visiting the page without a valid token shows a plain entry prompt
instead of the config UI, and a valid query-param hit sets a cookie so
the page's own fetch()/<img> calls stay authorized for the rest of the
visit. Device-facing /frame/* endpoints are unaffected -- a separate,
already-documented trust boundary.
Pressing the manage button (GPIO1) overlays a small QR code -- "SCAN TO
MANAGE" -- in the top-right corner of whatever photo is currently on
screen, linking to the server's config page, then reverts to the plain
photo after 30 seconds.
The overlay is spliced into the existing streaming fetch as chunks pass
through (frame_client.c's http_read_fn), rather than buffering the full
192,000-byte frame in RAM: only the small overlay rectangle itself
(~30KB) is ever held in memory, generated via new stride-parameterized
drawing helpers (epd_draw_*_ex in epd_draw.c) that let the existing
QR/text drawing code target an arbitrarily-sized buffer instead of a
full-frame one. epd7in3e.c is untouched -- it has no idea an overlay
exists.
Factory-reset (GPIO3, hold 10s): clears stored WiFi/server config and
restarts into provisioning -- the deliberate, USB-free replacement for
the earlier reverted RST-based auto-reprovisioning idea.
Next-photo (GPIO2, tap): wakes the device and forces the server to
advance immediately via a new POST /frame/advance, instead of waiting
for the refresh interval. Both buttons arm themselves as deep-sleep GPIO
wakeup sources so a press is noticed promptly even while asleep.
Also makes GET /frame/image side-effect-free: it now only advances once
refresh_interval_s has elapsed since the current photo was set (tracked
server-side), so a device reboot for any reason just redisplays the
current photo instead of silently skipping ahead. The server maintains a
small reorderable upcoming-photos queue, viewable and rearrangeable from
the web UI.
After a successful home WiFi connect, frame_client_run() now redraws the
panel as a two-row checklist (WiFi row with a checkmark, server row) so
the connection sequence is visible on-device rather than only in serial
logs. Refreshes once with the server row pending, probes the tools server
with a plain HTTP HEAD (any response, even 404, confirms the socket-level
connection works -- there's no real server yet), then refreshes again with
the final result. Two refreshes rather than one to actually show staged
progress, at the cost of the extra refresh time inherent to this panel.
Also fixes a second hardware-verified bug in the same area: on a failed
STA connect falling back to provisioning, wifi_init_softap()'s
esp_wifi_init() call was aborting with ESP_ERR_INVALID_STATE, because
frame_wifi_connect_sta() only stopped the WiFi driver on failure rather
than fully deinitializing it (and destroying the STA netif) before
handing back control.
Vendors two small MIT/BSD-3-Clause libraries rather than hand-rolling
either: Nayuki's qrcodegen (QR matrix generation) and Waveshare's Font24
bitmap table from their e-Paper repo (same repo the epd7in3e driver came
from) for rendering readable text on the panel.
qr_onboarding_show() builds a standard WIFI:T:WPA;S:...;P:...;; payload,
rasterizes the QR module matrix plus the SSID and password as plaintext
underneath (for anyone provisioning from a device that can't scan a QR)
onto a malloc'd frame buffer, and pushes it to the panel via
epd_display_buffer(). The buffer is heap-allocated on demand rather than
statically reserved, since 192KB held permanently in BSS would eat into
the RAM budget the HTTP fetch path (task 6) is specifically trying to keep
free.
Wired into wifi_provisioning_start() before the softAP comes up, so the
join instructions are already on-screen by the time the network is
joinable.
Ports Waveshare's official EPD_7in3e.c register/refresh sequence (the
panel has no public datasheet, so their reference driver is the source of
truth) to an ESP-IDF component using spi_master + gpio instead of the
bcm2835/RPi hardware abstraction the reference targets.
Unlike the reference driver, which toggles CS around every single byte,
this holds CS low for each logical command/data phase and DMAs pixel data
in 4KB chunks -- sending ~192,000 individual one-byte SPI transactions
would make a full refresh impractically slow.
Exposes a streaming API (epd_display_stream, pulling chunks from a
caller-supplied read_fn) so the eventual HTTP fetch path can feed the panel
without holding a full ~192KB frame in RAM, plus an in-memory convenience
wrapper (epd_display_buffer) for cases like the upcoming QR code screen
where buffering the whole frame is fine.
Wired into wifi_provisioning_start() as an init + white-clear for now, to
prove the driver builds/links/runs at the right point in the boot sequence
ahead of the actual QR code content.
Splits provisioning (softAP + captive portal + NVS-backed config) into
wifi_provisioning.c and home-network connection into frame_client.c.
/save_config now parses the form body and persists it to NVS; on boot the
device goes straight to STA mode if a config exists, retrying a few times
before falling back to provisioning if the home network is unreachable.
The provisioning AP is now always named ESPRESSO with a random per-device
password (generated once, persisted in NVS) instead of a fixed Kconfig
value, drawn from a charset that avoids visually ambiguous characters since
it'll be read off the e-ink panel and possibly typed by hand.
Moves the existing ESP-IDF captive_portal example into firmware/ to make
room for the new FastAPI server and project docs, ahead of building out the
full ESPresso Frame project (ESP32-C6 + Immich-backed e-ink photo frame).