Commit Graph

10 Commits

Author SHA1 Message Date
football2801 e2a8ea4a7e feat(rotation): X-Boot-Reason: cold forces a resync regardless of schedule
CI / test (push) Has been cancelled
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>
2026-05-08 12:18:43 -04:00
football2801 5b3e2e47d7 fix(rotation): gate poll-driven advance() behind isDue() check
CI / test (push) Has been cancelled
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>
2026-05-08 12:14:32 -04:00
football2801 ba9625d45d feat(home): live updates via Mercure — server pushes device state to the PWA
CI / test (push) Has been cancelled
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>
2026-05-07 16:20:21 -04:00
football2801 995445ed9e fix(home): "next sync" must reflect the schedule the device is *on*
CI / test (push) Has been cancelled
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>
2026-05-07 15:52:04 -04:00
football2801 d11ddff912 feat(device): replace daily wakeHour with multi-time wakeTimes (minutes)
CI / test (push) Has been cancelled
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>
2026-05-07 14:32:58 -04:00
football2801 100e101d05 feat(device-api): include SHA-256 of served .bin in X-Image-Sha256 header
CI / test (push) Has been cancelled
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>
2026-05-06 19:42:44 -04:00
football2801 8beb7331dd fix(home): preview tracks frame state even with locked images and 304 polls
CI / test (push) Has been cancelled
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>
2026-05-06 19:24:50 -04:00
football2801 b700a4a018 fix: include rendered_at in 304 cache check so re-renders invalidate
CI / test (push) Has been cancelled
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>
2026-05-06 16:16:48 -04:00
football2801 c387260ee7 fix: include orientation in device 304 cache check
CI / test (push) Has been cancelled
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>
2026-05-06 14:21:15 -04:00
football2801 12245759ac chore: stage all in-progress work before repo split
CI / test (push) Has been cancelled
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