@rhizomatics/signalk-einklabel-plugin 1.3.0-beta14 → 1.3.0-beta16

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.
@@ -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
  }
@@ -238,7 +247,24 @@ function bleApiBackend(bleApi, pluginId) {
238
247
  }
239
248
  throw new Error(`connecting to device timed out after ${timeoutMs}ms`);
240
249
  }
241
- return withSessionWatchdog(conn, GATT_SESSION_WATCHDOG_MS, async () => {
250
+ // Disconnecting alone doesn't always drop the server's claim - it only clears that itself once it
251
+ // sees the link close, which a half-dead link may never report - so every disconnect releases the
252
+ // claim explicitly too, leaving the device free for the next paint (or another plugin).
253
+ const released = {
254
+ read: conn.read.bind(conn),
255
+ write: conn.write.bind(conn),
256
+ startNotifications: conn.startNotifications.bind(conn),
257
+ stopNotifications: conn.stopNotifications.bind(conn),
258
+ discoverServices: conn.discoverServices.bind(conn),
259
+ onDisconnect: conn.onDisconnect.bind(conn),
260
+ get connected() {
261
+ return conn.connected;
262
+ },
263
+ async disconnect() {
264
+ await Promise.allSettled([conn.disconnect(), bleApi.releaseGATTDevice(address, pluginId)]);
265
+ },
266
+ };
267
+ return withSessionWatchdog(released, GATT_SESSION_WATCHDOG_MS, async () => {
242
268
  // Belt-and-suspenders, matching the timeout-cleanup above: `releaseGATTDevice` is the
243
269
  // authoritative claim release, `disconnect()` a secondary teardown of this specific handle -
244
270
  // do both regardless of which (if either) itself hangs or rejects.
@@ -56,7 +56,7 @@ class ZhsunycoDriver {
56
56
  const conn = await backend.connectGatt(config.address, config.connectTimeoutMs ?? DEFAULT_PAINT_CONNECT_TIMEOUT_MS);
57
57
  log("connected");
58
58
  try {
59
- const info = (0, protocol_1.decodeAdvertisedInfo)(await conn.read(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.config));
59
+ const info = (0, protocol_1.decodeAdvertisedInfo)(await readConfig(conn, log));
60
60
  if (!info) {
61
61
  throw new Error("zhsunyco device did not return valid config data");
62
62
  }
@@ -125,6 +125,22 @@ class ZhsunycoDriver {
125
125
  }
126
126
  }
127
127
  exports.ZhsunycoDriver = ZhsunycoDriver;
128
+ /**
129
+ * The first read of a paint. If the label's service isn't there at all (seen as node-ble's "Service not
130
+ * available" behind the BLE Manager), logs which services the connection does report - the difference
131
+ * between BlueZ not having discovered the label's services yet and it seeing a different set entirely.
132
+ */
133
+ async function readConfig(conn, log) {
134
+ try {
135
+ return await conn.read(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.config);
136
+ }
137
+ catch (err) {
138
+ const services = await conn.discoverServices().catch((discoverErr) => `could not list them: ${discoverErr.message}`);
139
+ const listed = Array.isArray(services) ? services.map((service) => service.uuid).join(", ") || "none" : services;
140
+ log(`reading the label's config failed (${err.message}) - services visible on this connection: ${listed}`);
141
+ throw err;
142
+ }
143
+ }
128
144
  /**
129
145
  * Battery level needs a connection regardless, so reuse it to also fill in the PID/hwVersion
130
146
  * when the advertisement didn't carry decodable manufacturer data - a scan matched purely by name
@@ -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");
@@ -393,6 +394,39 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
393
394
  }
394
395
  app.debug(succeeded ? `${label}: repainted (${repaintReason}, paint took ${paintDurationMs}ms)` : `${label}: repainted fallback warning`);
395
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
+ }
396
430
  function startRepaintScheduler(app, config) {
397
431
  const state = loadState(app);
398
432
  const unsubscribes = [];
@@ -404,15 +438,17 @@ function startRepaintScheduler(app, config) {
404
438
  // (startup check, interval, subscription alike) funnels through this one gate.
405
439
  const startedAt = Date.now();
406
440
  const settleMs = (config.settleSeconds ?? 120) * 1000;
407
- 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) => {
408
444
  const elapsedMs = Date.now() - startedAt;
409
445
  if (elapsedMs < settleMs) {
410
446
  app.debug(`"${device.friendlyName}": still settling (${Math.round(elapsedMs / 1000)}s/${Math.round(settleMs / 1000)}s) - skipping repaint`);
411
- return;
447
+ return false;
412
448
  }
413
449
  const targets = await resolveTargets(app, config, device);
414
450
  if (targets.length === 0) {
415
- return;
451
+ return false;
416
452
  }
417
453
  const results = await Promise.allSettled(targets.map((target) => considerRepaint(app, config, device, target, state, getApiUrl)));
418
454
  results.forEach((result, i) => {
@@ -423,9 +459,11 @@ function startRepaintScheduler(app, config) {
423
459
  // A single `forceRepaint` flag covers every target under `ALL_DEVICES` too - only clear it once
424
460
  // every target has actually succeeded, so a target that failed still gets forced again next time
425
461
  // instead of quietly reverting to ordinary hash-based dedup.
426
- 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) {
427
464
  clearForceRepaint(app, device.friendlyName);
428
465
  }
466
+ return allSucceeded;
429
467
  };
430
468
  const intervalDevices = config.devices.filter((device) => device.repaintTrigger === "interval");
431
469
  if (intervalDevices.length > 0) {
package/docs/bluetooth.md CHANGED
@@ -42,6 +42,10 @@ Bluetooth plugins can kick off scanning, and otherwise interfere with each other
42
42
 
43
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.
44
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
+
45
49
  ## Stuck Bluetooth Adapters
46
50
 
47
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhizomatics/signalk-einklabel-plugin",
3
- "version": "1.3.0-beta14",
3
+ "version": "1.3.0-beta16",
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",