@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.
- package/CHANGELOG.md +25 -1
- package/README.md +42 -3
- package/dist/cli/index.d.ts +4 -1
- package/dist/cli/index.js +25 -1
- package/dist/config.d.ts +46 -14
- package/dist/config.js +170 -53
- package/dist/devices/bleBackend.d.ts +30 -2
- package/dist/devices/bleBackend.js +179 -7
- package/dist/devices/bleDiscovery.d.ts +9 -2
- package/dist/devices/bleDiscovery.js +12 -3
- package/dist/devices/discoveryCoordinator.d.ts +18 -0
- package/dist/devices/discoveryCoordinator.js +89 -22
- package/dist/devices/gicisky/compression.d.ts +22 -9
- package/dist/devices/gicisky/compression.js +128 -13
- package/dist/devices/gicisky/encode.d.ts +1 -1
- package/dist/devices/gicisky/encode.js +2 -2
- package/dist/devices/gicisky/index.js +19 -8
- package/dist/devices/gicisky/layout.d.ts +13 -1
- package/dist/devices/gicisky/layout.js +21 -0
- package/dist/devices/types.d.ts +22 -0
- package/dist/devices/types.js +2 -0
- package/dist/devices/zhsunyco/compression.d.ts +1 -0
- package/dist/devices/zhsunyco/compression.js +30 -0
- package/dist/devices/zhsunyco/index.js +10 -4
- package/dist/devices/zhsunyco/protocol.d.ts +2 -0
- package/dist/devices/zhsunyco/protocol.js +2 -0
- package/dist/plugin.js +11 -2
- package/dist/render/mirror.d.ts +11 -0
- package/dist/render/mirror.js +24 -0
- package/dist/repaintScheduler.js +27 -12
- package/package.json +2 -2
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 <=
|
|
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).
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
*
|
|
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;
|