import Foundation import CoreBluetooth /// Provisioner for DXSmart / CP28 hardware /// /// Implements BOTH the new SDK protocol (preferred) and old SDK fallback: /// /// **New SDK (2024.10+)**: Writes to FFE2, notifications on FFE1 /// - Frame selection (0x11/0x12) → frame type (0x62 = iBeacon) /// - Param writes: 0x74 UUID, 0x75 Major, 0x76 Minor, 0x77 RSSI, 0x78 AdvInt, 0x79 TxPower /// - Save: 0x60 /// - All wrapped in 4E 4F protocol packets /// /// **Old SDK fallback**: Writes to FFE1, re-sends 555555 before each command /// - 0x36 UUID, 0x37 Major, 0x38 Minor, 0x39 TxPower, 0x40 RfPower, 0x41 AdvInt, 0x43 Name /// - 0x44 Restart (includes password) /// /// Auth: "555555" to FFE3 (config mode) → "dx1234" to FFE3 (authenticate) /// NOTE: CoreBluetooth doesn't expose raw MAC addresses, so 48:87:2D OUI detection /// (used on Android) is not available on iOS. Beacons are detected by name/service UUID. final class DXSmartProvisioner: NSObject, BeaconProvisioner { // MARK: - Constants private static let triggerPassword = "555555" private static let defaultPassword = "dx1234" // MARK: - State private let peripheral: CBPeripheral private let centralManager: CBCentralManager private var ffe1Char: CBCharacteristic? // FFE1 — notify (ACK responses) private var ffe2Char: CBCharacteristic? // FFE2 — write (new SDK commands) private var ffe3Char: CBCharacteristic? // FFE3 — password private var connectionContinuation: CheckedContinuation? private var serviceContinuation: CheckedContinuation? private var responseContinuation: CheckedContinuation? private var writeContinuation: CheckedContinuation? private(set) var isConnected = false private(set) var isFlashing = false // Beacon LED flashing after trigger private var useNewSDK = true // Prefer new SDK, fallback to old // 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 else { throw ProvisionError.notConnected } let uuidBytes = config.uuid.hexToBytes guard uuidBytes.count == 16 else { throw ProvisionError.writeFailed("Invalid UUID length") } // Try new SDK first (FFE2), fall back to old SDK (FFE1) if useNewSDK, let ffe2 = ffe2Char { try await writeConfigNewSDK(config, uuidBytes: uuidBytes, writeChar: ffe2) } else if let ffe1 = ffe1Char { try await writeConfigOldSDK(config, uuidBytes: uuidBytes, writeChar: ffe1) } else { throw ProvisionError.characteristicNotFound } isFlashing = false } func disconnect() { if peripheral.state == .connected || peripheral.state == .connecting { centralManager.cancelPeripheralConnection(peripheral) } isConnected = false isFlashing = false } // MARK: - New SDK Protocol (FFE2, 2024.10+) // Matches Android DXSmartProvisioner.writeBeaconConfig() private func writeConfigNewSDK(_ config: BeaconConfig, uuidBytes: [UInt8], writeChar: CBCharacteristic) async throws { // Build command sequence matching Android's writeBeaconConfig() let commands: [(String, Data)] = [ // Frame 1: device info + radio params ("Frame1_Select", buildProtocolPacket(cmd: 0x11, data: Data())), ("Frame1_DevInfo", buildProtocolPacket(cmd: 0x61, data: Data())), ("Frame1_RSSI", buildProtocolPacket(cmd: 0x77, data: Data([UInt8(bitPattern: config.measuredPower)]))), ("Frame1_AdvInt", buildProtocolPacket(cmd: 0x78, data: Data([UInt8(config.advInterval & 0xFF)]))), ("Frame1_TxPow", buildProtocolPacket(cmd: 0x79, data: Data([config.txPower]))), // Frame 2: iBeacon config ("Frame2_Select", buildProtocolPacket(cmd: 0x12, data: Data())), ("Frame2_iBeacon", buildProtocolPacket(cmd: 0x62, data: Data())), ("UUID", buildProtocolPacket(cmd: 0x74, data: Data(uuidBytes))), ("Major", buildProtocolPacket(cmd: 0x75, data: Data([UInt8(config.major >> 8), UInt8(config.major & 0xFF)]))), ("Minor", buildProtocolPacket(cmd: 0x76, data: Data([UInt8(config.minor >> 8), UInt8(config.minor & 0xFF)]))), ("RSSI@1m", buildProtocolPacket(cmd: 0x77, data: Data([UInt8(bitPattern: config.measuredPower)]))), ("AdvInterval", buildProtocolPacket(cmd: 0x78, data: Data([UInt8(config.advInterval & 0xFF)]))), ("TxPower", buildProtocolPacket(cmd: 0x79, data: Data([config.txPower]))), ("TriggerOff", buildProtocolPacket(cmd: 0xA0, data: Data())), // Disable frames 3-6 ("Frame3_Select", buildProtocolPacket(cmd: 0x13, data: Data())), ("Frame3_NoData", buildProtocolPacket(cmd: 0xFF, data: Data())), ("Frame4_Select", buildProtocolPacket(cmd: 0x14, data: Data())), ("Frame4_NoData", buildProtocolPacket(cmd: 0xFF, data: Data())), ("Frame5_Select", buildProtocolPacket(cmd: 0x15, data: Data())), ("Frame5_NoData", buildProtocolPacket(cmd: 0xFF, data: Data())), ("Frame6_Select", buildProtocolPacket(cmd: 0x16, data: Data())), ("Frame6_NoData", buildProtocolPacket(cmd: 0xFF, data: Data())), // Save to flash ("SaveConfig", buildProtocolPacket(cmd: 0x60, data: Data())), ] for (name, packet) in commands { try await writeToCharAndWaitACK(writeChar, data: packet, label: name) // 200ms between commands (matches Android SDK timer interval) try await Task.sleep(nanoseconds: 200_000_000) } } // MARK: - Old SDK Protocol (FFE1, pre-2024.10) // Matches Android DXSmartProvisioner.writeFrame1() // Key difference: must re-send "555555" to FFE3 before EVERY command private func writeConfigOldSDK(_ config: BeaconConfig, uuidBytes: [UInt8], writeChar: CBCharacteristic) async throws { guard let ffe3 = ffe3Char else { throw ProvisionError.characteristicNotFound } let commands: [(String, Data)] = [ ("UUID", buildProtocolPacket(cmd: 0x36, data: Data(uuidBytes))), ("Major", buildProtocolPacket(cmd: 0x37, data: Data([UInt8(config.major >> 8), UInt8(config.major & 0xFF)]))), ("Minor", buildProtocolPacket(cmd: 0x38, data: Data([UInt8(config.minor >> 8), UInt8(config.minor & 0xFF)]))), ("TxPower", buildProtocolPacket(cmd: 0x39, data: Data([UInt8(bitPattern: config.measuredPower)]))), ("RfPower", buildProtocolPacket(cmd: 0x40, data: Data([config.txPower]))), ("AdvInt", buildProtocolPacket(cmd: 0x41, data: Data([UInt8(config.advInterval & 0xFF)]))), ("Name", buildProtocolPacket(cmd: 0x43, data: Data("Payfrit".utf8))), ] for (name, packet) in commands { // Step 1: Re-send "555555" to FFE3 before each command (old SDK requirement) if let triggerData = Self.triggerPassword.data(using: .utf8) { peripheral.writeValue(triggerData, for: ffe3, type: .withResponse) try await waitForWriteCallback() } // Step 2: 50ms delay (SDK timer, half of 100ms default — tested OK) try await Task.sleep(nanoseconds: 50_000_000) // Step 3: Write command to FFE1 try await writeToCharAndWaitACK(writeChar, data: packet, label: name) // 200ms settle between params try await Task.sleep(nanoseconds: 200_000_000) } } // MARK: - Read-Back Verification /// Read frame 2 (iBeacon config) to verify the write succeeded. /// Returns the raw response data, or nil if read fails. func readFrame2() async throws -> Data? { guard let ffe2 = ffe2Char ?? ffe1Char else { return nil } let readCmd = buildProtocolPacket(cmd: 0x62, data: Data()) peripheral.writeValue(readCmd, for: ffe2, type: .withResponse) do { let response = try await waitForResponse(timeout: 2.0) return response } catch { return nil } } // MARK: - Protocol Packet Builder // Format: 4E 4F [CMD] [LEN] [DATA...] [CHECKSUM] // Checksum = XOR of CMD, LEN, and all data bytes private func buildProtocolPacket(cmd: UInt8, data: Data) -> Data { let len = UInt8(data.count) var checksum = Int(cmd) ^ Int(len) for byte in data { checksum ^= Int(byte) } var packet = Data([0x4E, 0x4F, cmd, len]) packet.append(data) packet.append(UInt8(checksum & 0xFF)) return packet } // MARK: - Private Helpers 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 — fire and forget (WRITE_NO_RESPONSE) — enters config mode /// 2. Send "dx1234" to FFE3 — fire and forget — authenticates /// Matches Android enterConfigModeAndLogin(): both use WRITE_TYPE_NO_RESPONSE private func authenticate() async throws { guard let ffe3 = ffe3Char else { throw ProvisionError.characteristicNotFound } // Step 1: Trigger — fire and forget (matches Android's WRITE_TYPE_NO_RESPONSE) if let triggerData = Self.triggerPassword.data(using: .utf8) { peripheral.writeValue(triggerData, for: ffe3, type: .withoutResponse) try await Task.sleep(nanoseconds: 100_000_000) // 100ms (matches Android SDK timer) } // Step 2: Auth password — fire and forget if let authData = Self.defaultPassword.data(using: .utf8) { peripheral.writeValue(authData, for: ffe3, type: .withoutResponse) try await Task.sleep(nanoseconds: 100_000_000) // 100ms settle } } /// Write data to a characteristic and wait for ACK notification on FFE1 private func writeToCharAndWaitACK(_ char: CBCharacteristic, data: Data, label: String) async throws { let _ = try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in responseContinuation = cont peripheral.writeValue(data, for: char, 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) } } } } /// Wait for a write callback (used for FFE3 password writes in old SDK path) private func waitForWriteCallback() async throws { try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in writeContinuation = cont DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in if let c = self?.writeContinuation { self?.writeContinuation = nil c.resume(throwing: ProvisionError.operationTimeout) } } } } /// Wait for a response notification with custom timeout private func waitForResponse(timeout: TimeInterval) async throws -> Data { try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in responseContinuation = cont DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { [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: ffe1Char = char // FFE1 is used for notify (ACK responses) if char.properties.contains(.notify) { peripheral.setNotifyValue(true, for: char) } case GATTConstants.ffe2Char: ffe2Char = char case GATTConstants.ffe3Char: ffe3Char = char default: break } } // Need at least FFE1 (notify) + FFE3 (password) // FFE2 is preferred for writes but optional (old firmware uses FFE1) if ffe1Char != nil && ffe3Char != nil { useNewSDK = (ffe2Char != 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?) { // Handle write callback for old SDK FFE3 password writes if let cont = writeContinuation { writeContinuation = nil if let error { cont.resume(throwing: error) } else { cont.resume() } return } // Handle write errors for command writes if let error, let cont = responseContinuation { responseContinuation = nil cont.resume(throwing: error) } } }