@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.
- package/CHANGELOG.md +15 -0
- package/README.md +25 -5
- package/dist/config.d.ts +51 -8
- package/dist/config.js +177 -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/plugin.js +24 -62
- package/dist/render/assets.d.ts +2 -2
- package/dist/render/assets.js +15 -13
- package/dist/repaintScheduler.js +111 -26
- 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 +19 -14
- 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
|
}
|
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 = {
|
|
@@ -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
|
-
|
|
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
|
},
|
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
|
*/
|
|
@@ -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
|
|
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
|
*/
|
package/dist/render/assets.js
CHANGED
|
@@ -23,18 +23,20 @@ function normalizeAssetKey(value) {
|
|
|
23
23
|
return normalized || undefined;
|
|
24
24
|
}
|
|
25
25
|
/**
|
|
26
|
-
* Picks which
|
|
27
|
-
* `templatesDir
|
|
28
|
-
* `bundledTemplatesDir
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
*/
|
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");
|
|
@@ -62,9 +64,13 @@ function stableStringify(value) {
|
|
|
62
64
|
}
|
|
63
65
|
return JSON.stringify(value);
|
|
64
66
|
}
|
|
65
|
-
/** Hashes
|
|
66
|
-
function
|
|
67
|
-
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");
|
|
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 =
|
|
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
|
-
|
|
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
|
|
177
|
-
|
|
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(
|
|
232
|
+
app.debug(`${label}: ${err.message}`);
|
|
188
233
|
return undefined;
|
|
189
234
|
});
|
|
190
235
|
const rawContext = await assembleRawContext(app, apiUrl, bindings);
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
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(
|
|
271
|
+
app.debug(`${label}: attempting paint ${attempt}/${config.paintRetries}`);
|
|
211
272
|
}
|
|
212
273
|
const startedAt = Date.now();
|
|
213
|
-
await driver.paint(bitmap, { address
|
|
274
|
+
await driver.paint(bitmap, { address, aesKey: device.aesKey, connectTimeoutMs });
|
|
214
275
|
paintDurationMs = Date.now() - startedAt;
|
|
215
276
|
});
|
|
216
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
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
|
|
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
|
@@ -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.
|
|
@@ -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>
|
package/templates/tide.svg
CHANGED
|
@@ -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="
|
|
28
|
+
inkscape:window-width="1256"
|
|
32
29
|
inkscape:window-height="674"
|
|
33
|
-
inkscape:window-x="
|
|
34
|
-
inkscape:window-y="
|
|
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="
|
|
127
|
-
y="204.
|
|
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="
|
|
136
|
-
y="
|
|
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="
|
|
146
|
-
y="
|
|
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
|
|
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>
|