@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.
- package/CHANGELOG.md +28 -0
- package/README.md +97 -9
- package/dist/cli/index.d.ts +4 -1
- package/dist/cli/index.js +30 -6
- package/dist/config.d.ts +53 -14
- package/dist/config.js +172 -41
- package/dist/devices/bleBackend.d.ts +49 -0
- package/dist/devices/bleBackend.js +268 -0
- package/dist/devices/bleDiscovery.d.ts +20 -2
- package/dist/devices/bleDiscovery.js +101 -3
- package/dist/devices/discoveryCoordinator.d.ts +25 -6
- package/dist/devices/discoveryCoordinator.js +148 -9
- package/dist/devices/gattConnection.d.ts +9 -0
- package/dist/devices/gattConnection.js +2 -0
- package/dist/devices/gicisky/compression.d.ts +22 -9
- package/dist/devices/gicisky/compression.js +128 -13
- package/dist/devices/gicisky/encode.d.ts +1 -1
- package/dist/devices/gicisky/encode.js +2 -2
- package/dist/devices/gicisky/index.d.ts +7 -2
- package/dist/devices/gicisky/index.js +118 -96
- package/dist/devices/gicisky/layout.d.ts +13 -1
- package/dist/devices/gicisky/layout.js +21 -0
- package/dist/devices/types.d.ts +48 -6
- package/dist/devices/types.js +2 -0
- package/dist/devices/zhsunyco/compression.d.ts +1 -0
- package/dist/devices/zhsunyco/compression.js +30 -0
- package/dist/devices/zhsunyco/index.d.ts +8 -2
- package/dist/devices/zhsunyco/index.js +92 -82
- package/dist/devices/zhsunyco/protocol.d.ts +2 -0
- package/dist/devices/zhsunyco/protocol.js +2 -0
- package/dist/plugin.js +38 -11
- package/dist/render/mirror.d.ts +11 -0
- package/dist/render/mirror.js +24 -0
- package/dist/repaintScheduler.js +29 -12
- package/docs/assets/images/mini_tidal_clock.png +0 -0
- 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.
|
|
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
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* `
|
|
118
|
-
*
|
|
119
|
-
*
|
|
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
|
|
172
|
+
function healStoredConfig(app) {
|
|
122
173
|
const { unwrapped, wasNested } = unwrapNestedConfiguration(app.readPluginOptions());
|
|
123
|
-
|
|
174
|
+
const { config, migrated } = migrateConfig(pickKnownKeys(unwrapped));
|
|
175
|
+
if (!wasNested && !migrated)
|
|
124
176
|
return;
|
|
125
|
-
app.savePluginOptions(
|
|
177
|
+
app.savePluginOptions(config, (err) => {
|
|
126
178
|
if (err)
|
|
127
|
-
app.debug(`failed to
|
|
179
|
+
app.debug(`failed to update the stored plugin config: ${err.message}`);
|
|
128
180
|
else
|
|
129
|
-
app.debug(
|
|
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
|
-
/**
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
+
}
|