payfrit-beacon-ios/PayfritBeacon/BeaconScanner.swift
Schwifty 237ac38557 refactor: consolidate UUID formatting into shared String extension
Created UUIDFormatting.swift with .normalizedUUID and .uuidWithDashes
String extensions, replacing 4 duplicate formatUuidWithDashes() methods
and 6+ inline .replacingOccurrences(of: "-", with: "").uppercased() calls
across Api.swift, BeaconScanner.swift, ScanView.swift,
ServicePointListView.swift, BeaconBanList.swift, and BeaconProvisioner.swift.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 10:02:58 +00:00

153 lines
5.3 KiB
Swift

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)"
}
}