@rhizomatics/signalk-einklabel-plugin 0.6.4 → 0.7.1
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 +22 -0
- package/README.md +19 -10
- package/dist/cli/index.js +129 -96
- package/dist/cli/liveContext.d.ts +2 -2
- package/dist/cli/liveContext.js +16 -38
- package/dist/cli/log.d.ts +1 -1
- package/dist/cli/log.js +4 -4
- package/dist/config.d.ts +12 -3
- package/dist/config.js +71 -49
- package/dist/devices/bleDiscovery.d.ts +1 -1
- package/dist/devices/bleDiscovery.js +9 -3
- package/dist/devices/registry.d.ts +1 -1
- package/dist/devices/types.d.ts +4 -4
- package/dist/devices/zhsunyco/encode.d.ts +2 -2
- package/dist/devices/zhsunyco/index.d.ts +3 -3
- package/dist/devices/zhsunyco/index.js +10 -9
- package/dist/devices/zhsunyco/metadata.d.ts +1 -1
- package/dist/devices/zhsunyco/metadata.js +12 -12
- package/dist/devices/zhsunyco/protocol.js +10 -10
- package/dist/httpJson.js +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/pathMeta.d.ts +1 -1
- package/dist/pathMeta.js +1 -1
- package/dist/plugin.d.ts +1 -1
- package/dist/plugin.js +16 -13
- package/dist/pluginVersion.d.ts +9 -0
- package/dist/pluginVersion.js +12 -2
- package/dist/render/assets.d.ts +14 -6
- package/dist/render/assets.js +65 -10
- package/dist/render/binding.d.ts +9 -2
- package/dist/render/binding.js +23 -23
- package/dist/render/fonts.js +6 -6
- package/dist/render/formatters.d.ts +1 -1
- package/dist/render/formatters.js +25 -22
- package/dist/render/png.d.ts +1 -1
- package/dist/render/svgRenderer.d.ts +9 -8
- package/dist/render/svgRenderer.js +33 -27
- package/dist/render/types.d.ts +1 -1
- package/dist/render/unwrapSignalkTree.d.ts +15 -0
- package/dist/render/unwrapSignalkTree.js +28 -0
- package/dist/repaintScheduler.d.ts +2 -2
- package/dist/repaintScheduler.js +37 -27
- package/dist/resolveApiUrl.js +3 -3
- package/dist/unitCategories.d.ts +1 -1
- package/dist/unitCategories.js +3 -3
- package/docs/assets/screenshots/plugin_config.png +0 -0
- package/package.json +42 -39
- package/templates/assets/lunar_phases/README.md +18 -0
- package/templates/tide.svg +11 -11
- /package/docs/assets/{inkscape_desc.png → screenshots/inkscape_desc.png} +0 -0
- /package/{resources/svg → templates/assets}/lunar_phases/first_quarter.svg +0 -0
- /package/{resources/svg → templates/assets}/lunar_phases/full_moon.svg +0 -0
- /package/{resources/svg → templates/assets}/lunar_phases/new_moon.svg +0 -0
- /package/{resources/svg → templates/assets}/lunar_phases/third_quarter.svg +0 -0
- /package/{resources/svg → templates/assets}/lunar_phases/waning_crescent.svg +0 -0
- /package/{resources/svg → templates/assets}/lunar_phases/waning_gibbous.svg +0 -0
- /package/{resources/svg → templates/assets}/lunar_phases/waxing_crescent.svg +0 -0
- /package/{resources/svg → templates/assets}/lunar_phases/waxing_gibbous.svg +0 -0
|
@@ -8,7 +8,7 @@ const mathjs_1 = require("mathjs");
|
|
|
8
8
|
/** Converts a base-SI value (always what SignalK paths deliver) to its metadata's preferred display unit and formats it with the unit's symbol, e.g. 3.42 -> "11.2ft". */
|
|
9
9
|
function formatDisplayUnits(value, displayUnits, round) {
|
|
10
10
|
const converted = displayUnits.formula ? Number((0, mathjs_1.evaluate)(displayUnits.formula, { value })) : value;
|
|
11
|
-
const decimals = round ?? (displayUnits.displayFormat?.includes(
|
|
11
|
+
const decimals = round ?? (displayUnits.displayFormat?.includes(".") ? displayUnits.displayFormat.split(".")[1].length : 0);
|
|
12
12
|
const symbol = displayUnits.symbol ?? displayUnits.targetUnit;
|
|
13
13
|
return `${converted.toFixed(decimals)}${symbol}`;
|
|
14
14
|
}
|
|
@@ -37,10 +37,13 @@ function selfTimezone(context) {
|
|
|
37
37
|
* binding (see `considerRepaint`).
|
|
38
38
|
*/
|
|
39
39
|
function resolveLocalZoneAbbreviation(context) {
|
|
40
|
-
const part = new Intl.DateTimeFormat(undefined, {
|
|
40
|
+
const part = new Intl.DateTimeFormat(undefined, {
|
|
41
|
+
timeZone: selfTimezone(context),
|
|
42
|
+
timeZoneName: "short",
|
|
43
|
+
})
|
|
41
44
|
.formatToParts(new Date())
|
|
42
|
-
.find((p) => p.type ===
|
|
43
|
-
return part?.value ??
|
|
45
|
+
.find((p) => p.type === "timeZoneName");
|
|
46
|
+
return part?.value ?? "";
|
|
44
47
|
}
|
|
45
48
|
/**
|
|
46
49
|
* A `resources`-sourced timestamp can arrive as either an ISO string (always true over HTTP, since
|
|
@@ -51,9 +54,9 @@ function resolveLocalZoneAbbreviation(context) {
|
|
|
51
54
|
*/
|
|
52
55
|
function parseTimestamp(value) {
|
|
53
56
|
if (value instanceof Date)
|
|
54
|
-
return luxon_1.DateTime.fromJSDate(value, { zone:
|
|
55
|
-
if (typeof value ===
|
|
56
|
-
return luxon_1.DateTime.fromISO(value, { zone:
|
|
57
|
+
return luxon_1.DateTime.fromJSDate(value, { zone: "utc" });
|
|
58
|
+
if (typeof value === "string")
|
|
59
|
+
return luxon_1.DateTime.fromISO(value, { zone: "utc" });
|
|
57
60
|
return undefined;
|
|
58
61
|
}
|
|
59
62
|
/**
|
|
@@ -63,51 +66,51 @@ function parseTimestamp(value) {
|
|
|
63
66
|
*/
|
|
64
67
|
function formatLocalTime(value, context) {
|
|
65
68
|
const dt = parseTimestamp(value)?.setZone(selfTimezone(context));
|
|
66
|
-
return dt?.isValid ? dt.toFormat(
|
|
69
|
+
return dt?.isValid ? dt.toFormat("HH:mm") : "";
|
|
67
70
|
}
|
|
68
71
|
/** e.g. "27 Jun" - day of month (no leading zero) and abbreviated month name, in the local vessel's timezone. */
|
|
69
72
|
function formatDayMonth(value, context) {
|
|
70
73
|
const dt = parseTimestamp(value)?.setZone(selfTimezone(context));
|
|
71
|
-
return dt?.isValid ? dt.toFormat(
|
|
74
|
+
return dt?.isValid ? dt.toFormat("d MMM") : "";
|
|
72
75
|
}
|
|
73
76
|
/** e.g. "21 Jun 26 18:05" - day of month (no leading zero), abbreviated month, 2-digit year, and 24h time, in the local vessel's timezone. */
|
|
74
77
|
function formatLocalDatetimeShort(value, context) {
|
|
75
78
|
const dt = parseTimestamp(value)?.setZone(selfTimezone(context));
|
|
76
|
-
return dt?.isValid ? dt.toFormat(
|
|
79
|
+
return dt?.isValid ? dt.toFormat("d MMM yy HH:mm") : "";
|
|
77
80
|
}
|
|
78
81
|
/**
|
|
79
82
|
* IANA region names are ambiguous about DST (e.g. "Europe/London" is UTC+00:00 in winter, UTC+01:00
|
|
80
83
|
* in summer); show the numeric offset actually in effect.
|
|
81
84
|
*/
|
|
82
85
|
function formatUtcOffset(value) {
|
|
83
|
-
if (typeof value !==
|
|
84
|
-
return
|
|
86
|
+
if (typeof value !== "string" || !value)
|
|
87
|
+
return "";
|
|
85
88
|
const dt = luxon_1.DateTime.now().setZone(value);
|
|
86
|
-
return dt.isValid ? `UTC${dt.toFormat(
|
|
89
|
+
return dt.isValid ? `UTC${dt.toFormat("ZZ")}` : "";
|
|
87
90
|
}
|
|
88
91
|
function formatPosition(value, round) {
|
|
89
92
|
const position = value;
|
|
90
|
-
if (typeof position?.latitude !==
|
|
91
|
-
return
|
|
93
|
+
if (typeof position?.latitude !== "number" || typeof position?.longitude !== "number")
|
|
94
|
+
return "";
|
|
92
95
|
const decimals = round ?? 4;
|
|
93
96
|
const lat = Math.abs(position.latitude).toFixed(decimals);
|
|
94
97
|
const lon = Math.abs(position.longitude).toFixed(decimals);
|
|
95
|
-
const latHemisphere = position.latitude >= 0 ?
|
|
96
|
-
const lonHemisphere = position.longitude >= 0 ?
|
|
98
|
+
const latHemisphere = position.latitude >= 0 ? "N" : "S";
|
|
99
|
+
const lonHemisphere = position.longitude >= 0 ? "E" : "W";
|
|
97
100
|
return `${lat}°${latHemisphere} ${lon}°${lonHemisphere}`;
|
|
98
101
|
}
|
|
99
102
|
/** Applies a named `format=` formatter to a resolved binding value. */
|
|
100
103
|
function applyFormat(name, value, context, round) {
|
|
101
104
|
switch (name) {
|
|
102
|
-
case
|
|
105
|
+
case "local_time":
|
|
103
106
|
return formatLocalTime(value, context);
|
|
104
|
-
case
|
|
107
|
+
case "day_mon":
|
|
105
108
|
return formatDayMonth(value, context);
|
|
106
|
-
case
|
|
109
|
+
case "local_datetime_short":
|
|
107
110
|
return formatLocalDatetimeShort(value, context);
|
|
108
|
-
case
|
|
111
|
+
case "utc_offset":
|
|
109
112
|
return formatUtcOffset(value);
|
|
110
|
-
case
|
|
113
|
+
case "position":
|
|
111
114
|
return formatPosition(value, round);
|
|
112
115
|
default:
|
|
113
116
|
throw new Error(`unknown format "${name}"`);
|
package/dist/render/png.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Bitmap, Renderer, TemplateContext } from
|
|
1
|
+
import { Bitmap, Renderer, TemplateContext } from "./types";
|
|
2
2
|
/**
|
|
3
3
|
* Renders an SVG+binding template to a common RGBA bitmap.
|
|
4
4
|
*
|
|
@@ -17,12 +17,13 @@ import { Bitmap, Renderer, TemplateContext } from './types';
|
|
|
17
17
|
* whole tree and rendered blank.
|
|
18
18
|
*
|
|
19
19
|
* Non-textual inclusion: an `<image>` element with a `<desc>` child works the same way, except the
|
|
20
|
-
* resolved value picks one of a directory of `.svg` files (an `assets=` key, required, names
|
|
21
|
-
* directory
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
20
|
+
* resolved value picks one of a directory of `.svg` files (an `assets=` key, required, names an
|
|
21
|
+
* `assets/<name>` directory looked up under `templatesDir` first and `BUNDLED_TEMPLATES_DIR` otherwise
|
|
22
|
+
* - see `resolveAssetPath` in `./assets.ts`) rather than substituting text, and the element is dropped
|
|
23
|
+
* entirely (rather than showing an error placeholder) whenever the value is unavailable or doesn't map
|
|
24
|
+
* to a file - e.g. a device without the `derived-data` plugin installed simply shows no moon-phase
|
|
25
|
+
* icon. The matched file's contents are inlined as a `data:image/svg+xml;base64,...` URI so no
|
|
26
|
+
* filesystem access happens at resvg-wasm's render step.
|
|
26
27
|
*
|
|
27
28
|
* resvg-wasm cannot see the host's installed fonts (`loadSystemFonts`/`fontFiles`
|
|
28
29
|
* are silently no-ops under plain Node) - it only renders text if given font
|
|
@@ -36,5 +37,5 @@ export declare class SvgRenderer implements Renderer {
|
|
|
36
37
|
private fontBuffers?;
|
|
37
38
|
constructor(fontPaths?: string[]);
|
|
38
39
|
private loadFontBuffers;
|
|
39
|
-
render(svgTemplatePath: string, context: TemplateContext, width: number, height: number): Promise<Bitmap>;
|
|
40
|
+
render(svgTemplatePath: string, context: TemplateContext, width: number, height: number, templatesDir?: string, bundledTemplatesDir?: string): Promise<Bitmap>;
|
|
40
41
|
}
|
|
@@ -2,15 +2,18 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.SvgRenderer = void 0;
|
|
4
4
|
const promises_1 = require("fs/promises");
|
|
5
|
+
const path_1 = require("path");
|
|
5
6
|
const xmldom_1 = require("@xmldom/xmldom");
|
|
6
7
|
const resvg_wasm_1 = require("@resvg/resvg-wasm");
|
|
7
8
|
const binding_1 = require("./binding");
|
|
8
9
|
const assets_1 = require("./assets");
|
|
9
10
|
const fonts_1 = require("./fonts");
|
|
11
|
+
const pluginVersion_1 = require("../pluginVersion");
|
|
12
|
+
const config_1 = require("../config");
|
|
10
13
|
let wasmReady;
|
|
11
14
|
function ensureWasmInitialized() {
|
|
12
15
|
if (!wasmReady) {
|
|
13
|
-
wasmReady = (0, promises_1.readFile)(require.resolve(
|
|
16
|
+
wasmReady = (0, promises_1.readFile)(require.resolve("@resvg/resvg-wasm/index_bg.wasm")).then((buffer) => (0, resvg_wasm_1.initWasm)(buffer));
|
|
14
17
|
}
|
|
15
18
|
return wasmReady;
|
|
16
19
|
}
|
|
@@ -53,12 +56,13 @@ function expandGenericFontFamilies(svgSource) {
|
|
|
53
56
|
* whole tree and rendered blank.
|
|
54
57
|
*
|
|
55
58
|
* Non-textual inclusion: an `<image>` element with a `<desc>` child works the same way, except the
|
|
56
|
-
* resolved value picks one of a directory of `.svg` files (an `assets=` key, required, names
|
|
57
|
-
* directory
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
59
|
+
* resolved value picks one of a directory of `.svg` files (an `assets=` key, required, names an
|
|
60
|
+
* `assets/<name>` directory looked up under `templatesDir` first and `BUNDLED_TEMPLATES_DIR` otherwise
|
|
61
|
+
* - see `resolveAssetPath` in `./assets.ts`) rather than substituting text, and the element is dropped
|
|
62
|
+
* entirely (rather than showing an error placeholder) whenever the value is unavailable or doesn't map
|
|
63
|
+
* to a file - e.g. a device without the `derived-data` plugin installed simply shows no moon-phase
|
|
64
|
+
* icon. The matched file's contents are inlined as a `data:image/svg+xml;base64,...` URI so no
|
|
65
|
+
* filesystem access happens at resvg-wasm's render step.
|
|
62
66
|
*
|
|
63
67
|
* resvg-wasm cannot see the host's installed fonts (`loadSystemFonts`/`fontFiles`
|
|
64
68
|
* are silently no-ops under plain Node) - it only renders text if given font
|
|
@@ -71,7 +75,7 @@ class SvgRenderer {
|
|
|
71
75
|
constructor(fontPaths = fonts_1.DEFAULT_FONT_PATHS) {
|
|
72
76
|
this.fontPaths = fontPaths;
|
|
73
77
|
if (fontPaths.length === 0) {
|
|
74
|
-
throw new Error(
|
|
78
|
+
throw new Error("SvgRenderer requires at least one font path - resvg-wasm cannot use host system fonts");
|
|
75
79
|
}
|
|
76
80
|
}
|
|
77
81
|
loadFontBuffers() {
|
|
@@ -80,73 +84,75 @@ class SvgRenderer {
|
|
|
80
84
|
}
|
|
81
85
|
return this.fontBuffers;
|
|
82
86
|
}
|
|
83
|
-
async render(svgTemplatePath, context, width, height) {
|
|
87
|
+
async render(svgTemplatePath, context, width, height, templatesDir = (0, path_1.dirname)(svgTemplatePath), bundledTemplatesDir = config_1.BUNDLED_TEMPLATES_DIR) {
|
|
84
88
|
const [, fontBuffers] = await Promise.all([ensureWasmInitialized(), this.loadFontBuffers()]);
|
|
85
|
-
const svgSource = expandGenericFontFamilies(await (0, promises_1.readFile)(svgTemplatePath,
|
|
86
|
-
const doc = new xmldom_1.DOMParser().parseFromString(svgSource,
|
|
87
|
-
const elements = doc.getElementsByTagName(
|
|
89
|
+
const svgSource = expandGenericFontFamilies(await (0, promises_1.readFile)(svgTemplatePath, "utf-8"));
|
|
90
|
+
const doc = new xmldom_1.DOMParser().parseFromString(svgSource, "image/svg+xml");
|
|
91
|
+
const elements = doc.getElementsByTagName("text");
|
|
88
92
|
for (let i = 0; i < elements.length; i++) {
|
|
89
93
|
const element = elements.item(i);
|
|
90
94
|
if (!element)
|
|
91
95
|
continue;
|
|
92
|
-
const descElement = element.getElementsByTagName(
|
|
96
|
+
const descElement = element.getElementsByTagName("desc").item(0);
|
|
93
97
|
if (!descElement)
|
|
94
98
|
continue;
|
|
95
99
|
// Scoped to this one element - a single bad/unavailable binding (e.g. a resource that hasn't
|
|
96
100
|
// loaded yet) must not blank out every other field on the label, nor abort the whole repaint.
|
|
97
101
|
try {
|
|
98
|
-
const binding = (0, binding_1.parseBinding)(descElement.textContent ??
|
|
102
|
+
const binding = (0, binding_1.parseBinding)(descElement.textContent ?? "");
|
|
99
103
|
element.textContent = (0, binding_1.renderBinding)(binding, context);
|
|
100
104
|
}
|
|
101
105
|
catch (err) {
|
|
102
|
-
console.error(
|
|
103
|
-
element.textContent =
|
|
106
|
+
console.error(`${pluginVersion_1.PLUGIN_NAME}: field "${descElement.textContent}" failed to render: ${err.message}`);
|
|
107
|
+
element.textContent = "ERROR";
|
|
104
108
|
}
|
|
105
109
|
}
|
|
106
110
|
// Collected up front, rather than iterated live like the `<text>` loop above - some of these get
|
|
107
111
|
// removed outright (see below), and removing from a live NodeList while iterating it by index would
|
|
108
112
|
// skip whatever shifts into the removed slot.
|
|
109
113
|
const imageElements = [];
|
|
110
|
-
const rawImageElements = doc.getElementsByTagName(
|
|
114
|
+
const rawImageElements = doc.getElementsByTagName("image");
|
|
111
115
|
for (let i = 0; i < rawImageElements.length; i++) {
|
|
112
116
|
const element = rawImageElements.item(i);
|
|
113
117
|
if (element)
|
|
114
118
|
imageElements.push(element);
|
|
115
119
|
}
|
|
116
120
|
for (const element of imageElements) {
|
|
117
|
-
const descElement = element.getElementsByTagName(
|
|
121
|
+
const descElement = element.getElementsByTagName("desc").item(0);
|
|
118
122
|
if (!descElement)
|
|
119
123
|
continue;
|
|
120
124
|
// Same one-field-at-a-time isolation as the `<text>` loop - and the same "no value" outcome
|
|
121
125
|
// (here, dropping the element rather than blanking to 'ERROR' text) for a value that isn't
|
|
122
126
|
// available at all as for one that doesn't map to any asset file, per the binding's contract.
|
|
123
127
|
try {
|
|
124
|
-
const binding = (0, binding_1.parseBinding)(descElement.textContent ??
|
|
128
|
+
const binding = (0, binding_1.parseBinding)(descElement.textContent ?? "");
|
|
125
129
|
if (!binding.assets) {
|
|
126
130
|
throw new Error('an <image> binding requires an "assets" key naming the directory to pick a file from');
|
|
127
131
|
}
|
|
128
132
|
const key = (0, assets_1.normalizeAssetKey)((0, binding_1.resolveBinding)(binding, context));
|
|
129
|
-
const assetPath = key && (0, assets_1.resolveAssetPath)(
|
|
133
|
+
const assetPath = key && (0, assets_1.resolveAssetPath)(templatesDir, bundledTemplatesDir, binding.assets, key);
|
|
130
134
|
if (!assetPath) {
|
|
135
|
+
const dirProblem = (0, assets_1.describeAssetsDirProblem)(templatesDir, bundledTemplatesDir, binding.assets);
|
|
131
136
|
console.error(key
|
|
132
|
-
?
|
|
133
|
-
:
|
|
137
|
+
? `${pluginVersion_1.PLUGIN_NAME}: image "${descElement.textContent}" has no asset file for value "${key}" in "${binding.assets}"`
|
|
138
|
+
: `${pluginVersion_1.PLUGIN_NAME}: image "${descElement.textContent}" resolved to no usable value to pick an asset file with`);
|
|
139
|
+
console.error(`${pluginVersion_1.PLUGIN_NAME}: ${dirProblem ?? `assets directory for "${binding.assets}" checked out fine - the miss is just this value's`}`);
|
|
134
140
|
element.parentNode?.removeChild(element);
|
|
135
141
|
continue;
|
|
136
142
|
}
|
|
137
|
-
const assetSource = await (0, promises_1.readFile)(assetPath,
|
|
138
|
-
const dataUri = `data:image/svg+xml;base64,${Buffer.from(assetSource).toString(
|
|
139
|
-
const hrefAttr = element.getAttribute(
|
|
143
|
+
const assetSource = await (0, promises_1.readFile)(assetPath, "utf-8");
|
|
144
|
+
const dataUri = `data:image/svg+xml;base64,${Buffer.from(assetSource).toString("base64")}`;
|
|
145
|
+
const hrefAttr = element.getAttribute("href") !== null ? "href" : "xlink:href";
|
|
140
146
|
element.setAttribute(hrefAttr, dataUri);
|
|
141
147
|
}
|
|
142
148
|
catch (err) {
|
|
143
|
-
console.error(
|
|
149
|
+
console.error(`${pluginVersion_1.PLUGIN_NAME}: image "${descElement.textContent}" failed to render: ${err.message}`);
|
|
144
150
|
element.parentNode?.removeChild(element);
|
|
145
151
|
}
|
|
146
152
|
}
|
|
147
153
|
const svgOutput = new xmldom_1.XMLSerializer().serializeToString(doc);
|
|
148
154
|
const resvg = new resvg_wasm_1.Resvg(svgOutput, {
|
|
149
|
-
fitTo: { mode:
|
|
155
|
+
fitTo: { mode: "width", value: width },
|
|
150
156
|
font: { fontBuffers },
|
|
151
157
|
});
|
|
152
158
|
const rendered = resvg.render();
|
package/dist/render/types.d.ts
CHANGED
|
@@ -22,5 +22,5 @@ export interface Bitmap {
|
|
|
22
22
|
*/
|
|
23
23
|
export type TemplateContext = Record<string, unknown>;
|
|
24
24
|
export interface Renderer {
|
|
25
|
-
render(svgTemplatePath: string, context: TemplateContext, width: number, height: number): Promise<Bitmap>;
|
|
25
|
+
render(svgTemplatePath: string, context: TemplateContext, width: number, height: number, templatesDir?: string, bundledTemplatesDir?: string): Promise<Bitmap>;
|
|
26
26
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SignalK's full data-model shape wraps every leaf as `{ value, $source, timestamp, ... }` rather
|
|
3
|
+
* than the bare value - true both over HTTP (`GET .../vessels/self`) and in-process
|
|
4
|
+
* (`app.getSelfPath`/`getPath` in the live plugin, see `repaintScheduler.ts` - despite that module's
|
|
5
|
+
* name, these do NOT already return bare values for a regular published path; only a handful of
|
|
6
|
+
* vessel-identity fields like `uuid` do). Recursively unwraps every such leaf so a `path=` binding
|
|
7
|
+
* resolves the same way regardless of which of the two ways the render context was assembled. Only
|
|
8
|
+
* applied to `signalk` fetches - `resources` responses have no such wrapper.
|
|
9
|
+
*
|
|
10
|
+
* Keys off `value` alone, not also requiring `timestamp`/`$source` - not every server includes both
|
|
11
|
+
* on every leaf, and a `value` key is otherwise meaningless on a SignalK data-model node (it isn't a
|
|
12
|
+
* regular vessel property name), so there's no real risk of unwrapping something that isn't this
|
|
13
|
+
* wrapper.
|
|
14
|
+
*/
|
|
15
|
+
export declare function unwrapSignalkTree(node: unknown): unknown;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.unwrapSignalkTree = unwrapSignalkTree;
|
|
4
|
+
/**
|
|
5
|
+
* SignalK's full data-model shape wraps every leaf as `{ value, $source, timestamp, ... }` rather
|
|
6
|
+
* than the bare value - true both over HTTP (`GET .../vessels/self`) and in-process
|
|
7
|
+
* (`app.getSelfPath`/`getPath` in the live plugin, see `repaintScheduler.ts` - despite that module's
|
|
8
|
+
* name, these do NOT already return bare values for a regular published path; only a handful of
|
|
9
|
+
* vessel-identity fields like `uuid` do). Recursively unwraps every such leaf so a `path=` binding
|
|
10
|
+
* resolves the same way regardless of which of the two ways the render context was assembled. Only
|
|
11
|
+
* applied to `signalk` fetches - `resources` responses have no such wrapper.
|
|
12
|
+
*
|
|
13
|
+
* Keys off `value` alone, not also requiring `timestamp`/`$source` - not every server includes both
|
|
14
|
+
* on every leaf, and a `value` key is otherwise meaningless on a SignalK data-model node (it isn't a
|
|
15
|
+
* regular vessel property name), so there's no real risk of unwrapping something that isn't this
|
|
16
|
+
* wrapper.
|
|
17
|
+
*/
|
|
18
|
+
function unwrapSignalkTree(node) {
|
|
19
|
+
if (node === null || typeof node !== "object")
|
|
20
|
+
return node;
|
|
21
|
+
if (Array.isArray(node))
|
|
22
|
+
return node.map(unwrapSignalkTree);
|
|
23
|
+
const obj = node;
|
|
24
|
+
if ("value" in obj) {
|
|
25
|
+
return obj.value;
|
|
26
|
+
}
|
|
27
|
+
return Object.fromEntries(Object.entries(obj).map(([key, value]) => [key, unwrapSignalkTree(value)]));
|
|
28
|
+
}
|
package/dist/repaintScheduler.js
CHANGED
|
@@ -10,6 +10,7 @@ const registry_1 = require("./devices/registry");
|
|
|
10
10
|
const svgRenderer_1 = require("./render/svgRenderer");
|
|
11
11
|
const binding_1 = require("./render/binding");
|
|
12
12
|
const formatters_1 = require("./render/formatters");
|
|
13
|
+
const unwrapSignalkTree_1 = require("./render/unwrapSignalkTree");
|
|
13
14
|
const unitCategories_1 = require("./unitCategories");
|
|
14
15
|
const pathMeta_1 = require("./pathMeta");
|
|
15
16
|
const resolveApiUrl_1 = require("./resolveApiUrl");
|
|
@@ -17,11 +18,11 @@ const pluginVersion_1 = require("./pluginVersion");
|
|
|
17
18
|
const INTERVAL_POLL_MS = 60000;
|
|
18
19
|
const SUBSCRIPTION_DEBOUNCE_MS = 2000;
|
|
19
20
|
function statePath(app) {
|
|
20
|
-
return (0, path_1.join)(app.getDataDirPath(),
|
|
21
|
+
return (0, path_1.join)(app.getDataDirPath(), "repaint-state.json");
|
|
21
22
|
}
|
|
22
23
|
function loadState(app) {
|
|
23
24
|
try {
|
|
24
|
-
return JSON.parse((0, fs_1.readFileSync)(statePath(app),
|
|
25
|
+
return JSON.parse((0, fs_1.readFileSync)(statePath(app), "utf-8"));
|
|
25
26
|
}
|
|
26
27
|
catch {
|
|
27
28
|
return {};
|
|
@@ -33,24 +34,24 @@ function saveState(app, state) {
|
|
|
33
34
|
/** Deterministic JSON serialisation (sorted keys) so re-ordered object keys don't change the hash. */
|
|
34
35
|
function stableStringify(value) {
|
|
35
36
|
if (Array.isArray(value)) {
|
|
36
|
-
return `[${value.map(stableStringify).join(
|
|
37
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
37
38
|
}
|
|
38
|
-
if (value !== null && typeof value ===
|
|
39
|
+
if (value !== null && typeof value === "object") {
|
|
39
40
|
const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b));
|
|
40
|
-
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(
|
|
41
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
|
|
41
42
|
}
|
|
42
43
|
return JSON.stringify(value);
|
|
43
44
|
}
|
|
44
45
|
function hashContext(context) {
|
|
45
|
-
return (0, crypto_1.createHash)(
|
|
46
|
+
return (0, crypto_1.createHash)("sha1").update(stableStringify(context)).digest("hex");
|
|
46
47
|
}
|
|
47
48
|
/** Merges `value` into `target` at the nested location described by a dotted SignalK path. */
|
|
48
49
|
function setAtPath(target, path, value) {
|
|
49
|
-
const segments = path.split(
|
|
50
|
+
const segments = path.split(".");
|
|
50
51
|
let node = target;
|
|
51
52
|
for (const segment of segments.slice(0, -1)) {
|
|
52
53
|
const next = node[segment];
|
|
53
|
-
node[segment] = typeof next ===
|
|
54
|
+
node[segment] = typeof next === "object" && next !== null ? next : {};
|
|
54
55
|
node = node[segment];
|
|
55
56
|
}
|
|
56
57
|
node[segments[segments.length - 1]] = value;
|
|
@@ -58,32 +59,40 @@ function setAtPath(target, path, value) {
|
|
|
58
59
|
/**
|
|
59
60
|
* Reads live data for exactly what a template's own bindings ask for - no separate config declaring it.
|
|
60
61
|
* `signalk`-sourced bindings are read directly (`self` via `getSelfPath`, anything else via `getPath`
|
|
61
|
-
* against that literal SignalK context) - in-process, no URL needed.
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
62
|
+
* against that literal SignalK context) - in-process, no URL needed. Despite their names, neither
|
|
63
|
+
* already returns a bare value for a regular published path - both return the full data-model node
|
|
64
|
+
* (`{ path, value, context, source, $source, timestamp }`); only a handful of vessel-identity fields
|
|
65
|
+
* like `uuid` come back bare. `unwrapSignalkTree` strips that wrapper the same way it does for the
|
|
66
|
+
* CLI's HTTP-fetched equivalent (`assembleLiveContext` in `cli/liveContext.ts`), so a `path=` binding
|
|
67
|
+
* resolves to the same value either way - without it, `resolveBinding` would hand back the whole
|
|
68
|
+
* wrapper object instead of e.g. a plain string, breaking any binding that expects one (a `<image>`'s
|
|
69
|
+
* `assets=`, a `format=` formatter, etc). `resources`-sourced bindings go through `app.resourcesApi`,
|
|
70
|
+
* in-process too, with no such wrapper. Per-path unit-conversion metadata (`pathMeta`, for automatic
|
|
71
|
+
* conversion - see `renderBinding`) and an explicit `category=` binding's resolved conversion both
|
|
72
|
+
* have no in-process equivalent (confirmed against the signalk-server source - that resolution only
|
|
73
|
+
* happens in its REST layer), so both need `apiUrl` - fetching `pathMeta` is best-effort (a
|
|
74
|
+
* missing/unreachable server just means no automatic conversion), but a `category=` binding is a
|
|
75
|
+
* declared dependency, so a missing `apiUrl` is a hard error there.
|
|
68
76
|
*/
|
|
69
77
|
async function assembleRawContext(app, apiUrl, bindings) {
|
|
70
78
|
var _a;
|
|
71
79
|
const signalk = {};
|
|
72
80
|
const seenSignalk = new Set();
|
|
73
81
|
for (const binding of bindings) {
|
|
74
|
-
if (binding.source !==
|
|
82
|
+
if (binding.source !== "signalk")
|
|
75
83
|
continue;
|
|
76
84
|
const key = `${binding.context} ${binding.path}`;
|
|
77
85
|
if (seenSignalk.has(key))
|
|
78
86
|
continue;
|
|
79
87
|
seenSignalk.add(key);
|
|
80
|
-
const
|
|
88
|
+
const rawValue = binding.context === "self" ? app.getSelfPath(binding.path) : app.getPath(`${binding.context}.${binding.path}`);
|
|
89
|
+
const value = (0, unwrapSignalkTree_1.unwrapSignalkTree)(rawValue);
|
|
81
90
|
const namespace = (signalk[_a = binding.context] ?? (signalk[_a] = {}));
|
|
82
91
|
setAtPath(namespace, binding.path, value);
|
|
83
92
|
}
|
|
84
93
|
signalk.self ?? (signalk.self = {});
|
|
85
94
|
const pathMeta = {};
|
|
86
|
-
const signalkContexts = new Set(bindings.filter((binding) => binding.source ===
|
|
95
|
+
const signalkContexts = new Set(bindings.filter((binding) => binding.source === "signalk").map((binding) => binding.context));
|
|
87
96
|
if (apiUrl) {
|
|
88
97
|
for (const ctx of signalkContexts) {
|
|
89
98
|
try {
|
|
@@ -95,7 +104,7 @@ async function assembleRawContext(app, apiUrl, bindings) {
|
|
|
95
104
|
}
|
|
96
105
|
}
|
|
97
106
|
const resources = {};
|
|
98
|
-
const resourceNames = new Set(bindings.filter((binding) => binding.source ===
|
|
107
|
+
const resourceNames = new Set(bindings.filter((binding) => binding.source === "resources").map((binding) => binding.resource));
|
|
99
108
|
for (const name of resourceNames) {
|
|
100
109
|
// `listResources`'s type only allows the standard SignalKResourceType union, but the underlying
|
|
101
110
|
// Resources API (and a custom provider like signalk-tides, registered under the non-standard
|
|
@@ -105,7 +114,7 @@ async function assembleRawContext(app, apiUrl, bindings) {
|
|
|
105
114
|
}
|
|
106
115
|
const categoryNames = new Set(bindings.filter((binding) => binding.category).map((binding) => binding.category));
|
|
107
116
|
if (categoryNames.size > 0 && !apiUrl) {
|
|
108
|
-
throw new Error(`binding references categor${categoryNames.size > 1 ?
|
|
117
|
+
throw new Error(`binding references categor${categoryNames.size > 1 ? "ies" : "y"} "${[...categoryNames].join(", ")}" but no SignalK API base URL is configured`);
|
|
109
118
|
}
|
|
110
119
|
const categories = apiUrl ? await (0, unitCategories_1.fetchCategoryDisplayUnits)(apiUrl, categoryNames) : {};
|
|
111
120
|
return { signalk, resources, pathMeta, categories };
|
|
@@ -126,8 +135,9 @@ async function considerRepaint(app, config, device, state, getApiUrl) {
|
|
|
126
135
|
app.debug(`"${device.friendlyName}": no driver/metadata for device "${device.device}", skipping`);
|
|
127
136
|
return;
|
|
128
137
|
}
|
|
129
|
-
const
|
|
130
|
-
const
|
|
138
|
+
const templatesDir = (0, config_1.resolveTemplatesDir)(config.templatesDir);
|
|
139
|
+
const templatePath = (0, config_1.resolveTemplatePath)(templatesDir, device.templateName);
|
|
140
|
+
const bindings = (0, binding_1.findBindings)((0, fs_1.readFileSync)(templatePath, "utf-8"));
|
|
131
141
|
const apiUrl = await getApiUrl().catch((err) => {
|
|
132
142
|
app.debug(`"${device.friendlyName}": ${err.message}`);
|
|
133
143
|
return undefined;
|
|
@@ -147,7 +157,7 @@ async function considerRepaint(app, config, device, state, getApiUrl) {
|
|
|
147
157
|
},
|
|
148
158
|
};
|
|
149
159
|
const renderer = new svgRenderer_1.SvgRenderer();
|
|
150
|
-
const bitmap = await renderer.render(templatePath, renderContext, metadata.width, metadata.height - metadata.voffset);
|
|
160
|
+
const bitmap = await renderer.render(templatePath, renderContext, metadata.width, metadata.height - metadata.voffset, templatesDir, config_1.BUNDLED_TEMPLATES_DIR);
|
|
151
161
|
const connectTimeoutMs = config.paintConnectTimeoutSeconds * 1000;
|
|
152
162
|
let paintDurationMs = 0;
|
|
153
163
|
await (0, bleDiscovery_1.withRetries)(config.paintRetries, async (attempt) => {
|
|
@@ -170,7 +180,7 @@ function startRepaintScheduler(app, config) {
|
|
|
170
180
|
const unsubscribes = [];
|
|
171
181
|
const getApiUrl = (0, resolveApiUrl_1.createApiUrlResolver)(config.signalkApiUrl);
|
|
172
182
|
const repaint = (device) => considerRepaint(app, config, device, state, getApiUrl).catch((err) => app.debug(`"${device.friendlyName}": repaint failed: ${err.message}`));
|
|
173
|
-
const intervalDevices = config.devices.filter((device) => device.repaintTrigger ===
|
|
183
|
+
const intervalDevices = config.devices.filter((device) => device.repaintTrigger === "interval");
|
|
174
184
|
if (intervalDevices.length > 0) {
|
|
175
185
|
const timer = setInterval(() => {
|
|
176
186
|
const now = new Date();
|
|
@@ -178,14 +188,14 @@ function startRepaintScheduler(app, config) {
|
|
|
178
188
|
const hours = device.intervalHours ?? 1;
|
|
179
189
|
const minute = device.intervalMinute ?? 0;
|
|
180
190
|
if (now.getHours() % hours === 0 && now.getMinutes() === minute) {
|
|
181
|
-
repaint(device);
|
|
191
|
+
void repaint(device);
|
|
182
192
|
}
|
|
183
193
|
}
|
|
184
194
|
}, INTERVAL_POLL_MS);
|
|
185
195
|
unsubscribes.push(() => clearInterval(timer));
|
|
186
196
|
}
|
|
187
197
|
for (const device of config.devices) {
|
|
188
|
-
if (device.repaintTrigger ===
|
|
198
|
+
if (device.repaintTrigger === "subscription" && device.triggerPath) {
|
|
189
199
|
const stream = app.streambundle.getSelfStream(device.triggerPath).debounce(SUBSCRIPTION_DEBOUNCE_MS);
|
|
190
200
|
const unsub = stream.onValue(() => repaint(device));
|
|
191
201
|
unsubscribes.push(unsub);
|
|
@@ -194,7 +204,7 @@ function startRepaintScheduler(app, config) {
|
|
|
194
204
|
// Check every device once at startup - harmless given hash dedup, and covers newly-added
|
|
195
205
|
// devices or a forceRepaint left set from before a restart.
|
|
196
206
|
for (const device of config.devices) {
|
|
197
|
-
repaint(device);
|
|
207
|
+
void repaint(device);
|
|
198
208
|
}
|
|
199
209
|
return {
|
|
200
210
|
stop() {
|
package/dist/resolveApiUrl.js
CHANGED
|
@@ -18,9 +18,9 @@ const httpJson_1 = require("./httpJson");
|
|
|
18
18
|
* entirely by install method: a bare `npm install` defaults to 3000; container/systemd installs
|
|
19
19
|
* commonly default to 80, or 443 if TLS-terminated locally.
|
|
20
20
|
*/
|
|
21
|
-
exports.SIGNALK_API_URL_OPTIONS = [
|
|
21
|
+
exports.SIGNALK_API_URL_OPTIONS = ["http://localhost:3000", "http://localhost", "https://localhost"];
|
|
22
22
|
/** Cheap, always-required (for `category=` resolution) and read-only, so safe to use as a connectivity+access probe. */
|
|
23
|
-
const PROBE_PATH =
|
|
23
|
+
const PROBE_PATH = "/signalk/v1/unitpreferences/categories";
|
|
24
24
|
async function probe(url) {
|
|
25
25
|
try {
|
|
26
26
|
await (0, httpJson_1.fetchJson)(`${url}${PROBE_PATH}`);
|
|
@@ -44,7 +44,7 @@ async function resolveSignalkApiUrl(configuredUrl) {
|
|
|
44
44
|
}
|
|
45
45
|
throw new Error(configuredUrl
|
|
46
46
|
? `configured SignalK API base URL "${configuredUrl}" did not respond to ${PROBE_PATH} - check the port, and that anonymous read access is enabled`
|
|
47
|
-
: `could not reach this server's API on any of ${exports.SIGNALK_API_URL_OPTIONS.join(
|
|
47
|
+
: `could not reach this server's API on any of ${exports.SIGNALK_API_URL_OPTIONS.join(", ")} - it may be on a different port, or anonymous read access may not be enabled`);
|
|
48
48
|
}
|
|
49
49
|
/**
|
|
50
50
|
* Memoizes a successful resolution only - a failure (server not ready yet, anonymous access
|
package/dist/unitCategories.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DisplayUnits } from
|
|
1
|
+
import { DisplayUnits } from "./render/formatters";
|
|
2
2
|
/**
|
|
3
3
|
* Resolves `category=` bindings (e.g. `category=depth` on a `source=resources` value with no per-path
|
|
4
4
|
* metadata of its own to auto-convert from) against this server's unit-preferences setup.
|
package/dist/unitCategories.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.fetchCategoryDisplayUnits = fetchCategoryDisplayUnits;
|
|
4
4
|
const httpJson_1 = require("./httpJson");
|
|
5
|
-
const CATEGORIES_PATH =
|
|
6
|
-
const ACTIVE_PRESET_PATH =
|
|
7
|
-
const DEFINITIONS_PATH =
|
|
5
|
+
const CATEGORIES_PATH = "/signalk/v1/unitpreferences/categories";
|
|
6
|
+
const ACTIVE_PRESET_PATH = "/signalk/v1/unitpreferences/active";
|
|
7
|
+
const DEFINITIONS_PATH = "/signalk/v1/unitpreferences/definitions";
|
|
8
8
|
/**
|
|
9
9
|
* Resolves `category=` bindings (e.g. `category=depth` on a `source=resources` value with no per-path
|
|
10
10
|
* metadata of its own to auto-convert from) against this server's unit-preferences setup.
|
|
Binary file
|