@rhizomatics/signalk-esl-plugin 0.3.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.
Files changed (51) hide show
  1. package/README.md +109 -0
  2. package/dist/cli/index.d.ts +2 -0
  3. package/dist/cli/index.js +244 -0
  4. package/dist/cli/liveContext.d.ts +11 -0
  5. package/dist/cli/liveContext.js +72 -0
  6. package/dist/cli/log.d.ts +5 -0
  7. package/dist/cli/log.js +18 -0
  8. package/dist/config.d.ts +74 -0
  9. package/dist/config.js +193 -0
  10. package/dist/devices/bleDiscovery.d.ts +54 -0
  11. package/dist/devices/bleDiscovery.js +134 -0
  12. package/dist/devices/registry.d.ts +4 -0
  13. package/dist/devices/registry.js +15 -0
  14. package/dist/devices/types.d.ts +70 -0
  15. package/dist/devices/types.js +2 -0
  16. package/dist/devices/zhsunyco/encode.d.ts +12 -0
  17. package/dist/devices/zhsunyco/encode.js +47 -0
  18. package/dist/devices/zhsunyco/index.d.ts +11 -0
  19. package/dist/devices/zhsunyco/index.js +148 -0
  20. package/dist/devices/zhsunyco/metadata.d.ts +20 -0
  21. package/dist/devices/zhsunyco/metadata.js +98 -0
  22. package/dist/devices/zhsunyco/protocol.d.ts +49 -0
  23. package/dist/devices/zhsunyco/protocol.js +86 -0
  24. package/dist/httpJson.d.ts +6 -0
  25. package/dist/httpJson.js +40 -0
  26. package/dist/index.d.ts +26 -0
  27. package/dist/index.js +23 -0
  28. package/dist/pathMeta.d.ts +16 -0
  29. package/dist/pathMeta.js +21 -0
  30. package/dist/plugin.d.ts +2 -0
  31. package/dist/plugin.js +99 -0
  32. package/dist/render/binding.d.ts +53 -0
  33. package/dist/render/binding.js +168 -0
  34. package/dist/render/fonts.d.ts +9 -0
  35. package/dist/render/fonts.js +16 -0
  36. package/dist/render/formatters.d.ts +18 -0
  37. package/dist/render/formatters.js +78 -0
  38. package/dist/render/png.d.ts +3 -0
  39. package/dist/render/png.js +10 -0
  40. package/dist/render/svgRenderer.d.ts +32 -0
  41. package/dist/render/svgRenderer.js +80 -0
  42. package/dist/render/types.d.ts +26 -0
  43. package/dist/render/types.js +2 -0
  44. package/dist/repaintScheduler.d.ts +6 -0
  45. package/dist/repaintScheduler.js +193 -0
  46. package/dist/resolveApiUrl.d.ts +28 -0
  47. package/dist/resolveApiUrl.js +62 -0
  48. package/dist/unitCategories.d.ts +11 -0
  49. package/dist/unitCategories.js +46 -0
  50. package/package.json +70 -0
  51. package/templates/tide.svg +213 -0
package/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # ESL for SignalK
2
+
3
+ ** BETA - basic functionality, limited vendor/product support **
4
+
5
+ A SignalK plugin to display data from SignalK paths, APIs and plugins on Electronic Shelf Labels over a Bluetooth Low Energy (BLE) connection.
6
+
7
+ Electronic Shelf Labels (ESLs) are [eInk](https://en.wikipedia.org/wiki/E_Ink) devices that consume very little battery energy, presuming they are not constantly updated - the battery is used only when the display changes, and a periodic BLE check for incoming changes. Perfect for info that changes only once or twice a day, like tidal information.
8
+
9
+ Since they are designed to be used in large quantity in small shops, they are cheap and simple devices. Earlier models required dedicated controllers, or updates over Wifi or NFC, whereas many modern ones are standalone BLE devices that can be updated from a phone or server.
10
+
11
+ Unlike some eInk projects, this plugin doesn't require any physical modification to the labels, or loading any new firmware. It can send an image to a supported shelf label fresh out of the box.
12
+
13
+ ## Examples
14
+
15
+ ![Tidal Clock](docs/assets/screenshots/example_tidal_clock.png)
16
+
17
+ The tide clock needs the [signalk-tides](https://github.com/openwatersio/signalk-tides) plugin to be installed and publishing tides to the Resources API. It can however be customized to run with other APIs or take data only from SignalK data paths.
18
+
19
+ ## Templating
20
+
21
+ Templates are simply SVG files, to which expressions can be added to use SignalK data, and optionally use helper functions to make it easier to read. The template can have sample data in the placeholder, so is easy to layout and visualize.
22
+
23
+ ### Template Source Specification
24
+
25
+ In the `description` of the SVG text box, use a comma separated
26
+ set of key value pairs to define the data source and formatting.
27
+
28
+ For example, `path=environment.forecast.description` uses the default data source (the `self` vessel context) and the named SignalK path. A bare path with no key/value pairs at all, e.g. just `environment.forecast.description`, is shorthand for the same thing. Overriding the default context can be done with `path=environment.forecast.description,context=vessels.urn:mrn:imo:mmsi:232345678` - the `context` value must match a real SignalK context exactly as it appears in the Data Browser; there's nothing to configure for this in the plugin itself.
29
+
30
+ The source can be overridden to use the SignalK server's Resources API instead. For example, `source=resources,resource=tides,path=station.name` picks the `tides` resource and pulls the `station.name` path out of the JSON response - this works for any resource type (`tides`, `waypoints`, `routes`, ...), and needs nothing configured: the plugin reaches the Resources API directly (`app.resourcesApi`), not over HTTP. Where a resource is specified, it will be fetched once for that render, and subsequent fields sourced from the same resource use that cached response.
31
+
32
+ A `format` can be specified to make the value easier to understand. The supported formats are:
33
+
34
+ * `local_time` - reduce a time stamp to just the time, omitting the date, and applying daylight savings if appropriate
35
+ * `day_mon` - reduce a time stamp to day and month, e.g. `27 Jun`, applying daylight savings if appropriate
36
+ * `utc_offset` - Show a timezone in `UTC+01:00` style format
37
+ * `position` - Format a `{ latitude, longitude }` value as decimal degrees with hemisphere letters, e.g. `56.6250°N 6.0700°W`
38
+ * `raw` - Don't apply automatic SignalK unit conversion and symbol display (see below)
39
+
40
+ SignalK's unit preferences are used to automatically convert a `signalk`-sourced numeric value to its preferred display unit, and append a unit symbol like `kt` or `m`, unless `format=raw` is specified to switch that off. However, when using plugin or API data there may be no path metadata to convert from (for example `signalk-tides` publishes tide data to the Resources API, and `level` is a raw metre value with no SignalK path of its own) - in these cases an explicit `category` can be given instead, and the unit preferences will be applied the same way, for example `category=depth` for the tides level figure.
41
+
42
+ Common categories:
43
+
44
+ * `depth` - Use the SignalK preferred depth unit, make the conversion if needed, and tack on the unit name as a suffix
45
+ * `speed` - Use the SignalK preferred speed unit, make the conversion if needed, and tack on the unit name as a suffix
46
+ * `temperature` - Use the SignalK preferred temperature unit, make the conversion if needed, and tack on the unit name as a suffix
47
+
48
+ Additionally, `round=n` can be used to round to limited decimal places.
49
+
50
+ These can all be combined as in `source=resources,resource=tides,path=extremes[2].level,category=depth,round=2`
51
+
52
+ ## CLI
53
+
54
+ To get fast feedback on templates and shelf devices without updating and configuring SignalK, a CLI is provided that has these commands.
55
+
56
+ - `vendors` - list supported vendors
57
+ - `scan` - report supported devices found from a BLE scan
58
+
59
+ See also the commands useful for debugging under [Developing Templates]
60
+ - `render` - transform an SVG template and data into a PNG
61
+ - `paint` - render an SVG template and data to a selected ESL
62
+
63
+ ## Vendors
64
+
65
+ ### Zhsunyco
66
+
67
+ Also known as 'Suny'
68
+
69
+ - [BLE ESLs](https://www.zhsunyco.com/digital-display-solution-for-small-retail-business/ble-esl-solution/)
70
+ - The range of labels available on retail sites like AliExpress may be larger than on their corporate site
71
+ - In mid 2026, a 4 colour (BWRY) 3.7" label retailed for about $35, with quantity discounts for bulk sets
72
+ - Cheapest units are 2 colour 1.54", and they go up to 7.5"
73
+
74
+ Python code for a variety of their labels at https://github.com/roxburghm/zhsunyco-esl and https://github.com/NickWaterton/Wolink
75
+
76
+ ## Architecture
77
+
78
+ The primary things managed and provided by the plugin are:
79
+
80
+ * ESL Vendor
81
+ * ESL Device
82
+ * SVG Template
83
+ * SignalK API base URL
84
+ - Used for automatic unit conversion on `signalk`-sourced numeric values and for resolving an explicit `category=` binding - neither has an in-process equivalent, both go via this server's own REST API
85
+ - Optional: left blank, the plugin probes the only realistic values in likelihood order at startup - `http://localhost:3000` (bare npm install), `http://localhost`, `https://localhost` (container/systemd installs) - and uses whichever responds, since it always runs on the same host as the server. Set it explicitly to skip probing
86
+ - Either way, errors clearly if nothing responds (wrong port) or the probe is rejected (anonymous read access not enabled) - these endpoints must allow anonymous read access, since the plugin has no login flow
87
+
88
+ ### Extending
89
+
90
+ #### Hardware
91
+
92
+ Additional vendors and devices can be added by a separate npm package that implements the `VendorDriver` interface and registers itself - there's no scanning of installed packages, registration is always an explicit call by the extension's own code.
93
+
94
+ - `import esl from '@rhizomatics/signalk-esl-plugin'; esl.registerVendorDriver(myDriver)`
95
+ - In the SignalK runtime, call this from the extension's own plugin `start()`. In the CLI, load the extension with `esl-cli --require <module> <command>`.
96
+ - Declare this package as a `peerDependency` (not a regular dependency) in the extension package, so npm resolves a single shared copy of the registry.
97
+
98
+ #### Developing Templates
99
+
100
+ 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).
101
+
102
+ Inkscape adds its own metadata to images, which can be stripped off by exporting a simple SVG, although can be left in place with no harm; main reason to simplify the SVG is manual changes in a text editor.
103
+
104
+ The `esl-cli` can be used to debug and validate templates quickly:
105
+
106
+ * `render` - Render templates with SignalK data and write to a local PNG file
107
+ * `paint` - Render templates with SignalK data and send to selected ESL device
108
+ * `fields` - List the fields in the template, with the source specification and the rendered data value
109
+ * `field` - Accept a source specification (outside of any template context) and return the rendered value if available
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ const promises_1 = require("fs/promises");
5
+ const commander_1 = require("commander");
6
+ const xmldom_1 = require("@xmldom/xmldom");
7
+ const registry_1 = require("../devices/registry");
8
+ const zhsunyco_1 = require("../devices/zhsunyco");
9
+ const bleDiscovery_1 = require("../devices/bleDiscovery");
10
+ const svgRenderer_1 = require("../render/svgRenderer");
11
+ const png_1 = require("../render/png");
12
+ const binding_1 = require("../render/binding");
13
+ const liveContext_1 = require("./liveContext");
14
+ const log_1 = require("./log");
15
+ (0, registry_1.registerDriver)(new zhsunyco_1.ZhsunycoDriver());
16
+ const VENDOR_IDENTIFY_TIMEOUT_MS = 30000;
17
+ const DEFAULT_SIGNALK_URL = 'http://localhost:3000';
18
+ const COLOUR_CODES = {
19
+ BW: ['black', 'white'],
20
+ BWR: ['black', 'white', 'red'],
21
+ BWRY: ['black', 'white', 'red', 'yellow'],
22
+ };
23
+ function parseColours(code) {
24
+ const colours = COLOUR_CODES[code.toUpperCase()];
25
+ if (!colours) {
26
+ throw new Error(`unknown --colours value "${code}" - expected one of ${Object.keys(COLOUR_CODES).join(', ')}`);
27
+ }
28
+ return colours;
29
+ }
30
+ /** Connects long enough to read the advertised name and manufacturer ID, then matches against registered drivers. */
31
+ async function identifyVendor(address) {
32
+ const { bluetooth, destroy } = (0, bleDiscovery_1.createBluetooth)();
33
+ try {
34
+ (0, log_1.logDebug)(`connecting to ${address} to identify its vendor (timeout ${VENDOR_IDENTIFY_TIMEOUT_MS}ms)`);
35
+ const adapter = await bluetooth.defaultAdapter();
36
+ const device = await (0, bleDiscovery_1.getOrDiscoverDevice)(adapter, address, VENDOR_IDENTIFY_TIMEOUT_MS);
37
+ const name = await device.getName().catch(() => undefined);
38
+ const manufacturerId = await (0, bleDiscovery_1.getManufacturerId)(device);
39
+ (0, log_1.logDebug)(`${address}: advertised name="${name ?? ''}" manufacturerId=${manufacturerId ?? 'unknown'}`);
40
+ const driver = (0, registry_1.allDrivers)().find((candidate) => candidate.matchesAdvertisement(name, manufacturerId));
41
+ if (!driver) {
42
+ throw new Error(`no registered vendor driver recognises device "${name ?? address}" - specify --vendor explicitly`);
43
+ }
44
+ return driver.vendor;
45
+ }
46
+ finally {
47
+ destroy();
48
+ }
49
+ }
50
+ const program = new commander_1.Command();
51
+ program.name('esl-cli').description('Local CLI for testing ESL device scan and paint without a SignalK server');
52
+ 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]);
53
+ program.option('-l, --log-level <level>', 'log verbosity: info or debug (e.g. trace which URLs are fetched)', 'info');
54
+ program.hook('preAction', () => {
55
+ (0, log_1.setLogLevel)(program.opts().logLevel);
56
+ for (const mod of program.opts().require ?? []) {
57
+ require(mod);
58
+ }
59
+ });
60
+ program
61
+ .command('vendors')
62
+ .description('List supported vendors and the device models each has confirmed metadata for')
63
+ .action(() => {
64
+ const header = ['vendor', 'pid', 'hwid', 'label', 'size', 'colours'];
65
+ const rows = [];
66
+ for (const driver of (0, registry_1.allDrivers)()) {
67
+ for (const device of driver.supportedDevices()) {
68
+ rows.push([
69
+ driver.vendor,
70
+ `0x${device.pid.toString(16).padStart(4, '0')}`,
71
+ device.hwVersion ? `0x${device.hwVersion}` : '',
72
+ device.label,
73
+ `${device.width}x${device.height}`,
74
+ device.colours.join(','),
75
+ ]);
76
+ }
77
+ }
78
+ if (rows.length === 0) {
79
+ console.log('(no confirmed devices yet)');
80
+ return;
81
+ }
82
+ const widths = header.map((title, col) => Math.max(title.length, ...rows.map((row) => row[col].length)));
83
+ const printRow = (row) => console.log(row.map((cell, col) => cell.padEnd(widths[col])).join(' '));
84
+ printRow(header);
85
+ rows.forEach(printRow);
86
+ });
87
+ program
88
+ .command('scan')
89
+ .description('Scan for supported BLE ESL devices across all registered vendor drivers')
90
+ .option('-d, --duration <seconds>', 'scan duration in seconds', '10')
91
+ .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')
92
+ .action(async (opts) => {
93
+ const durationMs = Number(opts.duration) * 1000;
94
+ const header = ['vendor', 'address', 'name', 'pid', 'label', 'mfr', 'battery', 'rssi'];
95
+ const rows = [];
96
+ let matchedCount = 0;
97
+ const drivers = (0, registry_1.allDrivers)();
98
+ (0, log_1.logDebug)(`scanning for ${durationMs}ms`);
99
+ await (0, bleDiscovery_1.withDiscovery)(durationMs, async (adapter) => {
100
+ await (0, bleDiscovery_1.forEachAdvertisedDevice)(adapter, async ({ device, address, name, manufacturerId, manufacturerData }) => {
101
+ const driver = drivers.find((candidate) => candidate.matchesAdvertisement(name, manufacturerId));
102
+ const mfr = manufacturerId !== undefined ? `0x${manufacturerId.toString(16).padStart(4, '0')}` : '';
103
+ if (!driver) {
104
+ if (opts.allDevices) {
105
+ const rssi = await device
106
+ .getRSSI()
107
+ .then((value) => (value === undefined ? undefined : Number(value)))
108
+ .catch(() => undefined);
109
+ rows.push(['(unmatched)', address, name ?? '', '', '', mfr, '', String(rssi ?? '')]);
110
+ }
111
+ return;
112
+ }
113
+ matchedCount++;
114
+ const found = await driver.identifyDevice(device, address, name, manufacturerId, manufacturerData);
115
+ (0, log_1.logDebug)(`${driver.vendor}: identified ${found.name ?? found.address}`);
116
+ const pid = found.pid !== undefined ? `0x${found.pid.toString(16).padStart(4, '0')}` : '';
117
+ const label = found.metadata?.label ?? '';
118
+ const battery = found.batteryMv !== undefined ? `${found.batteryMv}mV` : '';
119
+ rows.push([driver.vendor, found.address, found.name ?? '', pid, label, mfr, battery, String(found.rssi ?? '')]);
120
+ });
121
+ });
122
+ if (matchedCount === 0) {
123
+ console.log(`no devices found in ${opts.duration}s - try a longer scan with -d, e.g. "-d 30"`);
124
+ }
125
+ if (rows.length === 0) {
126
+ return;
127
+ }
128
+ const widths = header.map((title, col) => Math.max(title.length, ...rows.map((row) => row[col].length)));
129
+ const printRow = (row) => console.log(row.map((cell, col) => cell.padEnd(widths[col])).join(' '));
130
+ printRow(header);
131
+ rows.forEach(printRow);
132
+ });
133
+ program
134
+ .command('paint')
135
+ .description('Render a template against a live SignalK server and send it to a device')
136
+ .option('-v, --vendor <vendor>', 'vendor driver to use - if omitted, inferred from the device\'s advertised name')
137
+ .requiredOption('-a, --address <address>', 'BLE address of the device')
138
+ .requiredOption('-t, --template <path>', 'path to SVG template')
139
+ .option('-u, --url <url>', 'SignalK server base URL - resolves the template\'s source=signalk/resources bindings', DEFAULT_SIGNALK_URL)
140
+ .option('-k, --aes-key <hex>', 'AES-128 key for device authentication, as 32 hex characters - defaults to the vendor\'s stock key if omitted')
141
+ .option('-w, --width <px>', 'render width', '416')
142
+ .option('--height <px>', 'render height', '240')
143
+ .option('--voffset <px>', 'vertical pixel offset of the panel - overrides the looked-up model for unsupported hardware (requires --colours)', '0')
144
+ .option('--colours <code>', 'device colour palette for unsupported hardware: BW, BWR, or BWRY - overrides the looked-up model (uses --width/--height/--voffset)')
145
+ .option('--connect-timeout <seconds>', 'BLE connect timeout before giving up on an attempt', '30')
146
+ .option('--retries <n>', 'number of paint attempts (including the first) before giving up', '3')
147
+ .action(async (opts) => {
148
+ const vendor = opts.vendor ?? (await identifyVendor(opts.address));
149
+ const driver = (0, registry_1.getDriver)(vendor);
150
+ if (!driver) {
151
+ throw new Error(`no driver registered for vendor "${vendor}"`);
152
+ }
153
+ const modelOverride = opts.colours
154
+ ? {
155
+ label: 'manual override',
156
+ width: Number(opts.width),
157
+ height: Number(opts.height),
158
+ voffset: Number(opts.voffset),
159
+ colours: parseColours(opts.colours),
160
+ }
161
+ : undefined;
162
+ const bindings = (0, binding_1.findBindings)(await (0, promises_1.readFile)(opts.template, 'utf-8'));
163
+ const context = await (0, liveContext_1.assembleLiveContext)(opts.url, bindings);
164
+ const renderer = new svgRenderer_1.SvgRenderer();
165
+ const bitmap = await renderer.render(opts.template, context, Number(opts.width), Number(opts.height));
166
+ const connectTimeoutMs = Number(opts.connectTimeout) * 1000;
167
+ await (0, bleDiscovery_1.withRetries)(Number(opts.retries), async (attempt) => {
168
+ if (attempt > 1) {
169
+ (0, log_1.logDebug)(`paint attempt ${attempt}/${opts.retries}`);
170
+ }
171
+ await driver.paint(bitmap, { address: opts.address, aesKey: opts.aesKey, modelOverride, connectTimeoutMs });
172
+ });
173
+ console.log(`painted ${opts.address} (${bitmap.width}x${bitmap.height})`);
174
+ });
175
+ program
176
+ .command('render')
177
+ .description('Render a template against a live SignalK server and write a PNG, without needing a device')
178
+ .requiredOption('-t, --template <path>', 'path to SVG template')
179
+ .requiredOption('-o, --output <path>', 'output PNG path')
180
+ .option('-u, --url <url>', 'SignalK server base URL - resolves the template\'s source=signalk/resources bindings', DEFAULT_SIGNALK_URL)
181
+ .option('-w, --width <px>', 'render width', '416')
182
+ .option('--height <px>', 'render height', '240')
183
+ .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])
184
+ .action(async (opts) => {
185
+ const bindings = (0, binding_1.findBindings)(await (0, promises_1.readFile)(opts.template, 'utf-8'));
186
+ const context = await (0, liveContext_1.assembleLiveContext)(opts.url, bindings);
187
+ const renderer = opts.font ? new svgRenderer_1.SvgRenderer(opts.font) : new svgRenderer_1.SvgRenderer();
188
+ const bitmap = await renderer.render(opts.template, context, Number(opts.width), Number(opts.height));
189
+ await (0, promises_1.writeFile)(opts.output, (0, png_1.bitmapToPng)(bitmap));
190
+ console.log(`wrote ${opts.output} (${bitmap.width}x${bitmap.height})`);
191
+ });
192
+ program
193
+ .command('fields')
194
+ .description('List every <desc> binding in a template by element id, with its source spec and resolved value')
195
+ .requiredOption('-t, --template <path>', 'path to SVG template')
196
+ .option('-u, --url <url>', 'SignalK server base URL - resolves the template\'s source=signalk/resources bindings', DEFAULT_SIGNALK_URL)
197
+ .action(async (opts) => {
198
+ const doc = new xmldom_1.DOMParser().parseFromString(await (0, promises_1.readFile)(opts.template, 'utf-8'), 'image/svg+xml');
199
+ const elements = doc.getElementsByTagName('text');
200
+ const rows = [];
201
+ for (let i = 0; i < elements.length; i++) {
202
+ const element = elements.item(i);
203
+ const desc = element?.getElementsByTagName('desc').item(0);
204
+ if (!element || !desc?.textContent)
205
+ continue;
206
+ const id = element.getAttribute('id') ?? `#${i}`;
207
+ try {
208
+ rows.push({ id, desc: desc.textContent, binding: (0, binding_1.parseBinding)(desc.textContent) });
209
+ }
210
+ catch (err) {
211
+ rows.push({ id, desc: desc.textContent, error: err.message });
212
+ }
213
+ }
214
+ const header = ['id', 'spec', 'value'];
215
+ const table = await Promise.all(rows.map(async (row) => {
216
+ if (row.error || !row.binding)
217
+ return [row.id, row.desc, row.error ?? ''];
218
+ try {
219
+ const context = await (0, liveContext_1.assembleLiveContext)(opts.url, [row.binding]);
220
+ return [row.id, row.desc, (0, binding_1.renderBinding)(row.binding, context)];
221
+ }
222
+ catch (err) {
223
+ return [row.id, row.desc, `ERROR: ${err.message}`];
224
+ }
225
+ }));
226
+ const widths = header.map((title, col) => Math.max(title.length, ...table.map((cells) => cells[col].length)));
227
+ const printRow = (cells) => console.log(cells.map((cell, col) => cell.padEnd(widths[col])).join(' '));
228
+ printRow(header);
229
+ table.forEach(printRow);
230
+ });
231
+ program
232
+ .command('field')
233
+ .description('Resolve a single binding spec directly against a live SignalK server, with no template')
234
+ .argument('<spec>', 'binding spec, e.g. "source=resources,resource=tides,path=station.name" or a bare SignalK path')
235
+ .option('-u, --url <url>', 'SignalK server base URL - resolves the spec\'s source=signalk/resources binding', DEFAULT_SIGNALK_URL)
236
+ .action(async (spec, opts) => {
237
+ const binding = (0, binding_1.parseBinding)(spec);
238
+ const context = await (0, liveContext_1.assembleLiveContext)(opts.url, [binding]);
239
+ console.log((0, binding_1.renderBinding)(binding, context));
240
+ });
241
+ program.parseAsync(process.argv).catch((err) => {
242
+ console.error(err.message);
243
+ process.exitCode = 1;
244
+ });
@@ -0,0 +1,11 @@
1
+ import { Binding } from '../render/binding';
2
+ import { TemplateContext } from '../render/types';
3
+ /**
4
+ * CLI counterpart to `assembleRawContext` in repaintScheduler.ts - same `{ signalk, resources,
5
+ * pathMeta, categories }` shape, but fetched entirely over plain HTTP (the CLI has no live `ServerAPI`
6
+ * to call `getSelfPath`/`getPath`/`resourcesApi` on, and per-path metadata/`category=` resolution has
7
+ * no in-process equivalent at all - see `repaintScheduler.ts`) against a real or test SignalK server's
8
+ * REST API. Fetches each referenced context's/resource's *whole* subtree once and lets the existing
9
+ * binding resolver navigate `path` within it.
10
+ */
11
+ export declare function assembleLiveContext(signalkUrl: string, bindings: Binding[]): Promise<TemplateContext>;
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.assembleLiveContext = assembleLiveContext;
4
+ const httpJson_1 = require("../httpJson");
5
+ const unitCategories_1 = require("../unitCategories");
6
+ const pathMeta_1 = require("../pathMeta");
7
+ const log_1 = require("./log");
8
+ const RESOURCES_API_PATH = '/signalk/v2/api/resources';
9
+ /** `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. */
10
+ function contextPath(context) {
11
+ return context === 'self' ? 'vessels/self' : context.replace(/\./g, '/');
12
+ }
13
+ /**
14
+ * `GET .../vessels/self` (or any other subtree) returns SignalK's full delta-tree shape, where every
15
+ * leaf is wrapped as `{ value, $source, timestamp, ... }` rather than the bare value - unlike
16
+ * `app.getSelfPath`/`getPath` in the live plugin (repaintScheduler.ts), which already return bare
17
+ * values. Recursively unwraps every such leaf so `path=` bindings resolve the same way over HTTP as
18
+ * they do live. Only applied to `signalk` fetches - `resources` responses have no such wrapper.
19
+ *
20
+ * Keys off `value` alone, not also requiring `timestamp`/`$source` - not every server includes both on
21
+ * every leaf, and a `value` key is otherwise meaningless on a SignalK data-model node (it isn't a
22
+ * regular vessel property name), so there's no real risk of unwrapping something that isn't this
23
+ * wrapper.
24
+ */
25
+ function unwrapSignalkTree(node) {
26
+ if (node === null || typeof node !== 'object')
27
+ return node;
28
+ if (Array.isArray(node))
29
+ return node.map(unwrapSignalkTree);
30
+ const obj = node;
31
+ if ('value' in obj) {
32
+ return obj.value;
33
+ }
34
+ return Object.fromEntries(Object.entries(obj).map(([key, value]) => [key, unwrapSignalkTree(value)]));
35
+ }
36
+ /**
37
+ * CLI counterpart to `assembleRawContext` in repaintScheduler.ts - same `{ signalk, resources,
38
+ * pathMeta, categories }` shape, but fetched entirely over plain HTTP (the CLI has no live `ServerAPI`
39
+ * to call `getSelfPath`/`getPath`/`resourcesApi` on, and per-path metadata/`category=` resolution has
40
+ * no in-process equivalent at all - see `repaintScheduler.ts`) against a real or test SignalK server's
41
+ * REST API. Fetches each referenced context's/resource's *whole* subtree once and lets the existing
42
+ * binding resolver navigate `path` within it.
43
+ */
44
+ async function assembleLiveContext(signalkUrl, bindings) {
45
+ const signalk = {};
46
+ const contexts = new Set(bindings.filter((binding) => binding.source === 'signalk').map((binding) => binding.context));
47
+ for (const context of contexts) {
48
+ const url = `${signalkUrl}/signalk/v1/api/${contextPath(context)}`;
49
+ (0, log_1.logDebug)(`GET ${url}`);
50
+ signalk[context] = unwrapSignalkTree(await (0, httpJson_1.fetchJson)(url));
51
+ }
52
+ const pathMeta = {};
53
+ for (const context of contexts) {
54
+ try {
55
+ (0, log_1.logDebug)(`GET ${signalkUrl}/signalk/v1/api/${contextPath(context)}/meta`);
56
+ pathMeta[context] = await (0, pathMeta_1.fetchPathMeta)(signalkUrl, context);
57
+ }
58
+ catch (err) {
59
+ console.error(`warning: could not fetch path metadata for context "${context}" (${err.message}) - automatic unit conversion will show raw values`);
60
+ }
61
+ }
62
+ const resources = {};
63
+ const resourceNames = new Set(bindings.filter((binding) => binding.source === 'resources').map((binding) => binding.resource));
64
+ for (const name of resourceNames) {
65
+ const url = `${signalkUrl}${RESOURCES_API_PATH}/${name}`;
66
+ (0, log_1.logDebug)(`GET ${url}`);
67
+ resources[name] = await (0, httpJson_1.fetchJson)(url);
68
+ }
69
+ const categoryNames = new Set(bindings.filter((binding) => binding.category).map((binding) => binding.category));
70
+ const categories = await (0, unitCategories_1.fetchCategoryDisplayUnits)(signalkUrl, categoryNames);
71
+ return { signalk, resources, pathMeta, categories };
72
+ }
@@ -0,0 +1,5 @@
1
+ export type LogLevel = 'info' | 'debug';
2
+ /** Set once from the global `--log-level` option (see index.ts's `preAction` hook) - applies to every command. */
3
+ export declare function setLogLevel(level: string): void;
4
+ /** Internal tracing (e.g. which URL is being fetched) - silent unless --log-level debug, so default output stays uncluttered. */
5
+ export declare function logDebug(message: string): void;
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.setLogLevel = setLogLevel;
4
+ exports.logDebug = logDebug;
5
+ const LEVELS = ['info', 'debug'];
6
+ let currentLevel = 'info';
7
+ /** Set once from the global `--log-level` option (see index.ts's `preAction` hook) - applies to every command. */
8
+ function setLogLevel(level) {
9
+ if (!LEVELS.includes(level)) {
10
+ throw new Error(`unknown --log-level "${level}" - expected one of ${LEVELS.join(', ')}`);
11
+ }
12
+ currentLevel = level;
13
+ }
14
+ /** Internal tracing (e.g. which URL is being fetched) - silent unless --log-level debug, so default output stays uncluttered. */
15
+ function logDebug(message) {
16
+ if (currentLevel === 'debug')
17
+ console.error(`[debug] ${message}`);
18
+ }
@@ -0,0 +1,74 @@
1
+ import { ServerAPI } from '@signalk/server-api';
2
+ import { DiscoveredDevice } from './devices/types';
3
+ export interface DeviceConfig {
4
+ friendlyName: string;
5
+ /**
6
+ * `"<vendor>:<pid>[:<hwVersion>]@<address>"`, picked from a combined enum of recently
7
+ * scanned devices so one selection sets both the model (width/height/colours, known
8
+ * without a live BLE read) and the BLE address.
9
+ */
10
+ device: string;
11
+ /** Per-device override; if omitted, the vendor driver may fall back to a stock/manufacturer-default key. */
12
+ aesKey?: string;
13
+ templateName: string;
14
+ repaintTrigger: 'subscription' | 'interval';
15
+ /** SignalK path to subscribe to when `repaintTrigger` is `subscription` - a repaint is considered on every delta. */
16
+ triggerPath?: string;
17
+ /** When `repaintTrigger` is `interval`: repaint every N hours... */
18
+ intervalHours?: number;
19
+ /** ...at this minute past the hour. */
20
+ intervalMinute?: number;
21
+ /** One-shot override to repaint even if the data is unchanged; cleared automatically once that repaint completes. */
22
+ forceRepaint?: boolean;
23
+ }
24
+ export interface PluginConfig {
25
+ /**
26
+ * Directory the plugin scans for template files, instead of an upload UI - follows
27
+ * signalk-parquet's convention: empty for the default, a relative path resolved against
28
+ * `~/.signalk`, or an absolute path. Use `resolveTemplatesDir` to turn this into an actual path.
29
+ */
30
+ templatesDir: string;
31
+ /** Run a short BLE scan on plugin start and report discoveries via plugin status, like signalk-bluetti-plugin does. */
32
+ scanOnStart: boolean;
33
+ /** How long the startup scan runs, in seconds. */
34
+ scanDurationSeconds: number;
35
+ /** How long to wait for a device to accept a BLE connection before giving up on a repaint attempt, in seconds. */
36
+ paintConnectTimeoutSeconds: number;
37
+ /** How many times to attempt a repaint (including the first try) before giving up and reporting failure. */
38
+ paintRetries: number;
39
+ /**
40
+ * Base URL of this SignalK server, used for: (1) a `signalk`-sourced numeric value's automatic unit
41
+ * conversion (`GET .../vessels/<context>/meta`, see `../pathMeta.ts`) unless `format=raw`, and (2) an
42
+ * explicit `category=` binding (e.g. `category=depth` on a resource-sourced value with no path
43
+ * metadata of its own, see `../unitCategories.ts`). Neither has an in-process equivalent reachable via
44
+ * the plugin API - confirmed against the signalk-server source, this resolution only happens in its
45
+ * REST layer.
46
+ *
47
+ * Always the local loopback address - the plugin runs on the same host as the server, so it's
48
+ * reachable regardless of any external reverse proxy. Left unset, the plugin probes
49
+ * `SIGNALK_API_URL_OPTIONS` at startup (in likelihood order: 3000 for a bare `npm install`, then
50
+ * 80/443 for container/systemd installs) and uses whichever responds - see `./resolveApiUrl.ts`. Set
51
+ * explicitly only to skip probing or to confirm a specific one is reachable; either way, it must allow
52
+ * anonymous read access - the plugin has no login flow.
53
+ */
54
+ signalkApiUrl?: string;
55
+ devices: DeviceConfig[];
56
+ }
57
+ export declare function defaultConfig(): PluginConfig;
58
+ /**
59
+ * Resolves the user-facing `templatesDir` setting to an actual directory, mirroring
60
+ * signalk-parquet's `outputDirectory` convention: empty means the default location, a relative
61
+ * path is resolved against `~/.signalk` (where SignalK itself stores its config by default), and
62
+ * an absolute path is used as-is.
63
+ */
64
+ export declare function resolveTemplatesDir(templatesDir: string | undefined): string;
65
+ export declare function parseDevice(device: string): {
66
+ vendor: string;
67
+ pid: number;
68
+ hwVersion?: string;
69
+ address: string;
70
+ } | undefined;
71
+ /** Resolves a template name to an actual file path - a local template overrides the bundled one of the same name. */
72
+ export declare function resolveTemplatePath(templatesDir: string, templateName: string): string;
73
+ export declare function configSchema(app: ServerAPI, discovered?: DiscoveredDevice[]): object;
74
+ export declare function configUiSchema(): object;