@rhizomatics/signalk-einklabel-plugin 1.3.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ # 1.3.1
2
+
3
+ ## 🐛 Bug fixes
4
+
5
+ - 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
6
+
7
+ ## 📚 Documentation
8
+
9
+ - 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))
10
+
1
11
  # 1.3.0
2
12
 
3
13
  ## BLE Manager
package/README.md CHANGED
@@ -3,7 +3,6 @@
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
8
  [![boat tech directory](https://boat-tech-directory.rhizomatics.org.uk/images/badge.svg)](https://boat-tech-directory.rhizomatics.org.uk)
@@ -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,27 @@ 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
  },
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().
96
108
  stop() {
97
- stopped = true;
98
- scheduler?.stop();
109
+ generation++;
110
+ const stoppingScheduler = scheduler;
99
111
  scheduler = undefined;
100
112
  stopDiscoveryListener?.();
101
113
  stopDiscoveryListener = undefined;
102
- app.debug("stopped");
114
+ stopping = (stoppingScheduler?.stop() ?? Promise.resolve()).then(() => app.debug("stopped"));
115
+ return stopping;
103
116
  },
104
117
  };
105
118
  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,18 @@
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;
3
10
  export interface RepaintScheduler {
4
- stop(): void;
11
+ /**
12
+ * 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.
14
+ */
15
+ stop(): Promise<void>;
5
16
  }
6
17
  /**
7
18
  * The most recent wall-clock instant at or before `now` that an `interval`-triggered device's own
@@ -19,6 +30,15 @@ export declare function mostRecentScheduledSlot(now: Date, intervalHours: number
19
30
  * but only if it succeeded (`run` resolved `true`). For repaints: newer data still gets shown promptly,
20
31
  * but a label that's failing isn't hammered with a fresh round of retries straight after the last
21
32
  * one gave up - the next scheduled trigger tries again instead.
33
+ *
34
+ * `close()` makes every later call a no-op and drops pending follow-ups; `idle()` resolves once no
35
+ * `run` is in progress. Together they let the scheduler's `stop()` wait for a paint already under way
36
+ * without starting another.
22
37
  */
23
- export declare function oneAtATimePerKey<T>(keyOf: (arg: T) => string, run: (arg: T) => Promise<boolean>, onBusy?: (arg: T) => void): (arg: T) => Promise<void>;
38
+ export interface OneAtATimePerKey<T> {
39
+ (arg: T): Promise<void>;
40
+ close(): void;
41
+ idle(): Promise<void>;
42
+ }
43
+ export declare function oneAtATimePerKey<T>(keyOf: (arg: T) => string, run: (arg: T) => Promise<boolean>, onBusy?: (arg: T) => void): OneAtATimePerKey<T>;
24
44
  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.STOP_DRAIN_TIMEOUT_MS = void 0;
3
4
  exports.mostRecentScheduledSlot = mostRecentScheduledSlot;
4
5
  exports.oneAtATimePerKey = oneAtATimePerKey;
5
6
  exports.startRepaintScheduler = startRepaintScheduler;
@@ -33,6 +34,13 @@ 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;
36
44
  const RESOURCES_API_PATH = "/signalk/v2/api/resources";
37
45
  /**
38
46
  * The most recent wall-clock instant at or before `now` that an `interval`-triggered device's own
@@ -248,7 +256,7 @@ async function resolveTargets(app, config, device) {
248
256
  * prompt showing "gentle breezes" through an actual storm), and not persisting a hash means the very
249
257
  * next scheduled tick retries for real rather than being suppressed by dedup.
250
258
  */
251
- async function considerRepaint(app, config, device, target, state, getApiUrl) {
259
+ async function considerRepaint(app, config, device, target, state, getApiUrl, isStopping = () => false) {
252
260
  const { address, metadata, driver } = target;
253
261
  const label = `"${device.friendlyName}" [${address}]`;
254
262
  if (!device.templateName) {
@@ -334,8 +342,17 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
334
342
  description: device.description ?? "",
335
343
  },
336
344
  };
345
+ const providerRequest = { templateName: device.templateName, context: renderContext, width, height, colours: metadata.colours };
346
+ if (provider?.describeContent) {
347
+ try {
348
+ app.debug(`${label}: provider content:\n${provider.describeContent(providerRequest)}`);
349
+ }
350
+ catch (err) {
351
+ app.debug(`${label}: describeContent failed (${err.message})`);
352
+ }
353
+ }
337
354
  bitmap = provider
338
- ? await provider.render({ templateName: device.templateName, context: renderContext, width, height, colours: metadata.colours })
355
+ ? await provider.render(providerRequest)
339
356
  : await renderer.render(templatePath, renderContext, width, height, templatesDir, config_1.BUNDLED_TEMPLATES_DIR);
340
357
  succeeded = true;
341
358
  repaintReason = device.advanced?.forceRepaint
@@ -363,28 +380,35 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
363
380
  const connectTimeoutMs = (device.advanced?.paintConnectTimeoutSeconds ?? config.paintConnectTimeoutSeconds) * 1000;
364
381
  const gattBackend = config.useBleApi && app.bleApi ? (0, bleBackend_1.bleApiBackend)(app.bleApi, pluginVersion_1.PLUGIN_NAME) : undefined;
365
382
  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}`),
383
+ const paintWithRetries = () => {
384
+ // Rendering (or waiting for `exclusiveBleManagerAccess`) can outlast a `stop()` - don't open a
385
+ // BLE session for a plugin that's already stopping.
386
+ if (isStopping())
387
+ throw new Error("plugin stopping - paint skipped");
388
+ return (0, bleDiscovery_1.withRetries)(attempts, async (attempt) => {
389
+ app.debug(`${label}: attempting paint ${attempt}/${attempts}`);
390
+ const startedAt = Date.now();
391
+ const paint = driver.paint(bitmap, {
392
+ address,
393
+ pid: target.pid,
394
+ aesKey: device.advanced?.aesKey,
395
+ connectTimeoutMs,
396
+ reframe: device.advanced?.reframe,
397
+ mirror: device.advanced?.mirror,
398
+ compress: device.advanced?.compress,
399
+ compressionFormat: device.advanced?.compressionFormat,
400
+ writeWithoutResponse: device.advanced?.writeWithoutResponse,
401
+ gattBackend,
402
+ log: (message) => app.debug(`${label}: ${message}`),
403
+ });
404
+ await (0, bleDiscovery_1.withDeadline)(paint, connectTimeoutMs + PAINT_ATTEMPT_BACKSTOP_MS, `paint attempt ${attempt}/${attempts}`);
405
+ paintDurationMs = Date.now() - startedAt;
406
+ }, {
407
+ delayMs: PAINT_RETRY_DELAY_MS,
408
+ onError: (err, attempt) => app.debug(`${label}: paint attempt ${attempt}/${attempts} failed: ${err.message}`),
409
+ shouldStop: isStopping,
381
410
  });
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
- });
411
+ };
388
412
  await (gattBackend ? (0, bleBackend_1.exclusiveBleManagerAccess)(paintWithRetries) : paintWithRetries());
389
413
  (0, discoveredDevicesStore_1.touchDiscoveredDevice)(app, { address, vendor: target.vendor, pid: target.pid, hwVersion: target.hwVersion, metadata });
390
414
  // Only a successful render counts as "repainted" for dedup/catch-up purposes - a failure must not be
@@ -395,27 +419,24 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
395
419
  }
396
420
  app.debug(succeeded ? `${label}: repainted (${repaintReason}, paint took ${paintDurationMs}ms)` : `${label}: repainted fallback warning`);
397
421
  }
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
422
  function oneAtATimePerKey(keyOf, run, onBusy = () => { }) {
406
- const inProgress = new Set();
423
+ const inProgress = new Map();
407
424
  const followUps = new Map();
425
+ let closed = false;
408
426
  const call = async (arg) => {
427
+ if (closed)
428
+ return;
409
429
  const key = keyOf(arg);
410
430
  if (inProgress.has(key)) {
411
431
  followUps.set(key, arg);
412
432
  onBusy(arg);
413
433
  return;
414
434
  }
415
- inProgress.add(key);
435
+ const running = run(arg);
436
+ inProgress.set(key, running);
416
437
  let succeeded = false;
417
438
  try {
418
- succeeded = await run(arg);
439
+ succeeded = await running;
419
440
  }
420
441
  finally {
421
442
  inProgress.delete(key);
@@ -426,12 +447,25 @@ function oneAtATimePerKey(keyOf, run, onBusy = () => { }) {
426
447
  await call(followUp);
427
448
  }
428
449
  };
429
- return call;
450
+ return Object.assign(call, {
451
+ close() {
452
+ closed = true;
453
+ followUps.clear();
454
+ },
455
+ async idle() {
456
+ // A follow-up can start as an earlier run settles (unless closed), so loop until nothing is left.
457
+ while (inProgress.size > 0) {
458
+ await Promise.allSettled(inProgress.values());
459
+ }
460
+ },
461
+ });
430
462
  }
431
463
  function startRepaintScheduler(app, config) {
432
464
  const state = loadState(app);
433
465
  const unsubscribes = [];
434
466
  const getApiUrl = (0, resolveApiUrl_1.createApiUrlResolver)(config.signalkApiUrl);
467
+ let stopping = false;
468
+ const isStopping = () => stopping;
435
469
  // The first minute or two of a SignalK server's life is a chaos of plugin dependency sequencing -
436
470
  // a repaint attempted before other plugins (e.g. derived-data) have published the data a template
437
471
  // needs renders with missing/wrong values, and hash dedup (see `considerRepaint`) then means it
@@ -451,7 +485,7 @@ function startRepaintScheduler(app, config) {
451
485
  if (targets.length === 0) {
452
486
  return false;
453
487
  }
454
- const results = await Promise.allSettled(targets.map((target) => considerRepaint(app, config, device, target, state, getApiUrl)));
488
+ const results = await Promise.allSettled(targets.map((target) => considerRepaint(app, config, device, target, state, getApiUrl, isStopping)));
455
489
  results.forEach((result, i) => {
456
490
  if (result.status === "rejected") {
457
491
  app.debug(`"${device.friendlyName}" [${targets[i].address}]: repaint failed: ${result.reason.message}`);
@@ -525,9 +559,22 @@ function startRepaintScheduler(app, config) {
525
559
  }, settleMs);
526
560
  unsubscribes.push(() => clearTimeout(startupCheckTimer));
527
561
  return {
528
- stop() {
562
+ async stop() {
563
+ stopping = true;
529
564
  for (const unsubscribe of unsubscribes)
530
565
  unsubscribe();
566
+ repaint.close();
567
+ let timer;
568
+ const drained = await Promise.race([
569
+ repaint.idle().then(() => true),
570
+ new Promise((resolve) => {
571
+ timer = setTimeout(() => resolve(false), exports.STOP_DRAIN_TIMEOUT_MS);
572
+ }),
573
+ ]);
574
+ clearTimeout(timer);
575
+ if (!drained) {
576
+ app.debug(`a repaint was still running ${exports.STOP_DRAIN_TIMEOUT_MS / 1000}s after stop - not waiting any longer`);
577
+ }
531
578
  },
532
579
  };
533
580
  }
package/docs/bluetooth.md CHANGED
@@ -30,7 +30,7 @@ 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
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.
36
36
 
@@ -160,3 +160,32 @@ Following file created at /etc/tlp.d/99-bt500-no-autosuspend.conf
160
160
  ```
161
161
  USB_DENYLIST="0b05:190e"
162
162
  ```
163
+
164
+ ## Bluetooth Jargon Buster
165
+
166
+ Bluetooth comes with a lot of acronyms. Here's what the ones on this page mean, without needing to be a computer person.
167
+
168
+ 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.
169
+
170
+ - **BLE (Bluetooth Low Energy)** - the kind of Bluetooth made for small battery-powered gadgets like labels, sensors and battery monitors.
171
+ - It's different from the "classic" Bluetooth used by headphones and speakers, and doesn't have the usual pairing process.
172
+ - BLE devices talk in short bursts and sleep in between, which is how a label runs for years on a coin cell.
173
+ - They regularly broadcast short "advertisements" saying who they are, and the server picks these up by _scanning_.
174
+ - BLE needs an adapter supporting Bluetooth 4.0 or later.
175
+ - **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".
176
+ - Linux names each Bluetooth adapter after it, so `hci0` is the first adapter, `hci1` the second, and so on.
177
+ - A plugin using the adapter "directly" is sending HCI commands itself, works fine if there's only one Bluetooth app on the server.
178
+ - **BlueZ** - the standard Bluetooth software on Linux.
179
+ - 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.
180
+ - 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.
181
+ - **D-Bus** - the messaging system that programs on a Linux computer use to talk to each other.
182
+ - It's how the plugin, or SignalK's BLE Manager, asks BlueZ to scan or connect.
183
+ - 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.
184
+ - **GATT (Generic Attribute Profile)** - how a BLE device organises what it offers once you're connected to it.
185
+ - 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.
186
+ - Painting a label means connecting, finding its image service, and writing the picture into the right characteristic a piece at a time.
187
+ - 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.
188
+ - **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.
189
+ - 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.
190
+ - **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.
191
+ - 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.1",
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",
79
+ "oxfmt": "^0.74.0",
80
80
  "oxlint": "^1.85.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",