@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
|
-
|
|
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
|
-
|
|
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
|
|
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;
|
package/dist/repaintScheduler.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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-
|
|
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",
|