import Foundation import CoreBluetooth import CoreLocation /// Native beacon scanner using CoreBluetooth. /// Scans for iBeacon advertisements and returns UUID + RSSI. class BeaconScanner: NSObject { private static let TAG = "BeaconScanner" // iBeacon manufacturer ID (Apple) private static let IBEACON_MANUFACTURER_ID: UInt16 = 0x004C // Scan duration in seconds private static let SCAN_DURATION: TimeInterval = 2.0 // Minimum RSSI threshold private static let MIN_RSSI: Int = -90 private var centralManager: CBCentralManager? private var locationManager: CLLocationManager? private var isScanning = false // Callbacks private var onComplete: (([[String: Any]]) -> Void)? private var onError: ((String) -> Void)? // Collected beacon data: UUID -> list of RSSI samples private var beaconSamples: [String: [Int]] = [:] // Timer for scan completion private var scanTimer: Timer? // Bluetooth state private var bluetoothState: CBManagerState = .unknown private var pendingScan = false private var pendingRegions: [String]? override init() { super.init() centralManager = CBCentralManager(delegate: self, queue: nil, options: [ CBCentralManagerOptionShowPowerAlertKey: false ]) locationManager = CLLocationManager() } /// Check if Bluetooth permissions are granted func hasPermissions() -> Bool { // Check Bluetooth authorization if #available(iOS 13.1, *) { let bluetoothAuth = CBCentralManager.authorization if bluetoothAuth != .allowedAlways { return false } } // Check location authorization (required for beacon scanning) let locationAuth = locationManager?.authorizationStatus ?? .notDetermined return locationAuth == .authorizedWhenInUse || locationAuth == .authorizedAlways } /// Check if Bluetooth is enabled func isBluetoothEnabled() -> Bool { return bluetoothState == .poweredOn } /// Start scanning for beacons func startScan(regions: [String], onComplete: @escaping ([[String: Any]]) -> Void, onError: @escaping (String) -> Void) { if isScanning { onError("Scan already in progress") return } if !hasPermissions() { onError("Bluetooth or location permissions not granted") return } // If Bluetooth isn't ready yet, queue the scan if bluetoothState != .poweredOn { if bluetoothState == .poweredOff { onError("Bluetooth is not enabled") return } // State might be unknown/resetting, wait for it pendingScan = true pendingRegions = regions self.onComplete = onComplete self.onError = onError return } performScan(regions: regions, onComplete: onComplete, onError: onError) } private func performScan(regions: [String], onComplete: @escaping ([[String: Any]]) -> Void, onError: @escaping (String) -> Void) { NSLog("\(BeaconScanner.TAG): Starting CoreBluetooth beacon scan") isScanning = true self.onComplete = onComplete self.onError = onError beaconSamples.removeAll() // Start scanning for all devices (we'll filter for iBeacons in didDiscover) // Note: CoreBluetooth doesn't allow filtering by manufacturer data directly centralManager?.scanForPeripherals(withServices: nil, options: [ CBCentralManagerScanOptionAllowDuplicatesKey: true ]) // Schedule scan completion scanTimer = Timer.scheduledTimer(withTimeInterval: BeaconScanner.SCAN_DURATION, repeats: false) { [weak self] _ in self?.completeScan() } } /// Stop scanning func stopScan() { NSLog("\(BeaconScanner.TAG): Stopping beacon scan") scanTimer?.invalidate() scanTimer = nil isScanning = false pendingScan = false centralManager?.stopScan() } private func completeScan() { NSLog("\(BeaconScanner.TAG): Scan complete. Found \(beaconSamples.count) unique beacons") centralManager?.stopScan() isScanning = false // Build results var results: [[String: Any]] = [] for (uuid, samples) in beaconSamples { let avgRssi = samples.reduce(0, +) / max(samples.count, 1) results.append([ "uuid": uuid, "rssi": avgRssi, "samples": samples.count ]) } // Sort by RSSI descending (strongest first) results.sort { ($0["rssi"] as? Int ?? -100) > ($1["rssi"] as? Int ?? -100) } onComplete?(results) onComplete = nil onError = nil } /// Parse iBeacon UUID from manufacturer data private func parseIBeaconUuid(from advertisementData: [String: Any]) -> (uuid: String, rssi: Int)? { guard let manufacturerData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data else { return nil } // iBeacon format: // Bytes 0-1: Company ID (0x004C for Apple) // Byte 2: Type (0x02) // Byte 3: Length (0x15 = 21) // Bytes 4-19: UUID (16 bytes) // Bytes 20-21: Major // Bytes 22-23: Minor // Byte 24: TX Power guard manufacturerData.count >= 25 else { return nil } // Check Apple company ID (little endian) let companyId = UInt16(manufacturerData[0]) | (UInt16(manufacturerData[1]) << 8) guard companyId == BeaconScanner.IBEACON_MANUFACTURER_ID else { return nil } // Check iBeacon type guard manufacturerData[2] == 0x02 && manufacturerData[3] == 0x15 else { return nil } // Extract UUID (bytes 4-19) let uuidBytes = manufacturerData.subdata(in: 4..<20) let uuid = uuidBytes.map { String(format: "%02X", $0) }.joined() return (uuid: uuid, rssi: 0) // RSSI comes from the peripheral callback } } // MARK: - CBCentralManagerDelegate extension BeaconScanner: CBCentralManagerDelegate { func centralManagerDidUpdateState(_ central: CBCentralManager) { bluetoothState = central.state NSLog("\(BeaconScanner.TAG): Bluetooth state changed to \(central.state.rawValue)") // If we had a pending scan and Bluetooth is now ready, start it if pendingScan && central.state == .poweredOn { pendingScan = false if let regions = pendingRegions, let onComplete = onComplete, let onError = onError { performScan(regions: regions, onComplete: onComplete, onError: onError) } pendingRegions = nil } else if pendingScan && central.state == .poweredOff { pendingScan = false onError?("Bluetooth is not enabled") onError = nil onComplete = nil } } func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { let rssiValue = RSSI.intValue // Filter weak signals guard rssiValue >= BeaconScanner.MIN_RSSI else { return } // Try to parse as iBeacon guard let beacon = parseIBeaconUuid(from: advertisementData) else { return } NSLog("\(BeaconScanner.TAG): Detected iBeacon: \(beacon.uuid) RSSI=\(rssiValue)") // Add sample if beaconSamples[beacon.uuid] == nil { beaconSamples[beacon.uuid] = [] } beaconSamples[beacon.uuid]?.append(rssiValue) } }