@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.
- package/package.json +2 -10
- package/src/database/bootstrap.js +6 -4
- package/src/database/changes.js +29 -55
- package/src/database/encryption.js +35 -46
- package/src/database/index.js +51 -55
- package/src/lib/constants.js +14 -0
- package/src/lib/utils.js +19 -0
- package/src/network/index.js +40 -29
- package/src/pairing/index.js +8 -2
- package/src/rpc/peer.js +4 -1
- package/src/storage/index.js +23 -12
- package/types/database/changes.d.ts +6 -5
- package/types/database/encryption.d.ts +4 -3
- package/types/database/index.d.ts +10 -5
- package/types/lib/constants.d.ts +1 -0
- package/types/lib/utils.d.ts +1 -0
- package/types/network/index.d.ts +3 -5
- package/src/network/transports/ble.js +0 -1109
- package/src/network/transports/dht.js +0 -110
- package/src/network/transports/gatt.js +0 -42
- package/src/network/transports/l2cap.js +0 -90
- package/types/network/transports/ble.d.ts +0 -180
- package/types/network/transports/dht.d.ts +0 -75
- package/types/network/transports/gatt.d.ts +0 -21
- package/types/network/transports/l2cap.d.ts +0 -37
|
@@ -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
|
-
}
|