|
@@ -21,6 +21,38 @@ enum SparkRecorderConnectionEvent {
|
|
|
case failed(String)
|
|
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 {
|
|
enum SparkBLEError: LocalizedError {
|
|
|
case bluetoothUnavailable
|
|
case bluetoothUnavailable
|
|
|
case deviceUnavailable
|
|
case deviceUnavailable
|
|
@@ -49,14 +81,24 @@ enum SparkBLEError: LocalizedError {
|
|
|
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 serviceUUID = CBUUID(string: "001120a0-2233-4455-6677-88995a5b5c5d")
|
|
|
static let audioNotifyUUID = CBUUID(string: "001120a1-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 writeUUID = CBUUID(string: "001120a2-2233-4455-6677-88995a5b5c5d")
|
|
|
static let commandNotifyUUID = CBUUID(string: "001120a3-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 {
|
|
static func isSparkAdvertisement(name: String?, serviceUUIDs: [CBUUID]) -> Bool {
|
|
|
let normalizedName = name?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
|
|
let normalizedName = name?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
|
|
|
return serviceUUIDs.contains(serviceUUID)
|
|
return serviceUUIDs.contains(serviceUUID)
|
|
|
|
|
+ || isYLF20AdvertisementName(name)
|
|
|
|| normalizedName.contains("spark")
|
|
|| normalizedName.contains("spark")
|
|
|
|| normalizedName.contains("mr20")
|
|
|| normalizedName.contains("mr20")
|
|
|
|| normalizedName.contains("微光")
|
|
|| normalizedName.contains("微光")
|
|
@@ -65,6 +107,9 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
|
|
|
@Published private(set) var state: CBManagerState = .unknown
|
|
@Published private(set) var state: CBManagerState = .unknown
|
|
|
@Published private(set) var isScanning: Bool = false
|
|
@Published private(set) var isScanning: Bool = false
|
|
|
@Published private(set) var discoveredDevices: [DiscoveredBLEDevice] = []
|
|
@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 boundDevices: [BoundDevice] = []
|
|
|
@Published private(set) var connectionPhases: [String: SparkConnectionPhase] = [:]
|
|
@Published private(set) var connectionPhases: [String: SparkConnectionPhase] = [:]
|
|
|
@Published private(set) var recordingDeviceID: String?
|
|
@Published private(set) var recordingDeviceID: String?
|
|
@@ -92,6 +137,47 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
|
|
|
let completion: (Result<String, Error>) -> Void
|
|
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 restorationIdentifier = "com.celestia.trace.spark.central"
|
|
@@ -106,6 +192,26 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
|
|
|
private var audioConsumers: [String: (Data) -> Void] = [:]
|
|
private var audioConsumers: [String: (Data) -> Void] = [:]
|
|
|
private var recorderStateConsumers: [String: (SparkRecorderConnectionEvent) -> Void] = [:]
|
|
private var recorderStateConsumers: [String: (SparkRecorderConnectionEvent) -> Void] = [:]
|
|
|
private var scanRequested = false
|
|
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()
|
|
@@ -131,6 +237,10 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
|
|
|
connectionPhases[deviceID] ?? .disconnected
|
|
connectionPhases[deviceID] ?? .disconnected
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ func communicationLogs(for deviceID: String) -> [BLECommunicationLogEntry] {
|
|
|
|
|
+ communicationLogs.filter { $0.deviceID == deviceID }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
func renameDevice(id: String, to proposedName: String) {
|
|
func renameDevice(id: String, to proposedName: String) {
|
|
|
let trimmed = proposedName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let trimmed = proposedName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
guard !trimmed.isEmpty, let index = boundDevices.firstIndex(where: { $0.id == id }) else { return }
|
|
guard !trimmed.isEmpty, let index = boundDevices.firstIndex(where: { $0.id == id }) else { return }
|
|
@@ -169,11 +279,18 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
|
|
|
|
|
|
|
|
func startScanning() {
|
|
func startScanning() {
|
|
|
discoveredDevices.removeAll()
|
|
discoveredDevices.removeAll()
|
|
|
|
|
+ observedPeripheralIDs.removeAll()
|
|
|
|
|
+ observedPeripheralCount = 0
|
|
|
|
|
+ observedAdvertisementsByID.removeAll()
|
|
|
|
|
+ observedAdvertisements.removeAll()
|
|
|
lastErrorMessage = nil
|
|
lastErrorMessage = nil
|
|
|
scanRequested = true
|
|
scanRequested = true
|
|
|
|
|
+ startAdvertisementRefreshTimer()
|
|
|
|
|
|
|
|
guard centralManager.state == .poweredOn else {
|
|
guard centralManager.state == .poweredOn else {
|
|
|
- isScanning = true
|
|
|
|
|
|
|
+ // Keep the request pending while CoreBluetooth initializes. The
|
|
|
|
|
+ // delegate starts scanning if the state later becomes powered on.
|
|
|
|
|
+ isScanning = false
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -183,12 +300,14 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
|
|
|
// Spark service or a known product name.
|
|
// Spark service or a known product name.
|
|
|
centralManager.scanForPeripherals(
|
|
centralManager.scanForPeripherals(
|
|
|
withServices: nil,
|
|
withServices: nil,
|
|
|
- options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
|
|
|
|
|
|
|
+ options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func stopScanning() {
|
|
func stopScanning() {
|
|
|
scanRequested = false
|
|
scanRequested = false
|
|
|
|
|
+ advertisementRefreshTimer?.invalidate()
|
|
|
|
|
+ advertisementRefreshTimer = nil
|
|
|
if centralManager.state == .poweredOn {
|
|
if centralManager.state == .poweredOn {
|
|
|
centralManager.stopScan()
|
|
centralManager.stopScan()
|
|
|
}
|
|
}
|
|
@@ -209,6 +328,12 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ communicationLogs.removeAll { $0.deviceID == device.id }
|
|
|
|
|
+ appendCommunicationLog(
|
|
|
|
|
+ deviceID: device.id,
|
|
|
|
|
+ kind: "INFO",
|
|
|
|
|
+ message: "开始绑定 \(device.name),RSSI \(device.rssi) dBm"
|
|
|
|
|
+ )
|
|
|
let password = generatePairingPassword()
|
|
let password = generatePairingPassword()
|
|
|
pendingBindings[device.id] = PendingBinding(
|
|
pendingBindings[device.id] = PendingBinding(
|
|
|
discoveredDevice: device,
|
|
discoveredDevice: device,
|
|
@@ -219,9 +344,28 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
|
|
|
connectionPhases[device.id] = .connecting
|
|
connectionPhases[device.id] = .connecting
|
|
|
peripheral.delegate = self
|
|
peripheral.delegate = self
|
|
|
stopScanning()
|
|
stopScanning()
|
|
|
|
|
+ appendCommunicationLog(deviceID: device.id, kind: "INFO", message: "正在建立 BLE 连接")
|
|
|
centralManager.connect(peripheral, options: nil)
|
|
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
|
|
// MARK: - Spark recording control
|
|
|
|
|
|
|
|
func startSparkRecording(
|
|
func startSparkRecording(
|
|
@@ -279,6 +423,7 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
|
|
|
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
|
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
|
|
state = central.state
|
|
state = central.state
|
|
|
guard central.state == .poweredOn else {
|
|
guard central.state == .poweredOn else {
|
|
|
|
|
+ isScanning = false
|
|
|
markAllDevicesDisconnected()
|
|
markAllDevicesDisconnected()
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
@@ -287,7 +432,7 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
|
|
|
isScanning = true
|
|
isScanning = true
|
|
|
central.scanForPeripherals(
|
|
central.scanForPeripherals(
|
|
|
withServices: nil,
|
|
withServices: nil,
|
|
|
- options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
|
|
|
|
|
|
|
+ options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -314,19 +459,62 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
|
|
|
rssi RSSI: NSNumber
|
|
rssi RSSI: NSNumber
|
|
|
) {
|
|
) {
|
|
|
let id = peripheral.identifier.uuidString
|
|
let id = peripheral.identifier.uuidString
|
|
|
|
|
+ observedPeripheralIDs.insert(id)
|
|
|
|
|
+ observedPeripheralCount = observedPeripheralIDs.count
|
|
|
|
|
+
|
|
|
let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String
|
|
let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String
|
|
|
?? peripheral.name
|
|
?? peripheral.name
|
|
|
let advertisedServices = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? []
|
|
let advertisedServices = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? []
|
|
|
- guard Self.isSparkAdvertisement(name: advertisedName, serviceUUIDs: advertisedServices) else { return }
|
|
|
|
|
|
|
|
|
|
- let displayName = advertisedName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
guard RSSI.intValue != 127 else { return }
|
|
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
|
|
peripherals[id] = peripheral
|
|
|
peripheral.delegate = self
|
|
peripheral.delegate = self
|
|
|
let device = DiscoveredBLEDevice(
|
|
let device = DiscoveredBLEDevice(
|
|
|
id: id,
|
|
id: id,
|
|
|
- name: displayName?.isEmpty == false ? displayName! : "微光(Spark)",
|
|
|
|
|
|
|
+ name: trimmedAdvertisedName?.isEmpty == false ? trimmedAdvertisedName! : "微光(Spark)",
|
|
|
rssi: RSSI.intValue,
|
|
rssi: RSSI.intValue,
|
|
|
peripheralUUID: id
|
|
peripheralUUID: id
|
|
|
)
|
|
)
|
|
@@ -338,8 +526,61 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
|
|
|
discoveredDevices.sort { $0.rssi > $1.rssi }
|
|
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) {
|
|
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
|
|
let id = peripheral.identifier.uuidString
|
|
let id = peripheral.identifier.uuidString
|
|
|
|
|
+ appendCommunicationLog(deviceID: id, kind: "OK", message: "BLE 连接成功")
|
|
|
|
|
+ appendCommunicationLog(
|
|
|
|
|
+ deviceID: id,
|
|
|
|
|
+ kind: "INFO",
|
|
|
|
|
+ message: "查询主服务 \(Self.serviceUUID.uuidString)"
|
|
|
|
|
+ )
|
|
|
peripherals[id] = peripheral
|
|
peripherals[id] = peripheral
|
|
|
peripheral.delegate = self
|
|
peripheral.delegate = self
|
|
|
connectionPhases[id] = .discovering
|
|
connectionPhases[id] = .discovering
|
|
@@ -348,6 +589,11 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
|
|
|
|
|
|
|
|
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
|
|
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
|
|
|
let id = peripheral.identifier.uuidString
|
|
let id = peripheral.identifier.uuidString
|
|
|
|
|
+ appendCommunicationLog(
|
|
|
|
|
+ deviceID: id,
|
|
|
|
|
+ kind: "ERROR",
|
|
|
|
|
+ message: "BLE 连接失败:\(error?.localizedDescription ?? "未知错误")"
|
|
|
|
|
+ )
|
|
|
let failure = SparkBLEError.connectionFailed(error?.localizedDescription ?? "未知错误")
|
|
let failure = SparkBLEError.connectionFailed(error?.localizedDescription ?? "未知错误")
|
|
|
connectionPhases[id] = .failed(failure.localizedDescription)
|
|
connectionPhases[id] = .failed(failure.localizedDescription)
|
|
|
finishPendingBinding(deviceID: id, result: .failure(failure))
|
|
finishPendingBinding(deviceID: id, result: .failure(failure))
|
|
@@ -355,6 +601,13 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
|
|
|
|
|
|
|
|
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
|
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
|
|
let id = peripheral.identifier.uuidString
|
|
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 }
|
|
updateBoundDevice(id: id) { $0.isConnected = false }
|
|
|
characteristics.removeValue(forKey: id)
|
|
characteristics.removeValue(forKey: id)
|
|
|
authenticatingDeviceIDs.remove(id)
|
|
authenticatingDeviceIDs.remove(id)
|
|
@@ -388,12 +641,24 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
|
|
|
let id = peripheral.identifier.uuidString
|
|
let id = peripheral.identifier.uuidString
|
|
|
guard error == nil,
|
|
guard error == nil,
|
|
|
let service = peripheral.services?.first(where: { $0.uuid == Self.serviceUUID }) else {
|
|
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
|
|
let failure = SparkBLEError.incompatibleDevice
|
|
|
connectionPhases[id] = .failed(failure.localizedDescription)
|
|
connectionPhases[id] = .failed(failure.localizedDescription)
|
|
|
finishPendingBinding(deviceID: id, result: .failure(failure))
|
|
finishPendingBinding(deviceID: id, result: .failure(failure))
|
|
|
centralManager.cancelPeripheralConnection(peripheral)
|
|
centralManager.cancelPeripheralConnection(peripheral)
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
+ appendCommunicationLog(
|
|
|
|
|
+ deviceID: id,
|
|
|
|
|
+ kind: "OK",
|
|
|
|
|
+ message: "发现协议主服务 \(service.uuid.uuidString)"
|
|
|
|
|
+ )
|
|
|
|
|
+ appendCommunicationLog(deviceID: id, kind: "INFO", message: "查询音频 Notify、写、指令 Notify 特征")
|
|
|
peripheral.discoverCharacteristics(
|
|
peripheral.discoverCharacteristics(
|
|
|
[Self.audioNotifyUUID, Self.commandNotifyUUID, Self.writeUUID],
|
|
[Self.audioNotifyUUID, Self.commandNotifyUUID, Self.writeUUID],
|
|
|
for: service
|
|
for: service
|
|
@@ -403,6 +668,11 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
|
|
|
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
|
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
|
|
let id = peripheral.identifier.uuidString
|
|
let id = peripheral.identifier.uuidString
|
|
|
guard error == nil else {
|
|
guard error == nil else {
|
|
|
|
|
+ appendCommunicationLog(
|
|
|
|
|
+ deviceID: id,
|
|
|
|
|
+ kind: "ERROR",
|
|
|
|
|
+ message: "发现特征失败:\(error?.localizedDescription ?? "未知错误")"
|
|
|
|
|
+ )
|
|
|
failTransport(deviceID: id, message: error?.localizedDescription ?? "发现特征失败")
|
|
failTransport(deviceID: id, message: error?.localizedDescription ?? "发现特征失败")
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
@@ -417,10 +687,22 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
guard set.isComplete, let audio = set.audioNotify, let command = set.commandNotify else {
|
|
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: "设备缺少必要的录音特征")
|
|
failTransport(deviceID: id, message: "设备缺少必要的录音特征")
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
+ appendCommunicationLog(
|
|
|
|
|
+ deviceID: id,
|
|
|
|
|
+ kind: "OK",
|
|
|
|
|
+ message: "三个协议特征齐全:A1 音频 Notify、A2 写、A3 指令 Notify"
|
|
|
|
|
+ )
|
|
|
characteristics[id] = set
|
|
characteristics[id] = set
|
|
|
|
|
+ appendCommunicationLog(deviceID: id, kind: "INFO", message: "订阅 A1 音频与 A3 指令通知")
|
|
|
peripheral.setNotifyValue(true, for: command)
|
|
peripheral.setNotifyValue(true, for: command)
|
|
|
peripheral.setNotifyValue(true, for: audio)
|
|
peripheral.setNotifyValue(true, for: audio)
|
|
|
}
|
|
}
|
|
@@ -428,9 +710,19 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
|
|
|
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
|
|
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
|
|
|
let id = peripheral.identifier.uuidString
|
|
let id = peripheral.identifier.uuidString
|
|
|
guard error == nil else {
|
|
guard error == nil else {
|
|
|
|
|
+ appendCommunicationLog(
|
|
|
|
|
+ deviceID: id,
|
|
|
|
|
+ kind: "ERROR",
|
|
|
|
|
+ message: "订阅 \(characteristic.uuid.uuidString) 失败:\(error?.localizedDescription ?? "未知错误")"
|
|
|
|
|
+ )
|
|
|
failTransport(deviceID: id, message: error?.localizedDescription ?? "订阅设备通知失败")
|
|
failTransport(deviceID: id, message: error?.localizedDescription ?? "订阅设备通知失败")
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
+ appendCommunicationLog(
|
|
|
|
|
+ deviceID: id,
|
|
|
|
|
+ kind: characteristic.isNotifying ? "OK" : "ERROR",
|
|
|
|
|
+ message: "\(characteristic.uuid.uuidString) 通知\(characteristic.isNotifying ? "已开启" : "未开启")"
|
|
|
|
|
+ )
|
|
|
guard let set = characteristics[id],
|
|
guard let set = characteristics[id],
|
|
|
set.audioNotify?.isNotifying == true,
|
|
set.audioNotify?.isNotifying == true,
|
|
|
set.commandNotify?.isNotifying == true else { return }
|
|
set.commandNotify?.isNotifying == true else { return }
|
|
@@ -445,6 +737,7 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
|
|
|
audioConsumers[id]?(data)
|
|
audioConsumers[id]?(data)
|
|
|
} else if characteristic.uuid == Self.commandNotifyUUID {
|
|
} else if characteristic.uuid == Self.commandNotifyUUID {
|
|
|
for message in decodeCommandMessages(data) {
|
|
for message in decodeCommandMessages(data) {
|
|
|
|
|
+ appendCommunicationLog(deviceID: id, kind: "RX", message: message)
|
|
|
handleCommand(message, deviceID: id)
|
|
handleCommand(message, deviceID: id)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -453,6 +746,11 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
|
|
|
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
|
|
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
|
|
|
guard let error else { return }
|
|
guard let error else { return }
|
|
|
let id = peripheral.identifier.uuidString
|
|
let id = peripheral.identifier.uuidString
|
|
|
|
|
+ appendCommunicationLog(
|
|
|
|
|
+ deviceID: id,
|
|
|
|
|
+ kind: "ERROR",
|
|
|
|
|
+ message: "写入 \(characteristic.uuid.uuidString) 失败:\(error.localizedDescription)"
|
|
|
|
|
+ )
|
|
|
lastErrorMessage = "发送设备指令失败:\(error.localizedDescription)"
|
|
lastErrorMessage = "发送设备指令失败:\(error.localizedDescription)"
|
|
|
recorderStateConsumers[id]?(.failed(lastErrorMessage ?? "发送设备指令失败"))
|
|
recorderStateConsumers[id]?(.failed(lastErrorMessage ?? "发送设备指令失败"))
|
|
|
}
|
|
}
|
|
@@ -460,8 +758,14 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
|
|
|
// MARK: - Protocol handling
|
|
// MARK: - Protocol handling
|
|
|
|
|
|
|
|
private func transportDidBecomeReady(deviceID: String) {
|
|
private func transportDidBecomeReady(deviceID: String) {
|
|
|
|
|
+ appendCommunicationLog(deviceID: deviceID, kind: "OK", message: "A1 与 A3 通知均已订阅,通信通道就绪")
|
|
|
if let pending = pendingBindings[deviceID] {
|
|
if let pending = pendingBindings[deviceID] {
|
|
|
connectionPhases[deviceID] = .authenticating
|
|
connectionPhases[deviceID] = .authenticating
|
|
|
|
|
+ appendCommunicationLog(
|
|
|
|
|
+ deviceID: deviceID,
|
|
|
|
|
+ kind: "TX",
|
|
|
|
|
+ message: "PQ_BLE&SK&••••••••••••••••(16 位绑定密码已隐藏)"
|
|
|
|
|
+ )
|
|
|
do {
|
|
do {
|
|
|
try writeCommand("PQ_BLE&SK&\(pending.password)", to: deviceID)
|
|
try writeCommand("PQ_BLE&SK&\(pending.password)", to: deviceID)
|
|
|
} catch {
|
|
} catch {
|
|
@@ -487,10 +791,30 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func handleCommand(_ message: String, deviceID: String) {
|
|
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: "&")
|
|
let fields = message.components(separatedBy: "&")
|
|
|
guard fields.count >= 2 else { return }
|
|
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") {
|
|
if message.hasPrefix("PQ_DEV&SK&OK") {
|
|
|
|
|
+ appendCommunicationLog(deviceID: deviceID, kind: "OK", message: "设备确认密钥配对成功")
|
|
|
if let pending = pendingBindings[deviceID] {
|
|
if let pending = pendingBindings[deviceID] {
|
|
|
savePairingPassword(pending.password, for: deviceID)
|
|
savePairingPassword(pending.password, for: deviceID)
|
|
|
var bound = BoundDevice(
|
|
var bound = BoundDevice(
|
|
@@ -519,6 +843,7 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
if message.hasPrefix("PQ_DEV&SK&ERR") {
|
|
if message.hasPrefix("PQ_DEV&SK&ERR") {
|
|
|
|
|
+ appendCommunicationLog(deviceID: deviceID, kind: "ERROR", message: "设备拒绝密钥配对")
|
|
|
authenticatingDeviceIDs.remove(deviceID)
|
|
authenticatingDeviceIDs.remove(deviceID)
|
|
|
finishPendingBinding(deviceID: deviceID, result: .failure(SparkBLEError.pairingFailed))
|
|
finishPendingBinding(deviceID: deviceID, result: .failure(SparkBLEError.pairingFailed))
|
|
|
connectionPhases[deviceID] = .failed(SparkBLEError.pairingFailed.localizedDescription)
|
|
connectionPhases[deviceID] = .failed(SparkBLEError.pairingFailed.localizedDescription)
|
|
@@ -606,12 +931,21 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func writeCommand(_ command: String, to deviceID: String) throws {
|
|
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],
|
|
guard let peripheral = peripherals[deviceID],
|
|
|
peripheral.state == .connected,
|
|
peripheral.state == .connected,
|
|
|
let characteristic = characteristics[deviceID]?.write else {
|
|
let characteristic = characteristics[deviceID]?.write else {
|
|
|
throw SparkBLEError.commandUnavailable
|
|
throw SparkBLEError.commandUnavailable
|
|
|
}
|
|
}
|
|
|
- let data = Data(command.utf8)
|
|
|
|
|
|
|
+ let data = wireFormat.data(for: command)
|
|
|
let writeType: CBCharacteristicWriteType
|
|
let writeType: CBCharacteristicWriteType
|
|
|
if characteristic.properties.contains(.write) {
|
|
if characteristic.properties.contains(.write) {
|
|
|
writeType = .withResponse
|
|
writeType = .withResponse
|
|
@@ -623,16 +957,167 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
|
|
|
guard data.count <= peripheral.maximumWriteValueLength(for: writeType) else {
|
|
guard data.count <= peripheral.maximumWriteValueLength(for: writeType) else {
|
|
|
throw SparkBLEError.deviceReported("设备指令超过单帧写入长度。")
|
|
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)
|
|
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) {
|
|
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()]
|
|
let commands = ["PQ_BLE&STE", "PQ_BLE&BAT", "PQ_BLE&SPACE", "PQ_BLE&FW", "PQ_BLE&MAC", currentTimeCommand()]
|
|
|
for (index, command) in commands.enumerated() {
|
|
for (index, command) in commands.enumerated() {
|
|
|
- DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * 0.12) { [weak self] in
|
|
|
|
|
|
|
+ DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * 0.25) { [weak self] in
|
|
|
try? self?.writeCommand(command, to: deviceID)
|
|
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 {
|
|
private func currentTimeCommand() -> String {
|