@matter-server/ble-proxy 1.4.0 → 1.4.1-alpha.0-20261008-d619c2e
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/dist/esm/BleProxyConnection.d.ts.map +1 -1
- package/dist/esm/BleProxyConnection.js +47 -2
- package/dist/esm/BleProxyConnection.js.map +1 -1
- package/dist/esm/BleProxyHandler.d.ts.map +1 -1
- package/dist/esm/BleProxyHandler.js +26 -6
- package/dist/esm/BleProxyHandler.js.map +1 -1
- package/dist/esm/BleProxyProtocol.d.ts +6 -0
- package/dist/esm/BleProxyProtocol.d.ts.map +1 -1
- package/dist/esm/BleProxyProtocol.js +11 -0
- package/dist/esm/BleProxyProtocol.js.map +1 -1
- package/dist/esm/ProxyBleChannel.d.ts.map +1 -1
- package/dist/esm/ProxyBleChannel.js +2 -1
- package/dist/esm/ProxyBleChannel.js.map +1 -1
- package/dist/esm/example/NobleBleProxyClient.d.ts +63 -1
- package/dist/esm/example/NobleBleProxyClient.d.ts.map +1 -1
- package/dist/esm/example/NobleBleProxyClient.js +346 -59
- package/dist/esm/example/NobleBleProxyClient.js.map +1 -1
- package/package.json +7 -7
- package/src/BleProxyConnection.ts +55 -2
- package/src/BleProxyHandler.ts +33 -8
- package/src/BleProxyProtocol.ts +11 -0
- package/src/ProxyBleChannel.ts +1 -0
- package/src/example/NobleBleProxyClient.ts +481 -76
|
@@ -19,19 +19,32 @@ import {
|
|
|
19
19
|
BLE_PROXY_PROTOCOL_VERSION,
|
|
20
20
|
BinaryFrameOpcode,
|
|
21
21
|
type BleProxyCommandName,
|
|
22
|
+
BleProxyErrorCode,
|
|
23
|
+
type BleProxyErrorCodeValue,
|
|
22
24
|
type CommandMessage,
|
|
23
25
|
type ConnectArgs,
|
|
24
26
|
type DeviceDiscoveredData,
|
|
25
27
|
type DiscoverCharacteristicsArgs,
|
|
26
28
|
type DiscoverServicesArgs,
|
|
27
29
|
type ReadCharacteristicArgs,
|
|
30
|
+
type StartScanArgs,
|
|
28
31
|
type SubscribeCharacteristicArgs,
|
|
29
32
|
type UnsubscribeCharacteristicArgs,
|
|
33
|
+
type WriteAndSubscribeArgs,
|
|
30
34
|
type WriteCharacteristicArgs,
|
|
31
35
|
decodeBinaryFrame,
|
|
32
36
|
encodeBinaryFrame,
|
|
33
37
|
} from "../BleProxyProtocol.js";
|
|
34
38
|
|
|
39
|
+
/**
|
|
40
|
+
* A `connect` can arrive before this process has advertised the address it names — observed at
|
|
41
|
+
* 104 ms ahead of the client's own `device_discovered`.
|
|
42
|
+
*/
|
|
43
|
+
const CONNECT_DISCOVERY_TIMEOUT_MS = 5_000;
|
|
44
|
+
|
|
45
|
+
/** Bounds one noble scan call, so a wedged adapter cannot stall every later scan and connect. */
|
|
46
|
+
const SCAN_OPERATION_TIMEOUT_MS = 10_000;
|
|
47
|
+
|
|
35
48
|
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
|
|
36
49
|
return Promise.race([promise, new Promise<never>((_, reject) => setTimeout(() => reject(new Error(message)), ms))]);
|
|
37
50
|
}
|
|
@@ -57,15 +70,90 @@ function error(...args: unknown[]): void {
|
|
|
57
70
|
console.error(...args);
|
|
58
71
|
}
|
|
59
72
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
73
|
+
/**
|
|
74
|
+
* The part of noble this client drives. Structural so a test can supply a stand-in without
|
|
75
|
+
* implementing the full `Noble` surface; the real instance satisfies it.
|
|
76
|
+
*/
|
|
77
|
+
export interface NobleApi {
|
|
78
|
+
startScanningAsync(serviceUuids: string[], allowDuplicates: boolean): Promise<void>;
|
|
79
|
+
stopScanningAsync(): Promise<void>;
|
|
80
|
+
stop(): void;
|
|
81
|
+
on(event: "warning" | "stateChange", listener: (value: string) => void): unknown;
|
|
82
|
+
on(event: "discover", listener: (peripheral: Peripheral) => void): unknown;
|
|
83
|
+
removeAllListeners(event: "discover"): unknown;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The parts of noble's GATT objects this client drives. Structural for the same reason as
|
|
88
|
+
* `NobleApi`: a test can supply stand-ins, and noble's own types satisfy these on assignment.
|
|
89
|
+
*/
|
|
90
|
+
export interface Characteristic {
|
|
91
|
+
readonly uuid: string;
|
|
92
|
+
readonly properties: string[];
|
|
93
|
+
readAsync(): Promise<Buffer>;
|
|
94
|
+
writeAsync(data: Buffer, withoutResponse: boolean): Promise<void>;
|
|
95
|
+
subscribeAsync(): Promise<void>;
|
|
96
|
+
unsubscribeAsync(): Promise<void>;
|
|
97
|
+
on(event: "data", listener: NotificationListener): unknown;
|
|
98
|
+
removeListener(event: "data", listener: NotificationListener): unknown;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface Service {
|
|
102
|
+
readonly uuid: string;
|
|
103
|
+
readonly characteristics?: Characteristic[];
|
|
104
|
+
discoverCharacteristicsAsync(uuids?: string[]): Promise<Characteristic[]>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface Peripheral {
|
|
108
|
+
readonly id: string;
|
|
109
|
+
readonly address: string;
|
|
110
|
+
readonly rssi: number;
|
|
111
|
+
readonly mtu?: number | null;
|
|
112
|
+
readonly state: string;
|
|
113
|
+
readonly connectable?: boolean;
|
|
114
|
+
readonly advertisement: {
|
|
115
|
+
localName?: string;
|
|
116
|
+
serviceUuids?: string[];
|
|
117
|
+
serviceData?: Array<{ uuid: string; data: Uint8Array }>;
|
|
118
|
+
};
|
|
119
|
+
connectAsync(): Promise<void>;
|
|
120
|
+
disconnectAsync(): Promise<void>;
|
|
121
|
+
discoverServicesAsync(serviceUuids: string[]): Promise<Service[]>;
|
|
122
|
+
once(event: "disconnect", listener: () => void): unknown;
|
|
123
|
+
removeListener(event: "disconnect", listener: () => void): unknown;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** noble reuses the "data" event for read responses, and delivers a null payload on read failure. */
|
|
127
|
+
export type NotificationListener = (payload: Buffer | null) => void;
|
|
128
|
+
|
|
129
|
+
interface Subscription {
|
|
130
|
+
characteristic: Characteristic;
|
|
131
|
+
onData: NotificationListener;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The scan the server has asked for; `undefined` means it has asked for none. */
|
|
135
|
+
interface ScanConfig {
|
|
136
|
+
serviceUuids: string[];
|
|
137
|
+
reportDuplicates: boolean;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface NobleBleProxyClientOptions {
|
|
141
|
+
/** Stand-in for the dynamically imported noble instance. */
|
|
142
|
+
noble?: NobleApi;
|
|
143
|
+
scanOperationTimeoutMs?: number;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A pending `#awaitDiscovered`, kept so shutdown can settle it and clear its timer. */
|
|
147
|
+
interface DiscoveryWaiter {
|
|
148
|
+
resolve: (peripheral: Peripheral | undefined) => void;
|
|
149
|
+
timer: ReturnType<typeof setTimeout>;
|
|
150
|
+
}
|
|
63
151
|
|
|
64
152
|
interface ConnectionState {
|
|
65
153
|
peripheral: Peripheral;
|
|
66
154
|
services: Map<string, Service>;
|
|
67
155
|
characteristics: Map<string, Characteristic>;
|
|
68
|
-
subscriptions: Map<string,
|
|
156
|
+
subscriptions: Map<string, Subscription>;
|
|
69
157
|
lastWriteCharacteristic?: Characteristic;
|
|
70
158
|
}
|
|
71
159
|
|
|
@@ -83,20 +171,34 @@ export class NobleBleProxyClient {
|
|
|
83
171
|
readonly #serverUrl: string;
|
|
84
172
|
readonly #hciId?: number;
|
|
85
173
|
#ws?: WebSocket;
|
|
86
|
-
#noble?:
|
|
174
|
+
#noble?: NobleApi;
|
|
87
175
|
#connections = new Map<number, ConnectionState>();
|
|
88
176
|
#nextHandle = 1;
|
|
89
177
|
#discoveredPeripherals = new Map<string, Peripheral>();
|
|
178
|
+
#discoveryWaiters = new Map<string, Set<DiscoveryWaiter>>();
|
|
90
179
|
#lastDiscoverFingerprint = new Map<string, DiscoverFingerprint>();
|
|
180
|
+
#desiredScan?: ScanConfig;
|
|
181
|
+
#scanPauses = 0;
|
|
182
|
+
#scanGeneration = 0;
|
|
183
|
+
#scanOperations: Promise<unknown> = Promise.resolve();
|
|
184
|
+
#discoverListenerInstalled = false;
|
|
185
|
+
readonly #scanOperationTimeoutMs: number;
|
|
91
186
|
#commandHandlers = new Map<BleProxyCommandName, CommandHandler>();
|
|
92
187
|
#closing = false;
|
|
93
188
|
|
|
94
|
-
constructor(serverUrl: string, hciId?: number) {
|
|
189
|
+
constructor(serverUrl: string, hciId?: number, options: NobleBleProxyClientOptions = {}) {
|
|
95
190
|
this.#serverUrl = serverUrl;
|
|
96
191
|
this.#hciId = hciId;
|
|
192
|
+
this.#noble = options.noble;
|
|
193
|
+
this.#scanOperationTimeoutMs = options.scanOperationTimeoutMs ?? SCAN_OPERATION_TIMEOUT_MS;
|
|
97
194
|
this.#registerCommandHandlers();
|
|
98
195
|
}
|
|
99
196
|
|
|
197
|
+
/** Command names this client implements. Must cover every `BleProxyCommand` value. */
|
|
198
|
+
get supportedCommands(): BleProxyCommandName[] {
|
|
199
|
+
return [...this.#commandHandlers.keys()];
|
|
200
|
+
}
|
|
201
|
+
|
|
100
202
|
async connect(): Promise<void> {
|
|
101
203
|
// Load Noble dynamically (it's an optional dependency)
|
|
102
204
|
await this.#loadNoble();
|
|
@@ -118,7 +220,19 @@ export class NobleBleProxyClient {
|
|
|
118
220
|
return;
|
|
119
221
|
}
|
|
120
222
|
|
|
121
|
-
|
|
223
|
+
// A throw here escapes into ws's receiver and takes the process down with it.
|
|
224
|
+
let msg;
|
|
225
|
+
try {
|
|
226
|
+
msg = JSON.parse(data.toString());
|
|
227
|
+
} catch {
|
|
228
|
+
warn("Received invalid JSON from the server");
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
// Valid JSON is not necessarily an object, and `"id" in null` throws.
|
|
232
|
+
if (typeof msg !== "object" || msg === null) {
|
|
233
|
+
warn("Ignoring non-object JSON message from the server");
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
122
236
|
|
|
123
237
|
if (!handshakeComplete) {
|
|
124
238
|
if (msg.type === "hello_response") {
|
|
@@ -182,11 +296,21 @@ export class NobleBleProxyClient {
|
|
|
182
296
|
}
|
|
183
297
|
}
|
|
184
298
|
this.#connections.clear();
|
|
299
|
+
for (const waiters of this.#discoveryWaiters.values()) {
|
|
300
|
+
for (const waiter of waiters) {
|
|
301
|
+
clearTimeout(waiter.timer);
|
|
302
|
+
waiter.resolve(undefined);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
this.#discoveryWaiters.clear();
|
|
185
306
|
this.#noble?.stop();
|
|
186
307
|
this.#ws?.close();
|
|
187
308
|
}
|
|
188
309
|
|
|
189
310
|
async #loadNoble(): Promise<void> {
|
|
311
|
+
if (this.#noble) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
190
314
|
if (this.#hciId !== undefined) {
|
|
191
315
|
process.env.NOBLE_HCI_DEVICE_ID = this.#hciId.toString();
|
|
192
316
|
}
|
|
@@ -208,7 +332,7 @@ export class NobleBleProxyClient {
|
|
|
208
332
|
}
|
|
209
333
|
|
|
210
334
|
#registerCommandHandlers(): void {
|
|
211
|
-
this.#commandHandlers.set("start_scan", (id,
|
|
335
|
+
this.#commandHandlers.set("start_scan", (id, args) => this.#handleStartScan(id, args));
|
|
212
336
|
this.#commandHandlers.set("stop_scan", id => this.#handleStopScan(id));
|
|
213
337
|
this.#commandHandlers.set("connect", (id, args) => this.#handleConnect(id, args as unknown as ConnectArgs));
|
|
214
338
|
this.#commandHandlers.set("disconnect", (id, args) =>
|
|
@@ -229,6 +353,9 @@ export class NobleBleProxyClient {
|
|
|
229
353
|
this.#commandHandlers.set("subscribe_characteristic", (id, args) =>
|
|
230
354
|
this.#handleSubscribeCharacteristic(id, args as unknown as SubscribeCharacteristicArgs),
|
|
231
355
|
);
|
|
356
|
+
this.#commandHandlers.set("write_and_subscribe", (id, args) =>
|
|
357
|
+
this.#handleWriteAndSubscribe(id, args as unknown as WriteAndSubscribeArgs),
|
|
358
|
+
);
|
|
232
359
|
this.#commandHandlers.set("unsubscribe_characteristic", (id, args) =>
|
|
233
360
|
this.#handleUnsubscribeCharacteristic(id, args as unknown as UnsubscribeCharacteristicArgs),
|
|
234
361
|
);
|
|
@@ -247,33 +374,65 @@ export class NobleBleProxyClient {
|
|
|
247
374
|
const handler = this.#commandHandlers.get(msg.command);
|
|
248
375
|
if (!handler) {
|
|
249
376
|
error(`[→ERR] id=${msg.id} Unknown command: ${msg.command}`);
|
|
250
|
-
this.#sendError(msg.id,
|
|
377
|
+
this.#sendError(msg.id, BleProxyErrorCode.InternalError, `Unknown command: ${msg.command}`);
|
|
251
378
|
return;
|
|
252
379
|
}
|
|
253
380
|
try {
|
|
254
381
|
await handler(msg.id, msg.args ?? {});
|
|
255
382
|
} catch (err) {
|
|
256
383
|
error(`[→ERR] id=${msg.id} ${msg.command} threw: ${(err as Error).message}`);
|
|
257
|
-
this.#sendError(msg.id,
|
|
384
|
+
this.#sendError(msg.id, BleProxyErrorCode.InternalError, `${(err as Error).message}`);
|
|
258
385
|
}
|
|
259
386
|
}
|
|
260
387
|
|
|
261
388
|
// ─── Command Handlers ────────────────────────────────────────────────────
|
|
262
389
|
|
|
263
|
-
async #handleStartScan(id: number): Promise<void> {
|
|
390
|
+
async #handleStartScan(id: number, args: StartScanArgs): Promise<void> {
|
|
264
391
|
if (!this.#noble) {
|
|
265
|
-
this.#sendError(id,
|
|
392
|
+
this.#sendError(id, BleProxyErrorCode.BluetoothUnavailable, "Noble not initialized");
|
|
266
393
|
return;
|
|
267
394
|
}
|
|
268
395
|
|
|
396
|
+
const config: ScanConfig = {
|
|
397
|
+
serviceUuids: args.service_uuids ?? [],
|
|
398
|
+
reportDuplicates: args.allow_duplicates ?? true,
|
|
399
|
+
};
|
|
269
400
|
this.#lastDiscoverFingerprint.clear();
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
401
|
+
this.#installDiscoverListener();
|
|
402
|
+
|
|
403
|
+
try {
|
|
404
|
+
await this.#requestScan(config);
|
|
405
|
+
} catch (err) {
|
|
406
|
+
this.#sendError(id, BleProxyErrorCode.InternalError, (err as Error).message);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
log(
|
|
410
|
+
`[SCAN] BLE scan started (filter: ${config.serviceUuids.join(",") || "none"}` +
|
|
411
|
+
` allowDuplicates=${config.reportDuplicates})`,
|
|
412
|
+
);
|
|
413
|
+
this.#sendSuccess(id);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Installed once and kept: noble emits `discover` only while a scan is running. */
|
|
417
|
+
#installDiscoverListener(): void {
|
|
418
|
+
if (this.#discoverListenerInstalled) {
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
this.#discoverListenerInstalled = true;
|
|
422
|
+
this.#noble!.on("discover", (peripheral: Peripheral) => {
|
|
273
423
|
// On macOS, peripheral.address is often empty — fall back to peripheral.id (UUID)
|
|
274
424
|
const address = peripheral.address || peripheral.id;
|
|
275
425
|
this.#discoveredPeripherals.set(address, peripheral);
|
|
276
426
|
|
|
427
|
+
const waiters = this.#discoveryWaiters.get(address);
|
|
428
|
+
if (waiters) {
|
|
429
|
+
this.#discoveryWaiters.delete(address);
|
|
430
|
+
for (const waiter of waiters) {
|
|
431
|
+
clearTimeout(waiter.timer);
|
|
432
|
+
waiter.resolve(peripheral);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
277
436
|
const serviceData: Record<string, string> = {};
|
|
278
437
|
for (const sd of peripheral.advertisement.serviceData ?? []) {
|
|
279
438
|
serviceData[sd.uuid] = Buffer.from(sd.data).toString("base64");
|
|
@@ -301,7 +460,7 @@ export class NobleBleProxyClient {
|
|
|
301
460
|
prev.serviceUuids !== fingerprint.serviceUuids ||
|
|
302
461
|
prev.serviceData !== fingerprint.serviceData;
|
|
303
462
|
|
|
304
|
-
if (!changed) {
|
|
463
|
+
if (!(this.#desiredScan?.reportDuplicates ?? true) && !changed) {
|
|
305
464
|
return;
|
|
306
465
|
}
|
|
307
466
|
this.#lastDiscoverFingerprint.set(address, fingerprint);
|
|
@@ -323,25 +482,57 @@ export class NobleBleProxyClient {
|
|
|
323
482
|
|
|
324
483
|
this.#sendEvent("device_discovered", event as unknown as Record<string, unknown>);
|
|
325
484
|
});
|
|
326
|
-
|
|
327
|
-
await this.#noble.startScanningAsync(["fff6"], true);
|
|
328
|
-
log("[SCAN] BLE scan started (filter: fff6)");
|
|
329
|
-
this.#sendSuccess(id);
|
|
330
485
|
}
|
|
331
486
|
|
|
332
487
|
async #handleStopScan(id: number): Promise<void> {
|
|
333
|
-
|
|
488
|
+
try {
|
|
489
|
+
await this.#requestScan(undefined);
|
|
490
|
+
} catch (err) {
|
|
491
|
+
this.#sendError(id, BleProxyErrorCode.InternalError, (err as Error).message);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
334
494
|
log("[SCAN] BLE scan stopped");
|
|
335
495
|
this.#sendSuccess(id);
|
|
336
496
|
}
|
|
337
497
|
|
|
498
|
+
/** Resolves once `address` is advertised, or undefined once the wait times out. */
|
|
499
|
+
#awaitDiscovered(address: string): Promise<Peripheral | undefined> {
|
|
500
|
+
const known = this.#discoveredPeripherals.get(address);
|
|
501
|
+
if (known) {
|
|
502
|
+
return Promise.resolve(known);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (!this.#desiredScan) {
|
|
506
|
+
return Promise.resolve(undefined);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
log(`[CONN] "${address}" not advertised yet, waiting up to ${CONNECT_DISCOVERY_TIMEOUT_MS}ms`);
|
|
510
|
+
return new Promise<Peripheral | undefined>(resolve => {
|
|
511
|
+
const waiters = this.#discoveryWaiters.get(address) ?? new Set();
|
|
512
|
+
this.#discoveryWaiters.set(address, waiters);
|
|
513
|
+
|
|
514
|
+
const waiter: DiscoveryWaiter = {
|
|
515
|
+
resolve,
|
|
516
|
+
timer: setTimeout(() => {
|
|
517
|
+
waiters.delete(waiter);
|
|
518
|
+
if (waiters.size === 0) {
|
|
519
|
+
this.#discoveryWaiters.delete(address);
|
|
520
|
+
}
|
|
521
|
+
resolve(undefined);
|
|
522
|
+
}, CONNECT_DISCOVERY_TIMEOUT_MS),
|
|
523
|
+
};
|
|
524
|
+
waiters.add(waiter);
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
338
528
|
async #handleConnect(id: number, args: ConnectArgs): Promise<void> {
|
|
339
|
-
const peripheral = this.#
|
|
529
|
+
const peripheral = await this.#awaitDiscovered(args.address);
|
|
340
530
|
if (!peripheral) {
|
|
341
531
|
error(
|
|
342
|
-
`[CONN] No peripheral found for address "${args.address}"
|
|
532
|
+
`[CONN] No peripheral found for address "${args.address}" after ${CONNECT_DISCOVERY_TIMEOUT_MS}ms.` +
|
|
533
|
+
` Known: ${[...this.#discoveredPeripherals.keys()].join(", ")}`,
|
|
343
534
|
);
|
|
344
|
-
this.#sendError(id,
|
|
535
|
+
this.#sendError(id, BleProxyErrorCode.DeviceNotFound, `No device found for address ${args.address}`);
|
|
345
536
|
return;
|
|
346
537
|
}
|
|
347
538
|
|
|
@@ -366,12 +557,13 @@ export class NobleBleProxyClient {
|
|
|
366
557
|
peripheral.once("disconnect", disconnectListener);
|
|
367
558
|
|
|
368
559
|
log(`[CONN] Connecting to "${args.address}" (state=${peripheral.state})...`);
|
|
560
|
+
let releaseScanPause: (() => Promise<void>) | undefined;
|
|
369
561
|
try {
|
|
370
562
|
// Pause scanning during connect + GATT discovery. On macOS, scanning concurrently
|
|
371
563
|
// with `service.discoverCharacteristicsAsync` causes the CoreBluetooth delegate
|
|
372
564
|
// callback to never fire; the peripheral stays connected but discovery hangs.
|
|
373
565
|
log(`[SCAN] pausing scan for connect+interview...`);
|
|
374
|
-
await this.#
|
|
566
|
+
releaseScanPause = await this.#acquireScanPause();
|
|
375
567
|
|
|
376
568
|
await peripheral.connectAsync();
|
|
377
569
|
log(`[CONN] Connected handle=${handle} state=${peripheral.state} mtu=${peripheral.mtu ?? "?"}`);
|
|
@@ -404,10 +596,6 @@ export class NobleBleProxyClient {
|
|
|
404
596
|
const mtu = peripheral.mtu ?? 23;
|
|
405
597
|
log(`[GATT] handle=${handle} ready mtu=${mtu}`);
|
|
406
598
|
|
|
407
|
-
// Resume scanning so the server can still observe new devices and rssi updates.
|
|
408
|
-
log(`[SCAN] resuming scan after connect+interview...`);
|
|
409
|
-
await this.#noble!.startScanningAsync(["fff6"], true);
|
|
410
|
-
|
|
411
599
|
this.#sendSuccess(id, { connection_handle: handle, mtu });
|
|
412
600
|
} catch (err) {
|
|
413
601
|
const reason = disconnectedReason ?? (err as Error).message;
|
|
@@ -423,20 +611,19 @@ export class NobleBleProxyClient {
|
|
|
423
611
|
warn(`[CONN] handle=${handle} cleanup disconnect failed: ${(disconnectErr as Error).message}`),
|
|
424
612
|
);
|
|
425
613
|
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
this.#sendError(id, "internal_error", reason);
|
|
614
|
+
this.#sendError(id, BleProxyErrorCode.ConnectionFailed, reason);
|
|
615
|
+
} finally {
|
|
616
|
+
log(`[SCAN] resuming scan after connect+interview...`);
|
|
617
|
+
await releaseScanPause?.().catch(scanErr =>
|
|
618
|
+
warn(`[SCAN] failed to resume scanning after connect: ${(scanErr as Error).message}`),
|
|
619
|
+
);
|
|
433
620
|
}
|
|
434
621
|
}
|
|
435
622
|
|
|
436
623
|
async #handleDisconnect(id: number, connectionHandle: number): Promise<void> {
|
|
437
624
|
const conn = this.#connections.get(connectionHandle);
|
|
438
625
|
if (!conn) {
|
|
439
|
-
this.#sendError(id,
|
|
626
|
+
this.#sendError(id, BleProxyErrorCode.NotConnected, `No connection with handle ${connectionHandle}`);
|
|
440
627
|
return;
|
|
441
628
|
}
|
|
442
629
|
|
|
@@ -450,7 +637,7 @@ export class NobleBleProxyClient {
|
|
|
450
637
|
async #handleDiscoverServices(id: number, args: DiscoverServicesArgs): Promise<void> {
|
|
451
638
|
const conn = this.#connections.get(args.connection_handle);
|
|
452
639
|
if (!conn) {
|
|
453
|
-
this.#sendError(id,
|
|
640
|
+
this.#sendError(id, BleProxyErrorCode.NotConnected, `No connection with handle ${args.connection_handle}`);
|
|
454
641
|
return;
|
|
455
642
|
}
|
|
456
643
|
|
|
@@ -464,11 +651,17 @@ export class NobleBleProxyClient {
|
|
|
464
651
|
|
|
465
652
|
// Fallback: lazy discover
|
|
466
653
|
log(`[GATT] handle=${args.connection_handle} discovering services (lazy)...`);
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
654
|
+
let services: Service[];
|
|
655
|
+
try {
|
|
656
|
+
services = await withTimeout(
|
|
657
|
+
conn.peripheral.discoverServicesAsync([]),
|
|
658
|
+
10_000,
|
|
659
|
+
"discoverServices timed out after 10s",
|
|
660
|
+
);
|
|
661
|
+
} catch (err) {
|
|
662
|
+
this.#sendError(id, BleProxyErrorCode.DiscoveryFailed, (err as Error).message);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
472
665
|
for (const service of services) {
|
|
473
666
|
conn.services.set(service.uuid, service);
|
|
474
667
|
}
|
|
@@ -482,13 +675,13 @@ export class NobleBleProxyClient {
|
|
|
482
675
|
async #handleDiscoverCharacteristics(id: number, args: DiscoverCharacteristicsArgs): Promise<void> {
|
|
483
676
|
const conn = this.#connections.get(args.connection_handle);
|
|
484
677
|
if (!conn) {
|
|
485
|
-
this.#sendError(id,
|
|
678
|
+
this.#sendError(id, BleProxyErrorCode.NotConnected, `No connection with handle ${args.connection_handle}`);
|
|
486
679
|
return;
|
|
487
680
|
}
|
|
488
681
|
|
|
489
682
|
const service = conn.services.get(args.service_uuid);
|
|
490
683
|
if (!service) {
|
|
491
|
-
this.#sendError(id,
|
|
684
|
+
this.#sendError(id, BleProxyErrorCode.ServiceNotFound, `Service ${args.service_uuid} not found`);
|
|
492
685
|
return;
|
|
493
686
|
}
|
|
494
687
|
|
|
@@ -510,11 +703,17 @@ export class NobleBleProxyClient {
|
|
|
510
703
|
|
|
511
704
|
// Fallback: lazy discover
|
|
512
705
|
log(`[GATT] handle=${args.connection_handle} discovering characteristics for ${args.service_uuid} (lazy)...`);
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
706
|
+
let characteristics: Characteristic[];
|
|
707
|
+
try {
|
|
708
|
+
characteristics = await withTimeout(
|
|
709
|
+
service.discoverCharacteristicsAsync([]),
|
|
710
|
+
10_000,
|
|
711
|
+
`discoverCharacteristics(${args.service_uuid}) timed out after 10s`,
|
|
712
|
+
);
|
|
713
|
+
} catch (err) {
|
|
714
|
+
this.#sendError(id, BleProxyErrorCode.DiscoveryFailed, (err as Error).message);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
518
717
|
for (const char of characteristics) {
|
|
519
718
|
conn.characteristics.set(char.uuid, char);
|
|
520
719
|
}
|
|
@@ -534,17 +733,31 @@ export class NobleBleProxyClient {
|
|
|
534
733
|
async #handleReadCharacteristic(id: number, args: ReadCharacteristicArgs): Promise<void> {
|
|
535
734
|
const conn = this.#connections.get(args.connection_handle);
|
|
536
735
|
if (!conn) {
|
|
537
|
-
this.#sendError(id,
|
|
736
|
+
this.#sendError(id, BleProxyErrorCode.NotConnected, `No connection with handle ${args.connection_handle}`);
|
|
538
737
|
return;
|
|
539
738
|
}
|
|
540
739
|
|
|
541
740
|
const char = this.#findCharacteristic(conn, args.characteristic_uuid);
|
|
542
741
|
if (!char) {
|
|
543
|
-
this.#sendError(
|
|
742
|
+
this.#sendError(
|
|
743
|
+
id,
|
|
744
|
+
BleProxyErrorCode.CharacteristicNotFound,
|
|
745
|
+
`Characteristic ${args.characteristic_uuid} not found`,
|
|
746
|
+
);
|
|
544
747
|
return;
|
|
545
748
|
}
|
|
546
749
|
|
|
547
|
-
|
|
750
|
+
let data: Buffer;
|
|
751
|
+
try {
|
|
752
|
+
data = await char.readAsync();
|
|
753
|
+
} catch (err) {
|
|
754
|
+
this.#sendError(
|
|
755
|
+
id,
|
|
756
|
+
BleProxyErrorCode.ReadFailed,
|
|
757
|
+
`read(${args.characteristic_uuid}): ${(err as Error).message}`,
|
|
758
|
+
);
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
548
761
|
log(`[GATT] read ${args.characteristic_uuid} → ${data.length} bytes`);
|
|
549
762
|
this.#sendSuccess(id, { value: Buffer.from(data).toString("base64") });
|
|
550
763
|
}
|
|
@@ -552,20 +765,33 @@ export class NobleBleProxyClient {
|
|
|
552
765
|
async #handleWriteCharacteristic(id: number, args: WriteCharacteristicArgs): Promise<void> {
|
|
553
766
|
const conn = this.#connections.get(args.connection_handle);
|
|
554
767
|
if (!conn) {
|
|
555
|
-
this.#sendError(id,
|
|
768
|
+
this.#sendError(id, BleProxyErrorCode.NotConnected, `No connection with handle ${args.connection_handle}`);
|
|
556
769
|
return;
|
|
557
770
|
}
|
|
558
771
|
|
|
559
772
|
const char = this.#findCharacteristic(conn, args.characteristic_uuid);
|
|
560
773
|
if (!char) {
|
|
561
|
-
this.#sendError(
|
|
774
|
+
this.#sendError(
|
|
775
|
+
id,
|
|
776
|
+
BleProxyErrorCode.CharacteristicNotFound,
|
|
777
|
+
`Characteristic ${args.characteristic_uuid} not found`,
|
|
778
|
+
);
|
|
562
779
|
return;
|
|
563
780
|
}
|
|
564
781
|
|
|
565
782
|
const data = Buffer.from(args.value, "base64");
|
|
566
783
|
const withResponse = args.response ?? false;
|
|
567
784
|
log(`[GATT] write ${args.characteristic_uuid} ${data.length} bytes withResponse=${withResponse}`);
|
|
568
|
-
|
|
785
|
+
try {
|
|
786
|
+
await char.writeAsync(data, !withResponse);
|
|
787
|
+
} catch (err) {
|
|
788
|
+
this.#sendError(
|
|
789
|
+
id,
|
|
790
|
+
BleProxyErrorCode.WriteFailed,
|
|
791
|
+
`write(${args.characteristic_uuid}): ${(err as Error).message}`,
|
|
792
|
+
);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
569
795
|
conn.lastWriteCharacteristic = char;
|
|
570
796
|
this.#sendSuccess(id);
|
|
571
797
|
}
|
|
@@ -573,45 +799,123 @@ export class NobleBleProxyClient {
|
|
|
573
799
|
async #handleSubscribeCharacteristic(id: number, args: SubscribeCharacteristicArgs): Promise<void> {
|
|
574
800
|
const conn = this.#connections.get(args.connection_handle);
|
|
575
801
|
if (!conn) {
|
|
576
|
-
this.#sendError(id,
|
|
802
|
+
this.#sendError(id, BleProxyErrorCode.NotConnected, `No connection with handle ${args.connection_handle}`);
|
|
577
803
|
return;
|
|
578
804
|
}
|
|
579
805
|
|
|
580
806
|
const char = this.#findCharacteristic(conn, args.characteristic_uuid);
|
|
581
807
|
if (!char) {
|
|
582
|
-
this.#sendError(
|
|
808
|
+
this.#sendError(
|
|
809
|
+
id,
|
|
810
|
+
BleProxyErrorCode.CharacteristicNotFound,
|
|
811
|
+
`Characteristic ${args.characteristic_uuid} not found`,
|
|
812
|
+
);
|
|
583
813
|
return;
|
|
584
814
|
}
|
|
585
815
|
|
|
586
816
|
const handle = args.connection_handle;
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
this.#sendBinaryFrame(BinaryFrameOpcode.Notification, handle, new Uint8Array(data));
|
|
591
|
-
});
|
|
592
|
-
|
|
593
|
-
await char.subscribeAsync();
|
|
594
|
-
conn.subscriptions.set(args.characteristic_uuid, char);
|
|
817
|
+
if (!(await this.#subscribe(id, conn, handle, args.characteristic_uuid, char))) {
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
595
820
|
log(`[GATT] subscribe ${args.characteristic_uuid} handle=${handle}`);
|
|
596
821
|
this.#sendSuccess(id);
|
|
597
822
|
}
|
|
598
823
|
|
|
824
|
+
/** No-op when `uuid` is already subscribed. Returns false once an error response was sent. */
|
|
825
|
+
async #subscribe(
|
|
826
|
+
id: number,
|
|
827
|
+
conn: ConnectionState,
|
|
828
|
+
handle: number,
|
|
829
|
+
uuid: string,
|
|
830
|
+
char: Characteristic,
|
|
831
|
+
): Promise<boolean> {
|
|
832
|
+
// noble's `_notify` resolves without re-enabling CCCD when already notifying, so a
|
|
833
|
+
// second "data" listener would double every forwarded notification.
|
|
834
|
+
const key = NobleBleProxyClient.#subscriptionKey(uuid);
|
|
835
|
+
if (conn.subscriptions.has(key)) {
|
|
836
|
+
return true;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Attached before the CCCD enable: noble drops "data" emitted with no listener attached.
|
|
840
|
+
const onData = this.#notificationForwarder(handle, uuid);
|
|
841
|
+
char.on("data", onData);
|
|
842
|
+
try {
|
|
843
|
+
await char.subscribeAsync();
|
|
844
|
+
} catch (err) {
|
|
845
|
+
char.removeListener("data", onData);
|
|
846
|
+
this.#sendError(id, BleProxyErrorCode.SubscribeFailed, `subscribe(${uuid}): ${(err as Error).message}`);
|
|
847
|
+
return false;
|
|
848
|
+
}
|
|
849
|
+
conn.subscriptions.set(key, { characteristic: char, onData });
|
|
850
|
+
return true;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
async #handleWriteAndSubscribe(id: number, args: WriteAndSubscribeArgs): Promise<void> {
|
|
854
|
+
const conn = this.#connections.get(args.connection_handle);
|
|
855
|
+
if (!conn) {
|
|
856
|
+
this.#sendError(id, BleProxyErrorCode.NotConnected, `No connection with handle ${args.connection_handle}`);
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const writeChar = this.#findCharacteristic(conn, args.write_uuid);
|
|
861
|
+
if (!writeChar) {
|
|
862
|
+
this.#sendError(
|
|
863
|
+
id,
|
|
864
|
+
BleProxyErrorCode.CharacteristicNotFound,
|
|
865
|
+
`Characteristic ${args.write_uuid} not found`,
|
|
866
|
+
);
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
const subscribeChar = this.#findCharacteristic(conn, args.subscribe_uuid);
|
|
871
|
+
if (!subscribeChar) {
|
|
872
|
+
this.#sendError(
|
|
873
|
+
id,
|
|
874
|
+
BleProxyErrorCode.CharacteristicNotFound,
|
|
875
|
+
`Characteristic ${args.subscribe_uuid} not found`,
|
|
876
|
+
);
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const handle = args.connection_handle;
|
|
881
|
+
const value = Buffer.from(args.write_value, "base64");
|
|
882
|
+
const withResponse = args.write_response ?? false;
|
|
883
|
+
log(
|
|
884
|
+
`[GATT] write_and_subscribe handle=${handle} write=${args.write_uuid} ${value.length} bytes` +
|
|
885
|
+
` withResponse=${withResponse} subscribe=${args.subscribe_uuid}`,
|
|
886
|
+
);
|
|
887
|
+
|
|
888
|
+
try {
|
|
889
|
+
await writeChar.writeAsync(value, !withResponse);
|
|
890
|
+
} catch (err) {
|
|
891
|
+
this.#sendError(id, BleProxyErrorCode.WriteFailed, `write(${args.write_uuid}): ${(err as Error).message}`);
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
conn.lastWriteCharacteristic = writeChar;
|
|
895
|
+
|
|
896
|
+
if (!(await this.#subscribe(id, conn, handle, args.subscribe_uuid, subscribeChar))) {
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
this.#sendSuccess(id);
|
|
900
|
+
}
|
|
901
|
+
|
|
599
902
|
async #handleUnsubscribeCharacteristic(id: number, args: UnsubscribeCharacteristicArgs): Promise<void> {
|
|
600
903
|
const conn = this.#connections.get(args.connection_handle);
|
|
601
904
|
if (!conn) {
|
|
602
|
-
this.#sendError(id,
|
|
905
|
+
this.#sendError(id, BleProxyErrorCode.NotConnected, `No connection with handle ${args.connection_handle}`);
|
|
603
906
|
return;
|
|
604
907
|
}
|
|
605
908
|
|
|
606
|
-
const
|
|
607
|
-
|
|
608
|
-
|
|
909
|
+
const key = NobleBleProxyClient.#subscriptionKey(args.characteristic_uuid);
|
|
910
|
+
const subscription = conn.subscriptions.get(key);
|
|
911
|
+
if (!subscription) {
|
|
912
|
+
this.#sendError(id, BleProxyErrorCode.NotSubscribed, `Not subscribed to ${args.characteristic_uuid}`);
|
|
609
913
|
return;
|
|
610
914
|
}
|
|
611
915
|
|
|
612
|
-
await
|
|
613
|
-
|
|
614
|
-
conn.subscriptions.delete(
|
|
916
|
+
await subscription.characteristic.unsubscribeAsync();
|
|
917
|
+
subscription.characteristic.removeListener("data", subscription.onData);
|
|
918
|
+
conn.subscriptions.delete(key);
|
|
615
919
|
log(`[GATT] unsubscribe ${args.characteristic_uuid} handle=${args.connection_handle}`);
|
|
616
920
|
this.#sendSuccess(id);
|
|
617
921
|
}
|
|
@@ -619,7 +923,7 @@ export class NobleBleProxyClient {
|
|
|
619
923
|
async #handleRequestMtu(id: number, connectionHandle: number, mtu: number): Promise<void> {
|
|
620
924
|
const conn = this.#connections.get(connectionHandle);
|
|
621
925
|
if (!conn) {
|
|
622
|
-
this.#sendError(id,
|
|
926
|
+
this.#sendError(id, BleProxyErrorCode.NotConnected, `No connection with handle ${connectionHandle}`);
|
|
623
927
|
return;
|
|
624
928
|
}
|
|
625
929
|
// Noble doesn't have explicit MTU request - return the peripheral's MTU
|
|
@@ -631,7 +935,14 @@ export class NobleBleProxyClient {
|
|
|
631
935
|
// ─── Binary Frame Handling ───────────────────────────────────────────────
|
|
632
936
|
|
|
633
937
|
#handleBinaryFrame(data: Buffer): void {
|
|
634
|
-
|
|
938
|
+
// A throw here escapes into ws's receiver and takes the process down with it.
|
|
939
|
+
let frame;
|
|
940
|
+
try {
|
|
941
|
+
frame = decodeBinaryFrame(new Uint8Array(data));
|
|
942
|
+
} catch (err) {
|
|
943
|
+
warn(`[←BIN] failed to decode binary frame: ${(err as Error).message}`);
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
635
946
|
log(`[←BIN] opcode=${frame.opcode} handle=${frame.connectionHandle} payload=${frame.payload.length} bytes`);
|
|
636
947
|
|
|
637
948
|
if (frame.opcode === BinaryFrameOpcode.WriteData) {
|
|
@@ -650,6 +961,100 @@ export class NobleBleProxyClient {
|
|
|
650
961
|
|
|
651
962
|
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
652
963
|
|
|
964
|
+
/** Records the scan the server wants, then drives noble to it. Resolves once that settled. */
|
|
965
|
+
#requestScan(config: ScanConfig | undefined): Promise<void> {
|
|
966
|
+
this.#desiredScan = config;
|
|
967
|
+
return this.#reconcileScan(++this.#scanGeneration);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Suspends the running scan without forgetting it, so a resume restores the server's filter.
|
|
972
|
+
* Counted, not a flag: `connect` commands overlap, and the first to finish must not resume a
|
|
973
|
+
* scan while another is still interviewing — on macOS that reinstates the discovery hang the
|
|
974
|
+
* pause exists to prevent. Returns the release, which is safe to call more than once.
|
|
975
|
+
*/
|
|
976
|
+
async #acquireScanPause(): Promise<() => Promise<void>> {
|
|
977
|
+
this.#scanPauses++;
|
|
978
|
+
let released = false;
|
|
979
|
+
const release = async () => {
|
|
980
|
+
if (released) {
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
released = true;
|
|
984
|
+
this.#scanPauses--;
|
|
985
|
+
await this.#reconcileScan(++this.#scanGeneration);
|
|
986
|
+
};
|
|
987
|
+
|
|
988
|
+
try {
|
|
989
|
+
await this.#reconcileScan(++this.#scanGeneration);
|
|
990
|
+
} catch (err) {
|
|
991
|
+
await release();
|
|
992
|
+
throw err;
|
|
993
|
+
}
|
|
994
|
+
return release;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Drives noble to the state `generation` asked for, or returns once a later request has taken
|
|
999
|
+
* ownership of the outcome. noble registers each scan call's completion callback with
|
|
1000
|
+
* `onceExclusive`, which removes the previously registered one, so two overlapping calls leave
|
|
1001
|
+
* the earlier promise unsettled forever — every scan call is therefore serialized here.
|
|
1002
|
+
*/
|
|
1003
|
+
#reconcileScan(generation: number): Promise<void> {
|
|
1004
|
+
return this.#serializeScanOperation(async () => {
|
|
1005
|
+
if (generation !== this.#scanGeneration) {
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
const target = this.#scanPauses > 0 ? undefined : this.#desiredScan;
|
|
1009
|
+
// The hci-socket binding returns early from startScanning while a scan is running,
|
|
1010
|
+
// keeping the previous service-uuid filter, so a filter change needs a stop first.
|
|
1011
|
+
await this.#callNoble(noble => noble.stopScanningAsync(), "stopScanning");
|
|
1012
|
+
if (target) {
|
|
1013
|
+
// Noble is always asked for duplicates; `allow_duplicates` is honoured when reporting.
|
|
1014
|
+
await this.#callNoble(noble => noble.startScanningAsync(target.serviceUuids, true), "startScanning");
|
|
1015
|
+
}
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
#serializeScanOperation<T>(operation: () => Promise<T>): Promise<T> {
|
|
1020
|
+
const result = this.#scanOperations.then(operation);
|
|
1021
|
+
this.#scanOperations = result.catch(() => undefined);
|
|
1022
|
+
return result;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
async #callNoble(call: (noble: NobleApi) => Promise<void>, description: string): Promise<void> {
|
|
1026
|
+
const noble = this.#noble;
|
|
1027
|
+
if (!noble) {
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
const pending = call(noble);
|
|
1031
|
+
// Rejecting after the timeout has already won the race still needs a handler here.
|
|
1032
|
+
pending.catch(() => undefined);
|
|
1033
|
+
await withTimeout(
|
|
1034
|
+
pending,
|
|
1035
|
+
this.#scanOperationTimeoutMs,
|
|
1036
|
+
`${description} did not settle within ${this.#scanOperationTimeoutMs}ms`,
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Forwarded regardless of noble's `isNotification`: on macOS that flag is derived from a
|
|
1041
|
+
// manager-wide pendingRead, so it reads false for a notification racing a read on any
|
|
1042
|
+
// peripheral. Safe only while no subscribed characteristic is ever read.
|
|
1043
|
+
#notificationForwarder(handle: number, uuid: string): NotificationListener {
|
|
1044
|
+
return payload => {
|
|
1045
|
+
if (payload === null) {
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
log(`[GATT] notify ${uuid} handle=${handle} ${payload.length} bytes`);
|
|
1049
|
+
this.#sendBinaryFrame(BinaryFrameOpcode.Notification, handle, new Uint8Array(payload));
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/** Subscription key; `#findCharacteristic` resolves spellings that differ only in case or dashes. */
|
|
1054
|
+
static #subscriptionKey(uuid: string): string {
|
|
1055
|
+
return uuid.replace(/-/g, "").toLowerCase();
|
|
1056
|
+
}
|
|
1057
|
+
|
|
653
1058
|
#findCharacteristic(conn: ConnectionState, uuid: string): Characteristic | undefined {
|
|
654
1059
|
// Try exact match first, then case-insensitive
|
|
655
1060
|
return (
|
|
@@ -663,7 +1068,7 @@ export class NobleBleProxyClient {
|
|
|
663
1068
|
this.#ws?.send(JSON.stringify({ id, success: true, result: result ?? {} }));
|
|
664
1069
|
}
|
|
665
1070
|
|
|
666
|
-
#sendError(id: number, error:
|
|
1071
|
+
#sendError(id: number, error: BleProxyErrorCodeValue, message: string): void {
|
|
667
1072
|
this.#ws?.send(JSON.stringify({ id, success: false, error, message }));
|
|
668
1073
|
}
|
|
669
1074
|
|