BLEManager.swift 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991
  1. import Foundation
  2. import CoreBluetooth
  3. import Combine
  4. import Security
  5. enum SparkConnectionPhase: Equatable {
  6. case disconnected
  7. case connecting
  8. case discovering
  9. case authenticating
  10. case ready
  11. case recording
  12. case reconnecting
  13. case failed(String)
  14. }
  15. enum SparkRecorderConnectionEvent {
  16. case recording(fileName: String)
  17. case reconnecting
  18. case ready
  19. case failed(String)
  20. }
  21. enum SparkBLEError: LocalizedError {
  22. case bluetoothUnavailable
  23. case deviceUnavailable
  24. case incompatibleDevice
  25. case connectionFailed(String)
  26. case commandUnavailable
  27. case commandTimedOut(String)
  28. case pairingFailed
  29. case deviceReported(String)
  30. var errorDescription: String? {
  31. switch self {
  32. case .bluetoothUnavailable: return "蓝牙当前不可用,请确认系统蓝牙已开启。"
  33. case .deviceUnavailable: return "找不到该微光设备,请让设备靠近 iPhone 后重试。"
  34. case .incompatibleDevice: return "该设备没有提供微光录音服务。"
  35. case .connectionFailed(let reason): return "连接微光失败:\(reason)"
  36. case .commandUnavailable: return "微光指令通道尚未就绪。"
  37. case .commandTimedOut(let command): return "设备未及时响应指令 \(command)。"
  38. case .pairingFailed: return "微光密钥配对失败,请重置设备后重试。"
  39. case .deviceReported(let message): return message
  40. }
  41. }
  42. }
  43. /// Owns the CoreBluetooth central role and implements the MR20/Spark command protocol.
  44. final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeripheralDelegate {
  45. static let shared = BLEManager()
  46. static let serviceUUID = CBUUID(string: "001120a0-2233-4455-6677-88995a5b5c5d")
  47. static let audioNotifyUUID = CBUUID(string: "001120a1-2233-4455-6677-88995a5b5c5d")
  48. static let writeUUID = CBUUID(string: "001120a2-2233-4455-6677-88995a5b5c5d")
  49. static let commandNotifyUUID = CBUUID(string: "001120a3-2233-4455-6677-88995a5b5c5d")
  50. @Published private(set) var state: CBManagerState = .unknown
  51. @Published private(set) var isScanning: Bool = false
  52. @Published private(set) var discoveredDevices: [DiscoveredBLEDevice] = []
  53. @Published private(set) var boundDevices: [BoundDevice] = []
  54. @Published private(set) var connectionPhases: [String: SparkConnectionPhase] = [:]
  55. @Published private(set) var recordingDeviceID: String?
  56. @Published private(set) var lastErrorMessage: String?
  57. private struct SparkCharacteristics {
  58. var audioNotify: CBCharacteristic?
  59. var commandNotify: CBCharacteristic?
  60. var write: CBCharacteristic?
  61. var isComplete: Bool {
  62. audioNotify != nil && commandNotify != nil && write != nil
  63. }
  64. }
  65. private struct PendingBinding {
  66. let discoveredDevice: DiscoveredBLEDevice
  67. let userID: String
  68. let password: String
  69. let completion: (Result<BoundDevice, Error>) -> Void
  70. }
  71. private struct PendingCommand {
  72. let token: UUID
  73. let completion: (Result<String, Error>) -> Void
  74. }
  75. private var centralManager: CBCentralManager!
  76. private let boundDevicesKey = "com.celestia.trace.bound_devices"
  77. private let restorationIdentifier = "com.celestia.trace.spark.central"
  78. private let keychainService = "com.celestia.trace.spark.pairing"
  79. private var peripherals: [String: CBPeripheral] = [:]
  80. private var characteristics: [String: SparkCharacteristics] = [:]
  81. private var pendingBindings: [String: PendingBinding] = [:]
  82. private var authenticatingDeviceIDs: Set<String> = []
  83. private var pendingStarts: [String: PendingCommand] = [:]
  84. private var pendingStops: [String: PendingCommand] = [:]
  85. private var audioConsumers: [String: (Data) -> Void] = [:]
  86. private var recorderStateConsumers: [String: (SparkRecorderConnectionEvent) -> Void] = [:]
  87. private var scanRequested = false
  88. override private init() {
  89. super.init()
  90. loadBoundDevices()
  91. centralManager = CBCentralManager(
  92. delegate: self,
  93. queue: nil,
  94. options: [CBCentralManagerOptionRestoreIdentifierKey: restorationIdentifier]
  95. )
  96. }
  97. // MARK: - Public device access
  98. func devices(forUserId userId: String) -> [BoundDevice] {
  99. boundDevices.filter { $0.boundUserId == userId }
  100. }
  101. func connectedDevices(forUserId userId: String) -> [BoundDevice] {
  102. devices(forUserId: userId).filter { $0.isConnected }
  103. }
  104. func phase(for deviceID: String) -> SparkConnectionPhase {
  105. connectionPhases[deviceID] ?? .disconnected
  106. }
  107. func renameDevice(id: String, to proposedName: String) {
  108. let trimmed = proposedName.trimmingCharacters(in: .whitespacesAndNewlines)
  109. guard !trimmed.isEmpty, let index = boundDevices.firstIndex(where: { $0.id == id }) else { return }
  110. boundDevices[index].name = String(trimmed.prefix(30))
  111. saveBoundDevices()
  112. }
  113. func unbindDevice(id: String) {
  114. var peripheralToDisconnect: CBPeripheral?
  115. if boundDevices.first(where: { $0.id == id })?.isConnected == true {
  116. try? writeCommand("PQ_BLE&BLE&OFF", to: id)
  117. peripheralToDisconnect = peripherals[id]
  118. }
  119. deletePairingPassword(for: id)
  120. boundDevices.removeAll(where: { $0.id == id })
  121. connectionPhases[id] = .disconnected
  122. if let peripheralToDisconnect {
  123. DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self, weak peripheralToDisconnect] in
  124. guard let self, let peripheralToDisconnect else { return }
  125. self.centralManager.cancelPeripheralConnection(peripheralToDisconnect)
  126. }
  127. } else if let peripheral = peripherals[id] {
  128. centralManager.cancelPeripheralConnection(peripheral)
  129. }
  130. saveBoundDevices()
  131. }
  132. // MARK: - Scanning and binding
  133. func startScanning() {
  134. discoveredDevices.removeAll()
  135. lastErrorMessage = nil
  136. scanRequested = true
  137. guard centralManager.state == .poweredOn else {
  138. isScanning = true
  139. return
  140. }
  141. isScanning = true
  142. // The vendor sheet doesn't guarantee that the service UUID is advertised,
  143. // so discovery is broad and results are filtered by service/name hints.
  144. centralManager.scanForPeripherals(
  145. withServices: nil,
  146. options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
  147. )
  148. #if targetEnvironment(simulator)
  149. DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
  150. guard let self, self.isScanning else { return }
  151. self.discoveredDevices = [
  152. DiscoveredBLEDevice(
  153. id: "MOCK-SPARK-01",
  154. name: "MR20 Spark Demo",
  155. rssi: -55,
  156. peripheralUUID: "MOCK-SPARK-01"
  157. )
  158. ]
  159. }
  160. #endif
  161. }
  162. func stopScanning() {
  163. scanRequested = false
  164. if centralManager.state == .poweredOn {
  165. centralManager.stopScan()
  166. }
  167. isScanning = false
  168. }
  169. func connectAndBind(
  170. _ device: DiscoveredBLEDevice,
  171. userId: String,
  172. completion: @escaping (Result<BoundDevice, Error>) -> Void
  173. ) {
  174. #if targetEnvironment(simulator)
  175. if device.id.hasPrefix("MOCK-") {
  176. let bound = BoundDevice(
  177. id: device.id,
  178. name: "微光(Spark)",
  179. peripheralUUID: device.peripheralUUID,
  180. isConnected: true,
  181. batteryLevel: 86,
  182. firmwareVersion: "Demo",
  183. boundUserId: userId,
  184. advertisedName: device.name,
  185. freeStorageMB: 1_024,
  186. totalStorageMB: 2_048
  187. )
  188. upsertBoundDevice(bound)
  189. connectionPhases[device.id] = .ready
  190. completion(.success(bound))
  191. return
  192. }
  193. #endif
  194. guard centralManager.state == .poweredOn else {
  195. completion(.failure(SparkBLEError.bluetoothUnavailable))
  196. return
  197. }
  198. guard let peripheral = peripherals[device.id] else {
  199. completion(.failure(SparkBLEError.deviceUnavailable))
  200. return
  201. }
  202. let password = generatePairingPassword()
  203. pendingBindings[device.id] = PendingBinding(
  204. discoveredDevice: device,
  205. userID: userId,
  206. password: password,
  207. completion: completion
  208. )
  209. connectionPhases[device.id] = .connecting
  210. peripheral.delegate = self
  211. stopScanning()
  212. centralManager.connect(peripheral, options: nil)
  213. }
  214. // MARK: - Spark recording control
  215. func startSparkRecording(
  216. deviceID: String,
  217. onAudioData: @escaping (Data) -> Void,
  218. onStateChange: @escaping (SparkRecorderConnectionEvent) -> Void,
  219. completion: @escaping (Result<String, Error>) -> Void
  220. ) {
  221. guard boundDevices.first(where: { $0.id == deviceID })?.isConnected == true else {
  222. completion(.failure(SparkBLEError.deviceUnavailable))
  223. return
  224. }
  225. audioConsumers[deviceID] = onAudioData
  226. recorderStateConsumers[deviceID] = onStateChange
  227. let token = UUID()
  228. pendingStarts[deviceID] = PendingCommand(token: token, completion: completion)
  229. do {
  230. try writeCommand("PQ_BLE&STA", to: deviceID)
  231. scheduleTimeout(for: deviceID, command: "STA", token: token, isStart: true)
  232. } catch {
  233. pendingStarts.removeValue(forKey: deviceID)
  234. completion(.failure(error))
  235. }
  236. }
  237. func stopSparkRecording(
  238. deviceID: String,
  239. completion: @escaping (Result<String, Error>) -> Void
  240. ) {
  241. guard boundDevices.first(where: { $0.id == deviceID })?.isConnected == true else {
  242. completion(.failure(SparkBLEError.deviceUnavailable))
  243. return
  244. }
  245. let token = UUID()
  246. pendingStops[deviceID] = PendingCommand(token: token, completion: completion)
  247. do {
  248. try writeCommand("PQ_BLE&STO", to: deviceID)
  249. scheduleTimeout(for: deviceID, command: "STO", token: token, isStart: false)
  250. } catch {
  251. pendingStops.removeValue(forKey: deviceID)
  252. completion(.failure(error))
  253. }
  254. }
  255. func detachRecorder(deviceID: String) {
  256. audioConsumers.removeValue(forKey: deviceID)
  257. recorderStateConsumers.removeValue(forKey: deviceID)
  258. }
  259. // MARK: - CBCentralManagerDelegate
  260. func centralManagerDidUpdateState(_ central: CBCentralManager) {
  261. state = central.state
  262. guard central.state == .poweredOn else {
  263. markAllDevicesDisconnected()
  264. return
  265. }
  266. reconnectBoundDevices()
  267. if scanRequested {
  268. isScanning = true
  269. central.scanForPeripherals(
  270. withServices: nil,
  271. options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
  272. )
  273. }
  274. }
  275. func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
  276. let restored = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] ?? []
  277. for peripheral in restored {
  278. let id = peripheral.identifier.uuidString
  279. peripherals[id] = peripheral
  280. peripheral.delegate = self
  281. connectionPhases[id] = peripheral.state == .connected ? .discovering : .reconnecting
  282. if peripheral.state == .connected {
  283. peripheral.discoverServices([Self.serviceUUID])
  284. } else {
  285. central.connect(peripheral, options: nil)
  286. }
  287. }
  288. }
  289. func centralManager(
  290. _ central: CBCentralManager,
  291. didDiscover peripheral: CBPeripheral,
  292. advertisementData: [String: Any],
  293. rssi RSSI: NSNumber
  294. ) {
  295. let id = peripheral.identifier.uuidString
  296. let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String
  297. ?? peripheral.name
  298. ?? "微光录音设备"
  299. let advertisedServices = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? []
  300. let normalizedName = advertisedName.lowercased()
  301. let matchesProduct = advertisedServices.contains(Self.serviceUUID)
  302. || normalizedName.contains("spark")
  303. || normalizedName.contains("mr20")
  304. || normalizedName.contains("微光")
  305. guard matchesProduct else { return }
  306. peripherals[id] = peripheral
  307. peripheral.delegate = self
  308. let device = DiscoveredBLEDevice(
  309. id: id,
  310. name: advertisedName,
  311. rssi: RSSI.intValue,
  312. peripheralUUID: id
  313. )
  314. if let index = discoveredDevices.firstIndex(where: { $0.id == id }) {
  315. discoveredDevices[index] = device
  316. } else {
  317. discoveredDevices.append(device)
  318. }
  319. }
  320. func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
  321. let id = peripheral.identifier.uuidString
  322. peripherals[id] = peripheral
  323. peripheral.delegate = self
  324. connectionPhases[id] = .discovering
  325. peripheral.discoverServices([Self.serviceUUID])
  326. }
  327. func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
  328. let id = peripheral.identifier.uuidString
  329. let failure = SparkBLEError.connectionFailed(error?.localizedDescription ?? "未知错误")
  330. connectionPhases[id] = .failed(failure.localizedDescription)
  331. finishPendingBinding(deviceID: id, result: .failure(failure))
  332. }
  333. func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
  334. let id = peripheral.identifier.uuidString
  335. updateBoundDevice(id: id) { $0.isConnected = false }
  336. characteristics.removeValue(forKey: id)
  337. authenticatingDeviceIDs.remove(id)
  338. if recordingDeviceID == id {
  339. connectionPhases[id] = .reconnecting
  340. recorderStateConsumers[id]?(.reconnecting)
  341. } else {
  342. connectionPhases[id] = .disconnected
  343. }
  344. if let pending = pendingStarts.removeValue(forKey: id) {
  345. pending.completion(.failure(SparkBLEError.connectionFailed("设备已断开")))
  346. }
  347. if let pending = pendingStops.removeValue(forKey: id) {
  348. pending.completion(.failure(SparkBLEError.connectionFailed("停止录音前设备已断开")))
  349. }
  350. finishPendingBinding(deviceID: id, result: .failure(SparkBLEError.connectionFailed("设备已断开")))
  351. guard boundDevices.contains(where: { $0.id == id }) else { return }
  352. DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self, weak peripheral] in
  353. guard let self, let peripheral, self.centralManager.state == .poweredOn else { return }
  354. self.connectionPhases[id] = .reconnecting
  355. self.centralManager.connect(peripheral, options: nil)
  356. }
  357. }
  358. // MARK: - CBPeripheralDelegate
  359. func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
  360. let id = peripheral.identifier.uuidString
  361. guard error == nil,
  362. let service = peripheral.services?.first(where: { $0.uuid == Self.serviceUUID }) else {
  363. let failure = SparkBLEError.incompatibleDevice
  364. connectionPhases[id] = .failed(failure.localizedDescription)
  365. finishPendingBinding(deviceID: id, result: .failure(failure))
  366. centralManager.cancelPeripheralConnection(peripheral)
  367. return
  368. }
  369. peripheral.discoverCharacteristics(
  370. [Self.audioNotifyUUID, Self.commandNotifyUUID, Self.writeUUID],
  371. for: service
  372. )
  373. }
  374. func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
  375. let id = peripheral.identifier.uuidString
  376. guard error == nil else {
  377. failTransport(deviceID: id, message: error?.localizedDescription ?? "发现特征失败")
  378. return
  379. }
  380. var set = SparkCharacteristics()
  381. for characteristic in service.characteristics ?? [] {
  382. switch characteristic.uuid {
  383. case Self.audioNotifyUUID: set.audioNotify = characteristic
  384. case Self.commandNotifyUUID: set.commandNotify = characteristic
  385. case Self.writeUUID: set.write = characteristic
  386. default: break
  387. }
  388. }
  389. guard set.isComplete, let audio = set.audioNotify, let command = set.commandNotify else {
  390. failTransport(deviceID: id, message: "设备缺少必要的录音特征")
  391. return
  392. }
  393. characteristics[id] = set
  394. peripheral.setNotifyValue(true, for: command)
  395. peripheral.setNotifyValue(true, for: audio)
  396. }
  397. func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
  398. let id = peripheral.identifier.uuidString
  399. guard error == nil else {
  400. failTransport(deviceID: id, message: error?.localizedDescription ?? "订阅设备通知失败")
  401. return
  402. }
  403. guard let set = characteristics[id],
  404. set.audioNotify?.isNotifying == true,
  405. set.commandNotify?.isNotifying == true else { return }
  406. transportDidBecomeReady(deviceID: id)
  407. }
  408. func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
  409. let id = peripheral.identifier.uuidString
  410. guard error == nil, let data = characteristic.value else { return }
  411. if characteristic.uuid == Self.audioNotifyUUID {
  412. audioConsumers[id]?(data)
  413. } else if characteristic.uuid == Self.commandNotifyUUID {
  414. for message in decodeCommandMessages(data) {
  415. handleCommand(message, deviceID: id)
  416. }
  417. }
  418. }
  419. func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
  420. guard let error else { return }
  421. let id = peripheral.identifier.uuidString
  422. lastErrorMessage = "发送设备指令失败:\(error.localizedDescription)"
  423. recorderStateConsumers[id]?(.failed(lastErrorMessage ?? "发送设备指令失败"))
  424. }
  425. // MARK: - Protocol handling
  426. private func transportDidBecomeReady(deviceID: String) {
  427. if let pending = pendingBindings[deviceID] {
  428. connectionPhases[deviceID] = .authenticating
  429. do {
  430. try writeCommand("PQ_BLE&SK&\(pending.password)", to: deviceID)
  431. } catch {
  432. finishPendingBinding(deviceID: deviceID, result: .failure(error))
  433. }
  434. return
  435. }
  436. if let password = pairingPassword(for: deviceID) {
  437. authenticatingDeviceIDs.insert(deviceID)
  438. connectionPhases[deviceID] = .authenticating
  439. do {
  440. try writeCommand("PQ_BLE&SK&\(password)", to: deviceID)
  441. } catch {
  442. authenticatingDeviceIDs.remove(deviceID)
  443. markDeviceReady(deviceID)
  444. }
  445. } else {
  446. // Existing pre-Spark installations have no key in Keychain. Keep them
  447. // usable and let the first explicit rebind establish one.
  448. markDeviceReady(deviceID)
  449. }
  450. }
  451. private func handleCommand(_ message: String, deviceID: String) {
  452. let fields = message.components(separatedBy: "&")
  453. guard fields.count >= 2 else { return }
  454. if message.hasPrefix("PQ_DEV&SK&OK") {
  455. if let pending = pendingBindings[deviceID] {
  456. savePairingPassword(pending.password, for: deviceID)
  457. var bound = BoundDevice(
  458. id: deviceID,
  459. name: "微光(Spark)",
  460. peripheralUUID: pending.discoveredDevice.peripheralUUID,
  461. boundAt: Date(),
  462. isConnected: true,
  463. batteryLevel: nil,
  464. firmwareVersion: nil,
  465. boundUserId: pending.userID,
  466. advertisedName: pending.discoveredDevice.name
  467. )
  468. if let existing = boundDevices.first(where: { $0.id == deviceID }) {
  469. bound.name = existing.name
  470. }
  471. upsertBoundDevice(bound)
  472. connectionPhases[deviceID] = .ready
  473. pending.completion(.success(bound))
  474. pendingBindings.removeValue(forKey: deviceID)
  475. queryDeviceStatus(deviceID)
  476. } else if authenticatingDeviceIDs.remove(deviceID) != nil {
  477. markDeviceReady(deviceID)
  478. }
  479. return
  480. }
  481. if message.hasPrefix("PQ_DEV&SK&ERR") {
  482. authenticatingDeviceIDs.remove(deviceID)
  483. finishPendingBinding(deviceID: deviceID, result: .failure(SparkBLEError.pairingFailed))
  484. connectionPhases[deviceID] = .failed(SparkBLEError.pairingFailed.localizedDescription)
  485. return
  486. }
  487. if message.hasPrefix("PQ_DEV&STA&") {
  488. let fileName = fields.dropFirst(2).joined(separator: "&")
  489. recordingDeviceID = deviceID
  490. connectionPhases[deviceID] = .recording
  491. recorderStateConsumers[deviceID]?(.recording(fileName: fileName))
  492. pendingStarts.removeValue(forKey: deviceID)?.completion(.success(fileName))
  493. return
  494. }
  495. if message == "PQ_DEV&STO" || message.hasPrefix("PQ_DEV&STO&") {
  496. recordingDeviceID = nil
  497. connectionPhases[deviceID] = .ready
  498. pendingStops.removeValue(forKey: deviceID)?.completion(.success(message))
  499. recorderStateConsumers[deviceID]?(.ready)
  500. return
  501. }
  502. if message.hasPrefix("PQ_DEV&RT&") {
  503. recordingDeviceID = deviceID
  504. connectionPhases[deviceID] = .recording
  505. if fields.count >= 4 {
  506. recorderStateConsumers[deviceID]?(.recording(fileName: fields[2]))
  507. }
  508. return
  509. }
  510. if message.hasPrefix("PQ_DEV&BAT&"), let value = fields.last.flatMap(Int.init) {
  511. updateBoundDevice(id: deviceID) { $0.batteryLevel = min(100, max(0, value)) }
  512. } else if message.hasPrefix("PQ_DEV&STE&"), let value = fields.last.flatMap(Int.init) {
  513. if value == 1 {
  514. recordingDeviceID = deviceID
  515. connectionPhases[deviceID] = .recording
  516. } else if recordingDeviceID == deviceID {
  517. recordingDeviceID = nil
  518. connectionPhases[deviceID] = .ready
  519. }
  520. } else if message.hasPrefix("PQ_DEV&FW&"), fields.count >= 3 {
  521. updateBoundDevice(id: deviceID) { $0.firmwareVersion = fields.dropFirst(2).joined(separator: "&") }
  522. } else if message.hasPrefix("PQ_DEV&SPA&"), fields.count >= 4 {
  523. updateBoundDevice(id: deviceID) {
  524. $0.freeStorageMB = Int(fields[2])
  525. $0.totalStorageMB = Int(fields[3])
  526. }
  527. } else if message.hasPrefix("PQ_DEV&MAC&"), fields.count >= 3 {
  528. updateBoundDevice(id: deviceID) { $0.hardwareMAC = fields[2] }
  529. } else if message == "PQ_EV&REC&ERR" || message == "PQ_DEV&REC&ERR" {
  530. reportRecordingError(deviceID: deviceID, message: "微光报告录音失败,请检查设备后重试。")
  531. } else if message == "PQ_DEV&DISK&ERR" {
  532. reportRecordingError(deviceID: deviceID, message: "微光存储空间已满,录音无法继续。")
  533. }
  534. }
  535. private func decodeCommandMessages(_ data: Data) -> [String] {
  536. guard var text = String(data: data, encoding: .utf8) else { return [] }
  537. text = text.replacingOccurrences(of: "\0", with: "")
  538. .trimmingCharacters(in: .whitespacesAndNewlines)
  539. guard !text.isEmpty else { return [] }
  540. let lineMessages = text.components(separatedBy: .newlines)
  541. .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
  542. .filter { !$0.isEmpty }
  543. if lineMessages.count > 1 { return lineMessages }
  544. // Also tolerate multiple prefix-delimited messages in one notification.
  545. let pattern = "(?=PQ_(?:DEV|EV)&)"
  546. if let regex = try? NSRegularExpression(pattern: pattern) {
  547. let range = NSRange(text.startIndex..., in: text)
  548. let matches = regex.matches(in: text, range: range)
  549. if matches.count > 1 {
  550. return matches.enumerated().compactMap { index, match in
  551. let start = match.range.location
  552. let end = index + 1 < matches.count ? matches[index + 1].range.location : range.length
  553. guard let swiftRange = Range(NSRange(location: start, length: end - start), in: text) else { return nil }
  554. return String(text[swiftRange])
  555. }
  556. }
  557. }
  558. return [text]
  559. }
  560. private func writeCommand(_ command: String, to deviceID: String) throws {
  561. guard let peripheral = peripherals[deviceID],
  562. peripheral.state == .connected,
  563. let characteristic = characteristics[deviceID]?.write else {
  564. throw SparkBLEError.commandUnavailable
  565. }
  566. let data = Data(command.utf8)
  567. let writeType: CBCharacteristicWriteType
  568. if characteristic.properties.contains(.write) {
  569. writeType = .withResponse
  570. } else if characteristic.properties.contains(.writeWithoutResponse) {
  571. writeType = .withoutResponse
  572. } else {
  573. throw SparkBLEError.commandUnavailable
  574. }
  575. guard data.count <= peripheral.maximumWriteValueLength(for: writeType) else {
  576. throw SparkBLEError.deviceReported("设备指令超过单帧写入长度。")
  577. }
  578. peripheral.writeValue(data, for: characteristic, type: writeType)
  579. }
  580. private func queryDeviceStatus(_ deviceID: String) {
  581. let commands = ["PQ_BLE&STE", "PQ_BLE&BAT", "PQ_BLE&SPACE", "PQ_BLE&FW", "PQ_BLE&MAC", currentTimeCommand()]
  582. for (index, command) in commands.enumerated() {
  583. DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * 0.12) { [weak self] in
  584. try? self?.writeCommand(command, to: deviceID)
  585. }
  586. }
  587. }
  588. private func currentTimeCommand() -> String {
  589. let formatter = DateFormatter()
  590. formatter.locale = Locale(identifier: "en_US_POSIX")
  591. formatter.dateFormat = "yyyyMMddHHmmss"
  592. return "PQ_BLE&T&\(formatter.string(from: Date()))"
  593. }
  594. // MARK: - State and persistence
  595. private func markDeviceReady(_ deviceID: String) {
  596. updateBoundDevice(id: deviceID) { $0.isConnected = true }
  597. connectionPhases[deviceID] = recordingDeviceID == deviceID ? .recording : .ready
  598. recorderStateConsumers[deviceID]?(.ready)
  599. queryDeviceStatus(deviceID)
  600. }
  601. private func reconnectBoundDevices() {
  602. let identifiers = boundDevices.compactMap { UUID(uuidString: $0.peripheralUUID) }
  603. guard !identifiers.isEmpty else { return }
  604. for peripheral in centralManager.retrievePeripherals(withIdentifiers: identifiers) {
  605. let id = peripheral.identifier.uuidString
  606. peripherals[id] = peripheral
  607. peripheral.delegate = self
  608. connectionPhases[id] = .reconnecting
  609. centralManager.connect(peripheral, options: nil)
  610. }
  611. }
  612. private func markAllDevicesDisconnected() {
  613. for index in boundDevices.indices {
  614. boundDevices[index].isConnected = false
  615. connectionPhases[boundDevices[index].id] = .disconnected
  616. }
  617. saveBoundDevices()
  618. }
  619. private func updateBoundDevice(id: String, mutation: (inout BoundDevice) -> Void) {
  620. guard let index = boundDevices.firstIndex(where: { $0.id == id }) else { return }
  621. mutation(&boundDevices[index])
  622. saveBoundDevices()
  623. }
  624. private func upsertBoundDevice(_ device: BoundDevice) {
  625. boundDevices.removeAll(where: { $0.id == device.id })
  626. boundDevices.append(device)
  627. saveBoundDevices()
  628. }
  629. private func finishPendingBinding(deviceID: String, result: Result<BoundDevice, Error>) {
  630. guard let pending = pendingBindings.removeValue(forKey: deviceID) else { return }
  631. pending.completion(result)
  632. }
  633. private func failTransport(deviceID: String, message: String) {
  634. let error = SparkBLEError.connectionFailed(message)
  635. connectionPhases[deviceID] = .failed(error.localizedDescription)
  636. finishPendingBinding(deviceID: deviceID, result: .failure(error))
  637. recorderStateConsumers[deviceID]?(.failed(error.localizedDescription))
  638. }
  639. private func reportRecordingError(deviceID: String, message: String) {
  640. lastErrorMessage = message
  641. recorderStateConsumers[deviceID]?(.failed(message))
  642. pendingStarts.removeValue(forKey: deviceID)?.completion(.failure(SparkBLEError.deviceReported(message)))
  643. }
  644. private func scheduleTimeout(for deviceID: String, command: String, token: UUID, isStart: Bool) {
  645. DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in
  646. guard let self else { return }
  647. if isStart, self.pendingStarts[deviceID]?.token == token {
  648. self.pendingStarts.removeValue(forKey: deviceID)?.completion(.failure(SparkBLEError.commandTimedOut(command)))
  649. } else if !isStart, self.pendingStops[deviceID]?.token == token {
  650. self.pendingStops.removeValue(forKey: deviceID)?.completion(.failure(SparkBLEError.commandTimedOut(command)))
  651. }
  652. }
  653. }
  654. private func saveBoundDevices() {
  655. if let data = try? JSONEncoder().encode(boundDevices) {
  656. UserDefaults.standard.set(data, forKey: boundDevicesKey)
  657. }
  658. }
  659. private func loadBoundDevices() {
  660. guard let data = UserDefaults.standard.data(forKey: boundDevicesKey),
  661. var devices = try? JSONDecoder().decode([BoundDevice].self, from: data) else { return }
  662. for index in devices.indices {
  663. devices[index].isConnected = false
  664. }
  665. boundDevices = devices
  666. }
  667. // MARK: - Pairing secret
  668. private func generatePairingPassword() -> String {
  669. let alphabet = Array("ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789")
  670. var bytes = [UInt8](repeating: 0, count: 16)
  671. let result = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
  672. if result == errSecSuccess {
  673. return String(bytes.map { alphabet[Int($0) % alphabet.count] })
  674. }
  675. return String(UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(16))
  676. }
  677. private func savePairingPassword(_ password: String, for deviceID: String) {
  678. deletePairingPassword(for: deviceID)
  679. let query: [String: Any] = [
  680. kSecClass as String: kSecClassGenericPassword,
  681. kSecAttrService as String: keychainService,
  682. kSecAttrAccount as String: deviceID,
  683. kSecValueData as String: Data(password.utf8),
  684. kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
  685. ]
  686. SecItemAdd(query as CFDictionary, nil)
  687. }
  688. private func pairingPassword(for deviceID: String) -> String? {
  689. let query: [String: Any] = [
  690. kSecClass as String: kSecClassGenericPassword,
  691. kSecAttrService as String: keychainService,
  692. kSecAttrAccount as String: deviceID,
  693. kSecReturnData as String: true,
  694. kSecMatchLimit as String: kSecMatchLimitOne
  695. ]
  696. var result: CFTypeRef?
  697. guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
  698. let data = result as? Data else { return nil }
  699. return String(data: data, encoding: .utf8)
  700. }
  701. private func deletePairingPassword(for deviceID: String) {
  702. let query: [String: Any] = [
  703. kSecClass as String: kSecClassGenericPassword,
  704. kSecAttrService as String: keychainService,
  705. kSecAttrAccount as String: deviceID
  706. ]
  707. SecItemDelete(query as CFDictionary)
  708. }
  709. }
  710. // MARK: - Spark audio recorder
  711. /// Records the MP3 notification stream produced by a bound Spark device.
  712. final class SparkAudioRecorder: AudioRecorderProtocol {
  713. @Published var isRecording: Bool = false
  714. @Published var elapsedTime: TimeInterval = 0
  715. @Published var currentAmplitude: Float = 0
  716. @Published var waveformSamples: [Float] = []
  717. @Published var outputFileURL: URL?
  718. @Published var isPaused: Bool = false
  719. @Published var statusMessage: String = "正在连接设备"
  720. @Published var errorMessage: String?
  721. let sourceDisplayName: String
  722. private let deviceID: String
  723. private let manager: BLEManager
  724. private let ioQueue = DispatchQueue(label: "com.celestia.trace.spark.audio-io")
  725. private var fileHandle: FileHandle?
  726. private var timer: Timer?
  727. private var recordingStartedAt: Date?
  728. private var stopCompletion: ((Result<URL?, Error>) -> Void)?
  729. init(deviceID: String, displayName: String, manager: BLEManager = .shared) {
  730. self.deviceID = deviceID
  731. self.sourceDisplayName = displayName
  732. self.manager = manager
  733. }
  734. func startRecording() {
  735. guard !isRecording, fileHandle == nil else { return }
  736. errorMessage = nil
  737. statusMessage = "正在启动 \(sourceDisplayName)"
  738. elapsedTime = 0
  739. waveformSamples = []
  740. currentAmplitude = 0
  741. do {
  742. let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
  743. let url = documents.appendingPathComponent("spark_\(UUID().uuidString).mp3")
  744. FileManager.default.createFile(atPath: url.path, contents: nil)
  745. fileHandle = try FileHandle(forWritingTo: url)
  746. outputFileURL = url
  747. } catch {
  748. errorMessage = "无法创建微光录音文件:\(error.localizedDescription)"
  749. statusMessage = "启动失败"
  750. return
  751. }
  752. manager.startSparkRecording(
  753. deviceID: deviceID,
  754. onAudioData: { [weak self] data in self?.appendAudioData(data) },
  755. onStateChange: { [weak self] event in self?.handleStateEvent(event) }
  756. ) { [weak self] result in
  757. guard let self else { return }
  758. DispatchQueue.main.async {
  759. switch result {
  760. case .success:
  761. self.isRecording = true
  762. self.statusMessage = "正在录音"
  763. self.recordingStartedAt = Date()
  764. self.startTimer()
  765. case .failure(let error):
  766. self.errorMessage = error.localizedDescription
  767. self.statusMessage = "启动失败"
  768. self.finishFile { _ in }
  769. }
  770. }
  771. }
  772. }
  773. func stopRecording() {
  774. stopRecording { _ in }
  775. }
  776. func stopRecording(completion: @escaping (Result<URL?, Error>) -> Void) {
  777. guard fileHandle != nil else {
  778. completion(.success(outputFileURL))
  779. return
  780. }
  781. guard isRecording else {
  782. finishFile(completion: completion)
  783. return
  784. }
  785. statusMessage = "正在保存设备录音"
  786. stopCompletion = completion
  787. manager.stopSparkRecording(deviceID: deviceID) { [weak self] result in
  788. guard let self else { return }
  789. DispatchQueue.main.async {
  790. switch result {
  791. case .success:
  792. self.isRecording = false
  793. self.invalidateTimer()
  794. self.finishFile { result in
  795. self.manager.detachRecorder(deviceID: self.deviceID)
  796. self.stopCompletion?(result)
  797. self.stopCompletion = nil
  798. }
  799. case .failure(let error):
  800. self.errorMessage = error.localizedDescription
  801. self.statusMessage = "设备尚未确认停止"
  802. self.stopCompletion?(.failure(error))
  803. self.stopCompletion = nil
  804. }
  805. }
  806. }
  807. }
  808. func pauseRecording() {
  809. errorMessage = "微光暂不支持暂停,请结束当前录音。"
  810. }
  811. func resumeRecording() {}
  812. private func appendAudioData(_ data: Data) {
  813. guard !data.isEmpty else { return }
  814. ioQueue.async { [weak self] in
  815. guard let self else { return }
  816. do {
  817. try self.fileHandle?.write(contentsOf: data)
  818. } catch {
  819. DispatchQueue.main.async {
  820. self.errorMessage = "保存微光音频失败:\(error.localizedDescription)"
  821. }
  822. }
  823. }
  824. // This represents stream activity until incremental MP3 PCM metering is available.
  825. let activity = min(1, max(0.08, Float(data.count) / 244.0))
  826. DispatchQueue.main.async { [weak self] in
  827. guard let self else { return }
  828. self.currentAmplitude = activity
  829. self.waveformSamples.append(activity)
  830. if self.waveformSamples.count > 200 {
  831. self.waveformSamples.removeFirst(self.waveformSamples.count - 200)
  832. }
  833. }
  834. }
  835. private func handleStateEvent(_ event: SparkRecorderConnectionEvent) {
  836. DispatchQueue.main.async { [weak self] in
  837. guard let self else { return }
  838. switch event {
  839. case .recording:
  840. if self.isRecording { self.statusMessage = "正在录音" }
  841. case .reconnecting:
  842. self.statusMessage = "连接中断,正在重连"
  843. case .ready:
  844. if self.isRecording { self.statusMessage = "正在录音" }
  845. case .failed(let message):
  846. self.errorMessage = message
  847. self.statusMessage = "设备异常"
  848. }
  849. }
  850. }
  851. private func startTimer() {
  852. invalidateTimer()
  853. timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
  854. guard let self, let startedAt = self.recordingStartedAt else { return }
  855. self.elapsedTime = Date().timeIntervalSince(startedAt)
  856. }
  857. if let timer { RunLoop.main.add(timer, forMode: .common) }
  858. }
  859. private func invalidateTimer() {
  860. timer?.invalidate()
  861. timer = nil
  862. }
  863. private func finishFile(completion: @escaping (Result<URL?, Error>) -> Void) {
  864. let url = outputFileURL
  865. ioQueue.async { [weak self] in
  866. guard let self else { return }
  867. do {
  868. try self.fileHandle?.synchronize()
  869. try self.fileHandle?.close()
  870. self.fileHandle = nil
  871. DispatchQueue.main.async { completion(.success(url)) }
  872. } catch {
  873. self.fileHandle = nil
  874. DispatchQueue.main.async { completion(.failure(error)) }
  875. }
  876. }
  877. }
  878. deinit {
  879. invalidateTimer()
  880. try? fileHandle?.close()
  881. manager.detachRecorder(deviceID: deviceID)
  882. }
  883. }