fix(devices): bootstrap-bypass when device sends no X-Current-Image-Id
CI / test (push) Has been cancelled
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:
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user