@rhizomatics/signalk-einklabel-plugin 1.3.0-beta9 → 1.3.1

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.
@@ -1,7 +1,18 @@
1
1
  import { ServerAPI } from "@signalk/server-api";
2
2
  import { PluginConfig } from "./config";
3
+ /**
4
+ * How long `stop()` waits for a paint already in progress before resolving anyway. Long enough for a
5
+ * normal transfer to finish (so a config save mid-paint doesn't have the next `start()` connect while
6
+ * the old BLE session is still open), short enough not to hold up server shutdown on a label that's
7
+ * stuck; such a paint is still ended by its own connect timeout and `PAINT_ATTEMPT_BACKSTOP_MS`.
8
+ */
9
+ export declare const STOP_DRAIN_TIMEOUT_MS = 30000;
3
10
  export interface RepaintScheduler {
4
- stop(): void;
11
+ /**
12
+ * Stops every trigger, lets no new paint or retry start, and resolves once any paint already in
13
+ * progress finishes - or after `STOP_DRAIN_TIMEOUT_MS`, whichever comes first.
14
+ */
15
+ stop(): Promise<void>;
5
16
  }
6
17
  /**
7
18
  * The most recent wall-clock instant at or before `now` that an `interval`-triggered device's own
@@ -13,4 +24,21 @@ export interface RepaintScheduler {
13
24
  * rather than waiting up to `intervalHours` for the next one).
14
25
  */
15
26
  export declare function mostRecentScheduledSlot(now: Date, intervalHours: number, intervalMinute: number): Date;
27
+ /**
28
+ * Wraps `run` so only one call per key is in progress at a time. A call made while that key is busy
29
+ * is folded into a single follow-up (with the latest arguments), run once the current one finishes -
30
+ * but only if it succeeded (`run` resolved `true`). For repaints: newer data still gets shown promptly,
31
+ * but a label that's failing isn't hammered with a fresh round of retries straight after the last
32
+ * one gave up - the next scheduled trigger tries again instead.
33
+ *
34
+ * `close()` makes every later call a no-op and drops pending follow-ups; `idle()` resolves once no
35
+ * `run` is in progress. Together they let the scheduler's `stop()` wait for a paint already under way
36
+ * without starting another.
37
+ */
38
+ export interface OneAtATimePerKey<T> {
39
+ (arg: T): Promise<void>;
40
+ close(): void;
41
+ idle(): Promise<void>;
42
+ }
43
+ export declare function oneAtATimePerKey<T>(keyOf: (arg: T) => string, run: (arg: T) => Promise<boolean>, onBusy?: (arg: T) => void): OneAtATimePerKey<T>;
16
44
  export declare function startRepaintScheduler(app: ServerAPI, config: PluginConfig): RepaintScheduler;
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.STOP_DRAIN_TIMEOUT_MS = void 0;
3
4
  exports.mostRecentScheduledSlot = mostRecentScheduledSlot;
5
+ exports.oneAtATimePerKey = oneAtATimePerKey;
4
6
  exports.startRepaintScheduler = startRepaintScheduler;
5
7
  const crypto_1 = require("crypto");
6
8
  const path_1 = require("path");
@@ -25,6 +27,20 @@ const INTERVAL_POLL_MS = 60000;
25
27
  const SUBSCRIPTION_DEBOUNCE_MS = 2000;
26
28
  /** Pause between paint attempts - see `withRetries`' `delayMs`. */
27
29
  const PAINT_RETRY_DELAY_MS = 5000;
30
+ /**
31
+ * Last-resort limit on one paint attempt, on top of its connect timeout - so an attempt stuck on
32
+ * anything, anywhere, still fails and gets retried instead of blocking this label (and, via
33
+ * `exclusiveBleManagerAccess`, every other label) forever. Longer than every inner limit it backs up,
34
+ * the longest being the 5-minute GATT session watchdog in `bleBackend.ts`.
35
+ */
36
+ const PAINT_ATTEMPT_BACKSTOP_MS = 7 * 60000;
37
+ /**
38
+ * How long `stop()` waits for a paint already in progress before resolving anyway. Long enough for a
39
+ * normal transfer to finish (so a config save mid-paint doesn't have the next `start()` connect while
40
+ * the old BLE session is still open), short enough not to hold up server shutdown on a label that's
41
+ * stuck; such a paint is still ended by its own connect timeout and `PAINT_ATTEMPT_BACKSTOP_MS`.
42
+ */
43
+ exports.STOP_DRAIN_TIMEOUT_MS = 30000;
28
44
  const RESOURCES_API_PATH = "/signalk/v2/api/resources";
29
45
  /**
30
46
  * The most recent wall-clock instant at or before `now` that an `interval`-triggered device's own
@@ -175,7 +191,7 @@ async function assembleRawContext(app, apiUrl, bindings) {
175
191
  }
176
192
  function clearForceRepaint(app, friendlyName) {
177
193
  const current = (0, config_1.readCurrentConfig)(app);
178
- const devices = (current.devices ?? []).map((device) => device.friendlyName === friendlyName ? { ...device, forceRepaint: false } : device);
194
+ const devices = (current.devices ?? []).map((device) => device.friendlyName === friendlyName ? { ...device, advanced: { ...device.advanced, forceRepaint: false } } : device);
179
195
  app.savePluginOptions({ ...current, devices }, (err) => {
180
196
  if (err)
181
197
  app.debug(`failed to clear forceRepaint for "${friendlyName}": ${err.message}`);
@@ -240,7 +256,7 @@ async function resolveTargets(app, config, device) {
240
256
  * prompt showing "gentle breezes" through an actual storm), and not persisting a hash means the very
241
257
  * next scheduled tick retries for real rather than being suppressed by dedup.
242
258
  */
243
- async function considerRepaint(app, config, device, target, state, getApiUrl) {
259
+ async function considerRepaint(app, config, device, target, state, getApiUrl, isStopping = () => false) {
244
260
  const { address, metadata, driver } = target;
245
261
  const label = `"${device.friendlyName}" [${address}]`;
246
262
  if (!device.templateName) {
@@ -260,6 +276,23 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
260
276
  let dataHash = "";
261
277
  let paintDurationMs = 0;
262
278
  let repaintReason = "render failed";
279
+ // Built up front, outside the try below, so the fallback warning render gets it too - it's all local
280
+ // facts (no network or template involved), so there's nothing here that can fail a render.
281
+ const rawPosition = (0, unwrapSignalkTree_1.unwrapSignalkTree)(app.getSelfPath("navigation.position"));
282
+ const position = typeof rawPosition?.latitude === "number" && typeof rawPosition?.longitude === "number"
283
+ ? { latitude: rawPosition.latitude, longitude: rawPosition.longitude }
284
+ : undefined;
285
+ const labelContext = (0, binding_1.buildLabelContext)({
286
+ // Falls back to the driver's own internal vendor key (e.g. "zhsunyco") when a device model has no
287
+ // explicit `manufacturer` of its own - see `DeviceMetadata.manufacturer`'s doc comment.
288
+ manufacturer: metadata.manufacturer ?? target.vendor,
289
+ label: metadata.label,
290
+ width,
291
+ height,
292
+ colours: metadata.colours,
293
+ description: device.description,
294
+ position,
295
+ });
263
296
  try {
264
297
  const apiUrl = await getApiUrl().catch((err) => {
265
298
  app.debug(`${label}: ${err.message}`);
@@ -281,21 +314,6 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
281
314
  bindings = (0, binding_1.findBindings)((0, fs_1.readFileSync)(templatePath, "utf-8"));
282
315
  }
283
316
  const rawContext = await assembleRawContext(app, apiUrl, bindings);
284
- const rawPosition = (0, unwrapSignalkTree_1.unwrapSignalkTree)(app.getSelfPath("navigation.position"));
285
- const position = typeof rawPosition?.latitude === "number" && typeof rawPosition?.longitude === "number"
286
- ? { latitude: rawPosition.latitude, longitude: rawPosition.longitude }
287
- : undefined;
288
- const labelContext = (0, binding_1.buildLabelContext)({
289
- // Falls back to the driver's own internal vendor key (e.g. "zhsunyco") when a device model has no
290
- // explicit `manufacturer` of its own - see `DeviceMetadata.manufacturer`'s doc comment.
291
- manufacturer: metadata.manufacturer ?? target.vendor,
292
- label: metadata.label,
293
- width,
294
- height,
295
- colours: metadata.colours,
296
- description: device.description,
297
- position,
298
- });
299
317
  // Hashed before `meta` is merged in below, deliberately, so a template merely *displaying* the
300
318
  // repaint timestamp doesn't perpetually invalidate its own dedup and force a repaint every check. A
301
319
  // full paint flashes the whole panel several times, and there's no confirmed partial-refresh path
@@ -309,7 +327,7 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
309
327
  // scheduled tick" (e.g. a regenerated forecast) is the whole point of one - which is exactly why a
310
328
  // provider-backed device should use `repaintTrigger: "interval"`, not `subscription`, for
311
329
  // cost/battery reasons (each repaint may be a paid API call on the provider's side).
312
- if (!provider && !templateChanged && !dataChanged && !device.forceRepaint) {
330
+ if (!provider && !templateChanged && !dataChanged && !device.advanced?.forceRepaint) {
313
331
  app.debug(`${label}: data unchanged, skipping repaint`);
314
332
  return;
315
333
  }
@@ -320,14 +338,24 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
320
338
  repainted: new Date().toISOString(),
321
339
  local_zone: (0, formatters_1.resolveLocalZoneAbbreviation)(rawContext),
322
340
  plugin_version: pluginVersion_1.PLUGIN_VERSION,
341
+ // Undocumented legacy alias of `label.description` - kept so templates already using it keep working.
323
342
  description: device.description ?? "",
324
343
  },
325
344
  };
345
+ const providerRequest = { templateName: device.templateName, context: renderContext, width, height, colours: metadata.colours };
346
+ if (provider?.describeContent) {
347
+ try {
348
+ app.debug(`${label}: provider content:\n${provider.describeContent(providerRequest)}`);
349
+ }
350
+ catch (err) {
351
+ app.debug(`${label}: describeContent failed (${err.message})`);
352
+ }
353
+ }
326
354
  bitmap = provider
327
- ? await provider.render({ templateName: device.templateName, context: renderContext, width, height, colours: metadata.colours })
355
+ ? await provider.render(providerRequest)
328
356
  : await renderer.render(templatePath, renderContext, width, height, templatesDir, config_1.BUNDLED_TEMPLATES_DIR);
329
357
  succeeded = true;
330
- repaintReason = device.forceRepaint
358
+ repaintReason = device.advanced?.forceRepaint
331
359
  ? "forced"
332
360
  : provider
333
361
  ? "provider-rendered"
@@ -344,30 +372,43 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
344
372
  height: metadata.height,
345
373
  colours: metadata.colours,
346
374
  });
347
- 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);
375
+ bitmap = await renderer.render(fallbackPath, {
376
+ label: labelContext,
377
+ meta: { repainted: new Date().toISOString(), plugin_version: pluginVersion_1.PLUGIN_VERSION, description: device.description ?? "" },
378
+ }, width, height, templatesDir, config_1.BUNDLED_TEMPLATES_DIR);
348
379
  }
349
- const connectTimeoutMs = config.paintConnectTimeoutSeconds * 1000;
380
+ const connectTimeoutMs = (device.advanced?.paintConnectTimeoutSeconds ?? config.paintConnectTimeoutSeconds) * 1000;
350
381
  const gattBackend = config.useBleApi && app.bleApi ? (0, bleBackend_1.bleApiBackend)(app.bleApi, pluginVersion_1.PLUGIN_NAME) : undefined;
351
- const attempts = Math.max(1, config.paintRetries);
352
- const paintWithRetries = () => (0, bleDiscovery_1.withRetries)(attempts, async (attempt) => {
353
- app.debug(`${label}: attempting paint ${attempt}/${attempts}`);
354
- const startedAt = Date.now();
355
- await driver.paint(bitmap, {
356
- address,
357
- pid: target.pid,
358
- aesKey: device.aesKey,
359
- connectTimeoutMs,
360
- reframe: device.reframe,
361
- mirror: device.mirror,
362
- compress: device.compress,
363
- compressionFormat: device.compressionFormat,
364
- gattBackend,
382
+ const attempts = Math.max(1, device.advanced?.paintRetries ?? config.paintRetries);
383
+ const paintWithRetries = () => {
384
+ // Rendering (or waiting for `exclusiveBleManagerAccess`) can outlast a `stop()` - don't open a
385
+ // BLE session for a plugin that's already stopping.
386
+ if (isStopping())
387
+ throw new Error("plugin stopping - paint skipped");
388
+ return (0, bleDiscovery_1.withRetries)(attempts, async (attempt) => {
389
+ app.debug(`${label}: attempting paint ${attempt}/${attempts}`);
390
+ const startedAt = Date.now();
391
+ const paint = driver.paint(bitmap, {
392
+ address,
393
+ pid: target.pid,
394
+ aesKey: device.advanced?.aesKey,
395
+ connectTimeoutMs,
396
+ reframe: device.advanced?.reframe,
397
+ mirror: device.advanced?.mirror,
398
+ compress: device.advanced?.compress,
399
+ compressionFormat: device.advanced?.compressionFormat,
400
+ writeWithoutResponse: device.advanced?.writeWithoutResponse,
401
+ gattBackend,
402
+ log: (message) => app.debug(`${label}: ${message}`),
403
+ });
404
+ await (0, bleDiscovery_1.withDeadline)(paint, connectTimeoutMs + PAINT_ATTEMPT_BACKSTOP_MS, `paint attempt ${attempt}/${attempts}`);
405
+ paintDurationMs = Date.now() - startedAt;
406
+ }, {
407
+ delayMs: PAINT_RETRY_DELAY_MS,
408
+ onError: (err, attempt) => app.debug(`${label}: paint attempt ${attempt}/${attempts} failed: ${err.message}`),
409
+ shouldStop: isStopping,
365
410
  });
366
- paintDurationMs = Date.now() - startedAt;
367
- }, {
368
- delayMs: PAINT_RETRY_DELAY_MS,
369
- onError: (err, attempt) => app.debug(`${label}: paint attempt ${attempt}/${attempts} failed: ${err.message}`),
370
- });
411
+ };
371
412
  await (gattBackend ? (0, bleBackend_1.exclusiveBleManagerAccess)(paintWithRetries) : paintWithRetries());
372
413
  (0, discoveredDevicesStore_1.touchDiscoveredDevice)(app, { address, vendor: target.vendor, pid: target.pid, hwVersion: target.hwVersion, metadata });
373
414
  // Only a successful render counts as "repainted" for dedup/catch-up purposes - a failure must not be
@@ -378,10 +419,53 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
378
419
  }
379
420
  app.debug(succeeded ? `${label}: repainted (${repaintReason}, paint took ${paintDurationMs}ms)` : `${label}: repainted fallback warning`);
380
421
  }
422
+ function oneAtATimePerKey(keyOf, run, onBusy = () => { }) {
423
+ const inProgress = new Map();
424
+ const followUps = new Map();
425
+ let closed = false;
426
+ const call = async (arg) => {
427
+ if (closed)
428
+ return;
429
+ const key = keyOf(arg);
430
+ if (inProgress.has(key)) {
431
+ followUps.set(key, arg);
432
+ onBusy(arg);
433
+ return;
434
+ }
435
+ const running = run(arg);
436
+ inProgress.set(key, running);
437
+ let succeeded = false;
438
+ try {
439
+ succeeded = await running;
440
+ }
441
+ finally {
442
+ inProgress.delete(key);
443
+ }
444
+ const followUp = followUps.get(key);
445
+ followUps.delete(key);
446
+ if (followUp !== undefined && succeeded) {
447
+ await call(followUp);
448
+ }
449
+ };
450
+ return Object.assign(call, {
451
+ close() {
452
+ closed = true;
453
+ followUps.clear();
454
+ },
455
+ async idle() {
456
+ // A follow-up can start as an earlier run settles (unless closed), so loop until nothing is left.
457
+ while (inProgress.size > 0) {
458
+ await Promise.allSettled(inProgress.values());
459
+ }
460
+ },
461
+ });
462
+ }
381
463
  function startRepaintScheduler(app, config) {
382
464
  const state = loadState(app);
383
465
  const unsubscribes = [];
384
466
  const getApiUrl = (0, resolveApiUrl_1.createApiUrlResolver)(config.signalkApiUrl);
467
+ let stopping = false;
468
+ const isStopping = () => stopping;
385
469
  // The first minute or two of a SignalK server's life is a chaos of plugin dependency sequencing -
386
470
  // a repaint attempted before other plugins (e.g. derived-data) have published the data a template
387
471
  // needs renders with missing/wrong values, and hash dedup (see `considerRepaint`) then means it
@@ -389,17 +473,19 @@ function startRepaintScheduler(app, config) {
389
473
  // (startup check, interval, subscription alike) funnels through this one gate.
390
474
  const startedAt = Date.now();
391
475
  const settleMs = (config.settleSeconds ?? 120) * 1000;
392
- const repaint = async (device) => {
476
+ const repaint = oneAtATimePerKey((device) => device.friendlyName, (device) => repaintOnce(device), (device) => app.debug(`"${device.friendlyName}": already repainting - will check again once it's done`));
477
+ /** Returns whether every target was repainted (or was already up to date). */
478
+ const repaintOnce = async (device) => {
393
479
  const elapsedMs = Date.now() - startedAt;
394
480
  if (elapsedMs < settleMs) {
395
481
  app.debug(`"${device.friendlyName}": still settling (${Math.round(elapsedMs / 1000)}s/${Math.round(settleMs / 1000)}s) - skipping repaint`);
396
- return;
482
+ return false;
397
483
  }
398
484
  const targets = await resolveTargets(app, config, device);
399
485
  if (targets.length === 0) {
400
- return;
486
+ return false;
401
487
  }
402
- const results = await Promise.allSettled(targets.map((target) => considerRepaint(app, config, device, target, state, getApiUrl)));
488
+ const results = await Promise.allSettled(targets.map((target) => considerRepaint(app, config, device, target, state, getApiUrl, isStopping)));
403
489
  results.forEach((result, i) => {
404
490
  if (result.status === "rejected") {
405
491
  app.debug(`"${device.friendlyName}" [${targets[i].address}]: repaint failed: ${result.reason.message}`);
@@ -408,9 +494,11 @@ function startRepaintScheduler(app, config) {
408
494
  // A single `forceRepaint` flag covers every target under `ALL_DEVICES` too - only clear it once
409
495
  // every target has actually succeeded, so a target that failed still gets forced again next time
410
496
  // instead of quietly reverting to ordinary hash-based dedup.
411
- if (device.forceRepaint && results.every((result) => result.status === "fulfilled")) {
497
+ const allSucceeded = results.every((result) => result.status === "fulfilled");
498
+ if (device.advanced?.forceRepaint && allSucceeded) {
412
499
  clearForceRepaint(app, device.friendlyName);
413
500
  }
501
+ return allSucceeded;
414
502
  };
415
503
  const intervalDevices = config.devices.filter((device) => device.repaintTrigger === "interval");
416
504
  if (intervalDevices.length > 0) {
@@ -455,7 +543,7 @@ function startRepaintScheduler(app, config) {
455
543
  // still avoids a redundant paint once targets are resolved.
456
544
  const startupCheckTimer = setTimeout(() => {
457
545
  for (const device of config.devices) {
458
- if (device.repaintTrigger === "interval" && !device.forceRepaint && device.device !== config_1.ALL_DEVICES) {
546
+ if (device.repaintTrigger === "interval" && !device.advanced?.forceRepaint && device.device !== config_1.ALL_DEVICES) {
459
547
  const hours = device.intervalHours ?? 1;
460
548
  const minute = device.intervalMinute ?? 0;
461
549
  const dueSlot = mostRecentScheduledSlot(new Date(), hours, minute);
@@ -471,9 +559,22 @@ function startRepaintScheduler(app, config) {
471
559
  }, settleMs);
472
560
  unsubscribes.push(() => clearTimeout(startupCheckTimer));
473
561
  return {
474
- stop() {
562
+ async stop() {
563
+ stopping = true;
475
564
  for (const unsubscribe of unsubscribes)
476
565
  unsubscribe();
566
+ repaint.close();
567
+ let timer;
568
+ const drained = await Promise.race([
569
+ repaint.idle().then(() => true),
570
+ new Promise((resolve) => {
571
+ timer = setTimeout(() => resolve(false), exports.STOP_DRAIN_TIMEOUT_MS);
572
+ }),
573
+ ]);
574
+ clearTimeout(timer);
575
+ if (!drained) {
576
+ app.debug(`a repaint was still running ${exports.STOP_DRAIN_TIMEOUT_MS / 1000}s after stop - not waiting any longer`);
577
+ }
477
578
  },
478
579
  };
479
580
  }
@@ -37,6 +37,9 @@ async function probe(url) {
37
37
  * and uses the first that responds.
38
38
  */
39
39
  async function resolveSignalkApiUrl(configuredUrl) {
40
+ // Typed free-form in the config UI, so tolerate stray whitespace and a trailing slash, which would
41
+ // otherwise double up with the leading slash of every API path appended to it.
42
+ configuredUrl = configuredUrl?.trim().replace(/\/+$/, "") || undefined;
40
43
  const candidates = configuredUrl ? [configuredUrl] : exports.SIGNALK_API_URL_OPTIONS;
41
44
  for (const url of candidates) {
42
45
  if (await probe(url))
@@ -0,0 +1,191 @@
1
+ # Bluetooth
2
+
3
+ Advice for getting a reliable Bluetooth Low Energy (BLE) connection between the SignalK server and your labels.
4
+
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.
16
+
17
+ ## Choosing a Bluetooth Adapter
18
+
19
+ First step is having a Bluetooth Low Energy (BLE) compatible bluetooth adapter available.
20
+
21
+ - Bluetooth adapters for Linux can be tricky
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))
23
+ - 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
24
+ - Some Raspberry Pi models come with suitable Bluetooth built in
25
+ - See advice at [Recommended Bluetooth Adapters for Linux](https://github.com/morrownr/USB-WiFi/blob/main/home/Recommended_Bluetooth_Adapters_for_Linux.md)
26
+ - 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.
27
+
28
+ > - Don't worry about the very latest Bluetooth versions, 4.0 is minimum for BLE, 5.0 is nice
29
+ > - Home Assistant is massively more popular than SignalK, and often also run on Raspberry Pi and similar, so good source of advice
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
+
33
+ ## BLE Manager Readiness
34
+
35
+ v2.31.0 is the minumum version of SignalK possible for BLE Manager. Several fixes went in to v2.33.0 so this is the practical minimum version for using the plugin.
36
+
37
+ Gicisky labels have been painted successfully using BLE Manager, however Zhsunyco have some different interactions that are waiting other fixes.
38
+
39
+ - [PR#3082](https://github.com/SignalK/signalk-server/pull/3082) - hung connections locking up device
40
+ - [PR#3088](https://github.com/SignalK/signalk-server/pull/3088) - Support plain GATT write requests
41
+ - [PR#3089](https://github.com/SignalK/signalk-server/pull/3089) - Pause scanning while GATT operation in progress
42
+
43
+ There's a workaround for **PR#3088** available in the _Advanced Options_, and **PR#3082** isn't a problem if operations don't fail, however **PR#3089** is a blocker for using Zhsunyco labels - they'll fail with a `0x0e` error code.
44
+
45
+ ## Weak Signal
46
+
47
+ 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.
48
+
49
+ 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.
50
+
51
+ ## Bluetooth Plugins Impacting Each Other
52
+
53
+ 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.
54
+
55
+ 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.
56
+
57
+ ## SignalK in Docker
58
+
59
+ 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).
60
+
61
+ ## Stuck Bluetooth Adapters
62
+
63
+ 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.
64
+
65
+ ## Zhsunyco Labels and the BLE Manager
66
+
67
+ With the "Use the SignalK BLE Manager API" setting on, Zhsunyco labels can connect and authenticate, then fail as soon as the image upload starts, with `Operation failed with ATT error: 0x0e` in the log. SignalK's BLE Manager (2.33 and earlier) sends every write that waits for an acknowledgement as a Bluetooth "reliable" write, which these labels don't support.
68
+
69
+ Either:
70
+
71
+ - turn on _Send image without waiting for each write (Zhsunyco)_ in the label's _Advanced settings_ - the image is sent with a small gap between writes instead, and the label still confirms once the whole image has arrived; or
72
+ - switch the BLE Manager setting off, if no other plugins need to share Bluetooth with this one.
73
+
74
+ Gicisky labels aren't affected, since they don't use acknowledged writes.
75
+
76
+ ## Stuck Labels
77
+
78
+ A label can itself get into a stuck state - usually after several connections were cut off part way through a repaint - where it still advertises and accepts connections, but no longer lists its services. Every repaint then connects and fails a couple of seconds later, with `Service not available` in this plugin's log, or `Characteristic … was not found` from other software such as Home Assistant.
79
+
80
+ You can confirm it's the label rather than your server: `bluetoothctl info <label address>` shows no `UUID:` lines even after a connection, and the same failure happens from a different adapter or computer.
81
+
82
+ The fix is to power-cycle the label - take the battery out for 10-20 seconds, put it back, then trigger a repaint (for example with _Force repaint_). If it still fails, the manufacturer's own app may be needed to reset it.
83
+
84
+ ## Tuning Bluetooth Connections
85
+
86
+ Labels spend most of their time asleep, so they can be slow to accept a connection, and slow to answer while an image is being sent to them. Linux's Bluetooth defaults are set with phones, headphones and sensors in mind, so if connections to labels regularly time out, or drop partway through a repaint (for example with GATT or connection-abort errors in the log), some Bluetooth settings on the server may need adjusting:
87
+
88
+ - **The plugin's own timeouts** - the _Paint connect timeout_ and _Paint retries_, set plugin-wide or per label (see [Setting up a Label](getting-started.md#setting-up-a-label)). Try these first, since they only affect this plugin.
89
+ - **BlueZ's connection settings**, in the `[LE]` section of `/etc/bluetooth/main.conf` - the connection interval range (`MinConnectionInterval`/`MaxConnectionInterval`), how long a quiet connection is kept before it's dropped (`ConnectionSupervisionTimeout`), and how long a connection attempt waits (`Autoconnecttimeout`). The file's own comments describe each one. Restart the Bluetooth service after changing it.
90
+ - **The kernel's Bluetooth settings** for the adapter, under `/sys/kernel/debug/bluetooth/hci0/` - such as `supervision_timeout`, `conn_min_interval` and `conn_max_interval`. Values written here are lost on reboot, unless something re-applies them at startup.
91
+
92
+ These apply whenever the server's Bluetooth goes through BlueZ, including the SignalK BLE Manager with a local adapter. They affect every Bluetooth device the server talks to, not just labels, so change one thing at a time and check that your other Bluetooth equipment still works.
93
+
94
+ No particular values are recommended here - what works depends on the adapter, the labels and whatever else is using Bluetooth. Get advice before changing them, for example from the [SignalK community](https://signalk.org) or Home Assistant's Bluetooth community, where many of the same adapters and Linux setups are used.
95
+
96
+ ## SignalK starts before the Bluetooth daemon
97
+
98
+ This and the next section 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.
99
+
100
+ 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.
101
+
102
+ 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:
103
+
104
+ ```bash
105
+ sudo systemctl edit signalk.service
106
+ ```
107
+
108
+ This opens an override file — add:
109
+
110
+ ```ini
111
+ [Unit]
112
+ After=bluetooth.target
113
+ Wants=bluetooth.target
114
+ ```
115
+
116
+ Save and exit, then:
117
+
118
+ ```bash
119
+ sudo systemctl daemon-reload
120
+ sudo systemctl restart signalk
121
+ ```
122
+
123
+ 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.
124
+
125
+ ## Adapter stops responding: "No gpio to reset"
126
+
127
+ Example log:
128
+
129
+ ```
130
+ Bluetooth: hci0: No gpio to reset Realtek device, ignoring
131
+ Bluetooth: hci0: Unable to disable scanning: -110
132
+ Bluetooth: hci0: command 0x2042 tx timeout
133
+ Bluetooth: hci0: Opcode 0x2042 failed: -110
134
+ ```
135
+
136
+ 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.
137
+
138
+ ## Adapter Goes to Sleep
139
+
140
+ 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
141
+
142
+ ### Example udev rule fix
143
+
144
+ > [!Note]
145
+ > 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.
146
+
147
+ Following file created at `/etc/udev/rules.d/99-bt500-no-autosuspend.rules`
148
+
149
+ ```
150
+ # Disable USB autosuspend for the ASUS USB-BT500 (RTL8761BU, 0b05:190e).
151
+ ACTION=="add", SUBSYSTEM=="usb", ATTR{idVendor}=="0b05", ATTR{idProduct}=="190e", TEST=="power/control", ATTR{power/control}="on"
152
+ ```
153
+
154
+ ### Example tlp fix
155
+
156
+ If `tlp` running to minimize power, it may have its own rules trying to suspend the Bluetooth dongle.
157
+
158
+ Following file created at /etc/tlp.d/99-bt500-no-autosuspend.conf
159
+
160
+ ```
161
+ USB_DENYLIST="0b05:190e"
162
+ ```
163
+
164
+ ## Bluetooth Jargon Buster
165
+
166
+ Bluetooth comes with a lot of acronyms. Here's what the ones on this page mean, without needing to be a computer person.
167
+
168
+ When the plugin paints a label, the request goes down a chain: the plugin asks **BlueZ** over **D-Bus**, BlueZ gives orders to the Bluetooth dongle over **HCI**, and the dongle's radio talks to the label using **BLE**, reading and writing the label's **GATT** services. A **gateway** or **proxy** simply moves the radio end of that chain somewhere else on the boat.
169
+
170
+ - **BLE (Bluetooth Low Energy)** - the kind of Bluetooth made for small battery-powered gadgets like labels, sensors and battery monitors.
171
+ - It's different from the "classic" Bluetooth used by headphones and speakers, and doesn't have the usual pairing process.
172
+ - BLE devices talk in short bursts and sleep in between, which is how a label runs for years on a coin cell.
173
+ - They regularly broadcast short "advertisements" saying who they are, and the server picks these up by _scanning_.
174
+ - BLE needs an adapter supporting Bluetooth 4.0 or later.
175
+ - **HCI (Host Controller Interface)** - the standard set of commands a computer uses to give orders to a Bluetooth radio chip, such as "start scanning" or "connect to this device".
176
+ - Linux names each Bluetooth adapter after it, so `hci0` is the first adapter, `hci1` the second, and so on.
177
+ - A plugin using the adapter "directly" is sending HCI commands itself, works fine if there's only one Bluetooth app on the server.
178
+ - **BlueZ** - the standard Bluetooth software on Linux.
179
+ - It runs quietly in the background as a service called `bluetoothd`, takes charge of the adapter, keeps a list of the devices it has heard, and handles connecting to them.
180
+ - Its a sane alternative to multiple Linux programs all trying to grab the limited resources of the Bluetooth adapter and stomping over each other. It does at a Linux level what BLE Manager does for SignalK - a single point of ownership and control over a contested resource.
181
+ - **D-Bus** - the messaging system that programs on a Linux computer use to talk to each other.
182
+ - It's how the plugin, or SignalK's BLE Manager, asks BlueZ to scan or connect.
183
+ - If you know NMEA 2000, it's a similar idea: one shared backbone that everything plugs into and exchanges messages over, rather than a separate cable between every pair of devices.
184
+ - **GATT (Generic Attribute Profile)** - how a BLE device organises what it offers once you're connected to it.
185
+ - The device has _services_, each a group of related features, and each service has _characteristics_, individual values you can read, write or be told about when they change.
186
+ - Painting a label means connecting, finding its image service, and writing the picture into the right characteristic a piece at a time.
187
+ - The "GATT claims" shown in SignalK's BLE Manager are simply which plugin has booked which device for a connection, so that two plugins don't try to talk to it at once.
188
+ - **Gateway** - a separate small device, often a cheap ESP32 board, with its own Bluetooth radio, which listens for and talks to BLE devices near it and relays everything to SignalK over the boat's network.
189
+ - It's effectively fitting a remote antenna closer to where it's needed: useful when a label is too far from the server, or on a big boat that needs more than one. SignalK's BLE Manager can use gateways alongside, or instead of, a local adapter.
190
+ - **Proxy** - much the same idea as a gateway: a device that does the Bluetooth radio work on the server's behalf, somewhere with a better signal.
191
+ - Different software uses different names for it. Home Assistant users, for example, will know ESP32 "Bluetooth proxies".