@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/CHANGELOG.md +29 -1
- package/README.md +42 -3
- package/dist/cli/index.d.ts +4 -1
- package/dist/cli/index.js +25 -1
- package/dist/config.d.ts +46 -14
- package/dist/config.js +170 -53
- package/dist/devices/bleBackend.d.ts +30 -2
- package/dist/devices/bleBackend.js +179 -7
- package/dist/devices/bleDiscovery.d.ts +9 -2
- package/dist/devices/bleDiscovery.js +12 -3
- package/dist/devices/discoveryCoordinator.d.ts +18 -0
- package/dist/devices/discoveryCoordinator.js +89 -22
- 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.js +19 -8
- package/dist/devices/gicisky/layout.d.ts +13 -1
- package/dist/devices/gicisky/layout.js +21 -0
- package/dist/devices/types.d.ts +22 -0
- 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.js +10 -4
- package/dist/devices/zhsunyco/protocol.d.ts +2 -0
- package/dist/devices/zhsunyco/protocol.js +2 -0
- package/dist/plugin.js +11 -2
- package/dist/render/mirror.d.ts +11 -0
- package/dist/render/mirror.js +24 -0
- package/dist/repaintScheduler.js +27 -12
- package/package.json +15 -9
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
|
|
@@ -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
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* `
|
|
120
|
-
*
|
|
121
|
-
*
|
|
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
|
|
172
|
+
function healStoredConfig(app) {
|
|
124
173
|
const { unwrapped, wasNested } = unwrapNestedConfiguration(app.readPluginOptions());
|
|
125
|
-
|
|
174
|
+
const { config, migrated } = migrateConfig(pickKnownKeys(unwrapped));
|
|
175
|
+
if (!wasNested && !migrated)
|
|
126
176
|
return;
|
|
127
|
-
app.savePluginOptions(
|
|
177
|
+
app.savePluginOptions(config, (err) => {
|
|
128
178
|
if (err)
|
|
129
|
-
app.debug(`failed to
|
|
179
|
+
app.debug(`failed to update the stored plugin config: ${err.message}`);
|
|
130
180
|
else
|
|
131
|
-
app.debug(
|
|
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
|
-
/**
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 <=
|
|
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;
|