@rhizomatics/signalk-einklabel-plugin 1.3.0-beta1 → 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.
@@ -12,10 +12,38 @@ export interface BleBackend {
12
12
  connectGatt(address: string, timeoutMs: number): Promise<GattConnection>;
13
13
  waitForManufacturerData(address: string, manufacturerId: number, timeoutMs: number): Promise<Buffer | undefined>;
14
14
  }
15
+ /**
16
+ * Wraps a connected `GattConnection` so the session as a whole - not just the connect step - can't
17
+ * hang forever. Starts a single timer when the connection opens; if `disconnect()` hasn't been
18
+ * called (i.e. the caller's `paint()` hasn't finished, successfully or not) by the time it fires,
19
+ * treats the session as stuck: calls `forceClose` (which must tear down the connection and, for a
20
+ * shared backend, release any claim on it) and fails every call still outstanding or made afterwards.
21
+ * `Promise.race`ing each call against that same failure - rather than just refusing new calls once
22
+ * killed - is what actually unblocks a caller `await`ing a hung `write()`/`discoverServices()`:
23
+ * forcing the underlying connection closed doesn't guarantee the hung call's own promise ever
24
+ * settles, so the caller needs a second, independent way to move on.
25
+ */
26
+ export declare function withSessionWatchdog(conn: GattConnection, timeoutMs: number, forceClose: () => Promise<void>): GattConnection;
15
27
  export declare function nodeBleBackend(): BleBackend;
28
+ /**
29
+ * Waits for the BLE Manager to know about `address` before a GATT connect is attempted against it -
30
+ * mirrors `getOrDiscoverDevice` in `bleDiscovery.ts`, which gives `nodeBleBackend` the same guarantee
31
+ * against BlueZ directly. Without this, a specifically-addressed device that the manager hasn't seen
32
+ * yet (e.g. this plugin's own `scanOnStart` is off and no other BLE plugin happens to be scanning)
33
+ * would sit on `bleApi.connectGATT()` until that call's own `timeoutMs` gives up, rather than the
34
+ * plugin quietly rediscovering it the way the direct-BlueZ backend already does.
35
+ */
36
+ export declare function ensureDeviceVisible(bleApi: BLEApi, pluginId: string, address: string, timeoutMs: number): Promise<void>;
37
+ /**
38
+ * Runs `fn` only once every earlier caller has finished, so paints to different devices never hold
39
+ * BLE Manager GATT slots at the same time. The server's local provider defaults to just a couple of
40
+ * slots shared with every other BLE plugin (e.g. Bluetti), so two concurrent connects can leave the
41
+ * second with none free - which the server reports as the misleading "No provider with GATT support
42
+ * can see <mac>".
43
+ */
44
+ export declare function exclusiveBleManagerAccess<T>(fn: () => Promise<T>): Promise<T>;
16
45
  /**
17
46
  * `bleApi.connectGATT()` has no timeout of its own, same story as node-ble's `Device#connect()` (see
18
- * `connectWithTimeout` in `bleDiscovery.ts`) - races it against `timeoutMs` and disconnects in the
19
- * background if it eventually resolves after the caller has already given up.
47
+ * `connectWithTimeout` in `bleDiscovery.ts`) - races it against `timeoutMs`.
20
48
  */
21
49
  export declare function bleApiBackend(bleApi: BLEApi, pluginId: string): BleBackend;
@@ -1,10 +1,82 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.withSessionWatchdog = withSessionWatchdog;
3
4
  exports.nodeBleBackend = nodeBleBackend;
5
+ exports.ensureDeviceVisible = ensureDeviceVisible;
6
+ exports.exclusiveBleManagerAccess = exclusiveBleManagerAccess;
4
7
  exports.bleApiBackend = bleApiBackend;
8
+ const pluginVersion_1 = require("../pluginVersion");
5
9
  const bleDiscovery_1 = require("./bleDiscovery");
6
10
  /** How long to wait for BlueZ to have (or acquire) a `Device` object for the target address before giving up - a separate budget from the connect step itself, matching both drivers' previous hardcoded constant. */
7
11
  const DEVICE_DISCOVERY_TIMEOUT_MS = 30000;
12
+ /**
13
+ * Bounds the whole post-connect GATT session (service discovery, every read/write/notification a
14
+ * driver's `paint()` makes until it calls `disconnect()`), not just the connect step above - unlike
15
+ * `connectWithTimeout`/`bleApiBackend`'s connect race, neither backend's underlying `write()` or
16
+ * `discoverServices()` has any timeout of its own, so a hang there (plausible against a flaky real
17
+ * device) would otherwise sit forever with the connection, and any BLE Manager GATT claim, held open
18
+ * - see `withSessionWatchdog`. Generous relative to any single driver operation's own timeout (e.g.
19
+ * gicisky's 15s per-chunk ack) since it has to cover a whole multi-chunk image transfer, not one step
20
+ * of it; it's a last-resort backstop, not a normal-path budget.
21
+ */
22
+ const GATT_SESSION_WATCHDOG_MS = 5 * 60000;
23
+ /**
24
+ * Wraps a connected `GattConnection` so the session as a whole - not just the connect step - can't
25
+ * hang forever. Starts a single timer when the connection opens; if `disconnect()` hasn't been
26
+ * called (i.e. the caller's `paint()` hasn't finished, successfully or not) by the time it fires,
27
+ * treats the session as stuck: calls `forceClose` (which must tear down the connection and, for a
28
+ * shared backend, release any claim on it) and fails every call still outstanding or made afterwards.
29
+ * `Promise.race`ing each call against that same failure - rather than just refusing new calls once
30
+ * killed - is what actually unblocks a caller `await`ing a hung `write()`/`discoverServices()`:
31
+ * forcing the underlying connection closed doesn't guarantee the hung call's own promise ever
32
+ * settles, so the caller needs a second, independent way to move on.
33
+ */
34
+ function withSessionWatchdog(conn, timeoutMs, forceClose) {
35
+ let killedError;
36
+ let resolveKilled;
37
+ const killed = new Promise((resolve) => {
38
+ resolveKilled = resolve;
39
+ });
40
+ let settled = false;
41
+ // `unref()` so this backstop timer never itself keeps the process alive (e.g. the CLI's `paint`
42
+ // command exiting naturally once its work is done) - it only ever needs to fire while something
43
+ // else is already keeping the event loop running anyway.
44
+ const timer = setTimeout(() => {
45
+ if (settled)
46
+ return;
47
+ killedError = new Error(`GATT session watchdog fired after ${timeoutMs}ms with no completion - forcing disconnect`);
48
+ console.error(`${pluginVersion_1.PLUGIN_NAME}: ${killedError.message}`);
49
+ resolveKilled(killedError);
50
+ void forceClose().catch(() => { });
51
+ }, timeoutMs).unref();
52
+ function guard(op) {
53
+ if (killedError)
54
+ return Promise.reject(killedError);
55
+ const result = op();
56
+ result.catch(() => { }); // observed here too, so losing the race below never surfaces as an unhandled rejection
57
+ return Promise.race([result, killed.then((err) => Promise.reject(err))]);
58
+ }
59
+ return {
60
+ read: (serviceUuid, charUuid) => guard(() => conn.read(serviceUuid, charUuid)),
61
+ write: (serviceUuid, charUuid, data, withResponse) => guard(() => conn.write(serviceUuid, charUuid, data, withResponse)),
62
+ startNotifications: (serviceUuid, charUuid, callback) => guard(() => conn.startNotifications(serviceUuid, charUuid, callback)),
63
+ stopNotifications: (serviceUuid, charUuid) => guard(() => conn.stopNotifications(serviceUuid, charUuid)),
64
+ discoverServices: () => guard(() => conn.discoverServices()),
65
+ onDisconnect: conn.onDisconnect.bind(conn),
66
+ get connected() {
67
+ return conn.connected;
68
+ },
69
+ async disconnect() {
70
+ if (settled)
71
+ return;
72
+ settled = true;
73
+ clearTimeout(timer);
74
+ if (killedError)
75
+ return; // already forced closed by the watchdog above
76
+ await conn.disconnect();
77
+ },
78
+ };
79
+ }
8
80
  function nodeBleBackend() {
9
81
  return {
10
82
  async connectGatt(address, timeoutMs) {
@@ -14,7 +86,14 @@ function nodeBleBackend() {
14
86
  const device = await (0, bleDiscovery_1.getOrDiscoverDevice)(adapter, address, DEVICE_DISCOVERY_TIMEOUT_MS);
15
87
  await (0, bleDiscovery_1.connectWithTimeout)(device, timeoutMs);
16
88
  const conn = (0, bleDiscovery_1.openNodeBleGattConnection)(device);
17
- return {
89
+ let destroyed = false;
90
+ const destroyOnce = () => {
91
+ if (destroyed)
92
+ return;
93
+ destroyed = true;
94
+ destroy();
95
+ };
96
+ const gatt = {
18
97
  read: conn.read.bind(conn),
19
98
  write: conn.write.bind(conn),
20
99
  startNotifications: conn.startNotifications.bind(conn),
@@ -25,10 +104,22 @@ function nodeBleBackend() {
25
104
  return conn.connected;
26
105
  },
27
106
  async disconnect() {
28
- await conn.disconnect();
29
- destroy();
107
+ try {
108
+ await conn.disconnect();
109
+ }
110
+ finally {
111
+ destroyOnce();
112
+ }
30
113
  },
31
114
  };
115
+ return withSessionWatchdog(gatt, GATT_SESSION_WATCHDOG_MS, async () => {
116
+ // `device.disconnect()` can hang for the same underlying reason the operations
117
+ // `withSessionWatchdog` already guards can (see its doc comment) - race it briefly rather
118
+ // than awaiting it unbounded here too, but tear down the D-Bus connection either way so
119
+ // those resources don't leak even if BlueZ's disconnect itself never completes.
120
+ await Promise.race([gatt.disconnect(), (0, bleDiscovery_1.sleep)(5000)]);
121
+ destroyOnce();
122
+ });
32
123
  }
33
124
  catch (err) {
34
125
  destroy();
@@ -48,10 +139,53 @@ function nodeBleBackend() {
48
139
  },
49
140
  };
50
141
  }
142
+ /**
143
+ * Waits for the BLE Manager to know about `address` before a GATT connect is attempted against it -
144
+ * mirrors `getOrDiscoverDevice` in `bleDiscovery.ts`, which gives `nodeBleBackend` the same guarantee
145
+ * against BlueZ directly. Without this, a specifically-addressed device that the manager hasn't seen
146
+ * yet (e.g. this plugin's own `scanOnStart` is off and no other BLE plugin happens to be scanning)
147
+ * would sit on `bleApi.connectGATT()` until that call's own `timeoutMs` gives up, rather than the
148
+ * plugin quietly rediscovering it the way the direct-BlueZ backend already does.
149
+ */
150
+ async function ensureDeviceVisible(bleApi, pluginId, address, timeoutMs) {
151
+ const known = await bleApi.getDevice(address).catch(() => null);
152
+ if (known)
153
+ return;
154
+ const seen = await new Promise((resolve) => {
155
+ const timer = setTimeout(() => {
156
+ unsubscribe();
157
+ resolve(false);
158
+ }, timeoutMs);
159
+ const unsubscribe = bleApi.onAdvertisement(pluginId, (adv) => {
160
+ if (adv.mac !== address)
161
+ return;
162
+ clearTimeout(timer);
163
+ unsubscribe();
164
+ resolve(true);
165
+ });
166
+ });
167
+ if (!seen) {
168
+ throw new Error(`device ${address} isn't visible to the SignalK BLE Manager yet (out of range, asleep, or never seen) after waiting ${timeoutMs}ms`);
169
+ }
170
+ }
171
+ /** Upper bound on how long a timed-out `bleApi.connectGATT()` is given to settle server-side before a retry - see `bleApiBackend`. */
172
+ const CONNECT_SETTLE_GRACE_MS = 30000;
173
+ let bleManagerQueue = Promise.resolve();
174
+ /**
175
+ * Runs `fn` only once every earlier caller has finished, so paints to different devices never hold
176
+ * BLE Manager GATT slots at the same time. The server's local provider defaults to just a couple of
177
+ * slots shared with every other BLE plugin (e.g. Bluetti), so two concurrent connects can leave the
178
+ * second with none free - which the server reports as the misleading "No provider with GATT support
179
+ * can see <mac>".
180
+ */
181
+ function exclusiveBleManagerAccess(fn) {
182
+ const run = bleManagerQueue.then(fn, fn);
183
+ bleManagerQueue = run.catch(() => { });
184
+ return run;
185
+ }
51
186
  /**
52
187
  * `bleApi.connectGATT()` has no timeout of its own, same story as node-ble's `Device#connect()` (see
53
- * `connectWithTimeout` in `bleDiscovery.ts`) - races it against `timeoutMs` and disconnects in the
54
- * background if it eventually resolves after the caller has already given up.
188
+ * `connectWithTimeout` in `bleDiscovery.ts`) - races it against `timeoutMs`.
55
189
  */
56
190
  function bleApiBackend(bleApi, pluginId) {
57
191
  return {
@@ -61,20 +195,58 @@ function bleApiBackend(bleApi, pluginId) {
61
195
  // "already claimed" until the server restarts - see signalk-bluetti-plugin's BleManagerDevice
62
196
  // for the same defensive call. A no-op if we don't currently hold the claim.
63
197
  await bleApi.releaseGATTDevice(address, pluginId).catch(() => { });
198
+ await ensureDeviceVisible(bleApi, pluginId, address, DEVICE_DISCOVERY_TIMEOUT_MS);
64
199
  const connecting = bleApi.connectGATT(address, pluginId);
65
200
  let timedOut = false;
66
201
  const conn = await Promise.race([connecting, (0, bleDiscovery_1.sleep)(timeoutMs).then(() => void (timedOut = true))]);
67
202
  if (timedOut || !conn) {
68
- connecting.then((c) => c.disconnect()).catch(() => { });
203
+ // The server registers the claim as soon as `connectGATT()` is called, not once it succeeds -
204
+ // giving up here without releasing it would leave the claim (and, once/if the connect attempt
205
+ // does eventually land server-side, a live connection) orphaned indefinitely, since this
206
+ // plugin never gets a handle back to disconnect it itself. `releaseGATTDevice` tears down
207
+ // whatever the claim currently is, connected or still connecting, regardless of how the
208
+ // original `connectGATT()` promise eventually settles - disconnecting it too if it does still
209
+ // resolve afterwards, harmlessly, since a connection the server already released is a no-op to
210
+ // disconnect again. The release is awaited (only the late disconnect is left in the
211
+ // background) so a caller retrying straight away - `withRetries` - can't race it with a new
212
+ // `connectGATT()` and get rejected with "has a GATT claim in progress" for its trouble.
213
+ await bleApi.releaseGATTDevice(address, pluginId).catch(() => { });
214
+ // ...except a claim still *connecting* isn't in the server's claim table yet, only its pending
215
+ // set, which `releaseGATTDevice` doesn't touch - so while the server's own connect is still in
216
+ // flight, a retry's `connectGATT()` is rejected outright with "has a GATT claim in progress".
217
+ // Give that connect a bounded chance to settle first, so the retry gets a clean slate.
218
+ const late = await Promise.race([
219
+ connecting.catch(() => undefined),
220
+ (0, bleDiscovery_1.sleep)(Math.min(timeoutMs, CONNECT_SETTLE_GRACE_MS)).then(() => undefined),
221
+ ]);
222
+ if (late) {
223
+ await Promise.allSettled([bleApi.releaseGATTDevice(address, pluginId), late.disconnect()]);
224
+ }
225
+ else {
226
+ void connecting.then((c) => c.disconnect()).catch(() => { });
227
+ }
69
228
  throw new Error(`connecting to device timed out after ${timeoutMs}ms`);
70
229
  }
71
- return conn;
230
+ return withSessionWatchdog(conn, GATT_SESSION_WATCHDOG_MS, async () => {
231
+ // Belt-and-suspenders, matching the timeout-cleanup above: `releaseGATTDevice` is the
232
+ // authoritative claim release, `disconnect()` a secondary teardown of this specific handle -
233
+ // do both regardless of which (if either) itself hangs or rejects.
234
+ await Promise.allSettled([bleApi.releaseGATTDevice(address, pluginId), conn.disconnect()]);
235
+ });
72
236
  },
73
237
  async waitForManufacturerData(address, manufacturerId, timeoutMs) {
74
238
  // Unlike node-ble's `device.getManufacturerData()`, there's no cached-instant-read path here -
75
239
  // `BLEDeviceInfo` (from `getDevices()`/`getDevice()`) carries mac/name/rssi/seenBy but not
76
240
  // manufacturer data (see `BLEDeviceInfoSchema` in `@signalk/server-api`'s `ble-schemas.ts`) -
77
241
  // only the streamed `BLEAdvertisement` does. So this always actively waits on the stream.
242
+ //
243
+ // Same defensive release as `connectGatt`, and just as necessary here: a device the BLE Manager
244
+ // still thinks *we* hold a GATT claim on (e.g. from a crash/reload mid-paint, before this call
245
+ // ever reaches `connectGatt` below) stops advertising while claimed - which would otherwise wedge
246
+ // this wait forever, since nothing else in this path ever calls `connectGatt` (and so never gets a
247
+ // chance to release the stale claim) unless a fresh advertisement shows up first. A no-op if we
248
+ // don't currently hold the claim.
249
+ await bleApi.releaseGATTDevice(address, pluginId).catch(() => { });
78
250
  return new Promise((resolve) => {
79
251
  const timer = setTimeout(() => {
80
252
  unsubscribe();
@@ -34,9 +34,16 @@ export declare function forEachAdvertisedDevice(adapter: Adapter, fn: (advertise
34
34
  /**
35
35
  * Retries `fn` up to `attempts` times (including the first try), returning on the first success -
36
36
  * shared by the repaint scheduler and the CLI's `paint` command so one flaky BLE connection
37
- * 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.
38
42
  */
39
- 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>;
40
47
  /**
41
48
  * Opens exactly one BLE discovery window and one D-Bus/BlueZ session, then hands the adapter to
42
49
  * `fn` - shared by `plugin.ts`'s startup scan and the CLI's `scan` command so scanning across
@@ -68,16 +68,25 @@ async function forEachAdvertisedDevice(adapter, fn) {
68
68
  /**
69
69
  * Retries `fn` up to `attempts` times (including the first try), returning on the first success -
70
70
  * shared by the repaint scheduler and the CLI's `paint` command so one flaky BLE connection
71
- * 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.
72
76
  */
73
- async function withRetries(attempts, fn) {
77
+ async function withRetries(attempts, fn, { delayMs = 0, onError } = {}) {
78
+ const total = Math.max(1, attempts);
74
79
  let lastErr;
75
- for (let attempt = 1; attempt <= Math.max(1, attempts); attempt++) {
80
+ for (let attempt = 1; attempt <= total; attempt++) {
76
81
  try {
77
82
  return await fn(attempt);
78
83
  }
79
84
  catch (err) {
80
85
  lastErr = err;
86
+ onError?.(err, attempt);
87
+ if (attempt < total && delayMs > 0) {
88
+ await sleep(delayMs);
89
+ }
81
90
  }
82
91
  }
83
92
  throw lastErr;
@@ -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;