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") // 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: - 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 this detection // path is unavailable on iOS. We rely on service UUID + device name instead. func detectBeaconType( name: String?, serviceUUIDs: [CBUUID]?, manufacturerData: Data? ) -> BeaconType { let deviceName = (name ?? "").lowercased() // 1. Service UUID matching if let services = serviceUUIDs { let serviceStrings = services.map { $0.uuidString.uppercased() } if serviceStrings.contains(where: { $0.hasPrefix("0000FFF0") }) { return .bluecharm } 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 } } // 2. Device name patterns 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-smart") || deviceName.contains("dxsmart") || deviceName.contains("pddaxlque") { return .dxsmart } // 3. Generic beacon patterns if deviceName.contains("ibeacon") || deviceName.contains("beacon") || deviceName.hasPrefix("ble") { return .dxsmart // Default to DXSmart like Android } // 4. Check manufacturer data for iBeacon advertisement if let mfgData = manufacturerData, mfgData.count >= 23 { // Apple iBeacon prefix: 0x4C00 0215 if mfgData.count >= 4 && mfgData[0] == 0x4C && mfgData[1] == 0x00 && mfgData[2] == 0x02 && mfgData[3] == 0x15 { // Extract minor (bytes 22-23) — high minors suggest DXSmart factory defaults if mfgData.count >= 24 { let minorVal = UInt16(mfgData[22]) << 8 | UInt16(mfgData[23]) if minorVal > 10000 { return .dxsmart } } return .kbeacon } } return .unknown } } // MARK: - CBCentralManagerDelegate extension BLEManager: CBCentralManagerDelegate { nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) { Task { @MainActor in bluetoothState = central.state } } 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 type = detectBeaconType(name: name, serviceUUIDs: serviceUUIDs, manufacturerData: mfgData) // Only show recognized beacons guard type != .unknown else { 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) } } } }