- Modified BeaconProvisioner to read device info (0x30) before writing config - Extract MAC address from beacon and return in ProvisioningResult - Use MAC address as hardware_id field (snake_case for backend) - Reorder scan view: Configurable Devices section now appears first - Add debug logging for beacon registration Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1181 lines
46 KiB
Swift
1181 lines
46 KiB
Swift
import Foundation
|
|
import CoreBluetooth
|
|
|
|
/// Result of a provisioning operation
|
|
enum ProvisioningResult {
|
|
case success(macAddress: String?)
|
|
case failure(String)
|
|
}
|
|
|
|
/// 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 password
|
|
private static let DXSMART_PASSWORD = "dx1234"
|
|
|
|
// 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
|
|
|
|
// Read config mode
|
|
private enum OperationMode { case provisioning, readingConfig }
|
|
private var operationMode: OperationMode = .provisioning
|
|
private var readCompletion: ((BeaconCheckResult?, String?) -> Void)?
|
|
private var readResult = BeaconCheckResult()
|
|
private var readTimeout: DispatchWorkItem?
|
|
|
|
// Read config exploration state
|
|
private var allDiscoveredServices: [CBService] = []
|
|
private var servicesToExplore: [CBService] = []
|
|
|
|
// DX-Smart read query state
|
|
private var dxReadQueries: [Data] = []
|
|
private var dxReadQueryIndex = 0
|
|
private var responseBuffer: [UInt8] = []
|
|
|
|
// Connection retry state
|
|
private var connectionRetryCount = 0
|
|
private static let MAX_CONNECTION_RETRIES = 3
|
|
private var currentBeacon: DiscoveredBeacon?
|
|
|
|
override init() {
|
|
super.init()
|
|
centralManager = CBCentralManager(delegate: self, queue: .main)
|
|
}
|
|
|
|
/// Re-retrieve peripheral from our own CBCentralManager (the one from scanner may not work)
|
|
private func resolvePeripheral(_ beacon: DiscoveredBeacon) -> CBPeripheral {
|
|
let retrieved = centralManager.retrievePeripherals(withIdentifiers: [beacon.peripheral.identifier])
|
|
return retrieved.first ?? beacon.peripheral
|
|
}
|
|
|
|
// MARK: - Provision
|
|
|
|
/// Provision a beacon with the given configuration
|
|
func provision(beacon: DiscoveredBeacon, config: BeaconConfig, completion: @escaping (ProvisioningResult) -> Void) {
|
|
guard centralManager.state == .poweredOn else {
|
|
completion(.failure("Bluetooth not available"))
|
|
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.connectionRetryCount = 0
|
|
self.currentBeacon = beacon
|
|
|
|
state = .connecting
|
|
progress = "Connecting to \(beacon.displayName)..."
|
|
|
|
centralManager.connect(resolvedPeripheral, options: nil)
|
|
|
|
// Timeout after 30 seconds
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 30) { [weak self] in
|
|
if self?.state != .success && self?.state != .idle {
|
|
self?.fail("Connection timeout")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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.characteristics.removeAll()
|
|
self.dxSmartAuthenticated = false
|
|
self.dxSmartNotifySubscribed = false
|
|
self.responseBuffer.removeAll()
|
|
self.dxReadQueries.removeAll()
|
|
self.dxReadQueryIndex = 0
|
|
self.allDiscoveredServices.removeAll()
|
|
self.connectionRetryCount = 0
|
|
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
|
|
connectionRetryCount = 0
|
|
currentBeacon = nil
|
|
state = .idle
|
|
progress = ""
|
|
}
|
|
|
|
private func fail(_ message: String) {
|
|
DebugLog.shared.log("BLE: Failed - \(message)")
|
|
state = .failed(message)
|
|
if let peripheral = peripheral {
|
|
centralManager.cancelPeripheralConnection(peripheral)
|
|
}
|
|
completion?(.failure(message))
|
|
cleanup()
|
|
}
|
|
|
|
private func succeed() {
|
|
DebugLog.shared.log("BLE: Success! MAC=\(provisioningMacAddress ?? "unknown")")
|
|
state = .success
|
|
if let peripheral = peripheral {
|
|
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")
|
|
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
|
|
private func dxSmartAuthenticate() {
|
|
guard let passwordChar = characteristics[BeaconProvisioner.DXSMART_PASSWORD_CHAR] else {
|
|
fail("DX-Smart password characteristic (FFE3) not found")
|
|
return
|
|
}
|
|
|
|
state = .authenticating
|
|
progress = "Authenticating..."
|
|
|
|
let passwordData = Data(BeaconProvisioner.DXSMART_PASSWORD.utf8)
|
|
DebugLog.shared.log("BLE: Writing password to FFE3 (\(passwordData.count) bytes)")
|
|
peripheral?.writeValue(passwordData, for: passwordChar, type: .withResponse)
|
|
}
|
|
|
|
/// Read device info (MAC address) before writing config
|
|
private func dxSmartReadDeviceInfoBeforeWrite() {
|
|
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")
|
|
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")
|
|
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
|
|
private func dxSmartReadAuth() {
|
|
guard let passwordChar = characteristics[BeaconProvisioner.DXSMART_PASSWORD_CHAR] else {
|
|
DebugLog.shared.log("BLE: No FFE3 for auth, finishing")
|
|
finishRead()
|
|
return
|
|
}
|
|
|
|
state = .authenticating
|
|
progress = "Authenticating..."
|
|
|
|
let passwordData = Data(BeaconProvisioner.DXSMART_PASSWORD.utf8)
|
|
DebugLog.shared.log("BLE: Read mode — writing password to FFE3")
|
|
peripheral?.writeValue(passwordData, for: passwordChar, type: .withResponse)
|
|
}
|
|
|
|
/// After auth, send read query commands
|
|
private func dxSmartReadQueryAfterAuth() {
|
|
dxReadQueries.removeAll()
|
|
dxReadQueryIndex = 0
|
|
responseBuffer.removeAll()
|
|
|
|
// Read commands: send with LEN=0 (no data) to request current config values
|
|
dxReadQueries.append(buildDXPacket(cmd: .frameTable, data: [])) // 0x10: frame assignment table
|
|
dxReadQueries.append(buildDXPacket(cmd: .iBeaconType, data: [])) // 0x62: iBeacon UUID/Major/Minor/etc
|
|
dxReadQueries.append(buildDXPacket(cmd: .deviceInfo, data: [])) // 0x30: battery, MAC, firmware
|
|
dxReadQueries.append(buildDXPacket(cmd: .deviceName, data: [])) // 0x43: device name
|
|
|
|
DebugLog.shared.log("BLE: Sending \(dxReadQueries.count) DX-Smart read queries")
|
|
state = .verifying
|
|
progress = "Reading config..."
|
|
dxSmartSendNextReadQuery()
|
|
}
|
|
|
|
private func dxSmartSendNextReadQuery() {
|
|
guard dxReadQueryIndex < dxReadQueries.count else {
|
|
DebugLog.shared.log("BLE: All read queries sent, waiting 2s for final responses")
|
|
progress = "Collecting responses..."
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
|
|
guard let self = self, self.operationMode == .readingConfig else { return }
|
|
self.finishRead()
|
|
}
|
|
return
|
|
}
|
|
|
|
guard let commandChar = characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] else {
|
|
DebugLog.shared.log("BLE: FFE2 not found, finishing read")
|
|
finishRead()
|
|
return
|
|
}
|
|
|
|
let packet = dxReadQueries[dxReadQueryIndex]
|
|
let current = dxReadQueryIndex + 1
|
|
let total = dxReadQueries.count
|
|
progress = "Reading \(current)/\(total)..."
|
|
DebugLog.shared.log("BLE: Read query \(current)/\(total): \(packet.map { String(format: "%02X", $0) }.joined(separator: " "))")
|
|
peripheral?.writeValue(packet, for: commandChar, type: .withResponse)
|
|
}
|
|
|
|
// MARK: - Read Config: Response Parsing
|
|
|
|
/// Process incoming FFE1 notification data — accumulate and parse DX-Smart response frames
|
|
private func processFFE1Response(_ data: Data) {
|
|
let hex = data.map { String(format: "%02X", $0) }.joined(separator: " ")
|
|
DebugLog.shared.log("BLE: FFE1 raw: \(hex)")
|
|
|
|
responseBuffer.append(contentsOf: data)
|
|
|
|
// Try to parse complete frames from buffer
|
|
while responseBuffer.count >= 5 { // Minimum frame: 4E 4F CMD 00 XOR = 5 bytes
|
|
// Find 4E 4F header
|
|
guard let headerIdx = findDXHeader() else {
|
|
responseBuffer.removeAll()
|
|
break
|
|
}
|
|
|
|
// Discard bytes before header
|
|
if headerIdx > 0 {
|
|
responseBuffer.removeFirst(headerIdx)
|
|
}
|
|
|
|
guard responseBuffer.count >= 5 else { break }
|
|
|
|
let cmd = responseBuffer[2]
|
|
let len = Int(responseBuffer[3])
|
|
let frameLen = 4 + len + 1 // header(2) + cmd(1) + len(1) + data(len) + xor(1)
|
|
|
|
guard responseBuffer.count >= frameLen else {
|
|
// Incomplete frame — wait for more data
|
|
break
|
|
}
|
|
|
|
// Extract frame
|
|
let frame = Array(responseBuffer[0..<frameLen])
|
|
responseBuffer.removeFirst(frameLen)
|
|
|
|
// Verify XOR checksum
|
|
var xor: UInt8 = 0
|
|
for i in 2..<(frameLen - 1) {
|
|
xor ^= frame[i]
|
|
}
|
|
|
|
let frameData = Array(frame[4..<(4 + len)])
|
|
|
|
if xor == frame[frameLen - 1] {
|
|
parseResponseCmd(cmd: cmd, data: frameData)
|
|
} else {
|
|
let frameHex = frame.map { String(format: "%02X", $0) }.joined(separator: " ")
|
|
DebugLog.shared.log("BLE: XOR checksum failed for frame: \(frameHex)")
|
|
readResult.rawResponses.append("0x\(String(format: "%02X", cmd)) XOR_FAIL: \(frameHex)")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Find 4E 4F header in response buffer
|
|
private func findDXHeader() -> Int? {
|
|
guard responseBuffer.count >= 2 else { return nil }
|
|
for i in 0..<(responseBuffer.count - 1) {
|
|
if responseBuffer[i] == 0x4E && responseBuffer[i + 1] == 0x4F {
|
|
return i
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// Parse a complete DX-Smart response by command type
|
|
private func parseResponseCmd(cmd: UInt8, data: [UInt8]) {
|
|
let dataHex = data.map { String(format: "%02X", $0) }.joined(separator: " ")
|
|
DebugLog.shared.log("BLE: Response cmd=0x\(String(format: "%02X", cmd)) len=\(data.count) data=[\(dataHex)]")
|
|
readResult.rawResponses.append("0x\(String(format: "%02X", cmd)): \(dataHex)")
|
|
|
|
switch DXCmd(rawValue: cmd) {
|
|
|
|
case .frameTable: // 0x10: Frame assignment table (one byte per slot)
|
|
readResult.frameSlots = data
|
|
DebugLog.shared.log("BLE: Frame slots: \(data.map { String(format: "0x%02X", $0) })")
|
|
|
|
case .iBeaconType: // 0x62: iBeacon config data
|
|
guard data.count >= 2 else { return }
|
|
var offset = 1 // Skip type echo byte
|
|
|
|
// UUID: 16 bytes
|
|
if data.count >= offset + 16 {
|
|
let uuidBytes = Array(data[offset..<(offset + 16)])
|
|
let uuidHex = uuidBytes.map { String(format: "%02X", $0) }.joined()
|
|
readResult.uuid = formatUUID(uuidHex)
|
|
offset += 16
|
|
}
|
|
|
|
// Major: 2 bytes big-endian
|
|
if data.count >= offset + 2 {
|
|
readResult.major = UInt16(data[offset]) << 8 | UInt16(data[offset + 1])
|
|
offset += 2
|
|
}
|
|
|
|
// Minor: 2 bytes big-endian
|
|
if data.count >= offset + 2 {
|
|
readResult.minor = UInt16(data[offset]) << 8 | UInt16(data[offset + 1])
|
|
offset += 2
|
|
}
|
|
|
|
// RSSI@1m: 1 byte signed
|
|
if data.count >= offset + 1 {
|
|
readResult.rssiAt1m = Int8(bitPattern: data[offset])
|
|
offset += 1
|
|
}
|
|
|
|
// Advertising interval: 1 byte (raw value)
|
|
if data.count >= offset + 1 {
|
|
readResult.advInterval = UInt16(data[offset])
|
|
offset += 1
|
|
}
|
|
|
|
// TX power: 1 byte
|
|
if data.count >= offset + 1 {
|
|
readResult.txPower = data[offset]
|
|
offset += 1
|
|
}
|
|
|
|
DebugLog.shared.log("BLE: Parsed iBeacon — UUID=\(readResult.uuid ?? "?") Major=\(readResult.major ?? 0) Minor=\(readResult.minor ?? 0)")
|
|
|
|
case .deviceInfo: // 0x30: Device info (battery, MAC, manufacturer, firmware)
|
|
if data.count >= 1 {
|
|
readResult.battery = data[0]
|
|
}
|
|
if data.count >= 7 {
|
|
let macBytes = Array(data[1..<7])
|
|
readResult.macAddress = macBytes.map { String(format: "%02X", $0) }.joined(separator: ":")
|
|
}
|
|
DebugLog.shared.log("BLE: Device info — battery=\(readResult.battery ?? 0)% MAC=\(readResult.macAddress ?? "?")")
|
|
|
|
case .deviceName: // 0x43: Device name
|
|
readResult.deviceName = String(bytes: data, encoding: .utf8)?.trimmingCharacters(in: .controlCharacters)
|
|
DebugLog.shared.log("BLE: Device name = \(readResult.deviceName ?? "?")")
|
|
|
|
case .authCheck: // 0x25: Auth check response
|
|
if data.count >= 1 {
|
|
let authRequired = data[0] != 0x00
|
|
DebugLog.shared.log("BLE: Auth required: \(authRequired)")
|
|
}
|
|
|
|
default:
|
|
DebugLog.shared.log("BLE: Unhandled response cmd 0x\(String(format: "%02X", cmd))")
|
|
}
|
|
}
|
|
|
|
// MARK: - Read Config: Finish
|
|
|
|
private func finishRead() {
|
|
readTimeout?.cancel()
|
|
readTimeout = nil
|
|
|
|
if let peripheral = peripheral {
|
|
centralManager.cancelPeripheralConnection(peripheral)
|
|
}
|
|
|
|
let result = readResult
|
|
state = .success
|
|
progress = ""
|
|
readCompletion?(result, nil)
|
|
cleanupRead()
|
|
}
|
|
|
|
private func readFail(_ message: String) {
|
|
DebugLog.shared.log("BLE: Read failed - \(message)")
|
|
readTimeout?.cancel()
|
|
readTimeout = nil
|
|
|
|
if let peripheral = peripheral {
|
|
centralManager.cancelPeripheralConnection(peripheral)
|
|
}
|
|
state = .failed(message)
|
|
readCompletion?(nil, message)
|
|
cleanupRead()
|
|
}
|
|
|
|
private func cleanupRead() {
|
|
peripheral = nil
|
|
readCompletion = nil
|
|
readResult = BeaconCheckResult()
|
|
readTimeout = nil
|
|
dxReadQueries.removeAll()
|
|
dxReadQueryIndex = 0
|
|
responseBuffer.removeAll()
|
|
allDiscoveredServices.removeAll()
|
|
servicesToExplore.removeAll()
|
|
configService = nil
|
|
characteristics.removeAll()
|
|
connectionRetryCount = 0
|
|
currentBeacon = nil
|
|
operationMode = .provisioning
|
|
state = .idle
|
|
progress = ""
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func hexStringToData(_ hex: String) -> Data? {
|
|
let clean = hex.replacingOccurrences(of: "-", with: "").uppercased()
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
|
DebugLog.shared.log("BLE: Disconnected from \(peripheral.name ?? "unknown")")
|
|
if operationMode == .readingConfig {
|
|
if state != .success && state != .idle {
|
|
finishRead()
|
|
}
|
|
} else if state != .success && state != .idle {
|
|
if case .failed = state {
|
|
// Already failed
|
|
} else {
|
|
fail("Unexpected disconnect")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)")
|
|
}
|
|
return
|
|
}
|
|
|
|
guard let services = peripheral.services else {
|
|
if operationMode == .readingConfig {
|
|
readFail("No services found")
|
|
} else {
|
|
fail("No services found")
|
|
}
|
|
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")
|
|
}
|
|
|
|
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)")
|
|
}
|
|
return
|
|
}
|
|
|
|
guard let chars = service.characteristics else {
|
|
if operationMode == .readingConfig {
|
|
exploreNextService()
|
|
} else {
|
|
fail("No characteristics found")
|
|
}
|
|
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 {
|
|
if operationMode == .readingConfig {
|
|
readFail("Authentication failed: \(error.localizedDescription)")
|
|
} else {
|
|
fail("Authentication failed: \(error.localizedDescription)")
|
|
}
|
|
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
|
|
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): \(error.localizedDescription)")
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
if operationMode == .readingConfig {
|
|
DebugLog.shared.log("BLE: Write failed in read mode, ignoring: \(error.localizedDescription)")
|
|
return
|
|
}
|
|
fail("Write failed: \(error.localizedDescription)")
|
|
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()
|
|
}
|
|
}
|