@cero-base/core 1.12.0 → 1.14.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.
@@ -1,1109 +0,0 @@
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
- import { L2CAPStream, readIdPreamble } from './l2cap.js'
8
-
9
- // One data characteristic (write + notify) carries the framed control traffic
10
- // both ways — and, on the gatt pipe, the data too.
11
- const DATA_UUID = 'ce1a0004-0000-1000-8000-00805f9b34fb'
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
-
21
- // Wire frame both directions: [type:1][sessionId:8][payload].
22
- const TYPE_OPEN = 1
23
- const TYPE_DATA = 2
24
- const TYPE_CLOSE = 3
25
- const TYPE_HELLO = 4
26
- const SID_LEN = 8
27
- const HEADER = 1 + SID_LEN
28
-
29
- const DEFAULT_MAX_OUTBOUND = 4
30
- const DEFAULT_MAX_INBOUND = 8
31
- const CONNECT_TIMEOUT = 15000
32
- // per-peer dial backoff: eager while unlinked, patient once linked; the cooldown
33
- // grows exponentially per consecutive failure.
34
- const DIAL_COOLDOWN_BASE = 8000
35
- const DIAL_COOLDOWN_BASE_LONELY = 2000
36
- const DIAL_COOLDOWN_MAX = 30000
37
- // one radio can't usefully dial faster than this
38
- const DIAL_MIN_INTERVAL = 500
39
- // iOS reports a peripheral once per scan session; restart to re-report a
40
- // re-advertised peer.
41
- const SCAN_RESTART_LONELY = 5000
42
- // linked: continuous scanning is the dominant battery cost, so duty-cycle it.
43
- const SCAN_DUTY_ON = 5000
44
- const SCAN_DUTY_OFF = 25000
45
- // suspend() drain window: let goodbye frames flush before hanging up.
46
- const DRAIN_MS = 300
47
-
48
- const EMPTY = b4a.alloc(0)
49
-
50
- /**
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.
54
- *
55
- * @param {number} type
56
- * @param {string} id Session id (hex).
57
- * @param {Uint8Array} [payload]
58
- * @returns {Buffer}
59
- */
60
- function frame(type, id, payload = EMPTY) {
61
- const out = b4a.allocUnsafe(HEADER + payload.byteLength)
62
- out[0] = type
63
- b4a.write(out, id, 1, SID_LEN, 'hex')
64
- if (payload.byteLength) b4a.copy(payload, out, HEADER)
65
- return out
66
- }
67
-
68
- /**
69
- * Parse a wire frame. Short/empty buffers → null (caller drops).
70
- *
71
- * @param {Uint8Array} buf
72
- * @returns {{ type: number, id: string, payload: Uint8Array } | null}
73
- */
74
- function parseFrame(buf) {
75
- if (!buf || buf.byteLength < HEADER) return null
76
- return {
77
- type: buf[0],
78
- id: b4a.toString(buf.subarray(1, HEADER), 'hex'),
79
- payload: buf.subarray(HEADER)
80
- }
81
- }
82
-
83
- /**
84
- * Derive a stable 128-bit BLE service UUID from a topic. Only devices that
85
- * compute the same UUID (same channel / same invite) ever discover each other.
86
- *
87
- * @param {Uint8Array} topic
88
- * @param {string} [tag] Namespace so channel and invite meshes never collide.
89
- * @returns {string}
90
- */
91
- export function toServiceUUID(topic, tag = 'cero-ble') {
92
- const h = hash([b4a.from(tag), topic])
93
- const hex = b4a.toString(h.subarray(0, 16), 'hex')
94
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
95
- }
96
-
97
- // Backend adapter state → the app-facing state the facade exposes.
98
- const STATE = {
99
- poweredOn: 'on',
100
- poweredOff: 'waiting',
101
- unauthorized: 'unauthorized',
102
- unsupported: 'unsupported',
103
- resetting: 'waiting',
104
- unknown: 'waiting'
105
- }
106
-
107
- const uuidEq = (a, b) =>
108
- String(a || '')
109
- .toLowerCase()
110
- .replace(/-/g, '') ===
111
- String(b || '')
112
- .toLowerCase()
113
- .replace(/-/g, '')
114
-
115
- const findByUUID = (items, uuid) => (items || []).find((i) => uuidEq(i.uuid, uuid)) || null
116
-
117
- /**
118
- * Dual-role BLE transport: advertises + scans one service UUID, opens a GATT
119
- * byte-stream to each discovered peer, and feeds it into `network.inject`. From
120
- * there replication and pairing are transport-agnostic (see Network.inject).
121
- *
122
- * The server adds one data characteristic (write + notify) and advertises. The
123
- * central connects, discovers the characteristic, subscribes, then framed bytes
124
- * flow both ways — central→server as GATT writes, server→central as
125
- * notifications — each tagged with an 8-byte session id. `backend` is
126
- * bare-bluetooth in production and a mock in tests.
127
- *
128
- * @extends ReadyResource
129
- */
130
- export class BLETransport extends ReadyResource {
131
- /**
132
- * @param {object} opts
133
- * @param {any} opts.backend bare-bluetooth-shaped module (Central, Server, Service, Characteristic).
134
- * @param {import('../index.js').Network} opts.network
135
- * @param {Uint8Array} opts.uuid The 32-byte topic the service UUID derives from.
136
- * @param {Uint8Array} opts.nodeId Stable local id (identity/device key) for the initiate tie-break.
137
- * @param {string} [opts.tag] UUID namespace (channel mesh vs invite mesh).
138
- * @param {number} [opts.maxOutbound] Max concurrent outbound dials/links; gossip covers the rest.
139
- * @param {number} [opts.maxInbound] Max concurrent inbound sessions; newcomers past this are refused.
140
- * @param {{ scanMode?: any }} [opts.scanOptions] Platform scan options (e.g. Android low-power).
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).
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.
145
- */
146
- constructor({
147
- backend,
148
- network,
149
- uuid,
150
- nodeId,
151
- tag = 'cero-ble',
152
- maxOutbound = DEFAULT_MAX_OUTBOUND,
153
- maxInbound = DEFAULT_MAX_INBOUND,
154
- scanOptions,
155
- keepLinks = false,
156
- name,
157
- pipe = 'l2cap',
158
- l2cap = {}
159
- }) {
160
- super()
161
- this.backend = backend
162
- this.network = network
163
- this.name = name || ''
164
- this.nodeId = nodeId
165
- this.nodeHex = b4a.toString(nodeId, 'hex')
166
- this.serviceUUID = toServiceUUID(uuid, tag)
167
- this.maxOutbound = maxOutbound
168
- this.maxInbound = maxInbound
169
- this.scanOptions = scanOptions
170
- this.keepLinks = keepLinks
171
- this.pipe = pipe
172
- this._l2capTimeout = l2cap.timeout ?? L2CAP_OPEN_TIMEOUT
173
-
174
- this.state = 'off'
175
- this.central = null
176
- this.server = null
177
- this._dataChar = null
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 */
181
- this._sessions = new Map()
182
- /** serialized server notify queue: { frame, resolve, reject } */
183
- this._notifyQueue = []
184
- this._scanning = false
185
- this._advertising = false
186
- this._serviceAdded = false
187
- /** peripheral id → per-peer dial state { timer, linked, coolUntil, failures, peerKey, peripheral } */
188
- this._devices = new Map()
189
- /** rate-limited discoveries held for the next dial window */
190
- this._candidates = new Map()
191
- this._dialTimer = null
192
- /** last central.connect timestamp — global inter-dial rate limit */
193
- this._lastDial = 0
194
- this._scanTimer = null
195
- this._cyclePending = false
196
- this._suspended = false
197
- /** live injected links keyed by remote node id hex */
198
- this.peers = new Map()
199
- }
200
-
201
- /**
202
- * Whether we should be the one to open the connection to `peerNodeId`. The
203
- * lexicographically smaller id initiates; the larger waits — so a pair
204
- * connects once, not twice. Equal (our own reflection) → false.
205
- *
206
- * @param {Uint8Array} peerNodeId
207
- * @returns {boolean}
208
- */
209
- shouldInitiate(peerNodeId) {
210
- return b4a.compare(this.nodeId, peerNodeId) < 0
211
- }
212
-
213
- get linkCount() {
214
- return this.peers.size
215
- }
216
-
217
- _device(id) {
218
- let d = this._devices.get(id)
219
- if (!d) {
220
- d = { timer: null, linked: false, coolUntil: 0, failures: 0, peerKey: null, peripheral: null }
221
- this._devices.set(id, d)
222
- }
223
- return d
224
- }
225
-
226
- _prune(id) {
227
- const d = this._devices.get(id)
228
- if (d && !d.timer && !d.linked && !d.coolUntil && !d.failures && !d.peerKey && !d.peripheral) {
229
- this._devices.delete(id)
230
- }
231
- }
232
-
233
- async _open() {
234
- const { Central, Server, Service, Characteristic } = this.backend
235
-
236
- this.server = new Server()
237
- this.server.on('stateChange', (s) => {
238
- this._onState(s)
239
- if (s === 'poweredOn') this._startServer(Service, Characteristic)
240
- else if (s === 'poweredOff' || s === 'resetting') this._onRadioDown()
241
- })
242
- this.server.on('serviceAdd', () => {
243
- this._serviceAdded = true
244
- this._maybeAdvertise()
245
- })
246
- this.server.on('writeRequest', (reqs) => this._onWriteRequests(reqs))
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))
252
- // writeRequests carry no central identifier, so an unsubscribe can't be
253
- // mapped to a session; teardown is left to _onChannel's keepalive/timeout.
254
- this.server.on('unsubscribe', () => {})
255
- this.server.on('error', safetyCatch)
256
-
257
- this.central = new Central()
258
- this.central.on('stateChange', (s) => {
259
- this._onState(s)
260
- if (s === 'poweredOn') this._startScan()
261
- else if (s === 'poweredOff' || s === 'resetting') this._onRadioDown()
262
- })
263
- this.central.on('discover', (peripheral) => this._onDiscover(peripheral))
264
- this.central.on('connect', (peripheral) => this._onConnect(peripheral))
265
- this.central.on('disconnect', () => {})
266
- this.central.on('error', (err) => this._onCentralError(err))
267
-
268
- this._startServer(Service, Characteristic)
269
- this._startScan()
270
- this.state = 'starting'
271
- }
272
-
273
- _startServer(Service, Characteristic) {
274
- if (this.server.state !== 'poweredOn') return
275
- if (!this._serviceAdded) {
276
- this._dataChar = new Characteristic(DATA_UUID, { write: true, notify: true })
277
- this.server.addService(new Service(this.serviceUUID, [this._dataChar]))
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()
320
- }
321
-
322
- _maybeAdvertise() {
323
- if (this._advertising || !this._serviceAdded) return
324
- this._advertising = true
325
- this.server.startAdvertising({ serviceUUIDs: [this.serviceUUID] })
326
- }
327
-
328
- // ─── server (peripheral) side ─────────────────────────────────────────────
329
-
330
- _onWriteRequests(requests) {
331
- const ok = this.server.constructor.ATT_SUCCESS ?? 0
332
- for (const req of requests) {
333
- // must respond within ms or the central times out — before any parsing
334
- if (req.responseNeeded !== false) this.server.respondToRequest(req, ok)
335
- if (this._suspended) continue
336
- this._onServerFrame(req.data)
337
- }
338
- }
339
-
340
- _onServerFrame(data) {
341
- const f = parseFrame(data)
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)
345
- if (f.type === TYPE_OPEN) {
346
- if (s) return
347
- if (this._sessions.size >= this.maxInbound) {
348
- // established links win: refuse newcomers with a CLOSE so the dialer's
349
- // stream ends cleanly and backs off — the mesh converges transitively.
350
- this._notifyClose(f.id)
351
- return
352
- }
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)
369
- } else if (f.type === TYPE_DATA) {
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))
378
- } else if (f.type === TYPE_HELLO) {
379
- if (s) this._applyPeerName(s, f.payload)
380
- } else if (f.type === TYPE_CLOSE) {
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)
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()
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)
444
- }
445
-
446
- _notifyClose(id) {
447
- this._enqueueNotify(frame(TYPE_CLOSE, id)).catch(safetyCatch)
448
- }
449
-
450
- // ─── peer display name (hello frame) ──────────────────────────────────────
451
-
452
- _helloPayload() {
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))
457
- }
458
-
459
- /**
460
- * @param {Uint8Array} payload
461
- * @returns {{ name: string, psm: number | null } | null}
462
- */
463
- _parseHello(payload) {
464
- try {
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
- }
470
- } catch {
471
- return null
472
- }
473
- }
474
-
475
- _applyPeerName(session, payload) {
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
480
- this.emit('update')
481
- }
482
-
483
- // Serialize notifications through the single characteristic: updateValue
484
- // returns false when the peripheral's queue is full — hold the head frame and
485
- // retry on the next 'readyToUpdate', preserving order.
486
- _enqueueNotify(f) {
487
- return new Promise((resolve, reject) => {
488
- this._notifyQueue.push({ frame: f, resolve, reject })
489
- this._drainNotify()
490
- })
491
- }
492
-
493
- _drainNotify() {
494
- while (this._notifyQueue.length) {
495
- const item = this._notifyQueue[0]
496
- let ok
497
- try {
498
- ok = this.server.updateValue(this._dataChar, item.frame)
499
- } catch (err) {
500
- this._notifyQueue.shift()
501
- item.reject(err)
502
- continue
503
- }
504
- if (!ok) return
505
- this._notifyQueue.shift()
506
- item.resolve()
507
- }
508
- }
509
-
510
- _startScan() {
511
- if (this._scanning || this.central.state !== 'poweredOn') return
512
- this._scanning = true
513
- this.central.startScan([this.serviceUUID], this.scanOptions)
514
- this._armScanRestart()
515
- }
516
-
517
- // Lonely: cycle the scan every SCAN_RESTART_LONELY so a re-advertised peer is
518
- // re-reported. Linked: duty-cycle SCAN_DUTY_ON on / SCAN_DUTY_OFF dark to save
519
- // battery. One timer drives the whole cycle; the next delay derives from phase.
520
- _armScanRestart() {
521
- if (this._scanTimer) clearTimeout(this._scanTimer)
522
- const delay =
523
- this.linkCount > 0 ? (this._scanning ? SCAN_DUTY_ON : SCAN_DUTY_OFF) : SCAN_RESTART_LONELY
524
- this._scanTimer = setTimeout(() => {
525
- this._scanTimer = null
526
- if (this.closing || this.closed || this._suspended) return
527
- // never toggle the scan mid-dial (would kill the connect) — defer a phase
528
- if (this._isDialing()) {
529
- this._armScanRestart()
530
- return
531
- }
532
- if (this.linkCount > 0) {
533
- if (this._scanning) {
534
- this._stopScan()
535
- this._armScanRestart()
536
- } else {
537
- this._startScan()
538
- }
539
- } else {
540
- this._stopScan()
541
- this._startScan()
542
- }
543
- }, delay)
544
- if (this._scanTimer.unref) this._scanTimer.unref()
545
- }
546
-
547
- _stopScan() {
548
- if (!this._scanning) return
549
- this._scanning = false
550
- try {
551
- this.central.stopScan()
552
- } catch (err) {
553
- safetyCatch(err)
554
- }
555
- }
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
-
594
- _onState(raw) {
595
- const next = STATE[raw] ?? 'waiting'
596
- if (next === this.state) return
597
- this.state = next
598
- this.emit('update')
599
- }
600
-
601
- _onDiscover(peripheral) {
602
- if (this.closing || this.closed || this._suspended) return
603
- const d = this._devices.get(peripheral.id)
604
- if (d) {
605
- if (d.linked) return
606
- if (d.peerKey && this.peers.has(d.peerKey)) return // linked via another channel
607
- if (d.coolUntil > Date.now()) return // failed recently — back off
608
- if (d.timer) return // already connecting to this one
609
- }
610
- if (this.linkCount >= this.maxOutbound) return // gossip covers the rest
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
- }
625
- // dial every discovery and open a session; a redundant link is dropped by
626
- // _track's dedup
627
- this._lastDial = Date.now()
628
- const timer = setTimeout(() => this._abortDial(peripheral, 'timeout'), CONNECT_TIMEOUT)
629
- this._device(peripheral.id).timer = timer
630
- try {
631
- this._stopScan()
632
- this.central.connect(peripheral)
633
- } catch (err) {
634
- this._abortDial(peripheral, err)
635
- }
636
- }
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
-
652
- _onConnect(peripheral) {
653
- this._device(peripheral.id).peripheral = peripheral
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
- })
661
- peripheral.once('servicesDiscover', (services) => {
662
- const svc = findByUUID(services, this.serviceUUID)
663
- if (svc) peripheral.discoverCharacteristics(svc, [DATA_UUID])
664
- else this._abortDial(peripheral, 'no-service')
665
- })
666
- peripheral.once('characteristicsDiscover', (_svc, chars) => {
667
- const dataChar = findByUUID(chars, DATA_UUID)
668
- if (dataChar) peripheral.subscribe(dataChar)
669
- else this._abortDial(peripheral, 'no-data-char')
670
- })
671
- peripheral.once('notifyState', (char, isNotifying) => {
672
- if (!isNotifying) {
673
- this._abortDial(peripheral, 'subscribe-failed')
674
- return
675
- }
676
- this._startCentralSession(peripheral, char)
677
- })
678
- // both sides open a session: on iOS the peer id isn't known until after
679
- // connect, so a tie-break yields only post-handshake and deadlocks if the
680
- // other side never dials back. _track keeps the first, drops the dup.
681
- peripheral.discoverServices([this.serviceUUID])
682
- }
683
-
684
- // ─── central side ─────────────────────────────────────────────────────────
685
-
686
- _startCentralSession(peripheral, char) {
687
- const id = b4a.toString(randomBytes(SID_LEN), 'hex')
688
- peripheral._session = { id, upgrading: false }
689
- peripheral._char = char // suspend's goodbye writes reuse it
690
- peripheral.on('notify', (_char, data) => this._onCentralNotify(peripheral, data))
691
- // open frame first: it registers the session on the server before any data
692
- this._centralSend(peripheral, char, frame(TYPE_OPEN, id)).catch(safetyCatch)
693
- this._centralSend(peripheral, char, frame(TYPE_HELLO, id, this._helloPayload())).catch(
694
- safetyCatch
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
717
- this._clearDial(peripheral.id)
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
- })
796
- }
797
-
798
- _onCentralNotify(peripheral, data) {
799
- const sess = peripheral._session
800
- if (!sess) return
801
- const f = parseFrame(data)
802
- if (!f) return
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
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
- }
820
- } else if (f.type === TYPE_CLOSE) {
821
- peripheral._session = null
822
- if (peripheral._stream) peripheral._stream.remoteEnd()
823
- else this._abortDial(peripheral, 'refused')
824
- }
825
- }
826
-
827
- // One write in flight per peripheral: chain each write behind the previous,
828
- // resolving on the 'write' completion event before the next is issued.
829
- _centralSend(peripheral, char, f) {
830
- const prev = peripheral._writeChain || Promise.resolve()
831
- const next = prev.then(() => this._writeOnce(peripheral, char, f))
832
- peripheral._writeChain = next.catch(safetyCatch) // keep the chain alive
833
- return next
834
- }
835
-
836
- _writeOnce(peripheral, char, f) {
837
- return new Promise((resolve, reject) => {
838
- const cleanup = () => {
839
- peripheral.removeListener('write', onWrite)
840
- peripheral.removeListener('error', onErr)
841
- }
842
- const onWrite = () => {
843
- cleanup()
844
- resolve()
845
- }
846
- const onErr = (err) => {
847
- cleanup()
848
- reject(err)
849
- }
850
- peripheral.once('write', onWrite)
851
- peripheral.once('error', onErr)
852
- try {
853
- peripheral.write(char, f, true)
854
- } catch (err) {
855
- cleanup()
856
- reject(err)
857
- }
858
- })
859
- }
860
-
861
- _abortDial(peripheral, _reason) {
862
- const id = peripheral?.id
863
- if (id != null) {
864
- const d = this._device(id)
865
- d.failures += 1
866
- const base = this.linkCount === 0 ? DIAL_COOLDOWN_BASE_LONELY : DIAL_COOLDOWN_BASE
867
- d.coolUntil =
868
- Date.now() + Math.min(DIAL_COOLDOWN_MAX, base * 2 ** Math.min(4, d.failures - 1))
869
- d.peripheral = null
870
- }
871
- this._clearDial(id)
872
- try {
873
- this.central.disconnect(peripheral)
874
- } catch (err) {
875
- safetyCatch(err)
876
- }
877
- this._startScan()
878
- }
879
-
880
- _clearDial(id) {
881
- if (id == null) return
882
- const d = this._devices.get(id)
883
- if (!d) return
884
- if (d.timer) clearTimeout(d.timer)
885
- d.timer = null
886
- this._prune(id)
887
- }
888
-
889
- _isDialing() {
890
- for (const d of this._devices.values()) if (d.timer) return true
891
- return false
892
- }
893
-
894
- // iOS & Android report errored connects as 'error' with a code, not
895
- // 'disconnect' — without this a failed dial pins the peripheral forever.
896
- _onCentralError(err) {
897
- safetyCatch(err)
898
- const code = err && err.code
899
- if (code === 'CONNECTION_FAILED' || code === 'DISCONNECT') {
900
- for (const [id, d] of this._devices) if (d.timer) this._clearDial(id)
901
- }
902
- }
903
-
904
- _onChannel(stream, isInitiator, peripheralId) {
905
- if (this.closing || this.closed || this._suspended) {
906
- stream.destroy()
907
- return
908
- }
909
- const conn = this.network.inject(stream, { isInitiator })
910
- // iOS never signals a disconnect for a vanished peer: keepalive pings and the
911
- // timeout are the liveness detector (keepalive refreshes the timeout).
912
- conn.setKeepAlive(5000)
913
- conn.setTimeout(15000)
914
- stream.on('error', safetyCatch)
915
- // marked at channel-open (not handshake-open): rediscovery must not dial a
916
- // peripheral whose channel is still handshaking
917
- if (peripheralId != null) {
918
- const d = this._device(peripheralId)
919
- d.linked = true
920
- d.coolUntil = 0
921
- d.failures = 0 // reached a live session — reset backoff
922
- conn.once('close', () => {
923
- const rec = this._devices.get(peripheralId)
924
- if (rec) {
925
- rec.linked = false
926
- this._prune(peripheralId)
927
- }
928
- })
929
- }
930
- conn.on('open', () => this._track(conn, peripheralId, isInitiator))
931
- conn.on('close', () => this._untrack(conn))
932
- this._startScan()
933
- return conn
934
- }
935
-
936
- _track(conn, peripheralId, isInitiator) {
937
- if (peripheralId != null && conn.remotePublicKey) {
938
- this._device(peripheralId).peerKey = b4a.toString(conn.remotePublicKey, 'hex')
939
- }
940
- if (this.closing || this.closed) return
941
- const key = b4a.toString(conn.remotePublicKey, 'hex')
942
- const existing = this.peers.get(key)
943
- if (existing && existing !== conn) {
944
- // Both sides dial, so a pair links twice. Dropping a channel closes it for
945
- // BOTH devices, so both must retire the SAME channel. Deterministic winner:
946
- // keep the channel whose initiator has the smaller static key — computed
947
- // identically on both ends, so the loser drops on both and never cascades.
948
- const initiatorIsUsSmaller = b4a.compare(conn.publicKey, conn.remotePublicKey) < 0
949
- const preferred = isInitiator === initiatorIsUsSmaller
950
- if (!preferred) {
951
- conn.destroy()
952
- return
953
- }
954
- }
955
- conn._peripheralId = peripheralId
956
- conn._peerKey = key
957
- this.peers.set(key, conn)
958
- if (existing && existing !== conn) existing.destroy() // retire the loser channel
959
- this.emit('update')
960
- }
961
-
962
- _untrack(conn) {
963
- const key = conn._peerKey
964
- if (key && this.peers.get(key) === conn) this.peers.delete(key)
965
- if (!this.closing && !this.closed) {
966
- // last link gone → hunt immediately instead of waiting out a dark window
967
- if (this.linkCount === 0 && !this._suspended) this._startScan()
968
- this.emit('update')
969
- }
970
- }
971
-
972
- // Best-effort TYPE_CLOSE to every live session — server sessions over notify,
973
- // central sessions over write — then wait up to DRAIN_MS for the frames to
974
- // flush. Resolves regardless: suspend must never hang on a wedged radio.
975
- async _sayGoodbye() {
976
- const sent = []
977
- for (const id of this._sessions.keys()) {
978
- sent.push(this._enqueueNotify(frame(TYPE_CLOSE, id)).catch(safetyCatch))
979
- }
980
- for (const d of this._devices.values()) {
981
- const peripheral = d.peripheral
982
- const sess = peripheral && peripheral._session
983
- if (!sess || !peripheral._char) continue
984
- const f = frame(TYPE_CLOSE, sess.id)
985
- sent.push(this._centralSend(peripheral, peripheral._char, f).catch(safetyCatch))
986
- }
987
- if (!sent.length) return
988
- await Promise.race([Promise.all(sent), new Promise((r) => setTimeout(r, DRAIN_MS))])
989
- }
990
-
991
- /**
992
- * Pause radio activity but KEEP the Server/Central instances and the
993
- * registered GATT service alive — the toggle-friendly counterpart to _close.
994
- * CoreBluetooth managers can't be destroy()ed (native double-free), so one
995
- * transport is reused across toggles rather than recreated. Idempotent.
996
- */
997
- async suspend() {
998
- this._suspended = true
999
- if (this._scanTimer) clearTimeout(this._scanTimer)
1000
- this._scanTimer = null
1001
- this._cyclePending = false // suspend tears the sessions down; resume republishes fresh
1002
- for (const d of this._devices.values()) if (d.timer) clearTimeout(d.timer)
1003
- this._clearCandidates()
1004
- // say goodbye BEFORE teardown so the remote reacts in <1s instead of waiting
1005
- // out the 15s keepalive: close frames, a short drain, then an ACL disconnect
1006
- // (an instant OS-level signal on both roles).
1007
- await this._sayGoodbye()
1008
- for (const d of this._devices.values()) {
1009
- if (!d.peripheral) continue
1010
- try {
1011
- this.central.disconnect(d.peripheral)
1012
- } catch (err) {
1013
- safetyCatch(err)
1014
- }
1015
- }
1016
- this._devices.clear()
1017
- try {
1018
- this._stopScan()
1019
- } catch (err) {
1020
- safetyCatch(err)
1021
- }
1022
- try {
1023
- this.server?.stopAdvertising()
1024
- } catch (err) {
1025
- safetyCatch(err)
1026
- }
1027
- this._advertising = false
1028
- for (const conn of this.peers.values()) {
1029
- try {
1030
- conn.destroy()
1031
- } catch (err) {
1032
- safetyCatch(err)
1033
- }
1034
- }
1035
- this.peers.clear()
1036
- for (const { stream, pipeTimer } of this._sessions.values()) {
1037
- if (pipeTimer) clearTimeout(pipeTimer)
1038
- try {
1039
- if (stream) stream.destroy()
1040
- } catch (err) {
1041
- safetyCatch(err)
1042
- }
1043
- }
1044
- this._sessions.clear()
1045
- for (const item of this._notifyQueue) item.reject(new Error('suspended'))
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()
1050
- this.state = 'off'
1051
- this.emit('update')
1052
- }
1053
-
1054
- /**
1055
- * Restart advertising + scanning on the SAME Server/Central. `_serviceAdded`
1056
- * is still true (the service was never removed) so advertising resumes
1057
- * immediately. Safe to call repeatedly; no-op once closing/closed.
1058
- */
1059
- resume() {
1060
- this._suspended = false
1061
- if (this.closing || this.closed) return
1062
- this._advertising = false
1063
- if (this.pipe === 'l2cap') this._publishListener()
1064
- this._maybeAdvertise()
1065
- this._startScan()
1066
- const raw = this.central?.state ?? this.server?.state
1067
- this.state = STATE[raw] ?? 'waiting'
1068
- this.emit('update')
1069
- }
1070
-
1071
- async _close() {
1072
- if (this._scanTimer) clearTimeout(this._scanTimer)
1073
- this._scanTimer = null
1074
- // never call central/server.destroy() — it double-frees in native teardown;
1075
- // stop advertising/scanning and let the runtime reclaim.
1076
- for (const d of this._devices.values()) if (d.timer) clearTimeout(d.timer)
1077
- this._devices.clear()
1078
- this._clearCandidates()
1079
- try {
1080
- this.central?.stopScan()
1081
- } catch (err) {
1082
- safetyCatch(err)
1083
- }
1084
- try {
1085
- this.server?.stopAdvertising()
1086
- } catch (err) {
1087
- safetyCatch(err)
1088
- }
1089
- if (!this.keepLinks) {
1090
- for (const conn of this.peers.values()) {
1091
- try {
1092
- conn.destroy()
1093
- } catch (err) {
1094
- safetyCatch(err)
1095
- }
1096
- }
1097
- // kept links keep carrying frames through their sessions/notify queue; only
1098
- // tear this down when we're actually dropping the links
1099
- for (const item of this._notifyQueue) item.reject(new Error('closed'))
1100
- this._notifyQueue = []
1101
- for (const { pipeTimer } of this._sessions.values()) {
1102
- if (pipeTimer) clearTimeout(pipeTimer)
1103
- }
1104
- this._sessions.clear()
1105
- }
1106
- this.peers.clear()
1107
- this.state = 'off'
1108
- }
1109
- }