@cero-base/core 1.9.0 → 1.10.1
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/package.json +6 -6
- package/src/database/encryption.js +5 -0
- package/src/network/transports/ble.js +397 -77
- package/src/network/transports/gatt.js +1 -16
- package/src/network/transports/l2cap.js +90 -0
- package/types/database/encryption.d.ts +1 -0
- package/types/network/transports/ble.d.ts +48 -4
- package/types/network/transports/gatt.d.ts +1 -5
- package/types/network/transports/l2cap.d.ts +37 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cero-base/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.1",
|
|
4
4
|
"description": "cero p2p primitives — identity, storage, network, database, blobs, rpc, pairing.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -144,15 +144,15 @@
|
|
|
144
144
|
},
|
|
145
145
|
"dependencies": {
|
|
146
146
|
"@hyperswarm/secret-stream": "^6.9.1",
|
|
147
|
-
"autobee": "
|
|
147
|
+
"autobee": "2.0.0-rc.6",
|
|
148
148
|
"autobee-encryption": "0.1.3",
|
|
149
149
|
"b4a": "^1.8.1",
|
|
150
150
|
"bare-crypto": "^1.15.3",
|
|
151
|
-
"bare-fs": "^4.
|
|
151
|
+
"bare-fs": "^4.8.0",
|
|
152
152
|
"bare-path": "^3.1.1",
|
|
153
153
|
"bip39-mnemonic": "^2.5.0",
|
|
154
154
|
"blind-pairing": "^2.3.1",
|
|
155
|
-
"blind-peering": "^2.
|
|
155
|
+
"blind-peering": "^2.6.1",
|
|
156
156
|
"compact-encoding": "^3.3.0",
|
|
157
157
|
"corestore": "^7.12.0",
|
|
158
158
|
"framed-stream": "^1.0.1",
|
|
@@ -181,8 +181,8 @@
|
|
|
181
181
|
"bare-events": "^2.9.1",
|
|
182
182
|
"bare-fetch": "^3.2.0",
|
|
183
183
|
"bare-process": "^4.5.1",
|
|
184
|
-
"bare-url": "^2.
|
|
185
|
-
"blind-peer": "^3.
|
|
184
|
+
"bare-url": "^2.5.2",
|
|
185
|
+
"blind-peer": "^3.13.2",
|
|
186
186
|
"brittle": "^4.1.0",
|
|
187
187
|
"typescript": "^5.9.3",
|
|
188
188
|
"which-runtime": "^1.4.0"
|
|
@@ -238,6 +238,11 @@ export class EpochAutobee extends Autobee {
|
|
|
238
238
|
// step so an UNKNOWN_EPOCH surfacing from batch processing parks too
|
|
239
239
|
// instead of crashing the bee.
|
|
240
240
|
async _bumpPendingWriters() {
|
|
241
|
+
if (this._catchupMigratedNodes !== null) {
|
|
242
|
+
await this._bumpMigratedWriters()
|
|
243
|
+
this._catchupMigratedNodes = null
|
|
244
|
+
}
|
|
245
|
+
|
|
241
246
|
let updated = false
|
|
242
247
|
|
|
243
248
|
const pending = this.writers.pending.slice()
|
|
@@ -4,12 +4,20 @@ import { hash, randomBytes } from 'hypercore-crypto'
|
|
|
4
4
|
import safetyCatch from 'safety-catch'
|
|
5
5
|
|
|
6
6
|
import { GattStream } from './gatt.js'
|
|
7
|
+
import { L2CAPStream, readIdPreamble } from './l2cap.js'
|
|
7
8
|
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// framed byte stream both ways instead.
|
|
9
|
+
// One data characteristic (write + notify) carries the framed control traffic
|
|
10
|
+
// both ways — and, on the gatt pipe, the data too.
|
|
11
11
|
const DATA_UUID = 'ce1a0004-0000-1000-8000-00805f9b34fb'
|
|
12
12
|
|
|
13
|
+
// The l2cap open historically hangs forever on some platform pairs (no error,
|
|
14
|
+
// no disconnect) — every open runs under this deadline instead.
|
|
15
|
+
const L2CAP_OPEN_TIMEOUT = 3000
|
|
16
|
+
// a pending server session must see its channel within this window or be reaped
|
|
17
|
+
const PIPE_PENDING_TIMEOUT = 20000
|
|
18
|
+
|
|
19
|
+
const PLATFORM = typeof Bare !== 'undefined' ? Bare.platform : process.platform
|
|
20
|
+
|
|
13
21
|
// Wire frame both directions: [type:1][sessionId:8][payload].
|
|
14
22
|
const TYPE_OPEN = 1
|
|
15
23
|
const TYPE_DATA = 2
|
|
@@ -40,17 +48,19 @@ const DRAIN_MS = 300
|
|
|
40
48
|
const EMPTY = b4a.alloc(0)
|
|
41
49
|
|
|
42
50
|
/**
|
|
43
|
-
* Build a wire frame [type][sessionId][payload].
|
|
51
|
+
* Build a wire frame [type][sessionId][payload]. The session id is a hex
|
|
52
|
+
* string end to end — the one canonical form (it is also the sessions map
|
|
53
|
+
* key); the codec is the only place it touches bytes.
|
|
44
54
|
*
|
|
45
55
|
* @param {number} type
|
|
46
|
-
* @param {
|
|
56
|
+
* @param {string} id Session id (hex).
|
|
47
57
|
* @param {Uint8Array} [payload]
|
|
48
58
|
* @returns {Buffer}
|
|
49
59
|
*/
|
|
50
|
-
function frame(type,
|
|
60
|
+
function frame(type, id, payload = EMPTY) {
|
|
51
61
|
const out = b4a.allocUnsafe(HEADER + payload.byteLength)
|
|
52
62
|
out[0] = type
|
|
53
|
-
b4a.
|
|
63
|
+
b4a.write(out, id, 1, SID_LEN, 'hex')
|
|
54
64
|
if (payload.byteLength) b4a.copy(payload, out, HEADER)
|
|
55
65
|
return out
|
|
56
66
|
}
|
|
@@ -59,15 +69,13 @@ function frame(type, sid, payload = EMPTY) {
|
|
|
59
69
|
* Parse a wire frame. Short/empty buffers → null (caller drops).
|
|
60
70
|
*
|
|
61
71
|
* @param {Uint8Array} buf
|
|
62
|
-
* @returns {{ type: number,
|
|
72
|
+
* @returns {{ type: number, id: string, payload: Uint8Array } | null}
|
|
63
73
|
*/
|
|
64
74
|
function parseFrame(buf) {
|
|
65
75
|
if (!buf || buf.byteLength < HEADER) return null
|
|
66
|
-
const sid = buf.subarray(1, HEADER)
|
|
67
76
|
return {
|
|
68
77
|
type: buf[0],
|
|
69
|
-
|
|
70
|
-
sidHex: b4a.toString(sid, 'hex'),
|
|
78
|
+
id: b4a.toString(buf.subarray(1, HEADER), 'hex'),
|
|
71
79
|
payload: buf.subarray(HEADER)
|
|
72
80
|
}
|
|
73
81
|
}
|
|
@@ -132,6 +140,8 @@ export class BLETransport extends ReadyResource {
|
|
|
132
140
|
* @param {{ scanMode?: any }} [opts.scanOptions] Platform scan options (e.g. Android low-power).
|
|
133
141
|
* @param {boolean} [opts.keepLinks] On close, stop the radio but leave established links alive (invite rendezvous: the link outlives the QR and carries the initial replication).
|
|
134
142
|
* @param {string} [opts.name] Local app-user display name, sent to peers over a hello frame.
|
|
143
|
+
* @param {'l2cap' | 'gatt'} [opts.pipe] Data pipe: 'l2cap' (default — a real channel per session, several times faster) or 'gatt' (framed characteristic stream). Both peers must match.
|
|
144
|
+
* @param {{ timeout?: number }} [opts.l2cap] Deadline for an l2cap channel open.
|
|
135
145
|
*/
|
|
136
146
|
constructor({
|
|
137
147
|
backend,
|
|
@@ -143,7 +153,9 @@ export class BLETransport extends ReadyResource {
|
|
|
143
153
|
maxInbound = DEFAULT_MAX_INBOUND,
|
|
144
154
|
scanOptions,
|
|
145
155
|
keepLinks = false,
|
|
146
|
-
name
|
|
156
|
+
name,
|
|
157
|
+
pipe = 'l2cap',
|
|
158
|
+
l2cap = {}
|
|
147
159
|
}) {
|
|
148
160
|
super()
|
|
149
161
|
this.backend = backend
|
|
@@ -156,12 +168,16 @@ export class BLETransport extends ReadyResource {
|
|
|
156
168
|
this.maxInbound = maxInbound
|
|
157
169
|
this.scanOptions = scanOptions
|
|
158
170
|
this.keepLinks = keepLinks
|
|
171
|
+
this.pipe = pipe
|
|
172
|
+
this._l2capTimeout = l2cap.timeout ?? L2CAP_OPEN_TIMEOUT
|
|
159
173
|
|
|
160
174
|
this.state = 'off'
|
|
161
175
|
this.central = null
|
|
162
176
|
this.server = null
|
|
163
177
|
this._dataChar = null
|
|
164
|
-
/**
|
|
178
|
+
/** the published l2cap listener's psm, advertised to centrals over hello */
|
|
179
|
+
this._psm = null
|
|
180
|
+
/** id → { id, stream, conn, name, pipeTimer } for server-side (peripheral) sessions */
|
|
165
181
|
this._sessions = new Map()
|
|
166
182
|
/** serialized server notify queue: { frame, resolve, reject } */
|
|
167
183
|
this._notifyQueue = []
|
|
@@ -170,9 +186,13 @@ export class BLETransport extends ReadyResource {
|
|
|
170
186
|
this._serviceAdded = false
|
|
171
187
|
/** peripheral id → per-peer dial state { timer, linked, coolUntil, failures, peerKey, peripheral } */
|
|
172
188
|
this._devices = new Map()
|
|
189
|
+
/** rate-limited discoveries held for the next dial window */
|
|
190
|
+
this._candidates = new Map()
|
|
191
|
+
this._dialTimer = null
|
|
173
192
|
/** last central.connect timestamp — global inter-dial rate limit */
|
|
174
193
|
this._lastDial = 0
|
|
175
194
|
this._scanTimer = null
|
|
195
|
+
this._cyclePending = false
|
|
176
196
|
this._suspended = false
|
|
177
197
|
/** live injected links keyed by remote node id hex */
|
|
178
198
|
this.peers = new Map()
|
|
@@ -217,6 +237,7 @@ export class BLETransport extends ReadyResource {
|
|
|
217
237
|
this.server.on('stateChange', (s) => {
|
|
218
238
|
this._onState(s)
|
|
219
239
|
if (s === 'poweredOn') this._startServer(Service, Characteristic)
|
|
240
|
+
else if (s === 'poweredOff' || s === 'resetting') this._onRadioDown()
|
|
220
241
|
})
|
|
221
242
|
this.server.on('serviceAdd', () => {
|
|
222
243
|
this._serviceAdded = true
|
|
@@ -224,6 +245,10 @@ export class BLETransport extends ReadyResource {
|
|
|
224
245
|
})
|
|
225
246
|
this.server.on('writeRequest', (reqs) => this._onWriteRequests(reqs))
|
|
226
247
|
this.server.on('readyToUpdate', () => this._drainNotify())
|
|
248
|
+
this.server.on('channelPublish', (psm) => {
|
|
249
|
+
this._psm = psm
|
|
250
|
+
})
|
|
251
|
+
this.server.on('channelOpen', (channel) => this._onServerChannel(channel))
|
|
227
252
|
// writeRequests carry no central identifier, so an unsubscribe can't be
|
|
228
253
|
// mapped to a session; teardown is left to _onChannel's keepalive/timeout.
|
|
229
254
|
this.server.on('unsubscribe', () => {})
|
|
@@ -233,6 +258,7 @@ export class BLETransport extends ReadyResource {
|
|
|
233
258
|
this.central.on('stateChange', (s) => {
|
|
234
259
|
this._onState(s)
|
|
235
260
|
if (s === 'poweredOn') this._startScan()
|
|
261
|
+
else if (s === 'poweredOff' || s === 'resetting') this._onRadioDown()
|
|
236
262
|
})
|
|
237
263
|
this.central.on('discover', (peripheral) => this._onDiscover(peripheral))
|
|
238
264
|
this.central.on('connect', (peripheral) => this._onConnect(peripheral))
|
|
@@ -250,6 +276,47 @@ export class BLETransport extends ReadyResource {
|
|
|
250
276
|
this._dataChar = new Characteristic(DATA_UUID, { write: true, notify: true })
|
|
251
277
|
this.server.addService(new Service(this.serviceUUID, [this._dataChar]))
|
|
252
278
|
}
|
|
279
|
+
if (this.pipe === 'l2cap') this._publishListener()
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
_publishListener() {
|
|
283
|
+
if (this._psm !== null || typeof this.server.publishChannel !== 'function') return
|
|
284
|
+
try {
|
|
285
|
+
// unencrypted: cero's own protocols provide the crypto; encryption here
|
|
286
|
+
// would demand BLE pairing and stall centrals that never trigger it
|
|
287
|
+
this.server.publishChannel({})
|
|
288
|
+
} catch (err) {
|
|
289
|
+
safetyCatch(err)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
_unpublishListener() {
|
|
294
|
+
if (this._psm === null) return
|
|
295
|
+
if (typeof this.server?.unpublishChannel === 'function') {
|
|
296
|
+
try {
|
|
297
|
+
this.server.unpublishChannel(this._psm)
|
|
298
|
+
} catch (err) {
|
|
299
|
+
safetyCatch(err)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
this._psm = null
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Fresh listener, fresh psm — the next hello advertises it. A dead session
|
|
306
|
+
// leaves its channel state on the shared radio link, and the OS refuses a
|
|
307
|
+
// second open to a psm it remembers there. Never yank the psm out from under
|
|
308
|
+
// a session still opening its channel — hold the rotation until the last
|
|
309
|
+
// pending session resolves (bind or reap).
|
|
310
|
+
_cycleListener() {
|
|
311
|
+
if (this.pipe !== 'l2cap' || this._suspended || this.closing || this.closed) return
|
|
312
|
+
for (const s of this._sessions.values()) {
|
|
313
|
+
if (s.stream) continue
|
|
314
|
+
this._cyclePending = true
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
this._cyclePending = false
|
|
318
|
+
this._unpublishListener()
|
|
319
|
+
this._publishListener()
|
|
253
320
|
}
|
|
254
321
|
|
|
255
322
|
_maybeAdvertise() {
|
|
@@ -273,64 +340,143 @@ export class BLETransport extends ReadyResource {
|
|
|
273
340
|
_onServerFrame(data) {
|
|
274
341
|
const f = parseFrame(data)
|
|
275
342
|
if (!f) return
|
|
343
|
+
// an OPEN for a live session is a dup; every other type needs one
|
|
344
|
+
const s = this._sessions.get(f.id)
|
|
276
345
|
if (f.type === TYPE_OPEN) {
|
|
277
|
-
if (
|
|
346
|
+
if (s) return
|
|
278
347
|
if (this._sessions.size >= this.maxInbound) {
|
|
279
348
|
// established links win: refuse newcomers with a CLOSE so the dialer's
|
|
280
349
|
// stream ends cleanly and backs off — the mesh converges transitively.
|
|
281
|
-
this.
|
|
350
|
+
this._notifyClose(f.id)
|
|
282
351
|
return
|
|
283
352
|
}
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
353
|
+
const session = { id: f.id, stream: null, conn: null, name: null, pipeTimer: null }
|
|
354
|
+
this._sessions.set(f.id, session)
|
|
355
|
+
if (this.pipe === 'l2cap' && this._psm !== null) {
|
|
356
|
+
// the session has no stream until the central opens our channel and
|
|
357
|
+
// its id preamble matches — reap it if that never happens
|
|
358
|
+
session.pipeTimer = setTimeout(() => {
|
|
359
|
+
session.pipeTimer = null
|
|
360
|
+
if (this._sessions.get(f.id) !== session || session.stream) return
|
|
361
|
+
this._reapSession(f.id, session)
|
|
362
|
+
this._notifyClose(f.id)
|
|
363
|
+
}, PIPE_PENDING_TIMEOUT)
|
|
364
|
+
if (session.pipeTimer.unref) session.pipeTimer.unref()
|
|
365
|
+
} else {
|
|
366
|
+
this._openServerGatt(session)
|
|
367
|
+
}
|
|
368
|
+
this._enqueueNotify(frame(TYPE_HELLO, f.id, this._helloPayload())).catch(safetyCatch)
|
|
293
369
|
} else if (f.type === TYPE_DATA) {
|
|
294
|
-
|
|
295
|
-
if (s
|
|
370
|
+
if (!s) return
|
|
371
|
+
if (!s.stream) {
|
|
372
|
+
// gatt data on a session awaiting its l2cap channel is a pipe
|
|
373
|
+
// mismatch — close instead of silently degrading
|
|
374
|
+
this._closeServerSession(f.id)
|
|
375
|
+
return
|
|
376
|
+
}
|
|
377
|
+
s.stream.receive(b4a.from(f.payload))
|
|
296
378
|
} else if (f.type === TYPE_HELLO) {
|
|
297
|
-
const s = this._sessions.get(f.sidHex)
|
|
298
379
|
if (s) this._applyPeerName(s, f.payload)
|
|
299
380
|
} else if (f.type === TYPE_CLOSE) {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
381
|
+
if (!s) return
|
|
382
|
+
this._reapSession(f.id, s)
|
|
383
|
+
if (s.stream) s.stream.remoteEnd()
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
_openServerGatt(session) {
|
|
388
|
+
const stream = new GattStream({
|
|
389
|
+
send: (payload) => this._enqueueNotify(frame(TYPE_DATA, session.id, payload))
|
|
390
|
+
})
|
|
391
|
+
this._bindServerStream(session, stream)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Incoming l2cap channel: the central writes its 8-byte session id first,
|
|
395
|
+
// matching the channel to the session negotiated over the characteristic.
|
|
396
|
+
async _onServerChannel(channel) {
|
|
397
|
+
if (this.closing || this.closed || this._suspended) {
|
|
398
|
+
try {
|
|
399
|
+
channel.destroy()
|
|
400
|
+
} catch (err) {
|
|
401
|
+
safetyCatch(err)
|
|
402
|
+
}
|
|
403
|
+
return
|
|
404
|
+
}
|
|
405
|
+
const { id, rest } = await readIdPreamble(channel, SID_LEN, this._l2capTimeout)
|
|
406
|
+
const session = id !== null ? this._sessions.get(id) : undefined
|
|
407
|
+
if (!session || session.stream) {
|
|
408
|
+
try {
|
|
409
|
+
channel.destroy()
|
|
410
|
+
} catch (err) {
|
|
411
|
+
safetyCatch(err)
|
|
304
412
|
}
|
|
413
|
+
return
|
|
305
414
|
}
|
|
415
|
+
const stream = new L2CAPStream(channel)
|
|
416
|
+
this._bindServerStream(session, stream)
|
|
417
|
+
if (rest.byteLength) stream.receive(rest)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// A pipe stream binds here — session wiring and close-time cleanup are
|
|
421
|
+
// transport concerns shared by both pipes.
|
|
422
|
+
_bindServerStream(session, stream) {
|
|
423
|
+
if (session.pipeTimer) clearTimeout(session.pipeTimer)
|
|
424
|
+
session.pipeTimer = null
|
|
425
|
+
session.stream = stream
|
|
426
|
+
stream.on('close', () => this._closeServerSession(session.id))
|
|
427
|
+
session.conn = this._onChannel(stream, false, null)
|
|
428
|
+
if (session.name && session.conn) session.conn._peerName = session.name
|
|
429
|
+
if (this._cyclePending) this._cycleListener()
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
_reapSession(id, session) {
|
|
433
|
+
if (session.pipeTimer) clearTimeout(session.pipeTimer)
|
|
434
|
+
session.pipeTimer = null
|
|
435
|
+
this._sessions.delete(id)
|
|
436
|
+
this._cycleListener()
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
_closeServerSession(id) {
|
|
440
|
+
const session = this._sessions.get(id)
|
|
441
|
+
if (!session) return
|
|
442
|
+
this._reapSession(id, session)
|
|
443
|
+
this._notifyClose(id)
|
|
306
444
|
}
|
|
307
445
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
this._sessions.delete(sidHex)
|
|
311
|
-
this._enqueueNotify(frame(TYPE_CLOSE, sid)).catch(safetyCatch)
|
|
446
|
+
_notifyClose(id) {
|
|
447
|
+
this._enqueueNotify(frame(TYPE_CLOSE, id)).catch(safetyCatch)
|
|
312
448
|
}
|
|
313
449
|
|
|
314
450
|
// ─── peer display name (hello frame) ──────────────────────────────────────
|
|
315
451
|
|
|
316
452
|
_helloPayload() {
|
|
317
|
-
|
|
453
|
+
const hello = { n: this.name || '' }
|
|
454
|
+
// servers advertise their l2cap listener so the central can open a channel
|
|
455
|
+
if (this.pipe === 'l2cap' && this._psm !== null) hello.p = this._psm
|
|
456
|
+
return b4a.from(JSON.stringify(hello))
|
|
318
457
|
}
|
|
319
458
|
|
|
459
|
+
/**
|
|
460
|
+
* @param {Uint8Array} payload
|
|
461
|
+
* @returns {{ name: string, psm: number | null } | null}
|
|
462
|
+
*/
|
|
320
463
|
_parseHello(payload) {
|
|
321
464
|
try {
|
|
322
|
-
const { n } = JSON.parse(b4a.toString(payload))
|
|
323
|
-
return
|
|
465
|
+
const { n, p } = JSON.parse(b4a.toString(payload))
|
|
466
|
+
return {
|
|
467
|
+
name: typeof n === 'string' ? n : '',
|
|
468
|
+
psm: Number.isInteger(p) ? p : null
|
|
469
|
+
}
|
|
324
470
|
} catch {
|
|
325
471
|
return null
|
|
326
472
|
}
|
|
327
473
|
}
|
|
328
474
|
|
|
329
475
|
_applyPeerName(session, payload) {
|
|
330
|
-
const
|
|
331
|
-
if (
|
|
332
|
-
session.name = name
|
|
333
|
-
if (session.conn) session.conn._peerName = name
|
|
476
|
+
const hello = this._parseHello(payload)
|
|
477
|
+
if (hello === null) return
|
|
478
|
+
session.name = hello.name
|
|
479
|
+
if (session.conn) session.conn._peerName = hello.name
|
|
334
480
|
this.emit('update')
|
|
335
481
|
}
|
|
336
482
|
|
|
@@ -408,6 +554,43 @@ export class BLETransport extends ReadyResource {
|
|
|
408
554
|
}
|
|
409
555
|
}
|
|
410
556
|
|
|
557
|
+
/**
|
|
558
|
+
* A radio power cycle invalidates the GATT service, advertising, scans,
|
|
559
|
+
* subscriptions and every open link, but the bookkeeping flags survive —
|
|
560
|
+
* without a reset the device never re-registers or re-advertises and goes
|
|
561
|
+
* dark until the app-level toggle is cycled. Reset so the poweredOn
|
|
562
|
+
* handlers bootstrap everything from scratch.
|
|
563
|
+
*/
|
|
564
|
+
_onRadioDown() {
|
|
565
|
+
this._serviceAdded = false
|
|
566
|
+
this._advertising = false
|
|
567
|
+
this._scanning = false
|
|
568
|
+
// the power cycle wiped the GATT db — the listener is gone with it
|
|
569
|
+
this._psm = null
|
|
570
|
+
this._cyclePending = false
|
|
571
|
+
for (const d of this._devices.values()) if (d.timer) clearTimeout(d.timer)
|
|
572
|
+
this._devices.clear()
|
|
573
|
+
this._clearCandidates()
|
|
574
|
+
for (const conn of this.peers.values()) {
|
|
575
|
+
try {
|
|
576
|
+
conn.destroy()
|
|
577
|
+
} catch (err) {
|
|
578
|
+
safetyCatch(err)
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
for (const { stream, pipeTimer } of this._sessions.values()) {
|
|
582
|
+
if (pipeTimer) clearTimeout(pipeTimer)
|
|
583
|
+
try {
|
|
584
|
+
if (stream) stream.destroy()
|
|
585
|
+
} catch (err) {
|
|
586
|
+
safetyCatch(err)
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
this._sessions.clear()
|
|
590
|
+
for (const item of this._notifyQueue) item.reject(new Error('radio down'))
|
|
591
|
+
this._notifyQueue = []
|
|
592
|
+
}
|
|
593
|
+
|
|
411
594
|
_onState(raw) {
|
|
412
595
|
const next = STATE[raw] ?? 'waiting'
|
|
413
596
|
if (next === this.state) return
|
|
@@ -425,7 +608,20 @@ export class BLETransport extends ReadyResource {
|
|
|
425
608
|
if (d.timer) return // already connecting to this one
|
|
426
609
|
}
|
|
427
610
|
if (this.linkCount >= this.maxOutbound) return // gossip covers the rest
|
|
428
|
-
|
|
611
|
+
const wait = DIAL_MIN_INTERVAL - (Date.now() - this._lastDial)
|
|
612
|
+
if (wait > 0) {
|
|
613
|
+
// hold rate-limited discoveries (the radio may not re-report them until
|
|
614
|
+
// the next scan cycle) and dial the strongest signal when the window opens
|
|
615
|
+
this._candidates.set(peripheral.id, peripheral)
|
|
616
|
+
if (!this._dialTimer) {
|
|
617
|
+
this._dialTimer = setTimeout(() => {
|
|
618
|
+
this._dialTimer = null
|
|
619
|
+
this._flushCandidates()
|
|
620
|
+
}, wait)
|
|
621
|
+
if (this._dialTimer.unref) this._dialTimer.unref()
|
|
622
|
+
}
|
|
623
|
+
return
|
|
624
|
+
}
|
|
429
625
|
// dial every discovery and open a session; a redundant link is dropped by
|
|
430
626
|
// _track's dedup
|
|
431
627
|
this._lastDial = Date.now()
|
|
@@ -439,9 +635,29 @@ export class BLETransport extends ReadyResource {
|
|
|
439
635
|
}
|
|
440
636
|
}
|
|
441
637
|
|
|
638
|
+
// strongest signal first — the nearest peer makes the best link. The first
|
|
639
|
+
// dial re-rate-limits the rest, so each window dials the strongest remaining.
|
|
640
|
+
_flushCandidates() {
|
|
641
|
+
const held = [...this._candidates.values()].sort((a, b) => (b.rssi ?? -100) - (a.rssi ?? -100))
|
|
642
|
+
this._candidates.clear()
|
|
643
|
+
for (const peripheral of held) this._onDiscover(peripheral)
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
_clearCandidates() {
|
|
647
|
+
if (this._dialTimer) clearTimeout(this._dialTimer)
|
|
648
|
+
this._dialTimer = null
|
|
649
|
+
this._candidates.clear()
|
|
650
|
+
}
|
|
651
|
+
|
|
442
652
|
_onConnect(peripheral) {
|
|
443
653
|
this._device(peripheral.id).peripheral = peripheral
|
|
444
|
-
peripheral.on('error', () =>
|
|
654
|
+
peripheral.on('error', (err) => {
|
|
655
|
+
// platforms emit benign error events during openL2CAPChannel — aborting
|
|
656
|
+
// would hang up the link under the in-flight open. The open's own
|
|
657
|
+
// deadline covers a genuinely dead link.
|
|
658
|
+
if (peripheral._session?.upgrading) return
|
|
659
|
+
this._abortDial(peripheral, err?.message ?? 'peripheral-error')
|
|
660
|
+
})
|
|
445
661
|
peripheral.once('servicesDiscover', (services) => {
|
|
446
662
|
const svc = findByUUID(services, this.serviceUUID)
|
|
447
663
|
if (svc) peripheral.discoverCharacteristics(svc, [DATA_UUID])
|
|
@@ -468,50 +684,143 @@ export class BLETransport extends ReadyResource {
|
|
|
468
684
|
// ─── central side ─────────────────────────────────────────────────────────
|
|
469
685
|
|
|
470
686
|
_startCentralSession(peripheral, char) {
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
peripheral._session = { sidHex, sid }
|
|
687
|
+
const id = b4a.toString(randomBytes(SID_LEN), 'hex')
|
|
688
|
+
peripheral._session = { id, upgrading: false }
|
|
474
689
|
peripheral._char = char // suspend's goodbye writes reuse it
|
|
475
690
|
peripheral.on('notify', (_char, data) => this._onCentralNotify(peripheral, data))
|
|
476
|
-
const stream = new GattStream({
|
|
477
|
-
send: (payload) => this._centralSend(peripheral, char, frame(TYPE_DATA, sid, payload)),
|
|
478
|
-
onclose: () => {
|
|
479
|
-
this._centralSend(peripheral, char, frame(TYPE_CLOSE, sid)).catch(safetyCatch)
|
|
480
|
-
const d = this._devices.get(peripheral.id)
|
|
481
|
-
if (d) d.peripheral = null
|
|
482
|
-
try {
|
|
483
|
-
this.central.disconnect(peripheral)
|
|
484
|
-
} catch (err) {
|
|
485
|
-
safetyCatch(err)
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
})
|
|
489
|
-
peripheral._stream = stream
|
|
490
691
|
// open frame first: it registers the session on the server before any data
|
|
491
|
-
this._centralSend(peripheral, char, frame(TYPE_OPEN,
|
|
492
|
-
this._centralSend(peripheral, char, frame(TYPE_HELLO,
|
|
692
|
+
this._centralSend(peripheral, char, frame(TYPE_OPEN, id)).catch(safetyCatch)
|
|
693
|
+
this._centralSend(peripheral, char, frame(TYPE_HELLO, id, this._helloPayload())).catch(
|
|
493
694
|
safetyCatch
|
|
494
695
|
)
|
|
696
|
+
if (this.pipe === 'l2cap') {
|
|
697
|
+
// no stream yet — the server's hello carries the psm to open a channel
|
|
698
|
+
// to; the dial timer keeps running until the channel binds
|
|
699
|
+
return
|
|
700
|
+
}
|
|
701
|
+
this._openCentralGatt(peripheral, peripheral._session)
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
_openCentralGatt(peripheral, sess) {
|
|
705
|
+
const stream = new GattStream({
|
|
706
|
+
send: (payload) =>
|
|
707
|
+
this._centralSend(peripheral, peripheral._char, frame(TYPE_DATA, sess.id, payload))
|
|
708
|
+
})
|
|
709
|
+
this._bindCentralStream(peripheral, sess, stream)
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// A pipe stream binds here — dial state, peripheral refs and close-time
|
|
713
|
+
// cleanup are transport concerns shared by both pipes.
|
|
714
|
+
_bindCentralStream(peripheral, sess, stream) {
|
|
715
|
+
stream.on('close', () => this._closeCentralSession(peripheral, sess))
|
|
716
|
+
peripheral._stream = stream
|
|
495
717
|
this._clearDial(peripheral.id)
|
|
496
718
|
peripheral._conn = this._onChannel(stream, true, peripheral.id)
|
|
719
|
+
if (peripheral._peerName && peripheral._conn) peripheral._conn._peerName = peripheral._peerName
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
_closeCentralSession(peripheral, sess) {
|
|
723
|
+
this._centralSend(peripheral, peripheral._char, frame(TYPE_CLOSE, sess.id)).catch(safetyCatch)
|
|
724
|
+
// platforms reuse peripheral objects across reconnects — stale refs here
|
|
725
|
+
// would make the next session look live and get dropped
|
|
726
|
+
peripheral._stream = null
|
|
727
|
+
peripheral._session = null
|
|
728
|
+
const d = this._devices.get(peripheral.id)
|
|
729
|
+
if (d) d.peripheral = null
|
|
730
|
+
try {
|
|
731
|
+
this.central.disconnect(peripheral)
|
|
732
|
+
} catch (err) {
|
|
733
|
+
safetyCatch(err)
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// One open attempt under a deadline; failure aborts the dial and the
|
|
738
|
+
// cooldown/redial cycle tries again.
|
|
739
|
+
async _openCentralL2CAP(peripheral, sess, psm) {
|
|
740
|
+
const gone = () =>
|
|
741
|
+
peripheral._session !== sess || this.closing || this.closed || this._suspended
|
|
742
|
+
sess.upgrading = true
|
|
743
|
+
try {
|
|
744
|
+
const channel = await this._openChannel(peripheral, psm)
|
|
745
|
+
if (gone()) {
|
|
746
|
+
if (channel) {
|
|
747
|
+
try {
|
|
748
|
+
channel.destroy()
|
|
749
|
+
} catch (err) {
|
|
750
|
+
safetyCatch(err)
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
return
|
|
754
|
+
}
|
|
755
|
+
if (!channel) {
|
|
756
|
+
this._abortDial(peripheral, 'l2cap-failed')
|
|
757
|
+
return
|
|
758
|
+
}
|
|
759
|
+
// id preamble first: the server matches the channel to the session
|
|
760
|
+
channel.write(b4a.from(sess.id, 'hex'))
|
|
761
|
+
this._bindCentralStream(peripheral, sess, new L2CAPStream(channel))
|
|
762
|
+
} finally {
|
|
763
|
+
sess.upgrading = false
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
_openChannel(peripheral, psm) {
|
|
768
|
+
return new Promise((resolve) => {
|
|
769
|
+
let done = false
|
|
770
|
+
const finish = (channel) => {
|
|
771
|
+
if (done) {
|
|
772
|
+
if (channel) {
|
|
773
|
+
try {
|
|
774
|
+
channel.destroy()
|
|
775
|
+
} catch (err) {
|
|
776
|
+
safetyCatch(err)
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
return
|
|
780
|
+
}
|
|
781
|
+
done = true
|
|
782
|
+
clearTimeout(timer)
|
|
783
|
+
peripheral.removeListener('channelOpen', finish)
|
|
784
|
+
resolve(channel || null)
|
|
785
|
+
}
|
|
786
|
+
const timer = setTimeout(() => finish(null), this._l2capTimeout)
|
|
787
|
+
if (timer.unref) timer.unref()
|
|
788
|
+
peripheral.once('channelOpen', finish)
|
|
789
|
+
try {
|
|
790
|
+
peripheral.openL2CAPChannel(psm)
|
|
791
|
+
} catch (err) {
|
|
792
|
+
safetyCatch(err)
|
|
793
|
+
finish(null)
|
|
794
|
+
}
|
|
795
|
+
})
|
|
497
796
|
}
|
|
498
797
|
|
|
499
798
|
_onCentralNotify(peripheral, data) {
|
|
500
799
|
const sess = peripheral._session
|
|
501
|
-
if (!sess
|
|
800
|
+
if (!sess) return
|
|
502
801
|
const f = parseFrame(data)
|
|
503
802
|
if (!f) return
|
|
504
|
-
if (f.
|
|
505
|
-
if (f.type === TYPE_DATA)
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
803
|
+
if (f.id !== sess.id) return // not our session
|
|
804
|
+
if (f.type === TYPE_DATA) {
|
|
805
|
+
if (peripheral._stream) peripheral._stream.receive(b4a.from(f.payload))
|
|
806
|
+
} else if (f.type === TYPE_HELLO) {
|
|
807
|
+
const hello = this._parseHello(f.payload)
|
|
808
|
+
if (hello === null) return
|
|
809
|
+
peripheral._peerName = hello.name
|
|
810
|
+
if (peripheral._conn) peripheral._conn._peerName = hello.name
|
|
511
811
|
this.emit('update')
|
|
812
|
+
if (this.pipe === 'l2cap' && !peripheral._stream && !sess.upgrading) {
|
|
813
|
+
if (hello.psm === null) {
|
|
814
|
+
// the server has no l2cap listener — pipe mismatch, never degrade
|
|
815
|
+
this._abortDial(peripheral, 'no-l2cap')
|
|
816
|
+
return
|
|
817
|
+
}
|
|
818
|
+
this._openCentralL2CAP(peripheral, sess, hello.psm).catch(safetyCatch)
|
|
819
|
+
}
|
|
512
820
|
} else if (f.type === TYPE_CLOSE) {
|
|
513
821
|
peripheral._session = null
|
|
514
|
-
peripheral._stream.remoteEnd()
|
|
822
|
+
if (peripheral._stream) peripheral._stream.remoteEnd()
|
|
823
|
+
else this._abortDial(peripheral, 'refused')
|
|
515
824
|
}
|
|
516
825
|
}
|
|
517
826
|
|
|
@@ -665,14 +974,14 @@ export class BLETransport extends ReadyResource {
|
|
|
665
974
|
// flush. Resolves regardless: suspend must never hang on a wedged radio.
|
|
666
975
|
async _sayGoodbye() {
|
|
667
976
|
const sent = []
|
|
668
|
-
for (const
|
|
669
|
-
sent.push(this._enqueueNotify(frame(TYPE_CLOSE,
|
|
977
|
+
for (const id of this._sessions.keys()) {
|
|
978
|
+
sent.push(this._enqueueNotify(frame(TYPE_CLOSE, id)).catch(safetyCatch))
|
|
670
979
|
}
|
|
671
980
|
for (const d of this._devices.values()) {
|
|
672
981
|
const peripheral = d.peripheral
|
|
673
982
|
const sess = peripheral && peripheral._session
|
|
674
983
|
if (!sess || !peripheral._char) continue
|
|
675
|
-
const f = frame(TYPE_CLOSE, sess.
|
|
984
|
+
const f = frame(TYPE_CLOSE, sess.id)
|
|
676
985
|
sent.push(this._centralSend(peripheral, peripheral._char, f).catch(safetyCatch))
|
|
677
986
|
}
|
|
678
987
|
if (!sent.length) return
|
|
@@ -689,7 +998,9 @@ export class BLETransport extends ReadyResource {
|
|
|
689
998
|
this._suspended = true
|
|
690
999
|
if (this._scanTimer) clearTimeout(this._scanTimer)
|
|
691
1000
|
this._scanTimer = null
|
|
1001
|
+
this._cyclePending = false // suspend tears the sessions down; resume republishes fresh
|
|
692
1002
|
for (const d of this._devices.values()) if (d.timer) clearTimeout(d.timer)
|
|
1003
|
+
this._clearCandidates()
|
|
693
1004
|
// say goodbye BEFORE teardown so the remote reacts in <1s instead of waiting
|
|
694
1005
|
// out the 15s keepalive: close frames, a short drain, then an ACL disconnect
|
|
695
1006
|
// (an instant OS-level signal on both roles).
|
|
@@ -722,9 +1033,10 @@ export class BLETransport extends ReadyResource {
|
|
|
722
1033
|
}
|
|
723
1034
|
}
|
|
724
1035
|
this.peers.clear()
|
|
725
|
-
for (const { stream } of this._sessions.values()) {
|
|
1036
|
+
for (const { stream, pipeTimer } of this._sessions.values()) {
|
|
1037
|
+
if (pipeTimer) clearTimeout(pipeTimer)
|
|
726
1038
|
try {
|
|
727
|
-
stream.destroy()
|
|
1039
|
+
if (stream) stream.destroy()
|
|
728
1040
|
} catch (err) {
|
|
729
1041
|
safetyCatch(err)
|
|
730
1042
|
}
|
|
@@ -732,6 +1044,9 @@ export class BLETransport extends ReadyResource {
|
|
|
732
1044
|
this._sessions.clear()
|
|
733
1045
|
for (const item of this._notifyQueue) item.reject(new Error('suspended'))
|
|
734
1046
|
this._notifyQueue = []
|
|
1047
|
+
// macOS only: a listener that lives through destroyed channels wedges the
|
|
1048
|
+
// resumed manager. iOS tolerates no manager surgery — keep its listener.
|
|
1049
|
+
if (PLATFORM === 'darwin') this._unpublishListener()
|
|
735
1050
|
this.state = 'off'
|
|
736
1051
|
this.emit('update')
|
|
737
1052
|
}
|
|
@@ -745,6 +1060,7 @@ export class BLETransport extends ReadyResource {
|
|
|
745
1060
|
this._suspended = false
|
|
746
1061
|
if (this.closing || this.closed) return
|
|
747
1062
|
this._advertising = false
|
|
1063
|
+
if (this.pipe === 'l2cap') this._publishListener()
|
|
748
1064
|
this._maybeAdvertise()
|
|
749
1065
|
this._startScan()
|
|
750
1066
|
const raw = this.central?.state ?? this.server?.state
|
|
@@ -759,6 +1075,7 @@ export class BLETransport extends ReadyResource {
|
|
|
759
1075
|
// stop advertising/scanning and let the runtime reclaim.
|
|
760
1076
|
for (const d of this._devices.values()) if (d.timer) clearTimeout(d.timer)
|
|
761
1077
|
this._devices.clear()
|
|
1078
|
+
this._clearCandidates()
|
|
762
1079
|
try {
|
|
763
1080
|
this.central?.stopScan()
|
|
764
1081
|
} catch (err) {
|
|
@@ -781,6 +1098,9 @@ export class BLETransport extends ReadyResource {
|
|
|
781
1098
|
// tear this down when we're actually dropping the links
|
|
782
1099
|
for (const item of this._notifyQueue) item.reject(new Error('closed'))
|
|
783
1100
|
this._notifyQueue = []
|
|
1101
|
+
for (const { pipeTimer } of this._sessions.values()) {
|
|
1102
|
+
if (pipeTimer) clearTimeout(pipeTimer)
|
|
1103
|
+
}
|
|
784
1104
|
this._sessions.clear()
|
|
785
1105
|
}
|
|
786
1106
|
this.peers.clear()
|
|
@@ -15,12 +15,10 @@ export class GattStream extends Duplex {
|
|
|
15
15
|
/**
|
|
16
16
|
* @param {object} opts
|
|
17
17
|
* @param {(buffer: Uint8Array) => Promise<void>} opts.send Transmit one payload piece (transport frames it).
|
|
18
|
-
* @param {() => void} [opts.onclose] Called once on teardown (send a close frame, disconnect).
|
|
19
18
|
*/
|
|
20
|
-
constructor({ send
|
|
19
|
+
constructor({ send } = {}) {
|
|
21
20
|
super()
|
|
22
21
|
this._send = send
|
|
23
|
-
this._onclose = onclose || null
|
|
24
22
|
}
|
|
25
23
|
|
|
26
24
|
async _write(chunk, cb) {
|
|
@@ -41,17 +39,4 @@ export class GattStream extends Duplex {
|
|
|
41
39
|
remoteEnd() {
|
|
42
40
|
this.push(null)
|
|
43
41
|
}
|
|
44
|
-
|
|
45
|
-
_destroy(cb) {
|
|
46
|
-
const onclose = this._onclose
|
|
47
|
-
this._onclose = null
|
|
48
|
-
if (onclose) {
|
|
49
|
-
try {
|
|
50
|
-
onclose()
|
|
51
|
-
} catch {
|
|
52
|
-
// teardown is best-effort
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
cb(null)
|
|
56
|
-
}
|
|
57
42
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Duplex } from 'streamx'
|
|
2
|
+
import b4a from 'b4a'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Byte pipe over an L2CAP channel — the GattStream-shaped wrapper so
|
|
6
|
+
* BLETransport can treat both pipes identically. The channel is already a
|
|
7
|
+
* reliable ordered duplex with credit-based flow control, so no framing or
|
|
8
|
+
* fragmentation is needed.
|
|
9
|
+
*
|
|
10
|
+
* @extends Duplex
|
|
11
|
+
*/
|
|
12
|
+
export class L2CAPStream extends Duplex {
|
|
13
|
+
/**
|
|
14
|
+
* @param {any} channel bare-bluetooth L2CAPChannel (a duplex).
|
|
15
|
+
*/
|
|
16
|
+
constructor(channel) {
|
|
17
|
+
super()
|
|
18
|
+
this.channel = channel
|
|
19
|
+
|
|
20
|
+
channel.on('data', (data) => {
|
|
21
|
+
// propagate read backpressure: hold the channel while our buffer is full
|
|
22
|
+
if (!this.push(b4a.from(data))) channel.pause()
|
|
23
|
+
})
|
|
24
|
+
channel.on('end', () => this.push(null))
|
|
25
|
+
channel.on('error', () => this.destroy())
|
|
26
|
+
channel.on('close', () => this.destroy())
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_read(cb) {
|
|
30
|
+
this.channel.resume()
|
|
31
|
+
cb(null)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
_write(chunk, cb) {
|
|
35
|
+
if (this.channel.write(chunk)) cb(null)
|
|
36
|
+
else this.channel.once('drain', () => cb(null))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
receive(buffer) {
|
|
40
|
+
this.push(buffer)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
remoteEnd() {
|
|
44
|
+
this.push(null)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
_destroy(cb) {
|
|
48
|
+
try {
|
|
49
|
+
this.channel.destroy()
|
|
50
|
+
} catch {
|
|
51
|
+
// channel may already be gone
|
|
52
|
+
}
|
|
53
|
+
cb(null)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Accumulate the session-id preamble a central writes first on a fresh
|
|
59
|
+
* channel. Resolves { id, rest } — id as a hex string and rest being any bytes
|
|
60
|
+
* delivered past it (the channel is a byte stream; the first payload bytes may
|
|
61
|
+
* arrive glued to the id). Owns the channel's data events until then so no
|
|
62
|
+
* bytes race past the switchover. Timeout resolves { id: null, rest: null }.
|
|
63
|
+
*
|
|
64
|
+
* @param {any} channel
|
|
65
|
+
* @param {number} idLen
|
|
66
|
+
* @param {number} timeout
|
|
67
|
+
* @returns {Promise<{ id: string | null, rest: Uint8Array | null }>}
|
|
68
|
+
*/
|
|
69
|
+
export function readIdPreamble(channel, idLen, timeout) {
|
|
70
|
+
return new Promise((resolve) => {
|
|
71
|
+
let buf = b4a.alloc(0)
|
|
72
|
+
|
|
73
|
+
const finish = (id, rest) => {
|
|
74
|
+
clearTimeout(timer)
|
|
75
|
+
channel.removeListener('data', onData)
|
|
76
|
+
resolve({ id, rest })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const onData = (data) => {
|
|
80
|
+
buf = b4a.concat([buf, b4a.from(data)])
|
|
81
|
+
if (buf.byteLength >= idLen) {
|
|
82
|
+
finish(b4a.toString(buf.subarray(0, idLen), 'hex'), buf.subarray(idLen))
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const timer = setTimeout(() => finish(null, null), timeout)
|
|
87
|
+
if (timer.unref) timer.unref()
|
|
88
|
+
channel.on('data', onData)
|
|
89
|
+
})
|
|
90
|
+
}
|
|
@@ -33,8 +33,10 @@ export class BLETransport extends ReadyResource {
|
|
|
33
33
|
* @param {{ scanMode?: any }} [opts.scanOptions] Platform scan options (e.g. Android low-power).
|
|
34
34
|
* @param {boolean} [opts.keepLinks] On close, stop the radio but leave established links alive (invite rendezvous: the link outlives the QR and carries the initial replication).
|
|
35
35
|
* @param {string} [opts.name] Local app-user display name, sent to peers over a hello frame.
|
|
36
|
+
* @param {'l2cap' | 'gatt'} [opts.pipe] Data pipe: 'l2cap' (default — a real channel per session, several times faster) or 'gatt' (framed characteristic stream). Both peers must match.
|
|
37
|
+
* @param {{ timeout?: number }} [opts.l2cap] Deadline for an l2cap channel open.
|
|
36
38
|
*/
|
|
37
|
-
constructor({ backend, network, uuid, nodeId, tag, maxOutbound, maxInbound, scanOptions, keepLinks, name }: {
|
|
39
|
+
constructor({ backend, network, uuid, nodeId, tag, maxOutbound, maxInbound, scanOptions, keepLinks, name, pipe, l2cap }: {
|
|
38
40
|
backend: any;
|
|
39
41
|
network: import("../index.js").Network;
|
|
40
42
|
uuid: Uint8Array;
|
|
@@ -47,6 +49,10 @@ export class BLETransport extends ReadyResource {
|
|
|
47
49
|
};
|
|
48
50
|
keepLinks?: boolean;
|
|
49
51
|
name?: string;
|
|
52
|
+
pipe?: "l2cap" | "gatt";
|
|
53
|
+
l2cap?: {
|
|
54
|
+
timeout?: number;
|
|
55
|
+
};
|
|
50
56
|
});
|
|
51
57
|
backend: any;
|
|
52
58
|
network: import("../index.js").Network;
|
|
@@ -60,11 +66,15 @@ export class BLETransport extends ReadyResource {
|
|
|
60
66
|
scanMode?: any;
|
|
61
67
|
};
|
|
62
68
|
keepLinks: boolean;
|
|
69
|
+
pipe: "l2cap" | "gatt";
|
|
70
|
+
_l2capTimeout: number;
|
|
63
71
|
state: string;
|
|
64
72
|
central: any;
|
|
65
73
|
server: any;
|
|
66
74
|
_dataChar: any;
|
|
67
|
-
/**
|
|
75
|
+
/** the published l2cap listener's psm, advertised to centrals over hello */
|
|
76
|
+
_psm: any;
|
|
77
|
+
/** id → { id, stream, conn, name, pipeTimer } for server-side (peripheral) sessions */
|
|
68
78
|
_sessions: Map<any, any>;
|
|
69
79
|
/** serialized server notify queue: { frame, resolve, reject } */
|
|
70
80
|
_notifyQueue: any[];
|
|
@@ -73,9 +83,13 @@ export class BLETransport extends ReadyResource {
|
|
|
73
83
|
_serviceAdded: boolean;
|
|
74
84
|
/** peripheral id → per-peer dial state { timer, linked, coolUntil, failures, peerKey, peripheral } */
|
|
75
85
|
_devices: Map<any, any>;
|
|
86
|
+
/** rate-limited discoveries held for the next dial window */
|
|
87
|
+
_candidates: Map<any, any>;
|
|
88
|
+
_dialTimer: any;
|
|
76
89
|
/** last central.connect timestamp — global inter-dial rate limit */
|
|
77
90
|
_lastDial: number;
|
|
78
91
|
_scanTimer: any;
|
|
92
|
+
_cyclePending: boolean;
|
|
79
93
|
_suspended: boolean;
|
|
80
94
|
/** live injected links keyed by remote node id hex */
|
|
81
95
|
peers: Map<any, any>;
|
|
@@ -92,22 +106,52 @@ export class BLETransport extends ReadyResource {
|
|
|
92
106
|
_device(id: any): any;
|
|
93
107
|
_prune(id: any): void;
|
|
94
108
|
_startServer(Service: any, Characteristic: any): void;
|
|
109
|
+
_publishListener(): void;
|
|
110
|
+
_unpublishListener(): void;
|
|
111
|
+
_cycleListener(): void;
|
|
95
112
|
_maybeAdvertise(): void;
|
|
96
113
|
_onWriteRequests(requests: any): void;
|
|
97
114
|
_onServerFrame(data: any): void;
|
|
98
|
-
|
|
115
|
+
_openServerGatt(session: any): void;
|
|
116
|
+
_onServerChannel(channel: any): Promise<void>;
|
|
117
|
+
_bindServerStream(session: any, stream: any): void;
|
|
118
|
+
_reapSession(id: any, session: any): void;
|
|
119
|
+
_closeServerSession(id: any): void;
|
|
120
|
+
_notifyClose(id: any): void;
|
|
99
121
|
_helloPayload(): any;
|
|
100
|
-
|
|
122
|
+
/**
|
|
123
|
+
* @param {Uint8Array} payload
|
|
124
|
+
* @returns {{ name: string, psm: number | null } | null}
|
|
125
|
+
*/
|
|
126
|
+
_parseHello(payload: Uint8Array): {
|
|
127
|
+
name: string;
|
|
128
|
+
psm: number | null;
|
|
129
|
+
} | null;
|
|
101
130
|
_applyPeerName(session: any, payload: any): void;
|
|
102
131
|
_enqueueNotify(f: any): Promise<any>;
|
|
103
132
|
_drainNotify(): void;
|
|
104
133
|
_startScan(): void;
|
|
105
134
|
_armScanRestart(): void;
|
|
106
135
|
_stopScan(): void;
|
|
136
|
+
/**
|
|
137
|
+
* A radio power cycle invalidates the GATT service, advertising, scans,
|
|
138
|
+
* subscriptions and every open link, but the bookkeeping flags survive —
|
|
139
|
+
* without a reset the device never re-registers or re-advertises and goes
|
|
140
|
+
* dark until the app-level toggle is cycled. Reset so the poweredOn
|
|
141
|
+
* handlers bootstrap everything from scratch.
|
|
142
|
+
*/
|
|
143
|
+
_onRadioDown(): void;
|
|
107
144
|
_onState(raw: any): void;
|
|
108
145
|
_onDiscover(peripheral: any): void;
|
|
146
|
+
_flushCandidates(): void;
|
|
147
|
+
_clearCandidates(): void;
|
|
109
148
|
_onConnect(peripheral: any): void;
|
|
110
149
|
_startCentralSession(peripheral: any, char: any): void;
|
|
150
|
+
_openCentralGatt(peripheral: any, sess: any): void;
|
|
151
|
+
_bindCentralStream(peripheral: any, sess: any, stream: any): void;
|
|
152
|
+
_closeCentralSession(peripheral: any, sess: any): void;
|
|
153
|
+
_openCentralL2CAP(peripheral: any, sess: any, psm: any): Promise<void>;
|
|
154
|
+
_openChannel(peripheral: any, psm: any): Promise<any>;
|
|
111
155
|
_onCentralNotify(peripheral: any, data: any): void;
|
|
112
156
|
_centralSend(peripheral: any, char: any, f: any): any;
|
|
113
157
|
_writeOnce(peripheral: any, char: any, f: any): Promise<any>;
|
|
@@ -9,17 +9,13 @@ export class GattStream extends Duplex<import("streamx").DuplexEvents> {
|
|
|
9
9
|
/**
|
|
10
10
|
* @param {object} opts
|
|
11
11
|
* @param {(buffer: Uint8Array) => Promise<void>} opts.send Transmit one payload piece (transport frames it).
|
|
12
|
-
* @param {() => void} [opts.onclose] Called once on teardown (send a close frame, disconnect).
|
|
13
12
|
*/
|
|
14
|
-
constructor({ send
|
|
13
|
+
constructor({ send }?: {
|
|
15
14
|
send: (buffer: Uint8Array) => Promise<void>;
|
|
16
|
-
onclose?: () => void;
|
|
17
15
|
});
|
|
18
16
|
_send: (buffer: Uint8Array) => Promise<void>;
|
|
19
|
-
_onclose: () => void;
|
|
20
17
|
_write(chunk: any, cb: any): Promise<void>;
|
|
21
18
|
receive(buffer: any): void;
|
|
22
19
|
remoteEnd(): void;
|
|
23
|
-
_destroy(cb: any): void;
|
|
24
20
|
}
|
|
25
21
|
import { Duplex } from 'streamx';
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accumulate the session-id preamble a central writes first on a fresh
|
|
3
|
+
* channel. Resolves { id, rest } — id as a hex string and rest being any bytes
|
|
4
|
+
* delivered past it (the channel is a byte stream; the first payload bytes may
|
|
5
|
+
* arrive glued to the id). Owns the channel's data events until then so no
|
|
6
|
+
* bytes race past the switchover. Timeout resolves { id: null, rest: null }.
|
|
7
|
+
*
|
|
8
|
+
* @param {any} channel
|
|
9
|
+
* @param {number} idLen
|
|
10
|
+
* @param {number} timeout
|
|
11
|
+
* @returns {Promise<{ id: string | null, rest: Uint8Array | null }>}
|
|
12
|
+
*/
|
|
13
|
+
export function readIdPreamble(channel: any, idLen: number, timeout: number): Promise<{
|
|
14
|
+
id: string | null;
|
|
15
|
+
rest: Uint8Array | null;
|
|
16
|
+
}>;
|
|
17
|
+
/**
|
|
18
|
+
* Byte pipe over an L2CAP channel — the GattStream-shaped wrapper so
|
|
19
|
+
* BLETransport can treat both pipes identically. The channel is already a
|
|
20
|
+
* reliable ordered duplex with credit-based flow control, so no framing or
|
|
21
|
+
* fragmentation is needed.
|
|
22
|
+
*
|
|
23
|
+
* @extends Duplex
|
|
24
|
+
*/
|
|
25
|
+
export class L2CAPStream extends Duplex<import("streamx").DuplexEvents> {
|
|
26
|
+
/**
|
|
27
|
+
* @param {any} channel bare-bluetooth L2CAPChannel (a duplex).
|
|
28
|
+
*/
|
|
29
|
+
constructor(channel: any);
|
|
30
|
+
channel: any;
|
|
31
|
+
_read(cb: any): void;
|
|
32
|
+
_write(chunk: any, cb: any): void;
|
|
33
|
+
receive(buffer: any): void;
|
|
34
|
+
remoteEnd(): void;
|
|
35
|
+
_destroy(cb: any): void;
|
|
36
|
+
}
|
|
37
|
+
import { Duplex } from 'streamx';
|