| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991 |
- 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<BoundDevice, Error>) -> Void
- }
- private struct PendingCommand {
- let token: UUID
- let completion: (Result<String, Error>) -> 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<String> = []
- 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<BoundDevice, Error>) -> 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<String, Error>) -> 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<String, Error>) -> 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<BoundDevice, Error>) {
- 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<URL?, Error>) -> 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<URL?, Error>) -> 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<URL?, Error>) -> 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)
- }
- }
|