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>
153 lines
5.3 KiB
Swift
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)"
|
|
}
|
|
}
|