@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +97 -9
  3. package/dist/cli/index.d.ts +4 -1
  4. package/dist/cli/index.js +30 -6
  5. package/dist/config.d.ts +53 -14
  6. package/dist/config.js +172 -41
  7. package/dist/devices/bleBackend.d.ts +49 -0
  8. package/dist/devices/bleBackend.js +268 -0
  9. package/dist/devices/bleDiscovery.d.ts +20 -2
  10. package/dist/devices/bleDiscovery.js +101 -3
  11. package/dist/devices/discoveryCoordinator.d.ts +25 -6
  12. package/dist/devices/discoveryCoordinator.js +148 -9
  13. package/dist/devices/gattConnection.d.ts +9 -0
  14. package/dist/devices/gattConnection.js +2 -0
  15. package/dist/devices/gicisky/compression.d.ts +22 -9
  16. package/dist/devices/gicisky/compression.js +128 -13
  17. package/dist/devices/gicisky/encode.d.ts +1 -1
  18. package/dist/devices/gicisky/encode.js +2 -2
  19. package/dist/devices/gicisky/index.d.ts +7 -2
  20. package/dist/devices/gicisky/index.js +118 -96
  21. package/dist/devices/gicisky/layout.d.ts +13 -1
  22. package/dist/devices/gicisky/layout.js +21 -0
  23. package/dist/devices/types.d.ts +48 -6
  24. package/dist/devices/types.js +2 -0
  25. package/dist/devices/zhsunyco/compression.d.ts +1 -0
  26. package/dist/devices/zhsunyco/compression.js +30 -0
  27. package/dist/devices/zhsunyco/index.d.ts +8 -2
  28. package/dist/devices/zhsunyco/index.js +92 -82
  29. package/dist/devices/zhsunyco/protocol.d.ts +2 -0
  30. package/dist/devices/zhsunyco/protocol.js +2 -0
  31. package/dist/plugin.js +38 -11
  32. package/dist/render/mirror.d.ts +11 -0
  33. package/dist/render/mirror.js +24 -0
  34. package/dist/repaintScheduler.js +29 -12
  35. package/docs/assets/images/mini_tidal_clock.png +0 -0
  36. package/package.json +3 -3
@@ -1,4 +1,5 @@
1
1
  import { Adapter, Bluetooth, Device } from "@naugehyde/node-ble";
2
+ import { GattConnection } from "./gattConnection";
2
3
  export declare function sleep(ms: number): Promise<void>;
3
4
  /**
4
5
  * Drop-in replacement for `node-ble`'s `createBluetooth` that fails fast and clearly when
@@ -33,9 +34,16 @@ export declare function forEachAdvertisedDevice(adapter: Adapter, fn: (advertise
33
34
  /**
34
35
  * Retries `fn` up to `attempts` times (including the first try), returning on the first success -
35
36
  * shared by the repaint scheduler and the CLI's `paint` command so one flaky BLE connection
36
- * doesn't fail a whole repaint after a single bad attempt.
37
+ * doesn't fail a whole repaint after a single bad attempt. `delayMs` pauses between attempts, giving
38
+ * the adapter (and any BLE Manager claim the failed attempt held) a moment to settle rather than
39
+ * hitting the device again in the same tick. `onError` sees every failed attempt's error, not just the
40
+ * last one that's eventually thrown - an early attempt's error is often the real cause, with later
41
+ * ones just fallout from it.
37
42
  */
38
- export declare function withRetries<T>(attempts: number, fn: (attempt: number) => Promise<T>): Promise<T>;
43
+ export declare function withRetries<T>(attempts: number, fn: (attempt: number) => Promise<T>, { delayMs, onError }?: {
44
+ delayMs?: number;
45
+ onError?: (err: unknown, attempt: number) => void;
46
+ }): Promise<T>;
39
47
  /**
40
48
  * Opens exactly one BLE discovery window and one D-Bus/BlueZ session, then hands the adapter to
41
49
  * `fn` - shared by `plugin.ts`'s startup scan and the CLI's `scan` command so scanning across
@@ -78,3 +86,13 @@ export declare function waitForManufacturerData(adapter: Adapter, device: Device
78
86
  * leaving it to retry forever in the background after the thing it was waiting to unblock is gone.
79
87
  */
80
88
  export declare function waitForAdapter(logDebug: (message: string) => void, cancelled?: () => boolean): Promise<boolean>;
89
+ /**
90
+ * Adapts an already-connected node-ble `Device` to the `GattConnection` facade device drivers
91
+ * (`zhsunyco`/`gicisky`) talk to - the same shape `app.bleApi.connectGATT()` returns natively (see
92
+ * `gattConnection.ts`), so a driver's GATT logic runs unchanged under either backend. node-ble
93
+ * resolves one `GattCharacteristic` object per (service, characteristic) pair and expects callers to
94
+ * hold onto it for reads/writes/notifications - this caches that resolution keyed by
95
+ * `"<serviceUuid>:<charUuid>"` so repeated calls (e.g. one `write` per image chunk) don't re-walk
96
+ * `device.gatt()` every time.
97
+ */
98
+ export declare function openNodeBleGattConnection(device: Device): GattConnection;
@@ -10,6 +10,7 @@ exports.connectWithTimeout = connectWithTimeout;
10
10
  exports.getOrDiscoverDevice = getOrDiscoverDevice;
11
11
  exports.waitForManufacturerData = waitForManufacturerData;
12
12
  exports.waitForAdapter = waitForAdapter;
13
+ exports.openNodeBleGattConnection = openNodeBleGattConnection;
13
14
  const node_ble_1 = require("@naugehyde/node-ble");
14
15
  function sleep(ms) {
15
16
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -67,16 +68,25 @@ async function forEachAdvertisedDevice(adapter, fn) {
67
68
  /**
68
69
  * Retries `fn` up to `attempts` times (including the first try), returning on the first success -
69
70
  * shared by the repaint scheduler and the CLI's `paint` command so one flaky BLE connection
70
- * doesn't fail a whole repaint after a single bad attempt.
71
+ * doesn't fail a whole repaint after a single bad attempt. `delayMs` pauses between attempts, giving
72
+ * the adapter (and any BLE Manager claim the failed attempt held) a moment to settle rather than
73
+ * hitting the device again in the same tick. `onError` sees every failed attempt's error, not just the
74
+ * last one that's eventually thrown - an early attempt's error is often the real cause, with later
75
+ * ones just fallout from it.
71
76
  */
72
- async function withRetries(attempts, fn) {
77
+ async function withRetries(attempts, fn, { delayMs = 0, onError } = {}) {
78
+ const total = Math.max(1, attempts);
73
79
  let lastErr;
74
- for (let attempt = 1; attempt <= Math.max(1, attempts); attempt++) {
80
+ for (let attempt = 1; attempt <= total; attempt++) {
75
81
  try {
76
82
  return await fn(attempt);
77
83
  }
78
84
  catch (err) {
79
85
  lastErr = err;
86
+ onError?.(err, attempt);
87
+ if (attempt < total && delayMs > 0) {
88
+ await sleep(delayMs);
89
+ }
80
90
  }
81
91
  }
82
92
  throw lastErr;
@@ -236,3 +246,91 @@ async function waitForAdapter(logDebug, cancelled = () => false) {
236
246
  }
237
247
  return false;
238
248
  }
249
+ /**
250
+ * Adapts an already-connected node-ble `Device` to the `GattConnection` facade device drivers
251
+ * (`zhsunyco`/`gicisky`) talk to - the same shape `app.bleApi.connectGATT()` returns natively (see
252
+ * `gattConnection.ts`), so a driver's GATT logic runs unchanged under either backend. node-ble
253
+ * resolves one `GattCharacteristic` object per (service, characteristic) pair and expects callers to
254
+ * hold onto it for reads/writes/notifications - this caches that resolution keyed by
255
+ * `"<serviceUuid>:<charUuid>"` so repeated calls (e.g. one `write` per image chunk) don't re-walk
256
+ * `device.gatt()` every time.
257
+ */
258
+ function openNodeBleGattConnection(device) {
259
+ const characteristics = new Map();
260
+ const notificationListeners = new Map();
261
+ let gattServer;
262
+ let connected = true;
263
+ const key = (serviceUuid, charUuid) => `${serviceUuid}:${charUuid}`;
264
+ async function resolveCharacteristic(serviceUuid, charUuid) {
265
+ const cacheKey = key(serviceUuid, charUuid);
266
+ const cached = characteristics.get(cacheKey);
267
+ if (cached) {
268
+ return cached;
269
+ }
270
+ gattServer ?? (gattServer = device.gatt());
271
+ const service = await (await gattServer).getPrimaryService(serviceUuid);
272
+ const characteristic = await service.getCharacteristic(charUuid);
273
+ characteristics.set(cacheKey, characteristic);
274
+ return characteristic;
275
+ }
276
+ device.on("disconnect", () => {
277
+ connected = false;
278
+ });
279
+ return {
280
+ async read(serviceUuid, charUuid) {
281
+ const characteristic = await resolveCharacteristic(serviceUuid, charUuid);
282
+ return characteristic.readValue();
283
+ },
284
+ async write(serviceUuid, charUuid, data, withResponse = true) {
285
+ const characteristic = await resolveCharacteristic(serviceUuid, charUuid);
286
+ if (withResponse === false) {
287
+ await characteristic.writeValueWithoutResponse(data);
288
+ }
289
+ else {
290
+ await characteristic.writeValueWithResponse(data);
291
+ }
292
+ },
293
+ async startNotifications(serviceUuid, charUuid, callback) {
294
+ const characteristic = await resolveCharacteristic(serviceUuid, charUuid);
295
+ notificationListeners.set(key(serviceUuid, charUuid), callback);
296
+ characteristic.on("valuechanged", callback);
297
+ await characteristic.startNotifications();
298
+ },
299
+ async stopNotifications(serviceUuid, charUuid) {
300
+ const cacheKey = key(serviceUuid, charUuid);
301
+ const characteristic = characteristics.get(cacheKey);
302
+ const listener = notificationListeners.get(cacheKey);
303
+ if (characteristic && listener) {
304
+ characteristic.removeListener("valuechanged", listener);
305
+ notificationListeners.delete(cacheKey);
306
+ await characteristic.stopNotifications().catch(() => { });
307
+ }
308
+ },
309
+ async discoverServices() {
310
+ gattServer ?? (gattServer = device.gatt());
311
+ const server = await gattServer;
312
+ const services = [];
313
+ for (const serviceUuid of await server.services()) {
314
+ const service = await server.getPrimaryService(serviceUuid);
315
+ const characteristicInfos = [];
316
+ for (const charUuid of await service.characteristics()) {
317
+ const characteristic = await service.getCharacteristic(charUuid);
318
+ characteristics.set(key(serviceUuid, charUuid), characteristic);
319
+ const properties = await characteristic.getFlags().catch(() => []);
320
+ characteristicInfos.push({ uuid: charUuid, properties });
321
+ }
322
+ services.push({ uuid: serviceUuid, characteristics: characteristicInfos });
323
+ }
324
+ return services;
325
+ },
326
+ async disconnect() {
327
+ await device.disconnect();
328
+ },
329
+ get connected() {
330
+ return connected;
331
+ },
332
+ onDisconnect(callback) {
333
+ device.on("disconnect", () => callback());
334
+ },
335
+ };
336
+ }
@@ -10,10 +10,29 @@ export interface ScanResult {
10
10
  /** epoch ms a scan was started at, if one is currently running - `undefined` otherwise. */
11
11
  export declare function scanInProgressSince(): number | undefined;
12
12
  /**
13
- * Runs one BLE discovery scan and persists what it finds. node-ble/BlueZ has no scan-cancellation
14
- * API and can't run two discovery sessions at once, so a caller that arrives while a scan is
15
- * already running (e.g. the startup scan and an on-demand scan for a `device: "ALL"` repaint both
16
- * wanting to scan at the same moment) is hooked into that *same* in-flight scan's eventual result
17
- * instead of starting a second BlueZ session, which would make both fail.
13
+ * Runs one BLE discovery scan and persists what it finds. Neither backend can run two discovery
14
+ * sessions at once (node-ble/BlueZ has no scan-cancellation API; the BLE Manager API tracks one
15
+ * subscription per plugin), so a caller that arrives while a scan is already running (e.g. the
16
+ * startup scan and an on-demand scan for a `device: "ALL"` repaint both wanting to scan at the same
17
+ * moment) is hooked into that *same* in-flight scan's eventual result instead of starting a second
18
+ * one, which would make both fail.
18
19
  */
19
- export declare function ensureScan(app: ServerAPI, durationSeconds: number): Promise<ScanResult>;
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,32 +2,38 @@
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");
7
+ const bleBackend_1 = require("./bleBackend");
6
8
  const discoveredDevicesStore_1 = require("./discoveredDevicesStore");
7
9
  const registry_1 = require("./registry");
10
+ const pluginVersion_1 = require("../pluginVersion");
8
11
  let inFlight;
9
12
  /** epoch ms a scan was started at, if one is currently running - `undefined` otherwise. */
10
13
  function scanInProgressSince() {
11
14
  return inFlight?.startedAt;
12
15
  }
16
+ /** How long a `connect()` closure built during a scan waits for the GATT connect step itself - a scan may be enumerating several devices, so kept short relative to `paint()`'s own connect timeout. */
17
+ const SCAN_GATT_CONNECT_TIMEOUT_MS = 10000;
13
18
  /**
14
- * Runs one BLE discovery scan and persists what it finds. node-ble/BlueZ has no scan-cancellation
15
- * API and can't run two discovery sessions at once, so a caller that arrives while a scan is
16
- * already running (e.g. the startup scan and an on-demand scan for a `device: "ALL"` repaint both
17
- * wanting to scan at the same moment) is hooked into that *same* in-flight scan's eventual result
18
- * instead of starting a second BlueZ session, which would make both fail.
19
+ * Runs one BLE discovery scan and persists what it finds. Neither backend can run two discovery
20
+ * sessions at once (node-ble/BlueZ has no scan-cancellation API; the BLE Manager API tracks one
21
+ * subscription per plugin), so a caller that arrives while a scan is already running (e.g. the
22
+ * startup scan and an on-demand scan for a `device: "ALL"` repaint both wanting to scan at the same
23
+ * moment) is hooked into that *same* in-flight scan's eventual result instead of starting a second
24
+ * one, which would make both fail.
19
25
  */
20
- function ensureScan(app, durationSeconds) {
26
+ function ensureScan(app, durationSeconds, useBleApi) {
21
27
  if (inFlight) {
22
28
  return inFlight.promise;
23
29
  }
24
- const promise = runScan(app, durationSeconds).finally(() => {
30
+ const promise = (useBleApi && !!app.bleApi ? runScanViaBleApi(app, durationSeconds) : runScanViaBlueZ(app, durationSeconds)).finally(() => {
25
31
  inFlight = undefined;
26
32
  });
27
33
  inFlight = { startedAt: Date.now(), promise };
28
34
  return promise;
29
35
  }
30
- async function runScan(app, durationSeconds) {
36
+ async function runScanViaBlueZ(app, durationSeconds) {
31
37
  const foundThisScan = [];
32
38
  const drivers = (0, registry_1.allDrivers)();
33
39
  try {
@@ -37,7 +43,12 @@ async function runScan(app, durationSeconds) {
37
43
  if (!driver) {
38
44
  return;
39
45
  }
40
- const found = await driver.identifyDevice(device, address, name, manufacturerId, manufacturerData).catch((err) => {
46
+ const rssi = await device
47
+ .getRSSI()
48
+ .then((value) => (value === undefined ? undefined : Number(value)))
49
+ .catch(() => undefined);
50
+ const connect = () => (0, bleDiscovery_1.connectWithTimeout)(device, SCAN_GATT_CONNECT_TIMEOUT_MS).then(() => (0, bleDiscovery_1.openNodeBleGattConnection)(device));
51
+ const found = await driver.identifyDevice({ address, name, manufacturerId, manufacturerData, rssi }, connect).catch((err) => {
41
52
  app.debug(`${driver.vendor} scan failed: ${err.message}\n${err.stack ?? ""}`);
42
53
  return undefined;
43
54
  });
@@ -57,3 +68,131 @@ async function runScan(app, durationSeconds) {
57
68
  const merged = (0, discoveredDevicesStore_1.recordScanResults)(app, foundThisScan);
58
69
  return { foundThisScan, merged };
59
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
+ }
101
+ /**
102
+ * Same as `runScanViaBlueZ` but sourced from the SignalK server's BLE Manager API instead of a direct
103
+ * BlueZ discovery session - subscribes to the merged advertisement stream for `durationSeconds`,
104
+ * seeded with whatever the server already knows about (mirrors `BleManagerScanner` in
105
+ * signalk-bluetti-plugin).
106
+ */
107
+ async function runScanViaBleApi(app, durationSeconds) {
108
+ const foundThisScan = [];
109
+ const seen = new Set();
110
+ const backend = (0, bleBackend_1.bleApiBackend)(app.bleApi, pluginVersion_1.PLUGIN_NAME);
111
+ const identify = async (mac, name, rssi, manufacturerDataHex) => {
112
+ if (seen.has(mac)) {
113
+ return;
114
+ }
115
+ seen.add(mac);
116
+ const found = await identifyOne(app, backend, mac, name, rssi, manufacturerDataHex);
117
+ if (found) {
118
+ foundThisScan.push(found);
119
+ }
120
+ };
121
+ try {
122
+ const unsubscribe = app.bleApi.onAdvertisement(pluginVersion_1.PLUGIN_NAME, (adv) => {
123
+ void identify(adv.mac, adv.name, adv.rssi, adv.manufacturerData);
124
+ });
125
+ try {
126
+ const known = await app.bleApi.getDevices();
127
+ for (const device of known) {
128
+ // BLEDeviceInfo (from getDevices()) doesn't carry manufacturer data - see `bleBackend.ts`'s
129
+ // waitForManufacturerData doc comment. A driver that needs it to identify (e.g. gicisky) will
130
+ // still show up once its advertisement arrives on the stream above during this scan window.
131
+ await identify(device.mac, device.name, device.rssi, undefined);
132
+ }
133
+ await (0, bleDiscovery_1.sleep)(durationSeconds * 1000);
134
+ }
135
+ finally {
136
+ unsubscribe();
137
+ }
138
+ }
139
+ catch (err) {
140
+ app.debug(`scan failed: ${err.message}\n${err.stack ?? ""}`);
141
+ }
142
+ const merged = (0, discoveredDevicesStore_1.recordScanResults)(app, foundThisScan);
143
+ return { foundThisScan, merged };
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
+ }
@@ -0,0 +1,9 @@
1
+ import { BLEGattConnection } from "@signalk/server-api";
2
+ /**
3
+ * Service+characteristic-UUID-keyed GATT surface both BLE backends satisfy - `app.bleApi.connectGATT()`
4
+ * already returns exactly this shape (it *is* `BLEGattConnection`), and `openNodeBleGattConnection`
5
+ * (`bleDiscovery.ts`) adapts a node-ble `Device` to it. Device drivers (`zhsunyco`/`gicisky`) talk to
6
+ * this instead of either BLE library directly, so they work unchanged under whichever backend a caller
7
+ * hands them - see `bleBackend.ts`.
8
+ */
9
+ export type GattConnection = BLEGattConnection;
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -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
  }