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

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
  }
@@ -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,18 +240,35 @@ 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(() => { });
227
247
  }
228
248
  throw new Error(`connecting to device timed out after ${timeoutMs}ms`);
229
249
  }
230
- 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 () => {
231
268
  // Belt-and-suspenders, matching the timeout-cleanup above: `releaseGATTDevice` is the
232
269
  // authoritative claim release, `disconnect()` a secondary teardown of this specific handle -
233
270
  // do both regardless of which (if either) itself hangs or rejects.
234
- await Promise.allSettled([bleApi.releaseGATTDevice(address, pluginId), conn.disconnect()]);
271
+ await boundedCleanup(bleApi.releaseGATTDevice(address, pluginId), conn.disconnect());
235
272
  });
236
273
  },
237
274
  async waitForManufacturerData(address, manufacturerId, timeoutMs) {
@@ -246,7 +283,7 @@ function bleApiBackend(bleApi, pluginId) {
246
283
  // this wait forever, since nothing else in this path ever calls `connectGatt` (and so never gets a
247
284
  // chance to release the stale claim) unless a fresh advertisement shows up first. A no-op if we
248
285
  // don't currently hold the claim.
249
- await bleApi.releaseGATTDevice(address, pluginId).catch(() => { });
286
+ await boundedCleanup(bleApi.releaseGATTDevice(address, pluginId));
250
287
  return new Promise((resolve) => {
251
288
  const timer = setTimeout(() => {
252
289
  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,17 @@ 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
+ * Send the image without waiting for each write to be acknowledged (zhsunyco; ignored otherwise).
88
+ * Works around SignalK's BLE Manager (2.33 and earlier) sending every acknowledged write as a
89
+ * "reliable" write, which zhsunyco labels reject with ATT error 0x0e. Defaults to `false`.
90
+ */
91
+ writeWithoutResponse?: boolean;
92
+ /**
93
+ * Receives one line per paint step (connecting, connected, uploading, ...), so a paint that stalls
94
+ * shows in the log where it stopped. Omitted means no step logging.
95
+ */
96
+ log?: (message: string) => void;
86
97
  /**
87
98
  * How `paint()` reaches the device's BLE hardware - omitted (always true for the CLI, which has no
88
99
  * `ServerAPI`/`app.bleApi` to source one from) means direct BlueZ access via a fresh
@@ -13,6 +13,8 @@ const protocol_1 = require("./protocol");
13
13
  /** node-ble has no MTU API; this matches the reference driver's mtu(247)-9 default. */
14
14
  const UPLOAD_CHUNK_SIZE = 238;
15
15
  const CHUNK_WRITE_DELAY_MS = 20;
16
+ /** With nothing acknowledging each write, pace them further apart so the label isn't sent data faster than it can take it. */
17
+ const UNACKNOWLEDGED_CHUNK_WRITE_DELAY_MS = 50;
16
18
  const AUTH_SETTLE_DELAY_MS = 500;
17
19
  const STATUS_WAIT_TIMEOUT_MS = 60000;
18
20
  /** Bounds `readDeviceDetails`' whole connect+read attempt during a scan - see its doc comment. */
@@ -50,10 +52,13 @@ class ZhsunycoDriver {
50
52
  }
51
53
  async paint(bitmap, config) {
52
54
  const aesKey = (0, protocol_1.resolveAesKey)(config.aesKey);
55
+ const log = config.log ?? (() => { });
53
56
  const backend = config.gattBackend ?? (0, bleBackend_1.nodeBleBackend)();
57
+ log("connecting");
54
58
  const conn = await backend.connectGatt(config.address, config.connectTimeoutMs ?? DEFAULT_PAINT_CONNECT_TIMEOUT_MS);
59
+ log("connected");
55
60
  try {
56
- const info = (0, protocol_1.decodeAdvertisedInfo)(await conn.read(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.config));
61
+ const info = (0, protocol_1.decodeAdvertisedInfo)(await readConfig(conn, log));
57
62
  if (!info) {
58
63
  throw new Error("zhsunyco device did not return valid config data");
59
64
  }
@@ -79,27 +84,35 @@ class ZhsunycoDriver {
79
84
  const challenge = await conn.read(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.authenticate);
80
85
  await conn.write(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.authenticate, (0, protocol_1.authResponse)(challenge, aesKey), false);
81
86
  await (0, bleDiscovery_1.sleep)(AUTH_SETTLE_DELAY_MS);
87
+ log("authenticated");
82
88
  const framed = (0, mirror_1.mirrorBitmap)((0, reframe_1.reframeBitmap)(bitmap, metadata.width, metadata.height - metadata.voffset, config.reframe ?? "crop"), config.mirror ?? "none");
83
89
  const pixelData = (0, encode_1.encodeBitmap)(framed, metadata);
84
90
  const compress = config.compress ?? true;
85
91
  // Upload offsets and the refresh length both count bytes of whatever's actually sent - the
86
92
  // compressed payload when compressing, not the raw buffer it inflates back to.
87
93
  const payload = compress ? (0, compression_1.compressWolinkBlocks)(pixelData) : pixelData;
94
+ const acknowledged = !config.writeWithoutResponse;
95
+ log(`uploading ${payload.length} bytes${compress ? ` (compressed from ${pixelData.length})` : ""} in ` +
96
+ `${Math.ceil(payload.length / UPLOAD_CHUNK_SIZE)} ${acknowledged ? "acknowledged" : "unacknowledged"} writes`);
88
97
  for (let offset = 0; offset < payload.length; offset += UPLOAD_CHUNK_SIZE) {
89
98
  const chunk = payload.subarray(offset, offset + UPLOAD_CHUNK_SIZE);
90
- 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
- await (0, bleDiscovery_1.sleep)(CHUNK_WRITE_DELAY_MS);
99
+ 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]), acknowledged);
100
+ await (0, bleDiscovery_1.sleep)(acknowledged ? CHUNK_WRITE_DELAY_MS : UNACKNOWLEDGED_CHUNK_WRITE_DELAY_MS);
92
101
  }
93
- 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);
102
+ 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), acknowledged);
103
+ log("refresh sent, waiting for the label to finish");
94
104
  // `Promise.race` can't cancel its loser, so once `statusReceived` settles (the common case)
95
105
  // this timer would otherwise sit alive for the rest of its 60s regardless - see the identical
96
106
  // reasoning on `readDeviceDetails`'s own race, below.
97
107
  let statusTimer;
98
108
  const statusTimeout = new Promise((resolve) => {
99
- statusTimer = setTimeout(resolve, STATUS_WAIT_TIMEOUT_MS);
109
+ statusTimer = setTimeout(() => resolve("timeout"), STATUS_WAIT_TIMEOUT_MS);
100
110
  });
101
111
  try {
102
- await Promise.race([statusReceived, statusTimeout]);
112
+ const outcome = await Promise.race([statusReceived, statusTimeout]);
113
+ log(outcome === "timeout"
114
+ ? `no reply from the label within ${STATUS_WAIT_TIMEOUT_MS}ms - assuming it painted`
115
+ : "label reported the paint complete");
103
116
  }
104
117
  finally {
105
118
  clearTimeout(statusTimer);
@@ -115,6 +128,22 @@ class ZhsunycoDriver {
115
128
  }
116
129
  }
117
130
  exports.ZhsunycoDriver = ZhsunycoDriver;
131
+ /**
132
+ * The first read of a paint. If the label's service isn't there at all (seen as node-ble's "Service not
133
+ * available" behind the BLE Manager), logs which services the connection does report - the difference
134
+ * between BlueZ not having discovered the label's services yet and it seeing a different set entirely.
135
+ */
136
+ async function readConfig(conn, log) {
137
+ try {
138
+ return await conn.read(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.config);
139
+ }
140
+ catch (err) {
141
+ const services = await conn.discoverServices().catch((discoverErr) => `could not list them: ${discoverErr.message}`);
142
+ const listed = Array.isArray(services) ? services.map((service) => service.uuid).join(", ") || "none" : services;
143
+ log(`reading the label's config failed (${err.message}) - services visible on this connection: ${listed}`);
144
+ throw err;
145
+ }
146
+ }
118
147
  /**
119
148
  * Battery level needs a connection regardless, so reuse it to also fill in the PID/hwVersion
120
149
  * when the advertisement didn't carry decodable manufacturer data - a scan matched purely by name
@@ -0,0 +1,18 @@
1
+ /** The reference block for one bundled template name, e.g. `tides` (a family) or `tide.svg`. */
2
+ export declare function templateReference(templateName: string, templatesDir?: string): string;
3
+ /** Every bundled template name a user can pick - plain `.svg` files and family directories, not dot-prefixed internals. */
4
+ export declare function bundledTemplateNames(templatesDir?: string): string[];
5
+ /** A one-row-per-template summary, linking each to the example page whose reference block covers it. */
6
+ export declare function templatesSummary(pagesByTemplate: Map<string, string>, templatesDir?: string): string;
7
+ /**
8
+ * Regenerates every marked block under `examplesDir`, returning each file's current and regenerated
9
+ * content. `templates-summary` blocks link to whichever page holds a template's `template-reference`
10
+ * block, so all pages are scanned for those first.
11
+ */
12
+ export declare function regenerateExampleDocs(examplesDir: string, templatesDir?: string): {
13
+ path: string;
14
+ current: string;
15
+ updated: string;
16
+ stale: boolean;
17
+ }[];
18
+ export declare const EXAMPLES_DOCS_DIR: string;
@@ -0,0 +1,207 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EXAMPLES_DOCS_DIR = void 0;
4
+ exports.templateReference = templateReference;
5
+ exports.bundledTemplateNames = bundledTemplateNames;
6
+ exports.templatesSummary = templatesSummary;
7
+ exports.regenerateExampleDocs = regenerateExampleDocs;
8
+ const fs_1 = require("fs");
9
+ const path_1 = require("path");
10
+ const binding_1 = require("../render/binding");
11
+ const config_1 = require("../config");
12
+ const zhsunyco_1 = require("../devices/zhsunyco");
13
+ const gicisky_1 = require("../devices/gicisky");
14
+ /**
15
+ * Generates the "Template reference" blocks in the docs' example pages from the bundled templates
16
+ * themselves - sizes, aspect ratios, colours, which supported labels each size fits, and the data
17
+ * fields each one reads - so that reference can't drift from the templates it describes. Blocks sit
18
+ * between `<!-- BEGIN GENERATED: <kind> [<template>] -->` and `<!-- END GENERATED -->` markers in
19
+ * hand-written pages; everything outside them is left alone. Run `npm run docs:templates` to update,
20
+ * and `templateReference.test.ts` fails when a block is stale.
21
+ */
22
+ const TEMPLATE_SOURCE_URL = "https://github.com/rhizomatics/signalk-einklabel-plugin/blob/main/templates";
23
+ const MARKER = /<!-- BEGIN GENERATED: ([\w-]+)(?: ([^\s>]+))? -->[\s\S]*?<!-- END GENERATED -->/g;
24
+ const COLOUR_LETTER = { black: "B", white: "W", red: "R", yellow: "Y" };
25
+ function supportedLabels() {
26
+ return [new zhsunyco_1.ZhsunycoDriver(), new gicisky_1.GiciskyDriver()].flatMap((driver) => driver.supportedDevices());
27
+ }
28
+ /** Every file a bundled template name covers - one for a plain `.svg`, one per variant for a family directory. */
29
+ function templateFiles(templatesDir, templateName) {
30
+ const path = (0, path_1.join)(templatesDir, templateName);
31
+ if (!(0, fs_1.existsSync)(path)) {
32
+ throw new Error(`template "${templateName}" not found in ${templatesDir}`);
33
+ }
34
+ const read = (relativePath, colours) => {
35
+ const source = (0, fs_1.readFileSync)((0, path_1.join)(templatesDir, relativePath), "utf-8");
36
+ return { path: relativePath, ...(0, binding_1.readTemplateDimensions)(source), colours, bindings: (0, binding_1.findBindings)(source) };
37
+ };
38
+ if ((0, fs_1.statSync)(path).isDirectory()) {
39
+ return (0, config_1.listTemplateVariants)(path)
40
+ .sort((a, b) => b.width - a.width || b.height - a.height)
41
+ .map((variant) => read(`${templateName}/${variant.fileName}`, variant.colours));
42
+ }
43
+ return [read(templateName)];
44
+ }
45
+ function variantName(file) {
46
+ return file.path
47
+ .split("/")
48
+ .pop()
49
+ .replace(/\.svg$/, "");
50
+ }
51
+ function aspectRatio(width, height) {
52
+ return `${(width / height).toFixed(2)} : 1`;
53
+ }
54
+ function matchingLabels(file, labels) {
55
+ const matches = labels
56
+ .filter((label) => label.width === file.width && label.height - label.voffset === file.height)
57
+ .map((label) => {
58
+ const colours = label.colours.map((colour) => COLOUR_LETTER[colour]).join("");
59
+ // Some models' own label already names their colours (e.g. Gicisky's `2.9" BWR`).
60
+ const name = label.label.includes(colours) ? label.label : `${label.label} ${colours}`;
61
+ return `${label.manufacturer ?? ""} ${name}`.trim();
62
+ });
63
+ return matches.length > 0 ? [...new Set(matches)].join(", ") : "none exactly - see [Reframing](../templates.md#reframing)";
64
+ }
65
+ function describeSource(binding) {
66
+ switch (binding.source) {
67
+ case "signalk":
68
+ return binding.context === "self" ? "Signal K path" : `Signal K path (${binding.context})`;
69
+ case "resources":
70
+ return `\`${binding.resource}\` resource${binding.provider ? ` (provider \`${binding.provider}\`)` : ""}`;
71
+ case "einklabel":
72
+ return "Plugin";
73
+ case "label":
74
+ return "Label details";
75
+ }
76
+ }
77
+ function describeOptions(binding) {
78
+ return [
79
+ binding.assets && `image from \`${binding.assets}\``,
80
+ binding.format && `format \`${binding.format}\``,
81
+ binding.category && `category \`${binding.category}\``,
82
+ binding.round !== undefined && `round ${binding.round}`,
83
+ binding.default !== undefined && `default \`${binding.default}\``,
84
+ ].filter((option) => Boolean(option));
85
+ }
86
+ /** A table row per distinct field - array indexes collapsed, so `extremes[0].time` and `extremes[2].time` are one row. */
87
+ function fieldsTable(files) {
88
+ const rows = new Map();
89
+ for (const file of files) {
90
+ for (const binding of file.bindings) {
91
+ const source = describeSource(binding);
92
+ const path = binding.path.replace(/\[\d+\]/g, "[n]");
93
+ const key = `${source}\u0000${path}`;
94
+ const row = rows.get(key) ?? { source, path, options: new Set(), usedIn: new Set() };
95
+ describeOptions(binding).forEach((option) => row.options.add(option));
96
+ row.usedIn.add(variantName(file));
97
+ rows.set(key, row);
98
+ }
99
+ }
100
+ const showUsedIn = files.length > 1;
101
+ const header = showUsedIn ? "| Source | Path | Options | Used in |\n|---|---|---|---|" : "| Source | Path | Options |\n|---|---|---|";
102
+ const sorted = [...rows.values()].sort((a, b) => a.source.localeCompare(b.source) || a.path.localeCompare(b.path));
103
+ const lines = sorted.map((row) => {
104
+ const usedIn = row.usedIn.size === files.length ? "all" : [...row.usedIn].join(", ");
105
+ const cells = [row.source, `\`${row.path}\``, [...row.options].join(", ") || "-", ...(showUsedIn ? [usedIn] : [])];
106
+ return `| ${cells.join(" | ")} |`;
107
+ });
108
+ return [header, ...lines].join("\n");
109
+ }
110
+ /** The reference block for one bundled template name, e.g. `tides` (a family) or `tide.svg`. */
111
+ function templateReference(templateName, templatesDir = config_1.BUNDLED_TEMPLATES_DIR) {
112
+ const files = templateFiles(templatesDir, templateName);
113
+ const labels = supportedLabels();
114
+ const sizes = files.map((file) => {
115
+ const size = file.width && file.height ? `${file.width} × ${file.height}` : "-";
116
+ const ratio = file.width && file.height ? aspectRatio(file.width, file.height) : "-";
117
+ const colours = file.colours?.join(", ") ?? "-";
118
+ return `| [\`${file.path}\`](${TEMPLATE_SOURCE_URL}/${file.path}) | ${size} | ${ratio} | ${colours} | ${matchingLabels(file, labels)} |`;
119
+ });
120
+ return [
121
+ `**Template:** \`${templateName}\``,
122
+ "",
123
+ "| File | Size (px) | Aspect ratio | Colours | Fits these labels exactly |",
124
+ "|---|---|---|---|---|",
125
+ ...sizes,
126
+ "",
127
+ "**Data used**",
128
+ "",
129
+ fieldsTable(files),
130
+ ].join("\n");
131
+ }
132
+ /** Every bundled template name a user can pick - plain `.svg` files and family directories, not dot-prefixed internals. */
133
+ function bundledTemplateNames(templatesDir = config_1.BUNDLED_TEMPLATES_DIR) {
134
+ return [...(0, config_1.listTemplateFamilies)(templatesDir), ...(0, config_1.listSvgFiles)(templatesDir)].sort();
135
+ }
136
+ /** A one-row-per-template summary, linking each to the example page whose reference block covers it. */
137
+ function templatesSummary(pagesByTemplate, templatesDir = config_1.BUNDLED_TEMPLATES_DIR) {
138
+ const rows = bundledTemplateNames(templatesDir).map((name) => {
139
+ const files = templateFiles(templatesDir, name);
140
+ const sizes = files.map((file) => (file.width && file.height ? `${file.width}×${file.height}` : "?")).join(", ");
141
+ // Only what has to come from outside the plugin - its own and the label's details are always there.
142
+ const external = files
143
+ .flatMap((file) => file.bindings)
144
+ .filter((binding) => binding.source === "signalk" || binding.source === "resources");
145
+ const resources = external.filter((binding) => binding.source === "resources").map(describeSource);
146
+ const paths = external.filter((binding) => binding.source === "signalk").map((binding) => `\`${binding.path}\``);
147
+ const sources = [...new Set(resources), ...(paths.length > 0 ? [`Signal K ${[...new Set(paths)].sort().join(", ")}`] : [])];
148
+ const page = pagesByTemplate.get(name);
149
+ return `| ${page ? `[\`${name}\`](${page})` : `\`${name}\``} | ${sizes} | ${sources.join(", ") || "-"} |`;
150
+ });
151
+ return ["| Template | Sizes | Data needed |", "|---|---|---|", ...rows].join("\n");
152
+ }
153
+ function markdownFiles(dir) {
154
+ return (0, fs_1.readdirSync)(dir, { withFileTypes: true }).flatMap((entry) => {
155
+ const path = (0, path_1.join)(dir, entry.name);
156
+ if (entry.isDirectory())
157
+ return markdownFiles(path);
158
+ return entry.name.endsWith(".md") ? [path] : [];
159
+ });
160
+ }
161
+ /**
162
+ * Regenerates every marked block under `examplesDir`, returning each file's current and regenerated
163
+ * content. `templates-summary` blocks link to whichever page holds a template's `template-reference`
164
+ * block, so all pages are scanned for those first.
165
+ */
166
+ function regenerateExampleDocs(examplesDir, templatesDir = config_1.BUNDLED_TEMPLATES_DIR) {
167
+ const pages = markdownFiles(examplesDir).map((path) => ({ path, current: (0, fs_1.readFileSync)(path, "utf-8") }));
168
+ const pagesByTemplate = new Map();
169
+ for (const page of pages) {
170
+ for (const match of page.current.matchAll(MARKER)) {
171
+ if (match[1] === "template-reference" && match[2]) {
172
+ pagesByTemplate.set(match[2], page.path.slice(examplesDir.length + 1));
173
+ }
174
+ }
175
+ }
176
+ return pages.map((page) => {
177
+ const updated = page.current.replace(MARKER, (_block, kind, arg) => {
178
+ const body = kind === "template-reference" && arg
179
+ ? templateReference(arg, templatesDir)
180
+ : kind === "templates-summary"
181
+ ? templatesSummary(pagesByTemplate, templatesDir)
182
+ : undefined;
183
+ if (body === undefined)
184
+ throw new Error(`${page.path}: unknown generated block "${kind}${arg ? ` ${arg}` : ""}"`);
185
+ return `<!-- BEGIN GENERATED: ${kind}${arg ? ` ${arg}` : ""} -->\n<!-- Generated from the bundled templates by \`npm run docs:templates\` - do not edit by hand. -->\n\n${body}\n\n<!-- END GENERATED -->`;
186
+ });
187
+ return { ...page, updated, stale: ignoringTableAlignment(updated) !== ignoringTableAlignment(page.current) };
188
+ });
189
+ }
190
+ /**
191
+ * The formatter (`oxfmt`, run by `npm run docs:templates` after this) pads Markdown table columns to
192
+ * line up, which this generator doesn't - so compare with that padding stripped, or a freshly
193
+ * formatted page would always look stale.
194
+ */
195
+ function ignoringTableAlignment(markdown) {
196
+ return markdown
197
+ .split("\n")
198
+ .map((line) => (line.startsWith("|") ? line.replace(/\s*\|\s*/g, "|").replace(/-{3,}/g, "---") : line))
199
+ .join("\n");
200
+ }
201
+ exports.EXAMPLES_DOCS_DIR = (0, path_1.join)(__dirname, "..", "..", "docs", "examples");
202
+ if (require.main === module) {
203
+ const changed = regenerateExampleDocs(exports.EXAMPLES_DOCS_DIR).filter((page) => page.stale);
204
+ for (const page of changed)
205
+ (0, fs_1.writeFileSync)(page.path, page.updated);
206
+ console.log(changed.length > 0 ? `updated ${changed.map((page) => page.path).join(", ")}` : "template docs already up to date");
207
+ }
package/dist/plugin.js CHANGED
@@ -56,10 +56,10 @@ function createPlugin(app) {
56
56
  start(config) {
57
57
  const pluginConfig = {
58
58
  ...(0, config_1.defaultConfig)(),
59
- ...config,
59
+ ...(0, config_1.migrateConfig)(config).config,
60
60
  };
61
61
  app.debug(`starting with ${pluginConfig.devices.length} configured device(s)`);
62
- (0, config_1.healNestedConfig)(app);
62
+ (0, config_1.healStoredConfig)(app);
63
63
  stopped = false;
64
64
  const useBleApi = pluginConfig.useBleApi && bleApiAvailable;
65
65
  if (pluginConfig.useBleApi && !bleApiAvailable) {
@@ -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;