@rhizomatics/signalk-einklabel-plugin 1.3.0 → 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,23 @@
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
+
11
+ # 1.3.1
12
+
13
+ ## 🐛 Bug fixes
14
+
15
+ - Stopping the plugin mid-paint (saving its config, disabling it, or shutting down the server) now waits up to 30 seconds for that paint to finish, and starts no new paint or retry, so the restarted plugin no longer connects while the old Bluetooth session is still open
16
+
17
+ ## 📚 Documentation
18
+
19
+ - Docs site made friendlier to AI coding agents: an [llms.txt](https://signalk-einklabel.rhizomatics.org.uk/llms.txt) index plus the whole documentation as Markdown in `llms-full.txt` and `llms-small.txt`, and every page available as Markdown by replacing its trailing `/` with `.md` (e.g. [getting-started.md](https://signalk-einklabel.rhizomatics.org.uk/getting-started.md))
20
+
1
21
  # 1.3.0
2
22
 
3
23
  ## BLE Manager
package/README.md CHANGED
@@ -3,10 +3,9 @@
3
3
  [![npm version](https://img.shields.io/npm/v/@rhizomatics/signalk-einklabel-plugin.svg)](https://www.npmjs.com/package/@rhizomatics/signalk-einklabel-plugin)
4
4
  [![npm downloads](https://img.shields.io/npm/dm/@rhizomatics/signalk-einklabel-plugin.svg)](https://www.npmjs.com/package/@rhizomatics/signalk-einklabel-plugin)
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
- [![codecov](https://img.shields.io/codecov/c/github/rhizomatics/signalk-einklabel-plugin)](https://codecov.io/gh/rhizomatics/signalk-einklabel-plugin)
7
6
  [![code style: oxfmt](https://img.shields.io/badge/code_style-oxfmt-blue.svg)](https://github.com)
8
7
  [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://github.com/rhizomatics/signalk-einklabel-plugin/blob/main/LICENSE)
9
- [![boat tech directory](https://boat-tech-directory.rhizomatics.org.uk/images/badge.svg)](https://boat-tech-directory.rhizomatics.org.uk)
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)
10
9
 
11
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.
12
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(() => {
@@ -45,9 +45,15 @@ export declare function forEachAdvertisedDevice(adapter: Adapter, fn: (advertise
45
45
  * work can't be cancelled - this only lets the caller stop waiting for it.
46
46
  */
47
47
  export declare function withDeadline<T>(promise: Promise<T>, ms: number, what: string): Promise<T>;
48
- export declare function withRetries<T>(attempts: number, fn: (attempt: number) => Promise<T>, { delayMs, onError }?: {
48
+ /**
49
+ * Runs `fn` up to `attempts` times, `delayMs` apart, rethrowing the last error if every attempt fails.
50
+ * `shouldStop` is checked after each failed attempt and again after the pause, so a plugin `stop()`
51
+ * ends the retry loop instead of opening a fresh BLE session behind the next `start()`.
52
+ */
53
+ export declare function withRetries<T>(attempts: number, fn: (attempt: number) => Promise<T>, { delayMs, onError, shouldStop, }?: {
49
54
  delayMs?: number;
50
55
  onError?: (err: unknown, attempt: number) => void;
56
+ shouldStop?: () => boolean;
51
57
  }): Promise<T>;
52
58
  /**
53
59
  * Opens exactly one BLE discovery window and one D-Bus/BlueZ session, then hands the adapter to
@@ -92,7 +92,12 @@ async function withDeadline(promise, ms, what) {
92
92
  clearTimeout(timer);
93
93
  }
94
94
  }
95
- async function withRetries(attempts, fn, { delayMs = 0, onError } = {}) {
95
+ /**
96
+ * Runs `fn` up to `attempts` times, `delayMs` apart, rethrowing the last error if every attempt fails.
97
+ * `shouldStop` is checked after each failed attempt and again after the pause, so a plugin `stop()`
98
+ * ends the retry loop instead of opening a fresh BLE session behind the next `start()`.
99
+ */
100
+ async function withRetries(attempts, fn, { delayMs = 0, onError, shouldStop = () => false, } = {}) {
96
101
  const total = Math.max(1, attempts);
97
102
  let lastErr;
98
103
  for (let attempt = 1; attempt <= total; attempt++) {
@@ -102,9 +107,11 @@ async function withRetries(attempts, fn, { delayMs = 0, onError } = {}) {
102
107
  catch (err) {
103
108
  lastErr = err;
104
109
  onError?.(err, attempt);
105
- if (attempt < total && delayMs > 0) {
110
+ if (attempt < total && delayMs > 0 && !shouldStop()) {
106
111
  await sleep(delayMs);
107
112
  }
113
+ if (shouldStop())
114
+ break;
108
115
  }
109
116
  }
110
117
  throw lastErr;
package/dist/plugin.js CHANGED
@@ -43,7 +43,11 @@ function createPlugin(app) {
43
43
  const bleApiAvailable = !!app.bleApi;
44
44
  let scheduler;
45
45
  let stopDiscoveryListener;
46
- let stopped = false;
46
+ // Bumped by every start() and stop(), so a start() whose plugin has since been stopped (or
47
+ // restarted) can tell it's stale - a plain `stopped` flag would be reset by the next start().
48
+ let generation = 0;
49
+ // The last stop()'s drain of any in-flight repaint; start() waits on it before touching BLE.
50
+ let stopping = Promise.resolve();
47
51
  const plugin = {
48
52
  id: "signalk-einklabel-plugin",
49
53
  name: "eInk ESL (Electronic Shelf Label)",
@@ -60,14 +64,19 @@ function createPlugin(app) {
60
64
  };
61
65
  app.debug(`starting with ${pluginConfig.devices.length} configured device(s)`);
62
66
  (0, config_1.healStoredConfig)(app);
63
- stopped = false;
67
+ const thisGeneration = ++generation;
68
+ const isCurrent = () => thisGeneration === generation;
69
+ const previousStop = stopping;
64
70
  const useBleApi = pluginConfig.useBleApi && bleApiAvailable;
65
71
  if (pluginConfig.useBleApi && !bleApiAvailable) {
66
72
  app.debug("useBleApi is enabled but this SignalK server has no BLE Manager API (requires >= 2.32.0) - falling back to direct BlueZ access");
67
73
  }
68
74
  app.debug(useBleApi ? "using the SignalK BLE Manager API for Bluetooth access" : "using direct BlueZ access for Bluetooth");
69
- const start = () => {
70
- if (stopped)
75
+ const start = async () => {
76
+ // A server that doesn't await stop()'s Promise (or calls start() first) would otherwise have
77
+ // this connect while the previous session's paint is still running.
78
+ await previousStop;
79
+ if (!isCurrent())
71
80
  return;
72
81
  if (pluginConfig.scanOnStart) {
73
82
  void runStartupScan(app, pluginConfig.scanDurationSeconds, useBleApi);
@@ -83,23 +92,31 @@ function createPlugin(app) {
83
92
  // The server/provider governs its own local-adapter readiness once BLE Manager mode owns
84
93
  // `hci0` (or has none at all, behind a remote gateway) - waiting on a *local* BlueZ adapter
85
94
  // here would be waiting on something this mode may never even need.
86
- start();
95
+ void start();
87
96
  }
88
97
  else {
89
98
  // Waits (with backoff, indefinitely on Linux) for a BLE adapter before the startup scan or
90
99
  // the repaint scheduler touch BLE at all - see `waitForAdapter`'s doc comment on the
91
- // boot-time race this covers. `stopped` is checked again inside `start()`, not just passed
100
+ // boot-time race this covers. `isCurrent` is checked again inside `start()`, not just passed
92
101
  // as `cancelled`, since the wait can also resolve `true` on its own right as `stop()` runs.
93
- void (0, bleDiscovery_1.waitForAdapter)((message) => app.debug(message), () => stopped).then(start);
102
+ void (0, bleDiscovery_1.waitForAdapter)((message) => app.debug(message), () => !isCurrent()).then(start);
94
103
  }
95
104
  },
96
- stop() {
97
- stopped = true;
98
- scheduler?.stop();
105
+ // Returns a Promise, as the server asks of a stop() with async work: it resolves once a paint
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(). 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) {
112
+ generation++;
113
+ const stoppingScheduler = scheduler;
99
114
  scheduler = undefined;
100
115
  stopDiscoveryListener?.();
101
116
  stopDiscoveryListener = undefined;
102
- 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"));
119
+ return stopping;
103
120
  },
104
121
  };
105
122
  return plugin;
@@ -19,7 +19,8 @@ function normalizeAssetKey(value) {
19
19
  .trim()
20
20
  .toLowerCase()
21
21
  .replace(/[^a-z0-9]+/g, "_")
22
- .replace(/^_+|_+$/g, "");
22
+ // runs are already collapsed to a single `_` above, so one at each end is all there can be
23
+ .replace(/^_|_$/g, "");
23
24
  return normalized || undefined;
24
25
  }
25
26
  /**
@@ -51,6 +51,13 @@ export interface TemplateProvider {
51
51
  * screen unmarked.
52
52
  */
53
53
  render(request: TemplateRenderRequest): Promise<Bitmap>;
54
+ /**
55
+ * Optional, debug only: a human-readable description of what `render()` will be given for this
56
+ * request (e.g. a GenAI provider's prompt with every placeholder substituted) - `considerRepaint`
57
+ * writes it to `app.debug` just before calling `render()`, standing in for the `<desc>` bindings an
58
+ * SVG template's debug output would otherwise show. Throwing only skips that debug line, never the render.
59
+ */
60
+ describeContent?(request: TemplateRenderRequest): string;
54
61
  }
55
62
  export declare function registerTemplateProvider(provider: TemplateProvider): void;
56
63
  export declare function allTemplateProviders(): TemplateProvider[];
@@ -1,7 +1,27 @@
1
1
  import { ServerAPI } from "@signalk/server-api";
2
2
  import { PluginConfig } from "./config";
3
+ /**
4
+ * How long `stop()` waits for a paint already in progress before resolving anyway. Long enough for a
5
+ * normal transfer to finish (so a config save mid-paint doesn't have the next `start()` connect while
6
+ * the old BLE session is still open), short enough not to hold up server shutdown on a label that's
7
+ * stuck; such a paint is still ended by its own connect timeout and `PAINT_ATTEMPT_BACKSTOP_MS`.
8
+ */
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;
3
19
  export interface RepaintScheduler {
4
- stop(): void;
20
+ /**
21
+ * Stops every trigger, lets no new paint or retry start, and resolves once any paint already in
22
+ * progress finishes - or after `drainTimeoutMs` (default `STOP_DRAIN_TIMEOUT_MS`), whichever comes first.
23
+ */
24
+ stop(drainTimeoutMs?: number): Promise<void>;
5
25
  }
6
26
  /**
7
27
  * The most recent wall-clock instant at or before `now` that an `interval`-triggered device's own
@@ -19,6 +39,15 @@ export declare function mostRecentScheduledSlot(now: Date, intervalHours: number
19
39
  * but only if it succeeded (`run` resolved `true`). For repaints: newer data still gets shown promptly,
20
40
  * but a label that's failing isn't hammered with a fresh round of retries straight after the last
21
41
  * one gave up - the next scheduled trigger tries again instead.
42
+ *
43
+ * `close()` makes every later call a no-op and drops pending follow-ups; `idle()` resolves once no
44
+ * `run` is in progress. Together they let the scheduler's `stop()` wait for a paint already under way
45
+ * without starting another.
22
46
  */
23
- export declare function oneAtATimePerKey<T>(keyOf: (arg: T) => string, run: (arg: T) => Promise<boolean>, onBusy?: (arg: T) => void): (arg: T) => Promise<void>;
47
+ export interface OneAtATimePerKey<T> {
48
+ (arg: T): Promise<void>;
49
+ close(): void;
50
+ idle(): Promise<void>;
51
+ }
52
+ export declare function oneAtATimePerKey<T>(keyOf: (arg: T) => string, run: (arg: T) => Promise<boolean>, onBusy?: (arg: T) => void): OneAtATimePerKey<T>;
24
53
  export declare function startRepaintScheduler(app: ServerAPI, config: PluginConfig): RepaintScheduler;
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SHUTDOWN_DRAIN_TIMEOUT_MS = exports.STOP_DRAIN_TIMEOUT_MS = void 0;
3
4
  exports.mostRecentScheduledSlot = mostRecentScheduledSlot;
4
5
  exports.oneAtATimePerKey = oneAtATimePerKey;
5
6
  exports.startRepaintScheduler = startRepaintScheduler;
@@ -33,6 +34,22 @@ const PAINT_RETRY_DELAY_MS = 5000;
33
34
  * the longest being the 5-minute GATT session watchdog in `bleBackend.ts`.
34
35
  */
35
36
  const PAINT_ATTEMPT_BACKSTOP_MS = 7 * 60000;
37
+ /**
38
+ * How long `stop()` waits for a paint already in progress before resolving anyway. Long enough for a
39
+ * normal transfer to finish (so a config save mid-paint doesn't have the next `start()` connect while
40
+ * the old BLE session is still open), short enough not to hold up server shutdown on a label that's
41
+ * stuck; such a paint is still ended by its own connect timeout and `PAINT_ATTEMPT_BACKSTOP_MS`.
42
+ */
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;
36
53
  const RESOURCES_API_PATH = "/signalk/v2/api/resources";
37
54
  /**
38
55
  * The most recent wall-clock instant at or before `now` that an `interval`-triggered device's own
@@ -248,7 +265,7 @@ async function resolveTargets(app, config, device) {
248
265
  * prompt showing "gentle breezes" through an actual storm), and not persisting a hash means the very
249
266
  * next scheduled tick retries for real rather than being suppressed by dedup.
250
267
  */
251
- async function considerRepaint(app, config, device, target, state, getApiUrl) {
268
+ async function considerRepaint(app, config, device, target, state, getApiUrl, isStopping = () => false) {
252
269
  const { address, metadata, driver } = target;
253
270
  const label = `"${device.friendlyName}" [${address}]`;
254
271
  if (!device.templateName) {
@@ -334,8 +351,17 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
334
351
  description: device.description ?? "",
335
352
  },
336
353
  };
354
+ const providerRequest = { templateName: device.templateName, context: renderContext, width, height, colours: metadata.colours };
355
+ if (provider?.describeContent) {
356
+ try {
357
+ app.debug(`${label}: provider content:\n${provider.describeContent(providerRequest)}`);
358
+ }
359
+ catch (err) {
360
+ app.debug(`${label}: describeContent failed (${err.message})`);
361
+ }
362
+ }
337
363
  bitmap = provider
338
- ? await provider.render({ templateName: device.templateName, context: renderContext, width, height, colours: metadata.colours })
364
+ ? await provider.render(providerRequest)
339
365
  : await renderer.render(templatePath, renderContext, width, height, templatesDir, config_1.BUNDLED_TEMPLATES_DIR);
340
366
  succeeded = true;
341
367
  repaintReason = device.advanced?.forceRepaint
@@ -363,28 +389,35 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
363
389
  const connectTimeoutMs = (device.advanced?.paintConnectTimeoutSeconds ?? config.paintConnectTimeoutSeconds) * 1000;
364
390
  const gattBackend = config.useBleApi && app.bleApi ? (0, bleBackend_1.bleApiBackend)(app.bleApi, pluginVersion_1.PLUGIN_NAME) : undefined;
365
391
  const attempts = Math.max(1, device.advanced?.paintRetries ?? config.paintRetries);
366
- const paintWithRetries = () => (0, bleDiscovery_1.withRetries)(attempts, async (attempt) => {
367
- app.debug(`${label}: attempting paint ${attempt}/${attempts}`);
368
- const startedAt = Date.now();
369
- const paint = driver.paint(bitmap, {
370
- address,
371
- pid: target.pid,
372
- aesKey: device.advanced?.aesKey,
373
- connectTimeoutMs,
374
- reframe: device.advanced?.reframe,
375
- mirror: device.advanced?.mirror,
376
- compress: device.advanced?.compress,
377
- compressionFormat: device.advanced?.compressionFormat,
378
- writeWithoutResponse: device.advanced?.writeWithoutResponse,
379
- gattBackend,
380
- log: (message) => app.debug(`${label}: ${message}`),
392
+ const paintWithRetries = () => {
393
+ // Rendering (or waiting for `exclusiveBleManagerAccess`) can outlast a `stop()` - don't open a
394
+ // BLE session for a plugin that's already stopping.
395
+ if (isStopping())
396
+ throw new Error("plugin stopping - paint skipped");
397
+ return (0, bleDiscovery_1.withRetries)(attempts, async (attempt) => {
398
+ app.debug(`${label}: attempting paint ${attempt}/${attempts}`);
399
+ const startedAt = Date.now();
400
+ const paint = driver.paint(bitmap, {
401
+ address,
402
+ pid: target.pid,
403
+ aesKey: device.advanced?.aesKey,
404
+ connectTimeoutMs,
405
+ reframe: device.advanced?.reframe,
406
+ mirror: device.advanced?.mirror,
407
+ compress: device.advanced?.compress,
408
+ compressionFormat: device.advanced?.compressionFormat,
409
+ writeWithoutResponse: device.advanced?.writeWithoutResponse,
410
+ gattBackend,
411
+ log: (message) => app.debug(`${label}: ${message}`),
412
+ });
413
+ await (0, bleDiscovery_1.withDeadline)(paint, connectTimeoutMs + PAINT_ATTEMPT_BACKSTOP_MS, `paint attempt ${attempt}/${attempts}`);
414
+ paintDurationMs = Date.now() - startedAt;
415
+ }, {
416
+ delayMs: PAINT_RETRY_DELAY_MS,
417
+ onError: (err, attempt) => app.debug(`${label}: paint attempt ${attempt}/${attempts} failed: ${err.message}`),
418
+ shouldStop: isStopping,
381
419
  });
382
- await (0, bleDiscovery_1.withDeadline)(paint, connectTimeoutMs + PAINT_ATTEMPT_BACKSTOP_MS, `paint attempt ${attempt}/${attempts}`);
383
- paintDurationMs = Date.now() - startedAt;
384
- }, {
385
- delayMs: PAINT_RETRY_DELAY_MS,
386
- onError: (err, attempt) => app.debug(`${label}: paint attempt ${attempt}/${attempts} failed: ${err.message}`),
387
- });
420
+ };
388
421
  await (gattBackend ? (0, bleBackend_1.exclusiveBleManagerAccess)(paintWithRetries) : paintWithRetries());
389
422
  (0, discoveredDevicesStore_1.touchDiscoveredDevice)(app, { address, vendor: target.vendor, pid: target.pid, hwVersion: target.hwVersion, metadata });
390
423
  // Only a successful render counts as "repainted" for dedup/catch-up purposes - a failure must not be
@@ -395,27 +428,24 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
395
428
  }
396
429
  app.debug(succeeded ? `${label}: repainted (${repaintReason}, paint took ${paintDurationMs}ms)` : `${label}: repainted fallback warning`);
397
430
  }
398
- /**
399
- * Wraps `run` so only one call per key is in progress at a time. A call made while that key is busy
400
- * is folded into a single follow-up (with the latest arguments), run once the current one finishes -
401
- * but only if it succeeded (`run` resolved `true`). For repaints: newer data still gets shown promptly,
402
- * but a label that's failing isn't hammered with a fresh round of retries straight after the last
403
- * one gave up - the next scheduled trigger tries again instead.
404
- */
405
431
  function oneAtATimePerKey(keyOf, run, onBusy = () => { }) {
406
- const inProgress = new Set();
432
+ const inProgress = new Map();
407
433
  const followUps = new Map();
434
+ let closed = false;
408
435
  const call = async (arg) => {
436
+ if (closed)
437
+ return;
409
438
  const key = keyOf(arg);
410
439
  if (inProgress.has(key)) {
411
440
  followUps.set(key, arg);
412
441
  onBusy(arg);
413
442
  return;
414
443
  }
415
- inProgress.add(key);
444
+ const running = run(arg);
445
+ inProgress.set(key, running);
416
446
  let succeeded = false;
417
447
  try {
418
- succeeded = await run(arg);
448
+ succeeded = await running;
419
449
  }
420
450
  finally {
421
451
  inProgress.delete(key);
@@ -426,12 +456,25 @@ function oneAtATimePerKey(keyOf, run, onBusy = () => { }) {
426
456
  await call(followUp);
427
457
  }
428
458
  };
429
- return call;
459
+ return Object.assign(call, {
460
+ close() {
461
+ closed = true;
462
+ followUps.clear();
463
+ },
464
+ async idle() {
465
+ // A follow-up can start as an earlier run settles (unless closed), so loop until nothing is left.
466
+ while (inProgress.size > 0) {
467
+ await Promise.allSettled(inProgress.values());
468
+ }
469
+ },
470
+ });
430
471
  }
431
472
  function startRepaintScheduler(app, config) {
432
473
  const state = loadState(app);
433
474
  const unsubscribes = [];
434
475
  const getApiUrl = (0, resolveApiUrl_1.createApiUrlResolver)(config.signalkApiUrl);
476
+ let stopping = false;
477
+ const isStopping = () => stopping;
435
478
  // The first minute or two of a SignalK server's life is a chaos of plugin dependency sequencing -
436
479
  // a repaint attempted before other plugins (e.g. derived-data) have published the data a template
437
480
  // needs renders with missing/wrong values, and hash dedup (see `considerRepaint`) then means it
@@ -451,7 +494,7 @@ function startRepaintScheduler(app, config) {
451
494
  if (targets.length === 0) {
452
495
  return false;
453
496
  }
454
- const results = await Promise.allSettled(targets.map((target) => considerRepaint(app, config, device, target, state, getApiUrl)));
497
+ const results = await Promise.allSettled(targets.map((target) => considerRepaint(app, config, device, target, state, getApiUrl, isStopping)));
455
498
  results.forEach((result, i) => {
456
499
  if (result.status === "rejected") {
457
500
  app.debug(`"${device.friendlyName}" [${targets[i].address}]: repaint failed: ${result.reason.message}`);
@@ -525,9 +568,22 @@ function startRepaintScheduler(app, config) {
525
568
  }, settleMs);
526
569
  unsubscribes.push(() => clearTimeout(startupCheckTimer));
527
570
  return {
528
- stop() {
571
+ async stop(drainTimeoutMs = exports.STOP_DRAIN_TIMEOUT_MS) {
572
+ stopping = true;
529
573
  for (const unsubscribe of unsubscribes)
530
574
  unsubscribe();
575
+ repaint.close();
576
+ let timer;
577
+ const drained = await Promise.race([
578
+ repaint.idle().then(() => true),
579
+ new Promise((resolve) => {
580
+ timer = setTimeout(() => resolve(false), drainTimeoutMs);
581
+ }),
582
+ ]);
583
+ clearTimeout(timer);
584
+ if (!drained) {
585
+ app.debug(`a repaint was still running ${drainTimeoutMs / 1000}s after stop - not waiting any longer`);
586
+ }
531
587
  },
532
588
  };
533
589
  }
package/docs/bluetooth.md CHANGED
@@ -30,17 +30,21 @@ First step is having a Bluetooth Low Energy (BLE) compatible bluetooth adapter a
30
30
 
31
31
  SignalK BLE Manager also supports BLE Gateways, which could be an MQTT topic or an ESP-32 device. The [espos-ble-gateway](https://github.com/dirkwa/espos-ble-gateway) can be used with a cheap ESP32 device (see the list of supported hardware), which allows positioning of the gateway closer to devices, or having multiple gateways on a big boat.
32
32
 
33
- ## BLE Manager Readiness
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.
@@ -160,3 +164,32 @@ Following file created at /etc/tlp.d/99-bt500-no-autosuspend.conf
160
164
  ```
161
165
  USB_DENYLIST="0b05:190e"
162
166
  ```
167
+
168
+ ## Bluetooth Jargon Buster
169
+
170
+ Bluetooth comes with a lot of acronyms. Here's what the ones on this page mean, without needing to be a computer person.
171
+
172
+ When the plugin paints a label, the request goes down a chain: the plugin asks **BlueZ** over **D-Bus**, BlueZ gives orders to the Bluetooth dongle over **HCI**, and the dongle's radio talks to the label using **BLE**, reading and writing the label's **GATT** services. A **gateway** or **proxy** simply moves the radio end of that chain somewhere else on the boat.
173
+
174
+ - **BLE (Bluetooth Low Energy)** - the kind of Bluetooth made for small battery-powered gadgets like labels, sensors and battery monitors.
175
+ - It's different from the "classic" Bluetooth used by headphones and speakers, and doesn't have the usual pairing process.
176
+ - BLE devices talk in short bursts and sleep in between, which is how a label runs for years on a coin cell.
177
+ - They regularly broadcast short "advertisements" saying who they are, and the server picks these up by _scanning_.
178
+ - BLE needs an adapter supporting Bluetooth 4.0 or later.
179
+ - **HCI (Host Controller Interface)** - the standard set of commands a computer uses to give orders to a Bluetooth radio chip, such as "start scanning" or "connect to this device".
180
+ - Linux names each Bluetooth adapter after it, so `hci0` is the first adapter, `hci1` the second, and so on.
181
+ - A plugin using the adapter "directly" is sending HCI commands itself, works fine if there's only one Bluetooth app on the server.
182
+ - **BlueZ** - the standard Bluetooth software on Linux.
183
+ - It runs quietly in the background as a service called `bluetoothd`, takes charge of the adapter, keeps a list of the devices it has heard, and handles connecting to them.
184
+ - Its a sane alternative to multiple Linux programs all trying to grab the limited resources of the Bluetooth adapter and stomping over each other. It does at a Linux level what BLE Manager does for SignalK - a single point of ownership and control over a contested resource.
185
+ - **D-Bus** - the messaging system that programs on a Linux computer use to talk to each other.
186
+ - It's how the plugin, or SignalK's BLE Manager, asks BlueZ to scan or connect.
187
+ - If you know NMEA 2000, it's a similar idea: one shared backbone that everything plugs into and exchanges messages over, rather than a separate cable between every pair of devices.
188
+ - **GATT (Generic Attribute Profile)** - how a BLE device organises what it offers once you're connected to it.
189
+ - The device has _services_, each a group of related features, and each service has _characteristics_, individual values you can read, write or be told about when they change.
190
+ - Painting a label means connecting, finding its image service, and writing the picture into the right characteristic a piece at a time.
191
+ - The "GATT claims" shown in SignalK's BLE Manager are simply which plugin has booked which device for a connection, so that two plugins don't try to talk to it at once.
192
+ - **Gateway** - a separate small device, often a cheap ESP32 board, with its own Bluetooth radio, which listens for and talks to BLE devices near it and relays everything to SignalK over the boat's network.
193
+ - It's effectively fitting a remote antenna closer to where it's needed: useful when a label is too far from the server, or on a big boat that needs more than one. SignalK's BLE Manager can use gateways alongside, or instead of, a local adapter.
194
+ - **Proxy** - much the same idea as a gateway: a device that does the Bluetooth radio work on the server's behalf, somewhere with a better signal.
195
+ - Different software uses different names for it. Home Assistant users, for example, will know ESP32 "Bluetooth proxies".
@@ -0,0 +1,9 @@
1
+ # Design Plans
2
+
3
+ ## Label Support
4
+
5
+ - More vendors, and more labels for existing vendors
6
+ - Ability for people to plug-in vendor support for themselves or as published plugins
7
+ - A model override in the plugin config, so a discovered device whose PID isn't in its driver's table yet can be repainted (the CLI's `paint --colours` already does this)
8
+ - Neaps or springs status (e.g. "Springs +1", "Neaps -2") on the tide template, pending upstream signalk-tides support
9
+ - Alternatives to SVG templates, perhaps [imagespec](https://pypi.org/project/imagespec/) or [signalk-hmi-designer](https://github.com/dirkwa/signalk-hmi-designer)
@@ -0,0 +1,59 @@
1
+ # Design Specification
2
+
3
+ A SignalK plugin, in TypeScript, that renders selected SignalK data to BLE eInk Electronic Shelf Labels (ESLs). This page describes the plugin as it is built. Plans and ideas not yet built are in the [roadmap](roadmap.md).
4
+
5
+ # Features
6
+
7
+ - Native TypeScript, Node 20 compatible (runs on armv7, e.g. a Cerbo GX)
8
+ - Bundled templates for a tide clock and a watch schedule, rendered from any SignalK data, Resources API resource or label fact a template binds to
9
+ - Pluggable vendor support
10
+ - Built-in drivers for Zhsunyco and Gicisky BLE labels (`src/devices/zhsunyco/`, `src/devices/gicisky/`), registered by `plugin.ts` at startup
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)
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
+ - Bluetooth access, either direct or shared
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
17
+ - Device discovery
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
+ - A scan already in progress is joined rather than a second one started, since neither backend can run two discovery sessions at once
20
+ - With direct BlueZ access, scanning happens in bounded windows: an optional startup scan (`scanOnStart`, off by default, `scanDurationSeconds` long), an on-demand scan when an "All discovered devices" entry has nothing discovered yet, or `esl-cli scan`. With BLE Manager, the plugin listens to its advertisement stream for as long as the plugin runs, and only identifies new addresses
21
+ - Discovered devices are persisted in the plugin data directory, keyed by BLE address, and kept for 24 hours after last being seen; a successful paint counts as being seen. The startup scan reports what it found in the plugin status line
22
+ - The device picker lists any device a driver identified as its vendor, even if the PID isn't in that driver's metadata table yet (labelled "vendor unrecognised PID 0x.... (address)"), so it can be reported. Such a device can't be repainted from the plugin, since there's no width/height to render with; `esl-cli paint` can paint one by supplying the model facts on the command line
23
+ - Device configuration
24
+ - Each device entry has a friendly name, a combined "Device" choice, an optional location/description, a template and a repaint trigger, with less common settings grouped under "Advanced settings"
25
+ - The "Device" value is `"<vendor>:<pid>[:<hwVersion>]@<address>"`, picked from recently discovered devices, so width/height/colours are known from config alone and never need a live BLE read or a rescan after a restart. The special value `ALL` ("All discovered devices") paints the same template and trigger to every currently-known device instead, resolved at repaint time
26
+ - Advanced settings: reframe (`crop` by default, `scale`, or `fixed` to fail on a size mismatch), compress upload (on by default), mirror (`none`, `horizontal`, `vertical`, `both`), Gicisky wire format (`auto` or experimental `chunked`), Zhsunyco write-without-response, force repaint, Zhsunyco AES key (falls back to a default key), and per-device overrides of the plugin-wide paint connect timeout and retry count
27
+ - Config saved before advanced settings were grouped, or nested by an older save bug, is rewritten into the current shape at plugin start (`healStoredConfig`)
28
+ - Templates
29
+ - SVG files, rendered to bitmaps with resvg-wasm (`src/render/svgRenderer.ts`). A user templates directory (`templatesDir`: empty for `~/.signalk/einklabel/templates`, relative to `~/.signalk`, or absolute) is scanned alongside the bundled `templates/` directory; a user template with the same name as a bundled one takes priority. There is no upload UI
30
+ - A template is either a single `.svg` file or a _template family_: a directory of variants named `<width>x<height>-<colours>.svg` (e.g. `tides/416x240-BWRY.svg`). The best variant for each device is picked at repaint time: exact size and colours, then exact size, then the nearest width with the closest aspect ratio. Dot-prefixed directories (`.assets`, `.blank`, `.error`) are never offered as templates
31
+ - Each template can be assigned to one or more devices. There is no separate data config; a template's own bindings declare what data it needs, re-read from the template on every repaint
32
+ - Text uses the bundled serif, sans-serif and monospace fonts (resvg-wasm can't see system fonts); the generic CSS family names are rewritten to the bundled fonts' exact names
33
+ - Bindings
34
+ - A dynamic `<text>` element carries a flat `key=value,key=value` binding in a `<desc>` child, not a templating engine - e.g. `source=resources,resource=tides,path=extremes[0].time,format=local_time`. `source`/`context` default to `signalk`/`self`, a bare path with no `=` is shorthand for `path=...`, and array indices use bracket notation. The element's own text is a placeholder for laying out the template in an editor. See `src/render/binding.ts` for the grammar
35
+ - Keys: `source`, `context`, `resource`, `provider`, `path`, `format`, `category`, `round`, `assets`, `default`. An unknown key is an error; `default=` replaces a missing value
36
+ - `source=signalk` (default): `context=self` reads via `getSelfPath`; any other `context` is read via `getPath` against that literal SignalK context, exactly as shown in the Data Browser (e.g. `vessels.urn:mrn:imo:mmsi:232345678`)
37
+ - `source=resources`: `resource=<name>` reads any Resources API resource type (`tides`, `waypoints`, ...) via `app.resourcesApi.listResources` in-process. `provider=<plugin id>` pins it to one resource provider; a pinned binding goes over the REST API instead, since the in-process call ignores the provider id (signalk-server 2.30.0)
38
+ - `source=label`: facts about the physical label - `manufacturer`, `label`, `width`, `height`, `colours`, `fonts`, `description`, and `position` (rounded to about 1km so GPS jitter doesn't force repaints)
39
+ - `source=einklabel`: plugin-injected values - `repainted` (repaint time), `local_zone` (timezone abbreviation) and `plugin_version`
40
+ - `format=` names a formatter from `src/render/formatters.ts`: `local_time`, `day_mon`, `local_datetime_short`, `utc_offset`, `position`, `csv`. Times use the vessel's `environment.time.timezoneRegion` when published, otherwise the host's timezone - never a tide station's own `timezone` field
41
+ - A `signalk`-sourced numeric value converts to its path's preferred display unit by default, via `GET {signalkApiUrl}/signalk/v1/api/vessels/<context>/meta` (there's no in-process equivalent); `format=raw` opts out. A value with no path of its own (e.g. resource-sourced) can take an explicit `category=<name>` (e.g. `category=depth`), resolved from `{signalkApiUrl}/signalk/v1/unitpreferences/...` client-side (`src/unitCategories.ts`). `round=` sets decimal places
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
+ - SignalK API base URL
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
+ - 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
+ - Path metadata is best-effort (no server just means raw values); a `category=` or `provider=` binding without a reachable server fails the render
47
+ - Repainting
48
+ - Each device picks one repaint trigger: a change on a SignalK path (`subscription`, debounced by 2 seconds, ignoring transient null/missing values), or a fixed interval (every N hours at a configurable minute past the hour, checked by one shared one-minute timer rather than one per device)
49
+ - Every trigger waits for a settle window after plugin start (`settleSeconds`, default 120), so a template isn't rendered before other plugins (e.g. derived-data) have published its data. After that window each device is checked once; an interval device already painted for its current slot skips this catch-up
50
+ - Before repainting, the template's mtime and the assembled data (bindings plus label facts, before plugin-injected values like the repaint time are added) are hashed and compared against the last successful paint, persisted in the plugin data directory per device entry and BLE address (`friendlyName:address`). If both are unchanged the repaint is skipped, to save the label's battery and avoid a full-panel flash. A template provider's output always repaints, since the plugin can't know whether it would differ
51
+ - "Force repaint" bypasses the unchanged-data skip for one repaint, then clears itself once every target device has painted successfully
52
+ - Only one repaint per device entry runs at a time; a trigger arriving mid-repaint queues one follow-up, run only if the current repaint succeeded
53
+ - A paint is retried up to `paintRetries` times (default 3, 5 seconds apart), each attempt bounded by `paintConnectTimeoutSeconds` (default 30) plus a backstop deadline
54
+ - Stopping the plugin (a config save, disable, or server shutdown) stops every trigger, drops queued follow-ups and retries, and skips a paint that hasn't connected yet. `stop()` returns a Promise that resolves once a paint already under way has finished, waiting at most 30 seconds, and the next `start()` also waits for it, so two BLE sessions don't overlap
55
+ - If rendering fails (a broken template, a missing file, or a template provider's `render()` rejecting), the bundled `.error` warning template is painted instead, so stale content is never left on screen unmarked. No success hash is saved, so the next trigger retries
56
+ - Template providers
57
+ - An extension package can offer content generated another way, alongside SVG templates, via `registerTemplateProvider` - e.g. `@rhizomatics/signalk-einklabel-genai-plugin` generating content from an LLM prompt. Its entries appear in the same "Template" dropdown, marked by the provider's `suffix`. See [Extending](../extending.md) and `src/render/templateProviders.ts`
58
+ - `esl-cli`, a local CLI for scanning, painting and testing templates outside the SignalK plugin
59
+ - `vendors`, `scan`, `paint` (with the same image options as the plugin, plus `--colours`/`--width`/`--height`/`--voffset` model facts for hardware not yet in a driver's table), `render` (template to PNG), `fields` (every binding in a template with its resolved value) and `field` (one binding spec). Bindings resolve against a live SignalK server over HTTP, or against local example JSON (`-e examples`). Extensions can add subcommands via `--require`. See [Command Line Interface](../cli.md)
package/docs/extending.md CHANGED
@@ -24,9 +24,9 @@ Additional vendors and devices can be added by a separate npm package that imple
24
24
 
25
25
  ## Template Providers
26
26
 
27
- An alternative way to produce a device's content, alongside hand-authored SVG templates, can be added the same way - `esl.registerTemplateProvider({ suffix, listTemplates, render })`, from a separate package's own plugin `start()` (or `esl-cli --require <module>`). This is what [`@rhizomatics/signalk-einklabel-genai-plugin`](templates.md#genai-rendering) is: `listTemplates()` returns the entries it currently offers (already including its own `suffix`, e.g. `"forecast (GenAI)"`) for the "Template" dropdown, and `render(request)` - given the same signalk/resources/label context any SVG template's bindings get - returns a `Bitmap`, exactly as `SvgRenderer.render()` would. Rejecting from `render()` routes the repaint through this plugin's own generic fallback-warning template, the same as a broken hand-authored template failing to render.
27
+ An alternative way to produce a device's content, alongside hand-authored SVG templates, can be added the same way - `esl.registerTemplateProvider({ suffix, listTemplates, render })`, from a separate package's own plugin `start()` (or `esl-cli --require <module>`). This is what [`@rhizomatics/signalk-einklabel-genai-plugin`](templates.md#genai-rendering) is: `listTemplates()` returns the entries it currently offers (already including its own `suffix`, e.g. `"forecast (GenAI)"`) for the "Template" dropdown, and `render(request)` - given the same signalk/resources/label context any SVG template's bindings get - returns a `Bitmap`, exactly as `new esl.Renderer().render()` would for an SVG template. Rejecting from `render()` routes the repaint through this plugin's own generic fallback-warning template, the same as a broken hand-authored template failing to render. An optional `describeContent(request)` returns a human-readable description of what `render()` will be given (the GenAI plugin returns its fully-substituted LLM prompt); when present, it's logged to the plugin's debug output just before each `render()`, standing in for the `<desc>` bindings an SVG template's debug output would show.
28
28
 
29
- `esl.SvgRenderer`, `esl.buildLabel`, `esl.findBindingsInText`, and `esl.substituteBindingsInText` are also exported for a `TemplateProvider` extension to reuse - e.g. to resolve its own `{...}`-style placeholders against the request's context, or to rasterize whatever SVG it produces into the `Bitmap` its `render()` must return.
29
+ `esl.Renderer` (the SVG renderer), `esl.bitmapToPng`, `esl.buildLabel`, `esl.findBindingsInText`, and `esl.substituteBindingsInText` are also exported for a `TemplateProvider` extension to reuse - e.g. to resolve its own `{...}`-style placeholders against the request's context, or to rasterize whatever SVG it produces into the `Bitmap` its `render()` must return.
30
30
 
31
31
  ## CLI Commands
32
32
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhizomatics/signalk-einklabel-plugin",
3
- "version": "1.3.0",
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,14 +76,15 @@
76
76
  "@types/luxon": "^3.7.5",
77
77
  "@types/node": "^26.6.0",
78
78
  "@types/pngjs": "^6.0.5",
79
- "oxfmt": "^0.70.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"
84
84
  },
85
85
  "engines": {
86
- "node": ">=22"
86
+ "node": ">=22",
87
+ "signalk-server": ">=2.25.0"
87
88
  },
88
89
  "signalk": {
89
90
  "displayName": "eInk Label Displays",
@@ -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
- }