@rhizomatics/signalk-einklabel-plugin 1.2.3 → 1.3.0-beta10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +97 -9
- package/dist/cli/index.d.ts +4 -1
- package/dist/cli/index.js +30 -6
- package/dist/config.d.ts +53 -14
- package/dist/config.js +172 -41
- package/dist/devices/bleBackend.d.ts +49 -0
- package/dist/devices/bleBackend.js +268 -0
- package/dist/devices/bleDiscovery.d.ts +20 -2
- package/dist/devices/bleDiscovery.js +101 -3
- package/dist/devices/discoveryCoordinator.d.ts +25 -6
- package/dist/devices/discoveryCoordinator.js +148 -9
- package/dist/devices/gattConnection.d.ts +9 -0
- package/dist/devices/gattConnection.js +2 -0
- package/dist/devices/gicisky/compression.d.ts +22 -9
- package/dist/devices/gicisky/compression.js +128 -13
- package/dist/devices/gicisky/encode.d.ts +1 -1
- package/dist/devices/gicisky/encode.js +2 -2
- package/dist/devices/gicisky/index.d.ts +7 -2
- package/dist/devices/gicisky/index.js +118 -96
- package/dist/devices/gicisky/layout.d.ts +13 -1
- package/dist/devices/gicisky/layout.js +21 -0
- package/dist/devices/types.d.ts +48 -6
- package/dist/devices/types.js +2 -0
- package/dist/devices/zhsunyco/compression.d.ts +1 -0
- package/dist/devices/zhsunyco/compression.js +30 -0
- package/dist/devices/zhsunyco/index.d.ts +8 -2
- package/dist/devices/zhsunyco/index.js +92 -82
- package/dist/devices/zhsunyco/protocol.d.ts +2 -0
- package/dist/devices/zhsunyco/protocol.js +2 -0
- package/dist/plugin.js +38 -11
- package/dist/render/mirror.d.ts +11 -0
- package/dist/render/mirror.js +24 -0
- package/dist/repaintScheduler.js +29 -12
- package/docs/assets/images/mini_tidal_clock.png +0 -0
- package/package.json +3 -3
|
@@ -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
|
|
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 <=
|
|
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.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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 =
|
|
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
|
|
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
|
|
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;
|
|
@@ -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
|
}
|