@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 CHANGED
@@ -1,3 +1,25 @@
1
+ # 1.2.0
2
+
3
+ - Added support for Gicisky labels (also known by other brands), tested with a 2.9" BWRY model
4
+ - CLI render command use template height and width as defaults, can still be overridden by arguments to preview particular size of label
5
+ - CLI paint commands use label height and width as defaults, overridden by arguments if needed
6
+ - CLI paint command now has a `--reframe` option for labels that don't match template size, so can quickly test a label without having perfect template right away
7
+
8
+ # 1.1.0
9
+
10
+ - Label images can now optionally be emailed to an address every time they change, including a table of fields and values used to complete the template, or the prompt if genai based
11
+
12
+ # 1.0.0
13
+
14
+ ## Full Release
15
+
16
+ - The `1.0.0` reflects that this plugin has been working in real environment for months, and has been refactored for better extensibility
17
+
18
+ ## Extension Support
19
+
20
+ - Custom templates and CLI commands can now be provided by a different plugin
21
+ - 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
22
+
1
23
  # 0.10.1
2
24
 
3
25
  - Test fixes
package/README.md CHANGED
@@ -6,8 +6,9 @@
6
6
  [![codecov](https://img.shields.io/codecov/c/github/rhizomatics/signalk-einklabel-plugin)](https://codecov.io/gh/rhizomatics/signalk-einklabel-plugin)
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
+ [![boat tech directory](https://boat-tech-directory.rhizomatics.org.uk/images/badge.svg)](https://boat-tech-directory.rhizomatics.org.uk)
9
10
 
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.
11
+ 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
12
 
12
13
  ![Companionway Tidal Clock](docs/assets/images/real_tidal_clock.jpg)
13
14
 
@@ -17,7 +18,7 @@ Electronic Shelf Labels are [eInk](https://en.wikipedia.org/wiki/E_Ink) devices
17
18
 
18
19
  Since they are designed to be used in large quantity in small shops, they are cheap and simple devices. Earlier models required dedicated controllers, or updates over Wifi or NFC, whereas many modern ones are standalone BLE devices that can be updated from a phone or server.
19
20
 
20
- Being battery operated, they can be stuck on anywhere without wiring - the only location constraints are bluetooth range, visibility (they need ambient light since the display is more like paper than a traditional lit-up electronic display) and out of the weather since the devices are intended for indoor use.
21
+ Being battery operated, they can be stuck on anywhere without wiring - the only location constraints are bluetooth range, visibility (they need ambient light since the display is more like paper than a traditional lit-up electronic display) and, for some labels, being out of the weather if they are not waterproof, although IP65 labels are available.
21
22
 
22
23
  ## Pre-requisites
23
24
 
@@ -34,8 +35,9 @@ Most of requirements below are to make SignalK work with Bluetooth Low Energy, w
34
35
 
35
36
  - Bluetooth adapters for Linux can be tricky, TP-Link UB400 and Asus USB-BT500 are two well-known and available ones
36
37
  - Some Raspberry Pi models come with suitable Bluetooth built in
37
- - Don't worry about the very latest Bluetooth versions, 4.0 is minimum for BLE, 5.0 is nice
38
- - Home Assistant is massively more popular than SignalK, and often also run on Raspberry Pi and similar, so good source of advice
38
+
39
+ > - Don't worry about the very latest Bluetooth versions, 4.0 is minimum for BLE, 5.0 is nice
40
+ > - Home Assistant is massively more popular than SignalK, and often also run on Raspberry Pi and similar, so good source of advice
39
41
 
40
42
  3. `bluez` package installed in Linux
41
43
 
@@ -44,7 +46,7 @@ Most of requirements below are to make SignalK work with Bluetooth Low Energy, w
44
46
 
45
47
  4. One or more supported Electronic Shelf Labels
46
48
 
47
- - The label used for testing this is the [Zhsunyco 3.7" BWRY](https://www.aliexpress.com/item/1005010050104435.html)
49
+ - The labels used for testing this are the [Zhsunyco 3.7" BWRY](https://www.aliexpress.com/item/1005010050104435.html) and a [Gicisky 2.9" BWRY](https://www.aliexpress.com/item/1005012933325056.html)
48
50
 
49
51
  5. Correct time zone set on server if local time is to be shown on display
50
52
 
@@ -113,7 +115,8 @@ Enable the plugin, and use the large **+** sign to add a label, which opens up t
113
115
 
114
116
  - _Friendly Name_ - Give the label any name (word or phrase) you like, for example 'Tide Clock'
115
117
  - _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
118
+ - _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)")
119
+ - _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
120
  - _Repaint Trigger_- Do you want this to repaint every few hours (at a chosen minutes past hour), or when a SignalK path changes?
118
121
  - If it's a SignalK path, enter it next, for example `environment.tide.state`
119
122
  - 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.
@@ -146,6 +149,14 @@ Also known as 'Suny' and 'WOLink'.
146
149
 
147
150
  Python code for a variety of their labels at https://github.com/roxburghm/zhsunyco-esl and https://github.com/NickWaterton/Wolink
148
151
 
152
+ ### Gicisky
153
+
154
+ Known by other names, e.g. 'Picksmart', and with white label brands
155
+
156
+ - BLE ESLs
157
+ - Official store is on [AliExpress](https://www.aliexpress.com/store/911771479/pages/all-items.html?productGroupId=40000001654819&spm=a2g0o.store_pc_home.pcShopHead_6000727597996.1_1)
158
+ - Cheapest labels under £10 GBP / $13 USD
159
+
149
160
  ## Templating
150
161
 
151
162
  Templates are simply SVG files, to which expressions can be added to use SignalK data, with options to make it easier to read, like rounding or simplifying dates and times. The template can have sample data in the placeholder, so is easy to layout and visualize.
@@ -207,6 +218,8 @@ Note that for dates and times, the server timezone must be set correctly, for ex
207
218
 
208
219
  Additionally, `round=n` can be used to round to limited decimal places.
209
220
 
221
+ `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.
222
+
210
223
  These can all be combined as in `source=resources,resource=tides,provider=tides,path=extremes[2].level,category=depth,round=2`
211
224
 
212
225
  ### Non-Textual Fields (Images)
@@ -231,6 +244,16 @@ Three font types are loaded by default, use the generic font family, or exact fo
231
244
  - `sans-serif` - `Roboto`
232
245
  - `monospace` - `Roboto Mono`
233
246
 
247
+ ## GenAI Rendering
248
+
249
+ 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.
250
+
251
+ 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.
252
+
253
+ 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`.
254
+
255
+ 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.
256
+
234
257
  ## Architecture
235
258
 
236
259
  The primary things managed and provided by the plugin are:
@@ -257,7 +280,17 @@ See also the commands useful for debugging under [Developing Templates]
257
280
  - `render` - transform an SVG template and data into a PNG
258
281
  - `paint` - render an SVG template and data to a selected ESL
259
282
 
260
- 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.
283
+ 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 with `-w/--width`, `--height`, `--voffset` and `--colours`. This could be used to help you choose what size of label to buy, or to get an unsupported label working.
284
+
285
+ Left unset, both `render` and `paint` default `-w/--width`/`--height` to the template's own declared `width`/`height` (or `viewBox`) - neither command connects to a device just to size the render, since that would mean an extra BLE connect ahead of `paint`'s own, and doing two back-to-back is exactly the kind of churn that trips real BLE hardware.
286
+
287
+ `paint` also takes `--reframe <mode>`, applied once it has connected and identified the device, for when the rendered image doesn't come out the same size as its actual panel:
288
+
289
+ - `fixed` (default) - no adjustment; a size mismatch is rejected with an error, as it always has been
290
+ - `scale` - stretches the rendered image onto the panel's exact dimensions (independently per axis, not preserving aspect ratio)
291
+ - `crop` - keeps pixels 1:1, placed from the top-left; a bigger render is truncated to fit, a smaller one leaves the extra panel space blank
292
+
293
+ `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.
261
294
 
262
295
  ( 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
296
 
@@ -278,7 +311,13 @@ Additional vendors and devices can be added by a separate npm package that imple
278
311
 
279
312
  - `import esl from '@rhizomatics/signalk-einklabel-plugin'; esl.registerVendorDriver(myDriver)`
280
313
  - 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.
314
+ - 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.
315
+
316
+ ### Template Providers
317
+
318
+ 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.
319
+
320
+ `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
321
 
283
322
  ### Developing Templates
284
323
 
@@ -307,8 +346,40 @@ The `esl-cli` can be used to debug and validate templates quickly:
307
346
  - `fields` - List the fields in the template, with the source specification and the rendered data value
308
347
  - `field` - Accept a source specification (outside of any template context) and return the rendered value if available
309
348
 
349
+ Use `--help` to get the full set of arguments for any of the commands.
350
+
310
351
  ### Examples
311
352
 
353
+ #### Paint Image Directly
354
+
355
+ The label address previously discovered via `esl-cli scan`
356
+
357
+ ```bash
358
+ npx esl-cli paint -t templates/tides/250x128-BWRY.svg -a FF:FF:92:84:53:93
359
+ ```
360
+
361
+ If the label turns out to be a different size than the template (e.g. it's a 250x128 template on a 416x240 panel), that's normally rejected as a mismatch - add `--reframe` to fit it instead:
362
+
363
+ ```bash
364
+ npx esl-cli paint -t templates/tides/250x128-BWRY.svg -a FF:FF:92:84:53:93 --reframe scale
365
+ ```
366
+
367
+ #### Test Template Without Updating Label
368
+
369
+ This will work even if you don't have a label, or even bluetooth. (The `-u` can be left out if your SignalK server running locally on default ports).
370
+
371
+ ```bash
372
+ npx esl-cli render -t templates/tides/250x128-BWRY.svg -o example.png -u http://localhost
373
+ ```
374
+
375
+ and this version will work even without a running SignalK server, using some pre-packaged example data:
376
+
377
+ ```bash
378
+ npx esl-cli render -t templates/tides/250x128-BWRY.svg -o example.png -e examples
379
+ ```
380
+
381
+
382
+
312
383
  #### List all Fields and Rendered Values
313
384
 
314
385
  ```bash
@@ -380,7 +451,38 @@ If you have an SVG viewer extension, this will show the image rather than allowi
380
451
 
381
452
  Check if the text boxes are normal text or flowed text, and correct to normal text.
382
453
 
383
- ## Other ESL and General eInk Resources
454
+ ### My label just shows "CONTENT UNAVAILABLE"
455
+
456
+ 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.
457
+
458
+ ### SignalK starts before the Bluetooth daemon — does the plugin need `bluetoothd` running at boot?
459
+
460
+ 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.
461
+
462
+ 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:
463
+
464
+ ```bash
465
+ sudo systemctl edit signalk.service
466
+ ```
467
+
468
+ This opens an override file — add:
469
+
470
+ ```ini
471
+ [Unit]
472
+ After=bluetooth.target
473
+ Wants=bluetooth.target
474
+ ```
475
+
476
+ Save and exit, then:
477
+
478
+ ```bash
479
+ sudo systemctl daemon-reload
480
+ sudo systemctl restart signalk
481
+ ```
482
+
483
+ 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.
484
+
485
+ ## Other ESL and General eInk Resources
384
486
 
385
487
  - [Open ePaper Link](https://openepaperlink.de) - Alternative open source firmware to flash onto eInk shelf labels, with Home Assistant integration.
386
488
  - [zhsunyco-esl](https://github.com/roxburghm/zhsunyco-esl) - Python interface
@@ -1,2 +1,16 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ import { Command } from "commander";
3
+ import { Colour } from "../devices/types";
4
+ import { ReframeMode } from "../render/reframe";
5
+ import { Binding } from "../render/binding";
6
+ import { TemplateContext } from "../render/types";
7
+ /** Tried in order when -u/--url is omitted (and -e/--example-data isn't given) - first one that answers wins. */
8
+ export declare const DEFAULT_SIGNALK_URLS: string[];
9
+ export declare function parseColours(code: string): Colour[];
10
+ export declare function parseReframeMode(value: string): ReframeMode;
11
+ /** 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. */
12
+ export declare function assembleContext(opts: {
13
+ url?: string;
14
+ exampleData?: string;
15
+ }, bindings: Binding[]): Promise<TemplateContext>;
16
+ export declare const program: Command;
package/dist/cli/index.js CHANGED
@@ -1,12 +1,17 @@
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.parseReframeMode = parseReframeMode;
7
+ exports.assembleContext = assembleContext;
4
8
  const promises_1 = require("fs/promises");
5
9
  const path_1 = require("path");
6
10
  const commander_1 = require("commander");
7
11
  const xmldom_1 = require("@xmldom/xmldom");
8
12
  const registry_1 = require("../devices/registry");
9
13
  const zhsunyco_1 = require("../devices/zhsunyco");
14
+ const gicisky_1 = require("../devices/gicisky");
10
15
  const bleDiscovery_1 = require("../devices/bleDiscovery");
11
16
  const svgRenderer_1 = require("../render/svgRenderer");
12
17
  const png_1 = require("../render/png");
@@ -17,9 +22,10 @@ const liveContext_1 = require("./liveContext");
17
22
  const httpJson_1 = require("../httpJson");
18
23
  const log_1 = require("./log");
19
24
  (0, registry_1.registerDriver)(new zhsunyco_1.ZhsunycoDriver());
25
+ (0, registry_1.registerDriver)(new gicisky_1.GiciskyDriver());
20
26
  const VENDOR_IDENTIFY_TIMEOUT_MS = 30000;
21
27
  /** 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"];
28
+ exports.DEFAULT_SIGNALK_URLS = ["http://localhost", "http://localhost:3000", "https://localhost"];
23
29
  const COLOUR_CODES = {
24
30
  BW: ["black", "white"],
25
31
  BWR: ["black", "white", "red"],
@@ -32,9 +38,16 @@ function parseColours(code) {
32
38
  }
33
39
  return colours;
34
40
  }
41
+ const REFRAME_MODES = ["fixed", "scale", "crop"];
42
+ function parseReframeMode(value) {
43
+ if (!REFRAME_MODES.includes(value)) {
44
+ throw new Error(`unknown --reframe value "${value}" - expected one of ${REFRAME_MODES.join(", ")}`);
45
+ }
46
+ return value;
47
+ }
35
48
  /** Probes DEFAULT_SIGNALK_URLS in order and returns the first that answers a plain GET - used when -u/--url is omitted. */
36
49
  async function resolveDefaultUrl() {
37
- for (const candidate of DEFAULT_SIGNALK_URLS) {
50
+ for (const candidate of exports.DEFAULT_SIGNALK_URLS) {
38
51
  try {
39
52
  (0, log_1.logDebug)(`probing ${candidate} as a default SignalK server`);
40
53
  await (0, httpJson_1.fetchJson)(`${candidate}/signalk`);
@@ -44,7 +57,15 @@ async function resolveDefaultUrl() {
44
57
  (0, log_1.logDebug)(`${candidate} did not answer: ${err.message}`);
45
58
  }
46
59
  }
47
- throw new Error(`no -u/--url given and none of ${DEFAULT_SIGNALK_URLS.join(", ")} answered - specify the server explicitly with -u/--url`);
60
+ throw new Error(`no -u/--url given and none of ${exports.DEFAULT_SIGNALK_URLS.join(", ")} answered - specify the server explicitly with -u/--url`);
61
+ }
62
+ const DEFAULT_RENDER_WIDTH = 416;
63
+ const DEFAULT_RENDER_HEIGHT = 240;
64
+ /** Shared by every command's -w/--width and --height: an explicit flag always wins; otherwise the first defined value in `sources` (in order); otherwise a generic last-resort default. */
65
+ function resolveDimension(explicit, sources, fallback) {
66
+ if (explicit !== undefined)
67
+ return Number(explicit);
68
+ return sources.find((value) => value !== undefined) ?? fallback;
48
69
  }
49
70
  /** 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
71
  async function assembleContext(opts, bindings) {
@@ -73,21 +94,49 @@ async function identifyVendor(address) {
73
94
  destroy();
74
95
  }
75
96
  }
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);
97
+ /**
98
+ * Values passed via `-r`/`--require` (repeatable, `--require=value` form included), read directly from
99
+ * `process.argv` rather than through commander - see the doc comment below on why.
100
+ */
101
+ function scanRequireModules(argv) {
102
+ const modules = [];
103
+ for (let i = 0; i < argv.length; i++) {
104
+ const arg = argv[i];
105
+ if (arg === "-r" || arg === "--require") {
106
+ if (argv[i + 1])
107
+ modules.push(argv[i + 1]);
108
+ }
109
+ else if (arg.startsWith("--require=")) {
110
+ modules.push(arg.slice("--require=".length));
111
+ }
88
112
  }
113
+ return modules;
114
+ }
115
+ // `program` is created (and exported) before anything is `require()`'d below, and before any built-in
116
+ // `.command(...)` is defined - a `-r`'d extension module commonly `require()`s this same compiled file
117
+ // (e.g. `require(".../dist/cli/index.js")`) to get at `program`/`assembleContext`/etc; since that's a
118
+ // circular require back into the module currently being loaded, Node hands it back this module's
119
+ // *exports object as it exists so far*, not the finished one - so `program` has to already be assigned
120
+ // by this point, or the extension would see it as `undefined`.
121
+ exports.program = new commander_1.Command();
122
+ exports.program.name("esl-cli").description("Local CLI for testing ESL device scan and paint without a SignalK server");
123
+ 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]);
124
+ exports.program.option("-l, --log-level <level>", "log verbosity: info or debug (e.g. trace which URLs are fetched)", "info");
125
+ exports.program.hook("preAction", () => {
126
+ (0, log_1.setLogLevel)(exports.program.opts().logLevel);
89
127
  });
90
- program
128
+ // Loaded here - after `program` exists (see above) but before any built-in `.command(...)` is defined,
129
+ // and well before `.parseAsync()` at the bottom of this file - not in a commander `preAction` hook (as
130
+ // this used to work), which only runs *after* commander has already resolved which command was invoked
131
+ // and parsed its arguments. That timing is fine for a `-r`'d module that only registers a vendor driver
132
+ // or template provider (an already-defined command's own action merely consults those registries when
133
+ // it runs), but too late for one to contribute a brand-new *subcommand* (e.g. a companion plugin adding
134
+ // its own `prompt`/`generate`) to this same invocation - by the time `preAction` fires, commander has
135
+ // already decided there's no such command.
136
+ for (const mod of scanRequireModules(process.argv)) {
137
+ require(mod);
138
+ }
139
+ exports.program
91
140
  .command("vendors")
92
141
  .description("List supported vendors and the device models each has confirmed metadata for")
93
142
  .action(() => {
@@ -114,10 +163,10 @@ program
114
163
  printRow(header);
115
164
  rows.forEach(printRow);
116
165
  });
117
- program
166
+ exports.program
118
167
  .command("scan")
119
168
  .description("Scan for supported BLE ESL devices across all registered vendor drivers")
120
- .option("-d, --duration <seconds>", "scan duration in seconds", "10")
169
+ .option("-d, --duration <seconds>", "scan duration in seconds", "30")
121
170
  .option("-a, --all-devices", "list every nearby BLE device, not just ones a registered driver recognised - unmatched devices show address/name/mfr/rssi only, since there's no driver to do a vendor-specific read like battery")
122
171
  .action(async (opts) => {
123
172
  const durationMs = Number(opts.duration) * 1000;
@@ -161,21 +210,22 @@ program
161
210
  printRow(header);
162
211
  rows.forEach(printRow);
163
212
  });
164
- program
213
+ exports.program
165
214
  .command("paint")
166
215
  .description("Render a template against a live SignalK server and send it to a device")
167
216
  .option("-v, --vendor <vendor>", "vendor driver to use - if omitted, inferred from the device's advertised name")
168
217
  .requiredOption("-a, --address <address>", "BLE address of the device")
169
218
  .requiredOption("-t, --template <path>", "path to SVG template")
170
219
  .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(", ") +
220
+ exports.DEFAULT_SIGNALK_URLS.join(", ") +
172
221
  " in turn")
173
222
  .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
223
  .option("-k, --aes-key <hex>", "AES-128 key for device authentication, as 32 hex characters - defaults to the vendor's stock key if omitted")
175
- .option("-w, --width <px>", "render width", "416")
176
- .option("--height <px>", "render height", "240")
224
+ .option("-w, --width <px>", "render width - defaults to the template's declared width/viewBox - see --reframe for fitting onto a differently-sized panel")
225
+ .option("--height <px>", "render height - defaults to the template's declared height/viewBox - see --reframe for fitting onto a differently-sized panel")
177
226
  .option("--voffset <px>", "vertical pixel offset of the panel - overrides the looked-up model for unsupported hardware (requires --colours)", "0")
178
227
  .option("--colours <code>", "device colour palette for unsupported hardware: BW, BWR, or BWRY - overrides the looked-up model (uses --width/--height/--voffset)")
228
+ .option("--reframe <mode>", "how to fit the rendered image onto the device's actual panel size when it doesn't match: fixed (default - reject the mismatch, as always), scale (stretch the template to the panel), crop (place at top-left, truncating or leaving the rest blank)", "fixed")
179
229
  .option("--connect-timeout <seconds>", "BLE connect timeout before giving up on an attempt", "30")
180
230
  .option("--retries <n>", "number of paint attempts (including the first) before giving up", "3")
181
231
  .action(async (opts) => {
@@ -184,19 +234,30 @@ program
184
234
  if (!driver) {
185
235
  throw new Error(`no driver registered for vendor "${vendor}"`);
186
236
  }
237
+ const svgSource = await (0, promises_1.readFile)(opts.template, "utf-8");
238
+ // Defaults from the template's own declared size, same as `render` - not from the device's real
239
+ // panel size, which would need its own identify connect ahead of driver.paint()'s own connect;
240
+ // two back-to-back BLE connects on the same device is exactly the kind of churn that trips BlueZ
241
+ // (confirmed: identifyDevice() does a full connect+disconnect for zhsunyco, to read its PID off a
242
+ // GATT characteristic - immediately followed by paint()'s own connect crashed with a raw D-Bus
243
+ // "recipient disconnected from message bus without replying" on real hardware). --reframe below
244
+ // covers a mismatch against the real panel size within paint()'s own single connect instead.
245
+ const declared = (0, binding_1.readTemplateDimensions)(svgSource);
246
+ const width = resolveDimension(opts.width, [declared.width], DEFAULT_RENDER_WIDTH);
247
+ const height = resolveDimension(opts.height, [declared.height], DEFAULT_RENDER_HEIGHT);
187
248
  const modelOverride = opts.colours
188
249
  ? {
189
250
  label: "manual override",
190
- width: Number(opts.width),
191
- height: Number(opts.height),
251
+ width,
252
+ height,
192
253
  voffset: Number(opts.voffset),
193
254
  colours: parseColours(opts.colours),
194
255
  }
195
256
  : undefined;
196
- const bindings = (0, binding_1.findBindings)(await (0, promises_1.readFile)(opts.template, "utf-8"));
257
+ const bindings = (0, binding_1.findBindings)(svgSource);
197
258
  const context = await assembleContext(opts, bindings);
198
259
  const renderer = new svgRenderer_1.SvgRenderer();
199
- const bitmap = await renderer.render(opts.template, context, Number(opts.width), Number(opts.height), (0, path_1.dirname)(opts.template), config_1.BUNDLED_TEMPLATES_DIR);
260
+ const bitmap = await renderer.render(opts.template, context, width, height, (0, path_1.dirname)(opts.template), config_1.BUNDLED_TEMPLATES_DIR);
200
261
  const connectTimeoutMs = Number(opts.connectTimeout) * 1000;
201
262
  await (0, bleDiscovery_1.withRetries)(Number(opts.retries), async (attempt) => {
202
263
  if (attempt > 1) {
@@ -207,38 +268,43 @@ program
207
268
  aesKey: opts.aesKey,
208
269
  modelOverride,
209
270
  connectTimeoutMs,
271
+ reframe: parseReframeMode(opts.reframe),
210
272
  });
211
273
  });
212
274
  console.log(`painted ${opts.address} (${bitmap.width}x${bitmap.height}) ${opts.colours}`);
213
275
  });
214
- program
276
+ exports.program
215
277
  .command("render")
216
278
  .description("Render a template against a live SignalK server and write a PNG, without needing a device")
217
279
  .requiredOption("-t, --template <path>", "path to SVG template")
218
280
  .requiredOption("-o, --output <path>", "output PNG path")
219
281
  .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(", ") +
282
+ exports.DEFAULT_SIGNALK_URLS.join(", ") +
221
283
  " in turn")
222
284
  .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
- .option("-w, --width <px>", "render width", "416")
224
- .option("--height <px>", "render height", "240")
285
+ .option("-w, --width <px>", "render width - defaults to the template's declared width/viewBox")
286
+ .option("--height <px>", "render height - defaults to the template's declared height/viewBox")
225
287
  .option("-f, --font <path>", "override a bundled font with this file (repeatable) - defaults to the bundled monospace/sans-serif/serif trio",
226
288
  // See the -r/--require option above for why `| undefined` is needed here despite commander's typings.
227
289
  (value, previous = []) => [...previous, value])
228
290
  .action(async (opts) => {
229
- const bindings = (0, binding_1.findBindings)(await (0, promises_1.readFile)(opts.template, "utf-8"));
291
+ const svgSource = await (0, promises_1.readFile)(opts.template, "utf-8");
292
+ const declared = (0, binding_1.readTemplateDimensions)(svgSource);
293
+ const width = resolveDimension(opts.width, [declared.width], DEFAULT_RENDER_WIDTH);
294
+ const height = resolveDimension(opts.height, [declared.height], DEFAULT_RENDER_HEIGHT);
295
+ const bindings = (0, binding_1.findBindings)(svgSource);
230
296
  const context = await assembleContext(opts, bindings);
231
297
  const renderer = opts.font ? new svgRenderer_1.SvgRenderer(opts.font) : new svgRenderer_1.SvgRenderer();
232
- const bitmap = await renderer.render(opts.template, context, Number(opts.width), Number(opts.height), (0, path_1.dirname)(opts.template), config_1.BUNDLED_TEMPLATES_DIR);
298
+ const bitmap = await renderer.render(opts.template, context, width, height, (0, path_1.dirname)(opts.template), config_1.BUNDLED_TEMPLATES_DIR);
233
299
  await (0, promises_1.writeFile)(opts.output, (0, png_1.bitmapToPng)(bitmap));
234
300
  console.log(`wrote ${opts.output} (${bitmap.width}x${bitmap.height})`);
235
301
  });
236
- program
302
+ exports.program
237
303
  .command("fields")
238
304
  .description("List every <desc> binding in a template by element id, with its source spec and resolved value")
239
305
  .requiredOption("-t, --template <path>", "path to SVG template")
240
306
  .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(", ") +
307
+ exports.DEFAULT_SIGNALK_URLS.join(", ") +
242
308
  " in turn")
243
309
  .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
310
  .action(async (opts) => {
@@ -296,12 +362,12 @@ program
296
362
  printRow(header);
297
363
  table.forEach(printRow);
298
364
  });
299
- program
365
+ exports.program
300
366
  .command("field")
301
367
  .description("Resolve a single binding spec directly against a live SignalK server, with no template")
302
368
  .argument("<spec>", 'binding spec, e.g. "source=resources,resource=tides,path=station.name" or a bare SignalK path')
303
369
  .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(", ") +
370
+ exports.DEFAULT_SIGNALK_URLS.join(", ") +
305
371
  " in turn")
306
372
  .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
373
  .action(async (spec, opts) => {
@@ -309,7 +375,7 @@ program
309
375
  const context = await assembleContext(opts, [binding]);
310
376
  console.log((0, binding_1.renderBinding)(binding, context));
311
377
  });
312
- program.parseAsync(process.argv).catch((err) => {
378
+ exports.program.parseAsync(process.argv).catch((err) => {
313
379
  console.error(err.message);
314
380
  process.exitCode = 1;
315
381
  });
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;