diff --git a/src/Controller/DeviceImageController.php b/src/Controller/DeviceImageController.php index 15226d1..e622c72 100644 --- a/src/Controller/DeviceImageController.php +++ b/src/Controller/DeviceImageController.php @@ -170,12 +170,24 @@ class DeviceImageController extends AbstractController // 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'); + // + // Recovery override: X-Draw-Pending: 1 means the device's drawNeeded + // NVS flag survived a power-loss-during-draw. We give it back its + // own current image so the firmware can finish repainting from its + // cached /img.bin. This explicitly overrides the cold-boot + // force-resync, because the typical interrupted-draw cause IS a + // reset that turns the next wake into a cold boot — without this + // bypass, force-resync chases a fresh image every interruption and + // the device churns through the rotation leaving torn frames. + $bootReason = strtolower((string) $request->headers->get('X-Boot-Reason', '')); + $forceResync = ($bootReason === 'cold'); $wantsBootstrap = $currentImageId < 0; + $drawPending = $request->headers->get('X-Draw-Pending') === '1'; if ($device->getLockedImage() !== null) { $image = $device->getLockedImage(); + } elseif ($drawPending) { + $image = $device->getCurrentImage(); } elseif ($forceResync || $wantsBootstrap || $this->rotation->isDue($device)) { $image = $this->rotation->advance($device); } else { diff --git a/tests/Functional/Controller/DeviceImageControllerTest.php b/tests/Functional/Controller/DeviceImageControllerTest.php index d12404e..2db96f7 100644 --- a/tests/Functional/Controller/DeviceImageControllerTest.php +++ b/tests/Functional/Controller/DeviceImageControllerTest.php @@ -437,6 +437,97 @@ class DeviceImageControllerTest extends AppWebTestCase ); } + // RECOVERY HANDSHAKE: X-Draw-Pending: 1 says the device is mid-recovery + // from a power-loss-during-draw. Even on a cold boot, the server MUST + // suppress rotation advancement and return the device's current image, + // so the firmware can repaint from its cached /img.bin instead of + // chasing a fresh image every reset. Without this override, force- + // resync defeats the firmware's drawNeeded recovery branch and the + // device churns through the pool leaving torn frames on the panel. + public function test_draw_pending_overrides_cold_boot_force_resync(): void + { + $setup = $this->createTestSetup(); + $device = $setup['device']; + + // Land a first image so the device has a current_image set. + $this->client->request('GET', '/api/device/' . self::MAC . '/image'); + $this->assertResponseStatusCodeSame(200); + $imageId = $this->client->getResponse()->headers->get('X-Image-Id'); + + // Sanity-check the no-pending path: a cold boot at this point WILL + // force-resync (this is the very behavior the pending header overrides). + $beforeColdNoPending = $this->countHistory($device); + $this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [ + 'HTTP_X_Boot_Reason' => 'cold', + 'HTTP_X_Current_Image_Id' => $imageId, + ]); + $afterColdNoPending = $this->countHistory($device); + $this->assertSame( + $beforeColdNoPending + 1, + $afterColdNoPending, + 'sanity: bare cold-boot must force-resync (baseline for the override below)', + ); + + // The actual feature: cold boot + X-Draw-Pending must NOT advance. + // Rotation advancement is what writes device_image_history rows, so + // an unchanged count is the proof that advance() was skipped. + $imageIdAfterFirstCold = $this->client->getResponse()->headers->get('X-Image-Id'); + $beforePending = $this->countHistory($device); + $this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [ + 'HTTP_X_Boot_Reason' => 'cold', + 'HTTP_X_Current_Image_Id' => $imageIdAfterFirstCold, + 'HTTP_X_Draw_Pending' => '1', + ]); + $afterPending = $this->countHistory($device); + + $this->assertSame( + $beforePending, + $afterPending, + 'X-Draw-Pending must override cold-boot force-resync — no advance, no new history row', + ); + $this->assertResponseStatusCodeSame( + 304, + 'with draw-pending, the device gets its current image back as 304 (it already has the bytes)', + ); + } + + // RECOVERY HANDSHAKE: X-Draw-Pending must also suppress the normal + // schedule-based rotation. The device is asking to redraw what it has, + // not for a fresh image — regardless of whether the schedule is due. + public function test_draw_pending_overrides_due_schedule(): void + { + $setup = $this->createTestSetup(); + $device = $setup['device']; + + // Establish current image, then make the schedule "always due" + // (rotation interval of 0 means every poll is past-due). + $this->client->request('GET', '/api/device/' . self::MAC . '/image'); + $this->assertResponseStatusCodeSame(200); + $imageId = $this->client->getResponse()->headers->get('X-Image-Id'); + $device->setRotationIntervalMinutes(1); + $this->em()->flush(); + // Backdate the served_at so isDue() reports true on the next poll. + $this->em()->getConnection()->executeStatement( + 'UPDATE device_image_history SET served_at = served_at - INTERVAL \'1 hour\' WHERE device_id = :id', + ['id' => $device->getId()], + ); + + $before = $this->countHistory($device); + $this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [ + 'HTTP_X_Boot_Reason' => 'timer', + 'HTTP_X_Current_Image_Id' => $imageId, + 'HTTP_X_Draw_Pending' => '1', + ]); + $after = $this->countHistory($device); + + $this->assertSame( + $before, + $after, + 'draw-pending must skip rotation even when the schedule reports due — no advance, no new history row', + ); + $this->assertResponseStatusCodeSame(304); + } + private function countHistory(\App\Entity\Device $device): int { return (int) $this->em()