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>
Subscribe per-device with a Symfony Mercure hub: server publishes a fresh
device payload after every poll (200/304/204), every PATCH, and every
lock/unlock. The frontend opens one EventSource per device topic and
splats inbound JSON straight into the devices store — same shape as
GET /api/devices, so no envelope handling.
Topic: https://pictureframe.edholm.me/devices/{id}
Stack mirrors aqua-iq:
- symfony/mercure-bundle + config/packages/mercure.yaml
- App\Service\MercurePublisher (errors swallowed + logged; a flaky hub
must not break a poll response)
- App\Service\DeviceSerializer extracted as the single source of truth
for the wire shape (REST + Mercure share it)
- Frontend useDeviceMercure() composable: opens/closes EventSources to
match the device list reactively, reconnects on hub-side closes
- SpaController exposes MERCURE_PUBLIC_URL via window.__PF_MERCURE_URL__
Production compose adds a dunglas/mercure container with Traefik labels
for pictureframe.edholm.me/.well-known/mercure (handled separately on
the host since the file isn't in this repo).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The card's "next sync" was computed locally as `lastSeenAt + interval`,
which broke the moment the user PATCHed a new interval: the device is
still asleep on whatever schedule was active at its last poll, but the
local record now has the new interval, so we'd display a misleading
"in 2m" after a 5→3 min change.
Fix: server stamps `nextPollExpectedAt` on every poll (200/304/204),
PWA reads it directly. The timestamp doesn't move when settings are
edited — only when the device actually polls and picks up a new
schedule. Same field also drives the settings-sheet "Next update"
preview, which had the same flaw.
Side effects:
- `markSeen()` now flushes on the 204 paths too — they previously
set lastSeenAt without flushing (latent bug for devices with no
approved images / missing assets).
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>
Lets the firmware verify integrity end-to-end and discard a corrupt
transfer before painting the panel — pairs with the firmware-side hash
check that lands in the same series of changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three problems were stacked:
1. The 200 serving path didn't set currentImage when a locked image was
served (RotationService.advance bypassed). The frame got the locked
photo; the DB kept the previous one; Home showed the old one.
2. The 304 path didn't flush at all. lastSeenAt (markSeen) was lost on
every no-change poll, and any drift in currentImage couldn't self-heal.
For a frame that's been locked for a while, polls cycle as 304 forever
and the DB stays wrong indefinitely.
3. Pull-to-refresh fetched via fetchDevices(), which flips loading=true
and replaces the cards with "Loading…" mid-fetch. The PTR spinner was
working but users couldn't see the result of their refresh.
Fixes:
- Both 200 and 304 paths now set currentImage = $image and flush. The
304 path becomes self-healing for any device whose currentImage drifted
from reality (e.g., from before the 200-path fix).
- fetchDevices / fetchImages take an optional { silent: true } that
skips toggling loading.value. PTR refresh callbacks pass silent so
the cards stay visible during background refresh.
- HomeView also listens on visibilitychange and silently re-fetches when
the PWA returns to foreground, so reopening the app shows current
state without a manual pull.
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>