import UIKit import CoreBluetooth import CoreLocation /// Beacon scanner for detecting BLE beacons by UUID. /// Uses CoreLocation iBeacon ranging with RSSI-based dwell time enforcement. /// All mutable state is confined to the main thread via @MainActor. @MainActor final class BeaconScanner: NSObject, ObservableObject { private let targetUUID: String private let normalizedTargetUUID: String private let onBeaconDetected: (Double) -> Void private let onRSSIUpdate: ((Int, Int) -> Void)? private let onBluetoothOff: (() -> Void)? private let onPermissionDenied: (() -> Void)? private let onError: ((String) -> Void)? @Published var isScanning = false private var locationManager: CLLocationManager? private var activeConstraint: CLBeaconIdentityConstraint? private var checkTimer: Timer? private var bluetoothManager: CBCentralManager? // RSSI samples for dwell time enforcement private var rssiSamples: [Int] = [] private let minSamplesToConfirm = 5 // ~5 seconds private let rssiThreshold = -75 private var hasConfirmed = false private var isPendingPermission = false init(targetUUID: String, onBeaconDetected: @escaping (Double) -> Void, onRSSIUpdate: ((Int, Int) -> Void)? = nil, onBluetoothOff: (() -> Void)? = nil, onPermissionDenied: (() -> Void)? = nil, onError: ((String) -> Void)? = nil) { self.targetUUID = targetUUID self.normalizedTargetUUID = targetUUID.replacingOccurrences(of: "-", with: "").uppercased() self.onBeaconDetected = onBeaconDetected self.onRSSIUpdate = onRSSIUpdate self.onBluetoothOff = onBluetoothOff self.onPermissionDenied = onPermissionDenied self.onError = onError super.init() } // MARK: - UUID formatting private nonisolated func formatUUID(_ uuid: String) -> String { let clean = uuid.replacingOccurrences(of: "-", with: "").uppercased() guard clean.count == 32 else { return uuid } let i = clean.startIndex let p1 = clean[i..= rssiThreshold { rssiSamples.append(rssi) onRSSIUpdate?(rssi, rssiSamples.count) if rssiSamples.count >= minSamplesToConfirm { let avg = Double(rssiSamples.reduce(0, +)) / Double(rssiSamples.count) hasConfirmed = true onBeaconDetected(avg) return } } else { if !rssiSamples.isEmpty { rssiSamples.removeAll() onRSSIUpdate?(rssi, 0) } } } if !foundThisCycle && !rssiSamples.isEmpty { rssiSamples.removeAll() onRSSIUpdate?(0, 0) } } fileprivate func handleRangingError(_ error: Error) { onError?("Beacon ranging failed: \(error.localizedDescription)") } fileprivate func handleAuthorizationChange(_ status: CLAuthorizationStatus) { if status == .authorizedWhenInUse || status == .authorizedAlways { // Permission granted — start ranging only if we were waiting for permission if isPendingPermission && !isScanning { isPendingPermission = false let formatted = formatUUID(targetUUID) if let uuid = UUID(uuidString: formatted) { beginRanging(uuid: uuid) } } } else if status == .denied || status == .restricted { isPendingPermission = false stopScanning() onPermissionDenied?() } } } // MARK: - CLLocationManagerDelegate // These delegate callbacks arrive on the main thread since CLLocationManager was created on main. // We forward to @MainActor methods above. extension BeaconScanner: CLLocationManagerDelegate { nonisolated func locationManager(_ manager: CLLocationManager, didRange beacons: [CLBeacon], satisfying constraint: CLBeaconIdentityConstraint) { Task { @MainActor in self.handleRangedBeacons(beacons) } } nonisolated func locationManager(_ manager: CLLocationManager, didFailRangingFor constraint: CLBeaconIdentityConstraint, error: Error) { Task { @MainActor in self.handleRangingError(error) } } nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { Task { @MainActor in self.handleAuthorizationChange(manager.authorizationStatus) } } } // MARK: - CBCentralManagerDelegate extension BeaconScanner: CBCentralManagerDelegate { nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) { Task { @MainActor in if central.state == .poweredOff { self.stopScanning() self.onBluetoothOff?() } } } }