BLEManager.swift 39 KB

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