BLEManager.swift 58 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463
  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. struct BLECommunicationLogEntry: Identifiable, Equatable {
  22. let id = UUID()
  23. let deviceID: String
  24. let timestamp: Date
  25. let kind: String
  26. let message: String
  27. }
  28. struct ObservedBLEAdvertisement: Identifiable, Equatable {
  29. let id: String
  30. var name: String
  31. var serviceUUIDs: [String]
  32. var rssi: Int
  33. var peakRSSI: Int
  34. var firstSeenAt: Date
  35. var lastSeenAt: Date
  36. var lastTransitionAt: Date
  37. var isPresent: Bool
  38. var reappearanceCount: Int
  39. var matchesSpark: Bool {
  40. BLEManager.isSparkAdvertisement(
  41. name: name,
  42. serviceUUIDs: serviceUUIDs.map { CBUUID(string: $0) }
  43. )
  44. }
  45. var isStrongSignal: Bool {
  46. rssi >= -60
  47. }
  48. }
  49. enum SparkBLEError: LocalizedError {
  50. case bluetoothUnavailable
  51. case deviceUnavailable
  52. case incompatibleDevice
  53. case connectionFailed(String)
  54. case commandUnavailable
  55. case commandTimedOut(String)
  56. case pairingFailed
  57. case deviceReported(String)
  58. var errorDescription: String? {
  59. switch self {
  60. case .bluetoothUnavailable: return "蓝牙当前不可用,请确认系统蓝牙已开启。"
  61. case .deviceUnavailable: return "找不到该微光设备,请让设备靠近 iPhone 后重试。"
  62. case .incompatibleDevice: return "该设备没有提供微光录音服务。"
  63. case .connectionFailed(let reason): return "连接微光失败:\(reason)"
  64. case .commandUnavailable: return "微光指令通道尚未就绪。"
  65. case .commandTimedOut(let command): return "设备未及时响应指令 \(command)。"
  66. case .pairingFailed: return "微光密钥配对失败,请重置设备后重试。"
  67. case .deviceReported(let message): return message
  68. }
  69. }
  70. }
  71. /// Owns the CoreBluetooth central role and implements the MR20/Spark command protocol.
  72. final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeripheralDelegate {
  73. static let shared = BLEManager()
  74. private static let debugMinimumRSSI = -75
  75. private static let advertisementDisappearanceInterval: TimeInterval = 4
  76. private static let disappearedAdvertisementRetentionInterval: TimeInterval = 30
  77. static let serviceUUID = CBUUID(string: "001120a0-2233-4455-6677-88995a5b5c5d")
  78. static let audioNotifyUUID = CBUUID(string: "001120a1-2233-4455-6677-88995a5b5c5d")
  79. static let writeUUID = CBUUID(string: "001120a2-2233-4455-6677-88995a5b5c5d")
  80. static let commandNotifyUUID = CBUUID(string: "001120a3-2233-4455-6677-88995a5b5c5d")
  81. static func isYLF20AdvertisementName(_ name: String?) -> Bool {
  82. let normalizedName = name?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
  83. return normalizedName == "ylf20" || normalizedName.hasPrefix("ylf20_")
  84. }
  85. static func isSparkAdvertisement(name: String?, serviceUUIDs: [CBUUID]) -> Bool {
  86. let normalizedName = name?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
  87. return serviceUUIDs.contains(serviceUUID)
  88. || isYLF20AdvertisementName(name)
  89. || normalizedName.contains("spark")
  90. || normalizedName.contains("mr20")
  91. || normalizedName.contains("微光")
  92. }
  93. @Published private(set) var state: CBManagerState = .unknown
  94. @Published private(set) var isScanning: Bool = false
  95. @Published private(set) var discoveredDevices: [DiscoveredBLEDevice] = []
  96. @Published private(set) var observedPeripheralCount: Int = 0
  97. @Published private(set) var observedAdvertisements: [ObservedBLEAdvertisement] = []
  98. @Published private(set) var communicationLogs: [BLECommunicationLogEntry] = []
  99. @Published private(set) var boundDevices: [BoundDevice] = []
  100. @Published private(set) var connectionPhases: [String: SparkConnectionPhase] = [:]
  101. @Published private(set) var recordingDeviceID: String?
  102. @Published private(set) var lastErrorMessage: String?
  103. private struct SparkCharacteristics {
  104. var audioNotify: CBCharacteristic?
  105. var commandNotify: CBCharacteristic?
  106. var write: CBCharacteristic?
  107. var isComplete: Bool {
  108. audioNotify != nil && commandNotify != nil && write != nil
  109. }
  110. }
  111. private struct PendingBinding {
  112. let discoveredDevice: DiscoveredBLEDevice
  113. let userID: String
  114. let password: String
  115. let completion: (Result<BoundDevice, Error>) -> Void
  116. }
  117. private struct PendingCommand {
  118. let token: UUID
  119. let completion: (Result<String, Error>) -> Void
  120. }
  121. private enum ProbeWireFormat: CaseIterable {
  122. case documented
  123. case documentedCRLF
  124. case documentedNull
  125. case legacyPrefix
  126. case legacyPrefixCRLF
  127. case legacyPrefixNull
  128. var label: String {
  129. switch self {
  130. case .documented: return "文档原格式"
  131. case .documentedCRLF: return "文档格式 + CRLF"
  132. case .documentedNull: return "文档格式 + NUL"
  133. case .legacyPrefix: return "无 PQ_ 前缀"
  134. case .legacyPrefixCRLF: return "无 PQ_ 前缀 + CRLF"
  135. case .legacyPrefixNull: return "无 PQ_ 前缀 + NUL"
  136. }
  137. }
  138. func data(for command: String) -> Data {
  139. let base: String
  140. switch self {
  141. case .legacyPrefix, .legacyPrefixCRLF, .legacyPrefixNull:
  142. base = command.replacingOccurrences(of: "PQ_BLE&", with: "BLE&")
  143. default:
  144. base = command
  145. }
  146. switch self {
  147. case .documentedCRLF, .legacyPrefixCRLF:
  148. return Data("\(base)\r\n".utf8)
  149. case .documentedNull, .legacyPrefixNull:
  150. var data = Data(base.utf8)
  151. data.append(0)
  152. return data
  153. default:
  154. return Data(base.utf8)
  155. }
  156. }
  157. }
  158. private var centralManager: CBCentralManager!
  159. private let boundDevicesKey = "com.celestia.trace.bound_devices"
  160. private let restorationIdentifier = "com.celestia.trace.spark.central"
  161. private let keychainService = "com.celestia.trace.spark.pairing"
  162. private var peripherals: [String: CBPeripheral] = [:]
  163. private var characteristics: [String: SparkCharacteristics] = [:]
  164. private var pendingBindings: [String: PendingBinding] = [:]
  165. private var authenticatingDeviceIDs: Set<String> = []
  166. private var pendingStarts: [String: PendingCommand] = [:]
  167. private var pendingStops: [String: PendingCommand] = [:]
  168. private var audioConsumers: [String: (Data) -> Void] = [:]
  169. private var recorderStateConsumers: [String: (SparkRecorderConnectionEvent) -> Void] = [:]
  170. private var scanRequested = false
  171. private var observedPeripheralIDs: Set<String> = []
  172. private var observedAdvertisementsByID: [String: ObservedBLEAdvertisement] = [:]
  173. private var advertisementRefreshTimer: Timer?
  174. private var pendingDiagnosticResponses: [String: [String: String]] = [:]
  175. private var diagnosticTokens: [String: UUID] = [:]
  176. private var protocolProbeTokens: [String: UUID] = [:]
  177. private var activeProbeFormats: [String: ProbeWireFormat] = [:]
  178. var scanUnavailableMessage: String? {
  179. switch state {
  180. case .unauthorized:
  181. return "蓝牙权限未开启。请前往“设置”允许星痕访问蓝牙。"
  182. case .poweredOff:
  183. return "系统蓝牙已关闭,请先在控制中心或“设置”中开启蓝牙。"
  184. case .unsupported:
  185. return "当前运行设备不支持 BLE 扫描。请使用支持蓝牙的 iPhone 真机测试。"
  186. default:
  187. return nil
  188. }
  189. }
  190. override private init() {
  191. super.init()
  192. loadBoundDevices()
  193. centralManager = CBCentralManager(
  194. delegate: self,
  195. queue: nil,
  196. options: [CBCentralManagerOptionRestoreIdentifierKey: restorationIdentifier]
  197. )
  198. }
  199. // MARK: - Public device access
  200. func devices(forUserId userId: String) -> [BoundDevice] {
  201. boundDevices.filter { $0.boundUserId == userId }
  202. }
  203. func connectedDevices(forUserId userId: String) -> [BoundDevice] {
  204. devices(forUserId: userId).filter { $0.isConnected }
  205. }
  206. func phase(for deviceID: String) -> SparkConnectionPhase {
  207. connectionPhases[deviceID] ?? .disconnected
  208. }
  209. func communicationLogs(for deviceID: String) -> [BLECommunicationLogEntry] {
  210. communicationLogs.filter { $0.deviceID == deviceID }
  211. }
  212. func renameDevice(id: String, to proposedName: String) {
  213. let trimmed = proposedName.trimmingCharacters(in: .whitespacesAndNewlines)
  214. guard !trimmed.isEmpty, let index = boundDevices.firstIndex(where: { $0.id == id }) else { return }
  215. boundDevices[index].name = String(trimmed.prefix(30))
  216. saveBoundDevices()
  217. }
  218. func unbindDevice(id: String) {
  219. var peripheralToDisconnect: CBPeripheral?
  220. if boundDevices.first(where: { $0.id == id })?.isConnected == true {
  221. try? writeCommand("PQ_BLE&BLE&OFF", to: id)
  222. peripheralToDisconnect = peripherals[id]
  223. }
  224. deletePairingPassword(for: id)
  225. boundDevices.removeAll(where: { $0.id == id })
  226. connectionPhases[id] = .disconnected
  227. if let peripheralToDisconnect {
  228. DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self, weak peripheralToDisconnect] in
  229. guard let self, let peripheralToDisconnect else { return }
  230. self.centralManager.cancelPeripheralConnection(peripheralToDisconnect)
  231. }
  232. } else if let peripheral = peripherals[id] {
  233. centralManager.cancelPeripheralConnection(peripheral)
  234. }
  235. saveBoundDevices()
  236. }
  237. func updateCloudRegistration(deviceID: String, cloudID: String?, error: String?) {
  238. updateBoundDevice(id: deviceID) {
  239. $0.cloudID = cloudID
  240. $0.cloudSyncError = error
  241. }
  242. }
  243. // MARK: - Scanning and binding
  244. func startScanning() {
  245. discoveredDevices.removeAll()
  246. observedPeripheralIDs.removeAll()
  247. observedPeripheralCount = 0
  248. observedAdvertisementsByID.removeAll()
  249. observedAdvertisements.removeAll()
  250. lastErrorMessage = nil
  251. scanRequested = true
  252. startAdvertisementRefreshTimer()
  253. guard centralManager.state == .poweredOn else {
  254. // Keep the request pending while CoreBluetooth initializes. The
  255. // delegate starts scanning if the state later becomes powered on.
  256. isScanning = false
  257. return
  258. }
  259. isScanning = true
  260. // Some Spark firmware versions do not advertise the service UUID. Scan
  261. // broadly, but only surface peripherals that explicitly advertise the
  262. // Spark service or a known product name.
  263. centralManager.scanForPeripherals(
  264. withServices: nil,
  265. options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
  266. )
  267. }
  268. func stopScanning() {
  269. scanRequested = false
  270. advertisementRefreshTimer?.invalidate()
  271. advertisementRefreshTimer = nil
  272. if centralManager.state == .poweredOn {
  273. centralManager.stopScan()
  274. }
  275. isScanning = false
  276. }
  277. func connectAndBind(
  278. _ device: DiscoveredBLEDevice,
  279. userId: String,
  280. completion: @escaping (Result<BoundDevice, Error>) -> Void
  281. ) {
  282. guard centralManager.state == .poweredOn else {
  283. completion(.failure(SparkBLEError.bluetoothUnavailable))
  284. return
  285. }
  286. guard let peripheral = peripherals[device.id] else {
  287. completion(.failure(SparkBLEError.deviceUnavailable))
  288. return
  289. }
  290. communicationLogs.removeAll { $0.deviceID == device.id }
  291. appendCommunicationLog(
  292. deviceID: device.id,
  293. kind: "INFO",
  294. message: "开始绑定 \(device.name),RSSI \(device.rssi) dBm"
  295. )
  296. let password = generatePairingPassword()
  297. pendingBindings[device.id] = PendingBinding(
  298. discoveredDevice: device,
  299. userID: userId,
  300. password: password,
  301. completion: completion
  302. )
  303. connectionPhases[device.id] = .connecting
  304. peripheral.delegate = self
  305. stopScanning()
  306. appendCommunicationLog(deviceID: device.id, kind: "INFO", message: "正在建立 BLE 连接")
  307. centralManager.connect(peripheral, options: nil)
  308. }
  309. func runProtocolDiagnostics(deviceID: String) {
  310. guard peripherals[deviceID]?.state == .connected,
  311. characteristics[deviceID]?.isComplete == true else {
  312. appendCommunicationLog(
  313. deviceID: deviceID,
  314. kind: "ERROR",
  315. message: "通信通道尚未就绪,无法发送诊断指令"
  316. )
  317. return
  318. }
  319. appendCommunicationLog(
  320. deviceID: deviceID,
  321. kind: "INFO",
  322. message: "重新执行协议诊断:状态、电量、容量、固件、MAC、校时"
  323. )
  324. queryDeviceStatus(deviceID)
  325. }
  326. // MARK: - Spark recording control
  327. func startSparkRecording(
  328. deviceID: String,
  329. onAudioData: @escaping (Data) -> Void,
  330. onStateChange: @escaping (SparkRecorderConnectionEvent) -> Void,
  331. completion: @escaping (Result<String, Error>) -> Void
  332. ) {
  333. guard boundDevices.first(where: { $0.id == deviceID })?.isConnected == true else {
  334. completion(.failure(SparkBLEError.deviceUnavailable))
  335. return
  336. }
  337. audioConsumers[deviceID] = onAudioData
  338. recorderStateConsumers[deviceID] = onStateChange
  339. let token = UUID()
  340. pendingStarts[deviceID] = PendingCommand(token: token, completion: completion)
  341. do {
  342. try writeCommand("PQ_BLE&STA", to: deviceID)
  343. scheduleTimeout(for: deviceID, command: "STA", token: token, isStart: true)
  344. } catch {
  345. pendingStarts.removeValue(forKey: deviceID)
  346. completion(.failure(error))
  347. }
  348. }
  349. func stopSparkRecording(
  350. deviceID: String,
  351. completion: @escaping (Result<String, Error>) -> Void
  352. ) {
  353. guard boundDevices.first(where: { $0.id == deviceID })?.isConnected == true else {
  354. completion(.failure(SparkBLEError.deviceUnavailable))
  355. return
  356. }
  357. let token = UUID()
  358. pendingStops[deviceID] = PendingCommand(token: token, completion: completion)
  359. do {
  360. try writeCommand("PQ_BLE&STO", to: deviceID)
  361. scheduleTimeout(for: deviceID, command: "STO", token: token, isStart: false)
  362. } catch {
  363. pendingStops.removeValue(forKey: deviceID)
  364. completion(.failure(error))
  365. }
  366. }
  367. func detachRecorder(deviceID: String) {
  368. audioConsumers.removeValue(forKey: deviceID)
  369. recorderStateConsumers.removeValue(forKey: deviceID)
  370. }
  371. // MARK: - CBCentralManagerDelegate
  372. func centralManagerDidUpdateState(_ central: CBCentralManager) {
  373. state = central.state
  374. guard central.state == .poweredOn else {
  375. isScanning = false
  376. markAllDevicesDisconnected()
  377. return
  378. }
  379. reconnectBoundDevices()
  380. if scanRequested {
  381. isScanning = true
  382. central.scanForPeripherals(
  383. withServices: nil,
  384. options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
  385. )
  386. }
  387. }
  388. func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
  389. let restored = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] ?? []
  390. for peripheral in restored {
  391. let id = peripheral.identifier.uuidString
  392. peripherals[id] = peripheral
  393. peripheral.delegate = self
  394. connectionPhases[id] = peripheral.state == .connected ? .discovering : .reconnecting
  395. if peripheral.state == .connected {
  396. peripheral.discoverServices([Self.serviceUUID])
  397. } else {
  398. central.connect(peripheral, options: nil)
  399. }
  400. }
  401. }
  402. func centralManager(
  403. _ central: CBCentralManager,
  404. didDiscover peripheral: CBPeripheral,
  405. advertisementData: [String: Any],
  406. rssi RSSI: NSNumber
  407. ) {
  408. let id = peripheral.identifier.uuidString
  409. observedPeripheralIDs.insert(id)
  410. observedPeripheralCount = observedPeripheralIDs.count
  411. let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String
  412. ?? peripheral.name
  413. let advertisedServices = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? []
  414. guard RSSI.intValue != 127 else { return }
  415. let now = Date()
  416. let trimmedAdvertisedName = advertisedName?
  417. .trimmingCharacters(in: .whitespacesAndNewlines)
  418. let displayName = trimmedAdvertisedName?.isEmpty == false
  419. ? trimmedAdvertisedName!
  420. : "未提供名称"
  421. let serviceUUIDStrings = advertisedServices.map(\.uuidString)
  422. if var existing = observedAdvertisementsByID[id] {
  423. let hadDisappeared = !existing.isPresent
  424. || now.timeIntervalSince(existing.lastSeenAt) >= Self.advertisementDisappearanceInterval
  425. existing.name = displayName
  426. existing.serviceUUIDs = serviceUUIDStrings
  427. existing.rssi = RSSI.intValue
  428. existing.peakRSSI = max(existing.peakRSSI, RSSI.intValue)
  429. existing.lastSeenAt = now
  430. existing.isPresent = true
  431. if hadDisappeared {
  432. existing.reappearanceCount += 1
  433. existing.lastTransitionAt = now
  434. }
  435. observedAdvertisementsByID[id] = existing
  436. } else if RSSI.intValue >= Self.debugMinimumRSSI {
  437. observedAdvertisementsByID[id] = ObservedBLEAdvertisement(
  438. id: id,
  439. name: displayName,
  440. serviceUUIDs: serviceUUIDStrings,
  441. rssi: RSSI.intValue,
  442. peakRSSI: RSSI.intValue,
  443. firstSeenAt: now,
  444. lastSeenAt: now,
  445. lastTransitionAt: now,
  446. isPresent: true,
  447. reappearanceCount: 0
  448. )
  449. }
  450. publishObservedAdvertisements(now: now)
  451. // The formal binding list is intentionally restricted to the confirmed
  452. // production broadcast name. Service/characteristic UUIDs are verified
  453. // after connecting.
  454. guard Self.isYLF20AdvertisementName(advertisedName) else { return }
  455. peripherals[id] = peripheral
  456. peripheral.delegate = self
  457. let device = DiscoveredBLEDevice(
  458. id: id,
  459. name: trimmedAdvertisedName?.isEmpty == false ? trimmedAdvertisedName! : "微光(Spark)",
  460. rssi: RSSI.intValue,
  461. peripheralUUID: id
  462. )
  463. if let index = discoveredDevices.firstIndex(where: { $0.id == id }) {
  464. discoveredDevices[index] = device
  465. } else {
  466. discoveredDevices.append(device)
  467. }
  468. discoveredDevices.sort { $0.rssi > $1.rssi }
  469. }
  470. private func startAdvertisementRefreshTimer() {
  471. advertisementRefreshTimer?.invalidate()
  472. advertisementRefreshTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
  473. self?.publishObservedAdvertisements(now: Date())
  474. }
  475. }
  476. private func publishObservedAdvertisements(now: Date) {
  477. for (id, var advertisement) in observedAdvertisementsByID {
  478. let elapsed = now.timeIntervalSince(advertisement.lastSeenAt)
  479. if advertisement.isPresent,
  480. elapsed >= Self.advertisementDisappearanceInterval {
  481. advertisement.isPresent = false
  482. advertisement.lastTransitionAt = now
  483. observedAdvertisementsByID[id] = advertisement
  484. }
  485. }
  486. observedAdvertisementsByID = observedAdvertisementsByID.filter {
  487. now.timeIntervalSince($0.value.lastSeenAt)
  488. < Self.disappearedAdvertisementRetentionInterval
  489. }
  490. observedAdvertisements = Array(observedAdvertisementsByID.values
  491. .filter {
  492. ($0.isPresent
  493. ? $0.rssi >= Self.debugMinimumRSSI
  494. : $0.peakRSSI >= Self.debugMinimumRSSI)
  495. && ($0.isPresent || now.timeIntervalSince($0.lastSeenAt) < 15)
  496. }
  497. .sorted {
  498. let leftTransitioning = now.timeIntervalSince($0.lastTransitionAt) < 8
  499. let rightTransitioning = now.timeIntervalSince($1.lastTransitionAt) < 8
  500. if leftTransitioning != rightTransitioning {
  501. return leftTransitioning
  502. }
  503. if $0.isStrongSignal != $1.isStrongSignal {
  504. return $0.isStrongSignal
  505. }
  506. if $0.matchesSpark != $1.matchesSpark {
  507. return $0.matchesSpark
  508. }
  509. return $0.rssi > $1.rssi
  510. }
  511. .prefix(50))
  512. }
  513. func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
  514. let id = peripheral.identifier.uuidString
  515. appendCommunicationLog(deviceID: id, kind: "OK", message: "BLE 连接成功")
  516. appendCommunicationLog(
  517. deviceID: id,
  518. kind: "INFO",
  519. message: "查询主服务 \(Self.serviceUUID.uuidString)"
  520. )
  521. peripherals[id] = peripheral
  522. peripheral.delegate = self
  523. connectionPhases[id] = .discovering
  524. peripheral.discoverServices([Self.serviceUUID])
  525. }
  526. func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
  527. let id = peripheral.identifier.uuidString
  528. appendCommunicationLog(
  529. deviceID: id,
  530. kind: "ERROR",
  531. message: "BLE 连接失败:\(error?.localizedDescription ?? "未知错误")"
  532. )
  533. let failure = SparkBLEError.connectionFailed(error?.localizedDescription ?? "未知错误")
  534. connectionPhases[id] = .failed(failure.localizedDescription)
  535. finishPendingBinding(deviceID: id, result: .failure(failure))
  536. }
  537. func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
  538. let id = peripheral.identifier.uuidString
  539. appendCommunicationLog(
  540. deviceID: id,
  541. kind: error == nil ? "INFO" : "ERROR",
  542. message: error == nil
  543. ? "BLE 连接已断开"
  544. : "BLE 异常断开:\(error!.localizedDescription)"
  545. )
  546. updateBoundDevice(id: id) { $0.isConnected = false }
  547. characteristics.removeValue(forKey: id)
  548. authenticatingDeviceIDs.remove(id)
  549. if recordingDeviceID == id {
  550. connectionPhases[id] = .reconnecting
  551. recorderStateConsumers[id]?(.reconnecting)
  552. } else {
  553. connectionPhases[id] = .disconnected
  554. }
  555. if let pending = pendingStarts.removeValue(forKey: id) {
  556. pending.completion(.failure(SparkBLEError.connectionFailed("设备已断开")))
  557. }
  558. if let pending = pendingStops.removeValue(forKey: id) {
  559. pending.completion(.failure(SparkBLEError.connectionFailed("停止录音前设备已断开")))
  560. }
  561. finishPendingBinding(deviceID: id, result: .failure(SparkBLEError.connectionFailed("设备已断开")))
  562. guard boundDevices.contains(where: { $0.id == id }) else { return }
  563. DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self, weak peripheral] in
  564. guard let self, let peripheral, self.centralManager.state == .poweredOn else { return }
  565. self.connectionPhases[id] = .reconnecting
  566. self.centralManager.connect(peripheral, options: nil)
  567. }
  568. }
  569. // MARK: - CBPeripheralDelegate
  570. func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
  571. let id = peripheral.identifier.uuidString
  572. guard error == nil,
  573. let service = peripheral.services?.first(where: { $0.uuid == Self.serviceUUID }) else {
  574. let discovered = peripheral.services?.map(\.uuid.uuidString).joined(separator: ", ") ?? "无"
  575. appendCommunicationLog(
  576. deviceID: id,
  577. kind: "ERROR",
  578. message: "未发现协议主服务;设备提供:\(discovered)"
  579. )
  580. let failure = SparkBLEError.incompatibleDevice
  581. connectionPhases[id] = .failed(failure.localizedDescription)
  582. finishPendingBinding(deviceID: id, result: .failure(failure))
  583. centralManager.cancelPeripheralConnection(peripheral)
  584. return
  585. }
  586. appendCommunicationLog(
  587. deviceID: id,
  588. kind: "OK",
  589. message: "发现协议主服务 \(service.uuid.uuidString)"
  590. )
  591. appendCommunicationLog(deviceID: id, kind: "INFO", message: "查询音频 Notify、写、指令 Notify 特征")
  592. peripheral.discoverCharacteristics(
  593. [Self.audioNotifyUUID, Self.commandNotifyUUID, Self.writeUUID],
  594. for: service
  595. )
  596. }
  597. func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
  598. let id = peripheral.identifier.uuidString
  599. guard error == nil else {
  600. appendCommunicationLog(
  601. deviceID: id,
  602. kind: "ERROR",
  603. message: "发现特征失败:\(error?.localizedDescription ?? "未知错误")"
  604. )
  605. failTransport(deviceID: id, message: error?.localizedDescription ?? "发现特征失败")
  606. return
  607. }
  608. var set = SparkCharacteristics()
  609. for characteristic in service.characteristics ?? [] {
  610. switch characteristic.uuid {
  611. case Self.audioNotifyUUID: set.audioNotify = characteristic
  612. case Self.commandNotifyUUID: set.commandNotify = characteristic
  613. case Self.writeUUID: set.write = characteristic
  614. default: break
  615. }
  616. }
  617. guard set.isComplete, let audio = set.audioNotify, let command = set.commandNotify else {
  618. let discovered = service.characteristics?.map(\.uuid.uuidString).joined(separator: ", ") ?? "无"
  619. appendCommunicationLog(
  620. deviceID: id,
  621. kind: "ERROR",
  622. message: "协议特征不完整;设备提供:\(discovered)"
  623. )
  624. failTransport(deviceID: id, message: "设备缺少必要的录音特征")
  625. return
  626. }
  627. appendCommunicationLog(
  628. deviceID: id,
  629. kind: "OK",
  630. message: "三个协议特征齐全:A1 音频 Notify、A2 写、A3 指令 Notify"
  631. )
  632. characteristics[id] = set
  633. appendCommunicationLog(deviceID: id, kind: "INFO", message: "订阅 A1 音频与 A3 指令通知")
  634. peripheral.setNotifyValue(true, for: command)
  635. peripheral.setNotifyValue(true, for: audio)
  636. }
  637. func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
  638. let id = peripheral.identifier.uuidString
  639. guard error == nil else {
  640. appendCommunicationLog(
  641. deviceID: id,
  642. kind: "ERROR",
  643. message: "订阅 \(characteristic.uuid.uuidString) 失败:\(error?.localizedDescription ?? "未知错误")"
  644. )
  645. failTransport(deviceID: id, message: error?.localizedDescription ?? "订阅设备通知失败")
  646. return
  647. }
  648. appendCommunicationLog(
  649. deviceID: id,
  650. kind: characteristic.isNotifying ? "OK" : "ERROR",
  651. message: "\(characteristic.uuid.uuidString) 通知\(characteristic.isNotifying ? "已开启" : "未开启")"
  652. )
  653. guard let set = characteristics[id],
  654. set.audioNotify?.isNotifying == true,
  655. set.commandNotify?.isNotifying == true else { return }
  656. transportDidBecomeReady(deviceID: id)
  657. }
  658. func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
  659. let id = peripheral.identifier.uuidString
  660. guard error == nil, let data = characteristic.value else { return }
  661. if characteristic.uuid == Self.audioNotifyUUID {
  662. audioConsumers[id]?(data)
  663. } else if characteristic.uuid == Self.commandNotifyUUID {
  664. for message in decodeCommandMessages(data) {
  665. appendCommunicationLog(deviceID: id, kind: "RX", message: message)
  666. handleCommand(message, deviceID: id)
  667. }
  668. }
  669. }
  670. func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
  671. guard let error else { return }
  672. let id = peripheral.identifier.uuidString
  673. appendCommunicationLog(
  674. deviceID: id,
  675. kind: "ERROR",
  676. message: "写入 \(characteristic.uuid.uuidString) 失败:\(error.localizedDescription)"
  677. )
  678. lastErrorMessage = "发送设备指令失败:\(error.localizedDescription)"
  679. recorderStateConsumers[id]?(.failed(lastErrorMessage ?? "发送设备指令失败"))
  680. }
  681. // MARK: - Protocol handling
  682. private func transportDidBecomeReady(deviceID: String) {
  683. appendCommunicationLog(deviceID: deviceID, kind: "OK", message: "A1 与 A3 通知均已订阅,通信通道就绪")
  684. if let pending = pendingBindings[deviceID] {
  685. connectionPhases[deviceID] = .authenticating
  686. appendCommunicationLog(
  687. deviceID: deviceID,
  688. kind: "TX",
  689. message: "PQ_BLE&SK&••••••••••••••••(16 位绑定密码已隐藏)"
  690. )
  691. do {
  692. try writeCommand("PQ_BLE&SK&\(pending.password)", to: deviceID)
  693. } catch {
  694. finishPendingBinding(deviceID: deviceID, result: .failure(error))
  695. }
  696. return
  697. }
  698. if let password = pairingPassword(for: deviceID) {
  699. authenticatingDeviceIDs.insert(deviceID)
  700. connectionPhases[deviceID] = .authenticating
  701. do {
  702. try writeCommand("PQ_BLE&SK&\(password)", to: deviceID)
  703. } catch {
  704. authenticatingDeviceIDs.remove(deviceID)
  705. markDeviceReady(deviceID)
  706. }
  707. } else {
  708. // Existing pre-Spark installations have no key in Keychain. Keep them
  709. // usable and let the first explicit rebind establish one.
  710. markDeviceReady(deviceID)
  711. }
  712. }
  713. private func handleCommand(_ message: String, deviceID: String) {
  714. // Some YLF20 firmware omits the documented "PQ_" prefix in replies
  715. // (for example, DEV&UNKNOWN). Normalize it for the protocol parser
  716. // while preserving the raw RX value in the debug log.
  717. let message = message.hasPrefix("DEV&") ? "PQ_\(message)" : message
  718. let fields = message.components(separatedBy: "&")
  719. guard fields.count >= 2 else { return }
  720. recordDiagnosticResponse(message, deviceID: deviceID)
  721. recordProtocolProbeResponse(message, deviceID: deviceID)
  722. if message == "DEV&UNKNOWN" || message == "PQ_DEV&UNKNOWN" {
  723. appendCommunicationLog(
  724. deviceID: deviceID,
  725. kind: "ERROR",
  726. message: "设备收到写入,但不识别当前命令格式"
  727. )
  728. if pendingBindings[deviceID] != nil,
  729. protocolProbeTokens[deviceID] == nil {
  730. startCompatibilityProbe(deviceID: deviceID)
  731. }
  732. return
  733. }
  734. if message.hasPrefix("PQ_DEV&SK&OK") {
  735. appendCommunicationLog(deviceID: deviceID, kind: "OK", message: "设备确认密钥配对成功")
  736. if let pending = pendingBindings[deviceID] {
  737. savePairingPassword(pending.password, for: deviceID)
  738. var bound = BoundDevice(
  739. id: deviceID,
  740. name: "微光(Spark)",
  741. peripheralUUID: pending.discoveredDevice.peripheralUUID,
  742. boundAt: Date(),
  743. isConnected: true,
  744. batteryLevel: nil,
  745. firmwareVersion: nil,
  746. boundUserId: pending.userID,
  747. advertisedName: pending.discoveredDevice.name
  748. )
  749. if let existing = boundDevices.first(where: { $0.id == deviceID }) {
  750. bound.name = existing.name
  751. }
  752. upsertBoundDevice(bound)
  753. connectionPhases[deviceID] = .ready
  754. pending.completion(.success(bound))
  755. pendingBindings.removeValue(forKey: deviceID)
  756. queryDeviceStatus(deviceID)
  757. } else if authenticatingDeviceIDs.remove(deviceID) != nil {
  758. markDeviceReady(deviceID)
  759. }
  760. return
  761. }
  762. if message.hasPrefix("PQ_DEV&SK&ERR") {
  763. appendCommunicationLog(deviceID: deviceID, kind: "ERROR", message: "设备拒绝密钥配对")
  764. authenticatingDeviceIDs.remove(deviceID)
  765. finishPendingBinding(deviceID: deviceID, result: .failure(SparkBLEError.pairingFailed))
  766. connectionPhases[deviceID] = .failed(SparkBLEError.pairingFailed.localizedDescription)
  767. return
  768. }
  769. if message.hasPrefix("PQ_DEV&STA&") {
  770. let fileName = fields.dropFirst(2).joined(separator: "&")
  771. recordingDeviceID = deviceID
  772. connectionPhases[deviceID] = .recording
  773. recorderStateConsumers[deviceID]?(.recording(fileName: fileName))
  774. pendingStarts.removeValue(forKey: deviceID)?.completion(.success(fileName))
  775. return
  776. }
  777. if message == "PQ_DEV&STO" || message.hasPrefix("PQ_DEV&STO&") {
  778. recordingDeviceID = nil
  779. connectionPhases[deviceID] = .ready
  780. pendingStops.removeValue(forKey: deviceID)?.completion(.success(message))
  781. recorderStateConsumers[deviceID]?(.ready)
  782. return
  783. }
  784. if message.hasPrefix("PQ_DEV&RT&") {
  785. recordingDeviceID = deviceID
  786. connectionPhases[deviceID] = .recording
  787. if fields.count >= 4 {
  788. recorderStateConsumers[deviceID]?(.recording(fileName: fields[2]))
  789. }
  790. return
  791. }
  792. if message.hasPrefix("PQ_DEV&BAT&"), let value = fields.last.flatMap(Int.init) {
  793. updateBoundDevice(id: deviceID) { $0.batteryLevel = min(100, max(0, value)) }
  794. } else if message.hasPrefix("PQ_DEV&STE&"), let value = fields.last.flatMap(Int.init) {
  795. if value == 1 {
  796. recordingDeviceID = deviceID
  797. connectionPhases[deviceID] = .recording
  798. } else if recordingDeviceID == deviceID {
  799. recordingDeviceID = nil
  800. connectionPhases[deviceID] = .ready
  801. }
  802. } else if message.hasPrefix("PQ_DEV&FW&"), fields.count >= 3 {
  803. updateBoundDevice(id: deviceID) { $0.firmwareVersion = fields.dropFirst(2).joined(separator: "&") }
  804. } else if message.hasPrefix("PQ_DEV&SPA&"), fields.count >= 4 {
  805. updateBoundDevice(id: deviceID) {
  806. $0.freeStorageMB = Int(fields[2])
  807. $0.totalStorageMB = Int(fields[3])
  808. }
  809. } else if message.hasPrefix("PQ_DEV&MAC&"), fields.count >= 3 {
  810. updateBoundDevice(id: deviceID) { $0.hardwareMAC = fields[2] }
  811. } else if message == "PQ_EV&REC&ERR" || message == "PQ_DEV&REC&ERR" {
  812. reportRecordingError(deviceID: deviceID, message: "微光报告录音失败,请检查设备后重试。")
  813. } else if message == "PQ_DEV&DISK&ERR" {
  814. reportRecordingError(deviceID: deviceID, message: "微光存储空间已满,录音无法继续。")
  815. }
  816. }
  817. private func decodeCommandMessages(_ data: Data) -> [String] {
  818. guard var text = String(data: data, encoding: .utf8) else { return [] }
  819. text = text.replacingOccurrences(of: "\0", with: "")
  820. .trimmingCharacters(in: .whitespacesAndNewlines)
  821. guard !text.isEmpty else { return [] }
  822. let lineMessages = text.components(separatedBy: .newlines)
  823. .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
  824. .filter { !$0.isEmpty }
  825. if lineMessages.count > 1 { return lineMessages }
  826. // Also tolerate multiple prefix-delimited messages in one notification.
  827. let pattern = "(?=PQ_(?:DEV|EV)&)"
  828. if let regex = try? NSRegularExpression(pattern: pattern) {
  829. let range = NSRange(text.startIndex..., in: text)
  830. let matches = regex.matches(in: text, range: range)
  831. if matches.count > 1 {
  832. return matches.enumerated().compactMap { index, match in
  833. let start = match.range.location
  834. let end = index + 1 < matches.count ? matches[index + 1].range.location : range.length
  835. guard let swiftRange = Range(NSRange(location: start, length: end - start), in: text) else { return nil }
  836. return String(text[swiftRange])
  837. }
  838. }
  839. }
  840. return [text]
  841. }
  842. private func writeCommand(_ command: String, to deviceID: String) throws {
  843. try writeCommand(command, to: deviceID, wireFormat: .documented)
  844. }
  845. private func writeCommand(
  846. _ command: String,
  847. to deviceID: String,
  848. wireFormat: ProbeWireFormat,
  849. logAsProbe: Bool = false
  850. ) throws {
  851. guard let peripheral = peripherals[deviceID],
  852. peripheral.state == .connected,
  853. let characteristic = characteristics[deviceID]?.write else {
  854. throw SparkBLEError.commandUnavailable
  855. }
  856. let data = wireFormat.data(for: command)
  857. let writeType: CBCharacteristicWriteType
  858. if characteristic.properties.contains(.write) {
  859. writeType = .withResponse
  860. } else if characteristic.properties.contains(.writeWithoutResponse) {
  861. writeType = .withoutResponse
  862. } else {
  863. throw SparkBLEError.commandUnavailable
  864. }
  865. guard data.count <= peripheral.maximumWriteValueLength(for: writeType) else {
  866. throw SparkBLEError.deviceReported("设备指令超过单帧写入长度。")
  867. }
  868. if logAsProbe {
  869. appendCommunicationLog(
  870. deviceID: deviceID,
  871. kind: "TX",
  872. message: "兼容探测[\(wireFormat.label)]:PQ_BLE&STE(\(data.count) bytes)"
  873. )
  874. } else if !command.hasPrefix("PQ_BLE&SK&") {
  875. appendCommunicationLog(deviceID: deviceID, kind: "TX", message: command)
  876. }
  877. peripheral.writeValue(data, for: characteristic, type: writeType)
  878. }
  879. private func startCompatibilityProbe(deviceID: String) {
  880. let token = UUID()
  881. protocolProbeTokens[deviceID] = token
  882. appendCommunicationLog(
  883. deviceID: deviceID,
  884. kind: "INFO",
  885. message: "开始只读兼容探测,不会改变录音或设备设置"
  886. )
  887. for (index, format) in ProbeWireFormat.allCases.enumerated() {
  888. DispatchQueue.main.asyncAfter(deadline: .now() + 0.5 + Double(index) * 0.9) { [weak self] in
  889. guard let self, self.protocolProbeTokens[deviceID] == token else { return }
  890. self.activeProbeFormats[deviceID] = format
  891. do {
  892. try self.writeCommand(
  893. "PQ_BLE&STE",
  894. to: deviceID,
  895. wireFormat: format,
  896. logAsProbe: true
  897. )
  898. } catch {
  899. self.appendCommunicationLog(
  900. deviceID: deviceID,
  901. kind: "ERROR",
  902. message: "兼容探测写入失败:\(error.localizedDescription)"
  903. )
  904. }
  905. }
  906. }
  907. DispatchQueue.main.asyncAfter(deadline: .now() + 7) { [weak self] in
  908. guard let self, self.protocolProbeTokens[deviceID] == token else { return }
  909. self.protocolProbeTokens.removeValue(forKey: deviceID)
  910. self.activeProbeFormats.removeValue(forKey: deviceID)
  911. self.appendCommunicationLog(
  912. deviceID: deviceID,
  913. kind: "ERROR",
  914. message: "六种安全格式均未得到录音状态回复;固件协议与当前文档不一致"
  915. )
  916. }
  917. }
  918. private func recordProtocolProbeResponse(_ message: String, deviceID: String) {
  919. guard protocolProbeTokens[deviceID] != nil,
  920. message.hasPrefix("PQ_DEV&STE&") || message.hasPrefix("DEV&STE&"),
  921. let format = activeProbeFormats[deviceID] else { return }
  922. protocolProbeTokens.removeValue(forKey: deviceID)
  923. activeProbeFormats.removeValue(forKey: deviceID)
  924. appendCommunicationLog(
  925. deviceID: deviceID,
  926. kind: "OK",
  927. message: "兼容探测成功:设备接受“\(format.label)”"
  928. )
  929. guard format != .documented,
  930. let pending = pendingBindings[deviceID] else {
  931. if pendingBindings[deviceID] != nil {
  932. appendCommunicationLog(
  933. deviceID: deviceID,
  934. kind: "ERROR",
  935. message: "普通查询可用,但当前固件不支持文档中的 SK 配对命令"
  936. )
  937. }
  938. return
  939. }
  940. appendCommunicationLog(
  941. deviceID: deviceID,
  942. kind: "TX",
  943. message: "使用“\(format.label)”重试 16 位配对命令(密码已隐藏)"
  944. )
  945. do {
  946. try writeCommand(
  947. "PQ_BLE&SK&\(pending.password)",
  948. to: deviceID,
  949. wireFormat: format
  950. )
  951. } catch {
  952. appendCommunicationLog(
  953. deviceID: deviceID,
  954. kind: "ERROR",
  955. message: "兼容配对写入失败:\(error.localizedDescription)"
  956. )
  957. }
  958. }
  959. private func queryDeviceStatus(_ deviceID: String) {
  960. let token = UUID()
  961. diagnosticTokens[deviceID] = token
  962. pendingDiagnosticResponses[deviceID] = [
  963. "录音状态": "PQ_DEV&STE&",
  964. "电量": "PQ_DEV&BAT&",
  965. "容量": "PQ_DEV&SPA&",
  966. "固件": "PQ_DEV&FW&",
  967. "MAC": "PQ_DEV&MAC&",
  968. "校时": "PQ_DEV&T&OK"
  969. ]
  970. let commands = ["PQ_BLE&STE", "PQ_BLE&BAT", "PQ_BLE&SPACE", "PQ_BLE&FW", "PQ_BLE&MAC", currentTimeCommand()]
  971. for (index, command) in commands.enumerated() {
  972. DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * 0.25) { [weak self] in
  973. try? self?.writeCommand(command, to: deviceID)
  974. }
  975. }
  976. DispatchQueue.main.asyncAfter(deadline: .now() + 6) { [weak self] in
  977. guard let self, self.diagnosticTokens[deviceID] == token else { return }
  978. let missing = self.pendingDiagnosticResponses[deviceID]?.keys.sorted() ?? []
  979. if missing.isEmpty {
  980. self.appendCommunicationLog(
  981. deviceID: deviceID,
  982. kind: "OK",
  983. message: "协议诊断完成,全部查询均收到预期格式回复"
  984. )
  985. } else {
  986. self.appendCommunicationLog(
  987. deviceID: deviceID,
  988. kind: "ERROR",
  989. message: "协议诊断超时,未收到:\(missing.joined(separator: "、"))"
  990. )
  991. }
  992. self.pendingDiagnosticResponses.removeValue(forKey: deviceID)
  993. self.diagnosticTokens.removeValue(forKey: deviceID)
  994. }
  995. }
  996. private func recordDiagnosticResponse(_ message: String, deviceID: String) {
  997. guard var pending = pendingDiagnosticResponses[deviceID] else { return }
  998. let matched = pending.first { message.hasPrefix($0.value) }
  999. guard let matched else { return }
  1000. pending.removeValue(forKey: matched.key)
  1001. pendingDiagnosticResponses[deviceID] = pending
  1002. appendCommunicationLog(
  1003. deviceID: deviceID,
  1004. kind: "OK",
  1005. message: "\(matched.key)回复格式正确"
  1006. )
  1007. }
  1008. private func appendCommunicationLog(deviceID: String, kind: String, message: String) {
  1009. communicationLogs.append(
  1010. BLECommunicationLogEntry(
  1011. deviceID: deviceID,
  1012. timestamp: Date(),
  1013. kind: kind,
  1014. message: message
  1015. )
  1016. )
  1017. if communicationLogs.count > 300 {
  1018. communicationLogs.removeFirst(communicationLogs.count - 300)
  1019. }
  1020. }
  1021. private func currentTimeCommand() -> String {
  1022. let formatter = DateFormatter()
  1023. formatter.locale = Locale(identifier: "en_US_POSIX")
  1024. formatter.dateFormat = "yyyyMMddHHmmss"
  1025. return "PQ_BLE&T&\(formatter.string(from: Date()))"
  1026. }
  1027. // MARK: - State and persistence
  1028. private func markDeviceReady(_ deviceID: String) {
  1029. updateBoundDevice(id: deviceID) { $0.isConnected = true }
  1030. connectionPhases[deviceID] = recordingDeviceID == deviceID ? .recording : .ready
  1031. recorderStateConsumers[deviceID]?(.ready)
  1032. queryDeviceStatus(deviceID)
  1033. }
  1034. private func reconnectBoundDevices() {
  1035. let identifiers = boundDevices.compactMap { UUID(uuidString: $0.peripheralUUID) }
  1036. guard !identifiers.isEmpty else { return }
  1037. for peripheral in centralManager.retrievePeripherals(withIdentifiers: identifiers) {
  1038. let id = peripheral.identifier.uuidString
  1039. peripherals[id] = peripheral
  1040. peripheral.delegate = self
  1041. connectionPhases[id] = .reconnecting
  1042. centralManager.connect(peripheral, options: nil)
  1043. }
  1044. }
  1045. private func markAllDevicesDisconnected() {
  1046. for index in boundDevices.indices {
  1047. boundDevices[index].isConnected = false
  1048. connectionPhases[boundDevices[index].id] = .disconnected
  1049. }
  1050. saveBoundDevices()
  1051. }
  1052. private func updateBoundDevice(id: String, mutation: (inout BoundDevice) -> Void) {
  1053. guard let index = boundDevices.firstIndex(where: { $0.id == id }) else { return }
  1054. mutation(&boundDevices[index])
  1055. saveBoundDevices()
  1056. }
  1057. private func upsertBoundDevice(_ device: BoundDevice) {
  1058. boundDevices.removeAll(where: { $0.id == device.id })
  1059. boundDevices.append(device)
  1060. saveBoundDevices()
  1061. }
  1062. private func finishPendingBinding(deviceID: String, result: Result<BoundDevice, Error>) {
  1063. guard let pending = pendingBindings.removeValue(forKey: deviceID) else { return }
  1064. pending.completion(result)
  1065. }
  1066. private func failTransport(deviceID: String, message: String) {
  1067. let error = SparkBLEError.connectionFailed(message)
  1068. connectionPhases[deviceID] = .failed(error.localizedDescription)
  1069. finishPendingBinding(deviceID: deviceID, result: .failure(error))
  1070. recorderStateConsumers[deviceID]?(.failed(error.localizedDescription))
  1071. }
  1072. private func reportRecordingError(deviceID: String, message: String) {
  1073. lastErrorMessage = message
  1074. recorderStateConsumers[deviceID]?(.failed(message))
  1075. pendingStarts.removeValue(forKey: deviceID)?.completion(.failure(SparkBLEError.deviceReported(message)))
  1076. }
  1077. private func scheduleTimeout(for deviceID: String, command: String, token: UUID, isStart: Bool) {
  1078. DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in
  1079. guard let self else { return }
  1080. if isStart, self.pendingStarts[deviceID]?.token == token {
  1081. self.pendingStarts.removeValue(forKey: deviceID)?.completion(.failure(SparkBLEError.commandTimedOut(command)))
  1082. } else if !isStart, self.pendingStops[deviceID]?.token == token {
  1083. self.pendingStops.removeValue(forKey: deviceID)?.completion(.failure(SparkBLEError.commandTimedOut(command)))
  1084. }
  1085. }
  1086. }
  1087. private func saveBoundDevices() {
  1088. if let data = try? JSONEncoder().encode(boundDevices) {
  1089. UserDefaults.standard.set(data, forKey: boundDevicesKey)
  1090. }
  1091. }
  1092. private func loadBoundDevices() {
  1093. guard let data = UserDefaults.standard.data(forKey: boundDevicesKey),
  1094. var devices = try? JSONDecoder().decode([BoundDevice].self, from: data) else { return }
  1095. let mockDeviceID = "MOCK-SPARK-01"
  1096. if devices.contains(where: { $0.id == mockDeviceID || $0.peripheralUUID == mockDeviceID }) {
  1097. devices.removeAll { $0.id == mockDeviceID || $0.peripheralUUID == mockDeviceID }
  1098. deletePairingPassword(for: mockDeviceID)
  1099. if let cleaned = try? JSONEncoder().encode(devices) {
  1100. UserDefaults.standard.set(cleaned, forKey: boundDevicesKey)
  1101. }
  1102. }
  1103. for index in devices.indices {
  1104. devices[index].isConnected = false
  1105. }
  1106. boundDevices = devices
  1107. }
  1108. // MARK: - Pairing secret
  1109. private func generatePairingPassword() -> String {
  1110. let alphabet = Array("ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789")
  1111. var bytes = [UInt8](repeating: 0, count: 16)
  1112. let result = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
  1113. if result == errSecSuccess {
  1114. return String(bytes.map { alphabet[Int($0) % alphabet.count] })
  1115. }
  1116. return String(UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(16))
  1117. }
  1118. private func savePairingPassword(_ password: String, for deviceID: String) {
  1119. deletePairingPassword(for: deviceID)
  1120. let query: [String: Any] = [
  1121. kSecClass as String: kSecClassGenericPassword,
  1122. kSecAttrService as String: keychainService,
  1123. kSecAttrAccount as String: deviceID,
  1124. kSecValueData as String: Data(password.utf8),
  1125. kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
  1126. ]
  1127. SecItemAdd(query as CFDictionary, nil)
  1128. }
  1129. private func pairingPassword(for deviceID: String) -> String? {
  1130. let query: [String: Any] = [
  1131. kSecClass as String: kSecClassGenericPassword,
  1132. kSecAttrService as String: keychainService,
  1133. kSecAttrAccount as String: deviceID,
  1134. kSecReturnData as String: true,
  1135. kSecMatchLimit as String: kSecMatchLimitOne
  1136. ]
  1137. var result: CFTypeRef?
  1138. guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
  1139. let data = result as? Data else { return nil }
  1140. return String(data: data, encoding: .utf8)
  1141. }
  1142. private func deletePairingPassword(for deviceID: String) {
  1143. let query: [String: Any] = [
  1144. kSecClass as String: kSecClassGenericPassword,
  1145. kSecAttrService as String: keychainService,
  1146. kSecAttrAccount as String: deviceID
  1147. ]
  1148. SecItemDelete(query as CFDictionary)
  1149. }
  1150. }
  1151. // MARK: - Spark audio recorder
  1152. /// Records the MP3 notification stream produced by a bound Spark device.
  1153. final class SparkAudioRecorder: AudioRecorderProtocol {
  1154. @Published var isRecording: Bool = false
  1155. @Published var elapsedTime: TimeInterval = 0
  1156. @Published var currentAmplitude: Float = 0
  1157. @Published var waveformSamples: [Float] = []
  1158. @Published var outputFileURL: URL?
  1159. @Published var isPaused: Bool = false
  1160. @Published var statusMessage: String = "正在连接设备"
  1161. @Published var errorMessage: String?
  1162. let sourceDisplayName: String
  1163. private let deviceID: String
  1164. private let manager: BLEManager
  1165. private let ioQueue = DispatchQueue(label: "com.celestia.trace.spark.audio-io")
  1166. private var fileHandle: FileHandle?
  1167. private var timer: Timer?
  1168. private var recordingStartedAt: Date?
  1169. private var stopCompletion: ((Result<URL?, Error>) -> Void)?
  1170. init(deviceID: String, displayName: String, manager: BLEManager = .shared) {
  1171. self.deviceID = deviceID
  1172. self.sourceDisplayName = displayName
  1173. self.manager = manager
  1174. }
  1175. func startRecording() {
  1176. guard !isRecording, fileHandle == nil else { return }
  1177. errorMessage = nil
  1178. statusMessage = "正在启动 \(sourceDisplayName)"
  1179. elapsedTime = 0
  1180. waveformSamples = []
  1181. currentAmplitude = 0
  1182. do {
  1183. let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
  1184. let url = documents.appendingPathComponent("spark_\(UUID().uuidString).mp3")
  1185. FileManager.default.createFile(atPath: url.path, contents: nil)
  1186. fileHandle = try FileHandle(forWritingTo: url)
  1187. outputFileURL = url
  1188. } catch {
  1189. errorMessage = "无法创建微光录音文件:\(error.localizedDescription)"
  1190. statusMessage = "启动失败"
  1191. return
  1192. }
  1193. manager.startSparkRecording(
  1194. deviceID: deviceID,
  1195. onAudioData: { [weak self] data in self?.appendAudioData(data) },
  1196. onStateChange: { [weak self] event in self?.handleStateEvent(event) }
  1197. ) { [weak self] result in
  1198. guard let self else { return }
  1199. DispatchQueue.main.async {
  1200. switch result {
  1201. case .success:
  1202. self.isRecording = true
  1203. self.statusMessage = "正在记录"
  1204. self.recordingStartedAt = Date()
  1205. self.startTimer()
  1206. case .failure(let error):
  1207. self.errorMessage = error.localizedDescription
  1208. self.statusMessage = "启动失败"
  1209. self.finishFile { _ in }
  1210. }
  1211. }
  1212. }
  1213. }
  1214. func stopRecording() {
  1215. stopRecording { _ in }
  1216. }
  1217. func stopRecording(completion: @escaping (Result<URL?, Error>) -> Void) {
  1218. guard fileHandle != nil else {
  1219. completion(.success(outputFileURL))
  1220. return
  1221. }
  1222. guard isRecording else {
  1223. finishFile(completion: completion)
  1224. return
  1225. }
  1226. statusMessage = "正在保存设备录音"
  1227. stopCompletion = completion
  1228. manager.stopSparkRecording(deviceID: deviceID) { [weak self] result in
  1229. guard let self else { return }
  1230. DispatchQueue.main.async {
  1231. switch result {
  1232. case .success:
  1233. self.isRecording = false
  1234. self.invalidateTimer()
  1235. self.finishFile { result in
  1236. self.manager.detachRecorder(deviceID: self.deviceID)
  1237. self.stopCompletion?(result)
  1238. self.stopCompletion = nil
  1239. }
  1240. case .failure(let error):
  1241. self.errorMessage = error.localizedDescription
  1242. self.statusMessage = "设备尚未确认停止"
  1243. self.stopCompletion?(.failure(error))
  1244. self.stopCompletion = nil
  1245. }
  1246. }
  1247. }
  1248. }
  1249. func pauseRecording() {
  1250. errorMessage = "微光暂不支持暂停,请结束当前录音。"
  1251. }
  1252. func resumeRecording() {}
  1253. private func appendAudioData(_ data: Data) {
  1254. guard !data.isEmpty else { return }
  1255. ioQueue.async { [weak self] in
  1256. guard let self else { return }
  1257. do {
  1258. try self.fileHandle?.write(contentsOf: data)
  1259. } catch {
  1260. DispatchQueue.main.async {
  1261. self.errorMessage = "保存微光音频失败:\(error.localizedDescription)"
  1262. }
  1263. }
  1264. }
  1265. // This represents stream activity until incremental MP3 PCM metering is available.
  1266. let activity = min(1, max(0.08, Float(data.count) / 244.0))
  1267. DispatchQueue.main.async { [weak self] in
  1268. guard let self else { return }
  1269. self.currentAmplitude = activity
  1270. self.waveformSamples.append(activity)
  1271. if self.waveformSamples.count > 200 {
  1272. self.waveformSamples.removeFirst(self.waveformSamples.count - 200)
  1273. }
  1274. }
  1275. }
  1276. private func handleStateEvent(_ event: SparkRecorderConnectionEvent) {
  1277. DispatchQueue.main.async { [weak self] in
  1278. guard let self else { return }
  1279. switch event {
  1280. case .recording:
  1281. if self.isRecording { self.statusMessage = "正在记录" }
  1282. case .reconnecting:
  1283. self.statusMessage = "连接中断,正在重连"
  1284. case .ready:
  1285. if self.isRecording { self.statusMessage = "正在记录" }
  1286. case .failed(let message):
  1287. self.errorMessage = message
  1288. self.statusMessage = "设备异常"
  1289. }
  1290. }
  1291. }
  1292. private func startTimer() {
  1293. invalidateTimer()
  1294. timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
  1295. guard let self, let startedAt = self.recordingStartedAt else { return }
  1296. self.elapsedTime = Date().timeIntervalSince(startedAt)
  1297. }
  1298. if let timer { RunLoop.main.add(timer, forMode: .common) }
  1299. }
  1300. private func invalidateTimer() {
  1301. timer?.invalidate()
  1302. timer = nil
  1303. }
  1304. private func finishFile(completion: @escaping (Result<URL?, Error>) -> Void) {
  1305. let url = outputFileURL
  1306. ioQueue.async { [weak self] in
  1307. guard let self else { return }
  1308. do {
  1309. try self.fileHandle?.synchronize()
  1310. try self.fileHandle?.close()
  1311. self.fileHandle = nil
  1312. DispatchQueue.main.async { completion(.success(url)) }
  1313. } catch {
  1314. self.fileHandle = nil
  1315. DispatchQueue.main.async { completion(.failure(error)) }
  1316. }
  1317. }
  1318. }
  1319. deinit {
  1320. invalidateTimer()
  1321. try? fileHandle?.close()
  1322. manager.detachRecorder(deviceID: deviceID)
  1323. }
  1324. }