@rhizomatics/signalk-einklabel-plugin 1.2.3 → 1.3.0-beta10
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 +28 -0
- package/README.md +97 -9
- package/dist/cli/index.d.ts +4 -1
- package/dist/cli/index.js +30 -6
- package/dist/config.d.ts +53 -14
- package/dist/config.js +172 -41
- package/dist/devices/bleBackend.d.ts +49 -0
- package/dist/devices/bleBackend.js +268 -0
- package/dist/devices/bleDiscovery.d.ts +20 -2
- package/dist/devices/bleDiscovery.js +101 -3
- package/dist/devices/discoveryCoordinator.d.ts +25 -6
- package/dist/devices/discoveryCoordinator.js +148 -9
- package/dist/devices/gattConnection.d.ts +9 -0
- package/dist/devices/gattConnection.js +2 -0
- package/dist/devices/gicisky/compression.d.ts +22 -9
- package/dist/devices/gicisky/compression.js +128 -13
- package/dist/devices/gicisky/encode.d.ts +1 -1
- package/dist/devices/gicisky/encode.js +2 -2
- package/dist/devices/gicisky/index.d.ts +7 -2
- package/dist/devices/gicisky/index.js +118 -96
- package/dist/devices/gicisky/layout.d.ts +13 -1
- package/dist/devices/gicisky/layout.js +21 -0
- package/dist/devices/types.d.ts +48 -6
- package/dist/devices/types.js +2 -0
- package/dist/devices/zhsunyco/compression.d.ts +1 -0
- package/dist/devices/zhsunyco/compression.js +30 -0
- package/dist/devices/zhsunyco/index.d.ts +8 -2
- package/dist/devices/zhsunyco/index.js +92 -82
- package/dist/devices/zhsunyco/protocol.d.ts +2 -0
- package/dist/devices/zhsunyco/protocol.js +2 -0
- package/dist/plugin.js +38 -11
- package/dist/render/mirror.d.ts +11 -0
- package/dist/render/mirror.js +24 -0
- package/dist/repaintScheduler.js +29 -12
- package/docs/assets/images/mini_tidal_clock.png +0 -0
- package/package.json +3 -3
|
@@ -2,19 +2,21 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.ZhsunycoDriver = void 0;
|
|
4
4
|
const bleDiscovery_1 = require("../bleDiscovery");
|
|
5
|
+
const bleBackend_1 = require("../bleBackend");
|
|
5
6
|
const pluginVersion_1 = require("../../pluginVersion");
|
|
6
7
|
const metadata_1 = require("./metadata");
|
|
7
8
|
const encode_1 = require("./encode");
|
|
8
9
|
const reframe_1 = require("../../render/reframe");
|
|
10
|
+
const mirror_1 = require("../../render/mirror");
|
|
11
|
+
const compression_1 = require("./compression");
|
|
9
12
|
const protocol_1 = require("./protocol");
|
|
10
13
|
/** node-ble has no MTU API; this matches the reference driver's mtu(247)-9 default. */
|
|
11
14
|
const UPLOAD_CHUNK_SIZE = 238;
|
|
12
15
|
const CHUNK_WRITE_DELAY_MS = 20;
|
|
13
16
|
const AUTH_SETTLE_DELAY_MS = 500;
|
|
14
17
|
const STATUS_WAIT_TIMEOUT_MS = 60000;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const SCAN_CONNECT_TIMEOUT_MS = 10000;
|
|
18
|
+
/** Bounds `readDeviceDetails`' whole connect+read attempt during a scan - see its doc comment. */
|
|
19
|
+
const IDENTIFY_READ_TIMEOUT_MS = 10000;
|
|
18
20
|
/** Fallback when `VendorDeviceConfig.connectTimeoutMs` is omitted (e.g. a bare CLI `paint` call) - matches `defaultConfig().paintConnectTimeoutSeconds`. */
|
|
19
21
|
const DEFAULT_PAINT_CONNECT_TIMEOUT_MS = 60000;
|
|
20
22
|
class ZhsunycoDriver {
|
|
@@ -31,9 +33,9 @@ class ZhsunycoDriver {
|
|
|
31
33
|
supportedDevices() {
|
|
32
34
|
return metadata_1.ZHSUNYCO_PID_METADATA;
|
|
33
35
|
}
|
|
34
|
-
async identifyDevice(
|
|
36
|
+
async identifyDevice({ address, name, manufacturerId, manufacturerData, rssi, }, connect) {
|
|
35
37
|
const advertisedInfo = manufacturerData ? (0, protocol_1.decodeAdvertisedInfo)(manufacturerData) : undefined;
|
|
36
|
-
const { info, batteryMv } = await readDeviceDetails(
|
|
38
|
+
const { info, batteryMv } = await readDeviceDetails(address, advertisedInfo, connect);
|
|
37
39
|
return {
|
|
38
40
|
address,
|
|
39
41
|
name,
|
|
@@ -43,99 +45,96 @@ class ZhsunycoDriver {
|
|
|
43
45
|
metadata: info ? this.metadataForPid(info.pid, info.hwVersion) : undefined,
|
|
44
46
|
manufacturerId,
|
|
45
47
|
batteryMv,
|
|
46
|
-
rssi
|
|
47
|
-
.getRSSI()
|
|
48
|
-
.then((value) => (value === undefined ? undefined : Number(value)))
|
|
49
|
-
.catch(() => undefined),
|
|
48
|
+
rssi,
|
|
50
49
|
};
|
|
51
50
|
}
|
|
52
51
|
async paint(bitmap, config) {
|
|
53
52
|
const aesKey = (0, protocol_1.resolveAesKey)(config.aesKey);
|
|
54
|
-
const
|
|
53
|
+
const backend = config.gattBackend ?? (0, bleBackend_1.nodeBleBackend)();
|
|
54
|
+
const conn = await backend.connectGatt(config.address, config.connectTimeoutMs ?? DEFAULT_PAINT_CONNECT_TIMEOUT_MS);
|
|
55
55
|
try {
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
const info = (0, protocol_1.decodeAdvertisedInfo)(await conn.read(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.config));
|
|
57
|
+
if (!info) {
|
|
58
|
+
throw new Error("zhsunyco device did not return valid config data");
|
|
59
|
+
}
|
|
60
|
+
const metadata = config.modelOverride ? { pid: info.pid, ...config.modelOverride } : this.metadataForPid(info.pid, info.hwVersion);
|
|
61
|
+
if (!metadata) {
|
|
62
|
+
throw new Error(`zhsunyco device reports unrecognised PID 0x${info.pid.toString(16).padStart(4, "0")} - ` +
|
|
63
|
+
"pass --width/--height/--voffset/--colours to describe it manually");
|
|
64
|
+
}
|
|
65
|
+
const statusReceived = new Promise((resolve, reject) => {
|
|
66
|
+
conn
|
|
67
|
+
.startNotifications(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.status, (data) => {
|
|
68
|
+
const { errorCode } = (0, protocol_1.decodeStatus)(data);
|
|
69
|
+
if (errorCode === 0) {
|
|
70
|
+
resolve();
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
reject(new Error(`zhsunyco device reported error 0x${errorCode.toString(16).padStart(2, "0")} after refresh`));
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
.catch(reject);
|
|
77
|
+
});
|
|
59
78
|
try {
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
const dataChar = await service.getCharacteristic(protocol_1.WOLINK_CHARACTERISTIC_UUIDS.data);
|
|
63
|
-
const configChar = await service.getCharacteristic(protocol_1.WOLINK_CHARACTERISTIC_UUIDS.config);
|
|
64
|
-
const authChar = await service.getCharacteristic(protocol_1.WOLINK_CHARACTERISTIC_UUIDS.authenticate);
|
|
65
|
-
const statusChar = await service.getCharacteristic(protocol_1.WOLINK_CHARACTERISTIC_UUIDS.status);
|
|
66
|
-
const info = (0, protocol_1.decodeAdvertisedInfo)(await configChar.readValue());
|
|
67
|
-
if (!info) {
|
|
68
|
-
throw new Error("zhsunyco device did not return valid config data");
|
|
69
|
-
}
|
|
70
|
-
const metadata = config.modelOverride ? { pid: info.pid, ...config.modelOverride } : this.metadataForPid(info.pid, info.hwVersion);
|
|
71
|
-
if (!metadata) {
|
|
72
|
-
throw new Error(`zhsunyco device reports unrecognised PID 0x${info.pid.toString(16).padStart(4, "0")} - ` +
|
|
73
|
-
"pass --width/--height/--voffset/--colours to describe it manually");
|
|
74
|
-
}
|
|
75
|
-
const statusReceived = new Promise((resolve, reject) => {
|
|
76
|
-
statusChar.once("valuechanged", (data) => {
|
|
77
|
-
const { errorCode } = (0, protocol_1.decodeStatus)(data);
|
|
78
|
-
if (errorCode === 0) {
|
|
79
|
-
resolve();
|
|
80
|
-
}
|
|
81
|
-
else {
|
|
82
|
-
reject(new Error(`zhsunyco device reported error 0x${errorCode.toString(16).padStart(2, "0")} after refresh`));
|
|
83
|
-
}
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
await statusChar.startNotifications();
|
|
87
|
-
const challenge = await authChar.readValue();
|
|
88
|
-
await authChar.writeValueWithoutResponse((0, protocol_1.authResponse)(challenge, aesKey));
|
|
79
|
+
const challenge = await conn.read(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.authenticate);
|
|
80
|
+
await conn.write(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.authenticate, (0, protocol_1.authResponse)(challenge, aesKey), false);
|
|
89
81
|
await (0, bleDiscovery_1.sleep)(AUTH_SETTLE_DELAY_MS);
|
|
90
|
-
const framed = (0, reframe_1.reframeBitmap)(bitmap, metadata.width, metadata.height - metadata.voffset, config.reframe ?? "crop");
|
|
82
|
+
const framed = (0, mirror_1.mirrorBitmap)((0, reframe_1.reframeBitmap)(bitmap, metadata.width, metadata.height - metadata.voffset, config.reframe ?? "crop"), config.mirror ?? "none");
|
|
91
83
|
const pixelData = (0, encode_1.encodeBitmap)(framed, metadata);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
84
|
+
const compress = config.compress ?? true;
|
|
85
|
+
// Upload offsets and the refresh length both count bytes of whatever's actually sent - the
|
|
86
|
+
// compressed payload when compressing, not the raw buffer it inflates back to.
|
|
87
|
+
const payload = compress ? (0, compression_1.compressWolinkBlocks)(pixelData) : pixelData;
|
|
88
|
+
for (let offset = 0; offset < payload.length; offset += UPLOAD_CHUNK_SIZE) {
|
|
89
|
+
const chunk = payload.subarray(offset, offset + UPLOAD_CHUNK_SIZE);
|
|
90
|
+
await conn.write(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.data, Buffer.concat([(0, protocol_1.commandHeader)(protocol_1.COMMAND.uploadBlock, offset), chunk]), true);
|
|
95
91
|
await (0, bleDiscovery_1.sleep)(CHUNK_WRITE_DELAY_MS);
|
|
96
92
|
}
|
|
97
|
-
await
|
|
98
|
-
|
|
93
|
+
await conn.write(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.data, (0, protocol_1.commandHeader)(compress ? protocol_1.COMMAND.refreshCompressed : protocol_1.COMMAND.refreshUncompressed, payload.length), true);
|
|
94
|
+
// `Promise.race` can't cancel its loser, so once `statusReceived` settles (the common case)
|
|
95
|
+
// this timer would otherwise sit alive for the rest of its 60s regardless - see the identical
|
|
96
|
+
// reasoning on `readDeviceDetails`'s own race, below.
|
|
97
|
+
let statusTimer;
|
|
98
|
+
const statusTimeout = new Promise((resolve) => {
|
|
99
|
+
statusTimer = setTimeout(resolve, STATUS_WAIT_TIMEOUT_MS);
|
|
100
|
+
});
|
|
101
|
+
try {
|
|
102
|
+
await Promise.race([statusReceived, statusTimeout]);
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
clearTimeout(statusTimer);
|
|
106
|
+
}
|
|
99
107
|
}
|
|
100
108
|
finally {
|
|
101
|
-
await
|
|
109
|
+
await conn.stopNotifications(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.status).catch(() => { });
|
|
102
110
|
}
|
|
103
111
|
}
|
|
104
112
|
finally {
|
|
105
|
-
|
|
113
|
+
await conn.disconnect();
|
|
106
114
|
}
|
|
107
115
|
}
|
|
108
116
|
}
|
|
109
117
|
exports.ZhsunycoDriver = ZhsunycoDriver;
|
|
110
118
|
/**
|
|
111
119
|
* Battery level needs a connection regardless, so reuse it to also fill in the PID/hwVersion
|
|
112
|
-
* when the advertisement didn't carry decodable manufacturer data -
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
* config characteristic `paint()` reads, just to identify the device rather than to size a
|
|
120
|
+
* when the advertisement didn't carry decodable manufacturer data - a scan matched purely by name
|
|
121
|
+
* prefix can lack that, which would otherwise leave a real, nearby device's model (and so its entry
|
|
122
|
+
* in the config UI's device picker - see `deviceOptions()` in `config.ts`) silently missing. Reads
|
|
123
|
+
* the same config characteristic `paint()` reads, just to identify the device rather than to size a
|
|
117
124
|
* render.
|
|
118
125
|
*/
|
|
119
|
-
async function readDeviceDetails(
|
|
126
|
+
async function readDeviceDetails(address, advertisedInfo, connect) {
|
|
120
127
|
const fallback = { info: advertisedInfo, batteryMv: undefined };
|
|
121
128
|
const read = async () => {
|
|
129
|
+
let conn;
|
|
122
130
|
try {
|
|
123
|
-
await (
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const batteryMv = (0, protocol_1.decodeBatteryMv)(await batteryChar.readValue());
|
|
129
|
-
let info = advertisedInfo;
|
|
130
|
-
if (!info) {
|
|
131
|
-
const configChar = await service.getCharacteristic(protocol_1.WOLINK_CHARACTERISTIC_UUIDS.config);
|
|
132
|
-
info = (0, protocol_1.decodeAdvertisedInfo)(await configChar.readValue());
|
|
133
|
-
}
|
|
134
|
-
return { info, batteryMv };
|
|
135
|
-
}
|
|
136
|
-
finally {
|
|
137
|
-
await device.disconnect();
|
|
131
|
+
conn = await connect();
|
|
132
|
+
const batteryMv = (0, protocol_1.decodeBatteryMv)(await conn.read(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.battery));
|
|
133
|
+
let info = advertisedInfo;
|
|
134
|
+
if (!info) {
|
|
135
|
+
info = (0, protocol_1.decodeAdvertisedInfo)(await conn.read(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.config));
|
|
138
136
|
}
|
|
137
|
+
return { info, batteryMv };
|
|
139
138
|
}
|
|
140
139
|
catch (err) {
|
|
141
140
|
// Swallowed rather than thrown - a device that refuses this connect (e.g. busy elsewhere,
|
|
@@ -145,16 +144,27 @@ async function readDeviceDetails(device, address, advertisedInfo) {
|
|
|
145
144
|
console.error(`${pluginVersion_1.PLUGIN_NAME}: zhsunyco [${address}]: battery/config read failed: ${err.message}`);
|
|
146
145
|
return fallback;
|
|
147
146
|
}
|
|
147
|
+
finally {
|
|
148
|
+
await conn?.disconnect().catch(() => { });
|
|
149
|
+
}
|
|
148
150
|
};
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
151
|
+
// A GATT call has no timeout of its own - race the whole read so one unresponsive device can't
|
|
152
|
+
// stall the rest of the scan (see `plugin.ts`'s `scanInProgress`, which otherwise stays set forever
|
|
153
|
+
// and silently skips every later scan). `Promise.race` alone can't cancel its loser, so a `read()`
|
|
154
|
+
// that settles fast (e.g. `connect` rejecting immediately because the device is busy elsewhere -
|
|
155
|
+
// exactly the case this function's own doc comment covers) would otherwise leave this timer running
|
|
156
|
+
// for the rest of its 30s regardless - harmless in the end, but needless to hold onto that long.
|
|
157
|
+
let timer;
|
|
158
|
+
const timeout = new Promise((resolve) => {
|
|
159
|
+
timer = setTimeout(() => {
|
|
160
|
+
console.error(`${pluginVersion_1.PLUGIN_NAME}: zhsunyco [${address}]: battery/config read timed out after ${IDENTIFY_READ_TIMEOUT_MS * 2}ms`);
|
|
161
|
+
resolve(fallback);
|
|
162
|
+
}, IDENTIFY_READ_TIMEOUT_MS * 3);
|
|
163
|
+
});
|
|
164
|
+
try {
|
|
165
|
+
return await Promise.race([read(), timeout]);
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
clearTimeout(timer);
|
|
169
|
+
}
|
|
160
170
|
}
|
|
@@ -15,6 +15,8 @@ export declare const WOLINK_CHARACTERISTIC_UUIDS: {
|
|
|
15
15
|
export declare const COMMAND: {
|
|
16
16
|
readonly uploadBlock: 42240;
|
|
17
17
|
readonly refreshUncompressed: 42241;
|
|
18
|
+
/** Refresh after uploading a `compressWolinkBlocks` payload rather than the raw buffer. */
|
|
19
|
+
readonly refreshCompressed: 42242;
|
|
18
20
|
};
|
|
19
21
|
export interface AdvertisedDeviceInfo {
|
|
20
22
|
pid: number;
|
|
@@ -25,6 +25,8 @@ exports.WOLINK_CHARACTERISTIC_UUIDS = {
|
|
|
25
25
|
exports.COMMAND = {
|
|
26
26
|
uploadBlock: 0xa500,
|
|
27
27
|
refreshUncompressed: 0xa501,
|
|
28
|
+
/** Refresh after uploading a `compressWolinkBlocks` payload rather than the raw buffer. */
|
|
29
|
+
refreshCompressed: 0xa502,
|
|
28
30
|
};
|
|
29
31
|
/** Decodes the 8-byte header shared by the advertising mirror and config characteristic. */
|
|
30
32
|
function decodeAdvertisedInfo(data) {
|
package/dist/plugin.js
CHANGED
|
@@ -9,8 +9,9 @@ const discoveryCoordinator_1 = require("./devices/discoveryCoordinator");
|
|
|
9
9
|
const bleDiscovery_1 = require("./devices/bleDiscovery");
|
|
10
10
|
const discoveredDevicesStore_1 = require("./devices/discoveredDevicesStore");
|
|
11
11
|
const repaintScheduler_1 = require("./repaintScheduler");
|
|
12
|
+
const pluginVersion_1 = require("./pluginVersion");
|
|
12
13
|
/** Mirrors signalk-bluetti-plugin's convention: scan briefly, report finds via plugin status for the user to copy-paste. */
|
|
13
|
-
async function runStartupScan(app, durationSeconds) {
|
|
14
|
+
async function runStartupScan(app, durationSeconds, useBleApi) {
|
|
14
15
|
const alreadyRunning = (0, discoveryCoordinator_1.scanInProgressSince)();
|
|
15
16
|
if (alreadyRunning !== undefined) {
|
|
16
17
|
const elapsedSeconds = ((Date.now() - alreadyRunning) / 1000).toFixed(0);
|
|
@@ -21,7 +22,7 @@ async function runStartupScan(app, durationSeconds) {
|
|
|
21
22
|
app.setPluginStatus(`Scanning for ESL devices for ${durationSeconds}s...`);
|
|
22
23
|
}
|
|
23
24
|
const startedAt = Date.now();
|
|
24
|
-
const { foundThisScan } = await (0, discoveryCoordinator_1.ensureScan)(app, durationSeconds).catch((err) => {
|
|
25
|
+
const { foundThisScan } = await (0, discoveryCoordinator_1.ensureScan)(app, durationSeconds, useBleApi).catch((err) => {
|
|
25
26
|
app.debug(`startup scan failed: ${err.message}`);
|
|
26
27
|
return { foundThisScan: [] };
|
|
27
28
|
});
|
|
@@ -36,7 +37,12 @@ async function runStartupScan(app, durationSeconds) {
|
|
|
36
37
|
function createPlugin(app) {
|
|
37
38
|
(0, registry_1.registerDriver)(new zhsunyco_1.ZhsunycoDriver());
|
|
38
39
|
(0, registry_1.registerDriver)(new gicisky_1.GiciskyDriver());
|
|
40
|
+
// Present on SignalK server >= 2.32.0 (the BLE Provider/Consumer API - "BLE Manager" in the admin
|
|
41
|
+
// UI); `ServerAPI`'s own type says this is always defined, but that's only true on servers new
|
|
42
|
+
// enough to have added it - checked at runtime rather than trusted from the type.
|
|
43
|
+
const bleApiAvailable = !!app.bleApi;
|
|
39
44
|
let scheduler;
|
|
45
|
+
let stopDiscoveryListener;
|
|
40
46
|
let stopped = false;
|
|
41
47
|
const plugin = {
|
|
42
48
|
id: "signalk-einklabel-plugin",
|
|
@@ -50,28 +56,49 @@ function createPlugin(app) {
|
|
|
50
56
|
start(config) {
|
|
51
57
|
const pluginConfig = {
|
|
52
58
|
...(0, config_1.defaultConfig)(),
|
|
53
|
-
...config,
|
|
59
|
+
...(0, config_1.migrateConfig)(config).config,
|
|
54
60
|
};
|
|
55
61
|
app.debug(`starting with ${pluginConfig.devices.length} configured device(s)`);
|
|
56
|
-
(0, config_1.
|
|
62
|
+
(0, config_1.healStoredConfig)(app);
|
|
57
63
|
stopped = false;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
64
|
+
const useBleApi = pluginConfig.useBleApi && bleApiAvailable;
|
|
65
|
+
if (pluginConfig.useBleApi && !bleApiAvailable) {
|
|
66
|
+
app.debug("useBleApi is enabled but this SignalK server has no BLE Manager API (requires >= 2.32.0) - falling back to direct BlueZ access");
|
|
67
|
+
}
|
|
68
|
+
app.debug(useBleApi ? "using the SignalK BLE Manager API for Bluetooth access" : "using direct BlueZ access for Bluetooth");
|
|
69
|
+
const start = () => {
|
|
63
70
|
if (stopped)
|
|
64
71
|
return;
|
|
65
72
|
if (pluginConfig.scanOnStart) {
|
|
66
|
-
void runStartupScan(app, pluginConfig.scanDurationSeconds);
|
|
73
|
+
void runStartupScan(app, pluginConfig.scanDurationSeconds, useBleApi);
|
|
67
74
|
}
|
|
68
75
|
scheduler = (0, repaintScheduler_1.startRepaintScheduler)(app, pluginConfig);
|
|
69
|
-
}
|
|
76
|
+
};
|
|
77
|
+
if (useBleApi) {
|
|
78
|
+
// BLE Manager already runs its own continuous scan server-side (that's what populates its own
|
|
79
|
+
// admin UI device list) - unlike direct BlueZ access below, discovery here doesn't need a
|
|
80
|
+
// bounded scan window at all, so this listens for as long as the plugin runs rather than only
|
|
81
|
+
// during `scanOnStart`/an on-demand `ALL_DEVICES` scan. See its own doc comment.
|
|
82
|
+
stopDiscoveryListener = (0, discoveryCoordinator_1.startBleApiDiscoveryListener)(app, pluginVersion_1.PLUGIN_NAME);
|
|
83
|
+
// The server/provider governs its own local-adapter readiness once BLE Manager mode owns
|
|
84
|
+
// `hci0` (or has none at all, behind a remote gateway) - waiting on a *local* BlueZ adapter
|
|
85
|
+
// here would be waiting on something this mode may never even need.
|
|
86
|
+
start();
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
// Waits (with backoff, indefinitely on Linux) for a BLE adapter before the startup scan or
|
|
90
|
+
// the repaint scheduler touch BLE at all - see `waitForAdapter`'s doc comment on the
|
|
91
|
+
// boot-time race this covers. `stopped` is checked again inside `start()`, not just passed
|
|
92
|
+
// as `cancelled`, since the wait can also resolve `true` on its own right as `stop()` runs.
|
|
93
|
+
void (0, bleDiscovery_1.waitForAdapter)((message) => app.debug(message), () => stopped).then(start);
|
|
94
|
+
}
|
|
70
95
|
},
|
|
71
96
|
stop() {
|
|
72
97
|
stopped = true;
|
|
73
98
|
scheduler?.stop();
|
|
74
99
|
scheduler = undefined;
|
|
100
|
+
stopDiscoveryListener?.();
|
|
101
|
+
stopDiscoveryListener = undefined;
|
|
75
102
|
app.debug("stopped");
|
|
76
103
|
},
|
|
77
104
|
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Bitmap } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* Flips the rendered image before it's encoded for the panel - for hardware whose RAM layout is
|
|
4
|
+
* mirrored relative to what a driver's encoder assumes (the Wolink HA integration found the 2.9"
|
|
5
|
+
* and 3.5" panels differ from the 3.7" this plugin's zhsunyco encoder was confirmed against), or a
|
|
6
|
+
* label that's simply mounted upside down (`"both"` is a 180° rotation).
|
|
7
|
+
*/
|
|
8
|
+
export type MirrorMode = "none" | "horizontal" | "vertical" | "both";
|
|
9
|
+
export declare const MIRROR_MODES: MirrorMode[];
|
|
10
|
+
/** Returns `bitmap` unchanged for `"none"`, otherwise a flipped copy. */
|
|
11
|
+
export declare function mirrorBitmap(bitmap: Bitmap, mode: MirrorMode): Bitmap;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MIRROR_MODES = void 0;
|
|
4
|
+
exports.mirrorBitmap = mirrorBitmap;
|
|
5
|
+
exports.MIRROR_MODES = ["none", "horizontal", "vertical", "both"];
|
|
6
|
+
/** Returns `bitmap` unchanged for `"none"`, otherwise a flipped copy. */
|
|
7
|
+
function mirrorBitmap(bitmap, mode) {
|
|
8
|
+
if (mode === "none") {
|
|
9
|
+
return bitmap;
|
|
10
|
+
}
|
|
11
|
+
const { width, height } = bitmap;
|
|
12
|
+
const flipX = mode === "horizontal" || mode === "both";
|
|
13
|
+
const flipY = mode === "vertical" || mode === "both";
|
|
14
|
+
const data = new Uint8Array(bitmap.data.length);
|
|
15
|
+
for (let y = 0; y < height; y++) {
|
|
16
|
+
const srcY = flipY ? height - 1 - y : y;
|
|
17
|
+
for (let x = 0; x < width; x++) {
|
|
18
|
+
const srcX = flipX ? width - 1 - x : x;
|
|
19
|
+
const srcOffset = (srcY * width + srcX) * 4;
|
|
20
|
+
data.set(bitmap.data.subarray(srcOffset, srcOffset + 4), (y * width + x) * 4);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return { width, height, data };
|
|
24
|
+
}
|
package/dist/repaintScheduler.js
CHANGED
|
@@ -7,6 +7,7 @@ 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 bleBackend_1 = require("./devices/bleBackend");
|
|
10
11
|
const discoveredDevicesStore_1 = require("./devices/discoveredDevicesStore");
|
|
11
12
|
const discoveryCoordinator_1 = require("./devices/discoveryCoordinator");
|
|
12
13
|
const registry_1 = require("./devices/registry");
|
|
@@ -22,6 +23,8 @@ const pluginVersion_1 = require("./pluginVersion");
|
|
|
22
23
|
const httpJson_1 = require("./httpJson");
|
|
23
24
|
const INTERVAL_POLL_MS = 60000;
|
|
24
25
|
const SUBSCRIPTION_DEBOUNCE_MS = 2000;
|
|
26
|
+
/** Pause between paint attempts - see `withRetries`' `delayMs`. */
|
|
27
|
+
const PAINT_RETRY_DELAY_MS = 5000;
|
|
25
28
|
const RESOURCES_API_PATH = "/signalk/v2/api/resources";
|
|
26
29
|
/**
|
|
27
30
|
* The most recent wall-clock instant at or before `now` that an `interval`-triggered device's own
|
|
@@ -172,7 +175,7 @@ async function assembleRawContext(app, apiUrl, bindings) {
|
|
|
172
175
|
}
|
|
173
176
|
function clearForceRepaint(app, friendlyName) {
|
|
174
177
|
const current = (0, config_1.readCurrentConfig)(app);
|
|
175
|
-
const devices = (current.devices ?? []).map((device) => device.friendlyName === friendlyName ? { ...device, forceRepaint: false } : device);
|
|
178
|
+
const devices = (current.devices ?? []).map((device) => device.friendlyName === friendlyName ? { ...device, advanced: { ...device.advanced, forceRepaint: false } } : device);
|
|
176
179
|
app.savePluginOptions({ ...current, devices }, (err) => {
|
|
177
180
|
if (err)
|
|
178
181
|
app.debug(`failed to clear forceRepaint for "${friendlyName}": ${err.message}`);
|
|
@@ -200,7 +203,7 @@ async function resolveTargets(app, config, device) {
|
|
|
200
203
|
let records = (0, discoveredDevicesStore_1.loadDiscoveredDevices)(app);
|
|
201
204
|
if (Object.keys(records).length === 0) {
|
|
202
205
|
app.debug(`"${device.friendlyName}": device is "${config_1.ALL_DEVICES}" and nothing discovered yet - scanning for ${config.scanDurationSeconds}s`);
|
|
203
|
-
records = (await (0, discoveryCoordinator_1.ensureScan)(app, config.scanDurationSeconds)).merged;
|
|
206
|
+
records = (await (0, discoveryCoordinator_1.ensureScan)(app, config.scanDurationSeconds, config.useBleApi && !!app.bleApi)).merged;
|
|
204
207
|
}
|
|
205
208
|
const targets = [];
|
|
206
209
|
for (const record of Object.values(records)) {
|
|
@@ -306,7 +309,7 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
|
|
|
306
309
|
// scheduled tick" (e.g. a regenerated forecast) is the whole point of one - which is exactly why a
|
|
307
310
|
// provider-backed device should use `repaintTrigger: "interval"`, not `subscription`, for
|
|
308
311
|
// cost/battery reasons (each repaint may be a paid API call on the provider's side).
|
|
309
|
-
if (!provider && !templateChanged && !dataChanged && !device.forceRepaint) {
|
|
312
|
+
if (!provider && !templateChanged && !dataChanged && !device.advanced?.forceRepaint) {
|
|
310
313
|
app.debug(`${label}: data unchanged, skipping repaint`);
|
|
311
314
|
return;
|
|
312
315
|
}
|
|
@@ -324,7 +327,7 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
|
|
|
324
327
|
? await provider.render({ templateName: device.templateName, context: renderContext, width, height, colours: metadata.colours })
|
|
325
328
|
: await renderer.render(templatePath, renderContext, width, height, templatesDir, config_1.BUNDLED_TEMPLATES_DIR);
|
|
326
329
|
succeeded = true;
|
|
327
|
-
repaintReason = device.forceRepaint
|
|
330
|
+
repaintReason = device.advanced?.forceRepaint
|
|
328
331
|
? "forced"
|
|
329
332
|
: provider
|
|
330
333
|
? "provider-rendered"
|
|
@@ -343,15 +346,29 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
|
|
|
343
346
|
});
|
|
344
347
|
bitmap = await renderer.render(fallbackPath, { meta: { repainted: new Date().toISOString(), plugin_version: pluginVersion_1.PLUGIN_VERSION, description: device.description ?? "" } }, width, height, templatesDir, config_1.BUNDLED_TEMPLATES_DIR);
|
|
345
348
|
}
|
|
346
|
-
const connectTimeoutMs = config.paintConnectTimeoutSeconds * 1000;
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
}
|
|
349
|
+
const connectTimeoutMs = (device.advanced?.paintConnectTimeoutSeconds ?? config.paintConnectTimeoutSeconds) * 1000;
|
|
350
|
+
const gattBackend = config.useBleApi && app.bleApi ? (0, bleBackend_1.bleApiBackend)(app.bleApi, pluginVersion_1.PLUGIN_NAME) : undefined;
|
|
351
|
+
const attempts = Math.max(1, device.advanced?.paintRetries ?? config.paintRetries);
|
|
352
|
+
const paintWithRetries = () => (0, bleDiscovery_1.withRetries)(attempts, async (attempt) => {
|
|
353
|
+
app.debug(`${label}: attempting paint ${attempt}/${attempts}`);
|
|
351
354
|
const startedAt = Date.now();
|
|
352
|
-
await driver.paint(bitmap, {
|
|
355
|
+
await driver.paint(bitmap, {
|
|
356
|
+
address,
|
|
357
|
+
pid: target.pid,
|
|
358
|
+
aesKey: device.advanced?.aesKey,
|
|
359
|
+
connectTimeoutMs,
|
|
360
|
+
reframe: device.reframe,
|
|
361
|
+
mirror: device.advanced?.mirror,
|
|
362
|
+
compress: device.advanced?.compress,
|
|
363
|
+
compressionFormat: device.advanced?.compressionFormat,
|
|
364
|
+
gattBackend,
|
|
365
|
+
});
|
|
353
366
|
paintDurationMs = Date.now() - startedAt;
|
|
367
|
+
}, {
|
|
368
|
+
delayMs: PAINT_RETRY_DELAY_MS,
|
|
369
|
+
onError: (err, attempt) => app.debug(`${label}: paint attempt ${attempt}/${attempts} failed: ${err.message}`),
|
|
354
370
|
});
|
|
371
|
+
await (gattBackend ? (0, bleBackend_1.exclusiveBleManagerAccess)(paintWithRetries) : paintWithRetries());
|
|
355
372
|
(0, discoveredDevicesStore_1.touchDiscoveredDevice)(app, { address, vendor: target.vendor, pid: target.pid, hwVersion: target.hwVersion, metadata });
|
|
356
373
|
// Only a successful render counts as "repainted" for dedup/catch-up purposes - a failure must not be
|
|
357
374
|
// recorded here (see this function's doc comment on why).
|
|
@@ -391,7 +408,7 @@ function startRepaintScheduler(app, config) {
|
|
|
391
408
|
// A single `forceRepaint` flag covers every target under `ALL_DEVICES` too - only clear it once
|
|
392
409
|
// every target has actually succeeded, so a target that failed still gets forced again next time
|
|
393
410
|
// instead of quietly reverting to ordinary hash-based dedup.
|
|
394
|
-
if (device.forceRepaint && results.every((result) => result.status === "fulfilled")) {
|
|
411
|
+
if (device.advanced?.forceRepaint && results.every((result) => result.status === "fulfilled")) {
|
|
395
412
|
clearForceRepaint(app, device.friendlyName);
|
|
396
413
|
}
|
|
397
414
|
};
|
|
@@ -438,7 +455,7 @@ function startRepaintScheduler(app, config) {
|
|
|
438
455
|
// still avoids a redundant paint once targets are resolved.
|
|
439
456
|
const startupCheckTimer = setTimeout(() => {
|
|
440
457
|
for (const device of config.devices) {
|
|
441
|
-
if (device.repaintTrigger === "interval" && !device.forceRepaint && device.device !== config_1.ALL_DEVICES) {
|
|
458
|
+
if (device.repaintTrigger === "interval" && !device.advanced?.forceRepaint && device.device !== config_1.ALL_DEVICES) {
|
|
442
459
|
const hours = device.intervalHours ?? 1;
|
|
443
460
|
const minute = device.intervalMinute ?? 0;
|
|
444
461
|
const dueSlot = mostRecentScheduledSlot(new Date(), hours, minute);
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rhizomatics/signalk-einklabel-plugin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0-beta10",
|
|
4
4
|
"description": "Display SignalK data on eInk Electronic Shelf Labels, includes working examples for tide clock and watch schedule.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ble",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"pngjs": "^7.0.0"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
|
-
"@signalk/server-api": "^2.
|
|
67
|
+
"@signalk/server-api": "^2.32.0",
|
|
68
68
|
"@types/luxon": "^3.7.1",
|
|
69
69
|
"@types/node": "^26.4.0",
|
|
70
70
|
"@types/pngjs": "^6.0.5",
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"typescript": "^5.5.0"
|
|
76
76
|
},
|
|
77
77
|
"engines": {
|
|
78
|
-
"node": ">=
|
|
78
|
+
"node": ">=22"
|
|
79
79
|
},
|
|
80
80
|
"signalk": {
|
|
81
81
|
"displayName": "eInk Label Displays",
|