@rhizomatics/signalk-einklabel-plugin 1.3.0-beta6 → 1.3.0-beta8

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
File without changes
@@ -168,6 +168,8 @@ async function ensureDeviceVisible(bleApi, pluginId, address, timeoutMs) {
168
168
  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
169
  }
170
170
  }
171
+ /** Upper bound on how long a timed-out `bleApi.connectGATT()` is given to settle server-side before a retry - see `bleApiBackend`. */
172
+ const CONNECT_SETTLE_GRACE_MS = 30000;
171
173
  let bleManagerQueue = Promise.resolve();
172
174
  /**
173
175
  * Runs `fn` only once every earlier caller has finished, so paints to different devices never hold
@@ -205,11 +207,24 @@ function bleApiBackend(bleApi, pluginId) {
205
207
  // whatever the claim currently is, connected or still connecting, regardless of how the
206
208
  // original `connectGATT()` promise eventually settles - disconnecting it too if it does still
207
209
  // resolve afterwards, harmlessly, since a connection the server already released is a no-op to
208
- // disconnect again.
209
- void bleApi
210
- .releaseGATTDevice(address, pluginId)
211
- .catch(() => { })
212
- .then(() => connecting.then((c) => c.disconnect()).catch(() => { }));
210
+ // disconnect again. The release is awaited (only the late disconnect is left in the
211
+ // background) so a caller retrying straight away - `withRetries` - can't race it with a new
212
+ // `connectGATT()` and get rejected with "has a GATT claim in progress" for its trouble.
213
+ await bleApi.releaseGATTDevice(address, pluginId).catch(() => { });
214
+ // ...except a claim still *connecting* isn't in the server's claim table yet, only its pending
215
+ // set, which `releaseGATTDevice` doesn't touch - so while the server's own connect is still in
216
+ // flight, a retry's `connectGATT()` is rejected outright with "has a GATT claim in progress".
217
+ // Give that connect a bounded chance to settle first, so the retry gets a clean slate.
218
+ const late = await Promise.race([
219
+ connecting.catch(() => undefined),
220
+ (0, bleDiscovery_1.sleep)(Math.min(timeoutMs, CONNECT_SETTLE_GRACE_MS)).then(() => undefined),
221
+ ]);
222
+ if (late) {
223
+ await Promise.allSettled([bleApi.releaseGATTDevice(address, pluginId), late.disconnect()]);
224
+ }
225
+ else {
226
+ void connecting.then((c) => c.disconnect()).catch(() => { });
227
+ }
213
228
  throw new Error(`connecting to device timed out after ${timeoutMs}ms`);
214
229
  }
215
230
  return withSessionWatchdog(conn, GATT_SESSION_WATCHDOG_MS, async () => {
@@ -34,9 +34,16 @@ export declare function forEachAdvertisedDevice(adapter: Adapter, fn: (advertise
34
34
  /**
35
35
  * Retries `fn` up to `attempts` times (including the first try), returning on the first success -
36
36
  * shared by the repaint scheduler and the CLI's `paint` command so one flaky BLE connection
37
- * doesn't fail a whole repaint after a single bad attempt.
37
+ * doesn't fail a whole repaint after a single bad attempt. `delayMs` pauses between attempts, giving
38
+ * the adapter (and any BLE Manager claim the failed attempt held) a moment to settle rather than
39
+ * hitting the device again in the same tick. `onError` sees every failed attempt's error, not just the
40
+ * last one that's eventually thrown - an early attempt's error is often the real cause, with later
41
+ * ones just fallout from it.
38
42
  */
39
- export declare function withRetries<T>(attempts: number, fn: (attempt: number) => Promise<T>): Promise<T>;
43
+ export declare function withRetries<T>(attempts: number, fn: (attempt: number) => Promise<T>, { delayMs, onError }?: {
44
+ delayMs?: number;
45
+ onError?: (err: unknown, attempt: number) => void;
46
+ }): Promise<T>;
40
47
  /**
41
48
  * Opens exactly one BLE discovery window and one D-Bus/BlueZ session, then hands the adapter to
42
49
  * `fn` - shared by `plugin.ts`'s startup scan and the CLI's `scan` command so scanning across
@@ -68,16 +68,25 @@ async function forEachAdvertisedDevice(adapter, fn) {
68
68
  /**
69
69
  * Retries `fn` up to `attempts` times (including the first try), returning on the first success -
70
70
  * shared by the repaint scheduler and the CLI's `paint` command so one flaky BLE connection
71
- * doesn't fail a whole repaint after a single bad attempt.
71
+ * doesn't fail a whole repaint after a single bad attempt. `delayMs` pauses between attempts, giving
72
+ * the adapter (and any BLE Manager claim the failed attempt held) a moment to settle rather than
73
+ * hitting the device again in the same tick. `onError` sees every failed attempt's error, not just the
74
+ * last one that's eventually thrown - an early attempt's error is often the real cause, with later
75
+ * ones just fallout from it.
72
76
  */
73
- async function withRetries(attempts, fn) {
77
+ async function withRetries(attempts, fn, { delayMs = 0, onError } = {}) {
78
+ const total = Math.max(1, attempts);
74
79
  let lastErr;
75
- for (let attempt = 1; attempt <= Math.max(1, attempts); attempt++) {
80
+ for (let attempt = 1; attempt <= total; attempt++) {
76
81
  try {
77
82
  return await fn(attempt);
78
83
  }
79
84
  catch (err) {
80
85
  lastErr = err;
86
+ onError?.(err, attempt);
87
+ if (attempt < total && delayMs > 0) {
88
+ await sleep(delayMs);
89
+ }
81
90
  }
82
91
  }
83
92
  throw lastErr;
@@ -53,19 +53,23 @@ class GiciskyDriver {
53
53
  */
54
54
  const manufacturerData = await backend.waitForManufacturerData(config.address, protocol_1.GICISKY_MANUFACTURER_ID, MANUFACTURER_DATA_RESCAN_TIMEOUT_MS);
55
55
  const info = manufacturerData ? (0, protocol_1.decodeAdvertisedInfo)(manufacturerData) : undefined;
56
+ // A device that's quiet right now (e.g. still refreshing from the last paint) can still be painted
57
+ // when its PID is already known from config/a previous scan - the connect below doesn't need the
58
+ // advertisement, only this lookup does.
59
+ const pid = info?.deviceId ?? config.pid;
56
60
  const metadata = config.modelOverride
57
- ? { pid: info?.deviceId ?? 0, ...config.modelOverride }
58
- : info
59
- ? this.metadataForPid(info.deviceId)
61
+ ? { pid: pid ?? 0, ...config.modelOverride }
62
+ : pid !== undefined
63
+ ? this.metadataForPid(pid)
60
64
  : undefined;
61
65
  if (!metadata) {
62
- throw new Error(info === undefined
66
+ throw new Error(pid === undefined
63
67
  ? "gicisky device isn't advertising - rescanned but got nothing back (out of range, asleep, or already connected " +
64
68
  "elsewhere) - pass --width/--height/--voffset/--colours to describe it manually"
65
- : `gicisky device reports unrecognised deviceId 0x${info.deviceId.toString(16).padStart(4, "0")} - ` +
69
+ : `gicisky device reports unrecognised deviceId 0x${pid.toString(16).padStart(4, "0")} - ` +
66
70
  "pass --width/--height/--voffset/--colours to describe it manually");
67
71
  }
68
- const layout = (info && layout_1.GICISKY_PID_LAYOUT[info.deviceId]) || (0, layout_1.defaultLayoutFor)(metadata.colours);
72
+ const layout = (pid !== undefined && layout_1.GICISKY_PID_LAYOUT[pid]) || (0, layout_1.defaultLayoutFor)(metadata.colours);
69
73
  const framed = (0, reframe_1.reframeBitmap)(bitmap, metadata.width, metadata.height, config.reframe ?? "crop");
70
74
  const payload = (0, encode_1.encodeBitmap)(framed, metadata, layout);
71
75
  const conn = await backend.connectGatt(config.address, config.connectTimeoutMs ?? DEFAULT_PAINT_CONNECT_TIMEOUT_MS);
@@ -89,6 +93,12 @@ class GiciskyDriver {
89
93
  const chunk = payload.subarray(part * chunkSize, Math.min(part * chunkSize + chunkSize, payload.length));
90
94
  const ackData = await writeAndAwaitAck(conn, imgServiceUuid, imgUuid, (0, protocol_1.imageChunkPacket)(part, chunk), ack);
91
95
  const decoded = (0, protocol_1.decodeTransferAck)(ackData);
96
+ if (decoded && !decoded.ok && part * chunkSize + chunk.length >= payload.length) {
97
+ // The device answers the final chunk with a non-zero status (seen: `05 08 00000000`) once
98
+ // it has the whole image and starts refreshing - the transfer's done, not failed. Matches
99
+ // hass-gicisky's writer, which ends the transfer on any non-zero status rather than erroring.
100
+ break;
101
+ }
92
102
  if (!decoded?.ok) {
93
103
  throw new Error(`gicisky device reported an error transferring image part ${part}: ${ackData.toString("hex")}`);
94
104
  }
@@ -53,6 +53,13 @@ export type DeviceModelOverride = Omit<DeviceMetadata, "pid">;
53
53
  /** Per-device settings the user supplies when registering a device, beyond what's in DeviceMetadata. */
54
54
  export interface VendorDeviceConfig {
55
55
  address: string;
56
+ /**
57
+ * The PID already known for this device (from the configured `"<vendor>:<pid>@<address>"` or a
58
+ * previous scan) - a fallback for drivers that otherwise only learn it from a fresh advertisement
59
+ * (gicisky), so a device that's quiet at paint time can still be painted. A live advertised PID
60
+ * takes precedence when there is one.
61
+ */
62
+ pid?: number;
56
63
  /** AES key for vendors that need it, entered by the user. If omitted, vendors that have one may fall back to a stock/manufacturer-default key instead of failing. */
57
64
  aesKey?: string;
58
65
  /** Forces the device model facts instead of looking up the advertised PID - for hardware not yet in the driver's table. */
@@ -23,6 +23,8 @@ const pluginVersion_1 = require("./pluginVersion");
23
23
  const httpJson_1 = require("./httpJson");
24
24
  const INTERVAL_POLL_MS = 60000;
25
25
  const SUBSCRIPTION_DEBOUNCE_MS = 2000;
26
+ /** Pause between paint attempts - see `withRetries`' `delayMs`. */
27
+ const PAINT_RETRY_DELAY_MS = 5000;
26
28
  const RESOURCES_API_PATH = "/signalk/v2/api/resources";
27
29
  /**
28
30
  * The most recent wall-clock instant at or before `now` that an `interval`-triggered device's own
@@ -346,13 +348,22 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
346
348
  }
347
349
  const connectTimeoutMs = config.paintConnectTimeoutSeconds * 1000;
348
350
  const gattBackend = config.useBleApi && app.bleApi ? (0, bleBackend_1.bleApiBackend)(app.bleApi, pluginVersion_1.PLUGIN_NAME) : undefined;
349
- const paintWithRetries = () => (0, bleDiscovery_1.withRetries)(config.paintRetries, async (attempt) => {
350
- if (attempt > 1) {
351
- app.debug(`${label}: attempting paint ${attempt}/${config.paintRetries}`);
352
- }
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}`);
353
354
  const startedAt = Date.now();
354
- await driver.paint(bitmap, { address, aesKey: device.aesKey, connectTimeoutMs, reframe: device.reframe, gattBackend });
355
+ await driver.paint(bitmap, {
356
+ address,
357
+ pid: target.pid,
358
+ aesKey: device.aesKey,
359
+ connectTimeoutMs,
360
+ reframe: device.reframe,
361
+ gattBackend,
362
+ });
355
363
  paintDurationMs = Date.now() - startedAt;
364
+ }, {
365
+ delayMs: PAINT_RETRY_DELAY_MS,
366
+ onError: (err, attempt) => app.debug(`${label}: paint attempt ${attempt}/${attempts} failed: ${err.message}`),
356
367
  });
357
368
  await (gattBackend ? (0, bleBackend_1.exclusiveBleManagerAccess)(paintWithRetries) : paintWithRetries());
358
369
  (0, discoveredDevicesStore_1.touchDiscoveredDevice)(app, { address, vendor: target.vendor, pid: target.pid, hwVersion: target.hwVersion, metadata });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhizomatics/signalk-einklabel-plugin",
3
- "version": "1.3.0-beta6",
3
+ "version": "1.3.0-beta8",
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",
@@ -75,7 +75,7 @@
75
75
  "typescript": "^5.5.0"
76
76
  },
77
77
  "engines": {
78
- "node": ">=18"
78
+ "node": ">=22"
79
79
  },
80
80
  "signalk": {
81
81
  "displayName": "eInk Label Displays",