From ddcb03d3dd496063e27de9995d20e61e2ebd99a9 Mon Sep 17 00:00:00 2001 From: Kate Meerburg Date: Sun, 24 May 2026 21:24:32 +0200 Subject: [PATCH] Add configurable plugin system and move hardcoded IP to config Introduces a plugin registry so the display mode is selectable per device via the NixOS module config (defaults to "calendar"). Moves the hardcoded render URL base into config.base_url. Adds tests exercising the plugin system with a synthetic ICS feed. Co-Authored-By: Claude Opus 4.6 (1M context) --- default.nix | 14 +++-- src/plugins.test.ts | 127 ++++++++++++++++++++++++++++++++++++++++++++ src/plugins.ts | 17 ++++++ src/template.ts | 2 + src/web.ts | 32 ++++++----- 5 files changed, 175 insertions(+), 17 deletions(-) create mode 100644 src/plugins.test.ts create mode 100644 src/plugins.ts diff --git a/default.nix b/default.nix index e9b0e26..1c77b23 100644 --- a/default.nix +++ b/default.nix @@ -35,10 +35,16 @@ in type = json.type; example = lib.literalExpression '' { - unknown = { - urls = ["https://user.fm/calendar/....ics"]; - refresh = 60; - model = "inkplate_10"; + base_url = "http://192.168.50.124:2300"; + devices = { + unknown = { + plugin = "calendar"; + settings = { + urls = ["https://user.fm/calendar/....ics"]; + }; + refresh = 60; + model = "inkplate_10"; + }; }; } ''; diff --git a/src/plugins.test.ts b/src/plugins.test.ts new file mode 100644 index 0000000..97b6e5d --- /dev/null +++ b/src/plugins.test.ts @@ -0,0 +1,127 @@ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import type { Server } from 'bun'; +import { createPlugin } from './plugins'; + +// --- Synthetic ICS --- + +const pad = (n: number) => n.toString().padStart(2, '0'); +const icsDate = (d: Date) => + `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}T${pad(d.getHours())}${pad(d.getMinutes())}00`; + +const now = new Date(); +const currentStart = new Date(now.getTime() - 30 * 60_000); +const currentEnd = new Date(now.getTime() + 30 * 60_000); +const nextStart = new Date(now.getTime() + 60 * 60_000); +const nextEnd = new Date(now.getTime() + 120 * 60_000); +const laterStart = new Date(now.getTime() + 180 * 60_000); +const laterEnd = new Date(now.getTime() + 240 * 60_000); + +const ics = `BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VEVENT +DTSTART:${icsDate(currentStart)} +DTEND:${icsDate(currentEnd)} +SUMMARY:Current Meeting +UID:test-current@test +END:VEVENT +BEGIN:VEVENT +DTSTART:${icsDate(nextStart)} +DTEND:${icsDate(nextEnd)} +SUMMARY:Next Meeting +UID:test-next@test +END:VEVENT +BEGIN:VEVENT +DTSTART:${icsDate(laterStart)} +DTEND:${icsDate(laterEnd)} +SUMMARY:Later Meeting +UID:test-later@test +END:VEVENT +END:VCALENDAR`; + +const fakeModel = { + width: 800, + height: 480, + css: { classes: { device: 'device', size: 'size', density: 'density' } }, +} as any; + +// --- ICS server --- + +let icsServer: Server; +let icsUrl: string; + +beforeAll(() => { + icsServer = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + routes: { + '/cal.ics': () => + new Response(ics, { + headers: { 'Content-Type': 'text/calendar' }, + }), + }, + }); + icsUrl = `http://127.0.0.1:${icsServer.port}/cal.ics`; +}); + +afterAll(() => { + icsServer.stop(); +}); + +// --- Tests --- + +describe('plugin registry', () => { + test('unknown plugin throws', () => { + expect(() => createPlugin('nonexistent', {})).toThrow( + 'Unknown plugin: nonexistent' + ); + }); + + test('calendar plugin creates an ICSRenderable', () => { + const plugin = createPlugin('calendar', { urls: [] }); + expect(plugin.hash).toBe(''); + expect(plugin.nextEvent).toBeUndefined(); + }); +}); + +describe('calendar plugin rendering', () => { + test('renders events from ICS feed', async () => { + const plugin = createPlugin('calendar', { urls: [icsUrl] }); + const html = await plugin.render('full', fakeModel); + + expect(html).toContain('Current Meeting'); + expect(html).toContain('Next Meeting'); + expect(html).toContain('Later Meeting'); + }); + + test('shows BEZET when there is a current event', async () => { + const plugin = createPlugin('calendar', { urls: [icsUrl] }); + const html = await plugin.render('full', fakeModel); + + expect(html).toContain('BEZET'); + }); + + test('computes hash after render', async () => { + const plugin = createPlugin('calendar', { urls: [icsUrl] }); + await plugin.render('full', fakeModel); + + expect(plugin.hash).toBeTruthy(); + }); + + test('sets nextEvent to upcoming event start', async () => { + const plugin = createPlugin('calendar', { urls: [icsUrl] }); + await plugin.render('full', fakeModel); + + expect(plugin.nextEvent).toBeInstanceOf(Date); + const diff = Math.abs(plugin.nextEvent!.getTime() - nextStart.getTime()); + expect(diff).toBeLessThan(120_000); + }); + + test('renders VRIJ with no events', async () => { + const plugin = createPlugin('calendar', { urls: [] }); + const html = await plugin.render('full', fakeModel); + + expect(html).toContain('VRIJ'); + expect(html).not.toContain('BEZET'); + }); +}); diff --git a/src/plugins.ts b/src/plugins.ts new file mode 100644 index 0000000..1c206dc --- /dev/null +++ b/src/plugins.ts @@ -0,0 +1,17 @@ +import type { Renderable } from './template'; +import { ICSRenderable } from './xlcalendar'; + +type PluginFactory = (settings: Record) => Renderable; + +const registry: Record = { + calendar: (settings) => new ICSRenderable(settings.urls ?? []), +}; + +export const createPlugin = ( + name: string, + settings: Record +): Renderable => { + const factory = registry[name]; + if (!factory) throw new Error(`Unknown plugin: ${name}`); + return factory(settings); +}; diff --git a/src/template.ts b/src/template.ts index 2f0b668..3763807 100644 --- a/src/template.ts +++ b/src/template.ts @@ -22,6 +22,8 @@ export type RenderMode = | 'half_vertical' | 'quadrant'; export interface Renderable { + hash: string; + nextEvent?: Date; render(mode: RenderMode, model: DeviceModel): Promise; } diff --git a/src/web.ts b/src/web.ts index 3171c4f..6b9e081 100644 --- a/src/web.ts +++ b/src/web.ts @@ -1,17 +1,21 @@ import { SHA256 } from 'bun'; import { devices } from './devices'; +import { createPlugin } from './plugins'; import { makeScreenshot } from './render'; import { render } from './template'; -import { ICSRenderable } from './xlcalendar'; let renderMap = new Map(); +interface DeviceConfiguration { + plugin?: string; + settings?: Record; + refresh?: number; + model: string; +} + interface Configuration { - [mac: string]: { - urls: string[]; - refresh?: number; - model: string; - }; + base_url: string; + devices: Record; } const config: Configuration = JSON.parse( @@ -40,15 +44,16 @@ Bun.serve({ const id = req.headers.get('ID') ?? 'unknown'; const newfriendly = id.replaceAll(':', ''); - const device = config[id] ?? { - urls: [], + const device = config.devices[id] ?? { + plugin: 'calendar', + settings: {}, refresh: undefined, model: 'inkplate_10', }; console.log(`Rendering for ${id}..`); - let plugin = new ICSRenderable(device.urls); + let plugin = createPlugin(device.plugin ?? 'calendar', device.settings ?? {}); const rendered = makeScreenshot( [[plugin, 'full']], devices.find((a) => a.name === device.model)! @@ -74,7 +79,7 @@ Bun.serve({ return Response.json({ status: 0, - image_url: `http://192.168.50.124:2300/api/render/${id.toLowerCase()}/${plugin.hash ?? Math.random()}.png`, + image_url: `${config.base_url}/api/render/${id.toLowerCase()}/${plugin.hash ?? Math.random()}.png`, refresh_rate: nextRefresh.toString(), update_firmware: false, firmware_url: null, @@ -87,13 +92,14 @@ Bun.serve({ const id = req.headers.get('ID') ?? 'unknown'; const newfriendly = id.replaceAll(':', ''); - const device = config[id] ?? { - urls: [], + const device = config.devices[id] ?? { + plugin: 'calendar', + settings: {}, refresh: undefined, model: 'inkplate_10', }; - let plugin = new ICSRenderable(device.urls); + let plugin = createPlugin(device.plugin ?? 'calendar', device.settings ?? {}); const rendered = await render( [[plugin, 'full']], devices.find((a) => a.name === device.model)!