feat: show currently selected image on home screen frame card
CI / test (push) Has been cancelled

Decode the device's rendered 4bpp Spectra-6 .bin into a PNG (cached
next to the .bin) so the home-screen preview matches the dithered
6-color output the e-ink actually displays.

- New endpoint: GET /api/devices/{id}/preview
- Expose currentImageId on device JSON
- HomeView passes preview URL to FrameCard for both single and compact layouts
- Drive-by: fix vite.config.ts to import defineConfig from vitest/config
  so the build no longer fails on the unknown `test` property; remove
  unused useUploadStore import in HomeView test

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 12:45:06 -04:00
parent 199a75fd72
commit fc0111a18e
18 changed files with 122 additions and 13 deletions
+99
View File
@@ -6,13 +6,18 @@ namespace App\Controller;
use App\Entity\Device;
use App\Entity\Image;
use App\Entity\RenderedAsset;
use App\Entity\User;
use App\Enum\Orientation;
use App\Enum\RenderStatus;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -20,6 +25,23 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_USER')]
class DeviceApiController extends AbstractController
{
// Spectra 6 palette — must match RenderImageMessageHandler::PALETTE.
// Used to decode the device's 4bpp .bin into an RGB preview that mirrors
// exactly what the e-ink displays.
private const PALETTE = [
0x0 => [26, 26, 26 ], // BLACK
0x1 => [245, 245, 240], // WHITE
0x2 => [240, 208, 0 ], // YELLOW
0x3 => [192, 48, 32 ], // RED
0x5 => [24, 64, 192], // BLUE
0x6 => [16, 160, 64 ], // GREEN
];
public function __construct(
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
) {}
#[Route('', name: 'api_devices_list', methods: ['GET'])]
public function list(EntityManagerInterface $em): JsonResponse
{
@@ -149,6 +171,83 @@ class DeviceApiController extends AbstractController
'linkedAt' => $d->getLinkedAt()->format(\DateTimeInterface::ATOM),
'lastSeenAt' => $d->getLastSeenAt()?->format(\DateTimeInterface::ATOM),
'lockedImageId' => $d->getLockedImage()?->getId(),
'currentImageId' => $d->getCurrentImage()?->getId(),
];
}
/**
* Serve a PNG preview of the image currently shown on the frame, decoded
* from the device's rendered 4bpp Spectra-6 .bin so the colors match what
* the e-ink actually displays. The PNG is cached on disk next to the .bin.
*/
#[Route('/{id}/preview', name: 'api_device_preview', methods: ['GET'])]
public function preview(int $id, EntityManagerInterface $em): Response
{
/** @var User $user */
$user = $this->getUser();
$device = $em->getRepository(Device::class)->findOneBy(['id' => $id, 'user' => $user]);
if (!$device) {
return $this->json(['error' => 'Device not found'], Response::HTTP_NOT_FOUND);
}
$image = $device->getLockedImage() ?? $device->getCurrentImage();
if (!$image || $image->isDeleted()) {
return $this->json(['error' => 'No current image'], Response::HTTP_NOT_FOUND);
}
$asset = $em->getRepository(RenderedAsset::class)->findOneBy([
'image' => $image,
'deviceModel' => $device->getModel(),
'orientation' => $device->getOrientation(),
'status' => RenderStatus::Ready,
]);
if (!$asset?->getFilePath()) {
return $this->json(['error' => 'Render not ready'], Response::HTTP_NOT_FOUND);
}
$binPath = $this->projectDir . '/' . $asset->getFilePath();
if (!file_exists($binPath)) {
return $this->json(['error' => 'Render file missing'], Response::HTTP_NOT_FOUND);
}
$pngPath = preg_replace('/\.bin$/', '.png', $binPath);
if (!file_exists($pngPath) || filemtime($pngPath) < filemtime($binPath)) {
$this->renderBinToPng(
$binPath,
$pngPath,
$device->getModel()->width($device->getOrientation()),
$device->getModel()->height($device->getOrientation()),
);
}
$response = new BinaryFileResponse($pngPath);
$response->headers->set('Content-Type', 'image/png');
$response->setContentDisposition(
ResponseHeaderBag::DISPOSITION_INLINE,
'frame-' . $device->getId() . '.png',
);
return $response;
}
private function renderBinToPng(string $binPath, string $pngPath, int $width, int $height): void
{
$bin = (string) file_get_contents($binPath);
$len = strlen($bin);
$rgb = '';
$white = self::PALETTE[0x1];
for ($i = 0; $i < $len; $i++) {
$byte = ord($bin[$i]);
foreach ([($byte >> 4) & 0xF, $byte & 0xF] as $idx) {
$c = self::PALETTE[$idx] ?? $white;
$rgb .= chr($c[0]) . chr($c[1]) . chr($c[2]);
}
}
$im = new \Imagick();
$im->newImage($width, $height, new \ImagickPixel('white'));
$im->importImagePixels(0, 0, $width, $height, 'RGB', \Imagick::PIXEL_CHAR, $rgb);
$im->setImageFormat('png');
$im->writeImage($pngPath);
$im->destroy();
}
}