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] = [] // Key: "UUID|Major|Minor", Value: beacon sample data private var beaconSamples: [String: BeaconSampleData] = [:] private struct BeaconSampleData { let uuid: String let major: UInt16 let minor: UInt16 var rssiSamples: [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 (_, data) in beaconSamples { let avgRssi = data.rssiSamples.reduce(0, +) / max(data.rssiSamples.count, 1) NSLog("\(BeaconScanner.TAG): Beacon \(data.uuid) major=\(data.major) minor=\(data.minor) - avgRssi=\(avgRssi), samples=\(data.rssiSamples.count)") results.append(DetectedBeacon( uuid: data.uuid, major: data.major, minor: data.minor, rssi: avgRssi, samples: data.rssiSamples.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.normalizedUUID let major = beacon.major.uint16Value let minor = beacon.minor.uint16Value let key = "\(uuid)|\(major)|\(minor)" if beaconSamples[key] == nil { beaconSamples[key] = BeaconSampleData(uuid: uuid, major: major, minor: minor, rssiSamples: []) } beaconSamples[key]?.rssiSamples.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 major: UInt16 let minor: UInt16 let rssi: Int let samples: Int /// Format for clipboard (for pasting into manufacturer beacon config apps) func copyableConfig() -> String { // Format UUID with dashes for standard display return "UUID: \(uuid.uuidWithDashes)\nMajor: \(major)\nMinor: \(minor)" } }