@rhizomatics/signalk-einklabel-plugin 1.3.0-beta1 → 1.3.0-beta11

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.
@@ -18,3 +18,21 @@ export declare function scanInProgressSince(): number | undefined;
18
18
  * one, which would make both fail.
19
19
  */
20
20
  export declare function ensureScan(app: ServerAPI, durationSeconds: number, useBleApi: boolean): Promise<ScanResult>;
21
+ /**
22
+ * Keeps the "Device" picker (and `ALL_DEVICES`) current for as long as the plugin runs, without any
23
+ * bounded "scan" step at all - unlike `runScanViaBleApi`/`runScanViaBlueZ` above, which only ever look
24
+ * for new devices during an explicit, timed window (`scanOnStart`, or an on-demand `ALL_DEVICES` scan).
25
+ * BLE Manager already runs its own continuous scan server-side to build the device list the admin UI's
26
+ * "BLE Manager" page shows - a device that's visible there but never appears in *this* plugin's own
27
+ * picker unless `scanOnStart` happens to be ticked is exactly that mismatch: nothing was ever listening
28
+ * for BLE Manager's advertisements outside a bounded window. This instead subscribes once, for good,
29
+ * so a device shows up here as soon as BLE Manager itself has seen it - no manual scan required.
30
+ *
31
+ * Only runs a genuinely new (or long-expired, see `DISCOVERED_DEVICE_TTL_MS`) address through
32
+ * `driver.identifyDevice()` - a real GATT connect+read. A device already in the persisted store just
33
+ * gets `lastSeenAt` bumped via `touchDiscoveredDevice`, since re-identifying it on every ~1s
34
+ * advertisement would otherwise hammer it (and the shared GATT slots) for no reason. `pending` guards
35
+ * against two advertisements for the same brand-new device starting a second identify attempt while
36
+ * the first is still connecting.
37
+ */
38
+ export declare function startBleApiDiscoveryListener(app: ServerAPI, pluginId: string): () => void;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.scanInProgressSince = scanInProgressSince;
4
4
  exports.ensureScan = ensureScan;
5
+ exports.startBleApiDiscoveryListener = startBleApiDiscoveryListener;
5
6
  const bleDiscovery_1 = require("./bleDiscovery");
6
7
  const bleBackend_1 = require("./bleBackend");
7
8
  const discoveredDevicesStore_1 = require("./discoveredDevicesStore");
@@ -67,43 +68,55 @@ async function runScanViaBlueZ(app, durationSeconds) {
67
68
  const merged = (0, discoveredDevicesStore_1.recordScanResults)(app, foundThisScan);
68
69
  return { foundThisScan, merged };
69
70
  }
71
+ /**
72
+ * The `matchesAdvertisement`+`identifyDevice` half of processing one BLE Manager sighting - shared by
73
+ * `runScanViaBleApi`'s bounded scan and `startBleApiDiscoveryListener`'s indefinite one below. Doesn't
74
+ * decide *whether* to attempt identification (a bounded scan's own per-call `seen` set vs. the
75
+ * listener's persisted-store check need different answers to that) - just does the attempt and reports
76
+ * what it found, or `undefined` for "no matching driver"/"identify failed". `adv.manufacturerData`
77
+ * (from either an advertisement or, seeded, a `getDevices()` entry) is `Record<number, string>`
78
+ * (decimal company ID -> hex-encoded payload) rather than node-ble's `Buffer` - converted here so
79
+ * drivers never see the difference.
80
+ */
81
+ async function identifyOne(app, backend, mac, name, rssi, manufacturerDataHex) {
82
+ const [manufacturerIdKey] = Object.keys(manufacturerDataHex ?? {});
83
+ const manufacturerId = manufacturerIdKey === undefined ? undefined : Number(manufacturerIdKey);
84
+ const manufacturerData = manufacturerId === undefined ? undefined : Buffer.from(manufacturerDataHex[manufacturerId], "hex");
85
+ const driver = (0, registry_1.allDrivers)().find((candidate) => candidate.matchesAdvertisement(name, manufacturerId));
86
+ if (!driver) {
87
+ return undefined;
88
+ }
89
+ const connect = () => backend.connectGatt(mac, SCAN_GATT_CONNECT_TIMEOUT_MS);
90
+ const found = await driver.identifyDevice({ address: mac, name, manufacturerId, manufacturerData, rssi }, connect).catch((err) => {
91
+ app.debug(`${driver.vendor} discovery failed for [${mac}]: ${err.message}\n${err.stack ?? ""}`);
92
+ return undefined;
93
+ });
94
+ if (found) {
95
+ const pid = found.pid !== undefined ? `0x${found.pid.toString(16).padStart(4, "0")}` : "unknown";
96
+ const hwid = found.hwVersion ?? "unknown";
97
+ app.debug(`discovered ${driver.vendor} device "${found.name ?? ""}" [${found.address}] pid=${pid} hwid=${hwid}`);
98
+ }
99
+ return found;
100
+ }
70
101
  /**
71
102
  * Same as `runScanViaBlueZ` but sourced from the SignalK server's BLE Manager API instead of a direct
72
103
  * BlueZ discovery session - subscribes to the merged advertisement stream for `durationSeconds`,
73
104
  * seeded with whatever the server already knows about (mirrors `BleManagerScanner` in
74
- * signalk-bluetti-plugin). `app.bleApi.getDevices()`/advertisements don't carry manufacturer data
75
- * directly comparable to node-ble's `Buffer` - `adv.manufacturerData` is `Record<number, string>`
76
- * (decimal company ID -> hex-encoded payload), converted here so drivers never see the difference.
105
+ * signalk-bluetti-plugin).
77
106
  */
78
107
  async function runScanViaBleApi(app, durationSeconds) {
79
108
  const foundThisScan = [];
80
109
  const seen = new Set();
81
- const drivers = (0, registry_1.allDrivers)();
82
110
  const backend = (0, bleBackend_1.bleApiBackend)(app.bleApi, pluginVersion_1.PLUGIN_NAME);
83
111
  const identify = async (mac, name, rssi, manufacturerDataHex) => {
84
112
  if (seen.has(mac)) {
85
113
  return;
86
114
  }
87
- const [manufacturerIdKey] = Object.keys(manufacturerDataHex ?? {});
88
- const manufacturerId = manufacturerIdKey === undefined ? undefined : Number(manufacturerIdKey);
89
- const manufacturerData = manufacturerId === undefined ? undefined : Buffer.from(manufacturerDataHex[manufacturerId], "hex");
90
- const driver = drivers.find((candidate) => candidate.matchesAdvertisement(name, manufacturerId));
91
- if (!driver) {
92
- return;
93
- }
94
115
  seen.add(mac);
95
- const connect = () => backend.connectGatt(mac, SCAN_GATT_CONNECT_TIMEOUT_MS);
96
- const found = await driver.identifyDevice({ address: mac, name, manufacturerId, manufacturerData, rssi }, connect).catch((err) => {
97
- app.debug(`${driver.vendor} scan failed: ${err.message}\n${err.stack ?? ""}`);
98
- return undefined;
99
- });
100
- if (!found) {
101
- return;
116
+ const found = await identifyOne(app, backend, mac, name, rssi, manufacturerDataHex);
117
+ if (found) {
118
+ foundThisScan.push(found);
102
119
  }
103
- foundThisScan.push(found);
104
- const pid = found.pid !== undefined ? `0x${found.pid.toString(16).padStart(4, "0")}` : "unknown";
105
- const hwid = found.hwVersion ?? "unknown";
106
- app.debug(`discovered ${driver.vendor} device "${found.name ?? ""}" [${found.address}] pid=${pid} hwid=${hwid}`);
107
120
  };
108
121
  try {
109
122
  const unsubscribe = app.bleApi.onAdvertisement(pluginVersion_1.PLUGIN_NAME, (adv) => {
@@ -129,3 +142,57 @@ async function runScanViaBleApi(app, durationSeconds) {
129
142
  const merged = (0, discoveredDevicesStore_1.recordScanResults)(app, foundThisScan);
130
143
  return { foundThisScan, merged };
131
144
  }
145
+ /**
146
+ * Keeps the "Device" picker (and `ALL_DEVICES`) current for as long as the plugin runs, without any
147
+ * bounded "scan" step at all - unlike `runScanViaBleApi`/`runScanViaBlueZ` above, which only ever look
148
+ * for new devices during an explicit, timed window (`scanOnStart`, or an on-demand `ALL_DEVICES` scan).
149
+ * BLE Manager already runs its own continuous scan server-side to build the device list the admin UI's
150
+ * "BLE Manager" page shows - a device that's visible there but never appears in *this* plugin's own
151
+ * picker unless `scanOnStart` happens to be ticked is exactly that mismatch: nothing was ever listening
152
+ * for BLE Manager's advertisements outside a bounded window. This instead subscribes once, for good,
153
+ * so a device shows up here as soon as BLE Manager itself has seen it - no manual scan required.
154
+ *
155
+ * Only runs a genuinely new (or long-expired, see `DISCOVERED_DEVICE_TTL_MS`) address through
156
+ * `driver.identifyDevice()` - a real GATT connect+read. A device already in the persisted store just
157
+ * gets `lastSeenAt` bumped via `touchDiscoveredDevice`, since re-identifying it on every ~1s
158
+ * advertisement would otherwise hammer it (and the shared GATT slots) for no reason. `pending` guards
159
+ * against two advertisements for the same brand-new device starting a second identify attempt while
160
+ * the first is still connecting.
161
+ */
162
+ function startBleApiDiscoveryListener(app, pluginId) {
163
+ const backend = (0, bleBackend_1.bleApiBackend)(app.bleApi, pluginId);
164
+ const pending = new Set();
165
+ const identify = async (mac, name, rssi, manufacturerDataHex) => {
166
+ const existing = (0, discoveredDevicesStore_1.loadDiscoveredDevices)(app)[mac];
167
+ if (existing) {
168
+ if (Date.now() - existing.lastSeenAt < discoveredDevicesStore_1.DISCOVERED_DEVICE_TTL_MS) {
169
+ (0, discoveredDevicesStore_1.touchDiscoveredDevice)(app, existing);
170
+ return;
171
+ }
172
+ }
173
+ if (pending.has(mac)) {
174
+ return;
175
+ }
176
+ pending.add(mac);
177
+ try {
178
+ const found = await identifyOne(app, backend, mac, name, rssi, manufacturerDataHex);
179
+ if (found) {
180
+ (0, discoveredDevicesStore_1.recordScanResults)(app, [found]);
181
+ }
182
+ }
183
+ finally {
184
+ pending.delete(mac);
185
+ }
186
+ };
187
+ const unsubscribe = app.bleApi.onAdvertisement(pluginId, (adv) => {
188
+ void identify(adv.mac, adv.name, adv.rssi, adv.manufacturerData);
189
+ });
190
+ // Seeds from whatever BLE Manager already knows about (e.g. a device it saw before this plugin
191
+ // subscribed) - same manufacturer-data caveat as `runScanViaBleApi`'s equivalent seed loop; a driver
192
+ // that needs it (rather than matching on name) picks it up once a fresh advertisement arrives above.
193
+ void app.bleApi
194
+ .getDevices()
195
+ .then((known) => Promise.all(known.map((device) => identify(device.mac, device.name, device.rssi, undefined))))
196
+ .catch((err) => app.debug(`discovery listener: could not read known devices: ${err.message}`));
197
+ return unsubscribe;
198
+ }
@@ -1,16 +1,29 @@
1
1
  /**
2
2
  * Wire framing for `packing: "chunked"` devices (the 7.5"/10.2" panels).
3
3
  *
4
- * The vendor firmware's real format supports each 64-byte chunk being either a `0x75`-tagged
5
- * QuickLZ-compressed block or a `0x74`-tagged raw one - see hass-gicisky's `gicisky_ble/compression.py`,
6
- * which ports a vendor-specific 64-bucket-hash variant of QuickLZ Level 1 to produce the smaller
7
- * `0x75` form. This driver always emits the `0x74` raw form instead: larger over the wire, but the
8
- * framing itself (chunk headers, the part1/part2 split) is unchanged, so a device that accepts
9
- * hass-gicisky's output accepts this too - it just costs more BLE writes per repaint than a real
10
- * compressor would.
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 raw-chunked bytes, matching `compress()`'s output shape in the reference driver.
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
- * The vendor firmware's real format supports each 64-byte chunk being either a `0x75`-tagged
6
- * QuickLZ-compressed block or a `0x74`-tagged raw one - see hass-gicisky's `gicisky_ble/compression.py`,
7
- * which ports a vendor-specific 64-bucket-hash variant of QuickLZ Level 1 to produce the smaller
8
- * `0x75` form. This driver always emits the `0x74` raw form instead: larger over the wire, but the
9
- * framing itself (chunk headers, the part1/part2 split) is unchanged, so a device that accepts
10
- * hass-gicisky's output accepts this too - it just costs more BLE writes per repaint than a real
11
- * compressor would.
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
- function chunkRaw(data) {
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 header = Buffer.from([RAW_CHUNK_TAG, 3 + chunk.length, chunk.length]);
22
- chunks.push(header, chunk);
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 raw-chunked bytes, matching `compress()`'s output shape in the reference driver.
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, chunkRaw(planeA), chunkRaw(planeB)]);
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;
@@ -53,21 +54,25 @@ class GiciskyDriver {
53
54
  */
54
55
  const manufacturerData = await backend.waitForManufacturerData(config.address, protocol_1.GICISKY_MANUFACTURER_ID, MANUFACTURER_DATA_RESCAN_TIMEOUT_MS);
55
56
  const info = manufacturerData ? (0, protocol_1.decodeAdvertisedInfo)(manufacturerData) : undefined;
57
+ // A device that's quiet right now (e.g. still refreshing from the last paint) can still be painted
58
+ // when its PID is already known from config/a previous scan - the connect below doesn't need the
59
+ // advertisement, only this lookup does.
60
+ const pid = info?.deviceId ?? config.pid;
56
61
  const metadata = config.modelOverride
57
- ? { pid: info?.deviceId ?? 0, ...config.modelOverride }
58
- : info
59
- ? this.metadataForPid(info.deviceId)
62
+ ? { pid: pid ?? 0, ...config.modelOverride }
63
+ : pid !== undefined
64
+ ? this.metadataForPid(pid)
60
65
  : undefined;
61
66
  if (!metadata) {
62
- throw new Error(info === undefined
67
+ throw new Error(pid === undefined
63
68
  ? "gicisky device isn't advertising - rescanned but got nothing back (out of range, asleep, or already connected " +
64
69
  "elsewhere) - pass --width/--height/--voffset/--colours to describe it manually"
65
- : `gicisky device reports unrecognised deviceId 0x${info.deviceId.toString(16).padStart(4, "0")} - ` +
70
+ : `gicisky device reports unrecognised deviceId 0x${pid.toString(16).padStart(4, "0")} - ` +
66
71
  "pass --width/--height/--voffset/--colours to describe it manually");
67
72
  }
68
- const layout = (info && layout_1.GICISKY_PID_LAYOUT[info.deviceId]) || (0, layout_1.defaultLayoutFor)(metadata.colours);
69
- const framed = (0, reframe_1.reframeBitmap)(bitmap, metadata.width, metadata.height, config.reframe ?? "crop");
70
- 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);
71
76
  const conn = await backend.connectGatt(config.address, config.connectTimeoutMs ?? DEFAULT_PAINT_CONNECT_TIMEOUT_MS);
72
77
  try {
73
78
  const { cmdServiceUuid, cmdUuid, imgServiceUuid, imgUuid } = await findCommandAndImageCharacteristics(conn);
@@ -89,6 +94,12 @@ class GiciskyDriver {
89
94
  const chunk = payload.subarray(part * chunkSize, Math.min(part * chunkSize + chunkSize, payload.length));
90
95
  const ackData = await writeAndAwaitAck(conn, imgServiceUuid, imgUuid, (0, protocol_1.imageChunkPacket)(part, chunk), ack);
91
96
  const decoded = (0, protocol_1.decodeTransferAck)(ackData);
97
+ if (decoded && !decoded.ok && part * chunkSize + chunk.length >= payload.length) {
98
+ // The device answers the final chunk with a non-zero status (seen: `05 08 00000000`) once
99
+ // it has the whole image and starts refreshing - the transfer's done, not failed. Matches
100
+ // hass-gicisky's writer, which ends the transfer on any non-zero status rather than erroring.
101
+ break;
102
+ }
92
103
  if (!decoded?.ok) {
93
104
  throw new Error(`gicisky device reported an error transferring image part ${part}: ${ackData.toString("hex")}`);
94
105
  }
@@ -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 raw 64-byte chunks framed per `compression.ts`'s `compress()` - the 7.5"/10.2" panels. */
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
+ }
@@ -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.
@@ -53,6 +62,13 @@ export type DeviceModelOverride = Omit<DeviceMetadata, "pid">;
53
62
  /** Per-device settings the user supplies when registering a device, beyond what's in DeviceMetadata. */
54
63
  export interface VendorDeviceConfig {
55
64
  address: string;
65
+ /**
66
+ * The PID already known for this device (from the configured `"<vendor>:<pid>@<address>"` or a
67
+ * previous scan) - a fallback for drivers that otherwise only learn it from a fresh advertisement
68
+ * (gicisky), so a device that's quiet at paint time can still be painted. A live advertised PID
69
+ * takes precedence when there is one.
70
+ */
71
+ pid?: number;
56
72
  /** AES key for vendors that need it, entered by the user. If omitted, vendors that have one may fall back to a stock/manufacturer-default key instead of failing. */
57
73
  aesKey?: string;
58
74
  /** Forces the device model facts instead of looking up the advertised PID - for hardware not yet in the driver's table. */
@@ -61,6 +77,12 @@ export interface VendorDeviceConfig {
61
77
  connectTimeoutMs?: number;
62
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. */
63
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;
64
86
  /**
65
87
  * How `paint()` reaches the device's BLE hardware - omitted (always true for the CLI, which has no
66
88
  * `ServerAPI`/`app.bleApi` to source one from) means direct BlueZ access via a fresh
@@ -1,2 +1,4 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.COMPRESSION_FORMATS = void 0;
4
+ exports.COMPRESSION_FORMATS = ["auto", "chunked"];
@@ -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
+ }
@@ -7,6 +7,8 @@ const pluginVersion_1 = require("../../pluginVersion");
7
7
  const metadata_1 = require("./metadata");
8
8
  const encode_1 = require("./encode");
9
9
  const reframe_1 = require("../../render/reframe");
10
+ const mirror_1 = require("../../render/mirror");
11
+ const compression_1 = require("./compression");
10
12
  const protocol_1 = require("./protocol");
11
13
  /** node-ble has no MTU API; this matches the reference driver's mtu(247)-9 default. */
12
14
  const UPLOAD_CHUNK_SIZE = 238;
@@ -77,14 +79,18 @@ class ZhsunycoDriver {
77
79
  const challenge = await conn.read(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.authenticate);
78
80
  await conn.write(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.authenticate, (0, protocol_1.authResponse)(challenge, aesKey), false);
79
81
  await (0, bleDiscovery_1.sleep)(AUTH_SETTLE_DELAY_MS);
80
- 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");
81
83
  const pixelData = (0, encode_1.encodeBitmap)(framed, metadata);
82
- for (let offset = 0; offset < pixelData.length; offset += UPLOAD_CHUNK_SIZE) {
83
- const chunk = pixelData.subarray(offset, offset + UPLOAD_CHUNK_SIZE);
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);
84
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);
85
91
  await (0, bleDiscovery_1.sleep)(CHUNK_WRITE_DELAY_MS);
86
92
  }
87
- await conn.write(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.data, (0, protocol_1.commandHeader)(protocol_1.COMMAND.refreshUncompressed, pixelData.length), true);
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);
88
94
  // `Promise.race` can't cancel its loser, so once `statusReceived` settles (the common case)
89
95
  // this timer would otherwise sit alive for the rest of its 60s regardless - see the identical
90
96
  // reasoning on `readDeviceDetails`'s own race, below.