payfrit-beacon-ios/PayfritBeacon/BeaconProvisioner.swift
Schwifty f0d2b2ae90 fix: add timeout for characteristic rediscovery to prevent hang
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.
2026-03-22 03:18:43 +00:00

1635 lines
71 KiB
Swift

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..<frameLen])
responseBuffer.removeFirst(frameLen)
// Verify XOR checksum
var xor: UInt8 = 0
for i in 2..<(frameLen - 1) {
xor ^= frame[i]
}
let frameData = Array(frame[4..<(4 + len)])
if xor == frame[frameLen - 1] {
parseResponseCmd(cmd: cmd, data: frameData)
} else {
let frameHex = frame.map { String(format: "%02X", $0) }.joined(separator: " ")
DebugLog.shared.log("BLE: XOR checksum failed for frame: \(frameHex)")
readResult.rawResponses.append("0x\(String(format: "%02X", cmd)) XOR_FAIL: \(frameHex)")
}
}
}
/// Find 4E 4F header in response buffer
private func findDXHeader() -> 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..<nextIndex])
if let byte = UInt8(byteString, radix: 16) {
data.append(byte)
} else {
return nil
}
index = nextIndex
}
return data
}
private func formatUUID(_ hex: String) -> 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()
}
}