24 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 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 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 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 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 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 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 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 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 4af67ee1bd chore: stage all in-progress work before repo split
Web app: new entities (Image, RenderedAsset, SharedImage, Token,
DeviceImageHistory), enums, repositories, controllers, message handlers,
migrations, tests, frontend upload/library/sticker UI, Vue components.

Firmware: EPD background screen binaries + gen scripts, setup_bg header.

Infra: ddev config, test bundle, gitignore coverage dir.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 12:11:31 -04:00