58 Commits

Author SHA1 Message Date
football2801 22c9edb09e fix(13e6): pre-rotate LANDSCAPE diagram for CCW-to-landscape rotation
The user rotates the frame 90° CCW into landscape (not CW as the
previous comment block assumed), so the LANDSCAPE orientation diagram
needs to be pre-rotated the opposite direction to land upright.

- Previous: ribbon on bottom edge, LEFT arrow, label rotated 90° CCW on
  the diagram's left side (matched CW user rotation; rendered upside-
  down once the user actually rotates CCW into landscape).
- Now: ribbon on top edge, RIGHT arrow, label rotated 90° CW down the
  diagram's right side. After the user's 90° CCW rotation it lands as
  wide rect, ribbon-left, up-arrow — correct upright landscape.

Adds right_arrow() helper as the mirror of left_arrow(). Regenerated
all three setup-screen .bins.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:15:59 -04:00
football2801 a31a39fdc4 feat(13e6): v1.0.0 — first known-good public release with WeVisto branding
Re-versions the 13.3" driver to a fresh v1.0.0 baseline. This is the
firmware/payload combination that's been verified end-to-end with the
WeVisto branding on a properly-powered Pi setup:

- 180° rotation of setup screens for ribbon-at-bottom mounting (from
  55ee5aa) — render path matches the server-side V2 physicalRotation=180.
- Clear-to-white pre-pass before each setup-screen draw (the d23f331
  change) so a transition from a full-color photo into the
  mostly-yellow AP screen doesn't leave ghost particles from the
  previous image.
- Setup screen renders the WeVisto wordmark (with yellow V), the
  Camogli harbor backdrop, two QRs, and the orientation tiles in full
  color over the existing hardware-SPI path.

A prior diagnostic detour (bit-banged SPI / multi-stage ghost_clear
cycles) was chasing what turned out to be a Pi 5 USB-A current budget
issue, not a firmware bug. With the host on a 27 W PSU and
usb_max_current_enable=1, hardware SPI at 4 MHz renders all six
Spectra-6 colors faithfully.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:41:37 -04:00
football2801 55ee5aa95c fix(13e6): 180° rotate setup screens for ribbon-at-bottom mounting
Matches the server-side V2 physicalRotationDegrees=180° introduced in
pictureFrame-webApp@b355572. The setup screens are firmware-drawn (not
server-rendered) so they need their own compensation:

- scripts/gen_screens_13e6.py rotates the PIL image 180° in save_bin()
  before packing to 4bpp; preview PNGs reflect the rotated layout too.
- All three bg .bins regenerated (ap_bg, ap_bg_retry, setup_bg).
- epd_driver.cpp QR overlay coords updated to the post-rotation
  positions (AP 642,590 → 40,492; Setup 313,750 → 313,276).
- PANEL_FW_VERSION → v1.0.2

To deploy: pio run -e <env> -t upload AND pio run -e <env> -t uploadfs
so the rotated .bins land in LittleFS alongside the new code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 13:19:52 -04:00
football2801 fc1367fc55 chore(13e6): bump PANEL_FW_VERSION to v1.0.1
First post-v1.0 driver release. Power-monitor telemetry from d900083
has been reverted (28b6a35) — clean release with no debug headers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:28:58 -04:00
football2801 28b6a353aa Revert "chore(13e6): TEMP power-monitor telemetry headers"
This reverts commit d900083398.
2026-05-15 20:28:22 -04:00
football2801 d900083398 chore(13e6): TEMP power-monitor telemetry headers
To validate the PIN_PWR rail-cut change (e2c9d8f) without a bench
multimeter, have the device report its previous cycle's awake time
and panel-init time on each poll:

  X-Prev-Awake-Ms       — millis() at the moment esp_deep_sleep_start
                          armed, last cycle. Total awake duration
                          since reset, ~5–10 s steady-state.
  X-Prev-Panel-Init-Ms  — duration of epd_init() last cycle. Spikes
                          here would suggest the rail isn't coming
                          back up cleanly after the GPIO-hold release.

Headers are sent only when the cached NVS values are non-zero (skips
the first boot under this firmware). All call sites marked `// TEMP:
power-monitor` for clean removal once the change is validated. Two
new NVS keys (tm_awk, tm_pin) sit alongside the existing ones; mock
Preferences extended with getUInt/putUInt to match.

Server side logs the headers via `device.poll.power_telemetry`
(separate commit in pictureFrame-webApp).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 14:15:37 -04:00
football2801 e2c9d8f1e4 feat(13e6): cut panel power rail in deep sleep via PIN_PWR + GPIO hold
The Waveshare board exposes PIN_PWR (GPIO 1) specifically so battery
designs can gate the panel rail between refreshes. Before this commit
PIN_PWR was driven HIGH at boot and never released, so the panel's
boost converter kept its quiescent draw (50–500 µA) through every
deep sleep. The e-ink particles are bistable so the displayed image
persists without VDD; dropping the rail is a free win.

Three pieces:
  • epd_sleep() drives PIN_PWR LOW after issuing the panel-internal
    DEEP_SLEEP command, then gpio_hold_en() latches the level so it
    survives the chip's RTC transition.
  • normal_operation_impl() calls gpio_deep_sleep_hold_en() just
    before esp_deep_sleep_start() so the per-pin hold extends through
    the deep sleep period itself (without this the holds release on
    the transition and the rail comes back up).
  • epd_setup_pins() calls gpio_hold_dis() at the very top to free
    PIN_PWR on wake before re-driving it HIGH; no-op on cold boot.

Tests: 47/47 pass. Added test/mocks/driver/gpio.h with no-op stubs so
the native test build links cleanly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 14:05:24 -04:00
football2801 8fb68e94e7 Revert "fix(13e6): X-Draw-Pending recovery handshake + CDC serial routing"
This reverts commit 3d7a793. The X-Draw-Pending header suppresses
rotation server-side whenever the firmware boots with NVS_KEY_DRAW_NEEDED
set — including overriding the cold-boot force-resync. If drawNeeded ever
gets stuck (e.g. an interrupted draw whose recovery branch itself fails),
the picture stops advancing entirely. Reverting until we have telemetry
confirming whether that flag is being asserted in the field; the bare
304-with-drawNeeded recovery branch alone is sufficient for the typical
power-loss-mid-draw case.

ARDUINO_USB_CDC_ON_BOOT=1 was bundled in the same commit and goes with
it — losing USB-serial visibility on the 13.3" until we re-add it cleanly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
13e6-v1.0.0
2026-05-15 09:12:49 -04:00
football2801 b0ea1ce216 feat(setup): Variant B full-bleed hero on 13.3 panel
Adopts the hero treatment Matt picked from the /tmp/setup-mockups
gallery: a 1200×460 harbour photo banner with the WeVisto wordmark at
200pt overlaid centred, then a 70-px accent band carrying the section
title. Replaces the prior 130-tall single band where the 110×110 logo
card couldn't render the Camogli photo recognisably under the 6-colour
palette.

Implementation notes:
- compose_hero_banner() crops from the hi-res IMG_2524.jpg (so we don't
  upsample the 900-square version), composites the SVG black-fade
  gradient, then Floyd-Steinberg dithers to the Spectra-6 palette so the
  photo reads as continuous tone instead of nearest-neighbour colour
  fields. Wordmark composited after the dither to keep text edges crisp.
- Compact orientation diagrams + smaller manual QR (box_size=5) so the
  AP screen's left column still fits the 4 steps + diagrams + help QR
  inside the 1070-px body left below the taller hero.
- Setup QR cell shrunk 16 → 14 (656 → 574 px) so the setup screen fits
  the QR + MAC chip + progress bar below the hero.
- Redundant two-line "Scan the QR to link this frame / to your
  wevisto.com account." dropped from setup screen — heading + label
  above the QR + MAC chip below it cover the same ground without
  crowding the post-hero body.
- epd_driver.cpp QR overlay coords updated to match: AP 230→590,
  setup (272,490,16) → (313,750,14).

compose_logo() (square card) kept for any future use; not currently
called by gen_ap/gen_setup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 23:16:43 -04:00
football2801 3420ec56f5 fix(brand): logo composited at SVG-native res, then downsampled
Previous render composed directly at the target size (110×110 / 44×44),
which produced thin text and lost the SVG's typographic intent. Now
the composition runs at SVG-native 320×320 — same coordinates as
webApp/frontend/public/logo.svg — and downsamples to the panel logo
size with LANCZOS. Adds stroke_width=2 around the wordmark to fake
font-weight 900 (DejaVuSans is only weight 700; no Black face is on
the build host, so this is the best approximation without bundling a
font binary into the firmware repo).

The yellow V comes through, the wordmark is heavier, and the harbour
background still palette-quantizes recognisably to Spectra-6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:49:23 -04:00
football2801 eff34717c9 feat(brand): real logo composited onto setup screens
Replaces the bordered text placeholder with the composed WeVisto logo
(harbour photo + dark gradient + 'WeVisto' wordmark with the yellow V)
in the top-right of every setup screen. Pure-PIL composition mirroring
webApp/frontend/public/logo.svg — no cairosvg/rsvg dependency needed.

Source asset: webApp/brand/IMG_2524-square900.jpg. Logo box went from a
300×92 wide placeholder to an 110×110 square on 13.3 and a 44×44 square
on 7.3 — matches Matt's request for a 'nice square, readable rendition'
and keeps a comfortable margin within each panel's header band.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:38:13 -04:00
football2801 c5bd0458ab fix(screens): wrap step subtitles to column width
Long step subtitles ("This will connect your phone to the WeVisto" /
"This will open the WeVisto setup page") overflowed the left column
and crossed the centre divider on both panels. Added a greedy word-
wrap helper and bumped step_pitch (13.3: 92→112, 7.3: 32→50) so the
wrapped second line has room below the first.

Regenerated both panels' setup_bg/ap_bg/ap_bg_retry assets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:24:14 -04:00
football2801 9829d1af37 feat(brand): AP SSID WeVisto-XXXX + logo placeholder + 4-step copy
Rename the AP broadcast SSID from PictureFrame-XXXX to WeVisto-XXXX
(operation.h:ap_ssid_from_mac + main.cpp:enter_provisioning). Tests
updated to match.

Setup screens (both panels):
- Top-right header chip replaced with a draw_logo_placeholder() box —
  a 'WeVisto' text mark with a 'PLACEHOLDER' subtitle. When the real
  brand asset lands, swap the function for a paste of the file at the
  same coordinates; no layout change needed.
- Step list rewritten to Matt's spec (4 steps, not 5):
    1. Turn on your WeVisto
    2. Unlock your phone
    3. Scan QR 1  — This will connect your phone to the WeVisto
    4. Scan QR 2  — This will open the WeVisto setup page
  Step 5 (type WiFi password) lived only in the on-panel guide; the
  user does that on the phone via the captive portal, where the
  prompt is already explicit.
- Regenerated both panels' setup_bg / ap_bg / ap_bg_retry assets via
  the gen_screens scripts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:02:53 -04:00
football2801 3358ec86ad feat(brand): captive portal copy from pictureFrame to WeVisto
Captive portal HTML title (<title>WeVisto Setup</title>) and the /log
diagnostic page header now use the public brand name. Recipient first
sees this when their phone joins the AP and the captive portal opens.

The AP SSID itself (PictureFrame-XXXX) is intentionally left alone —
changing it would force a coordinated reflash + reprint-of-setup-
instructions cycle and risk confusing recipients mid-setup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 21:51:49 -04:00
football2801 91cbe851a3 chore(screens): regenerated ap_bg outputs (companion to 8cbd035)
gen_screens scripts rewrite all six panels per run; ap_bg byte-output
shifted even though the AP-screen copy doesn't reference brand or URL.
Committed to keep data/ consistent with the scripts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 21:34:11 -04:00
football2801 8cbd035708 chore(branding): point firmware at wevisto.com
Flip APP_BASE_URL and the on-screen "go to <domain>/setup/..." text in
the rendered setup_bg images from pictureframe.edholm.me to wevisto.com.
Per the dual-domain migration plan (Option C — server keeps both alive
indefinitely), this only affects newly-flashed units; field devices on
the old URL keep working against the same backend.

Regenerated both panels' setup_bg.bin via gen_screens*.py so the
embedded URL in the on-screen QR overlay text matches the firmware's
runtime poll URL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 21:30:38 -04:00
football2801 3d7a793115 fix(13e6): X-Draw-Pending recovery handshake + CDC serial routing
When drawNeeded survives a power-loss-mid-draw, send X-Draw-Pending: 1
on the next poll so the server suppresses rotation advancement (incl.
the X-Boot-Reason: cold force-resync) and returns the SAME image back.
Without this, cold-boot rotation defeats the existing 304-with-drawNeeded
recovery branch — the device chases a fresh image every reset and the
13.3 panel ends up showing torn frames as draws keep getting interrupted.

Also enable ARDUINO_USB_CDC_ON_BOOT=1 for the 13.3 env so Serial routes
through the S3's native USB-CDC (visible as the "Espressif USB JTAG
serial debug unit" ACM port when awake). Without this, Serial goes to
UART0, whose pins aren't wired to either USB endpoint on the 13.3E6
board — making firmware logs invisible over USB and forcing reliance
on server-side telemetry alone.

Adds two unit tests covering header-present-when-set and absent-when-clear.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:43:08 -04:00
football2801 e9f2ec0629 test(firmware): broaden native-test coverage + gcov instrumentation
Extract the pre-first-image retry from normal_operation() into a
templated bootstrap_loop() helper in operation.h so the loop body
becomes unit-testable. Add four tests against it: two that verify the
X-Panel-Id header is sent on every poll and matches the compile-time
PANEL_ID (a silent server-side mis-routing risk if dropped), and two
that exercise the loop's exit-on-deep-sleep vs. iterate-while-204
behaviour.

Wire --coverage into env:native-test (compile + link via a post-script)
so `gcovr -r . --filter src/` produces a real number, and ignore the
stray *.gcov files gcovr drops at the repo root.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:25:00 -04:00
football2801 013e49d859 fix(13e6): partition + SPI corruption + bootstrap stay-awake
Three problems surfaced during the first 13.3" end-to-end run:

1) LittleFS IntegerDivideByZero on 200 → write /img.bin. Cause: the
   ~3.5 MB SPIFFS in default_16MB.csv can't fit three 960 KB setup
   screens + a 960 KB cached image (~3.84 MB). Switching to a custom
   partitions_13e6.csv with 24 MB LittleFS on the 32 MB flash.

2) Yellow wash across the panel on long SPI bursts. Cause: SPI DMA
   from a PSRAM-backed scratch buffer hits a cache-coherency window —
   the CPU's writes hadn't reached PSRAM yet when DMA read it. Push
   each half in 8 KB chunks through an internal-SRAM (DMA-coherent)
   scratch, and drop the bus clock to 4 MHz to match the 7.3"
   production speed.

3) Bootstrap window (no image yet) was deep-sleeping for 15 s between
   polls — each cycle a ~5 s ROM-boot + Wi-Fi reconnect, so the user
   waited ~20 s × N retries between scanning the setup QR and seeing
   their first photo land. Now normal_operation_impl returns early
   during bootstrap and main.cpp's normal_operation loops with a
   2 s delay, keeping Wi-Fi up. Once the first image arrives, the
   normal scheduled deep sleep takes over.

   Also fixes a related bug Matt called out: a transient TLS hiccup
   during bootstrap was hitting the 5xx fallback path and painting a
   full yellow fill over the green setup QR, leaving the user with
   no claim path. Criterion is now "does /img.bin exist?" (panel has
   something worth showing with a border) rather than "is currentImgId
   set?", so a fresh device with no cached image preserves the setup
   screen through transient network errors.

Diagnostic prints in the panel driver + [op] start/code lines in
normal_operation_impl that proved invaluable during bringup; leaving
them in for now. Tests updated for the new bootstrap semantics
(deep sleep no longer arms on bootstrap-cycle 204/404/5xx); 43/43
native tests pass, 7.3" production build stays byte-identical.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 11:50:36 -04:00
football2801 c75e4c003d ui(13e6): drop device-specific SSID chip placeholder
Replace the hardcoded "PictureFrame-91F8" in the AP setup screen with
a universal "PICTUREFRAME" brand chip. Firmware doesn't write text
into static .bin assets on this panel, so a per-device SSID placeholder
would lie on every other unit; the brand chip is the same image for
every frame.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 19:51:53 -04:00
football2801 8eec4bd5fa fix(13e6): SPI corruption fix + setup-screen polish
- SPI corruption: lower clock to 4 MHz (matches 7.3" prod) and push from
  internal SRAM in 8 KB chunks instead of streaming directly from a PSRAM
  scratch buffer. On the S3, Arduino's SPI DMA reads RAM directly — the
  CPU's cache can hold writes to PSRAM that the DMA never sees, painting
  the panel yellow/garbage. Internal-SRAM chunks are DMA-coherent.
- LittleFS partition: switch the env to default_16MB.csv. The stock
  partition table for esp32-s3-devkitc-1 reserves ~1.5 MB for SPIFFS;
  three 960 KB setup-screen .bin files need ~2.9 MB + LittleFS metadata.
- Setup screens: redesigned to match the 7.3" information density —
  yellow header band, two-column body with vertical divider, "Connect to
  WiFi" heading + 5 numbered steps + manual QR + side label on the left,
  Step 1 / Step 2 QRs on the right.
- Orientation diagrams: PORTRAIT drawn upright (ribbon-bottom, up-arrow);
  LANDSCAPE drawn pre-rotated 90° CCW so it snaps to upright landscape
  when the user rotates the frame 90° CW (ribbon-bottom + left-arrow in
  portrait view → ribbon-left + up-arrow after rotation). "LANDSCAPE"
  label runs vertically up the long edge so it reads horizontally once
  the frame is mounted landscape.
- New helper paste_rotated_text() — PIL's text() can't rotate, so render
  → rotate → paste-with-alpha. Used for the vertical LANDSCAPE label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 19:45:58 -04:00
football2801 569bec322f feat(13e6): bring up Waveshare 13.3" Spectra-6 end-to-end
Adds a second panel target alongside the 7.3":
- src/panels/waveshare13e6/v1/ — full epd.h impl with hardware SPI on
  FSPI, dual-CS dispatch (CS_M/CS_S split halves), PSRAM framebuffer
  for image/QR/setup-screen render paths
- src/test_display_13e6.cpp + [env:test-display-13e6] — self-contained
  first-pixels color-bar smoke test, kept as a hardware diagnostic
- [env:waveshare13e6-v1] — production env: ESP32-S3-WROOM-2 N32R16V
  with OPI flash + OPI PSRAM (the WROOM-2 is octal flash; QIO mode
  crashes at do_core_init startup.c:328)
- scripts/gen_screens_13e6.py + data/waveshare13e6-v1/ — 1200x1600
  portrait setup screens with QR overlay regions matching the driver
- scripts/data_dir.py — extra_scripts shim that routes uploadfs to the
  right data/ tree based on $PIOENV (PlatformIO ignores per-env data_dir)
- src/epd.h: epd_setup_pins() abstraction so each panel driver owns its
  own pinMode + SPI.begin; main/test_display/sim_border lose all
  panel-specific GPIO and call epd_setup_pins() once at boot
- src/operation.h: report PANEL_ID via X-Panel-Id header on every poll
  so the server can auto-correct Device.model

7.3" production env stays byte-identical, all 43 native tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:53:51 -04:00
football2801 c1bed8c218 ux(setup): rewrite captive-portal + AP/setup-screen copy
Beta tester called the previous setup wording "Chinglish."
Tighter, plainer language across all three surfaces:

- Captive portal (PORTAL_HTML): "Connect your frame", explicit
  "Home WiFi name / Home WiFi password", clearer footer.
- AP screen step list: 5-line plain-English checklist; no more
  Safari-specific reference.
- Setup screen: fixed step 2 wrapping mid-domain
  ("pictureframe / .edholm.me"), tightened steps 1 and 3.

Regenerated bg.bin to match the new gen_screens.py output.
NEEDS-FLASH: in-field beta unit still has prior copy.
2026-05-09 15:17:15 -04:00
football2801 f1d867c659 ux(provisioning): step 1 is now "Plug in power"
Frames will ship with the battery disconnected to preserve shelf life
— the setup screen polling is non-trivial draw on e-ink, and a unit
that sits on a shelf for weeks before unboxing would arrive flat.
Surface "plug in power" as the prerequisite step ahead of unlock and
scan, so the recipient can't miss it.

Step list grows from 4 to 5; pitch shrinks from 38 → 32 px to keep
the manual-help QR clear of the bottom edge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:41:49 -04:00
football2801 1399cc3756 fix(provisioning): two-QR flow — WiFi join + open-browser trigger
User report: iOS sees the captive network correctly (the captive UI
fires the moment any app makes an HTTP request), but won't auto-pop
it from a QR-scan join. This is recent-iOS hardening — Apple no
longer aggressively opens CNA on QR-initiated joins.

Workaround: a single QR can only encode one action, but two QRs
side-by-side close the loop —

  STEP 1 — WiFi-join QR (WIFI:T:WPA;S:NAME;P:pass;;)
           Phone joins PictureFrame.
  STEP 2 — URL QR (http://192.168.4.1/)
           Phone opens Safari → Safari hits 192.168.4.1 → that HTTP
           request is the "any app" trigger that fires the captive
           UI deterministically.

Implementation:
- WiFi QR shrinks from cell 5 (185 px) to cell 4 (148 px) to make
  room for the URL QR below.
- URL QR is static, baked into ap_bg.bin via Python qrcode at gen
  time — no firmware QR-render changes needed for it.
- epd_draw_ap_screen / _retry overlay coords updated to match the
  new WiFi QR position (581, 100, 4).
- Left-panel step list now reads "1. Unlock / 2. Scan QR 1 / 3. Scan
  QR 2 / 4. Enter password and tap Connect".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:30:39 -04:00
football2801 d1599a726d fix(provisioning): mirror aqua-iq's working AP pattern
After repeated 200-OK / DHCP-option / DNS-hijack permutations failed to
make the iOS captive banner fire reliably, port the configuration that
empirically works on the aqua-iq Pi to the ESP32:

* WPA2-PSK secured AP (was open). iOS handles secured-network
  captive-portal detection more aggressively than open networks. The
  PSK ('pictureframe') is baked into both firmware and the WIFI: QR so
  the user never types it — there's no real secret value here.
* Explicit channel 6 (was the softAP default of channel 1). Channel 6
  is the "middle" 2.4 GHz channel and tends to be less contested in
  dense environments; aqua-iq picks it for the same reason.
* 1.5 s settle delay after softAP. The radio + DHCP server need a
  beat before they're ready to handle a phone that joins the moment
  the SSID is broadcast (aqua-iq sleeps 3 s for NetworkManager+dnsmasq
  to fully initialize; the ESP softAP stack is faster but a small pad
  still kills race conditions).
* CNA paths revert to 302 redirect → "/". This is what aqua-iq does
  and what WiFiManager does. Serving the portal HTML inline at 200 on
  these endpoints (the previous attempt) didn't reliably trigger the
  iOS banner. The redirect is what iOS / Android / Windows look for.

QR string format updates from WIFI:S:NAME;T:nopass;; to
WIFI:T:WPA;S:NAME;P:pictureframe;; — phones consume both, but the
WPA variant is needed now that the AP requires a password.

Bg image's format-string footnote regenerated to match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:48:11 -04:00
football2801 6d3dee7659 fix(provisioning): rip out racy DHCP-option dance, add /log endpoint
The previous "force DHCP DNS offer" code (esp_netif_dhcps_stop / set
DNS / set option / dhcps_start) ran AFTER softAP was already serving
beacons. A fast iOS join — and CNA probes follow DHCP within 1-2s —
could land in the middle of the dance and either get a stale lease
or be racing the server start. ESP-IDF's softAP DHCP server already
advertises the AP IP as DNS by default, so the dance was at best
redundant. Strip it. Also drop the WiFi.disconnect(true) call before
mode-switching to AP — there's nothing to disconnect from on a cold
boot, and disconnect(true) cycles the radio for no benefit.

Add /log: in-memory ring buffer (32 entries, FIFO) of HTTP requests
and AP-state events served as plain text at http://192.168.4.1/log.
Lets the user diagnose without USB serial — join the AP, browse to
the URL, see exactly which CNA paths iOS hit (or didn't).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:41:08 -04:00
football2801 9c911b36b6 ux(provisioning): draw portrait diagram pre-rotated
Portrait was drawn upright (tall rect, ribbon on left, arrow up) so the
diagram only made sense while looking at the frame in landscape. The
intended UX is the opposite: the diagram is meant to be read AFTER the
user tilts the frame 90° CW into portrait, at which point it should
look correct.

Render the portrait diagram rotated 90° CCW from upright in the
landscape source — wide rect, ribbon on the bottom, arrow pointing
left. When the user tilts the frame CW, this rotates with the e-ink
content and lands as the canonical portrait view: tall rect, ribbon
on the left, up-arrow pointing up.

Side effect: the landscape diagram looks "rotated" when the frame is
in portrait, which is the same trick in reverse and the desired
behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:29:59 -04:00
football2801 e1eab1fdab ux(provisioning): portrait arrow points right, not up
In portrait orientation the cable/ribbon is on the left, so the
physical 'up' edge of the hung frame corresponds to the right side
of the rendered diagram — not the top. Swap the up-arrow inside the
portrait screen for a right-arrow so the diagram actually shows the
user which edge will be up when they hang the frame.

Landscape diagram is unchanged (cable on bottom → up is up).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:19:53 -04:00
football2801 251fafa01b ux(provisioning): up-arrow inside portrait diagram too
The arrow's purpose is to show the user which edge is "up" when they
hang the frame, regardless of orientation — so it belongs in both
diagrams, not just landscape. Slightly smaller arrow in portrait
(half_w=12, h=22) to match the narrower screen footprint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:16:41 -04:00
football2801 44bd2777c2 ux(provisioning): up-arrow inside landscape diagram, drop accent fill
Render both orientation diagrams in black so neither orientation looks
privileged-by-default — the previous yellow/green active highlight on
landscape was redundant once the supported orientation was clear from
the rest of the layout, and dropping it cleans up the centre panel.

Communicate "this edge is up" with a filled up-arrow inside the
landscape screen instead of relying on color. Setup screen (green
Step 2/2) inherits the same change since orientation_diagrams is
shared between gen_ap and gen_setup.

show_active_ls / accent params kept on the function signature for
call-site stability but no longer drive any colour decisions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:12:34 -04:00
football2801 e089911cfa ux(provisioning): unlock-first step + manual QR on Step 1/2
Two changes to the yellow Step 1/2 (and red retry-twin) screens, both
in service of the locked-phone-scan failure mode where iOS joins the
AP but never opens the captive portal:

* Promote 'Unlock your phone first' to step 1 of a four-step list
  (was three steps starting with 'Scan the QR'). Tightens step pitch
  from 46→38 px to fit the new step. Surfacing the requirement
  visually beats discovering it by scanning, getting nothing, and
  giving up.

* Bake a manual-link QR into the bottom of the left panel pointing
  to https://pictureframe.edholm.me/help. Side label 'Need help? /
  Scan for setup / & troubleshooting'. Static URL → encoded directly
  into ap_bg.bin via the qrcode Python lib at gen time, no firmware
  QR-render changes needed. Retry twin (ap_bg_retry.bin) inherits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:06:30 -04:00
football2801 4454e9a8a5 diag(provisioning): instrument captive flow + tighten DHCP/radio behavior
The previous protocol fix (302 → 200 portal HTML) didn't restore iOS
captive-banner reliability under the lock-screen-camera join: the user
joined, accepted the prompt, and got nothing on unlock. We're guessing
without data, so this round adds instrumentation alongside three
high-confidence behavioral fixes that are individually plausible
explanations.

Fixes:

* Force the AP DHCP server to advertise the AP IP as DNS via
  esp_netif_dhcps_option(ESP_NETIF_DOMAIN_NAME_SERVER). Arduino-ESP32's
  softAP doesn't set this explicitly; if a client comes in with cached
  cellular DNS the captive DNS hijack gets bypassed and iOS resolves
  captive.apple.com to real internet — no captive signal ever fires.

* WiFi.setSleep(false) so the AP radio doesn't park between beacons
  and drop probe packets that arrive during a sleep window.

* Cache-Control: no-store on the portal response, so iOS doesn't carry
  a "this SSID was fine last time" determination across forget+rejoin
  cycles.

Diagnostics (logged on serial at 115200, in AP mode only):

* Every HTTP request: method, URI, Host, User-Agent. Tells us whether
  iOS is reaching us and which CNA path it's hitting.
* WiFi AP events: STA-associated, IP-assigned, STA-disconnected.
  Tells us whether the join completed and DHCP succeeded.

Repro: pio device monitor -e waveshare73-v1, forget the network on
the phone, lock + scan + accept, watch the log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:48:24 -04:00
football2801 7c7e4745cf fix(provisioning): captive portal opens reliably on iOS lock-screen scans
The previous handler answered Apple/Android/Windows CNA probes with
HTTP 302 redirects to "/". That works in a desktop browser, but iOS —
particularly when joining via the lock-screen camera quick-scan path —
sometimes treats the redirect as "internet works" and never raises the
captive banner. The user has to remember the manual fallback URL on the
e-ink footer to recover.

Switch every probe URL to serve the portal HTML directly with 200 OK.
A 200 response whose body is not Apple's magic Success page is the
canonical "this is a captive network" signal; banner-fire becomes
deterministic on the first probe.

While here:
- Register HTTP handlers BEFORE softAP comes up so the very first probe
  from a fast-joining device lands on a ready server, not connection-
  refused.
- Drop the unconditional 500 ms post-softAP delay; softAPIP is valid
  immediately and the gap was just a window for races.
- Add /library/test/success.html (iOS legacy) and /connecttest.txt
  (Windows 10+) to the explicit handler list.
- Delete handle_captive (was the 302 redirect path).

Locked-phone caveat: iOS by design will not auto-open the captive
portal UI while the phone is locked — the best we can do is make the
banner notification fire reliably so it's waiting on unlock. This
change accomplishes that.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:40:01 -04:00
football2801 05e869d190 fix(provisioning): fast-fail wifi on bad PSK / missing SSID
Match the failure path's latency to the happy path. Before: a wrong
password meant the user stared at the yellow Step 1/2 screen for the
full 30 s WIFI_TIMEOUT_MS before the red retry repaint started — total
~50 s to "Connection Failed" visible. After: WL_CONNECT_FAILED and
WL_NO_SSID_AVAIL bail attempt_wifi() immediately, so the red repaint
starts within a few seconds of the radio giving up — total ~25 s,
matching the happy-path-to-Step-2/2 timing.

Also collapse the duplicate boot-time poll loop in main.cpp onto the
shared attempt_wifi() so the same fast-fail covers boot-with-stored-
creds, not just captive-portal submission.

Tests: FW-15a (auth fail) and FW-15b (no SSID) assert millis() never
reaches WIFI_TIMEOUT_MS on those statuses. Existing FW-15 tightened
to use WL_DISCONNECTED so it actually exercises the timeout path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:31:41 -04:00
football2801 6a924963e5 fix(build): move data_dir to [platformio] section so uploadfs sees the panel files
PlatformIO silently ignores data_dir inside [env:…] blocks (it warned
"Ignore unknown configuration option `data_dir`" on every run, but we
read past it). Without a recognized data_dir, uploadfs packed the entire
project data/ root into LittleFS, putting every file under
/waveshare73-v1/<file> instead of /<file>. The firmware looks at /, so
draw_from_lfs fell back to its solid-yellow epd_fill — a blank yellow
panel after a fresh uploadfs.

Moving the directive to the project-level [platformio] section makes
PlatformIO honor it. All envs currently target the V1 panel so a single
project-level data_dir is fine; when a second panel ships, swap via an
extra_scripts shim that picks the dir from \$PIOENV before uploadfs runs.
2026-05-08 23:48:20 -04:00
football2801 fb4c5ff5d3 fix(provisioning): stop redrawing the QR on every poll, add WiFi-fail retry screen
Two related fixes that together let the post-WiFi-setup window be quiet:

1. operation.h 204/404: skip the panel redraw entirely. The panel already
   holds the right thing — setup QR if no image has ever been painted
   (img_id == -1), or a real photo if img_id >= 0. Redrawing the QR every
   15s during the bootstrap claim window put the e-ink into a perpetual
   ~20s mid-refresh loop and risked ghosting. Tests updated to assert
   no redraw on either sub-case.

2. main.cpp WiFi-fail path: drop the epd_fill(RED) + 3s delay + AP
   re-redraw sequence (~43s of e-ink work that destroyed the QR mid-flow)
   and replace with a single repaint of a new "Connection Failed — try
   again" Step 1/2 screen with red accents. gen_screens.py grows a
   gen_ap_retry() variant that recolors yellow → red and swaps the
   header/QR labels; the result is shipped as ap_bg_retry.bin alongside
   ap_bg.bin in LittleFS. epd.h exposes epd_draw_ap_screen_retry().
2026-05-08 23:43:59 -04:00
football2801 e7f0a11ad3 fix(provisioning): drop the 2-minute QR display delay
The 15s FIRST_IMAGE_POLL_INTERVAL_MS bootstrap already keeps the QR on
the panel (204 responses don't trigger a redraw) until the user claims
via /setup/{mac} and the server's bootstrap-bypass serves an image. The
hard-coded delay(120000) was just dead time between WiFi save and the
first poll — observed in the field as ~110s of nothing happening after
login.

Also touches operation.h header comments to match the "hold until the
screen flashes" terminology and document the short-press fast-poll
gesture.
2026-05-08 19:11:02 -04:00
football2801 e37df03b7f fix(operation): EXT0 wakeup on BOOT button so 5-sec-hold reset works during sleep
Bug: the device only woke from deep sleep on a timer; pressing BOOT
during sleep did nothing. The 5-second-hold reset only worked in the
brief awake window during a poll, which made the documented "hold BOOT
to reset" gesture appear broken to the user. Reported live 2026-05-09.

Fix: arm EXT0 wakeup on PIN_BTN_RESET (active-low — BOOT is pulled-up
on the dev board) at every esp_deep_sleep_start. After the press wakes
the chip, setup() runs and the existing check_reset_button() handles
the rest of the 5-second hold and triggers the NVS clear + reprovision.

Mocks: esp_sleep.h gains gpio_num_t typedef + g_ext0_wakeup_pin/level
globals so the native test can assert the call shape.

Test: FW-RESET-WAKE pins the contract — every deep_sleep_start must
arm EXT0 on PIN_BTN_RESET, level 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:35:56 -04:00
football2801 2df2a14df6 feat(operation): poll every 15s until first image lands
A freshly-claimed device on a noon-daily schedule would otherwise sit
dark for up to 24 h after WiFi setup waiting for its first image. The
schedule kicks in only AFTER an image has actually been displayed.

Mechanism: at the bottom of normal_operation_impl, re-read NVS_KEY_IMG_ID
to see whether any successful 200-with-integrity-OK persisted an image
id this cycle (or any prior). If still -1, override sleepMs to
FIRST_IMAGE_POLL_INTERVAL_MS (15 s) — bypassing the schedule and the
clamp range, since SLEEP_CLAMP_MIN_MS is about runaway protection in
steady state and the bootstrap window is naturally bounded by "first
image arrives."

Tests:
  - FW-FIRST-IMG-A: 204 with no img_id in NVS → 15s override fires
    even when server says 6 hours.
  - FW-FIRST-IMG-B: img_id pre-set, 200 cycle → server interval honored
    (override doesn't trap the device in 15s forever).
  - FW-FIRST-IMG-C: first 200 ever (img_id was -1, now persisted) →
    server interval applies starting THIS cycle, no extra 15s nap.

Also patched FW-03 (304 sleep timing) to pre-set img_id so the test
exercises what it claims; 304 in production only happens when the
device already holds the image, so the override would never fire there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 16:13:53 -04:00
football2801 a0dc4e0115 feat(operation): X-Just-Provisioned + X-Claimed handshake
Closes the sell-to-friend gap where a buyer's freshly-reset device
would briefly display the seller's photos before the buyer reached
/setup/{mac} to claim. The firmware had no way to tell the server
"I just got reset" — now it does.

Flow:
  - WiFi-setup completion (handle_connect in main.cpp) writes
    NVS_KEY_JUST_PROVISIONED=1 alongside the SSID/PASS save.
  - Every poll while the flag is set sends X-Just-Provisioned: 1.
  - Server (DeviceImageController, paired commit on the webApp side)
    responds with 204 + X-Interval-Ms when the binding is stale,
    forcing the device to its setup-QR fallback. Once the user
    re-claims via /setup/{mac}, the binding is fresh, and the server
    answers with X-Claimed: 1 alongside whatever response code applies.
  - Firmware clears the NVS flag on seeing X-Claimed: 1 — once
    cleared, the device is back to normal long-stable polling.

Tests:
  - PROV-A: flag set in NVS → header on the request
  - PROV-B: no flag → no header (steady state)
  - PROV-C: response with X-Claimed: 1 → flag cleared
  - PROV-D: response without X-Claimed → flag stays (so the next
    poll keeps signaling "not yet acknowledged")

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 16:03:22 -04:00
football2801 bbd5e84db0 feat(operation): send X-Boot-Reason so power-cycle is a force-resync
Distinguish a cold-boot poll (UNDEFINED wakeup cause = power-on, hard
reset, plug-cycle) from a normal timer wake. Encoded as the
X-Boot-Reason request header; server uses it to deliberately bypass
the schedule and rotate. Matches how users actually use the device:
unplug-and-replug as a manual refresh.

Tests: two new native cases asserting the header is "cold" on
UNDEFINED wakeup and "timer" on TIMER wakeup. esp_sleep mock now
exposes a settable wakeup_cause global.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 12:18:32 -04:00
football2801 988759f738 feat(firmware): honor server X-Interval-Ms instead of capping at 60s
The dev-only cap that forced every-1-min polling regardless of the app's
schedule is removed. The device now sleeps for whatever X-Interval-Ms
the server hands back (driven by rotationIntervalMinutes / wakeTimes),
clamped to [30s, 25h] as a safety net against malformed values.

Renamed FETCH_INTERVAL_MS to FETCH_INTERVAL_MS_FALLBACK — it's now
*only* used when the header is absent (rare; rolling deploy / hand-
crafted response). Added SLEEP_CLAMP_MIN/MAX for the bounds.

Tests FW-09 and FW-10 flipped to lock the new behavior; added FW-10b
covering sub-MIN clamping (battery protection if server sends 1000ms).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:34:20 -04:00
football2801 8915a3d1f4 chore(firmware): mark TODOs for the dev-only 60s polling cap
Three cross-referenced markers — config.h, operation.h, and FW-10 in the
test file — calling out that the FETCH_INTERVAL_MS cap is intentionally
holding the polling rate at 1 minute for dev iteration. Once the firmware
is stable and we want the device to honor the app's per-frame
rotationIntervalMinutes / wakeHour settings, the cap in operation.h
becomes a sanity-clamp (e.g., 30 s ≤ sleep ≤ 25 h) and the no-header
fallback splits into its own constant.

Behavior unchanged — comments only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:37:59 -04:00
football2801 a6ed67a3f4 refactor(firmware): per-panel folder layout + parametrized config.h
Reorganizes the tree so adding a new panel is purely additive — drop in a
new src/panels/{vendor}/v{N}/ folder and a new platformio.ini env block,
no surgery to existing files.

Layout:
  src/                              shared across all panels
  src/panels/waveshare73/v1/        V1 driver, version, README
  data/waveshare73-v1/              LittleFS payload at this panel's size

src/config.h still defines the panel-agnostic bits (NVS keys, color
palette, network, sync-fail border) but EPD_WIDTH / EPD_HEIGHT / pin
assignments now come from each env's -D flags. Strict #error guards in
production builds; native tests get the V1 defaults via UNIT_TEST.

build_src_filter per env picks the right driver:
  waveshare73-v1   main + panels/waveshare73/v1/
  test-display     test_display + panels/waveshare73/v1/
  sim-yellow       sim_border + panels/waveshare73/v1/
  sim-red          sim_border + panels/waveshare73/v1/
  native-test      unchanged

When V2 hardware lands, the diff is a new env block, a new
src/panels/waveshare133/v1/epd_driver.cpp, and regenerated screens at
data/waveshare133-v1/. Existing V1 envs stay frozen — re-flashing old
units remains a one-liner.

scripts/gen_screens.py takes --panel to target the correct
data/{panel}/ subfolder; defaults to waveshare73-v1.

29/29 native tests pass. All four hardware envs build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:31:23 -04:00
football2801 27d01057e4 feat(operation): verify X-Image-Sha256 before painting the panel
Pairs with the server-side header. After streaming the response body to
LittleFS, hash the file with mbedtls/sha256 (hardware-accelerated on
ESP32-S3) and compare against the server's claim. On mismatch:

- Don't update NVS_KEY_IMG_ID, so the next poll reports the old id and
  the server sends 200 again with fresh bytes (natural retry, no extra
  HTTP round-trip in this cycle).
- Don't draw — panel keeps whatever was up before, no garbage on the
  e-ink.
- Raise NVS_KEY_ERR_BORDER so the next healthy 304 paints a clean
  recovery frame with the sync-fail border.

Verification is skipped when the header is absent, so the firmware
stays compatible with any server that hasn't deployed the matching
header yet. mbedtls compiles into a native-test no-op stub (returns
empty hex), so existing native tests don't need a SHA implementation.

Two new tests: FW-17a (mismatch path) and FW-17b (missing header
backward compat). Mock String now has equalsIgnoreCase so the new
comparison compiles in native-test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 19:43:02 -04:00
football2801 21871179bd feat: thinner border (4 px) and serial logging on border / recovery events
- BORDER_THICKNESS_PX: 16 -> 4. Hardware-tested at 4 px on both yellow
  and red; yellow appears slightly thicker due to the irradiation
  illusion (perception, not a rendering issue) — not compensating per
  color absent an explicit request.
- Add Serial.println at every state transition that touches the
  err_border lifecycle: schema migration firing, sync-fail else
  branch (with HTTP code, distinguishing border vs full-fill fallback),
  304 recovery (with which flags triggered it), and recovery completion
  / abort. Lets us trace why a frame is or isn't showing a border via
  pio device monitor without needing to instrument anew each time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:49:08 -04:00
football2801 3fb7eb6ac3 test: add sim-yellow and sim-red envs for visual border verification
Two pio envs that build a tiny sketch reading /img.bin from LittleFS and
calling epd_draw_image_with_border with the chosen color. Lets us verify
the actual on-device pixel composition of the sync-fail (yellow) and
no-WiFi (red) borders without standing up a server failure or pulling
the WiFi cable.

Each sim sets NVS err_border=1 before halting, so flashing back to the
normal env afterwards exercises the 304 → clean repaint recovery path
end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:41:22 -04:00
football2801 db50ce1d0a fix: schema migration forces clean repaint on first err-border-aware boot
Without this, devices upgrading from the old buggy fill-on-error firmware
get stuck on yellow forever: the new code reads NVS_KEY_ERR_BORDER == 0
(default — the old firmware never wrote that key), so the next 304 sees
no err flag and skips the redraw. NVS img_id matches what the server is
serving, so server says "you're current" indefinitely.

Add NVS_KEY_SCHEMA_V. On boot, if stored version is below
NVS_SCHEMA_VERSION (currently 1), treat errBorder as set for this cycle
and bump schema_v. The next 304 then redraws from LittleFS (the cached
.bin survives flashing) and clears the flag.

Tests: FW-06f locks in the upgrade path (schema_v missing → redraw on
304). FW-06g asserts the migration is one-shot (post-bump → no redraw
on steady-state 304). FW-06d updated to set schema_v explicitly so it
represents the post-migration steady state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:36:00 -04:00
football2801 5f8f10ed5b chore: ignore .pio build artifacts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:30:08 -04:00