@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.
- package/README.md +109 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +244 -0
- package/dist/cli/liveContext.d.ts +11 -0
- package/dist/cli/liveContext.js +72 -0
- package/dist/cli/log.d.ts +5 -0
- package/dist/cli/log.js +18 -0
- package/dist/config.d.ts +74 -0
- package/dist/config.js +193 -0
- package/dist/devices/bleDiscovery.d.ts +54 -0
- package/dist/devices/bleDiscovery.js +134 -0
- package/dist/devices/registry.d.ts +4 -0
- package/dist/devices/registry.js +15 -0
- package/dist/devices/types.d.ts +70 -0
- package/dist/devices/types.js +2 -0
- package/dist/devices/zhsunyco/encode.d.ts +12 -0
- package/dist/devices/zhsunyco/encode.js +47 -0
- package/dist/devices/zhsunyco/index.d.ts +11 -0
- package/dist/devices/zhsunyco/index.js +148 -0
- package/dist/devices/zhsunyco/metadata.d.ts +20 -0
- package/dist/devices/zhsunyco/metadata.js +98 -0
- package/dist/devices/zhsunyco/protocol.d.ts +49 -0
- package/dist/devices/zhsunyco/protocol.js +86 -0
- package/dist/httpJson.d.ts +6 -0
- package/dist/httpJson.js +40 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +23 -0
- package/dist/pathMeta.d.ts +16 -0
- package/dist/pathMeta.js +21 -0
- package/dist/plugin.d.ts +2 -0
- package/dist/plugin.js +99 -0
- package/dist/render/binding.d.ts +53 -0
- package/dist/render/binding.js +168 -0
- package/dist/render/fonts.d.ts +9 -0
- package/dist/render/fonts.js +16 -0
- package/dist/render/formatters.d.ts +18 -0
- package/dist/render/formatters.js +78 -0
- package/dist/render/png.d.ts +3 -0
- package/dist/render/png.js +10 -0
- package/dist/render/svgRenderer.d.ts +32 -0
- package/dist/render/svgRenderer.js +80 -0
- package/dist/render/types.d.ts +26 -0
- package/dist/render/types.js +2 -0
- package/dist/repaintScheduler.d.ts +6 -0
- package/dist/repaintScheduler.js +193 -0
- package/dist/resolveApiUrl.d.ts +28 -0
- package/dist/resolveApiUrl.js +62 -0
- package/dist/unitCategories.d.ts +11 -0
- package/dist/unitCategories.js +46 -0
- package/package.json +70 -0
- package/templates/tide.svg +213 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.startRepaintScheduler = startRepaintScheduler;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
const path_1 = require("path");
|
|
6
|
+
const fs_1 = require("fs");
|
|
7
|
+
const config_1 = require("./config");
|
|
8
|
+
const bleDiscovery_1 = require("./devices/bleDiscovery");
|
|
9
|
+
const registry_1 = require("./devices/registry");
|
|
10
|
+
const svgRenderer_1 = require("./render/svgRenderer");
|
|
11
|
+
const binding_1 = require("./render/binding");
|
|
12
|
+
const unitCategories_1 = require("./unitCategories");
|
|
13
|
+
const pathMeta_1 = require("./pathMeta");
|
|
14
|
+
const resolveApiUrl_1 = require("./resolveApiUrl");
|
|
15
|
+
const INTERVAL_POLL_MS = 60000;
|
|
16
|
+
const SUBSCRIPTION_DEBOUNCE_MS = 2000;
|
|
17
|
+
function statePath(app) {
|
|
18
|
+
return (0, path_1.join)(app.getDataDirPath(), 'repaint-state.json');
|
|
19
|
+
}
|
|
20
|
+
function loadState(app) {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse((0, fs_1.readFileSync)(statePath(app), 'utf-8'));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function saveState(app, state) {
|
|
29
|
+
(0, fs_1.writeFileSync)(statePath(app), JSON.stringify(state));
|
|
30
|
+
}
|
|
31
|
+
/** Deterministic JSON serialisation (sorted keys) so re-ordered object keys don't change the hash. */
|
|
32
|
+
function stableStringify(value) {
|
|
33
|
+
if (Array.isArray(value)) {
|
|
34
|
+
return `[${value.map(stableStringify).join(',')}]`;
|
|
35
|
+
}
|
|
36
|
+
if (value !== null && typeof value === 'object') {
|
|
37
|
+
const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b));
|
|
38
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(',')}}`;
|
|
39
|
+
}
|
|
40
|
+
return JSON.stringify(value);
|
|
41
|
+
}
|
|
42
|
+
function hashContext(context) {
|
|
43
|
+
return (0, crypto_1.createHash)('sha1').update(stableStringify(context)).digest('hex');
|
|
44
|
+
}
|
|
45
|
+
/** Merges `value` into `target` at the nested location described by a dotted SignalK path. */
|
|
46
|
+
function setAtPath(target, path, value) {
|
|
47
|
+
const segments = path.split('.');
|
|
48
|
+
let node = target;
|
|
49
|
+
for (const segment of segments.slice(0, -1)) {
|
|
50
|
+
const next = node[segment];
|
|
51
|
+
node[segment] = typeof next === 'object' && next !== null ? next : {};
|
|
52
|
+
node = node[segment];
|
|
53
|
+
}
|
|
54
|
+
node[segments[segments.length - 1]] = value;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Reads live data for exactly what a template's own bindings ask for - no separate config declaring it.
|
|
58
|
+
* `signalk`-sourced bindings are read directly (`self` via `getSelfPath`, anything else via `getPath`
|
|
59
|
+
* against that literal SignalK context) - in-process, no URL needed. `resources`-sourced bindings go
|
|
60
|
+
* through `app.resourcesApi`, in-process too. Per-path unit-conversion metadata (`pathMeta`, for
|
|
61
|
+
* automatic conversion - see `renderBinding`) and an explicit `category=` binding's resolved
|
|
62
|
+
* conversion both have no in-process equivalent (confirmed against the signalk-server source - that
|
|
63
|
+
* resolution only happens in its REST layer), so both need `apiUrl` - fetching `pathMeta` is
|
|
64
|
+
* best-effort (a missing/unreachable server just means no automatic conversion), but a `category=`
|
|
65
|
+
* binding is a declared dependency, so a missing `apiUrl` is a hard error there.
|
|
66
|
+
*/
|
|
67
|
+
async function assembleRawContext(app, apiUrl, bindings) {
|
|
68
|
+
var _a;
|
|
69
|
+
const signalk = {};
|
|
70
|
+
const seenSignalk = new Set();
|
|
71
|
+
for (const binding of bindings) {
|
|
72
|
+
if (binding.source !== 'signalk')
|
|
73
|
+
continue;
|
|
74
|
+
const key = `${binding.context} ${binding.path}`;
|
|
75
|
+
if (seenSignalk.has(key))
|
|
76
|
+
continue;
|
|
77
|
+
seenSignalk.add(key);
|
|
78
|
+
const value = binding.context === 'self' ? app.getSelfPath(binding.path) : app.getPath(`${binding.context}.${binding.path}`);
|
|
79
|
+
const namespace = (signalk[_a = binding.context] ?? (signalk[_a] = {}));
|
|
80
|
+
setAtPath(namespace, binding.path, value);
|
|
81
|
+
}
|
|
82
|
+
signalk.self ?? (signalk.self = {});
|
|
83
|
+
const pathMeta = {};
|
|
84
|
+
const signalkContexts = new Set(bindings.filter((binding) => binding.source === 'signalk').map((binding) => binding.context));
|
|
85
|
+
if (apiUrl) {
|
|
86
|
+
for (const ctx of signalkContexts) {
|
|
87
|
+
try {
|
|
88
|
+
pathMeta[ctx] = await (0, pathMeta_1.fetchPathMeta)(apiUrl, ctx);
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
app.debug(`could not fetch path metadata for context "${ctx}" (${err.message}) - automatic unit conversion will show raw values`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const resources = {};
|
|
96
|
+
const resourceNames = new Set(bindings.filter((binding) => binding.source === 'resources').map((binding) => binding.resource));
|
|
97
|
+
for (const name of resourceNames) {
|
|
98
|
+
// `listResources`'s type only allows the standard SignalKResourceType union, but the underlying
|
|
99
|
+
// Resources API (and a custom provider like signalk-tides, registered under the non-standard
|
|
100
|
+
// "tides" type) accepts any registered resource type string - this cast matches `getResource`'s
|
|
101
|
+
// wider, accurate signature.
|
|
102
|
+
resources[name] = await app.resourcesApi.listResources(name, {});
|
|
103
|
+
}
|
|
104
|
+
const categoryNames = new Set(bindings.filter((binding) => binding.category).map((binding) => binding.category));
|
|
105
|
+
if (categoryNames.size > 0 && !apiUrl) {
|
|
106
|
+
throw new Error(`binding references categor${categoryNames.size > 1 ? 'ies' : 'y'} "${[...categoryNames].join(', ')}" but no SignalK API base URL is configured`);
|
|
107
|
+
}
|
|
108
|
+
const categories = apiUrl ? await (0, unitCategories_1.fetchCategoryDisplayUnits)(apiUrl, categoryNames) : {};
|
|
109
|
+
return { signalk, resources, pathMeta, categories };
|
|
110
|
+
}
|
|
111
|
+
function clearForceRepaint(app, friendlyName) {
|
|
112
|
+
const current = { ...app.readPluginOptions() };
|
|
113
|
+
const devices = (current.devices ?? []).map((device) => device.friendlyName === friendlyName ? { ...device, forceRepaint: false } : device);
|
|
114
|
+
app.savePluginOptions({ ...current, devices }, (err) => {
|
|
115
|
+
if (err)
|
|
116
|
+
app.debug(`failed to clear forceRepaint for "${friendlyName}": ${err.message}`);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
async function considerRepaint(app, config, device, state, getApiUrl) {
|
|
120
|
+
const model = (0, config_1.parseDevice)(device.device);
|
|
121
|
+
const driver = model && (0, registry_1.getDriver)(model.vendor);
|
|
122
|
+
const metadata = model && driver?.metadataForPid(model.pid, model.hwVersion);
|
|
123
|
+
if (!model || !driver || !metadata) {
|
|
124
|
+
app.debug(`"${device.friendlyName}": no driver/metadata for device "${device.device}", skipping`);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const templatePath = (0, config_1.resolveTemplatePath)((0, config_1.resolveTemplatesDir)(config.templatesDir), device.templateName);
|
|
128
|
+
const bindings = (0, binding_1.findBindings)((0, fs_1.readFileSync)(templatePath, 'utf-8'));
|
|
129
|
+
const apiUrl = await getApiUrl().catch((err) => {
|
|
130
|
+
app.debug(`"${device.friendlyName}": ${err.message}`);
|
|
131
|
+
return undefined;
|
|
132
|
+
});
|
|
133
|
+
const rawContext = await assembleRawContext(app, apiUrl, bindings);
|
|
134
|
+
const hash = hashContext(rawContext);
|
|
135
|
+
if (state[device.friendlyName]?.hash === hash && !device.forceRepaint) {
|
|
136
|
+
app.debug(`"${device.friendlyName}": data unchanged, skipping repaint`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const renderContext = { ...rawContext, meta: { repaintedAt: new Date().toISOString() } };
|
|
140
|
+
const renderer = new svgRenderer_1.SvgRenderer();
|
|
141
|
+
const bitmap = await renderer.render(templatePath, renderContext, metadata.width, metadata.height - metadata.voffset);
|
|
142
|
+
const connectTimeoutMs = config.paintConnectTimeoutSeconds * 1000;
|
|
143
|
+
await (0, bleDiscovery_1.withRetries)(config.paintRetries, async (attempt) => {
|
|
144
|
+
if (attempt > 1) {
|
|
145
|
+
app.debug(`"${device.friendlyName}": paint attempt ${attempt}/${config.paintRetries}`);
|
|
146
|
+
}
|
|
147
|
+
await driver.paint(bitmap, { address: model.address, aesKey: device.aesKey, connectTimeoutMs });
|
|
148
|
+
});
|
|
149
|
+
state[device.friendlyName] = { hash };
|
|
150
|
+
saveState(app, state);
|
|
151
|
+
if (device.forceRepaint) {
|
|
152
|
+
clearForceRepaint(app, device.friendlyName);
|
|
153
|
+
}
|
|
154
|
+
app.debug(`"${device.friendlyName}": repainted`);
|
|
155
|
+
}
|
|
156
|
+
function startRepaintScheduler(app, config) {
|
|
157
|
+
const state = loadState(app);
|
|
158
|
+
const unsubscribes = [];
|
|
159
|
+
const getApiUrl = (0, resolveApiUrl_1.createApiUrlResolver)(config.signalkApiUrl);
|
|
160
|
+
const repaint = (device) => considerRepaint(app, config, device, state, getApiUrl).catch((err) => app.debug(`"${device.friendlyName}": repaint failed: ${err.message}`));
|
|
161
|
+
const intervalDevices = config.devices.filter((device) => device.repaintTrigger === 'interval');
|
|
162
|
+
if (intervalDevices.length > 0) {
|
|
163
|
+
const timer = setInterval(() => {
|
|
164
|
+
const now = new Date();
|
|
165
|
+
for (const device of intervalDevices) {
|
|
166
|
+
const hours = device.intervalHours ?? 1;
|
|
167
|
+
const minute = device.intervalMinute ?? 0;
|
|
168
|
+
if (now.getHours() % hours === 0 && now.getMinutes() === minute) {
|
|
169
|
+
repaint(device);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}, INTERVAL_POLL_MS);
|
|
173
|
+
unsubscribes.push(() => clearInterval(timer));
|
|
174
|
+
}
|
|
175
|
+
for (const device of config.devices) {
|
|
176
|
+
if (device.repaintTrigger === 'subscription' && device.triggerPath) {
|
|
177
|
+
const stream = app.streambundle.getSelfStream(device.triggerPath).debounce(SUBSCRIPTION_DEBOUNCE_MS);
|
|
178
|
+
const unsub = stream.onValue(() => repaint(device));
|
|
179
|
+
unsubscribes.push(unsub);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Check every device once at startup - harmless given hash dedup, and covers newly-added
|
|
183
|
+
// devices or a forceRepaint left set from before a restart.
|
|
184
|
+
for (const device of config.devices) {
|
|
185
|
+
repaint(device);
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
stop() {
|
|
189
|
+
for (const unsubscribe of unsubscribes)
|
|
190
|
+
unsubscribe();
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This whole module is a workaround for a gap in the plugin API, not a permanent design choice -
|
|
3
|
+
* `resourcesApi`/`weatherApi`/`courseApi` etc. on `ServerAPI` exist because of an ongoing upstream
|
|
4
|
+
* effort to broaden what plugins can reach in-process; unit-preferences resolution (and enhanced
|
|
5
|
+
* per-path metadata) just hasn't been added yet. If/when it is, this probing - and `unitCategories.ts`'s
|
|
6
|
+
* client-side recomposition of the same resolution - can likely be replaced with a direct in-process
|
|
7
|
+
* call, dropping the need for `signalkApiUrl` entirely.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* The only realistic values for this server's own base URL, in likelihood order - the plugin always
|
|
11
|
+
* runs on the same host as the server, so it's always the loopback address, and the port is determined
|
|
12
|
+
* entirely by install method: a bare `npm install` defaults to 3000; container/systemd installs
|
|
13
|
+
* commonly default to 80, or 443 if TLS-terminated locally.
|
|
14
|
+
*/
|
|
15
|
+
export declare const SIGNALK_API_URL_OPTIONS: string[];
|
|
16
|
+
/**
|
|
17
|
+
* Resolves this server's own base URL. If `configuredUrl` is set, trusts it but still confirms it
|
|
18
|
+
* actually works, so a bad config value surfaces a clear error instead of failing silently on every
|
|
19
|
+
* `category=` binding or per-path metadata fetch. If unset, probes `SIGNALK_API_URL_OPTIONS` in order
|
|
20
|
+
* and uses the first that responds.
|
|
21
|
+
*/
|
|
22
|
+
export declare function resolveSignalkApiUrl(configuredUrl: string | undefined): Promise<string>;
|
|
23
|
+
/**
|
|
24
|
+
* Memoizes a successful resolution only - a failure (server not ready yet, anonymous access
|
|
25
|
+
* temporarily misconfigured, ...) is retried on the next call rather than cached forever, since the
|
|
26
|
+
* port itself won't change once the server's actually up.
|
|
27
|
+
*/
|
|
28
|
+
export declare function createApiUrlResolver(configuredUrl: string | undefined): () => Promise<string>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SIGNALK_API_URL_OPTIONS = void 0;
|
|
4
|
+
exports.resolveSignalkApiUrl = resolveSignalkApiUrl;
|
|
5
|
+
exports.createApiUrlResolver = createApiUrlResolver;
|
|
6
|
+
const httpJson_1 = require("./httpJson");
|
|
7
|
+
/**
|
|
8
|
+
* This whole module is a workaround for a gap in the plugin API, not a permanent design choice -
|
|
9
|
+
* `resourcesApi`/`weatherApi`/`courseApi` etc. on `ServerAPI` exist because of an ongoing upstream
|
|
10
|
+
* effort to broaden what plugins can reach in-process; unit-preferences resolution (and enhanced
|
|
11
|
+
* per-path metadata) just hasn't been added yet. If/when it is, this probing - and `unitCategories.ts`'s
|
|
12
|
+
* client-side recomposition of the same resolution - can likely be replaced with a direct in-process
|
|
13
|
+
* call, dropping the need for `signalkApiUrl` entirely.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* The only realistic values for this server's own base URL, in likelihood order - the plugin always
|
|
17
|
+
* runs on the same host as the server, so it's always the loopback address, and the port is determined
|
|
18
|
+
* entirely by install method: a bare `npm install` defaults to 3000; container/systemd installs
|
|
19
|
+
* commonly default to 80, or 443 if TLS-terminated locally.
|
|
20
|
+
*/
|
|
21
|
+
exports.SIGNALK_API_URL_OPTIONS = ['http://localhost:3000', 'http://localhost', 'https://localhost'];
|
|
22
|
+
/** Cheap, always-required (for `category=` resolution) and read-only, so safe to use as a connectivity+access probe. */
|
|
23
|
+
const PROBE_PATH = '/signalk/v1/unitpreferences/categories';
|
|
24
|
+
async function probe(url) {
|
|
25
|
+
try {
|
|
26
|
+
await (0, httpJson_1.fetchJson)(`${url}${PROBE_PATH}`);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Resolves this server's own base URL. If `configuredUrl` is set, trusts it but still confirms it
|
|
35
|
+
* actually works, so a bad config value surfaces a clear error instead of failing silently on every
|
|
36
|
+
* `category=` binding or per-path metadata fetch. If unset, probes `SIGNALK_API_URL_OPTIONS` in order
|
|
37
|
+
* and uses the first that responds.
|
|
38
|
+
*/
|
|
39
|
+
async function resolveSignalkApiUrl(configuredUrl) {
|
|
40
|
+
const candidates = configuredUrl ? [configuredUrl] : exports.SIGNALK_API_URL_OPTIONS;
|
|
41
|
+
for (const url of candidates) {
|
|
42
|
+
if (await probe(url))
|
|
43
|
+
return url;
|
|
44
|
+
}
|
|
45
|
+
throw new Error(configuredUrl
|
|
46
|
+
? `configured SignalK API base URL "${configuredUrl}" did not respond to ${PROBE_PATH} - check the port, and that anonymous read access is enabled`
|
|
47
|
+
: `could not reach this server's API on any of ${exports.SIGNALK_API_URL_OPTIONS.join(', ')} - it may be on a different port, or anonymous read access may not be enabled`);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Memoizes a successful resolution only - a failure (server not ready yet, anonymous access
|
|
51
|
+
* temporarily misconfigured, ...) is retried on the next call rather than cached forever, since the
|
|
52
|
+
* port itself won't change once the server's actually up.
|
|
53
|
+
*/
|
|
54
|
+
function createApiUrlResolver(configuredUrl) {
|
|
55
|
+
let resolved;
|
|
56
|
+
return async () => {
|
|
57
|
+
if (!resolved) {
|
|
58
|
+
resolved = await resolveSignalkApiUrl(configuredUrl);
|
|
59
|
+
}
|
|
60
|
+
return resolved;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { DisplayUnits } from './render/formatters';
|
|
2
|
+
/**
|
|
3
|
+
* Resolves `category=` bindings (e.g. `category=depth` on a `source=resources` value with no per-path
|
|
4
|
+
* metadata of its own to auto-convert from) against this server's unit-preferences setup.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors signalk-server's own `resolveDisplayUnits` (`src/unitpreferences/resolver.ts`), composed
|
|
7
|
+
* client-side from the three REST endpoints that expose the same underlying data - that resolver isn't
|
|
8
|
+
* exposed via `ServerAPI`, it's an internal signalk-server module, so both the live plugin
|
|
9
|
+
* (repaintScheduler.ts) and the CLI (cli/liveContext.ts) fetch it the same way, over HTTP.
|
|
10
|
+
*/
|
|
11
|
+
export declare function fetchCategoryDisplayUnits(apiUrl: string, categoryNames: Set<string>): Promise<Record<string, DisplayUnits>>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.fetchCategoryDisplayUnits = fetchCategoryDisplayUnits;
|
|
4
|
+
const httpJson_1 = require("./httpJson");
|
|
5
|
+
const CATEGORIES_PATH = '/signalk/v1/unitpreferences/categories';
|
|
6
|
+
const ACTIVE_PRESET_PATH = '/signalk/v1/unitpreferences/active';
|
|
7
|
+
const DEFINITIONS_PATH = '/signalk/v1/unitpreferences/definitions';
|
|
8
|
+
/**
|
|
9
|
+
* Resolves `category=` bindings (e.g. `category=depth` on a `source=resources` value with no per-path
|
|
10
|
+
* metadata of its own to auto-convert from) against this server's unit-preferences setup.
|
|
11
|
+
*
|
|
12
|
+
* Mirrors signalk-server's own `resolveDisplayUnits` (`src/unitpreferences/resolver.ts`), composed
|
|
13
|
+
* client-side from the three REST endpoints that expose the same underlying data - that resolver isn't
|
|
14
|
+
* exposed via `ServerAPI`, it's an internal signalk-server module, so both the live plugin
|
|
15
|
+
* (repaintScheduler.ts) and the CLI (cli/liveContext.ts) fetch it the same way, over HTTP.
|
|
16
|
+
*/
|
|
17
|
+
async function fetchCategoryDisplayUnits(apiUrl, categoryNames) {
|
|
18
|
+
if (categoryNames.size === 0)
|
|
19
|
+
return {};
|
|
20
|
+
const [categoryMap, activePreset, definitions] = await Promise.all([
|
|
21
|
+
(0, httpJson_1.fetchJson)(`${apiUrl}${CATEGORIES_PATH}`),
|
|
22
|
+
(0, httpJson_1.fetchJson)(`${apiUrl}${ACTIVE_PRESET_PATH}`),
|
|
23
|
+
(0, httpJson_1.fetchJson)(`${apiUrl}${DEFINITIONS_PATH}`),
|
|
24
|
+
]);
|
|
25
|
+
const result = {};
|
|
26
|
+
for (const category of categoryNames) {
|
|
27
|
+
const siUnit = categoryMap.categoryToBaseUnit?.[category];
|
|
28
|
+
if (!siUnit) {
|
|
29
|
+
throw new Error(`unknown unit category "${category}" - not in this server's categoryToBaseUnit map`);
|
|
30
|
+
}
|
|
31
|
+
const presetCategory = activePreset.categories?.[category];
|
|
32
|
+
const targetUnit = presetCategory?.targetUnit ?? siUnit;
|
|
33
|
+
// A conversion entry only exists for a non-identity target unit - e.g. siUnit "m"/targetUnit "m"
|
|
34
|
+
// (the common case for depth on the default metric preset) has no "m"->"m" entry in definitions.
|
|
35
|
+
// Fall back to the target unit's own name as the symbol in that case, rather than showing nothing.
|
|
36
|
+
const conversion = targetUnit !== siUnit ? definitions[siUnit]?.conversions?.[targetUnit] : undefined;
|
|
37
|
+
result[category] = {
|
|
38
|
+
category,
|
|
39
|
+
targetUnit,
|
|
40
|
+
formula: conversion?.formula,
|
|
41
|
+
symbol: conversion?.symbol ?? targetUnit,
|
|
42
|
+
displayFormat: presetCategory?.displayFormat,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rhizomatics/signalk-esl-plugin",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "SignalK plugin that renders selected SignalK data to eInk Electronic Shelf Labels",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"bin": {
|
|
8
|
+
"esl-cli": "dist/cli/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"templates"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"watch": "tsc --watch",
|
|
17
|
+
"cli": "ts-node src/cli/index.ts"
|
|
18
|
+
},
|
|
19
|
+
"signalk":{
|
|
20
|
+
"displayName":"ESL eInk Instruments",
|
|
21
|
+
"appIcon": "/docs/assets/icons/icon_tmp.png",
|
|
22
|
+
"screenshots": [
|
|
23
|
+
"docs/assets/screenshots/example_tidal_clock.png"
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"signalk-node-server-plugin",
|
|
28
|
+
"signalk-category-instruments",
|
|
29
|
+
"signalk-category-hardware",
|
|
30
|
+
"signalk",
|
|
31
|
+
"eink",
|
|
32
|
+
"esl",
|
|
33
|
+
"display",
|
|
34
|
+
"instrument",
|
|
35
|
+
"ble"
|
|
36
|
+
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@fontsource/jetbrains-mono": "^5.2.8",
|
|
42
|
+
"@fontsource/playfair-display": "^5.2.8",
|
|
43
|
+
"@fontsource/roboto": "^5.2.10",
|
|
44
|
+
"@resvg/resvg-wasm": "^2.6.2",
|
|
45
|
+
"@xmldom/xmldom": "^0.9.10",
|
|
46
|
+
"commander": "^12.1.0",
|
|
47
|
+
"luxon": "^3.7.2",
|
|
48
|
+
"mathjs": "^15.2.0",
|
|
49
|
+
"node-ble": "^1.13.0",
|
|
50
|
+
"pngjs": "^7.0.0"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@signalk/server-api": "^2.28.0",
|
|
54
|
+
"@types/luxon": "^3.7.1",
|
|
55
|
+
"@types/node": "^20.14.0",
|
|
56
|
+
"@types/pngjs": "^6.0.5",
|
|
57
|
+
"ts-node": "^10.9.2",
|
|
58
|
+
"typescript": "^5.5.0"
|
|
59
|
+
},
|
|
60
|
+
"license": "APACHE-2.0",
|
|
61
|
+
"repository": {
|
|
62
|
+
"type": "git",
|
|
63
|
+
"url": "git+https://github.com/rhizomatics/signalk-esl-plugin.git"
|
|
64
|
+
},
|
|
65
|
+
"author": "jey burrows",
|
|
66
|
+
"bugs": {
|
|
67
|
+
"url": "https://github.com/rhizomatics/signalk-esl-plugin/issues"
|
|
68
|
+
},
|
|
69
|
+
"homepage": "https://github.com/rhizomatics/signalk-esl-plugin#readme"
|
|
70
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
2
|
+
<svg
|
|
3
|
+
width="416"
|
|
4
|
+
height="240"
|
|
5
|
+
viewBox="0 0 416 240"
|
|
6
|
+
version="1.1"
|
|
7
|
+
id="svg1"
|
|
8
|
+
sodipodi:docname="tide.svg"
|
|
9
|
+
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
|
|
10
|
+
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
|
11
|
+
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
|
12
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
13
|
+
xmlns:svg="http://www.w3.org/2000/svg">
|
|
14
|
+
<defs
|
|
15
|
+
id="defs1" />
|
|
16
|
+
<sodipodi:namedview
|
|
17
|
+
id="namedview1"
|
|
18
|
+
pagecolor="#ffffff"
|
|
19
|
+
bordercolor="#000000"
|
|
20
|
+
borderopacity="0.25"
|
|
21
|
+
inkscape:showpageshadow="2"
|
|
22
|
+
inkscape:pageopacity="0.0"
|
|
23
|
+
inkscape:pagecheckerboard="0"
|
|
24
|
+
inkscape:deskcolor="#d1d1d1"
|
|
25
|
+
inkscape:zoom="0.98333333"
|
|
26
|
+
inkscape:cx="103.22034"
|
|
27
|
+
inkscape:cy="169.32203"
|
|
28
|
+
inkscape:window-width="1488"
|
|
29
|
+
inkscape:window-height="674"
|
|
30
|
+
inkscape:window-x="0"
|
|
31
|
+
inkscape:window-y="33"
|
|
32
|
+
inkscape:window-maximized="0"
|
|
33
|
+
inkscape:current-layer="svg1" />
|
|
34
|
+
<rect
|
|
35
|
+
x="1.0169492"
|
|
36
|
+
y="2.0798673e-08"
|
|
37
|
+
width="416"
|
|
38
|
+
height="240"
|
|
39
|
+
fill="#ffffff"
|
|
40
|
+
id="rect1" />
|
|
41
|
+
<rect
|
|
42
|
+
style="fill:#ffff00;stroke:#000000;stroke-width:0.614045"
|
|
43
|
+
id="rect2"
|
|
44
|
+
width="416.94916"
|
|
45
|
+
height="18.410578"
|
|
46
|
+
x="-24.928873"
|
|
47
|
+
y="-242.40596"
|
|
48
|
+
transform="matrix(1,0,-0.1068988,-0.99426991,0,0)" />
|
|
49
|
+
<text
|
|
50
|
+
id="station.name"
|
|
51
|
+
x="11.235578"
|
|
52
|
+
y="26.851856"
|
|
53
|
+
font-size="18"
|
|
54
|
+
font-family="serif"
|
|
55
|
+
fill="yellow"
|
|
56
|
+
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:32px;font-family:serif;-inkscape-font-specification:'serif, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ffff00;stroke:#000000;stroke-width:1.2278;stroke-opacity:1"
|
|
57
|
+
transform="scale(0.89274878,1.1201359)">Tobermory<desc
|
|
58
|
+
id="desc1">source=resources,resource=tides,path=station.name</desc></text>
|
|
59
|
+
<text
|
|
60
|
+
id="station.name-8"
|
|
61
|
+
x="3.2937632"
|
|
62
|
+
y="234.27425"
|
|
63
|
+
font-size="18"
|
|
64
|
+
font-family="serif"
|
|
65
|
+
fill="black"
|
|
66
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:10.6667px;font-family:serif;-inkscape-font-specification:'serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#000000"><tspan
|
|
67
|
+
sodipodi:role="line"
|
|
68
|
+
id="tspan1"
|
|
69
|
+
x="3.2937632"
|
|
70
|
+
y="234.27425">signalk-esl-plugin by Rhizomatics</tspan></text>
|
|
71
|
+
<text
|
|
72
|
+
id="extremes.0"
|
|
73
|
+
x="7.9661016"
|
|
74
|
+
y="60"
|
|
75
|
+
font-size="14"
|
|
76
|
+
font-family="monospace"
|
|
77
|
+
fill="black"
|
|
78
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:18.6667px;font-family:monospace;-inkscape-font-specification:'monospace, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ff0000">High Water</text>
|
|
79
|
+
<text
|
|
80
|
+
id="extremes.1"
|
|
81
|
+
x="13.35283"
|
|
82
|
+
y="118.57627"
|
|
83
|
+
font-size="14"
|
|
84
|
+
font-family="monospace"
|
|
85
|
+
fill="black"
|
|
86
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:18.6667px;font-family:monospace;-inkscape-font-specification:'monospace, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ff0000">Low Water</text>
|
|
87
|
+
<text
|
|
88
|
+
id="extremes.2"
|
|
89
|
+
x="7.9661016"
|
|
90
|
+
y="177.55931"
|
|
91
|
+
font-size="14"
|
|
92
|
+
font-family="monospace"
|
|
93
|
+
fill="red"
|
|
94
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:18.6667px;font-family:monospace;-inkscape-font-specification:'monospace, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ff0000">High Water</text>
|
|
95
|
+
<text
|
|
96
|
+
id="environment.time.timezoneRegion"
|
|
97
|
+
x="338.47461"
|
|
98
|
+
y="17.457626"
|
|
99
|
+
font-size="12px"
|
|
100
|
+
font-family="sans-serif"
|
|
101
|
+
fill="#ffff00"
|
|
102
|
+
style="fill:#000000">UTC+01:00<desc
|
|
103
|
+
id="desc11">path=environment.time.timezoneRegion,format=utc_offset</desc></text>
|
|
104
|
+
<text
|
|
105
|
+
id="extremes.2.level"
|
|
106
|
+
x="173.64978"
|
|
107
|
+
y="199.27094"
|
|
108
|
+
font-size="14"
|
|
109
|
+
font-family="monospace"
|
|
110
|
+
fill="red"
|
|
111
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:32px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#000000;stroke-width:1.44101"
|
|
112
|
+
transform="scale(0.95993086,1.0417417)"
|
|
113
|
+
inkscape:transform-center-x="11.578901"
|
|
114
|
+
inkscape:transform-center-y="-62.5909"><tspan
|
|
115
|
+
sodipodi:role="line"
|
|
116
|
+
id="tspan2-0"
|
|
117
|
+
x="173.64978"
|
|
118
|
+
y="199.27094"
|
|
119
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:32px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;stroke-width:1.44101">3.3m</tspan><desc
|
|
120
|
+
id="desc6">source=resources,resource=tides,path=extremes.[2].level,category=depth</desc></text>
|
|
121
|
+
<text
|
|
122
|
+
id="extremes.2.time"
|
|
123
|
+
x="31.471279"
|
|
124
|
+
y="166.20743"
|
|
125
|
+
font-size="14"
|
|
126
|
+
font-family="monospace"
|
|
127
|
+
fill="red"
|
|
128
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:32px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#000000;stroke-width:1.31448"
|
|
129
|
+
transform="scale(0.77910527,1.2835236)">15:53<desc
|
|
130
|
+
id="desc10">source=resources,resource=tides,path=extremes.[2].time,format=local_time</desc></text>
|
|
131
|
+
<text
|
|
132
|
+
id="extremes.1.level"
|
|
133
|
+
x="182.71886"
|
|
134
|
+
y="135.3521"
|
|
135
|
+
font-size="14"
|
|
136
|
+
font-family="monospace"
|
|
137
|
+
fill="red"
|
|
138
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:32px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#000000;stroke-width:1.40832"
|
|
139
|
+
transform="scale(0.90784041,1.1015152)"
|
|
140
|
+
inkscape:transform-center-y="-96.23206"
|
|
141
|
+
inkscape:transform-center-x="-0.00043342573"><tspan
|
|
142
|
+
sodipodi:role="line"
|
|
143
|
+
id="tspan2-1"
|
|
144
|
+
x="182.71886"
|
|
145
|
+
y="135.3521"
|
|
146
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:32px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;stroke-width:1.40832">1.6m</tspan><desc
|
|
147
|
+
id="desc5">source=resources,resource=tides,path=extremes.[1].level,category=depth</desc></text>
|
|
148
|
+
<text
|
|
149
|
+
id="extremes.1.time"
|
|
150
|
+
x="33.328102"
|
|
151
|
+
y="118.54723"
|
|
152
|
+
font-size="14"
|
|
153
|
+
font-family="monospace"
|
|
154
|
+
fill="red"
|
|
155
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:32px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#000000;stroke-width:1.31609"
|
|
156
|
+
transform="scale(0.77081545,1.2973274)">09:36<desc
|
|
157
|
+
id="desc8">source=resources,resource=tides,path=extremes.[1].time,format=local_time</desc></text>
|
|
158
|
+
<text
|
|
159
|
+
id="extremes.0.time"
|
|
160
|
+
x="29.798925"
|
|
161
|
+
y="79.890121"
|
|
162
|
+
font-size="14"
|
|
163
|
+
font-family="monospace"
|
|
164
|
+
fill="red"
|
|
165
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:32px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#000000;stroke-width:1.32646"
|
|
166
|
+
transform="scale(0.82428216,1.2131768)">03:27<desc
|
|
167
|
+
id="desc3">source=resources,resource=tides,path=extremes.[0].time,format=local_time</desc></text>
|
|
168
|
+
<text
|
|
169
|
+
id="extremes.0.time-8"
|
|
170
|
+
x="378.11157"
|
|
171
|
+
y="79.890121"
|
|
172
|
+
font-size="14"
|
|
173
|
+
font-family="monospace"
|
|
174
|
+
fill="red"
|
|
175
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:32px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#666666;stroke-width:1.32646"
|
|
176
|
+
transform="scale(0.82428216,1.2131768)"><desc
|
|
177
|
+
id="desc2">source=resources,resource=tides,path=extremes.[0].time,format=day_mon</desc>27 Jun</text>
|
|
178
|
+
<text
|
|
179
|
+
id="extremes.0.time-8-5"
|
|
180
|
+
x="378.11157"
|
|
181
|
+
y="125.12273"
|
|
182
|
+
font-size="14"
|
|
183
|
+
font-family="monospace"
|
|
184
|
+
fill="red"
|
|
185
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:32px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#666666;stroke-width:1.32646"
|
|
186
|
+
transform="scale(0.82428216,1.2131768)"><desc
|
|
187
|
+
id="desc7">source=resources,resource=tides,path=extremes.[1].time,format=day_mon</desc>27 Jun</text>
|
|
188
|
+
<text
|
|
189
|
+
id="extremes.0.time-8-9"
|
|
190
|
+
x="378.11157"
|
|
191
|
+
y="174.46791"
|
|
192
|
+
font-size="14"
|
|
193
|
+
font-family="monospace"
|
|
194
|
+
fill="red"
|
|
195
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:32px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#666666;stroke-width:1.32646"
|
|
196
|
+
transform="scale(0.82428216,1.2131768)"><desc
|
|
197
|
+
id="desc9">source=resources,resource=tides,path=extremes.[2].time,format=day_mon</desc>27 Jun</text>
|
|
198
|
+
<text
|
|
199
|
+
id="extremes.0.level"
|
|
200
|
+
x="173.7571"
|
|
201
|
+
y="88.829666"
|
|
202
|
+
font-size="14"
|
|
203
|
+
font-family="monospace"
|
|
204
|
+
fill="red"
|
|
205
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:32px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#000000;stroke-width:1.38653"
|
|
206
|
+
transform="scale(0.95553786,1.046531)"><tspan
|
|
207
|
+
sodipodi:role="line"
|
|
208
|
+
id="tspan2"
|
|
209
|
+
x="173.7571"
|
|
210
|
+
y="88.829666"
|
|
211
|
+
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:32px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;stroke-width:1.38653">3.4m</tspan><desc
|
|
212
|
+
id="desc4">source=resources,resource=tides,path=extremes.[0].level,category=depth</desc></text>
|
|
213
|
+
</svg>
|