@rhizomatics/signalk-einklabel-plugin 0.4.0 → 0.4.2

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 (54) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +4 -2
  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 +91 -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 +88 -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
  54. package/templates/tide.svg +10 -10
@@ -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: { repainted: 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
+ }
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhizomatics/signalk-einklabel-plugin",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "SignalK plugin that renders selected SignalK data to eInk Electronic Shelf Labels",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -10,6 +10,7 @@
10
10
  "files": [
11
11
  "dist",
12
12
  "templates",
13
+ "docs",
13
14
  "CHANGELOG.md",
14
15
  "README.md"
15
16
  ],
@@ -17,11 +18,12 @@
17
18
  "build": "tsc",
18
19
  "watch": "tsc --watch",
19
20
  "cli": "ts-node src/cli/index.ts",
20
- "test": "node --require ts-node/register --test \"src/**/*.test.ts\""
21
+ "test": "node --require ts-node/register --test \"src/**/*.test.ts\"",
22
+ "prepublishOnly": "npm run build"
21
23
  },
22
24
  "signalk":{
23
- "displayName":"eInk ESL Instruments",
24
- "appIcon": "/docs/assets/icons/icon_tmp.png",
25
+ "displayName":"eInk Label Instruments",
26
+ "appIcon": "docs/assets/icons/icon_tmp.png",
25
27
  "screenshots": [
26
28
  "docs/assets/screenshots/example_tidal_clock.png"
27
29
  ]
@@ -63,15 +63,15 @@
63
63
  font-size="18"
64
64
  font-family="serif"
65
65
  fill="black"
66
- style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:12px;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:#999999;stroke-width:2.39571"
66
+ style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:12px;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:#ffffff;stroke-width:2.39571"
67
67
  transform="scale(0.83037506,1.2042751)"><tspan
68
68
  sodipodi:role="line"
69
69
  id="tspan1"
70
70
  x="325.49939"
71
71
  y="195.87753"
72
- style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:12px;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:2.39571;fill:#999999">signalk-einklabel-plugin</tspan></text>
72
+ style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:11.99999995px;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:2.39571;fill:#ffffff">signalk-einklabel-plugin</tspan></text>
73
73
  <text
74
- id="station.name-8-2"
74
+ id="last_repaint"
75
75
  x="4.3477793"
76
76
  y="201.9895"
77
77
  font-size="18"
@@ -81,10 +81,7 @@
81
81
  transform="scale(0.85412558,1.170788)"><desc
82
82
  id="desc12">source=einklabel,path=repainted,format=local_datetime_short</desc><tspan
83
83
  sodipodi:role="line"
84
- id="tspan3"
85
- x="4.3477793"
86
- y="201.9895"
87
- style="stroke-width:1.19309;-inkscape-font-specification:'Sans, Normal';font-family:Sans;font-weight:normal;font-style:normal;font-stretch:normal;font-variant:normal;font-size:13.33333347px;font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal">Last updated 27 Jun 26 18:05</tspan></text>
84
+ id="tspan3">27 Jun 26 18:05</tspan></text>
88
85
  <text
89
86
  id="extremes.0"
90
87
  x="7.9661016"
@@ -92,7 +89,8 @@
92
89
  font-size="14"
93
90
  font-family="monospace"
94
91
  fill="black"
95
- 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>
92
+ 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"><desc
93
+ id="desc13">source=resources,resource=tides,path=extremes.[0].label</desc>High Water</text>
96
94
  <text
97
95
  id="extremes.1"
98
96
  x="13.35283"
@@ -100,7 +98,8 @@
100
98
  font-size="14"
101
99
  font-family="monospace"
102
100
  fill="black"
103
- 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>
101
+ 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"><desc
102
+ id="desc14">source=resources,resource=tides,path=extremes.[1].label</desc>Low Water</text>
104
103
  <text
105
104
  id="extremes.2"
106
105
  x="7.9661016"
@@ -108,7 +107,8 @@
108
107
  font-size="14"
109
108
  font-family="monospace"
110
109
  fill="red"
111
- 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>
110
+ 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"><desc
111
+ id="desc15">source=resources,resource=tides,path=extremes.[2].label</desc>High Water</text>
112
112
  <text
113
113
  id="environment.time.timezoneRegion"
114
114
  x="338.47461"