fix(devices): bootstrap-bypass when device sends no X-Current-Image-Id
CI / test (push) Has been cancelled

A freshly-claimed device (BOOT-button reset → buyer logs in / registers
→ linkToUser sets noon-daily wakeTimes default) was polling every 15s
per the firmware's FIRST_IMAGE_POLL bootstrap, but the server's
schedule-gating refused to run advance() because we weren't at noon
yet. Result: panel sat dark from claim until the next wakeTime fired,
which could be hours away.

Add a third bypass case in DeviceImageController::image: when the
device sends no X-Current-Image-Id header (i.e. its NVS img_id is
still -1, meaning it has never successfully painted an image),
treat the poll as a bootstrap and advance() regardless of schedule.
Once the panel paints, the next poll carries X-Current-Image-Id and
schedule-gating resumes.

Compatible with all the existing bypass logic:
  - Locked image still wins.
  - Cold-boot resync (X-Boot-Reason: cold) still bypasses.
  - The just-provisioned + stale-binding 204 returns BEFORE this
    branch, so a stranger device still can't pull the seller's image.

Test: bootstrap_poll_advances_even_when_schedule_says_not_due — sets
wakeTimes such that schedule says not-due, then polls without the
X-Current-Image-Id header and verifies a new history row was written.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 19:01:56 -04:00
parent f777c790fa
commit f30a6a8f87
2 changed files with 62 additions and 6 deletions
+19 -6
View File
@@ -131,17 +131,30 @@ class DeviceImageController extends AbstractController
};
// Locked image bypasses rotation entirely. Otherwise, advance only
// when the device's configured schedule says it's due — except a
// cold-boot poll (X-Boot-Reason: cold) is treated as a deliberate
// user-driven force-refresh: unplug → replug → fresh rotation,
// regardless of wakeTimes. Timer wakes stay schedule-gated, so users
// don't see surprise refreshes between configured slots.
// when the device's configured schedule says it's due — except in
// three deliberate bypass cases:
//
// 1. Cold-boot poll (X-Boot-Reason: cold) — unplug→replug treated
// as a manual refresh.
// 2. Bootstrap poll — the device hasn't sent X-Current-Image-Id,
// meaning its NVS img_id is still -1 and it has never
// successfully painted an image. Common after a BOOT-button
// reset + new claim: the buyer's account has approved images
// ready, but the noon-daily schedule says "not due till
// tomorrow" so without this bypass the panel sits dark until
// the next wakeTime fires. Schedule-gating resumes once the
// first image is painted (the device starts sending
// X-Current-Image-Id with that id).
// 3. Schedule says due (the normal case).
//
// Timer wakes after first-image otherwise stay schedule-gated.
$bootReason = strtolower((string) $request->headers->get('X-Boot-Reason', ''));
$forceResync = ($bootReason === 'cold');
$wantsBootstrap = $currentImageId < 0;
if ($device->getLockedImage() !== null) {
$image = $device->getLockedImage();
} elseif ($forceResync || $this->rotation->isDue($device)) {
} elseif ($forceResync || $wantsBootstrap || $this->rotation->isDue($device)) {
$image = $this->rotation->advance($device);
} else {
$image = $device->getCurrentImage();