Pairs with the firmware change of the same scope. Server-side contract:
- X-Just-Provisioned: 1 + binding older than 5 min → 204 (the device
paints its setup QR) regardless of any approved images on the
seller's account. No image content leaks.
- X-Just-Provisioned: 1 + binding fresher than 5 min → normal
response (200/304/204), with X-Claimed: 1 stamped so the firmware
clears its NVS flag and returns to standard operation.
- No header → no special behavior. Long-stable devices unaffected.
This is what makes the BOOT-5s reset actually safe to use as the
"factory reset" gesture: now it forces a real re-claim cycle, instead
of silently inheriting the prior owner's content because the MAC is
still bound on the server.
3 functional tests: stale-binding 204, fresh-binding 200 + X-Claimed,
and absence-of-header preserves legacy behavior.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pairs with the new claim-on-takeover checkbox: now the seller can purge
their data BEFORE handing the device over, so even if they forget to
hold the BOOT button to wipe NVS, the next owner can't accidentally pull
their photos.
Backend:
- DELETE /api/devices/{id}: owner-only (404 for cross-tenant). Revokes
image-device approvals, drops history rows, removes the Device row
entirely so the MAC is unclaimed. The next poll from that physical
frame returns 404 → setup QR for the next owner.
- DeviceService::deleteDeviceForOwner extracts the cleanup so the
controller stays thin.
- Mercure publish on delete sends {id, deleted: true} so any other
open PWA tabs splice the row out instantly.
Frontend:
- Settings sheet (BaseBottomSheet): "Remove this frame" link below
Save, in danger red with an explanatory hint about when to use it.
- Native window.confirm gate — destructive + irreversible, the
weight of native-confirm is honest. (A bespoke modal would be
polish.)
- useDeviceMercure: handles the {id, deleted: true} sentinel — splices
the device out + closes its own EventSource for that topic.
- useDevicesStore.removeDevice: DELETE + local store filter.
Tests added:
- DeviceApiControllerTest: 4 cases — happy-path delete purges
everything, 404 cross-tenant, anon redirects to login, and
post-delete the device-poll endpoint 404s (fresh-MAC guarantee).
- HomeView.test.ts: confirm-yes calls store + closes sheet,
confirm-cancel does NOT call removeDevice.
- useDeviceMercure.test.ts: deletion sentinel splices the device
out and closes the EventSource.
Coverage: 99.71% lines / 98.21% methods backend, 98.31% lines frontend.
558 tests total via ddev tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Use case: old owner sells the device to a friend. Friend holds the BOOT
button to wipe NVS, joins the device's AP, sets new WiFi. The old
owner's account is still bound to the MAC server-side, so without
explicit consent the friend would silently take over (or, worse, the
old owner's photos would keep displaying until claim).
Flow now:
- GET /setup/{mac} detects MAC bound to anyone and renders a
"Claim this frame as my own" checkbox + a banner explaining what
the takeover wipes. Both register and login panels carry the
checkbox; submitting either form without it bounces back through
the index with a session-flashed error.
- DeviceService::linkToUser now requires allowClaim=true to
transfer ownership. Without it, throws DeviceClaimRequiredException
that the controller catches and turns into the bounce-with-error.
- On a successful claim, the takeover wipes:
* old image-device approvals
* device_image_history rows for the device
* name, wakeTimes, currentImage*, lockedImage, nextPollExpectedAt
so the new owner starts from a fresh slate, not inheriting the
seller's "Living Room / 4:30 AM" preset.
- Already-logged-in user visiting /setup/{mac} for someone else's
device falls through to the form (instead of silently transferring
on page load) so the checkbox is the only path.
Test matrix:
- SetupControllerTest: 5 new functional cases — checkbox renders for
bound MACs, register/login without checkbox bounce + retain old
ownership, register WITH checkbox transfers + purges, logged-in
other-user falls through to form.
- DeviceServiceTest: 3 new unit cases — throw without consent,
isClaimedByAnotherUser true/false matrix, takeover resets device
state.
Coverage: 99.70% lines / 98.19% methods backend, 333 frontend tests
green via ddev tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Started: 89.08% backend / 97.01% frontend lines.
Landed: 99.69% backend / 98.62% frontend.
Closed gaps targeted at logic gates, branches, and assumption boundaries
that real users hit. Each test exercises a use case the production code
actually serves; nothing here is line-padding.
Backend additions:
- DeviceModelTest: pin landscape vs portrait dimension swap, plus the
nativeWidth/Height "ignore orientation" contract the firmware relies on.
- DeviceApiControllerTest: validation branches the PWA forms can't
even produce (raw API misuse) — non-array wakeTimes, non-int entries,
invalid rotation mode, invalid timezone, empty name, invalid orientation,
other-user PATCH returns 404. Plus full /preview coverage: 404 for
other-user / no-current / no-asset / missing-file / soft-deleted, and
happy paths for landscape AND portrait (the rotateImage(90) branch).
- ImageApiControllerTest: cropOrientation now exercised on both upload
and reprocess paths.
- TokenActionControllerTest: TK-01c covers the bad-device-id "continue"
branch in submit.
- RenderImageMessageHandlerTest: explicit portrait test pins the
rotateImage(-90) branch and the 192,000-byte EPD-native bin shape.
- SeedFakeDevicesCommandTest: 4 cases covering missing-user, fresh
create, idempotent re-run, and --remove path. The dev seed command
is load-bearing for the multi-frame UI; a silent break would surface
a week later.
- RerenderAssetsCommandTest: reset + dispatch path, no-assets path.
Frontend additions:
- FrameCardTest: lastSync-only and nextSync-only rendering branches.
- HomeView.test:
* + Add time fallback path when all 9 default candidates are taken.
* Multi-day "in Nd" nextSync formatting (offline / huge-interval case).
* Medium-horizon (5h) nextSync formats as clock-time + day label.
* visibilitychange triggers a silent re-fetch.
* add-photo handler creates input + navigates to /upload after pick.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors aqua-iq's pattern but adapted for pictureFrame's stack:
postgres 16, php 8.4, node 22, imagick + pcov via apt extras,
Mercure hub at https://pictureframe.ddev.site/.well-known/mercure,
and four custom commands — `ddev tests`, `ddev coverage`,
`ddev frontend` (vite HMR), `ddev worker`.
Also restores dev deps (DAMA, Doctrine fixtures, symfony/uid) that
got dropped during earlier composer reshuffles, and adds a separate
`db_test` connection in .env.test so DAMA's transactional isolation
doesn't share state with whatever dev is mid-experiment with.
Two test fixes the new env exposed:
- RotationServiceTest::test_prioritize_never_shown_falls_through_when_all_shown
needed uniquenessWindow=2 so the recent-window filter wipes the
set and the fallback restores the full pool — otherwise window=1
excluded the most-recently-served image and the assertion drifted.
- DeviceImageControllerTest::test_locked_image_served_without_rotation_advance
was asserting currentImage stays null on a lock poll, but the
controller intentionally sets currentImage on the lock path so
Home reflects the live frame state. Now asserts both the
currentImage update AND that no DeviceImageHistory row was
written (the actual rotation-bypass guarantee).
Backend coverage (full suite via `ddev coverage`): 89.08% lines /
92.24% methods / 74.36% classes — the first real number we've had.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Symptom: wakeTimes schedules silently never fire on non-UTC devices.
Reported live by Matt's EDT frame: wakeTimes=[12:30 PM NY] saved,
12:30 came and went, no rotation. Same bug pattern would fire
*every* poll on east-of-UTC tzs.
Root cause: device_image_history.served_at is `timestamp without time
zone`, written by `new DateTimeImmutable()` so it stores UTC
components ("2026-05-08 16:28:50"). The boundary in isDue() was
bound through Doctrine with the device's local tz still attached,
so Doctrine's format() emitted local-tz components ("12:30:00").
Postgres compared the strings literally — for west-of-UTC tzs the
UTC timestamp is numerically larger than the local-tz boundary, so
every same-day row falsely satisfied `servedAt >= :wakeTime` and
isDue returned false.
Fix: $boundary->setTimezone(UTC) before binding. Both sides now
format in UTC components, so Postgres's literal compare is correct.
Regression test ID-TZ-01: device in America/New_York, wakeTimes
[12:30 PM NY], history at 12:00 PM NY (= 16:00 UTC). With the fix
isDue returns true; without it the test falsely-matches and fails.
Skipped before 13:00 NY since the assertion needs the wake slot to
have already passed today.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cold-boot polls (firmware sends X-Boot-Reason: cold on UNDEFINED
wakeup cause) are treated as a deliberate "force a refresh" gesture
from the user — unplug → replug to re-pull whatever the web app
queued. Timer wakes still respect the wakeTimes schedule, so the
schedule-gated semantics aren't undermined.
Test: a cold-boot poll between scheduled wake times advances the
rotation and writes a fresh DeviceImageHistory row, while an
otherwise-identical timer-wake poll returns 304 without rotating.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Symptom: with wakeTimes=[4 AM, 9 PM, 9:15 PM], the frame rotated to a
fresh photo at 10:14 AM when the device was reconnected and polled.
The wakeTimes only governed *when* the device polled (via X-Interval-Ms);
they didn't gate whether the server picked a new image when it did.
Power-on or button-press polls would always rotate.
Fix: move the existing isDue() logic from AdvanceRotationMessageHandler
into RotationService as a public method, and gate
DeviceImageController::image so off-schedule polls return the device's
current image (which 304s when X-Current-Image-Id matches) rather than
calling advance(). The scheduler-driven handler still uses the same
isDue — both code paths now share one source of truth.
Tests:
- DeviceImageControllerTest: new test asserting an off-schedule poll
returns 304 without rotating; existing wakeTimes tests reworked to
use slot lists that always have a past slot regardless of run time.
- AdvanceRotationMessageHandlerTest: existing AR-04 through AR-07
keep covering isDue's semantics — they now go through the service.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Frontend (90.15→95.37 stmts / 91.83→97.01 lines):
- useDeviceMercure: full composable test suite via a fake EventSource —
open/merge/ignore-stale/parse-error/reconnect/dynamic-add/remove/
no-op-when-unconfigured/cleanup-on-unmount.
- HomeView: cover onTimePart's AM/PM and minute branches plus the
nextPollExpectedAt-null fallback paths in the next-update preview.
Backend (no instrumentation before; pcov was already in the image,
just needed a <coverage> block in phpunit.dist.xml):
- RotationService: one test per mode (NewestUpload, Random,
LeastRecentlyShown), one for never-shown sorting first under LRS,
and two for prioritizeNeverShown — narrows when never-shown exists,
falls through to mode otherwise.
- DeviceSerializer: contract test on the wire shape (REST + Mercure
use the same serializer; silent rename here would break live updates
instantly).
- MercurePublisher: topic format + JSON encoding + the swallow-
exceptions guarantee (a flaky hub must not break poll responses).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Frame settings now offer two update-frequency modes: "at specific times" or
"every X minutes". Times are stored as an int[] of minutes-since-midnight,
allowing multiple slots per day at minute granularity. Backend computes the
earliest upcoming slot for X-Interval-Ms and uses the most-recent-past slot
as the rotation-due boundary. PWA settings sheet has hour/minute/AM-PM
dropdowns with + Add / trash, a live "next update" preview, and a note
that changes only take effect at the device's next sync.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After re-cropping an image, the renderer regenerates the .bin and
advances the asset's rendered_at, but the device's 304 short-circuit
still matched on (image_id, orientation) only — so the device kept
serving the old upside-down/stale bytes from its local cache despite
the server having freshly-rendered correct ones.
Adds device.current_rendered_at, populated whenever a 200 response is
served, and tightens the 304 condition to require all three (image id,
orientation, rendered_at) to match. The asset lookup now happens before
the 304 check so its rendered_at is in scope for the comparison.
No firmware change — this is server-side cache logic. Existing devices
get null current_rendered_at after the migration; their next poll falls
through 304 and re-fetches once, then the cache is in sync.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 304 short-circuit at DeviceImageController only compared image IDs,
so flipping a device between landscape and portrait would not invalidate
the cache: the device kept showing the previously-rendered .bin even
after the user changed orientation in the webapp.
Now the device row tracks currentImageOrientation — set whenever a 200
binary response is sent — and the 304 path requires both image id AND
current orientation to match the device's stored orientation. An
orientation flip naturally falls through to the 200 path on the next
poll, the freshly-rendered portrait .bin is delivered, and the device
redraws.
No firmware change: the existing X-Current-Image-Id header from the
device is sufficient. Existing devices migrate cleanly — null
currentImageOrientation just forces one full re-send on first post-
migration poll, which is harmless.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>