import Foundation import CoreBluetooth import Combine import Security enum SparkConnectionPhase: Equatable { case disconnected case connecting case discovering case authenticating case ready case recording case reconnecting case failed(String) } enum SparkRecorderConnectionEvent { case recording(fileName: String) case reconnecting case ready case failed(String) } struct BLECommunicationLogEntry: Identifiable, Equatable { let id = UUID() let deviceID: String let timestamp: Date let kind: String let message: String } struct ObservedBLEAdvertisement: Identifiable, Equatable { let id: String var name: String var serviceUUIDs: [String] var rssi: Int var peakRSSI: Int var firstSeenAt: Date var lastSeenAt: Date var lastTransitionAt: Date var isPresent: Bool var reappearanceCount: Int var matchesSpark: Bool { BLEManager.isSparkAdvertisement( name: name, serviceUUIDs: serviceUUIDs.map { CBUUID(string: $0) } ) } var isStrongSignal: Bool { rssi >= -60 } } enum SparkBLEError: LocalizedError { case bluetoothUnavailable case deviceUnavailable case incompatibleDevice case connectionFailed(String) case commandUnavailable case commandTimedOut(String) case pairingFailed case deviceReported(String) var errorDescription: String? { switch self { case .bluetoothUnavailable: return "蓝牙当前不可用,请确认系统蓝牙已开启。" case .deviceUnavailable: return "找不到该微光设备,请让设备靠近 iPhone 后重试。" case .incompatibleDevice: return "该设备没有提供微光录音服务。" case .connectionFailed(let reason): return "连接微光失败:\(reason)" case .commandUnavailable: return "微光指令通道尚未就绪。" case .commandTimedOut(let command): return "设备未及时响应指令 \(command)。" case .pairingFailed: return "微光密钥配对失败,请重置设备后重试。" case .deviceReported(let message): return message } } } /// Owns the CoreBluetooth central role and implements the MR20/Spark command protocol. final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeripheralDelegate { static let shared = BLEManager() private static let debugMinimumRSSI = -75 private static let advertisementDisappearanceInterval: TimeInterval = 4 private static let disappearedAdvertisementRetentionInterval: TimeInterval = 30 static let serviceUUID = CBUUID(string: "001120a0-2233-4455-6677-88995a5b5c5d") static let audioNotifyUUID = CBUUID(string: "001120a1-2233-4455-6677-88995a5b5c5d") static let writeUUID = CBUUID(string: "001120a2-2233-4455-6677-88995a5b5c5d") static let commandNotifyUUID = CBUUID(string: "001120a3-2233-4455-6677-88995a5b5c5d") static func isYLF20AdvertisementName(_ name: String?) -> Bool { let normalizedName = name?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" return normalizedName == "ylf20" || normalizedName.hasPrefix("ylf20_") } static func isSparkAdvertisement(name: String?, serviceUUIDs: [CBUUID]) -> Bool { let normalizedName = name?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" return serviceUUIDs.contains(serviceUUID) || isYLF20AdvertisementName(name) || normalizedName.contains("spark") || normalizedName.contains("mr20") || normalizedName.contains("微光") } @Published private(set) var state: CBManagerState = .unknown @Published private(set) var isScanning: Bool = false @Published private(set) var discoveredDevices: [DiscoveredBLEDevice] = [] @Published private(set) var observedPeripheralCount: Int = 0 @Published private(set) var observedAdvertisements: [ObservedBLEAdvertisement] = [] @Published private(set) var communicationLogs: [BLECommunicationLogEntry] = [] @Published private(set) var boundDevices: [BoundDevice] = [] @Published private(set) var connectionPhases: [String: SparkConnectionPhase] = [:] @Published private(set) var recordingDeviceID: String? @Published private(set) var lastErrorMessage: String? private struct SparkCharacteristics { var audioNotify: CBCharacteristic? var commandNotify: CBCharacteristic? var write: CBCharacteristic? var isComplete: Bool { audioNotify != nil && commandNotify != nil && write != nil } } private struct PendingBinding { let discoveredDevice: DiscoveredBLEDevice let userID: String let password: String let completion: (Result) -> Void } private struct PendingCommand { let token: UUID let completion: (Result) -> Void } private enum ProbeWireFormat: CaseIterable { case documented case documentedCRLF case documentedNull case legacyPrefix case legacyPrefixCRLF case legacyPrefixNull var label: String { switch self { case .documented: return "文档原格式" case .documentedCRLF: return "文档格式 + CRLF" case .documentedNull: return "文档格式 + NUL" case .legacyPrefix: return "无 PQ_ 前缀" case .legacyPrefixCRLF: return "无 PQ_ 前缀 + CRLF" case .legacyPrefixNull: return "无 PQ_ 前缀 + NUL" } } func data(for command: String) -> Data { let base: String switch self { case .legacyPrefix, .legacyPrefixCRLF, .legacyPrefixNull: base = command.replacingOccurrences(of: "PQ_BLE&", with: "BLE&") default: base = command } switch self { case .documentedCRLF, .legacyPrefixCRLF: return Data("\(base)\r\n".utf8) case .documentedNull, .legacyPrefixNull: var data = Data(base.utf8) data.append(0) return data default: return Data(base.utf8) } } } private var centralManager: CBCentralManager! private let boundDevicesKey = "com.celestia.trace.bound_devices" private let restorationIdentifier = "com.celestia.trace.spark.central" private let keychainService = "com.celestia.trace.spark.pairing" private var peripherals: [String: CBPeripheral] = [:] private var characteristics: [String: SparkCharacteristics] = [:] private var pendingBindings: [String: PendingBinding] = [:] private var authenticatingDeviceIDs: Set = [] private var pendingStarts: [String: PendingCommand] = [:] private var pendingStops: [String: PendingCommand] = [:] private var audioConsumers: [String: (Data) -> Void] = [:] private var recorderStateConsumers: [String: (SparkRecorderConnectionEvent) -> Void] = [:] private var scanRequested = false private var observedPeripheralIDs: Set = [] private var observedAdvertisementsByID: [String: ObservedBLEAdvertisement] = [:] private var advertisementRefreshTimer: Timer? private var pendingDiagnosticResponses: [String: [String: String]] = [:] private var diagnosticTokens: [String: UUID] = [:] private var protocolProbeTokens: [String: UUID] = [:] private var activeProbeFormats: [String: ProbeWireFormat] = [:] var scanUnavailableMessage: String? { switch state { case .unauthorized: return "蓝牙权限未开启。请前往“设置”允许星痕访问蓝牙。" case .poweredOff: return "系统蓝牙已关闭,请先在控制中心或“设置”中开启蓝牙。" case .unsupported: return "当前运行设备不支持 BLE 扫描。请使用支持蓝牙的 iPhone 真机测试。" default: return nil } } override private init() { super.init() loadBoundDevices() centralManager = CBCentralManager( delegate: self, queue: nil, options: [CBCentralManagerOptionRestoreIdentifierKey: restorationIdentifier] ) } // MARK: - Public device access func devices(forUserId userId: String) -> [BoundDevice] { boundDevices.filter { $0.boundUserId == userId } } func connectedDevices(forUserId userId: String) -> [BoundDevice] { devices(forUserId: userId).filter { $0.isConnected } } func phase(for deviceID: String) -> SparkConnectionPhase { connectionPhases[deviceID] ?? .disconnected } func communicationLogs(for deviceID: String) -> [BLECommunicationLogEntry] { communicationLogs.filter { $0.deviceID == deviceID } } func renameDevice(id: String, to proposedName: String) { let trimmed = proposedName.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, let index = boundDevices.firstIndex(where: { $0.id == id }) else { return } boundDevices[index].name = String(trimmed.prefix(30)) saveBoundDevices() } func unbindDevice(id: String) { var peripheralToDisconnect: CBPeripheral? if boundDevices.first(where: { $0.id == id })?.isConnected == true { try? writeCommand("PQ_BLE&BLE&OFF", to: id) peripheralToDisconnect = peripherals[id] } deletePairingPassword(for: id) boundDevices.removeAll(where: { $0.id == id }) connectionPhases[id] = .disconnected if let peripheralToDisconnect { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self, weak peripheralToDisconnect] in guard let self, let peripheralToDisconnect else { return } self.centralManager.cancelPeripheralConnection(peripheralToDisconnect) } } else if let peripheral = peripherals[id] { centralManager.cancelPeripheralConnection(peripheral) } saveBoundDevices() } func updateCloudRegistration(deviceID: String, cloudID: String?, error: String?) { updateBoundDevice(id: deviceID) { $0.cloudID = cloudID $0.cloudSyncError = error } } // MARK: - Scanning and binding func startScanning() { discoveredDevices.removeAll() observedPeripheralIDs.removeAll() observedPeripheralCount = 0 observedAdvertisementsByID.removeAll() observedAdvertisements.removeAll() lastErrorMessage = nil scanRequested = true startAdvertisementRefreshTimer() guard centralManager.state == .poweredOn else { // Keep the request pending while CoreBluetooth initializes. The // delegate starts scanning if the state later becomes powered on. isScanning = false return } isScanning = true // Some Spark firmware versions do not advertise the service UUID. Scan // broadly, but only surface peripherals that explicitly advertise the // Spark service or a known product name. centralManager.scanForPeripherals( withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: true] ) } func stopScanning() { scanRequested = false advertisementRefreshTimer?.invalidate() advertisementRefreshTimer = nil if centralManager.state == .poweredOn { centralManager.stopScan() } isScanning = false } func connectAndBind( _ device: DiscoveredBLEDevice, userId: String, completion: @escaping (Result) -> Void ) { guard centralManager.state == .poweredOn else { completion(.failure(SparkBLEError.bluetoothUnavailable)) return } guard let peripheral = peripherals[device.id] else { completion(.failure(SparkBLEError.deviceUnavailable)) return } communicationLogs.removeAll { $0.deviceID == device.id } appendCommunicationLog( deviceID: device.id, kind: "INFO", message: "开始绑定 \(device.name),RSSI \(device.rssi) dBm" ) let password = generatePairingPassword() pendingBindings[device.id] = PendingBinding( discoveredDevice: device, userID: userId, password: password, completion: completion ) connectionPhases[device.id] = .connecting peripheral.delegate = self stopScanning() appendCommunicationLog(deviceID: device.id, kind: "INFO", message: "正在建立 BLE 连接") centralManager.connect(peripheral, options: nil) } func runProtocolDiagnostics(deviceID: String) { guard peripherals[deviceID]?.state == .connected, characteristics[deviceID]?.isComplete == true else { appendCommunicationLog( deviceID: deviceID, kind: "ERROR", message: "通信通道尚未就绪,无法发送诊断指令" ) return } appendCommunicationLog( deviceID: deviceID, kind: "INFO", message: "重新执行协议诊断:状态、电量、容量、固件、MAC、校时" ) queryDeviceStatus(deviceID) } // MARK: - Spark recording control func startSparkRecording( deviceID: String, onAudioData: @escaping (Data) -> Void, onStateChange: @escaping (SparkRecorderConnectionEvent) -> Void, completion: @escaping (Result) -> Void ) { guard boundDevices.first(where: { $0.id == deviceID })?.isConnected == true else { completion(.failure(SparkBLEError.deviceUnavailable)) return } audioConsumers[deviceID] = onAudioData recorderStateConsumers[deviceID] = onStateChange let token = UUID() pendingStarts[deviceID] = PendingCommand(token: token, completion: completion) do { try writeCommand("PQ_BLE&STA", to: deviceID) scheduleTimeout(for: deviceID, command: "STA", token: token, isStart: true) } catch { pendingStarts.removeValue(forKey: deviceID) completion(.failure(error)) } } func stopSparkRecording( deviceID: String, completion: @escaping (Result) -> Void ) { guard boundDevices.first(where: { $0.id == deviceID })?.isConnected == true else { completion(.failure(SparkBLEError.deviceUnavailable)) return } let token = UUID() pendingStops[deviceID] = PendingCommand(token: token, completion: completion) do { try writeCommand("PQ_BLE&STO", to: deviceID) scheduleTimeout(for: deviceID, command: "STO", token: token, isStart: false) } catch { pendingStops.removeValue(forKey: deviceID) completion(.failure(error)) } } func detachRecorder(deviceID: String) { audioConsumers.removeValue(forKey: deviceID) recorderStateConsumers.removeValue(forKey: deviceID) } // MARK: - CBCentralManagerDelegate func centralManagerDidUpdateState(_ central: CBCentralManager) { state = central.state guard central.state == .poweredOn else { isScanning = false markAllDevicesDisconnected() return } reconnectBoundDevices() if scanRequested { isScanning = true central.scanForPeripherals( withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: true] ) } } func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) { let restored = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] ?? [] for peripheral in restored { let id = peripheral.identifier.uuidString peripherals[id] = peripheral peripheral.delegate = self connectionPhases[id] = peripheral.state == .connected ? .discovering : .reconnecting if peripheral.state == .connected { peripheral.discoverServices([Self.serviceUUID]) } else { central.connect(peripheral, options: nil) } } } func centralManager( _ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber ) { let id = peripheral.identifier.uuidString observedPeripheralIDs.insert(id) observedPeripheralCount = observedPeripheralIDs.count let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? peripheral.name let advertisedServices = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? [] guard RSSI.intValue != 127 else { return } let now = Date() let trimmedAdvertisedName = advertisedName? .trimmingCharacters(in: .whitespacesAndNewlines) let displayName = trimmedAdvertisedName?.isEmpty == false ? trimmedAdvertisedName! : "未提供名称" let serviceUUIDStrings = advertisedServices.map(\.uuidString) if var existing = observedAdvertisementsByID[id] { let hadDisappeared = !existing.isPresent || now.timeIntervalSince(existing.lastSeenAt) >= Self.advertisementDisappearanceInterval existing.name = displayName existing.serviceUUIDs = serviceUUIDStrings existing.rssi = RSSI.intValue existing.peakRSSI = max(existing.peakRSSI, RSSI.intValue) existing.lastSeenAt = now existing.isPresent = true if hadDisappeared { existing.reappearanceCount += 1 existing.lastTransitionAt = now } observedAdvertisementsByID[id] = existing } else if RSSI.intValue >= Self.debugMinimumRSSI { observedAdvertisementsByID[id] = ObservedBLEAdvertisement( id: id, name: displayName, serviceUUIDs: serviceUUIDStrings, rssi: RSSI.intValue, peakRSSI: RSSI.intValue, firstSeenAt: now, lastSeenAt: now, lastTransitionAt: now, isPresent: true, reappearanceCount: 0 ) } publishObservedAdvertisements(now: now) // The formal binding list is intentionally restricted to the confirmed // production broadcast name. Service/characteristic UUIDs are verified // after connecting. guard Self.isYLF20AdvertisementName(advertisedName) else { return } peripherals[id] = peripheral peripheral.delegate = self let device = DiscoveredBLEDevice( id: id, name: trimmedAdvertisedName?.isEmpty == false ? trimmedAdvertisedName! : "微光(Spark)", rssi: RSSI.intValue, peripheralUUID: id ) if let index = discoveredDevices.firstIndex(where: { $0.id == id }) { discoveredDevices[index] = device } else { discoveredDevices.append(device) } discoveredDevices.sort { $0.rssi > $1.rssi } } private func startAdvertisementRefreshTimer() { advertisementRefreshTimer?.invalidate() advertisementRefreshTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in self?.publishObservedAdvertisements(now: Date()) } } private func publishObservedAdvertisements(now: Date) { for (id, var advertisement) in observedAdvertisementsByID { let elapsed = now.timeIntervalSince(advertisement.lastSeenAt) if advertisement.isPresent, elapsed >= Self.advertisementDisappearanceInterval { advertisement.isPresent = false advertisement.lastTransitionAt = now observedAdvertisementsByID[id] = advertisement } } observedAdvertisementsByID = observedAdvertisementsByID.filter { now.timeIntervalSince($0.value.lastSeenAt) < Self.disappearedAdvertisementRetentionInterval } observedAdvertisements = Array(observedAdvertisementsByID.values .filter { ($0.isPresent ? $0.rssi >= Self.debugMinimumRSSI : $0.peakRSSI >= Self.debugMinimumRSSI) && ($0.isPresent || now.timeIntervalSince($0.lastSeenAt) < 15) } .sorted { let leftTransitioning = now.timeIntervalSince($0.lastTransitionAt) < 8 let rightTransitioning = now.timeIntervalSince($1.lastTransitionAt) < 8 if leftTransitioning != rightTransitioning { return leftTransitioning } if $0.isStrongSignal != $1.isStrongSignal { return $0.isStrongSignal } if $0.matchesSpark != $1.matchesSpark { return $0.matchesSpark } return $0.rssi > $1.rssi } .prefix(50)) } func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { let id = peripheral.identifier.uuidString appendCommunicationLog(deviceID: id, kind: "OK", message: "BLE 连接成功") appendCommunicationLog( deviceID: id, kind: "INFO", message: "查询主服务 \(Self.serviceUUID.uuidString)" ) peripherals[id] = peripheral peripheral.delegate = self connectionPhases[id] = .discovering peripheral.discoverServices([Self.serviceUUID]) } func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { let id = peripheral.identifier.uuidString appendCommunicationLog( deviceID: id, kind: "ERROR", message: "BLE 连接失败:\(error?.localizedDescription ?? "未知错误")" ) let failure = SparkBLEError.connectionFailed(error?.localizedDescription ?? "未知错误") connectionPhases[id] = .failed(failure.localizedDescription) finishPendingBinding(deviceID: id, result: .failure(failure)) } func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { let id = peripheral.identifier.uuidString appendCommunicationLog( deviceID: id, kind: error == nil ? "INFO" : "ERROR", message: error == nil ? "BLE 连接已断开" : "BLE 异常断开:\(error!.localizedDescription)" ) updateBoundDevice(id: id) { $0.isConnected = false } characteristics.removeValue(forKey: id) authenticatingDeviceIDs.remove(id) if recordingDeviceID == id { connectionPhases[id] = .reconnecting recorderStateConsumers[id]?(.reconnecting) } else { connectionPhases[id] = .disconnected } if let pending = pendingStarts.removeValue(forKey: id) { pending.completion(.failure(SparkBLEError.connectionFailed("设备已断开"))) } if let pending = pendingStops.removeValue(forKey: id) { pending.completion(.failure(SparkBLEError.connectionFailed("停止录音前设备已断开"))) } finishPendingBinding(deviceID: id, result: .failure(SparkBLEError.connectionFailed("设备已断开"))) guard boundDevices.contains(where: { $0.id == id }) else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self, weak peripheral] in guard let self, let peripheral, self.centralManager.state == .poweredOn else { return } self.connectionPhases[id] = .reconnecting self.centralManager.connect(peripheral, options: nil) } } // MARK: - CBPeripheralDelegate func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { let id = peripheral.identifier.uuidString guard error == nil, let service = peripheral.services?.first(where: { $0.uuid == Self.serviceUUID }) else { let discovered = peripheral.services?.map(\.uuid.uuidString).joined(separator: ", ") ?? "无" appendCommunicationLog( deviceID: id, kind: "ERROR", message: "未发现协议主服务;设备提供:\(discovered)" ) let failure = SparkBLEError.incompatibleDevice connectionPhases[id] = .failed(failure.localizedDescription) finishPendingBinding(deviceID: id, result: .failure(failure)) centralManager.cancelPeripheralConnection(peripheral) return } appendCommunicationLog( deviceID: id, kind: "OK", message: "发现协议主服务 \(service.uuid.uuidString)" ) appendCommunicationLog(deviceID: id, kind: "INFO", message: "查询音频 Notify、写、指令 Notify 特征") peripheral.discoverCharacteristics( [Self.audioNotifyUUID, Self.commandNotifyUUID, Self.writeUUID], for: service ) } func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { let id = peripheral.identifier.uuidString guard error == nil else { appendCommunicationLog( deviceID: id, kind: "ERROR", message: "发现特征失败:\(error?.localizedDescription ?? "未知错误")" ) failTransport(deviceID: id, message: error?.localizedDescription ?? "发现特征失败") return } var set = SparkCharacteristics() for characteristic in service.characteristics ?? [] { switch characteristic.uuid { case Self.audioNotifyUUID: set.audioNotify = characteristic case Self.commandNotifyUUID: set.commandNotify = characteristic case Self.writeUUID: set.write = characteristic default: break } } guard set.isComplete, let audio = set.audioNotify, let command = set.commandNotify else { let discovered = service.characteristics?.map(\.uuid.uuidString).joined(separator: ", ") ?? "无" appendCommunicationLog( deviceID: id, kind: "ERROR", message: "协议特征不完整;设备提供:\(discovered)" ) failTransport(deviceID: id, message: "设备缺少必要的录音特征") return } appendCommunicationLog( deviceID: id, kind: "OK", message: "三个协议特征齐全:A1 音频 Notify、A2 写、A3 指令 Notify" ) characteristics[id] = set appendCommunicationLog(deviceID: id, kind: "INFO", message: "订阅 A1 音频与 A3 指令通知") peripheral.setNotifyValue(true, for: command) peripheral.setNotifyValue(true, for: audio) } func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { let id = peripheral.identifier.uuidString guard error == nil else { appendCommunicationLog( deviceID: id, kind: "ERROR", message: "订阅 \(characteristic.uuid.uuidString) 失败:\(error?.localizedDescription ?? "未知错误")" ) failTransport(deviceID: id, message: error?.localizedDescription ?? "订阅设备通知失败") return } appendCommunicationLog( deviceID: id, kind: characteristic.isNotifying ? "OK" : "ERROR", message: "\(characteristic.uuid.uuidString) 通知\(characteristic.isNotifying ? "已开启" : "未开启")" ) guard let set = characteristics[id], set.audioNotify?.isNotifying == true, set.commandNotify?.isNotifying == true else { return } transportDidBecomeReady(deviceID: id) } func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { let id = peripheral.identifier.uuidString guard error == nil, let data = characteristic.value else { return } if characteristic.uuid == Self.audioNotifyUUID { audioConsumers[id]?(data) } else if characteristic.uuid == Self.commandNotifyUUID { for message in decodeCommandMessages(data) { appendCommunicationLog(deviceID: id, kind: "RX", message: message) handleCommand(message, deviceID: id) } } } func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { guard let error else { return } let id = peripheral.identifier.uuidString appendCommunicationLog( deviceID: id, kind: "ERROR", message: "写入 \(characteristic.uuid.uuidString) 失败:\(error.localizedDescription)" ) lastErrorMessage = "发送设备指令失败:\(error.localizedDescription)" recorderStateConsumers[id]?(.failed(lastErrorMessage ?? "发送设备指令失败")) } // MARK: - Protocol handling private func transportDidBecomeReady(deviceID: String) { appendCommunicationLog(deviceID: deviceID, kind: "OK", message: "A1 与 A3 通知均已订阅,通信通道就绪") if let pending = pendingBindings[deviceID] { connectionPhases[deviceID] = .authenticating appendCommunicationLog( deviceID: deviceID, kind: "TX", message: "PQ_BLE&SK&••••••••••••••••(16 位绑定密码已隐藏)" ) do { try writeCommand("PQ_BLE&SK&\(pending.password)", to: deviceID) } catch { finishPendingBinding(deviceID: deviceID, result: .failure(error)) } return } if let password = pairingPassword(for: deviceID) { authenticatingDeviceIDs.insert(deviceID) connectionPhases[deviceID] = .authenticating do { try writeCommand("PQ_BLE&SK&\(password)", to: deviceID) } catch { authenticatingDeviceIDs.remove(deviceID) markDeviceReady(deviceID) } } else { // Existing pre-Spark installations have no key in Keychain. Keep them // usable and let the first explicit rebind establish one. markDeviceReady(deviceID) } } private func handleCommand(_ message: String, deviceID: String) { // Some YLF20 firmware omits the documented "PQ_" prefix in replies // (for example, DEV&UNKNOWN). Normalize it for the protocol parser // while preserving the raw RX value in the debug log. let message = message.hasPrefix("DEV&") ? "PQ_\(message)" : message let fields = message.components(separatedBy: "&") guard fields.count >= 2 else { return } recordDiagnosticResponse(message, deviceID: deviceID) recordProtocolProbeResponse(message, deviceID: deviceID) if message == "DEV&UNKNOWN" || message == "PQ_DEV&UNKNOWN" { appendCommunicationLog( deviceID: deviceID, kind: "ERROR", message: "设备收到写入,但不识别当前命令格式" ) if pendingBindings[deviceID] != nil, protocolProbeTokens[deviceID] == nil { startCompatibilityProbe(deviceID: deviceID) } return } if message.hasPrefix("PQ_DEV&SK&OK") { appendCommunicationLog(deviceID: deviceID, kind: "OK", message: "设备确认密钥配对成功") if let pending = pendingBindings[deviceID] { savePairingPassword(pending.password, for: deviceID) var bound = BoundDevice( id: deviceID, name: "微光(Spark)", peripheralUUID: pending.discoveredDevice.peripheralUUID, boundAt: Date(), isConnected: true, batteryLevel: nil, firmwareVersion: nil, boundUserId: pending.userID, advertisedName: pending.discoveredDevice.name ) if let existing = boundDevices.first(where: { $0.id == deviceID }) { bound.name = existing.name } upsertBoundDevice(bound) connectionPhases[deviceID] = .ready pending.completion(.success(bound)) pendingBindings.removeValue(forKey: deviceID) queryDeviceStatus(deviceID) } else if authenticatingDeviceIDs.remove(deviceID) != nil { markDeviceReady(deviceID) } return } if message.hasPrefix("PQ_DEV&SK&ERR") { appendCommunicationLog(deviceID: deviceID, kind: "ERROR", message: "设备拒绝密钥配对") authenticatingDeviceIDs.remove(deviceID) finishPendingBinding(deviceID: deviceID, result: .failure(SparkBLEError.pairingFailed)) connectionPhases[deviceID] = .failed(SparkBLEError.pairingFailed.localizedDescription) return } if message.hasPrefix("PQ_DEV&STA&") { let fileName = fields.dropFirst(2).joined(separator: "&") recordingDeviceID = deviceID connectionPhases[deviceID] = .recording recorderStateConsumers[deviceID]?(.recording(fileName: fileName)) pendingStarts.removeValue(forKey: deviceID)?.completion(.success(fileName)) return } if message == "PQ_DEV&STO" || message.hasPrefix("PQ_DEV&STO&") { recordingDeviceID = nil connectionPhases[deviceID] = .ready pendingStops.removeValue(forKey: deviceID)?.completion(.success(message)) recorderStateConsumers[deviceID]?(.ready) return } if message.hasPrefix("PQ_DEV&RT&") { recordingDeviceID = deviceID connectionPhases[deviceID] = .recording if fields.count >= 4 { recorderStateConsumers[deviceID]?(.recording(fileName: fields[2])) } return } if message.hasPrefix("PQ_DEV&BAT&"), let value = fields.last.flatMap(Int.init) { updateBoundDevice(id: deviceID) { $0.batteryLevel = min(100, max(0, value)) } } else if message.hasPrefix("PQ_DEV&STE&"), let value = fields.last.flatMap(Int.init) { if value == 1 { recordingDeviceID = deviceID connectionPhases[deviceID] = .recording } else if recordingDeviceID == deviceID { recordingDeviceID = nil connectionPhases[deviceID] = .ready } } else if message.hasPrefix("PQ_DEV&FW&"), fields.count >= 3 { updateBoundDevice(id: deviceID) { $0.firmwareVersion = fields.dropFirst(2).joined(separator: "&") } } else if message.hasPrefix("PQ_DEV&SPA&"), fields.count >= 4 { updateBoundDevice(id: deviceID) { $0.freeStorageMB = Int(fields[2]) $0.totalStorageMB = Int(fields[3]) } } else if message.hasPrefix("PQ_DEV&MAC&"), fields.count >= 3 { updateBoundDevice(id: deviceID) { $0.hardwareMAC = fields[2] } } else if message == "PQ_EV&REC&ERR" || message == "PQ_DEV&REC&ERR" { reportRecordingError(deviceID: deviceID, message: "微光报告录音失败,请检查设备后重试。") } else if message == "PQ_DEV&DISK&ERR" { reportRecordingError(deviceID: deviceID, message: "微光存储空间已满,录音无法继续。") } } private func decodeCommandMessages(_ data: Data) -> [String] { guard var text = String(data: data, encoding: .utf8) else { return [] } text = text.replacingOccurrences(of: "\0", with: "") .trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return [] } let lineMessages = text.components(separatedBy: .newlines) .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } if lineMessages.count > 1 { return lineMessages } // Also tolerate multiple prefix-delimited messages in one notification. let pattern = "(?=PQ_(?:DEV|EV)&)" if let regex = try? NSRegularExpression(pattern: pattern) { let range = NSRange(text.startIndex..., in: text) let matches = regex.matches(in: text, range: range) if matches.count > 1 { return matches.enumerated().compactMap { index, match in let start = match.range.location let end = index + 1 < matches.count ? matches[index + 1].range.location : range.length guard let swiftRange = Range(NSRange(location: start, length: end - start), in: text) else { return nil } return String(text[swiftRange]) } } } return [text] } private func writeCommand(_ command: String, to deviceID: String) throws { try writeCommand(command, to: deviceID, wireFormat: .documented) } private func writeCommand( _ command: String, to deviceID: String, wireFormat: ProbeWireFormat, logAsProbe: Bool = false ) throws { guard let peripheral = peripherals[deviceID], peripheral.state == .connected, let characteristic = characteristics[deviceID]?.write else { throw SparkBLEError.commandUnavailable } let data = wireFormat.data(for: command) let writeType: CBCharacteristicWriteType if characteristic.properties.contains(.write) { writeType = .withResponse } else if characteristic.properties.contains(.writeWithoutResponse) { writeType = .withoutResponse } else { throw SparkBLEError.commandUnavailable } guard data.count <= peripheral.maximumWriteValueLength(for: writeType) else { throw SparkBLEError.deviceReported("设备指令超过单帧写入长度。") } if logAsProbe { appendCommunicationLog( deviceID: deviceID, kind: "TX", message: "兼容探测[\(wireFormat.label)]:PQ_BLE&STE(\(data.count) bytes)" ) } else if !command.hasPrefix("PQ_BLE&SK&") { appendCommunicationLog(deviceID: deviceID, kind: "TX", message: command) } peripheral.writeValue(data, for: characteristic, type: writeType) } private func startCompatibilityProbe(deviceID: String) { let token = UUID() protocolProbeTokens[deviceID] = token appendCommunicationLog( deviceID: deviceID, kind: "INFO", message: "开始只读兼容探测,不会改变录音或设备设置" ) for (index, format) in ProbeWireFormat.allCases.enumerated() { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5 + Double(index) * 0.9) { [weak self] in guard let self, self.protocolProbeTokens[deviceID] == token else { return } self.activeProbeFormats[deviceID] = format do { try self.writeCommand( "PQ_BLE&STE", to: deviceID, wireFormat: format, logAsProbe: true ) } catch { self.appendCommunicationLog( deviceID: deviceID, kind: "ERROR", message: "兼容探测写入失败:\(error.localizedDescription)" ) } } } DispatchQueue.main.asyncAfter(deadline: .now() + 7) { [weak self] in guard let self, self.protocolProbeTokens[deviceID] == token else { return } self.protocolProbeTokens.removeValue(forKey: deviceID) self.activeProbeFormats.removeValue(forKey: deviceID) self.appendCommunicationLog( deviceID: deviceID, kind: "ERROR", message: "六种安全格式均未得到录音状态回复;固件协议与当前文档不一致" ) } } private func recordProtocolProbeResponse(_ message: String, deviceID: String) { guard protocolProbeTokens[deviceID] != nil, message.hasPrefix("PQ_DEV&STE&") || message.hasPrefix("DEV&STE&"), let format = activeProbeFormats[deviceID] else { return } protocolProbeTokens.removeValue(forKey: deviceID) activeProbeFormats.removeValue(forKey: deviceID) appendCommunicationLog( deviceID: deviceID, kind: "OK", message: "兼容探测成功:设备接受“\(format.label)”" ) guard format != .documented, let pending = pendingBindings[deviceID] else { if pendingBindings[deviceID] != nil { appendCommunicationLog( deviceID: deviceID, kind: "ERROR", message: "普通查询可用,但当前固件不支持文档中的 SK 配对命令" ) } return } appendCommunicationLog( deviceID: deviceID, kind: "TX", message: "使用“\(format.label)”重试 16 位配对命令(密码已隐藏)" ) do { try writeCommand( "PQ_BLE&SK&\(pending.password)", to: deviceID, wireFormat: format ) } catch { appendCommunicationLog( deviceID: deviceID, kind: "ERROR", message: "兼容配对写入失败:\(error.localizedDescription)" ) } } private func queryDeviceStatus(_ deviceID: String) { let token = UUID() diagnosticTokens[deviceID] = token pendingDiagnosticResponses[deviceID] = [ "录音状态": "PQ_DEV&STE&", "电量": "PQ_DEV&BAT&", "容量": "PQ_DEV&SPA&", "固件": "PQ_DEV&FW&", "MAC": "PQ_DEV&MAC&", "校时": "PQ_DEV&T&OK" ] let commands = ["PQ_BLE&STE", "PQ_BLE&BAT", "PQ_BLE&SPACE", "PQ_BLE&FW", "PQ_BLE&MAC", currentTimeCommand()] for (index, command) in commands.enumerated() { DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * 0.25) { [weak self] in try? self?.writeCommand(command, to: deviceID) } } DispatchQueue.main.asyncAfter(deadline: .now() + 6) { [weak self] in guard let self, self.diagnosticTokens[deviceID] == token else { return } let missing = self.pendingDiagnosticResponses[deviceID]?.keys.sorted() ?? [] if missing.isEmpty { self.appendCommunicationLog( deviceID: deviceID, kind: "OK", message: "协议诊断完成,全部查询均收到预期格式回复" ) } else { self.appendCommunicationLog( deviceID: deviceID, kind: "ERROR", message: "协议诊断超时,未收到:\(missing.joined(separator: "、"))" ) } self.pendingDiagnosticResponses.removeValue(forKey: deviceID) self.diagnosticTokens.removeValue(forKey: deviceID) } } private func recordDiagnosticResponse(_ message: String, deviceID: String) { guard var pending = pendingDiagnosticResponses[deviceID] else { return } let matched = pending.first { message.hasPrefix($0.value) } guard let matched else { return } pending.removeValue(forKey: matched.key) pendingDiagnosticResponses[deviceID] = pending appendCommunicationLog( deviceID: deviceID, kind: "OK", message: "\(matched.key)回复格式正确" ) } private func appendCommunicationLog(deviceID: String, kind: String, message: String) { communicationLogs.append( BLECommunicationLogEntry( deviceID: deviceID, timestamp: Date(), kind: kind, message: message ) ) if communicationLogs.count > 300 { communicationLogs.removeFirst(communicationLogs.count - 300) } } private func currentTimeCommand() -> String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "yyyyMMddHHmmss" return "PQ_BLE&T&\(formatter.string(from: Date()))" } // MARK: - State and persistence private func markDeviceReady(_ deviceID: String) { updateBoundDevice(id: deviceID) { $0.isConnected = true } connectionPhases[deviceID] = recordingDeviceID == deviceID ? .recording : .ready recorderStateConsumers[deviceID]?(.ready) queryDeviceStatus(deviceID) } private func reconnectBoundDevices() { let identifiers = boundDevices.compactMap { UUID(uuidString: $0.peripheralUUID) } guard !identifiers.isEmpty else { return } for peripheral in centralManager.retrievePeripherals(withIdentifiers: identifiers) { let id = peripheral.identifier.uuidString peripherals[id] = peripheral peripheral.delegate = self connectionPhases[id] = .reconnecting centralManager.connect(peripheral, options: nil) } } private func markAllDevicesDisconnected() { for index in boundDevices.indices { boundDevices[index].isConnected = false connectionPhases[boundDevices[index].id] = .disconnected } saveBoundDevices() } private func updateBoundDevice(id: String, mutation: (inout BoundDevice) -> Void) { guard let index = boundDevices.firstIndex(where: { $0.id == id }) else { return } mutation(&boundDevices[index]) saveBoundDevices() } private func upsertBoundDevice(_ device: BoundDevice) { boundDevices.removeAll(where: { $0.id == device.id }) boundDevices.append(device) saveBoundDevices() } private func finishPendingBinding(deviceID: String, result: Result) { guard let pending = pendingBindings.removeValue(forKey: deviceID) else { return } pending.completion(result) } private func failTransport(deviceID: String, message: String) { let error = SparkBLEError.connectionFailed(message) connectionPhases[deviceID] = .failed(error.localizedDescription) finishPendingBinding(deviceID: deviceID, result: .failure(error)) recorderStateConsumers[deviceID]?(.failed(error.localizedDescription)) } private func reportRecordingError(deviceID: String, message: String) { lastErrorMessage = message recorderStateConsumers[deviceID]?(.failed(message)) pendingStarts.removeValue(forKey: deviceID)?.completion(.failure(SparkBLEError.deviceReported(message))) } private func scheduleTimeout(for deviceID: String, command: String, token: UUID, isStart: Bool) { DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in guard let self else { return } if isStart, self.pendingStarts[deviceID]?.token == token { self.pendingStarts.removeValue(forKey: deviceID)?.completion(.failure(SparkBLEError.commandTimedOut(command))) } else if !isStart, self.pendingStops[deviceID]?.token == token { self.pendingStops.removeValue(forKey: deviceID)?.completion(.failure(SparkBLEError.commandTimedOut(command))) } } } private func saveBoundDevices() { if let data = try? JSONEncoder().encode(boundDevices) { UserDefaults.standard.set(data, forKey: boundDevicesKey) } } private func loadBoundDevices() { guard let data = UserDefaults.standard.data(forKey: boundDevicesKey), var devices = try? JSONDecoder().decode([BoundDevice].self, from: data) else { return } let mockDeviceID = "MOCK-SPARK-01" if devices.contains(where: { $0.id == mockDeviceID || $0.peripheralUUID == mockDeviceID }) { devices.removeAll { $0.id == mockDeviceID || $0.peripheralUUID == mockDeviceID } deletePairingPassword(for: mockDeviceID) if let cleaned = try? JSONEncoder().encode(devices) { UserDefaults.standard.set(cleaned, forKey: boundDevicesKey) } } for index in devices.indices { devices[index].isConnected = false } boundDevices = devices } // MARK: - Pairing secret private func generatePairingPassword() -> String { let alphabet = Array("ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789") var bytes = [UInt8](repeating: 0, count: 16) let result = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) if result == errSecSuccess { return String(bytes.map { alphabet[Int($0) % alphabet.count] }) } return String(UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(16)) } private func savePairingPassword(_ password: String, for deviceID: String) { deletePairingPassword(for: deviceID) let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, kSecAttrAccount as String: deviceID, kSecValueData as String: Data(password.utf8), kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly ] SecItemAdd(query as CFDictionary, nil) } private func pairingPassword(for deviceID: String) -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, kSecAttrAccount as String: deviceID, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne ] var result: CFTypeRef? guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil } return String(data: data, encoding: .utf8) } private func deletePairingPassword(for deviceID: String) { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, kSecAttrAccount as String: deviceID ] SecItemDelete(query as CFDictionary) } } // MARK: - Spark audio recorder /// Records the MP3 notification stream produced by a bound Spark device. final class SparkAudioRecorder: AudioRecorderProtocol { @Published var isRecording: Bool = false @Published var elapsedTime: TimeInterval = 0 @Published var currentAmplitude: Float = 0 @Published var waveformSamples: [Float] = [] @Published var outputFileURL: URL? @Published var isPaused: Bool = false @Published var statusMessage: String = "正在连接设备" @Published var errorMessage: String? let sourceDisplayName: String private let deviceID: String private let manager: BLEManager private let ioQueue = DispatchQueue(label: "com.celestia.trace.spark.audio-io") private var fileHandle: FileHandle? private var timer: Timer? private var recordingStartedAt: Date? private var stopCompletion: ((Result) -> Void)? init(deviceID: String, displayName: String, manager: BLEManager = .shared) { self.deviceID = deviceID self.sourceDisplayName = displayName self.manager = manager } func startRecording() { guard !isRecording, fileHandle == nil else { return } errorMessage = nil statusMessage = "正在启动 \(sourceDisplayName)" elapsedTime = 0 waveformSamples = [] currentAmplitude = 0 do { let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let url = documents.appendingPathComponent("spark_\(UUID().uuidString).mp3") FileManager.default.createFile(atPath: url.path, contents: nil) fileHandle = try FileHandle(forWritingTo: url) outputFileURL = url } catch { errorMessage = "无法创建微光录音文件:\(error.localizedDescription)" statusMessage = "启动失败" return } manager.startSparkRecording( deviceID: deviceID, onAudioData: { [weak self] data in self?.appendAudioData(data) }, onStateChange: { [weak self] event in self?.handleStateEvent(event) } ) { [weak self] result in guard let self else { return } DispatchQueue.main.async { switch result { case .success: self.isRecording = true self.statusMessage = "正在记录" self.recordingStartedAt = Date() self.startTimer() case .failure(let error): self.errorMessage = error.localizedDescription self.statusMessage = "启动失败" self.finishFile { _ in } } } } } func stopRecording() { stopRecording { _ in } } func stopRecording(completion: @escaping (Result) -> Void) { guard fileHandle != nil else { completion(.success(outputFileURL)) return } guard isRecording else { finishFile(completion: completion) return } statusMessage = "正在保存设备录音" stopCompletion = completion manager.stopSparkRecording(deviceID: deviceID) { [weak self] result in guard let self else { return } DispatchQueue.main.async { switch result { case .success: self.isRecording = false self.invalidateTimer() self.finishFile { result in self.manager.detachRecorder(deviceID: self.deviceID) self.stopCompletion?(result) self.stopCompletion = nil } case .failure(let error): self.errorMessage = error.localizedDescription self.statusMessage = "设备尚未确认停止" self.stopCompletion?(.failure(error)) self.stopCompletion = nil } } } } func pauseRecording() { errorMessage = "微光暂不支持暂停,请结束当前录音。" } func resumeRecording() {} private func appendAudioData(_ data: Data) { guard !data.isEmpty else { return } ioQueue.async { [weak self] in guard let self else { return } do { try self.fileHandle?.write(contentsOf: data) } catch { DispatchQueue.main.async { self.errorMessage = "保存微光音频失败:\(error.localizedDescription)" } } } // This represents stream activity until incremental MP3 PCM metering is available. let activity = min(1, max(0.08, Float(data.count) / 244.0)) DispatchQueue.main.async { [weak self] in guard let self else { return } self.currentAmplitude = activity self.waveformSamples.append(activity) if self.waveformSamples.count > 200 { self.waveformSamples.removeFirst(self.waveformSamples.count - 200) } } } private func handleStateEvent(_ event: SparkRecorderConnectionEvent) { DispatchQueue.main.async { [weak self] in guard let self else { return } switch event { case .recording: if self.isRecording { self.statusMessage = "正在记录" } case .reconnecting: self.statusMessage = "连接中断,正在重连" case .ready: if self.isRecording { self.statusMessage = "正在记录" } case .failed(let message): self.errorMessage = message self.statusMessage = "设备异常" } } } private func startTimer() { invalidateTimer() timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in guard let self, let startedAt = self.recordingStartedAt else { return } self.elapsedTime = Date().timeIntervalSince(startedAt) } if let timer { RunLoop.main.add(timer, forMode: .common) } } private func invalidateTimer() { timer?.invalidate() timer = nil } private func finishFile(completion: @escaping (Result) -> Void) { let url = outputFileURL ioQueue.async { [weak self] in guard let self else { return } do { try self.fileHandle?.synchronize() try self.fileHandle?.close() self.fileHandle = nil DispatchQueue.main.async { completion(.success(url)) } } catch { self.fileHandle = nil DispatchQueue.main.async { completion(.failure(error)) } } } } deinit { invalidateTimer() try? fileHandle?.close() manager.detachRecorder(deviceID: deviceID) } }