@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
@@ -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>