diff --git a/README.md b/README.md index c20a867..e933d69 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Support project that uses inker: - **Custom Widgets** — Connect to any JSON API or RSS feed (including local network sources), JavaScript transformations, grid layouts with word-wrapped cells - **Plugins** — Grafana panel integration with dashboard picker, live preview, and section grid compositing. More homelab-native plugins coming soon! - **TRMNL OG & X** — Automatic model detection; full **TRMNL X** support (10.3″, 1872×1404, 16-level grayscale) alongside the 7.5″ OG (800×480, 1-bit) -- **Playlists** — Rotate multiple screens on devices automatically, with optional **TRMNL X touch-bar** tap-to-advance (per playlist) +- **Playlists** — Rotate multiple screens on devices automatically, schedule refreshes at a fixed daily time, or use the optional **TRMNL X touch-bar** tap-to-advance mode - **Device Management** — Auto-provisioning, firmware support, real-time status, logs - **BYOD Support** — Register any e-ink device manually with custom screen resolution - **Custom Ports** — Run on any port with Docker port mapping (e.g. `800:80`) diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 4f2fcab..74b06fc 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -88,10 +88,11 @@ model Screen { } model Playlist { - id Int @id @default(autoincrement()) - name String - description String? - isActive Boolean @default(false) @map("is_active") + id Int @id @default(autoincrement()) + name String + description String? + isActive Boolean @default(false) @map("is_active") + dailyRefreshAt String? @map("daily_refresh_at") // TRMNL X only: advance to the next screen on each device poll (middle-tap or on-schedule). advanceOnTap Boolean @default(false) @map("advance_on_tap") createdAt DateTime @default(now()) @map("created_at") diff --git a/backend/src/api/display/display.service.test.ts b/backend/src/api/display/display.service.test.ts index 901cd76..9c86c4c 100644 --- a/backend/src/api/display/display.service.test.ts +++ b/backend/src/api/display/display.service.test.ts @@ -397,6 +397,70 @@ describe('DisplayService', () => { }); }); + describe('getSecondsUntilDailyRefresh (private)', () => { + const dailyRefresh = (time: string, now: Date) => + (service as any).getSecondsUntilDailyRefresh(time, now); + + it('returns the delay until the next refresh in the configured timezone', () => { + mockConfig.get = createMock().mockImplementation((key: string) => + key === 'defaultTimezone' ? 'Europe/Amsterdam' : 'http://localhost:3002', + ); + + const result = dailyRefresh('00:00', new Date('2026-01-15T11:00:00Z')); + + expect(result).toBe(43205); + }); + + it('accounts for the start of daylight saving time', () => { + mockConfig.get = createMock().mockImplementation((key: string) => + key === 'defaultTimezone' ? 'Europe/Amsterdam' : 'http://localhost:3002', + ); + + const result = dailyRefresh('00:00', new Date('2026-03-28T23:00:10Z')); + + expect(result).toBe(82795); + }); + + it('accounts for the end of daylight saving time', () => { + mockConfig.get = createMock().mockImplementation((key: string) => + key === 'defaultTimezone' ? 'Europe/Amsterdam' : 'http://localhost:3002', + ); + + const result = dailyRefresh('00:00', new Date('2026-10-24T22:00:10Z')); + + expect(result).toBe(89995); + }); + + it('returns null for invalid values', () => { + expect(dailyRefresh('midnight', new Date())).toBeNull(); + }); + }); + + describe('getDisplayContent - daily refresh', () => { + it('uses the playlist daily refresh time instead of the screen duration', async () => { + const now = new Date(); + const targetMinutes = (now.getUTCHours() * 60 + now.getUTCMinutes() + 120) % 1440; + const dailyRefreshAt = `${String(Math.floor(targetMinutes / 60)).padStart(2, '0')}:${String(targetMinutes % 60).padStart(2, '0')}`; + mockPrisma.device.findFirst.mockResolvedValue({ + id: 1, name: 'Test', + playlist: { + dailyRefreshAt, + items: [{ duration: 60, screen: { id: 9, name: 'S', imageUrl: '/uploads/s.png' } }], + }, + refreshRate: 900, refreshPending: false, + width: 800, height: 480, sleepStartAt: null, sleepStopAt: null, + }); + mockPrisma.device.update.mockResolvedValue({ id: 1, battery: 80, wifi: -50 }); + mockPrisma.firmware.findFirst.mockResolvedValue(null); + + const result = await service.getDisplayContent('test-key'); + + expect(result.refresh_rate).toBeGreaterThan(7100); + expect(result.refresh_rate).toBeLessThanOrEqual(7210); + expect(result.refresh_at).toBeGreaterThan(Date.now() + 7100 * 1000); + }); + }); + describe('getDisplayContent - quiet hours', () => { const windowAroundNow = () => { const hhmm = (offsetMin: number) => { diff --git a/backend/src/api/display/display.service.ts b/backend/src/api/display/display.service.ts index dbcc778..a73cf24 100644 --- a/backend/src/api/display/display.service.ts +++ b/backend/src/api/display/display.service.ts @@ -420,8 +420,15 @@ export class DisplayService { // Quiet hours (freeze mode): keep the current screen but make the device // sleep until the wake time instead of refreshing at the normal interval. - const finalRefreshRate = sleepSeconds ?? effectiveRefreshRate; - const finalRefreshAt = sleepSeconds !== null ? Date.now() + sleepSeconds * 1000 : nextRefreshAt; + const dailyRefreshSeconds = device.playlist.dailyRefreshAt + ? this.getSecondsUntilDailyRefresh(device.playlist.dailyRefreshAt) + : null; + const finalRefreshRate = sleepSeconds ?? dailyRefreshSeconds ?? effectiveRefreshRate; + const scheduledRefreshAt = sleepSeconds !== null + ? Date.now() + sleepSeconds * 1000 + : dailyRefreshSeconds !== null + ? Date.now() + dailyRefreshSeconds * 1000 + : nextRefreshAt; // Handle both regular screens and designed screens if (currentScreen.screen) { @@ -457,7 +464,7 @@ export class DisplayService { special_function: '', temperature_profile: 'default', maximum_compatibility: screenChanged, - refresh_at: finalRefreshAt, + refresh_at: scheduledRefreshAt, battery: updatedDevice.battery, wifi: updatedDevice.wifi, }; @@ -504,7 +511,7 @@ export class DisplayService { special_function: '', temperature_profile: 'default', maximum_compatibility: screenChanged, - refresh_at: finalRefreshAt, + refresh_at: scheduledRefreshAt, battery: updatedDevice.battery, wifi: updatedDevice.wifi, }; @@ -534,7 +541,7 @@ export class DisplayService { special_function: '', temperature_profile: 'default', maximum_compatibility: screenChanged, - refresh_at: finalRefreshAt, + refresh_at: scheduledRefreshAt, battery: updatedDevice.battery, wifi: updatedDevice.wifi, }; @@ -816,6 +823,78 @@ export class DisplayService { return hour * 3600 + get('minute') * 60 + get('second'); } + /** + * Calculate the delay until the playlist's next daily refresh. The target is + * resolved in DEFAULT_TIMEZONE and converted back to UTC iteratively so DST + * transitions do not introduce a one-hour drift. + */ + private getSecondsUntilDailyRefresh(refreshAt: string, now = new Date()): number | null { + const target = this.parseTimeToSeconds(refreshAt); + if (target === null) { + return null; + } + + const timeZone = this.config.get('defaultTimezone', 'UTC'); + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone, + hour12: false, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + const getParts = (date: Date) => { + const parts = formatter.formatToParts(date); + const get = (type: string) => parseInt(parts.find((part) => part.type === type)?.value || '0', 10); + return { + year: get('year'), + month: get('month'), + day: get('day'), + hour: get('hour') % 24, + minute: get('minute'), + second: get('second'), + }; + }; + + const localNow = getParts(now); + const targetHour = Math.floor(target / 3600); + const targetMinute = Math.floor((target % 3600) / 60); + const boundaryBufferSeconds = 5; + const localNowSeconds = localNow.hour * 3600 + localNow.minute * 60 + localNow.second; + const targetPassed = localNowSeconds >= target + boundaryBufferSeconds; + const localTarget = new Date(Date.UTC( + localNow.year, + localNow.month - 1, + localNow.day + (targetPassed ? 1 : 0), + targetHour, + targetMinute, + boundaryBufferSeconds, + )); + const desiredTimestamp = localTarget.getTime(); + let candidateTimestamp = desiredTimestamp; + + for (let attempt = 0; attempt < 4; attempt += 1) { + const candidate = getParts(new Date(candidateTimestamp)); + const representedTimestamp = Date.UTC( + candidate.year, + candidate.month - 1, + candidate.day, + candidate.hour, + candidate.minute, + candidate.second, + ); + const correction = desiredTimestamp - representedTimestamp; + candidateTimestamp += correction; + if (correction === 0) { + break; + } + } + + return Math.max(10, Math.ceil((candidateTimestamp - now.getTime()) / 1000)); + } + /** * Check if a screen design contains a clock widget */ diff --git a/backend/src/playlists/dto/create-playlist.dto.ts b/backend/src/playlists/dto/create-playlist.dto.ts index 9546789..54b2912 100644 --- a/backend/src/playlists/dto/create-playlist.dto.ts +++ b/backend/src/playlists/dto/create-playlist.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsString, IsOptional, IsBoolean, IsArray, ValidateNested, MaxLength } from 'class-validator'; +import { IsString, IsOptional, IsBoolean, IsArray, ValidateNested, MaxLength, Matches } from 'class-validator'; import { Type } from 'class-transformer'; class PlaylistScreenDto { @@ -39,6 +39,15 @@ export class CreatePlaylistDto { @IsBoolean() isActive?: boolean; + @ApiPropertyOptional({ + example: '00:00', + description: 'Daily refresh time in HH:MM format, evaluated in the server timezone', + nullable: true, + }) + @IsOptional() + @Matches(/^([01]\d|2[0-3]):[0-5]\d$/, { message: 'dailyRefreshAt must be in HH:MM format' }) + dailyRefreshAt?: string | null; + @ApiPropertyOptional({ example: false, description: 'TRMNL X only: advance to the next screen on each device poll (middle tap / on schedule)', diff --git a/backend/src/playlists/dto/update-playlist.dto.ts b/backend/src/playlists/dto/update-playlist.dto.ts index 12c10eb..0063dfb 100644 --- a/backend/src/playlists/dto/update-playlist.dto.ts +++ b/backend/src/playlists/dto/update-playlist.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsString, IsOptional, IsBoolean, IsArray, ValidateNested, MaxLength } from 'class-validator'; +import { IsString, IsOptional, IsBoolean, IsArray, ValidateNested, MaxLength, Matches } from 'class-validator'; import { Type } from 'class-transformer'; class PlaylistScreenDto { @@ -40,6 +40,15 @@ export class UpdatePlaylistDto { @IsBoolean() isActive?: boolean; + @ApiPropertyOptional({ + example: '00:00', + description: 'Daily refresh time in HH:MM format, evaluated in the server timezone', + nullable: true, + }) + @IsOptional() + @Matches(/^([01]\d|2[0-3]):[0-5]\d$/, { message: 'dailyRefreshAt must be in HH:MM format' }) + dailyRefreshAt?: string | null; + @ApiPropertyOptional({ example: false, description: 'TRMNL X only: advance to the next screen on each device poll (middle tap / on schedule)', diff --git a/backend/src/playlists/playlists.service.ts b/backend/src/playlists/playlists.service.ts index ff59905..6820d7f 100644 --- a/backend/src/playlists/playlists.service.ts +++ b/backend/src/playlists/playlists.service.ts @@ -32,6 +32,7 @@ export class PlaylistsService { name: playlistData.name, description: playlistData.description, isActive: playlistData.isActive ?? true, + dailyRefreshAt: playlistData.dailyRefreshAt, advanceOnTap: playlistData.advanceOnTap ?? false, }, include: { @@ -348,6 +349,7 @@ export class PlaylistsService { name: playlistData.name, description: playlistData.description, isActive: playlistData.isActive, + dailyRefreshAt: playlistData.dailyRefreshAt, advanceOnTap: playlistData.advanceOnTap, }, include: { diff --git a/frontend/src/pages/playlists/PlaylistForm.tsx b/frontend/src/pages/playlists/PlaylistForm.tsx index b64b11e..af0f9bc 100644 --- a/frontend/src/pages/playlists/PlaylistForm.tsx +++ b/frontend/src/pages/playlists/PlaylistForm.tsx @@ -38,6 +38,7 @@ export function PlaylistForm() { description: '', screens: [], isActive: true, // Always active by default + dailyRefreshAt: null, advanceOnTap: false, }); @@ -200,6 +201,7 @@ export function PlaylistForm() { description: playlist.description || '', screens: playlist.screens || [], isActive: playlist.isActive, + dailyRefreshAt: playlist.dailyRefreshAt ?? null, advanceOnTap: playlist.advanceOnTap ?? false, }); } @@ -375,6 +377,37 @@ export function PlaylistForm() { /> +
+ + {formData.dailyRefreshAt && ( +
+ setFormData((prev) => ({ ...prev, dailyRefreshAt: e.target.value }))} + required + /> +
+ )} +
+