@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
package/CHANGELOG.md CHANGED
@@ -1,3 +1,25 @@
1
+ # 0.7.1
2
+
3
+ - Fix path value retrieved for Image Fields when running in live plugin
4
+
5
+ # 0.7.0
6
+
7
+ ## Logging
8
+
9
+ - Standardize all log output to show a `signalk-einklabel-plugin` prefix
10
+
11
+ ## Image Fields
12
+
13
+ - Include image fields on CLI `fields` command output
14
+ - Better logging diagnostics if asset image resolution fails or directory not found
15
+ - Replace separate fixed `resources/svg` directory for images with user overridable `templates/assets`
16
+ - Means that the bundled lunar phase icons can be easily replaced without touching the template - use the same file names in `templates/assets/lunar_phases` directory.
17
+ - Added attribution to the Lunar Phases icons
18
+
19
+ ## Build
20
+
21
+ - Added `pre-commit`, `oxlint` and `oxfmt` for code quality, retired `prettier`
22
+
1
23
  # 0.6.4
2
24
 
3
25
  - Log improvements for device paint requests
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![npm downloads](https://img.shields.io/npm/dm/@rhizomatics/signalk-einklabel-plugin.svg)](https://www.npmjs.com/package/@rhizomatics/signalk-einklabel-plugin)
5
5
  [![SignalK Plugin CI](https://github.com/rhizomatics/signalk-einklabel-plugin/actions/workflows/signalk-ci.yml/badge.svg)](https://github.com/rhizomatics/signalk-einklabel-plugin/actions/workflows/signalk-ci.yml)
6
6
  [![codecov](https://img.shields.io/codecov/c/github/rhizomatics/signalk-einklabel-plugin)](https://codecov.io/gh/rhizomatics/signalk-einklabel-plugin)
7
- [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier)
7
+ [![code style: oxfmt](https://img.shields.io/badge/code_style-oxfmt-blue.svg)](https://github.com)
8
8
  [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://github.com/rhizomatics/signalk-einklabel-plugin/blob/main/LICENSE)
9
9
 
10
10
  ** Fully working but limited vendor/product support and requires Linux for device access **
@@ -26,8 +26,10 @@ Unlike some eInk projects, this plugin doesn't require any physical modification
26
26
  Most of these requirements are about making SignalK work with Bluetooth Low Energy, which is good thing to have anyway, since vendors like Victron, Switchbot, Ruuvi and others have BLE enabled hardware that's useful to have on a boat. [Direct BLE support](https://github.com/SignalK/signalk-server/issues/2411) in SignalK is being planned in 2026.
27
27
 
28
28
  1. A SignalK server, **running Linux**
29
- - MacOS and Windows aren't supported by the [BLE interface layer](https://www.npmjs.com/package/@naugehyde/node-ble), however can be used for template development and
29
+
30
+ - MacOS and Windows aren't supported by the [BLE interface layer](https://www.npmjs.com/package/@naugehyde/node-ble), however can be used for template development and
30
31
  debugging (everything except `scan` and `paint`)
32
+
31
33
  2. A Bluetooth adapter, that can handle BLE (Bluetooth Low Energy), which is Bluetooth v4.0 or higher
32
34
 
33
35
  - Bluetooth adapters for Linux can be tricky, TP-Link UB400 and Asus USB-BT500 are two well-known and available ones
@@ -75,6 +77,12 @@ The tide clock needs the [signalk-tides](https://github.com/openwatersio/signalk
75
77
  To show the lunar phase, the `environment.moon.phaseName` path is required, which can
76
78
  be easily achieved by installing and configuring the `derived-data` plugin.
77
79
 
80
+ ## Configuration
81
+
82
+ Use the standard configuration option in the SignalK menu for the plugin.
83
+
84
+ ![Plugin Configuration](docs/assets/screenshots/plugin_config.png)
85
+
78
86
  ## Scanning for Devices
79
87
 
80
88
  Since these are ultra-low power devices, they don't respond instantly to either identify themselves or accept a new image. By default, both scanning and painting have time-outs to wait for a response, which can be altered in the plugin configuration or CLI argument.
@@ -88,10 +96,9 @@ The plugin will optionally re-scan whenever it starts up, although this isn't es
88
96
  The command line tools, run from inside the `.signalk` directory, can be used to help troubleshoot
89
97
 
90
98
  - Scan for longer, in this example 90 seconds
91
- - ```npx esl-cli scan -d 90```
99
+ - `npx esl-cli scan -d 90`
92
100
  - Scan for all BLE devices, whatever they are
93
- - ```npx esl-cli scan -a```
94
-
101
+ - `npx esl-cli scan -a`
95
102
 
96
103
  ## Templating
97
104
 
@@ -144,15 +151,17 @@ These can all be combined as in `source=resources,resource=tides,path=extremes[2
144
151
 
145
152
  ### Non-Textual Fields (Images)
146
153
 
147
- The same `<desc>` mechanism works on an `<image>` element instead of a `<text>` element, for a value that's better shown as a picture than as text - a moon phase icon, a wind direction arrow, a weather condition glyph, and so on. Rather than substituting text, the resolved value picks one of a directory of `.svg` files to embed, by an extra required `assets=` key naming that directory (resolved relative to the template file itself, so a bundled template and a user override both work the same way). For example, the tide clock's moon phase icon uses:
154
+ The same `<desc>` mechanism works on an `<image>` element instead of a `<text>` element, for a value that's better shown as a picture than as text - a moon phase icon, a wind direction arrow, a weather condition glyph, and so on. Rather than substituting text, the resolved value picks one of a directory of `.svg` files to embed, by an extra required `assets=` key naming that directory - an `assets/<name>` sub-directory looked up in your configured `templates` directory first, and the bundled `templates` directory otherwise. For example, the tide clock's moon phase icon uses:
148
155
 
149
156
  ```
150
- path=environment.moon.phaseName,assets=../resources/svg/lunar_phases
157
+ path=environment.moon.phaseName,assets=lunar_phases
151
158
  ```
152
159
 
153
- The resolved value (e.g. `"Waning Gibbous"`, as published by the [derived-data](https://www.npmjs.com/package/signalk-derived-data) plugin) is normalized to match a filename - lower-cased, punctuation and spaces collapsed to underscores - so `"Waning Gibbous"` picks `waning_gibbous.svg` out of that directory. If the underlying path has no value at all (e.g. the `derived-data` plugin isn't installed), or the value doesn't normalize to any file in the directory, the `<image>` element is simply omitted from that render - no broken image, no placeholder, nothing shown.
160
+ which resolves against `templates/assets/lunar_phases/` (bundled, or your own configured `templates` directory's `assets/lunar_phases/` if you have one). The resolved value (e.g. `"Waning Gibbous"`, as published by the [derived-data](https://www.npmjs.com/package/signalk-derived-data) plugin) is normalized to match a filename - lower-cased, punctuation and spaces collapsed to underscores - so `"Waning Gibbous"` picks `waning_gibbous.svg` out of that directory. If the underlying path has no value at all (e.g. the `derived-data` plugin isn't installed), or the value doesn't normalize to any file in the directory, the `<image>` element is omitted from that render - no broken image, no placeholder, nothing shown - and a line is logged to the console so a missing/unmatched value isn't silently invisible.
161
+
162
+ If you don't like the bundled moon phase icons, save your own `<value>.svg` files in the `assets/lunar_phases` sub-directory of your configured `templates` directory - the whole directory is used in place of the bundled one, so add all 8 phases you want to keep, not just the ones you're changing.
154
163
 
155
- This is a general mechanism, not specific to moon phases - any `source`/`context`/`path`/`format` combination valid for a `<text>` binding works here too (a `source=resources` value, an explicit `category=`, etc.), the only difference is the required `assets=` directory and the "no match -> no image" behaviour instead of substituted text. To add your own, put a directory of `<value>.svg` files somewhere relative to your template, add an `<image>` element in your SVG editor at the size/position you want, and give it a `<desc>` the same way you would a text field.
164
+ This is a general mechanism, not specific to moon phases - any `source`/`context`/`path`/`format` combination valid for a `<text>` binding works here too (a `source=resources` value, an explicit `category=`, etc.), the only difference is the required `assets=` directory and the "no match -> no image" behaviour instead of substituted text. To add your own, put a directory of `<value>.svg` files under an `assets/<name>` sub-directory of your `templates` directory, add an `<image>` element in your SVG editor at the size/position you want, and give it a `<desc>` the same way you would a text field - overriding just the template, just its assets, or both together, all work independently.
156
165
 
157
166
  ### Fonts
158
167
 
@@ -219,7 +228,7 @@ Additional vendors and devices can be added by a separate npm package that imple
219
228
 
220
229
  Templates can be added to the configurable directory. [Inkscape](https://inkscape.org) free, open source, and recommended for editing templates, or your own favourite editor, or by hand in a text editor for hard core (or just tidying up the template side).
221
230
 
222
- ![Example Field Definition](docs/assets/inkscape_desc.png)
231
+ ![Example Field Definition](docs/assets/screenshots/inkscape_desc.png)
223
232
 
224
233
  The object ID and label aren't used by the plugin, only the description is used to define fields. You can also add in ordinary text fields without field definitions, as labels, logos, help text etc.
225
234
 
package/dist/cli/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  "use strict";
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  const promises_1 = require("fs/promises");
5
+ const path_1 = require("path");
5
6
  const commander_1 = require("commander");
6
7
  const xmldom_1 = require("@xmldom/xmldom");
7
8
  const registry_1 = require("../devices/registry");
@@ -10,22 +11,24 @@ const bleDiscovery_1 = require("../devices/bleDiscovery");
10
11
  const svgRenderer_1 = require("../render/svgRenderer");
11
12
  const png_1 = require("../render/png");
12
13
  const binding_1 = require("../render/binding");
14
+ const assets_1 = require("../render/assets");
15
+ const config_1 = require("../config");
13
16
  const liveContext_1 = require("./liveContext");
14
17
  const httpJson_1 = require("../httpJson");
15
18
  const log_1 = require("./log");
16
19
  (0, registry_1.registerDriver)(new zhsunyco_1.ZhsunycoDriver());
17
20
  const VENDOR_IDENTIFY_TIMEOUT_MS = 30000;
18
21
  /** Tried in order when -u/--url is omitted (and -e/--example-data isn't given) - first one that answers wins. */
19
- const DEFAULT_SIGNALK_URLS = ['http://localhost', 'http://localhost:3000', 'https://localhost'];
22
+ const DEFAULT_SIGNALK_URLS = ["http://localhost", "http://localhost:3000", "https://localhost"];
20
23
  const COLOUR_CODES = {
21
- BW: ['black', 'white'],
22
- BWR: ['black', 'white', 'red'],
23
- BWRY: ['black', 'white', 'red', 'yellow'],
24
+ BW: ["black", "white"],
25
+ BWR: ["black", "white", "red"],
26
+ BWRY: ["black", "white", "red", "yellow"],
24
27
  };
25
28
  function parseColours(code) {
26
29
  const colours = COLOUR_CODES[code.toUpperCase()];
27
30
  if (!colours) {
28
- throw new Error(`unknown --colours value "${code}" - expected one of ${Object.keys(COLOUR_CODES).join(', ')}`);
31
+ throw new Error(`unknown --colours value "${code}" - expected one of ${Object.keys(COLOUR_CODES).join(", ")}`);
29
32
  }
30
33
  return colours;
31
34
  }
@@ -41,7 +44,7 @@ async function resolveDefaultUrl() {
41
44
  (0, log_1.logDebug)(`${candidate} did not answer: ${err.message}`);
42
45
  }
43
46
  }
44
- throw new Error(`no -u/--url given and none of ${DEFAULT_SIGNALK_URLS.join(', ')} answered - specify the server explicitly with -u/--url`);
47
+ throw new Error(`no -u/--url given and none of ${DEFAULT_SIGNALK_URLS.join(", ")} answered - specify the server explicitly with -u/--url`);
45
48
  }
46
49
  /** Shared by every command that takes -u/--url and -e/--example-data - -e wins when both are present; when neither is given, probes DEFAULT_SIGNALK_URLS for a default. */
47
50
  async function assembleContext(opts, bindings) {
@@ -59,7 +62,7 @@ async function identifyVendor(address) {
59
62
  const device = await (0, bleDiscovery_1.getOrDiscoverDevice)(adapter, address, VENDOR_IDENTIFY_TIMEOUT_MS);
60
63
  const name = await device.getName().catch(() => undefined);
61
64
  const manufacturerId = await (0, bleDiscovery_1.getManufacturerId)(device);
62
- (0, log_1.logDebug)(`${address}: advertised name="${name ?? ''}" manufacturerId=${manufacturerId ?? 'unknown'}`);
65
+ (0, log_1.logDebug)(`${address}: advertised name="${name ?? ""}" manufacturerId=${manufacturerId ?? "unknown"}`);
63
66
  const driver = (0, registry_1.allDrivers)().find((candidate) => candidate.matchesAdvertisement(name, manufacturerId));
64
67
  if (!driver) {
65
68
  throw new Error(`no registered vendor driver recognises device "${name ?? address}" - specify --vendor explicitly`);
@@ -71,50 +74,54 @@ async function identifyVendor(address) {
71
74
  }
72
75
  }
73
76
  const program = new commander_1.Command();
74
- program.name('esl-cli').description('Local CLI for testing ESL device scan and paint without a SignalK server');
75
- program.option('-r, --require <module>', 'require a module before running, e.g. an npm package that registers a vendor driver (repeatable)', (value, previous = []) => [...previous, value]);
76
- program.option('-l, --log-level <level>', 'log verbosity: info or debug (e.g. trace which URLs are fetched)', 'info');
77
- program.hook('preAction', () => {
77
+ program.name("esl-cli").description("Local CLI for testing ESL device scan and paint without a SignalK server");
78
+ program.option("-r, --require <module>", "require a module before running, e.g. an npm package that registers a vendor driver (repeatable)",
79
+ // Commander's own typings declare `previous` as always `string[]`, but at runtime it's actually
80
+ // `undefined` on the first invocation when no default is passed to `.option()` - the `| undefined`
81
+ // here keeps that true and makes the `= []` fallback meaningful rather than dead code.
82
+ (value, previous = []) => [...previous, value]);
83
+ program.option("-l, --log-level <level>", "log verbosity: info or debug (e.g. trace which URLs are fetched)", "info");
84
+ program.hook("preAction", () => {
78
85
  (0, log_1.setLogLevel)(program.opts().logLevel);
79
86
  for (const mod of program.opts().require ?? []) {
80
87
  require(mod);
81
88
  }
82
89
  });
83
90
  program
84
- .command('vendors')
85
- .description('List supported vendors and the device models each has confirmed metadata for')
91
+ .command("vendors")
92
+ .description("List supported vendors and the device models each has confirmed metadata for")
86
93
  .action(() => {
87
- const header = ['vendor', 'pid', 'hwid', 'label', 'size', 'colours'];
94
+ const header = ["vendor", "pid", "hwid", "label", "size", "colours"];
88
95
  const rows = [];
89
96
  for (const driver of (0, registry_1.allDrivers)()) {
90
97
  for (const device of driver.supportedDevices()) {
91
98
  rows.push([
92
99
  driver.vendor,
93
- `0x${device.pid.toString(16).padStart(4, '0')}`,
94
- device.hwVersion ? `0x${device.hwVersion}` : '',
100
+ `0x${device.pid.toString(16).padStart(4, "0")}`,
101
+ device.hwVersion ? `0x${device.hwVersion}` : "",
95
102
  device.label,
96
103
  `${device.width}x${device.height}`,
97
- device.colours.join(','),
104
+ device.colours.join(","),
98
105
  ]);
99
106
  }
100
107
  }
101
108
  if (rows.length === 0) {
102
- console.log('(no confirmed devices yet)');
109
+ console.log("(no confirmed devices yet)");
103
110
  return;
104
111
  }
105
112
  const widths = header.map((title, col) => Math.max(title.length, ...rows.map((row) => row[col].length)));
106
- const printRow = (row) => console.log(row.map((cell, col) => cell.padEnd(widths[col])).join(' '));
113
+ const printRow = (row) => console.log(row.map((cell, col) => cell.padEnd(widths[col])).join(" "));
107
114
  printRow(header);
108
115
  rows.forEach(printRow);
109
116
  });
110
117
  program
111
- .command('scan')
112
- .description('Scan for supported BLE ESL devices across all registered vendor drivers')
113
- .option('-d, --duration <seconds>', 'scan duration in seconds', '10')
114
- .option('-a, --all-devices', "list every nearby BLE device, not just ones a registered driver recognised - unmatched devices show address/name/mfr/rssi only, since there's no driver to do a vendor-specific read like battery")
118
+ .command("scan")
119
+ .description("Scan for supported BLE ESL devices across all registered vendor drivers")
120
+ .option("-d, --duration <seconds>", "scan duration in seconds", "10")
121
+ .option("-a, --all-devices", "list every nearby BLE device, not just ones a registered driver recognised - unmatched devices show address/name/mfr/rssi only, since there's no driver to do a vendor-specific read like battery")
115
122
  .action(async (opts) => {
116
123
  const durationMs = Number(opts.duration) * 1000;
117
- const header = ['vendor', 'address', 'name', 'pid', 'hwid', 'label', 'mfr', 'battery', 'rssi'];
124
+ const header = ["vendor", "address", "name", "pid", "hwid", "label", "mfr", "battery", "rssi"];
118
125
  const rows = [];
119
126
  let matchedCount = 0;
120
127
  const drivers = (0, registry_1.allDrivers)();
@@ -122,25 +129,25 @@ program
122
129
  await (0, bleDiscovery_1.withDiscovery)(durationMs, async (adapter) => {
123
130
  await (0, bleDiscovery_1.forEachAdvertisedDevice)(adapter, async ({ device, address, name, manufacturerId, manufacturerData }) => {
124
131
  const driver = drivers.find((candidate) => candidate.matchesAdvertisement(name, manufacturerId));
125
- const mfr = manufacturerId !== undefined ? `0x${manufacturerId.toString(16).padStart(4, '0')}` : '';
132
+ const mfr = manufacturerId !== undefined ? `0x${manufacturerId.toString(16).padStart(4, "0")}` : "";
126
133
  if (!driver) {
127
134
  if (opts.allDevices) {
128
135
  const rssi = await device
129
136
  .getRSSI()
130
137
  .then((value) => (value === undefined ? undefined : Number(value)))
131
138
  .catch(() => undefined);
132
- rows.push(['(unmatched)', address, name ?? '', '', '', '', mfr, '', String(rssi ?? '')]);
139
+ rows.push(["(unmatched)", address, name ?? "", "", "", "", mfr, "", String(rssi ?? "")]);
133
140
  }
134
141
  return;
135
142
  }
136
143
  matchedCount++;
137
144
  const found = await driver.identifyDevice(device, address, name, manufacturerId, manufacturerData);
138
145
  (0, log_1.logDebug)(`${driver.vendor}: identified ${found.name ?? found.address}`);
139
- const pid = found.pid !== undefined ? `0x${found.pid.toString(16).padStart(4, '0')}` : '';
140
- const hwid = found.hwVersion ? `0x${found.hwVersion}` : '';
141
- const label = found.metadata?.label ?? '';
142
- const battery = found.batteryMv !== undefined ? `${found.batteryMv}mV` : '';
143
- rows.push([driver.vendor, found.address, found.name ?? '', pid, hwid, label, mfr, battery, String(found.rssi ?? '')]);
146
+ const pid = found.pid !== undefined ? `0x${found.pid.toString(16).padStart(4, "0")}` : "";
147
+ const hwid = found.hwVersion ? `0x${found.hwVersion}` : "";
148
+ const label = found.metadata?.label ?? "";
149
+ const battery = found.batteryMv !== undefined ? `${found.batteryMv}mV` : "";
150
+ rows.push([driver.vendor, found.address, found.name ?? "", pid, hwid, label, mfr, battery, String(found.rssi ?? "")]);
144
151
  });
145
152
  });
146
153
  if (matchedCount === 0) {
@@ -150,27 +157,27 @@ program
150
157
  return;
151
158
  }
152
159
  const widths = header.map((title, col) => Math.max(title.length, ...rows.map((row) => row[col].length)));
153
- const printRow = (row) => console.log(row.map((cell, col) => cell.padEnd(widths[col])).join(' '));
160
+ const printRow = (row) => console.log(row.map((cell, col) => cell.padEnd(widths[col])).join(" "));
154
161
  printRow(header);
155
162
  rows.forEach(printRow);
156
163
  });
157
164
  program
158
- .command('paint')
159
- .description('Render a template against a live SignalK server and send it to a device')
160
- .option('-v, --vendor <vendor>', "vendor driver to use - if omitted, inferred from the device's advertised name")
161
- .requiredOption('-a, --address <address>', 'BLE address of the device')
162
- .requiredOption('-t, --template <path>', 'path to SVG template')
163
- .option('-u, --url <url>', "SignalK server base URL - resolves the template's source=signalk/resources bindings - if omitted, tries each of " +
164
- DEFAULT_SIGNALK_URLS.join(', ') +
165
- ' in turn')
166
- .option('-e, --example-data <dir>', 'load vessels/resources from local example JSON files in <dir> (e.g. ./examples) instead of a live SignalK server - alternative to -u')
167
- .option('-k, --aes-key <hex>', "AES-128 key for device authentication, as 32 hex characters - defaults to the vendor's stock key if omitted")
168
- .option('-w, --width <px>', 'render width', '416')
169
- .option('--height <px>', 'render height', '240')
170
- .option('--voffset <px>', 'vertical pixel offset of the panel - overrides the looked-up model for unsupported hardware (requires --colours)', '0')
171
- .option('--colours <code>', 'device colour palette for unsupported hardware: BW, BWR, or BWRY - overrides the looked-up model (uses --width/--height/--voffset)')
172
- .option('--connect-timeout <seconds>', 'BLE connect timeout before giving up on an attempt', '30')
173
- .option('--retries <n>', 'number of paint attempts (including the first) before giving up', '3')
165
+ .command("paint")
166
+ .description("Render a template against a live SignalK server and send it to a device")
167
+ .option("-v, --vendor <vendor>", "vendor driver to use - if omitted, inferred from the device's advertised name")
168
+ .requiredOption("-a, --address <address>", "BLE address of the device")
169
+ .requiredOption("-t, --template <path>", "path to SVG template")
170
+ .option("-u, --url <url>", "SignalK server base URL - resolves the template's source=signalk/resources bindings - if omitted, tries each of " +
171
+ DEFAULT_SIGNALK_URLS.join(", ") +
172
+ " in turn")
173
+ .option("-e, --example-data <dir>", "load vessels/resources from local example JSON files in <dir> (e.g. ./examples) instead of a live SignalK server - alternative to -u")
174
+ .option("-k, --aes-key <hex>", "AES-128 key for device authentication, as 32 hex characters - defaults to the vendor's stock key if omitted")
175
+ .option("-w, --width <px>", "render width", "416")
176
+ .option("--height <px>", "render height", "240")
177
+ .option("--voffset <px>", "vertical pixel offset of the panel - overrides the looked-up model for unsupported hardware (requires --colours)", "0")
178
+ .option("--colours <code>", "device colour palette for unsupported hardware: BW, BWR, or BWRY - overrides the looked-up model (uses --width/--height/--voffset)")
179
+ .option("--connect-timeout <seconds>", "BLE connect timeout before giving up on an attempt", "30")
180
+ .option("--retries <n>", "number of paint attempts (including the first) before giving up", "3")
174
181
  .action(async (opts) => {
175
182
  const vendor = opts.vendor ?? (await identifyVendor(opts.address));
176
183
  const driver = (0, registry_1.getDriver)(vendor);
@@ -179,96 +186,122 @@ program
179
186
  }
180
187
  const modelOverride = opts.colours
181
188
  ? {
182
- label: 'manual override',
189
+ label: "manual override",
183
190
  width: Number(opts.width),
184
191
  height: Number(opts.height),
185
192
  voffset: Number(opts.voffset),
186
193
  colours: parseColours(opts.colours),
187
194
  }
188
195
  : undefined;
189
- const bindings = (0, binding_1.findBindings)(await (0, promises_1.readFile)(opts.template, 'utf-8'));
196
+ const bindings = (0, binding_1.findBindings)(await (0, promises_1.readFile)(opts.template, "utf-8"));
190
197
  const context = await assembleContext(opts, bindings);
191
198
  const renderer = new svgRenderer_1.SvgRenderer();
192
- const bitmap = await renderer.render(opts.template, context, Number(opts.width), Number(opts.height));
199
+ const bitmap = await renderer.render(opts.template, context, Number(opts.width), Number(opts.height), (0, path_1.dirname)(opts.template), config_1.BUNDLED_TEMPLATES_DIR);
193
200
  const connectTimeoutMs = Number(opts.connectTimeout) * 1000;
194
201
  await (0, bleDiscovery_1.withRetries)(Number(opts.retries), async (attempt) => {
195
202
  if (attempt > 1) {
196
203
  (0, log_1.logDebug)(`Attempting paint ${attempt}/${opts.retries}`);
197
204
  }
198
- await driver.paint(bitmap, { address: opts.address, aesKey: opts.aesKey, modelOverride, connectTimeoutMs });
205
+ await driver.paint(bitmap, {
206
+ address: opts.address,
207
+ aesKey: opts.aesKey,
208
+ modelOverride,
209
+ connectTimeoutMs,
210
+ });
199
211
  });
200
212
  console.log(`painted ${opts.address} (${bitmap.width}x${bitmap.height}) ${opts.colours}`);
201
213
  });
202
214
  program
203
- .command('render')
204
- .description('Render a template against a live SignalK server and write a PNG, without needing a device')
205
- .requiredOption('-t, --template <path>', 'path to SVG template')
206
- .requiredOption('-o, --output <path>', 'output PNG path')
207
- .option('-u, --url <url>', "SignalK server base URL - resolves the template's source=signalk/resources bindings - if omitted, tries each of " +
208
- DEFAULT_SIGNALK_URLS.join(', ') +
209
- ' in turn')
210
- .option('-e, --example-data <dir>', 'load vessels/resources from local example JSON files in <dir> (e.g. ./examples) instead of a live SignalK server - alternative to -u')
211
- .option('-w, --width <px>', 'render width', '416')
212
- .option('--height <px>', 'render height', '240')
213
- .option('-f, --font <path>', 'override a bundled font with this file (repeatable) - defaults to the bundled monospace/sans-serif/serif trio', (value, previous = []) => [...previous, value])
215
+ .command("render")
216
+ .description("Render a template against a live SignalK server and write a PNG, without needing a device")
217
+ .requiredOption("-t, --template <path>", "path to SVG template")
218
+ .requiredOption("-o, --output <path>", "output PNG path")
219
+ .option("-u, --url <url>", "SignalK server base URL - resolves the template's source=signalk/resources bindings - if omitted, tries each of " +
220
+ DEFAULT_SIGNALK_URLS.join(", ") +
221
+ " in turn")
222
+ .option("-e, --example-data <dir>", "load vessels/resources from local example JSON files in <dir> (e.g. ./examples) instead of a live SignalK server - alternative to -u")
223
+ .option("-w, --width <px>", "render width", "416")
224
+ .option("--height <px>", "render height", "240")
225
+ .option("-f, --font <path>", "override a bundled font with this file (repeatable) - defaults to the bundled monospace/sans-serif/serif trio",
226
+ // See the -r/--require option above for why `| undefined` is needed here despite commander's typings.
227
+ (value, previous = []) => [...previous, value])
214
228
  .action(async (opts) => {
215
- const bindings = (0, binding_1.findBindings)(await (0, promises_1.readFile)(opts.template, 'utf-8'));
229
+ const bindings = (0, binding_1.findBindings)(await (0, promises_1.readFile)(opts.template, "utf-8"));
216
230
  const context = await assembleContext(opts, bindings);
217
231
  const renderer = opts.font ? new svgRenderer_1.SvgRenderer(opts.font) : new svgRenderer_1.SvgRenderer();
218
- const bitmap = await renderer.render(opts.template, context, Number(opts.width), Number(opts.height));
232
+ const bitmap = await renderer.render(opts.template, context, Number(opts.width), Number(opts.height), (0, path_1.dirname)(opts.template), config_1.BUNDLED_TEMPLATES_DIR);
219
233
  await (0, promises_1.writeFile)(opts.output, (0, png_1.bitmapToPng)(bitmap));
220
234
  console.log(`wrote ${opts.output} (${bitmap.width}x${bitmap.height})`);
221
235
  });
222
236
  program
223
- .command('fields')
224
- .description('List every <desc> binding in a template by element id, with its source spec and resolved value')
225
- .requiredOption('-t, --template <path>', 'path to SVG template')
226
- .option('-u, --url <url>', "SignalK server base URL - resolves the template's source=signalk/resources bindings - if omitted, tries each of " +
227
- DEFAULT_SIGNALK_URLS.join(', ') +
228
- ' in turn')
229
- .option('-e, --example-data <dir>', 'load vessels/resources from local example JSON files in <dir> (e.g. ./examples) instead of a live SignalK server - alternative to -u')
237
+ .command("fields")
238
+ .description("List every <desc> binding in a template by element id, with its source spec and resolved value")
239
+ .requiredOption("-t, --template <path>", "path to SVG template")
240
+ .option("-u, --url <url>", "SignalK server base URL - resolves the template's source=signalk/resources bindings - if omitted, tries each of " +
241
+ DEFAULT_SIGNALK_URLS.join(", ") +
242
+ " in turn")
243
+ .option("-e, --example-data <dir>", "load vessels/resources from local example JSON files in <dir> (e.g. ./examples) instead of a live SignalK server - alternative to -u")
230
244
  .action(async (opts) => {
231
- const doc = new xmldom_1.DOMParser().parseFromString(await (0, promises_1.readFile)(opts.template, 'utf-8'), 'image/svg+xml');
232
- const elements = doc.getElementsByTagName('text');
245
+ const doc = new xmldom_1.DOMParser().parseFromString(await (0, promises_1.readFile)(opts.template, "utf-8"), "image/svg+xml");
233
246
  const rows = [];
234
- for (let i = 0; i < elements.length; i++) {
235
- const element = elements.item(i);
236
- const desc = element?.getElementsByTagName('desc').item(0);
237
- if (!element || !desc?.textContent)
238
- continue;
239
- const id = element.getAttribute('id') ?? `#${i}`;
240
- try {
241
- rows.push({ id, desc: desc.textContent, binding: (0, binding_1.parseBinding)(desc.textContent) });
242
- }
243
- catch (err) {
244
- rows.push({ id, desc: desc.textContent, error: err.message });
247
+ for (const tag of ["text", "image"]) {
248
+ const elements = doc.getElementsByTagName(tag);
249
+ for (let i = 0; i < elements.length; i++) {
250
+ const element = elements.item(i);
251
+ const desc = element?.getElementsByTagName("desc").item(0);
252
+ if (!element || !desc?.textContent)
253
+ continue;
254
+ const id = element.getAttribute("id") ?? `${tag}#${i}`;
255
+ try {
256
+ rows.push({ id, tag, desc: desc.textContent, binding: (0, binding_1.parseBinding)(desc.textContent) });
257
+ }
258
+ catch (err) {
259
+ rows.push({ id, tag, desc: desc.textContent, error: err.message });
260
+ }
245
261
  }
246
262
  }
247
- const header = ['id', 'spec', 'value'];
263
+ const header = ["id", "spec", "value"];
248
264
  const table = await Promise.all(rows.map(async (row) => {
249
265
  if (row.error || !row.binding)
250
- return [row.id, row.desc, row.error ?? ''];
266
+ return [row.id, row.desc, row.error ?? ""];
251
267
  try {
252
268
  const context = await assembleContext(opts, [row.binding]);
253
- return [row.id, row.desc, (0, binding_1.renderBinding)(row.binding, context)];
269
+ if (row.tag !== "image") {
270
+ return [row.id, row.desc, (0, binding_1.renderBinding)(row.binding, context)];
271
+ }
272
+ // `<image>` bindings don't substitute text - they pick a `.svg` file (see SvgRenderer) -
273
+ // so show what asset that resolves to instead of running it through renderBinding's
274
+ // text formatting, otherwise this row would silently show nothing useful to test against.
275
+ if (!row.binding.assets) {
276
+ return [row.id, row.desc, 'ERROR: an <image> binding requires an "assets" key'];
277
+ }
278
+ const key = (0, assets_1.normalizeAssetKey)((0, binding_1.resolveBinding)(row.binding, context));
279
+ if (!key)
280
+ return [row.id, row.desc, "(no usable value - image would be omitted)"];
281
+ const assetPath = (0, assets_1.resolveAssetPath)((0, path_1.dirname)(opts.template), config_1.BUNDLED_TEMPLATES_DIR, row.binding.assets, key);
282
+ return [
283
+ row.id,
284
+ row.desc,
285
+ assetPath ? `"${key}" -> ${(0, path_1.basename)(assetPath)}` : `"${key}" -> no matching asset file (image would be omitted)`,
286
+ ];
254
287
  }
255
288
  catch (err) {
256
289
  return [row.id, row.desc, `ERROR: ${err.message}`];
257
290
  }
258
291
  }));
259
292
  const widths = header.map((title, col) => Math.max(title.length, ...table.map((cells) => cells[col].length)));
260
- const printRow = (cells) => console.log(cells.map((cell, col) => cell.padEnd(widths[col])).join(' '));
293
+ const printRow = (cells) => console.log(cells.map((cell, col) => cell.padEnd(widths[col])).join(" "));
261
294
  printRow(header);
262
295
  table.forEach(printRow);
263
296
  });
264
297
  program
265
- .command('field')
266
- .description('Resolve a single binding spec directly against a live SignalK server, with no template')
267
- .argument('<spec>', 'binding spec, e.g. "source=resources,resource=tides,path=station.name" or a bare SignalK path')
268
- .option('-u, --url <url>', "SignalK server base URL - resolves the spec's source=signalk/resources binding - if omitted, tries each of " +
269
- DEFAULT_SIGNALK_URLS.join(', ') +
270
- ' in turn')
271
- .option('-e, --example-data <dir>', 'load vessels/resources from local example JSON files in <dir> (e.g. ./examples) instead of a live SignalK server - alternative to -u')
298
+ .command("field")
299
+ .description("Resolve a single binding spec directly against a live SignalK server, with no template")
300
+ .argument("<spec>", 'binding spec, e.g. "source=resources,resource=tides,path=station.name" or a bare SignalK path')
301
+ .option("-u, --url <url>", "SignalK server base URL - resolves the spec's source=signalk/resources binding - if omitted, tries each of " +
302
+ DEFAULT_SIGNALK_URLS.join(", ") +
303
+ " in turn")
304
+ .option("-e, --example-data <dir>", "load vessels/resources from local example JSON files in <dir> (e.g. ./examples) instead of a live SignalK server - alternative to -u")
272
305
  .action(async (spec, opts) => {
273
306
  const binding = (0, binding_1.parseBinding)(spec);
274
307
  const context = await assembleContext(opts, [binding]);
@@ -1,5 +1,5 @@
1
- import { Binding } from '../render/binding';
2
- import { TemplateContext } from '../render/types';
1
+ import { Binding } from "../render/binding";
2
+ import { TemplateContext } from "../render/types";
3
3
  /**
4
4
  * CLI counterpart to `assembleRawContext` + `considerRepaint`'s `meta` injection in
5
5
  * repaintScheduler.ts - same `{ signalk, resources, pathMeta, categories, meta }` shape, but fetched