@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.
- package/package.json +8 -4
- package/src/network/index.js +40 -54
- package/src/network/transports/ble.js +779 -0
- package/src/network/transports/dht.js +110 -0
- package/src/network/transports/gatt.js +57 -0
- package/types/network/index.d.ts +7 -3
- package/types/network/{bluetooth.d.ts → transports/ble.d.ts} +59 -19
- package/types/network/transports/dht.d.ts +75 -0
- package/types/network/transports/gatt.d.ts +25 -0
- package/src/network/bluetooth.js +0 -324
|
@@ -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
|
+
}
|