215 lines
7.4 KiB
Swift
215 lines
7.4 KiB
Swift
import UIKit
|
|
import CoreBluetooth
|
|
import CoreLocation
|
|
|
|
/// Beacon scanner for task auto-completion using the Payfrit shard system.
|
|
/// Scans for ALL shard UUIDs and resolves via API to find the target ServicePoint.
|
|
final class BeaconScanner: NSObject, ObservableObject {
|
|
private let targetServicePointId: Int
|
|
private let onBeaconDetected: (Double) -> Void
|
|
private let onBluetoothOff: (() -> Void)?
|
|
private let onPermissionDenied: (() -> Void)?
|
|
|
|
@Published var isScanning = false
|
|
|
|
private var locationManager: CLLocationManager?
|
|
private var activeConstraints: [CLBeaconIdentityConstraint] = []
|
|
private var checkTimer: Timer?
|
|
|
|
// RSSI samples for dwell time enforcement
|
|
private var rssiSamples: [Int] = []
|
|
private let minSamplesToConfirm = 5 // ~5 seconds at 1Hz ranging
|
|
private let rssiThreshold = -75
|
|
|
|
// Track resolved beacons to avoid repeated API calls
|
|
private var resolvedBeacons: [String: Int] = [:] // "uuid-major-minor" -> servicePointId
|
|
private var pendingResolutions: Set<String> = []
|
|
|
|
init(targetServicePointId: Int,
|
|
onBeaconDetected: @escaping (Double) -> Void,
|
|
onBluetoothOff: (() -> Void)? = nil,
|
|
onPermissionDenied: (() -> Void)? = nil) {
|
|
self.targetServicePointId = targetServicePointId
|
|
self.onBeaconDetected = onBeaconDetected
|
|
self.onBluetoothOff = onBluetoothOff
|
|
self.onPermissionDenied = onPermissionDenied
|
|
super.init()
|
|
}
|
|
|
|
// Legacy init for backwards compatibility with UUID-based scanning
|
|
convenience init(targetUUID: String,
|
|
onBeaconDetected: @escaping (Double) -> Void,
|
|
onBluetoothOff: (() -> Void)? = nil,
|
|
onPermissionDenied: (() -> Void)? = nil) {
|
|
// Use 0 as a placeholder - this path won't use ServicePoint matching
|
|
self.init(targetServicePointId: 0,
|
|
onBeaconDetected: onBeaconDetected,
|
|
onBluetoothOff: onBluetoothOff,
|
|
onPermissionDenied: onPermissionDenied)
|
|
}
|
|
|
|
// MARK: - Start/Stop
|
|
|
|
func startScanning() {
|
|
guard !isScanning else { return }
|
|
|
|
locationManager = CLLocationManager()
|
|
locationManager?.delegate = self
|
|
|
|
let status = locationManager!.authorizationStatus
|
|
if status == .notDetermined {
|
|
locationManager?.requestWhenInUseAuthorization()
|
|
return
|
|
}
|
|
guard status == .authorizedWhenInUse || status == .authorizedAlways else {
|
|
onPermissionDenied?()
|
|
return
|
|
}
|
|
|
|
// Start ranging for ALL shard UUIDs
|
|
for uuid in BeaconShardPool.uuids {
|
|
let constraint = CLBeaconIdentityConstraint(uuid: uuid)
|
|
activeConstraints.append(constraint)
|
|
locationManager?.startRangingBeacons(satisfying: constraint)
|
|
}
|
|
|
|
isScanning = true
|
|
rssiSamples.removeAll()
|
|
|
|
// Keep screen 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?()
|
|
}
|
|
}
|
|
|
|
print("[BeaconScanner] Started scanning for \(BeaconShardPool.uuids.count) shard UUIDs, target ServicePointId: \(targetServicePointId)")
|
|
}
|
|
|
|
func stopScanning() {
|
|
isScanning = false
|
|
for constraint in activeConstraints {
|
|
locationManager?.stopRangingBeacons(satisfying: constraint)
|
|
}
|
|
activeConstraints.removeAll()
|
|
checkTimer?.invalidate()
|
|
checkTimer = nil
|
|
rssiSamples.removeAll()
|
|
DispatchQueue.main.async {
|
|
UIApplication.shared.isIdleTimerDisabled = false
|
|
}
|
|
}
|
|
|
|
func resetSamples() {
|
|
rssiSamples.removeAll()
|
|
}
|
|
|
|
func dispose() {
|
|
stopScanning()
|
|
locationManager = nil
|
|
}
|
|
|
|
// MARK: - Beacon Resolution
|
|
|
|
private func beaconKey(_ beacon: CLBeacon) -> String {
|
|
return "\(beacon.uuid.uuidString)-\(beacon.major)-\(beacon.minor)"
|
|
}
|
|
|
|
private func resolveBeacon(_ beacon: CLBeacon) {
|
|
let key = beaconKey(beacon)
|
|
guard resolvedBeacons[key] == nil && !pendingResolutions.contains(key) else { return }
|
|
|
|
pendingResolutions.insert(key)
|
|
|
|
Task {
|
|
do {
|
|
let servicePointId = try await APIService.shared.resolveServicePoint(
|
|
uuid: beacon.uuid.uuidString,
|
|
major: beacon.major.intValue,
|
|
minor: beacon.minor.intValue
|
|
)
|
|
await MainActor.run {
|
|
self.resolvedBeacons[key] = servicePointId
|
|
self.pendingResolutions.remove(key)
|
|
print("[BeaconScanner] Resolved \(key) -> ServicePointId \(servicePointId)")
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
self.pendingResolutions.remove(key)
|
|
print("[BeaconScanner] Failed to resolve \(key): \(error)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - CLLocationManagerDelegate
|
|
|
|
extension BeaconScanner: CLLocationManagerDelegate {
|
|
func locationManager(_ manager: CLLocationManager, didRange beacons: [CLBeacon],
|
|
satisfying constraint: CLBeaconIdentityConstraint) {
|
|
var foundTarget = false
|
|
|
|
for beacon in beacons {
|
|
let rssi = beacon.rssi
|
|
guard rssi != 0 else { continue }
|
|
|
|
// Check if this is a Payfrit beacon
|
|
guard BeaconShardPool.isPayfrit(beacon.uuid) else { continue }
|
|
|
|
let key = beaconKey(beacon)
|
|
|
|
// Try to resolve if not already resolved
|
|
if resolvedBeacons[key] == nil {
|
|
resolveBeacon(beacon)
|
|
continue
|
|
}
|
|
|
|
// Check if this is our target service point
|
|
guard resolvedBeacons[key] == targetServicePointId else { continue }
|
|
|
|
foundTarget = true
|
|
|
|
if rssi >= rssiThreshold {
|
|
rssiSamples.append(rssi)
|
|
|
|
if rssiSamples.count >= minSamplesToConfirm {
|
|
let avg = Double(rssiSamples.reduce(0, +)) / Double(rssiSamples.count)
|
|
print("[BeaconScanner] Target beacon confirmed! Avg RSSI: \(avg)")
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.onBeaconDetected(avg)
|
|
}
|
|
}
|
|
} else {
|
|
// Signal too weak, reset
|
|
if !rssiSamples.isEmpty { rssiSamples.removeAll() }
|
|
}
|
|
}
|
|
|
|
// Target beacon lost this cycle
|
|
if !foundTarget && !rssiSamples.isEmpty {
|
|
rssiSamples.removeAll()
|
|
}
|
|
}
|
|
|
|
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
|
let status = manager.authorizationStatus
|
|
if status == .authorizedWhenInUse || status == .authorizedAlways {
|
|
if !isScanning {
|
|
startScanning()
|
|
}
|
|
} else if status == .denied || status == .restricted {
|
|
onPermissionDenied?()
|
|
}
|
|
}
|
|
|
|
func locationManager(_ manager: CLLocationManager, didFailRangingFor constraint: CLBeaconIdentityConstraint, error: Error) {
|
|
print("[BeaconScanner] Ranging failed: \(error)")
|
|
}
|
|
}
|