@rhizomatics/signalk-einklabel-plugin 1.3.1 → 1.3.2

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,13 @@
1
+ # 1.3.2
2
+
3
+ ## 🐛 Bug fixes
4
+
5
+ - On a SignalK server shutdown or admin UI restart (server v2.35.0+), the plugin now waits at most 5 seconds for a repaint in progress instead of 30, so it stops inside the server's shutdown deadline and the server can close the label's Bluetooth connection cleanly before exiting
6
+
7
+ ## 📚 Documentation
8
+
9
+ - [Bluetooth](https://signalk-einklabel.rhizomatics.org.uk/bluetooth/) docs updated for the BLE Manager fixes in SignalK v2.34.0 and v2.35.0. v2.34.0 is now the practical minimum for BLE Manager, with v2.35.0 recommended. The BLE Manager setting stays off by default, since Zhsunyco labels not yet proven with it.
10
+
1
11
  # 1.3.1
2
12
 
3
13
  ## 🐛 Bug fixes
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![SignalK Plugin CI](https://github.com/rhizomatics/signalk-einklabel-plugin/actions/workflows/signalk-ci.yml/badge.svg)](https://github.com/rhizomatics/signalk-einklabel-plugin/actions/workflows/signalk-ci.yml)
6
6
  [![code style: oxfmt](https://img.shields.io/badge/code_style-oxfmt-blue.svg)](https://github.com)
7
7
  [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://github.com/rhizomatics/signalk-einklabel-plugin/blob/main/LICENSE)
8
- [![boat tech directory](https://boat-tech-directory.rhizomatics.org.uk/images/badge.svg)](https://boat-tech-directory.rhizomatics.org.uk)
8
+ [![boat tech directory](https://boat-tech-directory.rhizomatics.org.uk/images/badge.svg)](https://boat-tech-directory.rhizomatics.org.uk) [![managed by hq](https://raw.githubusercontent.com/rhizomatics/hq/main/assets/badge-managed-by-hq.svg)](https://github.com/rhizomatics/hq)
9
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, 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. Supports ESLs from two of the major Chinese manufacturers, and requires **no firmware or hardware modifications**, switch on and go.
11
11
 
package/dist/cli/index.js CHANGED
File without changes
@@ -213,7 +213,9 @@ function bleApiBackend(bleApi, pluginId) {
213
213
  // A previous ungraceful stop (crash, plugin reload mid-paint) can leave a stale GATT claim
214
214
  // registered under our own pluginId, which would otherwise block this connect with
215
215
  // "already claimed" until the server restarts - see signalk-bluetti-plugin's BleManagerDevice
216
- // for the same defensive call. A no-op if we don't currently hold the claim.
216
+ // for the same defensive call. A no-op if we don't currently hold the claim. Servers from 2.34.0
217
+ // release a stopped plugin's claims themselves (signalk-server#3093), so this only matters on
218
+ // older ones - kept for them.
217
219
  await boundedCleanup(bleApi.releaseGATTDevice(address, pluginId));
218
220
  await ensureDeviceVisible(bleApi, pluginId, address, DEVICE_DISCOVERY_TIMEOUT_MS);
219
221
  const connecting = bleApi.connectGATT(address, pluginId);
@@ -232,9 +234,12 @@ function bleApiBackend(bleApi, pluginId) {
232
234
  // `connectGATT()` and get rejected with "has a GATT claim in progress" for its trouble.
233
235
  await boundedCleanup(bleApi.releaseGATTDevice(address, pluginId));
234
236
  // ...except a claim still *connecting* isn't in the server's claim table yet, only its pending
235
- // set, which `releaseGATTDevice` doesn't touch - so while the server's own connect is still in
236
- // flight, a retry's `connectGATT()` is rejected outright with "has a GATT claim in progress".
237
- // Give that connect a bounded chance to settle first, so the retry gets a clean slate.
237
+ // set. Before 2.34.0 `releaseGATTDevice` didn't touch that at all; since then
238
+ // (signalk-server#3092) it marks the pending claim released, so the server closes the
239
+ // connection itself when it arrives - but the device stays reserved until the server's own
240
+ // connect returns (bounded at 60s since signalk-server#3082), and until then a retry's
241
+ // `connectGATT()` is rejected outright with "has a GATT claim in progress". Either way, give
242
+ // that connect a bounded chance to settle first, so the retry gets a clean slate.
238
243
  const late = await Promise.race([
239
244
  connecting.catch(() => undefined),
240
245
  (0, bleDiscovery_1.sleep)(Math.min(timeoutMs, CONNECT_SETTLE_GRACE_MS)).then(() => undefined),
@@ -282,7 +287,7 @@ function bleApiBackend(bleApi, pluginId) {
282
287
  // ever reaches `connectGatt` below) stops advertising while claimed - which would otherwise wedge
283
288
  // this wait forever, since nothing else in this path ever calls `connectGatt` (and so never gets a
284
289
  // chance to release the stale claim) unless a fresh advertisement shows up first. A no-op if we
285
- // don't currently hold the claim.
290
+ // don't currently hold the claim; only needed before server 2.34.0, as in `connectGatt`.
286
291
  await boundedCleanup(bleApi.releaseGATTDevice(address, pluginId));
287
292
  return new Promise((resolve) => {
288
293
  const timer = setTimeout(() => {
package/dist/plugin.js CHANGED
@@ -104,14 +104,18 @@ function createPlugin(app) {
104
104
  },
105
105
  // Returns a Promise, as the server asks of a stop() with async work: it resolves once a paint
106
106
  // already under way has finished (bounded by `STOP_DRAIN_TIMEOUT_MS`), so a config save mid-paint
107
- // or a server shutdown doesn't leave that BLE session running behind the next start().
108
- stop() {
107
+ // or a server shutdown doesn't leave that BLE session running behind the next start(). On a
108
+ // server shutdown (`reason`, passed by signalk-server 2.35.0+) the wait is cut to
109
+ // `SHUTDOWN_DRAIN_TIMEOUT_MS` to fit the server's own shutdown deadline - see its doc comment.
110
+ // `reason` is typed loosely so this still compiles against server-api versions before 2.35.0.
111
+ stop(options) {
109
112
  generation++;
110
113
  const stoppingScheduler = scheduler;
111
114
  scheduler = undefined;
112
115
  stopDiscoveryListener?.();
113
116
  stopDiscoveryListener = undefined;
114
- stopping = (stoppingScheduler?.stop() ?? Promise.resolve()).then(() => app.debug("stopped"));
117
+ const drainTimeoutMs = options?.reason === "shutdown" ? repaintScheduler_1.SHUTDOWN_DRAIN_TIMEOUT_MS : repaintScheduler_1.STOP_DRAIN_TIMEOUT_MS;
118
+ stopping = (stoppingScheduler?.stop(drainTimeoutMs) ?? Promise.resolve()).then(() => app.debug(options?.reason ? `stopped (${options.reason})` : "stopped"));
115
119
  return stopping;
116
120
  },
117
121
  };
@@ -7,12 +7,21 @@ import { PluginConfig } from "./config";
7
7
  * stuck; such a paint is still ended by its own connect timeout and `PAINT_ATTEMPT_BACKSTOP_MS`.
8
8
  */
9
9
  export declare const STOP_DRAIN_TIMEOUT_MS = 30000;
10
+ /**
11
+ * The drain limit for a server shutdown (signalk-server 2.35.0+ passes `stop()` a `reason`), in place
12
+ * of `STOP_DRAIN_TIMEOUT_MS`. The server gives all plugins' `stop()`s 6s together before it moves on,
13
+ * and exits at 8s - so a 30s drain would be cut off anyway, mid-transfer. Resolving inside that
14
+ * window instead lets the server's own release of this plugin's BLE Manager claims (signalk-server
15
+ * #3093, run once `stop()` returns) close the connection before the process exits. Since 2.35.0 the
16
+ * admin UI's Restart button goes through the same shutdown path; a config save doesn't.
17
+ */
18
+ export declare const SHUTDOWN_DRAIN_TIMEOUT_MS = 5000;
10
19
  export interface RepaintScheduler {
11
20
  /**
12
21
  * Stops every trigger, lets no new paint or retry start, and resolves once any paint already in
13
- * progress finishes - or after `STOP_DRAIN_TIMEOUT_MS`, whichever comes first.
22
+ * progress finishes - or after `drainTimeoutMs` (default `STOP_DRAIN_TIMEOUT_MS`), whichever comes first.
14
23
  */
15
- stop(): Promise<void>;
24
+ stop(drainTimeoutMs?: number): Promise<void>;
16
25
  }
17
26
  /**
18
27
  * The most recent wall-clock instant at or before `now` that an `interval`-triggered device's own
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.STOP_DRAIN_TIMEOUT_MS = void 0;
3
+ exports.SHUTDOWN_DRAIN_TIMEOUT_MS = exports.STOP_DRAIN_TIMEOUT_MS = void 0;
4
4
  exports.mostRecentScheduledSlot = mostRecentScheduledSlot;
5
5
  exports.oneAtATimePerKey = oneAtATimePerKey;
6
6
  exports.startRepaintScheduler = startRepaintScheduler;
@@ -41,6 +41,15 @@ const PAINT_ATTEMPT_BACKSTOP_MS = 7 * 60000;
41
41
  * stuck; such a paint is still ended by its own connect timeout and `PAINT_ATTEMPT_BACKSTOP_MS`.
42
42
  */
43
43
  exports.STOP_DRAIN_TIMEOUT_MS = 30000;
44
+ /**
45
+ * The drain limit for a server shutdown (signalk-server 2.35.0+ passes `stop()` a `reason`), in place
46
+ * of `STOP_DRAIN_TIMEOUT_MS`. The server gives all plugins' `stop()`s 6s together before it moves on,
47
+ * and exits at 8s - so a 30s drain would be cut off anyway, mid-transfer. Resolving inside that
48
+ * window instead lets the server's own release of this plugin's BLE Manager claims (signalk-server
49
+ * #3093, run once `stop()` returns) close the connection before the process exits. Since 2.35.0 the
50
+ * admin UI's Restart button goes through the same shutdown path; a config save doesn't.
51
+ */
52
+ exports.SHUTDOWN_DRAIN_TIMEOUT_MS = 5000;
44
53
  const RESOURCES_API_PATH = "/signalk/v2/api/resources";
45
54
  /**
46
55
  * The most recent wall-clock instant at or before `now` that an `interval`-triggered device's own
@@ -559,7 +568,7 @@ function startRepaintScheduler(app, config) {
559
568
  }, settleMs);
560
569
  unsubscribes.push(() => clearTimeout(startupCheckTimer));
561
570
  return {
562
- async stop() {
571
+ async stop(drainTimeoutMs = exports.STOP_DRAIN_TIMEOUT_MS) {
563
572
  stopping = true;
564
573
  for (const unsubscribe of unsubscribes)
565
574
  unsubscribe();
@@ -568,12 +577,12 @@ function startRepaintScheduler(app, config) {
568
577
  const drained = await Promise.race([
569
578
  repaint.idle().then(() => true),
570
579
  new Promise((resolve) => {
571
- timer = setTimeout(() => resolve(false), exports.STOP_DRAIN_TIMEOUT_MS);
580
+ timer = setTimeout(() => resolve(false), drainTimeoutMs);
572
581
  }),
573
582
  ]);
574
583
  clearTimeout(timer);
575
584
  if (!drained) {
576
- app.debug(`a repaint was still running ${exports.STOP_DRAIN_TIMEOUT_MS / 1000}s after stop - not waiting any longer`);
585
+ app.debug(`a repaint was still running ${drainTimeoutMs / 1000}s after stop - not waiting any longer`);
577
586
  }
578
587
  },
579
588
  };
package/docs/bluetooth.md CHANGED
@@ -32,15 +32,19 @@ SignalK BLE Manager also supports BLE Gateways, which could be an MQTT topic or
32
32
 
33
33
  ## BLE Manager Readiness
34
34
 
35
- v2.31.0 is the minumum version of SignalK possible for BLE Manager. Several fixes went in to v2.33.0 so this is the practical minimum version for using the plugin.
35
+ v2.31.0 is the minimum version of SignalK possible for BLE Manager. v2.34.0 is the practical minimum for using it with this plugin, and v2.35.0 is recommended.
36
36
 
37
- Gicisky labels have been painted successfully using BLE Manager, however Zhsunyco have some different interactions that are waiting other fixes.
37
+ Gicisky labels have been painted successfully using BLE Manager. Zhsunyco labels needed three server fixes, all released in v2.34.0:
38
38
 
39
39
  - [PR#3082](https://github.com/SignalK/signalk-server/pull/3082) - hung connections locking up device
40
40
  - [PR#3088](https://github.com/SignalK/signalk-server/pull/3088) - Support plain GATT write requests
41
41
  - [PR#3089](https://github.com/SignalK/signalk-server/pull/3089) - Pause scanning while GATT operation in progress
42
42
 
43
- There's a workaround for **PR#3088** available in the _Advanced Options_, and **PR#3082** isn't a problem if operations don't fail, however **PR#3089** is a blocker for using Zhsunyco labels - they'll fail with a `0x0e` error code.
43
+ Zhsunyco labels still have problems through BLE Manager even with these fixes, so the BLE Manager setting stays off by default for now. Direct BlueZ access remains the reliable choice for them.
44
+
45
+ v2.34.0 also releases a plugin's Bluetooth connections when the plugin stops ([PR#3093](https://github.com/SignalK/signalk-server/pull/3093)). v2.35.0 stops plugins cleanly when the server shuts down or is restarted from the admin UI ([PR#3194](https://github.com/SignalK/signalk-server/pull/3194)). On a shutdown this plugin only waits 5 seconds for a label part way through a repaint, rather than the usual 30, so that it finishes inside the server's shutdown deadline.
46
+
47
+ With BLE Manager, scanning pauses while any plugin is connecting to a Bluetooth device, for up to 60 seconds. If another plugin's device keeps failing to connect, a label may not be seen in time for a repaint, which then fails and is retried.
44
48
 
45
49
  ## Weak Signal
46
50
 
@@ -64,9 +68,9 @@ Sometime Bluetooth adapters, and/or the Linux services that use them, can get in
64
68
 
65
69
  ## Zhsunyco Labels and the BLE Manager
66
70
 
67
- With the "Use the SignalK BLE Manager API" setting on, Zhsunyco labels can connect and authenticate, then fail as soon as the image upload starts, with `Operation failed with ATT error: 0x0e` in the log. SignalK's BLE Manager (2.33 and earlier) sends every write that waits for an acknowledgement as a Bluetooth "reliable" write, which these labels don't support.
71
+ With the "Use the SignalK BLE Manager API" setting on, Zhsunyco labels can connect and authenticate, then fail as soon as the image upload starts, with `Operation failed with ATT error: 0x0e` in the log. SignalK's BLE Manager (2.33 and earlier) sends every write that waits for an acknowledgement as a Bluetooth "reliable" write, which these labels don't support. This was fixed in SignalK v2.34.0, so upgrading is the first thing to try.
68
72
 
69
- Either:
73
+ On 2.33 and earlier, either:
70
74
 
71
75
  - turn on _Send image without waiting for each write (Zhsunyco)_ in the label's _Advanced settings_ - the image is sent with a small gap between writes instead, and the label still confirms once the whole image has arrived; or
72
76
  - switch the BLE Manager setting off, if no other plugins need to share Bluetooth with this one.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhizomatics/signalk-einklabel-plugin",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
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",
@@ -76,8 +76,8 @@
76
76
  "@types/luxon": "^3.7.5",
77
77
  "@types/node": "^26.6.0",
78
78
  "@types/pngjs": "^6.0.5",
79
- "oxfmt": "^0.74.0",
80
- "oxlint": "^1.85.0",
79
+ "oxfmt": "^0.75.0",
80
+ "oxlint": "^1.89.0",
81
81
  "oxlint-tsgolint": "^7.0.2001",
82
82
  "ts-node": "^10.9.2",
83
83
  "typescript": "^5.5.0"
@@ -1,27 +0,0 @@
1
- import { EmailConfig } from "../config";
2
- import { Bitmap } from "../render/types";
3
- import { FieldRow } from "../render/fieldsTable";
4
- /** Either the SVG template's field-by-field table (`buildFieldsTable`), a `TemplateProvider`'s own rendered-content text (`describeContent`), or neither (a provider offered no `describeContent`). */
5
- export type EmailContentBody = {
6
- kind: "table";
7
- rows: FieldRow[];
8
- } | {
9
- kind: "text";
10
- text: string;
11
- } | {
12
- kind: "none";
13
- };
14
- export interface LabelEmailContent {
15
- friendlyName: string;
16
- description?: string;
17
- bitmap: Bitmap;
18
- body: EmailContentBody;
19
- }
20
- /**
21
- * Renders and sends one device's repaint as an email - a PNG attachment (also shown inline via a `cid`
22
- * reference), the same field-by-field table `esl-cli fields` shows for a hand-authored SVG template (or
23
- * a `TemplateProvider`'s own rendered-content text for something like a GenAI prompt - see `body`), and a
24
- * link back to the docs site. Called from `considerRepaint` (`../repaintScheduler.ts`) whenever a
25
- * device's `emailTo` is set and its render succeeds.
26
- */
27
- export declare function sendLabelEmail(emailConfig: EmailConfig, to: string, content: LabelEmailContent): Promise<void>;
@@ -1,66 +0,0 @@
1
- "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
- Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.sendLabelEmail = sendLabelEmail;
7
- const nodemailer_1 = __importDefault(require("nodemailer"));
8
- const png_1 = require("../render/png");
9
- const DOCS_URL = "https://rhizomatics.github.io/signalk-einklabel-plugin/";
10
- function escapeHtml(text) {
11
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
12
- }
13
- function bodyHtml(body) {
14
- if (body.kind === "table") {
15
- if (body.rows.length === 0)
16
- return "";
17
- const rows = body.rows
18
- .map((row) => `<tr><td>${escapeHtml(row.id)}</td><td>${escapeHtml(row.spec)}</td><td>${escapeHtml(row.value)}</td></tr>`)
19
- .join("");
20
- return ('<table border="1" cellpadding="4" cellspacing="0" style="border-collapse:collapse;font-family:monospace;font-size:13px">' +
21
- `<thead><tr><th>id</th><th>spec</th><th>value</th></tr></thead><tbody>${rows}</tbody></table>`);
22
- }
23
- if (body.kind === "text") {
24
- return `<pre style="white-space:pre-wrap;font-family:monospace;font-size:13px">${escapeHtml(body.text)}</pre>`;
25
- }
26
- return "";
27
- }
28
- /**
29
- * Renders and sends one device's repaint as an email - a PNG attachment (also shown inline via a `cid`
30
- * reference), the same field-by-field table `esl-cli fields` shows for a hand-authored SVG template (or
31
- * a `TemplateProvider`'s own rendered-content text for something like a GenAI prompt - see `body`), and a
32
- * link back to the docs site. Called from `considerRepaint` (`../repaintScheduler.ts`) whenever a
33
- * device's `emailTo` is set and its render succeeds.
34
- */
35
- async function sendLabelEmail(emailConfig, to, content) {
36
- const transporter = nodemailer_1.default.createTransport({
37
- host: emailConfig.smtpHost,
38
- port: emailConfig.smtpPort,
39
- secure: emailConfig.smtpSecure,
40
- auth: emailConfig.smtpUser ? { user: emailConfig.smtpUser, pass: emailConfig.smtpPassword } : undefined,
41
- });
42
- const png = (0, png_1.bitmapToPng)(content.bitmap);
43
- const html = [
44
- '<div style="font-family:sans-serif">',
45
- `<h2>${escapeHtml(content.friendlyName)}</h2>`,
46
- content.description ? `<p>${escapeHtml(content.description)}</p>` : "",
47
- `<p><img src="cid:label-image" alt="${escapeHtml(content.friendlyName)}" style="border:1px solid #ccc" /></p>`,
48
- bodyHtml(content.body),
49
- `<p><a href="${DOCS_URL}">${DOCS_URL}</a></p>`,
50
- "</div>",
51
- ].join("\n");
52
- await transporter.sendMail({
53
- from: emailConfig.fromAddress,
54
- to,
55
- subject: `eInk Label: ${content.friendlyName}`,
56
- html,
57
- attachments: [
58
- {
59
- filename: "label.png",
60
- content: png,
61
- contentType: "image/png",
62
- cid: "label-image",
63
- },
64
- ],
65
- });
66
- }
@@ -1,15 +0,0 @@
1
- import { TemplateContext } from "./types";
2
- export interface FieldRow {
3
- id: string;
4
- spec: string;
5
- value: string;
6
- }
7
- /**
8
- * Synchronous counterpart to the CLI's `fields` command (`../cli/index.ts`) - same three columns (id,
9
- * spec, resolved value), but resolved against a single already-assembled `context` rather than
10
- * re-fetching per binding, since a caller like `considerRepaint` (`../repaintScheduler.ts`) already has
11
- * the full context its render used. Only meaningful for a hand-authored SVG template - a
12
- * `TemplateProvider`-backed template (e.g. GenAI) has no `<desc>` bindings to walk; see
13
- * `TemplateProvider.describeContent` (`./templateProviders.ts`) for that case's own text summary instead.
14
- */
15
- export declare function buildFieldsTable(svgSource: string, context: TemplateContext, templatesDir: string, bundledTemplatesDir: string): FieldRow[];
@@ -1,52 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.buildFieldsTable = buildFieldsTable;
4
- const path_1 = require("path");
5
- const xmldom_1 = require("@xmldom/xmldom");
6
- const assets_1 = require("./assets");
7
- const binding_1 = require("./binding");
8
- function resolveFieldValue(tag, desc, context, templatesDir, bundledTemplatesDir) {
9
- try {
10
- const binding = (0, binding_1.parseBinding)(desc);
11
- if (tag !== "image") {
12
- return (0, binding_1.renderBinding)(binding, context);
13
- }
14
- if (!binding.assets) {
15
- return 'ERROR: an <image> binding requires an "assets" key';
16
- }
17
- const key = (0, assets_1.normalizeAssetKey)((0, binding_1.resolveBinding)(binding, context));
18
- if (!key)
19
- return "(no usable value - image would be omitted)";
20
- const assetPath = (0, assets_1.resolveAssetPath)(templatesDir, bundledTemplatesDir, binding.assets, key);
21
- return assetPath ? `"${key}" -> ${(0, path_1.basename)(assetPath)}` : `"${key}" -> no matching asset file (image would be omitted)`;
22
- }
23
- catch (err) {
24
- return `ERROR: ${err.message}`;
25
- }
26
- }
27
- /**
28
- * Synchronous counterpart to the CLI's `fields` command (`../cli/index.ts`) - same three columns (id,
29
- * spec, resolved value), but resolved against a single already-assembled `context` rather than
30
- * re-fetching per binding, since a caller like `considerRepaint` (`../repaintScheduler.ts`) already has
31
- * the full context its render used. Only meaningful for a hand-authored SVG template - a
32
- * `TemplateProvider`-backed template (e.g. GenAI) has no `<desc>` bindings to walk; see
33
- * `TemplateProvider.describeContent` (`./templateProviders.ts`) for that case's own text summary instead.
34
- */
35
- function buildFieldsTable(svgSource, context, templatesDir, bundledTemplatesDir) {
36
- const doc = new xmldom_1.DOMParser().parseFromString(svgSource, "image/svg+xml");
37
- const rows = [];
38
- for (const tag of ["text", "image"]) {
39
- const elements = doc.getElementsByTagName(tag);
40
- for (let i = 0; i < elements.length; i++) {
41
- const element = elements.item(i);
42
- if (!element)
43
- continue;
44
- const id = element.getAttribute("id") ?? `${tag}#${i}`;
45
- const desc = element.getElementsByTagName("desc").item(0);
46
- if (!desc?.textContent)
47
- continue;
48
- rows.push({ id, spec: desc.textContent, value: resolveFieldValue(tag, desc.textContent, context, templatesDir, bundledTemplatesDir) });
49
- }
50
- }
51
- return rows;
52
- }
@@ -1,86 +0,0 @@
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>;
@@ -1,199 +0,0 @@
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
- }