@rhizomatics/signalk-einklabel-plugin 1.3.0-beta8 → 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 +36 -1
- package/README.md +21 -511
- package/dist/cli/index.d.ts +4 -1
- package/dist/cli/index.js +26 -1
- package/dist/config.d.ts +62 -15
- package/dist/config.js +252 -60
- 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/compression.d.ts +22 -9
- package/dist/devices/gicisky/compression.js +128 -13
- package/dist/devices/gicisky/encode.d.ts +1 -1
- package/dist/devices/gicisky/encode.js +2 -2
- package/dist/devices/gicisky/index.js +9 -3
- package/dist/devices/gicisky/layout.d.ts +13 -1
- package/dist/devices/gicisky/layout.js +21 -0
- package/dist/devices/types.d.ts +26 -0
- package/dist/devices/types.js +2 -0
- package/dist/devices/zhsunyco/compression.d.ts +1 -0
- package/dist/devices/zhsunyco/compression.js +30 -0
- package/dist/devices/zhsunyco/index.js +44 -9
- package/dist/devices/zhsunyco/protocol.d.ts +2 -0
- package/dist/devices/zhsunyco/protocol.js +2 -0
- package/dist/docs/templateReference.d.ts +18 -0
- package/dist/docs/templateReference.js +207 -0
- package/dist/email/emailSender.d.ts +27 -0
- package/dist/email/emailSender.js +66 -0
- package/dist/plugin.js +2 -2
- package/dist/render/fieldsTable.d.ts +15 -0
- package/dist/render/fieldsTable.js +52 -0
- package/dist/render/llmPrompt.d.ts +86 -0
- package/dist/render/llmPrompt.js +199 -0
- package/dist/render/mirror.d.ts +11 -0
- package/dist/render/mirror.js +24 -0
- package/dist/repaintScheduler.d.ts +8 -0
- package/dist/repaintScheduler.js +86 -29
- 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
|
@@ -7,10 +7,14 @@ const pluginVersion_1 = require("../../pluginVersion");
|
|
|
7
7
|
const metadata_1 = require("./metadata");
|
|
8
8
|
const encode_1 = require("./encode");
|
|
9
9
|
const reframe_1 = require("../../render/reframe");
|
|
10
|
+
const mirror_1 = require("../../render/mirror");
|
|
11
|
+
const compression_1 = require("./compression");
|
|
10
12
|
const protocol_1 = require("./protocol");
|
|
11
13
|
/** node-ble has no MTU API; this matches the reference driver's mtu(247)-9 default. */
|
|
12
14
|
const UPLOAD_CHUNK_SIZE = 238;
|
|
13
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;
|
|
14
18
|
const AUTH_SETTLE_DELAY_MS = 500;
|
|
15
19
|
const STATUS_WAIT_TIMEOUT_MS = 60000;
|
|
16
20
|
/** Bounds `readDeviceDetails`' whole connect+read attempt during a scan - see its doc comment. */
|
|
@@ -48,10 +52,13 @@ class ZhsunycoDriver {
|
|
|
48
52
|
}
|
|
49
53
|
async paint(bitmap, config) {
|
|
50
54
|
const aesKey = (0, protocol_1.resolveAesKey)(config.aesKey);
|
|
55
|
+
const log = config.log ?? (() => { });
|
|
51
56
|
const backend = config.gattBackend ?? (0, bleBackend_1.nodeBleBackend)();
|
|
57
|
+
log("connecting");
|
|
52
58
|
const conn = await backend.connectGatt(config.address, config.connectTimeoutMs ?? DEFAULT_PAINT_CONNECT_TIMEOUT_MS);
|
|
59
|
+
log("connected");
|
|
53
60
|
try {
|
|
54
|
-
const info = (0, protocol_1.decodeAdvertisedInfo)(await conn
|
|
61
|
+
const info = (0, protocol_1.decodeAdvertisedInfo)(await readConfig(conn, log));
|
|
55
62
|
if (!info) {
|
|
56
63
|
throw new Error("zhsunyco device did not return valid config data");
|
|
57
64
|
}
|
|
@@ -77,23 +84,35 @@ class ZhsunycoDriver {
|
|
|
77
84
|
const challenge = await conn.read(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.authenticate);
|
|
78
85
|
await conn.write(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.authenticate, (0, protocol_1.authResponse)(challenge, aesKey), false);
|
|
79
86
|
await (0, bleDiscovery_1.sleep)(AUTH_SETTLE_DELAY_MS);
|
|
80
|
-
|
|
87
|
+
log("authenticated");
|
|
88
|
+
const framed = (0, mirror_1.mirrorBitmap)((0, reframe_1.reframeBitmap)(bitmap, metadata.width, metadata.height - metadata.voffset, config.reframe ?? "crop"), config.mirror ?? "none");
|
|
81
89
|
const pixelData = (0, encode_1.encodeBitmap)(framed, metadata);
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
90
|
+
const compress = config.compress ?? true;
|
|
91
|
+
// Upload offsets and the refresh length both count bytes of whatever's actually sent - the
|
|
92
|
+
// compressed payload when compressing, not the raw buffer it inflates back to.
|
|
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`);
|
|
97
|
+
for (let offset = 0; offset < payload.length; offset += UPLOAD_CHUNK_SIZE) {
|
|
98
|
+
const chunk = payload.subarray(offset, offset + UPLOAD_CHUNK_SIZE);
|
|
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);
|
|
86
101
|
}
|
|
87
|
-
await conn.write(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.data, (0, protocol_1.commandHeader)(protocol_1.COMMAND.refreshUncompressed,
|
|
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");
|
|
88
104
|
// `Promise.race` can't cancel its loser, so once `statusReceived` settles (the common case)
|
|
89
105
|
// this timer would otherwise sit alive for the rest of its 60s regardless - see the identical
|
|
90
106
|
// reasoning on `readDeviceDetails`'s own race, below.
|
|
91
107
|
let statusTimer;
|
|
92
108
|
const statusTimeout = new Promise((resolve) => {
|
|
93
|
-
statusTimer = setTimeout(resolve, STATUS_WAIT_TIMEOUT_MS);
|
|
109
|
+
statusTimer = setTimeout(() => resolve("timeout"), STATUS_WAIT_TIMEOUT_MS);
|
|
94
110
|
});
|
|
95
111
|
try {
|
|
96
|
-
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");
|
|
97
116
|
}
|
|
98
117
|
finally {
|
|
99
118
|
clearTimeout(statusTimer);
|
|
@@ -109,6 +128,22 @@ class ZhsunycoDriver {
|
|
|
109
128
|
}
|
|
110
129
|
}
|
|
111
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
|
+
}
|
|
112
147
|
/**
|
|
113
148
|
* Battery level needs a connection regardless, so reuse it to also fill in the PID/hwVersion
|
|
114
149
|
* when the advertisement didn't carry decodable manufacturer data - a scan matched purely by name
|
|
@@ -15,6 +15,8 @@ export declare const WOLINK_CHARACTERISTIC_UUIDS: {
|
|
|
15
15
|
export declare const COMMAND: {
|
|
16
16
|
readonly uploadBlock: 42240;
|
|
17
17
|
readonly refreshUncompressed: 42241;
|
|
18
|
+
/** Refresh after uploading a `compressWolinkBlocks` payload rather than the raw buffer. */
|
|
19
|
+
readonly refreshCompressed: 42242;
|
|
18
20
|
};
|
|
19
21
|
export interface AdvertisedDeviceInfo {
|
|
20
22
|
pid: number;
|
|
@@ -25,6 +25,8 @@ exports.WOLINK_CHARACTERISTIC_UUIDS = {
|
|
|
25
25
|
exports.COMMAND = {
|
|
26
26
|
uploadBlock: 0xa500,
|
|
27
27
|
refreshUncompressed: 0xa501,
|
|
28
|
+
/** Refresh after uploading a `compressWolinkBlocks` payload rather than the raw buffer. */
|
|
29
|
+
refreshCompressed: 0xa502,
|
|
28
30
|
};
|
|
29
31
|
/** Decodes the 8-byte header shared by the advertising mirror and config characteristic. */
|
|
30
32
|
function decodeAdvertisedInfo(data) {
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { EmailConfig } from "../config";
|
|
2
|
+
import { Bitmap } from "../render/types";
|
|
3
|
+
import { FieldRow } from "../render/fieldsTable";
|
|
4
|
+
/** Either the SVG template's field-by-field table (`buildFieldsTable`), a `TemplateProvider`'s own rendered-content text (`describeContent`), or neither (a provider offered no `describeContent`). */
|
|
5
|
+
export type EmailContentBody = {
|
|
6
|
+
kind: "table";
|
|
7
|
+
rows: FieldRow[];
|
|
8
|
+
} | {
|
|
9
|
+
kind: "text";
|
|
10
|
+
text: string;
|
|
11
|
+
} | {
|
|
12
|
+
kind: "none";
|
|
13
|
+
};
|
|
14
|
+
export interface LabelEmailContent {
|
|
15
|
+
friendlyName: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
bitmap: Bitmap;
|
|
18
|
+
body: EmailContentBody;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Renders and sends one device's repaint as an email - a PNG attachment (also shown inline via a `cid`
|
|
22
|
+
* reference), the same field-by-field table `esl-cli fields` shows for a hand-authored SVG template (or
|
|
23
|
+
* a `TemplateProvider`'s own rendered-content text for something like a GenAI prompt - see `body`), and a
|
|
24
|
+
* link back to the docs site. Called from `considerRepaint` (`../repaintScheduler.ts`) whenever a
|
|
25
|
+
* device's `emailTo` is set and its render succeeds.
|
|
26
|
+
*/
|
|
27
|
+
export declare function sendLabelEmail(emailConfig: EmailConfig, to: string, content: LabelEmailContent): Promise<void>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.sendLabelEmail = sendLabelEmail;
|
|
7
|
+
const nodemailer_1 = __importDefault(require("nodemailer"));
|
|
8
|
+
const png_1 = require("../render/png");
|
|
9
|
+
const DOCS_URL = "https://rhizomatics.github.io/signalk-einklabel-plugin/";
|
|
10
|
+
function escapeHtml(text) {
|
|
11
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
12
|
+
}
|
|
13
|
+
function bodyHtml(body) {
|
|
14
|
+
if (body.kind === "table") {
|
|
15
|
+
if (body.rows.length === 0)
|
|
16
|
+
return "";
|
|
17
|
+
const rows = body.rows
|
|
18
|
+
.map((row) => `<tr><td>${escapeHtml(row.id)}</td><td>${escapeHtml(row.spec)}</td><td>${escapeHtml(row.value)}</td></tr>`)
|
|
19
|
+
.join("");
|
|
20
|
+
return ('<table border="1" cellpadding="4" cellspacing="0" style="border-collapse:collapse;font-family:monospace;font-size:13px">' +
|
|
21
|
+
`<thead><tr><th>id</th><th>spec</th><th>value</th></tr></thead><tbody>${rows}</tbody></table>`);
|
|
22
|
+
}
|
|
23
|
+
if (body.kind === "text") {
|
|
24
|
+
return `<pre style="white-space:pre-wrap;font-family:monospace;font-size:13px">${escapeHtml(body.text)}</pre>`;
|
|
25
|
+
}
|
|
26
|
+
return "";
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Renders and sends one device's repaint as an email - a PNG attachment (also shown inline via a `cid`
|
|
30
|
+
* reference), the same field-by-field table `esl-cli fields` shows for a hand-authored SVG template (or
|
|
31
|
+
* a `TemplateProvider`'s own rendered-content text for something like a GenAI prompt - see `body`), and a
|
|
32
|
+
* link back to the docs site. Called from `considerRepaint` (`../repaintScheduler.ts`) whenever a
|
|
33
|
+
* device's `emailTo` is set and its render succeeds.
|
|
34
|
+
*/
|
|
35
|
+
async function sendLabelEmail(emailConfig, to, content) {
|
|
36
|
+
const transporter = nodemailer_1.default.createTransport({
|
|
37
|
+
host: emailConfig.smtpHost,
|
|
38
|
+
port: emailConfig.smtpPort,
|
|
39
|
+
secure: emailConfig.smtpSecure,
|
|
40
|
+
auth: emailConfig.smtpUser ? { user: emailConfig.smtpUser, pass: emailConfig.smtpPassword } : undefined,
|
|
41
|
+
});
|
|
42
|
+
const png = (0, png_1.bitmapToPng)(content.bitmap);
|
|
43
|
+
const html = [
|
|
44
|
+
'<div style="font-family:sans-serif">',
|
|
45
|
+
`<h2>${escapeHtml(content.friendlyName)}</h2>`,
|
|
46
|
+
content.description ? `<p>${escapeHtml(content.description)}</p>` : "",
|
|
47
|
+
`<p><img src="cid:label-image" alt="${escapeHtml(content.friendlyName)}" style="border:1px solid #ccc" /></p>`,
|
|
48
|
+
bodyHtml(content.body),
|
|
49
|
+
`<p><a href="${DOCS_URL}">${DOCS_URL}</a></p>`,
|
|
50
|
+
"</div>",
|
|
51
|
+
].join("\n");
|
|
52
|
+
await transporter.sendMail({
|
|
53
|
+
from: emailConfig.fromAddress,
|
|
54
|
+
to,
|
|
55
|
+
subject: `eInk Label: ${content.friendlyName}`,
|
|
56
|
+
html,
|
|
57
|
+
attachments: [
|
|
58
|
+
{
|
|
59
|
+
filename: "label.png",
|
|
60
|
+
content: png,
|
|
61
|
+
contentType: "image/png",
|
|
62
|
+
cid: "label-image",
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
});
|
|
66
|
+
}
|
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) {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { TemplateContext } from "./types";
|
|
2
|
+
export interface FieldRow {
|
|
3
|
+
id: string;
|
|
4
|
+
spec: string;
|
|
5
|
+
value: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Synchronous counterpart to the CLI's `fields` command (`../cli/index.ts`) - same three columns (id,
|
|
9
|
+
* spec, resolved value), but resolved against a single already-assembled `context` rather than
|
|
10
|
+
* re-fetching per binding, since a caller like `considerRepaint` (`../repaintScheduler.ts`) already has
|
|
11
|
+
* the full context its render used. Only meaningful for a hand-authored SVG template - a
|
|
12
|
+
* `TemplateProvider`-backed template (e.g. GenAI) has no `<desc>` bindings to walk; see
|
|
13
|
+
* `TemplateProvider.describeContent` (`./templateProviders.ts`) for that case's own text summary instead.
|
|
14
|
+
*/
|
|
15
|
+
export declare function buildFieldsTable(svgSource: string, context: TemplateContext, templatesDir: string, bundledTemplatesDir: string): FieldRow[];
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildFieldsTable = buildFieldsTable;
|
|
4
|
+
const path_1 = require("path");
|
|
5
|
+
const xmldom_1 = require("@xmldom/xmldom");
|
|
6
|
+
const assets_1 = require("./assets");
|
|
7
|
+
const binding_1 = require("./binding");
|
|
8
|
+
function resolveFieldValue(tag, desc, context, templatesDir, bundledTemplatesDir) {
|
|
9
|
+
try {
|
|
10
|
+
const binding = (0, binding_1.parseBinding)(desc);
|
|
11
|
+
if (tag !== "image") {
|
|
12
|
+
return (0, binding_1.renderBinding)(binding, context);
|
|
13
|
+
}
|
|
14
|
+
if (!binding.assets) {
|
|
15
|
+
return 'ERROR: an <image> binding requires an "assets" key';
|
|
16
|
+
}
|
|
17
|
+
const key = (0, assets_1.normalizeAssetKey)((0, binding_1.resolveBinding)(binding, context));
|
|
18
|
+
if (!key)
|
|
19
|
+
return "(no usable value - image would be omitted)";
|
|
20
|
+
const assetPath = (0, assets_1.resolveAssetPath)(templatesDir, bundledTemplatesDir, binding.assets, key);
|
|
21
|
+
return assetPath ? `"${key}" -> ${(0, path_1.basename)(assetPath)}` : `"${key}" -> no matching asset file (image would be omitted)`;
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
return `ERROR: ${err.message}`;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Synchronous counterpart to the CLI's `fields` command (`../cli/index.ts`) - same three columns (id,
|
|
29
|
+
* spec, resolved value), but resolved against a single already-assembled `context` rather than
|
|
30
|
+
* re-fetching per binding, since a caller like `considerRepaint` (`../repaintScheduler.ts`) already has
|
|
31
|
+
* the full context its render used. Only meaningful for a hand-authored SVG template - a
|
|
32
|
+
* `TemplateProvider`-backed template (e.g. GenAI) has no `<desc>` bindings to walk; see
|
|
33
|
+
* `TemplateProvider.describeContent` (`./templateProviders.ts`) for that case's own text summary instead.
|
|
34
|
+
*/
|
|
35
|
+
function buildFieldsTable(svgSource, context, templatesDir, bundledTemplatesDir) {
|
|
36
|
+
const doc = new xmldom_1.DOMParser().parseFromString(svgSource, "image/svg+xml");
|
|
37
|
+
const rows = [];
|
|
38
|
+
for (const tag of ["text", "image"]) {
|
|
39
|
+
const elements = doc.getElementsByTagName(tag);
|
|
40
|
+
for (let i = 0; i < elements.length; i++) {
|
|
41
|
+
const element = elements.item(i);
|
|
42
|
+
if (!element)
|
|
43
|
+
continue;
|
|
44
|
+
const id = element.getAttribute("id") ?? `${tag}#${i}`;
|
|
45
|
+
const desc = element.getElementsByTagName("desc").item(0);
|
|
46
|
+
if (!desc?.textContent)
|
|
47
|
+
continue;
|
|
48
|
+
rows.push({ id, spec: desc.textContent, value: resolveFieldValue(tag, desc.textContent, context, templatesDir, bundledTemplatesDir) });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return rows;
|
|
52
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { PluginConfig } from "../config";
|
|
2
|
+
import { Colour } from "../devices/types";
|
|
3
|
+
import { Binding } from "./binding";
|
|
4
|
+
import { TemplateContext } from "./types";
|
|
5
|
+
/** Facts about one physical label a prompt's `source=label,path=...` placeholders can reference - see `buildLabelContext`. */
|
|
6
|
+
export interface LabelMeta {
|
|
7
|
+
manufacturer: string;
|
|
8
|
+
/** The physical panel's own size label, e.g. `'3.7"'` - `DeviceMetadata.label` verbatim, see `../devices/types.ts`. */
|
|
9
|
+
label: string;
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
colours: Colour[];
|
|
13
|
+
description?: string;
|
|
14
|
+
position?: {
|
|
15
|
+
latitude: number;
|
|
16
|
+
longitude: number;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Builds the `context.label` object a prompt/target-guidance fragment addresses via
|
|
21
|
+
* `source=label,path=...` bindings (e.g. `{source=label,path=width}`, or the bare-path shorthand
|
|
22
|
+
* `{width}` is *not* supported here deliberately - see `./binding.ts`'s `parseBinding`, every `{...}`
|
|
23
|
+
* placeholder is a real binding, so a `label` path always needs the explicit `source=label,path=`
|
|
24
|
+
* form to disambiguate it from a `signalk` self path). `colours`/`fonts` are left as arrays (each
|
|
25
|
+
* colour entry pre-annotated with its hex code, e.g. `"black (#000000)"`) rather than joined into a
|
|
26
|
+
* single string, so a prompt author can either use them bare (renders as JSON, e.g. for a model that
|
|
27
|
+
* parses structured hints) or add `format=csv` (see `./formatters.ts`) for a plain comma-separated list.
|
|
28
|
+
*/
|
|
29
|
+
export declare function buildLabelContext(meta: LabelMeta): Record<string, unknown>;
|
|
30
|
+
/**
|
|
31
|
+
* Every binding referenced across one or more prompt fragments - every `{...}` placeholder, deduplicated
|
|
32
|
+
* across all of `texts` combined, parsed exactly the way a template's `<desc>` binding is
|
|
33
|
+
* (`parseBinding`, see `./binding.ts`), so a bare path (`{design.length}`, `source=signalk,context=self`
|
|
34
|
+
* shorthand), the full binding grammar (`{source=signalk,path=navigation.position,format=position}`),
|
|
35
|
+
* and a `source=label,path=...` binding all work uniformly. Pass the result to `assembleRawContext`
|
|
36
|
+
* (repaintScheduler.ts) exactly as a template's own bindings are - it fetches the `signalk`/
|
|
37
|
+
* `resources`-sourced ones and silently ignores `label`/`einklabel`-sourced ones, which resolve directly
|
|
38
|
+
* against `context.label`/`context.meta` instead (built by the caller, not fetched).
|
|
39
|
+
*
|
|
40
|
+
* A placeholder that isn't valid binding grammar (e.g. a typo like `{source=taheight}`) is silently
|
|
41
|
+
* skipped here rather than thrown - the same per-field isolation `SvgRenderer` gives a bad `<desc>`
|
|
42
|
+
* binding, so one malformed placeholder doesn't take down the whole prompt. `substitutePlaceholders`
|
|
43
|
+
* hits the identical parse error at substitution time and turns it into "???" for just that field.
|
|
44
|
+
*/
|
|
45
|
+
export declare function findPromptBindings(...texts: string[]): Binding[];
|
|
46
|
+
/**
|
|
47
|
+
* Substitutes every `{...}` placeholder in `text` with its resolved binding value against `context`
|
|
48
|
+
* (built by `assembleRawContext` plus `context.label` from `buildLabelContext` - see
|
|
49
|
+
* `findPromptBindings`). Mirrors `renderBinding`'s per-field isolation, but substitutes "???" for
|
|
50
|
+
* anything that resolves to no value at all (missing path, invalid binding grammar) rather than "" -
|
|
51
|
+
* prose with a silently-blank word reads as a fact ("for the sailor of a m vessel"), not as a gap the
|
|
52
|
+
* reader would notice. Two things override that "???": a binding that resolves successfully to a
|
|
53
|
+
* legitimately empty string (e.g. an unset `{source=label,path=description}`) is left as empty, since
|
|
54
|
+
* that's a real answer, not a miss; and a binding with an explicit `default=` (see `./binding.ts`) uses
|
|
55
|
+
* that default instead, since the prompt author has already said what a missing value should read as.
|
|
56
|
+
*/
|
|
57
|
+
export declare function substitutePlaceholders(text: string, context: TemplateContext): string;
|
|
58
|
+
/**
|
|
59
|
+
* Strips everything outside the first `<svg`...last `</svg>` span - a chat model asked for "only raw
|
|
60
|
+
* SVG markup" still often wraps it in a markdown code fence or adds a sentence of commentary either
|
|
61
|
+
* side, despite the target-guidance fragment telling it not to.
|
|
62
|
+
*/
|
|
63
|
+
export declare function extractSvg(raw: string): string;
|
|
64
|
+
export type LlmSettings = Pick<PluginConfig, "llmProvider" | "llmApiKey" | "llmModel" | "llmBaseUrl"> & {
|
|
65
|
+
llmTimeoutSeconds?: number;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* `ai` and every `@ai-sdk/*` provider package are `optionalDependencies` (see package.json), not plain
|
|
69
|
+
* `dependencies` - most installs (every device using `renderMode: "svg-template"` only) never touch an
|
|
70
|
+
* LLM at all, so they shouldn't have to pull in this whole gateway stack, and an install that fails to
|
|
71
|
+
* fetch one of these (network hiccup, `npm install --omit=optional`, an unsupported platform) must not
|
|
72
|
+
* break BLE painting/templates, which have nothing to do with it. That means every reference to one of
|
|
73
|
+
* these packages has to be a `require()` reached only when a `renderMode: "llm-prompt"` device actually
|
|
74
|
+
* calls `callLlm` - a top-level `import` would be resolved eagerly the moment this module loads (which
|
|
75
|
+
* is every plugin start, via `repaintScheduler.ts`), throwing before any config is even read. Types are
|
|
76
|
+
* still fully checked via `typeof import(...)` below, which - unlike a value `import` - is erased
|
|
77
|
+
* entirely at compile time and leaves no runtime trace for `tsc` to eagerly require.
|
|
78
|
+
*/
|
|
79
|
+
export declare function loadOptional<M>(moduleName: string): M;
|
|
80
|
+
/**
|
|
81
|
+
* Calls the configured LLM gateway with `prompt`, returning its raw text response - not yet extracted/
|
|
82
|
+
* validated as SVG, see `extractSvg`. This is the first network call in the codebase that needs its own
|
|
83
|
+
* timeout (`fetchJson` in `../httpJson.ts` has none - every existing call is to the local SignalK
|
|
84
|
+
* server's own REST API).
|
|
85
|
+
*/
|
|
86
|
+
export declare function callLlm(settings: LlmSettings, prompt: string): Promise<string>;
|