payfrit-works-ios/PayfritWorks/Services/BeaconScanner.swift
2026-02-01 23:38:34 -08:00

152 lines
5.1 KiB
Swift

import UIKit
import CoreBluetooth
import CoreLocation
/// Beacon scanner for task auto-completion.
/// Scans for a specific UUID and triggers callback after dwell time is met.
final class BeaconScanner: NSObject, ObservableObject {
private let targetUUID: String
private let onBeaconDetected: (Double) -> Void
private let onBluetoothOff: (() -> Void)?
private let onPermissionDenied: (() -> Void)?
@Published var isScanning = false
private var locationManager: CLLocationManager?
private var activeConstraint: CLBeaconIdentityConstraint?
private var checkTimer: Timer?
// RSSI samples for dwell time enforcement
private var rssiSamples: [Int] = []
private let minSamplesToConfirm = 5 // ~5 seconds
private let rssiThreshold = -75
init(targetUUID: String,
onBeaconDetected: @escaping (Double) -> Void,
onBluetoothOff: (() -> Void)? = nil,
onPermissionDenied: (() -> Void)? = nil) {
self.targetUUID = targetUUID
self.onBeaconDetected = onBeaconDetected
self.onBluetoothOff = onBluetoothOff
self.onPermissionDenied = onPermissionDenied
super.init()
}
// MARK: - UUID formatting
private 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..<clean.index(i, offsetBy: 8)]
let p2 = clean[clean.index(i, offsetBy: 8)..<clean.index(i, offsetBy: 12)]
let p3 = clean[clean.index(i, offsetBy: 12)..<clean.index(i, offsetBy: 16)]
let p4 = clean[clean.index(i, offsetBy: 16)..<clean.index(i, offsetBy: 20)]
let p5 = clean[clean.index(i, offsetBy: 20)..<clean.index(i, offsetBy: 32)]
return "\(p1)-\(p2)-\(p3)-\(p4)-\(p5)"
}
// MARK: - Start/Stop
func startScanning() {
guard !isScanning else { return }
let formatted = formatUUID(targetUUID)
guard let uuid = UUID(uuidString: formatted) else { return }
locationManager = CLLocationManager()
locationManager?.delegate = self
let status = locationManager!.authorizationStatus
guard status == .authorizedWhenInUse || status == .authorizedAlways else {
onPermissionDenied?()
return
}
let constraint = CLBeaconIdentityConstraint(uuid: uuid)
activeConstraint = constraint
locationManager?.startRangingBeacons(satisfying: constraint)
isScanning = true
rssiSamples.removeAll()
// Idle timer disabled so screen stays on during scanning
DispatchQueue.main.async {
UIApplication.shared.isIdleTimerDisabled = true
}
// Periodic Bluetooth check
checkTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
if CBCentralManager.authorization == .denied ||
CBCentralManager.authorization == .restricted {
self?.onBluetoothOff?()
}
}
}
func stopScanning() {
isScanning = false
if let constraint = activeConstraint {
locationManager?.stopRangingBeacons(satisfying: constraint)
}
activeConstraint = nil
checkTimer?.invalidate()
checkTimer = nil
rssiSamples.removeAll()
DispatchQueue.main.async {
UIApplication.shared.isIdleTimerDisabled = false
}
}
func resetSamples() {
rssiSamples.removeAll()
}
func dispose() {
stopScanning()
locationManager = nil
}
}
// MARK: - CLLocationManagerDelegate
extension BeaconScanner: CLLocationManagerDelegate {
func locationManager(_ manager: CLLocationManager, didRange beacons: [CLBeacon],
satisfying constraint: CLBeaconIdentityConstraint) {
let normalizedTarget = targetUUID.replacingOccurrences(of: "-", with: "").uppercased()
var foundThisCycle = false
for beacon in beacons {
let rssi = beacon.rssi
guard rssi != 0 else { continue }
let detectedUUID = beacon.uuid.uuidString.replacingOccurrences(of: "-", with: "").uppercased()
guard detectedUUID == normalizedTarget else { continue }
foundThisCycle = true
if rssi >= rssiThreshold {
rssiSamples.append(rssi)
if rssiSamples.count >= minSamplesToConfirm {
let avg = Double(rssiSamples.reduce(0, +)) / Double(rssiSamples.count)
DispatchQueue.main.async { [weak self] in
self?.onBeaconDetected(avg)
}
}
} else {
// Signal too weak, reset
if !rssiSamples.isEmpty { rssiSamples.removeAll() }
}
}
// Beacon lost this cycle
if !foundThisCycle && !rssiSamples.isEmpty {
rssiSamples.removeAll()
}
}
func locationManager(_ manager: CLLocationManager, didFailRangingFor constraint: CLBeaconIdentityConstraint, error: Error) {
// Ranging failed - could be Bluetooth off
}
}