Initial commit

This commit is contained in:
puck 2026-04-30 12:13:22 +00:00
commit ca0c91c299
15 changed files with 1690 additions and 0 deletions

40
src/devices.ts Normal file
View file

@ -0,0 +1,40 @@
export interface DeviceModel {
name: string;
label: string;
description: string;
width: number;
height: number;
colors: number;
bit_depth: number;
scale_factor: number;
rotation: number;
mime_type: string;
offset_x: number;
offset_y: number;
kind: string;
palette_ids: string[];
preview_white_point: string;
image_size_limit: number;
image_upload_supported: boolean;
css: {
classes: {
device: string;
size: string;
density: string;
};
variables: [string, string][];
};
}
export const devices: DeviceModel[] = (
(await (
await fetch(
(globalThis as any).document
? '/api/models'
: 'https://trmnl.com/api/models'
)
).json()) as {
data: DeviceModel[];
}
).data;

46
src/render.ts Normal file
View file

@ -0,0 +1,46 @@
import puppeteer from 'puppeteer-core';
import { type DeviceModel } from './devices';
import { render, type PluginList } from './template';
import { sleep } from 'bun';
import sharp from 'sharp';
const p = await puppeteer.launch({
browser: 'firefox',
args: ['--new-instance'],
executablePath: Bun.env['FIREFOX'],
dumpio: true,
});
export const makeScreenshot = async (
drawable: PluginList,
model: DeviceModel
): Promise<Uint8Array> => {
const page = await p.newPage({});
await page.setViewport({
width: model.width,
height: model.height,
deviceScaleFactor: 1,
});
await page.setContent(await render(drawable, model));
await sleep(2000);
const screenshot = await page.screenshot({ encoding: 'binary' });
await page.close();
const processed = await Bun.spawn(
[
'magick',
'-',
'-dither',
'FloydSteinberg',
'-remap',
Bun.env['COLORMAP'],
'-define',
'png:bit-depth=2',
'-define',
'png:color-type=0',
'-strip',
'png:-',
],
{ stdin: screenshot }
).stdout.bytes();
return processed;
};

39
src/template.ts Normal file
View file

@ -0,0 +1,39 @@
import { devices, type DeviceModel } from './devices';
const BASE = `
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;350;375;400;450;600;700&amp;display=swap" rel="stylesheet">
<link href="https://trmnl.com/css/3.0.3/plugins.css" rel="stylesheet">
</head>
<body class="environment trmnl">
<div class="screen screen--1bit {{classes}}" data-pixel-perfect="true">{{contents}}</div>
<script src="https://trmnl.com/js/3.0.3/plugins.js"></script>
</body>
</html>`;
export type RenderMode =
| 'full'
| 'half_horizontal'
| 'half_vertical'
| 'quadrant';
export interface Renderable {
render(mode: RenderMode, model: DeviceModel): Promise<string>;
}
export type PluginList = [Renderable, RenderMode][];
export const render = async (plugins: PluginList, model: DeviceModel) => {
const classes = Object.values(model.css.classes).join(' ');
let contents = '';
for (let [plugin, mode] of plugins) {
contents += await plugin.render(mode, model);
}
return BASE.replace('{{classes}}', classes).replace('{{contents}}', contents);
};

112
src/web.ts Normal file
View file

@ -0,0 +1,112 @@
import { SHA256 } from 'bun';
import { devices } from './devices';
import { makeScreenshot } from './render';
import { render } from './template';
import { ICSRenderable } from './xlcalendar';
let renderMap = new Map();
interface Configuration {
[mac: string]: {
urls: string[];
refresh?: number;
model: string;
};
}
const config: Configuration = JSON.parse(
await Bun.file(Bun.env['CONFIG_FILE'] ?? 'config.json').text()
);
Bun.serve({
hostname: '0.0.0.0',
routes: {
'/api/setup': async (req) => {
const id = req.headers.get('ID') ?? 'UNKNOWN';
const newfriendly = id.replaceAll(':', '');
return Response.json({
status: 200,
message: 'hi',
api_key: id,
friendly_id: newfriendly,
image_url: 'https://trmnl.com/images/setup/setup-logo.bmp',
filename: 'empty_state',
});
},
'/api/display': async (req) => {
const id = req.headers.get('ID') ?? 'unknown';
const newfriendly = id.replaceAll(':', '');
const device = config[id] ?? {
urls: [],
refresh: undefined,
model: 'inkplate_10',
};
console.log(`Rendering for ${id}..`);
let plugin = new ICSRenderable(device.urls);
const rendered = makeScreenshot(
[[plugin, 'full']],
devices.find((a) => a.name === device.model)!
);
renderMap.set(id.toLowerCase(), rendered);
await Bun.sleep(3500);
let nextRefresh = device.refresh ?? 5 * 60;
if (plugin.nextEvent) {
let timeUntilNextEvent =
(plugin.nextEvent.valueOf() - Date.now()) / 1000;
if (timeUntilNextEvent < 60) timeUntilNextEvent = 60;
if (device.refresh && timeUntilNextEvent > device.refresh)
timeUntilNextEvent = device.refresh;
if (timeUntilNextEvent < nextRefresh) nextRefresh = timeUntilNextEvent;
}
if (!plugin.hash) {
console.log(`Had to respond before plugin for ${id} was rendered.`);
}
return Response.json({
status: 0,
image_url: `http://192.168.50.124:2300/api/render/${id.toLowerCase()}/${plugin.hash ?? Math.random()}.png`,
refresh_rate: nextRefresh.toString(),
update_firmware: false,
firmware_url: null,
reset_firmware: false,
filename: `${plugin.hash ?? Math.random()}.png`,
});
},
'/api/display/html': async (req) => {
const id = req.headers.get('ID') ?? 'unknown';
const newfriendly = id.replaceAll(':', '');
const device = config[id] ?? {
urls: [],
refresh: undefined,
model: 'inkplate_10',
};
let plugin = new ICSRenderable(device.urls);
const rendered = await render(
[[plugin, 'full']],
devices.find((a) => a.name === device.model)!
);
return new Response(rendered, {
headers: { 'Content-Type': 'text/html' },
});
},
'/api/render/:id/:ignore': async (req) => {
return new Response(await renderMap.get(req.params.id), {
headers: { 'Content-Type': 'image/png' },
});
},
},
});

386
src/xlcalendar.tsx Normal file
View file

@ -0,0 +1,386 @@
// This code is derived from https://github.com/hossain-khan/trmnl-calendar-xl
// Copyright (c) 2026 Hossain Khan, under the MIT license.
import { renderToString } from 'react-dom/server';
import * as ical from 'node-ical';
import type { Renderable, RenderMode } from '../src/template';
import type { DeviceModel } from '../src/devices';
type ProcessedEvent = ical.EventInstance & { calname: string };
const SectionLabel = ({
text,
meta,
inverted = false,
}: {
text: string;
meta?: string;
inverted?: boolean;
}) => {
const classes = inverted
? 'label lg:label--large label--underline'
: 'label lg:label--large label--filled';
return (
<div className="flex flex--row flex--between flex--center-y gap--xsmall">
<span className={classes}>{text}</span>
{meta && <span className={classes}>{meta}</span>}
</div>
);
};
const TitleBar = ({
title,
instance,
}: {
title: string;
instance?: string;
}) => {
return (
<div className="title_bar">
<span className="title">{title}</span>
{instance && <span className="instance">{instance}</span>}
</div>
);
};
interface Data {
current_event?: ProcessedEvent;
next_event?: ProcessedEvent;
secondary_event?: ProcessedEvent;
concurrent_event_count: number;
upcoming_count: number;
}
const processCalendar = (cal: ProcessedEvent[]): Data => {
const data: Data = { concurrent_event_count: 0, upcoming_count: 0 };
const now = Date.now();
for (let event of cal) {
if (event.start.valueOf() > now) data.upcoming_count++;
if (event.isFullDay) continue;
if (event.start.valueOf() <= now && event.end.valueOf() > now) {
if (data.current_event) data.concurrent_event_count++;
else {
data.current_event = event;
}
}
if (!data.next_event && event.start.valueOf() > now) {
data.next_event = event;
} else if (
!data.secondary_event &&
event.start.valueOf() > now &&
data.next_event
) {
data.secondary_event = event;
}
if (
data.current_event &&
data.next_event &&
data.secondary_event &&
event.start.valueOf() > now
)
break;
}
return data;
};
const getTimeLabel = (event: ProcessedEvent): string => {
let now = new Date(Date.now());
if (
event.start.toDateString() == event.end.toDateString() &&
now.toDateString() == event.start.toDateString()
)
return (
event.start.toLocaleTimeString('nl-NL', { timeStyle: 'short' }) +
' - ' +
event.end.toLocaleTimeString('nl-NL', { timeStyle: 'short' })
);
else
return (
event.start.toLocaleDateString('nl-NL', { dateStyle: 'medium' }) +
(event.isFullDay
? ''
: ' om ' +
event.start.toLocaleTimeString('nl-NL', { timeStyle: 'short' }))
);
};
// title bar shows either 'FREE NOW' or 'BUSY' (optionally 'BUSY · {upcoming_count} MORE')
const HeroPanel = ({
event,
now,
concurrent,
}: {
event?: ProcessedEvent;
now: boolean;
concurrent: number;
}) => {
// show current event if one, otherwise show next
// hero panel doesn't care about this but
const primaryLabel = event && now ? 'NU' : 'VOLGENDE';
let timeLabel = event ? getTimeLabel(event) : '';
if (concurrent) timeLabel += ` + ${concurrent} meer`;
if (event) {
return (
<div className="rounded--large p--2 flex flex--col gap--small h--[60cqh] bg--black text--white">
<SectionLabel text={primaryLabel} meta={timeLabel} inverted />
<span className={now ? "bg--black rounded p--[4cqw] " : ""}>
<span
className={
(now ? 'text--white' : 'text--black') +
' rounded p--[4cqw] value value--xxxlarge text--center block lg:hidden'
}
data-value-fit
data-value-fit-max-height="150">
{event?.summary as string}
</span>
<span
className={
(now ? 'text--white' : 'text--black') +
' rounded p--[4cqw] value value--xxxlarge text--center hidden lg:block lg:portrait:hidden'
}
data-value-fit
data-value-fit-max-height="320">
{event?.summary as string}
</span>
<span
className={
(now ? 'text--white' : 'text--black') +
' value value--xxxlarge text--center hidden lg:portrait:block'
}
data-value-fit
data-value-fit-max-height="480">
{event?.summary as string}
</span>
</span>
</div>
);
} else {
return (
<div className="rounded--large p--2 flex flex--col gap--small h--[60cqh]">
<div className="value value--xxxlarge">VRIJ</div>
<div className="title title--small">Geen events vandaag</div>
</div>
);
}
};
const NextEvent = ({
event,
hasNow,
}: {
event?: ProcessedEvent;
hasNow: boolean;
}) => {
let ev;
if (event)
ev = (
<>
<div
className="value text--center md:portrait:value--small lg:value--large lg:portrait:value--large"
data-clamp="2">
{event.summary as string}
</div>
<div className="value text--center value--small md:value--medium lg:value">
{getTimeLabel(event)}
</div>
</>
);
else
ev = (
<>
<div className="title title--small">VRIJ</div>
<div className="description">Geen events meer hierna.</div>
</>
);
return (
<div className="outline rounded--large p--2 flex flex--col gap--small">
<SectionLabel text={hasNow ? 'VOLGENDE' : 'DAARNA'} inverted />
{ev}
</div>
);
};
const SmallRenderer = ({ event }: { event: ProcessedEvent }) => {
const date = event.start
.toLocaleDateString('nl-NL', { dateStyle: 'medium' })
.replace(' ' + event.start.getFullYear().toString(), '');
const time = event.start.toLocaleTimeString('nl-NL', { timeStyle: 'short' });
return (
<div className="item">
<div className="meta"></div>
<div className="content">
<span
className="title title--small md:title--medium lg:title--large"
data-clamp="1">
{event.summary as string}
</span>
<span className="label label--small lg:label--base lg:label--outline">
{time} · {date}
</span>
</div>
</div>
);
};
const LaterEvents = ({
data,
events,
}: {
data: Data;
events: ProcessedEvent[];
}) => {
let eligibleEvents = [];
let now = Date.now();
for (let event of events) {
if (
event === data.current_event ||
event === data.next_event ||
event.isFullDay
)
continue;
const eligible_ts = event.isFullDay ? event.end : event.start;
if (eligible_ts.valueOf() > now) {
eligibleEvents.push(
<SmallRenderer
key={event.start.toString() + event.event.uid}
event={event}
/>
);
if (eligibleEvents.length > 2) break;
}
}
return (
<div className="outline rounded--large p--2 flex flex--col gap--small">
<SectionLabel text="LATER" inverted />
<div className="flex flex--col gap--xsmall lg:gap--small">
{eligibleEvents.length ? eligibleEvents : null}
{eligibleEvents.length ? null : (
<div className="description">Niks anders gepland.</div>
)}
</div>
</div>
);
};
const Main = ({ data, events }: { data: Data; events: ProcessedEvent[] }) => {
let titlebarFlag = 'VRIJ';
if (data.current_event) {
titlebarFlag = 'BEZET';
if (data.upcoming_count) titlebarFlag += ` · ${data.upcoming_count} MEER`;
}
const now = new Date(Date.now()).toLocaleString('nl-NL', {
dateStyle: 'medium',
timeStyle: 'short',
});
return (
<>
<div className="layout">
<div className="flex flex--col gap--medium h--full">
<HeroPanel
event={data.current_event ?? data.next_event}
now={data.current_event !== undefined}
concurrent={data.concurrent_event_count}
/>
<div className="grid grid--cols-2 gap--medium grow">
<NextEvent
event={
data.current_event ? data.secondary_event : data.next_event
}
hasNow={!!data.current_event}
/>
<LaterEvents data={data} events={events} />
</div>
</div>
</div>
<TitleBar
title={now.substring(0, now.length - 1) + 'x'}
instance={titlebarFlag}
/>
</>
);
};
export class ICSRenderable implements Renderable {
public nextEvent?: Date;
public hash: string = '';
constructor(private files: string[]) {}
private async fetch(
url: string,
start: Date,
end: Date
): Promise<ProcessedEvent[]> {
const resp = await fetch(url, { headers: [['User-Agent', 'private TRMNL fetcher']] });
let data = await resp.text();
let events = Object.values(await ical.async.parseICS(data)).filter(
(a) => a?.type === 'VEVENT'
);
let processed: ProcessedEvent[] = [];
for (let event of events) {
for (let subevent of ical.expandRecurringEvent(event, {
from: start,
to: end,
excludeExdates: true,
expandOngoing: true,
includeOverrides: true,
})) {
processed.push({ ...subevent, calname: 'Test' });
}
}
return processed;
}
async render(_mode: RenderMode): Promise<string> {
const start = new Date(Date.now());
const end = new Date(start.valueOf());
end.setDate(start.getDate() + 7);
let events = (
await Promise.all(this.files.map((a) => this.fetch(a, start, end)))
).flat();
events.sort((a, b) => a.start.valueOf() - b.start.valueOf());
let data = processCalendar(events);
const now = new Date(Date.now()).toLocaleString('nl-NL', {
dateStyle: 'medium',
timeStyle: 'short',
});
this.hash = Bun.hash
.xxHash64(
JSON.stringify([
data.current_event?.summary,
data.next_event?.summary,
data.current_event?.start.toString(),
now.substring(0, now.length - 1)
]),
2n
)
.toString();
this.nextEvent = (data.next_event ?? data.secondary_event)?.start;
return (
'<div class="view view--full">' +
renderToString(<Main data={data} events={events} />) +
'</div>'
);
}
}