From d11ddff9123cdc5450e0d239042bf7bf83222fd9 Mon Sep 17 00:00:00 2001 From: Matt Edholm Date: Thu, 7 May 2026 14:32:58 -0400 Subject: [PATCH] feat(device): replace daily wakeHour with multi-time wakeTimes (minutes) 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) --- frontend/src/stores/devices.ts | 2 +- .../src/test/components/ApproveCard.test.ts | 2 +- .../src/test/components/DevicePicker.test.ts | 2 +- frontend/src/test/stores/devices.test.ts | 2 +- frontend/src/test/views/HomeView.test.ts | 140 +++++- frontend/src/test/views/LibraryView.test.ts | 2 +- frontend/src/test/views/UploadView.test.ts | 2 +- frontend/src/types/index.ts | 3 +- frontend/src/views/HomeView.vue | 448 +++++++++++++++--- migrations/Version20260507230001.php | 30 ++ ...Uhdoq2-.js => BaseBottomSheet-DEL-6DQj.js} | 2 +- ...r-DB6TSbzz.js => DevicePicker-BpEfhBZd.js} | 2 +- public/build/assets/HomeView-B1mPAJMq.js | 1 - public/build/assets/HomeView-BZig6Ltw.js | 1 + ...iew-CaEZqvVK.css => HomeView-CmEi-ND2.css} | 2 +- ...ew-olQux25G.js => LibraryView-BlqdGf9w.js} | 2 +- ...w-BHhuChZl.js => SettingsView-mCQWdADc.js} | 2 +- ...iew-yzz3wNGy.js => UploadView-Bzndg4jf.js} | 2 +- .../{index-DwuxDERh.js => index-Drxuo2vC.js} | 4 +- public/build/index.html | 2 +- src/Command/SeedFakeDevicesCommand.php | 14 +- src/Controller/DeviceApiController.php | 19 +- src/Controller/DeviceImageController.php | 17 +- src/Entity/Device.php | 36 +- .../AdvanceRotationMessageHandler.php | 22 +- src/Schedule.php | 5 +- .../Controller/DeviceApiControllerTest.php | 39 +- .../Controller/DeviceImageControllerTest.php | 26 +- .../AdvanceRotationMessageHandlerTest.php | 45 +- 29 files changed, 720 insertions(+), 156 deletions(-) create mode 100644 migrations/Version20260507230001.php rename public/build/assets/{BaseBottomSheet-lUhdoq2-.js => BaseBottomSheet-DEL-6DQj.js} (98%) rename public/build/assets/{DevicePicker-DB6TSbzz.js => DevicePicker-BpEfhBZd.js} (92%) delete mode 100644 public/build/assets/HomeView-B1mPAJMq.js create mode 100644 public/build/assets/HomeView-BZig6Ltw.js rename public/build/assets/{HomeView-CaEZqvVK.css => HomeView-CmEi-ND2.css} (63%) rename public/build/assets/{LibraryView-olQux25G.js => LibraryView-BlqdGf9w.js} (98%) rename public/build/assets/{SettingsView-BHhuChZl.js => SettingsView-mCQWdADc.js} (92%) rename public/build/assets/{UploadView-yzz3wNGy.js => UploadView-Bzndg4jf.js} (98%) rename public/build/assets/{index-DwuxDERh.js => index-Drxuo2vC.js} (99%) diff --git a/frontend/src/stores/devices.ts b/frontend/src/stores/devices.ts index fc8322f..06dc541 100644 --- a/frontend/src/stores/devices.ts +++ b/frontend/src/stores/devices.ts @@ -26,7 +26,7 @@ export const useDevicesStore = defineStore('devices', () => { } } - async function updateDevice(id: number, patch: Partial>) { + async function updateDevice(id: number, patch: Partial>) { const res = await fetch(`/api/devices/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, diff --git a/frontend/src/test/components/ApproveCard.test.ts b/frontend/src/test/components/ApproveCard.test.ts index e7bb235..0b1cbbf 100644 --- a/frontend/src/test/components/ApproveCard.test.ts +++ b/frontend/src/test/components/ApproveCard.test.ts @@ -38,7 +38,7 @@ const makeDevice = (overrides: Partial = {}): Device => ({ name: 'Living Room', orientation: 'landscape', rotationIntervalMinutes: 60, - wakeHour: null, + wakeTimes: [], timezone: 'UTC', uniquenessWindow: 30, linkedAt: '2026-01-01T00:00:00Z', diff --git a/frontend/src/test/components/DevicePicker.test.ts b/frontend/src/test/components/DevicePicker.test.ts index 932698e..1335768 100644 --- a/frontend/src/test/components/DevicePicker.test.ts +++ b/frontend/src/test/components/DevicePicker.test.ts @@ -27,7 +27,7 @@ const makeDevice = (overrides: Partial = {}): Device => ({ name: 'Living Room', orientation: 'landscape', rotationIntervalMinutes: 60, - wakeHour: null, + wakeTimes: [], timezone: 'America/Chicago', uniquenessWindow: 30, linkedAt: '2026-01-01T00:00:00Z', diff --git a/frontend/src/test/stores/devices.test.ts b/frontend/src/test/stores/devices.test.ts index 409ff3c..2bead8c 100644 --- a/frontend/src/test/stores/devices.test.ts +++ b/frontend/src/test/stores/devices.test.ts @@ -9,7 +9,7 @@ const makeDevice = (overrides: Partial = {}): Device => ({ name: 'Living Room', orientation: 'landscape', rotationIntervalMinutes: 60, - wakeHour: null, + wakeTimes: [], timezone: 'America/Chicago', uniquenessWindow: 30, linkedAt: '2026-01-01T00:00:00Z', diff --git a/frontend/src/test/views/HomeView.test.ts b/frontend/src/test/views/HomeView.test.ts index 3c019c4..032e89d 100644 --- a/frontend/src/test/views/HomeView.test.ts +++ b/frontend/src/test/views/HomeView.test.ts @@ -66,7 +66,7 @@ const makeDevice = (overrides: Partial = {}): Device => ({ name: 'Living Room', orientation: 'landscape', rotationIntervalMinutes: 60, - wakeHour: null, + wakeTimes: [], timezone: 'America/Chicago', uniquenessWindow: 30, linkedAt: '2026-01-01T00:00:00Z', @@ -253,7 +253,7 @@ describe('HomeView', () => { // HV-05: edit opens the settings sheet pre-filled from the device record it('edit emits open the settings sheet pre-populated from the device', async () => { const devicesStore = useDevicesStore() - devicesStore.devices = [makeDevice({ id: 9, name: 'Den', wakeHour: 22, timezone: 'America/Chicago' })] + devicesStore.devices = [makeDevice({ id: 9, name: 'Den', wakeTimes: [22 * 60], timezone: 'America/Chicago' })] vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() const wrapper = mountView() @@ -287,7 +287,7 @@ describe('HomeView', () => { // HV-06: saving the sheet calls updateDevice and closes it it('saving the settings sheet PATCHes via the store and closes', async () => { const devicesStore = useDevicesStore() - devicesStore.devices = [makeDevice({ id: 5, name: 'Old', wakeHour: 4, timezone: 'UTC' })] + devicesStore.devices = [makeDevice({ id: 5, name: 'Old', wakeTimes: [4 * 60], timezone: 'UTC' })] vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() const updateSpy = vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 })) @@ -304,7 +304,7 @@ describe('HomeView', () => { expect(updateSpy).toHaveBeenCalledWith(5, expect.objectContaining({ orientation: 'landscape', - wakeHour: 4, + wakeTimes: [4 * 60], timezone: 'UTC', })) const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' }) @@ -347,13 +347,13 @@ describe('HomeView', () => { expect(wrapper.findComponent({ name: 'FrameCard' }).props('status')).toBe('sync-fail') }) - it('uses a 24h window for devices configured with a daily wakeHour', async () => { + it('uses a 24h window for devices configured with explicit wake times', async () => { const devicesStore = useDevicesStore() - // wakeHour set, last seen 30h ago — between 1×24h and 2×24h → sync-fail + // wakeTimes set, last seen 30h ago — between 1×24h and 2×24h → sync-fail devicesStore.devices = [makeDevice({ id: 1, - wakeHour: 4, - rotationIntervalMinutes: 5, // ignored when wakeHour is set + wakeTimes: [4 * 60], + rotationIntervalMinutes: 5, // ignored when wakeTimes is non-empty lastSeenAt: new Date(Date.now() - 1000 * 60 * 60 * 30).toISOString(), })] vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() @@ -377,11 +377,11 @@ describe('HomeView', () => { expect(props.nextSync).toMatch(/next sync in/) }) - it('passes a wakeHour-based nextSync label when the device wakes daily', async () => { + it('passes a wakeTimes-based nextSync label when the device has explicit wake times', async () => { const devicesStore = useDevicesStore() devicesStore.devices = [makeDevice({ id: 1, - wakeHour: 4, + wakeTimes: [4 * 60], timezone: 'UTC', lastSeenAt: new Date(Date.now() - 1000 * 60 * 60).toISOString(), })] @@ -432,24 +432,29 @@ describe('HomeView', () => { expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toBeNull() }) - it('formats wakeHour 12 PM, 12 AM, and 8 PM correctly', async () => { + it('formats wake times 12 PM, 12 AM, 8 PM, and 6:30 AM correctly', async () => { const devicesStore = useDevicesStore() vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() - devicesStore.devices = [makeDevice({ id: 1, wakeHour: 12, timezone: 'UTC' })] + devicesStore.devices = [makeDevice({ id: 1, wakeTimes: [12 * 60], timezone: 'UTC' })] let wrapper = mountView() await flushPromises() expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/12 PM/) - devicesStore.devices = [makeDevice({ id: 1, wakeHour: 0, timezone: 'UTC' })] + devicesStore.devices = [makeDevice({ id: 1, wakeTimes: [0], timezone: 'UTC' })] wrapper = mountView() await flushPromises() expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/12 AM/) - devicesStore.devices = [makeDevice({ id: 1, wakeHour: 20, timezone: 'UTC' })] + devicesStore.devices = [makeDevice({ id: 1, wakeTimes: [20 * 60], timezone: 'UTC' })] wrapper = mountView() await flushPromises() expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/8 PM/) + + devicesStore.devices = [makeDevice({ id: 1, wakeTimes: [6 * 60 + 30], timezone: 'UTC' })] + wrapper = mountView() + await flushPromises() + expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/6:30 AM/) }) it('returns null lastSync when the device has no recorded last-seen time', async () => { @@ -493,9 +498,9 @@ describe('HomeView', () => { expect(wrapper.findComponent({ name: 'FrameCard' }).props('thumbnailUrl')).toBeUndefined() }) - it('updates editWakeHour when the user picks a different hour chip', async () => { + it('+ Add time appends a new wake time and saves it', async () => { const devicesStore = useDevicesStore() - devicesStore.devices = [makeDevice({ id: 5, wakeHour: 4 })] + devicesStore.devices = [makeDevice({ id: 5, wakeTimes: [4 * 60] })] vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 })) @@ -504,16 +509,95 @@ describe('HomeView', () => { await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5) await flushPromises() - const chips = wrapper.findAll('.home-view__interval-chip') - const chip8pm = chips.find(c => c.text() === '8 PM')! - await chip8pm.trigger('click') - expect(chip8pm.classes()).toContain('home-view__interval-chip--on') + // Sheet opens in 'times' mode (because device.wakeTimes is non-empty). + // Click the + Add time button — it should add 9:00 AM (first default + // candidate not already in the list). + const addBtn = wrapper.find('.home-view__time-add') + await addBtn.trigger('click') + await flushPromises() + expect(wrapper.findAll('.home-view__time-row')).toHaveLength(2) const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' }) .find(b => b.text().toLowerCase().includes('sav'))! await saveBtn.trigger('click') await flushPromises() - expect(devicesStore.updateDevice).toHaveBeenCalledWith(5, expect.objectContaining({ wakeHour: 20 })) + expect(devicesStore.updateDevice).toHaveBeenCalledWith(5, expect.objectContaining({ + wakeTimes: [4 * 60, 9 * 60], + })) + }) + + it('trash button removes a wake time from the list', async () => { + const devicesStore = useDevicesStore() + devicesStore.devices = [makeDevice({ id: 5, wakeTimes: [6 * 60, 18 * 60] })] + vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() + vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 })) + + const wrapper = mountView() + await flushPromises() + await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5) + await flushPromises() + + const removeButtons = wrapper.findAll('.home-view__time-remove') + expect(removeButtons).toHaveLength(2) + // Remove the first row (6 AM) + await removeButtons[0].trigger('click') + await flushPromises() + expect(wrapper.findAll('.home-view__time-row')).toHaveLength(1) + + const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' }) + .find(b => b.text().toLowerCase().includes('sav'))! + await saveBtn.trigger('click') + await flushPromises() + expect(devicesStore.updateDevice).toHaveBeenCalledWith(5, expect.objectContaining({ + wakeTimes: [18 * 60], + })) + }) + + it('switching the mode dropdown to interval saves rotationIntervalMinutes and clears wakeTimes', async () => { + const devicesStore = useDevicesStore() + devicesStore.devices = [makeDevice({ + id: 5, + wakeTimes: [4 * 60], + rotationIntervalMinutes: 60, + })] + vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() + vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 })) + + const wrapper = mountView() + await flushPromises() + await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5) + await flushPromises() + + const modeSelect = wrapper.find('.home-view__mode-select') + ;(modeSelect.element as HTMLSelectElement).value = 'interval' + await modeSelect.trigger('change') + + const intervalInput = wrapper.find('.home-view__interval-input') + ;(intervalInput.element as HTMLInputElement).value = '15' + await intervalInput.trigger('input') + + const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' }) + .find(b => b.text().toLowerCase().includes('sav'))! + await saveBtn.trigger('click') + await flushPromises() + expect(devicesStore.updateDevice).toHaveBeenCalledWith(5, expect.objectContaining({ + wakeTimes: [], + rotationIntervalMinutes: 15, + })) + }) + + it('shows the propagation note in the settings sheet', async () => { + const devicesStore = useDevicesStore() + devicesStore.devices = [makeDevice({ id: 5 })] + vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() + + const wrapper = mountView() + await flushPromises() + await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5) + await flushPromises() + + expect(wrapper.find('.home-view__propagation-note').text()) + .toMatch(/take effect at the next device update/i) }) it('saving while no device is being edited is a no-op (defensive guard)', async () => { @@ -536,7 +620,7 @@ describe('HomeView', () => { it('updates editName/orientation/timezone when their components emit changes', async () => { const devicesStore = useDevicesStore() - devicesStore.devices = [makeDevice({ id: 5, name: 'Original', wakeHour: 4, timezone: 'UTC' })] + devicesStore.devices = [makeDevice({ id: 5, name: 'Original', wakeTimes: [4 * 60], timezone: 'UTC' })] vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() const updateSpy = vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 })) @@ -562,12 +646,13 @@ describe('HomeView', () => { })) }) - it('edit defaults wakeHour to 4 and timezone to UTC when the device has neither', async () => { + it('opens in interval mode and defaults timezone to UTC when device has empty wakeTimes', async () => { const devicesStore = useDevicesStore() devicesStore.devices = [makeDevice({ id: 5, name: 'Den', - wakeHour: null, + wakeTimes: [], + rotationIntervalMinutes: 60, timezone: null as any, })] vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() @@ -578,12 +663,17 @@ describe('HomeView', () => { await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5) await flushPromises() + // Sheet opens in interval mode — interval input is shown, time-list is not. + expect(wrapper.find('.home-view__interval-input').exists()).toBe(true) + expect(wrapper.find('.home-view__time-add').exists()).toBe(false) + const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' }) .find(b => b.text().toLowerCase().includes('sav'))! await saveBtn.trigger('click') await flushPromises() expect(updateSpy).toHaveBeenCalledWith(5, expect.objectContaining({ - wakeHour: 4, + wakeTimes: [], + rotationIntervalMinutes: 60, timezone: 'UTC', })) }) diff --git a/frontend/src/test/views/LibraryView.test.ts b/frontend/src/test/views/LibraryView.test.ts index 8c01572..9f4f3a1 100644 --- a/frontend/src/test/views/LibraryView.test.ts +++ b/frontend/src/test/views/LibraryView.test.ts @@ -80,7 +80,7 @@ const makeDevice = (overrides: Partial = {}): Device => ({ name: 'Living Room', orientation: 'landscape', rotationIntervalMinutes: 60, - wakeHour: null, + wakeTimes: [], timezone: 'America/Chicago', uniquenessWindow: 30, linkedAt: '2026-01-01T00:00:00Z', diff --git a/frontend/src/test/views/UploadView.test.ts b/frontend/src/test/views/UploadView.test.ts index fc7b123..1e36a16 100644 --- a/frontend/src/test/views/UploadView.test.ts +++ b/frontend/src/test/views/UploadView.test.ts @@ -56,7 +56,7 @@ const makeDevice = (overrides: Partial = {}): Device => ({ name: 'Living Room', orientation: 'landscape', rotationIntervalMinutes: 60, - wakeHour: null, + wakeTimes: [], timezone: 'UTC', uniquenessWindow: 30, linkedAt: '2026-01-01T00:00:00Z', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 4f081c2..22b2f00 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -12,7 +12,8 @@ export interface Device { name: string orientation: 'landscape' | 'portrait' rotationIntervalMinutes: number - wakeHour: number | null + /** Wake times as minutes-since-midnight (0-1439). Empty = use rotationIntervalMinutes. */ + wakeTimes: number[] timezone: string uniquenessWindow: number linkedAt: string diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 87471d2..346d737 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -88,21 +88,106 @@
-

Update time

-
- -
- + + + +
+
+
+ + : + + + +
+ +

No update times yet — add one below.

+
+ + + + +
+ +
+
+ Every + + minutes +
+
+ +

{{ nextUpdatePreview }}

+

+ Changes will only take effect at the next device update. +

+ diff --git a/src/Command/SeedFakeDevicesCommand.php b/src/Command/SeedFakeDevicesCommand.php index 29bc993..bdd3d79 100644 --- a/src/Command/SeedFakeDevicesCommand.php +++ b/src/Command/SeedFakeDevicesCommand.php @@ -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']))); diff --git a/src/Controller/DeviceApiController.php b/src/Controller/DeviceApiController.php index 94dddca..899c7fd 100644 --- a/src/Controller/DeviceApiController.php +++ b/src/Controller/DeviceApiController.php @@ -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), diff --git a/src/Controller/DeviceImageController.php b/src/Controller/DeviceImageController.php index 819b6e5..f7e5ebe 100644 --- a/src/Controller/DeviceImageController.php +++ b/src/Controller/DeviceImageController.php @@ -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; diff --git a/src/Entity/Device.php b/src/Entity/Device.php index c434b2b..1d03d16 100644 --- a/src/Entity/Device.php +++ b/src/Entity/Device.php @@ -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; } diff --git a/src/MessageHandler/AdvanceRotationMessageHandler.php b/src/MessageHandler/AdvanceRotationMessageHandler.php index 5a0d705..c3f4e11 100644 --- a/src/MessageHandler/AdvanceRotationMessageHandler.php +++ b/src/MessageHandler/AdvanceRotationMessageHandler.php @@ -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(); diff --git a/src/Schedule.php b/src/Schedule.php index f54b19f..73191a1 100644 --- a/src/Schedule.php +++ b/src/Schedule.php @@ -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) diff --git a/tests/Functional/Controller/DeviceApiControllerTest.php b/tests/Functional/Controller/DeviceApiControllerTest.php index d7f0231..4352163 100644 --- a/tests/Functional/Controller/DeviceApiControllerTest.php +++ b/tests/Functional/Controller/DeviceApiControllerTest.php @@ -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 diff --git a/tests/Functional/Controller/DeviceImageControllerTest.php b/tests/Functional/Controller/DeviceImageControllerTest.php index 7c178bf..862c2b9 100644 --- a/tests/Functional/Controller/DeviceImageControllerTest.php +++ b/tests/Functional/Controller/DeviceImageControllerTest.php @@ -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 { diff --git a/tests/Integration/MessageHandler/AdvanceRotationMessageHandlerTest.php b/tests/Integration/MessageHandler/AdvanceRotationMessageHandlerTest.php index 4f2c177..beb7b59 100644 --- a/tests/Integration/MessageHandler/AdvanceRotationMessageHandlerTest.php +++ b/tests/Integration/MessageHandler/AdvanceRotationMessageHandlerTest.php @@ -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', + ); } }