chore: stage all in-progress work before repo split
CI / test (push) Has been cancelled

Web app: new entities (Image, RenderedAsset, SharedImage, Token,
DeviceImageHistory), enums, repositories, controllers, message handlers,
migrations, tests, frontend upload/library/sticker UI, Vue components.

Firmware: EPD background screen binaries + gen scripts, setup_bg header.

Infra: ddev config, test bundle, gitignore coverage dir.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 12:11:31 -04:00
parent 062c52eec7
commit 12245759ac
149 changed files with 14846 additions and 92 deletions
+133
View File
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Device;
use App\Entity\RenderedAsset;
use App\Enum\RenderStatus;
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 function computeIntervalMs(Device $device): int
{
if ($device->getWakeHour() !== null) {
$tz = new \DateTimeZone($device->getTimezone());
$now = new \DateTimeImmutable('now', $tz);
$next = $now->setTime($device->getWakeHour(), 0, 0);
if ($next->getTimestamp() <= $now->getTimestamp()) {
$next = $next->modify('+1 day');
}
return (int) (($next->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();
// Locked image bypasses rotation entirely.
$image = $device->getLockedImage() ?? $this->rotation->advance($device);
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;
}
// 304: device already has this image — skip the binary transfer and redraw.
if ($image->getId() === $currentImageId) {
$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;
}
$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;
}
$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;
}
$this->logger->info('device.poll.served', [
'device_id' => $device->getId(),
'mac' => $mac,
'image_id' => $image->getId(),
'interval_ms' => $intervalMs,
'bytes' => filesize($binPath),
]);
$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-Interval-Ms', (string) $intervalMs);
return $response;
}
}