@rhizomatics/signalk-einklabel-plugin 0.4.0 → 0.4.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 (53) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/README.md +1 -1
  3. package/dist/cli/index.d.ts +2 -0
  4. package/dist/cli/index.js +244 -0
  5. package/dist/cli/liveContext.d.ts +12 -0
  6. package/dist/cli/liveContext.js +76 -0
  7. package/dist/cli/log.d.ts +5 -0
  8. package/dist/cli/log.js +18 -0
  9. package/dist/config.d.ts +74 -0
  10. package/dist/config.js +193 -0
  11. package/dist/devices/bleDiscovery.d.ts +54 -0
  12. package/dist/devices/bleDiscovery.js +134 -0
  13. package/dist/devices/registry.d.ts +4 -0
  14. package/dist/devices/registry.js +15 -0
  15. package/dist/devices/types.d.ts +70 -0
  16. package/dist/devices/types.js +2 -0
  17. package/dist/devices/zhsunyco/encode.d.ts +12 -0
  18. package/dist/devices/zhsunyco/encode.js +47 -0
  19. package/dist/devices/zhsunyco/index.d.ts +11 -0
  20. package/dist/devices/zhsunyco/index.js +148 -0
  21. package/dist/devices/zhsunyco/metadata.d.ts +20 -0
  22. package/dist/devices/zhsunyco/metadata.js +98 -0
  23. package/dist/devices/zhsunyco/protocol.d.ts +49 -0
  24. package/dist/devices/zhsunyco/protocol.js +86 -0
  25. package/dist/httpJson.d.ts +6 -0
  26. package/dist/httpJson.js +40 -0
  27. package/dist/index.d.ts +26 -0
  28. package/dist/index.js +23 -0
  29. package/dist/pathMeta.d.ts +16 -0
  30. package/dist/pathMeta.js +21 -0
  31. package/dist/plugin.d.ts +2 -0
  32. package/dist/plugin.js +99 -0
  33. package/dist/render/binding.d.ts +54 -0
  34. package/dist/render/binding.js +175 -0
  35. package/dist/render/fonts.d.ts +9 -0
  36. package/dist/render/fonts.js +16 -0
  37. package/dist/render/formatters.d.ts +18 -0
  38. package/dist/render/formatters.js +87 -0
  39. package/dist/render/png.d.ts +3 -0
  40. package/dist/render/png.js +10 -0
  41. package/dist/render/svgRenderer.d.ts +32 -0
  42. package/dist/render/svgRenderer.js +80 -0
  43. package/dist/render/types.d.ts +26 -0
  44. package/dist/render/types.js +2 -0
  45. package/dist/repaintScheduler.d.ts +6 -0
  46. package/dist/repaintScheduler.js +193 -0
  47. package/dist/resolveApiUrl.d.ts +28 -0
  48. package/dist/resolveApiUrl.js +62 -0
  49. package/dist/unitCategories.d.ts +11 -0
  50. package/dist/unitCategories.js +46 -0
  51. package/docs/assets/icons/icon_tmp.png +0 -0
  52. package/docs/assets/screenshots/example_tidal_clock.png +0 -0
  53. package/package.json +6 -4
package/CHANGELOG.md CHANGED
@@ -1,3 +1,5 @@
1
+ # 0.4.1
2
+ - Packaging fixes
1
3
  # 0.4.0
2
4
  - Added `einklabel` as a `source` for template fields, and `repainted` as path
3
5
  - Added `local_datetime_short` as a datetime format option, for `27 Jun 26 18:05` style output
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # ESL for SignalK
1
+ # eInk Labels for SignalK
2
2
 
3
3
  ** BETA - basic functionality, limited vendor/product support **
4
4
 
@@ -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,12 @@
1
+ import { Binding } from '../render/binding';
2
+ import { TemplateContext } from '../render/types';
3
+ /**
4
+ * CLI counterpart to `assembleRawContext` + `considerRepaint`'s `meta` injection in
5
+ * repaintScheduler.ts - same `{ signalk, resources, pathMeta, categories, meta }` shape, but fetched
6
+ * entirely over plain HTTP (the CLI has no live `ServerAPI` to call `getSelfPath`/`getPath`/
7
+ * `resourcesApi` on, and per-path metadata/`category=` resolution has no in-process equivalent at all
8
+ * - see `repaintScheduler.ts`) against a real or test SignalK server's REST API. Fetches each
9
+ * referenced context's/resource's *whole* subtree once and lets the existing binding resolver
10
+ * navigate `path` within it.
11
+ */
12
+ export declare function assembleLiveContext(signalkUrl: string, bindings: Binding[]): Promise<TemplateContext>;
@@ -0,0 +1,76 @@
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` + `considerRepaint`'s `meta` injection in
38
+ * repaintScheduler.ts - same `{ signalk, resources, pathMeta, categories, meta }` shape, but fetched
39
+ * entirely over plain HTTP (the CLI has no live `ServerAPI` to call `getSelfPath`/`getPath`/
40
+ * `resourcesApi` on, and per-path metadata/`category=` resolution has no in-process equivalent at all
41
+ * - see `repaintScheduler.ts`) against a real or test SignalK server's REST API. Fetches each
42
+ * referenced context's/resource's *whole* subtree once and lets the existing binding resolver
43
+ * navigate `path` within it.
44
+ */
45
+ async function assembleLiveContext(signalkUrl, bindings) {
46
+ const signalk = {};
47
+ const contexts = new Set(bindings.filter((binding) => binding.source === 'signalk').map((binding) => binding.context));
48
+ for (const context of contexts) {
49
+ const url = `${signalkUrl}/signalk/v1/api/${contextPath(context)}`;
50
+ (0, log_1.logDebug)(`GET ${url}`);
51
+ signalk[context] = unwrapSignalkTree(await (0, httpJson_1.fetchJson)(url));
52
+ }
53
+ const pathMeta = {};
54
+ for (const context of contexts) {
55
+ try {
56
+ (0, log_1.logDebug)(`GET ${signalkUrl}/signalk/v1/api/${contextPath(context)}/meta`);
57
+ pathMeta[context] = await (0, pathMeta_1.fetchPathMeta)(signalkUrl, context);
58
+ }
59
+ catch (err) {
60
+ console.error(`warning: could not fetch path metadata for context "${context}" (${err.message}) - automatic unit conversion will show raw values`);
61
+ }
62
+ }
63
+ const resources = {};
64
+ const resourceNames = new Set(bindings.filter((binding) => binding.source === 'resources').map((binding) => binding.resource));
65
+ for (const name of resourceNames) {
66
+ const url = `${signalkUrl}${RESOURCES_API_PATH}/${name}`;
67
+ (0, log_1.logDebug)(`GET ${url}`);
68
+ resources[name] = await (0, httpJson_1.fetchJson)(url);
69
+ }
70
+ const categoryNames = new Set(bindings.filter((binding) => binding.category).map((binding) => binding.category));
71
+ const categories = await (0, unitCategories_1.fetchCategoryDisplayUnits)(signalkUrl, categoryNames);
72
+ // Matches `considerRepaint` in repaintScheduler.ts - the CLI has no real device repaint to time, so
73
+ // a `source=einklabel,path=repainted` binding just resolves to "now", same as a live render would.
74
+ const meta = { repainted: new Date().toISOString() };
75
+ return { signalk, resources, pathMeta, categories, meta };
76
+ }
@@ -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;
package/dist/config.js ADDED
@@ -0,0 +1,193 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.defaultConfig = defaultConfig;
4
+ exports.resolveTemplatesDir = resolveTemplatesDir;
5
+ exports.parseDevice = parseDevice;
6
+ exports.resolveTemplatePath = resolveTemplatePath;
7
+ exports.configSchema = configSchema;
8
+ exports.configUiSchema = configUiSchema;
9
+ const fs_1 = require("fs");
10
+ const os_1 = require("os");
11
+ const path_1 = require("path");
12
+ const resolveApiUrl_1 = require("./resolveApiUrl");
13
+ /** The package's own bundled `templates/` directory (ships alongside `dist/`, see package.json's `files`) - templates here are always available, but a same-named template in the user's `templatesDir` takes priority. */
14
+ const BUNDLED_TEMPLATES_DIR = (0, path_1.join)(__dirname, '..', 'templates');
15
+ const SIGNALK_HOME_DIR = (0, path_1.join)((0, os_1.homedir)(), '.signalk');
16
+ const DEFAULT_TEMPLATES_DIR = (0, path_1.join)(SIGNALK_HOME_DIR, 'esl', 'templates');
17
+ function defaultConfig() {
18
+ return {
19
+ templatesDir: '',
20
+ scanOnStart: true,
21
+ scanDurationSeconds: 20,
22
+ paintConnectTimeoutSeconds: 30,
23
+ paintRetries: 3,
24
+ devices: [],
25
+ };
26
+ }
27
+ /**
28
+ * Resolves the user-facing `templatesDir` setting to an actual directory, mirroring
29
+ * signalk-parquet's `outputDirectory` convention: empty means the default location, a relative
30
+ * path is resolved against `~/.signalk` (where SignalK itself stores its config by default), and
31
+ * an absolute path is used as-is.
32
+ */
33
+ function resolveTemplatesDir(templatesDir) {
34
+ const trimmed = templatesDir?.trim();
35
+ if (!trimmed) {
36
+ return DEFAULT_TEMPLATES_DIR;
37
+ }
38
+ return (0, path_1.isAbsolute)(trimmed) ? trimmed : (0, path_1.join)(SIGNALK_HOME_DIR, trimmed);
39
+ }
40
+ /**
41
+ * Enum for the combined "device" field, built from recently scanned devices - including ones a
42
+ * driver identified as its vendor but whose PID isn't in its metadata table yet (clearly labelled,
43
+ * so the user can at least see what was found and report the PID; repainting such a device still
44
+ * does nothing without a model override, since there's no width/height to render with) - plus, as a
45
+ * fallback, whatever's already saved so an existing selection doesn't vanish from the dropdown just
46
+ * because this particular run hasn't re-scanned it yet.
47
+ */
48
+ function deviceOptions(discovered, current) {
49
+ const values = [];
50
+ const labels = [];
51
+ const seen = new Set();
52
+ for (const found of discovered) {
53
+ if (found.pid === undefined) {
54
+ continue;
55
+ }
56
+ const modelToken = [found.vendor, found.pid, found.hwVersion].filter((part) => part !== undefined).join(':');
57
+ const value = `${modelToken}@${found.address}`;
58
+ if (seen.has(value)) {
59
+ continue;
60
+ }
61
+ seen.add(value);
62
+ values.push(value);
63
+ const label = found.metadata
64
+ ? `${found.vendor} ${found.metadata.label} (${found.address})`
65
+ : `${found.vendor} unrecognised PID 0x${found.pid.toString(16).padStart(4, '0')} (${found.address})`;
66
+ labels.push(label);
67
+ }
68
+ for (const device of current.devices) {
69
+ if (!seen.has(device.device)) {
70
+ seen.add(device.device);
71
+ values.push(device.device);
72
+ labels.push(`${device.device} (not seen in last scan)`);
73
+ }
74
+ }
75
+ return { values, labels };
76
+ }
77
+ function parseDevice(device) {
78
+ const [modelToken, address] = device.split('@');
79
+ const [vendor, pidStr, hwVersion] = (modelToken ?? '').split(':');
80
+ const pid = Number(pidStr);
81
+ return vendor && address && Number.isInteger(pid) ? { vendor, pid, hwVersion, address } : undefined;
82
+ }
83
+ function listSvgFiles(dir) {
84
+ try {
85
+ return (0, fs_1.readdirSync)(dir).filter((name) => name.endsWith('.svg'));
86
+ }
87
+ catch {
88
+ return [];
89
+ }
90
+ }
91
+ /** Local templates take priority over a same-named bundled one; both show up as options. */
92
+ function templateNameOptions(templatesDir) {
93
+ const local = listSvgFiles(templatesDir);
94
+ const bundled = listSvgFiles(BUNDLED_TEMPLATES_DIR).filter((name) => !local.includes(name));
95
+ return [...local, ...bundled];
96
+ }
97
+ /** Resolves a template name to an actual file path - a local template overrides the bundled one of the same name. */
98
+ function resolveTemplatePath(templatesDir, templateName) {
99
+ const localPath = (0, path_1.join)(templatesDir, templateName);
100
+ return (0, fs_1.existsSync)(localPath) ? localPath : (0, path_1.join)(BUNDLED_TEMPLATES_DIR, templateName);
101
+ }
102
+ /** JSON Schema forbids an empty `enum` array, so only attach one when there's at least one option - otherwise the whole config schema fails validation. */
103
+ function withEnum(schema, values, names) {
104
+ return values.length > 0 ? { ...schema, enum: values, ...(names ? { enumNames: names } : {}) } : schema;
105
+ }
106
+ function configSchema(app, discovered = []) {
107
+ const defaults = defaultConfig();
108
+ const current = { ...defaults, ...app.readPluginOptions() };
109
+ const { values: deviceValues, labels: deviceLabels } = deviceOptions(discovered, current);
110
+ return {
111
+ type: 'object',
112
+ properties: {
113
+ templatesDir: {
114
+ type: 'string',
115
+ title: 'Templates directory',
116
+ description: `Relative path from ~/.signalk (e.g., "esl/templates" becomes ~/.signalk/esl/templates). ` +
117
+ `Leave empty for default (${DEFAULT_TEMPLATES_DIR}). Absolute paths also supported. A template here ` +
118
+ 'with the same name as a bundled one takes priority.',
119
+ default: defaults.templatesDir,
120
+ },
121
+ scanOnStart: {
122
+ type: 'boolean',
123
+ title: 'Scan for devices on plugin start',
124
+ description: 'Runs a short BLE scan so discovered devices show up in a device\'s "Device" picker below.',
125
+ default: defaults.scanOnStart,
126
+ },
127
+ scanDurationSeconds: {
128
+ type: 'number',
129
+ title: 'Scan duration (seconds)',
130
+ description: 'How long the startup scan runs - increase if devices are missing from the "Device" picker below.',
131
+ minimum: 1,
132
+ default: defaults.scanDurationSeconds,
133
+ },
134
+ paintConnectTimeoutSeconds: {
135
+ type: 'number',
136
+ title: 'Paint connect timeout (seconds)',
137
+ description: 'How long to wait for a device to accept a BLE connection before giving up on a repaint attempt.',
138
+ minimum: 1,
139
+ default: defaults.paintConnectTimeoutSeconds,
140
+ },
141
+ paintRetries: {
142
+ type: 'number',
143
+ title: 'Paint retries',
144
+ description: 'How many times to attempt a repaint (including the first try) before giving up and reporting failure.',
145
+ minimum: 1,
146
+ default: defaults.paintRetries,
147
+ },
148
+ signalkApiUrl: {
149
+ type: 'string',
150
+ title: 'SignalK API base URL (leave blank to auto-detect)',
151
+ description: 'Used for plugin access to SignalK REST APIs not yet integrated for direct plugin access. Left blank, the plugin probes the likely options at startup (3000, 80, 443 ) - only set this manually to skip probing. Anonymous read access is required.',
152
+ enum: ['', ...resolveApiUrl_1.SIGNALK_API_URL_OPTIONS],
153
+ },
154
+ devices: {
155
+ type: 'array',
156
+ title: 'Devices',
157
+ items: {
158
+ type: 'object',
159
+ required: ['friendlyName', 'device', 'templateName', 'repaintTrigger'],
160
+ properties: {
161
+ friendlyName: { type: 'string', title: 'Friendly name' },
162
+ device: withEnum({
163
+ type: 'string',
164
+ title: 'Device',
165
+ description: 'Picked from devices found by a scan (plugin start, or `esl-cli scan`) - sets both the model and BLE address.',
166
+ }, deviceValues, deviceLabels),
167
+ templateName: withEnum({ type: 'string', title: 'Template' }, templateNameOptions(resolveTemplatesDir(current.templatesDir))),
168
+ repaintTrigger: { type: 'string', title: 'Repaint trigger', enum: ['subscription', 'interval'] },
169
+ triggerPath: { type: 'string', title: 'Trigger SignalK path (if repaint trigger is subscription)' },
170
+ intervalHours: { type: 'number', title: 'Repaint every N hours (if repaint trigger is interval)', minimum: 1 },
171
+ intervalMinute: { type: 'number', title: 'Minutes past the hour (if repaint trigger is interval)', minimum: 0, maximum: 59, default: 0 },
172
+ aesKey: { type: 'string', title: 'BLE AES key (vendor-specific; leave blank to use a default key)' },
173
+ forceRepaint: {
174
+ type: 'boolean',
175
+ title: 'Force repaint',
176
+ description: 'Repaint even if the data is unchanged - clears itself automatically once that repaint completes',
177
+ default: false,
178
+ },
179
+ },
180
+ },
181
+ },
182
+ },
183
+ };
184
+ }
185
+ function configUiSchema() {
186
+ return {
187
+ devices: {
188
+ items: {
189
+ repaintTrigger: { 'ui:widget': 'radio' },
190
+ },
191
+ },
192
+ };
193
+ }