import Foundation import CoreLocation /// Native beacon scanner using CoreLocation for iBeacon detection. /// Based on the proven BeaconManager from payfrit-user-ios. class BeaconScanner: NSObject, ObservableObject, CLLocationManagerDelegate { private static let TAG = "BeaconScanner" private static let MIN_RSSI: Int = -90 @Published var isScanning = false @Published var authorizationStatus: CLAuthorizationStatus = .notDetermined private var locationManager: CLLocationManager! private var activeRegions: [CLBeaconRegion] = [] private var beaconSamples: [String: [Int]] = [:] override init() { super.init() if Thread.isMainThread { setupLocationManager() } else { DispatchQueue.main.sync { setupLocationManager() } } } private func setupLocationManager() { locationManager = CLLocationManager() locationManager.delegate = self authorizationStatus = locationManager.authorizationStatus } func requestPermission() { locationManager.requestWhenInUseAuthorization() } func hasPermissions() -> Bool { let status = locationManager.authorizationStatus return status == .authorizedWhenInUse || status == .authorizedAlways } /// Start ranging for the given UUIDs. Call stopAndCollect() after your desired duration. func startRanging(uuids: [UUID]) { NSLog("\(BeaconScanner.TAG): startRanging called with \(uuids.count) UUIDs") stopRanging() beaconSamples.removeAll() guard !uuids.isEmpty else { NSLog("\(BeaconScanner.TAG): No target UUIDs provided") return } isScanning = true for uuid in uuids { let constraint = CLBeaconIdentityConstraint(uuid: uuid) let region = CLBeaconRegion(beaconIdentityConstraint: constraint, identifier: uuid.uuidString) activeRegions.append(region) NSLog("\(BeaconScanner.TAG): Starting ranging for UUID: \(uuid.uuidString)") locationManager.startRangingBeacons(satisfying: constraint) } NSLog("\(BeaconScanner.TAG): Ranging started for \(activeRegions.count) regions") } /// Stop ranging and return collected results sorted by signal strength. func stopAndCollect() -> [DetectedBeacon] { NSLog("\(BeaconScanner.TAG): stopAndCollect - beaconSamples has \(beaconSamples.count) entries") stopRanging() var results: [DetectedBeacon] = [] for (uuid, samples) in beaconSamples { let avgRssi = samples.reduce(0, +) / max(samples.count, 1) NSLog("\(BeaconScanner.TAG): Beacon \(uuid) - avgRssi=\(avgRssi), samples=\(samples.count)") results.append(DetectedBeacon(uuid: uuid, rssi: avgRssi, samples: samples.count)) } results.sort { $0.rssi > $1.rssi } return results } private func stopRanging() { for region in activeRegions { let constraint = CLBeaconIdentityConstraint(uuid: region.uuid) locationManager.stopRangingBeacons(satisfying: constraint) } activeRegions.removeAll() isScanning = false } // MARK: - CLLocationManagerDelegate func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { let status = manager.authorizationStatus NSLog("\(BeaconScanner.TAG): Authorization changed to \(status.rawValue)") authorizationStatus = status } func locationManager(_ manager: CLLocationManager, didRange beacons: [CLBeacon], satisfying constraint: CLBeaconIdentityConstraint) { for beacon in beacons { let rssiValue = beacon.rssi guard rssiValue >= BeaconScanner.MIN_RSSI && rssiValue < 0 else { continue } let uuid = beacon.uuid.uuidString.replacingOccurrences(of: "-", with: "").uppercased() if beaconSamples[uuid] == nil { beaconSamples[uuid] = [] } beaconSamples[uuid]?.append(rssiValue) } } func locationManager(_ manager: CLLocationManager, didFailRangingFor constraint: CLBeaconIdentityConstraint, error: Error) { NSLog("\(BeaconScanner.TAG): Ranging FAILED for \(constraint.uuid): \(error.localizedDescription)") } } struct DetectedBeacon { let uuid: String // 32-char uppercase hex, no dashes let rssi: Int let samples: Int }