From f0d2b2ae90631427d4ed23181c324c38eaa74999 Mon Sep 17 00:00:00 2001 From: Schwifty Date: Sun, 22 Mar 2026 03:18:43 +0000 Subject: [PATCH 1/3] fix: add timeout for characteristic rediscovery to prevent hang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When FFE2 goes missing during writes, the rediscovery path had no timeout — if CoreBluetooth never called back didDiscoverCharacteristics, the app would hang at "Re-discovering characteristics..." indefinitely. Adds a 5-second timeout per rediscovery attempt. If it fires, it either retries (up to MAX_CHAR_REDISCOVERY) or fails with .timeout instead of hanging forever. --- PayfritBeacon/BeaconProvisioner.swift | 41 +++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/PayfritBeacon/BeaconProvisioner.swift b/PayfritBeacon/BeaconProvisioner.swift index a36b34a..6d5464c 100644 --- a/PayfritBeacon/BeaconProvisioner.swift +++ b/PayfritBeacon/BeaconProvisioner.swift @@ -192,6 +192,8 @@ class BeaconProvisioner: NSObject, ObservableObject { 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 @@ -331,6 +333,7 @@ class BeaconProvisioner: NSObject, ObservableObject { private func cleanup() { cancelWriteTimeout() cancelResponseGateTimeout() + cancelCharRediscoveryTimeout() awaitingCommandResponse = false peripheral = nil config = nil @@ -594,6 +597,7 @@ class BeaconProvisioner: NSObject, ObservableObject { 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, @@ -666,6 +670,42 @@ class BeaconProvisioner: NSObject, ObservableObject { 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. @@ -1368,6 +1408,7 @@ extension BeaconProvisioner: CBPeripheralDelegate { 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 From d123d2561a9f939cd0250a594d8bd125e0001faf Mon Sep 17 00:00:00 2001 From: Schwifty Date: Sun, 22 Mar 2026 03:54:29 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20slim=20provisioning=20=E2=80=94=20sk?= =?UTF-8?q?ip=20extra=20frame=20disables=20+=20full=20reconnect=20on=20FFE?= =?UTF-8?q?2=20miss?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two key changes: 1. Remove frames 3-6 disable commands (was steps 16-23, 8 extra BLE writes). We only configure Frame 1 (device info) and Frame 2 (iBeacon), then save. Fewer writes = fewer chances for supervision timeout disconnects. 2. When FFE2 characteristic goes missing after a disconnect, do a full disconnect → reconnect → re-discover services → re-auth → resume cycle instead of trying to rediscover characteristics on the same (stale GATT) connection. CoreBluetooth returns cached results on the same connection, so FFE2 stays missing. Full reconnect forces a fresh GATT discovery. Write sequence is now 16 steps (down from 24). Co-Authored-By: Claude Opus 4.6 (1M context) --- PayfritBeacon/BeaconProvisioner.swift | 81 ++++++++++++++++----------- 1 file changed, 49 insertions(+), 32 deletions(-) diff --git a/PayfritBeacon/BeaconProvisioner.swift b/PayfritBeacon/BeaconProvisioner.swift index 6d5464c..232f1de 100644 --- a/PayfritBeacon/BeaconProvisioner.swift +++ b/PayfritBeacon/BeaconProvisioner.swift @@ -470,8 +470,8 @@ class BeaconProvisioner: NSObject, ObservableObject { dxSmartWriteConfig() } - /// Build the full command sequence and start writing - /// New 24-step write sequence for DX-Smart CP28: + /// Build the command sequence and start writing + /// Slim write sequence for DX-Smart CP28 (no extra frame overwrites): /// 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) @@ -487,8 +487,11 @@ class BeaconProvisioner: NSObject, ObservableObject { /// 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 + /// 16. SaveConfig 0x60 — persist to flash + /// + /// NOTE: Frames 3-6 are intentionally left untouched. Disabling extra frames + /// caused additional BLE writes that increased disconnect risk. We only write + /// the two frames we care about (device info + iBeacon) and save. private func dxSmartWriteConfig() { guard let config = config else { fail("No config provided", code: .noConfig) @@ -553,21 +556,9 @@ class BeaconProvisioner: NSObject, ObservableObject { // 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: [])) + // Frames 3-6 intentionally left untouched — fewer writes = fewer disconnects - // 24. SaveConfig (0x60) — persist to flash + // 16. SaveConfig (0x60) — persist to flash dxSmartCommandQueue.append(buildDXPacket(cmd: .saveConfig, data: [])) DebugLog.shared.log("BLE: DX-Smart command queue built with \(dxSmartCommandQueue.count) commands") @@ -590,23 +581,43 @@ class BeaconProvisioner: NSObject, ObservableObject { 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 { + // FFE2 missing — the GATT cache is stale after a disconnect. Rediscovering + // characteristics on the same connection doesn't work because CoreBluetooth + // returns cached (stale) results. We need a full disconnect → reconnect → + // re-discover services → re-discover characteristics cycle. + if charRediscoveryCount < BeaconProvisioner.MAX_CHAR_REDISCOVERY { 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) + DebugLog.shared.log("BLE: FFE2 missing — triggering full disconnect/reconnect (attempt \(charRediscoveryCount)/\(BeaconProvisioner.MAX_CHAR_REDISCOVERY))") + progress = "FFE2 missing, reconnecting..." + + // Use the existing disconnect retry path — it handles reconnect + re-auth + resume + resumeWriteAfterDisconnect = true + dxSmartAuthenticated = false + dxSmartNotifySubscribed = false + passwordIndex = 0 + characteristics.removeAll() + responseBuffer.removeAll() + awaitingCommandResponse = false + cancelResponseGateTimeout() + state = .connecting + + // Disconnect — didDisconnectPeripheral won't re-enter because we set state = .connecting + // and handle reconnect manually here + if let peripheral = peripheral, peripheral.state == .connected { + centralManager.cancelPeripheralConnection(peripheral) + } + + 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) } } else { - fail("DX-Smart command characteristic (FFE2) not found after \(charRediscoveryCount) rediscovery attempts", code: .serviceNotFound) + fail("DX-Smart command characteristic (FFE2) not found after \(charRediscoveryCount) reconnect attempts", code: .serviceNotFound) } return } @@ -1227,6 +1238,12 @@ extension BeaconProvisioner: CBCentralManagerDelegate { if state == .success || state == .idle { return } + + // Intentional disconnect for FFE2 reconnect — we're already handling reconnect + if state == .connecting && resumeWriteAfterDisconnect { + DebugLog.shared.log("BLE: Intentional disconnect for FFE2 reconnect, ignoring") + return + } if case .failed = state { DebugLog.shared.log("BLE: Disconnect after failure, ignoring") return From 58facfda47242eb9a01ee9bfd5b65a8dbc5af054 Mon Sep 17 00:00:00 2001 From: Schwifty Date: Sun, 22 Mar 2026 05:07:58 +0000 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20v3=20BeaconProvisioner=20?= =?UTF-8?q?=E2=80=94=20clean=20refactor=20from=20pre-refactor=20baseline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Starting fresh from the working pre-refactor code (d123d25), this is a clean rewrite that preserves all the hard-won BLE reliability fixes while simplifying the architecture. Key changes: What's preserved (battle-tested, working): - Exact same 16-step write sequence (DeviceName, Frame1, Frame2 iBeacon, Save) - Same DX-Smart packet format (4E 4F CMD LEN DATA XOR) - Response gating between writes (1s timeout, matches Android) - Adaptive delays (1s for frame select/type, 0.8s for UUID, 0.5s base) - FFE2 missing → full disconnect/reconnect (CoreBluetooth stale GATT cache) - SaveConfig write-error = success (beacon reboots immediately) - Disconnect recovery with write position resume - Multi-password auth (555555, dx1234, 000000) - Skip device info read (0x30 causes disconnects, MAC is optional) - Skip extra frame disables (frames 3-6 untouched, fewer writes = fewer disconnects) What's cleaned up: - Removed dead device info provisioning code (was already skipped) - Removed processDeviceInfoForProvisioning (dead code) - Removed awaitingDeviceInfoForProvisioning flag - Removed skipDeviceInfoRead flag - Removed deviceInfoRetryCount (no longer needed) - Consolidated charRediscovery into handleFFE2Missing() - Renamed state vars for clarity (dxSmartWriteIndex → writeIndex, etc.) - Extracted scheduleGlobalTimeout (was inline closure) - Added cancelAllTimers() helper - Reduced mutable state from ~30 vars to ~22 1652 lines → 1441 lines (-211 lines, -13%) Co-Authored-By: Claude Opus 4.6 (1M context) --- PayfritBeacon/BeaconProvisioner.swift | 995 ++++++++++---------------- 1 file changed, 392 insertions(+), 603 deletions(-) diff --git a/PayfritBeacon/BeaconProvisioner.swift b/PayfritBeacon/BeaconProvisioner.swift index 232f1de..7215149 100644 --- a/PayfritBeacon/BeaconProvisioner.swift +++ b/PayfritBeacon/BeaconProvisioner.swift @@ -84,22 +84,37 @@ struct BeaconCheckResult { } } -/// Handles GATT connection and provisioning of beacons +// 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: - DX-Smart CP28 GATT Characteristics + // 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 - // 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) @@ -125,6 +140,22 @@ class BeaconProvisioner: NSObject, ObservableObject { 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 = "" @@ -139,95 +170,64 @@ class BeaconProvisioner: NSObject, ObservableObject { case failed(String) } + // MARK: - Core State + private var centralManager: CBCentralManager! private var peripheral: CBPeripheral? - private var beaconType: BeaconType = .unknown 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 writeQueue: [(CBCharacteristic, Data)] = [] + private var authenticated = false - // 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 + // Write queue state + private var commandQueue: [Data] = [] + private var writeIndex = 0 + private var resumeAfterReconnect = false // When true, skip queue rebuild on reconnect - // Read config mode + // 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? - - // 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 + // MARK: - Init 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 + // MARK: - Public: Provision /// Provision a beacon with the given configuration func provision(beacon: DiscoveredBeacon, config: BeaconConfig, completion: @escaping (ProvisioningResult) -> Void) { @@ -236,43 +236,21 @@ class BeaconProvisioner: NSObject, ObservableObject { return } + resetAllState() + 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)..." + DebugLog.shared.log("BLE: Starting provision for \(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) - } - } + scheduleGlobalTimeout() } /// Cancel current provisioning @@ -283,7 +261,7 @@ class BeaconProvisioner: NSObject, ObservableObject { cleanup() } - // MARK: - Read Config + // MARK: - Public: Read Config /// Read the current configuration from a beacon func readConfig(beacon: DiscoveredBeacon, completion: @escaping (BeaconCheckResult?, String?) -> Void) { @@ -292,26 +270,14 @@ class BeaconProvisioner: NSObject, ObservableObject { return } + resetAllState() + 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)..." @@ -328,38 +294,52 @@ class BeaconProvisioner: NSObject, ObservableObject { DispatchQueue.main.asyncAfter(deadline: .now() + 15, execute: timeout) } - // MARK: - Cleanup + // 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() { - cancelWriteTimeout() - cancelResponseGateTimeout() - cancelCharRediscoveryTimeout() - awaitingCommandResponse = false + cancelAllTimers() peripheral = nil config = nil completion = nil + currentBeacon = nil configService = nil characteristics.removeAll() - writeQueue.removeAll() - dxSmartAuthenticated = false - dxSmartNotifySubscribed = false - dxSmartCommandQueue.removeAll() - dxSmartWriteIndex = 0 - provisioningMacAddress = nil - awaitingDeviceInfoForProvisioning = false - skipDeviceInfoRead = false + commandQueue.removeAll() + writeIndex = 0 + passwordIndex = 0 + authenticated = false + resumeAfterReconnect = false + awaitingResponse = false isTerminating = false - resumeWriteAfterDisconnect = false connectionRetryCount = 0 - deviceInfoRetryCount = 0 disconnectRetryCount = 0 + ffe2ReconnectCount = 0 writeRetryCount = 0 - charRediscoveryCount = 0 - currentBeacon = nil + 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") @@ -368,9 +348,7 @@ class BeaconProvisioner: NSObject, ObservableObject { isTerminating = true DebugLog.shared.log("BLE: Failed [\(code?.rawValue ?? "UNTYPED")] - \(message)") state = .failed(message) - if let peripheral = peripheral, peripheral.state == .connected { - centralManager.cancelPeripheralConnection(peripheral) - } + disconnectPeripheral() if let code = code { completion?(.failureWithCode(code, detail: message)) } else { @@ -385,55 +363,54 @@ class BeaconProvisioner: NSObject, ObservableObject { return } isTerminating = true - DebugLog.shared.log("BLE: Success! MAC=\(provisioningMacAddress ?? "unknown")") + DebugLog.shared.log("BLE: Provisioning success!") state = .success - if let peripheral = peripheral, peripheral.state == .connected { - centralManager.cancelPeripheralConnection(peripheral) - } - let mac = provisioningMacAddress - completion?(.success(macAddress: mac)) + disconnectPeripheral() + completion?(.success(macAddress: nil)) cleanup() } - // MARK: - DX-Smart CP28 Provisioning - - private func provisionDXSmart() { - guard let service = configService else { - fail("DX-Smart config service not found", code: .serviceNotFound) - return + private func disconnectPeripheral() { + if let peripheral = peripheral, peripheral.state == .connected { + centralManager.cancelPeripheralConnection(peripheral) } - - 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() { + // 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 DX-Smart FFE1 notifications") + 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") - dxSmartNotifySubscribed = true - dxSmartAuthenticate() + authenticate() } } /// Write password to FFE3 (tries multiple passwords in sequence) - private func dxSmartAuthenticate() { + private func authenticate() { guard let passwordChar = characteristics[BeaconProvisioner.DXSMART_PASSWORD_CHAR] else { - fail("DX-Smart password characteristic (FFE3) not found", code: .serviceNotFound) + fail("FFE3 password characteristic not found", code: .serviceNotFound) return } guard passwordIndex < BeaconProvisioner.DXSMART_PASSWORDS.count else { - fail("Authentication failed - all passwords rejected", code: .authFailed) + if operationMode == .readingConfig { + DebugLog.shared.log("BLE: All passwords exhausted in read mode") + finishRead() + } else { + fail("Authentication failed - all passwords rejected", code: .authFailed) + } return } @@ -446,32 +423,26 @@ class BeaconProvisioner: NSObject, ObservableObject { peripheral?.writeValue(passwordData, for: passwordChar, type: .withResponse) } - /// Called when a password attempt fails — tries the next one - private func dxSmartRetryNextPassword() { + /// 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?.dxSmartAuthenticate() + self?.authenticate() } + } else if operationMode == .readingConfig { + finishRead() } 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() - } + // MARK: - DX-Smart: Write Config - /// Build the command sequence and start writing - /// Slim write sequence for DX-Smart CP28 (no extra frame overwrites): + /// 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) @@ -489,10 +460,8 @@ class BeaconProvisioner: NSObject, ObservableObject { /// 15. TriggerOff 0xA0 /// 16. SaveConfig 0x60 — persist to flash /// - /// NOTE: Frames 3-6 are intentionally left untouched. Disabling extra frames - /// caused additional BLE writes that increased disconnect risk. We only write - /// the two frames we care about (device info + iBeacon) and save. - private func dxSmartWriteConfig() { + /// 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 @@ -501,172 +470,180 @@ class BeaconProvisioner: NSObject, ObservableObject { state = .writing progress = "Writing DX-Smart configuration..." - dxSmartCommandQueue.removeAll() - dxSmartWriteIndex = 0 + commandQueue.removeAll() + writeIndex = 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) + // 1. DeviceName (0x71) 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)) + commandQueue.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 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 --- - // 7. Frame2_Select (0x12) - dxSmartCommandQueue.append(buildDXPacket(cmd: .frameSelectSlot1, data: [])) - // 8. Frame2_Type (0x62) — iBeacon - dxSmartCommandQueue.append(buildDXPacket(cmd: .iBeaconType, data: [])) + commandQueue.append(buildDXPacket(cmd: .frameSelectSlot1, data: [])) // 7. Select + commandQueue.append(buildDXPacket(cmd: .iBeaconType, data: [])) // 8. Type - // 9. UUID (0x74) [16 bytes] - if let uuidData = hexStringToData(config.uuid) { - dxSmartCommandQueue.append(buildDXPacket(cmd: .uuid, data: Array(uuidData))) + if let uuidData = hexStringToData(config.uuid) { // 9. UUID + commandQueue.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])) + commandQueue.append(buildDXPacket(cmd: .major, data: [majorHi, majorLo])) // 10. Major - // 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])) + commandQueue.append(buildDXPacket(cmd: .minor, data: [minorHi, minorLo])) // 11. Minor - // 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: [])) + 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 - // Frames 3-6 intentionally left untouched — fewer writes = fewer disconnects - - // 16. 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() + DebugLog.shared.log("BLE: Command queue built with \(commandQueue.count) commands") + sendNextCommand() } - /// Send the next command in the DX-Smart queue - private func dxSmartSendNextCommand() { - guard dxSmartWriteIndex < dxSmartCommandQueue.count else { + /// Send the next command in the queue + private func sendNextCommand() { + guard writeIndex < commandQueue.count else { cancelWriteTimeout() - DebugLog.shared.log("BLE: All DX-Smart commands written!") + DebugLog.shared.log("BLE: All commands written!") progress = "Configuration saved!" succeed() return } - let packet = dxSmartCommandQueue[dxSmartWriteIndex] - let total = dxSmartCommandQueue.count - let current = dxSmartWriteIndex + 1 + 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 — the GATT cache is stale after a disconnect. Rediscovering - // characteristics on the same connection doesn't work because CoreBluetooth - // returns cached (stale) results. We need a full disconnect → reconnect → - // re-discover services → re-discover characteristics cycle. - if charRediscoveryCount < BeaconProvisioner.MAX_CHAR_REDISCOVERY { - charRediscoveryCount += 1 - DebugLog.shared.log("BLE: FFE2 missing — triggering full disconnect/reconnect (attempt \(charRediscoveryCount)/\(BeaconProvisioner.MAX_CHAR_REDISCOVERY))") - progress = "FFE2 missing, reconnecting..." - - // Use the existing disconnect retry path — it handles reconnect + re-auth + resume - resumeWriteAfterDisconnect = true - dxSmartAuthenticated = false - dxSmartNotifySubscribed = false - passwordIndex = 0 - characteristics.removeAll() - responseBuffer.removeAll() - awaitingCommandResponse = false - cancelResponseGateTimeout() - state = .connecting - - // Disconnect — didDisconnectPeripheral won't re-enter because we set state = .connecting - // and handle reconnect manually here - if let peripheral = peripheral, peripheral.state == .connected { - centralManager.cancelPeripheralConnection(peripheral) - } - - 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) - } - } else { - fail("DX-Smart command characteristic (FFE2) not found after \(charRediscoveryCount) reconnect attempts", code: .serviceNotFound) - } + // FFE2 missing — CoreBluetooth returns cached stale GATT after disconnect. + // Need full disconnect → reconnect → re-discover → re-auth → resume. + handleFFE2Missing() 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). + /// 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.dxSmartWriteIndex + 1 - let total = self.dxSmartCommandQueue.count - let isNonFatal = self.dxSmartWriteIndex < 6 - let isSaveConfig = self.dxSmartWriteIndex >= self.dxSmartCommandQueue.count - 1 + 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 may not get a callback — beacon reboots. Treat as success. - DebugLog.shared.log("BLE: SaveConfig write timeout (beacon likely rebooted) — treating as success") + // 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 the write once + // Retry once self.writeRetryCount += 1 - DebugLog.shared.log("BLE: Write timeout for command \(current)/\(total) — retrying (\(self.writeRetryCount)/\(BeaconProvisioner.MAX_WRITE_RETRIES))") + 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.dxSmartCommandQueue[self.dxSmartWriteIndex] + let packet = self.commandQueue[self.writeIndex] self.scheduleWriteTimeout() self.peripheral?.writeValue(packet, for: commandChar, type: .withResponse) } } else if isNonFatal { - // Non-fatal commands (first 6) — skip and continue + // Non-fatal: skip DebugLog.shared.log("BLE: Write timeout for non-fatal command \(current)/\(total) — skipping") - self.dxSmartWriteIndex += 1 + self.writeIndex += 1 DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - self?.dxSmartSendNextCommand() + self?.sendNextCommand() } } else { - // Fatal command timed out after retry — fail + // Fatal timeout DebugLog.shared.log("BLE: Write timeout for critical command \(current)/\(total) — failing") self.fail("Write timeout at step \(current)/\(total)", code: .writeFailed) } @@ -675,127 +652,73 @@ class BeaconProvisioner: NSObject, ObservableObject { 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() + // MARK: - Global Timeout + + private func scheduleGlobalTimeout() { + cancelGlobalTimeout() 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) + if self.state != .success && self.state != .idle { + self.fail("Provisioning timeout after \(Int(BeaconProvisioner.GLOBAL_TIMEOUT))s", code: .connectionTimeout) } } - charRediscoveryTimer = timer - DispatchQueue.main.asyncAfter(deadline: .now() + BeaconProvisioner.CHAR_REDISCOVERY_TIMEOUT, execute: timer) + globalTimeoutTimer = timer + DispatchQueue.main.asyncAfter(deadline: .now() + BeaconProvisioner.GLOBAL_TIMEOUT, execute: timer) } - /// Cancel any pending characteristic rediscovery timeout - private func cancelCharRediscoveryTimeout() { - charRediscoveryTimer?.cancel() - charRediscoveryTimer = nil + private func cancelGlobalTimeout() { + globalTimeoutTimer?.cancel() + globalTimeoutTimer = 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 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 < dxSmartCommandQueue.count else { return BeaconProvisioner.BASE_WRITE_DELAY } + guard index < commandQueue.count else { return BeaconProvisioner.BASE_WRITE_DELAY } - let packet = dxSmartCommandQueue[index] + let packet = commandQueue[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) + let cmd = packet[2] // Command byte 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(contentsOf: BeaconProvisioner.DXSMART_HEADER) 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 @@ -807,7 +730,6 @@ class BeaconProvisioner: NSObject, ObservableObject { // 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") @@ -829,7 +751,6 @@ class BeaconProvisioner: NSObject, ObservableObject { 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 @@ -843,11 +764,9 @@ class BeaconProvisioner: NSObject, ObservableObject { // 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() @@ -856,18 +775,16 @@ class BeaconProvisioner: NSObject, ObservableObject { // Subscribe to FFE1 for responses if let notifyChar = characteristics[BeaconProvisioner.DXSMART_NOTIFY_CHAR] { - DebugLog.shared.log("BLE: Read mode — subscribing to FFE1 notifications") + DebugLog.shared.log("BLE: Read mode — subscribing to FFE1") 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() + readAuth() } } - /// Authenticate on FFE3 for read mode (uses same multi-password fallback) - private func dxSmartReadAuth() { + private func readAuth() { guard let passwordChar = characteristics[BeaconProvisioner.DXSMART_PASSWORD_CHAR] else { DebugLog.shared.log("BLE: No FFE3 for auth, finishing") finishRead() @@ -885,29 +802,27 @@ class BeaconProvisioner: NSObject, ObservableObject { 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") + DebugLog.shared.log("BLE: Read mode — writing password \(passwordIndex + 1) to FFE3") peripheral?.writeValue(passwordData, for: passwordChar, type: .withResponse) } - /// After auth, send read query commands - private func dxSmartReadQueryAfterAuth() { + private func readQueryAfterAuth() { 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 + 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) DX-Smart read queries") + DebugLog.shared.log("BLE: Sending \(dxReadQueries.count) read queries") state = .verifying progress = "Reading config..." - dxSmartSendNextReadQuery() + sendNextReadQuery() } - private func dxSmartSendNextReadQuery() { + private func sendNextReadQuery() { guard dxReadQueryIndex < dxReadQueries.count else { DebugLog.shared.log("BLE: All read queries sent, waiting 2s for final responses") progress = "Collecting responses..." @@ -934,22 +849,18 @@ class BeaconProvisioner: NSObject, ObservableObject { // 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 + while responseBuffer.count >= 5 { guard let headerIdx = findDXHeader() else { responseBuffer.removeAll() break } - // Discard bytes before header if headerIdx > 0 { responseBuffer.removeFirst(headerIdx) } @@ -958,14 +869,10 @@ class BeaconProvisioner: NSObject, ObservableObject { let cmd = responseBuffer[2] let len = Int(responseBuffer[3]) - let frameLen = 4 + len + 1 // header(2) + cmd(1) + len(1) + data(len) + xor(1) + let frameLen = 4 + len + 1 - guard responseBuffer.count >= frameLen else { - // Incomplete frame — wait for more data - break - } + guard responseBuffer.count >= frameLen else { break } - // Extract frame let frame = Array(responseBuffer[0.. Int? { guard responseBuffer.count >= 2 else { return nil } for i in 0..<(responseBuffer.count - 1) { @@ -998,7 +904,6 @@ class BeaconProvisioner: NSObject, ObservableObject { 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)]") @@ -1006,55 +911,43 @@ class BeaconProvisioner: NSObject, ObservableObject { switch DXCmd(rawValue: cmd) { - case .frameTable: // 0x10: Frame assignment table (one byte per slot) + case .frameTable: readResult.frameSlots = data DebugLog.shared.log("BLE: Frame slots: \(data.map { String(format: "0x%02X", $0) })") - case .iBeaconType: // 0x62: iBeacon config data + case .iBeaconType: 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) + case .deviceInfo: if data.count >= 1 { readResult.battery = data[0] } @@ -1064,11 +957,11 @@ class BeaconProvisioner: NSObject, ObservableObject { } DebugLog.shared.log("BLE: Device info — battery=\(readResult.battery ?? 0)% MAC=\(readResult.macAddress ?? "?")") - case .deviceName: // 0x43: Device name + case .deviceName: readResult.deviceName = String(bytes: data, encoding: .utf8)?.trimmingCharacters(in: .controlCharacters) DebugLog.shared.log("BLE: Device name = \(readResult.deviceName ?? "?")") - case .authCheck: // 0x25: Auth check response + case .authCheck: if data.count >= 1 { let authRequired = data[0] != 0x00 DebugLog.shared.log("BLE: Auth required: \(authRequired)") @@ -1085,9 +978,7 @@ class BeaconProvisioner: NSObject, ObservableObject { readTimeout?.cancel() readTimeout = nil - if let peripheral = peripheral { - centralManager.cancelPeripheralConnection(peripheral) - } + disconnectPeripheral() let result = readResult state = .success @@ -1101,9 +992,7 @@ class BeaconProvisioner: NSObject, ObservableObject { readTimeout?.cancel() readTimeout = nil - if let peripheral = peripheral { - centralManager.cancelPeripheralConnection(peripheral) - } + disconnectPeripheral() state = .failed(message) readCompletion?(nil, message) cleanupRead() @@ -1122,7 +1011,6 @@ class BeaconProvisioner: NSObject, ObservableObject { configService = nil characteristics.removeAll() connectionRetryCount = 0 - deviceInfoRetryCount = 0 disconnectRetryCount = 0 currentBeacon = nil operationMode = .provisioning @@ -1171,10 +1059,8 @@ extension BeaconProvisioner: CBCentralManagerDelegate { 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") + 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)") } @@ -1183,7 +1069,7 @@ extension BeaconProvisioner: CBCentralManagerDelegate { progress = "Discovering services..." if operationMode == .readingConfig { - peripheral.discoverServices(nil) // Discover all for exploration + peripheral.discoverServices(nil) } else { peripheral.discoverServices([BeaconProvisioner.DXSMART_SERVICE]) } @@ -1193,17 +1079,15 @@ extension BeaconProvisioner: CBCentralManagerDelegate { 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 + let delay = Double(connectionRetryCount) progress = "Connection failed, retrying (\(connectionRetryCount)/\(BeaconProvisioner.MAX_CONNECTION_RETRIES))..." - DebugLog.shared.log("BLE: Retrying connection in \(delay)s...") + 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 } // Don't retry if cancelled - + guard self.state == .connecting else { return } let resolvedPeripheral = self.resolvePeripheral(beacon) self.peripheral = resolvedPeripheral self.centralManager.connect(resolvedPeripheral, options: nil) @@ -1219,14 +1103,15 @@ extension BeaconProvisioner: CBCentralManagerDelegate { } 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")") + DebugLog.shared.log("BLE: Disconnected | state=\(state) mode=\(operationMode) writeIdx=\(writeIndex)/\(commandQueue.count) terminating=\(isTerminating) error=\(error?.localizedDescription ?? "none")") - // If we already called succeed() or fail(), this disconnect is expected cleanup — ignore it + // 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() @@ -1234,92 +1119,58 @@ extension BeaconProvisioner: CBCentralManagerDelegate { return } - // Already in a terminal state — nothing to do + // Terminal states if state == .success || state == .idle { return } - - // Intentional disconnect for FFE2 reconnect — we're already handling reconnect - if state == .connecting && resumeWriteAfterDisconnect { - DebugLog.shared.log("BLE: Intentional disconnect for FFE2 reconnect, ignoring") - 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") + // 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 } - // 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 + // Cancel pending timers cancelWriteTimeout() cancelResponseGateTimeout() - awaitingCommandResponse = false + awaitingResponse = false - // Unexpected disconnect during any active provisioning phase — retry with full reconnect + // 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 && !dxSmartCommandQueue.isEmpty) - DebugLog.shared.log("BLE: Disconnect during \(state) — reconnecting (attempt \(disconnectRetryCount)/\(BeaconProvisioner.MAX_DISCONNECT_RETRIES)) wasWriting=\(wasWriting) writeIdx=\(dxSmartWriteIndex)") + 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-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 + // Reset connection state, preserve write position if writing + authenticated = 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") + resumeAfterReconnect = true + DebugLog.shared.log("BLE: Will resume from command \(writeIndex + 1)/\(commandQueue.count) after reconnect") } else { - // Full reset for non-writing phases - dxSmartCommandQueue.removeAll() - dxSmartWriteIndex = 0 - resumeWriteAfterDisconnect = false + commandQueue.removeAll() + writeIndex = 0 + resumeAfterReconnect = false } state = .connecting - let delay = Double(disconnectRetryCount) + 2.0 // 3s, 4s, 5s, 6s, 7s backoff — give BLE time to settle + 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 } @@ -1331,8 +1182,8 @@ extension BeaconProvisioner: CBCentralManagerDelegate { 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)") + // 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) } } @@ -1374,7 +1225,13 @@ extension BeaconProvisioner: CBPeripheralDelegate { for service in services { if service.uuid == BeaconProvisioner.DXSMART_SERVICE { configService = service - provisionDXSmart() + state = .discoveringServices + progress = "Discovering characteristics..." + peripheral.discoverCharacteristics([ + BeaconProvisioner.DXSMART_NOTIFY_CHAR, + BeaconProvisioner.DXSMART_COMMAND_CHAR, + BeaconProvisioner.DXSMART_PASSWORD_CHAR + ], for: service) return } } @@ -1384,7 +1241,6 @@ extension BeaconProvisioner: CBPeripheralDelegate { 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 { @@ -1421,74 +1277,62 @@ extension BeaconProvisioner: CBPeripheralDelegate { } 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() + // Provisioning: start auth flow + startAuth() } } func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { - cancelWriteTimeout() // Write callback received — cancel the per-write timeout + 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 { - // 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() + retryNextPassword() } else if operationMode == .readingConfig { readFail("Authentication failed - all passwords rejected") } else { - fail("Authentication failed - all \(BeaconProvisioner.DXSMART_PASSWORDS.count) passwords rejected", code: .authFailed) + 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?.dxSmartSendNextReadQuery() + self?.sendNextReadQuery() } } 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 + let isNonFatal = writeIndex < 6 + let isSaveConfig = writeIndex >= commandQueue.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. + // SaveConfig write error = beacon rebooted = success 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 + } 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?.dxSmartSendNextCommand() + self?.sendNextCommand() } } else { - fail("Command write failed at step \(dxSmartWriteIndex + 1)/\(dxSmartCommandQueue.count): \(error.localizedDescription)", code: .writeFailed) + 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: \(error.localizedDescription)") + DebugLog.shared.log("BLE: Write failed in read mode, ignoring") return } fail("Write failed: \(error.localizedDescription)", code: .writeFailed) @@ -1500,42 +1344,37 @@ extension BeaconProvisioner: CBPeripheralDelegate { // Password auth succeeded if characteristic.uuid == BeaconProvisioner.DXSMART_PASSWORD_CHAR { DebugLog.shared.log("BLE: Authenticated!") - dxSmartAuthenticated = true + authenticated = true if operationMode == .readingConfig { - dxSmartReadQueryAfterAuth() - } else if resumeWriteAfterDisconnect { - // Reconnected after disconnect during writing — resume from saved position - resumeWriteAfterDisconnect = false + readQueryAfterAuth() + } else if resumeAfterReconnect { + // Resume writing from saved position + resumeAfterReconnect = false state = .writing - DebugLog.shared.log("BLE: Resuming write from command \(dxSmartWriteIndex + 1)/\(dxSmartCommandQueue.count) after reconnect") + DebugLog.shared.log("BLE: Resuming write from command \(writeIndex + 1)/\(commandQueue.count)") progress = "Resuming config write..." - // Longer delay after reconnect — give the beacon's BLE stack time to stabilize - // before resuming writes (prevents immediate re-disconnect) + // 1.5s delay after reconnect for BLE stability DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in - self?.dxSmartSendNextCommand() + self?.sendNextCommand() } } else { - // Read device info first to get MAC address, then write config - dxSmartReadDeviceInfoBeforeWrite() + // Fresh provisioning: build queue and start writing + DebugLog.shared.log("BLE: Auth complete, proceeding to config write") + buildAndStartWriting() } return } - // Command write succeeded → wait for beacon response before sending next - // (matches Android: writeCharacteristic → responseChannel.receive(1000ms) → delay → next) + // 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?.dxSmartSendNextReadQuery() + self?.sendNextReadQuery() } - } 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 + // Gate on FFE1 response before next command + awaitingResponse = true scheduleResponseGateTimeout() } return @@ -1550,13 +1389,10 @@ extension BeaconProvisioner: CBPeripheralDelegate { } if characteristic.uuid == BeaconProvisioner.DXSMART_NOTIFY_CHAR { - dxSmartNotifySubscribed = true if operationMode == .readingConfig { - // After subscribing FFE1 in read mode → authenticate - dxSmartReadAuth() + readAuth() } else { - // Provisioning mode → authenticate - dxSmartAuthenticate() + authenticate() } } } @@ -1571,37 +1407,29 @@ extension BeaconProvisioner: CBPeripheralDelegate { 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 + // 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 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 + if awaitingResponse { + awaitingResponse = false cancelResponseGateTimeout() - // Check for rejection: 4E 4F 00 means command rejected (matches Android check) + // 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 = dxSmartWriteIndex < 6 + let isNonFatal = writeIndex < 6 if isNonFatal { - DebugLog.shared.log("BLE: Command \(dxSmartWriteIndex + 1) rejected by beacon (non-fatal, continuing)") + DebugLog.shared.log("BLE: Command \(writeIndex + 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) + DebugLog.shared.log("BLE: Command \(writeIndex + 1) REJECTED by beacon") } } @@ -1610,43 +1438,4 @@ extension BeaconProvisioner: CBPeripheralDelegate { } } } - - /// 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() - } }