The device info disconnect handler was sharing connectionRetryCount with the initial connection retry logic. If earlier connection attempts burned through retries, the device info handler had zero retries left and immediately hit "retries exhausted" — causing the "Disconnected while reading device information" error John reported. Now uses a separate deviceInfoRetryCount (max 2) so device info retries are independent of connection retries. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1348 lines
55 KiB
Swift
1348 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 var deviceInfoRetryCount = 0
|
|
private static let MAX_CONNECTION_RETRIES = 3
|
|
private static let MAX_DEVICE_INFO_RETRIES = 2
|
|
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.deviceInfoRetryCount = 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.deviceInfoRetryCount = 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
|
|
deviceInfoRetryCount = 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
|
|
deviceInfoRetryCount = 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 deviceInfoRetryCount < BeaconProvisioner.MAX_DEVICE_INFO_RETRIES {
|
|
deviceInfoRetryCount += 1
|
|
let delay = Double(deviceInfoRetryCount)
|
|
progress = "Reconnecting (skip MAC read)..."
|
|
DebugLog.shared.log("BLE: Disconnect during device info read — reconnecting (\(deviceInfoRetryCount)/\(BeaconProvisioner.MAX_DEVICE_INFO_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 device info retries exhausted (\(deviceInfoRetryCount)/\(BeaconProvisioner.MAX_DEVICE_INFO_RETRIES))")
|
|
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()
|
|
}
|
|
}
|