@kiosko/core 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@
6
6
  import { TIME_FORMATS } from '@kiosko/sdk/format';
7
7
  import { DEFAULT_WORKDAY } from '@kiosko/sdk/workday';
8
8
  import { suggestGrid } from '../layout/grid.ts';
9
- import { CONFIG_VERSION, DEFAULT_CONFIG, type Config, type Screen } from './model.ts';
9
+ import { CONFIG_VERSION, DEFAULT_CONFIG, DEFAULT_MAX_BYTES, type Config, type Screen } from './model.ts';
10
10
 
11
11
  /**
12
12
  * Fills gaps of a config written by hand or by an older version. Never throws.
@@ -58,6 +58,7 @@ function normalizeScreen(s: Partial<Screen>, i: number): Screen {
58
58
  pixelShift: { enabled: true, amplitude: 2, periodMs: 8 * 60_000, ...(s.pixelShift ?? {}) },
59
59
  sleepWhenIdle: s.sleepWhenIdle ?? false,
60
60
  jpegQuality: s.jpegQuality ?? 90,
61
+ maxBytes: Math.max(0, Math.round(s.maxBytes ?? DEFAULT_MAX_BYTES)),
61
62
  maxFps: s.maxFps ?? 12,
62
63
  };
63
64
  }
@@ -262,6 +262,18 @@ export function configSchema(plugins: readonly PluginDefinition[]): S {
262
262
  keepalive: { type: 'boolean', default: true, description: 'Re-send the last frame now and then so the panel keeps it.' },
263
263
  },
264
264
  },
265
+ {
266
+ title: 'USB (LY panels)',
267
+ description: 'Talks to the panel over USB from Kiosko itself (Thermalright Trofeo Vision and other LY panels): no trcc, no Python. On Windows the device needs the WinUSB driver; on Linux a udev rule.',
268
+ type: 'object',
269
+ additionalProperties: false,
270
+ required: ['driver'],
271
+ properties: {
272
+ driver: { const: 'usb' },
273
+ serial: { type: 'string', description: 'USB serial number of the panel. With several identical panels it is the only way to tell them apart, and a serial that is not found is an error, never "another panel". Empty: the first one found.' },
274
+ keepalive: { type: 'number', minimum: 0.1, maximum: 2, default: 1, description: 'Seconds between re-sends of an unchanged frame; the firmware drops the picture after 2-3 s without one.' },
275
+ },
276
+ },
265
277
  {
266
278
  title: 'Frame file',
267
279
  description: 'Writes frame.jpg atomically into a folder; an external sender takes it to the panel.',
@@ -360,6 +372,7 @@ export function configSchema(plugins: readonly PluginDefinition[]): S {
360
372
  },
361
373
  sleepWhenIdle: { type: 'boolean', default: false, description: 'Black panel while every widget of the page has nothing worth showing.' },
362
374
  jpegQuality: { type: 'integer', minimum: 0, maximum: 100, default: 90, description: 'JPEG quality of the frames; 0 = PNG.' },
375
+ maxBytes: { type: 'integer', minimum: 0, maximum: 20_000_000, default: 480_000, description: 'Largest frame to send, in bytes: a bigger one is re-encoded at a lower JPEG quality until it fits. LY panels (Trofeo Vision…) drop frames over ~500 KB. 0 = no limit.' },
363
376
  maxFps: { type: 'integer', minimum: 1, maximum: 30, default: 12, description: 'Frames per second ceiling for animations.' },
364
377
  },
365
378
  },
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * Reading and writing the config file. The file holds secrets (calendar
3
- * URLs, tokens), so it's written with 0600 and atomically.
3
+ * URLs, tokens), so it's written atomically and readable by its owner only (0600; an ACL
4
+ * on Windows).
4
5
  */
5
- import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
6
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
6
7
  import { dirname } from 'node:path';
8
+ import { restrictToOwner } from '../fsperm.ts';
7
9
  import { paths } from '../paths.ts';
8
10
  import { CONFIG_SCHEMA_ID, type Config } from './model.ts';
9
11
  import { normalize } from './normalize.ts';
@@ -25,7 +27,7 @@ export function saveConfig(config: Config, path = paths.config): void {
25
27
  if (existsSync(path)) {
26
28
  try {
27
29
  copyFileSync(path, `${path}.bak`);
28
- chmodSync(`${path}.bak`, 0o600);
30
+ restrictToOwner(`${path}.bak`);
29
31
  } catch {
30
32
  /* best effort */
31
33
  }
@@ -36,9 +38,5 @@ export function saveConfig(config: Config, path = paths.config): void {
36
38
  const out = { $schema: $schema || CONFIG_SCHEMA_ID, ...rest };
37
39
  writeFileSync(tmp, `${JSON.stringify(out, null, 2)}\n`, { mode: 0o600 });
38
40
  renameSync(tmp, path);
39
- try {
40
- chmodSync(path, 0o600);
41
- } catch {
42
- /* Windows */
43
- }
41
+ restrictToOwner(path);
44
42
  }
package/src/devices.ts CHANGED
@@ -3,11 +3,13 @@
3
3
  *
4
4
  * virtual only the editor shows it (no hardware, demos, development)
5
5
  * trcc `trcc serve` REST API (thermalright-trcc-linux), via @loksly/trcc-client
6
+ * usb straight over USB from Node, via @loksly/usb-ly (LY panels)
6
7
  * file writes `frame.jpg` atomically for an external sender (e.g. a USB
7
8
  * sender bound to a serial number, for several identical panels)
8
9
  */
9
10
  import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
10
11
  import { join } from 'node:path';
12
+ import { LyPanel, MAX_FRAME_BYTES, findPanels, loadUsb, modelFor } from '@loksly/usb-ly';
11
13
  import { connectPanel, detectPanel, pinFrame, sendFrame, serverAlive, type TrccOptions } from '@loksly/trcc-client';
12
14
  import type { DeviceConfig } from './config/model.ts';
13
15
  import { expandPath } from './paths.ts';
@@ -31,6 +33,8 @@ export interface Device {
31
33
 
32
34
  export function encodeFor(device: DeviceConfig, jpegQuality: number): 'png' | 'jpeg' {
33
35
  if (device.driver === 'file') return device.format ?? 'jpeg';
36
+ // The LY firmware only takes JPEG.
37
+ if (device.driver === 'usb') return 'jpeg';
34
38
  return jpegQuality > 0 ? 'jpeg' : 'png';
35
39
  }
36
40
 
@@ -43,6 +47,24 @@ class VirtualDevice implements Device {
43
47
  close(): void {}
44
48
  }
45
49
 
50
+ /**
51
+ * `rename` over a file another process has open fails on Windows with
52
+ * EPERM/EBUSY/EACCES (the sender is reading it right now): it passes in a few
53
+ * milliseconds, so try again a few times before giving up.
54
+ */
55
+ export async function renameReplacing(from: string, to: string, rename: (a: string, b: string) => void = renameSync, tries = 8): Promise<void> {
56
+ for (let i = 1; ; i++) {
57
+ try {
58
+ rename(from, to);
59
+ return;
60
+ } catch (err) {
61
+ const code = (err as NodeJS.ErrnoException).code;
62
+ if (i >= tries || !(code === 'EPERM' || code === 'EBUSY' || code === 'EACCES')) throw err;
63
+ await new Promise((r) => setTimeout(r, 5 * i));
64
+ }
65
+ }
66
+ }
67
+
46
68
  class FileDevice implements Device {
47
69
  status: DeviceStatus;
48
70
  private readonly dir: string;
@@ -56,7 +78,7 @@ class FileDevice implements Device {
56
78
  const tmp = join(this.dir, '.frame.tmp');
57
79
  writeFileSync(tmp, frame);
58
80
  // Atomic: the sender never reads half a JPEG.
59
- renameSync(tmp, join(this.dir, 'frame.jpg'));
81
+ await renameReplacing(tmp, join(this.dir, 'frame.jpg'));
60
82
  this.status.ok = true;
61
83
  this.status.framesSent++;
62
84
  this.status.lastSentAt = Date.now();
@@ -114,10 +136,163 @@ class TrccDevice implements Device {
114
136
  }
115
137
  }
116
138
 
117
- export function openDevice(cfg: DeviceConfig, log: (m: string) => void): Device {
139
+ /**
140
+ * A panel over USB, owned by this process. Two things the firmware asks for:
141
+ * a new frame goes out as soon as it exists, and an unchanged one is sent
142
+ * again every `keepalive` seconds, or the panel falls back to the factory logo
143
+ * after 2-3 s. A bus error drops the connection and reconnects with a growing
144
+ * pause; a panel with the configured serial number that is not there is
145
+ * waited for, never replaced by another one.
146
+ */
147
+ export class UsbDevice implements Device {
148
+ status: DeviceStatus = { driver: 'usb', ok: false, message: 'connecting…', framesSent: 0, lastSentAt: null };
149
+ private readonly cfg: Extract<DeviceConfig, { driver: 'usb' }>;
150
+ private readonly log: (m: string) => void;
151
+ private readonly rotate: number | undefined;
152
+ private readonly open: (serial?: string) => Promise<LyPanel>;
153
+ private readonly retryMs: number;
154
+ private panel: LyPanel | null = null;
155
+ private latest: Buffer | null = null;
156
+ private fresh = false;
157
+ private lastWrite = 0;
158
+ private retryAt = 0;
159
+ private failures = 0;
160
+ private closed = false;
161
+ private pumping: Promise<void> | null = null;
162
+ private timer: ReturnType<typeof setInterval> | null = null;
163
+ private lastMessage = '';
164
+
165
+ constructor(cfg: Extract<DeviceConfig, { driver: 'usb' }>, log: (m: string) => void, o: { rotate?: number; open?: (serial?: string) => Promise<LyPanel>; retryMs?: number } = {}) {
166
+ this.cfg = cfg;
167
+ this.log = log;
168
+ this.rotate = o.rotate;
169
+ this.open = o.open ?? ((serial) => LyPanel.open(serial ? { serial } : {}));
170
+ this.retryMs = o.retryMs ?? 1000;
171
+ const every = Math.round(Math.min(2, Math.max(0.1, cfg.keepalive ?? 1)) * 1000);
172
+ this.timer = setInterval(() => void this.pump(), Math.min(every, 250));
173
+ this.timer.unref();
174
+ }
175
+
176
+ private say(message: string, ok: boolean): void {
177
+ this.status.ok = ok;
178
+ this.status.message = message;
179
+ if (message !== this.lastMessage) {
180
+ this.lastMessage = message;
181
+ this.log(message);
182
+ }
183
+ }
184
+
185
+ private due(now: number): boolean {
186
+ return now - this.lastWrite >= Math.min(2, Math.max(0.1, this.cfg.keepalive ?? 1)) * 1000;
187
+ }
188
+
189
+ /** Writes what is pending; concurrent callers share one run. */
190
+ private pump(): Promise<void> {
191
+ if (this.pumping) return this.pumping;
192
+ const run = async () => {
193
+ while (!this.closed && this.latest && Date.now() >= this.retryAt && (this.fresh || this.due(Date.now()))) {
194
+ try {
195
+ const frame = this.latest;
196
+ this.fresh = false;
197
+ if (!this.panel) await this.connect();
198
+ const kept = await this.panel!.send(frame);
199
+ this.lastWrite = Date.now();
200
+ this.failures = 0;
201
+ this.status.framesSent++;
202
+ this.status.lastSentAt = this.lastWrite;
203
+ if (!kept) this.say(`the panel dropped a ${Math.round(frame.length / 1024)} KB frame (limit ${Math.round(MAX_FRAME_BYTES / 1024)} KB): lower the JPEG quality`, false);
204
+ else if (!this.status.ok) this.say(this.describe(), true);
205
+ } catch (err) {
206
+ this.fresh = true; // a reconnected panel has nothing on it
207
+ const p = this.panel;
208
+ this.panel = null;
209
+ void p?.close().catch(() => undefined);
210
+ this.failures++;
211
+ this.retryAt = Date.now() + Math.min(30 * this.retryMs, this.retryMs * 2 ** Math.min(this.failures, 5));
212
+ const why = err instanceof Error ? err.message : String(err);
213
+ this.say(`${why}${this.cfg.serial && !why.includes(this.cfg.serial) ? ` (serial ${this.cfg.serial})` : ''}; retrying`, false);
214
+ }
215
+ }
216
+ };
217
+ this.pumping = run().finally(() => (this.pumping = null));
218
+ return this.pumping;
219
+ }
220
+
221
+ private describe(): string {
222
+ const i = this.panel?.info;
223
+ if (!i) return 'usb';
224
+ return `usb ${i.serial ?? i.location ?? ''} PM ${i.pm} SUB ${i.sub}${i.model ? ` ${i.model.width}×${i.model.height}` : ''}`.replace(/\s+/g, ' ');
225
+ }
226
+
227
+ private async connect(): Promise<void> {
228
+ const panel = await this.open(this.cfg.serial);
229
+ this.panel = panel;
230
+ const m = panel.info?.model;
231
+ if (m) {
232
+ this.status.detected = { width: m.width, height: m.height };
233
+ // The picture is turned in the compositor, before encoding: the firmware's mount angle must match the screen's.
234
+ if (this.rotate !== undefined && m.rotate !== this.rotate) this.log(`the panel's firmware expects rotation ${m.rotate}° but this screen has ${this.rotate}°: the picture may come out turned`);
235
+ }
236
+ this.say(this.describe(), true);
237
+ }
238
+
239
+ async send(frame: Buffer): Promise<void> {
240
+ this.latest = frame;
241
+ this.fresh = true;
242
+ await this.pump();
243
+ }
244
+
245
+ close(): void {
246
+ this.closed = true;
247
+ if (this.timer) clearInterval(this.timer);
248
+ this.timer = null;
249
+ const p = this.panel;
250
+ this.panel = null;
251
+ void p?.close().catch(() => undefined);
252
+ }
253
+ }
254
+
255
+ /** The LY panels libusb sees (serial and where), without claiming any: for `kiosko doctor`. */
256
+ export async function listUsb(): Promise<{ ok: boolean; panels: { serial?: string; pid: number; location: string; error?: string }[]; error?: string }> {
257
+ try {
258
+ const found = await findPanels(await loadUsb());
259
+ return { ok: true, panels: found.map((f) => ({ ...(f.serial ? { serial: f.serial } : {}), pid: f.pid, location: f.location, ...(f.error ? { error: f.error } : {}) })) };
260
+ } catch (err) {
261
+ return { ok: false, panels: [], error: err instanceof Error ? err.message : String(err) };
262
+ }
263
+ }
264
+
265
+ /** The LY panels libusb sees right now, for the "add screen" menu. Never throws. */
266
+ export async function discoverUsb(): Promise<{ serial: string; key: string; width: number; height: number; location: string; error?: string }[]> {
267
+ try {
268
+ const found = await findPanels(await loadUsb());
269
+ const out = [];
270
+ for (const f of found) {
271
+ // The resolution needs the hello, which needs the device: only if no other process has it.
272
+ let model: { width: number; height: number } | undefined;
273
+ if (f.serial) {
274
+ try {
275
+ const p = await LyPanel.open({ serial: f.serial });
276
+ model = p.info?.model;
277
+ await p.close();
278
+ } catch {
279
+ /* held by another process, or no driver: listed anyway */
280
+ }
281
+ }
282
+ out.push({ serial: f.serial ?? '', key: f.serial ?? `usb ${f.location}`, width: model?.width ?? 0, height: model?.height ?? 0, location: f.location, ...(f.error ? { error: f.error } : {}) });
283
+ }
284
+ return out;
285
+ } catch {
286
+ return [];
287
+ }
288
+ }
289
+
290
+ export function openDevice(cfg: DeviceConfig, log: (m: string) => void, rotate?: number): Device {
118
291
  switch (cfg.driver) {
119
292
  case 'trcc':
120
293
  return new TrccDevice(cfg, log);
294
+ case 'usb':
295
+ return new UsbDevice(cfg, log, rotate === undefined ? {} : { rotate });
121
296
  case 'file':
122
297
  return new FileDevice(cfg);
123
298
  default:
package/src/env.ts CHANGED
@@ -30,9 +30,23 @@ export type LogFn = (scope: string, msg: string) => void;
30
30
  export function baseContext(cfg: Config, plugin: string, log: LogFn, now: number): BaseContext {
31
31
  let made = false;
32
32
  const dir = paths.data(plugin);
33
+ const cache = paths.cache(plugin);
34
+ let cacheMade = false;
33
35
  const base = createBaseContext({ now, locale: resolveLocale(cfg), timeFormat: cfg.timeFormat, workday: cfg.workday, ...(cfg.location ? { location: cfg.location } : {}), log: (msg: string) => log(plugin, msg) });
34
36
  return {
35
37
  ...base,
38
+ get cacheDir() {
39
+ if (!cache) return this.dataDir;
40
+ if (!cacheMade) {
41
+ try {
42
+ mkdirSync(cache, { recursive: true });
43
+ } catch {
44
+ return this.dataDir;
45
+ }
46
+ cacheMade = true;
47
+ }
48
+ return cache;
49
+ },
36
50
  get dataDir() {
37
51
  if (!made) {
38
52
  try {
package/src/fsperm.ts ADDED
@@ -0,0 +1,23 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { chmodSync } from 'node:fs';
3
+ import { userInfo } from 'node:os';
4
+
5
+ /**
6
+ * Makes `path` readable by its owner only: mode 0600, or, on Windows (where
7
+ * `chmod` can't do it), an ACL without inheritance that grants just the
8
+ * current user. Best effort: a file system without permissions (FAT, some
9
+ * network shares) just keeps what it has.
10
+ */
11
+ export function restrictToOwner(path: string, os: NodeJS.Platform = process.platform): void {
12
+ try {
13
+ if (os !== 'win32') {
14
+ chmodSync(path, 0o600);
15
+ return;
16
+ }
17
+ const user = process.env.USERNAME || userInfo().username;
18
+ const domain = process.env.USERDOMAIN;
19
+ execFileSync('icacls', [path, '/inheritance:r', '/grant:r', `${domain ? `${domain}\\` : ''}${user}:F`], { stdio: 'ignore', windowsHide: true, timeout: 5000 });
20
+ } catch {
21
+ /* best effort */
22
+ }
23
+ }
package/src/index.ts CHANGED
@@ -14,3 +14,5 @@ export * from './devices.ts';
14
14
  export * from './fonts.ts';
15
15
  export * from './paths.ts';
16
16
  export * from './env.ts';
17
+ export * from './fsperm.ts';
18
+ export * from './sdk-resolver.ts';
package/src/paths.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { existsSync } from 'node:fs';
1
2
  /**
2
3
  * Where Kiosko keeps its things, per platform:
3
4
  * Linux $XDG_CONFIG_HOME/kiosko (~/.config/kiosko)
@@ -6,6 +7,7 @@
6
7
  * `KIOSKO_HOME` overrides all of them (tests, portable installs).
7
8
  */
8
9
  import { homedir, platform } from 'node:os';
10
+ import { expandPath } from '@kiosko/sdk';
9
11
  import { join } from 'node:path';
10
12
 
11
13
  export function kioskoHome(): string {
@@ -32,14 +34,21 @@ export const paths = {
32
34
  get plugins() {
33
35
  return join(kioskoHome(), 'plugins');
34
36
  },
35
- /** Per-plugin caches: `data/<plugin>`. */
37
+ /**
38
+ * Per-plugin re-fetchable caches, in RAM: `$XDG_RUNTIME_DIR/kiosko-cache/<plugin>`
39
+ * (or `/dev/shm/kiosko-cache-<uid>/<plugin>`). Null where there is no tmpfs.
40
+ * On Windows there is none, but the cache belongs in the machine-local
41
+ * profile (`%LOCALAPPDATA%\\Kiosko\\cache`), not in the roaming one `data/` uses.
42
+ */
43
+ cache(plugin: string): string | null {
44
+ if (platform() === 'win32') return join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'Kiosko', 'cache', plugin.replace(/[^a-z0-9._-]/gi, '_'));
45
+ const base = process.env.XDG_RUNTIME_DIR && existsSync(process.env.XDG_RUNTIME_DIR) ? join(process.env.XDG_RUNTIME_DIR, 'kiosko-cache') : existsSync('/dev/shm') ? `/dev/shm/kiosko-cache-${process.getuid?.() ?? 'u'}` : null;
46
+ return base && join(base, plugin.replace(/[^a-z0-9._-]/gi, '_'));
47
+ },
48
+ /** Per-plugin data that must persist: `data/<plugin>`. */
36
49
  data(plugin: string) {
37
50
  return join(kioskoHome(), 'data', plugin.replace(/[^a-z0-9._-]/gi, '_'));
38
51
  },
39
52
  };
40
53
 
41
- /** `~/x` and `$VAR`/`${VAR}` expanded. */
42
- export function expandPath(p: string): string {
43
- const v = p.replace(/\$(?:\{(\w+)\}|(\w+))/g, (whole, a?: string, b?: string) => process.env[a ?? b ?? ''] ?? whole);
44
- return v === '~' || v.startsWith('~/') ? join(homedir(), v.slice(1)) : v;
45
- }
54
+ export { expandPath };