@rhizomatics/signalk-einklabel-plugin 1.3.0-beta8 → 1.3.0
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 +36 -1
- package/README.md +21 -511
- package/dist/cli/index.d.ts +4 -1
- package/dist/cli/index.js +26 -1
- package/dist/config.d.ts +62 -15
- package/dist/config.js +252 -60
- package/dist/devices/bleBackend.js +44 -7
- package/dist/devices/bleDiscovery.d.ts +5 -0
- package/dist/devices/bleDiscovery.js +18 -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.js +9 -3
- package/dist/devices/gicisky/layout.d.ts +13 -1
- package/dist/devices/gicisky/layout.js +21 -0
- package/dist/devices/types.d.ts +26 -0
- 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.js +44 -9
- package/dist/devices/zhsunyco/protocol.d.ts +2 -0
- package/dist/devices/zhsunyco/protocol.js +2 -0
- package/dist/docs/templateReference.d.ts +18 -0
- package/dist/docs/templateReference.js +207 -0
- package/dist/email/emailSender.d.ts +27 -0
- package/dist/email/emailSender.js +66 -0
- package/dist/plugin.js +2 -2
- package/dist/render/fieldsTable.d.ts +15 -0
- package/dist/render/fieldsTable.js +52 -0
- package/dist/render/llmPrompt.d.ts +86 -0
- package/dist/render/llmPrompt.js +199 -0
- package/dist/render/mirror.d.ts +11 -0
- package/dist/render/mirror.js +24 -0
- package/dist/repaintScheduler.d.ts +8 -0
- package/dist/repaintScheduler.js +86 -29
- package/dist/resolveApiUrl.js +3 -0
- package/docs/bluetooth.md +162 -0
- package/docs/cli.md +131 -0
- package/docs/examples/README.md +25 -0
- package/docs/examples/tide-clock.md +93 -0
- package/docs/examples/watch-schedule.md +37 -0
- package/docs/extending.md +33 -0
- package/docs/faq.md +44 -0
- package/docs/getting-started.md +111 -0
- package/docs/templates.md +142 -0
- package/package.json +16 -7
|
@@ -73,7 +73,16 @@ function withSessionWatchdog(conn, timeoutMs, forceClose) {
|
|
|
73
73
|
clearTimeout(timer);
|
|
74
74
|
if (killedError)
|
|
75
75
|
return; // already forced closed by the watchdog above
|
|
76
|
-
|
|
76
|
+
// The watchdog is off from here, so the disconnect needs its own limit: a disconnect that never
|
|
77
|
+
// finishes (e.g. over a Bluetooth link that's already gone) would otherwise hang the caller - and
|
|
78
|
+
// hide whatever error it was cleaning up after - with the connection and any claim still held.
|
|
79
|
+
try {
|
|
80
|
+
await (0, bleDiscovery_1.withDeadline)(conn.disconnect(), CLEANUP_TIMEOUT_MS, "disconnecting");
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
console.error(`${pluginVersion_1.PLUGIN_NAME}: ${err.message} - forcing the connection closed`);
|
|
84
|
+
await forceClose().catch(() => { });
|
|
85
|
+
}
|
|
77
86
|
},
|
|
78
87
|
};
|
|
79
88
|
}
|
|
@@ -168,6 +177,17 @@ async function ensureDeviceVisible(bleApi, pluginId, address, timeoutMs) {
|
|
|
168
177
|
throw new Error(`device ${address} isn't visible to the SignalK BLE Manager yet (out of range, asleep, or never seen) after waiting ${timeoutMs}ms`);
|
|
169
178
|
}
|
|
170
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* Upper bound on each BLE Manager release/disconnect call this plugin waits on. Neither has a timeout
|
|
182
|
+
* of its own, and a release has to disconnect the device, which can wait on a Bluetooth link that's
|
|
183
|
+
* already gone - left unbounded, one stuck release wedges every later paint behind it (see
|
|
184
|
+
* `exclusiveBleManagerAccess`).
|
|
185
|
+
*/
|
|
186
|
+
const CLEANUP_TIMEOUT_MS = 10000;
|
|
187
|
+
/** Waits for a cleanup call for at most `CLEANUP_TIMEOUT_MS`, ignoring its outcome - cleanup is best-effort. */
|
|
188
|
+
async function boundedCleanup(...calls) {
|
|
189
|
+
await (0, bleDiscovery_1.withDeadline)(Promise.allSettled(calls), CLEANUP_TIMEOUT_MS, "BLE Manager cleanup").catch((err) => console.error(`${pluginVersion_1.PLUGIN_NAME}: ${err.message} - carrying on`));
|
|
190
|
+
}
|
|
171
191
|
/** Upper bound on how long a timed-out `bleApi.connectGATT()` is given to settle server-side before a retry - see `bleApiBackend`. */
|
|
172
192
|
const CONNECT_SETTLE_GRACE_MS = 30000;
|
|
173
193
|
let bleManagerQueue = Promise.resolve();
|
|
@@ -194,7 +214,7 @@ function bleApiBackend(bleApi, pluginId) {
|
|
|
194
214
|
// registered under our own pluginId, which would otherwise block this connect with
|
|
195
215
|
// "already claimed" until the server restarts - see signalk-bluetti-plugin's BleManagerDevice
|
|
196
216
|
// for the same defensive call. A no-op if we don't currently hold the claim.
|
|
197
|
-
await bleApi.releaseGATTDevice(address, pluginId)
|
|
217
|
+
await boundedCleanup(bleApi.releaseGATTDevice(address, pluginId));
|
|
198
218
|
await ensureDeviceVisible(bleApi, pluginId, address, DEVICE_DISCOVERY_TIMEOUT_MS);
|
|
199
219
|
const connecting = bleApi.connectGATT(address, pluginId);
|
|
200
220
|
let timedOut = false;
|
|
@@ -210,7 +230,7 @@ function bleApiBackend(bleApi, pluginId) {
|
|
|
210
230
|
// disconnect again. The release is awaited (only the late disconnect is left in the
|
|
211
231
|
// background) so a caller retrying straight away - `withRetries` - can't race it with a new
|
|
212
232
|
// `connectGATT()` and get rejected with "has a GATT claim in progress" for its trouble.
|
|
213
|
-
await bleApi.releaseGATTDevice(address, pluginId)
|
|
233
|
+
await boundedCleanup(bleApi.releaseGATTDevice(address, pluginId));
|
|
214
234
|
// ...except a claim still *connecting* isn't in the server's claim table yet, only its pending
|
|
215
235
|
// set, which `releaseGATTDevice` doesn't touch - so while the server's own connect is still in
|
|
216
236
|
// flight, a retry's `connectGATT()` is rejected outright with "has a GATT claim in progress".
|
|
@@ -220,18 +240,35 @@ function bleApiBackend(bleApi, pluginId) {
|
|
|
220
240
|
(0, bleDiscovery_1.sleep)(Math.min(timeoutMs, CONNECT_SETTLE_GRACE_MS)).then(() => undefined),
|
|
221
241
|
]);
|
|
222
242
|
if (late) {
|
|
223
|
-
await
|
|
243
|
+
await boundedCleanup(bleApi.releaseGATTDevice(address, pluginId), late.disconnect());
|
|
224
244
|
}
|
|
225
245
|
else {
|
|
226
246
|
void connecting.then((c) => c.disconnect()).catch(() => { });
|
|
227
247
|
}
|
|
228
248
|
throw new Error(`connecting to device timed out after ${timeoutMs}ms`);
|
|
229
249
|
}
|
|
230
|
-
|
|
250
|
+
// Disconnecting alone doesn't always drop the server's claim - it only clears that itself once it
|
|
251
|
+
// sees the link close, which a half-dead link may never report - so every disconnect releases the
|
|
252
|
+
// claim explicitly too, leaving the device free for the next paint (or another plugin).
|
|
253
|
+
const released = {
|
|
254
|
+
read: conn.read.bind(conn),
|
|
255
|
+
write: conn.write.bind(conn),
|
|
256
|
+
startNotifications: conn.startNotifications.bind(conn),
|
|
257
|
+
stopNotifications: conn.stopNotifications.bind(conn),
|
|
258
|
+
discoverServices: conn.discoverServices.bind(conn),
|
|
259
|
+
onDisconnect: conn.onDisconnect.bind(conn),
|
|
260
|
+
get connected() {
|
|
261
|
+
return conn.connected;
|
|
262
|
+
},
|
|
263
|
+
async disconnect() {
|
|
264
|
+
await Promise.allSettled([conn.disconnect(), bleApi.releaseGATTDevice(address, pluginId)]);
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
return withSessionWatchdog(released, GATT_SESSION_WATCHDOG_MS, async () => {
|
|
231
268
|
// Belt-and-suspenders, matching the timeout-cleanup above: `releaseGATTDevice` is the
|
|
232
269
|
// authoritative claim release, `disconnect()` a secondary teardown of this specific handle -
|
|
233
270
|
// do both regardless of which (if either) itself hangs or rejects.
|
|
234
|
-
await
|
|
271
|
+
await boundedCleanup(bleApi.releaseGATTDevice(address, pluginId), conn.disconnect());
|
|
235
272
|
});
|
|
236
273
|
},
|
|
237
274
|
async waitForManufacturerData(address, manufacturerId, timeoutMs) {
|
|
@@ -246,7 +283,7 @@ function bleApiBackend(bleApi, pluginId) {
|
|
|
246
283
|
// this wait forever, since nothing else in this path ever calls `connectGatt` (and so never gets a
|
|
247
284
|
// chance to release the stale claim) unless a fresh advertisement shows up first. A no-op if we
|
|
248
285
|
// don't currently hold the claim.
|
|
249
|
-
await bleApi.releaseGATTDevice(address, pluginId)
|
|
286
|
+
await boundedCleanup(bleApi.releaseGATTDevice(address, pluginId));
|
|
250
287
|
return new Promise((resolve) => {
|
|
251
288
|
const timer = setTimeout(() => {
|
|
252
289
|
unsubscribe();
|
|
@@ -40,6 +40,11 @@ export declare function forEachAdvertisedDevice(adapter: Adapter, fn: (advertise
|
|
|
40
40
|
* last one that's eventually thrown - an early attempt's error is often the real cause, with later
|
|
41
41
|
* ones just fallout from it.
|
|
42
42
|
*/
|
|
43
|
+
/**
|
|
44
|
+
* Rejects with `${what} timed out after ${ms}ms` if `promise` hasn't settled by then. The underlying
|
|
45
|
+
* work can't be cancelled - this only lets the caller stop waiting for it.
|
|
46
|
+
*/
|
|
47
|
+
export declare function withDeadline<T>(promise: Promise<T>, ms: number, what: string): Promise<T>;
|
|
43
48
|
export declare function withRetries<T>(attempts: number, fn: (attempt: number) => Promise<T>, { delayMs, onError }?: {
|
|
44
49
|
delayMs?: number;
|
|
45
50
|
onError?: (err: unknown, attempt: number) => void;
|
|
@@ -4,6 +4,7 @@ exports.sleep = sleep;
|
|
|
4
4
|
exports.createBluetooth = createBluetooth;
|
|
5
5
|
exports.getManufacturerId = getManufacturerId;
|
|
6
6
|
exports.forEachAdvertisedDevice = forEachAdvertisedDevice;
|
|
7
|
+
exports.withDeadline = withDeadline;
|
|
7
8
|
exports.withRetries = withRetries;
|
|
8
9
|
exports.withDiscovery = withDiscovery;
|
|
9
10
|
exports.connectWithTimeout = connectWithTimeout;
|
|
@@ -74,6 +75,23 @@ async function forEachAdvertisedDevice(adapter, fn) {
|
|
|
74
75
|
* last one that's eventually thrown - an early attempt's error is often the real cause, with later
|
|
75
76
|
* ones just fallout from it.
|
|
76
77
|
*/
|
|
78
|
+
/**
|
|
79
|
+
* Rejects with `${what} timed out after ${ms}ms` if `promise` hasn't settled by then. The underlying
|
|
80
|
+
* work can't be cancelled - this only lets the caller stop waiting for it.
|
|
81
|
+
*/
|
|
82
|
+
async function withDeadline(promise, ms, what) {
|
|
83
|
+
let timer;
|
|
84
|
+
const deadline = new Promise((_, reject) => {
|
|
85
|
+
timer = setTimeout(() => reject(new Error(`${what} timed out after ${ms}ms`)), ms);
|
|
86
|
+
});
|
|
87
|
+
promise.catch(() => { }); // observed here too, so losing the race never surfaces as an unhandled rejection
|
|
88
|
+
try {
|
|
89
|
+
return await Promise.race([promise, deadline]);
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
77
95
|
async function withRetries(attempts, fn, { delayMs = 0, onError } = {}) {
|
|
78
96
|
const total = Math.max(1, attempts);
|
|
79
97
|
let lastErr;
|
|
@@ -1,16 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Wire framing for `packing: "chunked"` devices (the 7.5"/10.2" panels).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
4
|
+
* Each 64-byte chunk of a plane is sent either as a `0x75`-tagged QuickLZ-compressed block or a
|
|
5
|
+
* `0x74`-tagged raw one, whichever the compressor decides - a port of hass-gicisky's
|
|
6
|
+
* `gicisky_ble/compression.py`. That's QuickLZ Level 1, but with the vendor firmware's 6-bit
|
|
7
|
+
* (64-bucket) hash rather than stock QuickLZ's 12-bit one: match tokens carry a hash-table slot, not
|
|
8
|
+
* an offset, and the panel's decoder only ever fills 64 slots - a token naming a slot above that
|
|
9
|
+
* reads one that was never written and silently decodes garbage. So this must stay byte-compatible
|
|
10
|
+
* with the vendor's hash, not just any valid QuickLZ.
|
|
11
|
+
*
|
|
12
|
+
* One deliberate difference from hass-gicisky: `UNCONDITIONAL_MATCHLEN` is stock QuickLZ's 6, not
|
|
13
|
+
* its 12, so matches may start closer to a chunk's end. That's what the vendor's own app does - with
|
|
14
|
+
* 6 this reproduces 483 of the 486 distinct `0x75` chunks in Cabalist's BLE captures of the app
|
|
15
|
+
* (https://github.com/Cabalist/gicisky_image_notes) byte for byte, against 408 with 12. The other 3
|
|
16
|
+
* are chunks the app sends "compressed" despite them growing; this sends those raw (`0x74`) instead.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* QuickLZ L1 compression of one chunk, mirroring `_qlz_compress_core` step for step (including its
|
|
20
|
+
* quirks, e.g. the run-of-identical-bytes special case) so the output matches the vendor app's byte for
|
|
21
|
+
* byte. Returns `undefined` when compressing wouldn't save anything.
|
|
11
22
|
*/
|
|
23
|
+
export declare function qlzCompressChunk(source: Buffer): Buffer | undefined;
|
|
12
24
|
/**
|
|
13
25
|
* Frames two equal-length bit-planes (e.g. BW and red) as `[4-byte LE length of planeB]` followed
|
|
14
|
-
* by each plane's
|
|
26
|
+
* by each plane's chunked bytes, matching `compress()`'s output shape in the reference driver.
|
|
27
|
+
* `compress: false` sends every chunk raw (`0x74`), like hass-gicisky's `force_raw`.
|
|
15
28
|
*/
|
|
16
|
-
export declare function frameChunkedPlanes(planeA: Buffer, planeB: Buffer): Buffer;
|
|
29
|
+
export declare function frameChunkedPlanes(planeA: Buffer, planeB: Buffer, compress?: boolean): Buffer;
|
|
@@ -2,33 +2,148 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Wire framing for `packing: "chunked"` devices (the 7.5"/10.2" panels).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
5
|
+
* Each 64-byte chunk of a plane is sent either as a `0x75`-tagged QuickLZ-compressed block or a
|
|
6
|
+
* `0x74`-tagged raw one, whichever the compressor decides - a port of hass-gicisky's
|
|
7
|
+
* `gicisky_ble/compression.py`. That's QuickLZ Level 1, but with the vendor firmware's 6-bit
|
|
8
|
+
* (64-bucket) hash rather than stock QuickLZ's 12-bit one: match tokens carry a hash-table slot, not
|
|
9
|
+
* an offset, and the panel's decoder only ever fills 64 slots - a token naming a slot above that
|
|
10
|
+
* reads one that was never written and silently decodes garbage. So this must stay byte-compatible
|
|
11
|
+
* with the vendor's hash, not just any valid QuickLZ.
|
|
12
|
+
*
|
|
13
|
+
* One deliberate difference from hass-gicisky: `UNCONDITIONAL_MATCHLEN` is stock QuickLZ's 6, not
|
|
14
|
+
* its 12, so matches may start closer to a chunk's end. That's what the vendor's own app does - with
|
|
15
|
+
* 6 this reproduces 483 of the 486 distinct `0x75` chunks in Cabalist's BLE captures of the app
|
|
16
|
+
* (https://github.com/Cabalist/gicisky_image_notes) byte for byte, against 408 with 12. The other 3
|
|
17
|
+
* are chunks the app sends "compressed" despite them growing; this sends those raw (`0x74`) instead.
|
|
12
18
|
*/
|
|
13
19
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.qlzCompressChunk = qlzCompressChunk;
|
|
14
21
|
exports.frameChunkedPlanes = frameChunkedPlanes;
|
|
15
22
|
const CHUNK_SIZE = 64;
|
|
16
23
|
const RAW_CHUNK_TAG = 0x74;
|
|
17
|
-
|
|
24
|
+
const COMPRESSED_CHUNK_TAG = 0x75;
|
|
25
|
+
const CWORD_LEN = 4;
|
|
26
|
+
const HASH_VALUES = 64;
|
|
27
|
+
const NO_ENTRY = -1;
|
|
28
|
+
const MIN_OFFSET = 2;
|
|
29
|
+
const UNCONDITIONAL_MATCHLEN = 6;
|
|
30
|
+
const UNCOMPRESSED_END = 4;
|
|
31
|
+
/** Control-word sentinel: the top bit marks where the 31 flag bits below it run out. */
|
|
32
|
+
const CWORD_SENTINEL = 0x80000000;
|
|
33
|
+
function hashOf(fetch) {
|
|
34
|
+
return ((fetch >>> 12) ^ fetch) & (HASH_VALUES - 1);
|
|
35
|
+
}
|
|
36
|
+
function read3(data, pos) {
|
|
37
|
+
return pos + 3 > data.length ? 0 : data[pos] | (data[pos + 1] << 8) | (data[pos + 2] << 16);
|
|
38
|
+
}
|
|
39
|
+
/** Whether the `n + 1` bytes from `pos` are all equal. */
|
|
40
|
+
function allSame(data, pos, n) {
|
|
41
|
+
if (pos < 0 || pos + n >= data.length)
|
|
42
|
+
return false;
|
|
43
|
+
for (let i = 1; i <= n; i++) {
|
|
44
|
+
if (data[pos + i] !== data[pos])
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* QuickLZ L1 compression of one chunk, mirroring `_qlz_compress_core` step for step (including its
|
|
51
|
+
* quirks, e.g. the run-of-identical-bytes special case) so the output matches the vendor app's byte for
|
|
52
|
+
* byte. Returns `undefined` when compressing wouldn't save anything.
|
|
53
|
+
*/
|
|
54
|
+
function qlzCompressChunk(source) {
|
|
55
|
+
const size = source.length;
|
|
56
|
+
const lastByte = size - 1;
|
|
57
|
+
const lastMatchStart = lastByte - UNCONDITIONAL_MATCHLEN - UNCOMPRESSED_END;
|
|
58
|
+
if (lastMatchStart < 0)
|
|
59
|
+
return undefined;
|
|
60
|
+
const out = Buffer.alloc(size * 2 + 400);
|
|
61
|
+
let cwordPtr = 0;
|
|
62
|
+
let dst = CWORD_LEN;
|
|
63
|
+
let cword = CWORD_SENTINEL;
|
|
64
|
+
let src = 0;
|
|
65
|
+
let lits = 0;
|
|
66
|
+
const hashOffset = new Int32Array(HASH_VALUES).fill(NO_ENTRY);
|
|
67
|
+
const hashCache = new Int32Array(HASH_VALUES);
|
|
68
|
+
const flushCword = () => {
|
|
69
|
+
out.writeUInt32LE(((cword >>> 1) | CWORD_SENTINEL) >>> 0, cwordPtr);
|
|
70
|
+
cwordPtr = dst;
|
|
71
|
+
dst += CWORD_LEN;
|
|
72
|
+
cword = CWORD_SENTINEL;
|
|
73
|
+
};
|
|
74
|
+
while (src <= lastMatchStart) {
|
|
75
|
+
if ((cword & 1) === 1) {
|
|
76
|
+
if (src > size >> 1 && dst > src - (src >> 5))
|
|
77
|
+
return undefined;
|
|
78
|
+
flushCword();
|
|
79
|
+
}
|
|
80
|
+
const fetch = read3(source, src);
|
|
81
|
+
const h = hashOf(fetch);
|
|
82
|
+
const cached = fetch ^ hashCache[h];
|
|
83
|
+
hashCache[h] = fetch;
|
|
84
|
+
const o = hashOffset[h];
|
|
85
|
+
hashOffset[h] = src;
|
|
86
|
+
if ((cached & 0xffffff) === 0 &&
|
|
87
|
+
o !== NO_ENTRY &&
|
|
88
|
+
(src - o > MIN_OFFSET || (src === o + 1 && lits >= 3 && src > 3 && allSame(source, src - 3, 6)))) {
|
|
89
|
+
let matchLen = 3;
|
|
90
|
+
const remaining = Math.min(255, lastByte - UNCOMPRESSED_END - src + 1);
|
|
91
|
+
while (matchLen < remaining && source[src + matchLen] === source[o + matchLen])
|
|
92
|
+
matchLen++;
|
|
93
|
+
const hShifted = h << 4;
|
|
94
|
+
cword = ((cword >>> 1) | CWORD_SENTINEL) >>> 0;
|
|
95
|
+
if (matchLen < 18) {
|
|
96
|
+
out.writeUInt16LE((matchLen - 2) | hShifted, dst);
|
|
97
|
+
dst += 2;
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
out.writeUInt16LE(hShifted, dst);
|
|
101
|
+
out[dst + 2] = matchLen;
|
|
102
|
+
dst += 3;
|
|
103
|
+
}
|
|
104
|
+
src += matchLen;
|
|
105
|
+
lits = 0;
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
lits++;
|
|
109
|
+
out[dst++] = source[src++];
|
|
110
|
+
cword >>>= 1;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
while (src <= lastByte) {
|
|
114
|
+
if ((cword & 1) === 1)
|
|
115
|
+
flushCword();
|
|
116
|
+
if (src <= lastByte - 2) {
|
|
117
|
+
const f = read3(source, src);
|
|
118
|
+
const hh = hashOf(f);
|
|
119
|
+
hashCache[hh] = f;
|
|
120
|
+
hashOffset[hh] = src;
|
|
121
|
+
}
|
|
122
|
+
out[dst++] = source[src++];
|
|
123
|
+
cword >>>= 1;
|
|
124
|
+
}
|
|
125
|
+
while ((cword & 1) !== 1)
|
|
126
|
+
cword >>>= 1;
|
|
127
|
+
out.writeUInt32LE(((cword >>> 1) | CWORD_SENTINEL) >>> 0, cwordPtr);
|
|
128
|
+
return dst >= size ? undefined : out.subarray(0, dst);
|
|
129
|
+
}
|
|
130
|
+
function chunkPlane(data, compress) {
|
|
18
131
|
const chunks = [];
|
|
19
132
|
for (let offset = 0; offset < data.length; offset += CHUNK_SIZE) {
|
|
20
133
|
const chunk = data.subarray(offset, Math.min(offset + CHUNK_SIZE, data.length));
|
|
21
|
-
const
|
|
22
|
-
|
|
134
|
+
const compressed = compress ? qlzCompressChunk(chunk) : undefined;
|
|
135
|
+
const body = compressed ?? chunk;
|
|
136
|
+
chunks.push(Buffer.from([compressed ? COMPRESSED_CHUNK_TAG : RAW_CHUNK_TAG, 3 + body.length, chunk.length]), body);
|
|
23
137
|
}
|
|
24
138
|
return Buffer.concat(chunks);
|
|
25
139
|
}
|
|
26
140
|
/**
|
|
27
141
|
* Frames two equal-length bit-planes (e.g. BW and red) as `[4-byte LE length of planeB]` followed
|
|
28
|
-
* by each plane's
|
|
142
|
+
* by each plane's chunked bytes, matching `compress()`'s output shape in the reference driver.
|
|
143
|
+
* `compress: false` sends every chunk raw (`0x74`), like hass-gicisky's `force_raw`.
|
|
29
144
|
*/
|
|
30
|
-
function frameChunkedPlanes(planeA, planeB) {
|
|
145
|
+
function frameChunkedPlanes(planeA, planeB, compress = true) {
|
|
31
146
|
const header = Buffer.alloc(4);
|
|
32
147
|
header.writeUInt32LE(planeB.length, 0);
|
|
33
|
-
return Buffer.concat([header,
|
|
148
|
+
return Buffer.concat([header, chunkPlane(planeA, compress), chunkPlane(planeB, compress)]);
|
|
34
149
|
}
|
|
@@ -7,4 +7,4 @@ import { GiciskyLayout } from "./layout";
|
|
|
7
7
|
* colour selection uses palette-nearest classification (matching this codebase's `zhsunyco`
|
|
8
8
|
* driver) rather than the reference driver's raw-luminance thresholds.
|
|
9
9
|
*/
|
|
10
|
-
export declare function encodeBitmap(bitmap: Bitmap, metadata: DeviceMetadata, layout: GiciskyLayout): Buffer;
|
|
10
|
+
export declare function encodeBitmap(bitmap: Bitmap, metadata: DeviceMetadata, layout: GiciskyLayout, compress?: boolean): Buffer;
|
|
@@ -115,7 +115,7 @@ function packFourColour(bitmap, layout, supported) {
|
|
|
115
115
|
* colour selection uses palette-nearest classification (matching this codebase's `zhsunyco`
|
|
116
116
|
* driver) rather than the reference driver's raw-luminance thresholds.
|
|
117
117
|
*/
|
|
118
|
-
function encodeBitmap(bitmap, metadata, layout) {
|
|
118
|
+
function encodeBitmap(bitmap, metadata, layout, compress = true) {
|
|
119
119
|
if (layout.packing === "unsupported") {
|
|
120
120
|
throw new Error(`gicisky paint: device "${metadata.label}" isn't supported yet (needs compression/resize support this driver doesn't implement)`);
|
|
121
121
|
}
|
|
@@ -134,7 +134,7 @@ function encodeBitmap(bitmap, metadata, layout) {
|
|
|
134
134
|
}
|
|
135
135
|
const redPlane = packPlane(rotated, layout, supported, (colour) => colour === "red");
|
|
136
136
|
if (layout.packing === "chunked") {
|
|
137
|
-
return (0, compression_1.frameChunkedPlanes)(bwPlane, redPlane);
|
|
137
|
+
return (0, compression_1.frameChunkedPlanes)(bwPlane, redPlane, compress);
|
|
138
138
|
}
|
|
139
139
|
return Buffer.concat([bwPlane, redPlane]);
|
|
140
140
|
}
|
|
@@ -6,6 +6,7 @@ const metadata_1 = require("./metadata");
|
|
|
6
6
|
const layout_1 = require("./layout");
|
|
7
7
|
const encode_1 = require("./encode");
|
|
8
8
|
const reframe_1 = require("../../render/reframe");
|
|
9
|
+
const mirror_1 = require("../../render/mirror");
|
|
9
10
|
const protocol_1 = require("./protocol");
|
|
10
11
|
/** How long to actively rescan for a fresh advertisement when the cached one is missing/stale - see `BleBackend.waitForManufacturerData`. */
|
|
11
12
|
const MANUFACTURER_DATA_RESCAN_TIMEOUT_MS = 15000;
|
|
@@ -69,10 +70,13 @@ class GiciskyDriver {
|
|
|
69
70
|
: `gicisky device reports unrecognised deviceId 0x${pid.toString(16).padStart(4, "0")} - ` +
|
|
70
71
|
"pass --width/--height/--voffset/--colours to describe it manually");
|
|
71
72
|
}
|
|
72
|
-
const layout = (pid !== undefined && layout_1.GICISKY_PID_LAYOUT[pid]) || (0, layout_1.defaultLayoutFor)(metadata.colours);
|
|
73
|
-
const framed = (0, reframe_1.reframeBitmap)(bitmap, metadata.width, metadata.height, config.reframe ?? "crop");
|
|
74
|
-
const payload = (0, encode_1.encodeBitmap)(framed, metadata, layout);
|
|
73
|
+
const layout = (0, layout_1.withCompressionFormat)((pid !== undefined && layout_1.GICISKY_PID_LAYOUT[pid]) || (0, layout_1.defaultLayoutFor)(metadata.colours), config.compressionFormat ?? "auto", metadata.colours);
|
|
74
|
+
const framed = (0, mirror_1.mirrorBitmap)((0, reframe_1.reframeBitmap)(bitmap, metadata.width, metadata.height, config.reframe ?? "crop"), config.mirror ?? "none");
|
|
75
|
+
const payload = (0, encode_1.encodeBitmap)(framed, metadata, layout, config.compress ?? true);
|
|
76
|
+
const log = config.log ?? (() => { });
|
|
77
|
+
log("connecting");
|
|
75
78
|
const conn = await backend.connectGatt(config.address, config.connectTimeoutMs ?? DEFAULT_PAINT_CONNECT_TIMEOUT_MS);
|
|
79
|
+
log("connected");
|
|
76
80
|
try {
|
|
77
81
|
const { cmdServiceUuid, cmdUuid, imgServiceUuid, imgUuid } = await findCommandAndImageCharacteristics(conn);
|
|
78
82
|
const ack = new AckChannel();
|
|
@@ -86,6 +90,7 @@ class GiciskyDriver {
|
|
|
86
90
|
if (!started?.ok) {
|
|
87
91
|
throw new Error(`gicisky device rejected start-image-transfer request: ${startImageAck.toString("hex")}`);
|
|
88
92
|
}
|
|
93
|
+
log(`uploading ${payload.length} bytes in ${Math.ceil(payload.length / chunkSize)} parts`);
|
|
89
94
|
let part = started.nextPart;
|
|
90
95
|
let lastPart = -1;
|
|
91
96
|
let repeats = 0;
|
|
@@ -114,6 +119,7 @@ class GiciskyDriver {
|
|
|
114
119
|
}
|
|
115
120
|
part = decoded.nextPart;
|
|
116
121
|
}
|
|
122
|
+
log("upload complete");
|
|
117
123
|
}
|
|
118
124
|
finally {
|
|
119
125
|
await conn.stopNotifications(cmdServiceUuid, cmdUuid).catch(() => { });
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CompressionFormat } from "../types";
|
|
1
2
|
/**
|
|
2
3
|
* Per-model wire-layout quirks, keyed the same way as `GICISKY_PID_METADATA` (by `deviceId`).
|
|
3
4
|
* These aren't part of the shared `DeviceMetadata` shape since no other vendor needs them -
|
|
@@ -9,7 +10,7 @@
|
|
|
9
10
|
export type GiciskyPacking =
|
|
10
11
|
/** Plain concatenated bit-planes, no framing - covers most panels. */
|
|
11
12
|
"plain"
|
|
12
|
-
/** Two bit-planes each split into
|
|
13
|
+
/** Two bit-planes each split into 64-byte chunks (QuickLZ-compressed or raw) framed per `compression.ts` - the 7.5"/10.2" panels. */
|
|
13
14
|
| "chunked"
|
|
14
15
|
/**
|
|
15
16
|
* A panel this driver can identify (for `scan`/discovery) but can't paint correctly yet - either
|
|
@@ -33,3 +34,14 @@ export interface GiciskyLayout {
|
|
|
33
34
|
export declare const GICISKY_PID_LAYOUT: Record<number, GiciskyLayout>;
|
|
34
35
|
/** Best-effort layout for a `modelOverride`d PID this table has no entry for - assumes the common case. */
|
|
35
36
|
export declare function defaultLayoutFor(colours: string[]): GiciskyLayout;
|
|
37
|
+
/**
|
|
38
|
+
* Applies a user's opt-in `compressionFormat: "chunked"` - switching a `"plain"` two-plane (BW + red)
|
|
39
|
+
* layout to the QuickLZ-chunked framing, with the flagged `writeScreen` command that goes with it.
|
|
40
|
+
* Opt-in rather than a model default because only one data point says a plain panel accepts it:
|
|
41
|
+
* Cabalist's 2023 BLE captures (https://github.com/Cabalist/gicisky_image_notes) of the vendor app
|
|
42
|
+
* sending a 4.2" BWR exactly this way, on firmware that may not match what's shipping now.
|
|
43
|
+
*
|
|
44
|
+
* Throws for layouts the framing can't carry, rather than quietly sending plain anyway - the user
|
|
45
|
+
* asked for chunked, so a repaint error says why they're not getting it.
|
|
46
|
+
*/
|
|
47
|
+
export declare function withCompressionFormat(layout: GiciskyLayout, format: CompressionFormat, colours: string[]): GiciskyLayout;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.GICISKY_PID_LAYOUT = void 0;
|
|
4
4
|
exports.defaultLayoutFor = defaultLayoutFor;
|
|
5
|
+
exports.withCompressionFormat = withCompressionFormat;
|
|
5
6
|
const DEFAULT_LAYOUT = {
|
|
6
7
|
rotation: 0,
|
|
7
8
|
mirrorX: false,
|
|
@@ -33,3 +34,23 @@ exports.GICISKY_PID_LAYOUT = {
|
|
|
33
34
|
function defaultLayoutFor(colours) {
|
|
34
35
|
return { ...DEFAULT_LAYOUT, fourColour: colours.includes("yellow") };
|
|
35
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Applies a user's opt-in `compressionFormat: "chunked"` - switching a `"plain"` two-plane (BW + red)
|
|
39
|
+
* layout to the QuickLZ-chunked framing, with the flagged `writeScreen` command that goes with it.
|
|
40
|
+
* Opt-in rather than a model default because only one data point says a plain panel accepts it:
|
|
41
|
+
* Cabalist's 2023 BLE captures (https://github.com/Cabalist/gicisky_image_notes) of the vendor app
|
|
42
|
+
* sending a 4.2" BWR exactly this way, on firmware that may not match what's shipping now.
|
|
43
|
+
*
|
|
44
|
+
* Throws for layouts the framing can't carry, rather than quietly sending plain anyway - the user
|
|
45
|
+
* asked for chunked, so a repaint error says why they're not getting it.
|
|
46
|
+
*/
|
|
47
|
+
function withCompressionFormat(layout, format, colours) {
|
|
48
|
+
if (format === "auto" || layout.packing === "chunked") {
|
|
49
|
+
return layout;
|
|
50
|
+
}
|
|
51
|
+
if (layout.packing !== "plain" || layout.fourColour || !colours.includes("red")) {
|
|
52
|
+
throw new Error('gicisky paint: compressionFormat "chunked" needs a panel sent as separate black/white and red planes - ' +
|
|
53
|
+
'this model isn\'t (four-colour, black/white only, or unsupported) - set it back to "auto"');
|
|
54
|
+
}
|
|
55
|
+
return { ...layout, packing: "chunked" };
|
|
56
|
+
}
|
package/dist/devices/types.d.ts
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import { Bitmap } from "../render/types";
|
|
2
2
|
import { ReframeMode } from "../render/reframe";
|
|
3
|
+
import { MirrorMode } from "../render/mirror";
|
|
3
4
|
import { BleBackend } from "./bleBackend";
|
|
4
5
|
import { GattConnection } from "./gattConnection";
|
|
5
6
|
export type Colour = "black" | "white" | "red" | "yellow";
|
|
7
|
+
/**
|
|
8
|
+
* Which wire format to send in - `"auto"` uses whatever the driver's model table says;
|
|
9
|
+
* `"chunked"` opts a gicisky panel with separate BW/red planes (e.g. the 4.2" BWR, normally sent
|
|
10
|
+
* plain) into the QuickLZ-compressed chunk framing the 7.5"/10.2" use - see `withCompressionFormat`
|
|
11
|
+
* in `gicisky/layout.ts`. Ignored by other vendors.
|
|
12
|
+
*/
|
|
13
|
+
export type CompressionFormat = "auto" | "chunked";
|
|
14
|
+
export declare const COMPRESSION_FORMATS: CompressionFormat[];
|
|
6
15
|
/**
|
|
7
16
|
* Static facts about one device model, keyed by (vendor, pid) by the registry —
|
|
8
17
|
* PID alone is not assumed unique across vendors.
|
|
@@ -68,6 +77,23 @@ export interface VendorDeviceConfig {
|
|
|
68
77
|
connectTimeoutMs?: number;
|
|
69
78
|
/** How to fit the bitmap onto the panel when its size doesn't already match - see `ReframeMode`. Defaults to `"crop"` - a live label showing *something*, even off-size, beats a repaint that just fails outright; pass `"fixed"` explicitly to get the old reject-the-mismatch behaviour back. */
|
|
70
79
|
reframe?: ReframeMode;
|
|
80
|
+
/** Flips the image after reframing, before encoding - see `MirrorMode`. Defaults to `"none"`. */
|
|
81
|
+
mirror?: MirrorMode;
|
|
82
|
+
/** Compress the upload where the vendor protocol supports it (zhsunyco; gicisky's chunked 7.5"/10.2" panels); ignored otherwise. Defaults to `true`. */
|
|
83
|
+
compress?: boolean;
|
|
84
|
+
/** Overrides the model's own wire format - see `CompressionFormat`. Defaults to `"auto"`. */
|
|
85
|
+
compressionFormat?: CompressionFormat;
|
|
86
|
+
/**
|
|
87
|
+
* Send the image without waiting for each write to be acknowledged (zhsunyco; ignored otherwise).
|
|
88
|
+
* Works around SignalK's BLE Manager (2.33 and earlier) sending every acknowledged write as a
|
|
89
|
+
* "reliable" write, which zhsunyco labels reject with ATT error 0x0e. Defaults to `false`.
|
|
90
|
+
*/
|
|
91
|
+
writeWithoutResponse?: boolean;
|
|
92
|
+
/**
|
|
93
|
+
* Receives one line per paint step (connecting, connected, uploading, ...), so a paint that stalls
|
|
94
|
+
* shows in the log where it stopped. Omitted means no step logging.
|
|
95
|
+
*/
|
|
96
|
+
log?: (message: string) => void;
|
|
71
97
|
/**
|
|
72
98
|
* How `paint()` reaches the device's BLE hardware - omitted (always true for the CLI, which has no
|
|
73
99
|
* `ServerAPI`/`app.bleApi` to source one from) means direct BlueZ access via a fresh
|
package/dist/devices/types.js
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function compressWolinkBlocks(data: Buffer): Buffer;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.compressWolinkBlocks = compressWolinkBlocks;
|
|
4
|
+
const zlib_1 = require("zlib");
|
|
5
|
+
/**
|
|
6
|
+
* Wolink block-deflate payload format, ported from the `zhsunyco_esl` package bundled with the
|
|
7
|
+
* Home Assistant Wolink ESL integration (`zhsunyco_esl/protocol.py`):
|
|
8
|
+
*
|
|
9
|
+
* A5 A6 <blockCount u8> 02, then per block: <1-based index u8> <compressedLength u16le> <raw deflate>
|
|
10
|
+
*
|
|
11
|
+
* Each block holds up to 8 KB of the uncompressed 2bpp buffer. Sent in place of the raw buffer,
|
|
12
|
+
* followed by `COMMAND.refreshCompressed` carrying the compressed length.
|
|
13
|
+
*/
|
|
14
|
+
const WOLINK_BLOCK_SIZE = 8192;
|
|
15
|
+
const WOLINK_BLOCK_FORMAT = 0x02;
|
|
16
|
+
function compressWolinkBlocks(data) {
|
|
17
|
+
const blockCount = Math.ceil(data.length / WOLINK_BLOCK_SIZE);
|
|
18
|
+
if (blockCount > 0xff) {
|
|
19
|
+
throw new Error(`zhsunyco compression: ${data.length} bytes needs ${blockCount} blocks, format allows at most 255`);
|
|
20
|
+
}
|
|
21
|
+
const parts = [Buffer.from([0xa5, 0xa6, blockCount, WOLINK_BLOCK_FORMAT])];
|
|
22
|
+
for (let i = 0; i < blockCount; i++) {
|
|
23
|
+
const deflated = (0, zlib_1.deflateRawSync)(data.subarray(i * WOLINK_BLOCK_SIZE, (i + 1) * WOLINK_BLOCK_SIZE), { level: 9 });
|
|
24
|
+
const header = Buffer.alloc(3);
|
|
25
|
+
header[0] = i + 1;
|
|
26
|
+
header.writeUInt16LE(deflated.length, 1);
|
|
27
|
+
parts.push(header, deflated);
|
|
28
|
+
}
|
|
29
|
+
return Buffer.concat(parts);
|
|
30
|
+
}
|