diff --git a/PayfritBeacon/BeaconProvisioner.swift b/PayfritBeacon/BeaconProvisioner.swift new file mode 100644 index 0000000..7215149 --- /dev/null +++ b/PayfritBeacon/BeaconProvisioner.swift @@ -0,0 +1,1441 @@ +import Foundation +import CoreBluetooth + +/// Structured provisioning error codes (matches Android's BeaconConfig error codes) +enum ProvisioningError: String, LocalizedError { + case bluetoothUnavailable = "BLUETOOTH_UNAVAILABLE" + case connectionFailed = "CONNECTION_FAILED" + case connectionTimeout = "CONNECTION_TIMEOUT" + case serviceNotFound = "SERVICE_NOT_FOUND" + case authFailed = "AUTH_FAILED" + case writeFailed = "WRITE_FAILED" + case verificationFailed = "VERIFICATION_FAILED" + case disconnected = "DISCONNECTED" + case noConfig = "NO_CONFIG" + case timeout = "TIMEOUT" + case unknown = "UNKNOWN" + + var errorDescription: String? { + switch self { + case .bluetoothUnavailable: return "Bluetooth not available" + case .connectionFailed: return "Failed to connect to beacon" + case .connectionTimeout: return "Connection timed out" + case .serviceNotFound: return "Config service not found on device" + case .authFailed: return "Authentication failed - all passwords rejected" + case .writeFailed: return "Failed to write configuration" + case .verificationFailed: return "Beacon not broadcasting expected values" + case .disconnected: return "Unexpected disconnect" + case .noConfig: return "No configuration provided" + case .timeout: return "Operation timed out" + case .unknown: return "Unknown error" + } + } +} + +/// Result of a provisioning operation +enum ProvisioningResult { + case success(macAddress: String?) + case failure(String) + case failureWithCode(ProvisioningError, detail: String? = nil) +} + +/// Configuration to write to a beacon +struct BeaconConfig { + let uuid: String // 32-char hex, no dashes + let major: UInt16 + let minor: UInt16 + let measuredPower: Int8 // RSSI@1m (e.g., -59) - from server, NOT hardcoded + let advInterval: UInt8 // Advertising interval raw value (e.g., 2 = 200ms) - from server + let txPower: UInt8 // TX power level - from server + let deviceName: String? // Service point name (max 20 ASCII chars for DX-Smart) + + init(uuid: String, major: UInt16, minor: UInt16, measuredPower: Int8, advInterval: UInt8, txPower: UInt8, deviceName: String? = nil) { + self.uuid = uuid + self.major = major + self.minor = minor + self.measuredPower = measuredPower + self.advInterval = advInterval + self.txPower = txPower + self.deviceName = deviceName + } +} + +/// Result of reading a beacon's current configuration +struct BeaconCheckResult { + // Parsed DX-Smart iBeacon config + var uuid: String? // iBeacon UUID (formatted with dashes) + var major: UInt16? + var minor: UInt16? + var rssiAt1m: Int8? + var advInterval: UInt16? // Raw value (multiply by 100 for ms) + var txPower: UInt8? + var deviceName: String? + var battery: UInt8? + var macAddress: String? + var frameSlots: [UInt8]? + + // Discovery info + var servicesFound: [String] = [] + var characteristicsFound: [String] = [] + var rawResponses: [String] = [] // Raw response hex for debugging + + var hasConfig: Bool { + uuid != nil || major != nil || minor != nil || deviceName != nil + } +} + +// MARK: - BeaconProvisioner + +/// Handles GATT connection and provisioning of DX-Smart CP28 beacons. +/// +/// v3: Clean refactor based on working pre-refactor code + Android reference. +/// +/// Architecture: +/// - Callback-driven state machine (CoreBluetooth delegates) +/// - Response gating: wait for FFE1 notification after each FFE2 write (matches Android) +/// - Disconnect recovery: reconnect + re-auth + resume from saved write index +/// - 16-step write sequence: DeviceName, Frame1 (device info), Frame2 (iBeacon), Save +/// +/// Key learnings preserved from previous iterations: +/// 1. Skip device info read (0x30) — causes disconnects, MAC is optional +/// 2. Skip extra frame disables (3-6) — fewer writes = fewer disconnects +/// 3. Full reconnect on FFE2 miss (CoreBluetooth caches stale GATT) +/// 4. SaveConfig write-error = success (beacon reboots immediately) +/// 5. Response gating between writes prevents MCU overload +/// 6. Adaptive delays: heavy commands (frame select/type) need 1s, others 0.5s +class BeaconProvisioner: NSObject, ObservableObject { + + // MARK: - Constants + + private static let DXSMART_SERVICE = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB") + private static let DXSMART_NOTIFY_CHAR = CBUUID(string: "0000FFE1-0000-1000-8000-00805F9B34FB") // Notifications (RX) + private static let DXSMART_COMMAND_CHAR = CBUUID(string: "0000FFE2-0000-1000-8000-00805F9B34FB") // Commands (TX) + private static let DXSMART_PASSWORD_CHAR = CBUUID(string: "0000FFE3-0000-1000-8000-00805F9B34FB") // Password auth + + private static let DXSMART_HEADER: [UInt8] = [0x4E, 0x4F] + private static let DXSMART_PASSWORDS = ["555555", "dx1234", "000000"] + + private enum DXCmd: UInt8 { + case frameTable = 0x10 + case frameSelectSlot0 = 0x11 // Frame 1 (device info) + case frameSelectSlot1 = 0x12 // Frame 2 (iBeacon) + case frameSelectSlot2 = 0x13 // Frame 3 + case frameSelectSlot3 = 0x14 // Frame 4 + case frameSelectSlot4 = 0x15 // Frame 5 + case frameSelectSlot5 = 0x16 // Frame 6 + case authCheck = 0x25 + case deviceInfo = 0x30 + case deviceName = 0x43 // Read device name + case saveConfig = 0x60 + case deviceInfoType = 0x61 // Set frame as device info (broadcasts name) + case iBeaconType = 0x62 // Set frame as iBeacon + case deviceNameWrite = 0x71 // Write device name (max 20 ASCII chars) + case uuid = 0x74 + case major = 0x75 + case minor = 0x76 + case rssiAt1m = 0x77 + case advInterval = 0x78 + case txPower = 0x79 + case triggerOff = 0xA0 + case frameDisable = 0xFF + } + + // Timing constants (tuned from extensive testing) + private static let BASE_WRITE_DELAY: Double = 0.5 // Default delay between commands + private static let HEAVY_WRITE_DELAY: Double = 1.0 // After frame select/type commands (MCU state change) + private static let LARGE_PAYLOAD_DELAY: Double = 0.8 // After UUID/large payload writes + private static let RESPONSE_GATE_TIMEOUT: Double = 1.0 // 1s matches Android's withTimeoutOrNull(1000L) + private static let WRITE_TIMEOUT_SECONDS: Double = 5.0 // Per-write timeout (matches Android) + private static let GLOBAL_TIMEOUT: Double = 90.0 // Overall provisioning timeout + + // Retry limits + private static let MAX_CONNECTION_RETRIES = 3 + private static let MAX_DISCONNECT_RETRIES = 5 + private static let MAX_FFE2_RECONNECTS = 2 + private static let MAX_WRITE_RETRIES = 1 + + // MARK: - Published State + + @Published var state: ProvisioningState = .idle + @Published var progress: String = "" + + enum ProvisioningState: Equatable { + case idle + case connecting + case discoveringServices + case authenticating + case writing + case verifying + case success + case failed(String) + } + + // MARK: - Core State + + private var centralManager: CBCentralManager! + private var peripheral: CBPeripheral? + private var config: BeaconConfig? + private var completion: ((ProvisioningResult) -> Void)? + private var currentBeacon: DiscoveredBeacon? + + // GATT state + private var configService: CBService? + private var characteristics: [CBUUID: CBCharacteristic] = [:] + + // Auth state + private var passwordIndex = 0 + private var authenticated = false + + // Write queue state + private var commandQueue: [Data] = [] + private var writeIndex = 0 + private var resumeAfterReconnect = false // When true, skip queue rebuild on reconnect + + // Retry counters + private var connectionRetryCount = 0 + private var disconnectRetryCount = 0 + private var ffe2ReconnectCount = 0 + private var writeRetryCount = 0 + + // Timers + private var writeTimeoutTimer: DispatchWorkItem? + private var responseGateTimer: DispatchWorkItem? + private var globalTimeoutTimer: DispatchWorkItem? + + // Response gating — wait for FFE1 notification after each FFE2 write + private var awaitingResponse = false + + // Guard against re-entrant disconnect/success/fail + private var isTerminating = false + + // Read config state + private enum OperationMode { case provisioning, readingConfig } + private var operationMode: OperationMode = .provisioning + private var readCompletion: ((BeaconCheckResult?, String?) -> Void)? + private var readResult = BeaconCheckResult() + private var readTimeout: DispatchWorkItem? + private var allDiscoveredServices: [CBService] = [] + private var servicesToExplore: [CBService] = [] + private var dxReadQueries: [Data] = [] + private var dxReadQueryIndex = 0 + private var responseBuffer: [UInt8] = [] + + // MARK: - Init + + override init() { + super.init() + centralManager = CBCentralManager(delegate: self, queue: .main) + } + + // MARK: - Public: Provision + + /// Provision a beacon with the given configuration + func provision(beacon: DiscoveredBeacon, config: BeaconConfig, completion: @escaping (ProvisioningResult) -> Void) { + guard centralManager.state == .poweredOn else { + completion(.failureWithCode(.bluetoothUnavailable)) + return + } + + resetAllState() + + let resolvedPeripheral = resolvePeripheral(beacon) + self.peripheral = resolvedPeripheral + self.config = config + self.completion = completion + self.operationMode = .provisioning + self.currentBeacon = beacon + + state = .connecting + progress = "Connecting to \(beacon.displayName)..." + DebugLog.shared.log("BLE: Starting provision for \(beacon.displayName)") + + centralManager.connect(resolvedPeripheral, options: nil) + scheduleGlobalTimeout() + } + + /// Cancel current provisioning + func cancel() { + if let peripheral = peripheral { + centralManager.cancelPeripheralConnection(peripheral) + } + cleanup() + } + + // MARK: - Public: Read Config + + /// Read the current configuration from a beacon + func readConfig(beacon: DiscoveredBeacon, completion: @escaping (BeaconCheckResult?, String?) -> Void) { + guard centralManager.state == .poweredOn else { + completion(nil, "Bluetooth not available") + return + } + + resetAllState() + + let resolvedPeripheral = resolvePeripheral(beacon) + self.peripheral = resolvedPeripheral + self.operationMode = .readingConfig + self.readCompletion = completion + self.readResult = BeaconCheckResult() + self.currentBeacon = beacon + + state = .connecting + progress = "Connecting to \(beacon.displayName)..." + + centralManager.connect(resolvedPeripheral, options: nil) + + // 15-second timeout for read operations + let timeout = DispatchWorkItem { [weak self] in + guard let self = self, self.operationMode == .readingConfig else { return } + DebugLog.shared.log("BLE: Read timeout reached") + self.finishRead() + } + readTimeout = timeout + DispatchQueue.main.asyncAfter(deadline: .now() + 15, execute: timeout) + } + + // MARK: - State Reset + + private func resetAllState() { + cancelAllTimers() + characteristics.removeAll() + commandQueue.removeAll() + writeIndex = 0 + passwordIndex = 0 + authenticated = false + resumeAfterReconnect = false + awaitingResponse = false + isTerminating = false + connectionRetryCount = 0 + disconnectRetryCount = 0 + ffe2ReconnectCount = 0 + writeRetryCount = 0 + responseBuffer.removeAll() + configService = nil + } + + private func cleanup() { + cancelAllTimers() + peripheral = nil + config = nil + completion = nil + currentBeacon = nil + configService = nil + characteristics.removeAll() + commandQueue.removeAll() + writeIndex = 0 + passwordIndex = 0 + authenticated = false + resumeAfterReconnect = false + awaitingResponse = false + isTerminating = false + connectionRetryCount = 0 + disconnectRetryCount = 0 + ffe2ReconnectCount = 0 + writeRetryCount = 0 + responseBuffer.removeAll() + state = .idle + progress = "" + } + + // MARK: - Terminal States + + private func fail(_ message: String, code: ProvisioningError? = nil) { + guard !isTerminating else { + DebugLog.shared.log("BLE: fail() called but already terminating, ignoring") + return + } + isTerminating = true + DebugLog.shared.log("BLE: Failed [\(code?.rawValue ?? "UNTYPED")] - \(message)") + state = .failed(message) + disconnectPeripheral() + if let code = code { + completion?(.failureWithCode(code, detail: message)) + } else { + completion?(.failure(message)) + } + cleanup() + } + + private func succeed() { + guard !isTerminating else { + DebugLog.shared.log("BLE: succeed() called but already terminating, ignoring") + return + } + isTerminating = true + DebugLog.shared.log("BLE: Provisioning success!") + state = .success + disconnectPeripheral() + completion?(.success(macAddress: nil)) + cleanup() + } + + private func disconnectPeripheral() { + if let peripheral = peripheral, peripheral.state == .connected { + centralManager.cancelPeripheralConnection(peripheral) + } + } + + // MARK: - Peripheral Resolution + + /// Re-retrieve peripheral from our own CBCentralManager (the one from scanner may not work) + private func resolvePeripheral(_ beacon: DiscoveredBeacon) -> CBPeripheral { + let retrieved = centralManager.retrievePeripherals(withIdentifiers: [beacon.peripheral.identifier]) + return retrieved.first ?? beacon.peripheral + } + + // MARK: - DX-Smart: Authentication + + /// Start auth flow: subscribe to FFE1 notifications, then write password to FFE3 + private func startAuth() { + if let notifyChar = characteristics[BeaconProvisioner.DXSMART_NOTIFY_CHAR] { + DebugLog.shared.log("BLE: Subscribing to FFE1 notifications") + peripheral?.setNotifyValue(true, for: notifyChar) + } else { + DebugLog.shared.log("BLE: FFE1 not found, proceeding to auth directly") + authenticate() + } + } + + /// Write password to FFE3 (tries multiple passwords in sequence) + private func authenticate() { + guard let passwordChar = characteristics[BeaconProvisioner.DXSMART_PASSWORD_CHAR] else { + fail("FFE3 password characteristic not found", code: .serviceNotFound) + return + } + + guard passwordIndex < BeaconProvisioner.DXSMART_PASSWORDS.count else { + if operationMode == .readingConfig { + DebugLog.shared.log("BLE: All passwords exhausted in read mode") + finishRead() + } else { + fail("Authentication failed - all passwords rejected", code: .authFailed) + } + return + } + + state = .authenticating + let currentPassword = BeaconProvisioner.DXSMART_PASSWORDS[passwordIndex] + progress = "Authenticating (attempt \(passwordIndex + 1)/\(BeaconProvisioner.DXSMART_PASSWORDS.count))..." + + let passwordData = Data(currentPassword.utf8) + DebugLog.shared.log("BLE: Writing password \(passwordIndex + 1)/\(BeaconProvisioner.DXSMART_PASSWORDS.count) to FFE3 (\(passwordData.count) bytes)") + peripheral?.writeValue(passwordData, for: passwordChar, type: .withResponse) + } + + /// Try next password after rejection + private func retryNextPassword() { + passwordIndex += 1 + if passwordIndex < BeaconProvisioner.DXSMART_PASSWORDS.count { + DebugLog.shared.log("BLE: Password rejected, trying next (\(passwordIndex + 1)/\(BeaconProvisioner.DXSMART_PASSWORDS.count))") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.authenticate() + } + } else if operationMode == .readingConfig { + finishRead() + } else { + fail("Authentication failed - all \(BeaconProvisioner.DXSMART_PASSWORDS.count) passwords rejected", code: .authFailed) + } + } + + // MARK: - DX-Smart: Write Config + + /// Build the 16-step command sequence and start writing. + /// + /// Write sequence for DX-Smart CP28: + /// 1. DeviceName 0x71 [name bytes] — service point name (max 20 ASCII chars) + /// 2. Frame1_Select 0x11 — select frame 1 + /// 3. Frame1_Type 0x61 — enable as device info (broadcasts name) + /// 4. Frame1_RSSI 0x77 [measuredPower] — RSSI@1m for frame 1 + /// 5. Frame1_AdvInt 0x78 [advInterval] — adv interval for frame 1 + /// 6. Frame1_TxPow 0x79 [txPower] — tx power for frame 1 + /// 7. Frame2_Select 0x12 — select frame 2 + /// 8. Frame2_Type 0x62 — set as iBeacon + /// 9. UUID 0x74 [16 bytes] + /// 10. Major 0x75 [2 bytes BE] + /// 11. Minor 0x76 [2 bytes BE] + /// 12. RSSI@1m 0x77 [measuredPower] + /// 13. AdvInterval 0x78 [advInterval] + /// 14. TxPower 0x79 [txPower] + /// 15. TriggerOff 0xA0 + /// 16. SaveConfig 0x60 — persist to flash + /// + /// Frames 3-6 intentionally left untouched — fewer writes = fewer disconnects. + private func buildAndStartWriting() { + guard let config = config else { + fail("No config provided", code: .noConfig) + return + } + + state = .writing + progress = "Writing DX-Smart configuration..." + + commandQueue.removeAll() + writeIndex = 0 + + let measuredPowerByte = UInt8(bitPattern: config.measuredPower) + + // 1. DeviceName (0x71) + if let name = config.deviceName, !name.isEmpty { + let truncatedName = String(name.prefix(20)) + let nameBytes = Array(truncatedName.utf8) + commandQueue.append(buildDXPacket(cmd: .deviceNameWrite, data: nameBytes)) + } + + // --- Frame 1: Device Info --- + commandQueue.append(buildDXPacket(cmd: .frameSelectSlot0, data: [])) // 2. Select + commandQueue.append(buildDXPacket(cmd: .deviceInfoType, data: [])) // 3. Type + commandQueue.append(buildDXPacket(cmd: .rssiAt1m, data: [measuredPowerByte])) // 4. RSSI + commandQueue.append(buildDXPacket(cmd: .advInterval, data: [config.advInterval])) // 5. AdvInt + commandQueue.append(buildDXPacket(cmd: .txPower, data: [config.txPower])) // 6. TxPower + + // --- Frame 2: iBeacon --- + commandQueue.append(buildDXPacket(cmd: .frameSelectSlot1, data: [])) // 7. Select + commandQueue.append(buildDXPacket(cmd: .iBeaconType, data: [])) // 8. Type + + if let uuidData = hexStringToData(config.uuid) { // 9. UUID + commandQueue.append(buildDXPacket(cmd: .uuid, data: Array(uuidData))) + } + + let majorHi = UInt8((config.major >> 8) & 0xFF) + let majorLo = UInt8(config.major & 0xFF) + commandQueue.append(buildDXPacket(cmd: .major, data: [majorHi, majorLo])) // 10. Major + + let minorHi = UInt8((config.minor >> 8) & 0xFF) + let minorLo = UInt8(config.minor & 0xFF) + commandQueue.append(buildDXPacket(cmd: .minor, data: [minorHi, minorLo])) // 11. Minor + + commandQueue.append(buildDXPacket(cmd: .rssiAt1m, data: [measuredPowerByte])) // 12. RSSI + commandQueue.append(buildDXPacket(cmd: .advInterval, data: [config.advInterval])) // 13. AdvInt + commandQueue.append(buildDXPacket(cmd: .txPower, data: [config.txPower])) // 14. TxPower + commandQueue.append(buildDXPacket(cmd: .triggerOff, data: [])) // 15. TriggerOff + commandQueue.append(buildDXPacket(cmd: .saveConfig, data: [])) // 16. Save + + DebugLog.shared.log("BLE: Command queue built with \(commandQueue.count) commands") + sendNextCommand() + } + + /// Send the next command in the queue + private func sendNextCommand() { + guard writeIndex < commandQueue.count else { + cancelWriteTimeout() + DebugLog.shared.log("BLE: All commands written!") + progress = "Configuration saved!" + succeed() + return + } + + let packet = commandQueue[writeIndex] + let current = writeIndex + 1 + let total = commandQueue.count + progress = "Writing config (\(current)/\(total))..." + + guard let commandChar = characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] else { + // FFE2 missing — CoreBluetooth returns cached stale GATT after disconnect. + // Need full disconnect → reconnect → re-discover → re-auth → resume. + handleFFE2Missing() + return + } + + DebugLog.shared.log("BLE: Writing command \(current)/\(total): \(packet.map { String(format: "%02X", $0) }.joined(separator: " "))") + writeRetryCount = 0 + scheduleWriteTimeout() + peripheral?.writeValue(packet, for: commandChar, type: .withResponse) + } + + /// Handle FFE2 characteristic missing (stale GATT cache) + private func handleFFE2Missing() { + guard ffe2ReconnectCount < BeaconProvisioner.MAX_FFE2_RECONNECTS else { + fail("FFE2 not found after \(ffe2ReconnectCount) reconnect attempts", code: .serviceNotFound) + return + } + ffe2ReconnectCount += 1 + DebugLog.shared.log("BLE: FFE2 missing — full reconnect (attempt \(ffe2ReconnectCount)/\(BeaconProvisioner.MAX_FFE2_RECONNECTS))") + progress = "FFE2 missing, reconnecting..." + + // Preserve write position, clear connection state + resumeAfterReconnect = true + authenticated = false + passwordIndex = 0 + characteristics.removeAll() + responseBuffer.removeAll() + awaitingResponse = false + cancelResponseGateTimeout() + state = .connecting + + disconnectPeripheral() + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + guard let self = self, let beacon = self.currentBeacon else { return } + guard self.state == .connecting else { return } + let resolvedPeripheral = self.resolvePeripheral(beacon) + self.peripheral = resolvedPeripheral + resolvedPeripheral.delegate = self + DebugLog.shared.log("BLE: Reconnecting after FFE2 miss...") + self.centralManager.connect(resolvedPeripheral, options: nil) + } + } + + // MARK: - Response Gating + + /// After a successful write, advance with the appropriate delay. + /// Called when FFE1 response arrives or when 1s gate timeout fires. + private func advanceToNextCommand() { + let justWritten = writeIndex + writeIndex += 1 + let delay = delayForCommand(at: justWritten) + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.sendNextCommand() + } + } + + /// Schedule 1s response gate timeout (matches Android's withTimeoutOrNull(1000L)) + private func scheduleResponseGateTimeout() { + cancelResponseGateTimeout() + let timer = DispatchWorkItem { [weak self] in + guard let self = self else { return } + guard self.awaitingResponse else { return } + self.awaitingResponse = false + DebugLog.shared.log("BLE: No FFE1 response within 1s for command \(self.writeIndex + 1) — advancing (OK)") + self.advanceToNextCommand() + } + responseGateTimer = timer + DispatchQueue.main.asyncAfter(deadline: .now() + BeaconProvisioner.RESPONSE_GATE_TIMEOUT, execute: timer) + } + + private func cancelResponseGateTimeout() { + responseGateTimer?.cancel() + responseGateTimer = nil + } + + // MARK: - Write Timeout + + /// Per-write timeout — if callback doesn't arrive, retry once or handle gracefully + private func scheduleWriteTimeout() { + cancelWriteTimeout() + let timer = DispatchWorkItem { [weak self] in + guard let self = self else { return } + guard self.state == .writing else { return } + + let current = self.writeIndex + 1 + let total = self.commandQueue.count + let isNonFatal = self.writeIndex < 6 // First 6 commands are optional + let isSaveConfig = self.writeIndex >= self.commandQueue.count - 1 + + if isSaveConfig { + // SaveConfig: beacon reboots, no callback expected + DebugLog.shared.log("BLE: SaveConfig write timeout (beacon rebooted) — treating as success") + self.succeed() + } else if self.writeRetryCount < BeaconProvisioner.MAX_WRITE_RETRIES { + // Retry once + self.writeRetryCount += 1 + DebugLog.shared.log("BLE: Write timeout for command \(current)/\(total) — retrying (\(self.writeRetryCount))") + if let commandChar = self.characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] { + let packet = self.commandQueue[self.writeIndex] + self.scheduleWriteTimeout() + self.peripheral?.writeValue(packet, for: commandChar, type: .withResponse) + } + } else if isNonFatal { + // Non-fatal: skip + DebugLog.shared.log("BLE: Write timeout for non-fatal command \(current)/\(total) — skipping") + self.writeIndex += 1 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.sendNextCommand() + } + } else { + // Fatal timeout + DebugLog.shared.log("BLE: Write timeout for critical command \(current)/\(total) — failing") + self.fail("Write timeout at step \(current)/\(total)", code: .writeFailed) + } + } + writeTimeoutTimer = timer + DispatchQueue.main.asyncAfter(deadline: .now() + BeaconProvisioner.WRITE_TIMEOUT_SECONDS, execute: timer) + } + + private func cancelWriteTimeout() { + writeTimeoutTimer?.cancel() + writeTimeoutTimer = nil + } + + // MARK: - Global Timeout + + private func scheduleGlobalTimeout() { + cancelGlobalTimeout() + let timer = DispatchWorkItem { [weak self] in + guard let self = self else { return } + if self.state != .success && self.state != .idle { + self.fail("Provisioning timeout after \(Int(BeaconProvisioner.GLOBAL_TIMEOUT))s", code: .connectionTimeout) + } + } + globalTimeoutTimer = timer + DispatchQueue.main.asyncAfter(deadline: .now() + BeaconProvisioner.GLOBAL_TIMEOUT, execute: timer) + } + + private func cancelGlobalTimeout() { + globalTimeoutTimer?.cancel() + globalTimeoutTimer = nil + } + + private func cancelAllTimers() { + cancelWriteTimeout() + cancelResponseGateTimeout() + cancelGlobalTimeout() + readTimeout?.cancel() + readTimeout = nil + } + + // MARK: - Adaptive Delays + + /// Calculate delay after writing a command. Frame selection (0x11-0x16) and type + /// commands (0x61, 0x62) trigger MCU state changes that need extra processing time. + private func delayForCommand(at index: Int) -> Double { + guard index < commandQueue.count else { return BeaconProvisioner.BASE_WRITE_DELAY } + + let packet = commandQueue[index] + guard packet.count >= 3 else { return BeaconProvisioner.BASE_WRITE_DELAY } + + let cmd = packet[2] // Command byte at offset 2 (after 4E 4F header) + + switch DXCmd(rawValue: cmd) { + case .frameSelectSlot0, .frameSelectSlot1, .frameSelectSlot2, + .frameSelectSlot3, .frameSelectSlot4, .frameSelectSlot5: + return BeaconProvisioner.HEAVY_WRITE_DELAY + case .deviceInfoType, .iBeaconType: + return BeaconProvisioner.HEAVY_WRITE_DELAY + case .uuid: + return BeaconProvisioner.LARGE_PAYLOAD_DELAY + default: + return BeaconProvisioner.BASE_WRITE_DELAY + } + } + + // MARK: - DX-Smart Packet Builder + + /// Build a DX-Smart CP28 packet: [4E][4F][CMD][LEN][DATA...][XOR_CHECKSUM] + private func buildDXPacket(cmd: DXCmd, data: [UInt8]) -> Data { + var packet: [UInt8] = [] + packet.append(contentsOf: BeaconProvisioner.DXSMART_HEADER) + packet.append(cmd.rawValue) + packet.append(UInt8(data.count)) + packet.append(contentsOf: data) + + var checksum: UInt8 = cmd.rawValue ^ UInt8(data.count) + for byte in data { + checksum ^= byte + } + packet.append(checksum) + + return Data(packet) + } + + // MARK: - Read Config: Service Exploration + + private func startReadExplore() { + guard let services = peripheral?.services, !services.isEmpty else { + readFail("No services found on device") + return + } + + allDiscoveredServices = services + servicesToExplore = services + state = .discoveringServices + progress = "Exploring \(services.count) services..." + + DebugLog.shared.log("BLE: Read mode — found \(services.count) services") + for s in services { + readResult.servicesFound.append(s.uuid.uuidString) + } + + exploreNextService() + } + + private func exploreNextService() { + guard !servicesToExplore.isEmpty else { + DebugLog.shared.log("BLE: All services explored, starting DX-Smart read") + startDXSmartRead() + return + } + + let service = servicesToExplore.removeFirst() + DebugLog.shared.log("BLE: Discovering chars for service \(service.uuid)") + progress = "Exploring \(service.uuid.uuidString.prefix(8))..." + peripheral?.discoverCharacteristics(nil, for: service) + } + + // MARK: - Read Config: DX-Smart Protocol + + private func startDXSmartRead() { + guard characteristics[BeaconProvisioner.DXSMART_PASSWORD_CHAR] != nil, + characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] != nil else { + DebugLog.shared.log("BLE: No FFE0 service — not a DX-Smart beacon") + progress = "No DX-Smart service found" + finishRead() + return + } + + // Subscribe to FFE1 for responses + if let notifyChar = characteristics[BeaconProvisioner.DXSMART_NOTIFY_CHAR] { + DebugLog.shared.log("BLE: Read mode — subscribing to FFE1") + progress = "Subscribing to notifications..." + peripheral?.setNotifyValue(true, for: notifyChar) + } else { + DebugLog.shared.log("BLE: FFE1 not found, attempting auth without notifications") + readAuth() + } + } + + private func readAuth() { + guard let passwordChar = characteristics[BeaconProvisioner.DXSMART_PASSWORD_CHAR] else { + DebugLog.shared.log("BLE: No FFE3 for auth, finishing") + finishRead() + return + } + + guard passwordIndex < BeaconProvisioner.DXSMART_PASSWORDS.count else { + DebugLog.shared.log("BLE: All passwords exhausted in read mode") + finishRead() + return + } + + state = .authenticating + let currentPassword = BeaconProvisioner.DXSMART_PASSWORDS[passwordIndex] + progress = "Authenticating (attempt \(passwordIndex + 1)/\(BeaconProvisioner.DXSMART_PASSWORDS.count))..." + + let passwordData = Data(currentPassword.utf8) + DebugLog.shared.log("BLE: Read mode — writing password \(passwordIndex + 1) to FFE3") + peripheral?.writeValue(passwordData, for: passwordChar, type: .withResponse) + } + + private func readQueryAfterAuth() { + dxReadQueries.removeAll() + dxReadQueryIndex = 0 + responseBuffer.removeAll() + + dxReadQueries.append(buildDXPacket(cmd: .frameTable, data: [])) // 0x10 + dxReadQueries.append(buildDXPacket(cmd: .iBeaconType, data: [])) // 0x62 + dxReadQueries.append(buildDXPacket(cmd: .deviceInfo, data: [])) // 0x30 + dxReadQueries.append(buildDXPacket(cmd: .deviceName, data: [])) // 0x43 + + DebugLog.shared.log("BLE: Sending \(dxReadQueries.count) read queries") + state = .verifying + progress = "Reading config..." + sendNextReadQuery() + } + + private func sendNextReadQuery() { + guard dxReadQueryIndex < dxReadQueries.count else { + DebugLog.shared.log("BLE: All read queries sent, waiting 2s for final responses") + progress = "Collecting responses..." + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + guard let self = self, self.operationMode == .readingConfig else { return } + self.finishRead() + } + return + } + + guard let commandChar = characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] else { + DebugLog.shared.log("BLE: FFE2 not found, finishing read") + finishRead() + return + } + + let packet = dxReadQueries[dxReadQueryIndex] + let current = dxReadQueryIndex + 1 + let total = dxReadQueries.count + progress = "Reading \(current)/\(total)..." + DebugLog.shared.log("BLE: Read query \(current)/\(total): \(packet.map { String(format: "%02X", $0) }.joined(separator: " "))") + peripheral?.writeValue(packet, for: commandChar, type: .withResponse) + } + + // MARK: - Read Config: Response Parsing + + private func processFFE1Response(_ data: Data) { + let hex = data.map { String(format: "%02X", $0) }.joined(separator: " ") + DebugLog.shared.log("BLE: FFE1 raw: \(hex)") + + responseBuffer.append(contentsOf: data) + + while responseBuffer.count >= 5 { + guard let headerIdx = findDXHeader() else { + responseBuffer.removeAll() + break + } + + if headerIdx > 0 { + responseBuffer.removeFirst(headerIdx) + } + + guard responseBuffer.count >= 5 else { break } + + let cmd = responseBuffer[2] + let len = Int(responseBuffer[3]) + let frameLen = 4 + len + 1 + + guard responseBuffer.count >= frameLen else { break } + + let frame = Array(responseBuffer[0.. Int? { + guard responseBuffer.count >= 2 else { return nil } + for i in 0..<(responseBuffer.count - 1) { + if responseBuffer[i] == 0x4E && responseBuffer[i + 1] == 0x4F { + return i + } + } + return nil + } + + private func parseResponseCmd(cmd: UInt8, data: [UInt8]) { + let dataHex = data.map { String(format: "%02X", $0) }.joined(separator: " ") + DebugLog.shared.log("BLE: Response cmd=0x\(String(format: "%02X", cmd)) len=\(data.count) data=[\(dataHex)]") + readResult.rawResponses.append("0x\(String(format: "%02X", cmd)): \(dataHex)") + + switch DXCmd(rawValue: cmd) { + + case .frameTable: + readResult.frameSlots = data + DebugLog.shared.log("BLE: Frame slots: \(data.map { String(format: "0x%02X", $0) })") + + case .iBeaconType: + guard data.count >= 2 else { return } + var offset = 1 // Skip type echo byte + + if data.count >= offset + 16 { + let uuidBytes = Array(data[offset..<(offset + 16)]) + let uuidHex = uuidBytes.map { String(format: "%02X", $0) }.joined() + readResult.uuid = formatUUID(uuidHex) + offset += 16 + } + if data.count >= offset + 2 { + readResult.major = UInt16(data[offset]) << 8 | UInt16(data[offset + 1]) + offset += 2 + } + if data.count >= offset + 2 { + readResult.minor = UInt16(data[offset]) << 8 | UInt16(data[offset + 1]) + offset += 2 + } + if data.count >= offset + 1 { + readResult.rssiAt1m = Int8(bitPattern: data[offset]) + offset += 1 + } + if data.count >= offset + 1 { + readResult.advInterval = UInt16(data[offset]) + offset += 1 + } + if data.count >= offset + 1 { + readResult.txPower = data[offset] + offset += 1 + } + DebugLog.shared.log("BLE: Parsed iBeacon — UUID=\(readResult.uuid ?? "?") Major=\(readResult.major ?? 0) Minor=\(readResult.minor ?? 0)") + + case .deviceInfo: + if data.count >= 1 { + readResult.battery = data[0] + } + if data.count >= 7 { + let macBytes = Array(data[1..<7]) + readResult.macAddress = macBytes.map { String(format: "%02X", $0) }.joined(separator: ":") + } + DebugLog.shared.log("BLE: Device info — battery=\(readResult.battery ?? 0)% MAC=\(readResult.macAddress ?? "?")") + + case .deviceName: + readResult.deviceName = String(bytes: data, encoding: .utf8)?.trimmingCharacters(in: .controlCharacters) + DebugLog.shared.log("BLE: Device name = \(readResult.deviceName ?? "?")") + + case .authCheck: + if data.count >= 1 { + let authRequired = data[0] != 0x00 + DebugLog.shared.log("BLE: Auth required: \(authRequired)") + } + + default: + DebugLog.shared.log("BLE: Unhandled response cmd 0x\(String(format: "%02X", cmd))") + } + } + + // MARK: - Read Config: Finish + + private func finishRead() { + readTimeout?.cancel() + readTimeout = nil + + disconnectPeripheral() + + let result = readResult + state = .success + progress = "" + readCompletion?(result, nil) + cleanupRead() + } + + private func readFail(_ message: String) { + DebugLog.shared.log("BLE: Read failed - \(message)") + readTimeout?.cancel() + readTimeout = nil + + disconnectPeripheral() + state = .failed(message) + readCompletion?(nil, message) + cleanupRead() + } + + private func cleanupRead() { + peripheral = nil + readCompletion = nil + readResult = BeaconCheckResult() + readTimeout = nil + dxReadQueries.removeAll() + dxReadQueryIndex = 0 + responseBuffer.removeAll() + allDiscoveredServices.removeAll() + servicesToExplore.removeAll() + configService = nil + characteristics.removeAll() + connectionRetryCount = 0 + disconnectRetryCount = 0 + currentBeacon = nil + operationMode = .provisioning + state = .idle + progress = "" + } + + // MARK: - Helpers + + private func hexStringToData(_ hex: String) -> Data? { + let clean = hex.normalizedUUID + guard clean.count == 32 else { return nil } + + var data = Data() + var index = clean.startIndex + while index < clean.endIndex { + let nextIndex = clean.index(index, offsetBy: 2) + let byteString = String(clean[index.. String { + let clean = hex.uppercased() + guard clean.count == 32 else { return hex } + let c = Array(clean) + return "\(String(c[0..<8]))-\(String(c[8..<12]))-\(String(c[12..<16]))-\(String(c[16..<20]))-\(String(c[20..<32]))" + } +} + +// MARK: - CBCentralManagerDelegate + +extension BeaconProvisioner: CBCentralManagerDelegate { + + func centralManagerDidUpdateState(_ central: CBCentralManager) { + DebugLog.shared.log("BLE: Central state = \(central.state.rawValue)") + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + DebugLog.shared.log("BLE: Connected to \(peripheral.name ?? "unknown")") + peripheral.delegate = self + + let maxWriteLen = peripheral.maximumWriteValueLength(for: .withResponse) + DebugLog.shared.log("BLE: Max write length: \(maxWriteLen) bytes") + if maxWriteLen < 21 { + DebugLog.shared.log("BLE: WARNING — max write length \(maxWriteLen) may be too small for UUID packet (21 bytes)") + } + + state = .discoveringServices + progress = "Discovering services..." + + if operationMode == .readingConfig { + peripheral.discoverServices(nil) + } else { + peripheral.discoverServices([BeaconProvisioner.DXSMART_SERVICE]) + } + } + + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + let errorMsg = error?.localizedDescription ?? "unknown error" + DebugLog.shared.log("BLE: Connection failed (attempt \(connectionRetryCount + 1)): \(errorMsg)") + + if connectionRetryCount < BeaconProvisioner.MAX_CONNECTION_RETRIES { + connectionRetryCount += 1 + let delay = Double(connectionRetryCount) + progress = "Connection failed, retrying (\(connectionRetryCount)/\(BeaconProvisioner.MAX_CONNECTION_RETRIES))..." + DebugLog.shared.log("BLE: Retrying in \(delay)s...") + + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self = self, let beacon = self.currentBeacon else { return } + guard self.state == .connecting else { return } + let resolvedPeripheral = self.resolvePeripheral(beacon) + self.peripheral = resolvedPeripheral + self.centralManager.connect(resolvedPeripheral, options: nil) + } + } else { + let msg = "Failed to connect after \(BeaconProvisioner.MAX_CONNECTION_RETRIES) attempts: \(errorMsg)" + if operationMode == .readingConfig { + readFail(msg) + } else { + fail(msg, code: .connectionFailed) + } + } + } + + func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { + DebugLog.shared.log("BLE: Disconnected | state=\(state) mode=\(operationMode) writeIdx=\(writeIndex)/\(commandQueue.count) terminating=\(isTerminating) error=\(error?.localizedDescription ?? "none")") + + // Already terminating (succeed/fail called) — expected cleanup disconnect + if isTerminating { + DebugLog.shared.log("BLE: Disconnect during termination, ignoring") + return + } + + // Read mode + if operationMode == .readingConfig { + if state != .success && state != .idle { + finishRead() + } + return + } + + // Terminal states + if state == .success || state == .idle { + return + } + if case .failed = state { + DebugLog.shared.log("BLE: Disconnect after failure, ignoring") + return + } + + // Intentional disconnect for FFE2 reconnect — already handling + if state == .connecting && resumeAfterReconnect { + DebugLog.shared.log("BLE: Intentional disconnect for FFE2 reconnect, ignoring") + return + } + + // SaveConfig was the last command — beacon rebooted to apply + if state == .writing && commandQueue.count > 0 && writeIndex >= commandQueue.count - 1 { + DebugLog.shared.log("BLE: Disconnect after SaveConfig (idx=\(writeIndex)/\(commandQueue.count)) — treating as success") + succeed() + return + } + + // Cancel pending timers + cancelWriteTimeout() + cancelResponseGateTimeout() + awaitingResponse = false + + // Unexpected disconnect during active phase — retry with reconnect + let isActivePhase = (state == .discoveringServices || state == .authenticating || state == .writing || state == .verifying) + if isActivePhase && disconnectRetryCount < BeaconProvisioner.MAX_DISCONNECT_RETRIES { + disconnectRetryCount += 1 + let wasWriting = (state == .writing && !commandQueue.isEmpty) + DebugLog.shared.log("BLE: Disconnect during \(state) — reconnecting (\(disconnectRetryCount)/\(BeaconProvisioner.MAX_DISCONNECT_RETRIES)) wasWriting=\(wasWriting) writeIdx=\(writeIndex)") + progress = "Beacon disconnected, reconnecting (\(disconnectRetryCount)/\(BeaconProvisioner.MAX_DISCONNECT_RETRIES))..." + + // Reset connection state, preserve write position if writing + authenticated = false + passwordIndex = 0 + characteristics.removeAll() + responseBuffer.removeAll() + + if wasWriting { + resumeAfterReconnect = true + DebugLog.shared.log("BLE: Will resume from command \(writeIndex + 1)/\(commandQueue.count) after reconnect") + } else { + commandQueue.removeAll() + writeIndex = 0 + resumeAfterReconnect = false + } + state = .connecting + + let delay = Double(disconnectRetryCount) + 2.0 // 3s, 4s, 5s... backoff + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self = self, let beacon = self.currentBeacon else { return } + guard self.state == .connecting else { return } + let resolvedPeripheral = self.resolvePeripheral(beacon) + self.peripheral = resolvedPeripheral + resolvedPeripheral.delegate = self + self.centralManager.connect(resolvedPeripheral, options: nil) + } + return + } + + // All retries exhausted + DebugLog.shared.log("BLE: UNEXPECTED disconnect — all retries exhausted") + fail("Beacon disconnected \(disconnectRetryCount + 1) times during \(state). Move closer to the beacon and try again.", code: .disconnected) + } +} + +// MARK: - CBPeripheralDelegate + +extension BeaconProvisioner: CBPeripheralDelegate { + + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + if let error = error { + if operationMode == .readingConfig { + readFail("Service discovery failed: \(error.localizedDescription)") + } else { + fail("Service discovery failed: \(error.localizedDescription)", code: .serviceNotFound) + } + return + } + + guard let services = peripheral.services else { + if operationMode == .readingConfig { + readFail("No services found") + } else { + fail("No services found", code: .serviceNotFound) + } + return + } + + DebugLog.shared.log("BLE: Discovered \(services.count) services") + for service in services { + NSLog(" Service: \(service.uuid)") + } + + if operationMode == .readingConfig { + startReadExplore() + return + } + + // Provisioning: look for DX-Smart service + for service in services { + if service.uuid == BeaconProvisioner.DXSMART_SERVICE { + configService = service + state = .discoveringServices + progress = "Discovering characteristics..." + peripheral.discoverCharacteristics([ + BeaconProvisioner.DXSMART_NOTIFY_CHAR, + BeaconProvisioner.DXSMART_COMMAND_CHAR, + BeaconProvisioner.DXSMART_PASSWORD_CHAR + ], for: service) + return + } + } + fail("Config service not found on device", code: .serviceNotFound) + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + if let error = error { + if operationMode == .readingConfig { + DebugLog.shared.log("BLE: Char discovery failed for \(service.uuid): \(error.localizedDescription)") + exploreNextService() + } else { + fail("Characteristic discovery failed: \(error.localizedDescription)", code: .serviceNotFound) + } + return + } + + guard let chars = service.characteristics else { + if operationMode == .readingConfig { + exploreNextService() + } else { + fail("No characteristics found", code: .serviceNotFound) + } + return + } + + DebugLog.shared.log("BLE: Discovered \(chars.count) characteristics for \(service.uuid)") + for char in chars { + let props = char.properties + let propStr = [ + props.contains(.read) ? "R" : "", + props.contains(.write) ? "W" : "", + props.contains(.writeWithoutResponse) ? "Wn" : "", + props.contains(.notify) ? "N" : "", + props.contains(.indicate) ? "I" : "" + ].filter { !$0.isEmpty }.joined(separator: ",") + NSLog(" Char: \(char.uuid) [\(propStr)]") + characteristics[char.uuid] = char + + if operationMode == .readingConfig { + readResult.characteristicsFound.append("\(char.uuid.uuidString)[\(propStr)]") + } + } + + if operationMode == .readingConfig { + exploreNextService() + } else { + // Provisioning: start auth flow + startAuth() + } + } + + func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { + cancelWriteTimeout() + + if let error = error { + DebugLog.shared.log("BLE: Write failed for \(characteristic.uuid): \(error.localizedDescription)") + + // Password rejected + if characteristic.uuid == BeaconProvisioner.DXSMART_PASSWORD_CHAR { + if passwordIndex + 1 < BeaconProvisioner.DXSMART_PASSWORDS.count { + retryNextPassword() + } else if operationMode == .readingConfig { + readFail("Authentication failed - all passwords rejected") + } else { + fail("Authentication failed - all passwords rejected", code: .authFailed) + } + return + } + + // Command write failed + if characteristic.uuid == BeaconProvisioner.DXSMART_COMMAND_CHAR { + if operationMode == .readingConfig { + DebugLog.shared.log("BLE: Read query failed, skipping") + dxReadQueryIndex += 1 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in + self?.sendNextReadQuery() + } + } else { + let isNonFatal = writeIndex < 6 + let isSaveConfig = writeIndex >= commandQueue.count - 1 + + if isSaveConfig { + // SaveConfig write error = beacon rebooted = success + DebugLog.shared.log("BLE: SaveConfig write error (beacon rebooted) — treating as success") + succeed() + } else if isNonFatal { + DebugLog.shared.log("BLE: Non-fatal command failed at step \(writeIndex + 1), continuing...") + writeIndex += 1 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.sendNextCommand() + } + } else { + fail("Command write failed at step \(writeIndex + 1)/\(commandQueue.count): \(error.localizedDescription)", code: .writeFailed) + } + } + return + } + + if operationMode == .readingConfig { + DebugLog.shared.log("BLE: Write failed in read mode, ignoring") + return + } + fail("Write failed: \(error.localizedDescription)", code: .writeFailed) + return + } + + DebugLog.shared.log("BLE: Write succeeded for \(characteristic.uuid)") + + // Password auth succeeded + if characteristic.uuid == BeaconProvisioner.DXSMART_PASSWORD_CHAR { + DebugLog.shared.log("BLE: Authenticated!") + authenticated = true + if operationMode == .readingConfig { + readQueryAfterAuth() + } else if resumeAfterReconnect { + // Resume writing from saved position + resumeAfterReconnect = false + state = .writing + DebugLog.shared.log("BLE: Resuming write from command \(writeIndex + 1)/\(commandQueue.count)") + progress = "Resuming config write..." + // 1.5s delay after reconnect for BLE stability + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + self?.sendNextCommand() + } + } else { + // Fresh provisioning: build queue and start writing + DebugLog.shared.log("BLE: Auth complete, proceeding to config write") + buildAndStartWriting() + } + return + } + + // Command write succeeded + if characteristic.uuid == BeaconProvisioner.DXSMART_COMMAND_CHAR { + if operationMode == .readingConfig { + dxReadQueryIndex += 1 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in + self?.sendNextReadQuery() + } + } else { + // Gate on FFE1 response before next command + awaitingResponse = true + scheduleResponseGateTimeout() + } + return + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { + if let error = error { + DebugLog.shared.log("BLE: Notification state failed for \(characteristic.uuid): \(error.localizedDescription)") + } else { + DebugLog.shared.log("BLE: Notifications \(characteristic.isNotifying ? "enabled" : "disabled") for \(characteristic.uuid)") + } + + if characteristic.uuid == BeaconProvisioner.DXSMART_NOTIFY_CHAR { + if operationMode == .readingConfig { + readAuth() + } else { + authenticate() + } + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + if let error = error { + DebugLog.shared.log("BLE: Read error for \(characteristic.uuid): \(error.localizedDescription)") + return + } + + let data = characteristic.value ?? Data() + + if operationMode == .readingConfig { + if characteristic.uuid == BeaconProvisioner.DXSMART_NOTIFY_CHAR { + processFFE1Response(data) + } else { + let hex = data.map { String(format: "%02X", $0) }.joined(separator: " ") + DebugLog.shared.log("BLE: Read \(characteristic.uuid): \(hex)") + } + } else { + // Provisioning: FFE1 notification = beacon response to our command + if characteristic.uuid == BeaconProvisioner.DXSMART_NOTIFY_CHAR { + let hex = data.map { String(format: "%02X", $0) }.joined(separator: " ") + DebugLog.shared.log("BLE: FFE1 notification: \(hex)") + + if awaitingResponse { + awaitingResponse = false + cancelResponseGateTimeout() + + // Check for rejection: 4E 4F 00 = command rejected + let bytes = [UInt8](data) + if bytes.count >= 3 && bytes[0] == 0x4E && bytes[1] == 0x4F && bytes[2] == 0x00 { + let isNonFatal = writeIndex < 6 + if isNonFatal { + DebugLog.shared.log("BLE: Command \(writeIndex + 1) rejected by beacon (non-fatal, continuing)") + } else { + DebugLog.shared.log("BLE: Command \(writeIndex + 1) REJECTED by beacon") + } + } + + advanceToNextCommand() + } + } + } + } +}