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) } 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() 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") @Published private(set) var state: CBManagerState = .unknown @Published private(set) var isScanning: Bool = false @Published private(set) var discoveredDevices: [DiscoveredBLEDevice] = [] @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 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 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 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() } // MARK: - Scanning and binding func startScanning() { discoveredDevices.removeAll() lastErrorMessage = nil scanRequested = true guard centralManager.state == .poweredOn else { isScanning = true return } isScanning = true // The vendor sheet doesn't guarantee that the service UUID is advertised, // so discovery is broad and results are filtered by service/name hints. centralManager.scanForPeripherals( withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] ) #if targetEnvironment(simulator) DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in guard let self, self.isScanning else { return } self.discoveredDevices = [ DiscoveredBLEDevice( id: "MOCK-SPARK-01", name: "MR20 Spark Demo", rssi: -55, peripheralUUID: "MOCK-SPARK-01" ) ] } #endif } func stopScanning() { scanRequested = false if centralManager.state == .poweredOn { centralManager.stopScan() } isScanning = false } func connectAndBind( _ device: DiscoveredBLEDevice, userId: String, completion: @escaping (Result) -> Void ) { #if targetEnvironment(simulator) if device.id.hasPrefix("MOCK-") { let bound = BoundDevice( id: device.id, name: "微光(Spark)", peripheralUUID: device.peripheralUUID, isConnected: true, batteryLevel: 86, firmwareVersion: "Demo", boundUserId: userId, advertisedName: device.name, freeStorageMB: 1_024, totalStorageMB: 2_048 ) upsertBoundDevice(bound) connectionPhases[device.id] = .ready completion(.success(bound)) return } #endif guard centralManager.state == .poweredOn else { completion(.failure(SparkBLEError.bluetoothUnavailable)) return } guard let peripheral = peripherals[device.id] else { completion(.failure(SparkBLEError.deviceUnavailable)) return } let password = generatePairingPassword() pendingBindings[device.id] = PendingBinding( discoveredDevice: device, userID: userId, password: password, completion: completion ) connectionPhases[device.id] = .connecting peripheral.delegate = self stopScanning() centralManager.connect(peripheral, options: nil) } // 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 { markAllDevicesDisconnected() return } reconnectBoundDevices() if scanRequested { isScanning = true central.scanForPeripherals( withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] ) } } 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 let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? peripheral.name ?? "微光录音设备" let advertisedServices = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? [] let normalizedName = advertisedName.lowercased() let matchesProduct = advertisedServices.contains(Self.serviceUUID) || normalizedName.contains("spark") || normalizedName.contains("mr20") || normalizedName.contains("微光") guard matchesProduct else { return } peripherals[id] = peripheral peripheral.delegate = self let device = DiscoveredBLEDevice( id: id, name: advertisedName, rssi: RSSI.intValue, peripheralUUID: id ) if let index = discoveredDevices.firstIndex(where: { $0.id == id }) { discoveredDevices[index] = device } else { discoveredDevices.append(device) } } func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { let id = peripheral.identifier.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 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 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 failure = SparkBLEError.incompatibleDevice connectionPhases[id] = .failed(failure.localizedDescription) finishPendingBinding(deviceID: id, result: .failure(failure)) centralManager.cancelPeripheralConnection(peripheral) return } 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 { 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 { failTransport(deviceID: id, message: "设备缺少必要的录音特征") return } characteristics[id] = set 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 { failTransport(deviceID: id, message: error?.localizedDescription ?? "订阅设备通知失败") return } 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) { handleCommand(message, deviceID: id) } } } func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { guard let error else { return } let id = peripheral.identifier.uuidString lastErrorMessage = "发送设备指令失败:\(error.localizedDescription)" recorderStateConsumers[id]?(.failed(lastErrorMessage ?? "发送设备指令失败")) } // MARK: - Protocol handling private func transportDidBecomeReady(deviceID: String) { if let pending = pendingBindings[deviceID] { connectionPhases[deviceID] = .authenticating 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) { let fields = message.components(separatedBy: "&") guard fields.count >= 2 else { return } if message.hasPrefix("PQ_DEV&SK&OK") { 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") { 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 { guard let peripheral = peripherals[deviceID], peripheral.state == .connected, let characteristic = characteristics[deviceID]?.write else { throw SparkBLEError.commandUnavailable } let data = Data(command.utf8) 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("设备指令超过单帧写入长度。") } peripheral.writeValue(data, for: characteristic, type: writeType) } private func queryDeviceStatus(_ deviceID: String) { 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.12) { [weak self] in try? self?.writeCommand(command, to: deviceID) } } } 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 } 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) } }