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 } } /// Handles GATT connection and provisioning of beacons class BeaconProvisioner: NSObject, ObservableObject { // MARK: - DX-Smart CP28 GATT Characteristics 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 // DX-Smart packet header private static let DXSMART_HEADER: [UInt8] = [0x4E, 0x4F] // DX-Smart connection passwords (tried in order until one works) private static let DXSMART_PASSWORDS = ["555555", "dx1234", "000000"] // DX-Smart command codes 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 } @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) } private var centralManager: CBCentralManager! private var peripheral: CBPeripheral? private var beaconType: BeaconType = .unknown private var config: BeaconConfig? private var completion: ((ProvisioningResult) -> Void)? private var configService: CBService? private var characteristics: [CBUUID: CBCharacteristic] = [:] private var passwordIndex = 0 private var writeQueue: [(CBCharacteristic, Data)] = [] // DX-Smart provisioning state private var dxSmartAuthenticated = false private var dxSmartNotifySubscribed = false private var dxSmartCommandQueue: [Data] = [] private var dxSmartWriteIndex = 0 private var provisioningMacAddress: String? private var awaitingDeviceInfoForProvisioning = false private var skipDeviceInfoRead = false // set after disconnect during device info — skip MAC read on reconnect private var isTerminating = false // guards against re-entrant disconnect handling private var resumeWriteAfterDisconnect = false // when true, skip queue rebuild on reconnect and resume from saved index // Read config mode 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? // Read config exploration state private var allDiscoveredServices: [CBService] = [] private var servicesToExplore: [CBService] = [] // DX-Smart read query state private var dxReadQueries: [Data] = [] private var dxReadQueryIndex = 0 private var responseBuffer: [UInt8] = [] // Connection retry state private var connectionRetryCount = 0 private var deviceInfoRetryCount = 0 private var disconnectRetryCount = 0 private static let MAX_CONNECTION_RETRIES = 3 private static let MAX_DEVICE_INFO_RETRIES = 2 private static let MAX_DISCONNECT_RETRIES = 5 private var currentBeacon: DiscoveredBeacon? // Per-write timeout (matches Android's 5-second per-write timeout) // If a write callback doesn't come back in time, we retry or fail gracefully // instead of hanging until the 30s global timeout private var writeTimeoutTimer: DispatchWorkItem? private var charRediscoveryCount = 0 private static let MAX_CHAR_REDISCOVERY = 2 private var charRediscoveryTimer: DispatchWorkItem? private static let CHAR_REDISCOVERY_TIMEOUT: Double = 5.0 private static let WRITE_TIMEOUT_SECONDS: Double = 5.0 private var writeRetryCount = 0 private static let MAX_WRITE_RETRIES = 1 // Retry once per command before failing // Response gating — wait for beacon's FFE1 notification response after each // write before sending the next command. Android does this with a 1000ms // responseChannel.receive() after every FFE2 write. Without this gate, iOS // hammers the beacon's MCU faster than it can process, causing supervision // timeout disconnects. private var awaitingCommandResponse = false private var responseGateTimer: DispatchWorkItem? private static let RESPONSE_GATE_TIMEOUT: Double = 1.0 // 1s matches Android's withTimeoutOrNull(1000L) // Adaptive inter-command delays to prevent BLE supervision timeouts. // DX-Smart CP28 beacons have tiny MCU buffers and share radio time between // advertising and GATT — rapid writes cause the beacon to miss connection // events, triggering link-layer supervision timeouts (the "unexpected disconnect" // that was happening 4x during provisioning). private static let BASE_WRITE_DELAY: Double = 0.5 // Default delay between commands (was 0.3) 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 override init() { super.init() centralManager = CBCentralManager(delegate: self, queue: .main) } /// 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: - 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 } let resolvedPeripheral = resolvePeripheral(beacon) self.peripheral = resolvedPeripheral self.beaconType = beacon.type self.config = config self.completion = completion self.operationMode = .provisioning self.passwordIndex = 0 self.characteristics.removeAll() self.writeQueue.removeAll() self.dxSmartAuthenticated = false self.dxSmartNotifySubscribed = false self.dxSmartCommandQueue.removeAll() self.dxSmartWriteIndex = 0 self.provisioningMacAddress = nil self.awaitingDeviceInfoForProvisioning = false self.skipDeviceInfoRead = false self.isTerminating = false self.resumeWriteAfterDisconnect = false self.awaitingCommandResponse = false cancelResponseGateTimeout() self.connectionRetryCount = 0 self.deviceInfoRetryCount = 0 self.disconnectRetryCount = 0 self.writeRetryCount = 0 self.currentBeacon = beacon state = .connecting progress = "Connecting to \(beacon.displayName)..." centralManager.connect(resolvedPeripheral, options: nil) // Timeout after 90 seconds (increased to accommodate 5 disconnect retries with resume) DispatchQueue.main.asyncAfter(deadline: .now() + 90) { [weak self] in if self?.state != .success && self?.state != .idle { self?.fail("Connection timeout", code: .connectionTimeout) } } } /// Cancel current provisioning func cancel() { if let peripheral = peripheral { centralManager.cancelPeripheralConnection(peripheral) } cleanup() } // MARK: - 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 } let resolvedPeripheral = resolvePeripheral(beacon) self.peripheral = resolvedPeripheral self.beaconType = beacon.type self.operationMode = .readingConfig self.readCompletion = completion self.readResult = BeaconCheckResult() self.passwordIndex = 0 self.characteristics.removeAll() self.dxSmartAuthenticated = false self.dxSmartNotifySubscribed = false self.responseBuffer.removeAll() self.dxReadQueries.removeAll() self.dxReadQueryIndex = 0 self.allDiscoveredServices.removeAll() self.connectionRetryCount = 0 self.deviceInfoRetryCount = 0 self.disconnectRetryCount = 0 self.isTerminating = false self.currentBeacon = beacon self.servicesToExplore.removeAll() 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: - Cleanup private func cleanup() { cancelWriteTimeout() cancelResponseGateTimeout() cancelCharRediscoveryTimeout() awaitingCommandResponse = false peripheral = nil config = nil completion = nil configService = nil characteristics.removeAll() writeQueue.removeAll() dxSmartAuthenticated = false dxSmartNotifySubscribed = false dxSmartCommandQueue.removeAll() dxSmartWriteIndex = 0 provisioningMacAddress = nil awaitingDeviceInfoForProvisioning = false skipDeviceInfoRead = false isTerminating = false resumeWriteAfterDisconnect = false connectionRetryCount = 0 deviceInfoRetryCount = 0 disconnectRetryCount = 0 writeRetryCount = 0 charRediscoveryCount = 0 currentBeacon = nil state = .idle progress = "" } 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) if let peripheral = peripheral, peripheral.state == .connected { centralManager.cancelPeripheralConnection(peripheral) } 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: Success! MAC=\(provisioningMacAddress ?? "unknown")") state = .success if let peripheral = peripheral, peripheral.state == .connected { centralManager.cancelPeripheralConnection(peripheral) } let mac = provisioningMacAddress completion?(.success(macAddress: mac)) cleanup() } // MARK: - DX-Smart CP28 Provisioning private func provisionDXSmart() { guard let service = configService else { fail("DX-Smart config service not found", code: .serviceNotFound) return } state = .discoveringServices progress = "Discovering DX-Smart characteristics..." peripheral?.discoverCharacteristics([ BeaconProvisioner.DXSMART_NOTIFY_CHAR, BeaconProvisioner.DXSMART_COMMAND_CHAR, BeaconProvisioner.DXSMART_PASSWORD_CHAR ], for: service) } /// Subscribe to FFE1 notifications, then authenticate on FFE3 private func dxSmartStartAuth() { if let notifyChar = characteristics[BeaconProvisioner.DXSMART_NOTIFY_CHAR] { DebugLog.shared.log("BLE: Subscribing to DX-Smart FFE1 notifications") peripheral?.setNotifyValue(true, for: notifyChar) } else { DebugLog.shared.log("BLE: FFE1 not found, proceeding to auth directly") dxSmartNotifySubscribed = true dxSmartAuthenticate() } } /// Write password to FFE3 (tries multiple passwords in sequence) private func dxSmartAuthenticate() { guard let passwordChar = characteristics[BeaconProvisioner.DXSMART_PASSWORD_CHAR] else { fail("DX-Smart password characteristic (FFE3) not found", code: .serviceNotFound) return } guard passwordIndex < BeaconProvisioner.DXSMART_PASSWORDS.count 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) } /// Called when a password attempt fails — tries the next one private func dxSmartRetryNextPassword() { 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?.dxSmartAuthenticate() } } else { fail("Authentication failed - all \(BeaconProvisioner.DXSMART_PASSWORDS.count) passwords rejected", code: .authFailed) } } /// Read device info (MAC address) before writing config /// NOTE: Device info read (0x30 query) is SKIPPED during provisioning because DX-Smart /// beacons frequently drop the BLE connection during this optional query, causing /// provisioning to fail. The MAC address is nice-to-have but not required — the API /// falls back to iBeacon UUID as hardware ID when MAC is unavailable. /// Device info is still read in readConfig/check mode where it doesn't block provisioning. private func dxSmartReadDeviceInfoBeforeWrite() { DebugLog.shared.log("BLE: Skipping device info read — proceeding directly to config write (MAC is optional)") dxSmartWriteConfig() } /// Build the full command sequence and start writing /// New 24-step 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-23. Frames 3-6 select + 0xFF (disable each) /// 24. SaveConfig 0x60 — persist to flash private func dxSmartWriteConfig() { guard let config = config else { fail("No config provided", code: .noConfig) return } state = .writing progress = "Writing DX-Smart configuration..." dxSmartCommandQueue.removeAll() dxSmartWriteIndex = 0 // Convert measuredPower (signed Int8) to unsigned byte for transmission let measuredPowerByte = UInt8(bitPattern: config.measuredPower) // 1. DeviceName (0x71) — service point name (max 20 ASCII chars) if let name = config.deviceName, !name.isEmpty { let truncatedName = String(name.prefix(20)) let nameBytes = Array(truncatedName.utf8) dxSmartCommandQueue.append(buildDXPacket(cmd: .deviceNameWrite, data: nameBytes)) } // --- Frame 1: Device Info (broadcasts name) --- // 2. Frame1_Select (0x11) dxSmartCommandQueue.append(buildDXPacket(cmd: .frameSelectSlot0, data: [])) // 3. Frame1_Type (0x61) — device info dxSmartCommandQueue.append(buildDXPacket(cmd: .deviceInfoType, data: [])) // 4. Frame1_RSSI (0x77) dxSmartCommandQueue.append(buildDXPacket(cmd: .rssiAt1m, data: [measuredPowerByte])) // 5. Frame1_AdvInt (0x78) dxSmartCommandQueue.append(buildDXPacket(cmd: .advInterval, data: [config.advInterval])) // 6. Frame1_TxPow (0x79) dxSmartCommandQueue.append(buildDXPacket(cmd: .txPower, data: [config.txPower])) // --- Frame 2: iBeacon --- // 7. Frame2_Select (0x12) dxSmartCommandQueue.append(buildDXPacket(cmd: .frameSelectSlot1, data: [])) // 8. Frame2_Type (0x62) — iBeacon dxSmartCommandQueue.append(buildDXPacket(cmd: .iBeaconType, data: [])) // 9. UUID (0x74) [16 bytes] if let uuidData = hexStringToData(config.uuid) { dxSmartCommandQueue.append(buildDXPacket(cmd: .uuid, data: Array(uuidData))) } // 10. Major (0x75) [2 bytes big-endian] let majorHi = UInt8((config.major >> 8) & 0xFF) let majorLo = UInt8(config.major & 0xFF) dxSmartCommandQueue.append(buildDXPacket(cmd: .major, data: [majorHi, majorLo])) // 11. Minor (0x76) [2 bytes big-endian] let minorHi = UInt8((config.minor >> 8) & 0xFF) let minorLo = UInt8(config.minor & 0xFF) dxSmartCommandQueue.append(buildDXPacket(cmd: .minor, data: [minorHi, minorLo])) // 12. RSSI@1m (0x77) dxSmartCommandQueue.append(buildDXPacket(cmd: .rssiAt1m, data: [measuredPowerByte])) // 13. AdvInterval (0x78) dxSmartCommandQueue.append(buildDXPacket(cmd: .advInterval, data: [config.advInterval])) // 14. TxPower (0x79) dxSmartCommandQueue.append(buildDXPacket(cmd: .txPower, data: [config.txPower])) // 15. TriggerOff (0xA0) dxSmartCommandQueue.append(buildDXPacket(cmd: .triggerOff, data: [])) // --- Frames 3-6: Disable each --- // 16-17. Frame 3: select (0x13) + disable (0xFF) dxSmartCommandQueue.append(buildDXPacket(cmd: .frameSelectSlot2, data: [])) dxSmartCommandQueue.append(buildDXPacket(cmd: .frameDisable, data: [])) // 18-19. Frame 4: select (0x14) + disable (0xFF) dxSmartCommandQueue.append(buildDXPacket(cmd: .frameSelectSlot3, data: [])) dxSmartCommandQueue.append(buildDXPacket(cmd: .frameDisable, data: [])) // 20-21. Frame 5: select (0x15) + disable (0xFF) dxSmartCommandQueue.append(buildDXPacket(cmd: .frameSelectSlot4, data: [])) dxSmartCommandQueue.append(buildDXPacket(cmd: .frameDisable, data: [])) // 22-23. Frame 6: select (0x16) + disable (0xFF) dxSmartCommandQueue.append(buildDXPacket(cmd: .frameSelectSlot5, data: [])) dxSmartCommandQueue.append(buildDXPacket(cmd: .frameDisable, data: [])) // 24. SaveConfig (0x60) — persist to flash dxSmartCommandQueue.append(buildDXPacket(cmd: .saveConfig, data: [])) DebugLog.shared.log("BLE: DX-Smart command queue built with \(dxSmartCommandQueue.count) commands") dxSmartSendNextCommand() } /// Send the next command in the DX-Smart queue private func dxSmartSendNextCommand() { guard dxSmartWriteIndex < dxSmartCommandQueue.count else { cancelWriteTimeout() DebugLog.shared.log("BLE: All DX-Smart commands written!") progress = "Configuration saved!" succeed() return } let packet = dxSmartCommandQueue[dxSmartWriteIndex] let total = dxSmartCommandQueue.count let current = dxSmartWriteIndex + 1 progress = "Writing config (\(current)/\(total))..." guard let commandChar = characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] else { // After disconnect+reconnect, characteristic discovery may return incomplete results. // Re-discover characteristics instead of hard-failing. if charRediscoveryCount < BeaconProvisioner.MAX_CHAR_REDISCOVERY, let service = configService { charRediscoveryCount += 1 DebugLog.shared.log("BLE: FFE2 missing — re-discovering characteristics (attempt \(charRediscoveryCount)/\(BeaconProvisioner.MAX_CHAR_REDISCOVERY))") progress = "Re-discovering characteristics..." state = .discoveringServices scheduleCharRediscoveryTimeout() DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in self?.peripheral?.discoverCharacteristics([ BeaconProvisioner.DXSMART_NOTIFY_CHAR, BeaconProvisioner.DXSMART_COMMAND_CHAR, BeaconProvisioner.DXSMART_PASSWORD_CHAR ], for: service) } } else { fail("DX-Smart command characteristic (FFE2) not found after \(charRediscoveryCount) rediscovery attempts", code: .serviceNotFound) } return } // Reset rediscovery counter on successful characteristic access charRediscoveryCount = 0 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) } /// Schedule a per-write timeout — if the write callback doesn't come back /// within WRITE_TIMEOUT_SECONDS, retry the write once or skip if non-fatal. /// This matches Android's 5-second per-write timeout via withTimeoutOrNull(5000L). 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.dxSmartWriteIndex + 1 let total = self.dxSmartCommandQueue.count let isNonFatal = self.dxSmartWriteIndex < 6 let isSaveConfig = self.dxSmartWriteIndex >= self.dxSmartCommandQueue.count - 1 if isSaveConfig { // SaveConfig may not get a callback — beacon reboots. Treat as success. DebugLog.shared.log("BLE: SaveConfig write timeout (beacon likely rebooted) — treating as success") self.succeed() } else if self.writeRetryCount < BeaconProvisioner.MAX_WRITE_RETRIES { // Retry the write once self.writeRetryCount += 1 DebugLog.shared.log("BLE: Write timeout for command \(current)/\(total) — retrying (\(self.writeRetryCount)/\(BeaconProvisioner.MAX_WRITE_RETRIES))") if let commandChar = self.characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] { let packet = self.dxSmartCommandQueue[self.dxSmartWriteIndex] self.scheduleWriteTimeout() self.peripheral?.writeValue(packet, for: commandChar, type: .withResponse) } } else if isNonFatal { // Non-fatal commands (first 6) — skip and continue DebugLog.shared.log("BLE: Write timeout for non-fatal command \(current)/\(total) — skipping") self.dxSmartWriteIndex += 1 DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.dxSmartSendNextCommand() } } else { // Fatal command timed out after retry — fail 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) } /// Cancel any pending write timeout private func cancelWriteTimeout() { writeTimeoutTimer?.cancel() writeTimeoutTimer = nil } /// Schedule a timeout for characteristic rediscovery. /// If didDiscoverCharacteristicsFor doesn't fire within 5 seconds, /// either retry or fail instead of hanging forever. private func scheduleCharRediscoveryTimeout() { cancelCharRediscoveryTimeout() let timer = DispatchWorkItem { [weak self] in guard let self = self else { return } guard self.state == .discoveringServices else { return } let attempt = self.charRediscoveryCount DebugLog.shared.log("BLE: Characteristic rediscovery timeout (attempt \(attempt)/\(BeaconProvisioner.MAX_CHAR_REDISCOVERY))") if attempt < BeaconProvisioner.MAX_CHAR_REDISCOVERY, let service = self.configService { // Try another rediscovery attempt self.charRediscoveryCount += 1 DebugLog.shared.log("BLE: Retrying characteristic rediscovery (attempt \(self.charRediscoveryCount)/\(BeaconProvisioner.MAX_CHAR_REDISCOVERY))") self.scheduleCharRediscoveryTimeout() self.peripheral?.discoverCharacteristics([ BeaconProvisioner.DXSMART_NOTIFY_CHAR, BeaconProvisioner.DXSMART_COMMAND_CHAR, BeaconProvisioner.DXSMART_PASSWORD_CHAR ], for: service) } else { self.fail("Characteristic rediscovery timed out after \(attempt) attempts", code: .timeout) } } charRediscoveryTimer = timer DispatchQueue.main.asyncAfter(deadline: .now() + BeaconProvisioner.CHAR_REDISCOVERY_TIMEOUT, execute: timer) } /// Cancel any pending characteristic rediscovery timeout private func cancelCharRediscoveryTimeout() { charRediscoveryTimer?.cancel() charRediscoveryTimer = nil } /// Calculate adaptive delay after writing a command. /// Frame selection (0x11-0x16) and type commands (0x61, 0x62) trigger internal /// state changes on the beacon MCU that need extra processing time. /// UUID writes are the largest payload (21 bytes) and also need breathing room. /// Without adaptive delays, the beacon's radio gets overwhelmed and drops the /// BLE connection (supervision timeout). private func delayForCommand(at index: Int) -> Double { guard index < dxSmartCommandQueue.count else { return BeaconProvisioner.BASE_WRITE_DELAY } let packet = dxSmartCommandQueue[index] guard packet.count >= 3 else { return BeaconProvisioner.BASE_WRITE_DELAY } let cmd = packet[2] // Command byte is at offset 2 (after 4E 4F header) switch DXCmd(rawValue: cmd) { case .frameSelectSlot0, .frameSelectSlot1, .frameSelectSlot2, .frameSelectSlot3, .frameSelectSlot4, .frameSelectSlot5: // Frame selection changes internal state — beacon needs time to switch context return BeaconProvisioner.HEAVY_WRITE_DELAY case .deviceInfoType, .iBeaconType: // Frame type assignment — triggers internal config restructuring return BeaconProvisioner.HEAVY_WRITE_DELAY case .uuid: // Largest payload (16 bytes + header = 21 bytes) — give extra time return BeaconProvisioner.LARGE_PAYLOAD_DELAY case .saveConfig: // Save to flash — beacon may reboot, no point waiting long return BeaconProvisioner.BASE_WRITE_DELAY default: return BeaconProvisioner.BASE_WRITE_DELAY } } // MARK: - Response Gating (matches Android responseChannel.receive pattern) /// After a successful write, advance to the next command with the appropriate delay. /// Called either when FFE1 response arrives or when the 1s response gate timeout fires. private func advanceToNextCommand() { let justWritten = dxSmartWriteIndex dxSmartWriteIndex += 1 let delay = delayForCommand(at: justWritten) DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in self?.dxSmartSendNextCommand() } } /// Schedule a 1s timeout for beacon response. If the beacon doesn't send an FFE1 /// notification within 1s, advance anyway (some commands don't produce responses). /// Matches Android's: withTimeoutOrNull(1000L) { responseChannel.receive() } private func scheduleResponseGateTimeout() { cancelResponseGateTimeout() let timer = DispatchWorkItem { [weak self] in guard let self = self else { return } guard self.awaitingCommandResponse else { return } self.awaitingCommandResponse = false DebugLog.shared.log("BLE: No FFE1 response within 1s for command \(self.dxSmartWriteIndex + 1) — advancing (OK)") self.advanceToNextCommand() } responseGateTimer = timer DispatchQueue.main.asyncAfter(deadline: .now() + BeaconProvisioner.RESPONSE_GATE_TIMEOUT, execute: timer) } /// Cancel any pending response gate timeout private func cancelResponseGateTimeout() { responseGateTimer?.cancel() responseGateTimer = nil } // 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) // 4E 4F packet.append(cmd.rawValue) packet.append(UInt8(data.count)) packet.append(contentsOf: data) // XOR checksum: CMD ^ LEN ^ each data byte 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 /// Explore all services on the device, then attempt DX-Smart read protocol 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 { // All services explored — start DX-Smart read protocol if FFE0 is present 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 /// After exploration, start DX-Smart read if FFE0 chars are present private func startDXSmartRead() { guard characteristics[BeaconProvisioner.DXSMART_PASSWORD_CHAR] != nil, characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] != nil else { // Not a DX-Smart beacon — finish with just the service/char listing 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 notifications") progress = "Subscribing to notifications..." peripheral?.setNotifyValue(true, for: notifyChar) } else { // No FFE1 — try auth anyway DebugLog.shared.log("BLE: FFE1 not found, attempting auth without notifications") dxSmartReadAuth() } } /// Authenticate on FFE3 for read mode (uses same multi-password fallback) private func dxSmartReadAuth() { 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)/\(BeaconProvisioner.DXSMART_PASSWORDS.count) to FFE3") peripheral?.writeValue(passwordData, for: passwordChar, type: .withResponse) } /// After auth, send read query commands private func dxSmartReadQueryAfterAuth() { dxReadQueries.removeAll() dxReadQueryIndex = 0 responseBuffer.removeAll() // Read commands: send with LEN=0 (no data) to request current config values dxReadQueries.append(buildDXPacket(cmd: .frameTable, data: [])) // 0x10: frame assignment table dxReadQueries.append(buildDXPacket(cmd: .iBeaconType, data: [])) // 0x62: iBeacon UUID/Major/Minor/etc dxReadQueries.append(buildDXPacket(cmd: .deviceInfo, data: [])) // 0x30: battery, MAC, firmware dxReadQueries.append(buildDXPacket(cmd: .deviceName, data: [])) // 0x43: device name DebugLog.shared.log("BLE: Sending \(dxReadQueries.count) DX-Smart read queries") state = .verifying progress = "Reading config..." dxSmartSendNextReadQuery() } private func dxSmartSendNextReadQuery() { 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 /// Process incoming FFE1 notification data — accumulate and parse DX-Smart response frames 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) // Try to parse complete frames from buffer while responseBuffer.count >= 5 { // Minimum frame: 4E 4F CMD 00 XOR = 5 bytes // Find 4E 4F header guard let headerIdx = findDXHeader() else { responseBuffer.removeAll() break } // Discard bytes before header 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 // header(2) + cmd(1) + len(1) + data(len) + xor(1) guard responseBuffer.count >= frameLen else { // Incomplete frame — wait for more data break } // Extract frame 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 } /// Parse a complete DX-Smart response by command type 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: // 0x10: Frame assignment table (one byte per slot) readResult.frameSlots = data DebugLog.shared.log("BLE: Frame slots: \(data.map { String(format: "0x%02X", $0) })") case .iBeaconType: // 0x62: iBeacon config data guard data.count >= 2 else { return } var offset = 1 // Skip type echo byte // UUID: 16 bytes 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 } // Major: 2 bytes big-endian if data.count >= offset + 2 { readResult.major = UInt16(data[offset]) << 8 | UInt16(data[offset + 1]) offset += 2 } // Minor: 2 bytes big-endian if data.count >= offset + 2 { readResult.minor = UInt16(data[offset]) << 8 | UInt16(data[offset + 1]) offset += 2 } // RSSI@1m: 1 byte signed if data.count >= offset + 1 { readResult.rssiAt1m = Int8(bitPattern: data[offset]) offset += 1 } // Advertising interval: 1 byte (raw value) if data.count >= offset + 1 { readResult.advInterval = UInt16(data[offset]) offset += 1 } // TX power: 1 byte 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: // 0x30: Device info (battery, MAC, manufacturer, firmware) 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: // 0x43: Device name readResult.deviceName = String(bytes: data, encoding: .utf8)?.trimmingCharacters(in: .controlCharacters) DebugLog.shared.log("BLE: Device name = \(readResult.deviceName ?? "?")") case .authCheck: // 0x25: Auth check response 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 if let peripheral = peripheral { centralManager.cancelPeripheralConnection(peripheral) } 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 if let peripheral = peripheral { centralManager.cancelPeripheralConnection(peripheral) } 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 deviceInfoRetryCount = 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 // Log negotiated MTU — CoreBluetooth auto-negotiates, but we need to verify // the max write length can handle our largest packet (UUID write = ~21 bytes) let maxWriteLen = peripheral.maximumWriteValueLength(for: .withResponse) DebugLog.shared.log("BLE: Max write length (withResponse): \(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) // Discover all for exploration } 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)") // Retry logic: up to 3 retries with increasing delay (1s, 2s, 3s) if connectionRetryCount < BeaconProvisioner.MAX_CONNECTION_RETRIES { connectionRetryCount += 1 let delay = Double(connectionRetryCount) // 1s, 2s, 3s progress = "Connection failed, retrying (\(connectionRetryCount)/\(BeaconProvisioner.MAX_CONNECTION_RETRIES))..." DebugLog.shared.log("BLE: Retrying connection 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 } // Don't retry if cancelled 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 from \(peripheral.name ?? "unknown") | state=\(state) mode=\(operationMode) writeIdx=\(dxSmartWriteIndex) queueCount=\(dxSmartCommandQueue.count) terminating=\(isTerminating) error=\(error?.localizedDescription ?? "none")") // If we already called succeed() or fail(), this disconnect is expected cleanup — ignore it if isTerminating { DebugLog.shared.log("BLE: Disconnect during termination, ignoring") return } if operationMode == .readingConfig { if state != .success && state != .idle { finishRead() } return } // Already in a terminal state — nothing to do if state == .success || state == .idle { return } if case .failed = state { DebugLog.shared.log("BLE: Disconnect after failure, ignoring") return } // SaveConfig (last command) was sent — beacon rebooted to apply config // Check: writing state AND at or past the last command in queue if state == .writing && dxSmartCommandQueue.count > 0 && dxSmartWriteIndex >= dxSmartCommandQueue.count - 1 { DebugLog.shared.log("BLE: Disconnect after SaveConfig (idx=\(dxSmartWriteIndex)/\(dxSmartCommandQueue.count)) — treating as success") succeed() return } // NOTE: Device info read is now skipped entirely during provisioning // (see dxSmartReadDeviceInfoBeforeWrite). This guard is kept as a safety net // in case device info is re-enabled in the future. if state == .authenticating && awaitingDeviceInfoForProvisioning && dxSmartAuthenticated { DebugLog.shared.log("BLE: Disconnect during device info read — proceeding without MAC (device info is optional)") awaitingDeviceInfoForProvisioning = false skipDeviceInfoRead = true // Reconnect and skip device info on next attempt dxSmartAuthenticated = false dxSmartNotifySubscribed = false dxSmartCommandQueue.removeAll() dxSmartWriteIndex = 0 passwordIndex = 0 characteristics.removeAll() responseBuffer.removeAll() state = .connecting DispatchQueue.main.asyncAfter(deadline: .now() + 1.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 self.centralManager.connect(resolvedPeripheral, options: nil) } return } // Cancel any pending timers — disconnect supersedes them cancelWriteTimeout() cancelResponseGateTimeout() awaitingCommandResponse = false // Unexpected disconnect during any active provisioning phase — retry with full reconnect let isActivePhase = (state == .discoveringServices || state == .authenticating || state == .writing || state == .verifying) if isActivePhase && disconnectRetryCount < BeaconProvisioner.MAX_DISCONNECT_RETRIES { disconnectRetryCount += 1 let wasWriting = (state == .writing && !dxSmartCommandQueue.isEmpty) DebugLog.shared.log("BLE: Disconnect during \(state) — reconnecting (attempt \(disconnectRetryCount)/\(BeaconProvisioner.MAX_DISCONNECT_RETRIES)) wasWriting=\(wasWriting) writeIdx=\(dxSmartWriteIndex)") progress = "Beacon disconnected, reconnecting (\(disconnectRetryCount)/\(BeaconProvisioner.MAX_DISCONNECT_RETRIES))..." // Reset connection-level state, but PRESERVE command queue and write index // so we can resume from where we left off instead of starting over dxSmartAuthenticated = false dxSmartNotifySubscribed = false passwordIndex = 0 characteristics.removeAll() responseBuffer.removeAll() if wasWriting { // Resume mode: keep the command queue and write index intact resumeWriteAfterDisconnect = true DebugLog.shared.log("BLE: Will resume writing from command \(dxSmartWriteIndex + 1)/\(dxSmartCommandQueue.count) after reconnect") } else { // Full reset for non-writing phases dxSmartCommandQueue.removeAll() dxSmartWriteIndex = 0 resumeWriteAfterDisconnect = false } state = .connecting let delay = Double(disconnectRetryCount) + 2.0 // 3s, 4s, 5s, 6s, 7s backoff — give BLE time to settle 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 or disconnect in unexpected state — fail DebugLog.shared.log("BLE: UNEXPECTED disconnect — state=\(state) writeIdx=\(dxSmartWriteIndex) queueCount=\(dxSmartCommandQueue.count) authenticated=\(dxSmartAuthenticated) disconnectRetries=\(disconnectRetryCount)") 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 provisionDXSmart() 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 { // Don't fail entirely — skip this service 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 { // Continue exploring next service exploreNextService() } else if charRediscoveryCount > 0 && dxSmartAuthenticated && !dxSmartCommandQueue.isEmpty { // Rediscovery during active write — resume writing directly (already authenticated) cancelCharRediscoveryTimeout() DebugLog.shared.log("BLE: Characteristics re-discovered after FFE2 miss — resuming write") state = .writing DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.dxSmartSendNextCommand() } } else { // Provisioning: DX-Smart auth flow dxSmartStartAuth() } } func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { cancelWriteTimeout() // Write callback received — cancel the per-write timeout if let error = error { DebugLog.shared.log("BLE: Write failed for \(characteristic.uuid): \(error.localizedDescription)") if characteristic.uuid == BeaconProvisioner.DXSMART_PASSWORD_CHAR { // Password rejected — try next password in the list if passwordIndex + 1 < BeaconProvisioner.DXSMART_PASSWORDS.count { DebugLog.shared.log("BLE: Password \(passwordIndex + 1) rejected, trying next...") dxSmartRetryNextPassword() } else if operationMode == .readingConfig { readFail("Authentication failed - all passwords rejected") } else { fail("Authentication failed - all \(BeaconProvisioner.DXSMART_PASSWORDS.count) passwords rejected", code: .authFailed) } return } 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?.dxSmartSendNextReadQuery() } } else { // Device name (0x71) and Frame 1 commands (steps 1-6) may be rejected by some firmware // Treat these as non-fatal: log and continue to next command let isNonFatalCommand = dxSmartWriteIndex < 6 // First 6 commands are optional let isSaveConfig = dxSmartWriteIndex >= dxSmartCommandQueue.count - 1 if isSaveConfig { // SaveConfig (0x60) write "error" is expected — beacon reboots immediately // after processing the save, which kills the BLE connection before the // ATT write response can be delivered. This is success, not failure. DebugLog.shared.log("BLE: SaveConfig write error (beacon rebooted) — treating as success") succeed() } else if isNonFatalCommand { DebugLog.shared.log("BLE: Non-fatal command failed at step \(dxSmartWriteIndex + 1), continuing...") dxSmartWriteIndex += 1 DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.dxSmartSendNextCommand() } } else { fail("Command write failed at step \(dxSmartWriteIndex + 1)/\(dxSmartCommandQueue.count): \(error.localizedDescription)", code: .writeFailed) } } return } if operationMode == .readingConfig { DebugLog.shared.log("BLE: Write failed in read mode, ignoring: \(error.localizedDescription)") 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!") dxSmartAuthenticated = true if operationMode == .readingConfig { dxSmartReadQueryAfterAuth() } else if resumeWriteAfterDisconnect { // Reconnected after disconnect during writing — resume from saved position resumeWriteAfterDisconnect = false state = .writing DebugLog.shared.log("BLE: Resuming write from command \(dxSmartWriteIndex + 1)/\(dxSmartCommandQueue.count) after reconnect") progress = "Resuming config write..." // Longer delay after reconnect — give the beacon's BLE stack time to stabilize // before resuming writes (prevents immediate re-disconnect) DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in self?.dxSmartSendNextCommand() } } else { // Read device info first to get MAC address, then write config dxSmartReadDeviceInfoBeforeWrite() } return } // Command write succeeded → wait for beacon response before sending next // (matches Android: writeCharacteristic → responseChannel.receive(1000ms) → delay → next) if characteristic.uuid == BeaconProvisioner.DXSMART_COMMAND_CHAR { if operationMode == .readingConfig { dxReadQueryIndex += 1 DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in self?.dxSmartSendNextReadQuery() } } else if awaitingDeviceInfoForProvisioning { // Device info query was sent - wait for response on FFE1, don't process as normal command DebugLog.shared.log("BLE: Device info query sent, waiting for response...") } else { // Gate on FFE1 response — don't fire next command until beacon responds // or 1s timeout elapses (some commands don't send responses) awaitingCommandResponse = 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 { dxSmartNotifySubscribed = true if operationMode == .readingConfig { // After subscribing FFE1 in read mode → authenticate dxSmartReadAuth() } else { // Provisioning mode → authenticate dxSmartAuthenticate() } } } 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 { // DX-Smart response data — parse protocol frames processFFE1Response(data) } else { // Log other characteristic updates let hex = data.map { String(format: "%02X", $0) }.joined(separator: " ") DebugLog.shared.log("BLE: Read \(characteristic.uuid): \(hex)") } } else { // Provisioning mode 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 awaiting device info for MAC address, process the response if awaitingDeviceInfoForProvisioning { processDeviceInfoForProvisioning(data) } else if awaitingCommandResponse { // Beacon responded to our command — check for rejection and advance awaitingCommandResponse = false cancelResponseGateTimeout() // Check for rejection: 4E 4F 00 means command rejected (matches Android check) let bytes = [UInt8](data) if bytes.count >= 3 && bytes[0] == 0x4E && bytes[1] == 0x4F && bytes[2] == 0x00 { let isNonFatal = dxSmartWriteIndex < 6 if isNonFatal { DebugLog.shared.log("BLE: Command \(dxSmartWriteIndex + 1) rejected by beacon (non-fatal, continuing)") } else { DebugLog.shared.log("BLE: Command \(dxSmartWriteIndex + 1) REJECTED by beacon") // Don't fail here — let the advance logic handle it like Android does // (Android logs rejection but continues for most commands) } } advanceToNextCommand() } } } } /// Process device info response during provisioning to extract MAC address private func processDeviceInfoForProvisioning(_ data: Data) { responseBuffer.append(contentsOf: data) // Look for complete frame: 4E 4F 30 LEN DATA XOR guard responseBuffer.count >= 5 else { return } // Find header guard let headerIdx = findDXHeader() else { responseBuffer.removeAll() return } if headerIdx > 0 { responseBuffer.removeFirst(headerIdx) } guard responseBuffer.count >= 5 else { return } let cmd = responseBuffer[2] let len = Int(responseBuffer[3]) let frameLen = 4 + len + 1 guard responseBuffer.count >= frameLen else { return } // Check if this is device info response (0x30) if cmd == DXCmd.deviceInfo.rawValue && len >= 7 { // Parse MAC address from bytes 1-6 (byte 0 is battery) let macBytes = Array(responseBuffer[5..<11]) provisioningMacAddress = macBytes.map { String(format: "%02X", $0) }.joined(separator: ":") DebugLog.shared.log("BLE: Got MAC address for provisioning: \(provisioningMacAddress ?? "nil")") } // Clear buffer and proceed to write config responseBuffer.removeAll() awaitingDeviceInfoForProvisioning = false dxSmartWriteConfig() } }