@rhizomatics/signalk-einklabel-plugin 1.2.3 → 1.3.0-beta10

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.
Files changed (36) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +97 -9
  3. package/dist/cli/index.d.ts +4 -1
  4. package/dist/cli/index.js +30 -6
  5. package/dist/config.d.ts +53 -14
  6. package/dist/config.js +172 -41
  7. package/dist/devices/bleBackend.d.ts +49 -0
  8. package/dist/devices/bleBackend.js +268 -0
  9. package/dist/devices/bleDiscovery.d.ts +20 -2
  10. package/dist/devices/bleDiscovery.js +101 -3
  11. package/dist/devices/discoveryCoordinator.d.ts +25 -6
  12. package/dist/devices/discoveryCoordinator.js +148 -9
  13. package/dist/devices/gattConnection.d.ts +9 -0
  14. package/dist/devices/gattConnection.js +2 -0
  15. package/dist/devices/gicisky/compression.d.ts +22 -9
  16. package/dist/devices/gicisky/compression.js +128 -13
  17. package/dist/devices/gicisky/encode.d.ts +1 -1
  18. package/dist/devices/gicisky/encode.js +2 -2
  19. package/dist/devices/gicisky/index.d.ts +7 -2
  20. package/dist/devices/gicisky/index.js +118 -96
  21. package/dist/devices/gicisky/layout.d.ts +13 -1
  22. package/dist/devices/gicisky/layout.js +21 -0
  23. package/dist/devices/types.d.ts +48 -6
  24. package/dist/devices/types.js +2 -0
  25. package/dist/devices/zhsunyco/compression.d.ts +1 -0
  26. package/dist/devices/zhsunyco/compression.js +30 -0
  27. package/dist/devices/zhsunyco/index.d.ts +8 -2
  28. package/dist/devices/zhsunyco/index.js +92 -82
  29. package/dist/devices/zhsunyco/protocol.d.ts +2 -0
  30. package/dist/devices/zhsunyco/protocol.js +2 -0
  31. package/dist/plugin.js +38 -11
  32. package/dist/render/mirror.d.ts +11 -0
  33. package/dist/render/mirror.js +24 -0
  34. package/dist/repaintScheduler.js +29 -12
  35. package/docs/assets/images/mini_tidal_clock.png +0 -0
  36. package/package.json +3 -3
package/dist/config.js CHANGED
@@ -1,9 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.RENDER_FALLBACK_TEMPLATE_NAME = exports.BUNDLED_TEMPLATES_DIR = exports.ALL_DEVICES = void 0;
4
+ exports.migrateConfig = migrateConfig;
4
5
  exports.defaultConfig = defaultConfig;
5
6
  exports.readCurrentConfig = readCurrentConfig;
6
- exports.healNestedConfig = healNestedConfig;
7
+ exports.healStoredConfig = healStoredConfig;
7
8
  exports.resolveTemplatesDir = resolveTemplatesDir;
8
9
  exports.parseDevice = parseDevice;
9
10
  exports.resolveTemplatePath = resolveTemplatePath;
@@ -23,6 +24,49 @@ const resolveApiUrl_1 = require("./resolveApiUrl");
23
24
  * an on-demand scan itself if nothing's been discovered yet.
24
25
  */
25
26
  exports.ALL_DEVICES = "ALL";
27
+ /**
28
+ * Every `AdvancedDeviceSettings` key - these all used to sit directly on `DeviceConfig`, so a config
29
+ * saved before they were grouped still has them there. See `migrateDeviceConfig`.
30
+ */
31
+ const ADVANCED_DEVICE_KEYS = [
32
+ "compress",
33
+ "mirror",
34
+ "compressionFormat",
35
+ "forceRepaint",
36
+ "aesKey",
37
+ "paintConnectTimeoutSeconds",
38
+ "paintRetries",
39
+ ];
40
+ /**
41
+ * Moves any `AdvancedDeviceSettings` key found at the top level of a device entry (the pre-grouping
42
+ * layout) into its `advanced` object, reporting whether anything moved. A value already under
43
+ * `advanced` wins over a legacy top-level one - it can only have got there from the grouped form, so
44
+ * it's the newer of the two.
45
+ */
46
+ function migrateDeviceConfig(raw) {
47
+ const legacy = {};
48
+ const rest = { ...raw };
49
+ for (const key of ADVANCED_DEVICE_KEYS) {
50
+ if (key in rest) {
51
+ legacy[key] = rest[key];
52
+ delete rest[key];
53
+ }
54
+ }
55
+ if (Object.keys(legacy).length === 0) {
56
+ return { device: raw, migrated: false };
57
+ }
58
+ return { device: { ...rest, advanced: { ...legacy, ...raw.advanced } }, migrated: true };
59
+ }
60
+ /** Applies `migrateDeviceConfig` to every device entry - see `healStoredConfig` for persisting the result. */
61
+ function migrateConfig(config) {
62
+ if (!Array.isArray(config.devices)) {
63
+ return { config, migrated: false };
64
+ }
65
+ const results = config.devices.map(migrateDeviceConfig);
66
+ return results.some((result) => result.migrated)
67
+ ? { config: { ...config, devices: results.map((result) => result.device) }, migrated: true }
68
+ : { config, migrated: false };
69
+ }
26
70
  /**
27
71
  * The package's own bundled `templates/` directory (ships alongside `dist/`, see
28
72
  * package.json's `files`) - templates here are always available, but a same-named template in the
@@ -51,6 +95,7 @@ function defaultConfig() {
51
95
  templatesDir: "",
52
96
  scanOnStart: false,
53
97
  scanDurationSeconds: 20,
98
+ useBleApi: false,
54
99
  paintConnectTimeoutSeconds: 30,
55
100
  paintRetries: 3,
56
101
  settleSeconds: 120,
@@ -61,6 +106,7 @@ const PLUGIN_CONFIG_KEYS = [
61
106
  "templatesDir",
62
107
  "scanOnStart",
63
108
  "scanDurationSeconds",
109
+ "useBleApi",
64
110
  "paintConnectTimeoutSeconds",
65
111
  "paintRetries",
66
112
  "settleSeconds",
@@ -106,27 +152,33 @@ function pickKnownKeys(raw) {
106
152
  }
107
153
  function readCurrentConfig(app) {
108
154
  const { unwrapped } = unwrapNestedConfiguration(app.readPluginOptions());
109
- return pickKnownKeys(unwrapped);
155
+ return migrateConfig(pickKnownKeys(unwrapped)).config;
110
156
  }
111
157
  /**
112
- * Actively rewrites the on-disk file once it's nested (see `readCurrentConfig`'s doc comment) -
113
- * `readCurrentConfig` alone only self-heals in memory for callers that go through it, but the admin
114
- * UI's own config-editing form round-trips whatever raw JSON it was handed verbatim, including a
115
- * stray nested `configuration` key it never touches (no schema field maps to it) - so left alone,
116
- * every future save from the UI keeps re-persisting that dead weight forever (see
117
- * `support/signalk-einklabel-plugin.json`). Called once at plugin start, which - unlike
118
- * `clearForceRepaint` - isn't gated on any device having `forceRepaint` set, so a nested file gets
119
- * flattened even if nothing ever triggers that path.
158
+ * Actively rewrites the on-disk file when it's in an outdated shape - `readCurrentConfig` alone only
159
+ * fixes things up in memory for callers that go through it, but the admin UI's config form
160
+ * round-trips whatever raw JSON it was handed verbatim. Two shapes are healed:
161
+ *
162
+ * - A nested file (see `readCurrentConfig`'s doc comment): left alone, the UI keeps re-persisting a
163
+ * stray nested `configuration` key it never touches (no schema field maps to it) forever (see
164
+ * `support/signalk-einklabel-plugin.json`).
165
+ * - Advanced device settings saved before they were grouped under `advanced` (see
166
+ * `migrateDeviceConfig`): left alone, the UI would show that group empty, even though the plugin
167
+ * itself still honours the old values.
168
+ *
169
+ * Called once at plugin start, which - unlike `clearForceRepaint` - isn't gated on any device having
170
+ * `forceRepaint` set, so an outdated file gets fixed even if nothing ever triggers that path.
120
171
  */
121
- function healNestedConfig(app) {
172
+ function healStoredConfig(app) {
122
173
  const { unwrapped, wasNested } = unwrapNestedConfiguration(app.readPluginOptions());
123
- if (!wasNested)
174
+ const { config, migrated } = migrateConfig(pickKnownKeys(unwrapped));
175
+ if (!wasNested && !migrated)
124
176
  return;
125
- app.savePluginOptions(pickKnownKeys(unwrapped), (err) => {
177
+ app.savePluginOptions(config, (err) => {
126
178
  if (err)
127
- app.debug(`failed to clean up legacy nested plugin config: ${err.message}`);
179
+ app.debug(`failed to update the stored plugin config: ${err.message}`);
128
180
  else
129
- app.debug("cleaned up a legacy nested plugin config file on disk");
181
+ app.debug(`updated the stored plugin config (${[wasNested && "flattened nesting", migrated && "grouped advanced device settings"].filter(Boolean).join(", ")})`);
130
182
  });
131
183
  }
132
184
  /**
@@ -296,9 +348,34 @@ function resolveTemplatePath(templatesDir, templateName, target) {
296
348
  const localPath = (0, path_1.join)(templatesDir, templateName);
297
349
  return (0, fs_1.existsSync)(localPath) ? localPath : (0, path_1.join)(exports.BUNDLED_TEMPLATES_DIR, templateName);
298
350
  }
299
- /** JSON Schema forbids an empty `enum` array, so only attach one when there's at least one option - otherwise the whole config schema fails validation. */
351
+ /**
352
+ * Restricts a string field to `values`, shown as `names` where given - as `oneOf` with `const`/`title`,
353
+ * the form RJSF 5 (the admin UI's form library) supports going forward, rather than the deprecated
354
+ * `enumNames`. JSON Schema forbids an empty `enum`/`oneOf` array, so neither is attached without at
355
+ * least one option - otherwise the whole config schema fails validation.
356
+ */
300
357
  function withEnum(schema, values, names) {
301
- return values.length > 0 ? { ...schema, enum: values, ...(names ? { enumNames: names } : {}) } : schema;
358
+ if (values.length === 0)
359
+ return schema;
360
+ return names ? { ...schema, oneOf: values.map((value, i) => ({ const: value, title: names[i] ?? value })) } : { ...schema, enum: values };
361
+ }
362
+ /**
363
+ * An enum-like string field whose options show explanatory labels rather than their raw stored values
364
+ * - `oneOf` with `const`/`title`, which RJSF 5 (the admin UI's form library) renders as each option's
365
+ * label while still saving the bare value, so existing configs are unaffected.
366
+ *
367
+ * Each label is prefixed with an en space: the admin UI renders RJSF's default-theme radio markup under
368
+ * Bootstrap 5, which has no styling for it, so the label otherwise butts straight up against its
369
+ * button - and a plugin can't ship its own CSS. An en space, unlike a plain one, isn't collapsed away
370
+ * by HTML whitespace handling.
371
+ */
372
+ function choiceField(title, options, extra = {}) {
373
+ return {
374
+ type: "string",
375
+ title,
376
+ ...extra,
377
+ oneOf: options.map(([value, label]) => ({ const: value, title: `\u2002${label}` })),
378
+ };
302
379
  }
303
380
  function configSchema(app, discovered = []) {
304
381
  const defaults = defaultConfig();
@@ -307,6 +384,18 @@ function configSchema(app, discovered = []) {
307
384
  return {
308
385
  type: "object",
309
386
  properties: {
387
+ ...(app.bleApi
388
+ ? {
389
+ useBleApi: {
390
+ type: "boolean",
391
+ title: "Use the SignalK BLE Manager API",
392
+ description: "Route Bluetooth access through SignalK server's BLE Manager API (server >= 2.32.0) instead of connecting to " +
393
+ "BlueZ directly, so this plugin shares the adapter with other BLE plugins instead of contending for it. Requires " +
394
+ "the server to have a local Bluetooth adapter or BLE gateway available (Server → Settings → Bluetooth).",
395
+ default: defaults.useBleApi,
396
+ },
397
+ }
398
+ : {}),
310
399
  templatesDir: {
311
400
  type: "string",
312
401
  title: "Templates directory",
@@ -319,7 +408,8 @@ function configSchema(app, discovered = []) {
319
408
  type: "boolean",
320
409
  title: "Scan for devices on plugin start",
321
410
  description: 'Runs a short BLE scan so discovered devices show up in a device\'s "Device" picker below. ' +
322
- 'Not needed if every device uses "All discovered devices" - that scans on demand instead.',
411
+ 'Not needed if every device uses "All discovered devices" (that scans on demand instead), or if ' +
412
+ '"Use BLE Manager" below is on (that discovers continuously in the background instead of needing a scan).',
323
413
  default: defaults.scanOnStart,
324
414
  },
325
415
  scanDurationSeconds: {
@@ -332,14 +422,16 @@ function configSchema(app, discovered = []) {
332
422
  paintConnectTimeoutSeconds: {
333
423
  type: "number",
334
424
  title: "Paint connect timeout (seconds)",
335
- description: "How long to wait for a device to accept a BLE connection before giving up on a repaint attempt.",
425
+ description: "How long to wait for a device to accept a BLE connection before giving up on a repaint attempt. " +
426
+ "The default for every device - each device can override it in its own settings below.",
336
427
  minimum: 1,
337
428
  default: defaults.paintConnectTimeoutSeconds,
338
429
  },
339
430
  paintRetries: {
340
431
  type: "number",
341
432
  title: "Paint retries",
342
- description: "How many times to attempt a repaint (including the first try) before giving up and reporting failure.",
433
+ description: "How many times to attempt a repaint (including the first try) before giving up and reporting failure. " +
434
+ "The default for every device - each device can override it in its own settings below.",
343
435
  minimum: 1,
344
436
  default: defaults.paintRetries,
345
437
  },
@@ -380,11 +472,10 @@ function configSchema(app, discovered = []) {
380
472
  'in poor light" - available to any template as source=einklabel,path=description or source=label,path=description.',
381
473
  },
382
474
  templateName: withEnum({ type: "string", title: "Template" }, templateNameOptions(resolveTemplatesDir(current.templatesDir))),
383
- repaintTrigger: {
384
- type: "string",
385
- title: "Repaint trigger",
386
- enum: ["subscription", "interval"],
387
- },
475
+ repaintTrigger: choiceField("Repaint trigger", [
476
+ ["subscription", "When a SignalK path changes"],
477
+ ["interval", "On a timed schedule"],
478
+ ]),
388
479
  triggerPath: {
389
480
  type: "string",
390
481
  title: "Trigger SignalK path (if repaint trigger is subscription)",
@@ -401,22 +492,58 @@ function configSchema(app, discovered = []) {
401
492
  maximum: 59,
402
493
  default: 0,
403
494
  },
404
- aesKey: {
405
- type: "string",
406
- title: "BLE AES key (vendor-specific; leave blank to use a default key)",
407
- },
408
- forceRepaint: {
409
- type: "boolean",
410
- title: "Force repaint",
411
- description: "Repaint even if the data is unchanged - clears itself automatically once that repaint completes",
412
- default: false,
413
- },
414
- reframe: {
415
- type: "string",
416
- title: "If the render doesn't match the panel size",
417
- description: "Crop: place at the top-left, truncating anything too big or leaving the rest blank if too small. Scale: stretch to fit exactly (may distort). Fixed: fail the repaint instead of showing an off-size image.",
418
- enum: ["crop", "scale", "fixed"],
419
- default: "crop",
495
+ reframe: choiceField("If the render doesn't match the panel size", [
496
+ ["crop", "Crop - place at the top-left, cutting off anything too big or leaving the rest blank"],
497
+ ["scale", "Scale - stretch to fit exactly (may distort)"],
498
+ ["fixed", "Fixed - fail the repaint rather than show an off-size image"],
499
+ ], { default: "crop" }),
500
+ advanced: {
501
+ type: "object",
502
+ title: "Advanced settings",
503
+ description: "Most labels never need these.",
504
+ properties: {
505
+ compress: {
506
+ type: "boolean",
507
+ title: 'Compress upload (Zhsunyco, Gicisky 7.5"/10.2")',
508
+ description: "Sends far less data over BLE, so repaints are quicker. Turn off if a label stops updating.",
509
+ default: true,
510
+ },
511
+ mirror: choiceField("Mirror", [
512
+ ["none", "No flip"],
513
+ ["horizontal", "Flip left to right"],
514
+ ["vertical", "Flip top to bottom"],
515
+ ["both", "Rotate 180° - for a label mounted upside down"],
516
+ ], { description: "Only needed if the image shows up mirrored or upside down on the label.", default: "none" }),
517
+ compressionFormat: choiceField("Wire format (Gicisky, experimental)", [
518
+ ["auto", "Auto - the model's usual format"],
519
+ [
520
+ "chunked",
521
+ 'Chunked - send compressed like the 7.5"/10.2" panels, e.g. to speed up a 4.2" BWR (untested on current firmware)',
522
+ ],
523
+ ], { description: "Chunked needs Compress upload on. Switch back to Auto if the label stops updating.", default: "auto" }),
524
+ forceRepaint: {
525
+ type: "boolean",
526
+ title: "Force repaint",
527
+ description: "Repaint even if the data is unchanged - clears itself automatically once that repaint completes",
528
+ default: false,
529
+ },
530
+ aesKey: {
531
+ type: "string",
532
+ title: "BLE AES key (vendor-specific; leave blank to use a default key)",
533
+ },
534
+ paintConnectTimeoutSeconds: {
535
+ type: "number",
536
+ title: "Paint connect timeout for this device (seconds)",
537
+ description: `Leave blank to use the plugin-wide setting (currently ${current.paintConnectTimeoutSeconds}s).`,
538
+ minimum: 1,
539
+ },
540
+ paintRetries: {
541
+ type: "number",
542
+ title: "Paint retries for this device",
543
+ description: `Leave blank to use the plugin-wide setting (currently ${current.paintRetries}).`,
544
+ minimum: 1,
545
+ },
546
+ },
420
547
  },
421
548
  },
422
549
  },
@@ -431,6 +558,10 @@ function configUiSchema() {
431
558
  description: { "ui:widget": "textarea" },
432
559
  repaintTrigger: { "ui:widget": "radio" },
433
560
  reframe: { "ui:widget": "radio" },
561
+ advanced: {
562
+ mirror: { "ui:widget": "radio" },
563
+ compressionFormat: { "ui:widget": "radio" },
564
+ },
434
565
  },
435
566
  },
436
567
  };
@@ -0,0 +1,49 @@
1
+ import { BLEApi } from "@signalk/server-api";
2
+ import { GattConnection } from "./gattConnection";
3
+ /**
4
+ * Opens a connection to `address` and (for gicisky) re-scans for a fresh advertisement carrying its
5
+ * manufacturer data - the two BLE-hardware-access operations a `paint()` needs beyond the
6
+ * backend-agnostic `GattConnection` a connection itself exposes. `nodeBleBackend()` talks to BlueZ
7
+ * directly; `bleApiBackend()` routes through the SignalK server's BLE Manager API (`app.bleApi`) so
8
+ * this plugin shares the adapter with other BLE plugins instead of contending for it - see
9
+ * `VendorDeviceConfig.gattBackend` in `types.ts` for how a driver picks one.
10
+ */
11
+ export interface BleBackend {
12
+ connectGatt(address: string, timeoutMs: number): Promise<GattConnection>;
13
+ waitForManufacturerData(address: string, manufacturerId: number, timeoutMs: number): Promise<Buffer | undefined>;
14
+ }
15
+ /**
16
+ * Wraps a connected `GattConnection` so the session as a whole - not just the connect step - can't
17
+ * hang forever. Starts a single timer when the connection opens; if `disconnect()` hasn't been
18
+ * called (i.e. the caller's `paint()` hasn't finished, successfully or not) by the time it fires,
19
+ * treats the session as stuck: calls `forceClose` (which must tear down the connection and, for a
20
+ * shared backend, release any claim on it) and fails every call still outstanding or made afterwards.
21
+ * `Promise.race`ing each call against that same failure - rather than just refusing new calls once
22
+ * killed - is what actually unblocks a caller `await`ing a hung `write()`/`discoverServices()`:
23
+ * forcing the underlying connection closed doesn't guarantee the hung call's own promise ever
24
+ * settles, so the caller needs a second, independent way to move on.
25
+ */
26
+ export declare function withSessionWatchdog(conn: GattConnection, timeoutMs: number, forceClose: () => Promise<void>): GattConnection;
27
+ export declare function nodeBleBackend(): BleBackend;
28
+ /**
29
+ * Waits for the BLE Manager to know about `address` before a GATT connect is attempted against it -
30
+ * mirrors `getOrDiscoverDevice` in `bleDiscovery.ts`, which gives `nodeBleBackend` the same guarantee
31
+ * against BlueZ directly. Without this, a specifically-addressed device that the manager hasn't seen
32
+ * yet (e.g. this plugin's own `scanOnStart` is off and no other BLE plugin happens to be scanning)
33
+ * would sit on `bleApi.connectGATT()` until that call's own `timeoutMs` gives up, rather than the
34
+ * plugin quietly rediscovering it the way the direct-BlueZ backend already does.
35
+ */
36
+ export declare function ensureDeviceVisible(bleApi: BLEApi, pluginId: string, address: string, timeoutMs: number): Promise<void>;
37
+ /**
38
+ * Runs `fn` only once every earlier caller has finished, so paints to different devices never hold
39
+ * BLE Manager GATT slots at the same time. The server's local provider defaults to just a couple of
40
+ * slots shared with every other BLE plugin (e.g. Bluetti), so two concurrent connects can leave the
41
+ * second with none free - which the server reports as the misleading "No provider with GATT support
42
+ * can see <mac>".
43
+ */
44
+ export declare function exclusiveBleManagerAccess<T>(fn: () => Promise<T>): Promise<T>;
45
+ /**
46
+ * `bleApi.connectGATT()` has no timeout of its own, same story as node-ble's `Device#connect()` (see
47
+ * `connectWithTimeout` in `bleDiscovery.ts`) - races it against `timeoutMs`.
48
+ */
49
+ export declare function bleApiBackend(bleApi: BLEApi, pluginId: string): BleBackend;
@@ -0,0 +1,268 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.withSessionWatchdog = withSessionWatchdog;
4
+ exports.nodeBleBackend = nodeBleBackend;
5
+ exports.ensureDeviceVisible = ensureDeviceVisible;
6
+ exports.exclusiveBleManagerAccess = exclusiveBleManagerAccess;
7
+ exports.bleApiBackend = bleApiBackend;
8
+ const pluginVersion_1 = require("../pluginVersion");
9
+ const bleDiscovery_1 = require("./bleDiscovery");
10
+ /** How long to wait for BlueZ to have (or acquire) a `Device` object for the target address before giving up - a separate budget from the connect step itself, matching both drivers' previous hardcoded constant. */
11
+ const DEVICE_DISCOVERY_TIMEOUT_MS = 30000;
12
+ /**
13
+ * Bounds the whole post-connect GATT session (service discovery, every read/write/notification a
14
+ * driver's `paint()` makes until it calls `disconnect()`), not just the connect step above - unlike
15
+ * `connectWithTimeout`/`bleApiBackend`'s connect race, neither backend's underlying `write()` or
16
+ * `discoverServices()` has any timeout of its own, so a hang there (plausible against a flaky real
17
+ * device) would otherwise sit forever with the connection, and any BLE Manager GATT claim, held open
18
+ * - see `withSessionWatchdog`. Generous relative to any single driver operation's own timeout (e.g.
19
+ * gicisky's 15s per-chunk ack) since it has to cover a whole multi-chunk image transfer, not one step
20
+ * of it; it's a last-resort backstop, not a normal-path budget.
21
+ */
22
+ const GATT_SESSION_WATCHDOG_MS = 5 * 60000;
23
+ /**
24
+ * Wraps a connected `GattConnection` so the session as a whole - not just the connect step - can't
25
+ * hang forever. Starts a single timer when the connection opens; if `disconnect()` hasn't been
26
+ * called (i.e. the caller's `paint()` hasn't finished, successfully or not) by the time it fires,
27
+ * treats the session as stuck: calls `forceClose` (which must tear down the connection and, for a
28
+ * shared backend, release any claim on it) and fails every call still outstanding or made afterwards.
29
+ * `Promise.race`ing each call against that same failure - rather than just refusing new calls once
30
+ * killed - is what actually unblocks a caller `await`ing a hung `write()`/`discoverServices()`:
31
+ * forcing the underlying connection closed doesn't guarantee the hung call's own promise ever
32
+ * settles, so the caller needs a second, independent way to move on.
33
+ */
34
+ function withSessionWatchdog(conn, timeoutMs, forceClose) {
35
+ let killedError;
36
+ let resolveKilled;
37
+ const killed = new Promise((resolve) => {
38
+ resolveKilled = resolve;
39
+ });
40
+ let settled = false;
41
+ // `unref()` so this backstop timer never itself keeps the process alive (e.g. the CLI's `paint`
42
+ // command exiting naturally once its work is done) - it only ever needs to fire while something
43
+ // else is already keeping the event loop running anyway.
44
+ const timer = setTimeout(() => {
45
+ if (settled)
46
+ return;
47
+ killedError = new Error(`GATT session watchdog fired after ${timeoutMs}ms with no completion - forcing disconnect`);
48
+ console.error(`${pluginVersion_1.PLUGIN_NAME}: ${killedError.message}`);
49
+ resolveKilled(killedError);
50
+ void forceClose().catch(() => { });
51
+ }, timeoutMs).unref();
52
+ function guard(op) {
53
+ if (killedError)
54
+ return Promise.reject(killedError);
55
+ const result = op();
56
+ result.catch(() => { }); // observed here too, so losing the race below never surfaces as an unhandled rejection
57
+ return Promise.race([result, killed.then((err) => Promise.reject(err))]);
58
+ }
59
+ return {
60
+ read: (serviceUuid, charUuid) => guard(() => conn.read(serviceUuid, charUuid)),
61
+ write: (serviceUuid, charUuid, data, withResponse) => guard(() => conn.write(serviceUuid, charUuid, data, withResponse)),
62
+ startNotifications: (serviceUuid, charUuid, callback) => guard(() => conn.startNotifications(serviceUuid, charUuid, callback)),
63
+ stopNotifications: (serviceUuid, charUuid) => guard(() => conn.stopNotifications(serviceUuid, charUuid)),
64
+ discoverServices: () => guard(() => conn.discoverServices()),
65
+ onDisconnect: conn.onDisconnect.bind(conn),
66
+ get connected() {
67
+ return conn.connected;
68
+ },
69
+ async disconnect() {
70
+ if (settled)
71
+ return;
72
+ settled = true;
73
+ clearTimeout(timer);
74
+ if (killedError)
75
+ return; // already forced closed by the watchdog above
76
+ await conn.disconnect();
77
+ },
78
+ };
79
+ }
80
+ function nodeBleBackend() {
81
+ return {
82
+ async connectGatt(address, timeoutMs) {
83
+ const { bluetooth, destroy } = (0, bleDiscovery_1.createBluetooth)();
84
+ try {
85
+ const adapter = await bluetooth.defaultAdapter();
86
+ const device = await (0, bleDiscovery_1.getOrDiscoverDevice)(adapter, address, DEVICE_DISCOVERY_TIMEOUT_MS);
87
+ await (0, bleDiscovery_1.connectWithTimeout)(device, timeoutMs);
88
+ const conn = (0, bleDiscovery_1.openNodeBleGattConnection)(device);
89
+ let destroyed = false;
90
+ const destroyOnce = () => {
91
+ if (destroyed)
92
+ return;
93
+ destroyed = true;
94
+ destroy();
95
+ };
96
+ const gatt = {
97
+ read: conn.read.bind(conn),
98
+ write: conn.write.bind(conn),
99
+ startNotifications: conn.startNotifications.bind(conn),
100
+ stopNotifications: conn.stopNotifications.bind(conn),
101
+ discoverServices: conn.discoverServices.bind(conn),
102
+ onDisconnect: conn.onDisconnect.bind(conn),
103
+ get connected() {
104
+ return conn.connected;
105
+ },
106
+ async disconnect() {
107
+ try {
108
+ await conn.disconnect();
109
+ }
110
+ finally {
111
+ destroyOnce();
112
+ }
113
+ },
114
+ };
115
+ return withSessionWatchdog(gatt, GATT_SESSION_WATCHDOG_MS, async () => {
116
+ // `device.disconnect()` can hang for the same underlying reason the operations
117
+ // `withSessionWatchdog` already guards can (see its doc comment) - race it briefly rather
118
+ // than awaiting it unbounded here too, but tear down the D-Bus connection either way so
119
+ // those resources don't leak even if BlueZ's disconnect itself never completes.
120
+ await Promise.race([gatt.disconnect(), (0, bleDiscovery_1.sleep)(5000)]);
121
+ destroyOnce();
122
+ });
123
+ }
124
+ catch (err) {
125
+ destroy();
126
+ throw err;
127
+ }
128
+ },
129
+ async waitForManufacturerData(address, manufacturerId, timeoutMs) {
130
+ const { bluetooth, destroy } = (0, bleDiscovery_1.createBluetooth)();
131
+ try {
132
+ const adapter = await bluetooth.defaultAdapter();
133
+ const device = await (0, bleDiscovery_1.getOrDiscoverDevice)(adapter, address, DEVICE_DISCOVERY_TIMEOUT_MS);
134
+ return await (0, bleDiscovery_1.waitForManufacturerData)(adapter, device, manufacturerId, timeoutMs);
135
+ }
136
+ finally {
137
+ destroy();
138
+ }
139
+ },
140
+ };
141
+ }
142
+ /**
143
+ * Waits for the BLE Manager to know about `address` before a GATT connect is attempted against it -
144
+ * mirrors `getOrDiscoverDevice` in `bleDiscovery.ts`, which gives `nodeBleBackend` the same guarantee
145
+ * against BlueZ directly. Without this, a specifically-addressed device that the manager hasn't seen
146
+ * yet (e.g. this plugin's own `scanOnStart` is off and no other BLE plugin happens to be scanning)
147
+ * would sit on `bleApi.connectGATT()` until that call's own `timeoutMs` gives up, rather than the
148
+ * plugin quietly rediscovering it the way the direct-BlueZ backend already does.
149
+ */
150
+ async function ensureDeviceVisible(bleApi, pluginId, address, timeoutMs) {
151
+ const known = await bleApi.getDevice(address).catch(() => null);
152
+ if (known)
153
+ return;
154
+ const seen = await new Promise((resolve) => {
155
+ const timer = setTimeout(() => {
156
+ unsubscribe();
157
+ resolve(false);
158
+ }, timeoutMs);
159
+ const unsubscribe = bleApi.onAdvertisement(pluginId, (adv) => {
160
+ if (adv.mac !== address)
161
+ return;
162
+ clearTimeout(timer);
163
+ unsubscribe();
164
+ resolve(true);
165
+ });
166
+ });
167
+ if (!seen) {
168
+ throw new Error(`device ${address} isn't visible to the SignalK BLE Manager yet (out of range, asleep, or never seen) after waiting ${timeoutMs}ms`);
169
+ }
170
+ }
171
+ /** Upper bound on how long a timed-out `bleApi.connectGATT()` is given to settle server-side before a retry - see `bleApiBackend`. */
172
+ const CONNECT_SETTLE_GRACE_MS = 30000;
173
+ let bleManagerQueue = Promise.resolve();
174
+ /**
175
+ * Runs `fn` only once every earlier caller has finished, so paints to different devices never hold
176
+ * BLE Manager GATT slots at the same time. The server's local provider defaults to just a couple of
177
+ * slots shared with every other BLE plugin (e.g. Bluetti), so two concurrent connects can leave the
178
+ * second with none free - which the server reports as the misleading "No provider with GATT support
179
+ * can see <mac>".
180
+ */
181
+ function exclusiveBleManagerAccess(fn) {
182
+ const run = bleManagerQueue.then(fn, fn);
183
+ bleManagerQueue = run.catch(() => { });
184
+ return run;
185
+ }
186
+ /**
187
+ * `bleApi.connectGATT()` has no timeout of its own, same story as node-ble's `Device#connect()` (see
188
+ * `connectWithTimeout` in `bleDiscovery.ts`) - races it against `timeoutMs`.
189
+ */
190
+ function bleApiBackend(bleApi, pluginId) {
191
+ return {
192
+ async connectGatt(address, timeoutMs) {
193
+ // A previous ungraceful stop (crash, plugin reload mid-paint) can leave a stale GATT claim
194
+ // registered under our own pluginId, which would otherwise block this connect with
195
+ // "already claimed" until the server restarts - see signalk-bluetti-plugin's BleManagerDevice
196
+ // for the same defensive call. A no-op if we don't currently hold the claim.
197
+ await bleApi.releaseGATTDevice(address, pluginId).catch(() => { });
198
+ await ensureDeviceVisible(bleApi, pluginId, address, DEVICE_DISCOVERY_TIMEOUT_MS);
199
+ const connecting = bleApi.connectGATT(address, pluginId);
200
+ let timedOut = false;
201
+ const conn = await Promise.race([connecting, (0, bleDiscovery_1.sleep)(timeoutMs).then(() => void (timedOut = true))]);
202
+ if (timedOut || !conn) {
203
+ // The server registers the claim as soon as `connectGATT()` is called, not once it succeeds -
204
+ // giving up here without releasing it would leave the claim (and, once/if the connect attempt
205
+ // does eventually land server-side, a live connection) orphaned indefinitely, since this
206
+ // plugin never gets a handle back to disconnect it itself. `releaseGATTDevice` tears down
207
+ // whatever the claim currently is, connected or still connecting, regardless of how the
208
+ // original `connectGATT()` promise eventually settles - disconnecting it too if it does still
209
+ // resolve afterwards, harmlessly, since a connection the server already released is a no-op to
210
+ // disconnect again. The release is awaited (only the late disconnect is left in the
211
+ // background) so a caller retrying straight away - `withRetries` - can't race it with a new
212
+ // `connectGATT()` and get rejected with "has a GATT claim in progress" for its trouble.
213
+ await bleApi.releaseGATTDevice(address, pluginId).catch(() => { });
214
+ // ...except a claim still *connecting* isn't in the server's claim table yet, only its pending
215
+ // set, which `releaseGATTDevice` doesn't touch - so while the server's own connect is still in
216
+ // flight, a retry's `connectGATT()` is rejected outright with "has a GATT claim in progress".
217
+ // Give that connect a bounded chance to settle first, so the retry gets a clean slate.
218
+ const late = await Promise.race([
219
+ connecting.catch(() => undefined),
220
+ (0, bleDiscovery_1.sleep)(Math.min(timeoutMs, CONNECT_SETTLE_GRACE_MS)).then(() => undefined),
221
+ ]);
222
+ if (late) {
223
+ await Promise.allSettled([bleApi.releaseGATTDevice(address, pluginId), late.disconnect()]);
224
+ }
225
+ else {
226
+ void connecting.then((c) => c.disconnect()).catch(() => { });
227
+ }
228
+ throw new Error(`connecting to device timed out after ${timeoutMs}ms`);
229
+ }
230
+ return withSessionWatchdog(conn, GATT_SESSION_WATCHDOG_MS, async () => {
231
+ // Belt-and-suspenders, matching the timeout-cleanup above: `releaseGATTDevice` is the
232
+ // authoritative claim release, `disconnect()` a secondary teardown of this specific handle -
233
+ // do both regardless of which (if either) itself hangs or rejects.
234
+ await Promise.allSettled([bleApi.releaseGATTDevice(address, pluginId), conn.disconnect()]);
235
+ });
236
+ },
237
+ async waitForManufacturerData(address, manufacturerId, timeoutMs) {
238
+ // Unlike node-ble's `device.getManufacturerData()`, there's no cached-instant-read path here -
239
+ // `BLEDeviceInfo` (from `getDevices()`/`getDevice()`) carries mac/name/rssi/seenBy but not
240
+ // manufacturer data (see `BLEDeviceInfoSchema` in `@signalk/server-api`'s `ble-schemas.ts`) -
241
+ // only the streamed `BLEAdvertisement` does. So this always actively waits on the stream.
242
+ //
243
+ // Same defensive release as `connectGatt`, and just as necessary here: a device the BLE Manager
244
+ // still thinks *we* hold a GATT claim on (e.g. from a crash/reload mid-paint, before this call
245
+ // ever reaches `connectGatt` below) stops advertising while claimed - which would otherwise wedge
246
+ // this wait forever, since nothing else in this path ever calls `connectGatt` (and so never gets a
247
+ // chance to release the stale claim) unless a fresh advertisement shows up first. A no-op if we
248
+ // don't currently hold the claim.
249
+ await bleApi.releaseGATTDevice(address, pluginId).catch(() => { });
250
+ return new Promise((resolve) => {
251
+ const timer = setTimeout(() => {
252
+ unsubscribe();
253
+ resolve(undefined);
254
+ }, timeoutMs);
255
+ const unsubscribe = bleApi.onAdvertisement(pluginId, (adv) => {
256
+ if (adv.mac !== address)
257
+ return;
258
+ const hex = adv.manufacturerData?.[manufacturerId];
259
+ if (hex === undefined)
260
+ return;
261
+ clearTimeout(timer);
262
+ unsubscribe();
263
+ resolve(Buffer.from(hex, "hex"));
264
+ });
265
+ });
266
+ },
267
+ };
268
+ }