From e2a8ea4a7e5e66cad3a6106eb607b2e7d378e8e0 Mon Sep 17 00:00:00 2001 From: Matt Edholm Date: Fri, 8 May 2026 12:18:43 -0400 Subject: [PATCH] feat(rotation): X-Boot-Reason: cold forces a resync regardless of schedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/Controller/DeviceImageController.php | 16 ++++-- .../Controller/DeviceImageControllerTest.php | 57 +++++++++++++++++++ 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/src/Controller/DeviceImageController.php b/src/Controller/DeviceImageController.php index 0d361cf..0597b49 100644 --- a/src/Controller/DeviceImageController.php +++ b/src/Controller/DeviceImageController.php @@ -83,14 +83,18 @@ class DeviceImageController extends AbstractController // the second flush triggers a second publish to keep it accurate. $this->mercure->publishDevice((int) $device->getId(), $this->serializer->serialize($device)); - // Locked image bypasses rotation entirely. Otherwise, only advance the - // rotation when the device's configured schedule says it's due — - // off-schedule polls (boot, button-mash, scheduler-driven status check) - // hand back the device's current image without rotating, so the user - // doesn't see surprise refreshes between configured wake times. + // 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. + $bootReason = strtolower((string) $request->headers->get('X-Boot-Reason', '')); + $forceResync = ($bootReason === 'cold'); + if ($device->getLockedImage() !== null) { $image = $device->getLockedImage(); - } elseif ($this->rotation->isDue($device)) { + } elseif ($forceResync || $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 50cc757..d3fe055 100644 --- a/tests/Functional/Controller/DeviceImageControllerTest.php +++ b/tests/Functional/Controller/DeviceImageControllerTest.php @@ -376,6 +376,63 @@ class DeviceImageControllerTest extends AppWebTestCase $this->assertLessThanOrEqual(8 * 60 * 60 * 1000, $intervalMs); } + // FORCE-RESYNC FEATURE: a cold-boot poll (X-Boot-Reason: cold) MUST + // advance the rotation even when the schedule says we're not due. This + // is documented behavior — unplug-and-replug = manual refresh — and + // this test exists specifically to keep the feature from regressing. + // See feedback_force_resync_via_powercycle.md in agent memory. + public function test_cold_boot_force_resyncs_off_schedule(): void + { + $setup = $this->createTestSetup(); + $device = $setup['device']; + + // Establish a current image, then configure wakeTimes such that the + // most-recent-past slot has already been served — schedule says + // we're NOT due for another rotation right now. + $this->client->request('GET', '/api/device/' . self::MAC . '/image'); + $this->assertResponseStatusCodeSame(200); + $imageId = $this->client->getResponse()->headers->get('X-Image-Id'); + $device->setWakeTimes([0])->setTimezone('UTC'); + $this->em()->flush(); + + // Sanity-check: a *timer* wake at this point would 304 (no rotation). + $this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [ + 'HTTP_X_Boot_Reason' => 'timer', + 'HTTP_X_Current_Image_Id' => $imageId, + ]); + $this->assertResponseStatusCodeSame(304, 'timer wake must respect the schedule'); + + // The actual feature test: a cold-boot poll force-rotates regardless. + // It returns 200 (or 304 if the rotation happened to land on the same + // image again — depends on pool size). The proof is that it ran + // through the rotation path, which we verify by checking that a new + // history row was written. + $beforeCount = $this->countHistory($device); + $this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [ + 'HTTP_X_Boot_Reason' => 'cold', + 'HTTP_X_Current_Image_Id' => $imageId, + ]); + $afterCount = $this->countHistory($device); + + $this->assertSame( + $beforeCount + 1, + $afterCount, + 'cold-boot poll must advance the rotation (write a fresh history row) even off-schedule', + ); + } + + private function countHistory(\App\Entity\Device $device): int + { + return (int) $this->em() + ->createQueryBuilder() + ->select('COUNT(h.id)') + ->from(\App\Entity\DeviceImageHistory::class, 'h') + ->where('h.device = :d') + ->setParameter('d', $device) + ->getQuery() + ->getSingleScalarResult(); + } + // 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