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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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.
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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().
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.
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>