Переглянути джерело

feat(ble): 引入 BLE 协议兼容探测、通信日志诊断面板及单元测试

bob.yuxinyang 1 місяць тому
батько
коміт
190982a03d

+ 493 - 8
CelestiaTrace/Services/Bluetooth/BLEManager.swift

@@ -21,6 +21,38 @@ enum SparkRecorderConnectionEvent {
     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
@@ -49,14 +81,24 @@ enum SparkBLEError: LocalizedError {
 final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeripheralDelegate {
     static let shared = BLEManager()
 
+    private static let debugMinimumRSSI = -75
+    private static let advertisementDisappearanceInterval: TimeInterval = 4
+    private static let disappearedAdvertisementRetentionInterval: TimeInterval = 30
+
     static let serviceUUID = CBUUID(string: "001120a0-2233-4455-6677-88995a5b5c5d")
     static let audioNotifyUUID = CBUUID(string: "001120a1-2233-4455-6677-88995a5b5c5d")
     static let writeUUID = CBUUID(string: "001120a2-2233-4455-6677-88995a5b5c5d")
     static let commandNotifyUUID = CBUUID(string: "001120a3-2233-4455-6677-88995a5b5c5d")
 
+    static func isYLF20AdvertisementName(_ name: String?) -> Bool {
+        let normalizedName = name?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
+        return normalizedName == "ylf20" || normalizedName.hasPrefix("ylf20_")
+    }
+
     static func isSparkAdvertisement(name: String?, serviceUUIDs: [CBUUID]) -> Bool {
         let normalizedName = name?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
         return serviceUUIDs.contains(serviceUUID)
+            || isYLF20AdvertisementName(name)
             || normalizedName.contains("spark")
             || normalizedName.contains("mr20")
             || normalizedName.contains("微光")
@@ -65,6 +107,9 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
     @Published private(set) var state: CBManagerState = .unknown
     @Published private(set) var isScanning: Bool = false
     @Published private(set) var discoveredDevices: [DiscoveredBLEDevice] = []
+    @Published private(set) var observedPeripheralCount: Int = 0
+    @Published private(set) var observedAdvertisements: [ObservedBLEAdvertisement] = []
+    @Published private(set) var communicationLogs: [BLECommunicationLogEntry] = []
     @Published private(set) var boundDevices: [BoundDevice] = []
     @Published private(set) var connectionPhases: [String: SparkConnectionPhase] = [:]
     @Published private(set) var recordingDeviceID: String?
@@ -92,6 +137,47 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
         let completion: (Result<String, Error>) -> Void
     }
 
+    private enum ProbeWireFormat: CaseIterable {
+        case documented
+        case documentedCRLF
+        case documentedNull
+        case legacyPrefix
+        case legacyPrefixCRLF
+        case legacyPrefixNull
+
+        var label: String {
+            switch self {
+            case .documented: return "文档原格式"
+            case .documentedCRLF: return "文档格式 + CRLF"
+            case .documentedNull: return "文档格式 + NUL"
+            case .legacyPrefix: return "无 PQ_ 前缀"
+            case .legacyPrefixCRLF: return "无 PQ_ 前缀 + CRLF"
+            case .legacyPrefixNull: return "无 PQ_ 前缀 + NUL"
+            }
+        }
+
+        func data(for command: String) -> Data {
+            let base: String
+            switch self {
+            case .legacyPrefix, .legacyPrefixCRLF, .legacyPrefixNull:
+                base = command.replacingOccurrences(of: "PQ_BLE&", with: "BLE&")
+            default:
+                base = command
+            }
+
+            switch self {
+            case .documentedCRLF, .legacyPrefixCRLF:
+                return Data("\(base)\r\n".utf8)
+            case .documentedNull, .legacyPrefixNull:
+                var data = Data(base.utf8)
+                data.append(0)
+                return data
+            default:
+                return Data(base.utf8)
+            }
+        }
+    }
+
     private var centralManager: CBCentralManager!
     private let boundDevicesKey = "com.celestia.trace.bound_devices"
     private let restorationIdentifier = "com.celestia.trace.spark.central"
@@ -106,6 +192,26 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
     private var audioConsumers: [String: (Data) -> Void] = [:]
     private var recorderStateConsumers: [String: (SparkRecorderConnectionEvent) -> Void] = [:]
     private var scanRequested = false
+    private var observedPeripheralIDs: Set<String> = []
+    private var observedAdvertisementsByID: [String: ObservedBLEAdvertisement] = [:]
+    private var advertisementRefreshTimer: Timer?
+    private var pendingDiagnosticResponses: [String: [String: String]] = [:]
+    private var diagnosticTokens: [String: UUID] = [:]
+    private var protocolProbeTokens: [String: UUID] = [:]
+    private var activeProbeFormats: [String: ProbeWireFormat] = [:]
+
+    var scanUnavailableMessage: String? {
+        switch state {
+        case .unauthorized:
+            return "蓝牙权限未开启。请前往“设置”允许星痕访问蓝牙。"
+        case .poweredOff:
+            return "系统蓝牙已关闭,请先在控制中心或“设置”中开启蓝牙。"
+        case .unsupported:
+            return "当前运行设备不支持 BLE 扫描。请使用支持蓝牙的 iPhone 真机测试。"
+        default:
+            return nil
+        }
+    }
 
     override private init() {
         super.init()
@@ -131,6 +237,10 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
         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 }
@@ -169,11 +279,18 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
 
     func startScanning() {
         discoveredDevices.removeAll()
+        observedPeripheralIDs.removeAll()
+        observedPeripheralCount = 0
+        observedAdvertisementsByID.removeAll()
+        observedAdvertisements.removeAll()
         lastErrorMessage = nil
         scanRequested = true
+        startAdvertisementRefreshTimer()
 
         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
         }
 
@@ -183,12 +300,14 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
         // Spark service or a known product name.
         centralManager.scanForPeripherals(
             withServices: nil,
-            options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
+            options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
         )
     }
 
     func stopScanning() {
         scanRequested = false
+        advertisementRefreshTimer?.invalidate()
+        advertisementRefreshTimer = nil
         if centralManager.state == .poweredOn {
             centralManager.stopScan()
         }
@@ -209,6 +328,12 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
             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,
@@ -219,9 +344,28 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
         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(
@@ -279,6 +423,7 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
     func centralManagerDidUpdateState(_ central: CBCentralManager) {
         state = central.state
         guard central.state == .poweredOn else {
+            isScanning = false
             markAllDevicesDisconnected()
             return
         }
@@ -287,7 +432,7 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
             isScanning = true
             central.scanForPeripherals(
                 withServices: nil,
-                options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
+                options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
             )
         }
     }
@@ -314,19 +459,62 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
         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 Self.isSparkAdvertisement(name: advertisedName, serviceUUIDs: advertisedServices) else { return }
 
-        let displayName = advertisedName?.trimmingCharacters(in: .whitespacesAndNewlines)
         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(
             id: id,
-            name: displayName?.isEmpty == false ? displayName! : "微光(Spark)",
+            name: trimmedAdvertisedName?.isEmpty == false ? trimmedAdvertisedName! : "微光(Spark)",
             rssi: RSSI.intValue,
             peripheralUUID: id
         )
@@ -338,8 +526,61 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
         discoveredDevices.sort { $0.rssi > $1.rssi }
     }
 
+    private func startAdvertisementRefreshTimer() {
+        advertisementRefreshTimer?.invalidate()
+        advertisementRefreshTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
+            self?.publishObservedAdvertisements(now: Date())
+        }
+    }
+
+    private func publishObservedAdvertisements(now: Date) {
+        for (id, var advertisement) in observedAdvertisementsByID {
+            let elapsed = now.timeIntervalSince(advertisement.lastSeenAt)
+            if advertisement.isPresent,
+               elapsed >= Self.advertisementDisappearanceInterval {
+                advertisement.isPresent = false
+                advertisement.lastTransitionAt = now
+                observedAdvertisementsByID[id] = advertisement
+            }
+        }
+
+        observedAdvertisementsByID = observedAdvertisementsByID.filter {
+            now.timeIntervalSince($0.value.lastSeenAt)
+                < Self.disappearedAdvertisementRetentionInterval
+        }
+
+        observedAdvertisements = Array(observedAdvertisementsByID.values
+            .filter {
+                ($0.isPresent
+                    ? $0.rssi >= Self.debugMinimumRSSI
+                    : $0.peakRSSI >= Self.debugMinimumRSSI)
+                    && ($0.isPresent || now.timeIntervalSince($0.lastSeenAt) < 15)
+            }
+            .sorted {
+                let leftTransitioning = now.timeIntervalSince($0.lastTransitionAt) < 8
+                let rightTransitioning = now.timeIntervalSince($1.lastTransitionAt) < 8
+                if leftTransitioning != rightTransitioning {
+                    return leftTransitioning
+                }
+                if $0.isStrongSignal != $1.isStrongSignal {
+                    return $0.isStrongSignal
+                }
+                if $0.matchesSpark != $1.matchesSpark {
+                    return $0.matchesSpark
+                }
+                return $0.rssi > $1.rssi
+            }
+            .prefix(50))
+    }
+
     func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
         let id = peripheral.identifier.uuidString
+        appendCommunicationLog(deviceID: id, kind: "OK", message: "BLE 连接成功")
+        appendCommunicationLog(
+            deviceID: id,
+            kind: "INFO",
+            message: "查询主服务 \(Self.serviceUUID.uuidString)"
+        )
         peripherals[id] = peripheral
         peripheral.delegate = self
         connectionPhases[id] = .discovering
@@ -348,6 +589,11 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
 
     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))
@@ -355,6 +601,13 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
 
     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)
@@ -388,12 +641,24 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
         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
@@ -403,6 +668,11 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
     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
         }
@@ -417,10 +687,22 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
             }
         }
         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)
     }
@@ -428,9 +710,19 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
     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 }
@@ -445,6 +737,7 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
             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)
             }
         }
@@ -453,6 +746,11 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
     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 ?? "发送设备指令失败"))
     }
@@ -460,8 +758,14 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
     // 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 {
@@ -487,10 +791,30 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
     }
 
     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(
@@ -519,6 +843,7 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
         }
 
         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)
@@ -606,12 +931,21 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
     }
 
     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 = Data(command.utf8)
+        let data = wireFormat.data(for: command)
         let writeType: CBCharacteristicWriteType
         if characteristic.properties.contains(.write) {
             writeType = .withResponse
@@ -623,16 +957,167 @@ final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB
         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.12) { [weak self] in
+            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 {

+ 293 - 33
CelestiaTrace/Views/Profile/Devices/DeviceScanView.swift

@@ -1,4 +1,5 @@
 import SwiftUI
+import UIKit
 
 /// Modal sheet for scanning nearby BLE audio recording devices and binding them to the logged-in user.
 struct DeviceScanView: View {
@@ -9,6 +10,8 @@ struct DeviceScanView: View {
     @State private var boundSuccessMessage: String?
     @State private var bindingDeviceID: String?
     @State private var bindingErrorMessage: String?
+    @State private var debugDeviceID: String?
+    @State private var copiedCompatibilityLog = false
     
     var body: some View {
         ZStack {
@@ -52,7 +55,7 @@ struct DeviceScanView: View {
                             .foregroundStyle(Color.primary)
                     }
                     
-                    Text(bleManager.isScanning ? "正在扫描附近的蓝牙录音设备..." : "扫描已停止")
+                    Text(scanStatusText)
                         .font(.system(size: 13, weight: .medium))
                         .foregroundStyle(Color.primary)
                     
@@ -80,6 +83,60 @@ struct DeviceScanView: View {
                 .background(Color.cardBackground.opacity(0.4))
                 .businessBorder(cornerRadius: 10)
                 .padding(.horizontal, 20)
+
+                if let issue = bleManager.scanUnavailableMessage {
+                    VStack(alignment: .leading, spacing: 10) {
+                        Label(issue, systemImage: "exclamationmark.triangle.fill")
+                            .font(.system(size: 12))
+                            .foregroundStyle(Color.orange)
+
+                        if bleManager.state == .unauthorized {
+                            Button("打开系统设置") {
+                                guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
+                                UIApplication.shared.open(url)
+                            }
+                            .font(.system(size: 12, weight: .medium))
+                        }
+                    }
+                    .frame(maxWidth: .infinity, alignment: .leading)
+                    .padding(14)
+                    .background(Color.orange.opacity(0.08))
+                    .businessBorder(cornerRadius: 10)
+                    .padding(.horizontal, 20)
+                }
+
+                formalBindingSection
+
+                if let debugDeviceID {
+                    communicationDebugPanel(deviceID: debugDeviceID)
+                } else if !bleManager.observedAdvertisements.isEmpty {
+                    VStack(alignment: .leading, spacing: 10) {
+                        HStack {
+                            Text("BLE 广播调试")
+                                .font(.system(size: 13, weight: .semibold))
+                                .foregroundStyle(Color.primary)
+
+                            Spacer()
+
+                            Text("\(bleManager.observedAdvertisements.count) 个设备 · 最多显示 50 个")
+                                .font(.system(size: 11, design: .monospaced))
+                                .foregroundStyle(Color.secondary)
+                        }
+
+                        ScrollView {
+                            LazyVStack(spacing: 8) {
+                                ForEach(bleManager.observedAdvertisements) { advertisement in
+                                    observedAdvertisementRow(advertisement)
+                                }
+                            }
+                        }
+                        .frame(height: 300)
+                    }
+                    .padding(14)
+                    .background(Color.cardBackground.opacity(0.4))
+                    .businessBorder(cornerRadius: 10)
+                    .padding(.horizontal, 20)
+                }
                 
                 // Success Toast Banner
                 if let successMsg = boundSuccessMessage {
@@ -97,30 +154,6 @@ struct DeviceScanView: View {
                     .transition(.move(edge: .top).combined(with: .opacity))
                 }
                 
-                // Discovered Devices List
-                if bleManager.discoveredDevices.isEmpty {
-                    VStack(spacing: 12) {
-                        Spacer()
-                        Image(systemName: "antenna.radiowaves.left.and.right")
-                            .font(.system(size: 36, weight: .ultraLight))
-                            .foregroundStyle(Color.secondary.opacity(0.4))
-                        
-                        Text("未搜索到可用的蓝牙录音设备")
-                            .font(.system(size: 13))
-                            .foregroundStyle(Color.secondary)
-                        Spacer()
-                    }
-                } else {
-                    ScrollView {
-                        VStack(spacing: 12) {
-                            ForEach(bleManager.discoveredDevices) { device in
-                                discoveredDeviceRow(device)
-                            }
-                        }
-                        .padding(.horizontal, 20)
-                        .padding(.bottom, 20)
-                    }
-                }
             }
         }
         .onAppear {
@@ -138,6 +171,232 @@ struct DeviceScanView: View {
             Text(bindingErrorMessage ?? "请稍后重试。")
         }
     }
+
+    @ViewBuilder
+    private var formalBindingSection: some View {
+        VStack(alignment: .leading, spacing: 10) {
+            Text("正式绑定设备(仅显示 YLF20)")
+                .font(.system(size: 13, weight: .semibold))
+                .foregroundStyle(Color.primary)
+
+            if bleManager.discoveredDevices.isEmpty {
+                Text(
+                    bleManager.observedPeripheralCount > 0
+                        ? "暂未发现名称以 YLF20 开头的设备。"
+                        : "正在等待 YLF20 设备广播…"
+                )
+                .font(.system(size: 11))
+                .foregroundStyle(Color.secondary)
+                .frame(maxWidth: .infinity, minHeight: 54, alignment: .center)
+            } else {
+                ScrollView {
+                    LazyVStack(spacing: 8) {
+                        ForEach(bleManager.discoveredDevices) { device in
+                            discoveredDeviceRow(device)
+                        }
+                    }
+                }
+                .frame(maxHeight: 150)
+            }
+        }
+        .padding(14)
+        .background(Color.cardBackground.opacity(0.4))
+        .businessBorder(cornerRadius: 10)
+        .padding(.horizontal, 20)
+    }
+
+    private func communicationDebugPanel(deviceID: String) -> some View {
+        let logs = bleManager.communicationLogs(for: deviceID)
+
+        return VStack(alignment: .leading, spacing: 10) {
+            HStack {
+                Text("绑定与协议通信调试")
+                    .font(.system(size: 13, weight: .semibold))
+                    .foregroundStyle(Color.primary)
+
+                Spacer()
+
+                Button(copiedCompatibilityLog ? "已复制" : "复制兼容日志") {
+                    copyCompatibilityLogs(deviceID: deviceID)
+                }
+                .font(.system(size: 11, weight: .medium))
+                .disabled(compatibilityLogs(deviceID: deviceID).isEmpty)
+
+                Button("重新测试") {
+                    bleManager.runProtocolDiagnostics(deviceID: deviceID)
+                }
+                .font(.system(size: 11, weight: .medium))
+                .disabled(bindingDeviceID != nil)
+            }
+
+            ScrollViewReader { proxy in
+                ScrollView {
+                    LazyVStack(alignment: .leading, spacing: 5) {
+                        ForEach(logs) { entry in
+                            HStack(alignment: .top, spacing: 7) {
+                                Text(entry.timestamp.formatted(date: .omitted, time: .standard))
+                                    .foregroundStyle(Color.secondary)
+
+                                Text(entry.kind)
+                                    .foregroundStyle(logColor(entry.kind))
+                                    .frame(width: 38, alignment: .leading)
+
+                                Text(entry.message)
+                                    .foregroundStyle(Color.primary)
+                                    .textSelection(.enabled)
+                            }
+                            .font(.system(size: 9, design: .monospaced))
+                            .id(entry.id)
+                        }
+                    }
+                }
+                .onChange(of: logs.count) {
+                    if let last = logs.last {
+                        withAnimation {
+                            proxy.scrollTo(last.id, anchor: .bottom)
+                        }
+                    }
+                }
+            }
+            .frame(height: 300)
+        }
+        .padding(14)
+        .background(Color.cardBackground.opacity(0.4))
+        .businessBorder(cornerRadius: 10)
+        .padding(.horizontal, 20)
+    }
+
+    private func compatibilityLogs(deviceID: String) -> [BLECommunicationLogEntry] {
+        let logs = bleManager.communicationLogs(for: deviceID)
+        guard let startIndex = logs.lastIndex(where: {
+            $0.message.contains("开始只读兼容探测")
+        }) else {
+            return []
+        }
+        return Array(logs[startIndex...])
+    }
+
+    private func copyCompatibilityLogs(deviceID: String) {
+        let logs = compatibilityLogs(deviceID: deviceID)
+        guard !logs.isEmpty else { return }
+
+        let formatter = DateFormatter()
+        formatter.locale = Locale(identifier: "en_US_POSIX")
+        formatter.dateFormat = "HH:mm:ss.SSS"
+
+        let lines = logs.map {
+            "\(formatter.string(from: $0.timestamp)) [\($0.kind)] \($0.message)"
+        }
+        let header = [
+            "YLF20 BLE 兼容探测日志",
+            "设备标识:\(deviceID)",
+            "导出时间:\(Date().formatted(date: .numeric, time: .standard))",
+            String(repeating: "-", count: 36)
+        ]
+        UIPasteboard.general.string = (header + lines).joined(separator: "\n")
+        copiedCompatibilityLog = true
+        DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
+            copiedCompatibilityLog = false
+        }
+    }
+
+    private func logColor(_ kind: String) -> Color {
+        switch kind {
+        case "OK": return .green
+        case "ERROR": return .red
+        case "TX": return .blue
+        case "RX": return .orange
+        default: return .secondary
+        }
+    }
+
+    private var scanStatusText: String {
+        if bleManager.isScanning {
+            return "正在扫描附近的蓝牙录音设备..."
+        }
+        switch bleManager.state {
+        case .unknown, .resetting:
+            return "正在初始化系统蓝牙..."
+        case .poweredOn:
+            return "扫描已停止"
+        case .poweredOff:
+            return "系统蓝牙未开启"
+        case .unauthorized:
+            return "没有蓝牙访问权限"
+        case .unsupported:
+            return "当前设备不支持 BLE 扫描"
+        @unknown default:
+            return "蓝牙状态不可用"
+        }
+    }
+
+    private func observedAdvertisementRow(_ advertisement: ObservedBLEAdvertisement) -> some View {
+        VStack(alignment: .leading, spacing: 3) {
+            HStack(spacing: 8) {
+                Text(advertisement.name)
+                    .font(.system(size: 12, weight: .medium))
+                    .foregroundStyle(Color.primary)
+                    .lineLimit(1)
+
+                if advertisement.matchesSpark {
+                    Text("匹配 Spark")
+                        .font(.system(size: 9, weight: .semibold))
+                        .foregroundStyle(Color.green)
+                        .padding(.horizontal, 6)
+                        .padding(.vertical, 2)
+                        .background(Color.green.opacity(0.12))
+                        .clipShape(Capsule())
+                }
+
+                if !advertisement.isPresent {
+                    debugBadge("刚消失", color: .red)
+                } else if advertisement.reappearanceCount > 0 {
+                    debugBadge("重新出现 ×\(advertisement.reappearanceCount)", color: .orange)
+                } else if Date().timeIntervalSince(advertisement.firstSeenAt) < 8 {
+                    debugBadge("新出现", color: .blue)
+                }
+
+                if advertisement.isStrongSignal {
+                    debugBadge("强信号", color: .green)
+                }
+
+                Spacer()
+
+                Text("\(advertisement.rssi) dBm")
+                    .font(.system(size: 10, design: .monospaced))
+                    .foregroundStyle(Color.secondary)
+            }
+
+            Text("设备:\(advertisement.id) · \(advertisement.lastSeenAt.formatted(date: .omitted, time: .standard))")
+                .font(.system(size: 9, design: .monospaced))
+                .foregroundStyle(Color.secondary.opacity(0.8))
+                .textSelection(.enabled)
+
+            Text(
+                advertisement.serviceUUIDs.isEmpty
+                    ? "Service UUID:广播中未提供"
+                    : "Service UUID:\(advertisement.serviceUUIDs.joined(separator: ", "))"
+            )
+            .font(.system(size: 9, design: .monospaced))
+            .foregroundStyle(Color.secondary.opacity(0.8))
+            .textSelection(.enabled)
+
+        }
+        .padding(7)
+        .background(Color.spaceBlack.opacity(0.35))
+        .clipShape(RoundedRectangle(cornerRadius: 7))
+        .opacity(advertisement.isPresent ? 1 : 0.65)
+    }
+
+    private func debugBadge(_ text: String, color: Color) -> some View {
+        Text(text)
+            .font(.system(size: 9, weight: .semibold))
+            .foregroundStyle(color)
+            .padding(.horizontal, 6)
+            .padding(.vertical, 2)
+            .background(color.opacity(0.12))
+            .clipShape(Capsule())
+    }
     
     private func discoveredDeviceRow(_ device: DiscoveredBLEDevice) -> some View {
         let isAlreadyBound = bleManager.boundDevices.contains(where: { $0.id == device.id })
@@ -180,14 +439,18 @@ struct DeviceScanView: View {
             Spacer()
             
             if isAlreadyBound {
-                Text("已绑定")
-                    .font(.system(size: 12, weight: .regular))
-                    .foregroundStyle(Color.secondary)
-                    .padding(.horizontal, 12)
-                    .padding(.vertical, 6)
+                Button("测试通信") {
+                    debugDeviceID = device.id
+                    bleManager.runProtocolDiagnostics(deviceID: device.id)
+                }
+                .font(.system(size: 12, weight: .medium))
+                .disabled(bindingDeviceID != nil)
             } else {
                 Button {
                     guard let userId = authManager.currentUser?.id else { return }
+                    debugDeviceID = device.id
+                    boundSuccessMessage = nil
+                    bindingErrorMessage = nil
                     bindingDeviceID = device.id
                     bleManager.connectAndBind(device, userId: userId) { result in
                         bindingDeviceID = nil
@@ -202,9 +465,6 @@ struct DeviceScanView: View {
                                     bleManager.updateCloudRegistration(deviceID: bound.id, cloudID: nil, error: error.localizedDescription)
                                     boundSuccessMessage = "设备已在本机绑定,云端登记将在稍后重试"
                                 }
-                                DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
-                                    dismiss()
-                                }
                             }
                         case .failure(let error):
                             bindingErrorMessage = error.localizedDescription

+ 2 - 0
Tests/CelestiaTraceTests/BLEFilteringTests.swift

@@ -12,6 +12,8 @@ final class BLEFilteringTests: XCTestCase {
     }
 
     func testKnownProductNamesAreAccepted() {
+        XCTAssertTrue(BLEManager.isSparkAdvertisement(name: "YLF20_12345", serviceUUIDs: []))
+        XCTAssertTrue(BLEManager.isSparkAdvertisement(name: "ylf20_abcde", serviceUUIDs: []))
         XCTAssertTrue(BLEManager.isSparkAdvertisement(name: "MR20", serviceUUIDs: []))
         XCTAssertTrue(BLEManager.isSparkAdvertisement(name: "Celestia Spark", serviceUUIDs: []))
         XCTAssertTrue(BLEManager.isSparkAdvertisement(name: "微光录音设备", serviceUUIDs: []))