@rhizomatics/signalk-einklabel-plugin 1.3.0-beta13 → 1.3.0-beta15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -291,6 +291,7 @@ exports.program
291
291
  mirror: parseMirrorMode(opts.mirror),
292
292
  compress: opts.compress,
293
293
  compressionFormat: parseCompressionFormat(opts.compressionFormat),
294
+ log: log_1.logDebug,
294
295
  });
295
296
  });
296
297
  console.log(`painted ${opts.address} (${bitmap.width}x${bitmap.height}) ${opts.colours}`);
@@ -73,7 +73,16 @@ function withSessionWatchdog(conn, timeoutMs, forceClose) {
73
73
  clearTimeout(timer);
74
74
  if (killedError)
75
75
  return; // already forced closed by the watchdog above
76
- await conn.disconnect();
76
+ // The watchdog is off from here, so the disconnect needs its own limit: a disconnect that never
77
+ // finishes (e.g. over a Bluetooth link that's already gone) would otherwise hang the caller - and
78
+ // hide whatever error it was cleaning up after - with the connection and any claim still held.
79
+ try {
80
+ await (0, bleDiscovery_1.withDeadline)(conn.disconnect(), CLEANUP_TIMEOUT_MS, "disconnecting");
81
+ }
82
+ catch (err) {
83
+ console.error(`${pluginVersion_1.PLUGIN_NAME}: ${err.message} - forcing the connection closed`);
84
+ await forceClose().catch(() => { });
85
+ }
77
86
  },
78
87
  };
79
88
  }
@@ -168,6 +177,17 @@ async function ensureDeviceVisible(bleApi, pluginId, address, timeoutMs) {
168
177
  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
178
  }
170
179
  }
180
+ /**
181
+ * Upper bound on each BLE Manager release/disconnect call this plugin waits on. Neither has a timeout
182
+ * of its own, and a release has to disconnect the device, which can wait on a Bluetooth link that's
183
+ * already gone - left unbounded, one stuck release wedges every later paint behind it (see
184
+ * `exclusiveBleManagerAccess`).
185
+ */
186
+ const CLEANUP_TIMEOUT_MS = 10000;
187
+ /** Waits for a cleanup call for at most `CLEANUP_TIMEOUT_MS`, ignoring its outcome - cleanup is best-effort. */
188
+ async function boundedCleanup(...calls) {
189
+ await (0, bleDiscovery_1.withDeadline)(Promise.allSettled(calls), CLEANUP_TIMEOUT_MS, "BLE Manager cleanup").catch((err) => console.error(`${pluginVersion_1.PLUGIN_NAME}: ${err.message} - carrying on`));
190
+ }
171
191
  /** Upper bound on how long a timed-out `bleApi.connectGATT()` is given to settle server-side before a retry - see `bleApiBackend`. */
172
192
  const CONNECT_SETTLE_GRACE_MS = 30000;
173
193
  let bleManagerQueue = Promise.resolve();
@@ -194,7 +214,7 @@ function bleApiBackend(bleApi, pluginId) {
194
214
  // registered under our own pluginId, which would otherwise block this connect with
195
215
  // "already claimed" until the server restarts - see signalk-bluetti-plugin's BleManagerDevice
196
216
  // for the same defensive call. A no-op if we don't currently hold the claim.
197
- await bleApi.releaseGATTDevice(address, pluginId).catch(() => { });
217
+ await boundedCleanup(bleApi.releaseGATTDevice(address, pluginId));
198
218
  await ensureDeviceVisible(bleApi, pluginId, address, DEVICE_DISCOVERY_TIMEOUT_MS);
199
219
  const connecting = bleApi.connectGATT(address, pluginId);
200
220
  let timedOut = false;
@@ -210,7 +230,7 @@ function bleApiBackend(bleApi, pluginId) {
210
230
  // disconnect again. The release is awaited (only the late disconnect is left in the
211
231
  // background) so a caller retrying straight away - `withRetries` - can't race it with a new
212
232
  // `connectGATT()` and get rejected with "has a GATT claim in progress" for its trouble.
213
- await bleApi.releaseGATTDevice(address, pluginId).catch(() => { });
233
+ await boundedCleanup(bleApi.releaseGATTDevice(address, pluginId));
214
234
  // ...except a claim still *connecting* isn't in the server's claim table yet, only its pending
215
235
  // set, which `releaseGATTDevice` doesn't touch - so while the server's own connect is still in
216
236
  // flight, a retry's `connectGATT()` is rejected outright with "has a GATT claim in progress".
@@ -220,7 +240,7 @@ function bleApiBackend(bleApi, pluginId) {
220
240
  (0, bleDiscovery_1.sleep)(Math.min(timeoutMs, CONNECT_SETTLE_GRACE_MS)).then(() => undefined),
221
241
  ]);
222
242
  if (late) {
223
- await Promise.allSettled([bleApi.releaseGATTDevice(address, pluginId), late.disconnect()]);
243
+ await boundedCleanup(bleApi.releaseGATTDevice(address, pluginId), late.disconnect());
224
244
  }
225
245
  else {
226
246
  void connecting.then((c) => c.disconnect()).catch(() => { });
@@ -231,7 +251,7 @@ function bleApiBackend(bleApi, pluginId) {
231
251
  // Belt-and-suspenders, matching the timeout-cleanup above: `releaseGATTDevice` is the
232
252
  // authoritative claim release, `disconnect()` a secondary teardown of this specific handle -
233
253
  // do both regardless of which (if either) itself hangs or rejects.
234
- await Promise.allSettled([bleApi.releaseGATTDevice(address, pluginId), conn.disconnect()]);
254
+ await boundedCleanup(bleApi.releaseGATTDevice(address, pluginId), conn.disconnect());
235
255
  });
236
256
  },
237
257
  async waitForManufacturerData(address, manufacturerId, timeoutMs) {
@@ -246,7 +266,7 @@ function bleApiBackend(bleApi, pluginId) {
246
266
  // this wait forever, since nothing else in this path ever calls `connectGatt` (and so never gets a
247
267
  // chance to release the stale claim) unless a fresh advertisement shows up first. A no-op if we
248
268
  // don't currently hold the claim.
249
- await bleApi.releaseGATTDevice(address, pluginId).catch(() => { });
269
+ await boundedCleanup(bleApi.releaseGATTDevice(address, pluginId));
250
270
  return new Promise((resolve) => {
251
271
  const timer = setTimeout(() => {
252
272
  unsubscribe();
@@ -40,6 +40,11 @@ export declare function forEachAdvertisedDevice(adapter: Adapter, fn: (advertise
40
40
  * last one that's eventually thrown - an early attempt's error is often the real cause, with later
41
41
  * ones just fallout from it.
42
42
  */
43
+ /**
44
+ * Rejects with `${what} timed out after ${ms}ms` if `promise` hasn't settled by then. The underlying
45
+ * work can't be cancelled - this only lets the caller stop waiting for it.
46
+ */
47
+ export declare function withDeadline<T>(promise: Promise<T>, ms: number, what: string): Promise<T>;
43
48
  export declare function withRetries<T>(attempts: number, fn: (attempt: number) => Promise<T>, { delayMs, onError }?: {
44
49
  delayMs?: number;
45
50
  onError?: (err: unknown, attempt: number) => void;
@@ -4,6 +4,7 @@ exports.sleep = sleep;
4
4
  exports.createBluetooth = createBluetooth;
5
5
  exports.getManufacturerId = getManufacturerId;
6
6
  exports.forEachAdvertisedDevice = forEachAdvertisedDevice;
7
+ exports.withDeadline = withDeadline;
7
8
  exports.withRetries = withRetries;
8
9
  exports.withDiscovery = withDiscovery;
9
10
  exports.connectWithTimeout = connectWithTimeout;
@@ -74,6 +75,23 @@ async function forEachAdvertisedDevice(adapter, fn) {
74
75
  * last one that's eventually thrown - an early attempt's error is often the real cause, with later
75
76
  * ones just fallout from it.
76
77
  */
78
+ /**
79
+ * Rejects with `${what} timed out after ${ms}ms` if `promise` hasn't settled by then. The underlying
80
+ * work can't be cancelled - this only lets the caller stop waiting for it.
81
+ */
82
+ async function withDeadline(promise, ms, what) {
83
+ let timer;
84
+ const deadline = new Promise((_, reject) => {
85
+ timer = setTimeout(() => reject(new Error(`${what} timed out after ${ms}ms`)), ms);
86
+ });
87
+ promise.catch(() => { }); // observed here too, so losing the race never surfaces as an unhandled rejection
88
+ try {
89
+ return await Promise.race([promise, deadline]);
90
+ }
91
+ finally {
92
+ clearTimeout(timer);
93
+ }
94
+ }
77
95
  async function withRetries(attempts, fn, { delayMs = 0, onError } = {}) {
78
96
  const total = Math.max(1, attempts);
79
97
  let lastErr;
@@ -73,7 +73,10 @@ class GiciskyDriver {
73
73
  const layout = (0, layout_1.withCompressionFormat)((pid !== undefined && layout_1.GICISKY_PID_LAYOUT[pid]) || (0, layout_1.defaultLayoutFor)(metadata.colours), config.compressionFormat ?? "auto", metadata.colours);
74
74
  const framed = (0, mirror_1.mirrorBitmap)((0, reframe_1.reframeBitmap)(bitmap, metadata.width, metadata.height, config.reframe ?? "crop"), config.mirror ?? "none");
75
75
  const payload = (0, encode_1.encodeBitmap)(framed, metadata, layout, config.compress ?? true);
76
+ const log = config.log ?? (() => { });
77
+ log("connecting");
76
78
  const conn = await backend.connectGatt(config.address, config.connectTimeoutMs ?? DEFAULT_PAINT_CONNECT_TIMEOUT_MS);
79
+ log("connected");
77
80
  try {
78
81
  const { cmdServiceUuid, cmdUuid, imgServiceUuid, imgUuid } = await findCommandAndImageCharacteristics(conn);
79
82
  const ack = new AckChannel();
@@ -87,6 +90,7 @@ class GiciskyDriver {
87
90
  if (!started?.ok) {
88
91
  throw new Error(`gicisky device rejected start-image-transfer request: ${startImageAck.toString("hex")}`);
89
92
  }
93
+ log(`uploading ${payload.length} bytes in ${Math.ceil(payload.length / chunkSize)} parts`);
90
94
  let part = started.nextPart;
91
95
  let lastPart = -1;
92
96
  let repeats = 0;
@@ -115,6 +119,7 @@ class GiciskyDriver {
115
119
  }
116
120
  part = decoded.nextPart;
117
121
  }
122
+ log("upload complete");
118
123
  }
119
124
  finally {
120
125
  await conn.stopNotifications(cmdServiceUuid, cmdUuid).catch(() => { });
@@ -83,6 +83,11 @@ export interface VendorDeviceConfig {
83
83
  compress?: boolean;
84
84
  /** Overrides the model's own wire format - see `CompressionFormat`. Defaults to `"auto"`. */
85
85
  compressionFormat?: CompressionFormat;
86
+ /**
87
+ * Receives one line per paint step (connecting, connected, uploading, ...), so a paint that stalls
88
+ * shows in the log where it stopped. Omitted means no step logging.
89
+ */
90
+ log?: (message: string) => void;
86
91
  /**
87
92
  * How `paint()` reaches the device's BLE hardware - omitted (always true for the CLI, which has no
88
93
  * `ServerAPI`/`app.bleApi` to source one from) means direct BlueZ access via a fresh
@@ -50,8 +50,11 @@ class ZhsunycoDriver {
50
50
  }
51
51
  async paint(bitmap, config) {
52
52
  const aesKey = (0, protocol_1.resolveAesKey)(config.aesKey);
53
+ const log = config.log ?? (() => { });
53
54
  const backend = config.gattBackend ?? (0, bleBackend_1.nodeBleBackend)();
55
+ log("connecting");
54
56
  const conn = await backend.connectGatt(config.address, config.connectTimeoutMs ?? DEFAULT_PAINT_CONNECT_TIMEOUT_MS);
57
+ log("connected");
55
58
  try {
56
59
  const info = (0, protocol_1.decodeAdvertisedInfo)(await conn.read(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.config));
57
60
  if (!info) {
@@ -79,27 +82,34 @@ class ZhsunycoDriver {
79
82
  const challenge = await conn.read(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.authenticate);
80
83
  await conn.write(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.authenticate, (0, protocol_1.authResponse)(challenge, aesKey), false);
81
84
  await (0, bleDiscovery_1.sleep)(AUTH_SETTLE_DELAY_MS);
85
+ log("authenticated");
82
86
  const framed = (0, mirror_1.mirrorBitmap)((0, reframe_1.reframeBitmap)(bitmap, metadata.width, metadata.height - metadata.voffset, config.reframe ?? "crop"), config.mirror ?? "none");
83
87
  const pixelData = (0, encode_1.encodeBitmap)(framed, metadata);
84
88
  const compress = config.compress ?? true;
85
89
  // Upload offsets and the refresh length both count bytes of whatever's actually sent - the
86
90
  // compressed payload when compressing, not the raw buffer it inflates back to.
87
91
  const payload = compress ? (0, compression_1.compressWolinkBlocks)(pixelData) : pixelData;
92
+ log(`uploading ${payload.length} bytes${compress ? ` (compressed from ${pixelData.length})` : ""} in ` +
93
+ `${Math.ceil(payload.length / UPLOAD_CHUNK_SIZE)} writes`);
88
94
  for (let offset = 0; offset < payload.length; offset += UPLOAD_CHUNK_SIZE) {
89
95
  const chunk = payload.subarray(offset, offset + UPLOAD_CHUNK_SIZE);
90
96
  await conn.write(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.data, Buffer.concat([(0, protocol_1.commandHeader)(protocol_1.COMMAND.uploadBlock, offset), chunk]), true);
91
97
  await (0, bleDiscovery_1.sleep)(CHUNK_WRITE_DELAY_MS);
92
98
  }
93
99
  await conn.write(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.data, (0, protocol_1.commandHeader)(compress ? protocol_1.COMMAND.refreshCompressed : protocol_1.COMMAND.refreshUncompressed, payload.length), true);
100
+ log("refresh sent, waiting for the label to finish");
94
101
  // `Promise.race` can't cancel its loser, so once `statusReceived` settles (the common case)
95
102
  // this timer would otherwise sit alive for the rest of its 60s regardless - see the identical
96
103
  // reasoning on `readDeviceDetails`'s own race, below.
97
104
  let statusTimer;
98
105
  const statusTimeout = new Promise((resolve) => {
99
- statusTimer = setTimeout(resolve, STATUS_WAIT_TIMEOUT_MS);
106
+ statusTimer = setTimeout(() => resolve("timeout"), STATUS_WAIT_TIMEOUT_MS);
100
107
  });
101
108
  try {
102
- await Promise.race([statusReceived, statusTimeout]);
109
+ const outcome = await Promise.race([statusReceived, statusTimeout]);
110
+ log(outcome === "timeout"
111
+ ? `no reply from the label within ${STATUS_WAIT_TIMEOUT_MS}ms - assuming it painted`
112
+ : "label reported the paint complete");
103
113
  }
104
114
  finally {
105
115
  clearTimeout(statusTimer);
@@ -13,4 +13,12 @@ export interface RepaintScheduler {
13
13
  * rather than waiting up to `intervalHours` for the next one).
14
14
  */
15
15
  export declare function mostRecentScheduledSlot(now: Date, intervalHours: number, intervalMinute: number): Date;
16
+ /**
17
+ * Wraps `run` so only one call per key is in progress at a time. A call made while that key is busy
18
+ * is folded into a single follow-up (with the latest arguments), run once the current one finishes -
19
+ * but only if it succeeded (`run` resolved `true`). For repaints: newer data still gets shown promptly,
20
+ * but a label that's failing isn't hammered with a fresh round of retries straight after the last
21
+ * one gave up - the next scheduled trigger tries again instead.
22
+ */
23
+ export declare function oneAtATimePerKey<T>(keyOf: (arg: T) => string, run: (arg: T) => Promise<boolean>, onBusy?: (arg: T) => void): (arg: T) => Promise<void>;
16
24
  export declare function startRepaintScheduler(app: ServerAPI, config: PluginConfig): RepaintScheduler;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.mostRecentScheduledSlot = mostRecentScheduledSlot;
4
+ exports.oneAtATimePerKey = oneAtATimePerKey;
4
5
  exports.startRepaintScheduler = startRepaintScheduler;
5
6
  const crypto_1 = require("crypto");
6
7
  const path_1 = require("path");
@@ -25,6 +26,13 @@ const INTERVAL_POLL_MS = 60000;
25
26
  const SUBSCRIPTION_DEBOUNCE_MS = 2000;
26
27
  /** Pause between paint attempts - see `withRetries`' `delayMs`. */
27
28
  const PAINT_RETRY_DELAY_MS = 5000;
29
+ /**
30
+ * Last-resort limit on one paint attempt, on top of its connect timeout - so an attempt stuck on
31
+ * anything, anywhere, still fails and gets retried instead of blocking this label (and, via
32
+ * `exclusiveBleManagerAccess`, every other label) forever. Longer than every inner limit it backs up,
33
+ * the longest being the 5-minute GATT session watchdog in `bleBackend.ts`.
34
+ */
35
+ const PAINT_ATTEMPT_BACKSTOP_MS = 7 * 60000;
28
36
  const RESOURCES_API_PATH = "/signalk/v2/api/resources";
29
37
  /**
30
38
  * The most recent wall-clock instant at or before `now` that an `interval`-triggered device's own
@@ -358,7 +366,7 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
358
366
  const paintWithRetries = () => (0, bleDiscovery_1.withRetries)(attempts, async (attempt) => {
359
367
  app.debug(`${label}: attempting paint ${attempt}/${attempts}`);
360
368
  const startedAt = Date.now();
361
- await driver.paint(bitmap, {
369
+ const paint = driver.paint(bitmap, {
362
370
  address,
363
371
  pid: target.pid,
364
372
  aesKey: device.advanced?.aesKey,
@@ -368,7 +376,9 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
368
376
  compress: device.advanced?.compress,
369
377
  compressionFormat: device.advanced?.compressionFormat,
370
378
  gattBackend,
379
+ log: (message) => app.debug(`${label}: ${message}`),
371
380
  });
381
+ await (0, bleDiscovery_1.withDeadline)(paint, connectTimeoutMs + PAINT_ATTEMPT_BACKSTOP_MS, `paint attempt ${attempt}/${attempts}`);
372
382
  paintDurationMs = Date.now() - startedAt;
373
383
  }, {
374
384
  delayMs: PAINT_RETRY_DELAY_MS,
@@ -384,6 +394,39 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
384
394
  }
385
395
  app.debug(succeeded ? `${label}: repainted (${repaintReason}, paint took ${paintDurationMs}ms)` : `${label}: repainted fallback warning`);
386
396
  }
397
+ /**
398
+ * Wraps `run` so only one call per key is in progress at a time. A call made while that key is busy
399
+ * is folded into a single follow-up (with the latest arguments), run once the current one finishes -
400
+ * but only if it succeeded (`run` resolved `true`). For repaints: newer data still gets shown promptly,
401
+ * but a label that's failing isn't hammered with a fresh round of retries straight after the last
402
+ * one gave up - the next scheduled trigger tries again instead.
403
+ */
404
+ function oneAtATimePerKey(keyOf, run, onBusy = () => { }) {
405
+ const inProgress = new Set();
406
+ const followUps = new Map();
407
+ const call = async (arg) => {
408
+ const key = keyOf(arg);
409
+ if (inProgress.has(key)) {
410
+ followUps.set(key, arg);
411
+ onBusy(arg);
412
+ return;
413
+ }
414
+ inProgress.add(key);
415
+ let succeeded = false;
416
+ try {
417
+ succeeded = await run(arg);
418
+ }
419
+ finally {
420
+ inProgress.delete(key);
421
+ }
422
+ const followUp = followUps.get(key);
423
+ followUps.delete(key);
424
+ if (followUp !== undefined && succeeded) {
425
+ await call(followUp);
426
+ }
427
+ };
428
+ return call;
429
+ }
387
430
  function startRepaintScheduler(app, config) {
388
431
  const state = loadState(app);
389
432
  const unsubscribes = [];
@@ -395,15 +438,17 @@ function startRepaintScheduler(app, config) {
395
438
  // (startup check, interval, subscription alike) funnels through this one gate.
396
439
  const startedAt = Date.now();
397
440
  const settleMs = (config.settleSeconds ?? 120) * 1000;
398
- const repaint = async (device) => {
441
+ const repaint = oneAtATimePerKey((device) => device.friendlyName, (device) => repaintOnce(device), (device) => app.debug(`"${device.friendlyName}": already repainting - will check again once it's done`));
442
+ /** Returns whether every target was repainted (or was already up to date). */
443
+ const repaintOnce = async (device) => {
399
444
  const elapsedMs = Date.now() - startedAt;
400
445
  if (elapsedMs < settleMs) {
401
446
  app.debug(`"${device.friendlyName}": still settling (${Math.round(elapsedMs / 1000)}s/${Math.round(settleMs / 1000)}s) - skipping repaint`);
402
- return;
447
+ return false;
403
448
  }
404
449
  const targets = await resolveTargets(app, config, device);
405
450
  if (targets.length === 0) {
406
- return;
451
+ return false;
407
452
  }
408
453
  const results = await Promise.allSettled(targets.map((target) => considerRepaint(app, config, device, target, state, getApiUrl)));
409
454
  results.forEach((result, i) => {
@@ -414,9 +459,11 @@ function startRepaintScheduler(app, config) {
414
459
  // A single `forceRepaint` flag covers every target under `ALL_DEVICES` too - only clear it once
415
460
  // every target has actually succeeded, so a target that failed still gets forced again next time
416
461
  // instead of quietly reverting to ordinary hash-based dedup.
417
- if (device.advanced?.forceRepaint && results.every((result) => result.status === "fulfilled")) {
462
+ const allSucceeded = results.every((result) => result.status === "fulfilled");
463
+ if (device.advanced?.forceRepaint && allSucceeded) {
418
464
  clearForceRepaint(app, device.friendlyName);
419
465
  }
466
+ return allSucceeded;
420
467
  };
421
468
  const intervalDevices = config.devices.filter((device) => device.repaintTrigger === "interval");
422
469
  if (intervalDevices.length > 0) {
package/docs/bluetooth.md CHANGED
@@ -2,11 +2,21 @@
2
2
 
3
3
  Advice for getting a reliable Bluetooth Low Energy (BLE) connection between the SignalK server and your labels.
4
4
 
5
- Most of this applies to direct BlueZ mode only. If the "Use the SignalK BLE Manager API" setting is enabled, the adapter and its lifecycle are managed by the SignalK server, under its own Bluetooth admin settings, once for every BLE-consuming plugin.
5
+ Bluetooth can be used in of three ways by a plugin:
6
+
7
+ - Direct access to dongle (usually `hci0` device). Not recommended
8
+ - Access via `bluez` and `dbus` services. Better but not ideal
9
+ - Using the BLE Manager added to SignalK in 2026. Recommended with caveats.
10
+ - "Use the SignalK BLE Manager API" setting is enabled, the adapter and its lifecycle are managed by the SignalK server, under its own Bluetooth admin settings, once for every BLE-consuming plugin.
11
+ - Under the hood, this uses `bluez` and `dbus` however manages them so that plugins are controlled in how they can impact each other
12
+ - It also has a very useful GUI for seeing all scanned devices, and all GATT claims (GATT being the protocol for directly connecting to BLE devices).
13
+ - This is still new and settling down, so not yet the default for this plugin
14
+
15
+ The advice here is general to Bluetooth on Linux, whichever way its being used.
6
16
 
7
17
  ## Choosing a Bluetooth Adapter
8
18
 
9
- Only needed in direct BlueZ mode - in BLE Manager mode the adapter is whatever the SignalK server's own Bluetooth settings provide.
19
+ First step is having a Bluetooth Low Energy (BLE) compatible bluetooth adapter available.
10
20
 
11
21
  - Bluetooth adapters for Linux can be tricky
12
22
  - 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 (see [Adapter stops responding](#adapter-stops-responding-no-gpio-to-reset))
@@ -18,16 +28,24 @@ Only needed in direct BlueZ mode - in BLE Manager mode the adapter is whatever t
18
28
  > - Don't worry about the very latest Bluetooth versions, 4.0 is minimum for BLE, 5.0 is nice
19
29
  > - Home Assistant is massively more popular than SignalK, and often also run on Raspberry Pi and similar, so good source of advice
20
30
 
31
+ SignalK BLE Manager also supports BLE Gateways, which could be an MQTT topic or an ESP-32 device. The [espos-ble-gateway](https://github.com/dirkwa/espos-ble-gateway) can be used with a cheap ESP32 device (see the list of supported hardware), which allows positioning of the gateway closer to devices, or having multiple gateways on a big boat.
32
+
21
33
  ## Weak Signal
22
34
 
23
35
  If the label is too far from the SignalK server's adapter, try a BLE proxy device - ESP32 is popular for this - or, with the BLE Manager API, a remote BLE gateway.
24
36
 
37
+ If your dongle is plugged into a USB3 port (usually blue-highlighted), then there's a good chance the [infamous USB3 interference on the 2.4Ghz spectrum](https://www.usb.org/sites/default/files/327216.pdf) is impacting your adapter. Switch to a USB2 port if you have one, or better, use a USB extension cable to position the dongle far away.
38
+
25
39
  ## Bluetooth Plugins Impacting Each Other
26
40
 
27
41
  Bluetooth plugins can kick off scanning, and otherwise interfere with each other. Worst case is when plugins attempt to connect directly to Bluetooth adapters. Better is when they connect using `bluez` and `dbus` Linux components, and best of all when they use the SignalK BLE Manager added in 2026.
28
42
 
29
43
  If you're having problems with Bluetooth connections, make sure other plugins are well behaved, using BLE Manager where they can, and consider temporarily switching them off if needed to debug label connections.
30
44
 
45
+ ## SignalK in Docker
46
+
47
+ Check for the `bluetooth` service working at both host level and inside the SignalK container. If there are stability issues, stop and disable the host level service (for example `sudo systemctl stop bluetooth` on a systemd controlled host).
48
+
31
49
  ## Stuck Bluetooth Adapters
32
50
 
33
51
  Sometime Bluetooth adapters, and/or the Linux services that use them, can get into a 'stuck' state, where the only solution is to reboot the server (although unplugging and plugging the dongle may help). The best way to avoid this is using a known good dongle, and using BLE Manager in SignalK wherever possible.
@@ -84,7 +102,11 @@ Bluetooth: hci0: command 0x2042 tx timeout
84
102
  Bluetooth: hci0: Opcode 0x2042 failed: -110
85
103
  ```
86
104
 
87
- 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.
105
+ Adapters like the popular ASUS USB-500 lack a GPIO to allow reset when suspended and it gets stuck, spamming the logs. See [Adapter Goes to Sleep](#adapter-goes-to-sleep) for stopping the auto-suspend happening.
106
+
107
+ ## Adapter Goes to Sleep
108
+
109
+ 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
88
110
 
89
111
  ### Example udev rule fix
90
112
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhizomatics/signalk-einklabel-plugin",
3
- "version": "1.3.0-beta13",
3
+ "version": "1.3.0-beta15",
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",