feat(device): replace daily wakeHour with multi-time wakeTimes (minutes)
CI / test (push) Has been cancelled
CI / test (push) Has been cancelled
Frame settings now offer two update-frequency modes: "at specific times" or "every X minutes". Times are stored as an int[] of minutes-since-midnight, allowing multiple slots per day at minute granularity. Backend computes the earliest upcoming slot for X-Interval-Ms and uses the most-recent-past slot as the rotation-due boundary. PWA settings sheet has hour/minute/AM-PM dropdowns with + Add / trash, a live "next update" preview, and a note that changes only take effect at the device's next sync. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -81,11 +81,11 @@ final class SeedFakeDevicesCommand extends Command
|
||||
// Five fakes covering each status state.
|
||||
$now = new \DateTimeImmutable();
|
||||
$fakes = [
|
||||
['name' => 'Living Room', 'orientation' => Orientation::Landscape, 'lastSeen' => 'PT5M', 'wakeHour' => null],
|
||||
['name' => 'Kitchen', 'orientation' => Orientation::Landscape, 'lastSeen' => 'PT90M', 'wakeHour' => null],
|
||||
['name' => 'Bedroom', 'orientation' => Orientation::Portrait, 'lastSeen' => 'P3D', 'wakeHour' => null],
|
||||
['name' => "Mom's Place", 'orientation' => Orientation::Portrait, 'lastSeen' => null, 'wakeHour' => null],
|
||||
['name' => 'Cabin', 'orientation' => Orientation::Landscape, 'lastSeen' => 'PT1H', 'wakeHour' => 4],
|
||||
['name' => 'Living Room', 'orientation' => Orientation::Landscape, 'lastSeen' => 'PT5M', 'wakeTimes' => []],
|
||||
['name' => 'Kitchen', 'orientation' => Orientation::Landscape, 'lastSeen' => 'PT90M', 'wakeTimes' => []],
|
||||
['name' => 'Bedroom', 'orientation' => Orientation::Portrait, 'lastSeen' => 'P3D', 'wakeTimes' => []],
|
||||
['name' => "Mom's Place", 'orientation' => Orientation::Portrait, 'lastSeen' => null, 'wakeTimes' => []],
|
||||
['name' => 'Cabin', 'orientation' => Orientation::Landscape, 'lastSeen' => 'PT1H', 'wakeTimes' => [4 * 60]],
|
||||
];
|
||||
|
||||
$reflLastSeen = new \ReflectionProperty(Device::class, 'lastSeenAt');
|
||||
@@ -100,8 +100,8 @@ final class SeedFakeDevicesCommand extends Command
|
||||
$device->setRotationIntervalMinutes(60);
|
||||
$device->setTimezone('America/New_York');
|
||||
$device->setUser($user);
|
||||
if ($cfg['wakeHour'] !== null) {
|
||||
$device->setWakeHour($cfg['wakeHour']);
|
||||
if (!empty($cfg['wakeTimes'])) {
|
||||
$device->setWakeTimes($cfg['wakeTimes']);
|
||||
}
|
||||
if ($cfg['lastSeen'] !== null) {
|
||||
$reflLastSeen->setValue($device, $now->sub(new \DateInterval($cfg['lastSeen'])));
|
||||
|
||||
@@ -85,8 +85,21 @@ class DeviceApiController extends AbstractController
|
||||
$device->setRotationIntervalMinutes(max(1, (int) $body['rotationIntervalMinutes']));
|
||||
}
|
||||
|
||||
if (array_key_exists('wakeHour', $body)) {
|
||||
$device->setWakeHour($body['wakeHour'] === null ? null : (int) $body['wakeHour']);
|
||||
if (array_key_exists('wakeTimes', $body)) {
|
||||
$times = $body['wakeTimes'];
|
||||
if (!is_array($times)) {
|
||||
return $this->json(['error' => 'wakeTimes must be an array'], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
foreach ($times as $t) {
|
||||
if (!is_int($t) && !(is_string($t) && ctype_digit($t))) {
|
||||
return $this->json(['error' => 'wakeTimes must contain integers 0-1439 (minutes since midnight)'], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
$ti = (int) $t;
|
||||
if ($ti < 0 || $ti > 1439) {
|
||||
return $this->json(['error' => 'wakeTimes must contain integers 0-1439 (minutes since midnight)'], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
}
|
||||
$device->setWakeTimes(array_map('intval', $times));
|
||||
}
|
||||
|
||||
if (isset($body['timezone'])) {
|
||||
@@ -165,7 +178,7 @@ class DeviceApiController extends AbstractController
|
||||
'name' => $d->getName(),
|
||||
'orientation' => $d->getOrientation()->value,
|
||||
'rotationIntervalMinutes' => $d->getRotationIntervalMinutes(),
|
||||
'wakeHour' => $d->getWakeHour(),
|
||||
'wakeTimes' => $d->getWakeTimes(),
|
||||
'timezone' => $d->getTimezone(),
|
||||
'uniquenessWindow' => $d->getUniquenessWindow(),
|
||||
'linkedAt' => $d->getLinkedAt()->format(\DateTimeInterface::ATOM),
|
||||
|
||||
@@ -28,14 +28,21 @@ class DeviceImageController extends AbstractController
|
||||
|
||||
private function computeIntervalMs(Device $device): int
|
||||
{
|
||||
if ($device->getWakeHour() !== null) {
|
||||
$wakeTimes = $device->getWakeTimes();
|
||||
if (!empty($wakeTimes)) {
|
||||
$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');
|
||||
$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) (($next->getTimestamp() - $now->getTimestamp()) * 1000);
|
||||
return (int) (($earliest->getTimestamp() - $now->getTimestamp()) * 1000);
|
||||
}
|
||||
|
||||
return $device->getRotationIntervalMinutes() * 60 * 1000;
|
||||
|
||||
+29
-7
@@ -30,15 +30,21 @@ class Device
|
||||
#[ORM\Column(enumType: Orientation::class)]
|
||||
private Orientation $orientation = Orientation::Landscape;
|
||||
|
||||
/** Minutes between rotation cycles (used when wakeHour is null). */
|
||||
/** Minutes between rotation cycles (used when wakeTimes is empty). */
|
||||
#[ORM\Column]
|
||||
private int $rotationIntervalMinutes = 1440;
|
||||
|
||||
/** Hour of day (0-23, local time) at which the device should wake; null = use rotationIntervalMinutes. */
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?int $wakeHour = null;
|
||||
/**
|
||||
* Wake times stored as minutes-since-midnight (0-1439) in `timezone`.
|
||||
* Empty array = use rotationIntervalMinutes (interval mode).
|
||||
* Non-empty = wake at each listed time of day.
|
||||
*
|
||||
* @var int[]
|
||||
*/
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $wakeTimes = [];
|
||||
|
||||
/** IANA timezone for wakeHour scheduling (e.g. 'Europe/Stockholm'). */
|
||||
/** IANA timezone for wakeTimes scheduling (e.g. 'Europe/Stockholm'). */
|
||||
#[ORM\Column(length: 60)]
|
||||
private string $timezone = 'UTC';
|
||||
|
||||
@@ -105,8 +111,24 @@ class Device
|
||||
public function getRotationIntervalMinutes(): int { return $this->rotationIntervalMinutes; }
|
||||
public function setRotationIntervalMinutes(int $m): static { $this->rotationIntervalMinutes = $m; return $this; }
|
||||
|
||||
public function getWakeHour(): ?int { return $this->wakeHour; }
|
||||
public function setWakeHour(?int $hour): static { $this->wakeHour = ($hour !== null) ? max(0, min(23, $hour)) : null; return $this; }
|
||||
/** @return int[] */
|
||||
public function getWakeTimes(): array { return $this->wakeTimes; }
|
||||
|
||||
/**
|
||||
* @param int[] $minutes minutes-since-midnight, 0-1439
|
||||
*/
|
||||
public function setWakeTimes(array $minutes): static
|
||||
{
|
||||
$clean = [];
|
||||
foreach ($minutes as $m) {
|
||||
$m = (int) $m;
|
||||
if ($m >= 0 && $m <= 1439) $clean[$m] = true;
|
||||
}
|
||||
$clean = array_keys($clean);
|
||||
sort($clean);
|
||||
$this->wakeTimes = $clean;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTimezone(): string { return $this->timezone; }
|
||||
public function setTimezone(string $tz): static { $this->timezone = $tz; return $this; }
|
||||
|
||||
@@ -40,23 +40,31 @@ class AdvanceRotationMessageHandler
|
||||
|
||||
private function isDue(Device $device): bool
|
||||
{
|
||||
if ($device->getWakeHour() !== null) {
|
||||
$tz = new \DateTimeZone($device->getTimezone());
|
||||
$now = new \DateTimeImmutable('now', $tz);
|
||||
$todayWake = $now->setTime($device->getWakeHour(), 0, 0);
|
||||
$wakeTimes = $device->getWakeTimes();
|
||||
if (!empty($wakeTimes)) {
|
||||
$tz = new \DateTimeZone($device->getTimezone());
|
||||
$now = new \DateTimeImmutable('now', $tz);
|
||||
|
||||
if ($now < $todayWake) {
|
||||
// Find the most recent wake time that has already passed today.
|
||||
// If none have hit yet, the next slot is in the future — not due.
|
||||
$boundary = null;
|
||||
foreach ($wakeTimes as $minutes) {
|
||||
$candidate = $now->setTime((int) ($minutes / 60), $minutes % 60, 0);
|
||||
if ($candidate <= $now && ($boundary === null || $candidate > $boundary)) {
|
||||
$boundary = $candidate;
|
||||
}
|
||||
}
|
||||
if ($boundary === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Due if no history entry exists since wakeHour today
|
||||
$entry = $this->em->createQueryBuilder()
|
||||
->select('h')
|
||||
->from(DeviceImageHistory::class, 'h')
|
||||
->where('h.device = :device')
|
||||
->andWhere('h.servedAt >= :wakeTime')
|
||||
->setParameter('device', $device)
|
||||
->setParameter('wakeTime', $todayWake)
|
||||
->setParameter('wakeTime', $boundary)
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
|
||||
+3
-2
@@ -20,8 +20,9 @@ class Schedule implements ScheduleProviderInterface
|
||||
public function getSchedule(): SymfonySchedule
|
||||
{
|
||||
// Rotation is handled at poll time in DeviceImageController — no scheduler needed.
|
||||
// DEV/PROD note: when switching to wakeHour mode, the device only polls once per day,
|
||||
// so rotation still happens correctly (isDue() fires on that single daily poll).
|
||||
// DEV/PROD note: when switching to wakeTimes mode, the device only polls
|
||||
// at each configured time, so rotation still happens correctly (isDue()
|
||||
// fires on each scheduled poll).
|
||||
return (new SymfonySchedule())
|
||||
->stateful($this->cache)
|
||||
->processOnlyLastMissedRun(true)
|
||||
|
||||
Reference in New Issue
Block a user