| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507 |
- 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<BoundDevice, Error>) -> Void
- }
- private struct PendingCommand {
- let token: UUID
- let completion: (Result<String, Error>) -> 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<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
- private var observedPeripheralIDs: Set<String> = []
- 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() {
- DeveloperLogStore.log("蓝牙", "开始搜索附近 BLE 设备")
- 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
- DeveloperLogStore.log(
- "蓝牙",
- "扫描请求等待系统蓝牙可用,当前状态:\(centralManager.state.developerDescription)",
- level: .warning
- )
- 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() {
- DeveloperLogStore.log("蓝牙", "停止搜索,累计发现 \(observedPeripheralCount) 个设备")
- scanRequested = false
- advertisementRefreshTimer?.invalidate()
- advertisementRefreshTimer = nil
- if centralManager.state == .poweredOn {
- centralManager.stopScan()
- }
- isScanning = false
- }
- func connectAndBind(
- _ device: DiscoveredBLEDevice,
- userId: String,
- completion: @escaping (Result<BoundDevice, Error>) -> 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<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
- DeveloperLogStore.log(
- "蓝牙",
- "系统蓝牙状态变为:\(central.state.developerDescription)",
- level: central.state == .poweredOn ? .success : .warning
- )
- 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
- DeveloperLogStore.log(
- "蓝牙扫描",
- "\(displayName) 重新出现,RSSI \(RSSI.intValue) dBm,服务 \(serviceUUIDStrings.isEmpty ? "无" : serviceUUIDStrings.joined(separator: ","))"
- )
- }
- 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
- )
- DeveloperLogStore.log(
- "蓝牙扫描",
- "发现 \(displayName),RSSI \(RSSI.intValue) dBm,服务 \(serviceUUIDStrings.isEmpty ? "无" : serviceUUIDStrings.joined(separator: ","))",
- level: Self.isSparkAdvertisement(name: advertisedName, serviceUUIDs: advertisedServices) ? .success : .info
- )
- }
- 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)
- }
- let level: DeveloperLogEntry.Level
- switch kind {
- case "OK": level = .success
- case "ERROR": level = .error
- case "TX": level = .transmit
- case "RX": level = .receive
- default: level = .info
- }
- DeveloperLogStore.log("蓝牙通信", "\(deviceID.prefix(8)) · \(message)", level: level)
- }
- 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 }
- 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)
- }
- }
- private extension CBManagerState {
- var developerDescription: String {
- switch self {
- case .unknown: "未知"
- case .resetting: "正在重置"
- case .unsupported: "设备不支持"
- case .unauthorized: "未授权"
- case .poweredOff: "已关闭"
- case .poweredOn: "可用"
- @unknown default: "其他(\(rawValue))"
- }
- }
- }
- // 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)
- }
- }
|