@rhizomatics/signalk-einklabel-plugin 1.3.1 → 1.3.3

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,29 @@
1
+ # 1.3.3
2
+
3
+ ## 🔐 SSL
4
+
5
+ - New "Allow self-signed certificates" setting, off by default. A SignalK server with SSL enabled redirects its http port to https, usually with a self-signed certificate, which the plugin previously rejected - so templates using the SignalK REST API (`category=` or a resource pinned with `provider=`) failed to render and the label showed the fallback warning. Turn the setting on for a server like this.
6
+
7
+ ## 🐛 Bug fixes
8
+
9
+ - A render failure that paints the fallback warning on a label is now logged as an error, with its cause, rather than only in debug logging
10
+ - When the SignalK API URL can't be reached, the logged error now gives the underlying reason for each address tried, e.g. a self-signed certificate, rather than only suggesting the port or anonymous access; a certificate failure points to the new setting
11
+
12
+ ## BLE Manager
13
+
14
+ - The "Use the SignalK BLE Manager API" setting is now on by default. Servers without BLE Manager (before v2.31.0) fall back to direct BlueZ access, and existing configs that already saved the setting keep their value
15
+ - Recent SignalK versions identify Zhsunyco labels by manufacturer, shown as "WOESL" in the BLE Manager device list
16
+
17
+ # 1.3.2
18
+
19
+ ## 🐛 Bug fixes
20
+
21
+ - 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
22
+
23
+ ## 📚 Documentation
24
+
25
+ - [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.
26
+
1
27
  # 1.3.1
2
28
 
3
29
  ## 🐛 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
package/dist/config.d.ts CHANGED
@@ -107,8 +107,9 @@ export interface PluginConfig {
107
107
  /**
108
108
  * Route BLE access through the SignalK server's BLE Manager API (`app.bleApi`, server >= 2.32.0)
109
109
  * instead of connecting to BlueZ directly, so this plugin shares the adapter with other BLE plugins
110
- * instead of contending for it - see `bleBackend.ts`. Off by default, and only ever offered in the
111
- * config schema when the running server actually has `app.bleApi` (see `configSchema` below).
110
+ * instead of contending for it - see `bleBackend.ts`. On by default; a server without `app.bleApi`
111
+ * falls back to direct BlueZ access (see `plugin.ts`), and the option is only ever offered in the
112
+ * config schema when the running server actually has it (see `configSchema` below).
112
113
  */
113
114
  useBleApi: boolean;
114
115
  /** How long to wait for a device to accept a BLE connection before giving up on a repaint attempt, in seconds. */
@@ -140,6 +141,11 @@ export interface PluginConfig {
140
141
  * anonymous read access - the plugin has no login flow.
141
142
  */
142
143
  signalkApiUrl?: string;
144
+ /**
145
+ * Accept a self-signed TLS certificate on the plugin's calls to `signalkApiUrl` - see
146
+ * `setAllowSelfSignedCertificates` in `./httpJson.ts`. Off by default, so certificates are verified.
147
+ */
148
+ allowSelfSignedCertificates: boolean;
143
149
  devices: DeviceConfig[];
144
150
  }
145
151
  /**
package/dist/config.js CHANGED
@@ -99,10 +99,11 @@ function defaultConfig() {
99
99
  templatesDir: "",
100
100
  scanOnStart: false,
101
101
  scanDurationSeconds: 20,
102
- useBleApi: false,
102
+ useBleApi: true,
103
103
  paintConnectTimeoutSeconds: 30,
104
104
  paintRetries: 3,
105
105
  settleSeconds: 120,
106
+ allowSelfSignedCertificates: false,
106
107
  devices: [],
107
108
  };
108
109
  }
@@ -115,6 +116,7 @@ const PLUGIN_CONFIG_KEYS = [
115
116
  "paintRetries",
116
117
  "settleSeconds",
117
118
  "signalkApiUrl",
119
+ "allowSelfSignedCertificates",
118
120
  "devices",
119
121
  ];
120
122
  /**
@@ -464,6 +466,13 @@ function configSchema(app, discovered = []) {
464
466
  examples: resolveApiUrl_1.SIGNALK_API_URL_OPTIONS,
465
467
  pattern: "^(\\s*|\\s*https?://\\S+\\s*)$",
466
468
  },
469
+ allowSelfSignedCertificates: {
470
+ type: "boolean",
471
+ title: "Allow self-signed certificates",
472
+ description: "Accept a self-signed certificate when calling the SignalK API above. Needed when the server has SSL enabled with its own " +
473
+ "generated certificate - its http port then redirects to https, which otherwise fails certificate checks. Leave off unless needed.",
474
+ default: defaults.allowSelfSignedCertificates,
475
+ },
467
476
  devices: {
468
477
  type: "array",
469
478
  title: "Devices",
@@ -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(() => {
@@ -1,3 +1,11 @@
1
+ /**
2
+ * Whether `fetchJson` accepts a self-signed (or otherwise unverifiable) TLS certificate. Off by default,
3
+ * so certificates are verified exactly as plain `fetch()` does. Set from the plugin's
4
+ * `allowSelfSignedCertificates` config on each start - a SignalK server with SSL enabled redirects its http
5
+ * port to https, usually with a self-signed certificate, which `fetch()` otherwise always rejects. Process-wide
6
+ * rather than per call, since every `fetchJson` call in the plugin goes to that one server's own API.
7
+ */
8
+ export declare function setAllowSelfSignedCertificates(allowed: boolean): void;
1
9
  /**
2
10
  * Fetches and parses a JSON endpoint, with an error message that's actually actionable: which URL,
3
11
  * what HTTP status, and the response/underlying error detail - see `describeCause` for why plain
package/dist/httpJson.js CHANGED
@@ -1,6 +1,24 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.setAllowSelfSignedCertificates = setAllowSelfSignedCertificates;
3
4
  exports.fetchJson = fetchJson;
5
+ const node_http_1 = require("node:http");
6
+ const node_https_1 = require("node:https");
7
+ /** How many redirects the self-signed path follows before giving up - `fetch()`'s own default is 20. */
8
+ const MAX_REDIRECTS = 5;
9
+ /** Per-request deadline on the self-signed path, which has none of `fetch()`'s built-in timeouts. */
10
+ const REQUEST_TIMEOUT_MS = 30000;
11
+ let allowSelfSigned = false;
12
+ /**
13
+ * Whether `fetchJson` accepts a self-signed (or otherwise unverifiable) TLS certificate. Off by default,
14
+ * so certificates are verified exactly as plain `fetch()` does. Set from the plugin's
15
+ * `allowSelfSignedCertificates` config on each start - a SignalK server with SSL enabled redirects its http
16
+ * port to https, usually with a self-signed certificate, which `fetch()` otherwise always rejects. Process-wide
17
+ * rather than per call, since every `fetchJson` call in the plugin goes to that one server's own API.
18
+ */
19
+ function setAllowSelfSignedCertificates(allowed) {
20
+ allowSelfSigned = allowed;
21
+ }
4
22
  /**
5
23
  * Describes a network-level fetch failure's `error.cause` - Node's `fetch()` only ever throws a
6
24
  * generic `TypeError: fetch failed`, with the actually useful detail (e.g. ECONNREFUSED) nested in
@@ -23,6 +41,8 @@ function describeCause(cause) {
23
41
  * `fetch()` failures need unwrapping to be useful at all.
24
42
  */
25
43
  async function fetchJson(url) {
44
+ if (allowSelfSigned)
45
+ return fetchJsonAllowingSelfSigned(url);
26
46
  let response;
27
47
  try {
28
48
  response = await fetch(url);
@@ -30,7 +50,7 @@ async function fetchJson(url) {
30
50
  catch (err) {
31
51
  const cause = err.cause;
32
52
  const detail = cause !== undefined ? describeCause(cause) : err.message;
33
- throw new Error(`fetch failed: ${url} - ${detail}`);
53
+ throw new Error(`fetch failed: ${url} - ${detail}${certificateHint(detail)}`);
34
54
  }
35
55
  if (!response.ok) {
36
56
  const body = await response.text().catch(() => "");
@@ -38,3 +58,60 @@ async function fetchJson(url) {
38
58
  }
39
59
  return response.json();
40
60
  }
61
+ /** Points a certificate failure at the setting that fixes it, since the raw TLS error doesn't. */
62
+ function certificateHint(detail) {
63
+ return /certificate|CERT_/i.test(detail)
64
+ ? ' - if this server uses a self-signed certificate, enable "Allow self-signed certificates" in the plugin config'
65
+ : "";
66
+ }
67
+ /**
68
+ * `fetchJson` over `node:http`/`node:https` with certificate verification off - built-in `fetch()` has no
69
+ * per-request way to skip it without the separate `undici` package. Follows redirects itself (the http to
70
+ * https redirect being the usual reason to need this at all), with the same error messages as `fetchJson`.
71
+ */
72
+ async function fetchJsonAllowingSelfSigned(url) {
73
+ let current = url;
74
+ for (let redirects = 0;; redirects++) {
75
+ let response;
76
+ try {
77
+ response = await sendGet(current);
78
+ }
79
+ catch (err) {
80
+ throw new Error(`fetch failed: ${url} - ${describeCause(err)}`);
81
+ }
82
+ const status = response.statusCode ?? 0;
83
+ const location = response.headers.location;
84
+ if (status >= 300 && status < 400 && location) {
85
+ response.resume();
86
+ if (redirects >= MAX_REDIRECTS)
87
+ throw new Error(`fetch failed: ${url} - more than ${MAX_REDIRECTS} redirects`);
88
+ current = new URL(location, current).toString();
89
+ continue;
90
+ }
91
+ const body = await readBody(response).catch(() => "");
92
+ if (status < 200 || status >= 300) {
93
+ throw new Error(`fetch failed: ${url} (${status} ${response.statusMessage ?? ""})${body ? ` - ${body}` : ""}`);
94
+ }
95
+ return JSON.parse(body);
96
+ }
97
+ }
98
+ function sendGet(url) {
99
+ return new Promise((resolve, reject) => {
100
+ const target = new URL(url);
101
+ const headers = { accept: "application/json" };
102
+ const request = target.protocol === "https:"
103
+ ? (0, node_https_1.request)(target, { headers, rejectUnauthorized: false }, resolve)
104
+ : (0, node_http_1.request)(target, { headers }, resolve);
105
+ request.setTimeout(REQUEST_TIMEOUT_MS, () => request.destroy(new Error(`no response after ${REQUEST_TIMEOUT_MS / 1000}s`)));
106
+ request.on("error", reject);
107
+ request.end();
108
+ });
109
+ }
110
+ function readBody(response) {
111
+ return new Promise((resolve, reject) => {
112
+ const chunks = [];
113
+ response.on("data", (chunk) => chunks.push(chunk));
114
+ response.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
115
+ response.on("error", reject);
116
+ });
117
+ }
package/dist/index.d.ts CHANGED
@@ -15,11 +15,12 @@ import type { Bitmap as BitmapType, TemplateContext as TemplateContextType } fro
15
15
  * `start()` (or at module load time). There's no scanning of installed packages -
16
16
  * registration is always an explicit call by the extension's own code.
17
17
  *
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.
18
+ * Declare the relationship via `"signalk": { "requires": ["@rhizomatics/signalk-einklabel-plugin"] }`
19
+ * in the extension's own package.json, plus a `devDependency` for types/tests - **not** a regular
20
+ * `dependency` or `peerDependency`. The App Store installs required plugins flat in
21
+ * `~/.signalk/node_modules`, so the extension's `require` resolves the one copy the running plugin
22
+ * uses; a regular `dependency` can leave a nested copy with its own empty registry, and a
23
+ * `peerDependency` interacts poorly with the server's plugin install layout (SignalK's own guidance).
23
24
  */
24
25
  declare function plugin(app: ServerAPI): Plugin;
25
26
  declare namespace plugin {
@@ -30,7 +31,7 @@ declare namespace plugin {
30
31
  * Public extension point for a package offering an alternative to hand-authored SVG templates - e.g.
31
32
  * `@rhizomatics/signalk-einklabel-genai-plugin` generating content from an LLM prompt - see
32
33
  * `TemplateProvider`'s own doc comment (`./render/templateProviders.ts`) for the full contract. Same
33
- * regular-`dependency`-plus-`signalk.requires` convention as `registerVendorDriver` above.
34
+ * `signalk.requires`-plus-`devDependency` convention as `registerVendorDriver` above.
34
35
  */
35
36
  const registerTemplateProvider: typeof registerTemplateProviderImpl;
36
37
  const getTemplateProvider: typeof findTemplateProviderImpl;
package/dist/index.js CHANGED
@@ -12,11 +12,12 @@ const binding_1 = require("./render/binding");
12
12
  * `start()` (or at module load time). There's no scanning of installed packages -
13
13
  * registration is always an explicit call by the extension's own code.
14
14
  *
15
- * Declare this package as a regular `dependency` in the extension package (**not** a
16
- * `peerDependency` - the SignalK team's own guidance is that npm's peer-dependency resolution
17
- * interacts poorly with the server's plugin install layout), and declare the SignalK-level
18
- * relationship via `"signalk": { "requires": ["@rhizomatics/signalk-einklabel-plugin"] }` in the
19
- * extension's own package.json instead, so the App Store can install/report the dependency.
15
+ * Declare the relationship via `"signalk": { "requires": ["@rhizomatics/signalk-einklabel-plugin"] }`
16
+ * in the extension's own package.json, plus a `devDependency` for types/tests - **not** a regular
17
+ * `dependency` or `peerDependency`. The App Store installs required plugins flat in
18
+ * `~/.signalk/node_modules`, so the extension's `require` resolves the one copy the running plugin
19
+ * uses; a regular `dependency` can leave a nested copy with its own empty registry, and a
20
+ * `peerDependency` interacts poorly with the server's plugin install layout (SignalK's own guidance).
20
21
  */
21
22
  function plugin(app) {
22
23
  return (0, plugin_1.createPlugin)(app);
@@ -29,7 +30,7 @@ function plugin(app) {
29
30
  * Public extension point for a package offering an alternative to hand-authored SVG templates - e.g.
30
31
  * `@rhizomatics/signalk-einklabel-genai-plugin` generating content from an LLM prompt - see
31
32
  * `TemplateProvider`'s own doc comment (`./render/templateProviders.ts`) for the full contract. Same
32
- * regular-`dependency`-plus-`signalk.requires` convention as `registerVendorDriver` above.
33
+ * `signalk.requires`-plus-`devDependency` convention as `registerVendorDriver` above.
33
34
  */
34
35
  plugin.registerTemplateProvider = templateProviders_1.registerTemplateProvider;
35
36
  plugin.getTemplateProvider = templateProviders_1.findTemplateProvider;
package/dist/plugin.js CHANGED
@@ -10,6 +10,7 @@ const bleDiscovery_1 = require("./devices/bleDiscovery");
10
10
  const discoveredDevicesStore_1 = require("./devices/discoveredDevicesStore");
11
11
  const repaintScheduler_1 = require("./repaintScheduler");
12
12
  const pluginVersion_1 = require("./pluginVersion");
13
+ const httpJson_1 = require("./httpJson");
13
14
  /** Mirrors signalk-bluetti-plugin's convention: scan briefly, report finds via plugin status for the user to copy-paste. */
14
15
  async function runStartupScan(app, durationSeconds, useBleApi) {
15
16
  const alreadyRunning = (0, discoveryCoordinator_1.scanInProgressSince)();
@@ -64,6 +65,7 @@ function createPlugin(app) {
64
65
  };
65
66
  app.debug(`starting with ${pluginConfig.devices.length} configured device(s)`);
66
67
  (0, config_1.healStoredConfig)(app);
68
+ (0, httpJson_1.setAllowSelfSignedCertificates)(pluginConfig.allowSelfSignedCertificates);
67
69
  const thisGeneration = ++generation;
68
70
  const isCurrent = () => thisGeneration === generation;
69
71
  const previousStop = stopping;
@@ -104,14 +106,18 @@ function createPlugin(app) {
104
106
  },
105
107
  // Returns a Promise, as the server asks of a stop() with async work: it resolves once a paint
106
108
  // 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() {
109
+ // or a server shutdown doesn't leave that BLE session running behind the next start(). On a
110
+ // server shutdown (`reason`, passed by signalk-server 2.35.0+) the wait is cut to
111
+ // `SHUTDOWN_DRAIN_TIMEOUT_MS` to fit the server's own shutdown deadline - see its doc comment.
112
+ // `reason` is typed loosely so this still compiles against server-api versions before 2.35.0.
113
+ stop(options) {
109
114
  generation++;
110
115
  const stoppingScheduler = scheduler;
111
116
  scheduler = undefined;
112
117
  stopDiscoveryListener?.();
113
118
  stopDiscoveryListener = undefined;
114
- stopping = (stoppingScheduler?.stop() ?? Promise.resolve()).then(() => app.debug("stopped"));
119
+ const drainTimeoutMs = options?.reason === "shutdown" ? repaintScheduler_1.SHUTDOWN_DRAIN_TIMEOUT_MS : repaintScheduler_1.STOP_DRAIN_TIMEOUT_MS;
120
+ stopping = (stoppingScheduler?.stop(drainTimeoutMs) ?? Promise.resolve()).then(() => app.debug(options?.reason ? `stopped (${options.reason})` : "stopped"));
115
121
  return stopping;
116
122
  },
117
123
  };
@@ -21,9 +21,9 @@ export interface TemplateRenderRequest {
21
21
  * Public extension point for a package that wants to offer an alternative way to produce a device's
22
22
  * content, alongside hand-authored SVG templates - e.g. `@rhizomatics/signalk-einklabel-genai-plugin`
23
23
  * generating content from an LLM prompt. Mirrors `VendorDriver`/`registerDriver`
24
- * (`../devices/registry.ts`): an extension declares this package as a regular `dependency` (**not** a
25
- * `peerDependency` - see `registerVendorDriver`'s own doc comment in `../index.ts` for why) plus
26
- * `"signalk": { "requires": [...] }` in its own package.json, calls `esl.registerTemplateProvider(...)`
24
+ * (`../devices/registry.ts`): an extension declares this package via `"signalk": { "requires": [...] }`
25
+ * plus a `devDependency` (**not** a regular `dependency` or `peerDependency` - see
26
+ * `registerVendorDriver`'s own doc comment in `../index.ts` for why), calls `esl.registerTemplateProvider(...)`
27
27
  * from its own SignalK plugin's `start()`, and its entries show up in the same "Template" dropdown as
28
28
  * ordinary `.svg` files, distinguished only by `suffix`.
29
29
  */
@@ -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
@@ -169,7 +178,7 @@ async function assembleRawContext(app, apiUrl, bindings) {
169
178
  // REST API's own `?provider=` query param doesn't have this bug (it queries only that provider),
170
179
  // so a pinned binding goes over HTTP instead of the in-process API to actually get what it asked for.
171
180
  if (!apiUrl) {
172
- throw new Error(`binding pins resource "${binding.resource}" to provider "${binding.provider}" but no SignalK API base URL is configured`);
181
+ throw new Error(`binding pins resource "${binding.resource}" to provider "${binding.provider}" but the SignalK API base URL could not be resolved (see the earlier error)`);
173
182
  }
174
183
  resources[key] = await (0, httpJson_1.fetchJson)(`${apiUrl}${RESOURCES_API_PATH}/${binding.resource}?provider=${encodeURIComponent(binding.provider)}`);
175
184
  }
@@ -184,7 +193,7 @@ async function assembleRawContext(app, apiUrl, bindings) {
184
193
  }
185
194
  const categoryNames = new Set(bindings.filter((binding) => binding.category).map((binding) => binding.category));
186
195
  if (categoryNames.size > 0 && !apiUrl) {
187
- throw new Error(`binding references categor${categoryNames.size > 1 ? "ies" : "y"} "${[...categoryNames].join(", ")}" but no SignalK API base URL is configured`);
196
+ throw new Error(`binding references categor${categoryNames.size > 1 ? "ies" : "y"} "${[...categoryNames].join(", ")}" but the SignalK API base URL could not be resolved (see the earlier error)`);
188
197
  }
189
198
  const categories = apiUrl ? await (0, unitCategories_1.fetchCategoryDisplayUnits)(apiUrl, categoryNames) : {};
190
199
  return { signalk, resources, pathMeta, categories };
@@ -276,6 +285,7 @@ async function considerRepaint(app, config, device, target, state, getApiUrl, is
276
285
  let dataHash = "";
277
286
  let paintDurationMs = 0;
278
287
  let repaintReason = "render failed";
288
+ let renderError = "";
279
289
  // Built up front, outside the try below, so the fallback warning render gets it too - it's all local
280
290
  // facts (no network or template involved), so there's nothing here that can fail a render.
281
291
  const rawPosition = (0, unwrapSignalkTree_1.unwrapSignalkTree)(app.getSelfPath("navigation.position"));
@@ -295,7 +305,9 @@ async function considerRepaint(app, config, device, target, state, getApiUrl, is
295
305
  });
296
306
  try {
297
307
  const apiUrl = await getApiUrl().catch((err) => {
298
- app.debug(`${label}: ${err.message}`);
308
+ // An error, not debug - templates that need the API (pinned resources, `category=`) fail to render
309
+ // without it, and this is the only place the underlying reason is reported.
310
+ app.error(`${label}: ${err.message}`);
299
311
  return undefined;
300
312
  });
301
313
  let bindings = [];
@@ -366,7 +378,10 @@ async function considerRepaint(app, config, device, target, state, getApiUrl, is
366
378
  : "data changed";
367
379
  }
368
380
  catch (err) {
369
- app.debug(`${label}: render failed (${err.message}) - showing fallback warning`);
381
+ // An error, not debug - the label shows only a generic warning, so the server log is the one place
382
+ // the actual cause is visible, and it must be there without the plugin's debug logging enabled.
383
+ renderError = err.message;
384
+ app.error(`${label}: render failed (${renderError}) - showing fallback warning`);
370
385
  const fallbackPath = (0, config_1.resolveTemplatePath)(templatesDir, config_1.RENDER_FALLBACK_TEMPLATE_NAME, {
371
386
  width: metadata.width,
372
387
  height: metadata.height,
@@ -417,7 +432,9 @@ async function considerRepaint(app, config, device, target, state, getApiUrl, is
417
432
  state[stateKey] = { templateHash, dataHash, repaintedAt: Date.now() };
418
433
  saveState(app, state);
419
434
  }
420
- app.debug(succeeded ? `${label}: repainted (${repaintReason}, paint took ${paintDurationMs}ms)` : `${label}: repainted fallback warning`);
435
+ app.debug(succeeded
436
+ ? `${label}: repainted (${repaintReason}, paint took ${paintDurationMs}ms)`
437
+ : `${label}: repainted fallback warning (render failed: ${renderError})`);
421
438
  }
422
439
  function oneAtATimePerKey(keyOf, run, onBusy = () => { }) {
423
440
  const inProgress = new Map();
@@ -559,7 +576,7 @@ function startRepaintScheduler(app, config) {
559
576
  }, settleMs);
560
577
  unsubscribes.push(() => clearTimeout(startupCheckTimer));
561
578
  return {
562
- async stop() {
579
+ async stop(drainTimeoutMs = exports.STOP_DRAIN_TIMEOUT_MS) {
563
580
  stopping = true;
564
581
  for (const unsubscribe of unsubscribes)
565
582
  unsubscribe();
@@ -568,12 +585,12 @@ function startRepaintScheduler(app, config) {
568
585
  const drained = await Promise.race([
569
586
  repaint.idle().then(() => true),
570
587
  new Promise((resolve) => {
571
- timer = setTimeout(() => resolve(false), exports.STOP_DRAIN_TIMEOUT_MS);
588
+ timer = setTimeout(() => resolve(false), drainTimeoutMs);
572
589
  }),
573
590
  ]);
574
591
  clearTimeout(timer);
575
592
  if (!drained) {
576
- app.debug(`a repaint was still running ${exports.STOP_DRAIN_TIMEOUT_MS / 1000}s after stop - not waiting any longer`);
593
+ app.debug(`a repaint was still running ${drainTimeoutMs / 1000}s after stop - not waiting any longer`);
577
594
  }
578
595
  },
579
596
  };
@@ -21,13 +21,14 @@ const httpJson_1 = require("./httpJson");
21
21
  exports.SIGNALK_API_URL_OPTIONS = ["http://localhost:3000", "http://localhost", "https://localhost"];
22
22
  /** Cheap, always-required (for `category=` resolution) and read-only, so safe to use as a connectivity+access probe. */
23
23
  const PROBE_PATH = "/signalk/v1/unitpreferences/categories";
24
+ /** Resolves to `undefined` when `url` answers the probe, or to the failure's message when it doesn't. */
24
25
  async function probe(url) {
25
26
  try {
26
27
  await (0, httpJson_1.fetchJson)(`${url}${PROBE_PATH}`);
27
- return true;
28
+ return undefined;
28
29
  }
29
- catch {
30
- return false;
30
+ catch (err) {
31
+ return err.message;
31
32
  }
32
33
  }
33
34
  /**
@@ -41,13 +42,19 @@ async function resolveSignalkApiUrl(configuredUrl) {
41
42
  // otherwise double up with the leading slash of every API path appended to it.
42
43
  configuredUrl = configuredUrl?.trim().replace(/\/+$/, "") || undefined;
43
44
  const candidates = configuredUrl ? [configuredUrl] : exports.SIGNALK_API_URL_OPTIONS;
45
+ // Kept so the error says *why* each candidate failed - e.g. an http URL redirected to https with a
46
+ // self-signed certificate, which otherwise looks identical to a wrong port.
47
+ const failures = [];
44
48
  for (const url of candidates) {
45
- if (await probe(url))
49
+ const failure = await probe(url);
50
+ if (failure === undefined)
46
51
  return url;
52
+ failures.push(failure);
47
53
  }
48
- throw new Error(configuredUrl
54
+ throw new Error((configuredUrl
49
55
  ? `configured SignalK API base URL "${configuredUrl}" did not respond to ${PROBE_PATH} - check the port, and that anonymous read access is enabled`
50
- : `could not reach this server's API on any of ${exports.SIGNALK_API_URL_OPTIONS.join(", ")} - it may be on a different port, or anonymous read access may not be enabled`);
56
+ : `could not reach this server's API on any of ${exports.SIGNALK_API_URL_OPTIONS.join(", ")} - it may be on a different port, or anonymous read access may not be enabled`) +
57
+ ` (${failures.join("; ")})`);
51
58
  }
52
59
  /**
53
60
  * Memoizes a successful resolution only - a failure (server not ready yet, anonymous access
package/docs/bluetooth.md CHANGED
@@ -6,11 +6,11 @@ Bluetooth can be used in of three ways by a plugin:
6
6
 
7
7
  - Direct access to dongle (usually `hci0` device). Not recommended
8
8
  - Access via `bluez` and `dbus` services. Better but not ideal
9
- - Using the BLE Manager added to SignalK in 2026. Recommended with caveats.
10
- - "Use the SignalK BLE Manager API" setting is enabled, the adapter and its lifecycle are managed by the SignalK server, under its own Bluetooth admin settings, once for every BLE-consuming plugin.
9
+ - Using the BLE Manager added to SignalK in 2026. Recommended, and the default for this plugin.
10
+ - With the "Use the SignalK BLE Manager API" setting enabled (the default), the adapter and its lifecycle are managed by the SignalK server, under its own Bluetooth admin settings, once for every BLE-consuming plugin.
11
11
  - Under the hood, this uses `bluez` and `dbus` however manages them so that plugins are controlled in how they can impact each other
12
12
  - It also has a very useful GUI for seeing all scanned devices, and all GATT claims (GATT being the protocol for directly connecting to BLE devices).
13
- - This is still new and settling down, so not yet the default for this plugin
13
+ - On a server without BLE Manager (before v2.31.0) the plugin falls back to direct `bluez` access
14
14
 
15
15
  The advice here is general to Bluetooth on Linux, whichever way its being used.
16
16
 
@@ -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
+ BLE Manager is now the default. If a label misbehaves through it, turn off "Use the SignalK BLE Manager API" to go back to direct BlueZ access.
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,12 @@ 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
+ > [!NOTE]
72
+ > ZhSunyCo labels may show as manufacturer label`WOESL`, or as a `WL` prefixed device name, on the BLE Manager.
73
+
74
+ 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
75
 
69
- Either:
76
+ On 2.33 and earlier, either:
70
77
 
71
78
  - 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
79
  - switch the BLE Manager setting off, if no other plugins need to share Bluetooth with this one.
@@ -9,11 +9,11 @@ A SignalK plugin, in TypeScript, that renders selected SignalK data to BLE eInk
9
9
  - Pluggable vendor support
10
10
  - Built-in drivers for Zhsunyco and Gicisky BLE labels (`src/devices/zhsunyco/`, `src/devices/gicisky/`), registered by `plugin.ts` at startup
11
11
  - Each driver has a metadata table of the models it supports, keyed by PID, with `hwVersion` used where a vendor reuses a PID across panel sizes. Each entry gives the label, width, height, vertical offset and colours. `esl-cli vendors` lists them
12
- - Additional vendors are added by a separate npm package implementing `VendorDriver` and calling the exported `registerVendorDriver` explicitly - no scanning of installed packages. In the SignalK runtime the extension calls it from its own plugin `start()`; the CLI loads it via `esl-cli --require <module>`. Extension packages declare this package as a regular `dependency`, not a `peerDependency` (SignalK's guidance is 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"] }` - see [Extending](../extending.md)
12
+ - Additional vendors are added by a separate npm package implementing `VendorDriver` and calling the exported `registerVendorDriver` explicitly - no scanning of installed packages. In the SignalK runtime the extension calls it from its own plugin `start()`; the CLI loads it via `esl-cli --require <module>`. Extension packages declare the relationship only via `"signalk": { "requires": ["@rhizomatics/signalk-einklabel-plugin"] }` (plus a `devDependency` for types/tests), not as a regular `dependency` or `peerDependency` - the App Store installs it flat in `~/.signalk/node_modules`, so `require` resolves the single running copy - see [Extending](../extending.md)
13
13
  - The config schema's device and template options are read live from the registries when the schema is built, so a newly-registered extension shows up the next time the config panel is opened
14
14
  - Bluetooth access, either direct or shared
15
15
  - Direct BlueZ access over D-Bus via `node-ble` (the default). The plugin waits, with backoff, for a local adapter before scanning or painting, to cover the boot-time race where the plugin starts before BlueZ is ready
16
- - The SignalK BLE Manager API (`app.bleApi`, server >= 2.32.0), opted into with `useBleApi`, so the plugin shares the adapter with other BLE plugins. Only offered in the config schema when the running server has it. Paints through BLE Manager are serialised so only one label holds a GATT connection at a time
16
+ - The SignalK BLE Manager API (`app.bleApi`, server >= 2.32.0), used by default (`useBleApi`, falling back to direct BlueZ access on older servers), so the plugin shares the adapter with other BLE plugins. Only offered in the config schema when the running server has it. Paints through BLE Manager are serialised so only one label holds a GATT connection at a time
17
17
  - Device discovery
18
18
  - One shared discovery path across all registered drivers (`discoveryCoordinator.ts`): each advertisement is read once and offered to each driver's `matchesAdvertisement`; only a matching driver's `identifyDevice` runs, which may open a GATT connection when the advertisement alone doesn't identify the model
19
19
  - A scan already in progress is joined rather than a second one started, since neither backend can run two discovery sessions at once
@@ -42,6 +42,7 @@ A SignalK plugin, in TypeScript, that renders selected SignalK data to BLE eInk
42
42
  - An `<image>` element with a `<desc>` binding and an `assets=<name>` key picks one `.svg` from an asset directory (`.assets/<name>/`, user templates directory first, then bundled) by the resolved value, e.g. the bundled `lunar_phases` icons for `environment.moon.phaseName`. The element is dropped when the value is missing or has no matching file
43
43
  - SignalK API base URL
44
44
  - `signalkApiUrl` is optional. Left unset, `resolveSignalkApiUrl` (`src/resolveApiUrl.ts`) probes `http://localhost:3000`, `http://localhost` and `https://localhost`, in that order, at the first repaint and caches whichever responds. Always the loopback address, since the plugin runs on the same host as the server. Setting it explicitly skips probing but is still verified
45
+ - `allowSelfSignedCertificates` (off by default) makes `fetchJson` (`src/httpJson.ts`) accept a self-signed certificate on these API calls, using `node:https` with verification off and following redirects itself. A server with SSL enabled redirects its http port to https, usually with its own generated certificate, which plain `fetch()` rejects. With it off, a certificate failure's error names the setting. A failure to resolve the URL is logged as an error, with each candidate's underlying cause
45
46
  - It errors clearly if nothing responds (wrong port) or every probe is rejected (anonymous read access not enabled) - these endpoints need anonymous read access, since the plugin has no login flow. A failed resolution is retried on the next repaint rather than cached
46
47
  - Path metadata is best-effort (no server just means raw values); a `category=` or `provider=` binding without a reachable server fails the render
47
48
  - Repainting
package/docs/extending.md CHANGED
@@ -20,7 +20,7 @@ Additional vendors and devices can be added by a separate npm package that imple
20
20
 
21
21
  - `import esl from '@rhizomatics/signalk-einklabel-plugin'; esl.registerVendorDriver(myDriver)`
22
22
  - 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>`.
23
- - 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.
23
+ - Don't list this package in the extension's `dependencies` or `peerDependencies` - declare the relationship via `"signalk": { "requires": ["@rhizomatics/signalk-einklabel-plugin"] }` in the extension's own `package.json` instead, and add it as a `devDependency` for types and tests. The App Store installs required plugins flat in `~/.signalk/node_modules`, so the extension's `require` still resolves at runtime - and always to the one copy the running plugin uses. A regular `dependency` can leave a separate nested copy under the extension (e.g. on a version mismatch), with its own empty registry, so the registration silently goes nowhere; a `peerDependency` interacts poorly with the server's plugin install layout, per SignalK's own guidance.
24
24
 
25
25
  ## Template Providers
26
26
 
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.3",
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
- }