@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.
Files changed (58) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +19 -10
  3. package/dist/cli/index.js +129 -96
  4. package/dist/cli/liveContext.d.ts +2 -2
  5. package/dist/cli/liveContext.js +16 -38
  6. package/dist/cli/log.d.ts +1 -1
  7. package/dist/cli/log.js +4 -4
  8. package/dist/config.d.ts +12 -3
  9. package/dist/config.js +71 -49
  10. package/dist/devices/bleDiscovery.d.ts +1 -1
  11. package/dist/devices/bleDiscovery.js +9 -3
  12. package/dist/devices/registry.d.ts +1 -1
  13. package/dist/devices/types.d.ts +4 -4
  14. package/dist/devices/zhsunyco/encode.d.ts +2 -2
  15. package/dist/devices/zhsunyco/index.d.ts +3 -3
  16. package/dist/devices/zhsunyco/index.js +10 -9
  17. package/dist/devices/zhsunyco/metadata.d.ts +1 -1
  18. package/dist/devices/zhsunyco/metadata.js +12 -12
  19. package/dist/devices/zhsunyco/protocol.js +10 -10
  20. package/dist/httpJson.js +3 -3
  21. package/dist/index.d.ts +3 -3
  22. package/dist/pathMeta.d.ts +1 -1
  23. package/dist/pathMeta.js +1 -1
  24. package/dist/plugin.d.ts +1 -1
  25. package/dist/plugin.js +16 -13
  26. package/dist/pluginVersion.d.ts +9 -0
  27. package/dist/pluginVersion.js +12 -2
  28. package/dist/render/assets.d.ts +14 -6
  29. package/dist/render/assets.js +65 -10
  30. package/dist/render/binding.d.ts +9 -2
  31. package/dist/render/binding.js +23 -23
  32. package/dist/render/fonts.js +6 -6
  33. package/dist/render/formatters.d.ts +1 -1
  34. package/dist/render/formatters.js +25 -22
  35. package/dist/render/png.d.ts +1 -1
  36. package/dist/render/svgRenderer.d.ts +9 -8
  37. package/dist/render/svgRenderer.js +33 -27
  38. package/dist/render/types.d.ts +1 -1
  39. package/dist/render/unwrapSignalkTree.d.ts +15 -0
  40. package/dist/render/unwrapSignalkTree.js +28 -0
  41. package/dist/repaintScheduler.d.ts +2 -2
  42. package/dist/repaintScheduler.js +37 -27
  43. package/dist/resolveApiUrl.js +3 -3
  44. package/dist/unitCategories.d.ts +1 -1
  45. package/dist/unitCategories.js +3 -3
  46. package/docs/assets/screenshots/plugin_config.png +0 -0
  47. package/package.json +42 -39
  48. package/templates/assets/lunar_phases/README.md +18 -0
  49. package/templates/tide.svg +11 -11
  50. /package/docs/assets/{inkscape_desc.png → screenshots/inkscape_desc.png} +0 -0
  51. /package/{resources/svg → templates/assets}/lunar_phases/first_quarter.svg +0 -0
  52. /package/{resources/svg → templates/assets}/lunar_phases/full_moon.svg +0 -0
  53. /package/{resources/svg → templates/assets}/lunar_phases/new_moon.svg +0 -0
  54. /package/{resources/svg → templates/assets}/lunar_phases/third_quarter.svg +0 -0
  55. /package/{resources/svg → templates/assets}/lunar_phases/waning_crescent.svg +0 -0
  56. /package/{resources/svg → templates/assets}/lunar_phases/waning_gibbous.svg +0 -0
  57. /package/{resources/svg → templates/assets}/lunar_phases/waxing_crescent.svg +0 -0
  58. /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('.') ? displayUnits.displayFormat.split('.')[1].length : 0);
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, { timeZone: selfTimezone(context), timeZoneName: 'short' })
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 === 'timeZoneName');
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: 'utc' });
55
- if (typeof value === 'string')
56
- return luxon_1.DateTime.fromISO(value, { zone: 'utc' });
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('HH:mm') : '';
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('d MMM') : '';
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('d MMM yy HH:mm') : '';
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 !== 'string' || !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('ZZ')}` : '';
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 !== 'number' || typeof position?.longitude !== 'number')
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 ? 'N' : 'S';
96
- const lonHemisphere = position.longitude >= 0 ? 'E' : 'W';
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 'local_time':
105
+ case "local_time":
103
106
  return formatLocalTime(value, context);
104
- case 'day_mon':
107
+ case "day_mon":
105
108
  return formatDayMonth(value, context);
106
- case 'local_datetime_short':
109
+ case "local_datetime_short":
107
110
  return formatLocalDatetimeShort(value, context);
108
- case 'utc_offset':
111
+ case "utc_offset":
109
112
  return formatUtcOffset(value);
110
- case 'position':
113
+ case "position":
111
114
  return formatPosition(value, round);
112
115
  default:
113
116
  throw new Error(`unknown format "${name}"`);
@@ -1,3 +1,3 @@
1
- import { Bitmap } from './types';
1
+ import { Bitmap } from "./types";
2
2
  /** Encodes a common Bitmap as PNG bytes, for previewing templates without a physical device. */
3
3
  export declare function bitmapToPng(bitmap: Bitmap): Buffer;
@@ -1,4 +1,4 @@
1
- import { Bitmap, Renderer, TemplateContext } from './types';
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 that
21
- * directory - see `./assets.ts`) rather than substituting text, and the element is dropped entirely
22
- * (rather than showing an error placeholder) whenever the value is unavailable or doesn't map to a
23
- * file - e.g. a device without the `derived-data` plugin installed simply shows no moon-phase icon.
24
- * The matched file's contents are inlined as a `data:image/svg+xml;base64,...` URI so no filesystem
25
- * access happens at resvg-wasm's render step.
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('@resvg/resvg-wasm/index_bg.wasm')).then((buffer) => (0, resvg_wasm_1.initWasm)(buffer));
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 that
57
- * directory - see `./assets.ts`) rather than substituting text, and the element is dropped entirely
58
- * (rather than showing an error placeholder) whenever the value is unavailable or doesn't map to a
59
- * file - e.g. a device without the `derived-data` plugin installed simply shows no moon-phase icon.
60
- * The matched file's contents are inlined as a `data:image/svg+xml;base64,...` URI so no filesystem
61
- * access happens at resvg-wasm's render step.
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('SvgRenderer requires at least one font path - resvg-wasm cannot use host system fonts');
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, 'utf-8'));
86
- const doc = new xmldom_1.DOMParser().parseFromString(svgSource, 'image/svg+xml');
87
- const elements = doc.getElementsByTagName('text');
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('desc').item(0);
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(`field "${descElement.textContent}" failed to render: ${err.message}`);
103
- element.textContent = 'ERROR';
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('image');
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('desc').item(0);
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)(svgTemplatePath, binding.assets, key);
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
- ? `image "${descElement.textContent}" has no asset file for value "${key}" in "${binding.assets}"`
133
- : `image "${descElement.textContent}" resolved to no usable value to pick an asset file with`);
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, 'utf-8');
138
- const dataUri = `data:image/svg+xml;base64,${Buffer.from(assetSource).toString('base64')}`;
139
- const hrefAttr = element.getAttribute('href') !== null ? 'href' : 'xlink:href';
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(`image "${descElement.textContent}" failed to render: ${err.message}`);
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: 'width', value: width },
155
+ fitTo: { mode: "width", value: width },
150
156
  font: { fontBuffers },
151
157
  });
152
158
  const rendered = resvg.render();
@@ -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
+ }
@@ -1,5 +1,5 @@
1
- import { ServerAPI } from '@signalk/server-api';
2
- import { PluginConfig } from './config';
1
+ import { ServerAPI } from "@signalk/server-api";
2
+ import { PluginConfig } from "./config";
3
3
  export interface RepaintScheduler {
4
4
  stop(): void;
5
5
  }
@@ -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(), 'repaint-state.json');
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), 'utf-8'));
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 === 'object') {
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)('sha1').update(stableStringify(context)).digest('hex');
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 === 'object' && next !== null ? 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. `resources`-sourced bindings go
62
- * through `app.resourcesApi`, in-process too. Per-path unit-conversion metadata (`pathMeta`, for
63
- * automatic conversion - see `renderBinding`) and an explicit `category=` binding's resolved
64
- * conversion both have no in-process equivalent (confirmed against the signalk-server source - that
65
- * resolution only happens in its REST layer), so both need `apiUrl` - fetching `pathMeta` is
66
- * best-effort (a missing/unreachable server just means no automatic conversion), but a `category=`
67
- * binding is a declared dependency, so a missing `apiUrl` is a hard error there.
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 !== 'signalk')
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 value = binding.context === 'self' ? app.getSelfPath(binding.path) : app.getPath(`${binding.context}.${binding.path}`);
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 === 'signalk').map((binding) => binding.context));
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 === 'resources').map((binding) => binding.resource));
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 ? 'ies' : 'y'} "${[...categoryNames].join(', ')}" but no SignalK API base URL is configured`);
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 templatePath = (0, config_1.resolveTemplatePath)((0, config_1.resolveTemplatesDir)(config.templatesDir), device.templateName);
130
- const bindings = (0, binding_1.findBindings)((0, fs_1.readFileSync)(templatePath, 'utf-8'));
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 === 'interval');
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 === 'subscription' && device.triggerPath) {
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() {
@@ -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 = ['http://localhost:3000', 'http://localhost', 'https://localhost'];
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 = '/signalk/v1/unitpreferences/categories';
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(', ')} - it may be on a different port, or anonymous read access may not be enabled`);
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
@@ -1,4 +1,4 @@
1
- import { DisplayUnits } from './render/formatters';
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.
@@ -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 = '/signalk/v1/unitpreferences/categories';
6
- const ACTIVE_PRESET_PATH = '/signalk/v1/unitpreferences/active';
7
- const DEFINITIONS_PATH = '/signalk/v1/unitpreferences/definitions';
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.