@rhizomatics/signalk-einklabel-plugin 0.6.3 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +37 -8
- package/dist/cli/index.js +130 -96
- package/dist/cli/liveContext.d.ts +2 -2
- package/dist/cli/liveContext.js +15 -15
- package/dist/cli/log.d.ts +1 -1
- package/dist/cli/log.js +4 -4
- package/dist/config.d.ts +12 -3
- package/dist/config.js +71 -49
- package/dist/devices/bleDiscovery.d.ts +1 -1
- package/dist/devices/bleDiscovery.js +9 -3
- package/dist/devices/registry.d.ts +1 -1
- package/dist/devices/types.d.ts +4 -4
- package/dist/devices/zhsunyco/encode.d.ts +2 -2
- package/dist/devices/zhsunyco/index.d.ts +3 -3
- package/dist/devices/zhsunyco/index.js +23 -11
- package/dist/devices/zhsunyco/metadata.d.ts +1 -1
- package/dist/devices/zhsunyco/metadata.js +12 -12
- package/dist/devices/zhsunyco/protocol.js +10 -10
- package/dist/httpJson.js +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/pathMeta.d.ts +1 -1
- package/dist/pathMeta.js +1 -1
- package/dist/plugin.d.ts +1 -1
- package/dist/plugin.js +16 -13
- package/dist/pluginVersion.d.ts +9 -0
- package/dist/pluginVersion.js +12 -2
- package/dist/render/assets.d.ts +14 -6
- package/dist/render/assets.js +65 -10
- package/dist/render/binding.d.ts +9 -2
- package/dist/render/binding.js +23 -23
- package/dist/render/fonts.js +6 -6
- package/dist/render/formatters.d.ts +1 -1
- package/dist/render/formatters.js +25 -22
- package/dist/render/png.d.ts +1 -1
- package/dist/render/svgRenderer.d.ts +9 -8
- package/dist/render/svgRenderer.js +33 -27
- package/dist/render/types.d.ts +1 -1
- package/dist/repaintScheduler.d.ts +2 -2
- package/dist/repaintScheduler.js +26 -22
- package/dist/resolveApiUrl.js +3 -3
- package/dist/unitCategories.d.ts +1 -1
- package/dist/unitCategories.js +3 -3
- package/docs/assets/screenshots/plugin_config.png +0 -0
- package/package.json +42 -39
- package/templates/assets/lunar_phases/README.md +18 -0
- package/templates/tide.svg +11 -11
- /package/docs/assets/{inkscape_desc.png → screenshots/inkscape_desc.png} +0 -0
- /package/{resources/svg → templates/assets}/lunar_phases/first_quarter.svg +0 -0
- /package/{resources/svg → templates/assets}/lunar_phases/full_moon.svg +0 -0
- /package/{resources/svg → templates/assets}/lunar_phases/new_moon.svg +0 -0
- /package/{resources/svg → templates/assets}/lunar_phases/third_quarter.svg +0 -0
- /package/{resources/svg → templates/assets}/lunar_phases/waning_crescent.svg +0 -0
- /package/{resources/svg → templates/assets}/lunar_phases/waning_gibbous.svg +0 -0
- /package/{resources/svg → templates/assets}/lunar_phases/waxing_crescent.svg +0 -0
- /package/{resources/svg → templates/assets}/lunar_phases/waxing_gibbous.svg +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
# 0.7.0
|
|
2
|
+
|
|
3
|
+
## Logging
|
|
4
|
+
|
|
5
|
+
- Standardize all log output to show a `signalk-einklabel-plugin` prefix
|
|
6
|
+
|
|
7
|
+
## Image Fields
|
|
8
|
+
|
|
9
|
+
- Include image fields on CLI `fields` command output
|
|
10
|
+
- Better logging diagnostics if asset image resolution fails or directory not found
|
|
11
|
+
- Replace separate fixed `resources/svg` directory for images with user overridable `templates/assets`
|
|
12
|
+
- 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.
|
|
13
|
+
- Added attribution to the Lunar Phases icons
|
|
14
|
+
|
|
15
|
+
## Build
|
|
16
|
+
|
|
17
|
+
- Added `pre-commit`, `oxlint` and `oxfmt` for code quality, retired `prettier`
|
|
18
|
+
|
|
19
|
+
# 0.6.4
|
|
20
|
+
|
|
21
|
+
- Log improvements for device paint requests
|
|
22
|
+
- CLI scan now shows `hwid`, and adds reason for failing to retrieve `battery` level
|
|
23
|
+
|
|
1
24
|
# 0.6.3
|
|
2
25
|
|
|
3
26
|
- Improve logging if an image cannot be found in an `assets` directory to match a SignalK path value
|
package/README.md
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/@rhizomatics/signalk-einklabel-plugin)
|
|
5
5
|
[](https://github.com/rhizomatics/signalk-einklabel-plugin/actions/workflows/signalk-ci.yml)
|
|
6
6
|
[](https://codecov.io/gh/rhizomatics/signalk-einklabel-plugin)
|
|
7
|
-
[](https://github.com)
|
|
8
8
|
[](https://github.com/rhizomatics/signalk-einklabel-plugin/blob/main/LICENSE)
|
|
9
9
|
|
|
10
|
-
** Fully working but limited vendor/product support **
|
|
10
|
+
** Fully working but limited vendor/product support and requires Linux for device access **
|
|
11
11
|
|
|
12
12
|
A SignalK plugin to display data from SignalK paths, APIs and plugins on Electronic Shelf Labels (ESL) over a Bluetooth Low Energy (BLE) connection using simple SVG templates.
|
|
13
13
|
|
|
@@ -25,7 +25,11 @@ Unlike some eInk projects, this plugin doesn't require any physical modification
|
|
|
25
25
|
|
|
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
|
-
1. A SignalK server,
|
|
28
|
+
1. A SignalK server, **running Linux**
|
|
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
|
|
31
|
+
debugging (everything except `scan` and `paint`)
|
|
32
|
+
|
|
29
33
|
2. A Bluetooth adapter, that can handle BLE (Bluetooth Low Energy), which is Bluetooth v4.0 or higher
|
|
30
34
|
|
|
31
35
|
- Bluetooth adapters for Linux can be tricky, TP-Link UB400 and Asus USB-BT500 are two well-known and available ones
|
|
@@ -73,6 +77,29 @@ The tide clock needs the [signalk-tides](https://github.com/openwatersio/signalk
|
|
|
73
77
|
To show the lunar phase, the `environment.moon.phaseName` path is required, which can
|
|
74
78
|
be easily achieved by installing and configuring the `derived-data` plugin.
|
|
75
79
|
|
|
80
|
+
## Configuration
|
|
81
|
+
|
|
82
|
+
Use the standard configuration option in the SignalK menu for the plugin.
|
|
83
|
+
|
|
84
|
+

|
|
85
|
+
|
|
86
|
+
## Scanning for Devices
|
|
87
|
+
|
|
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.
|
|
89
|
+
|
|
90
|
+
One other quirk is that some devices respond with a different name at different times, for example the genric `WOESL` sometimes and model specific `WL17500C74` other times. However, the MAC address, e.g. `66:66:17:50:0D:2B` is constant, and this is what's tracked by the plugin.
|
|
91
|
+
|
|
92
|
+
The plugin will optionally re-scan whenever it starts up, although this isn't essential once a label has been configured.
|
|
93
|
+
|
|
94
|
+
### Scans from CLI
|
|
95
|
+
|
|
96
|
+
The command line tools, run from inside the `.signalk` directory, can be used to help troubleshoot
|
|
97
|
+
|
|
98
|
+
- Scan for longer, in this example 90 seconds
|
|
99
|
+
- `npx esl-cli scan -d 90`
|
|
100
|
+
- Scan for all BLE devices, whatever they are
|
|
101
|
+
- `npx esl-cli scan -a`
|
|
102
|
+
|
|
76
103
|
## Templating
|
|
77
104
|
|
|
78
105
|
Templates are simply SVG files, to which expressions can be added to use SignalK data, with options to make it easier to read, like rounding or simplifying dates and times. The template can have sample data in the placeholder, so is easy to layout and visualize.
|
|
@@ -124,15 +151,17 @@ These can all be combined as in `source=resources,resource=tides,path=extremes[2
|
|
|
124
151
|
|
|
125
152
|
### Non-Textual Fields (Images)
|
|
126
153
|
|
|
127
|
-
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
|
|
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:
|
|
128
155
|
|
|
129
156
|
```
|
|
130
|
-
path=environment.moon.phaseName,assets
|
|
157
|
+
path=environment.moon.phaseName,assets=lunar_phases
|
|
131
158
|
```
|
|
132
159
|
|
|
133
|
-
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
|
|
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.
|
|
134
163
|
|
|
135
|
-
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
|
|
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.
|
|
136
165
|
|
|
137
166
|
### Fonts
|
|
138
167
|
|
|
@@ -199,7 +228,7 @@ Additional vendors and devices can be added by a separate npm package that imple
|
|
|
199
228
|
|
|
200
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).
|
|
201
230
|
|
|
202
|
-

|
|
231
|
+

|
|
203
232
|
|
|
204
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.
|
|
205
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 = [
|
|
22
|
+
const DEFAULT_SIGNALK_URLS = ["http://localhost", "http://localhost:3000", "https://localhost"];
|
|
20
23
|
const COLOUR_CODES = {
|
|
21
|
-
BW: [
|
|
22
|
-
BWR: [
|
|
23
|
-
BWRY: [
|
|
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(
|
|
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 ??
|
|
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(
|
|
75
|
-
program.option(
|
|
76
|
-
|
|
77
|
-
|
|
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(
|
|
85
|
-
.description(
|
|
91
|
+
.command("vendors")
|
|
92
|
+
.description("List supported vendors and the device models each has confirmed metadata for")
|
|
86
93
|
.action(() => {
|
|
87
|
-
const header = [
|
|
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,
|
|
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(
|
|
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(
|
|
112
|
-
.description(
|
|
113
|
-
.option(
|
|
114
|
-
.option(
|
|
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 = [
|
|
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,24 +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,
|
|
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([
|
|
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,
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
|
|
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 ?? "")]);
|
|
143
151
|
});
|
|
144
152
|
});
|
|
145
153
|
if (matchedCount === 0) {
|
|
@@ -149,27 +157,27 @@ program
|
|
|
149
157
|
return;
|
|
150
158
|
}
|
|
151
159
|
const widths = header.map((title, col) => Math.max(title.length, ...rows.map((row) => row[col].length)));
|
|
152
|
-
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(" "));
|
|
153
161
|
printRow(header);
|
|
154
162
|
rows.forEach(printRow);
|
|
155
163
|
});
|
|
156
164
|
program
|
|
157
|
-
.command(
|
|
158
|
-
.description(
|
|
159
|
-
.option(
|
|
160
|
-
.requiredOption(
|
|
161
|
-
.requiredOption(
|
|
162
|
-
.option(
|
|
163
|
-
DEFAULT_SIGNALK_URLS.join(
|
|
164
|
-
|
|
165
|
-
.option(
|
|
166
|
-
.option(
|
|
167
|
-
.option(
|
|
168
|
-
.option(
|
|
169
|
-
.option(
|
|
170
|
-
.option(
|
|
171
|
-
.option(
|
|
172
|
-
.option(
|
|
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")
|
|
173
181
|
.action(async (opts) => {
|
|
174
182
|
const vendor = opts.vendor ?? (await identifyVendor(opts.address));
|
|
175
183
|
const driver = (0, registry_1.getDriver)(vendor);
|
|
@@ -178,96 +186,122 @@ program
|
|
|
178
186
|
}
|
|
179
187
|
const modelOverride = opts.colours
|
|
180
188
|
? {
|
|
181
|
-
label:
|
|
189
|
+
label: "manual override",
|
|
182
190
|
width: Number(opts.width),
|
|
183
191
|
height: Number(opts.height),
|
|
184
192
|
voffset: Number(opts.voffset),
|
|
185
193
|
colours: parseColours(opts.colours),
|
|
186
194
|
}
|
|
187
195
|
: undefined;
|
|
188
|
-
const bindings = (0, binding_1.findBindings)(await (0, promises_1.readFile)(opts.template,
|
|
196
|
+
const bindings = (0, binding_1.findBindings)(await (0, promises_1.readFile)(opts.template, "utf-8"));
|
|
189
197
|
const context = await assembleContext(opts, bindings);
|
|
190
198
|
const renderer = new svgRenderer_1.SvgRenderer();
|
|
191
|
-
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);
|
|
192
200
|
const connectTimeoutMs = Number(opts.connectTimeout) * 1000;
|
|
193
201
|
await (0, bleDiscovery_1.withRetries)(Number(opts.retries), async (attempt) => {
|
|
194
202
|
if (attempt > 1) {
|
|
195
|
-
(0, log_1.logDebug)(`paint
|
|
203
|
+
(0, log_1.logDebug)(`Attempting paint ${attempt}/${opts.retries}`);
|
|
196
204
|
}
|
|
197
|
-
await driver.paint(bitmap, {
|
|
205
|
+
await driver.paint(bitmap, {
|
|
206
|
+
address: opts.address,
|
|
207
|
+
aesKey: opts.aesKey,
|
|
208
|
+
modelOverride,
|
|
209
|
+
connectTimeoutMs,
|
|
210
|
+
});
|
|
198
211
|
});
|
|
199
212
|
console.log(`painted ${opts.address} (${bitmap.width}x${bitmap.height}) ${opts.colours}`);
|
|
200
213
|
});
|
|
201
214
|
program
|
|
202
|
-
.command(
|
|
203
|
-
.description(
|
|
204
|
-
.requiredOption(
|
|
205
|
-
.requiredOption(
|
|
206
|
-
.option(
|
|
207
|
-
DEFAULT_SIGNALK_URLS.join(
|
|
208
|
-
|
|
209
|
-
.option(
|
|
210
|
-
.option(
|
|
211
|
-
.option(
|
|
212
|
-
.option(
|
|
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])
|
|
213
228
|
.action(async (opts) => {
|
|
214
|
-
const bindings = (0, binding_1.findBindings)(await (0, promises_1.readFile)(opts.template,
|
|
229
|
+
const bindings = (0, binding_1.findBindings)(await (0, promises_1.readFile)(opts.template, "utf-8"));
|
|
215
230
|
const context = await assembleContext(opts, bindings);
|
|
216
231
|
const renderer = opts.font ? new svgRenderer_1.SvgRenderer(opts.font) : new svgRenderer_1.SvgRenderer();
|
|
217
|
-
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);
|
|
218
233
|
await (0, promises_1.writeFile)(opts.output, (0, png_1.bitmapToPng)(bitmap));
|
|
219
234
|
console.log(`wrote ${opts.output} (${bitmap.width}x${bitmap.height})`);
|
|
220
235
|
});
|
|
221
236
|
program
|
|
222
|
-
.command(
|
|
223
|
-
.description(
|
|
224
|
-
.requiredOption(
|
|
225
|
-
.option(
|
|
226
|
-
DEFAULT_SIGNALK_URLS.join(
|
|
227
|
-
|
|
228
|
-
.option(
|
|
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")
|
|
229
244
|
.action(async (opts) => {
|
|
230
|
-
const doc = new xmldom_1.DOMParser().parseFromString(await (0, promises_1.readFile)(opts.template,
|
|
231
|
-
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");
|
|
232
246
|
const rows = [];
|
|
233
|
-
for (
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
+
}
|
|
244
261
|
}
|
|
245
262
|
}
|
|
246
|
-
const header = [
|
|
263
|
+
const header = ["id", "spec", "value"];
|
|
247
264
|
const table = await Promise.all(rows.map(async (row) => {
|
|
248
265
|
if (row.error || !row.binding)
|
|
249
|
-
return [row.id, row.desc, row.error ??
|
|
266
|
+
return [row.id, row.desc, row.error ?? ""];
|
|
250
267
|
try {
|
|
251
268
|
const context = await assembleContext(opts, [row.binding]);
|
|
252
|
-
|
|
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
|
+
];
|
|
253
287
|
}
|
|
254
288
|
catch (err) {
|
|
255
289
|
return [row.id, row.desc, `ERROR: ${err.message}`];
|
|
256
290
|
}
|
|
257
291
|
}));
|
|
258
292
|
const widths = header.map((title, col) => Math.max(title.length, ...table.map((cells) => cells[col].length)));
|
|
259
|
-
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(" "));
|
|
260
294
|
printRow(header);
|
|
261
295
|
table.forEach(printRow);
|
|
262
296
|
});
|
|
263
297
|
program
|
|
264
|
-
.command(
|
|
265
|
-
.description(
|
|
266
|
-
.argument(
|
|
267
|
-
.option(
|
|
268
|
-
DEFAULT_SIGNALK_URLS.join(
|
|
269
|
-
|
|
270
|
-
.option(
|
|
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")
|
|
271
305
|
.action(async (spec, opts) => {
|
|
272
306
|
const binding = (0, binding_1.parseBinding)(spec);
|
|
273
307
|
const context = await assembleContext(opts, [binding]);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Binding } from
|
|
2
|
-
import { TemplateContext } from
|
|
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
|
package/dist/cli/liveContext.js
CHANGED
|
@@ -13,10 +13,10 @@ const unitCategories_1 = require("../unitCategories");
|
|
|
13
13
|
const pathMeta_1 = require("../pathMeta");
|
|
14
14
|
const pluginVersion_1 = require("../pluginVersion");
|
|
15
15
|
const log_1 = require("./log");
|
|
16
|
-
const RESOURCES_API_PATH =
|
|
16
|
+
const RESOURCES_API_PATH = "/signalk/v2/api/resources";
|
|
17
17
|
/** `context=self` -> `vessels/self`, `context=vessels.urn:mrn:imo:mmsi:1` -> `vessels/urn:mrn:imo:mmsi:1` - matches the REST path for that context's full-data-model subtree. */
|
|
18
18
|
function contextPath(context) {
|
|
19
|
-
return context ===
|
|
19
|
+
return context === "self" ? "vessels/self" : context.replace(/\./g, "/");
|
|
20
20
|
}
|
|
21
21
|
/**
|
|
22
22
|
* `GET .../vessels/self` (or any other subtree) returns SignalK's full delta-tree shape, where every
|
|
@@ -31,12 +31,12 @@ function contextPath(context) {
|
|
|
31
31
|
* wrapper.
|
|
32
32
|
*/
|
|
33
33
|
function unwrapSignalkTree(node) {
|
|
34
|
-
if (node === null || typeof node !==
|
|
34
|
+
if (node === null || typeof node !== "object")
|
|
35
35
|
return node;
|
|
36
36
|
if (Array.isArray(node))
|
|
37
37
|
return node.map(unwrapSignalkTree);
|
|
38
38
|
const obj = node;
|
|
39
|
-
if (
|
|
39
|
+
if ("value" in obj) {
|
|
40
40
|
return obj.value;
|
|
41
41
|
}
|
|
42
42
|
return Object.fromEntries(Object.entries(obj).map(([key, value]) => [key, unwrapSignalkTree(value)]));
|
|
@@ -52,7 +52,7 @@ function unwrapSignalkTree(node) {
|
|
|
52
52
|
*/
|
|
53
53
|
async function assembleLiveContext(signalkUrl, bindings) {
|
|
54
54
|
const signalk = {};
|
|
55
|
-
const contexts = new Set(bindings.filter((binding) => binding.source ===
|
|
55
|
+
const contexts = new Set(bindings.filter((binding) => binding.source === "signalk").map((binding) => binding.context));
|
|
56
56
|
for (const context of contexts) {
|
|
57
57
|
const url = `${signalkUrl}/signalk/v1/api/${contextPath(context)}`;
|
|
58
58
|
(0, log_1.logDebug)(`GET ${url}`);
|
|
@@ -69,7 +69,7 @@ async function assembleLiveContext(signalkUrl, bindings) {
|
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
const resources = {};
|
|
72
|
-
const resourceNames = new Set(bindings.filter((binding) => binding.source ===
|
|
72
|
+
const resourceNames = new Set(bindings.filter((binding) => binding.source === "resources").map((binding) => binding.resource));
|
|
73
73
|
for (const name of resourceNames) {
|
|
74
74
|
const url = `${signalkUrl}${RESOURCES_API_PATH}/${name}`;
|
|
75
75
|
(0, log_1.logDebug)(`GET ${url}`);
|
|
@@ -89,7 +89,7 @@ async function assembleLiveContext(signalkUrl, bindings) {
|
|
|
89
89
|
async function readJsonFile(filePath) {
|
|
90
90
|
let raw;
|
|
91
91
|
try {
|
|
92
|
-
raw = await (0, promises_1.readFile)(filePath,
|
|
92
|
+
raw = await (0, promises_1.readFile)(filePath, "utf-8");
|
|
93
93
|
}
|
|
94
94
|
catch (err) {
|
|
95
95
|
throw new Error(`could not read example data file ${filePath} - ${err.message}`);
|
|
@@ -116,34 +116,34 @@ async function readJsonFile(filePath) {
|
|
|
116
116
|
*/
|
|
117
117
|
async function assembleExampleContext(examplesDir, bindings) {
|
|
118
118
|
const signalk = {};
|
|
119
|
-
const contexts = new Set(bindings.filter((binding) => binding.source ===
|
|
119
|
+
const contexts = new Set(bindings.filter((binding) => binding.source === "signalk").map((binding) => binding.context));
|
|
120
120
|
if (contexts.size > 0) {
|
|
121
|
-
const vesselsPath = path_1.default.join(examplesDir,
|
|
121
|
+
const vesselsPath = path_1.default.join(examplesDir, "vessels.json");
|
|
122
122
|
(0, log_1.logDebug)(`reading ${vesselsPath}`);
|
|
123
123
|
const vessels = await readJsonFile(vesselsPath);
|
|
124
124
|
for (const context of contexts) {
|
|
125
|
-
const segments = contextPath(context).split(
|
|
125
|
+
const segments = contextPath(context).split("/");
|
|
126
126
|
let node = vessels;
|
|
127
127
|
for (const segment of segments) {
|
|
128
|
-
node = node === null || typeof node !==
|
|
128
|
+
node = node === null || typeof node !== "object" ? undefined : node[segment];
|
|
129
129
|
}
|
|
130
130
|
if (node === undefined) {
|
|
131
|
-
throw new Error(`example data file ${vesselsPath} has no "${segments.join(
|
|
131
|
+
throw new Error(`example data file ${vesselsPath} has no "${segments.join(".")}" subtree for context "${context}"`);
|
|
132
132
|
}
|
|
133
133
|
signalk[context] = unwrapSignalkTree(node);
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
const resources = {};
|
|
137
|
-
const resourceNames = new Set(bindings.filter((binding) => binding.source ===
|
|
137
|
+
const resourceNames = new Set(bindings.filter((binding) => binding.source === "resources").map((binding) => binding.resource));
|
|
138
138
|
for (const name of resourceNames) {
|
|
139
|
-
const resourcePath = path_1.default.join(examplesDir,
|
|
139
|
+
const resourcePath = path_1.default.join(examplesDir, "resources", `${name}.json`);
|
|
140
140
|
(0, log_1.logDebug)(`reading ${resourcePath}`);
|
|
141
141
|
resources[name] = await readJsonFile(resourcePath);
|
|
142
142
|
}
|
|
143
143
|
const categoryNames = new Set(bindings.filter((binding) => binding.category).map((binding) => binding.category));
|
|
144
144
|
let categories = {};
|
|
145
145
|
if (categoryNames.size > 0) {
|
|
146
|
-
const categoriesPath = path_1.default.join(examplesDir,
|
|
146
|
+
const categoriesPath = path_1.default.join(examplesDir, "categories.json");
|
|
147
147
|
(0, log_1.logDebug)(`reading ${categoriesPath}`);
|
|
148
148
|
categories = (await readJsonFile(categoriesPath));
|
|
149
149
|
for (const category of categoryNames) {
|