import Foundation import CoreBluetooth /// Provisioner for KBeacon / KBPro hardware /// Protocol: FFE0 service, FFE1 write, FFE2 notify /// Auth via CMD_AUTH (0x01), config via CMD_WRITE_PARAMS (0x03), save via CMD_SAVE (0x04) final class KBeaconProvisioner: NSObject, BeaconProvisioner { // MARK: - Protocol Commands private enum CMD: UInt8 { case auth = 0x01 case readParams = 0x02 case writeParams = 0x03 case save = 0x04 } // MARK: - Parameter IDs private enum ParamID: UInt8 { case uuid = 0x10 case major = 0x11 case minor = 0x12 case txPower = 0x13 case advInterval = 0x14 } // MARK: - Known passwords (tried in order, matching Android) // Known KBeacon default passwords (tried in order) // Note: password 5 was previously a duplicate of password 3 — replaced with "000000" (6-char variant) private static let passwords: [Data] = [ "kd1234".data(using: .utf8)!, // KBeacon factory default Data(repeating: 0, count: 16), // Binary zeros (16 bytes) Data([0x31,0x32,0x33,0x34,0x35,0x36,0x37,0x38,0x39,0x30,0x31,0x32,0x33,0x34,0x35,0x36]), // "1234567890123456" "0000000000000000".data(using: .utf8)!, // ASCII zeros (16 bytes) "000000".data(using: .utf8)!, // Short zero default (6 bytes) ] // MARK: - State private let peripheral: CBPeripheral private let centralManager: CBCentralManager private var writeChar: CBCharacteristic? private var notifyChar: CBCharacteristic? private var connectionContinuation: CheckedContinuation? private var serviceContinuation: CheckedContinuation? private var writeContinuation: CheckedContinuation? private(set) var isConnected = false var diagnosticLog: ProvisionLog? var bleManager: BLEManager? /// Status callback — provisioner reports what phase it's in so UI can update var onStatusUpdate: ((String) -> Void)? // 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 { // Connect with retry for attempt in 1...GATTConstants.maxRetries { do { let attemptLabel = GATTConstants.maxRetries > 1 ? " (attempt \(attempt)/\(GATTConstants.maxRetries))" : "" await MainActor.run { onStatusUpdate?("Connecting to beacon…\(attemptLabel)") } await diagnosticLog?.log("connect", "Attempt \(attempt)/\(GATTConstants.maxRetries)") try await connectOnce() await MainActor.run { onStatusUpdate?("Discovering services…") } await diagnosticLog?.log("connect", "Connected — discovering services…") try await discoverServices() await diagnosticLog?.log("connect", "Services found — write:\(writeChar != nil) notify:\(notifyChar != nil)") await MainActor.run { onStatusUpdate?("Authenticating…") } await diagnosticLog?.log("auth", "Trying \(Self.passwords.count) passwords…") try await authenticate() await diagnosticLog?.log("auth", "Auth success") isConnected = true return } catch { await diagnosticLog?.log("connect", "Attempt \(attempt) failed: \(error.localizedDescription)", isError: true) disconnect() if attempt < GATTConstants.maxRetries { await MainActor.run { onStatusUpdate?("Retrying… (\(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 } // Build parameter payload var params = Data() // UUID (16 bytes) params.append(ParamID.uuid.rawValue) let uuidBytes = config.uuid.hexToBytes params.append(contentsOf: uuidBytes) // Major (2 bytes BE) params.append(ParamID.major.rawValue) params.append(UInt8(config.major >> 8)) params.append(UInt8(config.major & 0xFF)) // Minor (2 bytes BE) params.append(ParamID.minor.rawValue) params.append(UInt8(config.minor >> 8)) params.append(UInt8(config.minor & 0xFF)) // TX Power params.append(ParamID.txPower.rawValue) params.append(config.txPower) // Adv Interval params.append(ParamID.advInterval.rawValue) params.append(UInt8(config.advInterval >> 8)) params.append(UInt8(config.advInterval & 0xFF)) // Send CMD_WRITE_PARAMS await MainActor.run { onStatusUpdate?("Writing beacon parameters…") } await diagnosticLog?.log("write", "Sending CMD_WRITE_PARAMS (\(params.count) bytes)…") let writeCmd = Data([CMD.writeParams.rawValue]) + params let writeResp = try await sendCommand(writeCmd) guard writeResp.first == CMD.writeParams.rawValue else { throw ProvisionError.writeFailed("Unexpected write response") } await diagnosticLog?.log("write", "Params written OK — saving to flash…") // Send CMD_SAVE to flash await MainActor.run { onStatusUpdate?("Saving to flash…") } let saveResp = try await sendCommand(Data([CMD.save.rawValue])) guard saveResp.first == CMD.save.rawValue else { throw ProvisionError.saveFailed } await MainActor.run { onStatusUpdate?("Config saved ✓") } await diagnosticLog?.log("write", "Save confirmed") } func disconnect() { if peripheral.state == .connected || peripheral.state == .connecting { centralManager.cancelPeripheralConnection(peripheral) } isConnected = false } // MARK: - Private: Connection private func connectOnce() async throws { try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in connectionContinuation = cont // Register for connection callbacks via BLEManager (the CBCentralManagerDelegate) bleManager?.onPeripheralConnected = { [weak self] connectedPeripheral in guard connectedPeripheral.identifier == self?.peripheral.identifier else { return } if let c = self?.connectionContinuation { self?.connectionContinuation = nil c.resume() } } bleManager?.onPeripheralFailedToConnect = { [weak self] failedPeripheral, error in guard failedPeripheral.identifier == self?.peripheral.identifier else { return } if let c = self?.connectionContinuation { self?.connectionContinuation = nil c.resume(throwing: error ?? ProvisionError.connectionTimeout) } } centralManager.connect(peripheral, options: nil) // Timeout 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) } } } } private func authenticate() async throws { for (index, password) in Self.passwords.enumerated() { let passwordLabel = String(data: password.prefix(6), encoding: .utf8) ?? "binary" await MainActor.run { onStatusUpdate?("Authenticating… (\(index + 1)/\(Self.passwords.count))") } await diagnosticLog?.log("auth", "Trying password \(index + 1)/\(Self.passwords.count): \(passwordLabel)…") let cmd = Data([CMD.auth.rawValue]) + password do { let resp = try await sendCommand(cmd) if resp.first == CMD.auth.rawValue && resp.count > 1 && resp[1] == 0x00 { await MainActor.run { onStatusUpdate?("Authenticated ✓") } return // Auth success } await diagnosticLog?.log("auth", "Password \(index + 1) rejected (response: \(resp.map { String(format: "%02X", $0) }.joined()))") } catch { await diagnosticLog?.log("auth", "Password \(index + 1) timeout: \(error.localizedDescription)") continue } } throw ProvisionError.authFailed } private func sendCommand(_ data: Data) async throws -> Data { guard let writeChar else { throw ProvisionError.notConnected } return try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in writeContinuation = cont peripheral.writeValue(data, for: writeChar, type: .withResponse) DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in if let c = self?.writeContinuation { self?.writeContinuation = nil c.resume(throwing: ProvisionError.operationTimeout) } } } } } // MARK: - CBPeripheralDelegate extension KBeaconProvisioner: 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], 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 case GATTConstants.ffe2Char: notifyChar = char peripheral.setNotifyValue(true, for: char) default: break } } if writeChar != nil { serviceContinuation?.resume() serviceContinuation = nil } else { serviceContinuation?.resume(throwing: ProvisionError.characteristicNotFound) serviceContinuation = nil } } func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { guard characteristic.uuid == GATTConstants.ffe2Char, let data = characteristic.value else { return } if let cont = writeContinuation { writeContinuation = nil cont.resume(returning: data) } } func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { // Write acknowledgment — actual response comes via notify on FFE2 if let error { if let cont = writeContinuation { writeContinuation = nil cont.resume(throwing: error) } } } }