import Foundation import CoreBluetooth /// Provisioner for BlueCharm / BC04P hardware /// /// Supports two service variants: /// - FEA0 service (BC04P): FEA1 write, FEA2 notify, FEA3 config /// - FFF0 service (legacy): FFF1 password, FFF2 UUID, FFF3 major, FFF4 minor /// /// BC04P write methods (tried in order): /// 1. Direct config write to FEA3: [0x01] + UUID + Major + Minor + TxPower /// 2. Raw data write to FEA1: UUID + Major + Minor + TxPower + Interval, then save commands /// 3. Indexed parameter writes to FEA1: [index] + data, then [0xFF] save /// /// Legacy write: individual characteristics per parameter (FFF1-FFF4) final class BlueCharmProvisioner: NSObject, BeaconProvisioner { // MARK: - Constants // 5 passwords matching Android (16 bytes each) private static let passwords: [Data] = [ Data(repeating: 0, count: 16), // All zeros "0000000000000000".data(using: .utf8)!, // ASCII zeros "1234567890123456".data(using: .utf8)!, // Common "minew123".data(using: .utf8)!.padded(to: 16), // Minew default "bc04p".data(using: .utf8)!.padded(to: 16), // Model name ] // Legacy FFF0 passwords private static let legacyPasswords = ["000000", "123456", "bc0000"] // Legacy characteristic UUIDs private static let fff1Password = CBUUID(string: "0000FFF1-0000-1000-8000-00805F9B34FB") private static let fff2UUID = CBUUID(string: "0000FFF2-0000-1000-8000-00805F9B34FB") private static let fff3Major = CBUUID(string: "0000FFF3-0000-1000-8000-00805F9B34FB") private static let fff4Minor = CBUUID(string: "0000FFF4-0000-1000-8000-00805F9B34FB") // FEA0 characteristic UUIDs private static let fea1Write = CBUUID(string: "0000FEA1-0000-1000-8000-00805F9B34FB") private static let fea2Notify = CBUUID(string: "0000FEA2-0000-1000-8000-00805F9B34FB") private static let fea3Config = CBUUID(string: "0000FEA3-0000-1000-8000-00805F9B34FB") // MARK: - State private let peripheral: CBPeripheral private let centralManager: CBCentralManager private var discoveredService: CBService? private var writeChar: CBCharacteristic? // FEA1 or first writable private var notifyChar: CBCharacteristic? // FEA2 private var configChar: CBCharacteristic? // FEA3 private var isLegacy = false // Using FFF0 service private var connectionContinuation: CheckedContinuation? private var serviceContinuation: CheckedContinuation? private var writeContinuation: CheckedContinuation? private var writeOKContinuation: CheckedContinuation? private(set) var isConnected = false var diagnosticLog: ProvisionLog? var bleManager: BLEManager? // 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() if !isLegacy { try await authenticateBC04P() } isConnected = 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") } if isLegacy { try await writeLegacy(config, uuidBytes: uuidBytes) } else { try await writeBC04P(config, uuidBytes: uuidBytes) } } func disconnect() { if peripheral.state == .connected || peripheral.state == .connecting { centralManager.cancelPeripheralConnection(peripheral) } isConnected = false } // MARK: - BC04P Write (3 fallback methods, matching Android) private func writeBC04P(_ config: BeaconConfig, uuidBytes: [UInt8]) async throws { let majorBytes = Data([UInt8(config.major >> 8), UInt8(config.major & 0xFF)]) let minorBytes = Data([UInt8(config.minor >> 8), UInt8(config.minor & 0xFF)]) let txPowerByte = config.txPower let intervalUnits = UInt16(Double(config.advInterval) * 100.0 / 0.625) let intervalBytes = Data([UInt8(intervalUnits >> 8), UInt8(intervalUnits & 0xFF)]) // Method 1: Write directly to FEA3 (config characteristic) if let fea3 = configChar { var iBeaconData = Data([0x01]) // iBeacon frame type iBeaconData.append(contentsOf: uuidBytes) iBeaconData.append(majorBytes) iBeaconData.append(minorBytes) iBeaconData.append(txPowerByte) if let _ = try? await writeDirectAndWait(fea3, data: iBeaconData) { try await Task.sleep(nanoseconds: 500_000_000) return // Success } } // Method 2: Raw data write to FEA1 if let fea1 = writeChar { var rawData = Data(uuidBytes) rawData.append(majorBytes) rawData.append(minorBytes) rawData.append(txPowerByte) rawData.append(intervalBytes) if let _ = try? await writeDirectAndWait(fea1, data: rawData) { try await Task.sleep(nanoseconds: 300_000_000) // Send save/apply commands (matching Android) let _ = try? await writeDirectAndWait(fea1, data: Data([0xFF])) // Save variant 1 try await Task.sleep(nanoseconds: 200_000_000) let _ = try? await writeDirectAndWait(fea1, data: Data([0x00])) // Apply/commit try await Task.sleep(nanoseconds: 200_000_000) let _ = try? await writeDirectAndWait(fea1, data: Data([0x57])) // KBeacon save try await Task.sleep(nanoseconds: 200_000_000) let _ = try? await writeDirectAndWait(fea1, data: Data([0x01, 0x00])) // Enable slot 0 try await Task.sleep(nanoseconds: 500_000_000) return } } // Method 3: Indexed parameter writes to FEA1 if let fea1 = writeChar { // Index 0 = UUID let _ = try? await writeDirectAndWait(fea1, data: Data([0x00]) + Data(uuidBytes)) try await Task.sleep(nanoseconds: 100_000_000) // Index 1 = Major let _ = try? await writeDirectAndWait(fea1, data: Data([0x01]) + majorBytes) try await Task.sleep(nanoseconds: 100_000_000) // Index 2 = Minor let _ = try? await writeDirectAndWait(fea1, data: Data([0x02]) + minorBytes) try await Task.sleep(nanoseconds: 100_000_000) // Index 3 = TxPower let _ = try? await writeDirectAndWait(fea1, data: Data([0x03, txPowerByte])) try await Task.sleep(nanoseconds: 100_000_000) // Index 4 = Interval let _ = try? await writeDirectAndWait(fea1, data: Data([0x04]) + intervalBytes) try await Task.sleep(nanoseconds: 100_000_000) // Save command let _ = try? await writeDirectAndWait(fea1, data: Data([0xFF])) try await Task.sleep(nanoseconds: 500_000_000) return } throw ProvisionError.writeFailed("No write characteristic available") } // MARK: - Legacy FFF0 Write private func writeLegacy(_ config: BeaconConfig, uuidBytes: [UInt8]) async throws { guard let service = discoveredService else { throw ProvisionError.serviceNotFound } // Try passwords for password in Self.legacyPasswords { if let char = service.characteristics?.first(where: { $0.uuid == Self.fff1Password }), let data = password.data(using: .utf8) { let _ = try? await writeDirectAndWait(char, data: data) try await Task.sleep(nanoseconds: 200_000_000) } } // Write UUID if let char = service.characteristics?.first(where: { $0.uuid == Self.fff2UUID }) { let _ = try await writeDirectAndWait(char, data: Data(uuidBytes)) } // Write Major let majorBytes = Data([UInt8(config.major >> 8), UInt8(config.major & 0xFF)]) if let char = service.characteristics?.first(where: { $0.uuid == Self.fff3Major }) { let _ = try await writeDirectAndWait(char, data: majorBytes) } // Write Minor let minorBytes = Data([UInt8(config.minor >> 8), UInt8(config.minor & 0xFF)]) if let char = service.characteristics?.first(where: { $0.uuid == Self.fff4Minor }) { let _ = try await writeDirectAndWait(char, data: minorBytes) } } // MARK: - Auth (BC04P) private func authenticateBC04P() async throws { guard let fea1 = writeChar else { throw ProvisionError.characteristicNotFound } // Enable notifications on FEA2 if available if let fea2 = notifyChar { peripheral.setNotifyValue(true, for: fea2) try await Task.sleep(nanoseconds: 200_000_000) } // No explicit auth command needed for BC04P — the write methods // handle auth implicitly. Android's BlueCharm provisioner also // doesn't do a CMD_CONNECT auth for the FEA0 path. } // MARK: - Private Helpers 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) 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.fea0Service, GATTConstants.fff0Service]) 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 writeDirectAndWait(_ char: CBCharacteristic, data: Data) async throws -> Void { try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in writeOKContinuation = cont peripheral.writeValue(data, for: char, type: .withResponse) DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { [weak self] in if let c = self?.writeOKContinuation { self?.writeOKContinuation = nil c.resume(throwing: ProvisionError.operationTimeout) } } } } } // MARK: - CBPeripheralDelegate extension BlueCharmProvisioner: CBPeripheralDelegate { func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { if let error { serviceContinuation?.resume(throwing: error) serviceContinuation = nil return } // Prefer FEA0 (BC04P), fallback to FFF0 (legacy) if let fea0Service = peripheral.services?.first(where: { $0.uuid == GATTConstants.fea0Service }) { discoveredService = fea0Service isLegacy = false peripheral.discoverCharacteristics(nil, for: fea0Service) } else if let fff0Service = peripheral.services?.first(where: { $0.uuid == GATTConstants.fff0Service }) { discoveredService = fff0Service isLegacy = true peripheral.discoverCharacteristics(nil, for: fff0Service) } else { serviceContinuation?.resume(throwing: ProvisionError.serviceNotFound) serviceContinuation = nil } } func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { if let error { serviceContinuation?.resume(throwing: error) serviceContinuation = nil return } if isLegacy { // Legacy: just need the service with characteristics serviceContinuation?.resume() serviceContinuation = nil return } // BC04P: map specific characteristics for char in service.characteristics ?? [] { switch char.uuid { case Self.fea1Write: writeChar = char case Self.fea2Notify: notifyChar = char case Self.fea3Config: configChar = char default: // Also grab any writable char as fallback if writeChar == nil && (char.properties.contains(.write) || char.properties.contains(.writeWithoutResponse)) { writeChar = char } if notifyChar == nil && char.properties.contains(.notify) { notifyChar = char } } } if writeChar != nil || configChar != 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 = writeContinuation { writeContinuation = nil cont.resume(returning: data) } } func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { if let cont = writeOKContinuation { writeOKContinuation = nil if let error { cont.resume(throwing: error) } else { cont.resume() } return } if let error, let cont = writeContinuation { writeContinuation = nil cont.resume(throwing: ProvisionError.writeFailed(error.localizedDescription)) } } } // MARK: - Data Extension private extension Data { /// Pad data to target length with zero bytes func padded(to length: Int) -> Data { if count >= length { return self } var padded = self padded.append(contentsOf: [UInt8](repeating: 0, count: length - count)) return padded } }