@rhizomatics/signalk-einklabel-plugin 0.8.2 → 0.9.0-beta2

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.
@@ -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
  }
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 = {
@@ -74,22 +49,9 @@ function createPlugin(app) {
74
49
  ...config,
75
50
  };
76
51
  app.debug(`starting with ${pluginConfig.devices.length} configured device(s)`);
52
+ (0, config_1.healNestedConfig)(app);
77
53
  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
- }
54
+ void runStartupScan(app, pluginConfig.scanDurationSeconds);
93
55
  }
94
56
  scheduler = (0, repaintScheduler_1.startRepaintScheduler)(app, pluginConfig);
95
57
  },
@@ -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
  */
@@ -22,7 +22,7 @@ export declare function resolveAssetPath(templatesDir: string, bundledTemplatesD
22
22
  */
23
23
  export declare function describeAssetsDirProblem(templatesDir: string, bundledTemplatesDir: string, assetsName: string): string | undefined;
24
24
  /**
25
- * Counts the `.svg` files in whichever `assets/<assetsName>` directory `selectAssetsDir` would read
25
+ * Counts the `.svg` files in whichever `.assets/<assetsName>` directory `selectAssetsDir` would read
26
26
  * from, for a caller to report alongside a resolved-but-empty-key miss - confirms which directory is in
27
27
  * play and how populated it is, which "the directory exists" alone doesn't convey.
28
28
  */
@@ -23,18 +23,20 @@ function normalizeAssetKey(value) {
23
23
  return normalized || undefined;
24
24
  }
25
25
  /**
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`). This is a
29
- * whole-directory choice, not a per-file merge: a user who provides their own `assets/lunar_phases`
30
- * directory (even a partial one, missing some phases) gets exactly that directory and nothing from the
31
- * bundled set, so it's always clear which files are in play - see the module's "overrides only work at
32
- * directory level" design note. This choice is independent of which template file (bundled or a user
33
- * override) ended up being rendered, so a user can override just a template, just its resources, or
34
- * 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.
35
37
  */
36
38
  function selectAssetsDir(templatesDir, bundledTemplatesDir, assetsName) {
37
- const userDir = (0, path_1.join)(templatesDir, "assets", assetsName);
39
+ const userDir = (0, path_1.join)(templatesDir, ".assets", assetsName);
38
40
  try {
39
41
  if ((0, fs_1.statSync)(userDir).isDirectory())
40
42
  return userDir;
@@ -42,11 +44,11 @@ function selectAssetsDir(templatesDir, bundledTemplatesDir, assetsName) {
42
44
  catch {
43
45
  // doesn't exist (or isn't readable) - fall through to the bundled directory
44
46
  }
45
- return (0, path_1.join)(bundledTemplatesDir, "assets", assetsName);
47
+ return (0, path_1.join)(bundledTemplatesDir, ".assets", assetsName);
46
48
  }
47
49
  /**
48
50
  * Resolves an `assets=` binding to an actual `.svg` file, looked up as `<key>.svg` inside whichever
49
- * `assets/<assetsName>` directory `selectAssetsDir` picks. `undefined` if that directory has no
51
+ * `.assets/<assetsName>` directory `selectAssetsDir` picks. `undefined` if that directory has no
50
52
  * matching file, which callers treat as "no image" rather than an error, since an unmapped value
51
53
  * (e.g. a phase name the asset set doesn't cover) is an expected, not exceptional, case.
52
54
  */
@@ -88,7 +90,7 @@ function describeAssetsDirProblem(templatesDir, bundledTemplatesDir, assetsName)
88
90
  return problem && `assets directory ${problem}`;
89
91
  }
90
92
  /**
91
- * Counts the `.svg` files in whichever `assets/<assetsName>` directory `selectAssetsDir` would read
93
+ * Counts the `.svg` files in whichever `.assets/<assetsName>` directory `selectAssetsDir` would read
92
94
  * from, for a caller to report alongside a resolved-but-empty-key miss - confirms which directory is in
93
95
  * play and how populated it is, which "the directory exists" alone doesn't convey.
94
96
  */
@@ -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");
@@ -62,9 +64,13 @@ function stableStringify(value) {
62
64
  }
63
65
  return JSON.stringify(value);
64
66
  }
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");
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");
68
74
  }
69
75
  /** Merges `value` into `target` at the nested location described by a dotted SignalK path. */
70
76
  function setAtPath(target, path, value) {
@@ -164,33 +170,88 @@ async function assembleRawContext(app, apiUrl, bindings) {
164
170
  return { signalk, resources, pathMeta, categories };
165
171
  }
166
172
  function clearForceRepaint(app, friendlyName) {
167
- const current = { ...app.readPluginOptions() };
173
+ const current = (0, config_1.readCurrentConfig)(app);
168
174
  const devices = (current.devices ?? []).map((device) => device.friendlyName === friendlyName ? { ...device, forceRepaint: false } : device);
169
175
  app.savePluginOptions({ ...current, devices }, (err) => {
170
176
  if (err)
171
177
  app.debug(`failed to clear forceRepaint for "${friendlyName}": ${err.message}`);
172
178
  });
173
179
  }
174
- 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
+ }
175
212
  const model = (0, config_1.parseDevice)(device.device);
176
- const driver = model && (0, registry_1.getDriver)(model.vendor);
177
- const metadata = model && driver?.metadataForPid(model.pid, model.hwVersion);
178
- if (!model || !driver || !metadata) {
213
+ const target = model && resolveTarget(model.vendor, model.pid, model.hwVersion, model.address);
214
+ if (!target) {
179
215
  app.debug(`"${device.friendlyName}": no driver/metadata for device "${device.device}", skipping`);
180
- return;
216
+ return [];
181
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}]`;
182
223
  const templatesDir = (0, config_1.resolveTemplatesDir)(config.templatesDir);
183
- 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
+ });
184
229
  const templateMtimeMs = (0, fs_1.statSync)(templatePath).mtimeMs;
185
230
  const bindings = (0, binding_1.findBindings)((0, fs_1.readFileSync)(templatePath, "utf-8"));
186
231
  const apiUrl = await getApiUrl().catch((err) => {
187
- app.debug(`"${device.friendlyName}": ${err.message}`);
232
+ app.debug(`${label}: ${err.message}`);
188
233
  return undefined;
189
234
  });
190
235
  const rawContext = await assembleRawContext(app, apiUrl, bindings);
191
- const hash = hashContext(rawContext, templateMtimeMs);
192
- if (state[device.friendlyName]?.hash === hash && !device.forceRepaint) {
193
- app.debug(`"${device.friendlyName}": data unchanged, skipping repaint`);
236
+ const templateHash = hashTemplate(templateMtimeMs);
237
+ // Hashed from `rawContext`, not `renderContext` below - `meta.repainted` (and the rest of `meta`)
238
+ // is only added after this point, deliberately, so a template merely *displaying* the repaint
239
+ // timestamp doesn't perpetually invalidate its own dedup and force a repaint every check. A full
240
+ // paint flashes the whole panel several times, and there's no confirmed partial-refresh path on
241
+ // these devices - so a bound value simply being unchanged is worth trusting over any staleness in
242
+ // a displayed clock.
243
+ const dataHash = hashData(rawContext);
244
+ const stateKey = stateKeyFor(device.friendlyName, address);
245
+ const previous = state[stateKey];
246
+ const templateChanged = previous?.templateHash !== templateHash;
247
+ const dataChanged = previous?.dataHash !== dataHash;
248
+ // The devices are battery-constrained, so a repaint is skipped whenever neither the template nor
249
+ // the bound data has changed since the last successful paint - regardless of what triggered this
250
+ // check (interval or subscription) or whether it's a regular scheduled tick vs. the deferred
251
+ // startup catch-up (see `startupCheckTimer`). `forceRepaint` is the explicit, one-shot override
252
+ // for "repaint anyway".
253
+ if (!templateChanged && !dataChanged && !device.forceRepaint) {
254
+ app.debug(`${label}: data unchanged, skipping repaint`);
194
255
  return;
195
256
  }
196
257
  const renderContext = {
@@ -207,18 +268,23 @@ async function considerRepaint(app, config, device, state, getApiUrl) {
207
268
  let paintDurationMs = 0;
208
269
  await (0, bleDiscovery_1.withRetries)(config.paintRetries, async (attempt) => {
209
270
  if (attempt > 1) {
210
- app.debug(`"${device.friendlyName}": attempting paint ${attempt}/${config.paintRetries}`);
271
+ app.debug(`${label}: attempting paint ${attempt}/${config.paintRetries}`);
211
272
  }
212
273
  const startedAt = Date.now();
213
- await driver.paint(bitmap, { address: model.address, aesKey: device.aesKey, connectTimeoutMs });
274
+ await driver.paint(bitmap, { address, aesKey: device.aesKey, connectTimeoutMs });
214
275
  paintDurationMs = Date.now() - startedAt;
215
276
  });
216
- state[device.friendlyName] = { hash, repaintedAt: Date.now() };
277
+ (0, discoveredDevicesStore_1.touchDiscoveredDevice)(app, { address, vendor: target.vendor, pid: target.pid, hwVersion: target.hwVersion, metadata });
278
+ state[stateKey] = { templateHash, dataHash, repaintedAt: Date.now() };
217
279
  saveState(app, state);
218
- if (device.forceRepaint) {
219
- clearForceRepaint(app, device.friendlyName);
220
- }
221
- app.debug(`"${device.friendlyName}": repainted (paint took ${paintDurationMs}ms)`);
280
+ const reason = device.forceRepaint
281
+ ? "forced"
282
+ : templateChanged && dataChanged
283
+ ? "template and data changed"
284
+ : templateChanged
285
+ ? "template changed"
286
+ : "data changed";
287
+ app.debug(`${label}: repainted (${reason}, paint took ${paintDurationMs}ms)`);
222
288
  }
223
289
  function startRepaintScheduler(app, config) {
224
290
  const state = loadState(app);
@@ -231,13 +297,28 @@ function startRepaintScheduler(app, config) {
231
297
  // (startup check, interval, subscription alike) funnels through this one gate.
232
298
  const startedAt = Date.now();
233
299
  const settleMs = (config.settleSeconds ?? 120) * 1000;
234
- const repaint = (device) => {
300
+ const repaint = async (device) => {
235
301
  const elapsedMs = Date.now() - startedAt;
236
302
  if (elapsedMs < settleMs) {
237
303
  app.debug(`"${device.friendlyName}": still settling (${Math.round(elapsedMs / 1000)}s/${Math.round(settleMs / 1000)}s) - skipping repaint`);
238
- return Promise.resolve();
304
+ return;
305
+ }
306
+ const targets = await resolveTargets(app, config, device);
307
+ if (targets.length === 0) {
308
+ return;
309
+ }
310
+ const results = await Promise.allSettled(targets.map((target) => considerRepaint(app, config, device, target, state, getApiUrl)));
311
+ results.forEach((result, i) => {
312
+ if (result.status === "rejected") {
313
+ app.debug(`"${device.friendlyName}" [${targets[i].address}]: repaint failed: ${result.reason.message}`);
314
+ }
315
+ });
316
+ // A single `forceRepaint` flag covers every target under `ALL_DEVICES` too - only clear it once
317
+ // every target has actually succeeded, so a target that failed still gets forced again next time
318
+ // instead of quietly reverting to ordinary hash-based dedup.
319
+ if (device.forceRepaint && results.every((result) => result.status === "fulfilled")) {
320
+ clearForceRepaint(app, device.friendlyName);
239
321
  }
240
- return considerRepaint(app, config, device, state, getApiUrl).catch((err) => app.debug(`"${device.friendlyName}": repaint failed: ${err.message}`));
241
322
  };
242
323
  const intervalDevices = config.devices.filter((device) => device.repaintTrigger === "interval");
243
324
  if (intervalDevices.length > 0) {
@@ -277,13 +358,17 @@ function startRepaintScheduler(app, config) {
277
358
  // it's actually overdue - already painted for the current scheduled slot (e.g. plugin restarted
278
359
  // mid-way through its interval) just waits for the regular periodic check above to hit the *next*
279
360
  // slot, rather than jumping the gun with an extra unscheduled paint every time the plugin restarts.
361
+ // `ALL_DEVICES` can't be cheaply pre-checked this way without resolving targets first (which may
362
+ // itself trigger a scan) - it always falls through to `repaint()`, whose own per-target hash dedup
363
+ // still avoids a redundant paint once targets are resolved.
280
364
  const startupCheckTimer = setTimeout(() => {
281
365
  for (const device of config.devices) {
282
- if (device.repaintTrigger === "interval" && !device.forceRepaint) {
366
+ if (device.repaintTrigger === "interval" && !device.forceRepaint && device.device !== config_1.ALL_DEVICES) {
283
367
  const hours = device.intervalHours ?? 1;
284
368
  const minute = device.intervalMinute ?? 0;
285
369
  const dueSlot = mostRecentScheduledSlot(new Date(), hours, minute);
286
- const repaintedAt = state[device.friendlyName]?.repaintedAt;
370
+ const address = (0, config_1.parseDevice)(device.device)?.address;
371
+ const repaintedAt = address !== undefined ? state[stateKeyFor(device.friendlyName, address)]?.repaintedAt : undefined;
287
372
  if (repaintedAt !== undefined && repaintedAt >= dueSlot.getTime()) {
288
373
  app.debug(`"${device.friendlyName}": already painted for the current ${hours}h schedule slot - skipping startup catch-up`);
289
374
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhizomatics/signalk-einklabel-plugin",
3
- "version": "0.8.2",
3
+ "version": "0.9.0-beta2",
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.
@@ -0,0 +1,52 @@
1
+ <?xml version="1.0" encoding="UTF-8" standalone="no"?>
2
+ <!-- Created with Inkscape (http://www.inkscape.org/) -->
3
+
4
+ <svg
5
+ version="1.0"
6
+ width="400"
7
+ height="400"
8
+ id="svg2"
9
+ sodipodi:docname="third_quarter.svg"
10
+ inkscape:version="1.3.2 (091e20e, 2023-11-25)"
11
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
12
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
13
+ xmlns="http://www.w3.org/2000/svg"
14
+ xmlns:svg="http://www.w3.org/2000/svg">
15
+ <sodipodi:namedview
16
+ id="namedview1"
17
+ pagecolor="#ffffff"
18
+ bordercolor="#000000"
19
+ borderopacity="0.25"
20
+ inkscape:showpageshadow="2"
21
+ inkscape:pageopacity="0.0"
22
+ inkscape:pagecheckerboard="0"
23
+ inkscape:deskcolor="#d1d1d1"
24
+ inkscape:zoom="0.59"
25
+ inkscape:cx="200.84746"
26
+ inkscape:cy="200"
27
+ inkscape:window-width="1312"
28
+ inkscape:window-height="449"
29
+ inkscape:window-x="0"
30
+ inkscape:window-y="33"
31
+ inkscape:window-maximized="0"
32
+ inkscape:current-layer="svg2" />
33
+ <defs
34
+ id="defs4" />
35
+ <g
36
+ id="layer2">
37
+ <path
38
+ d="M 749.07161,200.5305 C 749.07161,293.70109 673.54193,369.23077 580.37134,369.23077 C 487.20075,369.23077 411.67107,293.70109 411.67107,200.5305 C 411.67107,107.35991 487.20075,31.83023 580.37134,31.83023 C 673.54193,31.83023 749.07161,107.35991 749.07161,200.5305 z "
39
+ transform="translate(-380.37134,-0.5305023)"
40
+ style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:3.54330707;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
41
+ id="path6166" />
42
+ </g>
43
+ <g
44
+ transform="matrix(-1,0,0,1,399.50816,0)"
45
+ id="layer3"
46
+ style="fill:#ffff00">
47
+ <path
48
+ d="M 199.86125,368.70045 C 292.83638,368.70045 368.20758,293.17071 368.20758,199.99995 C 368.20758,106.82922 292.83638,31.29945 199.86125,31.29945 C 199.12886,143.76656 199.72222,256.23321 199.86125,368.70045 z "
49
+ style="fill:#ffff00;fill-opacity:1;stroke:none"
50
+ id="path7307" />
51
+ </g>
52
+ </svg>
@@ -12,10 +12,7 @@
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"
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
15
+ xmlns:svg="http://www.w3.org/2000/svg"><defs
19
16
  id="defs1" /><sodipodi:namedview
20
17
  id="namedview1"
21
18
  pagecolor="#ffffff"
@@ -28,10 +25,10 @@
28
25
  inkscape:zoom="1.3906433"
29
26
  inkscape:cx="180.85155"
30
27
  inkscape:cy="134.11059"
31
- inkscape:window-width="1488"
28
+ inkscape:window-width="1256"
32
29
  inkscape:window-height="674"
33
- inkscape:window-x="24"
34
- inkscape:window-y="33"
30
+ inkscape:window-x="100"
31
+ inkscape:window-y="36"
35
32
  inkscape:window-maximized="0"
36
33
  inkscape:current-layer="svg1"
37
34
  showgrid="true"
@@ -123,8 +120,8 @@
123
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
124
121
  id="desc15">source=resources,resource=tides,provider=tides,path=extremes[2].label</desc>High or Low</text><text
125
122
  id="timezone"
126
- x="330.60489"
127
- y="204.67754"
123
+ x="393.8277"
124
+ y="204.05594"
128
125
  font-size="10.373px"
129
126
  font-family="sans-serif"
130
127
  fill="#ffff00"
@@ -132,8 +129,8 @@
132
129
  transform="scale(0.86441854,1.156847)">UTC+01:00<desc
133
130
  id="desc11">source=einklabel,path=local_zone</desc></text><text
134
131
  id="plugin_version"
135
- x="194.07487"
136
- y="204.51942"
132
+ x="164.95911"
133
+ y="203.89783"
137
134
  font-size="10.373px"
138
135
  font-family="sans-serif"
139
136
  fill="#ffff00"
@@ -142,8 +139,8 @@
142
139
  id="desc16">source=einklabel,path=plugin_version</desc><tspan
143
140
  sodipodi:role="line"
144
141
  id="tspan1"
145
- x="194.07487"
146
- y="204.51942">v0.0.1</tspan></text><text
142
+ x="164.95911"
143
+ y="203.89783">v0.0.1</tspan></text><text
147
144
  id="min_tide"
148
145
  x="282.65424"
149
146
  y="24.967674"
@@ -285,4 +282,12 @@
285
282
  id="moon_phase"
286
283
  x="340.3974"
287
284
  y="90.153488"><desc
288
- id="desc22">path=environment.moon.phaseName,assets=lunar_phases</desc></image></svg>
285
+ id="desc22">path=environment.moon.phaseName,assets=lunar_phases</desc></image><text
286
+ xml:space="preserve"
287
+ style="font-size:14.3917px;font-family:Roboto;-inkscape-font-specification:'Roboto, @wght=600';direction:rtl;fill:#ff0000;stroke:#fefe54;stroke-width:0.660888;font-weight:normal;font-style:normal;font-stretch:normal;font-variant:normal;font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;font-variation-settings:'wght' 600"
288
+ x="256.29999"
289
+ y="217.91586"
290
+ id="text4"
291
+ transform="scale(0.92577154,1.0801801)"><tspan
292
+ sodipodi:role="line"
293
+ id="tspan4">Deprecated</tspan></text></svg>