e2a8ea4a7e
CI / test (push) Has been cancelled
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) <noreply@anthropic.com>
221 lines
10 KiB
PHP
221 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Controller;
|
|
|
|
use App\Entity\Device;
|
|
use App\Entity\RenderedAsset;
|
|
use App\Enum\RenderStatus;
|
|
use App\Service\DeviceSerializer;
|
|
use App\Service\MercurePublisher;
|
|
use App\Service\RotationService;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Psr\Log\LoggerInterface;
|
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
use Symfony\Component\Routing\Attribute\Route;
|
|
|
|
class DeviceImageController extends AbstractController
|
|
{
|
|
public function __construct(
|
|
#[Autowire('%kernel.project_dir%')]
|
|
private readonly string $projectDir,
|
|
private readonly RotationService $rotation,
|
|
private readonly LoggerInterface $logger,
|
|
private readonly DeviceSerializer $serializer,
|
|
private readonly MercurePublisher $mercure,
|
|
) {}
|
|
|
|
private function computeIntervalMs(Device $device): int
|
|
{
|
|
$wakeTimes = $device->getWakeTimes();
|
|
if (!empty($wakeTimes)) {
|
|
$tz = new \DateTimeZone($device->getTimezone());
|
|
$now = new \DateTimeImmutable('now', $tz);
|
|
$earliest = null;
|
|
foreach ($wakeTimes as $minutes) {
|
|
$candidate = $now->setTime((int) ($minutes / 60), $minutes % 60, 0);
|
|
if ($candidate->getTimestamp() <= $now->getTimestamp()) {
|
|
$candidate = $candidate->modify('+1 day');
|
|
}
|
|
if ($earliest === null || $candidate < $earliest) {
|
|
$earliest = $candidate;
|
|
}
|
|
}
|
|
return (int) (($earliest->getTimestamp() - $now->getTimestamp()) * 1000);
|
|
}
|
|
|
|
return $device->getRotationIntervalMinutes() * 60 * 1000;
|
|
}
|
|
|
|
#[Route('/api/device/{mac}/image', name: 'api_device_image', methods: ['GET'])]
|
|
public function image(string $mac, Request $request, EntityManagerInterface $em): Response
|
|
{
|
|
$device = $em->getRepository(Device::class)->findOneBy(['mac' => strtoupper($mac)]);
|
|
if (!$device) {
|
|
$this->logger->warning('device.poll.unknown_mac', ['mac' => $mac]);
|
|
return new Response(null, Response::HTTP_NOT_FOUND);
|
|
}
|
|
|
|
$intervalMs = $this->computeIntervalMs($device);
|
|
$currentImageId = (int) $request->headers->get('X-Current-Image-Id', '-1');
|
|
$device->markSeen();
|
|
|
|
// Stamp when we expect the device to call back — the PWA reads this
|
|
// directly so its "next sync" label reflects the schedule the device
|
|
// is actually on, not the freshly-saved one that won't reach it
|
|
// until that next poll.
|
|
$device->setNextPollExpectedAt(
|
|
(new \DateTimeImmutable())->modify('+' . (int) ceil($intervalMs / 1000) . ' seconds')
|
|
);
|
|
// Flush up-front so the 204/no_image/no_asset paths persist these too
|
|
// (they previously didn't flush at all — latent bug for lastSeenAt).
|
|
$em->flush();
|
|
|
|
// Push the new state to any subscribed PWA clients. Done before we
|
|
// know which response branch we'll take — lastSeenAt + nextPollExpectedAt
|
|
// moved on every successful poll regardless of image change, and the
|
|
// PWA cares about both. If the 200 path mutates currentImage below,
|
|
// 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, 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 ($forceResync || $this->rotation->isDue($device)) {
|
|
$image = $this->rotation->advance($device);
|
|
} else {
|
|
$image = $device->getCurrentImage();
|
|
}
|
|
|
|
if ($image === null) {
|
|
$this->logger->info('device.poll.no_image', [
|
|
'device_id' => $device->getId(),
|
|
'mac' => $mac,
|
|
'interval_ms' => $intervalMs,
|
|
]);
|
|
$r = new Response(null, Response::HTTP_NO_CONTENT);
|
|
$r->headers->set('X-Interval-Ms', (string) $intervalMs);
|
|
return $r;
|
|
}
|
|
|
|
// Asset lookup is needed before the 304 check so we can compare its
|
|
// rendered_at timestamp — otherwise a re-cropped image (same id, same
|
|
// orientation, new bytes) would be incorrectly served as 304.
|
|
$asset = $em->getRepository(RenderedAsset::class)->findOneBy([
|
|
'image' => $image,
|
|
'deviceModel' => $device->getModel(),
|
|
'orientation' => $device->getOrientation(),
|
|
'status' => RenderStatus::Ready,
|
|
]);
|
|
|
|
if (!$asset?->getFilePath()) {
|
|
$this->logger->warning('device.poll.no_asset', [
|
|
'device_id' => $device->getId(),
|
|
'mac' => $mac,
|
|
'image_id' => $image->getId(),
|
|
'model' => $device->getModel()->value,
|
|
'orientation' => $device->getOrientation()->value,
|
|
]);
|
|
$r = new Response(null, Response::HTTP_NO_CONTENT);
|
|
$r->headers->set('X-Interval-Ms', (string) $intervalMs);
|
|
return $r;
|
|
}
|
|
|
|
// 304: device already has this image — skip the binary transfer and redraw.
|
|
// The image id, the orientation we last served at, AND the asset's
|
|
// rendered_at must all match. A re-render (e.g. after re-crop) advances
|
|
// rendered_at, so the device's cached bytes are stale and we re-send.
|
|
$renderedAt = $asset->getRenderedAt();
|
|
if ($image->getId() === $currentImageId
|
|
&& $device->getCurrentImageOrientation() === $device->getOrientation()
|
|
&& $renderedAt !== null
|
|
&& $device->getCurrentRenderedAt()?->getTimestamp() === $renderedAt->getTimestamp()) {
|
|
// Self-heal currentImage: locked-image polls bypass advance() which
|
|
// would otherwise have set this. Without the assignment, currentImage
|
|
// stays stale — Home would keep showing the previous photo even
|
|
// though the device has been confirming the new one for cycles.
|
|
$device->setCurrentImage($image);
|
|
$em->flush();
|
|
$this->mercure->publishDevice((int) $device->getId(), $this->serializer->serialize($device));
|
|
|
|
$this->logger->info('device.poll.no_change', [
|
|
'device_id' => $device->getId(),
|
|
'mac' => $mac,
|
|
'image_id' => $image->getId(),
|
|
'interval_ms' => $intervalMs,
|
|
]);
|
|
$r = new Response(null, Response::HTTP_NOT_MODIFIED);
|
|
$r->headers->set('X-Image-Id', (string) $image->getId());
|
|
$r->headers->set('X-Interval-Ms', (string) $intervalMs);
|
|
return $r;
|
|
}
|
|
|
|
$binPath = $this->projectDir . '/' . $asset->getFilePath();
|
|
if (!file_exists($binPath)) {
|
|
$this->logger->error('device.poll.file_missing', [
|
|
'device_id' => $device->getId(),
|
|
'mac' => $mac,
|
|
'image_id' => $image->getId(),
|
|
'path' => $binPath,
|
|
]);
|
|
$r = new Response(null, Response::HTTP_NO_CONTENT);
|
|
$r->headers->set('X-Interval-Ms', (string) $intervalMs);
|
|
return $r;
|
|
}
|
|
|
|
// Record what the device is now showing, plus the orientation and
|
|
// rendered_at we served at (so the next 304 check can detect a flip
|
|
// or a re-render and force a re-fetch).
|
|
//
|
|
// currentImage must be set here for the locked-image path: rotation
|
|
// is bypassed when a lock is in effect, so RotationService.advance()
|
|
// never runs to update currentImage. Without this assignment, Home
|
|
// would keep showing the previous photo even after the device pulled
|
|
// the locked one.
|
|
$device->setCurrentImage($image);
|
|
$device->setCurrentImageOrientation($device->getOrientation());
|
|
$device->setCurrentRenderedAt($renderedAt);
|
|
$em->flush();
|
|
|
|
// Re-publish: currentImageId just changed, so the SPA needs the
|
|
// updated thumbnail URL. Cheap; the hub deduplicates per topic.
|
|
$this->mercure->publishDevice((int) $device->getId(), $this->serializer->serialize($device));
|
|
|
|
$this->logger->info('device.poll.served', [
|
|
'device_id' => $device->getId(),
|
|
'mac' => $mac,
|
|
'image_id' => $image->getId(),
|
|
'orientation' => $device->getOrientation()->value,
|
|
'interval_ms' => $intervalMs,
|
|
'bytes' => filesize($binPath),
|
|
]);
|
|
|
|
// SHA-256 of the .bin lets the device verify integrity end-to-end:
|
|
// anything that gets corrupted between Imagick's render and the
|
|
// ESP32 framebuffer (TCP edge cases, memory glitch, partial flush)
|
|
// is caught before we commit the bytes to NVS or paint the panel.
|
|
// The ESP32-S3 has hardware SHA so the verification is essentially
|
|
// free on the device side.
|
|
$response = new BinaryFileResponse($binPath);
|
|
$response->headers->set('Content-Type', 'application/octet-stream');
|
|
$response->headers->set('X-Image-Id', (string) $image->getId());
|
|
$response->headers->set('X-Image-Sha256', hash_file('sha256', $binPath));
|
|
$response->headers->set('X-Interval-Ms', (string) $intervalMs);
|
|
|
|
return $response;
|
|
}
|
|
}
|