@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 +14 -0
- package/README.md +100 -49
- package/dist/cli/liveContext.js +24 -8
- package/dist/config.d.ts +9 -0
- package/dist/config.js +10 -0
- package/dist/render/binding.d.ts +17 -0
- package/dist/render/binding.js +19 -3
- package/dist/render/svgRenderer.js +2 -2
- package/dist/repaintScheduler.d.ts +10 -0
- package/dist/repaintScheduler.js +84 -15
- package/package.json +1 -1
- package/templates/tide.svg +16 -16
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
|
[](https://github.com)
|
|
8
8
|
[](https://github.com/rhizomatics/signalk-einklabel-plugin/blob/main/LICENSE)
|
|
9
9
|
|
|
10
|
-
|
|
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
|
|
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
|
-
-
|
|
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
|

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

|
|
85
85
|
|
|
86
|
-
|
|
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
|
-
###
|
|
94
|
+
### Scheduling
|
|
95
95
|
|
|
96
|
-
|
|
96
|
+
There are two ways of scheduling scans:
|
|
97
97
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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.
|
|
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
|

|
|
@@ -256,25 +272,25 @@ npx esl-cli fields -t templates/tide.svg -u http://localhost
|
|
|
256
272
|
```
|
|
257
273
|
|
|
258
274
|
```
|
|
259
|
-
id
|
|
260
|
-
station.name
|
|
261
|
-
source.name.
|
|
262
|
-
last_repaint
|
|
263
|
-
extremes.0
|
|
264
|
-
extremes.1
|
|
265
|
-
extremes.2
|
|
266
|
-
timezoneRegion
|
|
267
|
-
lat
|
|
268
|
-
hat
|
|
269
|
-
extremes.2.level
|
|
270
|
-
extremes.2.time
|
|
271
|
-
extremes.1.level
|
|
272
|
-
extremes.1.time
|
|
273
|
-
extremes.0.time
|
|
274
|
-
extremes.0.time-8
|
|
275
|
-
extremes.0.time-8-5
|
|
276
|
-
extremes.0.time-8-9
|
|
277
|
-
extremes.0.level
|
|
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).
|
package/dist/cli/liveContext.js
CHANGED
|
@@ -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
|
|
51
|
-
for (const
|
|
52
|
-
|
|
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[
|
|
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
|
|
116
|
-
for (const
|
|
117
|
-
|
|
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[
|
|
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)",
|
package/dist/render/binding.d.ts
CHANGED
|
@@ -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
|
package/dist/render/binding.js
CHANGED
|
@@ -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
|
|
130
|
+
const resourceKey = resourceContextKey(binding);
|
|
131
|
+
const resource = resources?.[resourceKey];
|
|
116
132
|
if (resource === undefined) {
|
|
117
|
-
throw new Error(`binding references resource "${
|
|
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
|
|
139
|
-
console.error(`${pluginVersion_1.PLUGIN_NAME}: ${dirProblem ?? `assets directory for "${binding.assets}"
|
|
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;
|
package/dist/repaintScheduler.js
CHANGED
|
@@ -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
|
-
|
|
46
|
-
|
|
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
|
|
108
|
-
for (const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
205
|
-
//
|
|
206
|
-
|
|
207
|
-
|
|
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
package/templates/tide.svg
CHANGED
|
@@ -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"
|