@rhizomatics/signalk-einklabel-plugin 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ # 0.8.2
2
+
3
+ - Work around a SignalK bug in Server API where `ResourcesApi.listResources()` merges provider values in undetermined order and ignores specified provider.
4
+
5
+ # 0.8.1
6
+
7
+ - A preferred `provider` can now be set for `resources`.
8
+ - The example `tide.svg` sets this to `provider=tides` to ignore other providers, (such as `mareas-ihm`)
9
+ - Devices will repaint at startup even if no schedule slot has been missed prior to startup, if the template itself has subsequently changed
10
+
1
11
  # 0.8.0
2
12
 
3
13
  - New `settle` time, configurable and defaulting to 120 seconds, to wait after plugin startup before attempting to access paths
@@ -6,7 +16,6 @@
6
16
  - Path based subscriptions ignore temporarily missing paths, so displays don't waste their tiny batteries on flapping values
7
17
  - Documentation now has a FAQ plus lots of other ESL/eInk hacking links.
8
18
 
9
-
10
19
  # 0.7.1
11
20
 
12
21
  - Fix path value retrieved for Image Fields when running in live plugin
package/README.md CHANGED
@@ -13,11 +13,11 @@ A SignalK plugin to display data from SignalK paths, APIs and plugins on Electro
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
 
@@ -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.
@@ -117,7 +117,9 @@ For example, `path=environment.forecast.description` uses the default data sourc
117
117
 
118
118
  #### SignalK REST APIs
119
119
 
120
- 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.
121
123
 
122
124
  #### Plugin Data
123
125
 
@@ -150,7 +152,7 @@ Common categories:
150
152
 
151
153
  Additionally, `round=n` can be used to round to limited decimal places.
152
154
 
153
- 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`
154
156
 
155
157
  ### Non-Textual Fields (Images)
156
158
 
@@ -174,7 +176,6 @@ Three font types are loaded by default, use the generic font family, or exact fo
174
176
  - `sans-serif` - `Roboto`
175
177
  - `monospace` - `Roboto Mono`
176
178
 
177
-
178
179
  ## Vendors
179
180
 
180
181
  ### Zhsunyco
@@ -253,7 +254,6 @@ Inkscape adds its own metadata to images, which can be stripped off by exporting
253
254
 
254
255
  Due to a limitation in the `resvg-wasm` library used to turn SVGs into images, the `font-family` is limited to `serif`,`sans-serif`,`monospace` or the exact name of one of the installed fonts - `Roboto` (sans serif), `Roboto Serif` or `Roboto Mono`. Inkscape has its own fonts, which won't match what's available in the SignalK plugin, so for more precise design, install [Roboto from Google](https://fonts.google.com/specimen/Roboto) via the web page, `brew` on MacOS or similar.
255
256
 
256
-
257
257
  ### Debugging Templates
258
258
 
259
259
  The `esl-cli` can be used to debug and validate templates quickly:
@@ -272,25 +272,25 @@ npx esl-cli fields -t templates/tide.svg -u http://localhost
272
272
  ```
273
273
 
274
274
  ```
275
- id spec value
276
- station.name source=resources,resource=tides,path=station.name Tobermory
277
- source.name. source=resources,resource=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,path=extremes[0].label Low
280
- extremes.1 source=resources,resource=tides,path=extremes[1].label High
281
- extremes.2 source=resources,resource=tides,path=extremes[2].label Low
282
- timezoneRegion source=einklabel,path=local_zone BST
283
- lat source=resources,resource=tides,path=station.datums.LAT,category=depth 0.2m
284
- hat source=resources,resource=tides,path=station.datums.HAT,category=depth 5.2m
285
- extremes.2.level source=resources,resource=tides,path=extremes[2].level,category=depth 1.1m
286
- extremes.2.time source=resources,resource=tides,path=extremes[2].time,format=local_time 13:15
287
- extremes.1.level source=resources,resource=tides,path=extremes[1].level,category=depth 3.8m
288
- extremes.1.time source=resources,resource=tides,path=extremes[1].time,format=local_time 07:05
289
- extremes.0.time source=resources,resource=tides,path=extremes[0].time,format=local_time 01:21
290
- extremes.0.time-8 source=resources,resource=tides,path=extremes[0].time,format=day_mon 30 Jun
291
- extremes.0.time-8-5 source=resources,resource=tides,path=extremes[1].time,format=day_mon 30 Jun
292
- extremes.0.time-8-9 source=resources,resource=tides,path=extremes[2].time,format=day_mon 30 Jun
293
- 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
294
294
  ```
295
295
 
296
296
  ### Offline Working
@@ -309,7 +309,7 @@ For example, `npx esl-cli fields -t templates/tide.svg -e examples` will show al
309
309
 
310
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
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.
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
313
 
314
314
  ### Times are showing incorrectly {#faq-timezone}
315
315
 
@@ -322,6 +322,10 @@ If you're a global cruiser, then use something like [signalk-set-gps-timezone](h
322
322
 
323
323
  Try a BLE proxy device, ESP32 is popular for this.
324
324
 
325
+ ### Can't edit the text contents of SVG template in VSCode
326
+
327
+ If you have an SVG viewer extension, this wll show the image rather than allowing editing of text. To solve, right click on the file in VSCode _Explorer_ view and choose to edit with _Text Editor_.
328
+
325
329
  ## Other ESL and General eInk Resources
326
330
 
327
331
  - [Open ePaper Link](https://openepaperlink.de) - Alternative open source firmware to flash onto eInk shelf labels, with Home Assistant integration.
@@ -329,7 +333,7 @@ Try a BLE proxy device, ESP32 is popular for this.
329
333
  - [eInk Dashboard Modern SK](https://github.com/VladimirKalachikhin/e-inkDashboardModernSK) - SignalK dashboard for non-ESL eInk display.
330
334
  - [esp32-esl-system](https://github.com/giobauermeister/esp32-esl-system) - Docker and ESP32 based system for updating ESLs.
331
335
  - [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).
336
+ - [ha-panda](https://github.com/moryoav/ha-panda) - Home Assistant integration for Panda ESLs ( a similar vendor to Zhsunyco).
333
337
  - [Dmitry.gr](https://dmitry.gr/?r=05.Projects&proj=29.%20eInk%20Price%20Tags) - Personal site of an ESL hacker
334
338
  - [Aaron Christobel](https://www.youtube.com/@atc1441) - YouTube channel of an ESL hacker.
335
339
  - [rbaron.net](https://rbaron.net/blog/2022/07/29/Daisy-chaining-multiple-electronic-shelf-labels) - Blog of an early ESL hacker.
@@ -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.js CHANGED
@@ -158,7 +158,7 @@ function configSchema(app, discovered = []) {
158
158
  type: "number",
159
159
  title: "Settle time after plugin start (seconds)",
160
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 is a chaos of plugin dependency sequencing, so data a template needs " +
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
162
  "(e.g. from the derived-data plugin) may not be published yet.",
163
163
  minimum: 0,
164
164
  default: defaults.settleSeconds,
@@ -150,7 +150,7 @@ async function readDeviceDetails(device, address, advertisedInfo) {
150
150
  // which otherwise stays set forever and silently skips every later scan).
151
151
  return Promise.race([
152
152
  read(),
153
- (0, bleDiscovery_1.sleep)(SCAN_CONNECT_TIMEOUT_MS * 2).then(() => {
153
+ (0, bleDiscovery_1.sleep)(SCAN_CONNECT_TIMEOUT_MS * 3).then(() => {
154
154
  console.error(`${pluginVersion_1.PLUGIN_NAME}: zhsunyco [${address}]: battery/config read timed out after ${SCAN_CONNECT_TIMEOUT_MS * 2}ms`);
155
155
  return fallback;
156
156
  }),
@@ -21,3 +21,9 @@ export declare function resolveAssetPath(templatesDir: string, bundledTemplatesD
21
21
  * directory, is readable) - the miss is then just this value's.
22
22
  */
23
23
  export declare function describeAssetsDirProblem(templatesDir: string, bundledTemplatesDir: string, assetsName: string): string | undefined;
24
+ /**
25
+ * Counts the `.svg` files in whichever `assets/<assetsName>` directory `selectAssetsDir` would read
26
+ * from, for a caller to report alongside a resolved-but-empty-key miss - confirms which directory is in
27
+ * play and how populated it is, which "the directory exists" alone doesn't convey.
28
+ */
29
+ export declare function countAssets(templatesDir: string, bundledTemplatesDir: string, assetsName: string): number;
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.normalizeAssetKey = normalizeAssetKey;
4
4
  exports.resolveAssetPath = resolveAssetPath;
5
5
  exports.describeAssetsDirProblem = describeAssetsDirProblem;
6
+ exports.countAssets = countAssets;
6
7
  const fs_1 = require("fs");
7
8
  const path_1 = require("path");
8
9
  /**
@@ -86,3 +87,16 @@ function describeAssetsDirProblem(templatesDir, bundledTemplatesDir, assetsName)
86
87
  const problem = describeDir(selectAssetsDir(templatesDir, bundledTemplatesDir, assetsName));
87
88
  return problem && `assets directory ${problem}`;
88
89
  }
90
+ /**
91
+ * Counts the `.svg` files in whichever `assets/<assetsName>` directory `selectAssetsDir` would read
92
+ * from, for a caller to report alongside a resolved-but-empty-key miss - confirms which directory is in
93
+ * play and how populated it is, which "the directory exists" alone doesn't convey.
94
+ */
95
+ function countAssets(templatesDir, bundledTemplatesDir, assetsName) {
96
+ try {
97
+ return (0, fs_1.readdirSync)(selectAssetsDir(templatesDir, bundledTemplatesDir, assetsName)).filter((name) => name.endsWith(".svg")).length;
98
+ }
99
+ catch {
100
+ return 0;
101
+ }
102
+ }
@@ -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
  }
@@ -136,7 +136,7 @@ class SvgRenderer {
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
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`}`);
139
+ console.error(`${pluginVersion_1.PLUGIN_NAME}: ${dirProblem ?? `directory "${binding.assets}" found with ${(0, assets_1.countAssets)(templatesDir, bundledTemplatesDir, binding.assets)} assets`}`);
140
140
  element.parentNode?.removeChild(element);
141
141
  continue;
142
142
  }
@@ -16,8 +16,10 @@ const unitCategories_1 = require("./unitCategories");
16
16
  const pathMeta_1 = require("./pathMeta");
17
17
  const resolveApiUrl_1 = require("./resolveApiUrl");
18
18
  const pluginVersion_1 = require("./pluginVersion");
19
+ const httpJson_1 = require("./httpJson");
19
20
  const INTERVAL_POLL_MS = 60000;
20
21
  const SUBSCRIPTION_DEBOUNCE_MS = 2000;
22
+ const RESOURCES_API_PATH = "/signalk/v2/api/resources";
21
23
  /**
22
24
  * The most recent wall-clock instant at or before `now` that an `interval`-triggered device's own
23
25
  * periodic check (below, in `startRepaintScheduler`) would fire at - every `intervalHours` hours,
@@ -60,8 +62,9 @@ function stableStringify(value) {
60
62
  }
61
63
  return JSON.stringify(value);
62
64
  }
63
- function hashContext(context) {
64
- return (0, crypto_1.createHash)("sha1").update(stableStringify(context)).digest("hex");
65
+ /** Hashes both the live data context and the template's mtime, so editing a template (even with no data change) triggers a repaint. */
66
+ function hashContext(context, templateMtimeMs) {
67
+ return (0, crypto_1.createHash)("sha1").update(stableStringify(context)).update(String(templateMtimeMs)).digest("hex");
65
68
  }
66
69
  /** Merges `value` into `target` at the nested location described by a dotted SignalK path. */
67
70
  function setAtPath(target, path, value) {
@@ -84,13 +87,16 @@ function setAtPath(target, path, value) {
84
87
  * CLI's HTTP-fetched equivalent (`assembleLiveContext` in `cli/liveContext.ts`), so a `path=` binding
85
88
  * resolves to the same value either way - without it, `resolveBinding` would hand back the whole
86
89
  * wrapper object instead of e.g. a plain string, breaking any binding that expects one (a `<image>`'s
87
- * `assets=`, a `format=` formatter, etc). `resources`-sourced bindings go through `app.resourcesApi`,
88
- * in-process too, with no such wrapper. Per-path unit-conversion metadata (`pathMeta`, for automatic
89
- * conversion - see `renderBinding`) and an explicit `category=` binding's resolved conversion both
90
- * have no in-process equivalent (confirmed against the signalk-server source - that resolution only
91
- * happens in its REST layer), so both need `apiUrl` - fetching `pathMeta` is best-effort (a
92
- * missing/unreachable server just means no automatic conversion), but a `category=` binding is a
93
- * declared dependency, so a missing `apiUrl` is a hard error there.
90
+ * `assets=`, a `format=` formatter, etc). `resources`-sourced bindings with no `provider=` pin go
91
+ * through `app.resourcesApi` in-process too, with no such wrapper - but a `provider=`-pinned binding
92
+ * goes over HTTP instead (see the resources loop below), since the in-process API doesn't actually
93
+ * honor `providerId` on signalk-server 2.30.0. Per-path unit-conversion metadata (`pathMeta`, for
94
+ * automatic conversion - see `renderBinding`) and an explicit `category=` binding's resolved
95
+ * conversion both have no in-process equivalent (confirmed against the signalk-server source - that
96
+ * resolution only happens in its REST layer), so both need `apiUrl` - fetching `pathMeta` is
97
+ * best-effort (a missing/unreachable server just means no automatic conversion), but a `category=`
98
+ * binding (and now a `provider=`-pinned resource binding) is a declared dependency, so a missing
99
+ * `apiUrl` is a hard error there.
94
100
  */
95
101
  async function assembleRawContext(app, apiUrl, bindings) {
96
102
  var _a;
@@ -122,13 +128,33 @@ async function assembleRawContext(app, apiUrl, bindings) {
122
128
  }
123
129
  }
124
130
  const resources = {};
125
- const resourceNames = new Set(bindings.filter((binding) => binding.source === "resources").map((binding) => binding.resource));
126
- for (const name of resourceNames) {
127
- // `listResources`'s type only allows the standard SignalKResourceType union, but the underlying
128
- // Resources API (and a custom provider like signalk-tides, registered under the non-standard
129
- // "tides" type) accepts any registered resource type string - this cast matches `getResource`'s
130
- // wider, accurate signature.
131
- resources[name] = await app.resourcesApi.listResources(name, {});
131
+ const resourceBindings = new Map();
132
+ for (const binding of bindings) {
133
+ if (binding.source !== "resources")
134
+ continue;
135
+ resourceBindings.set((0, binding_1.resourceContextKey)(binding), binding);
136
+ }
137
+ for (const [key, binding] of resourceBindings) {
138
+ if (binding.provider) {
139
+ // `app.resourcesApi.listResources(resType, params, providerId)` validates providerId against the
140
+ // registered providers but then - confirmed against signalk-server 2.30.0's own source - ignores
141
+ // it and merges every registered provider's results together (its internal `listFromAll`), so a
142
+ // second provider for the same resource type can silently clobber the one a binding pinned. The
143
+ // REST API's own `?provider=` query param doesn't have this bug (it queries only that provider),
144
+ // so a pinned binding goes over HTTP instead of the in-process API to actually get what it asked for.
145
+ if (!apiUrl) {
146
+ throw new Error(`binding pins resource "${binding.resource}" to provider "${binding.provider}" but no SignalK API base URL is configured`);
147
+ }
148
+ resources[key] = await (0, httpJson_1.fetchJson)(`${apiUrl}${RESOURCES_API_PATH}/${binding.resource}?provider=${encodeURIComponent(binding.provider)}`);
149
+ }
150
+ else {
151
+ // `listResources`'s type only allows the standard SignalKResourceType union, but the underlying
152
+ // Resources API (and a custom provider like signalk-tides, registered under the non-standard
153
+ // "tides" type) accepts any registered resource type string - this cast matches `getResource`'s
154
+ // wider, accurate signature. No `provider=` pin here, so the in-process call's default-provider
155
+ // behavior (whichever the server picks) is fine - and cheaper than a redundant HTTP round trip.
156
+ resources[key] = await app.resourcesApi.listResources(binding.resource, {}, undefined);
157
+ }
132
158
  }
133
159
  const categoryNames = new Set(bindings.filter((binding) => binding.category).map((binding) => binding.category));
134
160
  if (categoryNames.size > 0 && !apiUrl) {
@@ -155,13 +181,14 @@ async function considerRepaint(app, config, device, state, getApiUrl) {
155
181
  }
156
182
  const templatesDir = (0, config_1.resolveTemplatesDir)(config.templatesDir);
157
183
  const templatePath = (0, config_1.resolveTemplatePath)(templatesDir, device.templateName);
184
+ const templateMtimeMs = (0, fs_1.statSync)(templatePath).mtimeMs;
158
185
  const bindings = (0, binding_1.findBindings)((0, fs_1.readFileSync)(templatePath, "utf-8"));
159
186
  const apiUrl = await getApiUrl().catch((err) => {
160
187
  app.debug(`"${device.friendlyName}": ${err.message}`);
161
188
  return undefined;
162
189
  });
163
190
  const rawContext = await assembleRawContext(app, apiUrl, bindings);
164
- const hash = hashContext(rawContext);
191
+ const hash = hashContext(rawContext, templateMtimeMs);
165
192
  if (state[device.friendlyName]?.hash === hash && !device.forceRepaint) {
166
193
  app.debug(`"${device.friendlyName}": data unchanged, skipping repaint`);
167
194
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhizomatics/signalk-einklabel-plugin",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Display SignalK data on eInk Electronic Shelf Labels",
5
5
  "keywords": [
6
6
  "ble",
@@ -12,7 +12,10 @@
12
12
  xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
13
13
  xmlns:xlink="http://www.w3.org/1999/xlink"
14
14
  xmlns="http://www.w3.org/2000/svg"
15
- xmlns:svg="http://www.w3.org/2000/svg"><defs
15
+ xmlns:svg="http://www.w3.org/2000/svg"
16
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
17
+ xmlns:cc="http://creativecommons.org/ns#"
18
+ xmlns:dc="http://purl.org/dc/elements/1.1/"><defs
16
19
  id="defs1" /><sodipodi:namedview
17
20
  id="namedview1"
18
21
  pagecolor="#ffffff"
@@ -69,7 +72,7 @@
69
72
  fill="yellow"
70
73
  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
74
  transform="scale(0.89274878,1.1201359)">Tidal Station Name<desc
72
- id="desc1">source=resources,resource=tides,path=station.name</desc></text><text
75
+ id="desc1">source=resources,resource=tides,provider=tides,path=station.name</desc></text><text
73
76
  id="source"
74
77
  x="367.44577"
75
78
  y="226.1548"
@@ -78,7 +81,7 @@
78
81
  fill="black"
79
82
  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
83
  transform="scale(0.95907862,1.0426674)"><desc
81
- id="desc21">source=resources,resource=tides,path=station.source.name</desc><tspan
84
+ id="desc21">source=resources,resource=tides,provider=tides,path=station.source.name</desc><tspan
82
85
  sodipodi:role="line"
83
86
  id="tspan20"
84
87
  x="367.44577"
@@ -102,7 +105,7 @@
102
105
  font-family="monospace"
103
106
  fill="black"
104
107
  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
108
+ id="desc13">source=resources,resource=tides,provider=tides,path=extremes[0].label</desc>High or Low</text><text
106
109
  id="extremes.1"
107
110
  x="7.6000967"
108
111
  y="117.85719"
@@ -110,7 +113,7 @@
110
113
  font-family="monospace"
111
114
  fill="black"
112
115
  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
116
+ id="desc14">source=resources,resource=tides,provider=tides,path=extremes[1].label</desc>High or Low</text><text
114
117
  id="extremes.2"
115
118
  x="7.2470098"
116
119
  y="176.12112"
@@ -118,7 +121,7 @@
118
121
  font-family="monospace"
119
122
  fill="red"
120
123
  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
124
+ id="desc15">source=resources,resource=tides,provider=tides,path=extremes[2].label</desc>High or Low</text><text
122
125
  id="timezone"
123
126
  x="330.60489"
124
127
  y="204.67754"
@@ -148,7 +151,7 @@
148
151
  font-family="sans-serif"
149
152
  fill="#ffff00"
150
153
  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
154
+ id="desc18">source=resources,resource=tides,provider=tides,path=station.datums.LAT,category=depth,round=1</desc><tspan
152
155
  sodipodi:role="line"
153
156
  id="tspan17"
154
157
  x="282.65424"
@@ -160,7 +163,7 @@
160
163
  font-family="sans-serif"
161
164
  fill="#ffff00"
162
165
  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
166
+ id="desc19">source=resources,resource=tides,provider=tides,path=station.datums.HAT,category=depth,round=1</desc><tspan
164
167
  sodipodi:role="line"
165
168
  id="tspan18"
166
169
  x="360.77148"
@@ -180,7 +183,7 @@
180
183
  x="322.52386"
181
184
  y="158.08005"
182
185
  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
186
+ id="desc6">source=resources,resource=tides,provider=tides,path=extremes[2].level,category=depth</desc></text><text
184
187
  id="extremes.2.time"
185
188
  x="203.77321"
186
189
  y="146.1584"
@@ -189,7 +192,7 @@
189
192
  fill="red"
190
193
  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
194
  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
195
+ id="desc10">source=resources,resource=tides,provider=tides,path=extremes[2].time,format=local_time</desc></text><text
193
196
  id="extremes.1.level"
194
197
  x="334.30084"
195
198
  y="109.17664"
@@ -205,7 +208,7 @@
205
208
  x="334.30084"
206
209
  y="109.17664"
207
210
  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
211
+ id="desc5">source=resources,resource=tides,provider=tides,path=extremes[1].level,category=depth</desc></text><text
209
212
  id="extremes.1.time"
210
213
  x="188.63072"
211
214
  y="108.65867"
@@ -214,7 +217,7 @@
214
217
  fill="red"
215
218
  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
219
  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
220
+ id="desc8">source=resources,resource=tides,provider=tides,path=extremes[1].time,format=local_time</desc></text><text
218
221
  id="extremes.0.time"
219
222
  x="184.40854"
220
223
  y="64.242691"
@@ -223,7 +226,7 @@
223
226
  fill="red"
224
227
  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
228
  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
229
+ id="desc3">source=resources,resource=tides,provider=tides,path=extremes[0].time,format=local_time</desc></text><text
227
230
  id="extremes.0.date"
228
231
  x="10.326324"
229
232
  y="74.630478"
@@ -232,7 +235,7 @@
232
235
  fill="red"
233
236
  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
237
  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
238
+ id="desc2">source=resources,resource=tides,provider=tides,path=extremes[0].time,format=day_mon</desc>27 Jun</text><text
236
239
  id="extremes.1.date"
237
240
  x="9.9649849"
238
241
  y="123.34451"
@@ -241,7 +244,7 @@
241
244
  fill="red"
242
245
  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
246
  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
247
+ id="desc7">source=resources,resource=tides,provider=tides,path=extremes[1].time,format=day_mon</desc>27 Jun</text><text
245
248
  id="extremes.2.date"
246
249
  x="12.934491"
247
250
  y="156.77794"
@@ -250,7 +253,7 @@
250
253
  fill="red"
251
254
  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
255
  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
256
+ id="desc9">source=resources,resource=tides,provider=tides,path=extremes[2].time,format=day_mon</desc>27 Jun</text><text
254
257
  id="extremes.0.level"
255
258
  x="324.0769"
256
259
  y="64.82357"
@@ -264,7 +267,7 @@
264
267
  x="324.0769"
265
268
  y="64.82357"
266
269
  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
270
+ id="desc4">source=resources,resource=tides,provider=tides,path=extremes[0].level,category=depth</desc></text><text
268
271
  xml:space="preserve"
269
272
  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
273
  x="337.254"