@rhizomatics/signalk-einklabel-plugin 1.3.0-beta12 → 1.3.0-beta14
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/README.md +14 -556
- package/dist/cli/index.js +1 -0
- package/dist/config.d.ts +16 -0
- package/dist/config.js +13 -10
- package/dist/devices/bleBackend.js +16 -5
- package/dist/devices/bleDiscovery.d.ts +5 -0
- package/dist/devices/bleDiscovery.js +18 -0
- package/dist/devices/gicisky/index.js +5 -0
- package/dist/devices/types.d.ts +5 -0
- package/dist/devices/zhsunyco/index.js +12 -2
- package/dist/docs/templateReference.d.ts +18 -0
- package/dist/docs/templateReference.js +207 -0
- package/dist/repaintScheduler.js +10 -1
- package/docs/bluetooth.md +127 -0
- package/docs/cli.md +131 -0
- package/docs/examples/README.md +25 -0
- package/docs/examples/tide-clock.md +93 -0
- package/docs/examples/watch-schedule.md +37 -0
- package/docs/extending.md +33 -0
- package/docs/faq.md +40 -0
- package/docs/getting-started.md +111 -0
- package/docs/templates.md +142 -0
- package/package.json +5 -2
package/dist/config.js
CHANGED
|
@@ -7,6 +7,9 @@ exports.readCurrentConfig = readCurrentConfig;
|
|
|
7
7
|
exports.healStoredConfig = healStoredConfig;
|
|
8
8
|
exports.resolveTemplatesDir = resolveTemplatesDir;
|
|
9
9
|
exports.parseDevice = parseDevice;
|
|
10
|
+
exports.listSvgFiles = listSvgFiles;
|
|
11
|
+
exports.listTemplateVariants = listTemplateVariants;
|
|
12
|
+
exports.listTemplateFamilies = listTemplateFamilies;
|
|
10
13
|
exports.resolveTemplatePath = resolveTemplatePath;
|
|
11
14
|
exports.configSchema = configSchema;
|
|
12
15
|
exports.configUiSchema = configUiSchema;
|
|
@@ -362,9 +365,9 @@ function withEnum(schema, values, names) {
|
|
|
362
365
|
}
|
|
363
366
|
/** The plugin's documentation site - its sections' anchors are the README's own headings (see `site/scripts/sync-readme.mjs`). */
|
|
364
367
|
const DOCS_URL = "https://signalk-einklabel.rhizomatics.org.uk/";
|
|
365
|
-
/** A Markdown link to a docs section, for a field description rendered with `ui:enableMarkdownInDescription` (see `configUiSchema`). */
|
|
366
|
-
function docsLink(
|
|
367
|
-
return `[${text}](${DOCS_URL}
|
|
368
|
+
/** A Markdown link to a docs page (and optional section, e.g. `templates/#reframing`), for a field description rendered with `ui:enableMarkdownInDescription` (see `configUiSchema`). */
|
|
369
|
+
function docsLink(page, text = "More in the docs") {
|
|
370
|
+
return `[${text}](${DOCS_URL}${page})`;
|
|
368
371
|
}
|
|
369
372
|
/**
|
|
370
373
|
* An enum-like string field whose options show explanatory labels rather than their raw stored values
|
|
@@ -481,12 +484,12 @@ function configSchema(app, discovered = []) {
|
|
|
481
484
|
title: "Location/description (optional)",
|
|
482
485
|
description: 'Free-text notes about where this label is physically mounted/viewed from, e.g. "chart table, viewed from ~1m ' +
|
|
483
486
|
'in poor light" - available to any template as `source=label,path=description`. ' +
|
|
484
|
-
docsLink("label-details"),
|
|
487
|
+
docsLink("templates/#label-details"),
|
|
485
488
|
},
|
|
486
489
|
templateName: withEnum({
|
|
487
490
|
type: "string",
|
|
488
491
|
title: "Template",
|
|
489
|
-
description: `A bundled template, or one from your templates directory. ${docsLink("
|
|
492
|
+
description: `A bundled template, or one from your templates directory. ${docsLink("examples/")}`,
|
|
490
493
|
}, templateNameOptions(resolveTemplatesDir(current.templatesDir))),
|
|
491
494
|
repaintTrigger: choiceField("Repaint trigger", [
|
|
492
495
|
["subscription", "When a SignalK path changes"],
|
|
@@ -501,11 +504,11 @@ function configSchema(app, discovered = []) {
|
|
|
501
504
|
["crop", "Crop - place at the top-left, cutting off anything too big or leaving the rest blank"],
|
|
502
505
|
["scale", "Scale - stretch to fit exactly (may distort)"],
|
|
503
506
|
["fixed", "Fixed - fail the repaint rather than show an off-size image"],
|
|
504
|
-
], { description: docsLink("reframing"), default: "crop" }),
|
|
507
|
+
], { description: docsLink("templates/#reframing"), default: "crop" }),
|
|
505
508
|
compress: {
|
|
506
509
|
type: "boolean",
|
|
507
510
|
title: 'Compress upload (Zhsunyco, Gicisky 7.5"/10.2")',
|
|
508
|
-
description: `Sends far less data over BLE, so repaints are quicker. Turn off if a label stops updating. ${docsLink("other-image-options")}`,
|
|
511
|
+
description: `Sends far less data over BLE, so repaints are quicker. Turn off if a label stops updating. ${docsLink("templates/#other-image-options")}`,
|
|
509
512
|
default: true,
|
|
510
513
|
},
|
|
511
514
|
mirror: choiceField("Mirror", [
|
|
@@ -514,7 +517,7 @@ function configSchema(app, discovered = []) {
|
|
|
514
517
|
["vertical", "Flip top to bottom"],
|
|
515
518
|
["both", "Rotate 180° - for a label mounted upside down"],
|
|
516
519
|
], {
|
|
517
|
-
description: `Only needed if the image shows up mirrored or upside down on the label. ${docsLink("other-image-options")}`,
|
|
520
|
+
description: `Only needed if the image shows up mirrored or upside down on the label. ${docsLink("templates/#other-image-options")}`,
|
|
518
521
|
default: "none",
|
|
519
522
|
}),
|
|
520
523
|
compressionFormat: choiceField("Wire format (Gicisky, experimental)", [
|
|
@@ -524,7 +527,7 @@ function configSchema(app, discovered = []) {
|
|
|
524
527
|
'Chunked - send compressed like the 7.5"/10.2" panels, e.g. to speed up a 4.2" BWR (untested on current firmware)',
|
|
525
528
|
],
|
|
526
529
|
], {
|
|
527
|
-
description: `Chunked needs Compress upload on. Switch back to Auto if the label stops updating. ${docsLink("other-image-options")}`,
|
|
530
|
+
description: `Chunked needs Compress upload on. Switch back to Auto if the label stops updating. ${docsLink("templates/#other-image-options")}`,
|
|
528
531
|
default: "auto",
|
|
529
532
|
}),
|
|
530
533
|
forceRepaint: {
|
|
@@ -616,7 +619,7 @@ function configUiSchema() {
|
|
|
616
619
|
"advanced",
|
|
617
620
|
],
|
|
618
621
|
friendlyName: { "ui:placeholder": "e.g. Tide clock" },
|
|
619
|
-
description: { "ui:widget": "textarea", "ui:placeholder": "e.g.
|
|
622
|
+
description: { "ui:widget": "textarea", "ui:placeholder": "e.g. at companionway", ...MARKDOWN },
|
|
620
623
|
templateName: MARKDOWN,
|
|
621
624
|
repaintTrigger: { "ui:widget": "radio" },
|
|
622
625
|
triggerPath: { "ui:placeholder": "e.g. environment.tide.state" },
|
|
@@ -168,6 +168,17 @@ 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
|
+
/**
|
|
172
|
+
* Upper bound on each BLE Manager release/disconnect call this plugin waits on. Neither has a timeout
|
|
173
|
+
* of its own, and a release has to disconnect the device, which can wait on a Bluetooth link that's
|
|
174
|
+
* already gone - left unbounded, one stuck release wedges every later paint behind it (see
|
|
175
|
+
* `exclusiveBleManagerAccess`).
|
|
176
|
+
*/
|
|
177
|
+
const CLEANUP_TIMEOUT_MS = 10000;
|
|
178
|
+
/** Waits for a cleanup call for at most `CLEANUP_TIMEOUT_MS`, ignoring its outcome - cleanup is best-effort. */
|
|
179
|
+
async function boundedCleanup(...calls) {
|
|
180
|
+
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`));
|
|
181
|
+
}
|
|
171
182
|
/** Upper bound on how long a timed-out `bleApi.connectGATT()` is given to settle server-side before a retry - see `bleApiBackend`. */
|
|
172
183
|
const CONNECT_SETTLE_GRACE_MS = 30000;
|
|
173
184
|
let bleManagerQueue = Promise.resolve();
|
|
@@ -194,7 +205,7 @@ function bleApiBackend(bleApi, pluginId) {
|
|
|
194
205
|
// registered under our own pluginId, which would otherwise block this connect with
|
|
195
206
|
// "already claimed" until the server restarts - see signalk-bluetti-plugin's BleManagerDevice
|
|
196
207
|
// for the same defensive call. A no-op if we don't currently hold the claim.
|
|
197
|
-
await bleApi.releaseGATTDevice(address, pluginId)
|
|
208
|
+
await boundedCleanup(bleApi.releaseGATTDevice(address, pluginId));
|
|
198
209
|
await ensureDeviceVisible(bleApi, pluginId, address, DEVICE_DISCOVERY_TIMEOUT_MS);
|
|
199
210
|
const connecting = bleApi.connectGATT(address, pluginId);
|
|
200
211
|
let timedOut = false;
|
|
@@ -210,7 +221,7 @@ function bleApiBackend(bleApi, pluginId) {
|
|
|
210
221
|
// disconnect again. The release is awaited (only the late disconnect is left in the
|
|
211
222
|
// background) so a caller retrying straight away - `withRetries` - can't race it with a new
|
|
212
223
|
// `connectGATT()` and get rejected with "has a GATT claim in progress" for its trouble.
|
|
213
|
-
await bleApi.releaseGATTDevice(address, pluginId)
|
|
224
|
+
await boundedCleanup(bleApi.releaseGATTDevice(address, pluginId));
|
|
214
225
|
// ...except a claim still *connecting* isn't in the server's claim table yet, only its pending
|
|
215
226
|
// set, which `releaseGATTDevice` doesn't touch - so while the server's own connect is still in
|
|
216
227
|
// flight, a retry's `connectGATT()` is rejected outright with "has a GATT claim in progress".
|
|
@@ -220,7 +231,7 @@ function bleApiBackend(bleApi, pluginId) {
|
|
|
220
231
|
(0, bleDiscovery_1.sleep)(Math.min(timeoutMs, CONNECT_SETTLE_GRACE_MS)).then(() => undefined),
|
|
221
232
|
]);
|
|
222
233
|
if (late) {
|
|
223
|
-
await
|
|
234
|
+
await boundedCleanup(bleApi.releaseGATTDevice(address, pluginId), late.disconnect());
|
|
224
235
|
}
|
|
225
236
|
else {
|
|
226
237
|
void connecting.then((c) => c.disconnect()).catch(() => { });
|
|
@@ -231,7 +242,7 @@ function bleApiBackend(bleApi, pluginId) {
|
|
|
231
242
|
// Belt-and-suspenders, matching the timeout-cleanup above: `releaseGATTDevice` is the
|
|
232
243
|
// authoritative claim release, `disconnect()` a secondary teardown of this specific handle -
|
|
233
244
|
// do both regardless of which (if either) itself hangs or rejects.
|
|
234
|
-
await
|
|
245
|
+
await boundedCleanup(bleApi.releaseGATTDevice(address, pluginId), conn.disconnect());
|
|
235
246
|
});
|
|
236
247
|
},
|
|
237
248
|
async waitForManufacturerData(address, manufacturerId, timeoutMs) {
|
|
@@ -246,7 +257,7 @@ function bleApiBackend(bleApi, pluginId) {
|
|
|
246
257
|
// this wait forever, since nothing else in this path ever calls `connectGatt` (and so never gets a
|
|
247
258
|
// chance to release the stale claim) unless a fresh advertisement shows up first. A no-op if we
|
|
248
259
|
// don't currently hold the claim.
|
|
249
|
-
await bleApi.releaseGATTDevice(address, pluginId)
|
|
260
|
+
await boundedCleanup(bleApi.releaseGATTDevice(address, pluginId));
|
|
250
261
|
return new Promise((resolve) => {
|
|
251
262
|
const timer = setTimeout(() => {
|
|
252
263
|
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(() => { });
|
package/dist/devices/types.d.ts
CHANGED
|
@@ -83,6 +83,11 @@ 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
|
+
* Receives one line per paint step (connecting, connected, uploading, ...), so a paint that stalls
|
|
88
|
+
* shows in the log where it stopped. Omitted means no step logging.
|
|
89
|
+
*/
|
|
90
|
+
log?: (message: string) => void;
|
|
86
91
|
/**
|
|
87
92
|
* How `paint()` reaches the device's BLE hardware - omitted (always true for the CLI, which has no
|
|
88
93
|
* `ServerAPI`/`app.bleApi` to source one from) means direct BlueZ access via a fresh
|
|
@@ -50,8 +50,11 @@ class ZhsunycoDriver {
|
|
|
50
50
|
}
|
|
51
51
|
async paint(bitmap, config) {
|
|
52
52
|
const aesKey = (0, protocol_1.resolveAesKey)(config.aesKey);
|
|
53
|
+
const log = config.log ?? (() => { });
|
|
53
54
|
const backend = config.gattBackend ?? (0, bleBackend_1.nodeBleBackend)();
|
|
55
|
+
log("connecting");
|
|
54
56
|
const conn = await backend.connectGatt(config.address, config.connectTimeoutMs ?? DEFAULT_PAINT_CONNECT_TIMEOUT_MS);
|
|
57
|
+
log("connected");
|
|
55
58
|
try {
|
|
56
59
|
const info = (0, protocol_1.decodeAdvertisedInfo)(await conn.read(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.config));
|
|
57
60
|
if (!info) {
|
|
@@ -79,27 +82,34 @@ class ZhsunycoDriver {
|
|
|
79
82
|
const challenge = await conn.read(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.authenticate);
|
|
80
83
|
await conn.write(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.authenticate, (0, protocol_1.authResponse)(challenge, aesKey), false);
|
|
81
84
|
await (0, bleDiscovery_1.sleep)(AUTH_SETTLE_DELAY_MS);
|
|
85
|
+
log("authenticated");
|
|
82
86
|
const framed = (0, mirror_1.mirrorBitmap)((0, reframe_1.reframeBitmap)(bitmap, metadata.width, metadata.height - metadata.voffset, config.reframe ?? "crop"), config.mirror ?? "none");
|
|
83
87
|
const pixelData = (0, encode_1.encodeBitmap)(framed, metadata);
|
|
84
88
|
const compress = config.compress ?? true;
|
|
85
89
|
// Upload offsets and the refresh length both count bytes of whatever's actually sent - the
|
|
86
90
|
// compressed payload when compressing, not the raw buffer it inflates back to.
|
|
87
91
|
const payload = compress ? (0, compression_1.compressWolinkBlocks)(pixelData) : pixelData;
|
|
92
|
+
log(`uploading ${payload.length} bytes${compress ? ` (compressed from ${pixelData.length})` : ""} in ` +
|
|
93
|
+
`${Math.ceil(payload.length / UPLOAD_CHUNK_SIZE)} writes`);
|
|
88
94
|
for (let offset = 0; offset < payload.length; offset += UPLOAD_CHUNK_SIZE) {
|
|
89
95
|
const chunk = payload.subarray(offset, offset + UPLOAD_CHUNK_SIZE);
|
|
90
96
|
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
97
|
await (0, bleDiscovery_1.sleep)(CHUNK_WRITE_DELAY_MS);
|
|
92
98
|
}
|
|
93
99
|
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);
|
|
100
|
+
log("refresh sent, waiting for the label to finish");
|
|
94
101
|
// `Promise.race` can't cancel its loser, so once `statusReceived` settles (the common case)
|
|
95
102
|
// this timer would otherwise sit alive for the rest of its 60s regardless - see the identical
|
|
96
103
|
// reasoning on `readDeviceDetails`'s own race, below.
|
|
97
104
|
let statusTimer;
|
|
98
105
|
const statusTimeout = new Promise((resolve) => {
|
|
99
|
-
statusTimer = setTimeout(resolve, STATUS_WAIT_TIMEOUT_MS);
|
|
106
|
+
statusTimer = setTimeout(() => resolve("timeout"), STATUS_WAIT_TIMEOUT_MS);
|
|
100
107
|
});
|
|
101
108
|
try {
|
|
102
|
-
await Promise.race([statusReceived, statusTimeout]);
|
|
109
|
+
const outcome = await Promise.race([statusReceived, statusTimeout]);
|
|
110
|
+
log(outcome === "timeout"
|
|
111
|
+
? `no reply from the label within ${STATUS_WAIT_TIMEOUT_MS}ms - assuming it painted`
|
|
112
|
+
: "label reported the paint complete");
|
|
103
113
|
}
|
|
104
114
|
finally {
|
|
105
115
|
clearTimeout(statusTimer);
|
|
@@ -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/repaintScheduler.js
CHANGED
|
@@ -25,6 +25,13 @@ const INTERVAL_POLL_MS = 60000;
|
|
|
25
25
|
const SUBSCRIPTION_DEBOUNCE_MS = 2000;
|
|
26
26
|
/** Pause between paint attempts - see `withRetries`' `delayMs`. */
|
|
27
27
|
const PAINT_RETRY_DELAY_MS = 5000;
|
|
28
|
+
/**
|
|
29
|
+
* Last-resort limit on one paint attempt, on top of its connect timeout - so an attempt stuck on
|
|
30
|
+
* anything, anywhere, still fails and gets retried instead of blocking this label (and, via
|
|
31
|
+
* `exclusiveBleManagerAccess`, every other label) forever. Longer than every inner limit it backs up,
|
|
32
|
+
* the longest being the 5-minute GATT session watchdog in `bleBackend.ts`.
|
|
33
|
+
*/
|
|
34
|
+
const PAINT_ATTEMPT_BACKSTOP_MS = 7 * 60000;
|
|
28
35
|
const RESOURCES_API_PATH = "/signalk/v2/api/resources";
|
|
29
36
|
/**
|
|
30
37
|
* The most recent wall-clock instant at or before `now` that an `interval`-triggered device's own
|
|
@@ -358,7 +365,7 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
|
|
|
358
365
|
const paintWithRetries = () => (0, bleDiscovery_1.withRetries)(attempts, async (attempt) => {
|
|
359
366
|
app.debug(`${label}: attempting paint ${attempt}/${attempts}`);
|
|
360
367
|
const startedAt = Date.now();
|
|
361
|
-
|
|
368
|
+
const paint = driver.paint(bitmap, {
|
|
362
369
|
address,
|
|
363
370
|
pid: target.pid,
|
|
364
371
|
aesKey: device.advanced?.aesKey,
|
|
@@ -368,7 +375,9 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
|
|
|
368
375
|
compress: device.advanced?.compress,
|
|
369
376
|
compressionFormat: device.advanced?.compressionFormat,
|
|
370
377
|
gattBackend,
|
|
378
|
+
log: (message) => app.debug(`${label}: ${message}`),
|
|
371
379
|
});
|
|
380
|
+
await (0, bleDiscovery_1.withDeadline)(paint, connectTimeoutMs + PAINT_ATTEMPT_BACKSTOP_MS, `paint attempt ${attempt}/${attempts}`);
|
|
372
381
|
paintDurationMs = Date.now() - startedAt;
|
|
373
382
|
}, {
|
|
374
383
|
delayMs: PAINT_RETRY_DELAY_MS,
|