Root cause: iOS was firing the next GATT command as soon as the BLE write ACK arrived, without waiting for the beacon's FFE1 notification response. Android explicitly waits up to 1s for the beacon to respond (via responseChannel.receive) before sending the next command. This gives the beacon MCU time to process each command before the next one arrives. Without this gate, the beacon gets overwhelmed and drops the BLE connection (supervision timeout), causing the "DX Smart command characteristic" error John reported after repeated disconnects. Changes: - Add awaitingCommandResponse flag + 1s response gate timer - After each FFE2 write success, wait for FFE1 notification before advancing - If no response within 1s, advance anyway (some commands don't respond) - Check for 4E 4F 00 rejection pattern (matches Android) - Clean up gate timer on disconnect, cleanup, and state resets Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1594 lines
69 KiB
Swift
1594 lines
69 KiB
Swift
import Foundation
|
|
import CoreBluetooth
|
|
|
|
/// Structured provisioning error codes (matches Android's BeaconConfig error codes)
|
|
enum ProvisioningError: String, LocalizedError {
|
|
case bluetoothUnavailable = "BLUETOOTH_UNAVAILABLE"
|
|
case connectionFailed = "CONNECTION_FAILED"
|
|
case connectionTimeout = "CONNECTION_TIMEOUT"
|
|
case serviceNotFound = "SERVICE_NOT_FOUND"
|
|
case authFailed = "AUTH_FAILED"
|
|
case writeFailed = "WRITE_FAILED"
|
|
case verificationFailed = "VERIFICATION_FAILED"
|
|
case disconnected = "DISCONNECTED"
|
|
case noConfig = "NO_CONFIG"
|
|
case timeout = "TIMEOUT"
|
|
case unknown = "UNKNOWN"
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .bluetoothUnavailable: return "Bluetooth not available"
|
|
case .connectionFailed: return "Failed to connect to beacon"
|
|
case .connectionTimeout: return "Connection timed out"
|
|
case .serviceNotFound: return "Config service not found on device"
|
|
case .authFailed: return "Authentication failed - all passwords rejected"
|
|
case .writeFailed: return "Failed to write configuration"
|
|
case .verificationFailed: return "Beacon not broadcasting expected values"
|
|
case .disconnected: return "Unexpected disconnect"
|
|
case .noConfig: return "No configuration provided"
|
|
case .timeout: return "Operation timed out"
|
|
case .unknown: return "Unknown error"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Result of a provisioning operation
|
|
enum ProvisioningResult {
|
|
case success(macAddress: String?)
|
|
case failure(String)
|
|
case failureWithCode(ProvisioningError, detail: String? = nil)
|
|
}
|
|
|
|
/// Configuration to write to a beacon
|
|
struct BeaconConfig {
|
|
let uuid: String // 32-char hex, no dashes
|
|
let major: UInt16
|
|
let minor: UInt16
|
|
let measuredPower: Int8 // RSSI@1m (e.g., -59) - from server, NOT hardcoded
|
|
let advInterval: UInt8 // Advertising interval raw value (e.g., 2 = 200ms) - from server
|
|
let txPower: UInt8 // TX power level - from server
|
|
let deviceName: String? // Service point name (max 20 ASCII chars for DX-Smart)
|
|
|
|
init(uuid: String, major: UInt16, minor: UInt16, measuredPower: Int8, advInterval: UInt8, txPower: UInt8, deviceName: String? = nil) {
|
|
self.uuid = uuid
|
|
self.major = major
|
|
self.minor = minor
|
|
self.measuredPower = measuredPower
|
|
self.advInterval = advInterval
|
|
self.txPower = txPower
|
|
self.deviceName = deviceName
|
|
}
|
|
}
|
|
|
|
/// Result of reading a beacon's current configuration
|
|
struct BeaconCheckResult {
|
|
// Parsed DX-Smart iBeacon config
|
|
var uuid: String? // iBeacon UUID (formatted with dashes)
|
|
var major: UInt16?
|
|
var minor: UInt16?
|
|
var rssiAt1m: Int8?
|
|
var advInterval: UInt16? // Raw value (multiply by 100 for ms)
|
|
var txPower: UInt8?
|
|
var deviceName: String?
|
|
var battery: UInt8?
|
|
var macAddress: String?
|
|
var frameSlots: [UInt8]?
|
|
|
|
// Discovery info
|
|
var servicesFound: [String] = []
|
|
var characteristicsFound: [String] = []
|
|
var rawResponses: [String] = [] // Raw response hex for debugging
|
|
|
|
var hasConfig: Bool {
|
|
uuid != nil || major != nil || minor != nil || deviceName != nil
|
|
}
|
|
}
|
|
|
|
/// Handles GATT connection and provisioning of beacons
|
|
class BeaconProvisioner: NSObject, ObservableObject {
|
|
|
|
// MARK: - DX-Smart CP28 GATT Characteristics
|
|
private static let DXSMART_SERVICE = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB")
|
|
private static let DXSMART_NOTIFY_CHAR = CBUUID(string: "0000FFE1-0000-1000-8000-00805F9B34FB") // Notifications (RX)
|
|
private static let DXSMART_COMMAND_CHAR = CBUUID(string: "0000FFE2-0000-1000-8000-00805F9B34FB") // Commands (TX)
|
|
private static let DXSMART_PASSWORD_CHAR = CBUUID(string: "0000FFE3-0000-1000-8000-00805F9B34FB") // Password auth
|
|
|
|
// DX-Smart packet header
|
|
private static let DXSMART_HEADER: [UInt8] = [0x4E, 0x4F]
|
|
|
|
// DX-Smart connection passwords (tried in order until one works)
|
|
private static let DXSMART_PASSWORDS = ["555555", "dx1234", "000000"]
|
|
|
|
// DX-Smart command codes
|
|
private enum DXCmd: UInt8 {
|
|
case frameTable = 0x10
|
|
case frameSelectSlot0 = 0x11 // Frame 1 (device info)
|
|
case frameSelectSlot1 = 0x12 // Frame 2 (iBeacon)
|
|
case frameSelectSlot2 = 0x13 // Frame 3
|
|
case frameSelectSlot3 = 0x14 // Frame 4
|
|
case frameSelectSlot4 = 0x15 // Frame 5
|
|
case frameSelectSlot5 = 0x16 // Frame 6
|
|
case authCheck = 0x25
|
|
case deviceInfo = 0x30
|
|
case deviceName = 0x43 // Read device name
|
|
case saveConfig = 0x60
|
|
case deviceInfoType = 0x61 // Set frame as device info (broadcasts name)
|
|
case iBeaconType = 0x62 // Set frame as iBeacon
|
|
case deviceNameWrite = 0x71 // Write device name (max 20 ASCII chars)
|
|
case uuid = 0x74
|
|
case major = 0x75
|
|
case minor = 0x76
|
|
case rssiAt1m = 0x77
|
|
case advInterval = 0x78
|
|
case txPower = 0x79
|
|
case triggerOff = 0xA0
|
|
case frameDisable = 0xFF
|
|
}
|
|
|
|
@Published var state: ProvisioningState = .idle
|
|
@Published var progress: String = ""
|
|
|
|
enum ProvisioningState: Equatable {
|
|
case idle
|
|
case connecting
|
|
case discoveringServices
|
|
case authenticating
|
|
case writing
|
|
case verifying
|
|
case success
|
|
case failed(String)
|
|
}
|
|
|
|
private var centralManager: CBCentralManager!
|
|
private var peripheral: CBPeripheral?
|
|
private var beaconType: BeaconType = .unknown
|
|
private var config: BeaconConfig?
|
|
private var completion: ((ProvisioningResult) -> Void)?
|
|
|
|
private var configService: CBService?
|
|
private var characteristics: [CBUUID: CBCharacteristic] = [:]
|
|
private var passwordIndex = 0
|
|
private var writeQueue: [(CBCharacteristic, Data)] = []
|
|
|
|
// DX-Smart provisioning state
|
|
private var dxSmartAuthenticated = false
|
|
private var dxSmartNotifySubscribed = false
|
|
private var dxSmartCommandQueue: [Data] = []
|
|
private var dxSmartWriteIndex = 0
|
|
private var provisioningMacAddress: String?
|
|
private var awaitingDeviceInfoForProvisioning = false
|
|
private var skipDeviceInfoRead = false // set after disconnect during device info — skip MAC read on reconnect
|
|
private var isTerminating = false // guards against re-entrant disconnect handling
|
|
private var resumeWriteAfterDisconnect = false // when true, skip queue rebuild on reconnect and resume from saved index
|
|
|
|
// Read config mode
|
|
private enum OperationMode { case provisioning, readingConfig }
|
|
private var operationMode: OperationMode = .provisioning
|
|
private var readCompletion: ((BeaconCheckResult?, String?) -> Void)?
|
|
private var readResult = BeaconCheckResult()
|
|
private var readTimeout: DispatchWorkItem?
|
|
|
|
// Read config exploration state
|
|
private var allDiscoveredServices: [CBService] = []
|
|
private var servicesToExplore: [CBService] = []
|
|
|
|
// DX-Smart read query state
|
|
private var dxReadQueries: [Data] = []
|
|
private var dxReadQueryIndex = 0
|
|
private var responseBuffer: [UInt8] = []
|
|
|
|
// Connection retry state
|
|
private var connectionRetryCount = 0
|
|
private var deviceInfoRetryCount = 0
|
|
private var disconnectRetryCount = 0
|
|
private static let MAX_CONNECTION_RETRIES = 3
|
|
private static let MAX_DEVICE_INFO_RETRIES = 2
|
|
private static let MAX_DISCONNECT_RETRIES = 5
|
|
private var currentBeacon: DiscoveredBeacon?
|
|
|
|
// Per-write timeout (matches Android's 5-second per-write timeout)
|
|
// If a write callback doesn't come back in time, we retry or fail gracefully
|
|
// instead of hanging until the 30s global timeout
|
|
private var writeTimeoutTimer: DispatchWorkItem?
|
|
private var charRediscoveryCount = 0
|
|
private static let MAX_CHAR_REDISCOVERY = 2
|
|
private static let WRITE_TIMEOUT_SECONDS: Double = 5.0
|
|
private var writeRetryCount = 0
|
|
private static let MAX_WRITE_RETRIES = 1 // Retry once per command before failing
|
|
|
|
// Response gating — wait for beacon's FFE1 notification response after each
|
|
// write before sending the next command. Android does this with a 1000ms
|
|
// responseChannel.receive() after every FFE2 write. Without this gate, iOS
|
|
// hammers the beacon's MCU faster than it can process, causing supervision
|
|
// timeout disconnects.
|
|
private var awaitingCommandResponse = false
|
|
private var responseGateTimer: DispatchWorkItem?
|
|
private static let RESPONSE_GATE_TIMEOUT: Double = 1.0 // 1s matches Android's withTimeoutOrNull(1000L)
|
|
|
|
// Adaptive inter-command delays to prevent BLE supervision timeouts.
|
|
// DX-Smart CP28 beacons have tiny MCU buffers and share radio time between
|
|
// advertising and GATT — rapid writes cause the beacon to miss connection
|
|
// events, triggering link-layer supervision timeouts (the "unexpected disconnect"
|
|
// that was happening 4x during provisioning).
|
|
private static let BASE_WRITE_DELAY: Double = 0.5 // Default delay between commands (was 0.3)
|
|
private static let HEAVY_WRITE_DELAY: Double = 1.0 // After frame select/type commands (MCU state change)
|
|
private static let LARGE_PAYLOAD_DELAY: Double = 0.8 // After UUID/large payload writes
|
|
|
|
override init() {
|
|
super.init()
|
|
centralManager = CBCentralManager(delegate: self, queue: .main)
|
|
}
|
|
|
|
/// Re-retrieve peripheral from our own CBCentralManager (the one from scanner may not work)
|
|
private func resolvePeripheral(_ beacon: DiscoveredBeacon) -> CBPeripheral {
|
|
let retrieved = centralManager.retrievePeripherals(withIdentifiers: [beacon.peripheral.identifier])
|
|
return retrieved.first ?? beacon.peripheral
|
|
}
|
|
|
|
// MARK: - Provision
|
|
|
|
/// Provision a beacon with the given configuration
|
|
func provision(beacon: DiscoveredBeacon, config: BeaconConfig, completion: @escaping (ProvisioningResult) -> Void) {
|
|
guard centralManager.state == .poweredOn else {
|
|
completion(.failureWithCode(.bluetoothUnavailable))
|
|
return
|
|
}
|
|
|
|
let resolvedPeripheral = resolvePeripheral(beacon)
|
|
self.peripheral = resolvedPeripheral
|
|
self.beaconType = beacon.type
|
|
self.config = config
|
|
self.completion = completion
|
|
self.operationMode = .provisioning
|
|
self.passwordIndex = 0
|
|
self.characteristics.removeAll()
|
|
self.writeQueue.removeAll()
|
|
self.dxSmartAuthenticated = false
|
|
self.dxSmartNotifySubscribed = false
|
|
self.dxSmartCommandQueue.removeAll()
|
|
self.dxSmartWriteIndex = 0
|
|
self.provisioningMacAddress = nil
|
|
self.awaitingDeviceInfoForProvisioning = false
|
|
self.skipDeviceInfoRead = false
|
|
self.isTerminating = false
|
|
self.resumeWriteAfterDisconnect = false
|
|
self.awaitingCommandResponse = false
|
|
cancelResponseGateTimeout()
|
|
self.connectionRetryCount = 0
|
|
self.deviceInfoRetryCount = 0
|
|
self.disconnectRetryCount = 0
|
|
self.writeRetryCount = 0
|
|
self.currentBeacon = beacon
|
|
|
|
state = .connecting
|
|
progress = "Connecting to \(beacon.displayName)..."
|
|
|
|
centralManager.connect(resolvedPeripheral, options: nil)
|
|
|
|
// Timeout after 90 seconds (increased to accommodate 5 disconnect retries with resume)
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 90) { [weak self] in
|
|
if self?.state != .success && self?.state != .idle {
|
|
self?.fail("Connection timeout", code: .connectionTimeout)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Cancel current provisioning
|
|
func cancel() {
|
|
if let peripheral = peripheral {
|
|
centralManager.cancelPeripheralConnection(peripheral)
|
|
}
|
|
cleanup()
|
|
}
|
|
|
|
// MARK: - Read Config
|
|
|
|
/// Read the current configuration from a beacon
|
|
func readConfig(beacon: DiscoveredBeacon, completion: @escaping (BeaconCheckResult?, String?) -> Void) {
|
|
guard centralManager.state == .poweredOn else {
|
|
completion(nil, "Bluetooth not available")
|
|
return
|
|
}
|
|
|
|
let resolvedPeripheral = resolvePeripheral(beacon)
|
|
self.peripheral = resolvedPeripheral
|
|
self.beaconType = beacon.type
|
|
self.operationMode = .readingConfig
|
|
self.readCompletion = completion
|
|
self.readResult = BeaconCheckResult()
|
|
self.passwordIndex = 0
|
|
self.characteristics.removeAll()
|
|
self.dxSmartAuthenticated = false
|
|
self.dxSmartNotifySubscribed = false
|
|
self.responseBuffer.removeAll()
|
|
self.dxReadQueries.removeAll()
|
|
self.dxReadQueryIndex = 0
|
|
self.allDiscoveredServices.removeAll()
|
|
self.connectionRetryCount = 0
|
|
self.deviceInfoRetryCount = 0
|
|
self.disconnectRetryCount = 0
|
|
self.isTerminating = false
|
|
self.currentBeacon = beacon
|
|
self.servicesToExplore.removeAll()
|
|
|
|
state = .connecting
|
|
progress = "Connecting to \(beacon.displayName)..."
|
|
|
|
centralManager.connect(resolvedPeripheral, options: nil)
|
|
|
|
// 15-second timeout for read operations
|
|
let timeout = DispatchWorkItem { [weak self] in
|
|
guard let self = self, self.operationMode == .readingConfig else { return }
|
|
DebugLog.shared.log("BLE: Read timeout reached")
|
|
self.finishRead()
|
|
}
|
|
readTimeout = timeout
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 15, execute: timeout)
|
|
}
|
|
|
|
// MARK: - Cleanup
|
|
|
|
private func cleanup() {
|
|
cancelWriteTimeout()
|
|
cancelResponseGateTimeout()
|
|
awaitingCommandResponse = false
|
|
peripheral = nil
|
|
config = nil
|
|
completion = nil
|
|
configService = nil
|
|
characteristics.removeAll()
|
|
writeQueue.removeAll()
|
|
dxSmartAuthenticated = false
|
|
dxSmartNotifySubscribed = false
|
|
dxSmartCommandQueue.removeAll()
|
|
dxSmartWriteIndex = 0
|
|
provisioningMacAddress = nil
|
|
awaitingDeviceInfoForProvisioning = false
|
|
skipDeviceInfoRead = false
|
|
isTerminating = false
|
|
resumeWriteAfterDisconnect = false
|
|
connectionRetryCount = 0
|
|
deviceInfoRetryCount = 0
|
|
disconnectRetryCount = 0
|
|
writeRetryCount = 0
|
|
charRediscoveryCount = 0
|
|
currentBeacon = nil
|
|
state = .idle
|
|
progress = ""
|
|
}
|
|
|
|
private func fail(_ message: String, code: ProvisioningError? = nil) {
|
|
guard !isTerminating else {
|
|
DebugLog.shared.log("BLE: fail() called but already terminating, ignoring")
|
|
return
|
|
}
|
|
isTerminating = true
|
|
DebugLog.shared.log("BLE: Failed [\(code?.rawValue ?? "UNTYPED")] - \(message)")
|
|
state = .failed(message)
|
|
if let peripheral = peripheral, peripheral.state == .connected {
|
|
centralManager.cancelPeripheralConnection(peripheral)
|
|
}
|
|
if let code = code {
|
|
completion?(.failureWithCode(code, detail: message))
|
|
} else {
|
|
completion?(.failure(message))
|
|
}
|
|
cleanup()
|
|
}
|
|
|
|
private func succeed() {
|
|
guard !isTerminating else {
|
|
DebugLog.shared.log("BLE: succeed() called but already terminating, ignoring")
|
|
return
|
|
}
|
|
isTerminating = true
|
|
DebugLog.shared.log("BLE: Success! MAC=\(provisioningMacAddress ?? "unknown")")
|
|
state = .success
|
|
if let peripheral = peripheral, peripheral.state == .connected {
|
|
centralManager.cancelPeripheralConnection(peripheral)
|
|
}
|
|
let mac = provisioningMacAddress
|
|
completion?(.success(macAddress: mac))
|
|
cleanup()
|
|
}
|
|
|
|
// MARK: - DX-Smart CP28 Provisioning
|
|
|
|
private func provisionDXSmart() {
|
|
guard let service = configService else {
|
|
fail("DX-Smart config service not found", code: .serviceNotFound)
|
|
return
|
|
}
|
|
|
|
state = .discoveringServices
|
|
progress = "Discovering DX-Smart characteristics..."
|
|
|
|
peripheral?.discoverCharacteristics([
|
|
BeaconProvisioner.DXSMART_NOTIFY_CHAR,
|
|
BeaconProvisioner.DXSMART_COMMAND_CHAR,
|
|
BeaconProvisioner.DXSMART_PASSWORD_CHAR
|
|
], for: service)
|
|
}
|
|
|
|
/// Subscribe to FFE1 notifications, then authenticate on FFE3
|
|
private func dxSmartStartAuth() {
|
|
if let notifyChar = characteristics[BeaconProvisioner.DXSMART_NOTIFY_CHAR] {
|
|
DebugLog.shared.log("BLE: Subscribing to DX-Smart FFE1 notifications")
|
|
peripheral?.setNotifyValue(true, for: notifyChar)
|
|
} else {
|
|
DebugLog.shared.log("BLE: FFE1 not found, proceeding to auth directly")
|
|
dxSmartNotifySubscribed = true
|
|
dxSmartAuthenticate()
|
|
}
|
|
}
|
|
|
|
/// Write password to FFE3 (tries multiple passwords in sequence)
|
|
private func dxSmartAuthenticate() {
|
|
guard let passwordChar = characteristics[BeaconProvisioner.DXSMART_PASSWORD_CHAR] else {
|
|
fail("DX-Smart password characteristic (FFE3) not found", code: .serviceNotFound)
|
|
return
|
|
}
|
|
|
|
guard passwordIndex < BeaconProvisioner.DXSMART_PASSWORDS.count else {
|
|
fail("Authentication failed - all passwords rejected", code: .authFailed)
|
|
return
|
|
}
|
|
|
|
state = .authenticating
|
|
let currentPassword = BeaconProvisioner.DXSMART_PASSWORDS[passwordIndex]
|
|
progress = "Authenticating (attempt \(passwordIndex + 1)/\(BeaconProvisioner.DXSMART_PASSWORDS.count))..."
|
|
|
|
let passwordData = Data(currentPassword.utf8)
|
|
DebugLog.shared.log("BLE: Writing password \(passwordIndex + 1)/\(BeaconProvisioner.DXSMART_PASSWORDS.count) to FFE3 (\(passwordData.count) bytes)")
|
|
peripheral?.writeValue(passwordData, for: passwordChar, type: .withResponse)
|
|
}
|
|
|
|
/// Called when a password attempt fails — tries the next one
|
|
private func dxSmartRetryNextPassword() {
|
|
passwordIndex += 1
|
|
if passwordIndex < BeaconProvisioner.DXSMART_PASSWORDS.count {
|
|
DebugLog.shared.log("BLE: Password rejected, trying next (\(passwordIndex + 1)/\(BeaconProvisioner.DXSMART_PASSWORDS.count))")
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
self?.dxSmartAuthenticate()
|
|
}
|
|
} else {
|
|
fail("Authentication failed - all \(BeaconProvisioner.DXSMART_PASSWORDS.count) passwords rejected", code: .authFailed)
|
|
}
|
|
}
|
|
|
|
/// Read device info (MAC address) before writing config
|
|
/// NOTE: Device info read (0x30 query) is SKIPPED during provisioning because DX-Smart
|
|
/// beacons frequently drop the BLE connection during this optional query, causing
|
|
/// provisioning to fail. The MAC address is nice-to-have but not required — the API
|
|
/// falls back to iBeacon UUID as hardware ID when MAC is unavailable.
|
|
/// Device info is still read in readConfig/check mode where it doesn't block provisioning.
|
|
private func dxSmartReadDeviceInfoBeforeWrite() {
|
|
DebugLog.shared.log("BLE: Skipping device info read — proceeding directly to config write (MAC is optional)")
|
|
dxSmartWriteConfig()
|
|
}
|
|
|
|
/// Build the full command sequence and start writing
|
|
/// New 24-step write sequence for DX-Smart CP28:
|
|
/// 1. DeviceName 0x71 [name bytes] — service point name (max 20 ASCII chars)
|
|
/// 2. Frame1_Select 0x11 — select frame 1
|
|
/// 3. Frame1_Type 0x61 — enable as device info (broadcasts name)
|
|
/// 4. Frame1_RSSI 0x77 [measuredPower] — RSSI@1m for frame 1
|
|
/// 5. Frame1_AdvInt 0x78 [advInterval] — adv interval for frame 1
|
|
/// 6. Frame1_TxPow 0x79 [txPower] — tx power for frame 1
|
|
/// 7. Frame2_Select 0x12 — select frame 2
|
|
/// 8. Frame2_Type 0x62 — set as iBeacon
|
|
/// 9. UUID 0x74 [16 bytes]
|
|
/// 10. Major 0x75 [2 bytes BE]
|
|
/// 11. Minor 0x76 [2 bytes BE]
|
|
/// 12. RSSI@1m 0x77 [measuredPower]
|
|
/// 13. AdvInterval 0x78 [advInterval]
|
|
/// 14. TxPower 0x79 [txPower]
|
|
/// 15. TriggerOff 0xA0
|
|
/// 16-23. Frames 3-6 select + 0xFF (disable each)
|
|
/// 24. SaveConfig 0x60 — persist to flash
|
|
private func dxSmartWriteConfig() {
|
|
guard let config = config else {
|
|
fail("No config provided", code: .noConfig)
|
|
return
|
|
}
|
|
|
|
state = .writing
|
|
progress = "Writing DX-Smart configuration..."
|
|
|
|
dxSmartCommandQueue.removeAll()
|
|
dxSmartWriteIndex = 0
|
|
|
|
// Convert measuredPower (signed Int8) to unsigned byte for transmission
|
|
let measuredPowerByte = UInt8(bitPattern: config.measuredPower)
|
|
|
|
// 1. DeviceName (0x71) — service point name (max 20 ASCII chars)
|
|
if let name = config.deviceName, !name.isEmpty {
|
|
let truncatedName = String(name.prefix(20))
|
|
let nameBytes = Array(truncatedName.utf8)
|
|
dxSmartCommandQueue.append(buildDXPacket(cmd: .deviceNameWrite, data: nameBytes))
|
|
}
|
|
|
|
// --- Frame 1: Device Info (broadcasts name) ---
|
|
// 2. Frame1_Select (0x11)
|
|
dxSmartCommandQueue.append(buildDXPacket(cmd: .frameSelectSlot0, data: []))
|
|
// 3. Frame1_Type (0x61) — device info
|
|
dxSmartCommandQueue.append(buildDXPacket(cmd: .deviceInfoType, data: []))
|
|
// 4. Frame1_RSSI (0x77)
|
|
dxSmartCommandQueue.append(buildDXPacket(cmd: .rssiAt1m, data: [measuredPowerByte]))
|
|
// 5. Frame1_AdvInt (0x78)
|
|
dxSmartCommandQueue.append(buildDXPacket(cmd: .advInterval, data: [config.advInterval]))
|
|
// 6. Frame1_TxPow (0x79)
|
|
dxSmartCommandQueue.append(buildDXPacket(cmd: .txPower, data: [config.txPower]))
|
|
|
|
// --- Frame 2: iBeacon ---
|
|
// 7. Frame2_Select (0x12)
|
|
dxSmartCommandQueue.append(buildDXPacket(cmd: .frameSelectSlot1, data: []))
|
|
// 8. Frame2_Type (0x62) — iBeacon
|
|
dxSmartCommandQueue.append(buildDXPacket(cmd: .iBeaconType, data: []))
|
|
|
|
// 9. UUID (0x74) [16 bytes]
|
|
if let uuidData = hexStringToData(config.uuid) {
|
|
dxSmartCommandQueue.append(buildDXPacket(cmd: .uuid, data: Array(uuidData)))
|
|
}
|
|
|
|
// 10. Major (0x75) [2 bytes big-endian]
|
|
let majorHi = UInt8((config.major >> 8) & 0xFF)
|
|
let majorLo = UInt8(config.major & 0xFF)
|
|
dxSmartCommandQueue.append(buildDXPacket(cmd: .major, data: [majorHi, majorLo]))
|
|
|
|
// 11. Minor (0x76) [2 bytes big-endian]
|
|
let minorHi = UInt8((config.minor >> 8) & 0xFF)
|
|
let minorLo = UInt8(config.minor & 0xFF)
|
|
dxSmartCommandQueue.append(buildDXPacket(cmd: .minor, data: [minorHi, minorLo]))
|
|
|
|
// 12. RSSI@1m (0x77)
|
|
dxSmartCommandQueue.append(buildDXPacket(cmd: .rssiAt1m, data: [measuredPowerByte]))
|
|
// 13. AdvInterval (0x78)
|
|
dxSmartCommandQueue.append(buildDXPacket(cmd: .advInterval, data: [config.advInterval]))
|
|
// 14. TxPower (0x79)
|
|
dxSmartCommandQueue.append(buildDXPacket(cmd: .txPower, data: [config.txPower]))
|
|
// 15. TriggerOff (0xA0)
|
|
dxSmartCommandQueue.append(buildDXPacket(cmd: .triggerOff, data: []))
|
|
|
|
// --- Frames 3-6: Disable each ---
|
|
// 16-17. Frame 3: select (0x13) + disable (0xFF)
|
|
dxSmartCommandQueue.append(buildDXPacket(cmd: .frameSelectSlot2, data: []))
|
|
dxSmartCommandQueue.append(buildDXPacket(cmd: .frameDisable, data: []))
|
|
// 18-19. Frame 4: select (0x14) + disable (0xFF)
|
|
dxSmartCommandQueue.append(buildDXPacket(cmd: .frameSelectSlot3, data: []))
|
|
dxSmartCommandQueue.append(buildDXPacket(cmd: .frameDisable, data: []))
|
|
// 20-21. Frame 5: select (0x15) + disable (0xFF)
|
|
dxSmartCommandQueue.append(buildDXPacket(cmd: .frameSelectSlot4, data: []))
|
|
dxSmartCommandQueue.append(buildDXPacket(cmd: .frameDisable, data: []))
|
|
// 22-23. Frame 6: select (0x16) + disable (0xFF)
|
|
dxSmartCommandQueue.append(buildDXPacket(cmd: .frameSelectSlot5, data: []))
|
|
dxSmartCommandQueue.append(buildDXPacket(cmd: .frameDisable, data: []))
|
|
|
|
// 24. SaveConfig (0x60) — persist to flash
|
|
dxSmartCommandQueue.append(buildDXPacket(cmd: .saveConfig, data: []))
|
|
|
|
DebugLog.shared.log("BLE: DX-Smart command queue built with \(dxSmartCommandQueue.count) commands")
|
|
dxSmartSendNextCommand()
|
|
}
|
|
|
|
/// Send the next command in the DX-Smart queue
|
|
private func dxSmartSendNextCommand() {
|
|
guard dxSmartWriteIndex < dxSmartCommandQueue.count else {
|
|
cancelWriteTimeout()
|
|
DebugLog.shared.log("BLE: All DX-Smart commands written!")
|
|
progress = "Configuration saved!"
|
|
succeed()
|
|
return
|
|
}
|
|
|
|
let packet = dxSmartCommandQueue[dxSmartWriteIndex]
|
|
let total = dxSmartCommandQueue.count
|
|
let current = dxSmartWriteIndex + 1
|
|
progress = "Writing config (\(current)/\(total))..."
|
|
|
|
guard let commandChar = characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] else {
|
|
// After disconnect+reconnect, characteristic discovery may return incomplete results.
|
|
// Re-discover characteristics instead of hard-failing.
|
|
if charRediscoveryCount < BeaconProvisioner.MAX_CHAR_REDISCOVERY, let service = configService {
|
|
charRediscoveryCount += 1
|
|
DebugLog.shared.log("BLE: FFE2 missing — re-discovering characteristics (attempt \(charRediscoveryCount)/\(BeaconProvisioner.MAX_CHAR_REDISCOVERY))")
|
|
progress = "Re-discovering characteristics..."
|
|
state = .discoveringServices
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
|
self?.peripheral?.discoverCharacteristics([
|
|
BeaconProvisioner.DXSMART_NOTIFY_CHAR,
|
|
BeaconProvisioner.DXSMART_COMMAND_CHAR,
|
|
BeaconProvisioner.DXSMART_PASSWORD_CHAR
|
|
], for: service)
|
|
}
|
|
} else {
|
|
fail("DX-Smart command characteristic (FFE2) not found after \(charRediscoveryCount) rediscovery attempts", code: .serviceNotFound)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Reset rediscovery counter on successful characteristic access
|
|
charRediscoveryCount = 0
|
|
|
|
DebugLog.shared.log("BLE: Writing command \(current)/\(total): \(packet.map { String(format: "%02X", $0) }.joined(separator: " "))")
|
|
writeRetryCount = 0
|
|
scheduleWriteTimeout()
|
|
peripheral?.writeValue(packet, for: commandChar, type: .withResponse)
|
|
}
|
|
|
|
/// Schedule a per-write timeout — if the write callback doesn't come back
|
|
/// within WRITE_TIMEOUT_SECONDS, retry the write once or skip if non-fatal.
|
|
/// This matches Android's 5-second per-write timeout via withTimeoutOrNull(5000L).
|
|
private func scheduleWriteTimeout() {
|
|
cancelWriteTimeout()
|
|
let timer = DispatchWorkItem { [weak self] in
|
|
guard let self = self else { return }
|
|
guard self.state == .writing else { return }
|
|
|
|
let current = self.dxSmartWriteIndex + 1
|
|
let total = self.dxSmartCommandQueue.count
|
|
let isNonFatal = self.dxSmartWriteIndex < 6
|
|
let isSaveConfig = self.dxSmartWriteIndex >= self.dxSmartCommandQueue.count - 1
|
|
|
|
if isSaveConfig {
|
|
// SaveConfig may not get a callback — beacon reboots. Treat as success.
|
|
DebugLog.shared.log("BLE: SaveConfig write timeout (beacon likely rebooted) — treating as success")
|
|
self.succeed()
|
|
} else if self.writeRetryCount < BeaconProvisioner.MAX_WRITE_RETRIES {
|
|
// Retry the write once
|
|
self.writeRetryCount += 1
|
|
DebugLog.shared.log("BLE: Write timeout for command \(current)/\(total) — retrying (\(self.writeRetryCount)/\(BeaconProvisioner.MAX_WRITE_RETRIES))")
|
|
if let commandChar = self.characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] {
|
|
let packet = self.dxSmartCommandQueue[self.dxSmartWriteIndex]
|
|
self.scheduleWriteTimeout()
|
|
self.peripheral?.writeValue(packet, for: commandChar, type: .withResponse)
|
|
}
|
|
} else if isNonFatal {
|
|
// Non-fatal commands (first 6) — skip and continue
|
|
DebugLog.shared.log("BLE: Write timeout for non-fatal command \(current)/\(total) — skipping")
|
|
self.dxSmartWriteIndex += 1
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
self?.dxSmartSendNextCommand()
|
|
}
|
|
} else {
|
|
// Fatal command timed out after retry — fail
|
|
DebugLog.shared.log("BLE: Write timeout for critical command \(current)/\(total) — failing")
|
|
self.fail("Write timeout at step \(current)/\(total)", code: .writeFailed)
|
|
}
|
|
}
|
|
writeTimeoutTimer = timer
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + BeaconProvisioner.WRITE_TIMEOUT_SECONDS, execute: timer)
|
|
}
|
|
|
|
/// Cancel any pending write timeout
|
|
private func cancelWriteTimeout() {
|
|
writeTimeoutTimer?.cancel()
|
|
writeTimeoutTimer = nil
|
|
}
|
|
|
|
/// Calculate adaptive delay after writing a command.
|
|
/// Frame selection (0x11-0x16) and type commands (0x61, 0x62) trigger internal
|
|
/// state changes on the beacon MCU that need extra processing time.
|
|
/// UUID writes are the largest payload (21 bytes) and also need breathing room.
|
|
/// Without adaptive delays, the beacon's radio gets overwhelmed and drops the
|
|
/// BLE connection (supervision timeout).
|
|
private func delayForCommand(at index: Int) -> Double {
|
|
guard index < dxSmartCommandQueue.count else { return BeaconProvisioner.BASE_WRITE_DELAY }
|
|
|
|
let packet = dxSmartCommandQueue[index]
|
|
guard packet.count >= 3 else { return BeaconProvisioner.BASE_WRITE_DELAY }
|
|
|
|
let cmd = packet[2] // Command byte is at offset 2 (after 4E 4F header)
|
|
|
|
switch DXCmd(rawValue: cmd) {
|
|
case .frameSelectSlot0, .frameSelectSlot1, .frameSelectSlot2,
|
|
.frameSelectSlot3, .frameSelectSlot4, .frameSelectSlot5:
|
|
// Frame selection changes internal state — beacon needs time to switch context
|
|
return BeaconProvisioner.HEAVY_WRITE_DELAY
|
|
case .deviceInfoType, .iBeaconType:
|
|
// Frame type assignment — triggers internal config restructuring
|
|
return BeaconProvisioner.HEAVY_WRITE_DELAY
|
|
case .uuid:
|
|
// Largest payload (16 bytes + header = 21 bytes) — give extra time
|
|
return BeaconProvisioner.LARGE_PAYLOAD_DELAY
|
|
case .saveConfig:
|
|
// Save to flash — beacon may reboot, no point waiting long
|
|
return BeaconProvisioner.BASE_WRITE_DELAY
|
|
default:
|
|
return BeaconProvisioner.BASE_WRITE_DELAY
|
|
}
|
|
}
|
|
|
|
// MARK: - Response Gating (matches Android responseChannel.receive pattern)
|
|
|
|
/// After a successful write, advance to the next command with the appropriate delay.
|
|
/// Called either when FFE1 response arrives or when the 1s response gate timeout fires.
|
|
private func advanceToNextCommand() {
|
|
let justWritten = dxSmartWriteIndex
|
|
dxSmartWriteIndex += 1
|
|
let delay = delayForCommand(at: justWritten)
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
|
self?.dxSmartSendNextCommand()
|
|
}
|
|
}
|
|
|
|
/// Schedule a 1s timeout for beacon response. If the beacon doesn't send an FFE1
|
|
/// notification within 1s, advance anyway (some commands don't produce responses).
|
|
/// Matches Android's: withTimeoutOrNull(1000L) { responseChannel.receive() }
|
|
private func scheduleResponseGateTimeout() {
|
|
cancelResponseGateTimeout()
|
|
let timer = DispatchWorkItem { [weak self] in
|
|
guard let self = self else { return }
|
|
guard self.awaitingCommandResponse else { return }
|
|
self.awaitingCommandResponse = false
|
|
DebugLog.shared.log("BLE: No FFE1 response within 1s for command \(self.dxSmartWriteIndex + 1) — advancing (OK)")
|
|
self.advanceToNextCommand()
|
|
}
|
|
responseGateTimer = timer
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + BeaconProvisioner.RESPONSE_GATE_TIMEOUT, execute: timer)
|
|
}
|
|
|
|
/// Cancel any pending response gate timeout
|
|
private func cancelResponseGateTimeout() {
|
|
responseGateTimer?.cancel()
|
|
responseGateTimer = nil
|
|
}
|
|
|
|
// MARK: - DX-Smart Packet Builder
|
|
|
|
/// Build a DX-Smart CP28 packet: [4E][4F][CMD][LEN][DATA...][XOR_CHECKSUM]
|
|
private func buildDXPacket(cmd: DXCmd, data: [UInt8]) -> Data {
|
|
var packet: [UInt8] = []
|
|
packet.append(contentsOf: BeaconProvisioner.DXSMART_HEADER) // 4E 4F
|
|
packet.append(cmd.rawValue)
|
|
packet.append(UInt8(data.count))
|
|
packet.append(contentsOf: data)
|
|
|
|
// XOR checksum: CMD ^ LEN ^ each data byte
|
|
var checksum: UInt8 = cmd.rawValue ^ UInt8(data.count)
|
|
for byte in data {
|
|
checksum ^= byte
|
|
}
|
|
packet.append(checksum)
|
|
|
|
return Data(packet)
|
|
}
|
|
|
|
// MARK: - Read Config: Service Exploration
|
|
|
|
/// Explore all services on the device, then attempt DX-Smart read protocol
|
|
private func startReadExplore() {
|
|
guard let services = peripheral?.services, !services.isEmpty else {
|
|
readFail("No services found on device")
|
|
return
|
|
}
|
|
|
|
allDiscoveredServices = services
|
|
servicesToExplore = services
|
|
state = .discoveringServices
|
|
progress = "Exploring \(services.count) services..."
|
|
|
|
DebugLog.shared.log("BLE: Read mode — found \(services.count) services")
|
|
for s in services {
|
|
readResult.servicesFound.append(s.uuid.uuidString)
|
|
}
|
|
|
|
exploreNextService()
|
|
}
|
|
|
|
private func exploreNextService() {
|
|
guard !servicesToExplore.isEmpty else {
|
|
// All services explored — start DX-Smart read protocol if FFE0 is present
|
|
DebugLog.shared.log("BLE: All services explored, starting DX-Smart read")
|
|
startDXSmartRead()
|
|
return
|
|
}
|
|
|
|
let service = servicesToExplore.removeFirst()
|
|
DebugLog.shared.log("BLE: Discovering chars for service \(service.uuid)")
|
|
progress = "Exploring \(service.uuid.uuidString.prefix(8))..."
|
|
peripheral?.discoverCharacteristics(nil, for: service)
|
|
}
|
|
|
|
// MARK: - Read Config: DX-Smart Protocol
|
|
|
|
/// After exploration, start DX-Smart read if FFE0 chars are present
|
|
private func startDXSmartRead() {
|
|
guard characteristics[BeaconProvisioner.DXSMART_PASSWORD_CHAR] != nil,
|
|
characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] != nil else {
|
|
// Not a DX-Smart beacon — finish with just the service/char listing
|
|
DebugLog.shared.log("BLE: No FFE0 service — not a DX-Smart beacon")
|
|
progress = "No DX-Smart service found"
|
|
finishRead()
|
|
return
|
|
}
|
|
|
|
// Subscribe to FFE1 for responses
|
|
if let notifyChar = characteristics[BeaconProvisioner.DXSMART_NOTIFY_CHAR] {
|
|
DebugLog.shared.log("BLE: Read mode — subscribing to FFE1 notifications")
|
|
progress = "Subscribing to notifications..."
|
|
peripheral?.setNotifyValue(true, for: notifyChar)
|
|
} else {
|
|
// No FFE1 — try auth anyway
|
|
DebugLog.shared.log("BLE: FFE1 not found, attempting auth without notifications")
|
|
dxSmartReadAuth()
|
|
}
|
|
}
|
|
|
|
/// Authenticate on FFE3 for read mode (uses same multi-password fallback)
|
|
private func dxSmartReadAuth() {
|
|
guard let passwordChar = characteristics[BeaconProvisioner.DXSMART_PASSWORD_CHAR] else {
|
|
DebugLog.shared.log("BLE: No FFE3 for auth, finishing")
|
|
finishRead()
|
|
return
|
|
}
|
|
|
|
guard passwordIndex < BeaconProvisioner.DXSMART_PASSWORDS.count else {
|
|
DebugLog.shared.log("BLE: All passwords exhausted in read mode")
|
|
finishRead()
|
|
return
|
|
}
|
|
|
|
state = .authenticating
|
|
let currentPassword = BeaconProvisioner.DXSMART_PASSWORDS[passwordIndex]
|
|
progress = "Authenticating (attempt \(passwordIndex + 1)/\(BeaconProvisioner.DXSMART_PASSWORDS.count))..."
|
|
|
|
let passwordData = Data(currentPassword.utf8)
|
|
DebugLog.shared.log("BLE: Read mode — writing password \(passwordIndex + 1)/\(BeaconProvisioner.DXSMART_PASSWORDS.count) to FFE3")
|
|
peripheral?.writeValue(passwordData, for: passwordChar, type: .withResponse)
|
|
}
|
|
|
|
/// After auth, send read query commands
|
|
private func dxSmartReadQueryAfterAuth() {
|
|
dxReadQueries.removeAll()
|
|
dxReadQueryIndex = 0
|
|
responseBuffer.removeAll()
|
|
|
|
// Read commands: send with LEN=0 (no data) to request current config values
|
|
dxReadQueries.append(buildDXPacket(cmd: .frameTable, data: [])) // 0x10: frame assignment table
|
|
dxReadQueries.append(buildDXPacket(cmd: .iBeaconType, data: [])) // 0x62: iBeacon UUID/Major/Minor/etc
|
|
dxReadQueries.append(buildDXPacket(cmd: .deviceInfo, data: [])) // 0x30: battery, MAC, firmware
|
|
dxReadQueries.append(buildDXPacket(cmd: .deviceName, data: [])) // 0x43: device name
|
|
|
|
DebugLog.shared.log("BLE: Sending \(dxReadQueries.count) DX-Smart read queries")
|
|
state = .verifying
|
|
progress = "Reading config..."
|
|
dxSmartSendNextReadQuery()
|
|
}
|
|
|
|
private func dxSmartSendNextReadQuery() {
|
|
guard dxReadQueryIndex < dxReadQueries.count else {
|
|
DebugLog.shared.log("BLE: All read queries sent, waiting 2s for final responses")
|
|
progress = "Collecting responses..."
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
|
|
guard let self = self, self.operationMode == .readingConfig else { return }
|
|
self.finishRead()
|
|
}
|
|
return
|
|
}
|
|
|
|
guard let commandChar = characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] else {
|
|
DebugLog.shared.log("BLE: FFE2 not found, finishing read")
|
|
finishRead()
|
|
return
|
|
}
|
|
|
|
let packet = dxReadQueries[dxReadQueryIndex]
|
|
let current = dxReadQueryIndex + 1
|
|
let total = dxReadQueries.count
|
|
progress = "Reading \(current)/\(total)..."
|
|
DebugLog.shared.log("BLE: Read query \(current)/\(total): \(packet.map { String(format: "%02X", $0) }.joined(separator: " "))")
|
|
peripheral?.writeValue(packet, for: commandChar, type: .withResponse)
|
|
}
|
|
|
|
// MARK: - Read Config: Response Parsing
|
|
|
|
/// Process incoming FFE1 notification data — accumulate and parse DX-Smart response frames
|
|
private func processFFE1Response(_ data: Data) {
|
|
let hex = data.map { String(format: "%02X", $0) }.joined(separator: " ")
|
|
DebugLog.shared.log("BLE: FFE1 raw: \(hex)")
|
|
|
|
responseBuffer.append(contentsOf: data)
|
|
|
|
// Try to parse complete frames from buffer
|
|
while responseBuffer.count >= 5 { // Minimum frame: 4E 4F CMD 00 XOR = 5 bytes
|
|
// Find 4E 4F header
|
|
guard let headerIdx = findDXHeader() else {
|
|
responseBuffer.removeAll()
|
|
break
|
|
}
|
|
|
|
// Discard bytes before header
|
|
if headerIdx > 0 {
|
|
responseBuffer.removeFirst(headerIdx)
|
|
}
|
|
|
|
guard responseBuffer.count >= 5 else { break }
|
|
|
|
let cmd = responseBuffer[2]
|
|
let len = Int(responseBuffer[3])
|
|
let frameLen = 4 + len + 1 // header(2) + cmd(1) + len(1) + data(len) + xor(1)
|
|
|
|
guard responseBuffer.count >= frameLen else {
|
|
// Incomplete frame — wait for more data
|
|
break
|
|
}
|
|
|
|
// Extract frame
|
|
let frame = Array(responseBuffer[0..<frameLen])
|
|
responseBuffer.removeFirst(frameLen)
|
|
|
|
// Verify XOR checksum
|
|
var xor: UInt8 = 0
|
|
for i in 2..<(frameLen - 1) {
|
|
xor ^= frame[i]
|
|
}
|
|
|
|
let frameData = Array(frame[4..<(4 + len)])
|
|
|
|
if xor == frame[frameLen - 1] {
|
|
parseResponseCmd(cmd: cmd, data: frameData)
|
|
} else {
|
|
let frameHex = frame.map { String(format: "%02X", $0) }.joined(separator: " ")
|
|
DebugLog.shared.log("BLE: XOR checksum failed for frame: \(frameHex)")
|
|
readResult.rawResponses.append("0x\(String(format: "%02X", cmd)) XOR_FAIL: \(frameHex)")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Find 4E 4F header in response buffer
|
|
private func findDXHeader() -> Int? {
|
|
guard responseBuffer.count >= 2 else { return nil }
|
|
for i in 0..<(responseBuffer.count - 1) {
|
|
if responseBuffer[i] == 0x4E && responseBuffer[i + 1] == 0x4F {
|
|
return i
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// Parse a complete DX-Smart response by command type
|
|
private func parseResponseCmd(cmd: UInt8, data: [UInt8]) {
|
|
let dataHex = data.map { String(format: "%02X", $0) }.joined(separator: " ")
|
|
DebugLog.shared.log("BLE: Response cmd=0x\(String(format: "%02X", cmd)) len=\(data.count) data=[\(dataHex)]")
|
|
readResult.rawResponses.append("0x\(String(format: "%02X", cmd)): \(dataHex)")
|
|
|
|
switch DXCmd(rawValue: cmd) {
|
|
|
|
case .frameTable: // 0x10: Frame assignment table (one byte per slot)
|
|
readResult.frameSlots = data
|
|
DebugLog.shared.log("BLE: Frame slots: \(data.map { String(format: "0x%02X", $0) })")
|
|
|
|
case .iBeaconType: // 0x62: iBeacon config data
|
|
guard data.count >= 2 else { return }
|
|
var offset = 1 // Skip type echo byte
|
|
|
|
// UUID: 16 bytes
|
|
if data.count >= offset + 16 {
|
|
let uuidBytes = Array(data[offset..<(offset + 16)])
|
|
let uuidHex = uuidBytes.map { String(format: "%02X", $0) }.joined()
|
|
readResult.uuid = formatUUID(uuidHex)
|
|
offset += 16
|
|
}
|
|
|
|
// Major: 2 bytes big-endian
|
|
if data.count >= offset + 2 {
|
|
readResult.major = UInt16(data[offset]) << 8 | UInt16(data[offset + 1])
|
|
offset += 2
|
|
}
|
|
|
|
// Minor: 2 bytes big-endian
|
|
if data.count >= offset + 2 {
|
|
readResult.minor = UInt16(data[offset]) << 8 | UInt16(data[offset + 1])
|
|
offset += 2
|
|
}
|
|
|
|
// RSSI@1m: 1 byte signed
|
|
if data.count >= offset + 1 {
|
|
readResult.rssiAt1m = Int8(bitPattern: data[offset])
|
|
offset += 1
|
|
}
|
|
|
|
// Advertising interval: 1 byte (raw value)
|
|
if data.count >= offset + 1 {
|
|
readResult.advInterval = UInt16(data[offset])
|
|
offset += 1
|
|
}
|
|
|
|
// TX power: 1 byte
|
|
if data.count >= offset + 1 {
|
|
readResult.txPower = data[offset]
|
|
offset += 1
|
|
}
|
|
|
|
DebugLog.shared.log("BLE: Parsed iBeacon — UUID=\(readResult.uuid ?? "?") Major=\(readResult.major ?? 0) Minor=\(readResult.minor ?? 0)")
|
|
|
|
case .deviceInfo: // 0x30: Device info (battery, MAC, manufacturer, firmware)
|
|
if data.count >= 1 {
|
|
readResult.battery = data[0]
|
|
}
|
|
if data.count >= 7 {
|
|
let macBytes = Array(data[1..<7])
|
|
readResult.macAddress = macBytes.map { String(format: "%02X", $0) }.joined(separator: ":")
|
|
}
|
|
DebugLog.shared.log("BLE: Device info — battery=\(readResult.battery ?? 0)% MAC=\(readResult.macAddress ?? "?")")
|
|
|
|
case .deviceName: // 0x43: Device name
|
|
readResult.deviceName = String(bytes: data, encoding: .utf8)?.trimmingCharacters(in: .controlCharacters)
|
|
DebugLog.shared.log("BLE: Device name = \(readResult.deviceName ?? "?")")
|
|
|
|
case .authCheck: // 0x25: Auth check response
|
|
if data.count >= 1 {
|
|
let authRequired = data[0] != 0x00
|
|
DebugLog.shared.log("BLE: Auth required: \(authRequired)")
|
|
}
|
|
|
|
default:
|
|
DebugLog.shared.log("BLE: Unhandled response cmd 0x\(String(format: "%02X", cmd))")
|
|
}
|
|
}
|
|
|
|
// MARK: - Read Config: Finish
|
|
|
|
private func finishRead() {
|
|
readTimeout?.cancel()
|
|
readTimeout = nil
|
|
|
|
if let peripheral = peripheral {
|
|
centralManager.cancelPeripheralConnection(peripheral)
|
|
}
|
|
|
|
let result = readResult
|
|
state = .success
|
|
progress = ""
|
|
readCompletion?(result, nil)
|
|
cleanupRead()
|
|
}
|
|
|
|
private func readFail(_ message: String) {
|
|
DebugLog.shared.log("BLE: Read failed - \(message)")
|
|
readTimeout?.cancel()
|
|
readTimeout = nil
|
|
|
|
if let peripheral = peripheral {
|
|
centralManager.cancelPeripheralConnection(peripheral)
|
|
}
|
|
state = .failed(message)
|
|
readCompletion?(nil, message)
|
|
cleanupRead()
|
|
}
|
|
|
|
private func cleanupRead() {
|
|
peripheral = nil
|
|
readCompletion = nil
|
|
readResult = BeaconCheckResult()
|
|
readTimeout = nil
|
|
dxReadQueries.removeAll()
|
|
dxReadQueryIndex = 0
|
|
responseBuffer.removeAll()
|
|
allDiscoveredServices.removeAll()
|
|
servicesToExplore.removeAll()
|
|
configService = nil
|
|
characteristics.removeAll()
|
|
connectionRetryCount = 0
|
|
deviceInfoRetryCount = 0
|
|
disconnectRetryCount = 0
|
|
currentBeacon = nil
|
|
operationMode = .provisioning
|
|
state = .idle
|
|
progress = ""
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func hexStringToData(_ hex: String) -> Data? {
|
|
let clean = hex.normalizedUUID
|
|
guard clean.count == 32 else { return nil }
|
|
|
|
var data = Data()
|
|
var index = clean.startIndex
|
|
while index < clean.endIndex {
|
|
let nextIndex = clean.index(index, offsetBy: 2)
|
|
let byteString = String(clean[index..<nextIndex])
|
|
if let byte = UInt8(byteString, radix: 16) {
|
|
data.append(byte)
|
|
} else {
|
|
return nil
|
|
}
|
|
index = nextIndex
|
|
}
|
|
return data
|
|
}
|
|
|
|
private func formatUUID(_ hex: String) -> String {
|
|
let clean = hex.uppercased()
|
|
guard clean.count == 32 else { return hex }
|
|
let c = Array(clean)
|
|
return "\(String(c[0..<8]))-\(String(c[8..<12]))-\(String(c[12..<16]))-\(String(c[16..<20]))-\(String(c[20..<32]))"
|
|
}
|
|
}
|
|
|
|
// MARK: - CBCentralManagerDelegate
|
|
|
|
extension BeaconProvisioner: CBCentralManagerDelegate {
|
|
|
|
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
|
DebugLog.shared.log("BLE: Central state = \(central.state.rawValue)")
|
|
}
|
|
|
|
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
|
DebugLog.shared.log("BLE: Connected to \(peripheral.name ?? "unknown")")
|
|
peripheral.delegate = self
|
|
|
|
// Log negotiated MTU — CoreBluetooth auto-negotiates, but we need to verify
|
|
// the max write length can handle our largest packet (UUID write = ~21 bytes)
|
|
let maxWriteLen = peripheral.maximumWriteValueLength(for: .withResponse)
|
|
DebugLog.shared.log("BLE: Max write length (withResponse): \(maxWriteLen) bytes")
|
|
if maxWriteLen < 21 {
|
|
DebugLog.shared.log("BLE: WARNING — max write length \(maxWriteLen) may be too small for UUID packet (21 bytes)")
|
|
}
|
|
|
|
state = .discoveringServices
|
|
progress = "Discovering services..."
|
|
|
|
if operationMode == .readingConfig {
|
|
peripheral.discoverServices(nil) // Discover all for exploration
|
|
} else {
|
|
peripheral.discoverServices([BeaconProvisioner.DXSMART_SERVICE])
|
|
}
|
|
}
|
|
|
|
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
|
|
let errorMsg = error?.localizedDescription ?? "unknown error"
|
|
DebugLog.shared.log("BLE: Connection failed (attempt \(connectionRetryCount + 1)): \(errorMsg)")
|
|
|
|
// Retry logic: up to 3 retries with increasing delay (1s, 2s, 3s)
|
|
if connectionRetryCount < BeaconProvisioner.MAX_CONNECTION_RETRIES {
|
|
connectionRetryCount += 1
|
|
let delay = Double(connectionRetryCount) // 1s, 2s, 3s
|
|
progress = "Connection failed, retrying (\(connectionRetryCount)/\(BeaconProvisioner.MAX_CONNECTION_RETRIES))..."
|
|
DebugLog.shared.log("BLE: Retrying connection in \(delay)s...")
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
|
guard let self = self, let beacon = self.currentBeacon else { return }
|
|
guard self.state == .connecting else { return } // Don't retry if cancelled
|
|
|
|
let resolvedPeripheral = self.resolvePeripheral(beacon)
|
|
self.peripheral = resolvedPeripheral
|
|
self.centralManager.connect(resolvedPeripheral, options: nil)
|
|
}
|
|
} else {
|
|
let msg = "Failed to connect after \(BeaconProvisioner.MAX_CONNECTION_RETRIES) attempts: \(errorMsg)"
|
|
if operationMode == .readingConfig {
|
|
readFail(msg)
|
|
} else {
|
|
fail(msg, code: .connectionFailed)
|
|
}
|
|
}
|
|
}
|
|
|
|
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
|
DebugLog.shared.log("BLE: Disconnected from \(peripheral.name ?? "unknown") | state=\(state) mode=\(operationMode) writeIdx=\(dxSmartWriteIndex) queueCount=\(dxSmartCommandQueue.count) terminating=\(isTerminating) error=\(error?.localizedDescription ?? "none")")
|
|
|
|
// If we already called succeed() or fail(), this disconnect is expected cleanup — ignore it
|
|
if isTerminating {
|
|
DebugLog.shared.log("BLE: Disconnect during termination, ignoring")
|
|
return
|
|
}
|
|
|
|
if operationMode == .readingConfig {
|
|
if state != .success && state != .idle {
|
|
finishRead()
|
|
}
|
|
return
|
|
}
|
|
|
|
// Already in a terminal state — nothing to do
|
|
if state == .success || state == .idle {
|
|
return
|
|
}
|
|
if case .failed = state {
|
|
DebugLog.shared.log("BLE: Disconnect after failure, ignoring")
|
|
return
|
|
}
|
|
|
|
// SaveConfig (last command) was sent — beacon rebooted to apply config
|
|
// Check: writing state AND at or past the last command in queue
|
|
if state == .writing && dxSmartCommandQueue.count > 0 && dxSmartWriteIndex >= dxSmartCommandQueue.count - 1 {
|
|
DebugLog.shared.log("BLE: Disconnect after SaveConfig (idx=\(dxSmartWriteIndex)/\(dxSmartCommandQueue.count)) — treating as success")
|
|
succeed()
|
|
return
|
|
}
|
|
|
|
// NOTE: Device info read is now skipped entirely during provisioning
|
|
// (see dxSmartReadDeviceInfoBeforeWrite). This guard is kept as a safety net
|
|
// in case device info is re-enabled in the future.
|
|
if state == .authenticating && awaitingDeviceInfoForProvisioning && dxSmartAuthenticated {
|
|
DebugLog.shared.log("BLE: Disconnect during device info read — proceeding without MAC (device info is optional)")
|
|
awaitingDeviceInfoForProvisioning = false
|
|
skipDeviceInfoRead = true
|
|
|
|
// Reconnect and skip device info on next attempt
|
|
dxSmartAuthenticated = false
|
|
dxSmartNotifySubscribed = false
|
|
dxSmartCommandQueue.removeAll()
|
|
dxSmartWriteIndex = 0
|
|
passwordIndex = 0
|
|
characteristics.removeAll()
|
|
responseBuffer.removeAll()
|
|
state = .connecting
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
|
guard let self = self, let beacon = self.currentBeacon else { return }
|
|
guard self.state == .connecting else { return }
|
|
let resolvedPeripheral = self.resolvePeripheral(beacon)
|
|
self.peripheral = resolvedPeripheral
|
|
resolvedPeripheral.delegate = self
|
|
self.centralManager.connect(resolvedPeripheral, options: nil)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Cancel any pending timers — disconnect supersedes them
|
|
cancelWriteTimeout()
|
|
cancelResponseGateTimeout()
|
|
awaitingCommandResponse = false
|
|
|
|
// Unexpected disconnect during any active provisioning phase — retry with full reconnect
|
|
let isActivePhase = (state == .discoveringServices || state == .authenticating || state == .writing || state == .verifying)
|
|
if isActivePhase && disconnectRetryCount < BeaconProvisioner.MAX_DISCONNECT_RETRIES {
|
|
disconnectRetryCount += 1
|
|
let wasWriting = (state == .writing && !dxSmartCommandQueue.isEmpty)
|
|
DebugLog.shared.log("BLE: Disconnect during \(state) — reconnecting (attempt \(disconnectRetryCount)/\(BeaconProvisioner.MAX_DISCONNECT_RETRIES)) wasWriting=\(wasWriting) writeIdx=\(dxSmartWriteIndex)")
|
|
progress = "Beacon disconnected, reconnecting (\(disconnectRetryCount)/\(BeaconProvisioner.MAX_DISCONNECT_RETRIES))..."
|
|
|
|
// Reset connection-level state, but PRESERVE command queue and write index
|
|
// so we can resume from where we left off instead of starting over
|
|
dxSmartAuthenticated = false
|
|
dxSmartNotifySubscribed = false
|
|
passwordIndex = 0
|
|
characteristics.removeAll()
|
|
responseBuffer.removeAll()
|
|
|
|
if wasWriting {
|
|
// Resume mode: keep the command queue and write index intact
|
|
resumeWriteAfterDisconnect = true
|
|
DebugLog.shared.log("BLE: Will resume writing from command \(dxSmartWriteIndex + 1)/\(dxSmartCommandQueue.count) after reconnect")
|
|
} else {
|
|
// Full reset for non-writing phases
|
|
dxSmartCommandQueue.removeAll()
|
|
dxSmartWriteIndex = 0
|
|
resumeWriteAfterDisconnect = false
|
|
}
|
|
state = .connecting
|
|
|
|
let delay = Double(disconnectRetryCount) + 2.0 // 3s, 4s, 5s, 6s, 7s backoff — give BLE time to settle
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
|
guard let self = self, let beacon = self.currentBeacon else { return }
|
|
guard self.state == .connecting else { return }
|
|
let resolvedPeripheral = self.resolvePeripheral(beacon)
|
|
self.peripheral = resolvedPeripheral
|
|
resolvedPeripheral.delegate = self
|
|
self.centralManager.connect(resolvedPeripheral, options: nil)
|
|
}
|
|
return
|
|
}
|
|
|
|
// All retries exhausted or disconnect in unexpected state — fail
|
|
DebugLog.shared.log("BLE: UNEXPECTED disconnect — state=\(state) writeIdx=\(dxSmartWriteIndex) queueCount=\(dxSmartCommandQueue.count) authenticated=\(dxSmartAuthenticated) disconnectRetries=\(disconnectRetryCount)")
|
|
fail("Beacon disconnected \(disconnectRetryCount + 1) times during \(state). Move closer to the beacon and try again.", code: .disconnected)
|
|
}
|
|
}
|
|
|
|
// MARK: - CBPeripheralDelegate
|
|
|
|
extension BeaconProvisioner: CBPeripheralDelegate {
|
|
|
|
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
|
if let error = error {
|
|
if operationMode == .readingConfig {
|
|
readFail("Service discovery failed: \(error.localizedDescription)")
|
|
} else {
|
|
fail("Service discovery failed: \(error.localizedDescription)", code: .serviceNotFound)
|
|
}
|
|
return
|
|
}
|
|
|
|
guard let services = peripheral.services else {
|
|
if operationMode == .readingConfig {
|
|
readFail("No services found")
|
|
} else {
|
|
fail("No services found", code: .serviceNotFound)
|
|
}
|
|
return
|
|
}
|
|
|
|
DebugLog.shared.log("BLE: Discovered \(services.count) services")
|
|
for service in services {
|
|
NSLog(" Service: \(service.uuid)")
|
|
}
|
|
|
|
if operationMode == .readingConfig {
|
|
startReadExplore()
|
|
return
|
|
}
|
|
|
|
// Provisioning: look for DX-Smart service
|
|
for service in services {
|
|
if service.uuid == BeaconProvisioner.DXSMART_SERVICE {
|
|
configService = service
|
|
provisionDXSmart()
|
|
return
|
|
}
|
|
}
|
|
fail("Config service not found on device", code: .serviceNotFound)
|
|
}
|
|
|
|
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
|
if let error = error {
|
|
if operationMode == .readingConfig {
|
|
// Don't fail entirely — skip this service
|
|
DebugLog.shared.log("BLE: Char discovery failed for \(service.uuid): \(error.localizedDescription)")
|
|
exploreNextService()
|
|
} else {
|
|
fail("Characteristic discovery failed: \(error.localizedDescription)", code: .serviceNotFound)
|
|
}
|
|
return
|
|
}
|
|
|
|
guard let chars = service.characteristics else {
|
|
if operationMode == .readingConfig {
|
|
exploreNextService()
|
|
} else {
|
|
fail("No characteristics found", code: .serviceNotFound)
|
|
}
|
|
return
|
|
}
|
|
|
|
DebugLog.shared.log("BLE: Discovered \(chars.count) characteristics for \(service.uuid)")
|
|
for char in chars {
|
|
let props = char.properties
|
|
let propStr = [
|
|
props.contains(.read) ? "R" : "",
|
|
props.contains(.write) ? "W" : "",
|
|
props.contains(.writeWithoutResponse) ? "Wn" : "",
|
|
props.contains(.notify) ? "N" : "",
|
|
props.contains(.indicate) ? "I" : ""
|
|
].filter { !$0.isEmpty }.joined(separator: ",")
|
|
NSLog(" Char: \(char.uuid) [\(propStr)]")
|
|
characteristics[char.uuid] = char
|
|
|
|
if operationMode == .readingConfig {
|
|
readResult.characteristicsFound.append("\(char.uuid.uuidString)[\(propStr)]")
|
|
}
|
|
}
|
|
|
|
if operationMode == .readingConfig {
|
|
// Continue exploring next service
|
|
exploreNextService()
|
|
} else if charRediscoveryCount > 0 && dxSmartAuthenticated && !dxSmartCommandQueue.isEmpty {
|
|
// Rediscovery during active write — resume writing directly (already authenticated)
|
|
DebugLog.shared.log("BLE: Characteristics re-discovered after FFE2 miss — resuming write")
|
|
state = .writing
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
self?.dxSmartSendNextCommand()
|
|
}
|
|
} else {
|
|
// Provisioning: DX-Smart auth flow
|
|
dxSmartStartAuth()
|
|
}
|
|
}
|
|
|
|
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
|
|
cancelWriteTimeout() // Write callback received — cancel the per-write timeout
|
|
|
|
if let error = error {
|
|
DebugLog.shared.log("BLE: Write failed for \(characteristic.uuid): \(error.localizedDescription)")
|
|
|
|
if characteristic.uuid == BeaconProvisioner.DXSMART_PASSWORD_CHAR {
|
|
// Password rejected — try next password in the list
|
|
if passwordIndex + 1 < BeaconProvisioner.DXSMART_PASSWORDS.count {
|
|
DebugLog.shared.log("BLE: Password \(passwordIndex + 1) rejected, trying next...")
|
|
dxSmartRetryNextPassword()
|
|
} else if operationMode == .readingConfig {
|
|
readFail("Authentication failed - all passwords rejected")
|
|
} else {
|
|
fail("Authentication failed - all \(BeaconProvisioner.DXSMART_PASSWORDS.count) passwords rejected", code: .authFailed)
|
|
}
|
|
return
|
|
}
|
|
|
|
if characteristic.uuid == BeaconProvisioner.DXSMART_COMMAND_CHAR {
|
|
if operationMode == .readingConfig {
|
|
DebugLog.shared.log("BLE: Read query failed, skipping")
|
|
dxReadQueryIndex += 1
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in
|
|
self?.dxSmartSendNextReadQuery()
|
|
}
|
|
} else {
|
|
// Device name (0x71) and Frame 1 commands (steps 1-6) may be rejected by some firmware
|
|
// Treat these as non-fatal: log and continue to next command
|
|
let isNonFatalCommand = dxSmartWriteIndex < 6 // First 6 commands are optional
|
|
let isSaveConfig = dxSmartWriteIndex >= dxSmartCommandQueue.count - 1
|
|
if isSaveConfig {
|
|
// SaveConfig (0x60) write "error" is expected — beacon reboots immediately
|
|
// after processing the save, which kills the BLE connection before the
|
|
// ATT write response can be delivered. This is success, not failure.
|
|
DebugLog.shared.log("BLE: SaveConfig write error (beacon rebooted) — treating as success")
|
|
succeed()
|
|
} else if isNonFatalCommand {
|
|
DebugLog.shared.log("BLE: Non-fatal command failed at step \(dxSmartWriteIndex + 1), continuing...")
|
|
dxSmartWriteIndex += 1
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
self?.dxSmartSendNextCommand()
|
|
}
|
|
} else {
|
|
fail("Command write failed at step \(dxSmartWriteIndex + 1)/\(dxSmartCommandQueue.count): \(error.localizedDescription)", code: .writeFailed)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
if operationMode == .readingConfig {
|
|
DebugLog.shared.log("BLE: Write failed in read mode, ignoring: \(error.localizedDescription)")
|
|
return
|
|
}
|
|
fail("Write failed: \(error.localizedDescription)", code: .writeFailed)
|
|
return
|
|
}
|
|
|
|
DebugLog.shared.log("BLE: Write succeeded for \(characteristic.uuid)")
|
|
|
|
// Password auth succeeded
|
|
if characteristic.uuid == BeaconProvisioner.DXSMART_PASSWORD_CHAR {
|
|
DebugLog.shared.log("BLE: Authenticated!")
|
|
dxSmartAuthenticated = true
|
|
if operationMode == .readingConfig {
|
|
dxSmartReadQueryAfterAuth()
|
|
} else if resumeWriteAfterDisconnect {
|
|
// Reconnected after disconnect during writing — resume from saved position
|
|
resumeWriteAfterDisconnect = false
|
|
state = .writing
|
|
DebugLog.shared.log("BLE: Resuming write from command \(dxSmartWriteIndex + 1)/\(dxSmartCommandQueue.count) after reconnect")
|
|
progress = "Resuming config write..."
|
|
// Longer delay after reconnect — give the beacon's BLE stack time to stabilize
|
|
// before resuming writes (prevents immediate re-disconnect)
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
|
|
self?.dxSmartSendNextCommand()
|
|
}
|
|
} else {
|
|
// Read device info first to get MAC address, then write config
|
|
dxSmartReadDeviceInfoBeforeWrite()
|
|
}
|
|
return
|
|
}
|
|
|
|
// Command write succeeded → wait for beacon response before sending next
|
|
// (matches Android: writeCharacteristic → responseChannel.receive(1000ms) → delay → next)
|
|
if characteristic.uuid == BeaconProvisioner.DXSMART_COMMAND_CHAR {
|
|
if operationMode == .readingConfig {
|
|
dxReadQueryIndex += 1
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in
|
|
self?.dxSmartSendNextReadQuery()
|
|
}
|
|
} else if awaitingDeviceInfoForProvisioning {
|
|
// Device info query was sent - wait for response on FFE1, don't process as normal command
|
|
DebugLog.shared.log("BLE: Device info query sent, waiting for response...")
|
|
} else {
|
|
// Gate on FFE1 response — don't fire next command until beacon responds
|
|
// or 1s timeout elapses (some commands don't send responses)
|
|
awaitingCommandResponse = true
|
|
scheduleResponseGateTimeout()
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
|
|
if let error = error {
|
|
DebugLog.shared.log("BLE: Notification state failed for \(characteristic.uuid): \(error.localizedDescription)")
|
|
} else {
|
|
DebugLog.shared.log("BLE: Notifications \(characteristic.isNotifying ? "enabled" : "disabled") for \(characteristic.uuid)")
|
|
}
|
|
|
|
if characteristic.uuid == BeaconProvisioner.DXSMART_NOTIFY_CHAR {
|
|
dxSmartNotifySubscribed = true
|
|
if operationMode == .readingConfig {
|
|
// After subscribing FFE1 in read mode → authenticate
|
|
dxSmartReadAuth()
|
|
} else {
|
|
// Provisioning mode → authenticate
|
|
dxSmartAuthenticate()
|
|
}
|
|
}
|
|
}
|
|
|
|
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
|
if let error = error {
|
|
DebugLog.shared.log("BLE: Read error for \(characteristic.uuid): \(error.localizedDescription)")
|
|
return
|
|
}
|
|
|
|
let data = characteristic.value ?? Data()
|
|
|
|
if operationMode == .readingConfig {
|
|
if characteristic.uuid == BeaconProvisioner.DXSMART_NOTIFY_CHAR {
|
|
// DX-Smart response data — parse protocol frames
|
|
processFFE1Response(data)
|
|
} else {
|
|
// Log other characteristic updates
|
|
let hex = data.map { String(format: "%02X", $0) }.joined(separator: " ")
|
|
DebugLog.shared.log("BLE: Read \(characteristic.uuid): \(hex)")
|
|
}
|
|
} else {
|
|
// Provisioning mode
|
|
if characteristic.uuid == BeaconProvisioner.DXSMART_NOTIFY_CHAR {
|
|
let hex = data.map { String(format: "%02X", $0) }.joined(separator: " ")
|
|
DebugLog.shared.log("BLE: FFE1 notification: \(hex)")
|
|
|
|
// If awaiting device info for MAC address, process the response
|
|
if awaitingDeviceInfoForProvisioning {
|
|
processDeviceInfoForProvisioning(data)
|
|
} else if awaitingCommandResponse {
|
|
// Beacon responded to our command — check for rejection and advance
|
|
awaitingCommandResponse = false
|
|
cancelResponseGateTimeout()
|
|
|
|
// Check for rejection: 4E 4F 00 means command rejected (matches Android check)
|
|
let bytes = [UInt8](data)
|
|
if bytes.count >= 3 && bytes[0] == 0x4E && bytes[1] == 0x4F && bytes[2] == 0x00 {
|
|
let isNonFatal = dxSmartWriteIndex < 6
|
|
if isNonFatal {
|
|
DebugLog.shared.log("BLE: Command \(dxSmartWriteIndex + 1) rejected by beacon (non-fatal, continuing)")
|
|
} else {
|
|
DebugLog.shared.log("BLE: Command \(dxSmartWriteIndex + 1) REJECTED by beacon")
|
|
// Don't fail here — let the advance logic handle it like Android does
|
|
// (Android logs rejection but continues for most commands)
|
|
}
|
|
}
|
|
|
|
advanceToNextCommand()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Process device info response during provisioning to extract MAC address
|
|
private func processDeviceInfoForProvisioning(_ data: Data) {
|
|
responseBuffer.append(contentsOf: data)
|
|
|
|
// Look for complete frame: 4E 4F 30 LEN DATA XOR
|
|
guard responseBuffer.count >= 5 else { return }
|
|
|
|
// Find header
|
|
guard let headerIdx = findDXHeader() else {
|
|
responseBuffer.removeAll()
|
|
return
|
|
}
|
|
|
|
if headerIdx > 0 {
|
|
responseBuffer.removeFirst(headerIdx)
|
|
}
|
|
|
|
guard responseBuffer.count >= 5 else { return }
|
|
|
|
let cmd = responseBuffer[2]
|
|
let len = Int(responseBuffer[3])
|
|
let frameLen = 4 + len + 1
|
|
|
|
guard responseBuffer.count >= frameLen else { return }
|
|
|
|
// Check if this is device info response (0x30)
|
|
if cmd == DXCmd.deviceInfo.rawValue && len >= 7 {
|
|
// Parse MAC address from bytes 1-6 (byte 0 is battery)
|
|
let macBytes = Array(responseBuffer[5..<11])
|
|
provisioningMacAddress = macBytes.map { String(format: "%02X", $0) }.joined(separator: ":")
|
|
DebugLog.shared.log("BLE: Got MAC address for provisioning: \(provisioningMacAddress ?? "nil")")
|
|
}
|
|
|
|
// Clear buffer and proceed to write config
|
|
responseBuffer.removeAll()
|
|
awaitingDeviceInfoForProvisioning = false
|
|
dxSmartWriteConfig()
|
|
}
|
|
}
|