Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
9 changes: 5 additions & 4 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
64 changes: 64 additions & 0 deletions backend/src/api/display/display.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
89 changes: 84 additions & 5 deletions backend/src/api/display/display.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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<string>('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
*/
Expand Down
11 changes: 10 additions & 1 deletion backend/src/playlists/dto/create-playlist.dto.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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)',
Expand Down
11 changes: 10 additions & 1 deletion backend/src/playlists/dto/update-playlist.dto.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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)',
Expand Down
2 changes: 2 additions & 0 deletions backend/src/playlists/playlists.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -348,6 +349,7 @@ export class PlaylistsService {
name: playlistData.name,
description: playlistData.description,
isActive: playlistData.isActive,
dailyRefreshAt: playlistData.dailyRefreshAt,
advanceOnTap: playlistData.advanceOnTap,
},
include: {
Expand Down
47 changes: 44 additions & 3 deletions frontend/src/pages/playlists/PlaylistForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export function PlaylistForm() {
description: '',
screens: [],
isActive: true, // Always active by default
dailyRefreshAt: null,
advanceOnTap: false,
});

Expand Down Expand Up @@ -200,6 +201,7 @@ export function PlaylistForm() {
description: playlist.description || '',
screens: playlist.screens || [],
isActive: playlist.isActive,
dailyRefreshAt: playlist.dailyRefreshAt ?? null,
advanceOnTap: playlist.advanceOnTap ?? false,
});
}
Expand Down Expand Up @@ -375,6 +377,37 @@ export function PlaylistForm() {
/>
</div>

<div className="border-t border-border-light pt-6">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={Boolean(formData.dailyRefreshAt)}
onChange={(e) => setFormData((prev) => ({
...prev,
dailyRefreshAt: e.target.checked ? '00:00' : null,
}))}
className="mt-1 h-4 w-4 rounded border-border-light text-accent focus:ring-accent"
/>
<span className="text-sm flex-1">
<span className="font-medium text-text-primary">Refresh once daily</span>
<span className="block text-text-muted">
Schedule device refreshes at a fixed time in the server timezone.
</span>
</span>
</label>
{formData.dailyRefreshAt && (
<div className="mt-4 ml-7 max-w-xs">
<Input
label="Daily refresh time"
type="time"
value={formData.dailyRefreshAt}
onChange={(e) => setFormData((prev) => ({ ...prev, dailyRefreshAt: e.target.value }))}
required
/>
</div>
)}
</div>

<div className="border-t border-border-light pt-6">
<label className="flex items-start gap-3 cursor-pointer">
<input
Expand Down Expand Up @@ -422,6 +455,12 @@ export function PlaylistForm() {
</div>
)}

{formData.dailyRefreshAt && (
<p className="mb-4 text-sm text-text-muted">
Screen durations are inactive while daily refresh is enabled.
</p>
)}

{/* Add screen section */}
<div className="flex gap-4 mb-4">
{/* Screen Selection Preview */}
Expand Down Expand Up @@ -479,14 +518,15 @@ export function PlaylistForm() {
))}
</Select>
</div>
<div className="w-32">
<div className={`w-32 ${formData.dailyRefreshAt ? 'opacity-50' : ''}`}>
<Input
label="Duration (s)"
type="number"
value={durationInput}
onChange={(e) => setDurationInput(e.target.value)}
onBlur={() => setDurationInput(String(formData.advanceOnTap ? Math.max(0, Number(durationInput) || 0) : Math.max(60, Number(durationInput) || 60)))}
min={60}
disabled={Boolean(formData.dailyRefreshAt)}
/>
</div>
<div className="flex items-end">
Expand Down Expand Up @@ -559,14 +599,15 @@ export function PlaylistForm() {
)}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<div className={`flex items-center gap-1 flex-shrink-0 ${formData.dailyRefreshAt ? 'opacity-50' : ''}`}>
<input
type="number"
value={screen.duration || ''}
onChange={(e) => handleUpdateScreenDuration(index, e.target.value)}
onBlur={() => handleBlurScreenDuration(index)}
min={60}
className="w-16 px-2 py-1 text-sm text-center border border-border-light rounded focus:outline-none focus:ring-1 focus:ring-accent"
disabled={Boolean(formData.dailyRefreshAt)}
className="w-16 px-2 py-1 text-sm text-center border border-border-light rounded focus:outline-none focus:ring-1 focus:ring-accent disabled:bg-bg-card disabled:text-text-muted disabled:cursor-not-allowed"
/>
<span className="text-sm text-text-secondary">s</span>
</div>
Expand Down
Loading