payfrit-beacon-ios/PayfritBeacon/BeaconProvisioner.swift
Schwifty 3d56a1e31d fix: auto-reconnect on disconnect during device info read instead of failing
The beacon sometimes drops BLE connection during the optional MAC address
query (0x30) after auth. Previously this failed with "Disconnected after
auth during device info read". Now we reconnect and skip the MAC read on
retry, going straight to config write. MAC is nice-to-have, not required.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 23:32:14 +00:00

1342 lines
55 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
// 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 static let MAX_CONNECTION_RETRIES = 3
private var currentBeacon: DiscoveredBeacon?
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.connectionRetryCount = 0
self.currentBeacon = beacon
state = .connecting
progress = "Connecting to \(beacon.displayName)..."
centralManager.connect(resolvedPeripheral, options: nil)
// Timeout after 30 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 30) { [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.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() {
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
connectionRetryCount = 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
private func dxSmartReadDeviceInfoBeforeWrite() {
// If we previously disconnected during device info read, skip it entirely
if skipDeviceInfoRead {
DebugLog.shared.log("BLE: Skipping device info read (reconnect after previous disconnect)")
skipDeviceInfoRead = false
dxSmartWriteConfig()
return
}
guard let commandChar = characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] else {
DebugLog.shared.log("BLE: FFE2 not found, proceeding without MAC")
dxSmartWriteConfig()
return
}
progress = "Reading device info..."
awaitingDeviceInfoForProvisioning = true
responseBuffer.removeAll()
// Send device info query (0x30)
let packet = buildDXPacket(cmd: .deviceInfo, data: [])
DebugLog.shared.log("BLE: Sending device info query to get MAC address")
peripheral?.writeValue(packet, for: commandChar, type: .withResponse)
// Timeout after 3 seconds - proceed with write even if no MAC
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { [weak self] in
guard let self = self, self.awaitingDeviceInfoForProvisioning else { return }
DebugLog.shared.log("BLE: Device info timeout, proceeding without MAC")
self.awaitingDeviceInfoForProvisioning = false
self.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 {
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 {
fail("DX-Smart command characteristic (FFE2) not found", code: .serviceNotFound)
return
}
DebugLog.shared.log("BLE: Writing command \(current)/\(total): \(packet.map { String(format: "%02X", $0) }.joined(separator: " "))")
peripheral?.writeValue(packet, for: commandChar, type: .withResponse)
}
// 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
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
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
}
// Disconnect during device info read (post-auth, pre-write) beacon dropped
// connection during the optional MAC address query. Instead of failing, reconnect
// and skip the device info step (MAC is nice-to-have, not required).
if state == .authenticating && awaitingDeviceInfoForProvisioning && dxSmartAuthenticated {
awaitingDeviceInfoForProvisioning = false
skipDeviceInfoRead = true // on reconnect, go straight to config write
if connectionRetryCount < BeaconProvisioner.MAX_CONNECTION_RETRIES {
connectionRetryCount += 1
let delay = Double(connectionRetryCount)
progress = "Reconnecting (skip MAC read)..."
DebugLog.shared.log("BLE: Disconnect during device info read — reconnecting (\(connectionRetryCount)/\(BeaconProvisioner.MAX_CONNECTION_RETRIES)), will skip MAC read")
// Reset BLE state for reconnect
dxSmartAuthenticated = false
dxSmartNotifySubscribed = false
dxSmartCommandQueue.removeAll()
dxSmartWriteIndex = 0
characteristics.removeAll()
responseBuffer.removeAll()
state = .connecting
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
} else {
DebugLog.shared.log("BLE: Disconnect during device info read — max retries exhausted")
fail("Disconnected while reading device information (retries exhausted)", code: .disconnected)
return
}
}
// All other disconnects are unexpected
DebugLog.shared.log("BLE: UNEXPECTED disconnect — state=\(state) writeIdx=\(dxSmartWriteIndex) queueCount=\(dxSmartCommandQueue.count) authenticated=\(dxSmartAuthenticated)")
fail("Unexpected disconnect (state: \(state))", 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 {
// Provisioning: DX-Smart auth flow
dxSmartStartAuth()
}
}
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
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.2) { [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 {
// Read device info first to get MAC address, then write config
dxSmartReadDeviceInfoBeforeWrite()
}
return
}
// Command write succeeded send 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 {
dxSmartWriteIndex += 1
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
self?.dxSmartSendNextCommand()
}
}
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)
}
}
}
}
/// 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()
}
}