import Foundation import CoreBluetooth import Combine /// Central BLE manager — handles scanning and CP-28 beacon detection @MainActor final class BLEManager: NSObject, ObservableObject { // MARK: - Published State @Published var isScanning = false @Published var discoveredBeacons: [DiscoveredBeacon] = [] @Published var bluetoothState: CBManagerState = .unknown // MARK: - Constants static let scanDuration: TimeInterval = 5.0 static let verifyScanDuration: TimeInterval = 15.0 static let verifyPollInterval: TimeInterval = 0.5 // CP-28 uses FFE0 service static let ffe0Service = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB") // DX-Smart factory default iBeacon UUID static let dxSmartDefaultUUID = "E2C56DB5-DFFB-48D2-B060-D0F5A71096E0" // MARK: - Connection Callbacks (used by provisioners) var onPeripheralConnected: ((CBPeripheral) -> Void)? var onPeripheralFailedToConnect: ((CBPeripheral, Error?) -> Void)? var onPeripheralDisconnected: ((CBPeripheral, Error?) -> Void)? // MARK: - Private private(set) var centralManager: CBCentralManager! private var scanTimer: Timer? private var scanContinuation: CheckedContinuation<[DiscoveredBeacon], Never>? // MARK: - Init override init() { super.init() centralManager = CBCentralManager(delegate: self, queue: .main) } // MARK: - Scanning /// Scan for beacons for the given duration. Returns discovered beacons sorted by RSSI. func scan(duration: TimeInterval = scanDuration) async -> [DiscoveredBeacon] { guard bluetoothState == .poweredOn else { return [] } discoveredBeacons = [] isScanning = true let results = await withCheckedContinuation { (continuation: CheckedContinuation<[DiscoveredBeacon], Never>) in scanContinuation = continuation centralManager.scanForPeripherals(withServices: nil, options: [ CBCentralManagerScanOptionAllowDuplicatesKey: true ]) scanTimer = Timer.scheduledTimer(withTimeInterval: duration, repeats: false) { [weak self] _ in DispatchQueue.main.async { self?.stopScan() } } } return results.sorted { $0.rssi > $1.rssi } } func stopScan() { centralManager.stopScan() scanTimer?.invalidate() scanTimer = nil isScanning = false let results = discoveredBeacons if let cont = scanContinuation { scanContinuation = nil cont.resume(returning: results) } } /// Verify a beacon is broadcasting expected iBeacon values. func verifyBroadcast(uuid: String, major: UInt16, minor: UInt16) async -> VerifyResult { // TODO: Implement iBeacon region monitoring via CLLocationManager return VerifyResult( found: false, rssi: nil, message: "iBeacon verification requires CLLocationManager — coming soon" ) } // MARK: - iBeacon Manufacturer Data Parsing /// Parse iBeacon data from manufacturer data (Apple company ID 0x4C00) private func parseIBeaconData(_ mfgData: Data) -> (uuid: String, major: UInt16, minor: UInt16)? { guard mfgData.count >= 25 else { return nil } guard mfgData[0] == 0x4C && mfgData[1] == 0x00 && mfgData[2] == 0x02 && mfgData[3] == 0x15 else { return nil } let uuidBytes = mfgData.subdata(in: 4..<20) let hex = uuidBytes.map { String(format: "%02X", $0) }.joined() let uuid = "\(hex.prefix(8))-\(hex.dropFirst(8).prefix(4))-\(hex.dropFirst(12).prefix(4))-\(hex.dropFirst(16).prefix(4))-\(hex.dropFirst(20))" let major = UInt16(mfgData[20]) << 8 | UInt16(mfgData[21]) let minor = UInt16(mfgData[22]) << 8 | UInt16(mfgData[23]) return (uuid: uuid, major: major, minor: minor) } // MARK: - CP-28 Detection // Only detect DX-Smart / CP-28 beacons. Everything else is ignored. func detectBeaconType( name: String?, serviceUUIDs: [CBUUID]?, manufacturerData: Data? ) -> BeaconType? { let deviceName = (name ?? "").lowercased() // Parse iBeacon data if available let iBeaconData: (uuid: String, major: UInt16, minor: UInt16)? if let mfgData = manufacturerData { iBeaconData = parseIBeaconData(mfgData) } else { iBeaconData = nil } // 1. Service UUID: CP-28 uses FFE0 if let services = serviceUUIDs { let serviceStrings = services.map { $0.uuidString.uppercased() } if serviceStrings.contains(where: { $0.hasPrefix("0000FFE0") }) { // FFE0 with DX name patterns → definitely CP-28 if deviceName.contains("cp28") || deviceName.contains("cp-28") || deviceName.contains("dx") || deviceName.contains("pddaxlque") { return .dxsmart } // FFE0 without a specific name — still likely CP-28 return .dxsmart } // CP-28 also advertises FFF0 on some firmware if serviceStrings.contains(where: { $0.hasPrefix("0000FFF0") }) { // Any FFF0 device is likely CP-28 — don't filter by name return .dxsmart } } // 2. DX-Smart factory default iBeacon UUID if let ibeacon = iBeaconData { if ibeacon.uuid.caseInsensitiveCompare(Self.dxSmartDefaultUUID) == .orderedSame { return .dxsmart } // Already provisioned with a Payfrit shard UUID if BeaconShardPool.isPayfrit(ibeacon.uuid) { return .dxsmart } } // 3. Device name patterns for CP-28 (includes "payfrit" — our own provisioned name) if deviceName.contains("cp28") || deviceName.contains("cp-28") || deviceName.contains("dx-cp") || deviceName.contains("dx-smart") || deviceName.contains("dxsmart") || deviceName.contains("pddaxlque") || deviceName.contains("payfrit") { return .dxsmart } // 4. iBeacon minor in high range (factory default DX pattern) if let ibeacon = iBeaconData, ibeacon.minor > 10000 { return .dxsmart } // 5. Any iBeacon advertisement — likely a CP-28 in the field if iBeaconData != nil { return .dxsmart } // Not a CP-28 — don't show it return nil } } // MARK: - CBCentralManagerDelegate extension BLEManager: CBCentralManagerDelegate { nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) { let state = central.state DispatchQueue.main.async { [weak self] in self?.bluetoothState = state } } nonisolated func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { DispatchQueue.main.async { [weak self] in self?.onPeripheralConnected?(peripheral) } } nonisolated func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { DispatchQueue.main.async { [weak self] in self?.onPeripheralFailedToConnect?(peripheral, error) } } nonisolated func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { DispatchQueue.main.async { [weak self] in self?.onPeripheralDisconnected?(peripheral, error) } } nonisolated func centralManager( _ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber ) { let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "" let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data let peripheralId = peripheral.identifier let rssiValue = RSSI.intValue DispatchQueue.main.async { [weak self] in guard let self else { return } // Only show CP-28 beacons — everything else is filtered out guard let type = self.detectBeaconType(name: name, serviceUUIDs: serviceUUIDs, manufacturerData: mfgData) else { return } if let idx = self.discoveredBeacons.firstIndex(where: { $0.id == peripheralId }) { self.discoveredBeacons[idx].rssi = rssiValue self.discoveredBeacons[idx].lastSeen = Date() } else { let beacon = DiscoveredBeacon( id: peripheralId, peripheral: peripheral, name: name, type: type, rssi: rssiValue, lastSeen: Date() ) self.discoveredBeacons.append(beacon) } self.discoveredBeacons.sort { $0.rssi > $1.rssi } } } }