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; } }