@rhizomatics/signalk-einklabel-plugin 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # 0.8.1
2
+
3
+ - A preferred `provider` can now be set for `resources`.
4
+ - The example `tide.svg` sets this to `provider=tides` to ignore other providers, (such as `mareas-ihm`)
5
+ - Devices will repaint at startup even if no schedule slot has been missed prior to startup, if the template itself has subsequently changed
6
+
7
+ # 0.8.0
8
+
9
+ - New `settle` time, configurable and defaulting to 120 seconds, to wait after plugin startup before attempting to access paths
10
+ - First few minutes of a SignalK server startup can be a mess of plugins missing their dependencies and logging errors.
11
+ - Schedule correctly picks up on previous schedule at startup, only repainting if the last scheduled time was overdue
12
+ - Path based subscriptions ignore temporarily missing paths, so displays don't waste their tiny batteries on flapping values
13
+ - Documentation now has a FAQ plus lots of other ESL/eInk hacking links.
14
+
1
15
  # 0.7.1
2
16
 
3
17
  - Fix path value retrieved for Image Fields when running in live plugin
package/README.md CHANGED
@@ -7,17 +7,17 @@
7
7
  [![code style: oxfmt](https://img.shields.io/badge/code_style-oxfmt-blue.svg)](https://github.com)
8
8
  [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://github.com/rhizomatics/signalk-einklabel-plugin/blob/main/LICENSE)
9
9
 
10
- ** Fully working but limited vendor/product support and requires Linux for device access **
10
+ Fully working but limited vendor/product support and requires Linux for device access.
11
11
 
12
12
  A SignalK plugin to display data from SignalK paths, APIs and plugins on Electronic Shelf Labels (ESL) over a Bluetooth Low Energy (BLE) connection using simple SVG templates.
13
13
 
14
14
  ## What is an ESL?
15
15
 
16
- Electronic Shelf Labels are [eInk](https://en.wikipedia.org/wiki/E_Ink) devices that consume very little battery energy, presuming they are not constantly updated - the battery is used only when the display changes, and a periodic BLE check for incoming changes. Perfect for info that changes only once or twice a day, like tidal information.
16
+ Electronic Shelf Labels are [eInk](https://en.wikipedia.org/wiki/E_Ink) devices that consume very little battery energy, presuming they are not constantly updated - the battery is used only when the display changes (which can take 5-10 seconds) and a periodic BLE check for incoming changes. Perfect for info that changes only once or twice a day, like tidal information.
17
17
 
18
18
  Since they are designed to be used in large quantity in small shops, they are cheap and simple devices. Earlier models required dedicated controllers, or updates over Wifi or NFC, whereas many modern ones are standalone BLE devices that can be updated from a phone or server.
19
19
 
20
- Being battery operated, they can be stuck on anywhere without wiring - the only constraints are bluetooth range, visibility (they need ambient light since the display is more like paper than a traditional lit-up electronic display) and out of the weather since the devices are intended for indoor use.
20
+ Being battery operated, they can be stuck on anywhere without wiring - the only location constraints are bluetooth range, visibility (they need ambient light since the display is more like paper than a traditional lit-up electronic display) and out of the weather since the devices are intended for indoor use.
21
21
 
22
22
  ## Pre-requisites
23
23
 
@@ -48,7 +48,7 @@ Most of these requirements are about making SignalK work with Bluetooth Low Ener
48
48
 
49
49
  5. Correct time zone set on server if local time is to be shown on display
50
50
 
51
- - Use `raspi-config` on a Raspberry Pi, or `timedatectl` on a Linux server
51
+ - See [FAQ](#faq-timezone)
52
52
  - If not set, everything will work, but you may see the wrong zone or not have daylight savings applied
53
53
 
54
54
  Once you have all of that, it may be worth also installing [signalk-victron-ble](https://github.com/stefanor/signalk-victron-ble) or [bt-sensors-plugin](https://github.com/naugehyde/bt-sensors-plugin-sk) to pull in data from other sensors and equipment.
@@ -72,7 +72,7 @@ npm install @rhizomatics/signalk-einklabel-plugin
72
72
 
73
73
  ![Tide Clock](docs/assets/screenshots/example_tidal_clock.png)
74
74
 
75
- The tide clock needs the [signalk-tides](https://github.com/openwatersio/signalk-tides) plugin to be installed and publishing tides to the Resources API. The [tide.svg](https://github.com/rhizomatics/signalk-einklabel-plugin/blob/main/templates/tide.svg) can be customized to run with other APIs or take data only from SignalK data paths.
75
+ The tide clock needs the [signalk-tides](https://github.com/openwatersio/signalk-tides) plugin to be installed and publishing tides to the Resources API. The [tide.svg](https://github.com/rhizomatics/signalk-einklabel-plugin/blob/main/templates/tide.svg) can be customized to run with other APIs or take data only from SignalK data paths. For example, `source=resources,resource=tides,provider=tides,path=extremes[0].time,format=local_time` gets the first tide time, ensures its the preferred `signalk-tides` provider and makes it a simple local time rather than a UTC date time.
76
76
 
77
77
  To show the lunar phase, the `environment.moon.phaseName` path is required, which can
78
78
  be easily achieved by installing and configuring the `derived-data` plugin.
@@ -83,7 +83,7 @@ Use the standard configuration option in the SignalK menu for the plugin.
83
83
 
84
84
  ![Plugin Configuration](docs/assets/screenshots/plugin_config.png)
85
85
 
86
- ## Scanning for Devices
86
+ ### Scanning for Devices
87
87
 
88
88
  Since these are ultra-low power devices, they don't respond instantly to either identify themselves or accept a new image. By default, both scanning and painting have time-outs to wait for a response, which can be altered in the plugin configuration or CLI argument.
89
89
 
@@ -91,14 +91,17 @@ One other quirk is that some devices respond with a different name at different
91
91
 
92
92
  The plugin will optionally re-scan whenever it starts up, although this isn't essential once a label has been configured.
93
93
 
94
- ### Scans from CLI
94
+ ### Scheduling
95
95
 
96
- The command line tools, run from inside the `.signalk` directory, can be used to help troubleshoot
96
+ There are two ways of scheduling scans:
97
97
 
98
- - Scan for longer, in this example 90 seconds
99
- - `npx esl-cli scan -d 90`
100
- - Scan for all BLE devices, whatever they are
101
- - `npx esl-cli scan -a`
98
+ #### Time Based
99
+
100
+ This will schedule at fixed hours of the day, for example 00:00/08:00/16:00 for an 8h schedule, and at a selected minutes past the hour. At plugin startup/restart, the devices will be repainted if they missed their last slot.
101
+
102
+ #### Path Subscription
103
+
104
+ The devices will be painted when the plugin starts, and then every time the selected SignalK path changes.
102
105
 
103
106
  ## Templating
104
107
 
@@ -114,7 +117,9 @@ For example, `path=environment.forecast.description` uses the default data sourc
114
117
 
115
118
  #### SignalK REST APIs
116
119
 
117
- The source can be overridden to use the SignalK server's Resources API instead. For example, `source=resources,resource=tides,path=station.name` picks the `tides` resource and pulls the `station.name` path out of the JSON response - this works for any resource type (`tides`, `waypoints`, `routes`, ...), and needs nothing configured: the plugin reaches the Resources API directly. Where a resource is specified, it will be fetched once for that render, and subsequent fields sourced from the same resource use that cached response.
120
+ The source can be overridden to use the SignalK server's Resources API instead. Change `source` to `resources` and specify which resource with `resource`. If there are multiple providers for the same resource, and they're not equally useful, then either set a default provider in SignalK, or use the `provider` tag to set the name.
121
+
122
+ For example, `source=resources,resource=tides,provider=tides,path=station.name` picks the `tides` resource and pulls the `station.name` path out of the JSON response - this works for any resource type (`tides`, `waypoints`, `routes`, ...), and needs nothing configured: the plugin reaches the Resources API directly. Where a resource is specified, it will be fetched once for that render, and subsequent fields sourced from the same resource use that cached response. `provider` is optional, the default provider will be used if not specified.
118
123
 
119
124
  #### Plugin Data
120
125
 
@@ -147,7 +152,7 @@ Common categories:
147
152
 
148
153
  Additionally, `round=n` can be used to round to limited decimal places.
149
154
 
150
- These can all be combined as in `source=resources,resource=tides,path=extremes[2].level,category=depth,round=2`
155
+ These can all be combined as in `source=resources,resource=tides,provider=tides,path=extremes[2].level,category=depth,round=2`
151
156
 
152
157
  ### Non-Textual Fields (Images)
153
158
 
@@ -171,22 +176,6 @@ Three font types are loaded by default, use the generic font family, or exact fo
171
176
  - `sans-serif` - `Roboto`
172
177
  - `monospace` - `Roboto Mono`
173
178
 
174
- ## Command Line Interface
175
-
176
- To get fast feedback on templates and shelf devices without updating and configuring SignalK, a CLI call `esl-cli` is provided when the module is manually installed that has these commands. Use `--help` to get all the options.
177
-
178
- - `vendors` - list supported vendors
179
- - `scan` - report supported devices found from a BLE scan
180
-
181
- See also the commands useful for debugging under [Developing Templates]
182
-
183
- - `render` - transform an SVG template and data into a PNG
184
- - `paint` - render an SVG template and data to a selected ESL
185
-
186
- The width, height, vertical offset and colour palette for the device is taken from the internal register of devices, however can be overridden on the command line. This could be used to help you choose what size of label to buy, or to get an unsupported label working.
187
-
188
- ( The CLI can also be run from a checked out module, or by opening a terminal shell at `~/.signalk/node_modules/@rhizomatics/signalk-einklabel-plugin`, as `npx esl-cli command --args` )
189
-
190
179
  ## Vendors
191
180
 
192
181
  ### Zhsunyco
@@ -214,6 +203,31 @@ The primary things managed and provided by the plugin are:
214
203
  - Optional: left blank, the plugin probes the probable values in likelihood order at startup - `http://localhost:3000`, `http://localhost`, `https://localhost`. Set it explicitly to skip probing
215
204
  - Either way, errors clearly if nothing responds (wrong port) or the probe is rejected (anonymous read access not enabled) - these endpoints must allow anonymous read access, since the plugin has no login flow
216
205
 
206
+ ## Command Line Interface
207
+
208
+ To get fast feedback on templates and shelf devices without updating and configuring SignalK, a CLI call `esl-cli` is provided when the module is manually installed that has these commands. Use `--help` to get all the options.
209
+
210
+ - `vendors` - list supported vendors
211
+ - `scan` - report supported devices found from a BLE scan
212
+
213
+ See also the commands useful for debugging under [Developing Templates]
214
+
215
+ - `render` - transform an SVG template and data into a PNG
216
+ - `paint` - render an SVG template and data to a selected ESL
217
+
218
+ The width, height, vertical offset and colour palette for the device is taken from the internal register of devices, however can be overridden on the command line. This could be used to help you choose what size of label to buy, or to get an unsupported label working.
219
+
220
+ ( The CLI can also be run from a checked out module, or by opening a terminal shell at `~/.signalk/node_modules/@rhizomatics/signalk-einklabel-plugin`, as `npx esl-cli command --args` )
221
+
222
+ ### Scans from CLI
223
+
224
+ The command line tools, run from inside the `.signalk` directory, can be used to help troubleshoot
225
+
226
+ - Scan for longer, in this example 90 seconds
227
+ - `npx esl-cli scan -d 90`
228
+ - Scan for all BLE devices, whatever they are
229
+ - `npx esl-cli scan -a`
230
+
217
231
  ## Extending
218
232
 
219
233
  ### Hardware
@@ -226,6 +240,8 @@ Additional vendors and devices can be added by a separate npm package that imple
226
240
 
227
241
  ### Developing Templates
228
242
 
243
+ See the [Templating] section for more details.
244
+
229
245
  Templates can be added to the configurable directory. [Inkscape](https://inkscape.org) free, open source, and recommended for editing templates, or your own favourite editor, or by hand in a text editor for hard core (or just tidying up the template side).
230
246
 
231
247
  ![Example Field Definition](docs/assets/screenshots/inkscape_desc.png)
@@ -256,25 +272,25 @@ npx esl-cli fields -t templates/tide.svg -u http://localhost
256
272
  ```
257
273
 
258
274
  ```
259
- id spec value
260
- station.name source=resources,resource=tides,path=station.name Tobermory
261
- source.name. source=resources,resource=tides,path=station.source.name TICON-4
262
- last_repaint source=einklabel,path=repainted,format=local_datetime_short 30 Jun 26 00:08
263
- extremes.0 source=resources,resource=tides,path=extremes[0].label Low
264
- extremes.1 source=resources,resource=tides,path=extremes[1].label High
265
- extremes.2 source=resources,resource=tides,path=extremes[2].label Low
266
- timezoneRegion source=einklabel,path=local_zone BST
267
- lat source=resources,resource=tides,path=station.datums.LAT,category=depth 0.2m
268
- hat source=resources,resource=tides,path=station.datums.HAT,category=depth 5.2m
269
- extremes.2.level source=resources,resource=tides,path=extremes[2].level,category=depth 1.1m
270
- extremes.2.time source=resources,resource=tides,path=extremes[2].time,format=local_time 13:15
271
- extremes.1.level source=resources,resource=tides,path=extremes[1].level,category=depth 3.8m
272
- extremes.1.time source=resources,resource=tides,path=extremes[1].time,format=local_time 07:05
273
- extremes.0.time source=resources,resource=tides,path=extremes[0].time,format=local_time 01:21
274
- extremes.0.time-8 source=resources,resource=tides,path=extremes[0].time,format=day_mon 30 Jun
275
- extremes.0.time-8-5 source=resources,resource=tides,path=extremes[1].time,format=day_mon 30 Jun
276
- extremes.0.time-8-9 source=resources,resource=tides,path=extremes[2].time,format=day_mon 30 Jun
277
- extremes.0.level source=resources,resource=tides,path=extremes[0].level,category=depth 1.3m
275
+ id spec value
276
+ station.name source=resources,resource=tides,provider=tides,path=station.name Tobermory
277
+ source.name. source=resources,resource=tides,provider=tides,path=station.source.name TICON-4
278
+ last_repaint source=einklabel,path=repainted,format=local_datetime_short 30 Jun 26 00:08
279
+ extremes.0 source=resources,resource=tides,provider=tides,path=extremes[0].label Low
280
+ extremes.1 source=resources,resource=tides,provider=tides,path=extremes[1].label High
281
+ extremes.2 source=resources,resource=tides,provider=tides,path=extremes[2].label Low
282
+ timezoneRegion source=einklabel,path=local_zone BST
283
+ lat source=resources,resource=tides,provider=tides,path=station.datums.LAT,category=depth 0.2m
284
+ hat source=resources,resource=tides,provider=tides,path=station.datums.HAT,category=depth 5.2m
285
+ extremes.2.level source=resources,resource=tides,provider=tides,path=extremes[2].level,category=depth 1.1m
286
+ extremes.2.time source=resources,resource=tides,provider=tides,path=extremes[2].time,format=local_time 13:15
287
+ extremes.1.level source=resources,resource=tides,provider=tides,path=extremes[1].level,category=depth 3.8m
288
+ extremes.1.time source=resources,resource=tides,provider=tides,path=extremes[1].time,format=local_time 07:05
289
+ extremes.0.time source=resources,resource=tides,provider=tides,path=extremes[0].time,format=local_time 01:21
290
+ extremes.0.time-8 source=resources,resource=tides,provider=tides,path=extremes[0].time,format=day_mon 30 Jun
291
+ extremes.0.time-8-5 source=resources,resource=tides,provider=tides,path=extremes[1].time,format=day_mon 30 Jun
292
+ extremes.0.time-8-9 source=resources,resource=tides,provider=tides,path=extremes[2].time,format=day_mon 30 Jun
293
+ extremes.0.level source=resources,resource=tides,provider=tides,path=extremes[0].level,category=depth 1.3m
278
294
  ```
279
295
 
280
296
  ### Offline Working
@@ -286,3 +302,38 @@ extremes.0.level source=resources,resource=tides,path=extremes[0].level,
286
302
  - `categories.json` - SignalK unit categories needed for `category=depth` type formatting
287
303
 
288
304
  For example, `npx esl-cli fields -t templates/tide.svg -e examples` will show all the field data that will be populated from the example API, vessel and category data in the `examples` local directory.
305
+
306
+ ## Frequently Asked Questions
307
+
308
+ ### Sometimes values are missing on the display
309
+
310
+ If the plugin repaints a display at server startup, then the plugin that provides the data may not have started ( or in the case of `derived-data` the plugin that the plugin depends on! ) and unlike Home Assistant, there's no good way of sequencing the start of plugins.
311
+
312
+ Use the _settle_ time, to impose a minimum wait between the eInk Label plugin being initialized, and it attempting to paint any displays, and increase this value if its still missing data.
313
+
314
+ ### Times are showing incorrectly {#faq-timezone}
315
+
316
+ If times are in the wrong timezone, or don't have daylight savings applied correctly,
317
+ then check that the server itself (at the Linux level, not SignalK, which doesn't know)is configured for your timezone, assuming of course that you're a coastal sailor. Use `raspi-config` on a Raspberry Pi, or `timedatectl` on a Linux server.
318
+
319
+ If you're a global cruiser, then use something like [signalk-set-gps-timezone](https://github.com/hoeken/signalk-set-gps-timezone) to set the value in the operating system.
320
+
321
+ ### The ESL signal is too weak from my SignalK server
322
+
323
+ Try a BLE proxy device, ESP32 is popular for this.
324
+
325
+ ## Other ESL and General eInk Resources
326
+
327
+ - [Open ePaper Link](https://openepaperlink.de) - Alternative open source firmware to flash onto eInk shelf labels, with Home Assistant integration.
328
+ - [e-ink dashboard for Signal K](https://github.com/meri-imperiumi/dashboard) - Waveshare display based multi instrument display.
329
+ - [eInk Dashboard Modern SK](https://github.com/VladimirKalachikhin/e-inkDashboardModernSK) - SignalK dashboard for non-ESL eInk display.
330
+ - [esp32-esl-system](https://github.com/giobauermeister/esp32-esl-system) - Docker and ESP32 based system for updating ESLs.
331
+ - [hass-gicisky](https://github.com/eigger/hass-gicisky) - Home Assistant integration for Gicisky ESLs ( a similar vendor to Zhsunyco). Uses [imagespec](https://github.com/eigger/imagespec) for templating.
332
+ - [ha-panda](https://github.com/moryoav/ha-panda) - Home Assistant integration for Panda ESLs ( a similar vendor to Zhsunyco).
333
+ - [Dmitry.gr](https://dmitry.gr/?r=05.Projects&proj=29.%20eInk%20Price%20Tags) - Personal site of an ESL hacker
334
+ - [Aaron Christobel](https://www.youtube.com/@atc1441) - YouTube channel of an ESL hacker.
335
+ - [rbaron.net](https://rbaron.net/blog/2022/07/29/Daisy-chaining-multiple-electronic-shelf-labels) - Blog of an early ESL hacker.
336
+ - [Pimoroni](https://shop.pimoroni.com/collections/displays?tags=e-ink%20Displays) - All shapes and sizes of eInk displays, aimed at hackers, and with an [inky](https://github.com/pimoroni/inky) GitHub project to support them.
337
+ - [WaveShare](https://www.waveshare.com/product/displays/e-paper.htm) - Wide range of eInk displays for hardware projects, not limited to ESLs.
338
+
339
+ See also the [Boat Tech Directory](https://boat-tech-directory.rhizomatics.org.uk).
@@ -7,6 +7,7 @@ exports.assembleLiveContext = assembleLiveContext;
7
7
  exports.assembleExampleContext = assembleExampleContext;
8
8
  const promises_1 = require("fs/promises");
9
9
  const path_1 = __importDefault(require("path"));
10
+ const binding_1 = require("../render/binding");
10
11
  const formatters_1 = require("../render/formatters");
11
12
  const unwrapSignalkTree_1 = require("../render/unwrapSignalkTree");
12
13
  const httpJson_1 = require("../httpJson");
@@ -47,11 +48,19 @@ async function assembleLiveContext(signalkUrl, bindings) {
47
48
  }
48
49
  }
49
50
  const resources = {};
50
- const resourceNames = new Set(bindings.filter((binding) => binding.source === "resources").map((binding) => binding.resource));
51
- for (const name of resourceNames) {
52
- const url = `${signalkUrl}${RESOURCES_API_PATH}/${name}`;
51
+ const resourceBindings = new Map();
52
+ for (const binding of bindings) {
53
+ if (binding.source !== "resources")
54
+ continue;
55
+ resourceBindings.set((0, binding_1.resourceContextKey)(binding), binding);
56
+ }
57
+ for (const [key, binding] of resourceBindings) {
58
+ // A `provider=` binding pins which registered provider plugin answers the request (matches
59
+ // `checkForProvider`'s `?provider=` query param in signalk-server's resources REST API) rather
60
+ // than leaving it to whichever provider the server considers the default for that resource type.
61
+ const url = `${signalkUrl}${RESOURCES_API_PATH}/${binding.resource}${binding.provider ? `?provider=${encodeURIComponent(binding.provider)}` : ""}`;
53
62
  (0, log_1.logDebug)(`GET ${url}`);
54
- resources[name] = await (0, httpJson_1.fetchJson)(url);
63
+ resources[key] = await (0, httpJson_1.fetchJson)(url);
55
64
  }
56
65
  const categoryNames = new Set(bindings.filter((binding) => binding.category).map((binding) => binding.category));
57
66
  const categories = await (0, unitCategories_1.fetchCategoryDisplayUnits)(signalkUrl, categoryNames);
@@ -112,11 +121,18 @@ async function assembleExampleContext(examplesDir, bindings) {
112
121
  }
113
122
  }
114
123
  const resources = {};
115
- const resourceNames = new Set(bindings.filter((binding) => binding.source === "resources").map((binding) => binding.resource));
116
- for (const name of resourceNames) {
117
- const resourcePath = path_1.default.join(examplesDir, "resources", `${name}.json`);
124
+ const resourceBindings = new Map();
125
+ for (const binding of bindings) {
126
+ if (binding.source !== "resources")
127
+ continue;
128
+ resourceBindings.set((0, binding_1.resourceContextKey)(binding), binding);
129
+ }
130
+ for (const [key, binding] of resourceBindings) {
131
+ // Example data has no notion of multiple providers for the same resource type - the file is
132
+ // still named after the bare `resource=` type regardless of any `provider=` a binding pins.
133
+ const resourcePath = path_1.default.join(examplesDir, "resources", `${binding.resource}.json`);
118
134
  (0, log_1.logDebug)(`reading ${resourcePath}`);
119
- resources[name] = await readJsonFile(resourcePath);
135
+ resources[key] = await readJsonFile(resourcePath);
120
136
  }
121
137
  const categoryNames = new Set(bindings.filter((binding) => binding.category).map((binding) => binding.category));
122
138
  let categories = {};
package/dist/config.d.ts CHANGED
@@ -36,6 +36,15 @@ export interface PluginConfig {
36
36
  paintConnectTimeoutSeconds: number;
37
37
  /** How many times to attempt a repaint (including the first try) before giving up and reporting failure. */
38
38
  paintRetries: number;
39
+ /**
40
+ * How long after plugin start to hold off on every repaint trigger (startup check, interval, and
41
+ * subscription alike) - the first minute or two of a SignalK server's life is a chaos of plugin
42
+ * dependency sequencing (e.g. `derived-data` may not have published `environment.moon.phaseName`
43
+ * yet), so a repaint attempted immediately at startup can render with missing/wrong data, and hash
44
+ * dedup (see `considerRepaint`) then means it silently stays that way until the underlying data
45
+ * happens to change again. Defaults to 120s - see `startRepaintScheduler`.
46
+ */
47
+ settleSeconds: number;
39
48
  /**
40
49
  * Base URL of this SignalK server, used for: (1) a `signalk`-sourced numeric value's automatic unit
41
50
  * conversion (`GET .../vessels/<context>/meta`, see `../pathMeta.ts`) unless `format=raw`, and (2) an
package/dist/config.js CHANGED
@@ -29,6 +29,7 @@ function defaultConfig() {
29
29
  scanDurationSeconds: 20,
30
30
  paintConnectTimeoutSeconds: 30,
31
31
  paintRetries: 3,
32
+ settleSeconds: 120,
32
33
  devices: [],
33
34
  };
34
35
  }
@@ -153,6 +154,15 @@ function configSchema(app, discovered = []) {
153
154
  minimum: 1,
154
155
  default: defaults.paintRetries,
155
156
  },
157
+ settleSeconds: {
158
+ type: "number",
159
+ title: "Settle time after plugin start (seconds)",
160
+ description: "Holds off every repaint (startup check, interval, and subscription alike) until this long after the plugin starts - " +
161
+ "the first minute or two of SignalK startup often has many errors as plugins start before the paths they depend on are ready, so data a template needs " +
162
+ "(e.g. from the derived-data plugin) may not be published yet.",
163
+ minimum: 0,
164
+ default: defaults.settleSeconds,
165
+ },
156
166
  signalkApiUrl: {
157
167
  type: "string",
158
168
  title: "SignalK API base URL (leave blank to auto-detect)",
@@ -10,6 +10,15 @@ export interface Binding {
10
10
  context: string;
11
11
  /** Required when `source === 'resources'` - the Resources API resource type, e.g. `tides`, `waypoints`. */
12
12
  resource?: string;
13
+ /**
14
+ * Only meaningful when `source === 'resources'` - the plugin id of the Resource Provider to query
15
+ * (SignalK's `listResources(resType, params, providerId)`), e.g. `signalk-tides`. Optional - when
16
+ * omitted, the server picks whichever provider is registered for `resource` (its "default"), which is
17
+ * ambiguous/unpredictable when more than one plugin provides the same resource type (e.g. installing
18
+ * a second tides plugin can silently make it the one used instead of the one the template was authored
19
+ * for). Set this to pin a template to one provider regardless of what else is installed.
20
+ */
21
+ provider?: string;
13
22
  /** For `source === 'einklabel'`, a dotted path into the plugin's own injected `meta` (e.g. `repainted`), rather than into vessel/resource data. */
14
23
  path: string;
15
24
  /** A named formatter (see `./formatters.ts`), or `'raw'` to suppress automatic unit conversion (see `renderBinding`). */
@@ -37,6 +46,14 @@ export interface Binding {
37
46
  * unambiguously signals this shorthand.
38
47
  */
39
48
  export declare function parseBinding(desc: string): Binding;
49
+ /**
50
+ * Key under which a `source=resources` binding's data is stored in `context.resources` (see
51
+ * `assembleRawContext` in repaintScheduler.ts and `cli/liveContext.ts`'s equivalent) - incorporates
52
+ * `provider` so two bindings naming the same `resource` type but different providers (e.g. pinning one
53
+ * template's `tides` binding to `provider=signalk-tides` while another is left to the server's default)
54
+ * don't collide on a single cache entry.
55
+ */
56
+ export declare function resourceContextKey(binding: Pick<Binding, "resource" | "provider">): string;
40
57
  /**
41
58
  * Parses every `<text>` and `<image>` element's `<desc>` binding out of raw SVG source - lets a caller
42
59
  * discover what data a template needs before fetching anything, with no separate config declaring it
@@ -1,13 +1,14 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.parseBinding = parseBinding;
4
+ exports.resourceContextKey = resourceContextKey;
4
5
  exports.findBindings = findBindings;
5
6
  exports.resolveBinding = resolveBinding;
6
7
  exports.renderBinding = renderBinding;
7
8
  const xmldom_1 = require("@xmldom/xmldom");
8
9
  const formatters_1 = require("./formatters");
9
10
  const SOURCES = ["signalk", "resources", "einklabel"];
10
- const KNOWN_KEYS = new Set(["source", "context", "resource", "path", "format", "category", "round", "assets"]);
11
+ const KNOWN_KEYS = new Set(["source", "context", "resource", "provider", "path", "format", "category", "round", "assets"]);
11
12
  /**
12
13
  * Parses a `<desc>` element's text content into a `Binding`, e.g.
13
14
  * `source=resources,resource=tides,path=extremes[0].level,category=depth,round=2` or, using the
@@ -45,6 +46,9 @@ function parseBinding(desc) {
45
46
  if (source === "resources" && !fields.resource) {
46
47
  throw new Error(`invalid binding "${desc}" - source=resources requires a "resource" key`);
47
48
  }
49
+ if (fields.provider && source !== "resources") {
50
+ throw new Error(`invalid binding "${desc}" - "provider" is only valid with source=resources`);
51
+ }
48
52
  if (!fields.path) {
49
53
  throw new Error(`invalid binding "${desc}" - missing required "path" key`);
50
54
  }
@@ -52,6 +56,7 @@ function parseBinding(desc) {
52
56
  source,
53
57
  context,
54
58
  resource: fields.resource,
59
+ provider: fields.provider,
55
60
  path: fields.path,
56
61
  format: fields.format,
57
62
  category: fields.category,
@@ -59,6 +64,16 @@ function parseBinding(desc) {
59
64
  assets: fields.assets,
60
65
  };
61
66
  }
67
+ /**
68
+ * Key under which a `source=resources` binding's data is stored in `context.resources` (see
69
+ * `assembleRawContext` in repaintScheduler.ts and `cli/liveContext.ts`'s equivalent) - incorporates
70
+ * `provider` so two bindings naming the same `resource` type but different providers (e.g. pinning one
71
+ * template's `tides` binding to `provider=signalk-tides` while another is left to the server's default)
72
+ * don't collide on a single cache entry.
73
+ */
74
+ function resourceContextKey(binding) {
75
+ return binding.provider ? `${binding.resource}@${binding.provider}` : binding.resource;
76
+ }
62
77
  /**
63
78
  * Parses every `<text>` and `<image>` element's `<desc>` binding out of raw SVG source - lets a caller
64
79
  * discover what data a template needs before fetching anything, with no separate config declaring it
@@ -112,9 +127,10 @@ function resolveBinding(binding, context) {
112
127
  return getAtPath(meta, binding.path);
113
128
  }
114
129
  const resources = context.resources;
115
- const resource = resources?.[binding.resource];
130
+ const resourceKey = resourceContextKey(binding);
131
+ const resource = resources?.[resourceKey];
116
132
  if (resource === undefined) {
117
- throw new Error(`binding references resource "${binding.resource}" which is not present in the render context`);
133
+ throw new Error(`binding references resource "${resourceKey}" which is not present in the render context`);
118
134
  }
119
135
  return getAtPath(resource, binding.path);
120
136
  }
@@ -135,8 +135,8 @@ class SvgRenderer {
135
135
  const dirProblem = (0, assets_1.describeAssetsDirProblem)(templatesDir, bundledTemplatesDir, binding.assets);
136
136
  console.error(key
137
137
  ? `${pluginVersion_1.PLUGIN_NAME}: image "${descElement.textContent}" has no asset file for value "${key}" in "${binding.assets}"`
138
- : `${pluginVersion_1.PLUGIN_NAME}: image "${descElement.textContent}" resolved to no usable value to pick an asset file with`);
139
- console.error(`${pluginVersion_1.PLUGIN_NAME}: ${dirProblem ?? `assets directory for "${binding.assets}" checked out fine - the miss is just this value's`}`);
138
+ : `${pluginVersion_1.PLUGIN_NAME}: image "${descElement.textContent}" resolved to empty value to select asset`);
139
+ console.error(`${pluginVersion_1.PLUGIN_NAME}: ${dirProblem ?? `assets directory for "${binding.assets}" is present`}`);
140
140
  element.parentNode?.removeChild(element);
141
141
  continue;
142
142
  }
@@ -3,4 +3,14 @@ import { PluginConfig } from "./config";
3
3
  export interface RepaintScheduler {
4
4
  stop(): void;
5
5
  }
6
+ /**
7
+ * The most recent wall-clock instant at or before `now` that an `interval`-triggered device's own
8
+ * periodic check (below, in `startRepaintScheduler`) would fire at - every `intervalHours` hours,
9
+ * at `intervalMinute` minutes past the hour (e.g. hours=8,minute=0 -> 00:00, 08:00, 16:00 each day).
10
+ * Used only by the deferred startup catch-up check to tell an interval device that's already been
11
+ * painted for its current slot (skip - the regular periodic check will catch the *next* slot in due
12
+ * course) from one that's overdue (e.g. the plugin was down across a scheduled slot - paint now
13
+ * rather than waiting up to `intervalHours` for the next one).
14
+ */
15
+ export declare function mostRecentScheduledSlot(now: Date, intervalHours: number, intervalMinute: number): Date;
6
16
  export declare function startRepaintScheduler(app: ServerAPI, config: PluginConfig): RepaintScheduler;
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.mostRecentScheduledSlot = mostRecentScheduledSlot;
3
4
  exports.startRepaintScheduler = startRepaintScheduler;
4
5
  const crypto_1 = require("crypto");
5
6
  const path_1 = require("path");
@@ -17,6 +18,23 @@ const resolveApiUrl_1 = require("./resolveApiUrl");
17
18
  const pluginVersion_1 = require("./pluginVersion");
18
19
  const INTERVAL_POLL_MS = 60000;
19
20
  const SUBSCRIPTION_DEBOUNCE_MS = 2000;
21
+ /**
22
+ * The most recent wall-clock instant at or before `now` that an `interval`-triggered device's own
23
+ * periodic check (below, in `startRepaintScheduler`) would fire at - every `intervalHours` hours,
24
+ * at `intervalMinute` minutes past the hour (e.g. hours=8,minute=0 -> 00:00, 08:00, 16:00 each day).
25
+ * Used only by the deferred startup catch-up check to tell an interval device that's already been
26
+ * painted for its current slot (skip - the regular periodic check will catch the *next* slot in due
27
+ * course) from one that's overdue (e.g. the plugin was down across a scheduled slot - paint now
28
+ * rather than waiting up to `intervalHours` for the next one).
29
+ */
30
+ function mostRecentScheduledSlot(now, intervalHours, intervalMinute) {
31
+ const slot = new Date(now);
32
+ slot.setHours(Math.floor(now.getHours() / intervalHours) * intervalHours, intervalMinute, 0, 0);
33
+ if (slot.getTime() > now.getTime()) {
34
+ slot.setHours(slot.getHours() - intervalHours);
35
+ }
36
+ return slot;
37
+ }
20
38
  function statePath(app) {
21
39
  return (0, path_1.join)(app.getDataDirPath(), "repaint-state.json");
22
40
  }
@@ -42,8 +60,9 @@ function stableStringify(value) {
42
60
  }
43
61
  return JSON.stringify(value);
44
62
  }
45
- function hashContext(context) {
46
- return (0, crypto_1.createHash)("sha1").update(stableStringify(context)).digest("hex");
63
+ /** Hashes both the live data context and the template's mtime, so editing a template (even with no data change) triggers a repaint. */
64
+ function hashContext(context, templateMtimeMs) {
65
+ return (0, crypto_1.createHash)("sha1").update(stableStringify(context)).update(String(templateMtimeMs)).digest("hex");
47
66
  }
48
67
  /** Merges `value` into `target` at the nested location described by a dotted SignalK path. */
49
68
  function setAtPath(target, path, value) {
@@ -104,13 +123,20 @@ async function assembleRawContext(app, apiUrl, bindings) {
104
123
  }
105
124
  }
106
125
  const resources = {};
107
- const resourceNames = new Set(bindings.filter((binding) => binding.source === "resources").map((binding) => binding.resource));
108
- for (const name of resourceNames) {
126
+ const resourceBindings = new Map();
127
+ for (const binding of bindings) {
128
+ if (binding.source !== "resources")
129
+ continue;
130
+ resourceBindings.set((0, binding_1.resourceContextKey)(binding), binding);
131
+ }
132
+ for (const [key, binding] of resourceBindings) {
109
133
  // `listResources`'s type only allows the standard SignalKResourceType union, but the underlying
110
134
  // Resources API (and a custom provider like signalk-tides, registered under the non-standard
111
135
  // "tides" type) accepts any registered resource type string - this cast matches `getResource`'s
112
- // wider, accurate signature.
113
- resources[name] = await app.resourcesApi.listResources(name, {});
136
+ // wider, accurate signature. An explicit `provider=` pins which registered provider plugin
137
+ // answers the request (SignalK's `listResources(resType, params, providerId)`) rather than
138
+ // leaving it to whichever provider the server considers the default for that resource type.
139
+ resources[key] = await app.resourcesApi.listResources(binding.resource, {}, binding.provider);
114
140
  }
115
141
  const categoryNames = new Set(bindings.filter((binding) => binding.category).map((binding) => binding.category));
116
142
  if (categoryNames.size > 0 && !apiUrl) {
@@ -137,13 +163,14 @@ async function considerRepaint(app, config, device, state, getApiUrl) {
137
163
  }
138
164
  const templatesDir = (0, config_1.resolveTemplatesDir)(config.templatesDir);
139
165
  const templatePath = (0, config_1.resolveTemplatePath)(templatesDir, device.templateName);
166
+ const templateMtimeMs = (0, fs_1.statSync)(templatePath).mtimeMs;
140
167
  const bindings = (0, binding_1.findBindings)((0, fs_1.readFileSync)(templatePath, "utf-8"));
141
168
  const apiUrl = await getApiUrl().catch((err) => {
142
169
  app.debug(`"${device.friendlyName}": ${err.message}`);
143
170
  return undefined;
144
171
  });
145
172
  const rawContext = await assembleRawContext(app, apiUrl, bindings);
146
- const hash = hashContext(rawContext);
173
+ const hash = hashContext(rawContext, templateMtimeMs);
147
174
  if (state[device.friendlyName]?.hash === hash && !device.forceRepaint) {
148
175
  app.debug(`"${device.friendlyName}": data unchanged, skipping repaint`);
149
176
  return;
@@ -168,7 +195,7 @@ async function considerRepaint(app, config, device, state, getApiUrl) {
168
195
  await driver.paint(bitmap, { address: model.address, aesKey: device.aesKey, connectTimeoutMs });
169
196
  paintDurationMs = Date.now() - startedAt;
170
197
  });
171
- state[device.friendlyName] = { hash };
198
+ state[device.friendlyName] = { hash, repaintedAt: Date.now() };
172
199
  saveState(app, state);
173
200
  if (device.forceRepaint) {
174
201
  clearForceRepaint(app, device.friendlyName);
@@ -179,7 +206,21 @@ function startRepaintScheduler(app, config) {
179
206
  const state = loadState(app);
180
207
  const unsubscribes = [];
181
208
  const getApiUrl = (0, resolveApiUrl_1.createApiUrlResolver)(config.signalkApiUrl);
182
- const repaint = (device) => considerRepaint(app, config, device, state, getApiUrl).catch((err) => app.debug(`"${device.friendlyName}": repaint failed: ${err.message}`));
209
+ // The first minute or two of a SignalK server's life is a chaos of plugin dependency sequencing -
210
+ // a repaint attempted before other plugins (e.g. derived-data) have published the data a template
211
+ // needs renders with missing/wrong values, and hash dedup (see `considerRepaint`) then means it
212
+ // silently stays that way until the underlying data happens to change again. So every trigger
213
+ // (startup check, interval, subscription alike) funnels through this one gate.
214
+ const startedAt = Date.now();
215
+ const settleMs = (config.settleSeconds ?? 120) * 1000;
216
+ const repaint = (device) => {
217
+ const elapsedMs = Date.now() - startedAt;
218
+ if (elapsedMs < settleMs) {
219
+ app.debug(`"${device.friendlyName}": still settling (${Math.round(elapsedMs / 1000)}s/${Math.round(settleMs / 1000)}s) - skipping repaint`);
220
+ return Promise.resolve();
221
+ }
222
+ return considerRepaint(app, config, device, state, getApiUrl).catch((err) => app.debug(`"${device.friendlyName}": repaint failed: ${err.message}`));
223
+ };
183
224
  const intervalDevices = config.devices.filter((device) => device.repaintTrigger === "interval");
184
225
  if (intervalDevices.length > 0) {
185
226
  const timer = setInterval(() => {
@@ -197,15 +238,43 @@ function startRepaintScheduler(app, config) {
197
238
  for (const device of config.devices) {
198
239
  if (device.repaintTrigger === "subscription" && device.triggerPath) {
199
240
  const stream = app.streambundle.getSelfStream(device.triggerPath).debounce(SUBSCRIPTION_DEBOUNCE_MS);
200
- const unsub = stream.onValue(() => repaint(device));
241
+ // A path can report a transient `null`/missing value (e.g. a sensor briefly drops out) then go
242
+ // right back to what it was before - e.g. 1111, then missing, then 1111 again. Ignoring the
243
+ // missing emission entirely (rather than repainting for it) means it's never actually painted,
244
+ // so when the value comes back the same as before, `considerRepaint`'s hash dedup sees nothing
245
+ // changed since the last real paint and skips too - net effect: no repaint at all for this
246
+ // blip, instead of one flickering the display blank/wrong and a second restoring it.
247
+ const unsub = stream.onValue((value) => {
248
+ if (value !== null && value !== undefined)
249
+ void repaint(device);
250
+ });
201
251
  unsubscribes.push(unsub);
202
252
  }
203
253
  }
204
- // Check every device once at startup - harmless given hash dedup, and covers newly-added
205
- // devices or a forceRepaint left set from before a restart.
206
- for (const device of config.devices) {
207
- void repaint(device);
208
- }
254
+ // Check every device once, after the settle window. Deferred (rather than run immediately) so a
255
+ // subscription-triggered device whose path is slow-changing (e.g. tide/moon data that might not
256
+ // change again for hours) still gets its first real paint promptly once settled, instead of
257
+ // waiting on that path's next unrelated update - it's then left alone until the path changes
258
+ // again, same as always. An interval-triggered device, though, only gets this catch-up paint if
259
+ // it's actually overdue - already painted for the current scheduled slot (e.g. plugin restarted
260
+ // mid-way through its interval) just waits for the regular periodic check above to hit the *next*
261
+ // slot, rather than jumping the gun with an extra unscheduled paint every time the plugin restarts.
262
+ const startupCheckTimer = setTimeout(() => {
263
+ for (const device of config.devices) {
264
+ if (device.repaintTrigger === "interval" && !device.forceRepaint) {
265
+ const hours = device.intervalHours ?? 1;
266
+ const minute = device.intervalMinute ?? 0;
267
+ const dueSlot = mostRecentScheduledSlot(new Date(), hours, minute);
268
+ const repaintedAt = state[device.friendlyName]?.repaintedAt;
269
+ if (repaintedAt !== undefined && repaintedAt >= dueSlot.getTime()) {
270
+ app.debug(`"${device.friendlyName}": already painted for the current ${hours}h schedule slot - skipping startup catch-up`);
271
+ continue;
272
+ }
273
+ }
274
+ void repaint(device);
275
+ }
276
+ }, settleMs);
277
+ unsubscribes.push(() => clearTimeout(startupCheckTimer));
209
278
  return {
210
279
  stop() {
211
280
  for (const unsubscribe of unsubscribes)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhizomatics/signalk-einklabel-plugin",
3
- "version": "0.7.1",
3
+ "version": "0.8.1",
4
4
  "description": "Display SignalK data on eInk Electronic Shelf Labels",
5
5
  "keywords": [
6
6
  "ble",
@@ -69,7 +69,7 @@
69
69
  fill="yellow"
70
70
  style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:32px;font-family:serif;-inkscape-font-specification:'Roboto Serif 20pt';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"
71
71
  transform="scale(0.89274878,1.1201359)">Tidal Station Name<desc
72
- id="desc1">source=resources,resource=tides,path=station.name</desc></text><text
72
+ id="desc1">source=resources,resource=tides,provider=tides,path=station.name</desc></text><text
73
73
  id="source"
74
74
  x="367.44577"
75
75
  y="226.1548"
@@ -78,7 +78,7 @@
78
78
  fill="black"
79
79
  style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:13.3333px;font-family:sans-serif;-inkscape-font-specification:'\'Roboto\', sans-serif';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ff0000;stroke-width:3.80045"
80
80
  transform="scale(0.95907862,1.0426674)"><desc
81
- id="desc21">source=resources,resource=tides,path=station.source.name</desc><tspan
81
+ id="desc21">source=resources,resource=tides,provider=tides,path=station.source.name</desc><tspan
82
82
  sodipodi:role="line"
83
83
  id="tspan20"
84
84
  x="367.44577"
@@ -102,7 +102,7 @@
102
102
  font-family="monospace"
103
103
  fill="black"
104
104
  style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:22.6667px;font-family:sans-serif;-inkscape-font-specification:'Roboto, Normal',sans-serif;font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ff0000"><desc
105
- id="desc13">source=resources,resource=tides,path=extremes[0].label</desc>High or Low</text><text
105
+ id="desc13">source=resources,resource=tides,provider=tides,path=extremes[0].label</desc>High or Low</text><text
106
106
  id="extremes.1"
107
107
  x="7.6000967"
108
108
  y="117.85719"
@@ -110,7 +110,7 @@
110
110
  font-family="monospace"
111
111
  fill="black"
112
112
  style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:22.6667px;font-family:sans-serif;-inkscape-font-specification:'Roboto, Normal',sans-serif;font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ff0000"><desc
113
- id="desc14">source=resources,resource=tides,path=extremes[1].label</desc>High or Low</text><text
113
+ id="desc14">source=resources,resource=tides,provider=tides,path=extremes[1].label</desc>High or Low</text><text
114
114
  id="extremes.2"
115
115
  x="7.2470098"
116
116
  y="176.12112"
@@ -118,7 +118,7 @@
118
118
  font-family="monospace"
119
119
  fill="red"
120
120
  style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:22.6667px;font-family:sans-serif;-inkscape-font-specification:'Roboto, Normal',sans-serif;font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ff0000"><desc
121
- id="desc15">source=resources,resource=tides,path=extremes[2].label</desc>High or Low</text><text
121
+ id="desc15">source=resources,resource=tides,provider=tides,path=extremes[2].label</desc>High or Low</text><text
122
122
  id="timezone"
123
123
  x="330.60489"
124
124
  y="204.67754"
@@ -148,7 +148,7 @@
148
148
  font-family="sans-serif"
149
149
  fill="#ffff00"
150
150
  style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:21.3333px;font-family:sans-serif;-inkscape-font-specification:'\'Roboto, Normal\', sans-serif';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#1a1a1a;stroke:#fefe54;stroke-opacity:1"><desc
151
- id="desc18">source=resources,resource=tides,path=station.datums.LAT,category=depth,round=1</desc><tspan
151
+ id="desc18">source=resources,resource=tides,provider=tides,path=station.datums.LAT,category=depth,round=1</desc><tspan
152
152
  sodipodi:role="line"
153
153
  id="tspan17"
154
154
  x="282.65424"
@@ -160,7 +160,7 @@
160
160
  font-family="sans-serif"
161
161
  fill="#ffff00"
162
162
  style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:21.3333px;font-family:sans-serif;-inkscape-font-specification:'\'Roboto, Normal\', sans-serif';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#1a1a1a;stroke:#fefe54;stroke-opacity:1"><desc
163
- id="desc19">source=resources,resource=tides,path=station.datums.HAT,category=depth,round=1</desc><tspan
163
+ id="desc19">source=resources,resource=tides,provider=tides,path=station.datums.HAT,category=depth,round=1</desc><tspan
164
164
  sodipodi:role="line"
165
165
  id="tspan18"
166
166
  x="360.77148"
@@ -180,7 +180,7 @@
180
180
  x="322.52386"
181
181
  y="158.08005"
182
182
  style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:37.3333px;font-family:monospace;-inkscape-font-specification:'\'Roboto Mono, Bold\', monospace';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;stroke-width:1.78893">3.3m</tspan><desc
183
- id="desc6">source=resources,resource=tides,path=extremes[2].level,category=depth</desc></text><text
183
+ id="desc6">source=resources,resource=tides,provider=tides,path=extremes[2].level,category=depth</desc></text><text
184
184
  id="extremes.2.time"
185
185
  x="203.77321"
186
186
  y="146.1584"
@@ -189,7 +189,7 @@
189
189
  fill="red"
190
190
  style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:37.3333px;font-family:monospace;-inkscape-font-specification:'\'Roboto Mono, Bold\', monospace';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#000000;stroke-width:1.51352"
191
191
  transform="scale(0.70226239,1.4239692)">15:53<desc
192
- id="desc10">source=resources,resource=tides,path=extremes[2].time,format=local_time</desc></text><text
192
+ id="desc10">source=resources,resource=tides,provider=tides,path=extremes[2].time,format=local_time</desc></text><text
193
193
  id="extremes.1.level"
194
194
  x="334.30084"
195
195
  y="109.17664"
@@ -205,7 +205,7 @@
205
205
  x="334.30084"
206
206
  y="109.17664"
207
207
  style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:37.3333px;font-family:monospace;-inkscape-font-specification:'\'Roboto Mono, Bold\', monospace';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;stroke-width:1.701">1.6m</tspan><desc
208
- id="desc5">source=resources,resource=tides,path=extremes[1].level,category=depth</desc></text><text
208
+ id="desc5">source=resources,resource=tides,provider=tides,path=extremes[1].level,category=depth</desc></text><text
209
209
  id="extremes.1.time"
210
210
  x="188.63072"
211
211
  y="108.65867"
@@ -214,7 +214,7 @@
214
214
  fill="red"
215
215
  style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:37.3333px;font-family:monospace;-inkscape-font-specification:'\'Roboto Mono, Bold\', monospace';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#000000;stroke-width:1.48364"
216
216
  transform="scale(0.7345432,1.3613903)">09:36<desc
217
- id="desc8">source=resources,resource=tides,path=extremes[1].time,format=local_time</desc></text><text
217
+ id="desc8">source=resources,resource=tides,provider=tides,path=extremes[1].time,format=local_time</desc></text><text
218
218
  id="extremes.0.time"
219
219
  x="184.40854"
220
220
  y="64.242691"
@@ -223,7 +223,7 @@
223
223
  fill="red"
224
224
  style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:37.3333px;font-family:monospace;-inkscape-font-specification:'Roboto Mono, Bold',monospace;font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#000000;stroke-width:1.55397"
225
225
  transform="scale(0.74654856,1.3394976)">03:27<desc
226
- id="desc3">source=resources,resource=tides,path=extremes[0].time,format=local_time</desc></text><text
226
+ id="desc3">source=resources,resource=tides,provider=tides,path=extremes[0].time,format=local_time</desc></text><text
227
227
  id="extremes.0.date"
228
228
  x="10.326324"
229
229
  y="74.630478"
@@ -232,7 +232,7 @@
232
232
  fill="red"
233
233
  style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:22.6667px;font-family:sans-serif;-inkscape-font-specification:'Roboto, Normal',sans-serif;font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#666666;stroke-width:1.32646"
234
234
  transform="scale(0.82428216,1.2131768)"><desc
235
- id="desc2">source=resources,resource=tides,path=extremes[0].time,format=day_mon</desc>27 Jun</text><text
235
+ id="desc2">source=resources,resource=tides,provider=tides,path=extremes[0].time,format=day_mon</desc>27 Jun</text><text
236
236
  id="extremes.1.date"
237
237
  x="9.9649849"
238
238
  y="123.34451"
@@ -241,7 +241,7 @@
241
241
  fill="red"
242
242
  style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:22.6667px;font-family:sans-serif;-inkscape-font-specification:'Roboto, Normal',sans-serif;font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#666666;stroke-width:1.32646"
243
243
  transform="scale(0.82428216,1.2131768)"><desc
244
- id="desc7">source=resources,resource=tides,path=extremes[1].time,format=day_mon</desc>27 Jun</text><text
244
+ id="desc7">source=resources,resource=tides,provider=tides,path=extremes[1].time,format=day_mon</desc>27 Jun</text><text
245
245
  id="extremes.2.date"
246
246
  x="12.934491"
247
247
  y="156.77794"
@@ -250,7 +250,7 @@
250
250
  fill="red"
251
251
  style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:22.6667px;font-family:sans-serif;-inkscape-font-specification:'Roboto, Normal',sans-serif;font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#666666;stroke-width:1.4652"
252
252
  transform="scale(0.74623156,1.3400666)"><desc
253
- id="desc9">source=resources,resource=tides,path=extremes[2].time,format=day_mon</desc>27 Jun</text><text
253
+ id="desc9">source=resources,resource=tides,provider=tides,path=extremes[2].time,format=day_mon</desc>27 Jun</text><text
254
254
  id="extremes.0.level"
255
255
  x="324.0769"
256
256
  y="64.82357"
@@ -264,7 +264,7 @@
264
264
  x="324.0769"
265
265
  y="64.82357"
266
266
  style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:37.3333px;font-family:monospace;-inkscape-font-specification:'\'Roboto Mono, Bold\', monospace';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;stroke-width:1.71992">3.4m</tspan><desc
267
- id="desc4">source=resources,resource=tides,path=extremes[0].level,category=depth</desc></text><text
267
+ id="desc4">source=resources,resource=tides,provider=tides,path=extremes[0].level,category=depth</desc></text><text
268
268
  xml:space="preserve"
269
269
  style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:21.3333px;font-family:sans-serif;-inkscape-font-specification:'\'Roboto, Normal\', sans-serif';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#1a1a1a;stroke:#fefe54;stroke-width:0.612283;stroke-opacity:1"
270
270
  x="337.254"