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) <noreply@anthropic.com>
This commit is contained in:
parent
6188ecebb9
commit
ddcb03d3dd
5 changed files with 175 additions and 17 deletions
|
|
@ -35,11 +35,17 @@ in
|
||||||
type = json.type;
|
type = json.type;
|
||||||
example = lib.literalExpression ''
|
example = lib.literalExpression ''
|
||||||
{
|
{
|
||||||
|
base_url = "http://192.168.50.124:2300";
|
||||||
|
devices = {
|
||||||
unknown = {
|
unknown = {
|
||||||
|
plugin = "calendar";
|
||||||
|
settings = {
|
||||||
urls = ["https://user.fm/calendar/....ics"];
|
urls = ["https://user.fm/calendar/....ics"];
|
||||||
|
};
|
||||||
refresh = 60;
|
refresh = 60;
|
||||||
model = "inkplate_10";
|
model = "inkplate_10";
|
||||||
};
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
|
|
||||||
127
src/plugins.test.ts
Normal file
127
src/plugins.test.ts
Normal file
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
17
src/plugins.ts
Normal file
17
src/plugins.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import type { Renderable } from './template';
|
||||||
|
import { ICSRenderable } from './xlcalendar';
|
||||||
|
|
||||||
|
type PluginFactory = (settings: Record<string, any>) => Renderable;
|
||||||
|
|
||||||
|
const registry: Record<string, PluginFactory> = {
|
||||||
|
calendar: (settings) => new ICSRenderable(settings.urls ?? []),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createPlugin = (
|
||||||
|
name: string,
|
||||||
|
settings: Record<string, any>
|
||||||
|
): Renderable => {
|
||||||
|
const factory = registry[name];
|
||||||
|
if (!factory) throw new Error(`Unknown plugin: ${name}`);
|
||||||
|
return factory(settings);
|
||||||
|
};
|
||||||
|
|
@ -22,6 +22,8 @@ export type RenderMode =
|
||||||
| 'half_vertical'
|
| 'half_vertical'
|
||||||
| 'quadrant';
|
| 'quadrant';
|
||||||
export interface Renderable {
|
export interface Renderable {
|
||||||
|
hash: string;
|
||||||
|
nextEvent?: Date;
|
||||||
render(mode: RenderMode, model: DeviceModel): Promise<string>;
|
render(mode: RenderMode, model: DeviceModel): Promise<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
30
src/web.ts
30
src/web.ts
|
|
@ -1,17 +1,21 @@
|
||||||
import { SHA256 } from 'bun';
|
import { SHA256 } from 'bun';
|
||||||
import { devices } from './devices';
|
import { devices } from './devices';
|
||||||
|
import { createPlugin } from './plugins';
|
||||||
import { makeScreenshot } from './render';
|
import { makeScreenshot } from './render';
|
||||||
import { render } from './template';
|
import { render } from './template';
|
||||||
import { ICSRenderable } from './xlcalendar';
|
|
||||||
|
|
||||||
let renderMap = new Map();
|
let renderMap = new Map();
|
||||||
|
|
||||||
interface Configuration {
|
interface DeviceConfiguration {
|
||||||
[mac: string]: {
|
plugin?: string;
|
||||||
urls: string[];
|
settings?: Record<string, any>;
|
||||||
refresh?: number;
|
refresh?: number;
|
||||||
model: string;
|
model: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
|
interface Configuration {
|
||||||
|
base_url: string;
|
||||||
|
devices: Record<string, DeviceConfiguration>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config: Configuration = JSON.parse(
|
const config: Configuration = JSON.parse(
|
||||||
|
|
@ -40,15 +44,16 @@ Bun.serve({
|
||||||
const id = req.headers.get('ID') ?? 'unknown';
|
const id = req.headers.get('ID') ?? 'unknown';
|
||||||
const newfriendly = id.replaceAll(':', '');
|
const newfriendly = id.replaceAll(':', '');
|
||||||
|
|
||||||
const device = config[id] ?? {
|
const device = config.devices[id] ?? {
|
||||||
urls: [],
|
plugin: 'calendar',
|
||||||
|
settings: {},
|
||||||
refresh: undefined,
|
refresh: undefined,
|
||||||
model: 'inkplate_10',
|
model: 'inkplate_10',
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(`Rendering for ${id}..`);
|
console.log(`Rendering for ${id}..`);
|
||||||
|
|
||||||
let plugin = new ICSRenderable(device.urls);
|
let plugin = createPlugin(device.plugin ?? 'calendar', device.settings ?? {});
|
||||||
const rendered = makeScreenshot(
|
const rendered = makeScreenshot(
|
||||||
[[plugin, 'full']],
|
[[plugin, 'full']],
|
||||||
devices.find((a) => a.name === device.model)!
|
devices.find((a) => a.name === device.model)!
|
||||||
|
|
@ -74,7 +79,7 @@ Bun.serve({
|
||||||
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
status: 0,
|
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(),
|
refresh_rate: nextRefresh.toString(),
|
||||||
update_firmware: false,
|
update_firmware: false,
|
||||||
firmware_url: null,
|
firmware_url: null,
|
||||||
|
|
@ -87,13 +92,14 @@ Bun.serve({
|
||||||
const id = req.headers.get('ID') ?? 'unknown';
|
const id = req.headers.get('ID') ?? 'unknown';
|
||||||
const newfriendly = id.replaceAll(':', '');
|
const newfriendly = id.replaceAll(':', '');
|
||||||
|
|
||||||
const device = config[id] ?? {
|
const device = config.devices[id] ?? {
|
||||||
urls: [],
|
plugin: 'calendar',
|
||||||
|
settings: {},
|
||||||
refresh: undefined,
|
refresh: undefined,
|
||||||
model: 'inkplate_10',
|
model: 'inkplate_10',
|
||||||
};
|
};
|
||||||
|
|
||||||
let plugin = new ICSRenderable(device.urls);
|
let plugin = createPlugin(device.plugin ?? 'calendar', device.settings ?? {});
|
||||||
const rendered = await render(
|
const rendered = await render(
|
||||||
[[plugin, 'full']],
|
[[plugin, 'full']],
|
||||||
devices.find((a) => a.name === device.model)!
|
devices.find((a) => a.name === device.model)!
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue