import Foundation import CoreBluetooth import Combine /// Central BLE manager — handles scanning and beacon type detection /// Matches Android's BeaconScanner.kt behavior @MainActor final class BLEManager: NSObject, ObservableObject { // MARK: - Published State @Published var isScanning = false @Published var discoveredBeacons: [DiscoveredBeacon] = [] @Published var bluetoothState: CBManagerState = .unknown // MARK: - Constants (matching Android) static let scanDuration: TimeInterval = 5.0 static let verifyScanDuration: TimeInterval = 15.0 static let verifyPollInterval: TimeInterval = 0.5 // GATT Service UUIDs static let ffe0Service = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB") static let fff0Service = CBUUID(string: "0000FFF0-0000-1000-8000-00805F9B34FB") static let fea0Service = CBUUID(string: "0000FEA0-0000-1000-8000-00805F9B34FB") // DX-Smart factory default iBeacon UUID static let dxSmartDefaultUUID = "E2C56DB5-DFFB-48D2-B060-D0F5A71096E0" // MARK: - Connection Callbacks (used by provisioners) // Provisioners call centralManager.connect() but BLEManager is the delegate, // so we need to forward connection events back to provisioners via closures. 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 Task { @MainActor in 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. /// Scans for up to `verifyScanDuration` looking for matching UUID/Major/Minor. func verifyBroadcast(uuid: String, major: UInt16, minor: UInt16) async -> VerifyResult { // TODO: Implement iBeacon region monitoring via CLLocationManager // CoreLocation's CLBeaconRegion is the iOS-native way to detect iBeacon broadcasts // For now, return a placeholder that prompts manual verification 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) /// Returns (uuid, major, minor) if valid iBeacon advertisement found private func parseIBeaconData(_ mfgData: Data) -> (uuid: String, major: UInt16, minor: UInt16)? { // iBeacon format in manufacturer data: // [0x4C 0x00] (Apple company ID) [0x02 0x15] (iBeacon type+length) // [UUID 16 bytes] [Major 2 bytes] [Minor 2 bytes] [TX Power 1 byte] guard mfgData.count >= 25 else { return nil } guard mfgData[0] == 0x4C && mfgData[1] == 0x00 && mfgData[2] == 0x02 && mfgData[3] == 0x15 else { return nil } // Extract UUID (bytes 4-19) 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))" // Extract Major (bytes 20-21) and Minor (bytes 22-23) 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: - Beacon Type Detection (matches Android BeaconScanner.kt) // NOTE: Android also detects DX-Smart by MAC OUI prefix 48:87:2D. // CoreBluetooth does not expose raw MAC addresses, so we compensate // with broader iBeacon UUID detection and more permissive inclusion. func detectBeaconType( name: String?, serviceUUIDs: [CBUUID]?, manufacturerData: Data? ) -> BeaconType { let deviceName = (name ?? "").lowercased() // Parse iBeacon data if available (needed for UUID-based detection) let iBeaconData: (uuid: String, major: UInt16, minor: UInt16)? if let mfgData = manufacturerData { iBeaconData = parseIBeaconData(mfgData) } else { iBeaconData = nil } // 1. Service UUID matching (matches Android lines 122-126) if let services = serviceUUIDs { let serviceStrings = services.map { $0.uuidString.uppercased() } // Android: KBeacon uses FFE0 as primary service if serviceStrings.contains(where: { $0.hasPrefix("0000FFE0") }) { // Could be KBeacon or DXSmart — check name to differentiate if deviceName.contains("cp28") || deviceName.contains("cp-28") || deviceName.contains("dx") || deviceName.contains("pddaxlque") { return .dxsmart } return .kbeacon } // Android: DXSmart also uses FFF0 (line 125) // FIXED: Was incorrectly mapping FFF0 → BlueCharm only. // Android maps DXSmartProvisioner.SERVICE_UUID_FFF0 → DXSMART if serviceStrings.contains(where: { $0.hasPrefix("0000FFF0") }) { // Check name patterns to decide: DXSmart or BlueCharm if deviceName.contains("cp28") || deviceName.contains("cp-28") || deviceName.contains("dx") || deviceName.contains("pddaxlque") || deviceName.isEmpty { // DX beacons often have no name or DX-prefixed names return .dxsmart } if deviceName.contains("bluecharm") || deviceName.hasPrefix("bc") || deviceName.hasPrefix("table-") { return .bluecharm } // Default FFF0 to DXSmart (matching Android behavior) return .dxsmart } // Android: BlueCharm uses FEA0 (line 124) if serviceStrings.contains(where: { $0.hasPrefix("0000FEA0") }) { return .bluecharm } } // 2. Detect DX-Smart by factory default iBeacon UUID (Android line 130) // This is critical — catches DX beacons that don't advertise service UUIDs if let ibeacon = iBeaconData { if ibeacon.uuid.caseInsensitiveCompare(Self.dxSmartDefaultUUID) == .orderedSame { return .dxsmart } // Check if broadcasting a Payfrit shard UUID (already provisioned DX beacon) if BeaconShardPool.isPayfrit(ibeacon.uuid) { return .dxsmart } } // 3. Device name patterns (Android lines 131-147) if deviceName.contains("kbeacon") || deviceName.contains("kbpro") || deviceName.hasPrefix("kb") { return .kbeacon } if deviceName.contains("bluecharm") || deviceName.hasPrefix("bc") || deviceName.hasPrefix("table-") { return .bluecharm } if deviceName.contains("cp28") || deviceName.contains("cp-28") || deviceName.contains("dx-cp") || deviceName.contains("dx-smart") || deviceName.contains("dxsmart") || deviceName.contains("pddaxlque") { return .dxsmart } // 4. Detect by iBeacon minor in high range (Android line 143) if let ibeacon = iBeaconData, ibeacon.minor > 10000 { return .dxsmart } // 5. Generic beacon patterns (Android lines 145-147) if deviceName.contains("ibeacon") || deviceName.contains("beacon") || deviceName.hasPrefix("ble") { return .dxsmart // Default to DXSmart like Android } // 6. Any remaining iBeacon advertisement — still a beacon we should show if iBeaconData != nil { return .dxsmart } return .unknown } } // MARK: - CBCentralManagerDelegate extension BLEManager: CBCentralManagerDelegate { nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) { Task { @MainActor in bluetoothState = central.state } } nonisolated func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { Task { @MainActor in onPeripheralConnected?(peripheral) } } nonisolated func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { Task { @MainActor in onPeripheralFailedToConnect?(peripheral, error) } } nonisolated func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { Task { @MainActor in onPeripheralDisconnected?(peripheral, error) } } nonisolated func centralManager( _ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber ) { Task { @MainActor in let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "" let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data let isConnectable = advertisementData[CBAdvertisementDataIsConnectable] as? Bool ?? false let type = detectBeaconType(name: name, serviceUUIDs: serviceUUIDs, manufacturerData: mfgData) // Match Android behavior (lines 164-169): // Include devices that have a recognized type, OR // are broadcasting iBeacon data, OR // are connectable with a name (potential configurable beacon) if type == .unknown { let hasName = !name.isEmpty let hasIBeaconData = mfgData.flatMap { parseIBeaconData($0) } != nil if !hasIBeaconData && !(isConnectable && hasName) { return } } if let idx = discoveredBeacons.firstIndex(where: { $0.id == peripheral.identifier }) { // Update existing discoveredBeacons[idx].rssi = RSSI.intValue discoveredBeacons[idx].lastSeen = Date() } else { // New beacon let beacon = DiscoveredBeacon( id: peripheral.identifier, peripheral: peripheral, name: name, type: type, rssi: RSSI.intValue, lastSeen: Date() ) discoveredBeacons.append(beacon) } // Keep list sorted by RSSI (strongest/closest first) discoveredBeacons.sort { $0.rssi > $1.rssi } } } }