@rhizomatics/signalk-einklabel-plugin 0.10.1 → 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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,14 @@
1
+ # 1.0.0
2
+
3
+ ## Full Release
4
+
5
+ - The `1.0.0` reflects that this plugin has been working in real environment for months, and has been refactored for better extensibility
6
+
7
+ ## Extension Support
8
+
9
+ - Custom templates and CLI commands can now be provided by a different plugin
10
+ - Initially this is to support `signalk-einklabel-genai-plugin` which can use a prompt to make an image using a GenAI service like Anthropic or local Ollama
11
+
1
12
  # 0.10.1
2
13
 
3
14
  - Test fixes
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  [![code style: oxfmt](https://img.shields.io/badge/code_style-oxfmt-blue.svg)](https://github.com)
8
8
  [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://github.com/rhizomatics/signalk-einklabel-plugin/blob/main/LICENSE)
9
9
 
10
- A SignalK plugin to display data from SignalK paths, Resource APIs and plugins on Electronic Shelf Labels (ESL) over a Bluetooth Low Energy (BLE) connection using simple SVG templates.
10
+ A SignalK plugin to display data from SignalK paths, Resource APIs and plugins on Electronic Shelf Labels (ESL) over a Bluetooth Low Energy (BLE) connection using simple SVG templates, or optionally created from a crafted prompt by GenAI if the companion [`@rhizomatics/signalk-einklabel-genai-plugin`](https://github.com/rhizomatics/signalk-einklabel-genai-plugin) is installed.
11
11
 
12
12
  ![Companionway Tidal Clock](docs/assets/images/real_tidal_clock.jpg)
13
13
 
@@ -113,7 +113,8 @@ Enable the plugin, and use the large **+** sign to add a label, which opens up t
113
113
 
114
114
  - _Friendly Name_ - Give the label any name (word or phrase) you like, for example 'Tide Clock'
115
115
  - _Device_ - Unless you have multiple labels, don't bother with pre-scanning or selecting a specific device, instead pick **"All discovered devices"** and it will paint any compatible labels it finds. If you want to pick a specific device, you'll need to wait for a device scan to complete.
116
- - _Template_ - Choose a built in template, or one you've added to the local templates directory
116
+ - _Template_ - Choose a built-in template, one you've added to the local templates directory, or - if a companion plugin like [`@rhizomatics/signalk-einklabel-genai-plugin`](#genai-rendering) is installed - one of its own contributed entries (shown with a suffix, e.g. "forecast (GenAI)")
117
+ - _Location/description_ - Optional free-text notes on where this label is physically mounted/viewed from, e.g. "chart table, viewed from ~1m in poor light" - available to any template as `source=einklabel,path=description` or `source=label,path=description`
117
118
  - _Repaint Trigger_- Do you want this to repaint every few hours (at a chosen minutes past hour), or when a SignalK path changes?
118
119
  - If it's a SignalK path, enter it next, for example `environment.tide.state`
119
120
  - If it's time based, enter how many hours between repaints, for example 00:00/08:00/16:00 for an 8h schedule, and if you want a specific number of minutes after the hour.
@@ -207,6 +208,8 @@ Note that for dates and times, the server timezone must be set correctly, for ex
207
208
 
208
209
  Additionally, `round=n` can be used to round to limited decimal places.
209
210
 
211
+ `default=<value>` substitutes `<value>` whenever the resolved value is missing (e.g. an unpublished path), instead of falling through to an empty string - useful anywhere a blank would be misread as a real answer. `default=` with nothing after the `=` still counts as set, deliberately defaulting to an empty string rather than leaving the fallback behaviour unchanged.
212
+
210
213
  These can all be combined as in `source=resources,resource=tides,provider=tides,path=extremes[2].level,category=depth,round=2`
211
214
 
212
215
  ### Non-Textual Fields (Images)
@@ -231,6 +234,16 @@ Three font types are loaded by default, use the generic font family, or exact fo
231
234
  - `sans-serif` - `Roboto`
232
235
  - `monospace` - `Roboto Mono`
233
236
 
237
+ ## GenAI Rendering
238
+
239
+ As an alternative to hand-designed SVG templates, a device's content can be generated by an LLM instead - this is provided entirely by a separate companion plugin, [`@rhizomatics/signalk-einklabel-genai-plugin`](https://github.com/rhizomatics/signalk-einklabel-genai-plugin), not by this core plugin. The base plugin (BLE painting, SVG templates) has nothing to do with any LLM SDK or API key, so installing it never pulls in GenAI dependencies unless you also explicitly install and enable the companion plugin - the split is deliberate, for anyone on a small/constrained install or who'd simply rather not have any GenAI code in their install at all.
240
+
241
+ Once the companion plugin is installed and enabled, its own config screen handles provider/model/API-key selection (OpenAI, Anthropic, Google, xAI, DeepSeek, Moonshot AI, Ollama, or any other OpenAI-compatible endpoint), and its prompts show up as ordinary entries in this plugin's "Template" dropdown, suffixed (e.g. "forecast (GenAI)") - pick one exactly like you'd pick a `.svg` file. Under the hood it's a `TemplateProvider` (see [Extending](#extending) below) - the same extension mechanism a new vendor's hardware driver uses - so from this plugin's point of view it's just another way `templateName` gets resolved to an image.
242
+
243
+ If the LLM call fails (network/API error, exhausted retries) or its response isn't renderable, this core plugin pushes its own bundled warning template instead of leaving the previous, possibly now-wrong, content on screen - so a stale weather prompt never quietly shows yesterday's forecast through today's storm. This is a generic safety net, not GenAI-specific: the exact same fallback covers a broken hand-authored template failing to render at all. The next scheduled repaint retries automatically. Since each repaint may call a (usually paid) LLM API, a GenAI-backed device should use Repaint Trigger `interval`, not `subscription`.
244
+
245
+ See the companion plugin's own README for installing it, writing prompts, and its `esl-cli prompt`/`generate` commands for testing prompts without a device.
246
+
234
247
  ## Architecture
235
248
 
236
249
  The primary things managed and provided by the plugin are:
@@ -259,6 +272,8 @@ See also the commands useful for debugging under [Developing Templates]
259
272
 
260
273
  The width, height, vertical offset and colour palette for the device are taken from the internal register of devices, however can be overridden on the command line. This could be used to help you choose what size of label to buy, or to get an unsupported label working.
261
274
 
275
+ `esl-cli` can also be extended with new subcommands by a `-r/--require`'d package - see [Extending](#extending) below - which is how [`@rhizomatics/signalk-einklabel-genai-plugin`](#genai-rendering) adds its own `prompt`/`generate` commands for testing prompts without a device.
276
+
262
277
  ( The CLI can also be run from a checked out module, or by opening a terminal shell at `~/.signalk/node_modules/@rhizomatics/signalk-einklabel-plugin`, as `npx esl-cli command --args` )
263
278
 
264
279
  ### Scans from CLI
@@ -278,7 +293,13 @@ Additional vendors and devices can be added by a separate npm package that imple
278
293
 
279
294
  - `import esl from '@rhizomatics/signalk-einklabel-plugin'; esl.registerVendorDriver(myDriver)`
280
295
  - In the SignalK runtime, call this from the extension's own plugin `start()`. In the CLI, load the extension with `esl-cli --require <module> <command>`.
281
- - Declare this package as a `peerDependency` (not a regular dependency) in the extension package, so npm resolves a single shared copy of the registry.
296
+ - Declare this package as a regular npm `dependency` in the extension package - **not** a `peerDependency`, per SignalK's own guidance that npm's peer-dependency resolution interacts poorly with the server's plugin install layout - and declare the SignalK-level relationship via `"signalk": { "requires": ["@rhizomatics/signalk-einklabel-plugin"] }` in the extension's own `package.json` instead, so the App Store can install/report it.
297
+
298
+ ### Template Providers
299
+
300
+ An alternative way to produce a device's content, alongside hand-authored SVG templates, can be added the same way - `esl.registerTemplateProvider({ suffix, listTemplates, render })`, from a separate package's own plugin `start()` (or `esl-cli --require <module>`). This is what [`@rhizomatics/signalk-einklabel-genai-plugin`](#genai-rendering) is: `listTemplates()` returns the entries it currently offers (already including its own `suffix`, e.g. `"forecast (GenAI)"`) for the "Template" dropdown, and `render(request)` - given the same signalk/resources/label context any SVG template's bindings get - returns a `Bitmap`, exactly as `SvgRenderer.render()` would. Rejecting from `render()` routes the repaint through this plugin's own generic fallback-warning template, the same as a broken hand-authored template failing to render.
301
+
302
+ `esl.SvgRenderer`, `esl.buildLabel`, `esl.findBindingsInText`, and `esl.substituteBindingsInText` are also exported for a `TemplateProvider` extension to reuse - e.g. to resolve its own `{...}`-style placeholders against the request's context, or to rasterize whatever SVG it produces into the `Bitmap` its `render()` must return.
282
303
 
283
304
  ### Developing Templates
284
305
 
@@ -380,7 +401,38 @@ If you have an SVG viewer extension, this will show the image rather than allowi
380
401
 
381
402
  Check if the text boxes are normal text or flowed text, and correct to normal text.
382
403
 
383
- ## Other ESL and General eInk Resources
404
+ ### My label just shows "CONTENT UNAVAILABLE"
405
+
406
+ That's the bundled fallback warning, not necessarily an error in this plugin - it means the most recent repaint failed, whatever produced the content (a broken hand-authored template, or a `TemplateProvider` extension like [`@rhizomatics/signalk-einklabel-genai-plugin`](#genai-rendering) - e.g. its LLM call failing on no network/API access, an invalid API key, or a response that wasn't a renderable SVG, after using up its configured retries). Check the SignalK server logs (debug logging on for this plugin) for the specific error, and if it's a GenAI device, check that plugin's own provider/API key/model settings. This plugin deliberately never leaves old content on screen when a repaint fails - it retries automatically at the next scheduled interval.
407
+
408
+ ### SignalK starts before the Bluetooth daemon — does the plugin need `bluetoothd` running at boot?
409
+
410
+ The plugin retries BLE adapter initialisation with backoff (starting at 2s, capping at 30s) if `bluetoothd`/D-Bus isn't up yet when the plugin starts, so a slow-starting Bluetooth stack on boot will no longer strand it — it keeps retrying until the adapter appears rather than failing once and giving up. You'll see `BLE adapter not ready … — retrying in Ns …` in the SignalK logs in the meantime.
411
+
412
+ That said, it's cleaner to fix the boot ordering at the systemd level so the plugin finds the adapter ready on its first attempt. If SignalK runs as a systemd service (`systemctl status signalk`) and its unit file has no `[Unit]` section (check with `systemctl cat signalk`), add one:
413
+
414
+ ```bash
415
+ sudo systemctl edit signalk.service
416
+ ```
417
+
418
+ This opens an override file — add:
419
+
420
+ ```ini
421
+ [Unit]
422
+ After=bluetooth.target
423
+ Wants=bluetooth.target
424
+ ```
425
+
426
+ Save and exit, then:
427
+
428
+ ```bash
429
+ sudo systemctl daemon-reload
430
+ sudo systemctl restart signalk
431
+ ```
432
+
433
+ This tells systemd to start `bluetoothd` first and wait for it before starting SignalK, rather than relying on both racing to start in parallel at boot.
434
+
435
+ ## Other ESL and General eInk Resources
384
436
 
385
437
  - [Open ePaper Link](https://openepaperlink.de) - Alternative open source firmware to flash onto eInk shelf labels, with Home Assistant integration.
386
438
  - [zhsunyco-esl](https://github.com/roxburghm/zhsunyco-esl) - Python interface
@@ -1,2 +1,14 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ import { Command } from "commander";
3
+ import { Colour } from "../devices/types";
4
+ import { Binding } from "../render/binding";
5
+ import { TemplateContext } from "../render/types";
6
+ /** Tried in order when -u/--url is omitted (and -e/--example-data isn't given) - first one that answers wins. */
7
+ export declare const DEFAULT_SIGNALK_URLS: string[];
8
+ export declare function parseColours(code: string): Colour[];
9
+ /** Shared by every command that takes -u/--url and -e/--example-data - -e wins when both are present; when neither is given, probes DEFAULT_SIGNALK_URLS for a default. */
10
+ export declare function assembleContext(opts: {
11
+ url?: string;
12
+ exampleData?: string;
13
+ }, bindings: Binding[]): Promise<TemplateContext>;
14
+ export declare const program: Command;
package/dist/cli/index.js CHANGED
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.program = exports.DEFAULT_SIGNALK_URLS = void 0;
5
+ exports.parseColours = parseColours;
6
+ exports.assembleContext = assembleContext;
4
7
  const promises_1 = require("fs/promises");
5
8
  const path_1 = require("path");
6
9
  const commander_1 = require("commander");
@@ -19,7 +22,7 @@ const log_1 = require("./log");
19
22
  (0, registry_1.registerDriver)(new zhsunyco_1.ZhsunycoDriver());
20
23
  const VENDOR_IDENTIFY_TIMEOUT_MS = 30000;
21
24
  /** Tried in order when -u/--url is omitted (and -e/--example-data isn't given) - first one that answers wins. */
22
- const DEFAULT_SIGNALK_URLS = ["http://localhost", "http://localhost:3000", "https://localhost"];
25
+ exports.DEFAULT_SIGNALK_URLS = ["http://localhost", "http://localhost:3000", "https://localhost"];
23
26
  const COLOUR_CODES = {
24
27
  BW: ["black", "white"],
25
28
  BWR: ["black", "white", "red"],
@@ -34,7 +37,7 @@ function parseColours(code) {
34
37
  }
35
38
  /** Probes DEFAULT_SIGNALK_URLS in order and returns the first that answers a plain GET - used when -u/--url is omitted. */
36
39
  async function resolveDefaultUrl() {
37
- for (const candidate of DEFAULT_SIGNALK_URLS) {
40
+ for (const candidate of exports.DEFAULT_SIGNALK_URLS) {
38
41
  try {
39
42
  (0, log_1.logDebug)(`probing ${candidate} as a default SignalK server`);
40
43
  await (0, httpJson_1.fetchJson)(`${candidate}/signalk`);
@@ -44,7 +47,7 @@ async function resolveDefaultUrl() {
44
47
  (0, log_1.logDebug)(`${candidate} did not answer: ${err.message}`);
45
48
  }
46
49
  }
47
- throw new Error(`no -u/--url given and none of ${DEFAULT_SIGNALK_URLS.join(", ")} answered - specify the server explicitly with -u/--url`);
50
+ throw new Error(`no -u/--url given and none of ${exports.DEFAULT_SIGNALK_URLS.join(", ")} answered - specify the server explicitly with -u/--url`);
48
51
  }
49
52
  /** Shared by every command that takes -u/--url and -e/--example-data - -e wins when both are present; when neither is given, probes DEFAULT_SIGNALK_URLS for a default. */
50
53
  async function assembleContext(opts, bindings) {
@@ -73,21 +76,49 @@ async function identifyVendor(address) {
73
76
  destroy();
74
77
  }
75
78
  }
76
- const program = new commander_1.Command();
77
- program.name("esl-cli").description("Local CLI for testing ESL device scan and paint without a SignalK server");
78
- program.option("-r, --require <module>", "require a module before running, e.g. an npm package that registers a vendor driver (repeatable)",
79
- // Commander's own typings declare `previous` as always `string[]`, but at runtime it's actually
80
- // `undefined` on the first invocation when no default is passed to `.option()` - the `| undefined`
81
- // here keeps that true and makes the `= []` fallback meaningful rather than dead code.
82
- (value, previous = []) => [...previous, value]);
83
- program.option("-l, --log-level <level>", "log verbosity: info or debug (e.g. trace which URLs are fetched)", "info");
84
- program.hook("preAction", () => {
85
- (0, log_1.setLogLevel)(program.opts().logLevel);
86
- for (const mod of program.opts().require ?? []) {
87
- require(mod);
79
+ /**
80
+ * Values passed via `-r`/`--require` (repeatable, `--require=value` form included), read directly from
81
+ * `process.argv` rather than through commander - see the doc comment below on why.
82
+ */
83
+ function scanRequireModules(argv) {
84
+ const modules = [];
85
+ for (let i = 0; i < argv.length; i++) {
86
+ const arg = argv[i];
87
+ if (arg === "-r" || arg === "--require") {
88
+ if (argv[i + 1])
89
+ modules.push(argv[i + 1]);
90
+ }
91
+ else if (arg.startsWith("--require=")) {
92
+ modules.push(arg.slice("--require=".length));
93
+ }
88
94
  }
95
+ return modules;
96
+ }
97
+ // `program` is created (and exported) before anything is `require()`'d below, and before any built-in
98
+ // `.command(...)` is defined - a `-r`'d extension module commonly `require()`s this same compiled file
99
+ // (e.g. `require(".../dist/cli/index.js")`) to get at `program`/`assembleContext`/etc; since that's a
100
+ // circular require back into the module currently being loaded, Node hands it back this module's
101
+ // *exports object as it exists so far*, not the finished one - so `program` has to already be assigned
102
+ // by this point, or the extension would see it as `undefined`.
103
+ exports.program = new commander_1.Command();
104
+ exports.program.name("esl-cli").description("Local CLI for testing ESL device scan and paint without a SignalK server");
105
+ exports.program.option("-r, --require <module>", "require a module before running, e.g. an npm package that registers a vendor driver/template provider or contributes its own subcommands (repeatable) - loaded before any command is parsed", (value, previous = []) => [...previous, value]);
106
+ exports.program.option("-l, --log-level <level>", "log verbosity: info or debug (e.g. trace which URLs are fetched)", "info");
107
+ exports.program.hook("preAction", () => {
108
+ (0, log_1.setLogLevel)(exports.program.opts().logLevel);
89
109
  });
90
- program
110
+ // Loaded here - after `program` exists (see above) but before any built-in `.command(...)` is defined,
111
+ // and well before `.parseAsync()` at the bottom of this file - not in a commander `preAction` hook (as
112
+ // this used to work), which only runs *after* commander has already resolved which command was invoked
113
+ // and parsed its arguments. That timing is fine for a `-r`'d module that only registers a vendor driver
114
+ // or template provider (an already-defined command's own action merely consults those registries when
115
+ // it runs), but too late for one to contribute a brand-new *subcommand* (e.g. a companion plugin adding
116
+ // its own `prompt`/`generate`) to this same invocation - by the time `preAction` fires, commander has
117
+ // already decided there's no such command.
118
+ for (const mod of scanRequireModules(process.argv)) {
119
+ require(mod);
120
+ }
121
+ exports.program
91
122
  .command("vendors")
92
123
  .description("List supported vendors and the device models each has confirmed metadata for")
93
124
  .action(() => {
@@ -114,7 +145,7 @@ program
114
145
  printRow(header);
115
146
  rows.forEach(printRow);
116
147
  });
117
- program
148
+ exports.program
118
149
  .command("scan")
119
150
  .description("Scan for supported BLE ESL devices across all registered vendor drivers")
120
151
  .option("-d, --duration <seconds>", "scan duration in seconds", "10")
@@ -161,14 +192,14 @@ program
161
192
  printRow(header);
162
193
  rows.forEach(printRow);
163
194
  });
164
- program
195
+ exports.program
165
196
  .command("paint")
166
197
  .description("Render a template against a live SignalK server and send it to a device")
167
198
  .option("-v, --vendor <vendor>", "vendor driver to use - if omitted, inferred from the device's advertised name")
168
199
  .requiredOption("-a, --address <address>", "BLE address of the device")
169
200
  .requiredOption("-t, --template <path>", "path to SVG template")
170
201
  .option("-u, --url <url>", "SignalK server base URL - resolves the template's source=signalk/resources bindings - if omitted, tries each of " +
171
- DEFAULT_SIGNALK_URLS.join(", ") +
202
+ exports.DEFAULT_SIGNALK_URLS.join(", ") +
172
203
  " in turn")
173
204
  .option("-e, --example-data <dir>", "load vessels/resources from local example JSON files in <dir> (e.g. ./examples) instead of a live SignalK server - alternative to -u")
174
205
  .option("-k, --aes-key <hex>", "AES-128 key for device authentication, as 32 hex characters - defaults to the vendor's stock key if omitted")
@@ -211,13 +242,13 @@ program
211
242
  });
212
243
  console.log(`painted ${opts.address} (${bitmap.width}x${bitmap.height}) ${opts.colours}`);
213
244
  });
214
- program
245
+ exports.program
215
246
  .command("render")
216
247
  .description("Render a template against a live SignalK server and write a PNG, without needing a device")
217
248
  .requiredOption("-t, --template <path>", "path to SVG template")
218
249
  .requiredOption("-o, --output <path>", "output PNG path")
219
250
  .option("-u, --url <url>", "SignalK server base URL - resolves the template's source=signalk/resources bindings - if omitted, tries each of " +
220
- DEFAULT_SIGNALK_URLS.join(", ") +
251
+ exports.DEFAULT_SIGNALK_URLS.join(", ") +
221
252
  " in turn")
222
253
  .option("-e, --example-data <dir>", "load vessels/resources from local example JSON files in <dir> (e.g. ./examples) instead of a live SignalK server - alternative to -u")
223
254
  .option("-w, --width <px>", "render width", "416")
@@ -233,12 +264,12 @@ program
233
264
  await (0, promises_1.writeFile)(opts.output, (0, png_1.bitmapToPng)(bitmap));
234
265
  console.log(`wrote ${opts.output} (${bitmap.width}x${bitmap.height})`);
235
266
  });
236
- program
267
+ exports.program
237
268
  .command("fields")
238
269
  .description("List every <desc> binding in a template by element id, with its source spec and resolved value")
239
270
  .requiredOption("-t, --template <path>", "path to SVG template")
240
271
  .option("-u, --url <url>", "SignalK server base URL - resolves the template's source=signalk/resources bindings - if omitted, tries each of " +
241
- DEFAULT_SIGNALK_URLS.join(", ") +
272
+ exports.DEFAULT_SIGNALK_URLS.join(", ") +
242
273
  " in turn")
243
274
  .option("-e, --example-data <dir>", "load vessels/resources from local example JSON files in <dir> (e.g. ./examples) instead of a live SignalK server - alternative to -u")
244
275
  .action(async (opts) => {
@@ -296,12 +327,12 @@ program
296
327
  printRow(header);
297
328
  table.forEach(printRow);
298
329
  });
299
- program
330
+ exports.program
300
331
  .command("field")
301
332
  .description("Resolve a single binding spec directly against a live SignalK server, with no template")
302
333
  .argument("<spec>", 'binding spec, e.g. "source=resources,resource=tides,path=station.name" or a bare SignalK path')
303
334
  .option("-u, --url <url>", "SignalK server base URL - resolves the spec's source=signalk/resources binding - if omitted, tries each of " +
304
- DEFAULT_SIGNALK_URLS.join(", ") +
335
+ exports.DEFAULT_SIGNALK_URLS.join(", ") +
305
336
  " in turn")
306
337
  .option("-e, --example-data <dir>", "load vessels/resources from local example JSON files in <dir> (e.g. ./examples) instead of a live SignalK server - alternative to -u")
307
338
  .action(async (spec, opts) => {
@@ -309,7 +340,7 @@ program
309
340
  const context = await assembleContext(opts, [binding]);
310
341
  console.log((0, binding_1.renderBinding)(binding, context));
311
342
  });
312
- program.parseAsync(process.argv).catch((err) => {
343
+ exports.program.parseAsync(process.argv).catch((err) => {
313
344
  console.error(err.message);
314
345
  process.exitCode = 1;
315
346
  });
package/dist/config.d.ts CHANGED
@@ -17,6 +17,14 @@ export interface DeviceConfig {
17
17
  * live BLE read) and the BLE address, or the special value `ALL_DEVICES` (see above).
18
18
  */
19
19
  device: string;
20
+ /**
21
+ * Free-text notes about where this label is physically mounted/viewed from, e.g. "chart table,
22
+ * viewed from ~1m in poor light" - purely descriptive. Available to any template via
23
+ * `source=einklabel,path=description` or `source=label,path=description` (see `buildLabelContext` in
24
+ * `./render/binding.ts`) if it wants it - e.g. useful to a `TemplateProvider` extension (see
25
+ * `./render/templateProviders.ts`) tailoring content to where the label actually sits.
26
+ */
27
+ description?: string;
20
28
  /** Per-device override; if omitted, the vendor driver may fall back to a stock/manufacturer-default key. */
21
29
  aesKey?: string;
22
30
  /**
@@ -25,8 +33,13 @@ export interface DeviceConfig {
25
33
  * (e.g. `templates/tides/416x240-BWRY.svg`) - `pickBestVariant` then picks whichever file inside it
26
34
  * best matches this entry's actual device(s), so one `DeviceConfig` (and one `ALL_DEVICES` entry in
27
35
  * particular) works across different panel sizes without the user picking a file for each.
36
+ *
37
+ * Can also name an entry a registered `TemplateProvider` offers (see `./render/templateProviders.ts`)
38
+ * instead of a file - e.g. `signalk-einklabel-genai-plugin` contributing `"forecast (GenAI)"` -
39
+ * `considerRepaint` (`./repaintScheduler.ts`) checks the provider registry before falling back to
40
+ * resolving this as a file path.
28
41
  */
29
- templateName: string;
42
+ templateName?: string;
30
43
  repaintTrigger: "subscription" | "interval";
31
44
  /** SignalK path to subscribe to when `repaintTrigger` is `subscription` - a repaint is considered on every delta. */
32
45
  triggerPath?: string;
@@ -93,6 +106,18 @@ export interface PluginConfig {
93
106
  * `templates/.assets/lunar_phases`) just to keep a binding the override never touched working.
94
107
  */
95
108
  export declare const BUNDLED_TEMPLATES_DIR: string;
109
+ /**
110
+ * Bundled template family pushed instead of a device's real content whenever a repaint fails - a broken
111
+ * hand-authored template, or a registered `TemplateProvider`'s `render()` rejecting (e.g.
112
+ * `@rhizomatics/signalk-einklabel-genai-plugin`'s LLM call failing) - see `considerRepaint` in
113
+ * `./repaintScheduler.ts`. Generic on purpose: it has no idea *why* the render failed, only that it
114
+ * did, and that the previous, possibly now-wrong, content must never be left on screen unmarked.
115
+ *
116
+ * Dot-prefixed like `.assets`/`.blank` so `listTemplateFamilies` excludes it from the "Template"
117
+ * dropdown - it's an internal fallback mechanism, not something a user should ever pick as a device's
118
+ * own template.
119
+ */
120
+ export declare const RENDER_FALLBACK_TEMPLATE_NAME = ".error";
96
121
  export declare function defaultConfig(): PluginConfig;
97
122
  export declare function readCurrentConfig(app: ServerAPI): Partial<PluginConfig>;
98
123
  /**
@@ -106,12 +131,7 @@ export declare function readCurrentConfig(app: ServerAPI): Partial<PluginConfig>
106
131
  * flattened even if nothing ever triggers that path.
107
132
  */
108
133
  export declare function healNestedConfig(app: ServerAPI): void;
109
- /**
110
- * Resolves the user-facing `templatesDir` setting to an actual directory, mirroring
111
- * signalk-parquet's `outputDirectory` convention: empty means the default location, a relative
112
- * path is resolved against `~/.signalk` (where SignalK itself stores its config by default), and
113
- * an absolute path is used as-is.
114
- */
134
+ /** See `resolveDir` - `templatesDir`'s own resolution. */
115
135
  export declare function resolveTemplatesDir(templatesDir: string | undefined): string;
116
136
  export declare function parseDevice(device: string): {
117
137
  vendor: string;
package/dist/config.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.BUNDLED_TEMPLATES_DIR = exports.ALL_DEVICES = void 0;
3
+ exports.RENDER_FALLBACK_TEMPLATE_NAME = exports.BUNDLED_TEMPLATES_DIR = exports.ALL_DEVICES = void 0;
4
4
  exports.defaultConfig = defaultConfig;
5
5
  exports.readCurrentConfig = readCurrentConfig;
6
6
  exports.healNestedConfig = healNestedConfig;
@@ -12,6 +12,7 @@ exports.configUiSchema = configUiSchema;
12
12
  const fs_1 = require("fs");
13
13
  const os_1 = require("os");
14
14
  const path_1 = require("path");
15
+ const templateProviders_1 = require("./render/templateProviders");
15
16
  const resolveApiUrl_1 = require("./resolveApiUrl");
16
17
  /**
17
18
  * Special `device` value meaning "every currently-known discovered device" instead of one specific
@@ -31,6 +32,18 @@ exports.ALL_DEVICES = "ALL";
31
32
  * `templates/.assets/lunar_phases`) just to keep a binding the override never touched working.
32
33
  */
33
34
  exports.BUNDLED_TEMPLATES_DIR = (0, path_1.join)(__dirname, "..", "templates");
35
+ /**
36
+ * Bundled template family pushed instead of a device's real content whenever a repaint fails - a broken
37
+ * hand-authored template, or a registered `TemplateProvider`'s `render()` rejecting (e.g.
38
+ * `@rhizomatics/signalk-einklabel-genai-plugin`'s LLM call failing) - see `considerRepaint` in
39
+ * `./repaintScheduler.ts`. Generic on purpose: it has no idea *why* the render failed, only that it
40
+ * did, and that the previous, possibly now-wrong, content must never be left on screen unmarked.
41
+ *
42
+ * Dot-prefixed like `.assets`/`.blank` so `listTemplateFamilies` excludes it from the "Template"
43
+ * dropdown - it's an internal fallback mechanism, not something a user should ever pick as a device's
44
+ * own template.
45
+ */
46
+ exports.RENDER_FALLBACK_TEMPLATE_NAME = ".error";
34
47
  const SIGNALK_HOME_DIR = (0, path_1.join)((0, os_1.homedir)(), ".signalk");
35
48
  const DEFAULT_TEMPLATES_DIR = (0, path_1.join)(SIGNALK_HOME_DIR, "einklabel", "templates");
36
49
  function defaultConfig() {
@@ -117,18 +130,21 @@ function healNestedConfig(app) {
117
130
  });
118
131
  }
119
132
  /**
120
- * Resolves the user-facing `templatesDir` setting to an actual directory, mirroring
121
- * signalk-parquet's `outputDirectory` convention: empty means the default location, a relative
122
- * path is resolved against `~/.signalk` (where SignalK itself stores its config by default), and
123
- * an absolute path is used as-is.
133
+ * Resolves a user-facing `<x>Dir` setting to an actual directory, mirroring signalk-parquet's
134
+ * `outputDirectory` convention: empty means `defaultDir`, a relative path is resolved against
135
+ * `~/.signalk` (where SignalK itself stores its config by default), and an absolute path is used as-is.
124
136
  */
125
- function resolveTemplatesDir(templatesDir) {
126
- const trimmed = templatesDir?.trim();
137
+ function resolveDir(dir, defaultDir) {
138
+ const trimmed = dir?.trim();
127
139
  if (!trimmed) {
128
- return DEFAULT_TEMPLATES_DIR;
140
+ return defaultDir;
129
141
  }
130
142
  return (0, path_1.isAbsolute)(trimmed) ? trimmed : (0, path_1.join)(SIGNALK_HOME_DIR, trimmed);
131
143
  }
144
+ /** See `resolveDir` - `templatesDir`'s own resolution. */
145
+ function resolveTemplatesDir(templatesDir) {
146
+ return resolveDir(templatesDir, DEFAULT_TEMPLATES_DIR);
147
+ }
132
148
  /**
133
149
  * Enum for the combined "device" field, built from recently scanned devices - including ones a
134
150
  * driver identified as its vendor but whose PID isn't in its metadata table yet (clearly labelled,
@@ -219,11 +235,18 @@ function listTemplateFamilies(dir) {
219
235
  .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".") && listTemplateVariants((0, path_1.join)(dir, entry.name)).length > 0)
220
236
  .map((entry) => entry.name);
221
237
  }
222
- /** Local templates (files or template-family directories) take priority over a same-named bundled one; both show up as options. */
238
+ /**
239
+ * Local templates (files or template-family directories) take priority over a same-named bundled one;
240
+ * both show up as options, followed by every registered `TemplateProvider`'s own entries (e.g.
241
+ * `signalk-einklabel-genai-plugin` contributing `"forecast (GenAI)"` - see `./render/templateProviders.ts`)
242
+ * - so a provider-backed "template" is picked from this exact same dropdown, distinguished only by its
243
+ * `suffix`, with no separate render-mode field at all.
244
+ */
223
245
  function templateNameOptions(templatesDir) {
224
246
  const local = [...listSvgFiles(templatesDir), ...listTemplateFamilies(templatesDir)];
225
247
  const bundled = [...listSvgFiles(exports.BUNDLED_TEMPLATES_DIR), ...listTemplateFamilies(exports.BUNDLED_TEMPLATES_DIR)].filter((name) => !local.includes(name));
226
- return [...local, ...bundled];
248
+ const provided = (0, templateProviders_1.allTemplateProviders)().flatMap((provider) => provider.listTemplates());
249
+ return [...local, ...bundled, ...provided];
227
250
  }
228
251
  function sameColours(a, b) {
229
252
  if (a.length !== b.length)
@@ -350,6 +373,12 @@ function configSchema(app, discovered = []) {
350
373
  'address, or "All discovered devices" to paint this same template/trigger to every device the plugin currently ' +
351
374
  "knows about - simplest for a single label, and also covers several identical labels without listing each one.",
352
375
  }, deviceValues, deviceLabels),
376
+ description: {
377
+ type: "string",
378
+ title: "Location/description (optional)",
379
+ description: 'Free-text notes about where this label is physically mounted/viewed from, e.g. "chart table, viewed from ~1m ' +
380
+ 'in poor light" - available to any template as source=einklabel,path=description or source=label,path=description.',
381
+ },
353
382
  templateName: withEnum({ type: "string", title: "Template" }, templateNameOptions(resolveTemplatesDir(current.templatesDir))),
354
383
  repaintTrigger: {
355
384
  type: "string",
@@ -392,6 +421,7 @@ function configUiSchema() {
392
421
  return {
393
422
  devices: {
394
423
  items: {
424
+ description: { "ui:widget": "textarea" },
395
425
  repaintTrigger: { "ui:widget": "radio" },
396
426
  },
397
427
  },
@@ -13,6 +13,14 @@ export interface DeviceMetadata {
13
13
  * that should be treated as the default/fallback for that PID.
14
14
  */
15
15
  hwVersion?: string;
16
+ /**
17
+ * Real-world brand name (e.g. "Zhsunyco") - distinct from `VendorDriver.vendor` (e.g. also
18
+ * "zhsunyco"), which is this codebase's internal BLE-protocol driver key rather than a name meant
19
+ * for display. Optional since a CLI/config `DeviceModelOverride` for unsupported hardware has no
20
+ * table entry to source it from - `considerLlmPromptRepaint` (repaintScheduler.ts) falls back to
21
+ * the driver's own `vendor` key when unset, for a `source=label,path=manufacturer` prompt binding.
22
+ */
23
+ manufacturer?: string;
16
24
  label: string;
17
25
  width: number;
18
26
  height: number;
@@ -22,6 +22,7 @@ exports.ZHSUNYCO_PID_METADATA = void 0;
22
22
  exports.ZHSUNYCO_PID_METADATA = [
23
23
  {
24
24
  pid: 0x0008,
25
+ manufacturer: "Zhsunyco",
25
26
  label: '1.54"',
26
27
  width: 200,
27
28
  height: 200,
@@ -30,6 +31,7 @@ exports.ZHSUNYCO_PID_METADATA = [
30
31
  },
31
32
  {
32
33
  pid: 0x000a,
34
+ manufacturer: "Zhsunyco",
33
35
  label: '2.13"',
34
36
  width: 250,
35
37
  height: 128,
@@ -38,6 +40,7 @@ exports.ZHSUNYCO_PID_METADATA = [
38
40
  },
39
41
  {
40
42
  pid: 0x000e,
43
+ manufacturer: "Zhsunyco",
41
44
  label: '3.7"',
42
45
  width: 416,
43
46
  height: 240,
@@ -47,6 +50,7 @@ exports.ZHSUNYCO_PID_METADATA = [
47
50
  {
48
51
  pid: 0x000e,
49
52
  hwVersion: "0103",
53
+ manufacturer: "Zhsunyco",
50
54
  label: '2.13"',
51
55
  width: 250,
52
56
  height: 128,
@@ -56,6 +60,7 @@ exports.ZHSUNYCO_PID_METADATA = [
56
60
  {
57
61
  pid: 0x000e,
58
62
  hwVersion: "0201",
63
+ manufacturer: "Zhsunyco",
59
64
  label: '3.5"',
60
65
  width: 384,
61
66
  height: 184,
@@ -65,6 +70,7 @@ exports.ZHSUNYCO_PID_METADATA = [
65
70
  {
66
71
  pid: 0x000e,
67
72
  hwVersion: "0203",
73
+ manufacturer: "Zhsunyco",
68
74
  label: '7.5"',
69
75
  width: 800,
70
76
  height: 480,
@@ -73,6 +79,7 @@ exports.ZHSUNYCO_PID_METADATA = [
73
79
  },
74
80
  {
75
81
  pid: 0x0012,
82
+ manufacturer: "Zhsunyco",
76
83
  label: '2.9"',
77
84
  width: 296,
78
85
  height: 128,
@@ -81,6 +88,7 @@ exports.ZHSUNYCO_PID_METADATA = [
81
88
  },
82
89
  {
83
90
  pid: 0x0016,
91
+ manufacturer: "Zhsunyco",
84
92
  label: '4.2"',
85
93
  width: 400,
86
94
  height: 300,
@@ -89,6 +97,7 @@ exports.ZHSUNYCO_PID_METADATA = [
89
97
  },
90
98
  {
91
99
  pid: 0x001a,
100
+ manufacturer: "Zhsunyco",
92
101
  label: '5.8"',
93
102
  width: 648,
94
103
  height: 480,
package/dist/index.d.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  import { ServerAPI, Plugin } from "@signalk/server-api";
2
2
  import { registerDriver, getDriver, allDrivers } from "./devices/registry";
3
+ import { registerTemplateProvider as registerTemplateProviderImpl, findTemplateProvider as findTemplateProviderImpl, allTemplateProviders as allTemplateProvidersImpl } from "./render/templateProviders";
4
+ import { SvgRenderer } from "./render/svgRenderer";
5
+ import { bitmapToPng as bitmapToPngImpl } from "./render/png";
6
+ import { buildLabelContext, findTextBindings, substituteTextBindings } from "./render/binding";
3
7
  import type { VendorDriver as VendorDriverType, DeviceMetadata as DeviceMetadataType, DiscoveredDevice as DiscoveredDeviceType, VendorDeviceConfig as VendorDeviceConfigType, Colour as ColourType } from "./devices/types";
8
+ import type { TemplateProvider as TemplateProviderType, TemplateRenderRequest as TemplateRenderRequestType } from "./render/templateProviders";
9
+ import type { LabelMeta as LabelMetaType } from "./render/binding";
10
+ import type { Bitmap as BitmapType, TemplateContext as TemplateContextType } from "./render/types";
4
11
  /**
5
12
  * Public extension point for vendor packages. A package that adds support for a new
6
13
  * ESL vendor (e.g. `signalk-esl-shoplabelcorp-plugin`) imports this module and calls
@@ -8,19 +15,45 @@ import type { VendorDriver as VendorDriverType, DeviceMetadata as DeviceMetadata
8
15
  * `start()` (or at module load time). There's no scanning of installed packages -
9
16
  * registration is always an explicit call by the extension's own code.
10
17
  *
11
- * Declare this package as a `peerDependency` (not a regular dependency) in the
12
- * extension package, so npm resolves a single shared copy - otherwise the extension
13
- * would register into a different registry instance than the one this plugin reads from.
18
+ * Declare this package as a regular `dependency` in the extension package (**not** a
19
+ * `peerDependency` - the SignalK team's own guidance is that npm's peer-dependency resolution
20
+ * interacts poorly with the server's plugin install layout), and declare the SignalK-level
21
+ * relationship via `"signalk": { "requires": ["@rhizomatics/signalk-einklabel-plugin"] }` in the
22
+ * extension's own package.json instead, so the App Store can install/report the dependency.
14
23
  */
15
24
  declare function plugin(app: ServerAPI): Plugin;
16
25
  declare namespace plugin {
17
26
  const registerVendorDriver: typeof registerDriver;
18
27
  const getVendorDriver: typeof getDriver;
19
28
  const allVendorDrivers: typeof allDrivers;
29
+ /**
30
+ * Public extension point for a package offering an alternative to hand-authored SVG templates - e.g.
31
+ * `@rhizomatics/signalk-einklabel-genai-plugin` generating content from an LLM prompt - see
32
+ * `TemplateProvider`'s own doc comment (`./render/templateProviders.ts`) for the full contract. Same
33
+ * regular-`dependency`-plus-`signalk.requires` convention as `registerVendorDriver` above.
34
+ */
35
+ const registerTemplateProvider: typeof registerTemplateProviderImpl;
36
+ const getTemplateProvider: typeof findTemplateProviderImpl;
37
+ const allTemplateProviders: typeof allTemplateProvidersImpl;
38
+ /** Rasterizes an SVG string to a `Bitmap` - a template provider needs this to turn whatever SVG it produces (e.g. an LLM's response) into paintable pixels, exactly as a bundled template is rendered. */
39
+ const Renderer: typeof SvgRenderer;
40
+ /** Encodes a `Bitmap` as PNG bytes - useful for a CLI extension writing a preview file, same as the core plugin's own `render`/`generate` commands. */
41
+ const bitmapToPng: typeof bitmapToPngImpl;
42
+ /** Facts about one physical label (`source=label,path=...` bindings resolve against these) - see `./render/binding.ts`. */
43
+ const buildLabel: typeof buildLabelContext;
44
+ /** Every `{...}` placeholder referenced across one or more text fragments, parsed as bindings - see `./render/binding.ts`. */
45
+ const findBindingsInText: typeof findTextBindings;
46
+ /** Substitutes every `{...}` placeholder in `text` with its resolved binding value - see `./render/binding.ts`. */
47
+ const substituteBindingsInText: typeof substituteTextBindings;
20
48
  type VendorDriver = VendorDriverType;
21
49
  type DeviceMetadata = DeviceMetadataType;
22
50
  type DiscoveredDevice = DiscoveredDeviceType;
23
51
  type VendorDeviceConfig = VendorDeviceConfigType;
24
52
  type Colour = ColourType;
53
+ type TemplateProvider = TemplateProviderType;
54
+ type TemplateRenderRequest = TemplateRenderRequestType;
55
+ type LabelMeta = LabelMetaType;
56
+ type Bitmap = BitmapType;
57
+ type TemplateContext = TemplateContextType;
25
58
  }
26
59
  export = plugin;