@rhizomatics/signalk-einklabel-plugin 0.10.0 → 1.0.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.
@@ -0,0 +1,86 @@
1
+ import { PluginConfig } from "../config";
2
+ import { Colour } from "../devices/types";
3
+ import { Binding } from "./binding";
4
+ import { TemplateContext } from "./types";
5
+ /** Facts about one physical label a prompt's `source=label,path=...` placeholders can reference - see `buildLabelContext`. */
6
+ export interface LabelMeta {
7
+ manufacturer: string;
8
+ /** The physical panel's own size label, e.g. `'3.7"'` - `DeviceMetadata.label` verbatim, see `../devices/types.ts`. */
9
+ label: string;
10
+ width: number;
11
+ height: number;
12
+ colours: Colour[];
13
+ description?: string;
14
+ position?: {
15
+ latitude: number;
16
+ longitude: number;
17
+ };
18
+ }
19
+ /**
20
+ * Builds the `context.label` object a prompt/target-guidance fragment addresses via
21
+ * `source=label,path=...` bindings (e.g. `{source=label,path=width}`, or the bare-path shorthand
22
+ * `{width}` is *not* supported here deliberately - see `./binding.ts`'s `parseBinding`, every `{...}`
23
+ * placeholder is a real binding, so a `label` path always needs the explicit `source=label,path=`
24
+ * form to disambiguate it from a `signalk` self path). `colours`/`fonts` are left as arrays (each
25
+ * colour entry pre-annotated with its hex code, e.g. `"black (#000000)"`) rather than joined into a
26
+ * single string, so a prompt author can either use them bare (renders as JSON, e.g. for a model that
27
+ * parses structured hints) or add `format=csv` (see `./formatters.ts`) for a plain comma-separated list.
28
+ */
29
+ export declare function buildLabelContext(meta: LabelMeta): Record<string, unknown>;
30
+ /**
31
+ * Every binding referenced across one or more prompt fragments - every `{...}` placeholder, deduplicated
32
+ * across all of `texts` combined, parsed exactly the way a template's `<desc>` binding is
33
+ * (`parseBinding`, see `./binding.ts`), so a bare path (`{design.length}`, `source=signalk,context=self`
34
+ * shorthand), the full binding grammar (`{source=signalk,path=navigation.position,format=position}`),
35
+ * and a `source=label,path=...` binding all work uniformly. Pass the result to `assembleRawContext`
36
+ * (repaintScheduler.ts) exactly as a template's own bindings are - it fetches the `signalk`/
37
+ * `resources`-sourced ones and silently ignores `label`/`einklabel`-sourced ones, which resolve directly
38
+ * against `context.label`/`context.meta` instead (built by the caller, not fetched).
39
+ *
40
+ * A placeholder that isn't valid binding grammar (e.g. a typo like `{source=taheight}`) is silently
41
+ * skipped here rather than thrown - the same per-field isolation `SvgRenderer` gives a bad `<desc>`
42
+ * binding, so one malformed placeholder doesn't take down the whole prompt. `substitutePlaceholders`
43
+ * hits the identical parse error at substitution time and turns it into "???" for just that field.
44
+ */
45
+ export declare function findPromptBindings(...texts: string[]): Binding[];
46
+ /**
47
+ * Substitutes every `{...}` placeholder in `text` with its resolved binding value against `context`
48
+ * (built by `assembleRawContext` plus `context.label` from `buildLabelContext` - see
49
+ * `findPromptBindings`). Mirrors `renderBinding`'s per-field isolation, but substitutes "???" for
50
+ * anything that resolves to no value at all (missing path, invalid binding grammar) rather than "" -
51
+ * prose with a silently-blank word reads as a fact ("for the sailor of a m vessel"), not as a gap the
52
+ * reader would notice. Two things override that "???": a binding that resolves successfully to a
53
+ * legitimately empty string (e.g. an unset `{source=label,path=description}`) is left as empty, since
54
+ * that's a real answer, not a miss; and a binding with an explicit `default=` (see `./binding.ts`) uses
55
+ * that default instead, since the prompt author has already said what a missing value should read as.
56
+ */
57
+ export declare function substitutePlaceholders(text: string, context: TemplateContext): string;
58
+ /**
59
+ * Strips everything outside the first `<svg`...last `</svg>` span - a chat model asked for "only raw
60
+ * SVG markup" still often wraps it in a markdown code fence or adds a sentence of commentary either
61
+ * side, despite the target-guidance fragment telling it not to.
62
+ */
63
+ export declare function extractSvg(raw: string): string;
64
+ export type LlmSettings = Pick<PluginConfig, "llmProvider" | "llmApiKey" | "llmModel" | "llmBaseUrl"> & {
65
+ llmTimeoutSeconds?: number;
66
+ };
67
+ /**
68
+ * `ai` and every `@ai-sdk/*` provider package are `optionalDependencies` (see package.json), not plain
69
+ * `dependencies` - most installs (every device using `renderMode: "svg-template"` only) never touch an
70
+ * LLM at all, so they shouldn't have to pull in this whole gateway stack, and an install that fails to
71
+ * fetch one of these (network hiccup, `npm install --omit=optional`, an unsupported platform) must not
72
+ * break BLE painting/templates, which have nothing to do with it. That means every reference to one of
73
+ * these packages has to be a `require()` reached only when a `renderMode: "llm-prompt"` device actually
74
+ * calls `callLlm` - a top-level `import` would be resolved eagerly the moment this module loads (which
75
+ * is every plugin start, via `repaintScheduler.ts`), throwing before any config is even read. Types are
76
+ * still fully checked via `typeof import(...)` below, which - unlike a value `import` - is erased
77
+ * entirely at compile time and leaves no runtime trace for `tsc` to eagerly require.
78
+ */
79
+ export declare function loadOptional<M>(moduleName: string): M;
80
+ /**
81
+ * Calls the configured LLM gateway with `prompt`, returning its raw text response - not yet extracted/
82
+ * validated as SVG, see `extractSvg`. This is the first network call in the codebase that needs its own
83
+ * timeout (`fetchJson` in `../httpJson.ts` has none - every existing call is to the local SignalK
84
+ * server's own REST API).
85
+ */
86
+ export declare function callLlm(settings: LlmSettings, prompt: string): Promise<string>;
@@ -0,0 +1,199 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildLabelContext = buildLabelContext;
4
+ exports.findPromptBindings = findPromptBindings;
5
+ exports.substitutePlaceholders = substitutePlaceholders;
6
+ exports.extractSvg = extractSvg;
7
+ exports.loadOptional = loadOptional;
8
+ exports.callLlm = callLlm;
9
+ const binding_1 = require("./binding");
10
+ const formatters_1 = require("./formatters");
11
+ const COLOUR_HEX = { black: "#000000", white: "#FFFFFF", red: "#FF0000", yellow: "#FFFF00" };
12
+ /** The only `font-family` values `SvgRenderer` is guaranteed to render - see `expandGenericFontFamilies` in `./svgRenderer.ts`. */
13
+ const SAFE_FONT_FAMILIES = ["serif", "sans-serif", "monospace"];
14
+ /**
15
+ * Builds the `context.label` object a prompt/target-guidance fragment addresses via
16
+ * `source=label,path=...` bindings (e.g. `{source=label,path=width}`, or the bare-path shorthand
17
+ * `{width}` is *not* supported here deliberately - see `./binding.ts`'s `parseBinding`, every `{...}`
18
+ * placeholder is a real binding, so a `label` path always needs the explicit `source=label,path=`
19
+ * form to disambiguate it from a `signalk` self path). `colours`/`fonts` are left as arrays (each
20
+ * colour entry pre-annotated with its hex code, e.g. `"black (#000000)"`) rather than joined into a
21
+ * single string, so a prompt author can either use them bare (renders as JSON, e.g. for a model that
22
+ * parses structured hints) or add `format=csv` (see `./formatters.ts`) for a plain comma-separated list.
23
+ */
24
+ function buildLabelContext(meta) {
25
+ return {
26
+ manufacturer: meta.manufacturer,
27
+ label: meta.label,
28
+ width: meta.width,
29
+ height: meta.height,
30
+ colours: meta.colours.map((colour) => `${colour} (${COLOUR_HEX[colour]})`),
31
+ fonts: SAFE_FONT_FAMILIES,
32
+ description: meta.description ?? "",
33
+ position: meta.position ? (0, formatters_1.applyFormat)("position", meta.position, {}, 3) : undefined,
34
+ };
35
+ }
36
+ /**
37
+ * Every binding referenced across one or more prompt fragments - every `{...}` placeholder, deduplicated
38
+ * across all of `texts` combined, parsed exactly the way a template's `<desc>` binding is
39
+ * (`parseBinding`, see `./binding.ts`), so a bare path (`{design.length}`, `source=signalk,context=self`
40
+ * shorthand), the full binding grammar (`{source=signalk,path=navigation.position,format=position}`),
41
+ * and a `source=label,path=...` binding all work uniformly. Pass the result to `assembleRawContext`
42
+ * (repaintScheduler.ts) exactly as a template's own bindings are - it fetches the `signalk`/
43
+ * `resources`-sourced ones and silently ignores `label`/`einklabel`-sourced ones, which resolve directly
44
+ * against `context.label`/`context.meta` instead (built by the caller, not fetched).
45
+ *
46
+ * A placeholder that isn't valid binding grammar (e.g. a typo like `{source=taheight}`) is silently
47
+ * skipped here rather than thrown - the same per-field isolation `SvgRenderer` gives a bad `<desc>`
48
+ * binding, so one malformed placeholder doesn't take down the whole prompt. `substitutePlaceholders`
49
+ * hits the identical parse error at substitution time and turns it into "???" for just that field.
50
+ */
51
+ function findPromptBindings(...texts) {
52
+ const seen = new Set();
53
+ const bindings = [];
54
+ for (const text of texts) {
55
+ for (const match of text.matchAll(/\{([^{}]+)\}/g)) {
56
+ const key = match[1].trim();
57
+ if (seen.has(key))
58
+ continue;
59
+ seen.add(key);
60
+ try {
61
+ bindings.push((0, binding_1.parseBinding)(key));
62
+ }
63
+ catch {
64
+ // see doc comment above - left for `substitutePlaceholders` to turn into "???"
65
+ }
66
+ }
67
+ }
68
+ return bindings;
69
+ }
70
+ /**
71
+ * Substitutes every `{...}` placeholder in `text` with its resolved binding value against `context`
72
+ * (built by `assembleRawContext` plus `context.label` from `buildLabelContext` - see
73
+ * `findPromptBindings`). Mirrors `renderBinding`'s per-field isolation, but substitutes "???" for
74
+ * anything that resolves to no value at all (missing path, invalid binding grammar) rather than "" -
75
+ * prose with a silently-blank word reads as a fact ("for the sailor of a m vessel"), not as a gap the
76
+ * reader would notice. Two things override that "???": a binding that resolves successfully to a
77
+ * legitimately empty string (e.g. an unset `{source=label,path=description}`) is left as empty, since
78
+ * that's a real answer, not a miss; and a binding with an explicit `default=` (see `./binding.ts`) uses
79
+ * that default instead, since the prompt author has already said what a missing value should read as.
80
+ */
81
+ function substitutePlaceholders(text, context) {
82
+ return text.replace(/\{([^{}]+)\}/g, (_match, raw) => {
83
+ const key = raw.trim();
84
+ try {
85
+ const binding = (0, binding_1.parseBinding)(key);
86
+ const value = (0, binding_1.resolveBinding)(binding, context);
87
+ if ((value === undefined || value === null) && binding.default === undefined)
88
+ return "???";
89
+ return (0, binding_1.renderBinding)(binding, context);
90
+ }
91
+ catch {
92
+ return "???";
93
+ }
94
+ });
95
+ }
96
+ /**
97
+ * Strips everything outside the first `<svg`...last `</svg>` span - a chat model asked for "only raw
98
+ * SVG markup" still often wraps it in a markdown code fence or adds a sentence of commentary either
99
+ * side, despite the target-guidance fragment telling it not to.
100
+ */
101
+ function extractSvg(raw) {
102
+ const start = raw.indexOf("<svg");
103
+ const end = raw.lastIndexOf("</svg>");
104
+ if (start === -1 || end === -1 || end < start) {
105
+ throw new Error('LLM response did not contain a "<svg>...</svg>" document');
106
+ }
107
+ return raw.slice(start, end + "</svg>".length);
108
+ }
109
+ /**
110
+ * `ai` and every `@ai-sdk/*` provider package are `optionalDependencies` (see package.json), not plain
111
+ * `dependencies` - most installs (every device using `renderMode: "svg-template"` only) never touch an
112
+ * LLM at all, so they shouldn't have to pull in this whole gateway stack, and an install that fails to
113
+ * fetch one of these (network hiccup, `npm install --omit=optional`, an unsupported platform) must not
114
+ * break BLE painting/templates, which have nothing to do with it. That means every reference to one of
115
+ * these packages has to be a `require()` reached only when a `renderMode: "llm-prompt"` device actually
116
+ * calls `callLlm` - a top-level `import` would be resolved eagerly the moment this module loads (which
117
+ * is every plugin start, via `repaintScheduler.ts`), throwing before any config is even read. Types are
118
+ * still fully checked via `typeof import(...)` below, which - unlike a value `import` - is erased
119
+ * entirely at compile time and leaves no runtime trace for `tsc` to eagerly require.
120
+ */
121
+ function loadOptional(moduleName) {
122
+ try {
123
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
124
+ return require(moduleName);
125
+ }
126
+ catch (err) {
127
+ throw new Error(`LLM provider support needs the optional dependency "${moduleName}", which isn't installed - run "npm install ${moduleName}" (${err.message})`);
128
+ }
129
+ }
130
+ /** Ollama's own default local listen address - see the `"ollama"` case in `resolveModel` below. */
131
+ const DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434/v1";
132
+ /**
133
+ * Resolves `settings` to a Vercel AI SDK model handle. `"ollama"` and `"local"` both go through the
134
+ * generic `@ai-sdk/openai-compatible` provider (Ollama/LM Studio/vLLM all expose an OpenAI-compatible
135
+ * endpoint, and none of them has - or needs - its own dedicated `@ai-sdk/*` package): `"ollama"`
136
+ * defaults `llmBaseUrl` to Ollama's own standard local address so it works with no further config
137
+ * (override it only if Ollama is running elsewhere, e.g. on the SignalK server's own host reached over
138
+ * the network); `"local"` is for anything else OpenAI-compatible, where there's no sensible universal
139
+ * default, so `llmBaseUrl` is required.
140
+ */
141
+ function resolveModel(settings) {
142
+ const model = settings.llmModel;
143
+ if (!model) {
144
+ throw new Error("no LLM model configured (PluginConfig.llmModel)");
145
+ }
146
+ switch (settings.llmProvider ?? "openai") {
147
+ case "anthropic": {
148
+ const { createAnthropic } = loadOptional("@ai-sdk/anthropic");
149
+ return createAnthropic({ apiKey: settings.llmApiKey })(model);
150
+ }
151
+ case "google": {
152
+ const { createGoogleGenerativeAI } = loadOptional("@ai-sdk/google");
153
+ return createGoogleGenerativeAI({ apiKey: settings.llmApiKey })(model);
154
+ }
155
+ case "xai": {
156
+ const { createXai } = loadOptional("@ai-sdk/xai");
157
+ return createXai({ apiKey: settings.llmApiKey })(model);
158
+ }
159
+ case "ollama": {
160
+ const { createOpenAICompatible } = loadOptional("@ai-sdk/openai-compatible");
161
+ return createOpenAICompatible({
162
+ name: "ollama",
163
+ baseURL: settings.llmBaseUrl || DEFAULT_OLLAMA_BASE_URL,
164
+ apiKey: settings.llmApiKey,
165
+ })(model);
166
+ }
167
+ case "local": {
168
+ if (!settings.llmBaseUrl) {
169
+ throw new Error('llmBaseUrl is required when llmProvider is "local"');
170
+ }
171
+ const { createOpenAICompatible } = loadOptional("@ai-sdk/openai-compatible");
172
+ return createOpenAICompatible({ name: "local", baseURL: settings.llmBaseUrl, apiKey: settings.llmApiKey })(model);
173
+ }
174
+ case "openai":
175
+ default: {
176
+ const { createOpenAI } = loadOptional("@ai-sdk/openai");
177
+ return createOpenAI({ apiKey: settings.llmApiKey })(model);
178
+ }
179
+ }
180
+ }
181
+ /**
182
+ * Calls the configured LLM gateway with `prompt`, returning its raw text response - not yet extracted/
183
+ * validated as SVG, see `extractSvg`. This is the first network call in the codebase that needs its own
184
+ * timeout (`fetchJson` in `../httpJson.ts` has none - every existing call is to the local SignalK
185
+ * server's own REST API).
186
+ */
187
+ async function callLlm(settings, prompt) {
188
+ const model = resolveModel(settings);
189
+ const { generateText } = loadOptional("ai");
190
+ const controller = new AbortController();
191
+ const timer = setTimeout(() => controller.abort(), (settings.llmTimeoutSeconds ?? 30) * 1000);
192
+ try {
193
+ const { text } = await generateText({ model, prompt, abortSignal: controller.signal });
194
+ return text;
195
+ }
196
+ finally {
197
+ clearTimeout(timer);
198
+ }
199
+ }
@@ -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
+ }
@@ -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
- const bitmap = await renderer.render(templatePath, renderContext, metadata.width, metadata.height - metadata.voffset, templatesDir, config_1.BUNDLED_TEMPLATES_DIR);
267
- const connectTimeoutMs = config.paintConnectTimeoutSeconds * 1000;
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
- state[stateKey] = { templateHash, dataHash, repaintedAt: Date.now() };
279
- saveState(app, state);
280
- const reason = device.forceRepaint
281
- ? "forced"
282
- : templateChanged && dataChanged
283
- ? "template and data changed"
284
- : templateChanged
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,21 +1,21 @@
1
1
  {
2
2
  "name": "@rhizomatics/signalk-einklabel-plugin",
3
- "version": "0.10.0",
4
- "description": "Display SignalK data on eInk Electronic Shelf Labels",
3
+ "version": "1.0.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",
8
8
  "eink",
9
9
  "esl",
10
10
  "instrument",
11
- "watch-schedule",
12
11
  "signalk",
13
12
  "signalk-category-hardware",
14
13
  "signalk-category-instruments",
15
14
  "signalk-node-server-plugin",
16
- "tides"
15
+ "tides",
16
+ "watch-schedule"
17
17
  ],
18
- "homepage": "https://github.com/rhizomatics/signalk-einklabel-plugin#readme",
18
+ "homepage": "https://rhizomatics.github.io/signalk-einklabel-plugin/",
19
19
  "bugs": {
20
20
  "url": "https://github.com/rhizomatics/signalk-einklabel-plugin/issues"
21
21
  },
@@ -66,9 +66,9 @@
66
66
  "@types/luxon": "^3.7.1",
67
67
  "@types/node": "^20.14.0",
68
68
  "@types/pngjs": "^6.0.5",
69
- "oxfmt": "^0.56.0",
70
- "oxlint": "^1.71.0",
71
- "oxlint-tsgolint": "^0.23.0",
69
+ "oxfmt": "^0.59.0",
70
+ "oxlint": "^1.74.0",
71
+ "oxlint-tsgolint": "^7.0.0",
72
72
  "ts-node": "^10.9.2",
73
73
  "typescript": "^5.5.0"
74
74
  },
@@ -83,6 +83,11 @@
83
83
  "docs/assets/screenshots/example_watch_schedule.png",
84
84
  "docs/assets/screenshots/plugin_config.png",
85
85
  "docs/assets/screenshots/label_config.png"
86
+ ],
87
+ "recommends": [
88
+ "signalk-watch-schedule",
89
+ "signalk-tides",
90
+ "@rhizomatics/signalk-einklabel-genai-plugin"
86
91
  ]
87
92
  }
88
93
  }
@@ -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>
@@ -0,0 +1,12 @@
1
+ <?xml version="1.0" encoding="UTF-8" standalone="no"?>
2
+ <svg width="416" height="240" viewBox="0 0 416 240" xmlns="http://www.w3.org/2000/svg">
3
+ <rect x="0" y="0" width="416" height="240" fill="white" />
4
+ <polygon points="80,40 18,168 142,168" fill="red" stroke="black" stroke-width="5" stroke-linejoin="round" />
5
+ <rect x="75" y="85" width="12" height="48" fill="black" />
6
+ <circle cx="81" cy="150" r="7" fill="black" />
7
+ <text x="168" y="90" font-size="34" font-family="sans-serif" font-weight="bold" fill="black">CONTENT</text>
8
+ <text x="168" y="132" font-size="34" font-family="sans-serif" font-weight="bold" fill="black">UNAVAILABLE</text>
9
+ <text x="168" y="165" font-size="18" font-family="sans-serif" fill="black">Check manually</text>
10
+ <text id="last_attempt" x="168" y="205" font-size="16" font-family="monospace" fill="black"
11
+ ><desc>source=einklabel,path=repainted,format=local_datetime_short</desc>last attempt: --</text>
12
+ </svg>