@rhizomatics/signalk-einklabel-plugin 0.8.1 → 0.9.0-beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +29 -5
  3. package/dist/config.d.ts +52 -8
  4. package/dist/config.js +144 -15
  5. package/dist/devices/discoveredDevicesStore.d.ts +32 -0
  6. package/dist/devices/discoveredDevicesStore.js +67 -0
  7. package/dist/devices/discoveryCoordinator.d.ts +19 -0
  8. package/dist/devices/discoveryCoordinator.js +59 -0
  9. package/dist/devices/zhsunyco/encode.js +33 -11
  10. package/dist/devices/zhsunyco/index.js +1 -1
  11. package/dist/plugin.js +23 -62
  12. package/dist/render/assets.d.ts +7 -1
  13. package/dist/render/assets.js +28 -12
  14. package/dist/render/svgRenderer.js +1 -1
  15. package/dist/repaintScheduler.js +132 -40
  16. package/package.json +1 -1
  17. package/templates/{assets → .assets}/lunar_phases/README.md +6 -2
  18. package/templates/.assets/lunar_phases/third_quarter.svg +52 -0
  19. package/templates/tide.svg +18 -10
  20. package/templates/tides/250x128-BWRY.svg +160 -0
  21. package/templates/tides/416x240-BWRY.svg +288 -0
  22. /package/templates/{assets → .assets}/lunar_phases/first_quarter.svg +0 -0
  23. /package/templates/{assets → .assets}/lunar_phases/full_moon.svg +0 -0
  24. /package/templates/{assets/lunar_phases/third_quarter.svg → .assets/lunar_phases/last_quarter.svg} +0 -0
  25. /package/templates/{assets → .assets}/lunar_phases/new_moon.svg +0 -0
  26. /package/templates/{assets → .assets}/lunar_phases/waning_crescent.svg +0 -0
  27. /package/templates/{assets → .assets}/lunar_phases/waning_gibbous.svg +0 -0
  28. /package/templates/{assets → .assets}/lunar_phases/waxing_crescent.svg +0 -0
  29. /package/templates/{assets → .assets}/lunar_phases/waxing_gibbous.svg +0 -0
@@ -1,15 +1,37 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.encodeBitmap = encodeBitmap;
4
- /** Mirrors the reference driver's `from_pillow` nearest-colour decision tree. */
5
- function nearestColour(r, g, b) {
4
+ const WOLINK_CODE = {
5
+ black: 0 /* WolinkColour.Black */,
6
+ white: 1 /* WolinkColour.White */,
7
+ yellow: 2 /* WolinkColour.Yellow */,
8
+ red: 3 /* WolinkColour.Red */,
9
+ };
10
+ /**
11
+ * Where a classified colour isn't in a device's own palette, the nearest one that's actually
12
+ * supported - e.g. a BWR-only panel (no yellow layer) gets the yellow bucket's pixels sent as red,
13
+ * the closest available warm/accent colour, rather than a wire code the hardware doesn't render.
14
+ */
15
+ const FALLBACK = {
16
+ yellow: "red",
17
+ red: "black",
18
+ };
19
+ /** Mirrors the reference driver's `from_pillow` nearest-colour decision tree, then maps the result down onto whatever colours this particular device's panel actually supports (see `DeviceMetadata.colours`). */
20
+ function nearestColour(r, g, b, supported) {
21
+ let colour = "black";
6
22
  if (r > 150 && g > 150 && b > 150)
7
- return 1 /* WolinkColour.White */;
8
- if (r > 150 && g > 100 && b < 80)
9
- return 2 /* WolinkColour.Yellow */;
10
- if (r > 150 && g < 80 && b < 80)
11
- return 3 /* WolinkColour.Red */;
12
- return 0 /* WolinkColour.Black */;
23
+ colour = "white";
24
+ else if (r > 150 && g > 100 && b < 80)
25
+ colour = "yellow";
26
+ else if (r > 150 && g < 80 && b < 80)
27
+ colour = "red";
28
+ while (!supported.includes(colour)) {
29
+ const fallback = FALLBACK[colour];
30
+ if (!fallback)
31
+ break;
32
+ colour = fallback;
33
+ }
34
+ return WOLINK_CODE[colour];
13
35
  }
14
36
  /**
15
37
  * Quantises a common RGBA bitmap and packs it into the Wolink wire format: 2 bits per
@@ -32,7 +54,7 @@ function encodeBitmap(bitmap, metadata) {
32
54
  const physX = width - 1 - x;
33
55
  for (let y = 0; y < height; y++) {
34
56
  const srcY = y - voffset;
35
- const colour = srcY >= 0 && srcY < bitmap.height ? samplePixel(bitmap, x, srcY) : 0 /* WolinkColour.Black */;
57
+ const colour = srcY >= 0 && srcY < bitmap.height ? samplePixel(bitmap, x, srcY, metadata.colours) : 0 /* WolinkColour.Black */;
36
58
  const physY = height - 1 - y;
37
59
  const byteIdx = physX * bytesPerColumn + Math.floor(physY / 4);
38
60
  const bitShift = 6 - (physY % 4) * 2;
@@ -41,7 +63,7 @@ function encodeBitmap(bitmap, metadata) {
41
63
  }
42
64
  return data;
43
65
  }
44
- function samplePixel(bitmap, x, y) {
66
+ function samplePixel(bitmap, x, y, supported) {
45
67
  const offset = (y * bitmap.width + x) * 4;
46
- return nearestColour(bitmap.data[offset], bitmap.data[offset + 1], bitmap.data[offset + 2]);
68
+ return nearestColour(bitmap.data[offset], bitmap.data[offset + 1], bitmap.data[offset + 2], supported);
47
69
  }
@@ -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
  }),
package/dist/plugin.js CHANGED
@@ -4,69 +4,44 @@ exports.createPlugin = createPlugin;
4
4
  const config_1 = require("./config");
5
5
  const registry_1 = require("./devices/registry");
6
6
  const zhsunyco_1 = require("./devices/zhsunyco");
7
- const bleDiscovery_1 = require("./devices/bleDiscovery");
7
+ const discoveryCoordinator_1 = require("./devices/discoveryCoordinator");
8
+ const discoveredDevicesStore_1 = require("./devices/discoveredDevicesStore");
8
9
  const repaintScheduler_1 = require("./repaintScheduler");
9
10
  /** Mirrors signalk-bluetti-plugin's convention: scan briefly, report finds via plugin status for the user to copy-paste. */
10
- async function runStartupScan(app, discovered, durationSeconds) {
11
- app.setPluginStatus(`Scanning for ESL devices for ${durationSeconds}s...`);
12
- const startedAt = Date.now();
13
- let scanError;
14
- const drivers = (0, registry_1.allDrivers)();
15
- try {
16
- await (0, bleDiscovery_1.withDiscovery)(durationSeconds * 1000, async (adapter) => {
17
- await (0, bleDiscovery_1.forEachAdvertisedDevice)(adapter, async ({ device, address, name, manufacturerId, manufacturerData }) => {
18
- const driver = drivers.find((candidate) => candidate.matchesAdvertisement(name, manufacturerId));
19
- if (!driver) {
20
- return;
21
- }
22
- const found = await driver.identifyDevice(device, address, name, manufacturerId, manufacturerData).catch((err) => {
23
- scanError = `${driver.vendor} scan failed: ${err.message}`;
24
- app.debug(`${scanError}\n${err.stack ?? ""}`);
25
- return undefined;
26
- });
27
- if (!found) {
28
- return;
29
- }
30
- discovered.push(found);
31
- const pid = found.pid !== undefined ? `0x${found.pid.toString(16).padStart(4, "0")}` : "unknown";
32
- const hwid = found.hwVersion ?? "unknown";
33
- app.debug(`discovered ${driver.vendor} device "${found.name ?? ""}" [${found.address}] pid=${pid} hwid=${hwid}`);
34
- });
35
- });
11
+ async function runStartupScan(app, durationSeconds) {
12
+ const alreadyRunning = (0, discoveryCoordinator_1.scanInProgressSince)();
13
+ if (alreadyRunning !== undefined) {
14
+ const elapsedSeconds = ((Date.now() - alreadyRunning) / 1000).toFixed(0);
15
+ app.debug(`a scan from before this restart is still running (${elapsedSeconds}s) - joining it instead of starting a second BLE session`);
16
+ app.setPluginStatus(`Waiting for a scan already running from before this restart (${elapsedSeconds}s so far) to finish updating the "Device" picker below...`);
36
17
  }
37
- catch (err) {
38
- // Without this, an error thrown anywhere in the discovery window (e.g. BlueZ dropping the
39
- // D-Bus connection) skips every line below, leaving the admin UI stuck on "Scanning..."
40
- // forever even though `discovered` may already hold devices found before the failure.
41
- scanError = `scan failed: ${err.message}`;
42
- app.debug(`${scanError}\n${err.stack ?? ""}`);
18
+ else {
19
+ app.setPluginStatus(`Scanning for ESL devices for ${durationSeconds}s...`);
43
20
  }
44
- // Surfaces the real cause in the admin UI (instead of only the debug log) - a scan that
45
- // ends in well under its configured duration is almost always this, not "no devices nearby".
46
- app.setPluginError(scanError ?? "");
21
+ const startedAt = Date.now();
22
+ const { foundThisScan } = await (0, discoveryCoordinator_1.ensureScan)(app, durationSeconds).catch((err) => {
23
+ app.debug(`startup scan failed: ${err.message}`);
24
+ return { foundThisScan: [] };
25
+ });
47
26
  const elapsedSeconds = ((Date.now() - startedAt) / 1000).toFixed(1);
48
- if (discovered.length === 0) {
27
+ if (foundThisScan.length === 0) {
49
28
  app.setPluginStatus(`Scan complete - no ESL devices found nearby after ${elapsedSeconds} seconds.`);
50
29
  return;
51
30
  }
52
- const summary = discovered.map((device) => `${device.name ?? device.vendor} [${device.address}]`).join(", ");
53
- app.setPluginStatus(`Scan complete - found ${discovered.length} device(s) in ${elapsedSeconds}s: ${summary} - pick one from a device's "Device" field below`);
31
+ const summary = foundThisScan.map((device) => `${device.name ?? device.vendor} [${device.address}]`).join(", ");
32
+ app.setPluginStatus(`Scan complete - found ${foundThisScan.length} device(s) in ${elapsedSeconds}s: ${summary} - pick one from a device's "Device" field below`);
54
33
  }
55
34
  function createPlugin(app) {
56
35
  (0, registry_1.registerDriver)(new zhsunyco_1.ZhsunycoDriver());
57
36
  let scheduler;
58
- const lastDiscovered = [];
59
- // node-ble/BlueZ has no scan-cancellation API, so a scan started by a previous start()
60
- // keeps running its full duration even after stop() - tracking it here stops a quick
61
- // disable/re-enable from opening a second concurrent D-Bus/BlueZ session, which was
62
- // making the new scan fail (and report "no devices found") almost immediately.
63
- let scanInProgress;
64
- let scanStartedAt;
65
37
  const plugin = {
66
38
  id: "signalk-einklabel-plugin",
67
39
  name: "eInk ESL (Electronic Shelf Label)",
68
40
  description: "Renders selected SignalK data to BLE eInk Electronic Shelf Labels",
69
- schema: () => (0, config_1.configSchema)(app, lastDiscovered),
41
+ // Read fresh from disk on every call (rather than cached in memory) so it reflects whichever scan
42
+ // last completed - the startup scan below, an on-demand scan for a `device: "ALL"` repaint (see
43
+ // `resolveTargets` in `repaintScheduler.ts`), or `esl-cli scan` - regardless of which one wrote it.
44
+ schema: () => (0, config_1.configSchema)(app, Object.values((0, discoveredDevicesStore_1.loadDiscoveredDevices)(app))),
70
45
  uiSchema: () => (0, config_1.configUiSchema)(),
71
46
  start(config) {
72
47
  const pluginConfig = {
@@ -75,21 +50,7 @@ function createPlugin(app) {
75
50
  };
76
51
  app.debug(`starting with ${pluginConfig.devices.length} configured device(s)`);
77
52
  if (pluginConfig.scanOnStart) {
78
- if (scanInProgress) {
79
- const elapsedSeconds = ((Date.now() - (scanStartedAt ?? Date.now())) / 1000).toFixed(0);
80
- app.debug(`a scan from before this restart is still running (${elapsedSeconds}s) - skipping a new one to avoid a second concurrent BLE session`);
81
- app.setPluginStatus(`Skipped startup scan - a scan from before this restart is still finishing (running ${elapsedSeconds}s) and will update the "Device" picker below once it does.`);
82
- }
83
- else {
84
- lastDiscovered.length = 0;
85
- scanStartedAt = Date.now();
86
- const scan = runStartupScan(app, lastDiscovered, pluginConfig.scanDurationSeconds).catch((err) => app.debug(`startup scan failed: ${err.message}`));
87
- scanInProgress = scan;
88
- void scan.finally(() => {
89
- scanInProgress = undefined;
90
- scanStartedAt = undefined;
91
- });
92
- }
53
+ void runStartupScan(app, pluginConfig.scanDurationSeconds);
93
54
  }
94
55
  scheduler = (0, repaintScheduler_1.startRepaintScheduler)(app, pluginConfig);
95
56
  },
@@ -7,7 +7,7 @@
7
7
  export declare function normalizeAssetKey(value: unknown): string | undefined;
8
8
  /**
9
9
  * Resolves an `assets=` binding to an actual `.svg` file, looked up as `<key>.svg` inside whichever
10
- * `assets/<assetsName>` directory `selectAssetsDir` picks. `undefined` if that directory has no
10
+ * `.assets/<assetsName>` directory `selectAssetsDir` picks. `undefined` if that directory has no
11
11
  * matching file, which callers treat as "no image" rather than an error, since an unmapped value
12
12
  * (e.g. a phase name the asset set doesn't cover) is an expected, not exceptional, case.
13
13
  */
@@ -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
  /**
@@ -22,18 +23,20 @@ function normalizeAssetKey(value) {
22
23
  return normalized || undefined;
23
24
  }
24
25
  /**
25
- * Picks which `assets/<assetsName>` directory an `assets=` binding should read from - the user's own
26
- * `templatesDir/assets/<assetsName>` if it exists as a directory at all, otherwise
27
- * `bundledTemplatesDir/assets/<assetsName>` (see `BUNDLED_TEMPLATES_DIR` in `../config.ts`). This is a
28
- * whole-directory choice, not a per-file merge: a user who provides their own `assets/lunar_phases`
29
- * directory (even a partial one, missing some phases) gets exactly that directory and nothing from the
30
- * bundled set, so it's always clear which files are in play - see the module's "overrides only work at
31
- * directory level" design note. This choice is independent of which template file (bundled or a user
32
- * override) ended up being rendered, so a user can override just a template, just its resources, or
33
- * both, in any combination.
26
+ * Picks which `.assets/<assetsName>` directory an `assets=` binding should read from - the user's own
27
+ * `templatesDir/.assets/<assetsName>` if it exists as a directory at all, otherwise
28
+ * `bundledTemplatesDir/.assets/<assetsName>` (see `BUNDLED_TEMPLATES_DIR` in `../config.ts`). Dot-prefixed
29
+ * so it reads as internal machinery rather than a selectable item - it lives alongside template-family
30
+ * directories like `tides/` (see `pickBestVariant` in `../config.ts`), which are offered in the "Template"
31
+ * dropdown, and `.assets` plainly isn't one of those. This is a whole-directory choice, not a per-file
32
+ * merge: a user who provides their own `.assets/lunar_phases` directory (even a partial one, missing some
33
+ * phases) gets exactly that directory and nothing from the bundled set, so it's always clear which files
34
+ * are in play - see the module's "overrides only work at directory level" design note. This choice is
35
+ * independent of which template file (bundled or a user override) ended up being rendered, so a user can
36
+ * override just a template, just its resources, or both, in any combination.
34
37
  */
35
38
  function selectAssetsDir(templatesDir, bundledTemplatesDir, assetsName) {
36
- const userDir = (0, path_1.join)(templatesDir, "assets", assetsName);
39
+ const userDir = (0, path_1.join)(templatesDir, ".assets", assetsName);
37
40
  try {
38
41
  if ((0, fs_1.statSync)(userDir).isDirectory())
39
42
  return userDir;
@@ -41,11 +44,11 @@ function selectAssetsDir(templatesDir, bundledTemplatesDir, assetsName) {
41
44
  catch {
42
45
  // doesn't exist (or isn't readable) - fall through to the bundled directory
43
46
  }
44
- return (0, path_1.join)(bundledTemplatesDir, "assets", assetsName);
47
+ return (0, path_1.join)(bundledTemplatesDir, ".assets", assetsName);
45
48
  }
46
49
  /**
47
50
  * Resolves an `assets=` binding to an actual `.svg` file, looked up as `<key>.svg` inside whichever
48
- * `assets/<assetsName>` directory `selectAssetsDir` picks. `undefined` if that directory has no
51
+ * `.assets/<assetsName>` directory `selectAssetsDir` picks. `undefined` if that directory has no
49
52
  * matching file, which callers treat as "no image" rather than an error, since an unmapped value
50
53
  * (e.g. a phase name the asset set doesn't cover) is an expected, not exceptional, case.
51
54
  */
@@ -86,3 +89,16 @@ function describeAssetsDirProblem(templatesDir, bundledTemplatesDir, assetsName)
86
89
  const problem = describeDir(selectAssetsDir(templatesDir, bundledTemplatesDir, assetsName));
87
90
  return problem && `assets directory ${problem}`;
88
91
  }
92
+ /**
93
+ * Counts the `.svg` files in whichever `.assets/<assetsName>` directory `selectAssetsDir` would read
94
+ * from, for a caller to report alongside a resolved-but-empty-key miss - confirms which directory is in
95
+ * play and how populated it is, which "the directory exists" alone doesn't convey.
96
+ */
97
+ function countAssets(templatesDir, bundledTemplatesDir, assetsName) {
98
+ try {
99
+ return (0, fs_1.readdirSync)(selectAssetsDir(templatesDir, bundledTemplatesDir, assetsName)).filter((name) => name.endsWith(".svg")).length;
100
+ }
101
+ catch {
102
+ return 0;
103
+ }
104
+ }
@@ -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
  }
@@ -7,6 +7,8 @@ const path_1 = require("path");
7
7
  const fs_1 = require("fs");
8
8
  const config_1 = require("./config");
9
9
  const bleDiscovery_1 = require("./devices/bleDiscovery");
10
+ const discoveredDevicesStore_1 = require("./devices/discoveredDevicesStore");
11
+ const discoveryCoordinator_1 = require("./devices/discoveryCoordinator");
10
12
  const registry_1 = require("./devices/registry");
11
13
  const svgRenderer_1 = require("./render/svgRenderer");
12
14
  const binding_1 = require("./render/binding");
@@ -16,8 +18,10 @@ const unitCategories_1 = require("./unitCategories");
16
18
  const pathMeta_1 = require("./pathMeta");
17
19
  const resolveApiUrl_1 = require("./resolveApiUrl");
18
20
  const pluginVersion_1 = require("./pluginVersion");
21
+ const httpJson_1 = require("./httpJson");
19
22
  const INTERVAL_POLL_MS = 60000;
20
23
  const SUBSCRIPTION_DEBOUNCE_MS = 2000;
24
+ const RESOURCES_API_PATH = "/signalk/v2/api/resources";
21
25
  /**
22
26
  * The most recent wall-clock instant at or before `now` that an `interval`-triggered device's own
23
27
  * periodic check (below, in `startRepaintScheduler`) would fire at - every `intervalHours` hours,
@@ -60,9 +64,13 @@ function stableStringify(value) {
60
64
  }
61
65
  return JSON.stringify(value);
62
66
  }
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");
67
+ /** Hashes the template's mtime, so editing a template (even with no data change) triggers a repaint. */
68
+ function hashTemplate(templateMtimeMs) {
69
+ return (0, crypto_1.createHash)("sha1").update(String(templateMtimeMs)).digest("hex");
70
+ }
71
+ /** Hashes the live data context, so a data change triggers a repaint even with the template untouched. */
72
+ function hashData(context) {
73
+ return (0, crypto_1.createHash)("sha1").update(stableStringify(context)).digest("hex");
66
74
  }
67
75
  /** Merges `value` into `target` at the nested location described by a dotted SignalK path. */
68
76
  function setAtPath(target, path, value) {
@@ -85,13 +93,16 @@ function setAtPath(target, path, value) {
85
93
  * CLI's HTTP-fetched equivalent (`assembleLiveContext` in `cli/liveContext.ts`), so a `path=` binding
86
94
  * resolves to the same value either way - without it, `resolveBinding` would hand back the whole
87
95
  * wrapper object instead of e.g. a plain string, breaking any binding that expects one (a `<image>`'s
88
- * `assets=`, a `format=` formatter, etc). `resources`-sourced bindings go through `app.resourcesApi`,
89
- * in-process too, with no such wrapper. Per-path unit-conversion metadata (`pathMeta`, for automatic
90
- * conversion - see `renderBinding`) and an explicit `category=` binding's resolved conversion both
91
- * have no in-process equivalent (confirmed against the signalk-server source - that resolution only
92
- * happens in its REST layer), so both need `apiUrl` - fetching `pathMeta` is best-effort (a
93
- * missing/unreachable server just means no automatic conversion), but a `category=` binding is a
94
- * declared dependency, so a missing `apiUrl` is a hard error there.
96
+ * `assets=`, a `format=` formatter, etc). `resources`-sourced bindings with no `provider=` pin go
97
+ * through `app.resourcesApi` in-process too, with no such wrapper - but a `provider=`-pinned binding
98
+ * goes over HTTP instead (see the resources loop below), since the in-process API doesn't actually
99
+ * honor `providerId` on signalk-server 2.30.0. Per-path unit-conversion metadata (`pathMeta`, for
100
+ * automatic conversion - see `renderBinding`) and an explicit `category=` binding's resolved
101
+ * conversion both have no in-process equivalent (confirmed against the signalk-server source - that
102
+ * resolution only happens in its REST layer), so both need `apiUrl` - fetching `pathMeta` is
103
+ * best-effort (a missing/unreachable server just means no automatic conversion), but a `category=`
104
+ * binding (and now a `provider=`-pinned resource binding) is a declared dependency, so a missing
105
+ * `apiUrl` is a hard error there.
95
106
  */
96
107
  async function assembleRawContext(app, apiUrl, bindings) {
97
108
  var _a;
@@ -130,13 +141,26 @@ async function assembleRawContext(app, apiUrl, bindings) {
130
141
  resourceBindings.set((0, binding_1.resourceContextKey)(binding), binding);
131
142
  }
132
143
  for (const [key, binding] of resourceBindings) {
133
- // `listResources`'s type only allows the standard SignalKResourceType union, but the underlying
134
- // Resources API (and a custom provider like signalk-tides, registered under the non-standard
135
- // "tides" type) accepts any registered resource type string - this cast matches `getResource`'s
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);
144
+ if (binding.provider) {
145
+ // `app.resourcesApi.listResources(resType, params, providerId)` validates providerId against the
146
+ // registered providers but then - confirmed against signalk-server 2.30.0's own source - ignores
147
+ // it and merges every registered provider's results together (its internal `listFromAll`), so a
148
+ // second provider for the same resource type can silently clobber the one a binding pinned. The
149
+ // REST API's own `?provider=` query param doesn't have this bug (it queries only that provider),
150
+ // so a pinned binding goes over HTTP instead of the in-process API to actually get what it asked for.
151
+ if (!apiUrl) {
152
+ throw new Error(`binding pins resource "${binding.resource}" to provider "${binding.provider}" but no SignalK API base URL is configured`);
153
+ }
154
+ resources[key] = await (0, httpJson_1.fetchJson)(`${apiUrl}${RESOURCES_API_PATH}/${binding.resource}?provider=${encodeURIComponent(binding.provider)}`);
155
+ }
156
+ else {
157
+ // `listResources`'s type only allows the standard SignalKResourceType union, but the underlying
158
+ // Resources API (and a custom provider like signalk-tides, registered under the non-standard
159
+ // "tides" type) accepts any registered resource type string - this cast matches `getResource`'s
160
+ // wider, accurate signature. No `provider=` pin here, so the in-process call's default-provider
161
+ // behavior (whichever the server picks) is fine - and cheaper than a redundant HTTP round trip.
162
+ resources[key] = await app.resourcesApi.listResources(binding.resource, {}, undefined);
163
+ }
140
164
  }
141
165
  const categoryNames = new Set(bindings.filter((binding) => binding.category).map((binding) => binding.category));
142
166
  if (categoryNames.size > 0 && !apiUrl) {
@@ -146,33 +170,77 @@ async function assembleRawContext(app, apiUrl, bindings) {
146
170
  return { signalk, resources, pathMeta, categories };
147
171
  }
148
172
  function clearForceRepaint(app, friendlyName) {
149
- const current = { ...app.readPluginOptions() };
173
+ const current = (0, config_1.readCurrentConfig)(app);
150
174
  const devices = (current.devices ?? []).map((device) => device.friendlyName === friendlyName ? { ...device, forceRepaint: false } : device);
151
175
  app.savePluginOptions({ ...current, devices }, (err) => {
152
176
  if (err)
153
177
  app.debug(`failed to clear forceRepaint for "${friendlyName}": ${err.message}`);
154
178
  });
155
179
  }
156
- async function considerRepaint(app, config, device, state, getApiUrl) {
180
+ /** `RepaintState` key for one physical device under a given `DeviceConfig` entry - see `RepaintState`'s doc comment on why address is part of the key. */
181
+ function stateKeyFor(friendlyName, address) {
182
+ return `${friendlyName}:${address}`;
183
+ }
184
+ function resolveTarget(vendor, pid, hwVersion, address) {
185
+ const driver = (0, registry_1.getDriver)(vendor);
186
+ const metadata = pid !== undefined ? driver?.metadataForPid(pid, hwVersion) : undefined;
187
+ return driver && pid !== undefined && metadata ? { address, vendor, pid, hwVersion, metadata, driver } : undefined;
188
+ }
189
+ /**
190
+ * Resolves a `DeviceConfig` entry to the physical device(s) it should paint. A specific
191
+ * `"<vendor>:<pid>[:<hwVersion>]@<address>"` resolves to exactly that one device (or none, if its
192
+ * driver/model can't be found). `ALL_DEVICES` resolves to every currently-known discovered device
193
+ * (see `discoveredDevicesStore.ts`) - if none are known yet, it triggers an on-demand scan first
194
+ * (bounded by `config.scanDurationSeconds`, same as the startup scan), so a first-time "ALL" setup
195
+ * doesn't require `scanOnStart` or a manual `esl-cli scan`.
196
+ */
197
+ async function resolveTargets(app, config, device) {
198
+ if (device.device === config_1.ALL_DEVICES) {
199
+ let records = (0, discoveredDevicesStore_1.loadDiscoveredDevices)(app);
200
+ if (Object.keys(records).length === 0) {
201
+ app.debug(`"${device.friendlyName}": device is "${config_1.ALL_DEVICES}" and nothing discovered yet - scanning for ${config.scanDurationSeconds}s`);
202
+ records = (await (0, discoveryCoordinator_1.ensureScan)(app, config.scanDurationSeconds)).merged;
203
+ }
204
+ const targets = [];
205
+ for (const record of Object.values(records)) {
206
+ const target = resolveTarget(record.vendor, record.pid, record.hwVersion, record.address);
207
+ if (target)
208
+ targets.push(target);
209
+ }
210
+ return targets;
211
+ }
157
212
  const model = (0, config_1.parseDevice)(device.device);
158
- const driver = model && (0, registry_1.getDriver)(model.vendor);
159
- const metadata = model && driver?.metadataForPid(model.pid, model.hwVersion);
160
- if (!model || !driver || !metadata) {
213
+ const target = model && resolveTarget(model.vendor, model.pid, model.hwVersion, model.address);
214
+ if (!target) {
161
215
  app.debug(`"${device.friendlyName}": no driver/metadata for device "${device.device}", skipping`);
162
- return;
216
+ return [];
163
217
  }
218
+ return [target];
219
+ }
220
+ async function considerRepaint(app, config, device, target, state, getApiUrl) {
221
+ const { address, metadata, driver } = target;
222
+ const label = `"${device.friendlyName}" [${address}]`;
164
223
  const templatesDir = (0, config_1.resolveTemplatesDir)(config.templatesDir);
165
- const templatePath = (0, config_1.resolveTemplatePath)(templatesDir, device.templateName);
224
+ const templatePath = (0, config_1.resolveTemplatePath)(templatesDir, device.templateName, {
225
+ width: metadata.width,
226
+ height: metadata.height,
227
+ colours: metadata.colours,
228
+ });
166
229
  const templateMtimeMs = (0, fs_1.statSync)(templatePath).mtimeMs;
167
230
  const bindings = (0, binding_1.findBindings)((0, fs_1.readFileSync)(templatePath, "utf-8"));
168
231
  const apiUrl = await getApiUrl().catch((err) => {
169
- app.debug(`"${device.friendlyName}": ${err.message}`);
232
+ app.debug(`${label}: ${err.message}`);
170
233
  return undefined;
171
234
  });
172
235
  const rawContext = await assembleRawContext(app, apiUrl, bindings);
173
- const hash = hashContext(rawContext, templateMtimeMs);
174
- if (state[device.friendlyName]?.hash === hash && !device.forceRepaint) {
175
- app.debug(`"${device.friendlyName}": data unchanged, skipping repaint`);
236
+ const templateHash = hashTemplate(templateMtimeMs);
237
+ const dataHash = hashData(rawContext);
238
+ const stateKey = stateKeyFor(device.friendlyName, address);
239
+ const previous = state[stateKey];
240
+ const templateChanged = previous?.templateHash !== templateHash;
241
+ const dataChanged = previous?.dataHash !== dataHash;
242
+ if (!templateChanged && !dataChanged && !device.forceRepaint) {
243
+ app.debug(`${label}: data unchanged, skipping repaint`);
176
244
  return;
177
245
  }
178
246
  const renderContext = {
@@ -189,18 +257,23 @@ async function considerRepaint(app, config, device, state, getApiUrl) {
189
257
  let paintDurationMs = 0;
190
258
  await (0, bleDiscovery_1.withRetries)(config.paintRetries, async (attempt) => {
191
259
  if (attempt > 1) {
192
- app.debug(`"${device.friendlyName}": attempting paint ${attempt}/${config.paintRetries}`);
260
+ app.debug(`${label}: attempting paint ${attempt}/${config.paintRetries}`);
193
261
  }
194
262
  const startedAt = Date.now();
195
- await driver.paint(bitmap, { address: model.address, aesKey: device.aesKey, connectTimeoutMs });
263
+ await driver.paint(bitmap, { address, aesKey: device.aesKey, connectTimeoutMs });
196
264
  paintDurationMs = Date.now() - startedAt;
197
265
  });
198
- state[device.friendlyName] = { hash, repaintedAt: Date.now() };
266
+ (0, discoveredDevicesStore_1.touchDiscoveredDevice)(app, { address, vendor: target.vendor, pid: target.pid, hwVersion: target.hwVersion, metadata });
267
+ state[stateKey] = { templateHash, dataHash, repaintedAt: Date.now() };
199
268
  saveState(app, state);
200
- if (device.forceRepaint) {
201
- clearForceRepaint(app, device.friendlyName);
202
- }
203
- app.debug(`"${device.friendlyName}": repainted (paint took ${paintDurationMs}ms)`);
269
+ const reason = device.forceRepaint
270
+ ? "forced"
271
+ : templateChanged && dataChanged
272
+ ? "template and data changed"
273
+ : templateChanged
274
+ ? "template changed"
275
+ : "data changed";
276
+ app.debug(`${label}: repainted (${reason}, paint took ${paintDurationMs}ms)`);
204
277
  }
205
278
  function startRepaintScheduler(app, config) {
206
279
  const state = loadState(app);
@@ -213,13 +286,28 @@ function startRepaintScheduler(app, config) {
213
286
  // (startup check, interval, subscription alike) funnels through this one gate.
214
287
  const startedAt = Date.now();
215
288
  const settleMs = (config.settleSeconds ?? 120) * 1000;
216
- const repaint = (device) => {
289
+ const repaint = async (device) => {
217
290
  const elapsedMs = Date.now() - startedAt;
218
291
  if (elapsedMs < settleMs) {
219
292
  app.debug(`"${device.friendlyName}": still settling (${Math.round(elapsedMs / 1000)}s/${Math.round(settleMs / 1000)}s) - skipping repaint`);
220
- return Promise.resolve();
293
+ return;
294
+ }
295
+ const targets = await resolveTargets(app, config, device);
296
+ if (targets.length === 0) {
297
+ return;
298
+ }
299
+ const results = await Promise.allSettled(targets.map((target) => considerRepaint(app, config, device, target, state, getApiUrl)));
300
+ results.forEach((result, i) => {
301
+ if (result.status === "rejected") {
302
+ app.debug(`"${device.friendlyName}" [${targets[i].address}]: repaint failed: ${result.reason.message}`);
303
+ }
304
+ });
305
+ // A single `forceRepaint` flag covers every target under `ALL_DEVICES` too - only clear it once
306
+ // every target has actually succeeded, so a target that failed still gets forced again next time
307
+ // instead of quietly reverting to ordinary hash-based dedup.
308
+ if (device.forceRepaint && results.every((result) => result.status === "fulfilled")) {
309
+ clearForceRepaint(app, device.friendlyName);
221
310
  }
222
- return considerRepaint(app, config, device, state, getApiUrl).catch((err) => app.debug(`"${device.friendlyName}": repaint failed: ${err.message}`));
223
311
  };
224
312
  const intervalDevices = config.devices.filter((device) => device.repaintTrigger === "interval");
225
313
  if (intervalDevices.length > 0) {
@@ -259,13 +347,17 @@ function startRepaintScheduler(app, config) {
259
347
  // it's actually overdue - already painted for the current scheduled slot (e.g. plugin restarted
260
348
  // mid-way through its interval) just waits for the regular periodic check above to hit the *next*
261
349
  // slot, rather than jumping the gun with an extra unscheduled paint every time the plugin restarts.
350
+ // `ALL_DEVICES` can't be cheaply pre-checked this way without resolving targets first (which may
351
+ // itself trigger a scan) - it always falls through to `repaint()`, whose own per-target hash dedup
352
+ // still avoids a redundant paint once targets are resolved.
262
353
  const startupCheckTimer = setTimeout(() => {
263
354
  for (const device of config.devices) {
264
- if (device.repaintTrigger === "interval" && !device.forceRepaint) {
355
+ if (device.repaintTrigger === "interval" && !device.forceRepaint && device.device !== config_1.ALL_DEVICES) {
265
356
  const hours = device.intervalHours ?? 1;
266
357
  const minute = device.intervalMinute ?? 0;
267
358
  const dueSlot = mostRecentScheduledSlot(new Date(), hours, minute);
268
- const repaintedAt = state[device.friendlyName]?.repaintedAt;
359
+ const address = (0, config_1.parseDevice)(device.device)?.address;
360
+ const repaintedAt = address !== undefined ? state[stateKeyFor(device.friendlyName, address)]?.repaintedAt : undefined;
269
361
  if (repaintedAt !== undefined && repaintedAt >= dueSlot.getTime()) {
270
362
  app.debug(`"${device.friendlyName}": already painted for the current ${hours}h schedule slot - skipping startup catch-up`);
271
363
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhizomatics/signalk-einklabel-plugin",
3
- "version": "0.8.1",
3
+ "version": "0.9.0-beta1",
4
4
  "description": "Display SignalK data on eInk Electronic Shelf Labels",
5
5
  "keywords": [
6
6
  "ble",
@@ -10,9 +10,13 @@ Embed any of these in a template as a placeholder and set the `description`
10
10
  property to `path=environment.moon.phaseName,assets=lunar_phases`
11
11
 
12
12
  The image will be replaced at rendering time by the one matching the phase name.
13
- This will automatically adapt from `Waxing Gibbous` to `waxing_gibbous.svg`
13
+ This will automatically adapt from 'Waxing Gibbous' to `waxing_gibbous.svg`
14
14
 
15
15
  ## Replacing
16
16
 
17
- Define your own template directory in the plugin configuration, and add an `assets/lunar_phases` directory path beneath it. You will need a complete set of phases in
17
+ Define your own template directory in the plugin configuration, and add an `.assets/lunar_phases` directory path beneath it. You will need a complete set of phases in
18
18
  the new directory, its not possible to override only one of them.
19
+
20
+ ## Quirks
21
+
22
+ 'Third Quarter' is also known as 'Last Quarter' so the image is present under both names.