@cero-base/core 1.2.0 → 1.4.0

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.
@@ -0,0 +1,779 @@
1
+ import ReadyResource from 'ready-resource'
2
+ import b4a from 'b4a'
3
+ import { hash, randomBytes } from 'hypercore-crypto'
4
+ import safetyCatch from 'safety-catch'
5
+
6
+ import { GattStream } from './gatt.js'
7
+
8
+ // iOS never answers an L2CAP channel opened by a Mac central and gives no L2CAP
9
+ // disconnect signal, so one data characteristic (write + notify) carries a
10
+ // framed byte stream both ways instead.
11
+ const DATA_UUID = 'ce1a0004-0000-1000-8000-00805f9b34fb'
12
+
13
+ // Wire frame both directions: [type:1][sessionId:8][payload].
14
+ const TYPE_OPEN = 1
15
+ const TYPE_DATA = 2
16
+ const TYPE_CLOSE = 3
17
+ const TYPE_HELLO = 4
18
+ const SID_LEN = 8
19
+ const HEADER = 1 + SID_LEN
20
+
21
+ const DEFAULT_CAP = 4
22
+ const CONNECT_TIMEOUT = 15000
23
+ // per-peer dial backoff: eager while unlinked, patient once linked; the cooldown
24
+ // grows exponentially per consecutive failure.
25
+ const DIAL_COOLDOWN_BASE = 8000
26
+ const DIAL_COOLDOWN_BASE_LONELY = 2000
27
+ const DIAL_COOLDOWN_MAX = 30000
28
+ // one radio can't usefully dial faster than this
29
+ const DIAL_MIN_INTERVAL = 500
30
+ // iOS reports a peripheral once per scan session; restart to re-report a
31
+ // re-advertised peer.
32
+ const SCAN_RESTART_LONELY = 5000
33
+ // linked: continuous scanning is the dominant battery cost, so duty-cycle it.
34
+ const SCAN_DUTY_ON = 5000
35
+ const SCAN_DUTY_OFF = 25000
36
+ // suspend() drain window: let goodbye frames flush before hanging up.
37
+ const DRAIN_MS = 300
38
+
39
+ const EMPTY = b4a.alloc(0)
40
+
41
+ /**
42
+ * Build a wire frame [type][sessionId][payload].
43
+ *
44
+ * @param {number} type
45
+ * @param {Uint8Array} sid 8-byte session id.
46
+ * @param {Uint8Array} [payload]
47
+ * @returns {Buffer}
48
+ */
49
+ function frame(type, sid, payload = EMPTY) {
50
+ const out = b4a.allocUnsafe(HEADER + payload.byteLength)
51
+ out[0] = type
52
+ b4a.copy(sid, out, 1)
53
+ if (payload.byteLength) b4a.copy(payload, out, HEADER)
54
+ return out
55
+ }
56
+
57
+ /**
58
+ * Parse a wire frame. Short/empty buffers → null (caller drops).
59
+ *
60
+ * @param {Uint8Array} buf
61
+ * @returns {{ type: number, sid: Uint8Array, sidHex: string, payload: Uint8Array } | null}
62
+ */
63
+ function parseFrame(buf) {
64
+ if (!buf || buf.byteLength < HEADER) return null
65
+ const sid = buf.subarray(1, HEADER)
66
+ return {
67
+ type: buf[0],
68
+ sid,
69
+ sidHex: b4a.toString(sid, 'hex'),
70
+ payload: buf.subarray(HEADER)
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Derive a stable 128-bit BLE service UUID from a topic. Only devices that
76
+ * compute the same UUID (same channel / same invite) ever discover each other.
77
+ *
78
+ * @param {Uint8Array} topic
79
+ * @param {string} [tag] Namespace so channel and invite meshes never collide.
80
+ * @returns {string}
81
+ */
82
+ export function toServiceUUID(topic, tag = 'cero-ble') {
83
+ const h = hash([b4a.from(tag), topic])
84
+ const hex = b4a.toString(h.subarray(0, 16), 'hex')
85
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
86
+ }
87
+
88
+ // Backend adapter state → the app-facing state the facade exposes.
89
+ const STATE = {
90
+ poweredOn: 'on',
91
+ poweredOff: 'waiting',
92
+ unauthorized: 'unauthorized',
93
+ unsupported: 'unsupported',
94
+ resetting: 'waiting',
95
+ unknown: 'waiting'
96
+ }
97
+
98
+ const uuidEq = (a, b) =>
99
+ String(a || '')
100
+ .toLowerCase()
101
+ .replace(/-/g, '') ===
102
+ String(b || '')
103
+ .toLowerCase()
104
+ .replace(/-/g, '')
105
+
106
+ const findByUUID = (items, uuid) => (items || []).find((i) => uuidEq(i.uuid, uuid)) || null
107
+
108
+ /**
109
+ * Dual-role BLE transport: advertises + scans one service UUID, opens a GATT
110
+ * byte-stream to each discovered peer, and feeds it into `network.inject`. From
111
+ * there replication and pairing are transport-agnostic (see Network.inject).
112
+ *
113
+ * The server adds one data characteristic (write + notify) and advertises. The
114
+ * central connects, discovers the characteristic, subscribes, then framed bytes
115
+ * flow both ways — central→server as GATT writes, server→central as
116
+ * notifications — each tagged with an 8-byte session id. `backend` is
117
+ * bare-bluetooth in production and a mock in tests.
118
+ *
119
+ * @extends ReadyResource
120
+ */
121
+ export class BLETransport extends ReadyResource {
122
+ /**
123
+ * @param {object} opts
124
+ * @param {any} opts.backend bare-bluetooth-shaped module (Central, Server, Service, Characteristic).
125
+ * @param {import('../index.js').Network} opts.network
126
+ * @param {Uint8Array} opts.uuid The 32-byte topic the service UUID derives from.
127
+ * @param {Uint8Array} opts.nodeId Stable local id (identity/device key) for the initiate tie-break.
128
+ * @param {string} [opts.tag] UUID namespace (channel mesh vs invite mesh).
129
+ * @param {number} [opts.cap] Max concurrent links; gossip covers the rest.
130
+ * @param {{ scanMode?: any }} [opts.scanOptions] Platform scan options (e.g. Android low-power).
131
+ * @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).
132
+ * @param {string} [opts.name] Local app-user display name, sent to peers over a hello frame.
133
+ */
134
+ constructor({
135
+ backend,
136
+ network,
137
+ uuid,
138
+ nodeId,
139
+ tag = 'cero-ble',
140
+ cap = DEFAULT_CAP,
141
+ scanOptions,
142
+ keepLinks = false,
143
+ name
144
+ }) {
145
+ super()
146
+ this.backend = backend
147
+ this.network = network
148
+ this.name = name || ''
149
+ this.nodeId = nodeId
150
+ this.nodeHex = b4a.toString(nodeId, 'hex')
151
+ this.serviceUUID = toServiceUUID(uuid, tag)
152
+ this.cap = cap
153
+ this.scanOptions = scanOptions
154
+ this.keepLinks = keepLinks
155
+
156
+ this.state = 'off'
157
+ this.central = null
158
+ this.server = null
159
+ this._dataChar = null
160
+ /** sessionId hex → { stream, sid } for server-side (peripheral) sessions */
161
+ this._sessions = new Map()
162
+ /** serialized server notify queue: { frame, resolve, reject } */
163
+ this._notifyQueue = []
164
+ this._scanning = false
165
+ this._advertising = false
166
+ this._serviceAdded = false
167
+ /** peripheral id → per-peer dial state { timer, linked, coolUntil, failures, peerKey, peripheral } */
168
+ this._devices = new Map()
169
+ /** last central.connect timestamp — global inter-dial rate limit */
170
+ this._lastDial = 0
171
+ this._scanTimer = null
172
+ this._suspended = false
173
+ /** live injected links keyed by remote node id hex */
174
+ this.peers = new Map()
175
+ }
176
+
177
+ /**
178
+ * Whether we should be the one to open the connection to `peerNodeId`. The
179
+ * lexicographically smaller id initiates; the larger waits — so a pair
180
+ * connects once, not twice. Equal (our own reflection) → false.
181
+ *
182
+ * @param {Uint8Array} peerNodeId
183
+ * @returns {boolean}
184
+ */
185
+ shouldInitiate(peerNodeId) {
186
+ return b4a.compare(this.nodeId, peerNodeId) < 0
187
+ }
188
+
189
+ get linkCount() {
190
+ return this.peers.size
191
+ }
192
+
193
+ _device(id) {
194
+ let d = this._devices.get(id)
195
+ if (!d) {
196
+ d = { timer: null, linked: false, coolUntil: 0, failures: 0, peerKey: null, peripheral: null }
197
+ this._devices.set(id, d)
198
+ }
199
+ return d
200
+ }
201
+
202
+ _prune(id) {
203
+ const d = this._devices.get(id)
204
+ if (d && !d.timer && !d.linked && !d.coolUntil && !d.failures && !d.peerKey && !d.peripheral) {
205
+ this._devices.delete(id)
206
+ }
207
+ }
208
+
209
+ async _open() {
210
+ const { Central, Server, Service, Characteristic } = this.backend
211
+
212
+ this.server = new Server()
213
+ this.server.on('stateChange', (s) => {
214
+ this._onState(s)
215
+ if (s === 'poweredOn') this._startServer(Service, Characteristic)
216
+ })
217
+ this.server.on('serviceAdd', () => {
218
+ this._serviceAdded = true
219
+ this._maybeAdvertise()
220
+ })
221
+ this.server.on('writeRequest', (reqs) => this._onWriteRequests(reqs))
222
+ this.server.on('readyToUpdate', () => this._drainNotify())
223
+ // writeRequests carry no central identifier, so an unsubscribe can't be
224
+ // mapped to a session; teardown is left to _onChannel's keepalive/timeout.
225
+ this.server.on('unsubscribe', () => {})
226
+ this.server.on('error', safetyCatch)
227
+
228
+ this.central = new Central()
229
+ this.central.on('stateChange', (s) => {
230
+ this._onState(s)
231
+ if (s === 'poweredOn') this._startScan()
232
+ })
233
+ this.central.on('discover', (peripheral) => this._onDiscover(peripheral))
234
+ this.central.on('connect', (peripheral) => this._onConnect(peripheral))
235
+ this.central.on('disconnect', () => {})
236
+ this.central.on('error', (err) => this._onCentralError(err))
237
+
238
+ this._startServer(Service, Characteristic)
239
+ this._startScan()
240
+ this.state = 'starting'
241
+ }
242
+
243
+ _startServer(Service, Characteristic) {
244
+ if (this.server.state !== 'poweredOn') return
245
+ if (!this._serviceAdded) {
246
+ this._dataChar = new Characteristic(DATA_UUID, { write: true, notify: true })
247
+ this.server.addService(new Service(this.serviceUUID, [this._dataChar]))
248
+ }
249
+ }
250
+
251
+ _maybeAdvertise() {
252
+ if (this._advertising || !this._serviceAdded) return
253
+ this._advertising = true
254
+ this.server.startAdvertising({ serviceUUIDs: [this.serviceUUID] })
255
+ }
256
+
257
+ // ─── server (peripheral) side ─────────────────────────────────────────────
258
+
259
+ _onWriteRequests(requests) {
260
+ const ok = this.server.constructor.ATT_SUCCESS ?? 0
261
+ for (const req of requests) {
262
+ // must respond within ms or the central times out — before any parsing
263
+ if (req.responseNeeded !== false) this.server.respondToRequest(req, ok)
264
+ if (this._suspended) continue
265
+ this._onServerFrame(req.data)
266
+ }
267
+ }
268
+
269
+ _onServerFrame(data) {
270
+ const f = parseFrame(data)
271
+ if (!f) return
272
+ if (f.type === TYPE_OPEN) {
273
+ if (this._sessions.has(f.sidHex)) return
274
+ const sid = b4a.from(f.sid) // copy: f.sid views the transient request buffer
275
+ const stream = new GattStream({
276
+ send: (payload) => this._enqueueNotify(frame(TYPE_DATA, sid, payload)),
277
+ onclose: () => this._closeServerSession(f.sidHex, sid)
278
+ })
279
+ const session = { stream, sid, conn: null, name: null }
280
+ this._sessions.set(f.sidHex, session)
281
+ session.conn = this._onChannel(stream, false, null)
282
+ this._enqueueNotify(frame(TYPE_HELLO, sid, this._helloPayload())).catch(safetyCatch)
283
+ } else if (f.type === TYPE_DATA) {
284
+ const s = this._sessions.get(f.sidHex)
285
+ if (s) s.stream.receive(b4a.from(f.payload))
286
+ } else if (f.type === TYPE_HELLO) {
287
+ const s = this._sessions.get(f.sidHex)
288
+ if (s) this._applyPeerName(s, f.payload)
289
+ } else if (f.type === TYPE_CLOSE) {
290
+ const s = this._sessions.get(f.sidHex)
291
+ if (s) {
292
+ this._sessions.delete(f.sidHex)
293
+ s.stream.remoteEnd()
294
+ }
295
+ }
296
+ }
297
+
298
+ _closeServerSession(sidHex, sid) {
299
+ if (!this._sessions.has(sidHex)) return
300
+ this._sessions.delete(sidHex)
301
+ this._enqueueNotify(frame(TYPE_CLOSE, sid)).catch(safetyCatch)
302
+ }
303
+
304
+ // ─── peer display name (hello frame) ──────────────────────────────────────
305
+
306
+ _helloPayload() {
307
+ return b4a.from(JSON.stringify({ n: this.name || '' }))
308
+ }
309
+
310
+ _parseHello(payload) {
311
+ try {
312
+ const { n } = JSON.parse(b4a.toString(payload))
313
+ return typeof n === 'string' ? n : ''
314
+ } catch {
315
+ return null
316
+ }
317
+ }
318
+
319
+ _applyPeerName(session, payload) {
320
+ const name = this._parseHello(payload)
321
+ if (name === null) return
322
+ session.name = name
323
+ if (session.conn) session.conn._peerName = name
324
+ this.emit('update')
325
+ }
326
+
327
+ // Serialize notifications through the single characteristic: updateValue
328
+ // returns false when the peripheral's queue is full — hold the head frame and
329
+ // retry on the next 'readyToUpdate', preserving order.
330
+ _enqueueNotify(f) {
331
+ return new Promise((resolve, reject) => {
332
+ this._notifyQueue.push({ frame: f, resolve, reject })
333
+ this._drainNotify()
334
+ })
335
+ }
336
+
337
+ _drainNotify() {
338
+ while (this._notifyQueue.length) {
339
+ const item = this._notifyQueue[0]
340
+ let ok
341
+ try {
342
+ ok = this.server.updateValue(this._dataChar, item.frame)
343
+ } catch (err) {
344
+ this._notifyQueue.shift()
345
+ item.reject(err)
346
+ continue
347
+ }
348
+ if (!ok) return
349
+ this._notifyQueue.shift()
350
+ item.resolve()
351
+ }
352
+ }
353
+
354
+ _startScan() {
355
+ if (this._scanning || this.central.state !== 'poweredOn') return
356
+ this._scanning = true
357
+ this.central.startScan([this.serviceUUID], this.scanOptions)
358
+ this._armScanRestart()
359
+ }
360
+
361
+ // Lonely: cycle the scan every SCAN_RESTART_LONELY so a re-advertised peer is
362
+ // re-reported. Linked: duty-cycle SCAN_DUTY_ON on / SCAN_DUTY_OFF dark to save
363
+ // battery. One timer drives the whole cycle; the next delay derives from phase.
364
+ _armScanRestart() {
365
+ if (this._scanTimer) clearTimeout(this._scanTimer)
366
+ const delay =
367
+ this.linkCount > 0 ? (this._scanning ? SCAN_DUTY_ON : SCAN_DUTY_OFF) : SCAN_RESTART_LONELY
368
+ this._scanTimer = setTimeout(() => {
369
+ this._scanTimer = null
370
+ if (this.closing || this.closed || this._suspended) return
371
+ // never toggle the scan mid-dial (would kill the connect) — defer a phase
372
+ if (this._isDialing()) {
373
+ this._armScanRestart()
374
+ return
375
+ }
376
+ if (this.linkCount > 0) {
377
+ if (this._scanning) {
378
+ this._stopScan()
379
+ this._armScanRestart()
380
+ } else {
381
+ this._startScan()
382
+ }
383
+ } else {
384
+ this._stopScan()
385
+ this._startScan()
386
+ }
387
+ }, delay)
388
+ if (this._scanTimer.unref) this._scanTimer.unref()
389
+ }
390
+
391
+ _stopScan() {
392
+ if (!this._scanning) return
393
+ this._scanning = false
394
+ try {
395
+ this.central.stopScan()
396
+ } catch (err) {
397
+ safetyCatch(err)
398
+ }
399
+ }
400
+
401
+ _onState(raw) {
402
+ const next = STATE[raw] ?? 'waiting'
403
+ if (next === this.state) return
404
+ this.state = next
405
+ this.emit('update')
406
+ }
407
+
408
+ _onDiscover(peripheral) {
409
+ if (this.closing || this.closed || this._suspended) return
410
+ const d = this._devices.get(peripheral.id)
411
+ if (d) {
412
+ if (d.linked) return
413
+ if (d.peerKey && this.peers.has(d.peerKey)) return // linked via another channel
414
+ if (d.coolUntil > Date.now()) return // failed recently — back off
415
+ if (d.timer) return // already connecting to this one
416
+ }
417
+ if (this.linkCount >= this.cap) return // gossip covers the rest
418
+ if (Date.now() - this._lastDial < DIAL_MIN_INTERVAL) return
419
+ // dial every discovery and open a session; a redundant link is dropped by
420
+ // _track's dedup
421
+ this._lastDial = Date.now()
422
+ const timer = setTimeout(() => this._abortDial(peripheral, 'timeout'), CONNECT_TIMEOUT)
423
+ this._device(peripheral.id).timer = timer
424
+ try {
425
+ this._stopScan()
426
+ this.central.connect(peripheral)
427
+ } catch (err) {
428
+ this._abortDial(peripheral, err)
429
+ }
430
+ }
431
+
432
+ _onConnect(peripheral) {
433
+ this._device(peripheral.id).peripheral = peripheral
434
+ peripheral.on('error', () => this._abortDial(peripheral, 'peripheral-error'))
435
+ peripheral.once('servicesDiscover', (services) => {
436
+ const svc = findByUUID(services, this.serviceUUID)
437
+ if (svc) peripheral.discoverCharacteristics(svc, [DATA_UUID])
438
+ else this._abortDial(peripheral, 'no-service')
439
+ })
440
+ peripheral.once('characteristicsDiscover', (_svc, chars) => {
441
+ const dataChar = findByUUID(chars, DATA_UUID)
442
+ if (dataChar) peripheral.subscribe(dataChar)
443
+ else this._abortDial(peripheral, 'no-data-char')
444
+ })
445
+ peripheral.once('notifyState', (char, isNotifying) => {
446
+ if (!isNotifying) {
447
+ this._abortDial(peripheral, 'subscribe-failed')
448
+ return
449
+ }
450
+ this._startCentralSession(peripheral, char)
451
+ })
452
+ // both sides open a session: on iOS the peer id isn't known until after
453
+ // connect, so a tie-break yields only post-handshake and deadlocks if the
454
+ // other side never dials back. _track keeps the first, drops the dup.
455
+ peripheral.discoverServices([this.serviceUUID])
456
+ }
457
+
458
+ // ─── central side ─────────────────────────────────────────────────────────
459
+
460
+ _startCentralSession(peripheral, char) {
461
+ const sid = randomBytes(SID_LEN)
462
+ const sidHex = b4a.toString(sid, 'hex')
463
+ peripheral._session = { sidHex, sid }
464
+ peripheral._char = char // suspend's goodbye writes reuse it
465
+ peripheral.on('notify', (_char, data) => this._onCentralNotify(peripheral, data))
466
+ const stream = new GattStream({
467
+ send: (payload) => this._centralSend(peripheral, char, frame(TYPE_DATA, sid, payload)),
468
+ onclose: () => {
469
+ this._centralSend(peripheral, char, frame(TYPE_CLOSE, sid)).catch(safetyCatch)
470
+ const d = this._devices.get(peripheral.id)
471
+ if (d) d.peripheral = null
472
+ try {
473
+ this.central.disconnect(peripheral)
474
+ } catch (err) {
475
+ safetyCatch(err)
476
+ }
477
+ }
478
+ })
479
+ peripheral._stream = stream
480
+ // open frame first: it registers the session on the server before any data
481
+ this._centralSend(peripheral, char, frame(TYPE_OPEN, sid)).catch(safetyCatch)
482
+ this._centralSend(peripheral, char, frame(TYPE_HELLO, sid, this._helloPayload())).catch(
483
+ safetyCatch
484
+ )
485
+ this._clearDial(peripheral.id)
486
+ peripheral._conn = this._onChannel(stream, true, peripheral.id)
487
+ }
488
+
489
+ _onCentralNotify(peripheral, data) {
490
+ const sess = peripheral._session
491
+ if (!sess || !peripheral._stream) return
492
+ const f = parseFrame(data)
493
+ if (!f) return
494
+ if (f.sidHex !== sess.sidHex) return // not our session
495
+ if (f.type === TYPE_DATA) peripheral._stream.receive(b4a.from(f.payload))
496
+ else if (f.type === TYPE_HELLO) {
497
+ const name = this._parseHello(f.payload)
498
+ if (name === null) return
499
+ peripheral._peerName = name
500
+ if (peripheral._conn) peripheral._conn._peerName = name
501
+ this.emit('update')
502
+ } else if (f.type === TYPE_CLOSE) {
503
+ peripheral._session = null
504
+ peripheral._stream.remoteEnd()
505
+ }
506
+ }
507
+
508
+ // One write in flight per peripheral: chain each write behind the previous,
509
+ // resolving on the 'write' completion event before the next is issued.
510
+ _centralSend(peripheral, char, f) {
511
+ const prev = peripheral._writeChain || Promise.resolve()
512
+ const next = prev.then(() => this._writeOnce(peripheral, char, f))
513
+ peripheral._writeChain = next.catch(safetyCatch) // keep the chain alive
514
+ return next
515
+ }
516
+
517
+ _writeOnce(peripheral, char, f) {
518
+ return new Promise((resolve, reject) => {
519
+ const cleanup = () => {
520
+ peripheral.removeListener('write', onWrite)
521
+ peripheral.removeListener('error', onErr)
522
+ }
523
+ const onWrite = () => {
524
+ cleanup()
525
+ resolve()
526
+ }
527
+ const onErr = (err) => {
528
+ cleanup()
529
+ reject(err)
530
+ }
531
+ peripheral.once('write', onWrite)
532
+ peripheral.once('error', onErr)
533
+ try {
534
+ peripheral.write(char, f, true)
535
+ } catch (err) {
536
+ cleanup()
537
+ reject(err)
538
+ }
539
+ })
540
+ }
541
+
542
+ _abortDial(peripheral, _reason) {
543
+ const id = peripheral?.id
544
+ if (id != null) {
545
+ const d = this._device(id)
546
+ d.failures += 1
547
+ const base = this.linkCount === 0 ? DIAL_COOLDOWN_BASE_LONELY : DIAL_COOLDOWN_BASE
548
+ d.coolUntil =
549
+ Date.now() + Math.min(DIAL_COOLDOWN_MAX, base * 2 ** Math.min(4, d.failures - 1))
550
+ d.peripheral = null
551
+ }
552
+ this._clearDial(id)
553
+ try {
554
+ this.central.disconnect(peripheral)
555
+ } catch (err) {
556
+ safetyCatch(err)
557
+ }
558
+ this._startScan()
559
+ }
560
+
561
+ _clearDial(id) {
562
+ if (id == null) return
563
+ const d = this._devices.get(id)
564
+ if (!d) return
565
+ if (d.timer) clearTimeout(d.timer)
566
+ d.timer = null
567
+ this._prune(id)
568
+ }
569
+
570
+ _isDialing() {
571
+ for (const d of this._devices.values()) if (d.timer) return true
572
+ return false
573
+ }
574
+
575
+ // iOS & Android report errored connects as 'error' with a code, not
576
+ // 'disconnect' — without this a failed dial pins the peripheral forever.
577
+ _onCentralError(err) {
578
+ safetyCatch(err)
579
+ const code = err && err.code
580
+ if (code === 'CONNECTION_FAILED' || code === 'DISCONNECT') {
581
+ for (const [id, d] of this._devices) if (d.timer) this._clearDial(id)
582
+ }
583
+ }
584
+
585
+ _onChannel(stream, isInitiator, peripheralId) {
586
+ if (this.closing || this.closed || this._suspended) {
587
+ stream.destroy()
588
+ return
589
+ }
590
+ const conn = this.network.inject(stream, { isInitiator })
591
+ // iOS never signals a disconnect for a vanished peer: keepalive pings and the
592
+ // timeout are the liveness detector (keepalive refreshes the timeout).
593
+ conn.setKeepAlive(5000)
594
+ conn.setTimeout(15000)
595
+ stream.on('error', safetyCatch)
596
+ // marked at channel-open (not handshake-open): rediscovery must not dial a
597
+ // peripheral whose channel is still handshaking
598
+ if (peripheralId != null) {
599
+ const d = this._device(peripheralId)
600
+ d.linked = true
601
+ d.coolUntil = 0
602
+ d.failures = 0 // reached a live session — reset backoff
603
+ conn.once('close', () => {
604
+ const rec = this._devices.get(peripheralId)
605
+ if (rec) {
606
+ rec.linked = false
607
+ this._prune(peripheralId)
608
+ }
609
+ })
610
+ }
611
+ conn.on('open', () => this._track(conn, peripheralId, isInitiator))
612
+ conn.on('close', () => this._untrack(conn))
613
+ this._startScan()
614
+ return conn
615
+ }
616
+
617
+ _track(conn, peripheralId, isInitiator) {
618
+ if (peripheralId != null && conn.remotePublicKey) {
619
+ this._device(peripheralId).peerKey = b4a.toString(conn.remotePublicKey, 'hex')
620
+ }
621
+ if (this.closing || this.closed) return
622
+ const key = b4a.toString(conn.remotePublicKey, 'hex')
623
+ const existing = this.peers.get(key)
624
+ if (existing && existing !== conn) {
625
+ // Both sides dial, so a pair links twice. Dropping a channel closes it for
626
+ // BOTH devices, so both must retire the SAME channel. Deterministic winner:
627
+ // keep the channel whose initiator has the smaller static key — computed
628
+ // identically on both ends, so the loser drops on both and never cascades.
629
+ const initiatorIsUsSmaller = b4a.compare(conn.publicKey, conn.remotePublicKey) < 0
630
+ const preferred = isInitiator === initiatorIsUsSmaller
631
+ if (!preferred) {
632
+ conn.destroy()
633
+ return
634
+ }
635
+ }
636
+ conn._peripheralId = peripheralId
637
+ conn._peerKey = key
638
+ this.peers.set(key, conn)
639
+ if (existing && existing !== conn) existing.destroy() // retire the loser channel
640
+ this.emit('update')
641
+ }
642
+
643
+ _untrack(conn) {
644
+ const key = conn._peerKey
645
+ if (key && this.peers.get(key) === conn) this.peers.delete(key)
646
+ if (!this.closing && !this.closed) {
647
+ // last link gone → hunt immediately instead of waiting out a dark window
648
+ if (this.linkCount === 0 && !this._suspended) this._startScan()
649
+ this.emit('update')
650
+ }
651
+ }
652
+
653
+ // Best-effort TYPE_CLOSE to every live session — server sessions over notify,
654
+ // central sessions over write — then wait up to DRAIN_MS for the frames to
655
+ // flush. Resolves regardless: suspend must never hang on a wedged radio.
656
+ async _sayGoodbye() {
657
+ const sent = []
658
+ for (const { sid } of this._sessions.values()) {
659
+ sent.push(this._enqueueNotify(frame(TYPE_CLOSE, sid)).catch(safetyCatch))
660
+ }
661
+ for (const d of this._devices.values()) {
662
+ const peripheral = d.peripheral
663
+ const sess = peripheral && peripheral._session
664
+ if (!sess || !peripheral._char) continue
665
+ const f = frame(TYPE_CLOSE, sess.sid)
666
+ sent.push(this._centralSend(peripheral, peripheral._char, f).catch(safetyCatch))
667
+ }
668
+ if (!sent.length) return
669
+ await Promise.race([Promise.all(sent), new Promise((r) => setTimeout(r, DRAIN_MS))])
670
+ }
671
+
672
+ /**
673
+ * Pause radio activity but KEEP the Server/Central instances and the
674
+ * registered GATT service alive — the toggle-friendly counterpart to _close.
675
+ * CoreBluetooth managers can't be destroy()ed (native double-free), so one
676
+ * transport is reused across toggles rather than recreated. Idempotent.
677
+ */
678
+ async suspend() {
679
+ this._suspended = true
680
+ if (this._scanTimer) clearTimeout(this._scanTimer)
681
+ this._scanTimer = null
682
+ for (const d of this._devices.values()) if (d.timer) clearTimeout(d.timer)
683
+ // say goodbye BEFORE teardown so the remote reacts in <1s instead of waiting
684
+ // out the 15s keepalive: close frames, a short drain, then an ACL disconnect
685
+ // (an instant OS-level signal on both roles).
686
+ await this._sayGoodbye()
687
+ for (const d of this._devices.values()) {
688
+ if (!d.peripheral) continue
689
+ try {
690
+ this.central.disconnect(d.peripheral)
691
+ } catch (err) {
692
+ safetyCatch(err)
693
+ }
694
+ }
695
+ this._devices.clear()
696
+ try {
697
+ this._stopScan()
698
+ } catch (err) {
699
+ safetyCatch(err)
700
+ }
701
+ try {
702
+ this.server?.stopAdvertising()
703
+ } catch (err) {
704
+ safetyCatch(err)
705
+ }
706
+ this._advertising = false
707
+ for (const conn of this.peers.values()) {
708
+ try {
709
+ conn.destroy()
710
+ } catch (err) {
711
+ safetyCatch(err)
712
+ }
713
+ }
714
+ this.peers.clear()
715
+ for (const { stream } of this._sessions.values()) {
716
+ try {
717
+ stream.destroy()
718
+ } catch (err) {
719
+ safetyCatch(err)
720
+ }
721
+ }
722
+ this._sessions.clear()
723
+ for (const item of this._notifyQueue) item.reject(new Error('suspended'))
724
+ this._notifyQueue = []
725
+ this.state = 'off'
726
+ this.emit('update')
727
+ }
728
+
729
+ /**
730
+ * Restart advertising + scanning on the SAME Server/Central. `_serviceAdded`
731
+ * is still true (the service was never removed) so advertising resumes
732
+ * immediately. Safe to call repeatedly; no-op once closing/closed.
733
+ */
734
+ resume() {
735
+ this._suspended = false
736
+ if (this.closing || this.closed) return
737
+ this._advertising = false
738
+ this._maybeAdvertise()
739
+ this._startScan()
740
+ const raw = this.central?.state ?? this.server?.state
741
+ this.state = STATE[raw] ?? 'waiting'
742
+ this.emit('update')
743
+ }
744
+
745
+ async _close() {
746
+ if (this._scanTimer) clearTimeout(this._scanTimer)
747
+ this._scanTimer = null
748
+ // never call central/server.destroy() — it double-frees in native teardown;
749
+ // stop advertising/scanning and let the runtime reclaim.
750
+ for (const d of this._devices.values()) if (d.timer) clearTimeout(d.timer)
751
+ this._devices.clear()
752
+ try {
753
+ this.central?.stopScan()
754
+ } catch (err) {
755
+ safetyCatch(err)
756
+ }
757
+ try {
758
+ this.server?.stopAdvertising()
759
+ } catch (err) {
760
+ safetyCatch(err)
761
+ }
762
+ if (!this.keepLinks) {
763
+ for (const conn of this.peers.values()) {
764
+ try {
765
+ conn.destroy()
766
+ } catch (err) {
767
+ safetyCatch(err)
768
+ }
769
+ }
770
+ // kept links keep carrying frames through their sessions/notify queue; only
771
+ // tear this down when we're actually dropping the links
772
+ for (const item of this._notifyQueue) item.reject(new Error('closed'))
773
+ this._notifyQueue = []
774
+ this._sessions.clear()
775
+ }
776
+ this.peers.clear()
777
+ this.state = 'off'
778
+ }
779
+ }