@cero-base/core 1.10.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 +1 -1
- package/src/network/transports/ble.js +132 -93
- package/src/network/transports/gatt.js +1 -16
- package/src/network/transports/l2cap.js +25 -35
- package/types/network/transports/ble.d.ts +14 -5
- package/types/network/transports/gatt.d.ts +1 -5
- package/types/network/transports/l2cap.d.ts +11 -12
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@ 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,
|
|
7
|
+
import { L2CAPStream, readIdPreamble } from './l2cap.js'
|
|
8
8
|
|
|
9
9
|
// One data characteristic (write + notify) carries the framed control traffic
|
|
10
10
|
// both ways — and, on the gatt pipe, the data too.
|
|
@@ -48,17 +48,19 @@ const DRAIN_MS = 300
|
|
|
48
48
|
const EMPTY = b4a.alloc(0)
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
|
-
* 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.
|
|
52
54
|
*
|
|
53
55
|
* @param {number} type
|
|
54
|
-
* @param {
|
|
56
|
+
* @param {string} id Session id (hex).
|
|
55
57
|
* @param {Uint8Array} [payload]
|
|
56
58
|
* @returns {Buffer}
|
|
57
59
|
*/
|
|
58
|
-
function frame(type,
|
|
60
|
+
function frame(type, id, payload = EMPTY) {
|
|
59
61
|
const out = b4a.allocUnsafe(HEADER + payload.byteLength)
|
|
60
62
|
out[0] = type
|
|
61
|
-
b4a.
|
|
63
|
+
b4a.write(out, id, 1, SID_LEN, 'hex')
|
|
62
64
|
if (payload.byteLength) b4a.copy(payload, out, HEADER)
|
|
63
65
|
return out
|
|
64
66
|
}
|
|
@@ -67,15 +69,13 @@ function frame(type, sid, payload = EMPTY) {
|
|
|
67
69
|
* Parse a wire frame. Short/empty buffers → null (caller drops).
|
|
68
70
|
*
|
|
69
71
|
* @param {Uint8Array} buf
|
|
70
|
-
* @returns {{ type: number,
|
|
72
|
+
* @returns {{ type: number, id: string, payload: Uint8Array } | null}
|
|
71
73
|
*/
|
|
72
74
|
function parseFrame(buf) {
|
|
73
75
|
if (!buf || buf.byteLength < HEADER) return null
|
|
74
|
-
const sid = buf.subarray(1, HEADER)
|
|
75
76
|
return {
|
|
76
77
|
type: buf[0],
|
|
77
|
-
|
|
78
|
-
sidHex: b4a.toString(sid, 'hex'),
|
|
78
|
+
id: b4a.toString(buf.subarray(1, HEADER), 'hex'),
|
|
79
79
|
payload: buf.subarray(HEADER)
|
|
80
80
|
}
|
|
81
81
|
}
|
|
@@ -177,7 +177,7 @@ export class BLETransport extends ReadyResource {
|
|
|
177
177
|
this._dataChar = null
|
|
178
178
|
/** the published l2cap listener's psm, advertised to centrals over hello */
|
|
179
179
|
this._psm = null
|
|
180
|
-
/**
|
|
180
|
+
/** id → { id, stream, conn, name, pipeTimer } for server-side (peripheral) sessions */
|
|
181
181
|
this._sessions = new Map()
|
|
182
182
|
/** serialized server notify queue: { frame, resolve, reject } */
|
|
183
183
|
this._notifyQueue = []
|
|
@@ -186,9 +186,13 @@ export class BLETransport extends ReadyResource {
|
|
|
186
186
|
this._serviceAdded = false
|
|
187
187
|
/** peripheral id → per-peer dial state { timer, linked, coolUntil, failures, peerKey, peripheral } */
|
|
188
188
|
this._devices = new Map()
|
|
189
|
+
/** rate-limited discoveries held for the next dial window */
|
|
190
|
+
this._candidates = new Map()
|
|
191
|
+
this._dialTimer = null
|
|
189
192
|
/** last central.connect timestamp — global inter-dial rate limit */
|
|
190
193
|
this._lastDial = 0
|
|
191
194
|
this._scanTimer = null
|
|
195
|
+
this._cyclePending = false
|
|
192
196
|
this._suspended = false
|
|
193
197
|
/** live injected links keyed by remote node id hex */
|
|
194
198
|
this.peers = new Map()
|
|
@@ -300,9 +304,17 @@ export class BLETransport extends ReadyResource {
|
|
|
300
304
|
|
|
301
305
|
// Fresh listener, fresh psm — the next hello advertises it. A dead session
|
|
302
306
|
// leaves its channel state on the shared radio link, and the OS refuses a
|
|
303
|
-
// second open to a psm it remembers there.
|
|
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).
|
|
304
310
|
_cycleListener() {
|
|
305
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
|
|
306
318
|
this._unpublishListener()
|
|
307
319
|
this._publishListener()
|
|
308
320
|
}
|
|
@@ -328,66 +340,60 @@ export class BLETransport extends ReadyResource {
|
|
|
328
340
|
_onServerFrame(data) {
|
|
329
341
|
const f = parseFrame(data)
|
|
330
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)
|
|
331
345
|
if (f.type === TYPE_OPEN) {
|
|
332
|
-
if (
|
|
346
|
+
if (s) return
|
|
333
347
|
if (this._sessions.size >= this.maxInbound) {
|
|
334
348
|
// established links win: refuse newcomers with a CLOSE so the dialer's
|
|
335
349
|
// stream ends cleanly and backs off — the mesh converges transitively.
|
|
336
|
-
this.
|
|
350
|
+
this._notifyClose(f.id)
|
|
337
351
|
return
|
|
338
352
|
}
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
this._sessions.set(f.sidHex, session)
|
|
353
|
+
const session = { id: f.id, stream: null, conn: null, name: null, pipeTimer: null }
|
|
354
|
+
this._sessions.set(f.id, session)
|
|
342
355
|
if (this.pipe === 'l2cap' && this._psm !== null) {
|
|
343
356
|
// the session has no stream until the central opens our channel and
|
|
344
|
-
// its
|
|
357
|
+
// its id preamble matches — reap it if that never happens
|
|
345
358
|
session.pipeTimer = setTimeout(() => {
|
|
346
359
|
session.pipeTimer = null
|
|
347
|
-
if (this._sessions.get(f.
|
|
348
|
-
this._reapSession(f.
|
|
349
|
-
this.
|
|
360
|
+
if (this._sessions.get(f.id) !== session || session.stream) return
|
|
361
|
+
this._reapSession(f.id, session)
|
|
362
|
+
this._notifyClose(f.id)
|
|
350
363
|
}, PIPE_PENDING_TIMEOUT)
|
|
351
364
|
if (session.pipeTimer.unref) session.pipeTimer.unref()
|
|
352
365
|
} else {
|
|
353
|
-
this._openServerGatt(
|
|
366
|
+
this._openServerGatt(session)
|
|
354
367
|
}
|
|
355
|
-
this._enqueueNotify(frame(TYPE_HELLO,
|
|
368
|
+
this._enqueueNotify(frame(TYPE_HELLO, f.id, this._helloPayload())).catch(safetyCatch)
|
|
356
369
|
} else if (f.type === TYPE_DATA) {
|
|
357
|
-
const s = this._sessions.get(f.sidHex)
|
|
358
370
|
if (!s) return
|
|
359
371
|
if (!s.stream) {
|
|
360
372
|
// gatt data on a session awaiting its l2cap channel is a pipe
|
|
361
373
|
// mismatch — close instead of silently degrading
|
|
362
|
-
this.
|
|
363
|
-
this._enqueueNotify(frame(TYPE_CLOSE, f.sid)).catch(safetyCatch)
|
|
374
|
+
this._closeServerSession(f.id)
|
|
364
375
|
return
|
|
365
376
|
}
|
|
366
377
|
s.stream.receive(b4a.from(f.payload))
|
|
367
378
|
} else if (f.type === TYPE_HELLO) {
|
|
368
|
-
const s = this._sessions.get(f.sidHex)
|
|
369
379
|
if (s) this._applyPeerName(s, f.payload)
|
|
370
380
|
} else if (f.type === TYPE_CLOSE) {
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
if (s.stream) s.stream.remoteEnd()
|
|
375
|
-
}
|
|
381
|
+
if (!s) return
|
|
382
|
+
this._reapSession(f.id, s)
|
|
383
|
+
if (s.stream) s.stream.remoteEnd()
|
|
376
384
|
}
|
|
377
385
|
}
|
|
378
386
|
|
|
379
|
-
_openServerGatt(
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
send: (payload) => this._enqueueNotify(frame(TYPE_DATA, sid, payload)),
|
|
383
|
-
onclose: () => this._closeServerSession(sidHex, sid)
|
|
387
|
+
_openServerGatt(session) {
|
|
388
|
+
const stream = new GattStream({
|
|
389
|
+
send: (payload) => this._enqueueNotify(frame(TYPE_DATA, session.id, payload))
|
|
384
390
|
})
|
|
385
|
-
|
|
391
|
+
this._bindServerStream(session, stream)
|
|
386
392
|
}
|
|
387
393
|
|
|
388
394
|
// Incoming l2cap channel: the central writes its 8-byte session id first,
|
|
389
395
|
// matching the channel to the session negotiated over the characteristic.
|
|
390
|
-
_onServerChannel(channel) {
|
|
396
|
+
async _onServerChannel(channel) {
|
|
391
397
|
if (this.closing || this.closed || this._suspended) {
|
|
392
398
|
try {
|
|
393
399
|
channel.destroy()
|
|
@@ -396,45 +402,49 @@ export class BLETransport extends ReadyResource {
|
|
|
396
402
|
}
|
|
397
403
|
return
|
|
398
404
|
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
)
|
|
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)
|
|
412
|
+
}
|
|
413
|
+
return
|
|
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()
|
|
424
430
|
}
|
|
425
431
|
|
|
426
|
-
_reapSession(
|
|
432
|
+
_reapSession(id, session) {
|
|
427
433
|
if (session.pipeTimer) clearTimeout(session.pipeTimer)
|
|
428
434
|
session.pipeTimer = null
|
|
429
|
-
this._sessions.delete(
|
|
435
|
+
this._sessions.delete(id)
|
|
430
436
|
this._cycleListener()
|
|
431
437
|
}
|
|
432
438
|
|
|
433
|
-
_closeServerSession(
|
|
434
|
-
const session = this._sessions.get(
|
|
439
|
+
_closeServerSession(id) {
|
|
440
|
+
const session = this._sessions.get(id)
|
|
435
441
|
if (!session) return
|
|
436
|
-
this._reapSession(
|
|
437
|
-
this.
|
|
442
|
+
this._reapSession(id, session)
|
|
443
|
+
this._notifyClose(id)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
_notifyClose(id) {
|
|
447
|
+
this._enqueueNotify(frame(TYPE_CLOSE, id)).catch(safetyCatch)
|
|
438
448
|
}
|
|
439
449
|
|
|
440
450
|
// ─── peer display name (hello frame) ──────────────────────────────────────
|
|
@@ -557,8 +567,10 @@ export class BLETransport extends ReadyResource {
|
|
|
557
567
|
this._scanning = false
|
|
558
568
|
// the power cycle wiped the GATT db — the listener is gone with it
|
|
559
569
|
this._psm = null
|
|
570
|
+
this._cyclePending = false
|
|
560
571
|
for (const d of this._devices.values()) if (d.timer) clearTimeout(d.timer)
|
|
561
572
|
this._devices.clear()
|
|
573
|
+
this._clearCandidates()
|
|
562
574
|
for (const conn of this.peers.values()) {
|
|
563
575
|
try {
|
|
564
576
|
conn.destroy()
|
|
@@ -596,7 +608,20 @@ export class BLETransport extends ReadyResource {
|
|
|
596
608
|
if (d.timer) return // already connecting to this one
|
|
597
609
|
}
|
|
598
610
|
if (this.linkCount >= this.maxOutbound) return // gossip covers the rest
|
|
599
|
-
|
|
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
|
+
}
|
|
600
625
|
// dial every discovery and open a session; a redundant link is dropped by
|
|
601
626
|
// _track's dedup
|
|
602
627
|
this._lastDial = Date.now()
|
|
@@ -610,6 +635,20 @@ export class BLETransport extends ReadyResource {
|
|
|
610
635
|
}
|
|
611
636
|
}
|
|
612
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
|
+
|
|
613
652
|
_onConnect(peripheral) {
|
|
614
653
|
this._device(peripheral.id).peripheral = peripheral
|
|
615
654
|
peripheral.on('error', (err) => {
|
|
@@ -645,14 +684,13 @@ export class BLETransport extends ReadyResource {
|
|
|
645
684
|
// ─── central side ─────────────────────────────────────────────────────────
|
|
646
685
|
|
|
647
686
|
_startCentralSession(peripheral, char) {
|
|
648
|
-
const
|
|
649
|
-
|
|
650
|
-
peripheral._session = { sidHex, sid, upgrading: false }
|
|
687
|
+
const id = b4a.toString(randomBytes(SID_LEN), 'hex')
|
|
688
|
+
peripheral._session = { id, upgrading: false }
|
|
651
689
|
peripheral._char = char // suspend's goodbye writes reuse it
|
|
652
690
|
peripheral.on('notify', (_char, data) => this._onCentralNotify(peripheral, data))
|
|
653
691
|
// open frame first: it registers the session on the server before any data
|
|
654
|
-
this._centralSend(peripheral, char, frame(TYPE_OPEN,
|
|
655
|
-
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(
|
|
656
694
|
safetyCatch
|
|
657
695
|
)
|
|
658
696
|
if (this.pipe === 'l2cap') {
|
|
@@ -666,9 +704,15 @@ export class BLETransport extends ReadyResource {
|
|
|
666
704
|
_openCentralGatt(peripheral, sess) {
|
|
667
705
|
const stream = new GattStream({
|
|
668
706
|
send: (payload) =>
|
|
669
|
-
this._centralSend(peripheral, peripheral._char, frame(TYPE_DATA, sess.
|
|
670
|
-
onclose: () => this._closeCentralSession(peripheral, sess)
|
|
707
|
+
this._centralSend(peripheral, peripheral._char, frame(TYPE_DATA, sess.id, payload))
|
|
671
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))
|
|
672
716
|
peripheral._stream = stream
|
|
673
717
|
this._clearDial(peripheral.id)
|
|
674
718
|
peripheral._conn = this._onChannel(stream, true, peripheral.id)
|
|
@@ -676,7 +720,7 @@ export class BLETransport extends ReadyResource {
|
|
|
676
720
|
}
|
|
677
721
|
|
|
678
722
|
_closeCentralSession(peripheral, sess) {
|
|
679
|
-
this._centralSend(peripheral, peripheral._char, frame(TYPE_CLOSE, sess.
|
|
723
|
+
this._centralSend(peripheral, peripheral._char, frame(TYPE_CLOSE, sess.id)).catch(safetyCatch)
|
|
680
724
|
// platforms reuse peripheral objects across reconnects — stale refs here
|
|
681
725
|
// would make the next session look live and get dropped
|
|
682
726
|
peripheral._stream = null
|
|
@@ -712,17 +756,9 @@ export class BLETransport extends ReadyResource {
|
|
|
712
756
|
this._abortDial(peripheral, 'l2cap-failed')
|
|
713
757
|
return
|
|
714
758
|
}
|
|
715
|
-
//
|
|
716
|
-
channel.write(sess.
|
|
717
|
-
|
|
718
|
-
onclose: () => this._closeCentralSession(peripheral, sess)
|
|
719
|
-
})
|
|
720
|
-
peripheral._stream = stream
|
|
721
|
-
this._clearDial(peripheral.id)
|
|
722
|
-
peripheral._conn = this._onChannel(stream, true, peripheral.id)
|
|
723
|
-
if (peripheral._peerName && peripheral._conn) {
|
|
724
|
-
peripheral._conn._peerName = peripheral._peerName
|
|
725
|
-
}
|
|
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))
|
|
726
762
|
} finally {
|
|
727
763
|
sess.upgrading = false
|
|
728
764
|
}
|
|
@@ -764,7 +800,7 @@ export class BLETransport extends ReadyResource {
|
|
|
764
800
|
if (!sess) return
|
|
765
801
|
const f = parseFrame(data)
|
|
766
802
|
if (!f) return
|
|
767
|
-
if (f.
|
|
803
|
+
if (f.id !== sess.id) return // not our session
|
|
768
804
|
if (f.type === TYPE_DATA) {
|
|
769
805
|
if (peripheral._stream) peripheral._stream.receive(b4a.from(f.payload))
|
|
770
806
|
} else if (f.type === TYPE_HELLO) {
|
|
@@ -938,14 +974,14 @@ export class BLETransport extends ReadyResource {
|
|
|
938
974
|
// flush. Resolves regardless: suspend must never hang on a wedged radio.
|
|
939
975
|
async _sayGoodbye() {
|
|
940
976
|
const sent = []
|
|
941
|
-
for (const
|
|
942
|
-
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))
|
|
943
979
|
}
|
|
944
980
|
for (const d of this._devices.values()) {
|
|
945
981
|
const peripheral = d.peripheral
|
|
946
982
|
const sess = peripheral && peripheral._session
|
|
947
983
|
if (!sess || !peripheral._char) continue
|
|
948
|
-
const f = frame(TYPE_CLOSE, sess.
|
|
984
|
+
const f = frame(TYPE_CLOSE, sess.id)
|
|
949
985
|
sent.push(this._centralSend(peripheral, peripheral._char, f).catch(safetyCatch))
|
|
950
986
|
}
|
|
951
987
|
if (!sent.length) return
|
|
@@ -962,7 +998,9 @@ export class BLETransport extends ReadyResource {
|
|
|
962
998
|
this._suspended = true
|
|
963
999
|
if (this._scanTimer) clearTimeout(this._scanTimer)
|
|
964
1000
|
this._scanTimer = null
|
|
1001
|
+
this._cyclePending = false // suspend tears the sessions down; resume republishes fresh
|
|
965
1002
|
for (const d of this._devices.values()) if (d.timer) clearTimeout(d.timer)
|
|
1003
|
+
this._clearCandidates()
|
|
966
1004
|
// say goodbye BEFORE teardown so the remote reacts in <1s instead of waiting
|
|
967
1005
|
// out the 15s keepalive: close frames, a short drain, then an ACL disconnect
|
|
968
1006
|
// (an instant OS-level signal on both roles).
|
|
@@ -1037,6 +1075,7 @@ export class BLETransport extends ReadyResource {
|
|
|
1037
1075
|
// stop advertising/scanning and let the runtime reclaim.
|
|
1038
1076
|
for (const d of this._devices.values()) if (d.timer) clearTimeout(d.timer)
|
|
1039
1077
|
this._devices.clear()
|
|
1078
|
+
this._clearCandidates()
|
|
1040
1079
|
try {
|
|
1041
1080
|
this.central?.stopScan()
|
|
1042
1081
|
} catch (err) {
|
|
@@ -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
|
}
|
|
@@ -12,13 +12,10 @@ import b4a from 'b4a'
|
|
|
12
12
|
export class L2CAPStream extends Duplex {
|
|
13
13
|
/**
|
|
14
14
|
* @param {any} channel bare-bluetooth L2CAPChannel (a duplex).
|
|
15
|
-
* @param {object} [opts]
|
|
16
|
-
* @param {() => void} [opts.onclose] Called once on teardown (send a close frame, disconnect).
|
|
17
15
|
*/
|
|
18
|
-
constructor(channel
|
|
16
|
+
constructor(channel) {
|
|
19
17
|
super()
|
|
20
18
|
this.channel = channel
|
|
21
|
-
this._onclose = onclose || null
|
|
22
19
|
|
|
23
20
|
channel.on('data', (data) => {
|
|
24
21
|
// propagate read backpressure: hold the channel while our buffer is full
|
|
@@ -48,15 +45,6 @@ export class L2CAPStream extends Duplex {
|
|
|
48
45
|
}
|
|
49
46
|
|
|
50
47
|
_destroy(cb) {
|
|
51
|
-
const onclose = this._onclose
|
|
52
|
-
this._onclose = null
|
|
53
|
-
if (onclose) {
|
|
54
|
-
try {
|
|
55
|
-
onclose()
|
|
56
|
-
} catch {
|
|
57
|
-
// teardown is best-effort
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
48
|
try {
|
|
61
49
|
this.channel.destroy()
|
|
62
50
|
} catch {
|
|
@@ -68,33 +56,35 @@ export class L2CAPStream extends Duplex {
|
|
|
68
56
|
|
|
69
57
|
/**
|
|
70
58
|
* Accumulate the session-id preamble a central writes first on a fresh
|
|
71
|
-
* channel
|
|
72
|
-
*
|
|
73
|
-
*
|
|
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 }.
|
|
74
63
|
*
|
|
75
64
|
* @param {any} channel
|
|
76
|
-
* @param {number}
|
|
77
|
-
* @param {(sid: Uint8Array | null, leftover: Uint8Array | null) => void} onSid
|
|
65
|
+
* @param {number} idLen
|
|
78
66
|
* @param {number} timeout
|
|
67
|
+
* @returns {Promise<{ id: string | null, rest: Uint8Array | null }>}
|
|
79
68
|
*/
|
|
80
|
-
export function
|
|
81
|
-
|
|
82
|
-
|
|
69
|
+
export function readIdPreamble(channel, idLen, timeout) {
|
|
70
|
+
return new Promise((resolve) => {
|
|
71
|
+
let buf = b4a.alloc(0)
|
|
83
72
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
onSid(sid, leftover)
|
|
90
|
-
}
|
|
73
|
+
const finish = (id, rest) => {
|
|
74
|
+
clearTimeout(timer)
|
|
75
|
+
channel.removeListener('data', onData)
|
|
76
|
+
resolve({ id, rest })
|
|
77
|
+
}
|
|
91
78
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
+
}
|
|
96
85
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
86
|
+
const timer = setTimeout(() => finish(null, null), timeout)
|
|
87
|
+
if (timer.unref) timer.unref()
|
|
88
|
+
channel.on('data', onData)
|
|
89
|
+
})
|
|
100
90
|
}
|
|
@@ -74,7 +74,7 @@ export class BLETransport extends ReadyResource {
|
|
|
74
74
|
_dataChar: any;
|
|
75
75
|
/** the published l2cap listener's psm, advertised to centrals over hello */
|
|
76
76
|
_psm: any;
|
|
77
|
-
/**
|
|
77
|
+
/** id → { id, stream, conn, name, pipeTimer } for server-side (peripheral) sessions */
|
|
78
78
|
_sessions: Map<any, any>;
|
|
79
79
|
/** serialized server notify queue: { frame, resolve, reject } */
|
|
80
80
|
_notifyQueue: any[];
|
|
@@ -83,9 +83,13 @@ export class BLETransport extends ReadyResource {
|
|
|
83
83
|
_serviceAdded: boolean;
|
|
84
84
|
/** peripheral id → per-peer dial state { timer, linked, coolUntil, failures, peerKey, peripheral } */
|
|
85
85
|
_devices: Map<any, any>;
|
|
86
|
+
/** rate-limited discoveries held for the next dial window */
|
|
87
|
+
_candidates: Map<any, any>;
|
|
88
|
+
_dialTimer: any;
|
|
86
89
|
/** last central.connect timestamp — global inter-dial rate limit */
|
|
87
90
|
_lastDial: number;
|
|
88
91
|
_scanTimer: any;
|
|
92
|
+
_cyclePending: boolean;
|
|
89
93
|
_suspended: boolean;
|
|
90
94
|
/** live injected links keyed by remote node id hex */
|
|
91
95
|
peers: Map<any, any>;
|
|
@@ -108,10 +112,12 @@ export class BLETransport extends ReadyResource {
|
|
|
108
112
|
_maybeAdvertise(): void;
|
|
109
113
|
_onWriteRequests(requests: any): void;
|
|
110
114
|
_onServerFrame(data: any): void;
|
|
111
|
-
_openServerGatt(
|
|
112
|
-
_onServerChannel(channel: any): void
|
|
113
|
-
|
|
114
|
-
|
|
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;
|
|
115
121
|
_helloPayload(): any;
|
|
116
122
|
/**
|
|
117
123
|
* @param {Uint8Array} payload
|
|
@@ -137,9 +143,12 @@ export class BLETransport extends ReadyResource {
|
|
|
137
143
|
_onRadioDown(): void;
|
|
138
144
|
_onState(raw: any): void;
|
|
139
145
|
_onDiscover(peripheral: any): void;
|
|
146
|
+
_flushCandidates(): void;
|
|
147
|
+
_clearCandidates(): void;
|
|
140
148
|
_onConnect(peripheral: any): void;
|
|
141
149
|
_startCentralSession(peripheral: any, char: any): void;
|
|
142
150
|
_openCentralGatt(peripheral: any, sess: any): void;
|
|
151
|
+
_bindCentralStream(peripheral: any, sess: any, stream: any): void;
|
|
143
152
|
_closeCentralSession(peripheral: any, sess: any): void;
|
|
144
153
|
_openCentralL2CAP(peripheral: any, sess: any, psm: any): Promise<void>;
|
|
145
154
|
_openChannel(peripheral: any, psm: 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';
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Accumulate the session-id preamble a central writes first on a fresh
|
|
3
|
-
* channel
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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 }.
|
|
6
7
|
*
|
|
7
8
|
* @param {any} channel
|
|
8
|
-
* @param {number}
|
|
9
|
-
* @param {(sid: Uint8Array | null, leftover: Uint8Array | null) => void} onSid
|
|
9
|
+
* @param {number} idLen
|
|
10
10
|
* @param {number} timeout
|
|
11
|
+
* @returns {Promise<{ id: string | null, rest: Uint8Array | null }>}
|
|
11
12
|
*/
|
|
12
|
-
export function
|
|
13
|
+
export function readIdPreamble(channel: any, idLen: number, timeout: number): Promise<{
|
|
14
|
+
id: string | null;
|
|
15
|
+
rest: Uint8Array | null;
|
|
16
|
+
}>;
|
|
13
17
|
/**
|
|
14
18
|
* Byte pipe over an L2CAP channel — the GattStream-shaped wrapper so
|
|
15
19
|
* BLETransport can treat both pipes identically. The channel is already a
|
|
@@ -21,14 +25,9 @@ export function readSidPreamble(channel: any, sidLen: number, onSid: (sid: Uint8
|
|
|
21
25
|
export class L2CAPStream extends Duplex<import("streamx").DuplexEvents> {
|
|
22
26
|
/**
|
|
23
27
|
* @param {any} channel bare-bluetooth L2CAPChannel (a duplex).
|
|
24
|
-
* @param {object} [opts]
|
|
25
|
-
* @param {() => void} [opts.onclose] Called once on teardown (send a close frame, disconnect).
|
|
26
28
|
*/
|
|
27
|
-
constructor(channel: any
|
|
28
|
-
onclose?: () => void;
|
|
29
|
-
});
|
|
29
|
+
constructor(channel: any);
|
|
30
30
|
channel: any;
|
|
31
|
-
_onclose: () => void;
|
|
32
31
|
_read(cb: any): void;
|
|
33
32
|
_write(chunk: any, cb: any): void;
|
|
34
33
|
receive(buffer: any): void;
|