@rhizomatics/signalk-einklabel-plugin 0.10.1 → 1.2.0
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.
- package/CHANGELOG.md +22 -0
- package/README.md +111 -9
- package/dist/cli/index.d.ts +15 -1
- package/dist/cli/index.js +103 -37
- package/dist/config.d.ts +27 -7
- package/dist/config.js +40 -10
- package/dist/devices/gicisky/compression.d.ts +16 -0
- package/dist/devices/gicisky/compression.js +34 -0
- package/dist/devices/gicisky/encode.d.ts +10 -0
- package/dist/devices/gicisky/encode.js +130 -0
- package/dist/devices/gicisky/index.d.ts +11 -0
- package/dist/devices/gicisky/index.js +164 -0
- package/dist/devices/gicisky/layout.d.ts +35 -0
- package/dist/devices/gicisky/layout.js +35 -0
- package/dist/devices/gicisky/metadata.d.ts +16 -0
- package/dist/devices/gicisky/metadata.js +127 -0
- package/dist/devices/gicisky/protocol.d.ts +53 -0
- package/dist/devices/gicisky/protocol.js +88 -0
- package/dist/devices/types.d.ts +11 -0
- package/dist/devices/zhsunyco/index.js +3 -1
- package/dist/devices/zhsunyco/metadata.js +9 -0
- package/dist/index.d.ts +36 -3
- package/dist/index.js +28 -3
- package/dist/plugin.js +2 -0
- package/dist/render/binding.d.ts +86 -3
- package/dist/render/binding.js +135 -3
- package/dist/render/formatters.js +18 -0
- package/dist/render/reframe.d.ts +12 -0
- package/dist/render/reframe.js +36 -0
- package/dist/render/templateProviders.d.ts +58 -0
- package/dist/render/templateProviders.js +16 -0
- package/dist/repaintScheduler.js +126 -51
- package/package.json +15 -12
- package/templates/.error/250x128-BWRY.svg +12 -0
- package/templates/.error/416x240-BWRY.svg +12 -0
package/dist/render/binding.js
CHANGED
|
@@ -3,12 +3,16 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.parseBinding = parseBinding;
|
|
4
4
|
exports.resourceContextKey = resourceContextKey;
|
|
5
5
|
exports.findBindings = findBindings;
|
|
6
|
+
exports.readTemplateDimensions = readTemplateDimensions;
|
|
6
7
|
exports.resolveBinding = resolveBinding;
|
|
7
8
|
exports.renderBinding = renderBinding;
|
|
9
|
+
exports.buildLabelContext = buildLabelContext;
|
|
10
|
+
exports.findTextBindings = findTextBindings;
|
|
11
|
+
exports.substituteTextBindings = substituteTextBindings;
|
|
8
12
|
const xmldom_1 = require("@xmldom/xmldom");
|
|
9
13
|
const formatters_1 = require("./formatters");
|
|
10
|
-
const SOURCES = ["signalk", "resources", "einklabel"];
|
|
11
|
-
const KNOWN_KEYS = new Set(["source", "context", "resource", "provider", "path", "format", "category", "round", "assets"]);
|
|
14
|
+
const SOURCES = ["signalk", "resources", "einklabel", "label"];
|
|
15
|
+
const KNOWN_KEYS = new Set(["source", "context", "resource", "provider", "path", "format", "category", "round", "assets", "default"]);
|
|
12
16
|
/**
|
|
13
17
|
* Parses a `<desc>` element's text content into a `Binding`, e.g.
|
|
14
18
|
* `source=resources,resource=tides,path=extremes[0].level,category=depth,round=2` or, using the
|
|
@@ -62,6 +66,7 @@ function parseBinding(desc) {
|
|
|
62
66
|
category: fields.category,
|
|
63
67
|
round: fields.round !== undefined ? Number(fields.round) : undefined,
|
|
64
68
|
assets: fields.assets,
|
|
69
|
+
default: fields.default,
|
|
65
70
|
};
|
|
66
71
|
}
|
|
67
72
|
/**
|
|
@@ -95,6 +100,26 @@ function findBindings(svgSource) {
|
|
|
95
100
|
}
|
|
96
101
|
return bindings;
|
|
97
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Reads the root `<svg>` element's declared `width`/`height` (falling back to the 3rd/4th `viewBox`
|
|
105
|
+
* numbers when either attribute is missing) - lets a caller default a render size to what the template
|
|
106
|
+
* itself was authored for, e.g. the CLI's -w/--width and --height options.
|
|
107
|
+
*/
|
|
108
|
+
function readTemplateDimensions(svgSource) {
|
|
109
|
+
const doc = new xmldom_1.DOMParser().parseFromString(svgSource, "image/svg+xml");
|
|
110
|
+
const svg = doc.getElementsByTagName("svg").item(0);
|
|
111
|
+
if (!svg)
|
|
112
|
+
return {};
|
|
113
|
+
const viewBox = svg.getAttribute("viewBox")?.trim().split(/\s+/).map(Number);
|
|
114
|
+
const viewBoxWidth = viewBox?.length === 4 && Number.isFinite(viewBox[2]) ? viewBox[2] : undefined;
|
|
115
|
+
const viewBoxHeight = viewBox?.length === 4 && Number.isFinite(viewBox[3]) ? viewBox[3] : undefined;
|
|
116
|
+
const width = parseFloat(svg.getAttribute("width") ?? "");
|
|
117
|
+
const height = parseFloat(svg.getAttribute("height") ?? "");
|
|
118
|
+
return {
|
|
119
|
+
width: Number.isFinite(width) ? width : viewBoxWidth,
|
|
120
|
+
height: Number.isFinite(height) ? height : viewBoxHeight,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
98
123
|
/** Supports both `a.[0].b` and `a[0].b` array index notation, matching `setAtPath` in repaintScheduler.ts. */
|
|
99
124
|
function getAtPath(obj, path) {
|
|
100
125
|
const segments = path
|
|
@@ -126,6 +151,13 @@ function resolveBinding(binding, context) {
|
|
|
126
151
|
}
|
|
127
152
|
return getAtPath(meta, binding.path);
|
|
128
153
|
}
|
|
154
|
+
if (binding.source === "label") {
|
|
155
|
+
const label = context.label;
|
|
156
|
+
if (label === undefined) {
|
|
157
|
+
throw new Error('binding references source "label" but no "label" is present in the render context');
|
|
158
|
+
}
|
|
159
|
+
return getAtPath(label, binding.path);
|
|
160
|
+
}
|
|
129
161
|
const resources = context.resources;
|
|
130
162
|
const resourceKey = resourceContextKey(binding);
|
|
131
163
|
const resource = resources?.[resourceKey];
|
|
@@ -166,6 +198,8 @@ function resolveCategoryDisplayUnits(binding, context) {
|
|
|
166
198
|
* the CLI's `field`/`fields` commands show the same thing a real render would.
|
|
167
199
|
*
|
|
168
200
|
* Precedence for a numeric value:
|
|
201
|
+
* 0. A missing value (`undefined`/`null`, e.g. an unpublished path) with an explicit `default=` given -
|
|
202
|
+
* that default, verbatim, bypassing every step below (there's nothing to format).
|
|
169
203
|
* 1. An explicit named `format=` (anything other than `raw`) - `local_time`/`utc_offset`/`position`.
|
|
170
204
|
* 2. An explicit `category=` - for values with no path metadata of their own, e.g. a `source=resources`
|
|
171
205
|
* value.
|
|
@@ -173,10 +207,13 @@ function resolveCategoryDisplayUnits(binding, context) {
|
|
|
173
207
|
* `context.pathMeta`) by default - `format=raw` opts out of this step only.
|
|
174
208
|
* 4. Falls through to `round=` (`toFixed`), `JSON.stringify` for an unformatted object/array value
|
|
175
209
|
* (e.g. a path that resolved to a whole sub-tree rather than a leaf) instead of the useless
|
|
176
|
-
* `String(value)` -> `"[object Object]"`, else `String`.
|
|
210
|
+
* `String(value)` -> `"[object Object]"`, else `String`. A missing value with no `default=` given
|
|
211
|
+
* still falls through to the pre-existing "" here, unchanged from before `default=` existed.
|
|
177
212
|
*/
|
|
178
213
|
function renderBinding(binding, context) {
|
|
179
214
|
const value = resolveBinding(binding, context);
|
|
215
|
+
if ((value === null || value === undefined) && binding.default !== undefined)
|
|
216
|
+
return binding.default;
|
|
180
217
|
if (binding.format && binding.format !== "raw")
|
|
181
218
|
return (0, formatters_1.applyFormat)(binding.format, value, context, binding.round);
|
|
182
219
|
if (typeof value === "number") {
|
|
@@ -194,3 +231,98 @@ function renderBinding(binding, context) {
|
|
|
194
231
|
return JSON.stringify(value);
|
|
195
232
|
return String(value);
|
|
196
233
|
}
|
|
234
|
+
const COLOUR_HEX = { black: "#000000", white: "#FFFFFF", red: "#FF0000", yellow: "#FFFF00" };
|
|
235
|
+
/** The only `font-family` values `SvgRenderer` is guaranteed to render - see `expandGenericFontFamilies` in `./svgRenderer.ts`. */
|
|
236
|
+
const SAFE_FONT_FAMILIES = ["serif", "sans-serif", "monospace"];
|
|
237
|
+
/**
|
|
238
|
+
* Builds the `context.label` object a `source=label,path=...` binding addresses (e.g.
|
|
239
|
+
* `{source=label,path=width}` in free text, or a `<desc>source=label,path=width</desc>` in an SVG
|
|
240
|
+
* template - every `{...}`/`<desc>` placeholder is a real binding, so a `label` path always needs the
|
|
241
|
+
* explicit `source=label,path=` form to disambiguate it from a `signalk` self path). `colours`/`fonts`
|
|
242
|
+
* are left as arrays (each colour entry pre-annotated with its hex code, e.g. `"black (#000000)"`)
|
|
243
|
+
* rather than joined into a single string, so a caller can either use them bare (renders as JSON) or add
|
|
244
|
+
* `format=csv` (see `./formatters.ts`) for a plain comma-separated list. `position`, if given, is rounded
|
|
245
|
+
* to ~2 decimal places (~1.1km) - fine-grained enough to be meaningfully "for here", coarse enough that
|
|
246
|
+
* ordinary GPS jitter at anchor doesn't change it tick to tick, which matters because `considerRepaint`
|
|
247
|
+
* (`../repaintScheduler.ts`) folds this whole object into every device's dedup hash - full-precision
|
|
248
|
+
* jitter here would otherwise force a repaint (and, for a provider-rendered template, a fresh paid API
|
|
249
|
+
* call) far more often than the underlying position has actually meaningfully changed.
|
|
250
|
+
*/
|
|
251
|
+
function buildLabelContext(meta) {
|
|
252
|
+
const position = meta.position && {
|
|
253
|
+
latitude: Math.round(meta.position.latitude * 100) / 100,
|
|
254
|
+
longitude: Math.round(meta.position.longitude * 100) / 100,
|
|
255
|
+
};
|
|
256
|
+
return {
|
|
257
|
+
manufacturer: meta.manufacturer,
|
|
258
|
+
label: meta.label,
|
|
259
|
+
width: meta.width,
|
|
260
|
+
height: meta.height,
|
|
261
|
+
colours: meta.colours.map((colour) => `${colour} (${COLOUR_HEX[colour]})`),
|
|
262
|
+
fonts: SAFE_FONT_FAMILIES,
|
|
263
|
+
description: meta.description ?? "",
|
|
264
|
+
position: position ? (0, formatters_1.applyFormat)("position", position, {}, 2) : undefined,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
function placeholderContents(text) {
|
|
268
|
+
return [...new Set([...text.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1].trim()))];
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Every binding referenced across one or more free-text fragments - every `{...}` placeholder,
|
|
272
|
+
* deduplicated across all of `texts` combined, parsed exactly the way a template's `<desc>` binding is
|
|
273
|
+
* (`parseBinding` above), so a bare path (`{design.length}`, `source=signalk,context=self` shorthand),
|
|
274
|
+
* the full binding grammar (`{source=signalk,path=navigation.position,format=position}`), and a
|
|
275
|
+
* `source=label,path=...` binding all work uniformly. Pass the result to `assembleRawContext`
|
|
276
|
+
* (`../repaintScheduler.ts`) exactly as a template's own bindings are - it fetches the `signalk`/
|
|
277
|
+
* `resources`-sourced ones and silently ignores `label`/`einklabel`-sourced ones, which resolve directly
|
|
278
|
+
* against `context.label`/`context.meta` instead (built by the caller, not fetched).
|
|
279
|
+
*
|
|
280
|
+
* A placeholder that isn't valid binding grammar (e.g. a typo like `{source=taheight}`) is silently
|
|
281
|
+
* skipped here rather than thrown - the same per-field isolation `SvgRenderer` gives a bad `<desc>`
|
|
282
|
+
* binding, so one malformed placeholder doesn't take down the whole text. `substituteTextBindings` hits
|
|
283
|
+
* the identical parse error at substitution time and turns it into "???" for just that field.
|
|
284
|
+
*/
|
|
285
|
+
function findTextBindings(...texts) {
|
|
286
|
+
const seen = new Set();
|
|
287
|
+
const bindings = [];
|
|
288
|
+
for (const text of texts) {
|
|
289
|
+
for (const key of placeholderContents(text)) {
|
|
290
|
+
if (seen.has(key))
|
|
291
|
+
continue;
|
|
292
|
+
seen.add(key);
|
|
293
|
+
try {
|
|
294
|
+
bindings.push(parseBinding(key));
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
// see doc comment above - left for `substituteTextBindings` to turn into "???"
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return bindings;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Substitutes every `{...}` placeholder in `text` with its resolved binding value against `context`
|
|
305
|
+
* (built by `assembleRawContext` plus `context.label` from `buildLabelContext` - see
|
|
306
|
+
* `findTextBindings`). Mirrors `renderBinding`'s per-field isolation, but substitutes "???" for anything
|
|
307
|
+
* that resolves to no value at all (missing path, invalid binding grammar) rather than "" - prose with a
|
|
308
|
+
* silently-blank word reads as a fact ("for the sailor of a m vessel"), not as a gap the reader would
|
|
309
|
+
* notice. Two things override that "???": a binding that resolves successfully to a legitimately empty
|
|
310
|
+
* string (e.g. an unset `{source=label,path=description}`) is left as empty, since that's a real answer,
|
|
311
|
+
* not a miss; and a binding with an explicit `default=` uses that default instead, since the caller has
|
|
312
|
+
* already said what a missing value should read as.
|
|
313
|
+
*/
|
|
314
|
+
function substituteTextBindings(text, context) {
|
|
315
|
+
return text.replace(/\{([^{}]+)\}/g, (_match, raw) => {
|
|
316
|
+
const key = raw.trim();
|
|
317
|
+
try {
|
|
318
|
+
const binding = parseBinding(key);
|
|
319
|
+
const value = resolveBinding(binding, context);
|
|
320
|
+
if ((value === undefined || value === null) && binding.default === undefined)
|
|
321
|
+
return "???";
|
|
322
|
+
return renderBinding(binding, context);
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
return "???";
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
}
|
|
@@ -101,6 +101,22 @@ function formatPosition(value, round) {
|
|
|
101
101
|
const lonHemisphere = position.longitude >= 0 ? "E" : "W";
|
|
102
102
|
return `${lat}°${latHemisphere} ${lon}°${lonHemisphere}`;
|
|
103
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* A resolved value that's an array (e.g. `source=label,path=colours` - see `../render/llmPrompt.ts`'s
|
|
106
|
+
* `buildLabelContext`) joined into a plain comma-separated list, e.g. `["black","white"]` ->
|
|
107
|
+
* `"black, white"`. A non-array value falls through to the same null/object/scalar handling
|
|
108
|
+
* `renderBinding` uses for its own generic fallback, so `format=csv` is harmless on an ordinary
|
|
109
|
+
* scalar binding too.
|
|
110
|
+
*/
|
|
111
|
+
function formatCsv(value) {
|
|
112
|
+
if (Array.isArray(value))
|
|
113
|
+
return value.map((entry) => String(entry)).join(", ");
|
|
114
|
+
if (value === null || value === undefined)
|
|
115
|
+
return "";
|
|
116
|
+
if (typeof value === "object")
|
|
117
|
+
return JSON.stringify(value);
|
|
118
|
+
return String(value);
|
|
119
|
+
}
|
|
104
120
|
/** Applies a named `format=` formatter to a resolved binding value. */
|
|
105
121
|
function applyFormat(name, value, context, round) {
|
|
106
122
|
switch (name) {
|
|
@@ -114,6 +130,8 @@ function applyFormat(name, value, context, round) {
|
|
|
114
130
|
return formatUtcOffset(value);
|
|
115
131
|
case "position":
|
|
116
132
|
return formatPosition(value, round);
|
|
133
|
+
case "csv":
|
|
134
|
+
return formatCsv(value);
|
|
117
135
|
default:
|
|
118
136
|
throw new Error(`unknown format "${name}"`);
|
|
119
137
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Bitmap } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* How a rendered bitmap is fitted onto a device's actual panel size when the two don't match -
|
|
4
|
+
* `"fixed"` (the default, and the only behaviour before this existed) makes no change, leaving each
|
|
5
|
+
* vendor driver's own exact-size check in `encodeBitmap` to reject the mismatch; `"scale"` scales
|
|
6
|
+
* the source onto the target dimensions independently per axis (not preserving aspect ratio);
|
|
7
|
+
* `"crop"` keeps source pixels 1:1, placing them from the top-left and either truncating whatever
|
|
8
|
+
* doesn't fit (source bigger than target) or leaving the extra target space blank (source smaller).
|
|
9
|
+
*/
|
|
10
|
+
export type ReframeMode = "fixed" | "scale" | "crop";
|
|
11
|
+
/** Applies `mode` to fit `bitmap` onto a `targetWidth`x`targetHeight` panel - a no-op whenever the size already matches, regardless of mode. */
|
|
12
|
+
export declare function reframeBitmap(bitmap: Bitmap, targetWidth: number, targetHeight: number, mode: ReframeMode): Bitmap;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.reframeBitmap = reframeBitmap;
|
|
4
|
+
/** Nearest-neighbour, not bilinear: the vendor colour quantisers threshold each pixel independently into a small fixed palette (see `nearestColour`/`classifyColour`), so blended edge pixels from interpolation would just be re-thresholded arbitrarily rather than improving the result. */
|
|
5
|
+
function scaleBitmap(bitmap, targetWidth, targetHeight) {
|
|
6
|
+
const data = new Uint8Array(targetWidth * targetHeight * 4);
|
|
7
|
+
for (let y = 0; y < targetHeight; y++) {
|
|
8
|
+
const srcY = Math.min(bitmap.height - 1, Math.floor((y * bitmap.height) / targetHeight));
|
|
9
|
+
for (let x = 0; x < targetWidth; x++) {
|
|
10
|
+
const srcX = Math.min(bitmap.width - 1, Math.floor((x * bitmap.width) / targetWidth));
|
|
11
|
+
const srcOffset = (srcY * bitmap.width + srcX) * 4;
|
|
12
|
+
const dstOffset = (y * targetWidth + x) * 4;
|
|
13
|
+
data.set(bitmap.data.subarray(srcOffset, srcOffset + 4), dstOffset);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return { width: targetWidth, height: targetHeight, data };
|
|
17
|
+
}
|
|
18
|
+
/** Opaque white (matches an untouched label's own background) rather than transparent black, since the vendor colour quantisers treat a fully-transparent pixel the same as an opaque black one. */
|
|
19
|
+
function cropBitmap(bitmap, targetWidth, targetHeight) {
|
|
20
|
+
const data = new Uint8Array(targetWidth * targetHeight * 4).fill(255);
|
|
21
|
+
const copyWidth = Math.min(bitmap.width, targetWidth);
|
|
22
|
+
const copyHeight = Math.min(bitmap.height, targetHeight);
|
|
23
|
+
for (let y = 0; y < copyHeight; y++) {
|
|
24
|
+
const srcOffset = y * bitmap.width * 4;
|
|
25
|
+
const dstOffset = y * targetWidth * 4;
|
|
26
|
+
data.set(bitmap.data.subarray(srcOffset, srcOffset + copyWidth * 4), dstOffset);
|
|
27
|
+
}
|
|
28
|
+
return { width: targetWidth, height: targetHeight, data };
|
|
29
|
+
}
|
|
30
|
+
/** Applies `mode` to fit `bitmap` onto a `targetWidth`x`targetHeight` panel - a no-op whenever the size already matches, regardless of mode. */
|
|
31
|
+
function reframeBitmap(bitmap, targetWidth, targetHeight, mode) {
|
|
32
|
+
if (mode === "fixed" || (bitmap.width === targetWidth && bitmap.height === targetHeight)) {
|
|
33
|
+
return bitmap;
|
|
34
|
+
}
|
|
35
|
+
return mode === "scale" ? scaleBitmap(bitmap, targetWidth, targetHeight) : cropBitmap(bitmap, targetWidth, targetHeight);
|
|
36
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Colour } from "../devices/types";
|
|
2
|
+
import { Binding } from "./binding";
|
|
3
|
+
import { Bitmap, TemplateContext } from "./types";
|
|
4
|
+
/**
|
|
5
|
+
* Everything a `TemplateProvider` needs to render one device's repaint - the same
|
|
6
|
+
* signalk/resources/pathMeta/categories/meta context an ordinary SVG template's `<desc>` bindings
|
|
7
|
+
* resolve against (specifically, whatever `describeBindings` asked for), plus `context.label` (see
|
|
8
|
+
* `buildLabelContext` in `./binding.ts`), already built by `considerRepaint` (`../repaintScheduler.ts`)
|
|
9
|
+
* exactly as it would be for any other device. A `TemplateProvider` never gets direct `ServerAPI`
|
|
10
|
+
* access itself - like `SvgRenderer`, it only ever sees this already-assembled context.
|
|
11
|
+
*/
|
|
12
|
+
export interface TemplateRenderRequest {
|
|
13
|
+
/** The exact string picked from the "Template" dropdown, e.g. `"forecast (GenAI)"` - see `TemplateProvider.listTemplates`. */
|
|
14
|
+
templateName: string;
|
|
15
|
+
context: TemplateContext;
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
colours: Colour[];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Public extension point for a package that wants to offer an alternative way to produce a device's
|
|
22
|
+
* content, alongside hand-authored SVG templates - e.g. `@rhizomatics/signalk-einklabel-genai-plugin`
|
|
23
|
+
* generating content from an LLM prompt. Mirrors `VendorDriver`/`registerDriver`
|
|
24
|
+
* (`../devices/registry.ts`): an extension declares this package as a regular `dependency` (**not** a
|
|
25
|
+
* `peerDependency` - see `registerVendorDriver`'s own doc comment in `../index.ts` for why) plus
|
|
26
|
+
* `"signalk": { "requires": [...] }` in its own package.json, calls `esl.registerTemplateProvider(...)`
|
|
27
|
+
* from its own SignalK plugin's `start()`, and its entries show up in the same "Template" dropdown as
|
|
28
|
+
* ordinary `.svg` files, distinguished only by `suffix`.
|
|
29
|
+
*/
|
|
30
|
+
export interface TemplateProvider {
|
|
31
|
+
/** Appended to every one of this provider's entries in the "Template" dropdown, e.g. `"(GenAI)"`. */
|
|
32
|
+
suffix: string;
|
|
33
|
+
/**
|
|
34
|
+
* Every template name this provider currently offers, already including `suffix` (e.g.
|
|
35
|
+
* `["forecast (GenAI)"]`) - called fresh each time the config schema is built, so newly added/removed
|
|
36
|
+
* entries show up without a plugin restart.
|
|
37
|
+
*/
|
|
38
|
+
listTemplates(): string[];
|
|
39
|
+
/**
|
|
40
|
+
* The `signalk`/`resources` bindings one of `listTemplates()`'s entries needs (e.g. a prompt
|
|
41
|
+
* referencing `{design.length.overall}`) - `considerRepaint` fetches these the same way it would an
|
|
42
|
+
* SVG template's own `<desc>` bindings, *before* calling `render()`, so `request.context` arrives
|
|
43
|
+
* already populated. A `TemplateProvider` has no other way to get live SignalK data into its own
|
|
44
|
+
* render, since it never receives `ServerAPI` directly.
|
|
45
|
+
*/
|
|
46
|
+
describeBindings(templateName: string): Binding[];
|
|
47
|
+
/**
|
|
48
|
+
* Renders one of `listTemplates()`'s exact strings. Rejecting routes the repaint through the same
|
|
49
|
+
* bundled fallback-warning template any other render failure gets (see `RENDER_FALLBACK_TEMPLATE_NAME`,
|
|
50
|
+
* `../config.ts`, and `considerRepaint`) - never leaving the previous, possibly now-wrong, content on
|
|
51
|
+
* screen unmarked.
|
|
52
|
+
*/
|
|
53
|
+
render(request: TemplateRenderRequest): Promise<Bitmap>;
|
|
54
|
+
}
|
|
55
|
+
export declare function registerTemplateProvider(provider: TemplateProvider): void;
|
|
56
|
+
export declare function allTemplateProviders(): TemplateProvider[];
|
|
57
|
+
/** The registered provider (if any) whose `listTemplates()` currently offers `templateName`. */
|
|
58
|
+
export declare function findTemplateProvider(templateName: string): TemplateProvider | undefined;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerTemplateProvider = registerTemplateProvider;
|
|
4
|
+
exports.allTemplateProviders = allTemplateProviders;
|
|
5
|
+
exports.findTemplateProvider = findTemplateProvider;
|
|
6
|
+
const providers = [];
|
|
7
|
+
function registerTemplateProvider(provider) {
|
|
8
|
+
providers.push(provider);
|
|
9
|
+
}
|
|
10
|
+
function allTemplateProviders() {
|
|
11
|
+
return providers;
|
|
12
|
+
}
|
|
13
|
+
/** The registered provider (if any) whose `listTemplates()` currently offers `templateName`. */
|
|
14
|
+
function findTemplateProvider(templateName) {
|
|
15
|
+
return providers.find((provider) => provider.listTemplates().includes(templateName));
|
|
16
|
+
}
|
package/dist/repaintScheduler.js
CHANGED
|
@@ -13,6 +13,7 @@ const registry_1 = require("./devices/registry");
|
|
|
13
13
|
const svgRenderer_1 = require("./render/svgRenderer");
|
|
14
14
|
const binding_1 = require("./render/binding");
|
|
15
15
|
const formatters_1 = require("./render/formatters");
|
|
16
|
+
const templateProviders_1 = require("./render/templateProviders");
|
|
16
17
|
const unwrapSignalkTree_1 = require("./render/unwrapSignalkTree");
|
|
17
18
|
const unitCategories_1 = require("./unitCategories");
|
|
18
19
|
const pathMeta_1 = require("./pathMeta");
|
|
@@ -217,55 +218,132 @@ async function resolveTargets(app, config, device) {
|
|
|
217
218
|
}
|
|
218
219
|
return [target];
|
|
219
220
|
}
|
|
221
|
+
/**
|
|
222
|
+
* Renders and paints one device. `device.templateName` is resolved two ways: first against the
|
|
223
|
+
* `TemplateProvider` registry (`./render/templateProviders.ts`) - an extension like
|
|
224
|
+
* `signalk-einklabel-genai-plugin` contributing e.g. `"forecast (GenAI)"` - and only if no provider
|
|
225
|
+
* matches, as an ordinary `.svg` file/family (`resolveTemplatePath`), exactly as before this registry
|
|
226
|
+
* existed. Either way, the resolved content is rendered and pushed through the same paint step.
|
|
227
|
+
*
|
|
228
|
+
* `context.label` (`buildLabelContext`, `./render/binding.ts`) is now built for *every* device, not
|
|
229
|
+
* just provider-rendered ones - a plain SVG template can use `source=label,path=width` etc too. Its
|
|
230
|
+
* `position` is pre-rounded there, so folding it into every device's dedup hash below doesn't
|
|
231
|
+
* reintroduce GPS-jitter-driven repaints for a template that never even references it.
|
|
232
|
+
*
|
|
233
|
+
* On any failure - a broken hand-authored template, a missing template file, or a `TemplateProvider`'s
|
|
234
|
+
* `render()` rejecting (e.g. an LLM call failing) - pushes the bundled `RENDER_FALLBACK_TEMPLATE_NAME`
|
|
235
|
+
* warning instead, and deliberately does *not* persist a success hash: content that can't be generated
|
|
236
|
+
* must never leave the previous, possibly now-wrong, content on screen unmarked (e.g. a stale weather
|
|
237
|
+
* prompt showing "gentle breezes" through an actual storm), and not persisting a hash means the very
|
|
238
|
+
* next scheduled tick retries for real rather than being suppressed by dedup.
|
|
239
|
+
*/
|
|
220
240
|
async function considerRepaint(app, config, device, target, state, getApiUrl) {
|
|
221
241
|
const { address, metadata, driver } = target;
|
|
222
242
|
const label = `"${device.friendlyName}" [${address}]`;
|
|
243
|
+
if (!device.templateName) {
|
|
244
|
+
app.debug(`${label}: no template configured, skipping`);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const width = metadata.width;
|
|
248
|
+
const height = metadata.height - metadata.voffset;
|
|
249
|
+
const provider = (0, templateProviders_1.findTemplateProvider)(device.templateName);
|
|
223
250
|
const templatesDir = (0, config_1.resolveTemplatesDir)(config.templatesDir);
|
|
224
|
-
const templatePath = (0, config_1.resolveTemplatePath)(templatesDir, device.templateName, {
|
|
225
|
-
width: metadata.width,
|
|
226
|
-
height: metadata.height,
|
|
227
|
-
colours: metadata.colours,
|
|
228
|
-
});
|
|
229
|
-
const templateMtimeMs = (0, fs_1.statSync)(templatePath).mtimeMs;
|
|
230
|
-
const bindings = (0, binding_1.findBindings)((0, fs_1.readFileSync)(templatePath, "utf-8"));
|
|
231
|
-
const apiUrl = await getApiUrl().catch((err) => {
|
|
232
|
-
app.debug(`${label}: ${err.message}`);
|
|
233
|
-
return undefined;
|
|
234
|
-
});
|
|
235
|
-
const rawContext = await assembleRawContext(app, apiUrl, bindings);
|
|
236
|
-
const templateHash = hashTemplate(templateMtimeMs);
|
|
237
|
-
// Hashed from `rawContext`, not `renderContext` below - `meta.repainted` (and the rest of `meta`)
|
|
238
|
-
// is only added after this point, deliberately, so a template merely *displaying* the repaint
|
|
239
|
-
// timestamp doesn't perpetually invalidate its own dedup and force a repaint every check. A full
|
|
240
|
-
// paint flashes the whole panel several times, and there's no confirmed partial-refresh path on
|
|
241
|
-
// these devices - so a bound value simply being unchanged is worth trusting over any staleness in
|
|
242
|
-
// a displayed clock.
|
|
243
|
-
const dataHash = hashData(rawContext);
|
|
244
251
|
const stateKey = stateKeyFor(device.friendlyName, address);
|
|
245
252
|
const previous = state[stateKey];
|
|
246
|
-
const templateChanged = previous?.templateHash !== templateHash;
|
|
247
|
-
const dataChanged = previous?.dataHash !== dataHash;
|
|
248
|
-
// The devices are battery-constrained, so a repaint is skipped whenever neither the template nor
|
|
249
|
-
// the bound data has changed since the last successful paint - regardless of what triggered this
|
|
250
|
-
// check (interval or subscription) or whether it's a regular scheduled tick vs. the deferred
|
|
251
|
-
// startup catch-up (see `startupCheckTimer`). `forceRepaint` is the explicit, one-shot override
|
|
252
|
-
// for "repaint anyway".
|
|
253
|
-
if (!templateChanged && !dataChanged && !device.forceRepaint) {
|
|
254
|
-
app.debug(`${label}: data unchanged, skipping repaint`);
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
const renderContext = {
|
|
258
|
-
...rawContext,
|
|
259
|
-
meta: {
|
|
260
|
-
repainted: new Date().toISOString(),
|
|
261
|
-
local_zone: (0, formatters_1.resolveLocalZoneAbbreviation)(rawContext),
|
|
262
|
-
plugin_version: pluginVersion_1.PLUGIN_VERSION,
|
|
263
|
-
},
|
|
264
|
-
};
|
|
265
253
|
const renderer = new svgRenderer_1.SvgRenderer();
|
|
266
|
-
|
|
267
|
-
|
|
254
|
+
let bitmap;
|
|
255
|
+
let succeeded = false;
|
|
256
|
+
let templateHash = "";
|
|
257
|
+
let dataHash = "";
|
|
268
258
|
let paintDurationMs = 0;
|
|
259
|
+
let repaintReason = "render failed";
|
|
260
|
+
try {
|
|
261
|
+
const apiUrl = await getApiUrl().catch((err) => {
|
|
262
|
+
app.debug(`${label}: ${err.message}`);
|
|
263
|
+
return undefined;
|
|
264
|
+
});
|
|
265
|
+
let bindings = [];
|
|
266
|
+
let templatePath = "";
|
|
267
|
+
if (provider) {
|
|
268
|
+
templateHash = (0, crypto_1.createHash)("sha1").update(device.templateName).digest("hex");
|
|
269
|
+
bindings = provider.describeBindings(device.templateName);
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
templatePath = (0, config_1.resolveTemplatePath)(templatesDir, device.templateName, {
|
|
273
|
+
width: metadata.width,
|
|
274
|
+
height: metadata.height,
|
|
275
|
+
colours: metadata.colours,
|
|
276
|
+
});
|
|
277
|
+
templateHash = hashTemplate((0, fs_1.statSync)(templatePath).mtimeMs);
|
|
278
|
+
bindings = (0, binding_1.findBindings)((0, fs_1.readFileSync)(templatePath, "utf-8"));
|
|
279
|
+
}
|
|
280
|
+
const rawContext = await assembleRawContext(app, apiUrl, bindings);
|
|
281
|
+
const rawPosition = (0, unwrapSignalkTree_1.unwrapSignalkTree)(app.getSelfPath("navigation.position"));
|
|
282
|
+
const position = typeof rawPosition?.latitude === "number" && typeof rawPosition?.longitude === "number"
|
|
283
|
+
? { latitude: rawPosition.latitude, longitude: rawPosition.longitude }
|
|
284
|
+
: undefined;
|
|
285
|
+
const labelContext = (0, binding_1.buildLabelContext)({
|
|
286
|
+
// Falls back to the driver's own internal vendor key (e.g. "zhsunyco") when a device model has no
|
|
287
|
+
// explicit `manufacturer` of its own - see `DeviceMetadata.manufacturer`'s doc comment.
|
|
288
|
+
manufacturer: metadata.manufacturer ?? target.vendor,
|
|
289
|
+
label: metadata.label,
|
|
290
|
+
width,
|
|
291
|
+
height,
|
|
292
|
+
colours: metadata.colours,
|
|
293
|
+
description: device.description,
|
|
294
|
+
position,
|
|
295
|
+
});
|
|
296
|
+
// Hashed before `meta` is merged in below, deliberately, so a template merely *displaying* the
|
|
297
|
+
// repaint timestamp doesn't perpetually invalidate its own dedup and force a repaint every check. A
|
|
298
|
+
// full paint flashes the whole panel several times, and there's no confirmed partial-refresh path
|
|
299
|
+
// on these devices - so a bound value simply being unchanged is worth trusting over any staleness
|
|
300
|
+
// in a displayed clock.
|
|
301
|
+
dataHash = hashData({ ...rawContext, label: labelContext });
|
|
302
|
+
const templateChanged = previous?.templateHash !== templateHash;
|
|
303
|
+
const dataChanged = previous?.dataHash !== dataHash;
|
|
304
|
+
// A provider-rendered template always attempts a fresh render when triggered, bypassing dedup
|
|
305
|
+
// entirely - core can't know whether the provider's output would differ, and "fresh content each
|
|
306
|
+
// scheduled tick" (e.g. a regenerated forecast) is the whole point of one - which is exactly why a
|
|
307
|
+
// provider-backed device should use `repaintTrigger: "interval"`, not `subscription`, for
|
|
308
|
+
// cost/battery reasons (each repaint may be a paid API call on the provider's side).
|
|
309
|
+
if (!provider && !templateChanged && !dataChanged && !device.forceRepaint) {
|
|
310
|
+
app.debug(`${label}: data unchanged, skipping repaint`);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const renderContext = {
|
|
314
|
+
...rawContext,
|
|
315
|
+
label: labelContext,
|
|
316
|
+
meta: {
|
|
317
|
+
repainted: new Date().toISOString(),
|
|
318
|
+
local_zone: (0, formatters_1.resolveLocalZoneAbbreviation)(rawContext),
|
|
319
|
+
plugin_version: pluginVersion_1.PLUGIN_VERSION,
|
|
320
|
+
description: device.description ?? "",
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
bitmap = provider
|
|
324
|
+
? await provider.render({ templateName: device.templateName, context: renderContext, width, height, colours: metadata.colours })
|
|
325
|
+
: await renderer.render(templatePath, renderContext, width, height, templatesDir, config_1.BUNDLED_TEMPLATES_DIR);
|
|
326
|
+
succeeded = true;
|
|
327
|
+
repaintReason = device.forceRepaint
|
|
328
|
+
? "forced"
|
|
329
|
+
: provider
|
|
330
|
+
? "provider-rendered"
|
|
331
|
+
: templateChanged && dataChanged
|
|
332
|
+
? "template and data changed"
|
|
333
|
+
: templateChanged
|
|
334
|
+
? "template changed"
|
|
335
|
+
: "data changed";
|
|
336
|
+
}
|
|
337
|
+
catch (err) {
|
|
338
|
+
app.debug(`${label}: render failed (${err.message}) - showing fallback warning`);
|
|
339
|
+
const fallbackPath = (0, config_1.resolveTemplatePath)(templatesDir, config_1.RENDER_FALLBACK_TEMPLATE_NAME, {
|
|
340
|
+
width: metadata.width,
|
|
341
|
+
height: metadata.height,
|
|
342
|
+
colours: metadata.colours,
|
|
343
|
+
});
|
|
344
|
+
bitmap = await renderer.render(fallbackPath, { meta: { repainted: new Date().toISOString(), plugin_version: pluginVersion_1.PLUGIN_VERSION, description: device.description ?? "" } }, width, height, templatesDir, config_1.BUNDLED_TEMPLATES_DIR);
|
|
345
|
+
}
|
|
346
|
+
const connectTimeoutMs = config.paintConnectTimeoutSeconds * 1000;
|
|
269
347
|
await (0, bleDiscovery_1.withRetries)(config.paintRetries, async (attempt) => {
|
|
270
348
|
if (attempt > 1) {
|
|
271
349
|
app.debug(`${label}: attempting paint ${attempt}/${config.paintRetries}`);
|
|
@@ -275,16 +353,13 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
|
|
|
275
353
|
paintDurationMs = Date.now() - startedAt;
|
|
276
354
|
});
|
|
277
355
|
(0, discoveredDevicesStore_1.touchDiscoveredDevice)(app, { address, vendor: target.vendor, pid: target.pid, hwVersion: target.hwVersion, metadata });
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
? "template changed"
|
|
286
|
-
: "data changed";
|
|
287
|
-
app.debug(`${label}: repainted (${reason}, paint took ${paintDurationMs}ms)`);
|
|
356
|
+
// Only a successful render counts as "repainted" for dedup/catch-up purposes - a failure must not be
|
|
357
|
+
// recorded here (see this function's doc comment on why).
|
|
358
|
+
if (succeeded) {
|
|
359
|
+
state[stateKey] = { templateHash, dataHash, repaintedAt: Date.now() };
|
|
360
|
+
saveState(app, state);
|
|
361
|
+
}
|
|
362
|
+
app.debug(succeeded ? `${label}: repainted (${repaintReason}, paint took ${paintDurationMs}ms)` : `${label}: repainted fallback warning`);
|
|
288
363
|
}
|
|
289
364
|
function startRepaintScheduler(app, config) {
|
|
290
365
|
const state = loadState(app);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rhizomatics/signalk-einklabel-plugin",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Display SignalK data on eInk Electronic Shelf Labels",
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Display SignalK data on eInk Electronic Shelf Labels, includes working examples for tide clock and watch schedule.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ble",
|
|
7
7
|
"display",
|
|
@@ -9,13 +9,15 @@
|
|
|
9
9
|
"esl",
|
|
10
10
|
"instrument",
|
|
11
11
|
"signalk",
|
|
12
|
+
"zhsunyco",
|
|
13
|
+
"gicisky",
|
|
12
14
|
"signalk-category-hardware",
|
|
13
15
|
"signalk-category-instruments",
|
|
14
16
|
"signalk-node-server-plugin",
|
|
15
17
|
"tides",
|
|
16
18
|
"watch-schedule"
|
|
17
19
|
],
|
|
18
|
-
"homepage": "https://
|
|
20
|
+
"homepage": "https://signalk-einklabel.rhizomatics.org.uk/",
|
|
19
21
|
"bugs": {
|
|
20
22
|
"url": "https://github.com/rhizomatics/signalk-einklabel-plugin/issues"
|
|
21
23
|
},
|
|
@@ -49,26 +51,26 @@
|
|
|
49
51
|
"lint": "oxlint"
|
|
50
52
|
},
|
|
51
53
|
"dependencies": {
|
|
52
|
-
"@fontsource/roboto": "^5.
|
|
53
|
-
"@fontsource/roboto-mono": "^5.
|
|
54
|
-
"@fontsource/roboto-serif": "^5.
|
|
54
|
+
"@fontsource/roboto": "^5.3.0",
|
|
55
|
+
"@fontsource/roboto-mono": "^5.3.0",
|
|
56
|
+
"@fontsource/roboto-serif": "^5.3.0",
|
|
55
57
|
"@naugehyde/node-ble": "^1.13.5",
|
|
56
58
|
"@resvg/resvg-wasm": "^2.6.2",
|
|
57
59
|
"@xmldom/xmldom": "^0.9.10",
|
|
58
|
-
"commander": "^
|
|
60
|
+
"commander": "^15.0.0",
|
|
59
61
|
"form-data": "^4.0.6",
|
|
60
62
|
"luxon": "^3.7.2",
|
|
61
63
|
"mathjs": "^15.2.0",
|
|
62
64
|
"pngjs": "^7.0.0"
|
|
63
65
|
},
|
|
64
66
|
"devDependencies": {
|
|
65
|
-
"@signalk/server-api": "^2.
|
|
67
|
+
"@signalk/server-api": "^2.31.0",
|
|
66
68
|
"@types/luxon": "^3.7.1",
|
|
67
69
|
"@types/node": "^20.14.0",
|
|
68
70
|
"@types/pngjs": "^6.0.5",
|
|
69
|
-
"oxfmt": "^0.
|
|
70
|
-
"oxlint": "^1.
|
|
71
|
-
"oxlint-tsgolint": "^0.
|
|
71
|
+
"oxfmt": "^0.66.0",
|
|
72
|
+
"oxlint": "^1.81.0",
|
|
73
|
+
"oxlint-tsgolint": "^7.0.2001",
|
|
72
74
|
"ts-node": "^10.9.2",
|
|
73
75
|
"typescript": "^5.5.0"
|
|
74
76
|
},
|
|
@@ -86,7 +88,8 @@
|
|
|
86
88
|
],
|
|
87
89
|
"recommends": [
|
|
88
90
|
"signalk-watch-schedule",
|
|
89
|
-
"signalk-tides"
|
|
91
|
+
"signalk-tides",
|
|
92
|
+
"@rhizomatics/signalk-einklabel-genai-plugin"
|
|
90
93
|
]
|
|
91
94
|
}
|
|
92
95
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
2
|
+
<svg width="250" height="128" viewBox="0 0 250 128" xmlns="http://www.w3.org/2000/svg">
|
|
3
|
+
<rect x="0" y="0" width="250" height="128" fill="white" />
|
|
4
|
+
<polygon points="45,18 12,96 78,96" fill="red" stroke="black" stroke-width="3" stroke-linejoin="round" />
|
|
5
|
+
<rect x="41" y="46" width="8" height="26" fill="black" />
|
|
6
|
+
<circle cx="45" cy="84" r="5" fill="black" />
|
|
7
|
+
<text x="92" y="46" font-size="22" font-family="sans-serif" font-weight="bold" fill="black">CONTENT</text>
|
|
8
|
+
<text x="92" y="72" font-size="22" font-family="sans-serif" font-weight="bold" fill="black">UNAVAILABLE</text>
|
|
9
|
+
<text x="92" y="92" font-size="12" font-family="sans-serif" fill="black">Check manually</text>
|
|
10
|
+
<text id="last_attempt" x="92" y="112" font-size="10" font-family="monospace" fill="black"
|
|
11
|
+
><desc>source=einklabel,path=repainted,format=local_datetime_short</desc>last attempt: --</text>
|
|
12
|
+
</svg>
|