@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
@@ -26,7 +26,7 @@ exports.ZHSUNYCO_PID_METADATA = [
26
26
  width: 200,
27
27
  height: 200,
28
28
  voffset: 0,
29
- colours: ['black', 'white', 'red', 'yellow'],
29
+ colours: ["black", "white", "red", "yellow"],
30
30
  },
31
31
  {
32
32
  pid: 0x000a,
@@ -34,7 +34,7 @@ exports.ZHSUNYCO_PID_METADATA = [
34
34
  width: 250,
35
35
  height: 128,
36
36
  voffset: 0,
37
- colours: ['black', 'white', 'red', 'yellow'],
37
+ colours: ["black", "white", "red", "yellow"],
38
38
  },
39
39
  {
40
40
  pid: 0x000e,
@@ -42,34 +42,34 @@ exports.ZHSUNYCO_PID_METADATA = [
42
42
  width: 416,
43
43
  height: 240,
44
44
  voffset: 0,
45
- colours: ['black', 'white', 'red', 'yellow'],
45
+ colours: ["black", "white", "red", "yellow"],
46
46
  },
47
47
  {
48
48
  pid: 0x000e,
49
- hwVersion: '0103',
49
+ hwVersion: "0103",
50
50
  label: '2.13"',
51
51
  width: 250,
52
52
  height: 128,
53
53
  voffset: 0,
54
- colours: ['black', 'white', 'red', 'yellow'],
54
+ colours: ["black", "white", "red", "yellow"],
55
55
  },
56
56
  {
57
57
  pid: 0x000e,
58
- hwVersion: '0201',
58
+ hwVersion: "0201",
59
59
  label: '3.5"',
60
60
  width: 384,
61
61
  height: 184,
62
62
  voffset: 0,
63
- colours: ['black', 'white', 'red', 'yellow'],
63
+ colours: ["black", "white", "red", "yellow"],
64
64
  },
65
65
  {
66
66
  pid: 0x000e,
67
- hwVersion: '0203',
67
+ hwVersion: "0203",
68
68
  label: '7.5"',
69
69
  width: 800,
70
70
  height: 480,
71
71
  voffset: 0,
72
- colours: ['black', 'white', 'red', 'yellow'],
72
+ colours: ["black", "white", "red", "yellow"],
73
73
  },
74
74
  {
75
75
  pid: 0x0012,
@@ -77,7 +77,7 @@ exports.ZHSUNYCO_PID_METADATA = [
77
77
  width: 296,
78
78
  height: 128,
79
79
  voffset: 0,
80
- colours: ['black', 'white', 'red', 'yellow'],
80
+ colours: ["black", "white", "red", "yellow"],
81
81
  },
82
82
  {
83
83
  pid: 0x0016,
@@ -85,7 +85,7 @@ exports.ZHSUNYCO_PID_METADATA = [
85
85
  width: 400,
86
86
  height: 300,
87
87
  voffset: 0,
88
- colours: ['black', 'white', 'red', 'yellow'],
88
+ colours: ["black", "white", "red", "yellow"],
89
89
  },
90
90
  {
91
91
  pid: 0x001a,
@@ -93,6 +93,6 @@ exports.ZHSUNYCO_PID_METADATA = [
93
93
  width: 648,
94
94
  height: 480,
95
95
  voffset: 0,
96
- colours: ['black', 'white', 'red', 'yellow'],
96
+ colours: ["black", "white", "red", "yellow"],
97
97
  },
98
98
  ];
@@ -12,15 +12,15 @@ const crypto_1 = require("crypto");
12
12
  * Wolink ESL GATT service ("WOLINKBLEESL2020") and characteristic UUIDs, transcribed
13
13
  * from the reference driver (examples/device_driver/zhunyco/wolink_ble.py).
14
14
  */
15
- exports.WOLINK_SERVICE_UUID = '30323032-4c53-4545-4c42-4b4e494c4f57';
15
+ exports.WOLINK_SERVICE_UUID = "30323032-4c53-4545-4c42-4b4e494c4f57";
16
16
  /** BLE manufacturer ID Zhsunyco/Wolink devices advertise under. */
17
17
  exports.ZHSUNYCO_MANUFACTURER_ID = 0xbbaa;
18
18
  exports.WOLINK_CHARACTERISTIC_UUIDS = {
19
- data: '31323032-4c53-4545-4c42-4b4e494c4f57',
20
- config: '32323032-4c53-4545-4c42-4b4e494c4f57',
21
- authenticate: '33323032-4c53-4545-4c42-4b4e494c4f57',
22
- status: '34323032-4c53-4545-4c42-4b4e494c4f57',
23
- battery: '35323032-4c53-4545-4c42-4b4e494c4f57',
19
+ data: "31323032-4c53-4545-4c42-4b4e494c4f57",
20
+ config: "32323032-4c53-4545-4c42-4b4e494c4f57",
21
+ authenticate: "33323032-4c53-4545-4c42-4b4e494c4f57",
22
+ status: "34323032-4c53-4545-4c42-4b4e494c4f57",
23
+ battery: "35323032-4c53-4545-4c42-4b4e494c4f57",
24
24
  };
25
25
  exports.COMMAND = {
26
26
  uploadBlock: 0xa500,
@@ -33,8 +33,8 @@ function decodeAdvertisedInfo(data) {
33
33
  }
34
34
  return {
35
35
  pid: data.readUInt16BE(2),
36
- appVersion: data.readUInt16BE(4).toString(16).padStart(4, '0'),
37
- hwVersion: data.readUInt16BE(6).toString(16).padStart(4, '0'),
36
+ appVersion: data.readUInt16BE(4).toString(16).padStart(4, "0"),
37
+ hwVersion: data.readUInt16BE(6).toString(16).padStart(4, "0"),
38
38
  };
39
39
  }
40
40
  function decodeBatteryMv(data) {
@@ -60,7 +60,7 @@ const AES_CHALLENGE_LENGTH = 16;
60
60
  exports.DEFAULT_BLE_AUTH = [155, 96, 159, 40, 188, 73, 226, 87, 41, 189, 123, 141, 242, 43, 68, 32];
61
61
  /** Resolves the configured per-device hex key, falling back to `DEFAULT_BLE_AUTH`. */
62
62
  function resolveAesKey(aesKeyHex) {
63
- return aesKeyHex ? Buffer.from(aesKeyHex, 'hex') : Buffer.from(exports.DEFAULT_BLE_AUTH);
63
+ return aesKeyHex ? Buffer.from(aesKeyHex, "hex") : Buffer.from(exports.DEFAULT_BLE_AUTH);
64
64
  }
65
65
  /**
66
66
  * Encrypts the device's auth challenge with the AES-128 key (CBC, zero IV).
@@ -77,7 +77,7 @@ function authResponse(challenge, key) {
77
77
  if (challenge.length !== AES_CHALLENGE_LENGTH) {
78
78
  throw new Error(`zhsunyco auth challenge expected ${AES_CHALLENGE_LENGTH} bytes, got ${challenge.length}`);
79
79
  }
80
- const cipher = (0, crypto_1.createCipheriv)('aes-128-cbc', key, Buffer.alloc(16));
80
+ const cipher = (0, crypto_1.createCipheriv)("aes-128-cbc", key, Buffer.alloc(16));
81
81
  cipher.setAutoPadding(false);
82
82
  return Buffer.concat([cipher.update(challenge), cipher.final()]);
83
83
  }
package/dist/httpJson.js CHANGED
@@ -10,7 +10,7 @@ exports.fetchJson = fetchJson;
10
10
  function describeCause(cause) {
11
11
  const errors = cause.errors;
12
12
  if (Array.isArray(errors)) {
13
- return errors.map(describeCause).join('; ');
13
+ return errors.map(describeCause).join("; ");
14
14
  }
15
15
  if (cause instanceof Error) {
16
16
  return cause.message || cause.code || cause.toString();
@@ -33,8 +33,8 @@ async function fetchJson(url) {
33
33
  throw new Error(`fetch failed: ${url} - ${detail}`);
34
34
  }
35
35
  if (!response.ok) {
36
- const body = await response.text().catch(() => '');
37
- throw new Error(`fetch failed: ${url} (${response.status} ${response.statusText})${body ? ` - ${body}` : ''}`);
36
+ const body = await response.text().catch(() => "");
37
+ throw new Error(`fetch failed: ${url} (${response.status} ${response.statusText})${body ? ` - ${body}` : ""}`);
38
38
  }
39
39
  return response.json();
40
40
  }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { ServerAPI, Plugin } from '@signalk/server-api';
2
- import { registerDriver, getDriver, allDrivers } from './devices/registry';
3
- import type { VendorDriver as VendorDriverType, DeviceMetadata as DeviceMetadataType, DiscoveredDevice as DiscoveredDeviceType, VendorDeviceConfig as VendorDeviceConfigType, Colour as ColourType } from './devices/types';
1
+ import { ServerAPI, Plugin } from "@signalk/server-api";
2
+ import { registerDriver, getDriver, allDrivers } from "./devices/registry";
3
+ import type { VendorDriver as VendorDriverType, DeviceMetadata as DeviceMetadataType, DiscoveredDevice as DiscoveredDeviceType, VendorDeviceConfig as VendorDeviceConfigType, Colour as ColourType } from "./devices/types";
4
4
  /**
5
5
  * Public extension point for vendor packages. A package that adds support for a new
6
6
  * ESL vendor (e.g. `signalk-esl-shoplabelcorp-plugin`) imports this module and calls
@@ -1,4 +1,4 @@
1
- import { DisplayUnits } from './render/formatters';
1
+ import { DisplayUnits } from "./render/formatters";
2
2
  export interface PathMetadata {
3
3
  units?: string;
4
4
  description?: string;
package/dist/pathMeta.js CHANGED
@@ -4,7 +4,7 @@ exports.fetchPathMeta = fetchPathMeta;
4
4
  const httpJson_1 = require("./httpJson");
5
5
  /** `self` -> `vessels/self`, `vessels.urn:mrn:imo:mmsi:1` -> `vessels/urn:mrn:imo:mmsi:1` - matches the REST path for that context's whole-vessel metadata. */
6
6
  function metaContextPath(context) {
7
- return context === 'self' ? 'vessels/self' : context.replace(/\./g, '/');
7
+ return context === "self" ? "vessels/self" : context.replace(/\./g, "/");
8
8
  }
9
9
  /**
10
10
  * Fetches all of a vessel's per-path metadata in one request - `GET .../vessels/<context>/meta` returns
package/dist/plugin.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import { Plugin, ServerAPI } from '@signalk/server-api';
1
+ import { Plugin, ServerAPI } from "@signalk/server-api";
2
2
  export declare function createPlugin(app: ServerAPI): Plugin;
package/dist/plugin.js CHANGED
@@ -21,16 +21,16 @@ async function runStartupScan(app, discovered, durationSeconds) {
21
21
  }
22
22
  const found = await driver.identifyDevice(device, address, name, manufacturerId, manufacturerData).catch((err) => {
23
23
  scanError = `${driver.vendor} scan failed: ${err.message}`;
24
- app.debug(`${scanError}\n${err.stack ?? ''}`);
24
+ app.debug(`${scanError}\n${err.stack ?? ""}`);
25
25
  return undefined;
26
26
  });
27
27
  if (!found) {
28
28
  return;
29
29
  }
30
30
  discovered.push(found);
31
- const pid = found.pid !== undefined ? `0x${found.pid.toString(16).padStart(4, '0')}` : 'unknown';
32
- const hwid = found.hwVersion ?? 'unknown';
33
- app.debug(`discovered ${driver.vendor} device "${found.name ?? ''}" [${found.address}] pid=${pid} hwid=${hwid}`);
31
+ const pid = found.pid !== undefined ? `0x${found.pid.toString(16).padStart(4, "0")}` : "unknown";
32
+ const hwid = found.hwVersion ?? "unknown";
33
+ app.debug(`discovered ${driver.vendor} device "${found.name ?? ""}" [${found.address}] pid=${pid} hwid=${hwid}`);
34
34
  });
35
35
  });
36
36
  }
@@ -39,17 +39,17 @@ async function runStartupScan(app, discovered, durationSeconds) {
39
39
  // D-Bus connection) skips every line below, leaving the admin UI stuck on "Scanning..."
40
40
  // forever even though `discovered` may already hold devices found before the failure.
41
41
  scanError = `scan failed: ${err.message}`;
42
- app.debug(`${scanError}\n${err.stack ?? ''}`);
42
+ app.debug(`${scanError}\n${err.stack ?? ""}`);
43
43
  }
44
44
  // Surfaces the real cause in the admin UI (instead of only the debug log) - a scan that
45
45
  // ends in well under its configured duration is almost always this, not "no devices nearby".
46
- app.setPluginError(scanError ?? '');
46
+ app.setPluginError(scanError ?? "");
47
47
  const elapsedSeconds = ((Date.now() - startedAt) / 1000).toFixed(1);
48
48
  if (discovered.length === 0) {
49
49
  app.setPluginStatus(`Scan complete - no ESL devices found nearby after ${elapsedSeconds} seconds.`);
50
50
  return;
51
51
  }
52
- const summary = discovered.map((device) => `${device.name ?? device.vendor} [${device.address}]`).join(', ');
52
+ const summary = discovered.map((device) => `${device.name ?? device.vendor} [${device.address}]`).join(", ");
53
53
  app.setPluginStatus(`Scan complete - found ${discovered.length} device(s) in ${elapsedSeconds}s: ${summary} - pick one from a device's "Device" field below`);
54
54
  }
55
55
  function createPlugin(app) {
@@ -63,13 +63,16 @@ function createPlugin(app) {
63
63
  let scanInProgress;
64
64
  let scanStartedAt;
65
65
  const plugin = {
66
- id: 'signalk-einklabel-plugin',
67
- name: 'eInk ESL (Electronic Shelf Label)',
68
- description: 'Renders selected SignalK data to BLE eInk Electronic Shelf Labels',
66
+ id: "signalk-einklabel-plugin",
67
+ name: "eInk ESL (Electronic Shelf Label)",
68
+ description: "Renders selected SignalK data to BLE eInk Electronic Shelf Labels",
69
69
  schema: () => (0, config_1.configSchema)(app, lastDiscovered),
70
70
  uiSchema: () => (0, config_1.configUiSchema)(),
71
71
  start(config) {
72
- const pluginConfig = { ...(0, config_1.defaultConfig)(), ...config };
72
+ const pluginConfig = {
73
+ ...(0, config_1.defaultConfig)(),
74
+ ...config,
75
+ };
73
76
  app.debug(`starting with ${pluginConfig.devices.length} configured device(s)`);
74
77
  if (pluginConfig.scanOnStart) {
75
78
  if (scanInProgress) {
@@ -82,7 +85,7 @@ function createPlugin(app) {
82
85
  scanStartedAt = Date.now();
83
86
  const scan = runStartupScan(app, lastDiscovered, pluginConfig.scanDurationSeconds).catch((err) => app.debug(`startup scan failed: ${err.message}`));
84
87
  scanInProgress = scan;
85
- scan.finally(() => {
88
+ void scan.finally(() => {
86
89
  scanInProgress = undefined;
87
90
  scanStartedAt = undefined;
88
91
  });
@@ -93,7 +96,7 @@ function createPlugin(app) {
93
96
  stop() {
94
97
  scheduler?.stop();
95
98
  scheduler = undefined;
96
- app.debug('stopped');
99
+ app.debug("stopped");
97
100
  },
98
101
  };
99
102
  return plugin;
@@ -4,3 +4,12 @@
4
4
  * `cli/liveContext.ts`), e.g. to show a build/version marker on a label for support purposes.
5
5
  */
6
6
  export declare const PLUGIN_VERSION: string;
7
+ /**
8
+ * The installed plugin's own package name, with any npm scope (`@rhizomatics/`) stripped - prefixed
9
+ * onto `console.error` lines from code shared with the CLI (rendering/device-driver modules with no
10
+ * `ServerAPI` to call `app.debug` on). SignalK server's own `app.debug` calls are auto-namespaced
11
+ * with the plugin id (unscoped, e.g. `signalk-einklabel-plugin`) by the `debug` module underneath
12
+ * it, so without this prefix those `console.error` lines would show up in the server log
13
+ * unidentified next to ones that are - matching the unscoped form keeps the two consistent.
14
+ */
15
+ export declare const PLUGIN_NAME: string;
@@ -1,11 +1,21 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.PLUGIN_VERSION = void 0;
3
+ exports.PLUGIN_NAME = exports.PLUGIN_VERSION = void 0;
4
4
  const fs_1 = require("fs");
5
5
  const path_1 = require("path");
6
+ const packageJson = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(__dirname, "..", "package.json"), "utf-8"));
6
7
  /**
7
8
  * The installed plugin's own version, read from `package.json` once at module load - exposed to
8
9
  * templates as `source=einklabel,path=plugin_version` (see `meta` in repaintScheduler.ts and
9
10
  * `cli/liveContext.ts`), e.g. to show a build/version marker on a label for support purposes.
10
11
  */
11
- exports.PLUGIN_VERSION = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(__dirname, '..', 'package.json'), 'utf-8')).version;
12
+ exports.PLUGIN_VERSION = packageJson.version;
13
+ /**
14
+ * The installed plugin's own package name, with any npm scope (`@rhizomatics/`) stripped - prefixed
15
+ * onto `console.error` lines from code shared with the CLI (rendering/device-driver modules with no
16
+ * `ServerAPI` to call `app.debug` on). SignalK server's own `app.debug` calls are auto-namespaced
17
+ * with the plugin id (unscoped, e.g. `signalk-einklabel-plugin`) by the `debug` module underneath
18
+ * it, so without this prefix those `console.error` lines would show up in the server log
19
+ * unidentified next to ones that are - matching the unscoped form keeps the two consistent.
20
+ */
21
+ exports.PLUGIN_NAME = packageJson.name.replace(/^@[^/]+\//, "");
@@ -6,10 +6,18 @@
6
6
  */
7
7
  export declare function normalizeAssetKey(value: unknown): string | undefined;
8
8
  /**
9
- * Resolves an `assets=` binding to an actual `.svg` file, looked up as `<key>.svg` inside `assetsDir`
10
- * resolved relative to the template's own directory (mirroring how a bundled template's relative asset
11
- * references keep working whether the template itself is the bundled copy or a user override elsewhere) -
12
- * `undefined` if no matching file exists, which callers treat as "no image" rather than an error, since an
13
- * unmapped value (e.g. a phase name the asset set doesn't cover) is an expected, not exceptional, case.
9
+ * Resolves an `assets=` binding to an actual `.svg` file, looked up as `<key>.svg` inside whichever
10
+ * `assets/<assetsName>` directory `selectAssetsDir` picks. `undefined` if that directory has no
11
+ * matching file, which callers treat as "no image" rather than an error, since an unmapped value
12
+ * (e.g. a phase name the asset set doesn't cover) is an expected, not exceptional, case.
14
13
  */
15
- export declare function resolveAssetPath(templatePath: string, assetsDir: string, key: string): string | undefined;
14
+ export declare function resolveAssetPath(templatesDir: string, bundledTemplatesDir: string, assetsName: string, key: string): string | undefined;
15
+ /**
16
+ * Diagnoses why an `assets=` directory itself might be at fault, for a caller to log when
17
+ * `resolveAssetPath` comes back empty - a wrong/misconfigured `assets=` name (typo, nothing bundled or
18
+ * user-supplied under that name) looks identical to "this value just has no icon" from
19
+ * `resolveAssetPath` alone, and the two need very different fixes. Checks the same directory
20
+ * `resolveAssetPath` would have read from; `undefined` when that directory is fine (exists, is a
21
+ * directory, is readable) - the miss is then just this value's.
22
+ */
23
+ export declare function describeAssetsDirProblem(templatesDir: string, bundledTemplatesDir: string, assetsName: string): string | undefined;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.normalizeAssetKey = normalizeAssetKey;
4
4
  exports.resolveAssetPath = resolveAssetPath;
5
+ exports.describeAssetsDirProblem = describeAssetsDirProblem;
5
6
  const fs_1 = require("fs");
6
7
  const path_1 = require("path");
7
8
  /**
@@ -11,23 +12,77 @@ const path_1 = require("path");
11
12
  * a file (non-string, empty, or all-punctuation), which callers treat as "no image".
12
13
  */
13
14
  function normalizeAssetKey(value) {
14
- if (typeof value !== 'string')
15
+ if (typeof value !== "string")
15
16
  return undefined;
16
17
  const normalized = value
17
18
  .trim()
18
19
  .toLowerCase()
19
- .replace(/[^a-z0-9]+/g, '_')
20
- .replace(/^_+|_+$/g, '');
20
+ .replace(/[^a-z0-9]+/g, "_")
21
+ .replace(/^_+|_+$/g, "");
21
22
  return normalized || undefined;
22
23
  }
23
24
  /**
24
- * Resolves an `assets=` binding to an actual `.svg` file, looked up as `<key>.svg` inside `assetsDir`
25
- * resolved relative to the template's own directory (mirroring how a bundled template's relative asset
26
- * references keep working whether the template itself is the bundled copy or a user override elsewhere) -
27
- * `undefined` if no matching file exists, which callers treat as "no image" rather than an error, since an
28
- * unmapped value (e.g. a phase name the asset set doesn't cover) is an expected, not exceptional, case.
25
+ * Picks which `assets/<assetsName>` directory an `assets=` binding should read from - the user's own
26
+ * `templatesDir/assets/<assetsName>` if it exists as a directory at all, otherwise
27
+ * `bundledTemplatesDir/assets/<assetsName>` (see `BUNDLED_TEMPLATES_DIR` in `../config.ts`). This is a
28
+ * whole-directory choice, not a per-file merge: a user who provides their own `assets/lunar_phases`
29
+ * directory (even a partial one, missing some phases) gets exactly that directory and nothing from the
30
+ * bundled set, so it's always clear which files are in play - see the module's "overrides only work at
31
+ * directory level" design note. This choice is independent of which template file (bundled or a user
32
+ * override) ended up being rendered, so a user can override just a template, just its resources, or
33
+ * both, in any combination.
29
34
  */
30
- function resolveAssetPath(templatePath, assetsDir, key) {
31
- const candidate = (0, path_1.join)((0, path_1.dirname)(templatePath), assetsDir, `${key}.svg`);
35
+ function selectAssetsDir(templatesDir, bundledTemplatesDir, assetsName) {
36
+ const userDir = (0, path_1.join)(templatesDir, "assets", assetsName);
37
+ try {
38
+ if ((0, fs_1.statSync)(userDir).isDirectory())
39
+ return userDir;
40
+ }
41
+ catch {
42
+ // doesn't exist (or isn't readable) - fall through to the bundled directory
43
+ }
44
+ return (0, path_1.join)(bundledTemplatesDir, "assets", assetsName);
45
+ }
46
+ /**
47
+ * Resolves an `assets=` binding to an actual `.svg` file, looked up as `<key>.svg` inside whichever
48
+ * `assets/<assetsName>` directory `selectAssetsDir` picks. `undefined` if that directory has no
49
+ * matching file, which callers treat as "no image" rather than an error, since an unmapped value
50
+ * (e.g. a phase name the asset set doesn't cover) is an expected, not exceptional, case.
51
+ */
52
+ function resolveAssetPath(templatesDir, bundledTemplatesDir, assetsName, key) {
53
+ const candidate = (0, path_1.join)(selectAssetsDir(templatesDir, bundledTemplatesDir, assetsName), `${key}.svg`);
32
54
  return (0, fs_1.existsSync)(candidate) ? candidate : undefined;
33
55
  }
56
+ /** `undefined` when `dir` is a readable directory - otherwise a human-readable reason it isn't. */
57
+ function describeDir(dir) {
58
+ if (!(0, fs_1.existsSync)(dir))
59
+ return `"${dir}" does not exist`;
60
+ let stats;
61
+ try {
62
+ stats = (0, fs_1.statSync)(dir);
63
+ }
64
+ catch (err) {
65
+ return `"${dir}" could not be read: ${err.message}`;
66
+ }
67
+ if (!stats.isDirectory())
68
+ return `"${dir}" is not a directory`;
69
+ try {
70
+ (0, fs_1.accessSync)(dir, fs_1.constants.R_OK);
71
+ }
72
+ catch (err) {
73
+ return `"${dir}" is not readable: ${err.message}`;
74
+ }
75
+ return undefined;
76
+ }
77
+ /**
78
+ * Diagnoses why an `assets=` directory itself might be at fault, for a caller to log when
79
+ * `resolveAssetPath` comes back empty - a wrong/misconfigured `assets=` name (typo, nothing bundled or
80
+ * user-supplied under that name) looks identical to "this value just has no icon" from
81
+ * `resolveAssetPath` alone, and the two need very different fixes. Checks the same directory
82
+ * `resolveAssetPath` would have read from; `undefined` when that directory is fine (exists, is a
83
+ * directory, is readable) - the miss is then just this value's.
84
+ */
85
+ function describeAssetsDirProblem(templatesDir, bundledTemplatesDir, assetsName) {
86
+ const problem = describeDir(selectAssetsDir(templatesDir, bundledTemplatesDir, assetsName));
87
+ return problem && `assets directory ${problem}`;
88
+ }
@@ -1,4 +1,4 @@
1
- import { TemplateContext } from './types';
1
+ import { TemplateContext } from "./types";
2
2
  declare const SOURCES: readonly ["signalk", "resources", "einklabel"];
3
3
  type Source = (typeof SOURCES)[number];
4
4
  /**
@@ -17,7 +17,14 @@ export interface Binding {
17
17
  /** Explicit unit-preferences category (e.g. `depth`, `speed`, `temperature`) for a numeric value with no path metadata of its own, e.g. a `source=resources` value - see `../unitCategories.ts`. */
18
18
  category?: string;
19
19
  round?: number;
20
- /** Only meaningful on an `<image>` element's binding (see `SvgRenderer`) - a directory, resolved relative to the template file, of `<value>.svg` files to pick from by the resolved value (see `../assets.ts`'s `normalizeAssetKey`). */
20
+ /**
21
+ * Only meaningful on an `<image>` element's binding (see `SvgRenderer`) - names an `assets/<name>`
22
+ * directory (e.g. `assets=lunar_phases` for `templates/assets/lunar_phases/`) of `<value>.svg` files
23
+ * to pick from by the resolved value (see `../assets.ts`'s `normalizeAssetKey`/`resolveAssetPath`).
24
+ * Looked up under the user's configured templates directory first, then the plugin's own bundled
25
+ * `templates/` directory - never relative to the specific template file itself, so overriding a
26
+ * template doesn't also require duplicating its bundled asset sets.
27
+ */
21
28
  assets?: string;
22
29
  }
23
30
  /**
@@ -6,8 +6,8 @@ exports.resolveBinding = resolveBinding;
6
6
  exports.renderBinding = renderBinding;
7
7
  const xmldom_1 = require("@xmldom/xmldom");
8
8
  const formatters_1 = require("./formatters");
9
- const SOURCES = ['signalk', 'resources', 'einklabel'];
10
- const KNOWN_KEYS = new Set(['source', 'context', 'resource', 'path', 'format', 'category', 'round', 'assets']);
9
+ const SOURCES = ["signalk", "resources", "einklabel"];
10
+ const KNOWN_KEYS = new Set(["source", "context", "resource", "path", "format", "category", "round", "assets"]);
11
11
  /**
12
12
  * Parses a `<desc>` element's text content into a `Binding`, e.g.
13
13
  * `source=resources,resource=tides,path=extremes[0].level,category=depth,round=2` or, using the
@@ -19,15 +19,15 @@ const KNOWN_KEYS = new Set(['source', 'context', 'resource', 'path', 'format', '
19
19
  */
20
20
  function parseBinding(desc) {
21
21
  const trimmedDesc = desc.trim();
22
- if (trimmedDesc && !trimmedDesc.includes('=')) {
23
- return { source: 'signalk', context: 'self', path: trimmedDesc };
22
+ if (trimmedDesc && !trimmedDesc.includes("=")) {
23
+ return { source: "signalk", context: "self", path: trimmedDesc };
24
24
  }
25
25
  const fields = {};
26
- for (const pair of desc.split(',')) {
26
+ for (const pair of desc.split(",")) {
27
27
  const trimmed = pair.trim();
28
28
  if (!trimmed)
29
29
  continue;
30
- const eq = trimmed.indexOf('=');
30
+ const eq = trimmed.indexOf("=");
31
31
  if (eq < 0) {
32
32
  throw new Error(`invalid binding "${desc}" - expected "key=value" pairs, got "${trimmed}"`);
33
33
  }
@@ -37,12 +37,12 @@ function parseBinding(desc) {
37
37
  }
38
38
  fields[key] = trimmed.slice(eq + 1).trim();
39
39
  }
40
- const source = (fields.source ?? 'signalk');
40
+ const source = (fields.source ?? "signalk");
41
41
  if (!SOURCES.includes(source)) {
42
42
  throw new Error(`invalid binding "${desc}" - unknown source "${source}"`);
43
43
  }
44
- const context = fields.context ?? 'self';
45
- if (source === 'resources' && !fields.resource) {
44
+ const context = fields.context ?? "self";
45
+ if (source === "resources" && !fields.resource) {
46
46
  throw new Error(`invalid binding "${desc}" - source=resources requires a "resource" key`);
47
47
  }
48
48
  if (!fields.path) {
@@ -67,12 +67,12 @@ function parseBinding(desc) {
67
67
  * way as a `<text>` binding.
68
68
  */
69
69
  function findBindings(svgSource) {
70
- const doc = new xmldom_1.DOMParser().parseFromString(svgSource, 'image/svg+xml');
70
+ const doc = new xmldom_1.DOMParser().parseFromString(svgSource, "image/svg+xml");
71
71
  const bindings = [];
72
- for (const tagName of ['text', 'image']) {
72
+ for (const tagName of ["text", "image"]) {
73
73
  const elements = doc.getElementsByTagName(tagName);
74
74
  for (let i = 0; i < elements.length; i++) {
75
- const desc = elements.item(i)?.getElementsByTagName('desc').item(0);
75
+ const desc = elements.item(i)?.getElementsByTagName("desc").item(0);
76
76
  if (desc?.textContent) {
77
77
  bindings.push(parseBinding(desc.textContent));
78
78
  }
@@ -83,12 +83,12 @@ function findBindings(svgSource) {
83
83
  /** Supports both `a.[0].b` and `a[0].b` array index notation, matching `setAtPath` in repaintScheduler.ts. */
84
84
  function getAtPath(obj, path) {
85
85
  const segments = path
86
- .replace(/\[(\d+)\]/g, '.$1')
87
- .split('.')
86
+ .replace(/\[(\d+)\]/g, ".$1")
87
+ .split(".")
88
88
  .filter((segment) => segment.length > 0);
89
89
  let node = obj;
90
90
  for (const segment of segments) {
91
- if (node === null || typeof node !== 'object')
91
+ if (node === null || typeof node !== "object")
92
92
  return undefined;
93
93
  node = node[segment];
94
94
  }
@@ -96,7 +96,7 @@ function getAtPath(obj, path) {
96
96
  }
97
97
  /** Resolves a parsed `Binding` against the render context assembled by `assembleRawContext`. */
98
98
  function resolveBinding(binding, context) {
99
- if (binding.source === 'signalk') {
99
+ if (binding.source === "signalk") {
100
100
  const signalk = context.signalk;
101
101
  const vessel = signalk?.[binding.context];
102
102
  if (vessel === undefined) {
@@ -104,7 +104,7 @@ function resolveBinding(binding, context) {
104
104
  }
105
105
  return getAtPath(vessel, binding.path);
106
106
  }
107
- if (binding.source === 'einklabel') {
107
+ if (binding.source === "einklabel") {
108
108
  const meta = context.meta;
109
109
  if (meta === undefined) {
110
110
  throw new Error('binding references source "einklabel" but no "meta" is present in the render context');
@@ -127,7 +127,7 @@ function resolveBinding(binding, context) {
127
127
  * rather than an error).
128
128
  */
129
129
  function resolveDisplayUnits(binding, context) {
130
- if (binding.source !== 'signalk')
130
+ if (binding.source !== "signalk")
131
131
  return undefined;
132
132
  const pathMeta = context.pathMeta;
133
133
  return pathMeta?.[binding.context]?.[binding.path]?.displayUnits;
@@ -161,20 +161,20 @@ function resolveCategoryDisplayUnits(binding, context) {
161
161
  */
162
162
  function renderBinding(binding, context) {
163
163
  const value = resolveBinding(binding, context);
164
- if (binding.format && binding.format !== 'raw')
164
+ if (binding.format && binding.format !== "raw")
165
165
  return (0, formatters_1.applyFormat)(binding.format, value, context, binding.round);
166
- if (typeof value === 'number') {
166
+ if (typeof value === "number") {
167
167
  if (binding.category)
168
168
  return (0, formatters_1.formatDisplayUnits)(value, resolveCategoryDisplayUnits(binding, context), binding.round);
169
- const displayUnits = binding.format === 'raw' ? undefined : resolveDisplayUnits(binding, context);
169
+ const displayUnits = binding.format === "raw" ? undefined : resolveDisplayUnits(binding, context);
170
170
  if (displayUnits)
171
171
  return (0, formatters_1.formatDisplayUnits)(value, displayUnits, binding.round);
172
172
  if (binding.round !== undefined)
173
173
  return value.toFixed(binding.round);
174
174
  }
175
175
  if (value === null || value === undefined)
176
- return '';
177
- if (typeof value === 'object')
176
+ return "";
177
+ if (typeof value === "object")
178
178
  return JSON.stringify(value);
179
179
  return String(value);
180
180
  }
@@ -24,9 +24,9 @@ exports.GENERIC_FONT_FAMILY_MAP = exports.DEFAULT_FONT_PATHS = void 0;
24
24
  */
25
25
  exports.DEFAULT_FONT_PATHS = [
26
26
  // first font is the default
27
- require.resolve('@fontsource/roboto/files/roboto-latin-400-normal.woff2'),
28
- require.resolve('@fontsource/roboto-mono/files/roboto-mono-latin-400-normal.woff2'),
29
- require.resolve('@fontsource/roboto-serif/files/roboto-serif-latin-400-normal.woff2'),
27
+ require.resolve("@fontsource/roboto/files/roboto-latin-400-normal.woff2"),
28
+ require.resolve("@fontsource/roboto-mono/files/roboto-mono-latin-400-normal.woff2"),
29
+ require.resolve("@fontsource/roboto-serif/files/roboto-serif-latin-400-normal.woff2"),
30
30
  ];
31
31
  /**
32
32
  * Maps each CSS generic font-family keyword to the literal embedded name (nameID1) of the
@@ -36,7 +36,7 @@ exports.DEFAULT_FONT_PATHS = [
36
36
  * - see project memory. Keep these in sync with DEFAULT_FONT_PATHS if a bucket's font ever changes.
37
37
  */
38
38
  exports.GENERIC_FONT_FAMILY_MAP = {
39
- 'sans-serif': 'Roboto',
40
- monospace: 'Roboto Mono',
41
- serif: 'Roboto Serif 20pt',
39
+ "sans-serif": "Roboto",
40
+ monospace: "Roboto Mono",
41
+ serif: "Roboto Serif 20pt",
42
42
  };
@@ -1,4 +1,4 @@
1
- import { TemplateContext } from './types';
1
+ import { TemplateContext } from "./types";
2
2
  /**
3
3
  * Matches `displayUnits` on a path's own metadata (`app.getMetadata(path).displayUnits` in the live
4
4
  * plugin, the `.../meta` tree over HTTP in the CLI) - SignalK's per-path unit-preference info, fully