From f30a6a8f870989b4e491ae6e662a886f6aeb7198 Mon Sep 17 00:00:00 2001 From: Matt Edholm Date: Fri, 8 May 2026 19:01:56 -0400 Subject: [PATCH] fix(devices): bootstrap-bypass when device sends no X-Current-Image-Id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/Controller/DeviceImageController.php | 25 ++++++++--- .../Controller/DeviceImageControllerTest.php | 43 +++++++++++++++++++ 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/Controller/DeviceImageController.php b/src/Controller/DeviceImageController.php index 2dfdede..31df85e 100644 --- a/src/Controller/DeviceImageController.php +++ b/src/Controller/DeviceImageController.php @@ -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(); diff --git a/tests/Functional/Controller/DeviceImageControllerTest.php b/tests/Functional/Controller/DeviceImageControllerTest.php index 3231284..e26c8da 100644 --- a/tests/Functional/Controller/DeviceImageControllerTest.php +++ b/tests/Functional/Controller/DeviceImageControllerTest.php @@ -449,6 +449,49 @@ class DeviceImageControllerTest extends AppWebTestCase ->getSingleScalarResult(); } + // BOOTSTRAP BYPASS: a freshly-claimed device has no NVS img_id and + // therefore sends no X-Current-Image-Id header. The schedule + // (defaulting to noon-daily) would otherwise refuse to advance for + // up to 24h, and the buyer would see a dark panel after registering. + // Bypass schedule-gating in that case and serve whatever's available. + public function test_bootstrap_poll_advances_even_when_schedule_says_not_due(): void + { + $setup = $this->createTestSetup(); + $device = $setup['device']; + + // First, take the device through a poll cycle so it has currentImage, + // then configure wakeTimes such that the most-recent past slot has + // already been served. From this state, an off-schedule timer-wake + // poll WITH X-Current-Image-Id would 304 (covered elsewhere). But a + // bootstrap poll (no X-Current-Image-Id, simulating fresh NVS) MUST + // still advance — the user just claimed and is waiting for an image. + $this->client->request('GET', '/api/device/' . self::MAC . '/image'); + $this->assertResponseStatusCodeSame(200); + $device->setWakeTimes([0])->setTimezone('UTC'); + $this->em()->flush(); + + $beforeCount = (int) $this->em()->createQueryBuilder() + ->select('COUNT(h.id)') + ->from(\App\Entity\DeviceImageHistory::class, 'h') + ->where('h.device = :d') + ->setParameter('d', $device) + ->getQuery() + ->getSingleScalarResult(); + + // No X-Current-Image-Id header — bootstrap. + $this->client->request('GET', '/api/device/' . self::MAC . '/image'); + $this->assertResponseIsSuccessful(); + + $afterCount = (int) $this->em()->createQueryBuilder() + ->select('COUNT(h.id)') + ->from(\App\Entity\DeviceImageHistory::class, 'h') + ->where('h.device = :d') + ->setParameter('d', $device) + ->getQuery() + ->getSingleScalarResult(); + $this->assertSame($beforeCount + 1, $afterCount, 'bootstrap poll wrote a fresh history row → advance() ran'); + } + // Off-schedule poll (wakeTimes set, but the most-recent past slot has // already been served): the controller MUST NOT advance the rotation. // It returns 304 (device's X-Current-Image-Id matches the existing