@rhizomatics/signalk-einklabel-plugin 1.3.0-beta4 → 1.3.0-beta6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  First implementation of using new SignalK BLE Manager rather than directly using the `bluez` services. Off by default until longer term stability demonstrated.
4
4
 
5
-
6
5
  # 1.2.3
7
6
 
8
7
  - Improved example tide template for 2.9" Gicisky, and added blank and error templates
package/dist/config.d.ts CHANGED
@@ -71,6 +71,9 @@ export interface PluginConfig {
71
71
  * signalk-bluetti-plugin does. Off by default - a `device: ALL_DEVICES` entry scans on demand the
72
72
  * first time it has nothing discovered yet (see `resolveTargets` in `repaintScheduler.ts`), and an
73
73
  * explicit device selection only ever needed this to populate the dropdown once at initial setup.
74
+ * Also unnecessary with `useBleApi` on, which discovers continuously in the background instead (see
75
+ * `startBleApiDiscoveryListener` in `discoveryCoordinator.ts`) - kept meaningful mainly for direct
76
+ * BlueZ access, which has no continuous-scan equivalent to piggyback on.
74
77
  */
75
78
  scanOnStart: boolean;
76
79
  /** How long the startup scan runs, in seconds. */
package/dist/config.js CHANGED
@@ -333,7 +333,8 @@ function configSchema(app, discovered = []) {
333
333
  type: "boolean",
334
334
  title: "Scan for devices on plugin start",
335
335
  description: 'Runs a short BLE scan so discovered devices show up in a device\'s "Device" picker below. ' +
336
- 'Not needed if every device uses "All discovered devices" - that scans on demand instead.',
336
+ 'Not needed if every device uses "All discovered devices" (that scans on demand instead), or if ' +
337
+ '"Use BLE Manager" below is on (that discovers continuously in the background instead of needing a scan).',
337
338
  default: defaults.scanOnStart,
338
339
  },
339
340
  scanDurationSeconds: {
@@ -34,6 +34,14 @@ export declare function nodeBleBackend(): BleBackend;
34
34
  * plugin quietly rediscovering it the way the direct-BlueZ backend already does.
35
35
  */
36
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>;
37
45
  /**
38
46
  * `bleApi.connectGATT()` has no timeout of its own, same story as node-ble's `Device#connect()` (see
39
47
  * `connectWithTimeout` in `bleDiscovery.ts`) - races it against `timeoutMs`.
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.withSessionWatchdog = withSessionWatchdog;
4
4
  exports.nodeBleBackend = nodeBleBackend;
5
5
  exports.ensureDeviceVisible = ensureDeviceVisible;
6
+ exports.exclusiveBleManagerAccess = exclusiveBleManagerAccess;
6
7
  exports.bleApiBackend = bleApiBackend;
7
8
  const pluginVersion_1 = require("../pluginVersion");
8
9
  const bleDiscovery_1 = require("./bleDiscovery");
@@ -167,6 +168,19 @@ async function ensureDeviceVisible(bleApi, pluginId, address, timeoutMs) {
167
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`);
168
169
  }
169
170
  }
171
+ let bleManagerQueue = Promise.resolve();
172
+ /**
173
+ * Runs `fn` only once every earlier caller has finished, so paints to different devices never hold
174
+ * BLE Manager GATT slots at the same time. The server's local provider defaults to just a couple of
175
+ * slots shared with every other BLE plugin (e.g. Bluetti), so two concurrent connects can leave the
176
+ * second with none free - which the server reports as the misleading "No provider with GATT support
177
+ * can see <mac>".
178
+ */
179
+ function exclusiveBleManagerAccess(fn) {
180
+ const run = bleManagerQueue.then(fn, fn);
181
+ bleManagerQueue = run.catch(() => { });
182
+ return run;
183
+ }
170
184
  /**
171
185
  * `bleApi.connectGATT()` has no timeout of its own, same story as node-ble's `Device#connect()` (see
172
186
  * `connectWithTimeout` in `bleDiscovery.ts`) - races it against `timeoutMs`.
@@ -18,3 +18,21 @@ export declare function scanInProgressSince(): number | undefined;
18
18
  * one, which would make both fail.
19
19
  */
20
20
  export declare function ensureScan(app: ServerAPI, durationSeconds: number, useBleApi: boolean): Promise<ScanResult>;
21
+ /**
22
+ * Keeps the "Device" picker (and `ALL_DEVICES`) current for as long as the plugin runs, without any
23
+ * bounded "scan" step at all - unlike `runScanViaBleApi`/`runScanViaBlueZ` above, which only ever look
24
+ * for new devices during an explicit, timed window (`scanOnStart`, or an on-demand `ALL_DEVICES` scan).
25
+ * BLE Manager already runs its own continuous scan server-side to build the device list the admin UI's
26
+ * "BLE Manager" page shows - a device that's visible there but never appears in *this* plugin's own
27
+ * picker unless `scanOnStart` happens to be ticked is exactly that mismatch: nothing was ever listening
28
+ * for BLE Manager's advertisements outside a bounded window. This instead subscribes once, for good,
29
+ * so a device shows up here as soon as BLE Manager itself has seen it - no manual scan required.
30
+ *
31
+ * Only runs a genuinely new (or long-expired, see `DISCOVERED_DEVICE_TTL_MS`) address through
32
+ * `driver.identifyDevice()` - a real GATT connect+read. A device already in the persisted store just
33
+ * gets `lastSeenAt` bumped via `touchDiscoveredDevice`, since re-identifying it on every ~1s
34
+ * advertisement would otherwise hammer it (and the shared GATT slots) for no reason. `pending` guards
35
+ * against two advertisements for the same brand-new device starting a second identify attempt while
36
+ * the first is still connecting.
37
+ */
38
+ export declare function startBleApiDiscoveryListener(app: ServerAPI, pluginId: string): () => void;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.scanInProgressSince = scanInProgressSince;
4
4
  exports.ensureScan = ensureScan;
5
+ exports.startBleApiDiscoveryListener = startBleApiDiscoveryListener;
5
6
  const bleDiscovery_1 = require("./bleDiscovery");
6
7
  const bleBackend_1 = require("./bleBackend");
7
8
  const discoveredDevicesStore_1 = require("./discoveredDevicesStore");
@@ -67,43 +68,55 @@ async function runScanViaBlueZ(app, durationSeconds) {
67
68
  const merged = (0, discoveredDevicesStore_1.recordScanResults)(app, foundThisScan);
68
69
  return { foundThisScan, merged };
69
70
  }
71
+ /**
72
+ * The `matchesAdvertisement`+`identifyDevice` half of processing one BLE Manager sighting - shared by
73
+ * `runScanViaBleApi`'s bounded scan and `startBleApiDiscoveryListener`'s indefinite one below. Doesn't
74
+ * decide *whether* to attempt identification (a bounded scan's own per-call `seen` set vs. the
75
+ * listener's persisted-store check need different answers to that) - just does the attempt and reports
76
+ * what it found, or `undefined` for "no matching driver"/"identify failed". `adv.manufacturerData`
77
+ * (from either an advertisement or, seeded, a `getDevices()` entry) is `Record<number, string>`
78
+ * (decimal company ID -> hex-encoded payload) rather than node-ble's `Buffer` - converted here so
79
+ * drivers never see the difference.
80
+ */
81
+ async function identifyOne(app, backend, mac, name, rssi, manufacturerDataHex) {
82
+ const [manufacturerIdKey] = Object.keys(manufacturerDataHex ?? {});
83
+ const manufacturerId = manufacturerIdKey === undefined ? undefined : Number(manufacturerIdKey);
84
+ const manufacturerData = manufacturerId === undefined ? undefined : Buffer.from(manufacturerDataHex[manufacturerId], "hex");
85
+ const driver = (0, registry_1.allDrivers)().find((candidate) => candidate.matchesAdvertisement(name, manufacturerId));
86
+ if (!driver) {
87
+ return undefined;
88
+ }
89
+ const connect = () => backend.connectGatt(mac, SCAN_GATT_CONNECT_TIMEOUT_MS);
90
+ const found = await driver.identifyDevice({ address: mac, name, manufacturerId, manufacturerData, rssi }, connect).catch((err) => {
91
+ app.debug(`${driver.vendor} discovery failed for [${mac}]: ${err.message}\n${err.stack ?? ""}`);
92
+ return undefined;
93
+ });
94
+ if (found) {
95
+ const pid = found.pid !== undefined ? `0x${found.pid.toString(16).padStart(4, "0")}` : "unknown";
96
+ const hwid = found.hwVersion ?? "unknown";
97
+ app.debug(`discovered ${driver.vendor} device "${found.name ?? ""}" [${found.address}] pid=${pid} hwid=${hwid}`);
98
+ }
99
+ return found;
100
+ }
70
101
  /**
71
102
  * Same as `runScanViaBlueZ` but sourced from the SignalK server's BLE Manager API instead of a direct
72
103
  * BlueZ discovery session - subscribes to the merged advertisement stream for `durationSeconds`,
73
104
  * seeded with whatever the server already knows about (mirrors `BleManagerScanner` in
74
- * signalk-bluetti-plugin). `app.bleApi.getDevices()`/advertisements don't carry manufacturer data
75
- * directly comparable to node-ble's `Buffer` - `adv.manufacturerData` is `Record<number, string>`
76
- * (decimal company ID -> hex-encoded payload), converted here so drivers never see the difference.
105
+ * signalk-bluetti-plugin).
77
106
  */
78
107
  async function runScanViaBleApi(app, durationSeconds) {
79
108
  const foundThisScan = [];
80
109
  const seen = new Set();
81
- const drivers = (0, registry_1.allDrivers)();
82
110
  const backend = (0, bleBackend_1.bleApiBackend)(app.bleApi, pluginVersion_1.PLUGIN_NAME);
83
111
  const identify = async (mac, name, rssi, manufacturerDataHex) => {
84
112
  if (seen.has(mac)) {
85
113
  return;
86
114
  }
87
- const [manufacturerIdKey] = Object.keys(manufacturerDataHex ?? {});
88
- const manufacturerId = manufacturerIdKey === undefined ? undefined : Number(manufacturerIdKey);
89
- const manufacturerData = manufacturerId === undefined ? undefined : Buffer.from(manufacturerDataHex[manufacturerId], "hex");
90
- const driver = drivers.find((candidate) => candidate.matchesAdvertisement(name, manufacturerId));
91
- if (!driver) {
92
- return;
93
- }
94
115
  seen.add(mac);
95
- const connect = () => backend.connectGatt(mac, SCAN_GATT_CONNECT_TIMEOUT_MS);
96
- const found = await driver.identifyDevice({ address: mac, name, manufacturerId, manufacturerData, rssi }, connect).catch((err) => {
97
- app.debug(`${driver.vendor} scan failed: ${err.message}\n${err.stack ?? ""}`);
98
- return undefined;
99
- });
100
- if (!found) {
101
- return;
116
+ const found = await identifyOne(app, backend, mac, name, rssi, manufacturerDataHex);
117
+ if (found) {
118
+ foundThisScan.push(found);
102
119
  }
103
- foundThisScan.push(found);
104
- const pid = found.pid !== undefined ? `0x${found.pid.toString(16).padStart(4, "0")}` : "unknown";
105
- const hwid = found.hwVersion ?? "unknown";
106
- app.debug(`discovered ${driver.vendor} device "${found.name ?? ""}" [${found.address}] pid=${pid} hwid=${hwid}`);
107
120
  };
108
121
  try {
109
122
  const unsubscribe = app.bleApi.onAdvertisement(pluginVersion_1.PLUGIN_NAME, (adv) => {
@@ -129,3 +142,57 @@ async function runScanViaBleApi(app, durationSeconds) {
129
142
  const merged = (0, discoveredDevicesStore_1.recordScanResults)(app, foundThisScan);
130
143
  return { foundThisScan, merged };
131
144
  }
145
+ /**
146
+ * Keeps the "Device" picker (and `ALL_DEVICES`) current for as long as the plugin runs, without any
147
+ * bounded "scan" step at all - unlike `runScanViaBleApi`/`runScanViaBlueZ` above, which only ever look
148
+ * for new devices during an explicit, timed window (`scanOnStart`, or an on-demand `ALL_DEVICES` scan).
149
+ * BLE Manager already runs its own continuous scan server-side to build the device list the admin UI's
150
+ * "BLE Manager" page shows - a device that's visible there but never appears in *this* plugin's own
151
+ * picker unless `scanOnStart` happens to be ticked is exactly that mismatch: nothing was ever listening
152
+ * for BLE Manager's advertisements outside a bounded window. This instead subscribes once, for good,
153
+ * so a device shows up here as soon as BLE Manager itself has seen it - no manual scan required.
154
+ *
155
+ * Only runs a genuinely new (or long-expired, see `DISCOVERED_DEVICE_TTL_MS`) address through
156
+ * `driver.identifyDevice()` - a real GATT connect+read. A device already in the persisted store just
157
+ * gets `lastSeenAt` bumped via `touchDiscoveredDevice`, since re-identifying it on every ~1s
158
+ * advertisement would otherwise hammer it (and the shared GATT slots) for no reason. `pending` guards
159
+ * against two advertisements for the same brand-new device starting a second identify attempt while
160
+ * the first is still connecting.
161
+ */
162
+ function startBleApiDiscoveryListener(app, pluginId) {
163
+ const backend = (0, bleBackend_1.bleApiBackend)(app.bleApi, pluginId);
164
+ const pending = new Set();
165
+ const identify = async (mac, name, rssi, manufacturerDataHex) => {
166
+ const existing = (0, discoveredDevicesStore_1.loadDiscoveredDevices)(app)[mac];
167
+ if (existing) {
168
+ if (Date.now() - existing.lastSeenAt < discoveredDevicesStore_1.DISCOVERED_DEVICE_TTL_MS) {
169
+ (0, discoveredDevicesStore_1.touchDiscoveredDevice)(app, existing);
170
+ return;
171
+ }
172
+ }
173
+ if (pending.has(mac)) {
174
+ return;
175
+ }
176
+ pending.add(mac);
177
+ try {
178
+ const found = await identifyOne(app, backend, mac, name, rssi, manufacturerDataHex);
179
+ if (found) {
180
+ (0, discoveredDevicesStore_1.recordScanResults)(app, [found]);
181
+ }
182
+ }
183
+ finally {
184
+ pending.delete(mac);
185
+ }
186
+ };
187
+ const unsubscribe = app.bleApi.onAdvertisement(pluginId, (adv) => {
188
+ void identify(adv.mac, adv.name, adv.rssi, adv.manufacturerData);
189
+ });
190
+ // Seeds from whatever BLE Manager already knows about (e.g. a device it saw before this plugin
191
+ // subscribed) - same manufacturer-data caveat as `runScanViaBleApi`'s equivalent seed loop; a driver
192
+ // that needs it (rather than matching on name) picks it up once a fresh advertisement arrives above.
193
+ void app.bleApi
194
+ .getDevices()
195
+ .then((known) => Promise.all(known.map((device) => identify(device.mac, device.name, device.rssi, undefined))))
196
+ .catch((err) => app.debug(`discovery listener: could not read known devices: ${err.message}`));
197
+ return unsubscribe;
198
+ }
package/dist/plugin.js CHANGED
@@ -9,6 +9,7 @@ const discoveryCoordinator_1 = require("./devices/discoveryCoordinator");
9
9
  const bleDiscovery_1 = require("./devices/bleDiscovery");
10
10
  const discoveredDevicesStore_1 = require("./devices/discoveredDevicesStore");
11
11
  const repaintScheduler_1 = require("./repaintScheduler");
12
+ const pluginVersion_1 = require("./pluginVersion");
12
13
  /** Mirrors signalk-bluetti-plugin's convention: scan briefly, report finds via plugin status for the user to copy-paste. */
13
14
  async function runStartupScan(app, durationSeconds, useBleApi) {
14
15
  const alreadyRunning = (0, discoveryCoordinator_1.scanInProgressSince)();
@@ -41,6 +42,7 @@ function createPlugin(app) {
41
42
  // enough to have added it - checked at runtime rather than trusted from the type.
42
43
  const bleApiAvailable = !!app.bleApi;
43
44
  let scheduler;
45
+ let stopDiscoveryListener;
44
46
  let stopped = false;
45
47
  const plugin = {
46
48
  id: "signalk-einklabel-plugin",
@@ -73,6 +75,11 @@ function createPlugin(app) {
73
75
  scheduler = (0, repaintScheduler_1.startRepaintScheduler)(app, pluginConfig);
74
76
  };
75
77
  if (useBleApi) {
78
+ // BLE Manager already runs its own continuous scan server-side (that's what populates its own
79
+ // admin UI device list) - unlike direct BlueZ access below, discovery here doesn't need a
80
+ // bounded scan window at all, so this listens for as long as the plugin runs rather than only
81
+ // during `scanOnStart`/an on-demand `ALL_DEVICES` scan. See its own doc comment.
82
+ stopDiscoveryListener = (0, discoveryCoordinator_1.startBleApiDiscoveryListener)(app, pluginVersion_1.PLUGIN_NAME);
76
83
  // The server/provider governs its own local-adapter readiness once BLE Manager mode owns
77
84
  // `hci0` (or has none at all, behind a remote gateway) - waiting on a *local* BlueZ adapter
78
85
  // here would be waiting on something this mode may never even need.
@@ -90,6 +97,8 @@ function createPlugin(app) {
90
97
  stopped = true;
91
98
  scheduler?.stop();
92
99
  scheduler = undefined;
100
+ stopDiscoveryListener?.();
101
+ stopDiscoveryListener = undefined;
93
102
  app.debug("stopped");
94
103
  },
95
104
  };
@@ -345,15 +345,16 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
345
345
  bitmap = await renderer.render(fallbackPath, { meta: { repainted: new Date().toISOString(), plugin_version: pluginVersion_1.PLUGIN_VERSION, description: device.description ?? "" } }, width, height, templatesDir, config_1.BUNDLED_TEMPLATES_DIR);
346
346
  }
347
347
  const connectTimeoutMs = config.paintConnectTimeoutSeconds * 1000;
348
- await (0, bleDiscovery_1.withRetries)(config.paintRetries, async (attempt) => {
348
+ const gattBackend = config.useBleApi && app.bleApi ? (0, bleBackend_1.bleApiBackend)(app.bleApi, pluginVersion_1.PLUGIN_NAME) : undefined;
349
+ const paintWithRetries = () => (0, bleDiscovery_1.withRetries)(config.paintRetries, async (attempt) => {
349
350
  if (attempt > 1) {
350
351
  app.debug(`${label}: attempting paint ${attempt}/${config.paintRetries}`);
351
352
  }
352
353
  const startedAt = Date.now();
353
- const gattBackend = config.useBleApi && app.bleApi ? (0, bleBackend_1.bleApiBackend)(app.bleApi, pluginVersion_1.PLUGIN_NAME) : undefined;
354
354
  await driver.paint(bitmap, { address, aesKey: device.aesKey, connectTimeoutMs, reframe: device.reframe, gattBackend });
355
355
  paintDurationMs = Date.now() - startedAt;
356
356
  });
357
+ await (gattBackend ? (0, bleBackend_1.exclusiveBleManagerAccess)(paintWithRetries) : paintWithRetries());
357
358
  (0, discoveredDevicesStore_1.touchDiscoveredDevice)(app, { address, vendor: target.vendor, pid: target.pid, hwVersion: target.hwVersion, metadata });
358
359
  // Only a successful render counts as "repainted" for dedup/catch-up purposes - a failure must not be
359
360
  // recorded here (see this function's doc comment on why).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhizomatics/signalk-einklabel-plugin",
3
- "version": "1.3.0-beta4",
3
+ "version": "1.3.0-beta6",
4
4
  "description": "Display SignalK data on eInk Electronic Shelf Labels, includes working examples for tide clock and watch schedule.",
5
5
  "keywords": [
6
6
  "ble",