@rhizomatics/signalk-einklabel-plugin 0.10.0 → 1.0.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 +20 -2
- package/README.md +74 -22
- package/dist/cli/index.d.ts +13 -1
- package/dist/cli/index.js +57 -26
- package/dist/config.d.ts +27 -7
- package/dist/config.js +48 -12
- package/dist/devices/types.d.ts +8 -0
- package/dist/devices/zhsunyco/metadata.js +9 -0
- package/dist/index.d.ts +36 -3
- package/dist/index.js +28 -3
- package/dist/render/binding.d.ts +77 -3
- package/dist/render/binding.js +114 -3
- package/dist/render/formatters.js +18 -0
- package/dist/render/llmPrompt.d.ts +86 -0
- package/dist/render/llmPrompt.js +199 -0
- package/dist/render/templateProviders.d.ts +58 -0
- package/dist/render/templateProviders.js +16 -0
- package/dist/repaintScheduler.js +126 -51
- package/docs/assets/images/real_tidal_clock.jpg +0 -0
- package/package.json +13 -8
- package/templates/.error/250x128-BWRY.svg +12 -0
- package/templates/.error/416x240-BWRY.svg +12 -0
- package/templates/watch/416x240-BWRY.svg +1 -1
- /package/templates/{blank → .blank}/250x128-BWRY.svg +0 -0
- /package/templates/{blank → .blank}/416x240-BWRY.svg +0 -0
package/dist/config.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.BUNDLED_TEMPLATES_DIR = exports.ALL_DEVICES = void 0;
|
|
3
|
+
exports.RENDER_FALLBACK_TEMPLATE_NAME = exports.BUNDLED_TEMPLATES_DIR = exports.ALL_DEVICES = void 0;
|
|
4
4
|
exports.defaultConfig = defaultConfig;
|
|
5
5
|
exports.readCurrentConfig = readCurrentConfig;
|
|
6
6
|
exports.healNestedConfig = healNestedConfig;
|
|
@@ -12,6 +12,7 @@ exports.configUiSchema = configUiSchema;
|
|
|
12
12
|
const fs_1 = require("fs");
|
|
13
13
|
const os_1 = require("os");
|
|
14
14
|
const path_1 = require("path");
|
|
15
|
+
const templateProviders_1 = require("./render/templateProviders");
|
|
15
16
|
const resolveApiUrl_1 = require("./resolveApiUrl");
|
|
16
17
|
/**
|
|
17
18
|
* Special `device` value meaning "every currently-known discovered device" instead of one specific
|
|
@@ -31,6 +32,18 @@ exports.ALL_DEVICES = "ALL";
|
|
|
31
32
|
* `templates/.assets/lunar_phases`) just to keep a binding the override never touched working.
|
|
32
33
|
*/
|
|
33
34
|
exports.BUNDLED_TEMPLATES_DIR = (0, path_1.join)(__dirname, "..", "templates");
|
|
35
|
+
/**
|
|
36
|
+
* Bundled template family pushed instead of a device's real content whenever a repaint fails - a broken
|
|
37
|
+
* hand-authored template, or a registered `TemplateProvider`'s `render()` rejecting (e.g.
|
|
38
|
+
* `@rhizomatics/signalk-einklabel-genai-plugin`'s LLM call failing) - see `considerRepaint` in
|
|
39
|
+
* `./repaintScheduler.ts`. Generic on purpose: it has no idea *why* the render failed, only that it
|
|
40
|
+
* did, and that the previous, possibly now-wrong, content must never be left on screen unmarked.
|
|
41
|
+
*
|
|
42
|
+
* Dot-prefixed like `.assets`/`.blank` so `listTemplateFamilies` excludes it from the "Template"
|
|
43
|
+
* dropdown - it's an internal fallback mechanism, not something a user should ever pick as a device's
|
|
44
|
+
* own template.
|
|
45
|
+
*/
|
|
46
|
+
exports.RENDER_FALLBACK_TEMPLATE_NAME = ".error";
|
|
34
47
|
const SIGNALK_HOME_DIR = (0, path_1.join)((0, os_1.homedir)(), ".signalk");
|
|
35
48
|
const DEFAULT_TEMPLATES_DIR = (0, path_1.join)(SIGNALK_HOME_DIR, "einklabel", "templates");
|
|
36
49
|
function defaultConfig() {
|
|
@@ -117,18 +130,21 @@ function healNestedConfig(app) {
|
|
|
117
130
|
});
|
|
118
131
|
}
|
|
119
132
|
/**
|
|
120
|
-
* Resolves
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
* an absolute path is used as-is.
|
|
133
|
+
* Resolves a user-facing `<x>Dir` setting to an actual directory, mirroring signalk-parquet's
|
|
134
|
+
* `outputDirectory` convention: empty means `defaultDir`, a relative path is resolved against
|
|
135
|
+
* `~/.signalk` (where SignalK itself stores its config by default), and an absolute path is used as-is.
|
|
124
136
|
*/
|
|
125
|
-
function
|
|
126
|
-
const trimmed =
|
|
137
|
+
function resolveDir(dir, defaultDir) {
|
|
138
|
+
const trimmed = dir?.trim();
|
|
127
139
|
if (!trimmed) {
|
|
128
|
-
return
|
|
140
|
+
return defaultDir;
|
|
129
141
|
}
|
|
130
142
|
return (0, path_1.isAbsolute)(trimmed) ? trimmed : (0, path_1.join)(SIGNALK_HOME_DIR, trimmed);
|
|
131
143
|
}
|
|
144
|
+
/** See `resolveDir` - `templatesDir`'s own resolution. */
|
|
145
|
+
function resolveTemplatesDir(templatesDir) {
|
|
146
|
+
return resolveDir(templatesDir, DEFAULT_TEMPLATES_DIR);
|
|
147
|
+
}
|
|
132
148
|
/**
|
|
133
149
|
* Enum for the combined "device" field, built from recently scanned devices - including ones a
|
|
134
150
|
* driver identified as its vendor but whose PID isn't in its metadata table yet (clearly labelled,
|
|
@@ -200,7 +216,13 @@ function listTemplateVariants(dir) {
|
|
|
200
216
|
.map(parseTemplateVariant)
|
|
201
217
|
.filter((variant) => variant !== undefined);
|
|
202
218
|
}
|
|
203
|
-
/**
|
|
219
|
+
/**
|
|
220
|
+
* A directory only counts as a template-family option if it actually has at least one parseable
|
|
221
|
+
* variant file in it - otherwise it's something else entirely, e.g. `.assets`. Dot-prefixed
|
|
222
|
+
* directories (e.g. `.assets`, `.blank`) are always excluded, even if they happen to contain
|
|
223
|
+
* parseable variant files, since they're reserved for non-template-option use (asset bundles,
|
|
224
|
+
* work-in-progress templates not ready to appear in the dropdown, etc).
|
|
225
|
+
*/
|
|
204
226
|
function listTemplateFamilies(dir) {
|
|
205
227
|
let entries;
|
|
206
228
|
try {
|
|
@@ -210,14 +232,21 @@ function listTemplateFamilies(dir) {
|
|
|
210
232
|
return [];
|
|
211
233
|
}
|
|
212
234
|
return entries
|
|
213
|
-
.filter((entry) => entry.isDirectory() && listTemplateVariants((0, path_1.join)(dir, entry.name)).length > 0)
|
|
235
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith(".") && listTemplateVariants((0, path_1.join)(dir, entry.name)).length > 0)
|
|
214
236
|
.map((entry) => entry.name);
|
|
215
237
|
}
|
|
216
|
-
/**
|
|
238
|
+
/**
|
|
239
|
+
* Local templates (files or template-family directories) take priority over a same-named bundled one;
|
|
240
|
+
* both show up as options, followed by every registered `TemplateProvider`'s own entries (e.g.
|
|
241
|
+
* `signalk-einklabel-genai-plugin` contributing `"forecast (GenAI)"` - see `./render/templateProviders.ts`)
|
|
242
|
+
* - so a provider-backed "template" is picked from this exact same dropdown, distinguished only by its
|
|
243
|
+
* `suffix`, with no separate render-mode field at all.
|
|
244
|
+
*/
|
|
217
245
|
function templateNameOptions(templatesDir) {
|
|
218
246
|
const local = [...listSvgFiles(templatesDir), ...listTemplateFamilies(templatesDir)];
|
|
219
247
|
const bundled = [...listSvgFiles(exports.BUNDLED_TEMPLATES_DIR), ...listTemplateFamilies(exports.BUNDLED_TEMPLATES_DIR)].filter((name) => !local.includes(name));
|
|
220
|
-
|
|
248
|
+
const provided = (0, templateProviders_1.allTemplateProviders)().flatMap((provider) => provider.listTemplates());
|
|
249
|
+
return [...local, ...bundled, ...provided];
|
|
221
250
|
}
|
|
222
251
|
function sameColours(a, b) {
|
|
223
252
|
if (a.length !== b.length)
|
|
@@ -344,6 +373,12 @@ function configSchema(app, discovered = []) {
|
|
|
344
373
|
'address, or "All discovered devices" to paint this same template/trigger to every device the plugin currently ' +
|
|
345
374
|
"knows about - simplest for a single label, and also covers several identical labels without listing each one.",
|
|
346
375
|
}, deviceValues, deviceLabels),
|
|
376
|
+
description: {
|
|
377
|
+
type: "string",
|
|
378
|
+
title: "Location/description (optional)",
|
|
379
|
+
description: 'Free-text notes about where this label is physically mounted/viewed from, e.g. "chart table, viewed from ~1m ' +
|
|
380
|
+
'in poor light" - available to any template as source=einklabel,path=description or source=label,path=description.',
|
|
381
|
+
},
|
|
347
382
|
templateName: withEnum({ type: "string", title: "Template" }, templateNameOptions(resolveTemplatesDir(current.templatesDir))),
|
|
348
383
|
repaintTrigger: {
|
|
349
384
|
type: "string",
|
|
@@ -386,6 +421,7 @@ function configUiSchema() {
|
|
|
386
421
|
return {
|
|
387
422
|
devices: {
|
|
388
423
|
items: {
|
|
424
|
+
description: { "ui:widget": "textarea" },
|
|
389
425
|
repaintTrigger: { "ui:widget": "radio" },
|
|
390
426
|
},
|
|
391
427
|
},
|
package/dist/devices/types.d.ts
CHANGED
|
@@ -13,6 +13,14 @@ export interface DeviceMetadata {
|
|
|
13
13
|
* that should be treated as the default/fallback for that PID.
|
|
14
14
|
*/
|
|
15
15
|
hwVersion?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Real-world brand name (e.g. "Zhsunyco") - distinct from `VendorDriver.vendor` (e.g. also
|
|
18
|
+
* "zhsunyco"), which is this codebase's internal BLE-protocol driver key rather than a name meant
|
|
19
|
+
* for display. Optional since a CLI/config `DeviceModelOverride` for unsupported hardware has no
|
|
20
|
+
* table entry to source it from - `considerLlmPromptRepaint` (repaintScheduler.ts) falls back to
|
|
21
|
+
* the driver's own `vendor` key when unset, for a `source=label,path=manufacturer` prompt binding.
|
|
22
|
+
*/
|
|
23
|
+
manufacturer?: string;
|
|
16
24
|
label: string;
|
|
17
25
|
width: number;
|
|
18
26
|
height: number;
|
|
@@ -22,6 +22,7 @@ exports.ZHSUNYCO_PID_METADATA = void 0;
|
|
|
22
22
|
exports.ZHSUNYCO_PID_METADATA = [
|
|
23
23
|
{
|
|
24
24
|
pid: 0x0008,
|
|
25
|
+
manufacturer: "Zhsunyco",
|
|
25
26
|
label: '1.54"',
|
|
26
27
|
width: 200,
|
|
27
28
|
height: 200,
|
|
@@ -30,6 +31,7 @@ exports.ZHSUNYCO_PID_METADATA = [
|
|
|
30
31
|
},
|
|
31
32
|
{
|
|
32
33
|
pid: 0x000a,
|
|
34
|
+
manufacturer: "Zhsunyco",
|
|
33
35
|
label: '2.13"',
|
|
34
36
|
width: 250,
|
|
35
37
|
height: 128,
|
|
@@ -38,6 +40,7 @@ exports.ZHSUNYCO_PID_METADATA = [
|
|
|
38
40
|
},
|
|
39
41
|
{
|
|
40
42
|
pid: 0x000e,
|
|
43
|
+
manufacturer: "Zhsunyco",
|
|
41
44
|
label: '3.7"',
|
|
42
45
|
width: 416,
|
|
43
46
|
height: 240,
|
|
@@ -47,6 +50,7 @@ exports.ZHSUNYCO_PID_METADATA = [
|
|
|
47
50
|
{
|
|
48
51
|
pid: 0x000e,
|
|
49
52
|
hwVersion: "0103",
|
|
53
|
+
manufacturer: "Zhsunyco",
|
|
50
54
|
label: '2.13"',
|
|
51
55
|
width: 250,
|
|
52
56
|
height: 128,
|
|
@@ -56,6 +60,7 @@ exports.ZHSUNYCO_PID_METADATA = [
|
|
|
56
60
|
{
|
|
57
61
|
pid: 0x000e,
|
|
58
62
|
hwVersion: "0201",
|
|
63
|
+
manufacturer: "Zhsunyco",
|
|
59
64
|
label: '3.5"',
|
|
60
65
|
width: 384,
|
|
61
66
|
height: 184,
|
|
@@ -65,6 +70,7 @@ exports.ZHSUNYCO_PID_METADATA = [
|
|
|
65
70
|
{
|
|
66
71
|
pid: 0x000e,
|
|
67
72
|
hwVersion: "0203",
|
|
73
|
+
manufacturer: "Zhsunyco",
|
|
68
74
|
label: '7.5"',
|
|
69
75
|
width: 800,
|
|
70
76
|
height: 480,
|
|
@@ -73,6 +79,7 @@ exports.ZHSUNYCO_PID_METADATA = [
|
|
|
73
79
|
},
|
|
74
80
|
{
|
|
75
81
|
pid: 0x0012,
|
|
82
|
+
manufacturer: "Zhsunyco",
|
|
76
83
|
label: '2.9"',
|
|
77
84
|
width: 296,
|
|
78
85
|
height: 128,
|
|
@@ -81,6 +88,7 @@ exports.ZHSUNYCO_PID_METADATA = [
|
|
|
81
88
|
},
|
|
82
89
|
{
|
|
83
90
|
pid: 0x0016,
|
|
91
|
+
manufacturer: "Zhsunyco",
|
|
84
92
|
label: '4.2"',
|
|
85
93
|
width: 400,
|
|
86
94
|
height: 300,
|
|
@@ -89,6 +97,7 @@ exports.ZHSUNYCO_PID_METADATA = [
|
|
|
89
97
|
},
|
|
90
98
|
{
|
|
91
99
|
pid: 0x001a,
|
|
100
|
+
manufacturer: "Zhsunyco",
|
|
92
101
|
label: '5.8"',
|
|
93
102
|
width: 648,
|
|
94
103
|
height: 480,
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { ServerAPI, Plugin } from "@signalk/server-api";
|
|
2
2
|
import { registerDriver, getDriver, allDrivers } from "./devices/registry";
|
|
3
|
+
import { registerTemplateProvider as registerTemplateProviderImpl, findTemplateProvider as findTemplateProviderImpl, allTemplateProviders as allTemplateProvidersImpl } from "./render/templateProviders";
|
|
4
|
+
import { SvgRenderer } from "./render/svgRenderer";
|
|
5
|
+
import { bitmapToPng as bitmapToPngImpl } from "./render/png";
|
|
6
|
+
import { buildLabelContext, findTextBindings, substituteTextBindings } from "./render/binding";
|
|
3
7
|
import type { VendorDriver as VendorDriverType, DeviceMetadata as DeviceMetadataType, DiscoveredDevice as DiscoveredDeviceType, VendorDeviceConfig as VendorDeviceConfigType, Colour as ColourType } from "./devices/types";
|
|
8
|
+
import type { TemplateProvider as TemplateProviderType, TemplateRenderRequest as TemplateRenderRequestType } from "./render/templateProviders";
|
|
9
|
+
import type { LabelMeta as LabelMetaType } from "./render/binding";
|
|
10
|
+
import type { Bitmap as BitmapType, TemplateContext as TemplateContextType } from "./render/types";
|
|
4
11
|
/**
|
|
5
12
|
* Public extension point for vendor packages. A package that adds support for a new
|
|
6
13
|
* ESL vendor (e.g. `signalk-esl-shoplabelcorp-plugin`) imports this module and calls
|
|
@@ -8,19 +15,45 @@ import type { VendorDriver as VendorDriverType, DeviceMetadata as DeviceMetadata
|
|
|
8
15
|
* `start()` (or at module load time). There's no scanning of installed packages -
|
|
9
16
|
* registration is always an explicit call by the extension's own code.
|
|
10
17
|
*
|
|
11
|
-
* Declare this package as a `
|
|
12
|
-
*
|
|
13
|
-
*
|
|
18
|
+
* Declare this package as a regular `dependency` in the extension package (**not** a
|
|
19
|
+
* `peerDependency` - the SignalK team's own guidance is that npm's peer-dependency resolution
|
|
20
|
+
* interacts poorly with the server's plugin install layout), and declare the SignalK-level
|
|
21
|
+
* relationship via `"signalk": { "requires": ["@rhizomatics/signalk-einklabel-plugin"] }` in the
|
|
22
|
+
* extension's own package.json instead, so the App Store can install/report the dependency.
|
|
14
23
|
*/
|
|
15
24
|
declare function plugin(app: ServerAPI): Plugin;
|
|
16
25
|
declare namespace plugin {
|
|
17
26
|
const registerVendorDriver: typeof registerDriver;
|
|
18
27
|
const getVendorDriver: typeof getDriver;
|
|
19
28
|
const allVendorDrivers: typeof allDrivers;
|
|
29
|
+
/**
|
|
30
|
+
* Public extension point for a package offering an alternative to hand-authored SVG templates - e.g.
|
|
31
|
+
* `@rhizomatics/signalk-einklabel-genai-plugin` generating content from an LLM prompt - see
|
|
32
|
+
* `TemplateProvider`'s own doc comment (`./render/templateProviders.ts`) for the full contract. Same
|
|
33
|
+
* regular-`dependency`-plus-`signalk.requires` convention as `registerVendorDriver` above.
|
|
34
|
+
*/
|
|
35
|
+
const registerTemplateProvider: typeof registerTemplateProviderImpl;
|
|
36
|
+
const getTemplateProvider: typeof findTemplateProviderImpl;
|
|
37
|
+
const allTemplateProviders: typeof allTemplateProvidersImpl;
|
|
38
|
+
/** Rasterizes an SVG string to a `Bitmap` - a template provider needs this to turn whatever SVG it produces (e.g. an LLM's response) into paintable pixels, exactly as a bundled template is rendered. */
|
|
39
|
+
const Renderer: typeof SvgRenderer;
|
|
40
|
+
/** Encodes a `Bitmap` as PNG bytes - useful for a CLI extension writing a preview file, same as the core plugin's own `render`/`generate` commands. */
|
|
41
|
+
const bitmapToPng: typeof bitmapToPngImpl;
|
|
42
|
+
/** Facts about one physical label (`source=label,path=...` bindings resolve against these) - see `./render/binding.ts`. */
|
|
43
|
+
const buildLabel: typeof buildLabelContext;
|
|
44
|
+
/** Every `{...}` placeholder referenced across one or more text fragments, parsed as bindings - see `./render/binding.ts`. */
|
|
45
|
+
const findBindingsInText: typeof findTextBindings;
|
|
46
|
+
/** Substitutes every `{...}` placeholder in `text` with its resolved binding value - see `./render/binding.ts`. */
|
|
47
|
+
const substituteBindingsInText: typeof substituteTextBindings;
|
|
20
48
|
type VendorDriver = VendorDriverType;
|
|
21
49
|
type DeviceMetadata = DeviceMetadataType;
|
|
22
50
|
type DiscoveredDevice = DiscoveredDeviceType;
|
|
23
51
|
type VendorDeviceConfig = VendorDeviceConfigType;
|
|
24
52
|
type Colour = ColourType;
|
|
53
|
+
type TemplateProvider = TemplateProviderType;
|
|
54
|
+
type TemplateRenderRequest = TemplateRenderRequestType;
|
|
55
|
+
type LabelMeta = LabelMetaType;
|
|
56
|
+
type Bitmap = BitmapType;
|
|
57
|
+
type TemplateContext = TemplateContextType;
|
|
25
58
|
}
|
|
26
59
|
export = plugin;
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
const plugin_1 = require("./plugin");
|
|
3
3
|
const registry_1 = require("./devices/registry");
|
|
4
|
+
const templateProviders_1 = require("./render/templateProviders");
|
|
5
|
+
const svgRenderer_1 = require("./render/svgRenderer");
|
|
6
|
+
const png_1 = require("./render/png");
|
|
7
|
+
const binding_1 = require("./render/binding");
|
|
4
8
|
/**
|
|
5
9
|
* Public extension point for vendor packages. A package that adds support for a new
|
|
6
10
|
* ESL vendor (e.g. `signalk-esl-shoplabelcorp-plugin`) imports this module and calls
|
|
@@ -8,9 +12,11 @@ const registry_1 = require("./devices/registry");
|
|
|
8
12
|
* `start()` (or at module load time). There's no scanning of installed packages -
|
|
9
13
|
* registration is always an explicit call by the extension's own code.
|
|
10
14
|
*
|
|
11
|
-
* Declare this package as a `
|
|
12
|
-
*
|
|
13
|
-
*
|
|
15
|
+
* Declare this package as a regular `dependency` in the extension package (**not** a
|
|
16
|
+
* `peerDependency` - the SignalK team's own guidance is that npm's peer-dependency resolution
|
|
17
|
+
* interacts poorly with the server's plugin install layout), and declare the SignalK-level
|
|
18
|
+
* relationship via `"signalk": { "requires": ["@rhizomatics/signalk-einklabel-plugin"] }` in the
|
|
19
|
+
* extension's own package.json instead, so the App Store can install/report the dependency.
|
|
14
20
|
*/
|
|
15
21
|
function plugin(app) {
|
|
16
22
|
return (0, plugin_1.createPlugin)(app);
|
|
@@ -19,5 +25,24 @@ function plugin(app) {
|
|
|
19
25
|
plugin.registerVendorDriver = registry_1.registerDriver;
|
|
20
26
|
plugin.getVendorDriver = registry_1.getDriver;
|
|
21
27
|
plugin.allVendorDrivers = registry_1.allDrivers;
|
|
28
|
+
/**
|
|
29
|
+
* Public extension point for a package offering an alternative to hand-authored SVG templates - e.g.
|
|
30
|
+
* `@rhizomatics/signalk-einklabel-genai-plugin` generating content from an LLM prompt - see
|
|
31
|
+
* `TemplateProvider`'s own doc comment (`./render/templateProviders.ts`) for the full contract. Same
|
|
32
|
+
* regular-`dependency`-plus-`signalk.requires` convention as `registerVendorDriver` above.
|
|
33
|
+
*/
|
|
34
|
+
plugin.registerTemplateProvider = templateProviders_1.registerTemplateProvider;
|
|
35
|
+
plugin.getTemplateProvider = templateProviders_1.findTemplateProvider;
|
|
36
|
+
plugin.allTemplateProviders = templateProviders_1.allTemplateProviders;
|
|
37
|
+
/** Rasterizes an SVG string to a `Bitmap` - a template provider needs this to turn whatever SVG it produces (e.g. an LLM's response) into paintable pixels, exactly as a bundled template is rendered. */
|
|
38
|
+
plugin.Renderer = svgRenderer_1.SvgRenderer;
|
|
39
|
+
/** Encodes a `Bitmap` as PNG bytes - useful for a CLI extension writing a preview file, same as the core plugin's own `render`/`generate` commands. */
|
|
40
|
+
plugin.bitmapToPng = png_1.bitmapToPng;
|
|
41
|
+
/** Facts about one physical label (`source=label,path=...` bindings resolve against these) - see `./render/binding.ts`. */
|
|
42
|
+
plugin.buildLabel = binding_1.buildLabelContext;
|
|
43
|
+
/** Every `{...}` placeholder referenced across one or more text fragments, parsed as bindings - see `./render/binding.ts`. */
|
|
44
|
+
plugin.findBindingsInText = binding_1.findTextBindings;
|
|
45
|
+
/** Substitutes every `{...}` placeholder in `text` with its resolved binding value - see `./render/binding.ts`. */
|
|
46
|
+
plugin.substituteBindingsInText = binding_1.substituteTextBindings;
|
|
22
47
|
})(plugin || (plugin = {}));
|
|
23
48
|
module.exports = plugin;
|
package/dist/render/binding.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { Colour } from "../devices/types";
|
|
1
2
|
import { TemplateContext } from "./types";
|
|
2
|
-
declare const SOURCES: readonly ["signalk", "resources", "einklabel"];
|
|
3
|
+
declare const SOURCES: readonly ["signalk", "resources", "einklabel", "label"];
|
|
3
4
|
type Source = (typeof SOURCES)[number];
|
|
4
5
|
/**
|
|
5
6
|
* Parsed form of a `<desc>`'s `key=value,key=value` content - see `parseBinding` for the grammar.
|
|
@@ -19,7 +20,12 @@ export interface Binding {
|
|
|
19
20
|
* for). Set this to pin a template to one provider regardless of what else is installed.
|
|
20
21
|
*/
|
|
21
22
|
provider?: string;
|
|
22
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* For `source === 'einklabel'`, a dotted path into the plugin's own injected `meta` (e.g. `repainted`);
|
|
25
|
+
* for `source === 'label'`, a dotted path into the physical label's own facts (`manufacturer`, `label`,
|
|
26
|
+
* `width`, `height`, `colours`, `fonts`, `description`, `position` - see `buildLabelContext` below)
|
|
27
|
+
* rather than into vessel/resource data.
|
|
28
|
+
*/
|
|
23
29
|
path: string;
|
|
24
30
|
/** A named formatter (see `./formatters.ts`), or `'raw'` to suppress automatic unit conversion (see `renderBinding`). */
|
|
25
31
|
format?: string;
|
|
@@ -35,6 +41,14 @@ export interface Binding {
|
|
|
35
41
|
* template doesn't also require duplicating its bundled asset sets.
|
|
36
42
|
*/
|
|
37
43
|
assets?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Substituted in place of the resolved value when that value is missing (`undefined`/`null`,
|
|
46
|
+
* e.g. an unpublished SignalK path) - see `renderBinding`. Distinct from an *unset* default (this
|
|
47
|
+
* field itself being `undefined`), which falls through to the pre-existing "" fallback - explicitly
|
|
48
|
+
* writing `default=` (an empty value) still counts as "given", so a binding can deliberately default
|
|
49
|
+
* to blank rather than to `substituteTextBindings`' own "???" below.
|
|
50
|
+
*/
|
|
51
|
+
default?: string;
|
|
38
52
|
}
|
|
39
53
|
/**
|
|
40
54
|
* Parses a `<desc>` element's text content into a `Binding`, e.g.
|
|
@@ -69,6 +83,8 @@ export declare function resolveBinding(binding: Binding, context: TemplateContex
|
|
|
69
83
|
* the CLI's `field`/`fields` commands show the same thing a real render would.
|
|
70
84
|
*
|
|
71
85
|
* Precedence for a numeric value:
|
|
86
|
+
* 0. A missing value (`undefined`/`null`, e.g. an unpublished path) with an explicit `default=` given -
|
|
87
|
+
* that default, verbatim, bypassing every step below (there's nothing to format).
|
|
72
88
|
* 1. An explicit named `format=` (anything other than `raw`) - `local_time`/`utc_offset`/`position`.
|
|
73
89
|
* 2. An explicit `category=` - for values with no path metadata of their own, e.g. a `source=resources`
|
|
74
90
|
* value.
|
|
@@ -76,7 +92,65 @@ export declare function resolveBinding(binding: Binding, context: TemplateContex
|
|
|
76
92
|
* `context.pathMeta`) by default - `format=raw` opts out of this step only.
|
|
77
93
|
* 4. Falls through to `round=` (`toFixed`), `JSON.stringify` for an unformatted object/array value
|
|
78
94
|
* (e.g. a path that resolved to a whole sub-tree rather than a leaf) instead of the useless
|
|
79
|
-
* `String(value)` -> `"[object Object]"`, else `String`.
|
|
95
|
+
* `String(value)` -> `"[object Object]"`, else `String`. A missing value with no `default=` given
|
|
96
|
+
* still falls through to the pre-existing "" here, unchanged from before `default=` existed.
|
|
80
97
|
*/
|
|
81
98
|
export declare function renderBinding(binding: Binding, context: TemplateContext): string;
|
|
99
|
+
/** Facts about one physical label a `source=label,path=...` binding can reference - see `buildLabelContext`. */
|
|
100
|
+
export interface LabelMeta {
|
|
101
|
+
manufacturer: string;
|
|
102
|
+
/** The physical panel's own size label, e.g. `'3.7"'` - `DeviceMetadata.label` verbatim, see `../devices/types.ts`. */
|
|
103
|
+
label: string;
|
|
104
|
+
width: number;
|
|
105
|
+
height: number;
|
|
106
|
+
colours: Colour[];
|
|
107
|
+
description?: string;
|
|
108
|
+
position?: {
|
|
109
|
+
latitude: number;
|
|
110
|
+
longitude: number;
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Builds the `context.label` object a `source=label,path=...` binding addresses (e.g.
|
|
115
|
+
* `{source=label,path=width}` in free text, or a `<desc>source=label,path=width</desc>` in an SVG
|
|
116
|
+
* template - every `{...}`/`<desc>` placeholder is a real binding, so a `label` path always needs the
|
|
117
|
+
* explicit `source=label,path=` form to disambiguate it from a `signalk` self path). `colours`/`fonts`
|
|
118
|
+
* are left as arrays (each colour entry pre-annotated with its hex code, e.g. `"black (#000000)"`)
|
|
119
|
+
* rather than joined into a single string, so a caller can either use them bare (renders as JSON) or add
|
|
120
|
+
* `format=csv` (see `./formatters.ts`) for a plain comma-separated list. `position`, if given, is rounded
|
|
121
|
+
* to ~2 decimal places (~1.1km) - fine-grained enough to be meaningfully "for here", coarse enough that
|
|
122
|
+
* ordinary GPS jitter at anchor doesn't change it tick to tick, which matters because `considerRepaint`
|
|
123
|
+
* (`../repaintScheduler.ts`) folds this whole object into every device's dedup hash - full-precision
|
|
124
|
+
* jitter here would otherwise force a repaint (and, for a provider-rendered template, a fresh paid API
|
|
125
|
+
* call) far more often than the underlying position has actually meaningfully changed.
|
|
126
|
+
*/
|
|
127
|
+
export declare function buildLabelContext(meta: LabelMeta): Record<string, unknown>;
|
|
128
|
+
/**
|
|
129
|
+
* Every binding referenced across one or more free-text fragments - every `{...}` placeholder,
|
|
130
|
+
* deduplicated across all of `texts` combined, parsed exactly the way a template's `<desc>` binding is
|
|
131
|
+
* (`parseBinding` above), so a bare path (`{design.length}`, `source=signalk,context=self` shorthand),
|
|
132
|
+
* the full binding grammar (`{source=signalk,path=navigation.position,format=position}`), and a
|
|
133
|
+
* `source=label,path=...` binding all work uniformly. Pass the result to `assembleRawContext`
|
|
134
|
+
* (`../repaintScheduler.ts`) exactly as a template's own bindings are - it fetches the `signalk`/
|
|
135
|
+
* `resources`-sourced ones and silently ignores `label`/`einklabel`-sourced ones, which resolve directly
|
|
136
|
+
* against `context.label`/`context.meta` instead (built by the caller, not fetched).
|
|
137
|
+
*
|
|
138
|
+
* A placeholder that isn't valid binding grammar (e.g. a typo like `{source=taheight}`) is silently
|
|
139
|
+
* skipped here rather than thrown - the same per-field isolation `SvgRenderer` gives a bad `<desc>`
|
|
140
|
+
* binding, so one malformed placeholder doesn't take down the whole text. `substituteTextBindings` hits
|
|
141
|
+
* the identical parse error at substitution time and turns it into "???" for just that field.
|
|
142
|
+
*/
|
|
143
|
+
export declare function findTextBindings(...texts: string[]): Binding[];
|
|
144
|
+
/**
|
|
145
|
+
* Substitutes every `{...}` placeholder in `text` with its resolved binding value against `context`
|
|
146
|
+
* (built by `assembleRawContext` plus `context.label` from `buildLabelContext` - see
|
|
147
|
+
* `findTextBindings`). Mirrors `renderBinding`'s per-field isolation, but substitutes "???" for anything
|
|
148
|
+
* that resolves to no value at all (missing path, invalid binding grammar) rather than "" - prose with a
|
|
149
|
+
* silently-blank word reads as a fact ("for the sailor of a m vessel"), not as a gap the reader would
|
|
150
|
+
* notice. Two things override that "???": a binding that resolves successfully to a legitimately empty
|
|
151
|
+
* string (e.g. an unset `{source=label,path=description}`) is left as empty, since that's a real answer,
|
|
152
|
+
* not a miss; and a binding with an explicit `default=` uses that default instead, since the caller has
|
|
153
|
+
* already said what a missing value should read as.
|
|
154
|
+
*/
|
|
155
|
+
export declare function substituteTextBindings(text: string, context: TemplateContext): string;
|
|
82
156
|
export {};
|
package/dist/render/binding.js
CHANGED
|
@@ -5,10 +5,13 @@ exports.resourceContextKey = resourceContextKey;
|
|
|
5
5
|
exports.findBindings = findBindings;
|
|
6
6
|
exports.resolveBinding = resolveBinding;
|
|
7
7
|
exports.renderBinding = renderBinding;
|
|
8
|
+
exports.buildLabelContext = buildLabelContext;
|
|
9
|
+
exports.findTextBindings = findTextBindings;
|
|
10
|
+
exports.substituteTextBindings = substituteTextBindings;
|
|
8
11
|
const xmldom_1 = require("@xmldom/xmldom");
|
|
9
12
|
const formatters_1 = require("./formatters");
|
|
10
|
-
const SOURCES = ["signalk", "resources", "einklabel"];
|
|
11
|
-
const KNOWN_KEYS = new Set(["source", "context", "resource", "provider", "path", "format", "category", "round", "assets"]);
|
|
13
|
+
const SOURCES = ["signalk", "resources", "einklabel", "label"];
|
|
14
|
+
const KNOWN_KEYS = new Set(["source", "context", "resource", "provider", "path", "format", "category", "round", "assets", "default"]);
|
|
12
15
|
/**
|
|
13
16
|
* Parses a `<desc>` element's text content into a `Binding`, e.g.
|
|
14
17
|
* `source=resources,resource=tides,path=extremes[0].level,category=depth,round=2` or, using the
|
|
@@ -62,6 +65,7 @@ function parseBinding(desc) {
|
|
|
62
65
|
category: fields.category,
|
|
63
66
|
round: fields.round !== undefined ? Number(fields.round) : undefined,
|
|
64
67
|
assets: fields.assets,
|
|
68
|
+
default: fields.default,
|
|
65
69
|
};
|
|
66
70
|
}
|
|
67
71
|
/**
|
|
@@ -126,6 +130,13 @@ function resolveBinding(binding, context) {
|
|
|
126
130
|
}
|
|
127
131
|
return getAtPath(meta, binding.path);
|
|
128
132
|
}
|
|
133
|
+
if (binding.source === "label") {
|
|
134
|
+
const label = context.label;
|
|
135
|
+
if (label === undefined) {
|
|
136
|
+
throw new Error('binding references source "label" but no "label" is present in the render context');
|
|
137
|
+
}
|
|
138
|
+
return getAtPath(label, binding.path);
|
|
139
|
+
}
|
|
129
140
|
const resources = context.resources;
|
|
130
141
|
const resourceKey = resourceContextKey(binding);
|
|
131
142
|
const resource = resources?.[resourceKey];
|
|
@@ -166,6 +177,8 @@ function resolveCategoryDisplayUnits(binding, context) {
|
|
|
166
177
|
* the CLI's `field`/`fields` commands show the same thing a real render would.
|
|
167
178
|
*
|
|
168
179
|
* Precedence for a numeric value:
|
|
180
|
+
* 0. A missing value (`undefined`/`null`, e.g. an unpublished path) with an explicit `default=` given -
|
|
181
|
+
* that default, verbatim, bypassing every step below (there's nothing to format).
|
|
169
182
|
* 1. An explicit named `format=` (anything other than `raw`) - `local_time`/`utc_offset`/`position`.
|
|
170
183
|
* 2. An explicit `category=` - for values with no path metadata of their own, e.g. a `source=resources`
|
|
171
184
|
* value.
|
|
@@ -173,10 +186,13 @@ function resolveCategoryDisplayUnits(binding, context) {
|
|
|
173
186
|
* `context.pathMeta`) by default - `format=raw` opts out of this step only.
|
|
174
187
|
* 4. Falls through to `round=` (`toFixed`), `JSON.stringify` for an unformatted object/array value
|
|
175
188
|
* (e.g. a path that resolved to a whole sub-tree rather than a leaf) instead of the useless
|
|
176
|
-
* `String(value)` -> `"[object Object]"`, else `String`.
|
|
189
|
+
* `String(value)` -> `"[object Object]"`, else `String`. A missing value with no `default=` given
|
|
190
|
+
* still falls through to the pre-existing "" here, unchanged from before `default=` existed.
|
|
177
191
|
*/
|
|
178
192
|
function renderBinding(binding, context) {
|
|
179
193
|
const value = resolveBinding(binding, context);
|
|
194
|
+
if ((value === null || value === undefined) && binding.default !== undefined)
|
|
195
|
+
return binding.default;
|
|
180
196
|
if (binding.format && binding.format !== "raw")
|
|
181
197
|
return (0, formatters_1.applyFormat)(binding.format, value, context, binding.round);
|
|
182
198
|
if (typeof value === "number") {
|
|
@@ -194,3 +210,98 @@ function renderBinding(binding, context) {
|
|
|
194
210
|
return JSON.stringify(value);
|
|
195
211
|
return String(value);
|
|
196
212
|
}
|
|
213
|
+
const COLOUR_HEX = { black: "#000000", white: "#FFFFFF", red: "#FF0000", yellow: "#FFFF00" };
|
|
214
|
+
/** The only `font-family` values `SvgRenderer` is guaranteed to render - see `expandGenericFontFamilies` in `./svgRenderer.ts`. */
|
|
215
|
+
const SAFE_FONT_FAMILIES = ["serif", "sans-serif", "monospace"];
|
|
216
|
+
/**
|
|
217
|
+
* Builds the `context.label` object a `source=label,path=...` binding addresses (e.g.
|
|
218
|
+
* `{source=label,path=width}` in free text, or a `<desc>source=label,path=width</desc>` in an SVG
|
|
219
|
+
* template - every `{...}`/`<desc>` placeholder is a real binding, so a `label` path always needs the
|
|
220
|
+
* explicit `source=label,path=` form to disambiguate it from a `signalk` self path). `colours`/`fonts`
|
|
221
|
+
* are left as arrays (each colour entry pre-annotated with its hex code, e.g. `"black (#000000)"`)
|
|
222
|
+
* rather than joined into a single string, so a caller can either use them bare (renders as JSON) or add
|
|
223
|
+
* `format=csv` (see `./formatters.ts`) for a plain comma-separated list. `position`, if given, is rounded
|
|
224
|
+
* to ~2 decimal places (~1.1km) - fine-grained enough to be meaningfully "for here", coarse enough that
|
|
225
|
+
* ordinary GPS jitter at anchor doesn't change it tick to tick, which matters because `considerRepaint`
|
|
226
|
+
* (`../repaintScheduler.ts`) folds this whole object into every device's dedup hash - full-precision
|
|
227
|
+
* jitter here would otherwise force a repaint (and, for a provider-rendered template, a fresh paid API
|
|
228
|
+
* call) far more often than the underlying position has actually meaningfully changed.
|
|
229
|
+
*/
|
|
230
|
+
function buildLabelContext(meta) {
|
|
231
|
+
const position = meta.position && {
|
|
232
|
+
latitude: Math.round(meta.position.latitude * 100) / 100,
|
|
233
|
+
longitude: Math.round(meta.position.longitude * 100) / 100,
|
|
234
|
+
};
|
|
235
|
+
return {
|
|
236
|
+
manufacturer: meta.manufacturer,
|
|
237
|
+
label: meta.label,
|
|
238
|
+
width: meta.width,
|
|
239
|
+
height: meta.height,
|
|
240
|
+
colours: meta.colours.map((colour) => `${colour} (${COLOUR_HEX[colour]})`),
|
|
241
|
+
fonts: SAFE_FONT_FAMILIES,
|
|
242
|
+
description: meta.description ?? "",
|
|
243
|
+
position: position ? (0, formatters_1.applyFormat)("position", position, {}, 2) : undefined,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
function placeholderContents(text) {
|
|
247
|
+
return [...new Set([...text.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1].trim()))];
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Every binding referenced across one or more free-text fragments - every `{...}` placeholder,
|
|
251
|
+
* deduplicated across all of `texts` combined, parsed exactly the way a template's `<desc>` binding is
|
|
252
|
+
* (`parseBinding` above), so a bare path (`{design.length}`, `source=signalk,context=self` shorthand),
|
|
253
|
+
* the full binding grammar (`{source=signalk,path=navigation.position,format=position}`), and a
|
|
254
|
+
* `source=label,path=...` binding all work uniformly. Pass the result to `assembleRawContext`
|
|
255
|
+
* (`../repaintScheduler.ts`) exactly as a template's own bindings are - it fetches the `signalk`/
|
|
256
|
+
* `resources`-sourced ones and silently ignores `label`/`einklabel`-sourced ones, which resolve directly
|
|
257
|
+
* against `context.label`/`context.meta` instead (built by the caller, not fetched).
|
|
258
|
+
*
|
|
259
|
+
* A placeholder that isn't valid binding grammar (e.g. a typo like `{source=taheight}`) is silently
|
|
260
|
+
* skipped here rather than thrown - the same per-field isolation `SvgRenderer` gives a bad `<desc>`
|
|
261
|
+
* binding, so one malformed placeholder doesn't take down the whole text. `substituteTextBindings` hits
|
|
262
|
+
* the identical parse error at substitution time and turns it into "???" for just that field.
|
|
263
|
+
*/
|
|
264
|
+
function findTextBindings(...texts) {
|
|
265
|
+
const seen = new Set();
|
|
266
|
+
const bindings = [];
|
|
267
|
+
for (const text of texts) {
|
|
268
|
+
for (const key of placeholderContents(text)) {
|
|
269
|
+
if (seen.has(key))
|
|
270
|
+
continue;
|
|
271
|
+
seen.add(key);
|
|
272
|
+
try {
|
|
273
|
+
bindings.push(parseBinding(key));
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
// see doc comment above - left for `substituteTextBindings` to turn into "???"
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return bindings;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Substitutes every `{...}` placeholder in `text` with its resolved binding value against `context`
|
|
284
|
+
* (built by `assembleRawContext` plus `context.label` from `buildLabelContext` - see
|
|
285
|
+
* `findTextBindings`). Mirrors `renderBinding`'s per-field isolation, but substitutes "???" for anything
|
|
286
|
+
* that resolves to no value at all (missing path, invalid binding grammar) rather than "" - prose with a
|
|
287
|
+
* silently-blank word reads as a fact ("for the sailor of a m vessel"), not as a gap the reader would
|
|
288
|
+
* notice. Two things override that "???": a binding that resolves successfully to a legitimately empty
|
|
289
|
+
* string (e.g. an unset `{source=label,path=description}`) is left as empty, since that's a real answer,
|
|
290
|
+
* not a miss; and a binding with an explicit `default=` uses that default instead, since the caller has
|
|
291
|
+
* already said what a missing value should read as.
|
|
292
|
+
*/
|
|
293
|
+
function substituteTextBindings(text, context) {
|
|
294
|
+
return text.replace(/\{([^{}]+)\}/g, (_match, raw) => {
|
|
295
|
+
const key = raw.trim();
|
|
296
|
+
try {
|
|
297
|
+
const binding = parseBinding(key);
|
|
298
|
+
const value = resolveBinding(binding, context);
|
|
299
|
+
if ((value === undefined || value === null) && binding.default === undefined)
|
|
300
|
+
return "???";
|
|
301
|
+
return renderBinding(binding, context);
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
return "???";
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
}
|
|
@@ -101,6 +101,22 @@ function formatPosition(value, round) {
|
|
|
101
101
|
const lonHemisphere = position.longitude >= 0 ? "E" : "W";
|
|
102
102
|
return `${lat}°${latHemisphere} ${lon}°${lonHemisphere}`;
|
|
103
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* A resolved value that's an array (e.g. `source=label,path=colours` - see `../render/llmPrompt.ts`'s
|
|
106
|
+
* `buildLabelContext`) joined into a plain comma-separated list, e.g. `["black","white"]` ->
|
|
107
|
+
* `"black, white"`. A non-array value falls through to the same null/object/scalar handling
|
|
108
|
+
* `renderBinding` uses for its own generic fallback, so `format=csv` is harmless on an ordinary
|
|
109
|
+
* scalar binding too.
|
|
110
|
+
*/
|
|
111
|
+
function formatCsv(value) {
|
|
112
|
+
if (Array.isArray(value))
|
|
113
|
+
return value.map((entry) => String(entry)).join(", ");
|
|
114
|
+
if (value === null || value === undefined)
|
|
115
|
+
return "";
|
|
116
|
+
if (typeof value === "object")
|
|
117
|
+
return JSON.stringify(value);
|
|
118
|
+
return String(value);
|
|
119
|
+
}
|
|
104
120
|
/** Applies a named `format=` formatter to a resolved binding value. */
|
|
105
121
|
function applyFormat(name, value, context, round) {
|
|
106
122
|
switch (name) {
|
|
@@ -114,6 +130,8 @@ function applyFormat(name, value, context, round) {
|
|
|
114
130
|
return formatUtcOffset(value);
|
|
115
131
|
case "position":
|
|
116
132
|
return formatPosition(value, round);
|
|
133
|
+
case "csv":
|
|
134
|
+
return formatCsv(value);
|
|
117
135
|
default:
|
|
118
136
|
throw new Error(`unknown format "${name}"`);
|
|
119
137
|
}
|