From 20c73fc2cb51227738396844133fc20eff96f2b2 Mon Sep 17 00:00:00 2001 From: Marc-Oliver Ochlast Date: Fri, 12 Jun 2026 09:58:56 +0200 Subject: [PATCH 1/4] Add per-device night sleep schedule (quiet hours) Devices can now be configured with a nightly quiet-hours window. During the window the display endpoint returns refresh_rate = seconds-until-wake, so the device deep-sleeps the whole night in one cycle (battery saving, no nighttime e-ink refreshes) and wakes at the configured time. Two per-device modes (showSleepScreen): - false (default): keep the current screen frozen until morning - true: show a dedicated sleep screen (crescent moon + "Sleeping until HH:MM"), generated as a 1-bit e-ink PNG via Sharp (new SleepScreenService) The window is evaluated in the global DEFAULT_TIMEZONE and supports ranges that cross midnight. The sleepStartAt/sleepStopAt columns already existed in the schema; adds showSleepScreen (applied via prisma db push, no migration). Wires the fields through the device update DTO/service and the device edit form (toggle + time inputs). Unit tests cover the quiet-hours logic. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/prisma/schema.prisma | 1 + backend/src/api/api.module.ts | 5 +- .../src/api/display/display.service.test.ts | 151 +++++++++++- backend/src/api/display/display.service.ts | 136 ++++++++++- .../src/api/display/sleep-screen.service.ts | 226 ++++++++++++++++++ backend/src/devices/devices.service.ts | 4 + backend/src/devices/dto/update-device.dto.ts | 30 +++ frontend/src/pages/devices/DeviceForm.tsx | 106 +++++++- frontend/src/types/index.ts | 9 + 9 files changed, 655 insertions(+), 13 deletions(-) create mode 100644 backend/src/api/display/sleep-screen.service.ts diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index ba9759d..f11e95c 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -29,6 +29,7 @@ model Device { firmwareUpdate Boolean @default(true) @map("firmware_update") sleepStartAt String? @map("sleep_start_at") sleepStopAt String? @map("sleep_stop_at") + showSleepScreen Boolean @default(false) @map("show_sleep_screen") lastSeenAt DateTime? @map("last_seen_at") refreshPending Boolean @default(false) @map("refresh_pending") lastScreenId String? @map("last_screen_id") // Track last displayed screen for ghosting prevention diff --git a/backend/src/api/api.module.ts b/backend/src/api/api.module.ts index 6f1ecd5..357b831 100644 --- a/backend/src/api/api.module.ts +++ b/backend/src/api/api.module.ts @@ -4,6 +4,7 @@ import { join } from 'path'; import { ApiController } from './api.controller'; import { DisplayService } from './display/display.service'; import { DefaultScreenService } from './display/default-screen.service'; +import { SleepScreenService } from './display/sleep-screen.service'; import { SetupService } from './setup/setup.service'; import { SetupScreenService } from './setup/setup-screen.service'; import { LogService } from './log/log.service'; @@ -38,7 +39,7 @@ import { PluginsModule } from '../plugins/plugins.module'; }), ], controllers: [ApiController], - providers: [DisplayService, DefaultScreenService, SetupService, SetupScreenService, LogService], - exports: [DisplayService, DefaultScreenService, SetupService, SetupScreenService, LogService], + providers: [DisplayService, DefaultScreenService, SleepScreenService, SetupService, SetupScreenService, LogService], + exports: [DisplayService, DefaultScreenService, SleepScreenService, SetupService, SetupScreenService, LogService], }) export class ApiModule {} diff --git a/backend/src/api/display/display.service.test.ts b/backend/src/api/display/display.service.test.ts index 42ecce0..c055eca 100644 --- a/backend/src/api/display/display.service.test.ts +++ b/backend/src/api/display/display.service.test.ts @@ -8,11 +8,16 @@ describe('DisplayService', () => { let mockPrisma: ReturnType; let mockConfig: any; let mockDefaultScreenService: any; + let mockSleepScreenService: any; let mockScreenRendererService: any; beforeEach(() => { mockPrisma = createMockPrisma(); - mockConfig = { get: createMock().mockReturnValue('http://localhost:3002') }; + mockConfig = { + get: createMock().mockImplementation((key: string) => + key === 'defaultTimezone' ? 'UTC' : 'http://localhost:3002', + ), + }; mockDefaultScreenService = { getDefaultScreenUrl: createMock().mockReturnValue('/uploads/default-screen.png'), getDefaultScreenBase64: createMock().mockResolvedValue('base64data'), @@ -20,6 +25,13 @@ describe('DisplayService', () => { getDefaultScreenPreviewBuffer: createMock().mockResolvedValue(Buffer.from('PNG')), ensureDefaultScreenExists: createMock().mockResolvedValue(undefined), }; + mockSleepScreenService = { + getSleepScreen: createMock().mockResolvedValue({ + url: '/assets/sleep-800x480-0700.png', + filename: 'sleep-800x480-0700.png', + }), + getSleepScreenBase64: createMock().mockResolvedValue('base64sleep'), + }; mockScreenRendererService = { renderScreenDesign: createMock().mockResolvedValue(Buffer.from('PNG')), }; @@ -27,6 +39,7 @@ describe('DisplayService', () => { mockPrisma as any, mockConfig, mockDefaultScreenService, + mockSleepScreenService, mockScreenRendererService, ); }); @@ -299,4 +312,140 @@ describe('DisplayService', () => { expect(getFilename('http://localhost/uploads/test.png')).toBe('test.png'); }); }); + + describe('parseTimeToSeconds (private)', () => { + const parse = (v: string) => (service as any).parseTimeToSeconds(v); + + it('parses valid HH:MM values', () => { + expect(parse('00:00')).toBe(0); + expect(parse('07:00')).toBe(25200); + expect(parse('22:30')).toBe(81000); + expect(parse('23:59')).toBe(86340); + }); + + it('returns null for invalid values', () => { + expect(parse('24:00')).toBeNull(); + expect(parse('7:00')).toBeNull(); + expect(parse('12:60')).toBeNull(); + expect(parse('foo')).toBeNull(); + }); + }); + + describe('getQuietHoursSeconds (private)', () => { + const quiet = (device: any) => (service as any).getQuietHoursSeconds(device); + // Build an "HH:MM" string offset by a number of minutes from current UTC time. + const hhmmFromNowOffset = (offsetMin: number) => { + const now = new Date(); + const total = ((now.getUTCHours() * 60 + now.getUTCMinutes() + offsetMin) % 1440 + 1440) % 1440; + const h = Math.floor(total / 60); + const m = total % 60; + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; + }; + + it('returns null when sleep times are missing', () => { + expect(quiet({ sleepStartAt: null, sleepStopAt: null })).toBeNull(); + expect(quiet({ sleepStartAt: '22:00', sleepStopAt: null })).toBeNull(); + }); + + it('returns null when start equals stop', () => { + expect(quiet({ sleepStartAt: '07:00', sleepStopAt: '07:00' })).toBeNull(); + }); + + it('returns null when now is outside the window', () => { + const device = { + sleepStartAt: hhmmFromNowOffset(120), + sleepStopAt: hhmmFromNowOffset(240), + }; + expect(quiet(device)).toBeNull(); + }); + + it('returns seconds-until-wake when now is inside the window', () => { + const device = { + sleepStartAt: hhmmFromNowOffset(-60), // started 1h ago + sleepStopAt: hhmmFromNowOffset(120), // ends in 2h + }; + const result = quiet(device); + expect(result).not.toBeNull(); + // ~2h until wake (+60s buffer), with slack for the current seconds-in-minute + expect(result).toBeGreaterThan(2 * 3600 - 120); + expect(result).toBeLessThanOrEqual(2 * 3600 + 120); + }); + + it('handles windows that wrap past midnight and caps at 24h', () => { + const device = { + sleepStartAt: hhmmFromNowOffset(-30), + sleepStopAt: hhmmFromNowOffset(30), + }; + const result = quiet(device); + expect(result).not.toBeNull(); + expect(result).toBeLessThanOrEqual(86400); + }); + }); + + describe('getDisplayContent - quiet hours', () => { + const windowAroundNow = () => { + const hhmm = (offsetMin: number) => { + const now = new Date(); + const total = ((now.getUTCHours() * 60 + now.getUTCMinutes() + offsetMin) % 1440 + 1440) % 1440; + return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`; + }; + return { start: hhmm(-60), stop: hhmm(120) }; + }; + + it('serves the sleep screen during quiet hours when showSleepScreen is true', async () => { + const { start, stop } = windowAroundNow(); + mockPrisma.device.findFirst.mockResolvedValue({ + id: 1, name: 'Test', playlist: null, refreshRate: 900, refreshPending: false, + width: 800, height: 480, sleepStartAt: start, sleepStopAt: stop, showSleepScreen: true, + }); + mockPrisma.device.update.mockResolvedValue({ id: 1, battery: 80, wifi: -50 }); + mockPrisma.firmware.findFirst.mockResolvedValue(null); + + const result = await service.getDisplayContent('test-key'); + expect(result.image_url).toContain('/assets/sleep-'); + expect(mockSleepScreenService.getSleepScreen.calls.length).toBe(1); + expect(result.update_firmware).toBe(false); + expect(result.refresh_rate).toBeGreaterThan(2 * 3600 - 120); + }); + + it('freezes the current screen during quiet hours when showSleepScreen is false', async () => { + const { start, stop } = windowAroundNow(); + mockPrisma.device.findFirst.mockResolvedValue({ + id: 1, name: 'Test', + playlist: { items: [{ duration: 60, screen: { id: 9, name: 'S', imageUrl: '/uploads/s.png' } }] }, + refreshRate: 900, refreshPending: false, + width: 800, height: 480, sleepStartAt: start, sleepStopAt: stop, showSleepScreen: false, + }); + mockPrisma.device.update.mockResolvedValue({ id: 1, battery: 80, wifi: -50 }); + mockPrisma.firmware.findFirst.mockResolvedValue(null); + + const result = await service.getDisplayContent('test-key'); + // Keeps the current screen (no sleep screen) ... + expect(result.image_url).toContain('/uploads/s.png'); + expect(mockSleepScreenService.getSleepScreen.calls.length).toBe(0); + // ... but sleeps until the wake time (long refresh rate) + expect(result.refresh_rate).toBeGreaterThan(2 * 3600 - 120); + }); + + it('uses normal refresh_rate outside quiet hours', async () => { + const hhmm = (offsetMin: number) => { + const now = new Date(); + const total = ((now.getUTCHours() * 60 + now.getUTCMinutes() + offsetMin) % 1440 + 1440) % 1440; + return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`; + }; + mockPrisma.device.findFirst.mockResolvedValue({ + id: 1, name: 'Test', + playlist: { items: [{ duration: 120, screen: { id: 9, name: 'S', imageUrl: '/uploads/s.png' } }] }, + refreshRate: 900, refreshPending: false, + width: 800, height: 480, sleepStartAt: hhmm(120), sleepStopAt: hhmm(240), showSleepScreen: true, + }); + mockPrisma.device.update.mockResolvedValue({ id: 1, battery: 80, wifi: -50 }); + mockPrisma.firmware.findFirst.mockResolvedValue(null); + + const result = await service.getDisplayContent('test-key'); + expect(result.image_url).toContain('/uploads/s.png'); + expect(mockSleepScreenService.getSleepScreen.calls.length).toBe(0); + expect(result.refresh_rate).toBe(120); + }); + }); }); diff --git a/backend/src/api/display/display.service.ts b/backend/src/api/display/display.service.ts index a01b863..91a749c 100644 --- a/backend/src/api/display/display.service.ts +++ b/backend/src/api/display/display.service.ts @@ -6,6 +6,7 @@ import { import { PrismaService } from '../../prisma/prisma.service'; import { ConfigService } from '@nestjs/config'; import { DefaultScreenService } from './default-screen.service'; +import { SleepScreenService } from './sleep-screen.service'; import { ScreenRendererService } from '../../screen-designer/services/screen-renderer.service'; import { PluginsService } from '../../plugins/plugins.service'; import { SetupService } from '../setup/setup.service'; @@ -26,6 +27,7 @@ export class DisplayService { private prisma: PrismaService, private config: ConfigService, private defaultScreenService: DefaultScreenService, + private sleepScreenService: SleepScreenService, private screenRendererService: ScreenRendererService, private pluginsService: PluginsService, private setupService: SetupService, @@ -198,8 +200,50 @@ export class DisplayService { // Check for firmware update const firmwareUrl = await this.getFirmwareUpdateUrl(device.firmwareVersion || undefined); - // Default refresh rate (used for default screens or when no playlist) - const defaultRefreshRate = device.refreshRate; + // Quiet hours: if the device is within its configured sleep window, return + // a refresh_rate equal to the seconds until the wake time, so the device + // sleeps the whole night in a single deep-sleep cycle instead of polling. + const sleepSeconds = this.getQuietHoursSeconds(device); + + // Sleep-screen mode: show a dedicated sleep screen until the wake time. + // (Freeze mode keeps the current screen and is handled via the refresh-rate + // overrides below, so we only short-circuit here when a sleep screen is wanted.) + if (sleepSeconds !== null && device.showSleepScreen) { + const wakeTime = device.sleepStopAt as string; + const { url, filename } = await this.sleepScreenService.getSleepScreen( + device.width, + device.height, + wakeTime, + ); + const imageData = useBase64 + ? await this.sleepScreenService.getSleepScreenBase64(device.width, device.height, wakeTime) + : undefined; + + this.logger.log( + `Device ${device.name} in quiet hours - serving sleep screen, next poll in ${sleepSeconds}s`, + ); + + return { + status: 0, + image_url: `${apiUrl}${url}`, + filename, + image_url_timeout: 0, + image_data: imageData, + firmware_url: '', + update_firmware: false, + refresh_rate: sleepSeconds, + reset_firmware: false, + special_function: '', + temperature_profile: 'default', + maximum_compatibility: true, + battery: updatedDevice.battery, + wifi: updatedDevice.wifi, + }; + } + + // Default refresh rate (used for default screens or when no playlist). + // In quiet hours (freeze mode) this is overridden with the sleep duration. + const defaultRefreshRate = sleepSeconds ?? device.refreshRate; // If no playlist or no screens in playlist, return the default welcome screen if (!device.playlist || !device.playlist.items || device.playlist.items.length === 0) { @@ -321,6 +365,11 @@ export class DisplayService { effectiveDeviceRate, ); + // 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; + // Handle both regular screens and designed screens if (currentScreen.screen) { // Regular uploaded screen @@ -340,12 +389,12 @@ export class DisplayService { image_data: useBase64 ? await this.getBase64Image(currentScreen.screen.imageUrl) : undefined, firmware_url: firmwareUrl, update_firmware: !!firmwareUrl, - refresh_rate: effectiveRefreshRate, + refresh_rate: finalRefreshRate, reset_firmware: false, special_function: '', temperature_profile: 'default', maximum_compatibility: screenChanged, - refresh_at: nextRefreshAt, + refresh_at: finalRefreshAt, battery: updatedDevice.battery, wifi: updatedDevice.wifi, }; @@ -383,12 +432,12 @@ export class DisplayService { image_data: undefined, firmware_url: firmwareUrl, update_firmware: !!firmwareUrl, - refresh_rate: effectiveRefreshRate, + refresh_rate: finalRefreshRate, reset_firmware: false, special_function: '', temperature_profile: 'default', maximum_compatibility: screenChanged, - refresh_at: nextRefreshAt, + refresh_at: finalRefreshAt, battery: updatedDevice.battery, wifi: updatedDevice.wifi, }; @@ -413,12 +462,12 @@ export class DisplayService { image_data: undefined, firmware_url: firmwareUrl, update_firmware: !!firmwareUrl, - refresh_rate: effectiveRefreshRate, + refresh_rate: finalRefreshRate, reset_firmware: false, special_function: '', temperature_profile: 'default', maximum_compatibility: screenChanged, - refresh_at: nextRefreshAt, + refresh_at: finalRefreshAt, battery: updatedDevice.battery, wifi: updatedDevice.wifi, }; @@ -565,6 +614,77 @@ export class DisplayService { return undefined; } + /** + * Determine whether the device is currently within its configured quiet-hours + * (sleep) window and, if so, how many seconds remain until the wake time. + * + * Times are stored as "HH:MM" strings and evaluated against the server's + * configured DEFAULT_TIMEZONE (the same timezone used by clock/date widgets). + * Handles windows that cross midnight (e.g. 22:00–07:00). + * + * @returns seconds until the wake time when sleeping, otherwise null + */ + private getQuietHoursSeconds(device: { + sleepStartAt: string | null; + sleepStopAt: string | null; + }): number | null { + const startSec = device.sleepStartAt ? this.parseTimeToSeconds(device.sleepStartAt) : null; + const stopSec = device.sleepStopAt ? this.parseTimeToSeconds(device.sleepStopAt) : null; + if (startSec === null || stopSec === null || startSec === stopSec) { + return null; + } + + const nowSec = this.getSecondsSinceMidnight(); + + // Is "now" inside the window? Handle windows that wrap past midnight. + const inWindow = + startSec < stopSec + ? nowSec >= startSec && nowSec < stopSec + : nowSec >= startSec || nowSec < stopSec; + + if (!inWindow) { + return null; + } + + // Seconds until the next occurrence of the stop time, plus a small buffer so + // the device wakes just after the window ends. Capped at 24h for safety. + const SECONDS_PER_DAY = 86400; + const untilWake = + (((stopSec - nowSec) % SECONDS_PER_DAY) + SECONDS_PER_DAY) % SECONDS_PER_DAY || SECONDS_PER_DAY; + return Math.min(untilWake + 60, SECONDS_PER_DAY); + } + + /** + * Parse an "HH:MM" string into seconds since midnight, or null if invalid. + */ + private parseTimeToSeconds(value: string): number | null { + const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value.trim()); + if (!match) { + return null; + } + return parseInt(match[1], 10) * 3600 + parseInt(match[2], 10) * 60; + } + + /** + * Current seconds since midnight in the configured DEFAULT_TIMEZONE. + */ + private getSecondsSinceMidnight(): number { + const timeZone = this.config.get('defaultTimezone', 'UTC'); + const parts = new Intl.DateTimeFormat('en-US', { + timeZone, + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).formatToParts(new Date()); + + const get = (type: string) => + parseInt(parts.find((p) => p.type === type)?.value || '0', 10); + // Intl can emit "24" for the midnight hour in some environments; normalize to 0. + const hour = get('hour') % 24; + return hour * 3600 + get('minute') * 60 + get('second'); + } + /** * Check if a screen design contains a clock widget */ diff --git a/backend/src/api/display/sleep-screen.service.ts b/backend/src/api/display/sleep-screen.service.ts new file mode 100644 index 0000000..7d6eaa3 --- /dev/null +++ b/backend/src/api/display/sleep-screen.service.ts @@ -0,0 +1,226 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as sharpModule from 'sharp'; +// Handle both ESM and CJS imports for Bun compatibility +const sharp = (sharpModule as any).default || sharpModule; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +/** + * Escape XML special characters to prevent SVG corruption + */ +function escapeXml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Sleep Screen Service + * + * Generates the screen shown on a device during its configured quiet hours + * (when `showSleepScreen` is enabled). A clean, centered crescent-moon design + * with the wake-up time, rendered as a 1-bit e-ink PNG via Sharp (no Puppeteer). + * + * Mirrors DefaultScreenService: same grayscale + Floyd-Steinberg pipeline, no + * colour inversion (firmware handles display colour mapping). Generated images + * are cached per resolution + wake time so the device gets a stable URL (and + * caches it) for the whole night. + */ +@Injectable() +export class SleepScreenService { + private readonly logger = new Logger(SleepScreenService.name); + private readonly assetsDir: string; + + private readonly DEFAULT_WIDTH = 800; + private readonly DEFAULT_HEIGHT = 480; + + constructor() { + this.assetsDir = path.join(process.cwd(), 'assets'); + } + + /** + * Build the cache filename for a given resolution + wake time. + * e.g. sleep-800x480-0700.png + */ + private getFilename(width: number, height: number, wakeTime: string): string { + const safeWake = wakeTime.replace(/[^0-9]/g, '') || '0000'; + return `sleep-${width}x${height}-${safeWake}.png`; + } + + /** + * Ensure the sleep screen for this resolution + wake time exists and return + * its public URL path and filename. Generates the image on first request. + */ + async getSleepScreen( + width: number, + height: number, + wakeTime: string, + ): Promise<{ url: string; filename: string }> { + const w = width > 0 ? width : this.DEFAULT_WIDTH; + const h = height > 0 ? height : this.DEFAULT_HEIGHT; + const filename = this.getFilename(w, h, wakeTime); + const outputPath = path.join(this.assetsDir, filename); + + try { + await fs.mkdir(this.assetsDir, { recursive: true }); + try { + await fs.access(outputPath); + } catch { + await this.generate(outputPath, w, h, wakeTime); + } + } catch (error) { + this.logger.error('Failed to ensure sleep screen exists:', error); + } + + return { url: `/assets/${filename}`, filename }; + } + + /** + * Get the sleep screen as a base64-encoded string (for devices that request + * inline image data via the BASE64 header). + */ + async getSleepScreenBase64( + width: number, + height: number, + wakeTime: string, + ): Promise { + const w = width > 0 ? width : this.DEFAULT_WIDTH; + const h = height > 0 ? height : this.DEFAULT_HEIGHT; + const filename = this.getFilename(w, h, wakeTime); + const outputPath = path.join(this.assetsDir, filename); + try { + await this.getSleepScreen(w, h, wakeTime); + const buffer = await fs.readFile(outputPath); + return buffer.toString('base64'); + } catch (error) { + this.logger.warn('Failed to get sleep screen base64:', error); + return undefined; + } + } + + /** + * Generate the sleep screen image and write it to disk. + */ + private async generate( + outputPath: string, + width: number, + height: number, + wakeTime: string, + ): Promise { + this.logger.log(`Generating sleep screen: ${width}x${height}, wake=${wakeTime}`); + + const svg = this.createSleepScreenSvg(width, height, wakeTime); + + // Same e-ink pipeline as DefaultScreenService: grayscale → Floyd-Steinberg + // dithering → 8-bit b-w PNG. No negate — firmware maps colours for display. + const grayBuffer = await sharp(Buffer.from(svg)) + .grayscale() + .normalise() + .raw() + .toBuffer({ resolveWithObject: true }); + + const dithered = this.applyFloydSteinbergDithering( + grayBuffer.data, + grayBuffer.info.width, + grayBuffer.info.height, + 140, + ); + + await sharp(dithered, { + raw: { width: grayBuffer.info.width, height: grayBuffer.info.height, channels: 1 }, + }) + .toColorspace('b-w') + .png({ compressionLevel: 9 }) + .toFile(outputPath); + + this.logger.log(`Sleep screen saved to: ${outputPath}`); + } + + /** + * Centered crescent moon + "Sleeping until HH:MM" caption. + * Pure SVG shapes only (no emoji) so it renders reliably for e-ink. + */ + private createSleepScreenSvg(width: number, height: number, wakeTime: string): string { + const centerX = width / 2; + const moonR = Math.min(width, height) * 0.13; + const moonCy = height / 2 - height * 0.08; + // Crescent: a filled circle with an offset white circle carved out of it. + const carveDx = moonR * 0.55; + const carveDy = -moonR * 0.2; + const carveR = moonR * 0.92; + + const captionSize = Math.max(16, Math.round(height * 0.06)); + const captionY = moonCy + moonR + captionSize * 1.8; + const caption = escapeXml(`Sleeping until ${wakeTime}`); + + // A few simple 4-point stars around the moon. + const star = (sx: number, sy: number, s: number) => + ``; + + return ` + + + + + + + ${star(centerX + moonR * 1.8, moonCy - moonR * 0.9, moonR * 0.18)} + ${star(centerX + moonR * 2.4, moonCy + moonR * 0.2, moonR * 0.12)} + ${star(centerX - moonR * 2.0, moonCy - moonR * 0.4, moonR * 0.14)} + ${caption} + + `.trim(); + } + + /** + * Floyd-Steinberg dithering for 1-bit e-ink output (matches DefaultScreenService). + */ + private applyFloydSteinbergDithering( + data: Buffer, + width: number, + height: number, + threshold: number, + ): Buffer { + const pixels = new Float32Array(data.length); + for (let i = 0; i < data.length; i++) { + const val = data[i]; + if (val > 200) pixels[i] = 255; + else if (val < 55) pixels[i] = 0; + else pixels[i] = val; + } + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = y * width + x; + const oldPixel = pixels[idx]; + const newPixel = oldPixel > threshold ? 255 : 0; + pixels[idx] = newPixel; + const error = oldPixel - newPixel; + if (x + 1 < width) pixels[idx + 1] += (error * 7) / 16; + if (y + 1 < height) { + if (x - 1 >= 0) pixels[(y + 1) * width + (x - 1)] += (error * 3) / 16; + pixels[(y + 1) * width + x] += (error * 5) / 16; + if (x + 1 < width) pixels[(y + 1) * width + (x + 1)] += (error * 1) / 16; + } + } + } + + const result = Buffer.alloc(data.length); + for (let i = 0; i < data.length; i++) { + result[i] = Math.max(0, Math.min(255, Math.round(pixels[i]))); + } + return result; + } +} diff --git a/backend/src/devices/devices.service.ts b/backend/src/devices/devices.service.ts index d29f570..71b4d39 100644 --- a/backend/src/devices/devices.service.ts +++ b/backend/src/devices/devices.service.ts @@ -172,6 +172,10 @@ export class DevicesService { isActive: updateDeviceDto.isActive, width: updateDeviceDto.width, height: updateDeviceDto.height, + // Quiet hours / sleep schedule (undefined = no change, null = clear/disable) + sleepStartAt: updateDeviceDto.sleepStartAt, + sleepStopAt: updateDeviceDto.sleepStopAt, + showSleepScreen: updateDeviceDto.showSleepScreen, // Set refreshPending if playlist changed to trigger immediate device refresh ...(playlistChanging && { refreshPending: true }), }, diff --git a/backend/src/devices/dto/update-device.dto.ts b/backend/src/devices/dto/update-device.dto.ts index 56eee07..ab2d2f9 100644 --- a/backend/src/devices/dto/update-device.dto.ts +++ b/backend/src/devices/dto/update-device.dto.ts @@ -66,4 +66,34 @@ export class UpdateDeviceDto { @IsInt() @Min(1) height?: number; + + @ApiPropertyOptional({ + example: '22:00', + description: 'Quiet hours start time (HH:MM, evaluated in DEFAULT_TIMEZONE). Set to null to disable night sleep.', + nullable: true, + }) + @IsOptional() + @ValidateIf((object, value) => value !== null) + @IsString() + @Matches(/^([01]\d|2[0-3]):[0-5]\d$/, { message: 'sleepStartAt must be in HH:MM format' }) + sleepStartAt?: string | null; + + @ApiPropertyOptional({ + example: '07:00', + description: 'Quiet hours end / wake time (HH:MM, evaluated in DEFAULT_TIMEZONE). Set to null to disable night sleep.', + nullable: true, + }) + @IsOptional() + @ValidateIf((object, value) => value !== null) + @IsString() + @Matches(/^([01]\d|2[0-3]):[0-5]\d$/, { message: 'sleepStopAt must be in HH:MM format' }) + sleepStopAt?: string | null; + + @ApiPropertyOptional({ + example: false, + description: 'During quiet hours, show a dedicated sleep screen (true) or keep the current screen frozen (false)', + }) + @IsOptional() + @IsBoolean() + showSleepScreen?: boolean; } diff --git a/frontend/src/pages/devices/DeviceForm.tsx b/frontend/src/pages/devices/DeviceForm.tsx index 8d44971..bd2aa4e 100644 --- a/frontend/src/pages/devices/DeviceForm.tsx +++ b/frontend/src/pages/devices/DeviceForm.tsx @@ -6,6 +6,33 @@ import { useApi, useMutation } from '../../hooks/useApi'; import { deviceService } from '../../services/api'; import type { Device, DeviceFormData } from '../../types'; +/** Small toggle switch matching the app's design tokens. */ +function ToggleSwitch({ + id, + checked, + onChange, +}: { + id: string; + checked: boolean; + onChange: (checked: boolean) => void; +}) { + return ( +