@rhizomatics/signalk-einklabel-plugin 1.3.0-beta1 → 1.3.0-beta11

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/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
@@ -108,27 +152,33 @@ function pickKnownKeys(raw) {
108
152
  }
109
153
  function readCurrentConfig(app) {
110
154
  const { unwrapped } = unwrapNestedConfiguration(app.readPluginOptions());
111
- return pickKnownKeys(unwrapped);
155
+ return migrateConfig(pickKnownKeys(unwrapped)).config;
112
156
  }
113
157
  /**
114
- * Actively rewrites the on-disk file once it's nested (see `readCurrentConfig`'s doc comment) -
115
- * `readCurrentConfig` alone only self-heals in memory for callers that go through it, but the admin
116
- * UI's own config-editing form round-trips whatever raw JSON it was handed verbatim, including a
117
- * stray nested `configuration` key it never touches (no schema field maps to it) - so left alone,
118
- * every future save from the UI keeps re-persisting that dead weight forever (see
119
- * `support/signalk-einklabel-plugin.json`). Called once at plugin start, which - unlike
120
- * `clearForceRepaint` - isn't gated on any device having `forceRepaint` set, so a nested file gets
121
- * 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.
122
171
  */
123
- function healNestedConfig(app) {
172
+ function healStoredConfig(app) {
124
173
  const { unwrapped, wasNested } = unwrapNestedConfiguration(app.readPluginOptions());
125
- if (!wasNested)
174
+ const { config, migrated } = migrateConfig(pickKnownKeys(unwrapped));
175
+ if (!wasNested && !migrated)
126
176
  return;
127
- app.savePluginOptions(pickKnownKeys(unwrapped), (err) => {
177
+ app.savePluginOptions(config, (err) => {
128
178
  if (err)
129
- app.debug(`failed to clean up legacy nested plugin config: ${err.message}`);
179
+ app.debug(`failed to update the stored plugin config: ${err.message}`);
130
180
  else
131
- 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(", ")})`);
132
182
  });
133
183
  }
134
184
  /**
@@ -298,9 +348,34 @@ function resolveTemplatePath(templatesDir, templateName, target) {
298
348
  const localPath = (0, path_1.join)(templatesDir, templateName);
299
349
  return (0, fs_1.existsSync)(localPath) ? localPath : (0, path_1.join)(exports.BUNDLED_TEMPLATES_DIR, templateName);
300
350
  }
301
- /** 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
+ */
302
357
  function withEnum(schema, values, names) {
303
- 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
+ };
304
379
  }
305
380
  function configSchema(app, discovered = []) {
306
381
  const defaults = defaultConfig();
@@ -309,6 +384,18 @@ function configSchema(app, discovered = []) {
309
384
  return {
310
385
  type: "object",
311
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
+ : {}),
312
399
  templatesDir: {
313
400
  type: "string",
314
401
  title: "Templates directory",
@@ -321,7 +408,8 @@ function configSchema(app, discovered = []) {
321
408
  type: "boolean",
322
409
  title: "Scan for devices on plugin start",
323
410
  description: 'Runs a short BLE scan so discovered devices show up in a device\'s "Device" picker below. ' +
324
- '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).',
325
413
  default: defaults.scanOnStart,
326
414
  },
327
415
  scanDurationSeconds: {
@@ -331,29 +419,19 @@ function configSchema(app, discovered = []) {
331
419
  minimum: 1,
332
420
  default: defaults.scanDurationSeconds,
333
421
  },
334
- ...(app.bleApi
335
- ? {
336
- useBleApi: {
337
- type: "boolean",
338
- title: "Use the SignalK BLE Manager API",
339
- description: "Route Bluetooth access through SignalK server's BLE Manager API (server >= 2.32.0) instead of connecting to " +
340
- "BlueZ directly, so this plugin shares the adapter with other BLE plugins instead of contending for it. Requires " +
341
- "the server to have a local Bluetooth adapter or BLE gateway available (Server → Settings → Bluetooth).",
342
- default: defaults.useBleApi,
343
- },
344
- }
345
- : {}),
346
422
  paintConnectTimeoutSeconds: {
347
423
  type: "number",
348
424
  title: "Paint connect timeout (seconds)",
349
- 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.",
350
427
  minimum: 1,
351
428
  default: defaults.paintConnectTimeoutSeconds,
352
429
  },
353
430
  paintRetries: {
354
431
  type: "number",
355
432
  title: "Paint retries",
356
- 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.",
357
435
  minimum: 1,
358
436
  default: defaults.paintRetries,
359
437
  },
@@ -394,11 +472,10 @@ function configSchema(app, discovered = []) {
394
472
  'in poor light" - available to any template as source=einklabel,path=description or source=label,path=description.',
395
473
  },
396
474
  templateName: withEnum({ type: "string", title: "Template" }, templateNameOptions(resolveTemplatesDir(current.templatesDir))),
397
- repaintTrigger: {
398
- type: "string",
399
- title: "Repaint trigger",
400
- enum: ["subscription", "interval"],
401
- },
475
+ repaintTrigger: choiceField("Repaint trigger", [
476
+ ["subscription", "When a SignalK path changes"],
477
+ ["interval", "On a timed schedule"],
478
+ ]),
402
479
  triggerPath: {
403
480
  type: "string",
404
481
  title: "Trigger SignalK path (if repaint trigger is subscription)",
@@ -415,22 +492,58 @@ function configSchema(app, discovered = []) {
415
492
  maximum: 59,
416
493
  default: 0,
417
494
  },
418
- aesKey: {
419
- type: "string",
420
- title: "BLE AES key (vendor-specific; leave blank to use a default key)",
421
- },
422
- forceRepaint: {
423
- type: "boolean",
424
- title: "Force repaint",
425
- description: "Repaint even if the data is unchanged - clears itself automatically once that repaint completes",
426
- default: false,
427
- },
428
- reframe: {
429
- type: "string",
430
- title: "If the render doesn't match the panel size",
431
- 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.",
432
- enum: ["crop", "scale", "fixed"],
433
- 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
+ },
434
547
  },
435
548
  },
436
549
  },
@@ -445,6 +558,10 @@ function configUiSchema() {
445
558
  description: { "ui:widget": "textarea" },
446
559
  repaintTrigger: { "ui:widget": "radio" },
447
560
  reframe: { "ui:widget": "radio" },
561
+ advanced: {
562
+ mirror: { "ui:widget": "radio" },
563
+ compressionFormat: { "ui:widget": "radio" },
564
+ },
448
565
  },
449
566
  },
450
567
  };
@@ -12,10 +12,38 @@ export interface BleBackend {
12
12
  connectGatt(address: string, timeoutMs: number): Promise<GattConnection>;
13
13
  waitForManufacturerData(address: string, manufacturerId: number, timeoutMs: number): Promise<Buffer | undefined>;
14
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;
15
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>;
16
45
  /**
17
46
  * `bleApi.connectGATT()` has no timeout of its own, same story as node-ble's `Device#connect()` (see
18
- * `connectWithTimeout` in `bleDiscovery.ts`) - races it against `timeoutMs` and disconnects in the
19
- * background if it eventually resolves after the caller has already given up.
47
+ * `connectWithTimeout` in `bleDiscovery.ts`) - races it against `timeoutMs`.
20
48
  */
21
49
  export declare function bleApiBackend(bleApi: BLEApi, pluginId: string): BleBackend;
@@ -1,10 +1,82 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.withSessionWatchdog = withSessionWatchdog;
3
4
  exports.nodeBleBackend = nodeBleBackend;
5
+ exports.ensureDeviceVisible = ensureDeviceVisible;
6
+ exports.exclusiveBleManagerAccess = exclusiveBleManagerAccess;
4
7
  exports.bleApiBackend = bleApiBackend;
8
+ const pluginVersion_1 = require("../pluginVersion");
5
9
  const bleDiscovery_1 = require("./bleDiscovery");
6
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. */
7
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
+ }
8
80
  function nodeBleBackend() {
9
81
  return {
10
82
  async connectGatt(address, timeoutMs) {
@@ -14,7 +86,14 @@ function nodeBleBackend() {
14
86
  const device = await (0, bleDiscovery_1.getOrDiscoverDevice)(adapter, address, DEVICE_DISCOVERY_TIMEOUT_MS);
15
87
  await (0, bleDiscovery_1.connectWithTimeout)(device, timeoutMs);
16
88
  const conn = (0, bleDiscovery_1.openNodeBleGattConnection)(device);
17
- return {
89
+ let destroyed = false;
90
+ const destroyOnce = () => {
91
+ if (destroyed)
92
+ return;
93
+ destroyed = true;
94
+ destroy();
95
+ };
96
+ const gatt = {
18
97
  read: conn.read.bind(conn),
19
98
  write: conn.write.bind(conn),
20
99
  startNotifications: conn.startNotifications.bind(conn),
@@ -25,10 +104,22 @@ function nodeBleBackend() {
25
104
  return conn.connected;
26
105
  },
27
106
  async disconnect() {
28
- await conn.disconnect();
29
- destroy();
107
+ try {
108
+ await conn.disconnect();
109
+ }
110
+ finally {
111
+ destroyOnce();
112
+ }
30
113
  },
31
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
+ });
32
123
  }
33
124
  catch (err) {
34
125
  destroy();
@@ -48,10 +139,53 @@ function nodeBleBackend() {
48
139
  },
49
140
  };
50
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
+ }
51
186
  /**
52
187
  * `bleApi.connectGATT()` has no timeout of its own, same story as node-ble's `Device#connect()` (see
53
- * `connectWithTimeout` in `bleDiscovery.ts`) - races it against `timeoutMs` and disconnects in the
54
- * background if it eventually resolves after the caller has already given up.
188
+ * `connectWithTimeout` in `bleDiscovery.ts`) - races it against `timeoutMs`.
55
189
  */
56
190
  function bleApiBackend(bleApi, pluginId) {
57
191
  return {
@@ -61,20 +195,58 @@ function bleApiBackend(bleApi, pluginId) {
61
195
  // "already claimed" until the server restarts - see signalk-bluetti-plugin's BleManagerDevice
62
196
  // for the same defensive call. A no-op if we don't currently hold the claim.
63
197
  await bleApi.releaseGATTDevice(address, pluginId).catch(() => { });
198
+ await ensureDeviceVisible(bleApi, pluginId, address, DEVICE_DISCOVERY_TIMEOUT_MS);
64
199
  const connecting = bleApi.connectGATT(address, pluginId);
65
200
  let timedOut = false;
66
201
  const conn = await Promise.race([connecting, (0, bleDiscovery_1.sleep)(timeoutMs).then(() => void (timedOut = true))]);
67
202
  if (timedOut || !conn) {
68
- connecting.then((c) => c.disconnect()).catch(() => { });
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
+ }
69
228
  throw new Error(`connecting to device timed out after ${timeoutMs}ms`);
70
229
  }
71
- return conn;
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
+ });
72
236
  },
73
237
  async waitForManufacturerData(address, manufacturerId, timeoutMs) {
74
238
  // Unlike node-ble's `device.getManufacturerData()`, there's no cached-instant-read path here -
75
239
  // `BLEDeviceInfo` (from `getDevices()`/`getDevice()`) carries mac/name/rssi/seenBy but not
76
240
  // manufacturer data (see `BLEDeviceInfoSchema` in `@signalk/server-api`'s `ble-schemas.ts`) -
77
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(() => { });
78
250
  return new Promise((resolve) => {
79
251
  const timer = setTimeout(() => {
80
252
  unsubscribe();
@@ -34,9 +34,16 @@ export declare function forEachAdvertisedDevice(adapter: Adapter, fn: (advertise
34
34
  /**
35
35
  * Retries `fn` up to `attempts` times (including the first try), returning on the first success -
36
36
  * shared by the repaint scheduler and the CLI's `paint` command so one flaky BLE connection
37
- * doesn't fail a whole repaint after a single bad attempt.
37
+ * doesn't fail a whole repaint after a single bad attempt. `delayMs` pauses between attempts, giving
38
+ * the adapter (and any BLE Manager claim the failed attempt held) a moment to settle rather than
39
+ * hitting the device again in the same tick. `onError` sees every failed attempt's error, not just the
40
+ * last one that's eventually thrown - an early attempt's error is often the real cause, with later
41
+ * ones just fallout from it.
38
42
  */
39
- export declare function withRetries<T>(attempts: number, fn: (attempt: number) => Promise<T>): Promise<T>;
43
+ export declare function withRetries<T>(attempts: number, fn: (attempt: number) => Promise<T>, { delayMs, onError }?: {
44
+ delayMs?: number;
45
+ onError?: (err: unknown, attempt: number) => void;
46
+ }): Promise<T>;
40
47
  /**
41
48
  * Opens exactly one BLE discovery window and one D-Bus/BlueZ session, then hands the adapter to
42
49
  * `fn` - shared by `plugin.ts`'s startup scan and the CLI's `scan` command so scanning across
@@ -68,16 +68,25 @@ async function forEachAdvertisedDevice(adapter, fn) {
68
68
  /**
69
69
  * Retries `fn` up to `attempts` times (including the first try), returning on the first success -
70
70
  * shared by the repaint scheduler and the CLI's `paint` command so one flaky BLE connection
71
- * doesn't fail a whole repaint after a single bad attempt.
71
+ * doesn't fail a whole repaint after a single bad attempt. `delayMs` pauses between attempts, giving
72
+ * the adapter (and any BLE Manager claim the failed attempt held) a moment to settle rather than
73
+ * hitting the device again in the same tick. `onError` sees every failed attempt's error, not just the
74
+ * last one that's eventually thrown - an early attempt's error is often the real cause, with later
75
+ * ones just fallout from it.
72
76
  */
73
- async function withRetries(attempts, fn) {
77
+ async function withRetries(attempts, fn, { delayMs = 0, onError } = {}) {
78
+ const total = Math.max(1, attempts);
74
79
  let lastErr;
75
- for (let attempt = 1; attempt <= Math.max(1, attempts); attempt++) {
80
+ for (let attempt = 1; attempt <= total; attempt++) {
76
81
  try {
77
82
  return await fn(attempt);
78
83
  }
79
84
  catch (err) {
80
85
  lastErr = err;
86
+ onError?.(err, attempt);
87
+ if (attempt < total && delayMs > 0) {
88
+ await sleep(delayMs);
89
+ }
81
90
  }
82
91
  }
83
92
  throw lastErr;