@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.
@@ -0,0 +1,60 @@
1
+ /**
2
+ * When a widget's animation plays. The planning is here, apart from the
3
+ * painting and the sending (runtime.ts), so it can be tested without a
4
+ * screen: which shown widget is asked, and why (`idle`, now and then, or
5
+ * `data`, because what it paints with changed), and what the answer is worth.
6
+ *
7
+ * One animation at a time per screen is the caller's business (it asks
8
+ * only while nothing plays); what is kept here is per widget instance: when
9
+ * it was last asked, when its last animation ended and what it had then.
10
+ */
11
+ import type { AnimationPlan } from '@kiosko/sdk';
12
+ import type { Animatable } from './compositor.ts';
13
+ /** The most frames one animation may have: 30 s at 20 fps. A wrong plan must not eat the CPU. */
14
+ export declare const MAX_FRAMES = 600;
15
+ /**
16
+ * How many animations the runtime plays together on a screen. Widgets don't
17
+ * know: every one asks as if it were alone, and what doesn't fit waits its
18
+ * turn. The whole chain (planner, compositor, player) works for any number;
19
+ * one is what the panel's USB budget is sized for.
20
+ */
21
+ export declare const MAX_CONCURRENT = 1;
22
+ /** Default time between the `idle` questions of a widget. */
23
+ export declare const DEFAULT_GAP: {
24
+ minMs: number;
25
+ maxMs: number;
26
+ };
27
+ /** A plan as the runtime plays it: checked, with the screen's fps ceiling applied. */
28
+ export interface PlayablePlan {
29
+ plan: AnimationPlan;
30
+ frames: number;
31
+ fps: number;
32
+ interruptible: boolean;
33
+ /** Index (into 0..frames-1) where it holds, and for how long. */
34
+ hold?: {
35
+ index: number;
36
+ ms: number;
37
+ };
38
+ }
39
+ /** Why a plan isn't playable, or the plan ready to play. */
40
+ export declare function checkPlan(plan: unknown, maxFps: number): PlayablePlan | string;
41
+ export declare class AnimationScheduler {
42
+ private memory;
43
+ private readonly rnd;
44
+ private readonly onInvalid;
45
+ constructor(rnd?: () => number, onInvalid?: (type: string, why: string) => void);
46
+ private gapAfter;
47
+ /**
48
+ * The animations to play now, up to `limit`, among the shown widgets that
49
+ * have one. Call it only while nothing is playing. A widget seen for the
50
+ * first time isn't asked: starting up must not set off a gesture. When more
51
+ * are due than fit, the one that has waited longest goes first (the order
52
+ * of the page doesn't decide), so none starves.
53
+ */
54
+ next(list: Animatable[], now: number, maxFps: number, limit?: number): {
55
+ animatable: Animatable;
56
+ play: PlayablePlan;
57
+ }[];
58
+ /** An animation of `instanceId` has just ended (or was cut): its cooldown starts. */
59
+ ended(instanceId: string, now: number, a?: Animatable): void;
60
+ }
@@ -0,0 +1,111 @@
1
+ /** The most frames one animation may have: 30 s at 20 fps. A wrong plan must not eat the CPU. */
2
+ export const MAX_FRAMES = 600;
3
+ /**
4
+ * How many animations the runtime plays together on a screen. Widgets don't
5
+ * know: every one asks as if it were alone, and what doesn't fit waits its
6
+ * turn. The whole chain (planner, compositor, player) works for any number;
7
+ * one is what the panel's USB budget is sized for.
8
+ */
9
+ export const MAX_CONCURRENT = 1;
10
+ /** Default time between the `idle` questions of a widget. */
11
+ export const DEFAULT_GAP = { minMs: 20_000, maxMs: 60_000 };
12
+ /** Why a plan isn't playable, or the plan ready to play. */
13
+ export function checkPlan(plan, maxFps) {
14
+ const p = plan;
15
+ if (!p || typeof p !== 'object')
16
+ return 'not an object';
17
+ if (typeof p.id !== 'string' || !p.id)
18
+ return 'no id';
19
+ if (!Number.isInteger(p.frames) || p.frames < 3)
20
+ return `frames must be an integer of at least 3 (got ${String(p.frames)})`;
21
+ if (p.frames > MAX_FRAMES)
22
+ return `too many frames (${p.frames}, at most ${MAX_FRAMES})`;
23
+ if (typeof p.fps !== 'number' || !(p.fps > 0))
24
+ return `fps must be positive (got ${String(p.fps)})`;
25
+ const frames = p.frames;
26
+ const hold = p.hold && Number.isFinite(p.hold.at) && p.hold.ms > 0 ? { index: Math.max(1, Math.min(frames - 2, Math.round(p.hold.at * (frames - 1)))), ms: p.hold.ms } : undefined;
27
+ return { plan: p, frames, fps: Math.max(1, Math.min(p.fps, maxFps)), interruptible: p.interruptible !== false, ...(hold ? { hold } : {}) };
28
+ }
29
+ export class AnimationScheduler {
30
+ memory = new Map();
31
+ rnd;
32
+ onInvalid;
33
+ constructor(rnd = Math.random, onInvalid = () => undefined) {
34
+ this.rnd = rnd;
35
+ this.onInvalid = onInvalid;
36
+ }
37
+ gapAfter(a, now) {
38
+ const g = a.def.animationGap ?? DEFAULT_GAP;
39
+ const min = Math.max(0, g.minMs), max = Math.max(min, g.maxMs);
40
+ return now + min + this.rnd() * (max - min);
41
+ }
42
+ /**
43
+ * The animations to play now, up to `limit`, among the shown widgets that
44
+ * have one. Call it only while nothing is playing. A widget seen for the
45
+ * first time isn't asked: starting up must not set off a gesture. When more
46
+ * are due than fit, the one that has waited longest goes first (the order
47
+ * of the page doesn't decide), so none starves.
48
+ */
49
+ next(list, now, maxFps, limit = MAX_CONCURRENT) {
50
+ const seen = new Set();
51
+ const due = [];
52
+ for (const a of list) {
53
+ seen.add(a.instanceId);
54
+ const m = this.memory.get(a.instanceId);
55
+ if (!m) {
56
+ this.memory.set(a.instanceId, { data: a.data, sources: a.sources, nextIdleAt: this.gapAfter(a, now), endedAt: -Infinity, dueSince: Infinity });
57
+ continue;
58
+ }
59
+ const changed = a.data !== m.data || Object.keys(a.sources).some((k) => a.sources[k] !== m.sources[k]);
60
+ const prev = m.pending ?? { data: m.data, sources: m.sources };
61
+ m.data = a.data;
62
+ m.sources = a.sources;
63
+ if ((changed || m.pending) && now - m.endedAt >= (a.def.animationCooldownMs ?? 0)) {
64
+ m.pending = prev;
65
+ due.push({ a, m, trigger: 'data', prev });
66
+ }
67
+ else if (now >= m.nextIdleAt)
68
+ due.push({ a, m, trigger: 'idle', prev });
69
+ }
70
+ // Forget the instances that are gone (removed, or no longer shown).
71
+ for (const id of this.memory.keys())
72
+ if (!seen.has(id))
73
+ this.memory.delete(id);
74
+ // Who has waited longest first; a tie keeps the page's order.
75
+ for (const d of due)
76
+ d.m.dueSince = Math.min(d.m.dueSince, d.trigger === 'data' ? now : d.m.nextIdleAt);
77
+ due.sort((x, y) => x.m.dueSince - y.m.dueSince);
78
+ const out = [];
79
+ for (const { a, m, trigger, prev } of due) {
80
+ if (out.length >= limit)
81
+ break;
82
+ if (trigger === 'idle')
83
+ m.nextIdleAt = this.gapAfter(a, now);
84
+ else
85
+ m.pending = undefined;
86
+ const plan = a.ask(trigger, this.rnd, trigger === 'data' ? prev : undefined);
87
+ if (plan === null) {
88
+ m.dueSince = Infinity;
89
+ continue;
90
+ }
91
+ const ok = checkPlan(plan, maxFps);
92
+ if (typeof ok === 'string') {
93
+ this.onInvalid(a.type, ok);
94
+ m.dueSince = Infinity;
95
+ continue;
96
+ }
97
+ out.push({ animatable: a, play: ok });
98
+ }
99
+ return out;
100
+ }
101
+ /** An animation of `instanceId` has just ended (or was cut): its cooldown starts. */
102
+ ended(instanceId, now, a) {
103
+ const m = this.memory.get(instanceId);
104
+ if (!m)
105
+ return;
106
+ m.endedAt = now;
107
+ m.dueSince = Infinity;
108
+ if (a)
109
+ m.nextIdleAt = Math.max(m.nextIdleAt, this.gapAfter(a, now));
110
+ }
111
+ }
@@ -5,7 +5,7 @@
5
5
  * go through here and can't disagree.
6
6
  */
7
7
  import { type Canvas, type SKRSContext2D } from '@napi-rs/canvas';
8
- import { type Theme, type WidgetDefinition } from '@kiosko/sdk';
8
+ import { type AnimationFrame, type AnimationPlan, type Box, type Theme, type WidgetDefinition } from '@kiosko/sdk';
9
9
  import { type PxRect } from './layout/grid.ts';
10
10
  import { type Config, type Page, type Screen, type Tile, type WidgetInstance } from './config/model.ts';
11
11
  import { type LogFn } from './env.ts';
@@ -35,6 +35,35 @@ export interface ComposeInput {
35
35
  noShift?: boolean;
36
36
  /** Reuse tiles whose inputs didn't change (the panel; not previews). */
37
37
  cache?: TileCache;
38
+ /**
39
+ * Paint these frames of animations, each in its own tile (see
40
+ * `AnimationFrame`); every other tile as usual. Any number at once: how
41
+ * many the runtime plays together is its decision (`MAX_CONCURRENT`).
42
+ */
43
+ animate?: {
44
+ tileId: string;
45
+ instanceId: string;
46
+ frame: AnimationFrame;
47
+ }[];
48
+ }
49
+ /**
50
+ * A widget on screen that has an `animation` hook: asks it, with the state
51
+ * the page was painted with. The runtime decides when to ask.
52
+ */
53
+ export interface Animatable {
54
+ tileId: string;
55
+ instanceId: string;
56
+ type: string;
57
+ def: Pick<WidgetDefinition<any, any>, 'animationGap' | 'animationCooldownMs'>;
58
+ /** What the widget painted with, by reference (replaced, never mutated), to tell when it changed. */
59
+ data: unknown;
60
+ sources: Record<string, unknown>;
61
+ ask: (trigger: 'idle' | 'data', rnd: () => number, prev?: {
62
+ data: unknown;
63
+ sources: Record<string, unknown>;
64
+ }) => AnimationPlan | null;
65
+ /** Tells the widget an animation of its has ended (see `WidgetDefinition.animationEnded`). */
66
+ ended: (plan: AnimationPlan, completed: boolean) => void;
38
67
  }
39
68
  export interface TileResult {
40
69
  id: string;
@@ -50,6 +79,8 @@ export interface ComposeResult {
50
79
  idle: boolean;
51
80
  /** When something will change by itself (clock, rotation): next repaint. */
52
81
  nextAt: number;
82
+ /** The shown widgets that can animate. */
83
+ animatable: Animatable[];
53
84
  }
54
85
  /**
55
86
  * The instance's switches over the widget's hooks: `takeOver` (keep the tile
@@ -76,6 +107,16 @@ export type TileCache = Map<string, {
76
107
  canvas: Canvas;
77
108
  error?: string;
78
109
  }>;
110
+ /**
111
+ * Where a widget paints inside its tile: `outer` is the area left by the
112
+ * card's padding and title, and `box` is what the widget sees: the same area
113
+ * `zoom` times smaller when zoomed (it keeps its top-left corner).
114
+ */
115
+ export declare function tileBoxes(theme: Theme, screenH: number, inst: WidgetInstance, rect: PxRect, chrome: 'card' | 'bare'): {
116
+ outer: Box;
117
+ box: Box;
118
+ zoom: number;
119
+ };
79
120
  /** Paints `page` on a new canvas of the screen size. */
80
121
  export declare function composePage(input: ComposeInput): ComposeResult;
81
122
  /** A canvas rotated for a panel mounted at `rotate` degrees. */
@@ -116,7 +116,7 @@ function drawCard(g, rect, theme) {
116
116
  roundRect(g, rect.x + 0.5, rect.y + 0.5, rect.w - 1, rect.h - 1, theme.radius);
117
117
  g.stroke();
118
118
  }
119
- function paintTile(g, input, tile, rect, out, nextAt, idles) {
119
+ function paintTile(g, input, tile, rect, out, nextAt, idles, animatable) {
120
120
  const { theme, now, working } = input;
121
121
  const candidates = tile.widgets
122
122
  .filter((w) => !w.disabled && whenMatches(w.when, working))
@@ -148,8 +148,26 @@ function paintTile(g, input, tile, rect, out, nextAt, idles) {
148
148
  const step = redrawStep(r.def, state);
149
149
  if (step)
150
150
  nextAt.v = Math.min(nextAt.v, (Math.floor(now / step) + 1) * step);
151
- const draw = (tg) => drawTileContent(tg, input, r, rect, chrome, out);
152
- if (!input.cache) {
151
+ const animation = r.def.animation;
152
+ if (animation && !input.preview) {
153
+ animatable.push({
154
+ tileId: tile.id,
155
+ instanceId: r.inst.id,
156
+ type: r.inst.type,
157
+ def: r.def,
158
+ data: r.data,
159
+ sources: r.sources,
160
+ ended: (plan, completed) => {
161
+ const [plugin] = r.inst.type.split('/');
162
+ safe(() => r.def.animationEnded?.({ ...baseContext(input.cfg, plugin, input.log ?? (() => undefined), input.now), options: r.options, plan, completed, data: r.data }), undefined);
163
+ },
164
+ ask: (trigger, rnd, prev) => safe(() => animation({ ...state, box: tileBoxes(theme, input.screen.height, r.inst, rect, chrome).box, trigger, rnd, ...(prev !== undefined ? { prev } : {}) }), null),
165
+ });
166
+ }
167
+ // The tile whose animation is being painted: this frame, never from or into the cache.
168
+ const frame = input.animate?.find((a) => a.tileId === tile.id && a.instanceId === r.inst.id)?.frame;
169
+ const draw = (tg) => drawTileContent(tg, input, r, rect, chrome, out, frame);
170
+ if (frame || !input.cache) {
153
171
  draw(g);
154
172
  return;
155
173
  }
@@ -172,27 +190,37 @@ function paintTile(g, input, tile, rect, out, nextAt, idles) {
172
190
  g.drawImage(canvas, rect.x, rect.y);
173
191
  input.cache.set(tile.id, { key, refs, canvas, ...(out.error ? { error: out.error } : {}) });
174
192
  }
175
- function drawTileContent(g, input, r, rect, chrome, out) {
193
+ /**
194
+ * Where a widget paints inside its tile: `outer` is the area left by the
195
+ * card's padding and title, and `box` is what the widget sees: the same area
196
+ * `zoom` times smaller when zoomed (it keeps its top-left corner).
197
+ */
198
+ export function tileBoxes(theme, screenH, inst, rect, chrome) {
199
+ const m = chromeMetrics(theme, screenH, rect);
200
+ const pad = chrome === 'card' ? m.pad : 0;
201
+ const top = rect.y + pad + (inst.title?.trim() && chrome === 'card' ? m.titleBar : 0);
202
+ const outer = { x: rect.x + pad, y: top, w: Math.max(1, rect.w - 2 * pad), h: Math.max(1, rect.y + rect.h - pad - top) };
203
+ // Zoom: the widget gets a box `zoom` times smaller and is painted `zoom`
204
+ // times bigger, so it fills the same space with bigger (or smaller) type.
205
+ // Its box keeps its top-left corner, so widgets see nothing unusual.
206
+ const zoom = zoomOf(inst);
207
+ const box = zoom === 1 ? outer : { x: outer.x, y: outer.y, w: Math.max(1, outer.w / zoom), h: Math.max(1, outer.h / zoom) };
208
+ return { outer, box, zoom };
209
+ }
210
+ function drawTileContent(g, input, r, rect, chrome, out, anim) {
176
211
  const { theme, now, working } = input;
177
212
  const m = chromeMetrics(theme, input.screen.height, rect);
178
213
  if (chrome === 'card')
179
214
  drawCard(g, rect, theme);
180
215
  const pad = chrome === 'card' ? m.pad : 0;
181
- let top = rect.y + pad;
182
216
  const title = r.inst.title?.trim();
183
217
  if (title && chrome === 'card') {
184
218
  g.font = `${m.title}px "${theme.fonts.sans}"`;
185
219
  g.fillStyle = theme.colors.inkDim;
186
220
  g.textBaseline = 'alphabetic';
187
221
  g.fillText(fitText(g, title, rect.w - 2 * pad), rect.x + pad, rect.y + pad + m.title * 0.8);
188
- top = rect.y + pad + m.titleBar;
189
222
  }
190
- const outer = { x: rect.x + pad, y: top, w: Math.max(1, rect.w - 2 * pad), h: Math.max(1, rect.y + rect.h - pad - top) };
191
- // Zoom: the widget gets a box `zoom` times smaller and is painted `zoom`
192
- // times bigger, so it fills the same space with bigger (or smaller) type.
193
- // Its box keeps its top-left corner, so widgets see nothing unusual.
194
- const zoom = zoomOf(r.inst);
195
- const box = zoom === 1 ? outer : { x: outer.x, y: outer.y, w: Math.max(1, outer.w / zoom), h: Math.max(1, outer.h / zoom) };
223
+ const { outer, box, zoom } = tileBoxes(theme, input.screen.height, r.inst, rect, chrome);
196
224
  const [plugin] = r.inst.type.split('/');
197
225
  const ctx = {
198
226
  ...baseContext(input.cfg, plugin, input.log ?? (() => undefined), now),
@@ -203,6 +231,7 @@ function drawTileContent(g, input, r, rect, chrome, out) {
203
231
  kit,
204
232
  canvas: CANVAS,
205
233
  preview: !!input.preview,
234
+ ...(anim ? { anim, frame: anim.i } : {}),
206
235
  };
207
236
  g.save();
208
237
  roundRect(g, rect.x, rect.y, rect.w, rect.h, chrome === 'card' ? theme.radius : 0);
@@ -243,11 +272,12 @@ export function composePage(input) {
243
272
  const nextAt = { v: Infinity };
244
273
  const idles = [];
245
274
  const tiles = [];
275
+ const animatable = [];
246
276
  for (const tile of page.tiles) {
247
277
  const rect = toPx(tile, page.grid, screen.width, screen.height);
248
278
  const out = { id: tile.id, rect, shown: null };
249
279
  tiles.push(out);
250
- paintTile(g, input, tile, rect, out, nextAt, idles);
280
+ paintTile(g, input, tile, rect, out, nextAt, idles, animatable);
251
281
  }
252
282
  if (input.cache)
253
283
  for (const id of input.cache.keys())
@@ -256,7 +286,7 @@ export function composePage(input) {
256
286
  g.restore();
257
287
  if (screen.pixelShift.enabled && !input.preview)
258
288
  nextAt.v = Math.min(nextAt.v, (Math.floor(now / screen.pixelShift.periodMs) + 1) * screen.pixelShift.periodMs);
259
- return { canvas, tiles, idle: idles.length > 0 && idles.every(Boolean), nextAt: nextAt.v };
289
+ return { canvas, tiles, idle: idles.length > 0 && idles.every(Boolean), nextAt: nextAt.v, animatable };
260
290
  }
261
291
  /** A canvas rotated for a panel mounted at `rotate` degrees. */
262
292
  export function rotateCanvas(src, rotate) {
@@ -35,7 +35,7 @@ const pageText = (p) => {
35
35
  const ts = pageTypes(p);
36
36
  return `page ${q(p.name)}: ${plural(p.tiles.length, 'tile')}${ts.length ? ` (${ts.slice(0, 6).join(', ')}${ts.length > 6 ? `, +${ts.length - 6} more` : ''})` : ''}`;
37
37
  };
38
- const deviceText = (d) => (d.driver === 'trcc' ? `trcc ${d.url}` : d.driver === 'file' ? `file ${d.dir}` : d.driver);
38
+ const deviceText = (d) => (d.driver === 'trcc' ? `trcc ${d.url}` : d.driver === 'file' ? `file ${d.dir}` : d.driver === 'usb' ? `usb${d.serial ? ` ${d.serial}` : ''}` : d.driver);
39
39
  const changedKeys = (a, b) => [...new Set([...Object.keys(a), ...Object.keys(b)])].filter((k) => !same(a[k], b[k]));
40
40
  function diffTiles(before, after, depth, out) {
41
41
  const m = match(before.tiles, after.tiles, rect);
@@ -34,6 +34,12 @@ export type DeviceConfig = {
34
34
  serial?: string;
35
35
  keepalive?: boolean;
36
36
  }
37
+ /** Straight over USB from Node (LY panels, Trofeo Vision…): no trcc, no Python. */
38
+ | {
39
+ driver: 'usb';
40
+ serial?: string;
41
+ keepalive?: number;
42
+ }
37
43
  /** Writes `frame.jpg` atomically to `dir`; a sender (e.g. by USB serial) takes it to the panel. */
38
44
  | {
39
45
  driver: 'file';
@@ -136,9 +142,17 @@ export interface Screen {
136
142
  sleepWhenIdle: boolean;
137
143
  /** JPEG quality for photo-like pages; 0 = PNG. */
138
144
  jpegQuality: number;
145
+ /**
146
+ * Largest frame to send, in bytes; a bigger one is re-encoded at a lower
147
+ * JPEG quality until it fits. LY panels (Trofeo Vision and friends) silently
148
+ * drop frames over ~500 KB and keep showing the last one. 0 = no limit.
149
+ */
150
+ maxBytes: number;
139
151
  /** Frames per second ceiling for animations. */
140
152
  maxFps: number;
141
153
  }
154
+ /** Default `maxBytes`: under the ~500 KB LY panels accept, with some margin. */
155
+ export declare const DEFAULT_MAX_BYTES = 480000;
142
156
  export interface Config {
143
157
  /** JSON Schema of the file, for editors. Kept as written; `CONFIG_SCHEMA_ID` when missing. */
144
158
  $schema?: string;
@@ -16,6 +16,8 @@ export function zoomOf(inst) {
16
16
  return 1;
17
17
  return Math.round(Math.min(ZOOM.max, Math.max(ZOOM.min, z)) * 100) / 100;
18
18
  }
19
+ /** Default `maxBytes`: under the ~500 KB LY panels accept, with some margin. */
20
+ export const DEFAULT_MAX_BYTES = 480_000;
19
21
  let counter = 0;
20
22
  /** Short unique id. Not crypto: only needs to be unique inside one config. */
21
23
  export function newId(prefix = 'id') {
@@ -46,6 +48,7 @@ export function newScreen(name, width = 1920, height = 462, device = { driver: '
46
48
  pixelShift: { enabled: true, amplitude: 2, periodMs: 8 * 60_000 },
47
49
  sleepWhenIdle: false,
48
50
  jpegQuality: 90,
51
+ maxBytes: DEFAULT_MAX_BYTES,
49
52
  maxFps: 12,
50
53
  };
51
54
  }
@@ -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.js";
9
- import { CONFIG_VERSION, DEFAULT_CONFIG } from "./model.js";
9
+ import { CONFIG_VERSION, DEFAULT_CONFIG, DEFAULT_MAX_BYTES } from "./model.js";
10
10
  /**
11
11
  * Fills gaps of a config written by hand or by an older version. Never throws.
12
12
  * The top-level shape it accepts is described, for other tools, by
@@ -56,6 +56,7 @@ function normalizeScreen(s, i) {
56
56
  pixelShift: { enabled: true, amplitude: 2, periodMs: 8 * 60_000, ...(s.pixelShift ?? {}) },
57
57
  sleepWhenIdle: s.sleepWhenIdle ?? false,
58
58
  jpegQuality: s.jpegQuality ?? 90,
59
+ maxBytes: Math.max(0, Math.round(s.maxBytes ?? DEFAULT_MAX_BYTES)),
59
60
  maxFps: s.maxFps ?? 12,
60
61
  };
61
62
  }
@@ -243,6 +243,18 @@ export function configSchema(plugins) {
243
243
  keepalive: { type: 'boolean', default: true, description: 'Re-send the last frame now and then so the panel keeps it.' },
244
244
  },
245
245
  },
246
+ {
247
+ title: 'USB (LY panels)',
248
+ 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.',
249
+ type: 'object',
250
+ additionalProperties: false,
251
+ required: ['driver'],
252
+ properties: {
253
+ driver: { const: 'usb' },
254
+ 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.' },
255
+ 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.' },
256
+ },
257
+ },
246
258
  {
247
259
  title: 'Frame file',
248
260
  description: 'Writes frame.jpg atomically into a folder; an external sender takes it to the panel.',
@@ -341,6 +353,7 @@ export function configSchema(plugins) {
341
353
  },
342
354
  sleepWhenIdle: { type: 'boolean', default: false, description: 'Black panel while every widget of the page has nothing worth showing.' },
343
355
  jpegQuality: { type: 'integer', minimum: 0, maximum: 100, default: 90, description: 'JPEG quality of the frames; 0 = PNG.' },
356
+ 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.' },
344
357
  maxFps: { type: 'integer', minimum: 1, maximum: 30, default: 12, description: 'Frames per second ceiling for animations.' },
345
358
  },
346
359
  },
@@ -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.js";
7
9
  import { paths } from "../paths.js";
8
10
  import { CONFIG_SCHEMA_ID } from "./model.js";
9
11
  import { normalize } from "./normalize.js";
@@ -24,7 +26,7 @@ export function saveConfig(config, path = paths.config) {
24
26
  if (existsSync(path)) {
25
27
  try {
26
28
  copyFileSync(path, `${path}.bak`);
27
- chmodSync(`${path}.bak`, 0o600);
29
+ restrictToOwner(`${path}.bak`);
28
30
  }
29
31
  catch {
30
32
  /* best effort */
@@ -36,10 +38,5 @@ export function saveConfig(config, path = paths.config) {
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
- }
42
- catch {
43
- /* Windows */
44
- }
41
+ restrictToOwner(path);
45
42
  }
package/dist/devices.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { LyPanel } from '@loksly/usb-ly';
1
2
  import type { DeviceConfig } from './config/model.ts';
2
3
  export interface DeviceStatus {
3
4
  driver: DeviceConfig['driver'];
@@ -18,7 +19,74 @@ export interface Device {
18
19
  close(): void;
19
20
  }
20
21
  export declare function encodeFor(device: DeviceConfig, jpegQuality: number): 'png' | 'jpeg';
21
- export declare function openDevice(cfg: DeviceConfig, log: (m: string) => void): Device;
22
+ /**
23
+ * `rename` over a file another process has open fails on Windows with
24
+ * EPERM/EBUSY/EACCES (the sender is reading it right now): it passes in a few
25
+ * milliseconds, so try again a few times before giving up.
26
+ */
27
+ export declare function renameReplacing(from: string, to: string, rename?: (a: string, b: string) => void, tries?: number): Promise<void>;
28
+ /**
29
+ * A panel over USB, owned by this process. Two things the firmware asks for:
30
+ * a new frame goes out as soon as it exists, and an unchanged one is sent
31
+ * again every `keepalive` seconds, or the panel falls back to the factory logo
32
+ * after 2-3 s. A bus error drops the connection and reconnects with a growing
33
+ * pause; a panel with the configured serial number that is not there is
34
+ * waited for, never replaced by another one.
35
+ */
36
+ export declare class UsbDevice implements Device {
37
+ status: DeviceStatus;
38
+ private readonly cfg;
39
+ private readonly log;
40
+ private readonly rotate;
41
+ private readonly open;
42
+ private readonly retryMs;
43
+ private panel;
44
+ private latest;
45
+ private fresh;
46
+ private lastWrite;
47
+ private retryAt;
48
+ private failures;
49
+ private closed;
50
+ private pumping;
51
+ private timer;
52
+ private lastMessage;
53
+ constructor(cfg: Extract<DeviceConfig, {
54
+ driver: 'usb';
55
+ }>, log: (m: string) => void, o?: {
56
+ rotate?: number;
57
+ open?: (serial?: string) => Promise<LyPanel>;
58
+ retryMs?: number;
59
+ });
60
+ private say;
61
+ private due;
62
+ /** Writes what is pending; concurrent callers share one run. */
63
+ private pump;
64
+ private describe;
65
+ private connect;
66
+ send(frame: Buffer): Promise<void>;
67
+ close(): void;
68
+ }
69
+ /** The LY panels libusb sees (serial and where), without claiming any: for `kiosko doctor`. */
70
+ export declare function listUsb(): Promise<{
71
+ ok: boolean;
72
+ panels: {
73
+ serial?: string;
74
+ pid: number;
75
+ location: string;
76
+ error?: string;
77
+ }[];
78
+ error?: string;
79
+ }>;
80
+ /** The LY panels libusb sees right now, for the "add screen" menu. Never throws. */
81
+ export declare function discoverUsb(): Promise<{
82
+ serial: string;
83
+ key: string;
84
+ width: number;
85
+ height: number;
86
+ location: string;
87
+ error?: string;
88
+ }[]>;
89
+ export declare function openDevice(cfg: DeviceConfig, log: (m: string) => void, rotate?: number): Device;
22
90
  /** Panels `trcc serve` can see right now (for the "add screen" dialog). */
23
91
  export declare function discoverTrcc(url?: string, tokenFile?: string): Promise<{
24
92
  key: string;