@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.
- package/CHANGELOG.md +19 -0
- package/README.md +29 -5
- package/dist/config.d.ts +52 -8
- package/dist/config.js +144 -15
- package/dist/devices/discoveredDevicesStore.d.ts +32 -0
- package/dist/devices/discoveredDevicesStore.js +67 -0
- package/dist/devices/discoveryCoordinator.d.ts +19 -0
- package/dist/devices/discoveryCoordinator.js +59 -0
- package/dist/devices/zhsunyco/encode.js +33 -11
- package/dist/devices/zhsunyco/index.js +1 -1
- package/dist/plugin.js +23 -62
- package/dist/render/assets.d.ts +7 -1
- package/dist/render/assets.js +28 -12
- package/dist/render/svgRenderer.js +1 -1
- package/dist/repaintScheduler.js +132 -40
- package/package.json +1 -1
- package/templates/{assets → .assets}/lunar_phases/README.md +6 -2
- package/templates/.assets/lunar_phases/third_quarter.svg +52 -0
- package/templates/tide.svg +18 -10
- package/templates/tides/250x128-BWRY.svg +160 -0
- package/templates/tides/416x240-BWRY.svg +288 -0
- /package/templates/{assets → .assets}/lunar_phases/first_quarter.svg +0 -0
- /package/templates/{assets → .assets}/lunar_phases/full_moon.svg +0 -0
- /package/templates/{assets/lunar_phases/third_quarter.svg → .assets/lunar_phases/last_quarter.svg} +0 -0
- /package/templates/{assets → .assets}/lunar_phases/new_moon.svg +0 -0
- /package/templates/{assets → .assets}/lunar_phases/waning_crescent.svg +0 -0
- /package/templates/{assets → .assets}/lunar_phases/waning_gibbous.svg +0 -0
- /package/templates/{assets → .assets}/lunar_phases/waxing_crescent.svg +0 -0
- /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
|
-
|
|
5
|
-
|
|
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
|
-
|
|
8
|
-
if (r > 150 && g > 100 && b < 80)
|
|
9
|
-
|
|
10
|
-
if (r > 150 && g < 80 && b < 80)
|
|
11
|
-
|
|
12
|
-
|
|
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 *
|
|
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
|
|
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,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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 (
|
|
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 =
|
|
53
|
-
app.setPluginStatus(`Scan complete - found ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
},
|
package/dist/render/assets.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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;
|
package/dist/render/assets.js
CHANGED
|
@@ -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
|
|
26
|
-
* `templatesDir
|
|
27
|
-
* `bundledTemplatesDir
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
-
*
|
|
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 ?? `
|
|
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
|
}
|
package/dist/repaintScheduler.js
CHANGED
|
@@ -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
|
|
64
|
-
function
|
|
65
|
-
return (0, crypto_1.createHash)("sha1").update(
|
|
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
|
|
89
|
-
* in-process too, with no such wrapper
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
159
|
-
|
|
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(
|
|
232
|
+
app.debug(`${label}: ${err.message}`);
|
|
170
233
|
return undefined;
|
|
171
234
|
});
|
|
172
235
|
const rawContext = await assembleRawContext(app, apiUrl, bindings);
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
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(
|
|
260
|
+
app.debug(`${label}: attempting paint ${attempt}/${config.paintRetries}`);
|
|
193
261
|
}
|
|
194
262
|
const startedAt = Date.now();
|
|
195
|
-
await driver.paint(bitmap, { address
|
|
263
|
+
await driver.paint(bitmap, { address, aesKey: device.aesKey, connectTimeoutMs });
|
|
196
264
|
paintDurationMs = Date.now() - startedAt;
|
|
197
265
|
});
|
|
198
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
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
|
|
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
|
@@ -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
|
|
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
|
|
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.
|