BLEManager.swift 60 KB

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