feat(device): replace daily wakeHour with multi-time wakeTimes (minutes)
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:
2026-05-07 14:32:58 -04:00
parent 100e101d05
commit d11ddff912
29 changed files with 720 additions and 156 deletions
@@ -209,19 +209,52 @@ class DeviceApiControllerTest extends AppWebTestCase
$this->assertResponseStatusCodeSame(422);
}
public function test_patch_sets_wake_hour(): void
public function test_patch_sets_wake_times(): void
{
$user = $this->createUser('patchwake@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:B4', $user);
$client = $this->loginAs($user);
// 6:00 AM, 3:00 PM, 7:30 PM expressed as minutes since midnight
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['wakeHour' => 8]));
], json_encode(['wakeTimes' => [6 * 60, 15 * 60, 19 * 60 + 30]]));
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertSame(8, $data['wakeHour']);
$this->assertSame([360, 900, 1170], $data['wakeTimes']);
}
public function test_patch_rejects_out_of_range_wake_times(): void
{
$user = $this->createUser('patchwakebad@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:B7', $user);
$client = $this->loginAs($user);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['wakeTimes' => [1500]]));
$this->assertResponseStatusCodeSame(422);
}
public function test_patch_clears_wake_times_with_empty_array(): void
{
$user = $this->createUser('patchwakeclear@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:B8', $user);
$device->setWakeTimes([6 * 60, 18 * 60]);
$em = static::getContainer()->get('doctrine')->getManager();
$em->flush();
$client = $this->loginAs($user);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['wakeTimes' => []]));
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertSame([], $data['wakeTimes']);
}
public function test_patch_sets_uniqueness_window(): void
@@ -336,13 +336,13 @@ class DeviceImageControllerTest extends AppWebTestCase
$this->assertResponseStatusCodeSame(204);
}
// When wakeHour is set, X-Interval-Ms should be > 0 and <= 24h in ms
public function test_wake_hour_interval_used_when_set(): void
// When wakeTimes is set, X-Interval-Ms should be > 0 and <= 24h in ms
public function test_wake_times_interval_used_when_set(): void
{
$setup = $this->createTestSetup();
$device = $setup['device'];
$device->setWakeHour(3)->setTimezone('UTC');
$device->setWakeTimes([3 * 60])->setTimezone('UTC');
$this->em()->flush();
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
@@ -353,6 +353,26 @@ class DeviceImageControllerTest extends AppWebTestCase
$this->assertLessThanOrEqual(24 * 60 * 60 * 1000, $intervalMs);
}
// With multiple wake times, X-Interval-Ms must point to the *earliest*
// upcoming time, not just the first in the list.
public function test_wake_times_picks_earliest_upcoming(): void
{
$setup = $this->createTestSetup();
$device = $setup['device'];
// Use a fixed UTC tz; with three slots evenly spread, the gap to the
// next slot can never exceed 24h / count = 8h.
$device->setWakeTimes([6 * 60, 14 * 60, 22 * 60])->setTimezone('UTC');
$this->em()->flush();
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
$this->assertResponseStatusCodeSame(200);
$intervalMs = (int) $this->client->getResponse()->headers->get('X-Interval-Ms');
$this->assertGreaterThan(0, $intervalMs);
$this->assertLessThanOrEqual(8 * 60 * 60 * 1000, $intervalMs);
}
// Returns 204 when RenderedAsset has Ready status but filePath is null (device.poll.no_asset path)
public function test_returns_204_when_ready_asset_has_null_file_path(): void
{
@@ -119,11 +119,11 @@ class AdvanceRotationMessageHandlerTest extends AppKernelTestCase
$this->assertNotNull($reloaded->getCurrentImage());
}
// AR-04: wakeHour=0 (midnight, always past) + no history today → rotation occurs
public function test_ar04_wake_hour_past_no_history_rotates(): void
// AR-04: wakeTimes=[00:00] (always past) + no history today → rotation occurs
public function test_ar04_wake_time_past_no_history_rotates(): void
{
$device = $this->makeDevice();
$device->setWakeHour(0)->setTimezone('UTC');
$device->setWakeTimes([0])->setTimezone('UTC');
$image = $this->makeReadyImage($device);
$this->em()->flush();
@@ -138,11 +138,11 @@ class AdvanceRotationMessageHandlerTest extends AppKernelTestCase
$this->assertSame($imageId, $reloaded->getCurrentImage()->getId());
}
// AR-05: wakeHour=0 (midnight) + history exists since midnight → already served today → not due
public function test_ar05_wake_hour_already_served_today_is_skipped(): void
// AR-05: wakeTimes=[00:00] + history exists since midnight → already served today → not due
public function test_ar05_wake_time_already_served_today_is_skipped(): void
{
$device = $this->makeDevice();
$device->setWakeHour(0)->setTimezone('UTC');
$device->setWakeTimes([0])->setTimezone('UTC');
$image = $this->makeReadyImage($device);
$this->em()->flush();
@@ -163,10 +163,10 @@ class AdvanceRotationMessageHandlerTest extends AppKernelTestCase
$this->assertSame($imageId, $reloaded->getCurrentImage()?->getId());
}
// AR-06: wakeHour in future → isDue returns false → no rotation
// Uses 'Etc/GMT+11' (UTC-11) so local time is always before wakeHour=22
// AR-06: wakeTime in future → isDue returns false → no rotation
// Uses 'Etc/GMT+11' (UTC-11) so local time is always before 23:00 local
// except during UTC 09:00-10:59; test is skipped then.
public function test_ar06_wake_hour_in_future_is_not_due(): void
public function test_ar06_wake_time_in_future_is_not_due(): void
{
$utcHour = (int)(new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('G');
if ($utcHour >= 9 && $utcHour <= 10) {
@@ -174,8 +174,8 @@ class AdvanceRotationMessageHandlerTest extends AppKernelTestCase
}
$device = $this->makeDevice();
// UTC-11: local time is at most 12:59 when UTC is 23:59 → wakeHour=23 is always future
$device->setWakeHour(23)->setTimezone('Etc/GMT+11');
// UTC-11: local time is at most 12:59 when UTC is 23:59 → 23:00 always future
$device->setWakeTimes([23 * 60])->setTimezone('Etc/GMT+11');
$image = $this->makeReadyImage($device);
$this->em()->flush();
@@ -183,6 +183,27 @@ class AdvanceRotationMessageHandlerTest extends AppKernelTestCase
$this->em()->clear();
$reloaded = $this->em()->find(Device::class, $device->getId());
$this->assertNull($reloaded->getCurrentImage(), 'Rotation must not happen when wakeHour is still in the future');
$this->assertNull($reloaded->getCurrentImage(), 'Rotation must not happen when wake time is still in the future');
}
// AR-07: multiple wakeTimes — 00:00 has passed, so device is due even
// though later slots haven't fired yet. Validates that we use the most
// recent past slot as the boundary, not the earliest.
public function test_ar07_multiple_wake_times_uses_most_recent_past_slot(): void
{
$device = $this->makeDevice();
// 00:00 always past, 23:00 future for most of the day
$device->setWakeTimes([0, 23 * 60])->setTimezone('UTC');
$image = $this->makeReadyImage($device);
$this->em()->flush();
$this->invokeHandler();
$this->em()->clear();
$reloaded = $this->em()->find(Device::class, $device->getId());
$this->assertNotNull(
$reloaded->getCurrentImage(),
'Device with multiple wake times should rotate when at least one has passed today and no history exists since',
);
}
}