Commit Graph
11 Commits
Author SHA1 Message Date
tfaour 914eaed71c Add CLAUDE.md and docs/widgets.md; fix stale single-mode architecture docs
Repo-tracked context so a fresh Claude Code session (this machine or a
remote/cloud one) gets accurate project context without relying on this
session's local, machine-specific memory: repo conventions (no
co-author trailers, flag copyleft deps explicitly, scope security
gates broadly -- each backed by a real past incident), testing/deploy
workflow, and pointers into the existing docs.

docs/architecture.md's sequence diagram and boot-flow text still
described the pre-widget-system single-photo-queue model (e.g. "force-
advance to next queued photo") even though that was fully replaced by
the widget system across this branch's recent history -- fixed, and
added docs/widgets.md distilling the widget system's actual design
(data model, grid placement, compositor, button-action dispatch) as
current-state documentation, including the still-open Phase 6 cleanup
(legacy Frame columns not yet dropped) as a known gap.
2026-07-24 20:04:08 -04:00
tfaour 4f4b2844e6 Firmware: WiFi fast-connect cache (skip scan + DHCP on the next wake)
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.
2026-07-20 23:46:57 -04:00
tfaour 37145ef135 Docs: clarify battery divider resistor value is flexible, not fixed at 200k
It's a ratio divider -- what matters is the two resistors matching each
other, not hitting 200k exactly. Also note sdkconfig.xiao now enables
FRAME_BATTERY_ADC_GPIO by default, which the doc's "off by default"
line no longer reflected.
2026-07-20 23:08:31 -04:00
tfaour a3ab6c5f13 Firmware: OTA client, dual-board build (devkit/XIAO), version reporting, XIAO fixes
Build and push server image / build-and-push (push) Successful in 36s
- 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.
2026-07-20 22:24:15 -04:00
tfaour 015993af00 Add battery level reporting (Kconfig-gated) and display orientation
Build and push server image / build-and-push (push) Successful in 32s
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).
2026-07-19 22:11:14 -04:00
tfaour d5de882b1e Fix stale documentation found by a doc-accuracy audit
firmware/README.md's HTTP vs HTTPS section still described the
public-CA-bundle trust approach that was tried and abandoned in favor
of pinning one specific certificate -- rewritten to match what's
actually there. docs/architecture.md was missing the back-photo button
entirely (sequence diagram and boot-flow bullets only covered next) and
still said the system talks "over plain HTTP" despite HTTPS support.
docker-compose.yml.example's MANAGEMENT_TOKEN comment understated its
scope (said "the web UI", omitting that every /frame/* endpoint is
gated too).
2026-07-19 15:20:12 -04:00
tfaour 3868d357ff Add back-photo button; consolidate reset/manage onto one hold-duration button
Build and push server image / build-and-push (push) Successful in 32s
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.
2026-07-19 12:53:26 -04:00
tfaour f74085cedf Add third button: "scan to manage" QR overlay on the current photo
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.
2026-07-19 00:50:07 -04:00
tfaour b9649c35ec Skip redundant panel refreshes and fetch the image before the config check
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.
2026-07-18 23:51:01 -04:00
tfaour d395cf3bb9 Add two physical buttons: factory-reset and next-photo
Build and push server image / build-and-push (push) Successful in 35s
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.
2026-07-18 23:28:36 -04:00
tfaour 7013311249 Add READMEs, docs, and LICENSE for publishing
Build and push server image / build-and-push (push) Successful in 32s
- LICENSE: MIT, with attribution notes for the vendored qrcode/epaper_fonts/
  dns_server code and the epd7in3e driver's transcription of Waveshare's
  register sequence.
- Top-level README.md: project overview, hardware list, quick-start
  pointing at firmware/ and server/, repo layout, license, Claude Code
  attribution.
- firmware/README.md: full rewrite (was still the stock ESP-IDF captive
  portal example's README) -- build/flash instructions, Kconfig reference
  table, first-boot walkthrough, and how to reset to provisioning mode via
  NVS erase (the only way in right now; a proper reconfigure trigger is a
  future addition).
- docs/hardware.md: wiring table + parts list + strapping-pin/SPI-speed notes.
- docs/architecture.md: sequence diagram and walkthrough of the full
  provision -> connect -> fetch -> display -> sleep cycle, plus the
  reasoning behind doing image processing server-side and reusing Immich's
  face detection instead of bundling a detector.
- server/README.md: fixed stale endpoint docs (missing GET /frame/config,
  POST /api/config still describing removed immich_url/api_key fields).
2026-07-18 16:47:30 -04:00