import Foundation import CoreBluetooth /// Provisioner for DXSmart / CP28 hardware /// Special two-step auth: "555555" → FFE3 (starts flashing), then password → FFE3 /// Config written as 10 iBeacon commands to FFE1, ACK'd by beacon, final 0x60 save final class DXSmartProvisioner: NSObject, BeaconProvisioner { // MARK: - Constants private static let triggerPassword = "555555" private static let defaultPassword = "dx1234" // DXSmart iBeacon config command IDs private enum ConfigCmd: UInt8 { case setUUID1 = 0x50 // UUID bytes 0-7 case setUUID2 = 0x51 // UUID bytes 8-15 case setMajor = 0x52 case setMinor = 0x53 case setTxPower = 0x54 case setInterval = 0x55 case setMeasured = 0x56 case setName = 0x57 case reserved1 = 0x58 case reserved2 = 0x59 case save = 0x60 } // MARK: - State private let peripheral: CBPeripheral private let centralManager: CBCentralManager private var writeChar: CBCharacteristic? // FFE1 — TX/RX private var passwordChar: CBCharacteristic? // FFE3 — Password private var notifyChar: CBCharacteristic? // FFE1 also used for notify private var connectionContinuation: CheckedContinuation? private var serviceContinuation: CheckedContinuation? private var responseContinuation: CheckedContinuation? private(set) var isConnected = false private(set) var isFlashing = false // Beacon LED flashing after trigger // MARK: - Init init(peripheral: CBPeripheral, centralManager: CBCentralManager) { self.peripheral = peripheral self.centralManager = centralManager super.init() self.peripheral.delegate = self } // MARK: - BeaconProvisioner func connect() async throws { for attempt in 1...GATTConstants.maxRetries { do { try await connectOnce() try await discoverServices() try await authenticate() isConnected = true isFlashing = true return } catch { disconnect() if attempt < GATTConstants.maxRetries { try await Task.sleep(nanoseconds: UInt64(GATTConstants.retryDelay * 1_000_000_000)) } else { throw error } } } } func writeConfig(_ config: BeaconConfig) async throws { guard isConnected, let writeChar else { throw ProvisionError.notConnected } let uuidBytes = config.uuid.hexToBytes guard uuidBytes.count == 16 else { throw ProvisionError.writeFailed("Invalid UUID length") } // Send 10 config commands, each ACK'd let commands: [(UInt8, Data)] = [ (ConfigCmd.setUUID1.rawValue, Data(uuidBytes[0..<8])), (ConfigCmd.setUUID2.rawValue, Data(uuidBytes[8..<16])), (ConfigCmd.setMajor.rawValue, Data([UInt8(config.major >> 8), UInt8(config.major & 0xFF)])), (ConfigCmd.setMinor.rawValue, Data([UInt8(config.minor >> 8), UInt8(config.minor & 0xFF)])), (ConfigCmd.setTxPower.rawValue, Data([config.txPower])), (ConfigCmd.setInterval.rawValue, Data([UInt8(config.advInterval >> 8), UInt8(config.advInterval & 0xFF)])), (ConfigCmd.setMeasured.rawValue, Data([UInt8(bitPattern: config.measuredPower)])), ] for (cmdId, payload) in commands { let packet = Data([cmdId]) + payload try await sendAndWaitAck(packet) } // Save to flash let savePacket = Data([ConfigCmd.save.rawValue]) try await sendAndWaitAck(savePacket) isFlashing = false } func disconnect() { if peripheral.state == .connected || peripheral.state == .connecting { centralManager.cancelPeripheralConnection(peripheral) } isConnected = false isFlashing = false } // MARK: - Private private func connectOnce() async throws { try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in connectionContinuation = cont centralManager.connect(peripheral, options: nil) DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.connectionTimeout) { [weak self] in if let c = self?.connectionContinuation { self?.connectionContinuation = nil c.resume(throwing: ProvisionError.connectionTimeout) } } } } private func discoverServices() async throws { try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in serviceContinuation = cont peripheral.discoverServices([GATTConstants.ffe0Service]) DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in if let c = self?.serviceContinuation { self?.serviceContinuation = nil c.resume(throwing: ProvisionError.serviceDiscoveryTimeout) } } } } /// Two-step DXSmart auth: /// 1. Send "555555" to FFE3 (beacon starts flashing) /// 2. Send "dx1234" to FFE3 (actual auth) private func authenticate() async throws { guard let passwordChar else { throw ProvisionError.characteristicNotFound } // Step 1: Trigger — fire and forget (WRITE_NO_RESPONSE) if let triggerData = Self.triggerPassword.data(using: .utf8) { peripheral.writeValue(triggerData, for: passwordChar, type: .withoutResponse) try await Task.sleep(nanoseconds: 500_000_000) // 500ms settle } // Step 2: Auth password — fire and forget if let authData = Self.defaultPassword.data(using: .utf8) { peripheral.writeValue(authData, for: passwordChar, type: .withoutResponse) try await Task.sleep(nanoseconds: 500_000_000) // 500ms settle } } private func sendAndWaitAck(_ data: Data) async throws { guard let writeChar else { throw ProvisionError.notConnected } let _ = try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in responseContinuation = cont peripheral.writeValue(data, for: writeChar, type: .withResponse) DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in if let c = self?.responseContinuation { self?.responseContinuation = nil c.resume(throwing: ProvisionError.operationTimeout) } } } } } // MARK: - CBPeripheralDelegate extension DXSmartProvisioner: CBPeripheralDelegate { func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { if let error { serviceContinuation?.resume(throwing: error) serviceContinuation = nil return } guard let service = peripheral.services?.first(where: { $0.uuid == GATTConstants.ffe0Service }) else { serviceContinuation?.resume(throwing: ProvisionError.serviceNotFound) serviceContinuation = nil return } peripheral.discoverCharacteristics( [GATTConstants.ffe1Char, GATTConstants.ffe2Char, GATTConstants.ffe3Char], for: service ) } func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { if let error { serviceContinuation?.resume(throwing: error) serviceContinuation = nil return } for char in service.characteristics ?? [] { switch char.uuid { case GATTConstants.ffe1Char: writeChar = char // FFE1 is also used for notify on DXSmart if char.properties.contains(.notify) { peripheral.setNotifyValue(true, for: char) } case GATTConstants.ffe3Char: passwordChar = char default: break } } if writeChar != nil && passwordChar != nil { serviceContinuation?.resume() serviceContinuation = nil } else { serviceContinuation?.resume(throwing: ProvisionError.characteristicNotFound) serviceContinuation = nil } } func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { guard let data = characteristic.value else { return } if let cont = responseContinuation { responseContinuation = nil cont.resume(returning: data) } } func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { if let error, let cont = responseContinuation { responseContinuation = nil cont.resume(throwing: error) } } }