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

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
@@ -1,3 +1,7 @@
1
+ # 1.3.0
2
+
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
+
1
5
  # 1.2.3
2
6
 
3
7
  - Improved example tide template for 2.9" Gicisky, and added blank and error templates
package/README.md CHANGED
@@ -24,22 +24,31 @@ Being battery operated, they can be stuck on anywhere without wiring - the only
24
24
 
25
25
  Unlike some eInk projects, this plugin doesn't require any physical modification to the labels, or loading any new firmware. It can send an image to a supported shelf label fresh out of the box.
26
26
 
27
- Most of requirements below are to make SignalK work with Bluetooth Low Energy, which is good thing to have anyway, since vendors like Victron, Switchbot, Ruuvi and others have BLE enabled hardware that's useful to have on a boat. [Direct BLE support](https://github.com/SignalK/signalk-server/issues/2411) in SignalK is being planned in 2026 and this plugin will support that when it comes.
27
+ Most of requirements below are to make SignalK work with Bluetooth Low Energy, which is good thing to have anyway, since vendors like Victron, Switchbot, Ruuvi and others have BLE enabled hardware that's useful to have on a boat.
28
28
 
29
- 1. A SignalK server, **running Linux**
29
+ This plugin can reach BLE hardware two ways - pick whichever fits your setup:
30
30
 
31
- - MacOS and Windows aren't supported by the [BLE interface layer](https://www.npmjs.com/package/@naugehyde/node-ble), however they can be used for template development and
31
+ - **Direct BlueZ access** (default) - the plugin talks to BlueZ over D-Bus itself. Requirements 1-3 below apply.
32
+ - **SignalK BLE Manager API** (opt-in) - SignalK server >= 2.32.0 ships a [BLE Provider/Consumer API](https://github.com/SignalK/signalk-server/issues/2411) (admin UI: "BLE Manager") that arbitrates adapter access across every BLE-consuming plugin instead of each one grabbing `hci0` for itself, and can source BLE over a remote gateway instead of local hardware at all. Enable the "Use the SignalK BLE Manager API" setting in this plugin's config once it's available (it only appears once the running server has it) - requirements 1-3 below then become the SignalK server's problem, under its own Bluetooth admin settings, not this plugin's.
33
+
34
+ 1. A SignalK server, **running Linux** (direct BlueZ mode only - BLE Manager mode with a remote gateway provider has no such requirement)
35
+
36
+ - MacOS and Windows aren't supported by the [BLE interface layer](https://www.npmjs.com/package/@naugehyde/node-ble) in direct BlueZ mode, however they can be used for template development and
32
37
  debugging (everything except `scan` and `paint`)
33
38
 
34
- 2. A Bluetooth adapter, that can handle BLE (Bluetooth Low Energy).
39
+ 2. A Bluetooth adapter, that can handle BLE (Bluetooth Low Energy) - direct BlueZ mode only; in BLE Manager mode this is whatever the server's own Bluetooth settings provide.
35
40
 
36
- - Bluetooth adapters for Linux can be tricky, TP-Link UB400 and Asus USB-BT500 are two well-known and available ones
41
+ - Bluetooth adapters for Linux can be tricky
42
+ - TP-Link UB400 and Asus USB-BT500 are two well-known and available ones, though the ASUS USB-BT500 one can have problems with some Pi type boards
43
+ - CSR4.0 dongles (CSR8510 chip) have had kernel support for years, and there are well known work arounds for some of them, including in the Linux kernel since v5.17
37
44
  - Some Raspberry Pi models come with suitable Bluetooth built in
45
+ - See advice at [Recommended Bluetooth Adapters for Linux](https://github.com/morrownr/USB-WiFi/blob/main/home/Recommended_Bluetooth_Adapters_for_Linux.md)
46
+ - Bluetooth adapters typically prefer being in USB2.0 ports rather than USB3.0 ports, since often the USB3.0 implementation leaks radio energy on the same 2.4Ghz spectrum as Bluetooth. If no USB2.0 port available, try a shielded USB2.0 extension lead to distance the dongle from the port. Some dongle manufacturees seems to do a better job at shielding for this than others.
38
47
 
39
48
  > - Don't worry about the very latest Bluetooth versions, 4.0 is minimum for BLE, 5.0 is nice
40
49
  > - Home Assistant is massively more popular than SignalK, and often also run on Raspberry Pi and similar, so good source of advice
41
50
 
42
- 3. `bluez` package installed in Linux
51
+ 3. `bluez` package installed in Linux - direct BlueZ mode only
43
52
 
44
53
  - No need to do this if you have a Raspberry Pi with recent Raspian version, since bluez comes built in.
45
54
  - If you're not running a Raspberry Pi, then ensure that the `dbus` package is installed
@@ -82,6 +91,10 @@ Use the standard configuration option in the SignalK menu for the plugin.
82
91
 
83
92
  Template available as 416x240-BWRY for 3.7" ESLs and a simpler template, sized 250x128, also BWRY, for the cheapest 2.13" labels.
84
93
 
94
+ This mini tide clock is a 2.9" Gicisky device, less than £10 inc delivery in summer 2026.
95
+
96
+ ![2.9" Tide Clock](docs/assets/images/mini_tidal_clock.png)
97
+
85
98
  #### Pre-requisites
86
99
 
87
100
  A _tides_ provider plugin for the Resources API installed and enabled, currently one of:
@@ -461,6 +474,8 @@ That's the bundled fallback warning, not necessarily an error in this plugin - i
461
474
 
462
475
  ### SignalK starts before the Bluetooth daemon — does the plugin need `bluetoothd` running at boot?
463
476
 
477
+ This and the next entry are about direct BlueZ mode only - if the "Use the SignalK BLE Manager API" setting is enabled, adapter/dongle lifecycle is the SignalK server's problem to manage once, for every BLE-consuming plugin, not this plugin's.
478
+
464
479
  The plugin retries BLE adapter initialisation with backoff (starting at 2s, capping at 30s) if `bluetoothd`/D-Bus isn't up yet when the plugin starts, so a slow-starting Bluetooth stack on boot will no longer strand it — it keeps retrying until the adapter appears rather than failing once and giving up. You'll see `BLE adapter not ready … — retrying in Ns …` in the SignalK logs in the meantime.
465
480
 
466
481
  That said, it's cleaner to fix the boot ordering at the systemd level so the plugin finds the adapter ready on its first attempt. If SignalK runs as a systemd service (`systemctl status signalk`) and its unit file has no `[Unit]` section (check with `systemctl cat signalk`), add one:
@@ -486,6 +501,40 @@ sudo systemctl restart signalk
486
501
 
487
502
  This tells systemd to start `bluetoothd` first and wait for it before starting SignalK, rather than relying on both racing to start in parallel at boot.
488
503
 
504
+ ### Bluetooth Dongle with "No gpio to reset"
505
+
506
+ Example log:
507
+
508
+ ```
509
+ Bluetooth: hci0: No gpio to reset Realtek device, ignoring
510
+ Bluetooth: hci0: Unable to disable scanning: -110
511
+ Bluetooth: hci0: command 0x2042 tx timeout
512
+ Bluetooth: hci0: Opcode 0x2042 failed: -110
513
+ ```
514
+
515
+ This happens when USB autosuspend cycles the dongle in and out of low-power suspend while idle. When bluetoothd sends an HCI command while the device is suspended or mid-resume, it never gets answered — adapters like the popular ASUS USB-500 lack a GPIO to allow reset and its stuck, and spams logs.
516
+
517
+ In these examples the dongle is for vendor `0b05` and product `190e`, adapt for your own devices, use `lsusb` to find out, and if there's no `lsusb` command, install the `usbutils` package.
518
+
519
+ #### Example udev rule fix
520
+
521
+ Following file created at `/etc/udev/rules.d/99-bt500-no-autosuspend.rules`
522
+
523
+ ```
524
+ # Disable USB autosuspend for the ASUS USB-BT500 (RTL8761BU, 0b05:190e).
525
+ ACTION=="add", SUBSYSTEM=="usb", ATTR{idVendor}=="0b05", ATTR{idProduct}=="190e", TEST=="power/control", ATTR{power/control}="on"
526
+ ```
527
+
528
+ If `tlp` running to minimize power, it may have its own rules trying to suspend the Bluetooth dongle.
529
+
530
+ #### Example tlp fix
531
+
532
+ Following file created at /etc/tlp.d/99-bt500-no-autosuspend.conf
533
+
534
+ ```
535
+ USB_DENYLIST="0b05:190e"
536
+ ```
537
+
489
538
  ## Other ESL and General eInk Resources
490
539
 
491
540
  - [Open ePaper Link](https://openepaperlink.de) - Alternative open source firmware to flash onto eInk shelf labels, with Home Assistant integration.
package/dist/cli/index.js CHANGED
@@ -179,18 +179,18 @@ exports.program
179
179
  await (0, bleDiscovery_1.forEachAdvertisedDevice)(adapter, async ({ device, address, name, manufacturerId, manufacturerData }) => {
180
180
  const driver = drivers.find((candidate) => candidate.matchesAdvertisement(name, manufacturerId));
181
181
  const mfr = manufacturerId !== undefined ? `0x${manufacturerId.toString(16).padStart(4, "0")}` : "";
182
+ const rssi = await device
183
+ .getRSSI()
184
+ .then((value) => (value === undefined ? undefined : Number(value)))
185
+ .catch(() => undefined);
182
186
  if (!driver) {
183
187
  if (opts.allDevices) {
184
- const rssi = await device
185
- .getRSSI()
186
- .then((value) => (value === undefined ? undefined : Number(value)))
187
- .catch(() => undefined);
188
188
  rows.push(["(unmatched)", address, name ?? "", "", "", "", mfr, "", String(rssi ?? "")]);
189
189
  }
190
190
  return;
191
191
  }
192
192
  matchedCount++;
193
- const found = await driver.identifyDevice(device, address, name, manufacturerId, manufacturerData);
193
+ const found = await driver.identifyDevice({ address, name, manufacturerId, manufacturerData, rssi }, () => (0, bleDiscovery_1.connectWithTimeout)(device, VENDOR_IDENTIFY_TIMEOUT_MS).then(() => (0, bleDiscovery_1.openNodeBleGattConnection)(device)));
194
194
  (0, log_1.logDebug)(`${driver.vendor}: identified ${found.name ?? found.address}`);
195
195
  const pid = found.pid !== undefined ? `0x${found.pid.toString(16).padStart(4, "0")}` : "";
196
196
  const hwid = found.hwVersion ? `0x${found.hwVersion}` : "";
package/dist/config.d.ts CHANGED
@@ -75,6 +75,13 @@ export interface PluginConfig {
75
75
  scanOnStart: boolean;
76
76
  /** How long the startup scan runs, in seconds. */
77
77
  scanDurationSeconds: number;
78
+ /**
79
+ * Route BLE access through the SignalK server's BLE Manager API (`app.bleApi`, server >= 2.32.0)
80
+ * instead of connecting to BlueZ directly, so this plugin shares the adapter with other BLE plugins
81
+ * instead of contending for it - see `bleBackend.ts`. Off by default, and only ever offered in the
82
+ * config schema when the running server actually has `app.bleApi` (see `configSchema` below).
83
+ */
84
+ useBleApi: boolean;
78
85
  /** How long to wait for a device to accept a BLE connection before giving up on a repaint attempt, in seconds. */
79
86
  paintConnectTimeoutSeconds: number;
80
87
  /** How many times to attempt a repaint (including the first try) before giving up and reporting failure. */
package/dist/config.js CHANGED
@@ -51,6 +51,7 @@ function defaultConfig() {
51
51
  templatesDir: "",
52
52
  scanOnStart: false,
53
53
  scanDurationSeconds: 20,
54
+ useBleApi: false,
54
55
  paintConnectTimeoutSeconds: 30,
55
56
  paintRetries: 3,
56
57
  settleSeconds: 120,
@@ -61,6 +62,7 @@ const PLUGIN_CONFIG_KEYS = [
61
62
  "templatesDir",
62
63
  "scanOnStart",
63
64
  "scanDurationSeconds",
65
+ "useBleApi",
64
66
  "paintConnectTimeoutSeconds",
65
67
  "paintRetries",
66
68
  "settleSeconds",
@@ -307,6 +309,18 @@ function configSchema(app, discovered = []) {
307
309
  return {
308
310
  type: "object",
309
311
  properties: {
312
+ ...(app.bleApi
313
+ ? {
314
+ useBleApi: {
315
+ type: "boolean",
316
+ title: "Use the SignalK BLE Manager API",
317
+ description: "Route Bluetooth access through SignalK server's BLE Manager API (server >= 2.32.0) instead of connecting to " +
318
+ "BlueZ directly, so this plugin shares the adapter with other BLE plugins instead of contending for it. Requires " +
319
+ "the server to have a local Bluetooth adapter or BLE gateway available (Server → Settings → Bluetooth).",
320
+ default: defaults.useBleApi,
321
+ },
322
+ }
323
+ : {}),
310
324
  templatesDir: {
311
325
  type: "string",
312
326
  title: "Templates directory",
@@ -0,0 +1,20 @@
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
+ export declare function nodeBleBackend(): BleBackend;
16
+ /**
17
+ * `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
+ */
20
+ export declare function bleApiBackend(bleApi: BLEApi, pluginId: string): BleBackend;
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.nodeBleBackend = nodeBleBackend;
4
+ exports.bleApiBackend = bleApiBackend;
5
+ const bleDiscovery_1 = require("./bleDiscovery");
6
+ /** 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
+ const DEVICE_DISCOVERY_TIMEOUT_MS = 30000;
8
+ function nodeBleBackend() {
9
+ return {
10
+ async connectGatt(address, timeoutMs) {
11
+ const { bluetooth, destroy } = (0, bleDiscovery_1.createBluetooth)();
12
+ try {
13
+ const adapter = await bluetooth.defaultAdapter();
14
+ const device = await (0, bleDiscovery_1.getOrDiscoverDevice)(adapter, address, DEVICE_DISCOVERY_TIMEOUT_MS);
15
+ await (0, bleDiscovery_1.connectWithTimeout)(device, timeoutMs);
16
+ const conn = (0, bleDiscovery_1.openNodeBleGattConnection)(device);
17
+ return {
18
+ read: conn.read.bind(conn),
19
+ write: conn.write.bind(conn),
20
+ startNotifications: conn.startNotifications.bind(conn),
21
+ stopNotifications: conn.stopNotifications.bind(conn),
22
+ discoverServices: conn.discoverServices.bind(conn),
23
+ onDisconnect: conn.onDisconnect.bind(conn),
24
+ get connected() {
25
+ return conn.connected;
26
+ },
27
+ async disconnect() {
28
+ await conn.disconnect();
29
+ destroy();
30
+ },
31
+ };
32
+ }
33
+ catch (err) {
34
+ destroy();
35
+ throw err;
36
+ }
37
+ },
38
+ async waitForManufacturerData(address, manufacturerId, timeoutMs) {
39
+ const { bluetooth, destroy } = (0, bleDiscovery_1.createBluetooth)();
40
+ try {
41
+ const adapter = await bluetooth.defaultAdapter();
42
+ const device = await (0, bleDiscovery_1.getOrDiscoverDevice)(adapter, address, DEVICE_DISCOVERY_TIMEOUT_MS);
43
+ return await (0, bleDiscovery_1.waitForManufacturerData)(adapter, device, manufacturerId, timeoutMs);
44
+ }
45
+ finally {
46
+ destroy();
47
+ }
48
+ },
49
+ };
50
+ }
51
+ /**
52
+ * `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
+ */
55
+ function bleApiBackend(bleApi, pluginId) {
56
+ return {
57
+ async connectGatt(address, timeoutMs) {
58
+ // A previous ungraceful stop (crash, plugin reload mid-paint) can leave a stale GATT claim
59
+ // registered under our own pluginId, which would otherwise block this connect with
60
+ // "already claimed" until the server restarts - see signalk-bluetti-plugin's BleManagerDevice
61
+ // for the same defensive call. A no-op if we don't currently hold the claim.
62
+ await bleApi.releaseGATTDevice(address, pluginId).catch(() => { });
63
+ const connecting = bleApi.connectGATT(address, pluginId);
64
+ let timedOut = false;
65
+ const conn = await Promise.race([connecting, (0, bleDiscovery_1.sleep)(timeoutMs).then(() => void (timedOut = true))]);
66
+ if (timedOut || !conn) {
67
+ // The server registers the claim as soon as `connectGATT()` is called, not once it succeeds -
68
+ // giving up here without releasing it would leave the claim (and, once/if the connect attempt
69
+ // does eventually land server-side, a live connection) orphaned indefinitely, since this
70
+ // plugin never gets a handle back to disconnect it itself. `releaseGATTDevice` tears down
71
+ // whatever the claim currently is, connected or still connecting, regardless of how the
72
+ // original `connectGATT()` promise eventually settles - disconnecting it too if it does still
73
+ // resolve afterwards, harmlessly, since a connection the server already released is a no-op to
74
+ // disconnect again.
75
+ void bleApi
76
+ .releaseGATTDevice(address, pluginId)
77
+ .catch(() => { })
78
+ .then(() => connecting.then((c) => c.disconnect()).catch(() => { }));
79
+ throw new Error(`connecting to device timed out after ${timeoutMs}ms`);
80
+ }
81
+ return conn;
82
+ },
83
+ async waitForManufacturerData(address, manufacturerId, timeoutMs) {
84
+ // Unlike node-ble's `device.getManufacturerData()`, there's no cached-instant-read path here -
85
+ // `BLEDeviceInfo` (from `getDevices()`/`getDevice()`) carries mac/name/rssi/seenBy but not
86
+ // manufacturer data (see `BLEDeviceInfoSchema` in `@signalk/server-api`'s `ble-schemas.ts`) -
87
+ // only the streamed `BLEAdvertisement` does. So this always actively waits on the stream.
88
+ return new Promise((resolve) => {
89
+ const timer = setTimeout(() => {
90
+ unsubscribe();
91
+ resolve(undefined);
92
+ }, timeoutMs);
93
+ const unsubscribe = bleApi.onAdvertisement(pluginId, (adv) => {
94
+ if (adv.mac !== address)
95
+ return;
96
+ const hex = adv.manufacturerData?.[manufacturerId];
97
+ if (hex === undefined)
98
+ return;
99
+ clearTimeout(timer);
100
+ unsubscribe();
101
+ resolve(Buffer.from(hex, "hex"));
102
+ });
103
+ });
104
+ },
105
+ };
106
+ }
@@ -1,4 +1,5 @@
1
1
  import { Adapter, Bluetooth, Device } from "@naugehyde/node-ble";
2
+ import { GattConnection } from "./gattConnection";
2
3
  export declare function sleep(ms: number): Promise<void>;
3
4
  /**
4
5
  * Drop-in replacement for `node-ble`'s `createBluetooth` that fails fast and clearly when
@@ -78,3 +79,13 @@ export declare function waitForManufacturerData(adapter: Adapter, device: Device
78
79
  * leaving it to retry forever in the background after the thing it was waiting to unblock is gone.
79
80
  */
80
81
  export declare function waitForAdapter(logDebug: (message: string) => void, cancelled?: () => boolean): Promise<boolean>;
82
+ /**
83
+ * Adapts an already-connected node-ble `Device` to the `GattConnection` facade device drivers
84
+ * (`zhsunyco`/`gicisky`) talk to - the same shape `app.bleApi.connectGATT()` returns natively (see
85
+ * `gattConnection.ts`), so a driver's GATT logic runs unchanged under either backend. node-ble
86
+ * resolves one `GattCharacteristic` object per (service, characteristic) pair and expects callers to
87
+ * hold onto it for reads/writes/notifications - this caches that resolution keyed by
88
+ * `"<serviceUuid>:<charUuid>"` so repeated calls (e.g. one `write` per image chunk) don't re-walk
89
+ * `device.gatt()` every time.
90
+ */
91
+ export declare function openNodeBleGattConnection(device: Device): GattConnection;
@@ -10,6 +10,7 @@ exports.connectWithTimeout = connectWithTimeout;
10
10
  exports.getOrDiscoverDevice = getOrDiscoverDevice;
11
11
  exports.waitForManufacturerData = waitForManufacturerData;
12
12
  exports.waitForAdapter = waitForAdapter;
13
+ exports.openNodeBleGattConnection = openNodeBleGattConnection;
13
14
  const node_ble_1 = require("@naugehyde/node-ble");
14
15
  function sleep(ms) {
15
16
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -236,3 +237,91 @@ async function waitForAdapter(logDebug, cancelled = () => false) {
236
237
  }
237
238
  return false;
238
239
  }
240
+ /**
241
+ * Adapts an already-connected node-ble `Device` to the `GattConnection` facade device drivers
242
+ * (`zhsunyco`/`gicisky`) talk to - the same shape `app.bleApi.connectGATT()` returns natively (see
243
+ * `gattConnection.ts`), so a driver's GATT logic runs unchanged under either backend. node-ble
244
+ * resolves one `GattCharacteristic` object per (service, characteristic) pair and expects callers to
245
+ * hold onto it for reads/writes/notifications - this caches that resolution keyed by
246
+ * `"<serviceUuid>:<charUuid>"` so repeated calls (e.g. one `write` per image chunk) don't re-walk
247
+ * `device.gatt()` every time.
248
+ */
249
+ function openNodeBleGattConnection(device) {
250
+ const characteristics = new Map();
251
+ const notificationListeners = new Map();
252
+ let gattServer;
253
+ let connected = true;
254
+ const key = (serviceUuid, charUuid) => `${serviceUuid}:${charUuid}`;
255
+ async function resolveCharacteristic(serviceUuid, charUuid) {
256
+ const cacheKey = key(serviceUuid, charUuid);
257
+ const cached = characteristics.get(cacheKey);
258
+ if (cached) {
259
+ return cached;
260
+ }
261
+ gattServer ?? (gattServer = device.gatt());
262
+ const service = await (await gattServer).getPrimaryService(serviceUuid);
263
+ const characteristic = await service.getCharacteristic(charUuid);
264
+ characteristics.set(cacheKey, characteristic);
265
+ return characteristic;
266
+ }
267
+ device.on("disconnect", () => {
268
+ connected = false;
269
+ });
270
+ return {
271
+ async read(serviceUuid, charUuid) {
272
+ const characteristic = await resolveCharacteristic(serviceUuid, charUuid);
273
+ return characteristic.readValue();
274
+ },
275
+ async write(serviceUuid, charUuid, data, withResponse = true) {
276
+ const characteristic = await resolveCharacteristic(serviceUuid, charUuid);
277
+ if (withResponse === false) {
278
+ await characteristic.writeValueWithoutResponse(data);
279
+ }
280
+ else {
281
+ await characteristic.writeValueWithResponse(data);
282
+ }
283
+ },
284
+ async startNotifications(serviceUuid, charUuid, callback) {
285
+ const characteristic = await resolveCharacteristic(serviceUuid, charUuid);
286
+ notificationListeners.set(key(serviceUuid, charUuid), callback);
287
+ characteristic.on("valuechanged", callback);
288
+ await characteristic.startNotifications();
289
+ },
290
+ async stopNotifications(serviceUuid, charUuid) {
291
+ const cacheKey = key(serviceUuid, charUuid);
292
+ const characteristic = characteristics.get(cacheKey);
293
+ const listener = notificationListeners.get(cacheKey);
294
+ if (characteristic && listener) {
295
+ characteristic.removeListener("valuechanged", listener);
296
+ notificationListeners.delete(cacheKey);
297
+ await characteristic.stopNotifications().catch(() => { });
298
+ }
299
+ },
300
+ async discoverServices() {
301
+ gattServer ?? (gattServer = device.gatt());
302
+ const server = await gattServer;
303
+ const services = [];
304
+ for (const serviceUuid of await server.services()) {
305
+ const service = await server.getPrimaryService(serviceUuid);
306
+ const characteristicInfos = [];
307
+ for (const charUuid of await service.characteristics()) {
308
+ const characteristic = await service.getCharacteristic(charUuid);
309
+ characteristics.set(key(serviceUuid, charUuid), characteristic);
310
+ const properties = await characteristic.getFlags().catch(() => []);
311
+ characteristicInfos.push({ uuid: charUuid, properties });
312
+ }
313
+ services.push({ uuid: serviceUuid, characteristics: characteristicInfos });
314
+ }
315
+ return services;
316
+ },
317
+ async disconnect() {
318
+ await device.disconnect();
319
+ },
320
+ get connected() {
321
+ return connected;
322
+ },
323
+ onDisconnect(callback) {
324
+ device.on("disconnect", () => callback());
325
+ },
326
+ };
327
+ }
@@ -10,10 +10,11 @@ export interface ScanResult {
10
10
  /** epoch ms a scan was started at, if one is currently running - `undefined` otherwise. */
11
11
  export declare function scanInProgressSince(): number | undefined;
12
12
  /**
13
- * Runs one BLE discovery scan and persists what it finds. node-ble/BlueZ has no scan-cancellation
14
- * API and can't run two discovery sessions at once, so a caller that arrives while a scan is
15
- * already running (e.g. the startup scan and an on-demand scan for a `device: "ALL"` repaint both
16
- * wanting to scan at the same moment) is hooked into that *same* in-flight scan's eventual result
17
- * instead of starting a second BlueZ session, which would make both fail.
13
+ * Runs one BLE discovery scan and persists what it finds. Neither backend can run two discovery
14
+ * sessions at once (node-ble/BlueZ has no scan-cancellation API; the BLE Manager API tracks one
15
+ * subscription per plugin), so a caller that arrives while a scan is already running (e.g. the
16
+ * startup scan and an on-demand scan for a `device: "ALL"` repaint both wanting to scan at the same
17
+ * moment) is hooked into that *same* in-flight scan's eventual result instead of starting a second
18
+ * one, which would make both fail.
18
19
  */
19
- export declare function ensureScan(app: ServerAPI, durationSeconds: number): Promise<ScanResult>;
20
+ export declare function ensureScan(app: ServerAPI, durationSeconds: number, useBleApi: boolean): Promise<ScanResult>;
@@ -3,31 +3,36 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.scanInProgressSince = scanInProgressSince;
4
4
  exports.ensureScan = ensureScan;
5
5
  const bleDiscovery_1 = require("./bleDiscovery");
6
+ const bleBackend_1 = require("./bleBackend");
6
7
  const discoveredDevicesStore_1 = require("./discoveredDevicesStore");
7
8
  const registry_1 = require("./registry");
9
+ const pluginVersion_1 = require("../pluginVersion");
8
10
  let inFlight;
9
11
  /** epoch ms a scan was started at, if one is currently running - `undefined` otherwise. */
10
12
  function scanInProgressSince() {
11
13
  return inFlight?.startedAt;
12
14
  }
15
+ /** How long a `connect()` closure built during a scan waits for the GATT connect step itself - a scan may be enumerating several devices, so kept short relative to `paint()`'s own connect timeout. */
16
+ const SCAN_GATT_CONNECT_TIMEOUT_MS = 10000;
13
17
  /**
14
- * Runs one BLE discovery scan and persists what it finds. node-ble/BlueZ has no scan-cancellation
15
- * API and can't run two discovery sessions at once, so a caller that arrives while a scan is
16
- * already running (e.g. the startup scan and an on-demand scan for a `device: "ALL"` repaint both
17
- * wanting to scan at the same moment) is hooked into that *same* in-flight scan's eventual result
18
- * instead of starting a second BlueZ session, which would make both fail.
18
+ * Runs one BLE discovery scan and persists what it finds. Neither backend can run two discovery
19
+ * sessions at once (node-ble/BlueZ has no scan-cancellation API; the BLE Manager API tracks one
20
+ * subscription per plugin), so a caller that arrives while a scan is already running (e.g. the
21
+ * startup scan and an on-demand scan for a `device: "ALL"` repaint both wanting to scan at the same
22
+ * moment) is hooked into that *same* in-flight scan's eventual result instead of starting a second
23
+ * one, which would make both fail.
19
24
  */
20
- function ensureScan(app, durationSeconds) {
25
+ function ensureScan(app, durationSeconds, useBleApi) {
21
26
  if (inFlight) {
22
27
  return inFlight.promise;
23
28
  }
24
- const promise = runScan(app, durationSeconds).finally(() => {
29
+ const promise = (useBleApi && !!app.bleApi ? runScanViaBleApi(app, durationSeconds) : runScanViaBlueZ(app, durationSeconds)).finally(() => {
25
30
  inFlight = undefined;
26
31
  });
27
32
  inFlight = { startedAt: Date.now(), promise };
28
33
  return promise;
29
34
  }
30
- async function runScan(app, durationSeconds) {
35
+ async function runScanViaBlueZ(app, durationSeconds) {
31
36
  const foundThisScan = [];
32
37
  const drivers = (0, registry_1.allDrivers)();
33
38
  try {
@@ -37,7 +42,12 @@ async function runScan(app, durationSeconds) {
37
42
  if (!driver) {
38
43
  return;
39
44
  }
40
- const found = await driver.identifyDevice(device, address, name, manufacturerId, manufacturerData).catch((err) => {
45
+ const rssi = await device
46
+ .getRSSI()
47
+ .then((value) => (value === undefined ? undefined : Number(value)))
48
+ .catch(() => undefined);
49
+ const connect = () => (0, bleDiscovery_1.connectWithTimeout)(device, SCAN_GATT_CONNECT_TIMEOUT_MS).then(() => (0, bleDiscovery_1.openNodeBleGattConnection)(device));
50
+ const found = await driver.identifyDevice({ address, name, manufacturerId, manufacturerData, rssi }, connect).catch((err) => {
41
51
  app.debug(`${driver.vendor} scan failed: ${err.message}\n${err.stack ?? ""}`);
42
52
  return undefined;
43
53
  });
@@ -57,3 +67,65 @@ async function runScan(app, durationSeconds) {
57
67
  const merged = (0, discoveredDevicesStore_1.recordScanResults)(app, foundThisScan);
58
68
  return { foundThisScan, merged };
59
69
  }
70
+ /**
71
+ * Same as `runScanViaBlueZ` but sourced from the SignalK server's BLE Manager API instead of a direct
72
+ * BlueZ discovery session - subscribes to the merged advertisement stream for `durationSeconds`,
73
+ * 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.
77
+ */
78
+ async function runScanViaBleApi(app, durationSeconds) {
79
+ const foundThisScan = [];
80
+ const seen = new Set();
81
+ const drivers = (0, registry_1.allDrivers)();
82
+ const backend = (0, bleBackend_1.bleApiBackend)(app.bleApi, pluginVersion_1.PLUGIN_NAME);
83
+ const identify = async (mac, name, rssi, manufacturerDataHex) => {
84
+ if (seen.has(mac)) {
85
+ return;
86
+ }
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
+ 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;
102
+ }
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
+ };
108
+ try {
109
+ const unsubscribe = app.bleApi.onAdvertisement(pluginVersion_1.PLUGIN_NAME, (adv) => {
110
+ void identify(adv.mac, adv.name, adv.rssi, adv.manufacturerData);
111
+ });
112
+ try {
113
+ const known = await app.bleApi.getDevices();
114
+ for (const device of known) {
115
+ // BLEDeviceInfo (from getDevices()) doesn't carry manufacturer data - see `bleBackend.ts`'s
116
+ // waitForManufacturerData doc comment. A driver that needs it to identify (e.g. gicisky) will
117
+ // still show up once its advertisement arrives on the stream above during this scan window.
118
+ await identify(device.mac, device.name, device.rssi, undefined);
119
+ }
120
+ await (0, bleDiscovery_1.sleep)(durationSeconds * 1000);
121
+ }
122
+ finally {
123
+ unsubscribe();
124
+ }
125
+ }
126
+ catch (err) {
127
+ app.debug(`scan failed: ${err.message}\n${err.stack ?? ""}`);
128
+ }
129
+ const merged = (0, discoveredDevicesStore_1.recordScanResults)(app, foundThisScan);
130
+ return { foundThisScan, merged };
131
+ }
@@ -0,0 +1,9 @@
1
+ import { BLEGattConnection } from "@signalk/server-api";
2
+ /**
3
+ * Service+characteristic-UUID-keyed GATT surface both BLE backends satisfy - `app.bleApi.connectGATT()`
4
+ * already returns exactly this shape (it *is* `BLEGattConnection`), and `openNodeBleGattConnection`
5
+ * (`bleDiscovery.ts`) adapts a node-ble `Device` to it. Device drivers (`zhsunyco`/`gicisky`) talk to
6
+ * this instead of either BLE library directly, so they work unchanged under whichever backend a caller
7
+ * hands them - see `bleBackend.ts`.
8
+ */
9
+ export type GattConnection = BLEGattConnection;
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -1,4 +1,3 @@
1
- import { Device } from "@naugehyde/node-ble";
2
1
  import { Bitmap } from "../../render/types";
3
2
  import { DeviceMetadata, DiscoveredDevice, VendorDeviceConfig, VendorDriver } from "../types";
4
3
  export declare class GiciskyDriver implements VendorDriver {
@@ -6,6 +5,12 @@ export declare class GiciskyDriver implements VendorDriver {
6
5
  matchesAdvertisement(_name: string | undefined, manufacturerId: number | undefined): boolean;
7
6
  metadataForPid(pid: number): DeviceMetadata | undefined;
8
7
  supportedDevices(): DeviceMetadata[];
9
- identifyDevice(device: Device, address: string, name: string | undefined, manufacturerId: number | undefined, manufacturerData: Buffer | undefined): Promise<DiscoveredDevice>;
8
+ identifyDevice({ address, name, manufacturerId, manufacturerData, rssi, }: {
9
+ address: string;
10
+ name: string | undefined;
11
+ manufacturerId: number | undefined;
12
+ manufacturerData: Buffer | undefined;
13
+ rssi: number | undefined;
14
+ }): Promise<DiscoveredDevice>;
10
15
  paint(bitmap: Bitmap, config: VendorDeviceConfig): Promise<void>;
11
16
  }