fetch_face_labels() clamped the server-reported label count against
max_labels by casting the count to int first -- a value >= 2^31 (a
perfectly ordinary decimal in JSON) went negative under that cast, so
the comparison was always false and the clamp never fired. The loop
then ran with the full, unclamped count, writing past the caller's
fixed MANAGE_FACE_LABELS_MAX-element stack array on a crafted
/frame/face-labels response. Reachable by a compromised/malicious
tools server, or a MITM on the default plain-HTTP connection.
Fixed by comparing unsigned instead of casting to int.
battery_read_percent() was called once at the very start of boot, before
WiFi even connects, and that value was reused both for the manage-menu
overlay and the server report. Taken right after a reset (e.g. the OTA
reboot that immediately precedes it), the rail may still be settling --
plausible source of noisy jumps in reported battery level.
Now there's a single read, in frame_client_run() right before
report_battery(), after the photo (and manage overlay, if shown) is
already on the panel -- the fetch/display work already done this cycle
is the settle time, no delay to guess. The manage overlay no longer
needs an early local reading at all: it shows the server's last-known
value instead, added to the /frame/photo-info response it already
fetches.
The frame-claiming pipeline, end to end. Firmware: every request now
carries ?id=<12-hex STA MAC> via build_url (mirrored in build_ota_url),
and the captive portal's success page became a redirect that hands the
user's browser to <server>/claim?device_id=... after ~7s -- enough time
for the phone to drop the provisioning AP while the device reboots.
The server pushes a per-frame device token through /frame/config during
a one-time handshake; the firmware persists it to NVS (a dedicated
single-key write that deliberately doesn't reset the connected-once
flag or WiFi cache) and prefers it over the provisioned shared token
from the next request on. Config response buffer grows 256->512. Both
board variants compile clean; new firmware also works against an old
server (which ignores ?id=) and old firmware against this server (the
phase A legacy mapping), so either deploy order survives.
Server: /claim lands the captive-portal redirect -- claim-gated signup
(a valid unclaimed/unregistered device id IS the enrollment invitation),
pending claims for the user-beats-the-frame race (auto-attached at
self-registration, 24h expiry), and a waiting page that refreshes until
the frame checks in. Unclaimed/unconfigured frames get a rendered
instruction placeholder with a QR from /frame/image (200, never an
error loop) -- new qrcode dep, placeholder shares the exact
quantize/pack path photos use.
The on-frame manage QR now resolves to a limited no-login page: scans
of / carrying device credentials (new ?id&token or the legacy shared
token) 303 to /m/<manage_token>, which allows exactly view queue,
show-next, advance, back, and scoped thumbnails -- no settings, no
removal, no other frames. Full control means logging in.
One real protocol hole found by simulating full wake cycles: after
self-registration the device could never authenticate again (the wake
cycle fetches the image BEFORE /frame/config delivers its token).
require_device now treats the id itself as the credential until the
first authenticated request flips device_token_ack -- the same trust
level as open registration, closing permanently once the handshake
completes.
All firmware-related controls (manual upload, Gitea repo URL,
auto-update checkbox, detected board, Update frame button) now live in
one "Firmware update" card instead of being split across the main
Settings form and a separate card.
The board variant used to pick a Gitea release asset was a dropdown
the user had to set by hand and could get wrong. The device now
reports it itself via a new X-Frame-Board header (CONFIG_FRAME_BOARD_NAME,
"devkit" by default, "xiao" in sdkconfig.xiao) on every /frame/config
poll, stored as device_board_variant -- the server learns it instead.
Update checks/applies are gated on the board being known, since there's
nothing to fetch until a device has checked in at least once.
After a successful home-WiFi connection, caches BSSID/channel and
IP/netmask/gateway/DNS in NVS. The next wake's first connect attempt
uses the cached BSSID/channel (skips the all-channel scan) and applies
the cached IP directly once the link comes up (skips DHCP) -- a couple
fewer seconds of radio-on time per wake, free every wake since nothing
about the network actually needs renegotiating most of the time.
Falls back to a normal scan+DHCP attempt, and clears the cache, if: the
fast attempt itself fails, or it "succeeds" at the WiFi layer but the
full fetch cycle then fails anyway (a stale cached IP/DNS/gateway that
associates but can't actually reach the server). Also cleared on
(re)provisioning and factory reset, since a new network shouldn't try
to reuse the old one's cache.
The static-IP path needed care to get right without touching untested
territory: esp_netif_set_ip_info() only posts IP_EVENT_STA_GOT_IP (what
the existing connect-wait logic blocks on) once the netif is already
up, which the internal netif-glue's own WIFI_EVENT_STA_CONNECTED
handler guarantees by running first (registered earlier, in
esp_netif_create_default_wifi_sta()) -- confirmed against ESP-IDF's own
static_ip example and esp_netif_handlers.c source rather than assumed.
Falling back after a failed fast attempt also needed an explicit
esp_netif_dhcpc_start() first: esp_netif_dhcpc_stop() leaves the netif's
DHCP status STOPPED rather than resetting to INIT, and left alone the
glue would silently re-post the stale cached IP on the next connect
instead of actually running DHCP (esp_netif_action_connected).
Version bumped to 1.1.0 (real feature, not just a fix); build-verified
clean on both board configs (devkit 8MB, XIAO 4MB), no new warnings.
- 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.
Found by a thorough code review:
- server/app/config.py's load()/save() each locked only their own file
I/O, not the full read-modify-write cycle each route does around
them. Since uvicorn dispatches sync routes to a thread pool, two
concurrent requests (e.g. the device's own poll landing alongside a
web UI edit) could each load() the same on-disk state and the
second's save() silently clobber the first's changes. Added
config.locked() (backed by an RLock, since load()/save() also take
the lock internally) and wrapped every mutating route's
load/mutate/save span in it -- kept outside the lock wherever a route
also does slow Immich network I/O, re-loading fresh state right
before the actual mutation instead. Verified with a new concurrency
stress test (many concurrent /api/queue/promote and /api/config
calls) alongside the existing scratch suite.
- firmware/main/root.html's SSID/password/toolsserver/access-token
inputs had no maxlength, so pasting something longer than the
matching NVS buffer (wifi_provisioning.h's FRAME_CFG_*_MAX_LEN) was
silently truncated with no indication why the device later can't
connect or gets 401s.
- frame_client.c's share_url buffer (256 bytes) could be too small in
the worst case -- toolsserver (128) + "/frame/share/" + asset_id (47)
+ "?token=" + access_token (64) can reach ~266 bytes, silently
dropping the token off a request that would then just 401 with no
obvious cause. Widened to 320.
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.
The management token only gated / and /api/* -- every device-facing
/frame/* endpoint (including /frame/image, which serves the actual
photo bytes) stayed open regardless. That was fine while the server
was assumed LAN-only, but defeats the point now that HTTPS exists
specifically to let this sit behind a public hostname.
build_url() (frame_client.c) is the one chokepoint all firmware-side
URL construction already went through, so it now appends ?token= to
every request it builds -- device fetches and QR-embedded links alike
-- instead of that being bolted on per-callsite. Server-side, the
former require_management_token dependency (renamed require_access_token)
is applied to /frame/config, /frame/image, /frame/advance,
/frame/photo-info, /frame/face-labels, and /frame/share/{asset_id} too.
/health stays open -- pure liveness, nothing sensitive to protect.
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.
Two rounds of follow-up work on the manage-button overlay:
1. Location formatting: US/Canada now show abbreviated state/province
("CA", "ON") instead of the full name, other countries show the full
country name, and each is its own line (was one line, now wraps to
two) so longer international place names have more room without
threatening to overlap the top-right QR box. The bottom-left share QR
also gets a "SCAN TO DOWNLOAD" caption.
2. Escalating menu: pressing the manage button again while its overlay
is already up adds a second level -- each Immich-identified person's
name labeled next to their face in the photo (using Immich's own
face recognition/People data, no detection/recognition added to this
project). A third press exits immediately instead of waiting out the
30s auto-revert timer. No new Immich API needed -- GET /api/faces
already embeds a nullable person.name per face; new
server/app/face_labels.py maps a named face's box into the final
800x480 frame's pixel space (reusing crop-box math extracted from
image_pipeline.py's face-aware cropping). Capped at 4 named faces,
sized to a real firmware RAM budget: each label is its own malloc'd
overlay region on the device, alongside the 4 fixed corner regions
already in use. New GET /frame/face-labels returns a flattened
fixed-slot JSON shape (not a real array) so firmware's existing
flat-scalar parser can read it without needing an actual array
parser. No persistent state needed for the escalation itself -- it's
all local control flow within one continuous awake session
(frame_client.c's run_management_menu()).
Two changes, bundled since they landed in the same session and touch
overlapping files:
1. Fix: "Show next" sent the browser's full queue snapshot to
POST /api/queue/reorder, which hard-rejected if the server's queue
had shifted since the last fetch (e.g. right after a queue-length
trim). New POST /api/queue/promote moves one photo to the front
authoritatively, with no dependency on client staleness. /reorder
itself is now tolerant too -- unrecognized IDs are dropped and
missing ones appended, instead of rejecting the whole request.
2. Feature: the manage button's overlay now also shows the photo's
location (top-left, only if Immich reverse-geocoded it from GPS
EXIF), the date it was taken (bottom-right), and a QR code (bottom-
left) linking to a 30-minute public Immich share link -- created
lazily when someone actually scans it, not when the button's
pressed. New server endpoints GET /frame/photo-info and
GET /frame/share/{asset_id} (scoped to the frame's current/queued
photos, not any arbitrary Immich asset). Firmware-side, the overlay
mechanism generalizes from one spliced region to up to four
(manage_qr_overlay.c), each its own small buffer, still never
holding the full frame in RAM.
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.
The panel driver now splits writing a frame into its SPI buffer
(epd_write_frame(), which also computes a CRC32 as it streams) from
actually triggering the physical refresh (epd_turn_on_display()).
frame_client.c compares the new CRC against the last one that was
actually refreshed (persisted in NVS) and skips the refresh entirely
when they match -- e.g. a reboot redisplaying the same photo before the
server's refresh interval elapsed no longer causes a visible flash for
no visual change.
Also reorders the per-wake fetch cycle: the image fetch (15s timeout)
now goes before the config check (3s timeout), instead of after. The
config check's tighter timeout was intermittently tripping on
connection-setup latency that's common on the first request after
waking from a long deep sleep (e.g. stale ARP); putting the more
tolerant request first absorbs that latency, and the config check then
rides the connection it already warmed up.
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.
Found on hardware: the Tools Server field was pointed at Immich's own
port instead of the frame server's, so /frame/image was actually hitting
Immich and getting back a small error response (~10KB) instead of a
192,000-byte frame. epd_display_stream() logged a size-mismatch warning
but called epd_turn_on_display() anyway, physically refreshing the panel
with a buffer that was ~95% whatever was left over from before -- visible
as "garbage" on screen, overwriting a previously-good image.
epd_display_stream() now returns ESP_ERR_INVALID_SIZE instead of
refreshing when the stream doesn't supply exactly EPD_FRAME_BYTES. Since
this check happens before epd_turn_on_display() is ever called, the
pixel data that *did* arrive only ever reached the panel's internal RAM
over SPI, not the physically visible display, so aborting here leaves the
screen exactly as it was.
This also means fetch_and_display() failing now always implies the panel
was never touched -- simplified frame_client_run() accordingly (dropped
the now-always-true/false out-param that used to distinguish "failed
before vs. during streaming", and always shows the FAILED status screen
on any fetch/display error, since it's now guaranteed safe to do so).
RST/power-on trigger: checks esp_reset_reason() at the very top of boot.
ESP32-C6 can't electrically distinguish the RST/EN button from a genuine
power-on (both report ESP_RST_POWERON -- confirmed against ESP-IDF's own
docs, ESP_RST_EXT is explicitly "not applicable"), so POWERON is treated
as "user wants to reconfigure" and routes straight to provisioning. Safe
because the device's only normal restart path is ESP_RST_DEEPSLEEP (its
own scheduled wake), and crash-type resets (brownout/watchdog/panic)
report their own distinct reasons, not POWERON -- so a flaky power supply
or transient crash won't get bounced into provisioning, only an actual
power cycle or RST press will (which plausibly means the frame is being
moved/redeployed anyway).
Auto-fallback: a new NVS-persisted consecutive-failure counter
(frame_config_record_server_failure/reset_server_failures) tracks wakes
where the tools server was unreachable. After
CONFIG_FRAME_REPROVISION_AFTER_FAILURES in a row (default 12, ~1hr at the
retry interval), the device clears its stored WiFi config and
esp_restart()s rather than calling wifi_provisioning_start() directly --
doing that inline would mean initializing the display driver a second
time in the same session (frame_client_run already did once), the same
class of double-init bug hit earlier with WiFi. The next boot's
frame_config_load() naturally reports "not provisioned" and routes
through the existing, already-tested provisioning path with a single
fresh epd_init(). Solves the "I moved the server to a new address" case
without needing USB access.
Both frame_config_save() (fresh provisioning) and any successful server
contact reset the failure counter.
Replaces check_server_reachable() (bare bool, GET /health) with
fetch_frame_config(), which GETs the server's new /frame/config endpoint
instead -- doubles as the reachability check (any completed HTTP response
counts, same as before) and delivers the server-configured
refresh_interval_s, used for the success-path deep sleep duration instead
of the Kconfig-only default.
Parses the tiny JSON response with a hand-rolled scalar extractor
(json_extract_uint) rather than pulling in a JSON library -- cJSON isn't
bundled in this ESP-IDF install, and a single flat integer field doesn't
justify a new dependency. Verified standalone against exactly the JSON
shape the server emits, including a missing-field fallback case.
FRAME_SLEEP_INTERVAL_S (Kconfig) is now just the fallback used before the
device has ever reached a configured server, or if the response is
missing/unparseable -- documented as such in its help text.
Two bugs found testing against a real (partially-configured) server:
- The reachability check hit HEAD / with -- our server only registers
GET on that route, so it always got a 405. Harmless for the check
itself (any completed HTTP response counts as "reachable"), but noisy
and semantically wrong. Points at GET /health instead, which exists
for exactly this.
- fetch_and_display() failing before any pixel data was sent (e.g. a
400/404 on /frame/image) was treated the same as a mid-stream failure,
which skips drawing a status screen to avoid compounding flashing on
top of an already-refreshed panel. But a pre-stream failure never
touches the panel at all, so skipping the status screen there just
left the old provisioning QR code on screen with no indication
anything had gone wrong. fetch_and_display() now reports whether
streaming ever started so the caller can tell the two cases apart.
- The status screen now always shows on the very first successful
connection after (re)provisioning, regardless of outcome, via a new
"connected_once" NVS flag that frame_config_save() resets on every
fresh provisioning event. Later wakes skip it on success (straight to
the photo) but still show it on any failure, matching the intent from
the original status-screen feature.
frame_client_run() now does what it was always meant to: probe the tools
server, GET /frame/image and stream the response straight into the panel
via epd_display_stream() (esp_http_client's manual open/fetch_headers/read
API pulls in exactly the shape epd_display_stream()'s read_fn expects, so
the ~192KB frame never sits in RAM at once), then epd_sleep() and
esp_deep_sleep_start() for an hour.
Skips the WiFi/server status checklist screen on the happy path now that
there's a real photo to show instead -- three full refreshes every single
hour (status-pending, status-final, photo) wasn't worth it once bring-up
was actually working. Still shows it (status FAILED) when the server
isn't reachable, since nothing's been drawn yet that cycle and it's the
cheapest useful diagnostic. A mid-fetch failure after the panel's already
started refreshing just logs and retries sooner, rather than compounding
with a second refresh.
New Kconfig knobs: FRAME_FETCH_TIMEOUT_MS, FRAME_SLEEP_INTERVAL_S
(default 3600s), FRAME_RETRY_INTERVAL_S (default 300s on failure).
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.
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.