Initial commit
This commit is contained in:
commit
ca0c91c299
15 changed files with 1690 additions and 0 deletions
40
src/devices.ts
Normal file
40
src/devices.ts
Normal 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
46
src/render.ts
Normal 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
39
src/template.ts
Normal 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&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
112
src/web.ts
Normal 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
386
src/xlcalendar.tsx
Normal 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>'
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue