@rhizomatics/signalk-einklabel-plugin 1.3.2 → 1.3.3

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