@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.
- package/CHANGELOG.md +18 -1
- package/README.md +14 -537
- package/dist/cli/index.js +1 -0
- package/dist/config.d.ts +53 -18
- package/dist/config.js +252 -85
- package/dist/devices/bleBackend.js +44 -7
- 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 +11 -0
- package/dist/devices/zhsunyco/index.js +35 -6
- package/dist/docs/templateReference.d.ts +18 -0
- package/dist/docs/templateReference.js +207 -0
- package/dist/plugin.js +2 -2
- package/dist/repaintScheduler.d.ts +8 -0
- package/dist/repaintScheduler.js +86 -32
- package/dist/resolveApiUrl.js +3 -0
- package/docs/bluetooth.md +162 -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 +44 -0
- package/docs/getting-started.md +111 -0
- package/docs/templates.md +142 -0
- package/package.json +16 -7
|
@@ -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
|
}
|
|
@@ -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)
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
|
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)
|
|
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(() => { });
|
package/dist/devices/types.d.ts
CHANGED
|
@@ -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
|
|
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]),
|
|
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),
|
|
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.
|
|
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;
|