|
@@ -1,127 +1,1463 @@
|
|
|
import Foundation
|
|
import Foundation
|
|
|
import CoreBluetooth
|
|
import CoreBluetooth
|
|
|
import Combine
|
|
import Combine
|
|
|
|
|
+import Security
|
|
|
|
|
|
|
|
-/// Manages Bluetooth Low Energy (BLE) scanning, device connection, and binding to user accounts.
|
|
|
|
|
|
|
+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 {
|
|
final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeripheralDelegate {
|
|
|
static let shared = BLEManager()
|
|
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 state: CBManagerState = .unknown
|
|
|
- @Published var isScanning: Bool = false
|
|
|
|
|
- @Published var discoveredDevices: [DiscoveredBLEDevice] = []
|
|
|
|
|
- @Published var boundDevices: [BoundDevice] = []
|
|
|
|
|
-
|
|
|
|
|
|
|
+ @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 var centralManager: CBCentralManager!
|
|
|
private let boundDevicesKey = "com.celestia.trace.bound_devices"
|
|
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() {
|
|
override private init() {
|
|
|
super.init()
|
|
super.init()
|
|
|
- self.centralManager = CBCentralManager(delegate: self, queue: nil)
|
|
|
|
|
loadBoundDevices()
|
|
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 }
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- // MARK: - CentralManager Delegate
|
|
|
|
|
-
|
|
|
|
|
|
|
+
|
|
|
|
|
+ func connectedDevices(forUserId userId: String) -> [BoundDevice] {
|
|
|
|
|
+ devices(forUserId: userId).filter { $0.isConnected }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func phase(for deviceID: String) -> SparkConnectionPhase {
|
|
|
|
|
+ connectionPhases[deviceID] ?? .disconnected
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func communicationLogs(for deviceID: String) -> [BLECommunicationLogEntry] {
|
|
|
|
|
+ communicationLogs.filter { $0.deviceID == deviceID }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func renameDevice(id: String, to proposedName: String) {
|
|
|
|
|
+ let trimmed = proposedName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ guard !trimmed.isEmpty, let index = boundDevices.firstIndex(where: { $0.id == id }) else { return }
|
|
|
|
|
+ boundDevices[index].name = String(trimmed.prefix(30))
|
|
|
|
|
+ saveBoundDevices()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func unbindDevice(id: String) {
|
|
|
|
|
+ var peripheralToDisconnect: CBPeripheral?
|
|
|
|
|
+ if boundDevices.first(where: { $0.id == id })?.isConnected == true {
|
|
|
|
|
+ try? writeCommand("PQ_BLE&BLE&OFF", to: id)
|
|
|
|
|
+ peripheralToDisconnect = peripherals[id]
|
|
|
|
|
+ }
|
|
|
|
|
+ deletePairingPassword(for: id)
|
|
|
|
|
+ boundDevices.removeAll(where: { $0.id == id })
|
|
|
|
|
+ connectionPhases[id] = .disconnected
|
|
|
|
|
+ if let peripheralToDisconnect {
|
|
|
|
|
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self, weak peripheralToDisconnect] in
|
|
|
|
|
+ guard let self, let peripheralToDisconnect else { return }
|
|
|
|
|
+ self.centralManager.cancelPeripheralConnection(peripheralToDisconnect)
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if let peripheral = peripherals[id] {
|
|
|
|
|
+ centralManager.cancelPeripheralConnection(peripheral)
|
|
|
|
|
+ }
|
|
|
|
|
+ saveBoundDevices()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func updateCloudRegistration(deviceID: String, cloudID: String?, error: String?) {
|
|
|
|
|
+ updateBoundDevice(id: deviceID) {
|
|
|
|
|
+ $0.cloudID = cloudID
|
|
|
|
|
+ $0.cloudSyncError = error
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // MARK: - Scanning and binding
|
|
|
|
|
+
|
|
|
|
|
+ func startScanning() {
|
|
|
|
|
+ discoveredDevices.removeAll()
|
|
|
|
|
+ observedPeripheralIDs.removeAll()
|
|
|
|
|
+ observedPeripheralCount = 0
|
|
|
|
|
+ observedAdvertisementsByID.removeAll()
|
|
|
|
|
+ observedAdvertisements.removeAll()
|
|
|
|
|
+ lastErrorMessage = nil
|
|
|
|
|
+ scanRequested = true
|
|
|
|
|
+ startAdvertisementRefreshTimer()
|
|
|
|
|
+
|
|
|
|
|
+ guard centralManager.state == .poweredOn else {
|
|
|
|
|
+ // Keep the request pending while CoreBluetooth initializes. The
|
|
|
|
|
+ // delegate starts scanning if the state later becomes powered on.
|
|
|
|
|
+ isScanning = false
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ isScanning = true
|
|
|
|
|
+ // Some Spark firmware versions do not advertise the service UUID. Scan
|
|
|
|
|
+ // broadly, but only surface peripherals that explicitly advertise the
|
|
|
|
|
+ // Spark service or a known product name.
|
|
|
|
|
+ centralManager.scanForPeripherals(
|
|
|
|
|
+ withServices: nil,
|
|
|
|
|
+ options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func stopScanning() {
|
|
|
|
|
+ scanRequested = false
|
|
|
|
|
+ advertisementRefreshTimer?.invalidate()
|
|
|
|
|
+ advertisementRefreshTimer = nil
|
|
|
|
|
+ if centralManager.state == .poweredOn {
|
|
|
|
|
+ centralManager.stopScan()
|
|
|
|
|
+ }
|
|
|
|
|
+ isScanning = false
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func connectAndBind(
|
|
|
|
|
+ _ device: DiscoveredBLEDevice,
|
|
|
|
|
+ userId: String,
|
|
|
|
|
+ completion: @escaping (Result<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) {
|
|
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
|
|
- DispatchQueue.main.async {
|
|
|
|
|
- self.state = central.state
|
|
|
|
|
|
|
+ state = central.state
|
|
|
|
|
+ guard central.state == .poweredOn else {
|
|
|
|
|
+ isScanning = false
|
|
|
|
|
+ markAllDevicesDisconnected()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ reconnectBoundDevices()
|
|
|
|
|
+ if scanRequested {
|
|
|
|
|
+ isScanning = true
|
|
|
|
|
+ central.scanForPeripherals(
|
|
|
|
|
+ withServices: nil,
|
|
|
|
|
+ options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
|
|
|
|
|
+ )
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
|
|
|
|
|
- let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "现场录音设备"
|
|
|
|
|
|
|
+
|
|
|
|
|
+ func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
|
|
|
|
|
+ let restored = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] ?? []
|
|
|
|
|
+ for peripheral in restored {
|
|
|
|
|
+ let id = peripheral.identifier.uuidString
|
|
|
|
|
+ peripherals[id] = peripheral
|
|
|
|
|
+ peripheral.delegate = self
|
|
|
|
|
+ connectionPhases[id] = peripheral.state == .connected ? .discovering : .reconnecting
|
|
|
|
|
+ if peripheral.state == .connected {
|
|
|
|
|
+ peripheral.discoverServices([Self.serviceUUID])
|
|
|
|
|
+ } else {
|
|
|
|
|
+ central.connect(peripheral, options: nil)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func centralManager(
|
|
|
|
|
+ _ central: CBCentralManager,
|
|
|
|
|
+ didDiscover peripheral: CBPeripheral,
|
|
|
|
|
+ advertisementData: [String: Any],
|
|
|
|
|
+ rssi RSSI: NSNumber
|
|
|
|
|
+ ) {
|
|
|
|
|
+ let id = peripheral.identifier.uuidString
|
|
|
|
|
+ observedPeripheralIDs.insert(id)
|
|
|
|
|
+ observedPeripheralCount = observedPeripheralIDs.count
|
|
|
|
|
+
|
|
|
|
|
+ let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String
|
|
|
|
|
+ ?? peripheral.name
|
|
|
|
|
+ let advertisedServices = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? []
|
|
|
|
|
+
|
|
|
|
|
+ guard RSSI.intValue != 127 else { return }
|
|
|
|
|
+ let now = Date()
|
|
|
|
|
+ let trimmedAdvertisedName = advertisedName?
|
|
|
|
|
+ .trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ let displayName = trimmedAdvertisedName?.isEmpty == false
|
|
|
|
|
+ ? trimmedAdvertisedName!
|
|
|
|
|
+ : "未提供名称"
|
|
|
|
|
+ let serviceUUIDStrings = advertisedServices.map(\.uuidString)
|
|
|
|
|
+
|
|
|
|
|
+ if var existing = observedAdvertisementsByID[id] {
|
|
|
|
|
+ let hadDisappeared = !existing.isPresent
|
|
|
|
|
+ || now.timeIntervalSince(existing.lastSeenAt) >= Self.advertisementDisappearanceInterval
|
|
|
|
|
+ existing.name = displayName
|
|
|
|
|
+ existing.serviceUUIDs = serviceUUIDStrings
|
|
|
|
|
+ existing.rssi = RSSI.intValue
|
|
|
|
|
+ existing.peakRSSI = max(existing.peakRSSI, RSSI.intValue)
|
|
|
|
|
+ existing.lastSeenAt = now
|
|
|
|
|
+ existing.isPresent = true
|
|
|
|
|
+ if hadDisappeared {
|
|
|
|
|
+ existing.reappearanceCount += 1
|
|
|
|
|
+ existing.lastTransitionAt = now
|
|
|
|
|
+ }
|
|
|
|
|
+ observedAdvertisementsByID[id] = existing
|
|
|
|
|
+ } else if RSSI.intValue >= Self.debugMinimumRSSI {
|
|
|
|
|
+ observedAdvertisementsByID[id] = ObservedBLEAdvertisement(
|
|
|
|
|
+ id: id,
|
|
|
|
|
+ name: displayName,
|
|
|
|
|
+ serviceUUIDs: serviceUUIDStrings,
|
|
|
|
|
+ rssi: RSSI.intValue,
|
|
|
|
|
+ peakRSSI: RSSI.intValue,
|
|
|
|
|
+ firstSeenAt: now,
|
|
|
|
|
+ lastSeenAt: now,
|
|
|
|
|
+ lastTransitionAt: now,
|
|
|
|
|
+ isPresent: true,
|
|
|
|
|
+ reappearanceCount: 0
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ publishObservedAdvertisements(now: now)
|
|
|
|
|
+
|
|
|
|
|
+ // The formal binding list is intentionally restricted to the confirmed
|
|
|
|
|
+ // production broadcast name. Service/characteristic UUIDs are verified
|
|
|
|
|
+ // after connecting.
|
|
|
|
|
+ guard Self.isYLF20AdvertisementName(advertisedName) else { return }
|
|
|
|
|
+
|
|
|
|
|
+ peripherals[id] = peripheral
|
|
|
|
|
+ peripheral.delegate = self
|
|
|
let device = DiscoveredBLEDevice(
|
|
let device = DiscoveredBLEDevice(
|
|
|
- id: peripheral.identifier.uuidString,
|
|
|
|
|
- name: name,
|
|
|
|
|
|
|
+ id: id,
|
|
|
|
|
+ name: trimmedAdvertisedName?.isEmpty == false ? trimmedAdvertisedName! : "微光(Spark)",
|
|
|
rssi: RSSI.intValue,
|
|
rssi: RSSI.intValue,
|
|
|
- peripheralUUID: peripheral.identifier.uuidString
|
|
|
|
|
|
|
+ peripheralUUID: id
|
|
|
)
|
|
)
|
|
|
-
|
|
|
|
|
- DispatchQueue.main.async {
|
|
|
|
|
- if !self.discoveredDevices.contains(where: { $0.id == device.id }) {
|
|
|
|
|
- self.discoveredDevices.append(device)
|
|
|
|
|
|
|
+ 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))
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- // MARK: - Scanning & Mocking
|
|
|
|
|
-
|
|
|
|
|
- func startScanning() {
|
|
|
|
|
- discoveredDevices.removeAll()
|
|
|
|
|
- isScanning = true
|
|
|
|
|
-
|
|
|
|
|
- if centralManager.state == .poweredOn {
|
|
|
|
|
- centralManager.scanForPeripherals(withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Populate mock devices for simulator or testing environment
|
|
|
|
|
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in
|
|
|
|
|
- guard let self = self, self.isScanning else { return }
|
|
|
|
|
- let mockDevices = [
|
|
|
|
|
- DiscoveredBLEDevice(id: "MOCK-BLE-01", name: "星痕专业录音麦克风 01", rssi: -58, peripheralUUID: "0000180A-0000-1000-8000-00805F9B34FB"),
|
|
|
|
|
- DiscoveredBLEDevice(id: "MOCK-BLE-02", name: "星痕无线音频集线器", rssi: -72, peripheralUUID: "0000180F-0000-1000-8000-00805F9B34FB"),
|
|
|
|
|
- DiscoveredBLEDevice(id: "MOCK-BLE-03", name: "智能胸卡录音器 A2", rssi: -85, peripheralUUID: "0000181A-0000-1000-8000-00805F9B34FB")
|
|
|
|
|
- ]
|
|
|
|
|
- for device in mockDevices {
|
|
|
|
|
- if !self.discoveredDevices.contains(where: { $0.id == device.id }) {
|
|
|
|
|
- self.discoveredDevices.append(device)
|
|
|
|
|
|
|
+
|
|
|
|
|
+ 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: "微光存储空间已满,录音无法继续。")
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- func stopScanning() {
|
|
|
|
|
- if centralManager.state == .poweredOn {
|
|
|
|
|
- centralManager.stopScan()
|
|
|
|
|
|
|
+
|
|
|
|
|
+ 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])
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
- isScanning = false
|
|
|
|
|
|
|
+ return [text]
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func writeCommand(_ command: String, to deviceID: String) throws {
|
|
|
|
|
+ try writeCommand(command, to: deviceID, wireFormat: .documented)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func writeCommand(
|
|
|
|
|
+ _ command: String,
|
|
|
|
|
+ to deviceID: String,
|
|
|
|
|
+ wireFormat: ProbeWireFormat,
|
|
|
|
|
+ logAsProbe: Bool = false
|
|
|
|
|
+ ) throws {
|
|
|
|
|
+ guard let peripheral = peripherals[deviceID],
|
|
|
|
|
+ peripheral.state == .connected,
|
|
|
|
|
+ let characteristic = characteristics[deviceID]?.write else {
|
|
|
|
|
+ throw SparkBLEError.commandUnavailable
|
|
|
|
|
+ }
|
|
|
|
|
+ let data = wireFormat.data(for: command)
|
|
|
|
|
+ let writeType: CBCharacteristicWriteType
|
|
|
|
|
+ if characteristic.properties.contains(.write) {
|
|
|
|
|
+ writeType = .withResponse
|
|
|
|
|
+ } else if characteristic.properties.contains(.writeWithoutResponse) {
|
|
|
|
|
+ writeType = .withoutResponse
|
|
|
|
|
+ } else {
|
|
|
|
|
+ throw SparkBLEError.commandUnavailable
|
|
|
|
|
+ }
|
|
|
|
|
+ guard data.count <= peripheral.maximumWriteValueLength(for: writeType) else {
|
|
|
|
|
+ throw SparkBLEError.deviceReported("设备指令超过单帧写入长度。")
|
|
|
|
|
+ }
|
|
|
|
|
+ if logAsProbe {
|
|
|
|
|
+ appendCommunicationLog(
|
|
|
|
|
+ deviceID: deviceID,
|
|
|
|
|
+ kind: "TX",
|
|
|
|
|
+ message: "兼容探测[\(wireFormat.label)]:PQ_BLE&STE(\(data.count) bytes)"
|
|
|
|
|
+ )
|
|
|
|
|
+ } else if !command.hasPrefix("PQ_BLE&SK&") {
|
|
|
|
|
+ appendCommunicationLog(deviceID: deviceID, kind: "TX", message: command)
|
|
|
|
|
+ }
|
|
|
|
|
+ peripheral.writeValue(data, for: characteristic, type: writeType)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func startCompatibilityProbe(deviceID: String) {
|
|
|
|
|
+ let token = UUID()
|
|
|
|
|
+ protocolProbeTokens[deviceID] = token
|
|
|
|
|
+ appendCommunicationLog(
|
|
|
|
|
+ deviceID: deviceID,
|
|
|
|
|
+ kind: "INFO",
|
|
|
|
|
+ message: "开始只读兼容探测,不会改变录音或设备设置"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ for (index, format) in ProbeWireFormat.allCases.enumerated() {
|
|
|
|
|
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5 + Double(index) * 0.9) { [weak self] in
|
|
|
|
|
+ guard let self, self.protocolProbeTokens[deviceID] == token else { return }
|
|
|
|
|
+ self.activeProbeFormats[deviceID] = format
|
|
|
|
|
+ do {
|
|
|
|
|
+ try self.writeCommand(
|
|
|
|
|
+ "PQ_BLE&STE",
|
|
|
|
|
+ to: deviceID,
|
|
|
|
|
+ wireFormat: format,
|
|
|
|
|
+ logAsProbe: true
|
|
|
|
|
+ )
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ self.appendCommunicationLog(
|
|
|
|
|
+ deviceID: deviceID,
|
|
|
|
|
+ kind: "ERROR",
|
|
|
|
|
+ message: "兼容探测写入失败:\(error.localizedDescription)"
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ DispatchQueue.main.asyncAfter(deadline: .now() + 7) { [weak self] in
|
|
|
|
|
+ guard let self, self.protocolProbeTokens[deviceID] == token else { return }
|
|
|
|
|
+ self.protocolProbeTokens.removeValue(forKey: deviceID)
|
|
|
|
|
+ self.activeProbeFormats.removeValue(forKey: deviceID)
|
|
|
|
|
+ self.appendCommunicationLog(
|
|
|
|
|
+ deviceID: deviceID,
|
|
|
|
|
+ kind: "ERROR",
|
|
|
|
|
+ message: "六种安全格式均未得到录音状态回复;固件协议与当前文档不一致"
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func recordProtocolProbeResponse(_ message: String, deviceID: String) {
|
|
|
|
|
+ guard protocolProbeTokens[deviceID] != nil,
|
|
|
|
|
+ message.hasPrefix("PQ_DEV&STE&") || message.hasPrefix("DEV&STE&"),
|
|
|
|
|
+ let format = activeProbeFormats[deviceID] else { return }
|
|
|
|
|
+ protocolProbeTokens.removeValue(forKey: deviceID)
|
|
|
|
|
+ activeProbeFormats.removeValue(forKey: deviceID)
|
|
|
|
|
+ appendCommunicationLog(
|
|
|
|
|
+ deviceID: deviceID,
|
|
|
|
|
+ kind: "OK",
|
|
|
|
|
+ message: "兼容探测成功:设备接受“\(format.label)”"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ guard format != .documented,
|
|
|
|
|
+ let pending = pendingBindings[deviceID] else {
|
|
|
|
|
+ if pendingBindings[deviceID] != nil {
|
|
|
|
|
+ appendCommunicationLog(
|
|
|
|
|
+ deviceID: deviceID,
|
|
|
|
|
+ kind: "ERROR",
|
|
|
|
|
+ message: "普通查询可用,但当前固件不支持文档中的 SK 配对命令"
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ appendCommunicationLog(
|
|
|
|
|
+ deviceID: deviceID,
|
|
|
|
|
+ kind: "TX",
|
|
|
|
|
+ message: "使用“\(format.label)”重试 16 位配对命令(密码已隐藏)"
|
|
|
|
|
+ )
|
|
|
|
|
+ do {
|
|
|
|
|
+ try writeCommand(
|
|
|
|
|
+ "PQ_BLE&SK&\(pending.password)",
|
|
|
|
|
+ to: deviceID,
|
|
|
|
|
+ wireFormat: format
|
|
|
|
|
+ )
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ appendCommunicationLog(
|
|
|
|
|
+ deviceID: deviceID,
|
|
|
|
|
+ kind: "ERROR",
|
|
|
|
|
+ message: "兼容配对写入失败:\(error.localizedDescription)"
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func queryDeviceStatus(_ deviceID: String) {
|
|
|
|
|
+ let token = UUID()
|
|
|
|
|
+ diagnosticTokens[deviceID] = token
|
|
|
|
|
+ pendingDiagnosticResponses[deviceID] = [
|
|
|
|
|
+ "录音状态": "PQ_DEV&STE&",
|
|
|
|
|
+ "电量": "PQ_DEV&BAT&",
|
|
|
|
|
+ "容量": "PQ_DEV&SPA&",
|
|
|
|
|
+ "固件": "PQ_DEV&FW&",
|
|
|
|
|
+ "MAC": "PQ_DEV&MAC&",
|
|
|
|
|
+ "校时": "PQ_DEV&T&OK"
|
|
|
|
|
+ ]
|
|
|
|
|
+ let commands = ["PQ_BLE&STE", "PQ_BLE&BAT", "PQ_BLE&SPACE", "PQ_BLE&FW", "PQ_BLE&MAC", currentTimeCommand()]
|
|
|
|
|
+ for (index, command) in commands.enumerated() {
|
|
|
|
|
+ DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * 0.25) { [weak self] in
|
|
|
|
|
+ try? self?.writeCommand(command, to: deviceID)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ DispatchQueue.main.asyncAfter(deadline: .now() + 6) { [weak self] in
|
|
|
|
|
+ guard let self, self.diagnosticTokens[deviceID] == token else { return }
|
|
|
|
|
+ let missing = self.pendingDiagnosticResponses[deviceID]?.keys.sorted() ?? []
|
|
|
|
|
+ if missing.isEmpty {
|
|
|
|
|
+ self.appendCommunicationLog(
|
|
|
|
|
+ deviceID: deviceID,
|
|
|
|
|
+ kind: "OK",
|
|
|
|
|
+ message: "协议诊断完成,全部查询均收到预期格式回复"
|
|
|
|
|
+ )
|
|
|
|
|
+ } else {
|
|
|
|
|
+ self.appendCommunicationLog(
|
|
|
|
|
+ deviceID: deviceID,
|
|
|
|
|
+ kind: "ERROR",
|
|
|
|
|
+ message: "协议诊断超时,未收到:\(missing.joined(separator: "、"))"
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ self.pendingDiagnosticResponses.removeValue(forKey: deviceID)
|
|
|
|
|
+ self.diagnosticTokens.removeValue(forKey: deviceID)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func recordDiagnosticResponse(_ message: String, deviceID: String) {
|
|
|
|
|
+ guard var pending = pendingDiagnosticResponses[deviceID] else { return }
|
|
|
|
|
+ let matched = pending.first { message.hasPrefix($0.value) }
|
|
|
|
|
+ guard let matched else { return }
|
|
|
|
|
+ pending.removeValue(forKey: matched.key)
|
|
|
|
|
+ pendingDiagnosticResponses[deviceID] = pending
|
|
|
|
|
+ appendCommunicationLog(
|
|
|
|
|
+ deviceID: deviceID,
|
|
|
|
|
+ kind: "OK",
|
|
|
|
|
+ message: "\(matched.key)回复格式正确"
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func appendCommunicationLog(deviceID: String, kind: String, message: String) {
|
|
|
|
|
+ communicationLogs.append(
|
|
|
|
|
+ BLECommunicationLogEntry(
|
|
|
|
|
+ deviceID: deviceID,
|
|
|
|
|
+ timestamp: Date(),
|
|
|
|
|
+ kind: kind,
|
|
|
|
|
+ message: message
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ if communicationLogs.count > 300 {
|
|
|
|
|
+ communicationLogs.removeFirst(communicationLogs.count - 300)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func currentTimeCommand() -> String {
|
|
|
|
|
+ let formatter = DateFormatter()
|
|
|
|
|
+ formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
|
|
|
+ formatter.dateFormat = "yyyyMMddHHmmss"
|
|
|
|
|
+ return "PQ_BLE&T&\(formatter.string(from: Date()))"
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- // MARK: - Binding Management
|
|
|
|
|
-
|
|
|
|
|
- /// Binds a discovered BLE device to the specified user account ID.
|
|
|
|
|
- func bindDevice(_ device: DiscoveredBLEDevice, userId: String) -> BoundDevice {
|
|
|
|
|
- let boundDevice = BoundDevice(
|
|
|
|
|
- id: device.id,
|
|
|
|
|
- name: device.name,
|
|
|
|
|
- peripheralUUID: device.peripheralUUID,
|
|
|
|
|
- boundAt: Date(),
|
|
|
|
|
- isConnected: true,
|
|
|
|
|
- batteryLevel: Int.random(in: 75...99),
|
|
|
|
|
- firmwareVersion: "v1.4.2",
|
|
|
|
|
- boundUserId: userId
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- // Unbind any existing duplicate
|
|
|
|
|
- boundDevices.removeAll(where: { $0.id == boundDevice.id })
|
|
|
|
|
- boundDevices.append(boundDevice)
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // 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()
|
|
saveBoundDevices()
|
|
|
- return boundDevice
|
|
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- /// Unbinds a device by its ID.
|
|
|
|
|
- func unbindDevice(id: String) {
|
|
|
|
|
- boundDevices.removeAll(where: { $0.id == id })
|
|
|
|
|
|
|
+
|
|
|
|
|
+ private func updateBoundDevice(id: String, mutation: (inout BoundDevice) -> Void) {
|
|
|
|
|
+ guard let index = boundDevices.firstIndex(where: { $0.id == id }) else { return }
|
|
|
|
|
+ mutation(&boundDevices[index])
|
|
|
saveBoundDevices()
|
|
saveBoundDevices()
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- /// Returns devices bound to the given user account.
|
|
|
|
|
- func devices(forUserId userId: String) -> [BoundDevice] {
|
|
|
|
|
- return boundDevices.filter { $0.boundUserId == userId }
|
|
|
|
|
|
|
+
|
|
|
|
|
+ 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))
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- // MARK: - Local Persistence
|
|
|
|
|
-
|
|
|
|
|
|
|
+
|
|
|
|
|
+ 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() {
|
|
private func saveBoundDevices() {
|
|
|
if let data = try? JSONEncoder().encode(boundDevices) {
|
|
if let data = try? JSONEncoder().encode(boundDevices) {
|
|
|
UserDefaults.standard.set(data, forKey: boundDevicesKey)
|
|
UserDefaults.standard.set(data, forKey: boundDevicesKey)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
|
|
+
|
|
|
private func loadBoundDevices() {
|
|
private func loadBoundDevices() {
|
|
|
- if let data = UserDefaults.standard.data(forKey: boundDevicesKey),
|
|
|
|
|
- let devices = try? JSONDecoder().decode([BoundDevice].self, from: data) {
|
|
|
|
|
- self.boundDevices = devices
|
|
|
|
|
|
|
+ guard let data = UserDefaults.standard.data(forKey: boundDevicesKey),
|
|
|
|
|
+ var devices = try? JSONDecoder().decode([BoundDevice].self, from: data) else { return }
|
|
|
|
|
+ let mockDeviceID = "MOCK-SPARK-01"
|
|
|
|
|
+ if devices.contains(where: { $0.id == mockDeviceID || $0.peripheralUUID == mockDeviceID }) {
|
|
|
|
|
+ devices.removeAll { $0.id == mockDeviceID || $0.peripheralUUID == mockDeviceID }
|
|
|
|
|
+ deletePairingPassword(for: mockDeviceID)
|
|
|
|
|
+ if let cleaned = try? JSONEncoder().encode(devices) {
|
|
|
|
|
+ UserDefaults.standard.set(cleaned, forKey: boundDevicesKey)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ for index in devices.indices {
|
|
|
|
|
+ devices[index].isConnected = false
|
|
|
}
|
|
}
|
|
|
|
|
+ boundDevices = devices
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // MARK: - Pairing secret
|
|
|
|
|
+
|
|
|
|
|
+ private func generatePairingPassword() -> String {
|
|
|
|
|
+ let alphabet = Array("ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789")
|
|
|
|
|
+ var bytes = [UInt8](repeating: 0, count: 16)
|
|
|
|
|
+ let result = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
|
|
|
|
+ if result == errSecSuccess {
|
|
|
|
|
+ return String(bytes.map { alphabet[Int($0) % alphabet.count] })
|
|
|
|
|
+ }
|
|
|
|
|
+ return String(UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(16))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func savePairingPassword(_ password: String, for deviceID: String) {
|
|
|
|
|
+ deletePairingPassword(for: deviceID)
|
|
|
|
|
+ let query: [String: Any] = [
|
|
|
|
|
+ kSecClass as String: kSecClassGenericPassword,
|
|
|
|
|
+ kSecAttrService as String: keychainService,
|
|
|
|
|
+ kSecAttrAccount as String: deviceID,
|
|
|
|
|
+ kSecValueData as String: Data(password.utf8),
|
|
|
|
|
+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
|
|
|
|
+ ]
|
|
|
|
|
+ SecItemAdd(query as CFDictionary, nil)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func pairingPassword(for deviceID: String) -> String? {
|
|
|
|
|
+ let query: [String: Any] = [
|
|
|
|
|
+ kSecClass as String: kSecClassGenericPassword,
|
|
|
|
|
+ kSecAttrService as String: keychainService,
|
|
|
|
|
+ kSecAttrAccount as String: deviceID,
|
|
|
|
|
+ kSecReturnData as String: true,
|
|
|
|
|
+ kSecMatchLimit as String: kSecMatchLimitOne
|
|
|
|
|
+ ]
|
|
|
|
|
+ var result: CFTypeRef?
|
|
|
|
|
+ guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
|
|
|
|
+ let data = result as? Data else { return nil }
|
|
|
|
|
+ return String(data: data, encoding: .utf8)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func deletePairingPassword(for deviceID: String) {
|
|
|
|
|
+ let query: [String: Any] = [
|
|
|
|
|
+ kSecClass as String: kSecClassGenericPassword,
|
|
|
|
|
+ kSecAttrService as String: keychainService,
|
|
|
|
|
+ kSecAttrAccount as String: deviceID
|
|
|
|
|
+ ]
|
|
|
|
|
+ SecItemDelete(query as CFDictionary)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - Spark audio recorder
|
|
|
|
|
+
|
|
|
|
|
+/// Records the MP3 notification stream produced by a bound Spark device.
|
|
|
|
|
+final class SparkAudioRecorder: AudioRecorderProtocol {
|
|
|
|
|
+ @Published var isRecording: Bool = false
|
|
|
|
|
+ @Published var elapsedTime: TimeInterval = 0
|
|
|
|
|
+ @Published var currentAmplitude: Float = 0
|
|
|
|
|
+ @Published var waveformSamples: [Float] = []
|
|
|
|
|
+ @Published var outputFileURL: URL?
|
|
|
|
|
+ @Published var isPaused: Bool = false
|
|
|
|
|
+ @Published var statusMessage: String = "正在连接设备"
|
|
|
|
|
+ @Published var errorMessage: String?
|
|
|
|
|
+
|
|
|
|
|
+ let sourceDisplayName: String
|
|
|
|
|
+
|
|
|
|
|
+ private let deviceID: String
|
|
|
|
|
+ private let manager: BLEManager
|
|
|
|
|
+ private let ioQueue = DispatchQueue(label: "com.celestia.trace.spark.audio-io")
|
|
|
|
|
+ private var fileHandle: FileHandle?
|
|
|
|
|
+ private var timer: Timer?
|
|
|
|
|
+ private var recordingStartedAt: Date?
|
|
|
|
|
+ private var stopCompletion: ((Result<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)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|