@magicred-1/react-native-lxmf 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,494 @@
1
+ import Foundation
2
+ import CoreBluetooth
3
+
4
+ /// Dual-role BLE manager for Reticulum mesh networking
5
+ ///
6
+ /// Handles two types of BLE connections:
7
+ /// 1. Phone-to-phone mesh: custom GATT service, HDLC+segmentation via ble_iface.rs
8
+ /// 2. RNode (Heltec V3): Nordic UART Service (NUS), KISS framing via nus_iface.rs
9
+ ///
10
+ /// BLE data flows:
11
+ /// Mesh: peer writes → lxmf_ble_receive → Rust BleInterface (HDLC)
12
+ /// RNode: NUS notify → lxmf_nus_receive → Rust NusInterface (KISS)
13
+ class BLEManager: NSObject {
14
+ // Phone-to-phone mesh UUIDs (must match ble_iface.rs)
15
+ static let meshServiceUUID = CBUUID(string: "5f3bafcd-2bb7-4de0-9c6f-2c5f88b6b8f2")
16
+ static let rxCharUUID = CBUUID(string: "3b28e4f6-5a30-4a5f-b700-68bb74d1b036")
17
+ static let txCharUUID = CBUUID(string: "8b6ded1a-ea65-4a1e-a1f0-5cf69d5dc2ad")
18
+
19
+ // RNode NUS UUIDs (Nordic UART Service — must match nus_iface.rs)
20
+ static let nusServiceUUID = CBUUID(string: "6e400001-b5a3-f393-e0a9-e50e24dcca9e")
21
+ static let nusTxCharUUID = CBUUID(string: "6e400002-b5a3-f393-e0a9-e50e24dcca9e") // write TO RNode
22
+ static let nusRxCharUUID = CBUUID(string: "6e400003-b5a3-f393-e0a9-e50e24dcca9e") // notify FROM RNode
23
+
24
+ // Central (scanner/client)
25
+ private var centralManager: CBCentralManager!
26
+ private var connectedPeripherals: [UUID: CBPeripheral] = [:]
27
+ private var txCharacteristics: [UUID: CBCharacteristic] = [:]
28
+
29
+ // Peripheral (advertiser/server)
30
+ private var peripheralManager: CBPeripheralManager!
31
+ private var rxCharacteristic: CBMutableCharacteristic?
32
+ private var txCharacteristic: CBMutableCharacteristic?
33
+ private var subscribedCentrals: [CBCentral] = []
34
+
35
+ // Peer address mapping — iOS uses 128-bit UUIDs, Rust uses 6-byte addrs.
36
+ // We derive a 6-byte pseudo-MAC from each UUID and maintain reverse mappings
37
+ // so lxmf_ble_poll_tx frames can be routed to the correct peer.
38
+ private var addrToPeripheralUUID: [Data: UUID] = [:]
39
+ private var addrToCentral: [Data: CBCentral] = [:]
40
+
41
+ // RNode NUS connections — separate from mesh peers
42
+ private var nusPeripherals: [UUID: CBPeripheral] = [:]
43
+ private var nusTxChars: [UUID: CBCharacteristic] = [:] // write TO RNode
44
+
45
+ // Persisted set of peripheral UUIDs that have successfully completed
46
+ // characteristic discovery (i.e. OS-level pairing succeeded).
47
+ // Survives app restarts so we only auto-connect to known-good devices.
48
+ private var bondedPeripherals: Set<UUID> = []
49
+
50
+ // RNode peripherals discovered during scan but not yet paired via iOS Settings.
51
+ // Exposed to the UI so it can prompt the user to pair in Settings first.
52
+ private(set) var discoveredUnpairedRNodes: [UUID: CBPeripheral] = [:]
53
+
54
+ private var isRunning = false
55
+
56
+ private static let bondedKey = "lxmf.ble.bondedPeripheralUUIDs"
57
+
58
+ private func loadBondedPeripherals() {
59
+ if let uuids = UserDefaults.standard.array(forKey: Self.bondedKey) as? [String] {
60
+ bondedPeripherals = Set(uuids.compactMap { UUID(uuidString: $0) })
61
+ }
62
+ }
63
+
64
+ private func saveBondedPeripherals() {
65
+ let uuids = bondedPeripherals.map { $0.uuidString }
66
+ UserDefaults.standard.set(uuids, forKey: Self.bondedKey)
67
+ }
68
+
69
+ override init() {
70
+ super.init()
71
+ }
72
+
73
+ func start() {
74
+ guard !isRunning else { return }
75
+ isRunning = true
76
+ loadBondedPeripherals()
77
+ discoveredUnpairedRNodes.removeAll()
78
+
79
+ // Use restoration identifiers for background BLE
80
+ centralManager = CBCentralManager(
81
+ delegate: self,
82
+ queue: DispatchQueue(label: "lxmf.ble.central"),
83
+ options: [CBCentralManagerOptionRestoreIdentifierKey: "lxmf-central"]
84
+ )
85
+
86
+ peripheralManager = CBPeripheralManager(
87
+ delegate: self,
88
+ queue: DispatchQueue(label: "lxmf.ble.peripheral"),
89
+ options: [CBPeripheralManagerOptionRestoreIdentifierKey: "lxmf-peripheral"]
90
+ )
91
+ }
92
+
93
+ func stop() {
94
+ guard isRunning else { return }
95
+ isRunning = false
96
+
97
+ centralManager?.stopScan()
98
+ for (_, peripheral) in connectedPeripherals {
99
+ centralManager?.cancelPeripheralConnection(peripheral)
100
+ }
101
+ for (_, peripheral) in nusPeripherals {
102
+ centralManager?.cancelPeripheralConnection(peripheral)
103
+ }
104
+ connectedPeripherals.removeAll()
105
+ txCharacteristics.removeAll()
106
+ addrToPeripheralUUID.removeAll()
107
+ addrToCentral.removeAll()
108
+ nusPeripherals.removeAll()
109
+ nusTxChars.removeAll()
110
+ discoveredUnpairedRNodes.removeAll()
111
+ // Don't clear bondedPeripherals — they persist across sessions
112
+
113
+ peripheralManager?.stopAdvertising()
114
+ peripheralManager?.removeAllServices()
115
+ subscribedCentrals.removeAll()
116
+ }
117
+
118
+ /// Send data to all connected peers via TX characteristic
119
+ func sendToAll(_ data: Data) {
120
+ // Send via peripheral role (to subscribed centrals)
121
+ if let txChar = txCharacteristic {
122
+ for central in subscribedCentrals {
123
+ peripheralManager?.updateValue(data, for: txChar, onSubscribedCentrals: [central])
124
+ }
125
+ }
126
+
127
+ // Send via central role (write to connected peripherals' RX)
128
+ for (uuid, char) in txCharacteristics {
129
+ if let peripheral = connectedPeripherals[uuid] {
130
+ peripheral.writeValue(data, for: char, type: .withoutResponse)
131
+ }
132
+ }
133
+ }
134
+
135
+ /// Send data to a specific peer by CoreBluetooth UUID
136
+ func sendToPeer(_ peerUUID: UUID, data: Data) {
137
+ if let peripheral = connectedPeripherals[peerUUID],
138
+ let char = txCharacteristics[peerUUID] {
139
+ peripheral.writeValue(data, for: char, type: .withoutResponse)
140
+ }
141
+ }
142
+
143
+ /// Send data to a specific peer by 6-byte pseudo-MAC address.
144
+ /// Used by drainOutgoing() to route frames from lxmf_ble_poll_tx.
145
+ func sendToPeerAddr(_ addr: Data, data: Data) {
146
+ // Try peripheral role: send notification to a subscribed central
147
+ if let central = addrToCentral[addr], let txChar = txCharacteristic {
148
+ peripheralManager?.updateValue(data, for: txChar, onSubscribedCentrals: [central])
149
+ return
150
+ }
151
+
152
+ // Try central role: write to connected peripheral's RX characteristic
153
+ if let peripheralUUID = addrToPeripheralUUID[addr],
154
+ let peripheral = connectedPeripherals[peripheralUUID],
155
+ let char = txCharacteristics[peripheralUUID] {
156
+ peripheral.writeValue(data, for: char, type: .withoutResponse)
157
+ }
158
+ }
159
+
160
+ /// Write KISS-framed data to all connected RNodes via NUS TX characteristic.
161
+ /// Called by drainNusOutgoing() in LxmfModule.
162
+ func sendToNus(_ data: Data) {
163
+ for (uuid, char) in nusTxChars {
164
+ if let peripheral = nusPeripherals[uuid] {
165
+ // Chunk data to fit NUS MTU. CoreBluetooth negotiates MTU
166
+ // automatically; maximumWriteValueLength gives the usable size.
167
+ let mtu = peripheral.maximumWriteValueLength(for: .withoutResponse)
168
+ if data.count <= mtu {
169
+ peripheral.writeValue(data, for: char, type: .withoutResponse)
170
+ } else {
171
+ // Chunk into MTU-sized writes
172
+ var offset = 0
173
+ while offset < data.count {
174
+ let end = min(offset + mtu, data.count)
175
+ let chunk = data[offset..<end]
176
+ peripheral.writeValue(chunk, for: char, type: .withoutResponse)
177
+ offset = end
178
+ }
179
+ }
180
+ }
181
+ }
182
+ }
183
+
184
+ /// Check if any RNode (NUS) peripherals are connected.
185
+ var hasNusConnection: Bool {
186
+ return !nusPeripherals.isEmpty
187
+ }
188
+
189
+ /// Derive a 6-byte pseudo-MAC from a CoreBluetooth UUID.
190
+ /// XOR-folds the 16-byte UUID into 6 bytes for stable peer identification.
191
+ static func uuidToAddr(_ uuid: UUID) -> Data {
192
+ let u = uuid.uuid
193
+ let bytes: [UInt8] = [u.0, u.1, u.2, u.3, u.4, u.5, u.6, u.7,
194
+ u.8, u.9, u.10, u.11, u.12, u.13, u.14, u.15]
195
+ return Data([
196
+ bytes[0] ^ bytes[6] ^ bytes[12],
197
+ bytes[1] ^ bytes[7] ^ bytes[13],
198
+ bytes[2] ^ bytes[8] ^ bytes[14],
199
+ bytes[3] ^ bytes[9] ^ bytes[15],
200
+ bytes[4] ^ bytes[10],
201
+ bytes[5] ^ bytes[11],
202
+ ])
203
+ }
204
+
205
+ // MARK: - Peripheral Setup
206
+
207
+ private func setupPeripheral() {
208
+ let rxChar = CBMutableCharacteristic(
209
+ type: BLEManager.rxCharUUID,
210
+ properties: [.write, .writeWithoutResponse],
211
+ value: nil,
212
+ permissions: [.writeable]
213
+ )
214
+
215
+ let txChar = CBMutableCharacteristic(
216
+ type: BLEManager.txCharUUID,
217
+ properties: [.notify, .read],
218
+ value: nil,
219
+ permissions: [.readable]
220
+ )
221
+
222
+ let service = CBMutableService(type: BLEManager.meshServiceUUID, primary: true)
223
+ service.characteristics = [rxChar, txChar]
224
+
225
+ self.rxCharacteristic = rxChar
226
+ self.txCharacteristic = txChar
227
+
228
+ peripheralManager.add(service)
229
+ }
230
+
231
+ private func startAdvertising() {
232
+ peripheralManager.startAdvertising([
233
+ CBAdvertisementDataServiceUUIDsKey: [BLEManager.meshServiceUUID],
234
+ CBAdvertisementDataLocalNameKey: "lxmf-mesh"
235
+ ])
236
+ }
237
+
238
+ // MARK: - Central Setup
239
+
240
+ private func startScanning() {
241
+ centralManager.scanForPeripherals(
242
+ withServices: [BLEManager.meshServiceUUID, BLEManager.nusServiceUUID],
243
+ options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
244
+ )
245
+ }
246
+ }
247
+
248
+ // MARK: - CBCentralManagerDelegate
249
+
250
+ extension BLEManager: CBCentralManagerDelegate {
251
+ func centralManagerDidUpdateState(_ central: CBCentralManager) {
252
+ if central.state == .poweredOn && isRunning {
253
+ startScanning()
254
+ }
255
+ }
256
+
257
+ func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
258
+ advertisementData: [String: Any], rssi RSSI: NSNumber) {
259
+ guard connectedPeripherals[peripheral.identifier] == nil else { return }
260
+
261
+ // Check if this is an RNode (NUS service) that we haven't paired with yet.
262
+ // If so, don't auto-connect — the user needs to pair in iOS Settings first.
263
+ let advertisedServices = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? []
264
+ let isNus = advertisedServices.contains(BLEManager.nusServiceUUID)
265
+
266
+ if isNus && !bondedPeripherals.contains(peripheral.identifier) {
267
+ // Track as discovered-but-unpaired so UI can prompt user
268
+ discoveredUnpairedRNodes[peripheral.identifier] = peripheral
269
+ return
270
+ }
271
+
272
+ // Bonded RNode or mesh peer — connect normally
273
+ connectedPeripherals[peripheral.identifier] = peripheral
274
+ peripheral.delegate = self
275
+ central.connect(peripheral, options: nil)
276
+ }
277
+
278
+ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
279
+ // Don't register with Rust yet — wait until service discovery tells us
280
+ // whether this is a mesh peer (→ lxmf_ble_connected) or RNode (→ NUS path).
281
+ connectedPeripherals[peripheral.identifier] = peripheral
282
+ peripheral.discoverServices([BLEManager.meshServiceUUID, BLEManager.nusServiceUUID])
283
+ }
284
+
285
+ func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
286
+ connectedPeripherals.removeValue(forKey: peripheral.identifier)
287
+ // Retry after short delay for bonded devices
288
+ if isRunning && bondedPeripherals.contains(peripheral.identifier) {
289
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
290
+ guard let self = self, self.isRunning else { return }
291
+ central.connect(peripheral, options: nil)
292
+ }
293
+ }
294
+ }
295
+
296
+ func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
297
+ let isNus = nusPeripherals[peripheral.identifier] != nil
298
+
299
+ // Always remove from connectedPeripherals (used by both mesh and NUS)
300
+ connectedPeripherals.removeValue(forKey: peripheral.identifier)
301
+
302
+ if isNus {
303
+ // RNode NUS disconnect
304
+ nusPeripherals.removeValue(forKey: peripheral.identifier)
305
+ nusTxChars.removeValue(forKey: peripheral.identifier)
306
+ } else {
307
+ // Mesh peer disconnect — notify Rust
308
+ let addr = BLEManager.uuidToAddr(peripheral.identifier)
309
+ addrToPeripheralUUID.removeValue(forKey: addr)
310
+ addr.withUnsafeBytes { ptr in
311
+ _ = lxmf_ble_disconnected(ptr.baseAddress?.assumingMemoryBound(to: UInt8.self))
312
+ }
313
+ txCharacteristics.removeValue(forKey: peripheral.identifier)
314
+ }
315
+
316
+ // Auto-reconnect bonded devices only
317
+ if isRunning && bondedPeripherals.contains(peripheral.identifier) {
318
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
319
+ guard let self = self, self.isRunning else { return }
320
+ central.connect(peripheral, options: nil)
321
+ }
322
+ }
323
+ }
324
+
325
+ // Background restoration
326
+ func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
327
+ if let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] {
328
+ for peripheral in peripherals {
329
+ connectedPeripherals[peripheral.identifier] = peripheral
330
+ peripheral.delegate = self
331
+ }
332
+ }
333
+ }
334
+ }
335
+
336
+ // MARK: - CBPeripheralDelegate
337
+
338
+ extension BLEManager: CBPeripheralDelegate {
339
+ func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
340
+ guard let services = peripheral.services else { return }
341
+ for service in services {
342
+ if service.uuid == BLEManager.nusServiceUUID {
343
+ // RNode NUS — discover NUS characteristics
344
+ peripheral.discoverCharacteristics(
345
+ [BLEManager.nusTxCharUUID, BLEManager.nusRxCharUUID],
346
+ for: service
347
+ )
348
+ } else if service.uuid == BLEManager.meshServiceUUID {
349
+ // Phone-to-phone mesh — discover mesh characteristics
350
+ peripheral.discoverCharacteristics(
351
+ [BLEManager.rxCharUUID, BLEManager.txCharUUID],
352
+ for: service
353
+ )
354
+ }
355
+ }
356
+ }
357
+
358
+ func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
359
+ guard let chars = service.characteristics else { return }
360
+
361
+ if service.uuid == BLEManager.nusServiceUUID {
362
+ // RNode NUS characteristics — mark as bonded (pairing succeeded)
363
+ bondedPeripherals.insert(peripheral.identifier)
364
+ saveBondedPeripherals()
365
+ discoveredUnpairedRNodes.removeValue(forKey: peripheral.identifier)
366
+ nusPeripherals[peripheral.identifier] = peripheral
367
+ for char in chars {
368
+ if char.uuid == BLEManager.nusTxCharUUID {
369
+ // NUS TX — we write TO the RNode on this char
370
+ nusTxChars[peripheral.identifier] = char
371
+ } else if char.uuid == BLEManager.nusRxCharUUID {
372
+ // NUS RX — subscribe for notifications FROM RNode
373
+ peripheral.setNotifyValue(true, for: char)
374
+ }
375
+ }
376
+ return
377
+ }
378
+
379
+ // Phone-to-phone mesh characteristics — mark as bonded and register with Rust
380
+ bondedPeripherals.insert(peripheral.identifier)
381
+ saveBondedPeripherals()
382
+ let addr = BLEManager.uuidToAddr(peripheral.identifier)
383
+ addrToPeripheralUUID[addr] = peripheral.identifier
384
+ addr.withUnsafeBytes { ptr in
385
+ _ = lxmf_ble_connected(ptr.baseAddress?.assumingMemoryBound(to: UInt8.self))
386
+ }
387
+
388
+ for char in chars {
389
+ if char.uuid == BLEManager.rxCharUUID {
390
+ // This is the peer's RX — we write to it
391
+ txCharacteristics[peripheral.identifier] = char
392
+ } else if char.uuid == BLEManager.txCharUUID {
393
+ // This is the peer's TX — subscribe for notifications
394
+ peripheral.setNotifyValue(true, for: char)
395
+ }
396
+ }
397
+ }
398
+
399
+ func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
400
+ guard let value = characteristic.value, !value.isEmpty else { return }
401
+
402
+ if characteristic.uuid == BLEManager.nusRxCharUUID {
403
+ // Inbound data from RNode via NUS — push raw bytes into Rust NusInterface.
404
+ // KISS deframing is handled on the Rust side (stateful).
405
+ value.withUnsafeBytes { dataPtr in
406
+ _ = lxmf_nus_receive(
407
+ dataPtr.baseAddress?.assumingMemoryBound(to: UInt8.self),
408
+ value.count
409
+ )
410
+ }
411
+ return
412
+ }
413
+
414
+ // Inbound data from a mesh peer — push into Rust BleInterface
415
+ let addr = BLEManager.uuidToAddr(peripheral.identifier)
416
+ addr.withUnsafeBytes { addrPtr in
417
+ value.withUnsafeBytes { dataPtr in
418
+ _ = lxmf_ble_receive(
419
+ addrPtr.baseAddress?.assumingMemoryBound(to: UInt8.self),
420
+ dataPtr.baseAddress?.assumingMemoryBound(to: UInt8.self),
421
+ value.count
422
+ )
423
+ }
424
+ }
425
+ }
426
+ }
427
+
428
+ // MARK: - CBPeripheralManagerDelegate
429
+
430
+ extension BLEManager: CBPeripheralManagerDelegate {
431
+ func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
432
+ if peripheral.state == .poweredOn && isRunning {
433
+ setupPeripheral()
434
+ }
435
+ }
436
+
437
+ func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
438
+ if error == nil {
439
+ startAdvertising()
440
+ }
441
+ }
442
+
443
+ func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) {
444
+ for request in requests {
445
+ if request.characteristic.uuid == BLEManager.rxCharUUID,
446
+ let value = request.value, !value.isEmpty {
447
+ // Inbound write from a central peer — push into Rust BleInterface
448
+ let addr = BLEManager.uuidToAddr(request.central.identifier)
449
+ addr.withUnsafeBytes { addrPtr in
450
+ value.withUnsafeBytes { dataPtr in
451
+ _ = lxmf_ble_receive(
452
+ addrPtr.baseAddress?.assumingMemoryBound(to: UInt8.self),
453
+ dataPtr.baseAddress?.assumingMemoryBound(to: UInt8.self),
454
+ value.count
455
+ )
456
+ }
457
+ }
458
+ }
459
+ peripheral.respond(to: request, withResult: .success)
460
+ }
461
+ }
462
+
463
+ func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral,
464
+ didSubscribeTo characteristic: CBCharacteristic) {
465
+ if !subscribedCentrals.contains(where: { $0.identifier == central.identifier }) {
466
+ subscribedCentrals.append(central)
467
+ // Register central as a peer with Rust
468
+ let addr = BLEManager.uuidToAddr(central.identifier)
469
+ addrToCentral[addr] = central
470
+ addr.withUnsafeBytes { ptr in
471
+ _ = lxmf_ble_connected(ptr.baseAddress?.assumingMemoryBound(to: UInt8.self))
472
+ }
473
+ }
474
+ }
475
+
476
+ func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral,
477
+ didUnsubscribeFrom characteristic: CBCharacteristic) {
478
+ subscribedCentrals.removeAll { $0.identifier == central.identifier }
479
+ // Notify Rust of central disconnection
480
+ let addr = BLEManager.uuidToAddr(central.identifier)
481
+ addrToCentral.removeValue(forKey: addr)
482
+ addr.withUnsafeBytes { ptr in
483
+ _ = lxmf_ble_disconnected(ptr.baseAddress?.assumingMemoryBound(to: UInt8.self))
484
+ }
485
+ }
486
+
487
+ // Background restoration
488
+ func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String: Any]) {
489
+ // Re-setup services on restoration
490
+ if isRunning {
491
+ setupPeripheral()
492
+ }
493
+ }
494
+ }