import Foundation import CoreBluetooth /// Beacon type detected by service UUID enum BeaconType: String { case kbeacon = "KBeacon" case bluecharm = "BlueCharm" case unknown = "Unknown" } /// A discovered BLE beacon that can be provisioned struct DiscoveredBeacon: Identifiable { let id: UUID // CoreBluetooth peripheral identifier let peripheral: CBPeripheral let name: String let type: BeaconType var rssi: Int var lastSeen: Date var displayName: String { if name.isEmpty || name == "Unknown" { return "\(type.rawValue) (\(id.uuidString.prefix(8))...)" } return name } } /// Scans for BLE beacons that can be configured (KBeacon and BlueCharm) class BLEBeaconScanner: NSObject, ObservableObject { // KBeacon config service static let KBEACON_SERVICE = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB") // BlueCharm config service static let BLUECHARM_SERVICE = CBUUID(string: "0000FFF0-0000-1000-8000-00805F9B34FB") @Published var isScanning = false @Published var discoveredBeacons: [DiscoveredBeacon] = [] @Published var bluetoothState: CBManagerState = .unknown private var centralManager: CBCentralManager! private var scanTimer: Timer? override init() { super.init() centralManager = CBCentralManager(delegate: self, queue: .main) } /// Start scanning for configurable beacons func startScanning() { guard centralManager.state == .poweredOn else { NSLog("BLEBeaconScanner: Bluetooth not ready, state=\(centralManager.state.rawValue)") return } NSLog("BLEBeaconScanner: Starting scan for configurable beacons") discoveredBeacons.removeAll() isScanning = true // Scan for devices advertising our config services // Note: We scan for all devices and filter by service after connection // because some beacons don't advertise their config service UUID centralManager.scanForPeripherals( withServices: nil, // Scan all - we'll filter by name/characteristics options: [CBCentralManagerScanOptionAllowDuplicatesKey: true] ) // Auto-stop after 10 seconds scanTimer?.invalidate() scanTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: false) { [weak self] _ in self?.stopScanning() } } /// Stop scanning func stopScanning() { NSLog("BLEBeaconScanner: Stopping scan, found \(discoveredBeacons.count) beacons") centralManager.stopScan() isScanning = false scanTimer?.invalidate() scanTimer = nil } /// Check if Bluetooth is available var isBluetoothReady: Bool { centralManager.state == .poweredOn } } // MARK: - CBCentralManagerDelegate extension BLEBeaconScanner: CBCentralManagerDelegate { func centralManagerDidUpdateState(_ central: CBCentralManager) { bluetoothState = central.state NSLog("BLEBeaconScanner: Bluetooth state changed to \(central.state.rawValue)") if central.state == .poweredOn && isScanning { // Resume scanning if we were trying to scan startScanning() } } func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { let rssiValue = RSSI.intValue guard rssiValue > -90 && rssiValue < 0 else { return } // Filter weak signals let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "" // Determine beacon type from name or advertised services var beaconType: BeaconType = .unknown // Check advertised service UUIDs if let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] { if serviceUUIDs.contains(BLEBeaconScanner.KBEACON_SERVICE) { beaconType = .kbeacon } else if serviceUUIDs.contains(BLEBeaconScanner.BLUECHARM_SERVICE) { beaconType = .bluecharm } } // Also check by name patterns if beaconType == .unknown { let lowerName = name.lowercased() if lowerName.contains("kbeacon") || lowerName.contains("kbpro") || lowerName.hasPrefix("kb") { beaconType = .kbeacon } else if lowerName.contains("bluecharm") || lowerName.contains("bc") || lowerName.hasPrefix("bc") { beaconType = .bluecharm } } // Only track beacons we can identify guard beaconType != .unknown else { return } // Update or add beacon if let index = discoveredBeacons.firstIndex(where: { $0.id == peripheral.identifier }) { discoveredBeacons[index].rssi = rssiValue discoveredBeacons[index].lastSeen = Date() } else { let beacon = DiscoveredBeacon( id: peripheral.identifier, peripheral: peripheral, name: name, type: beaconType, rssi: rssiValue, lastSeen: Date() ) discoveredBeacons.append(beacon) NSLog("BLEBeaconScanner: Discovered \(beaconType.rawValue) beacon: \(name) RSSI=\(rssiValue)") } // Sort by RSSI (strongest first) discoveredBeacons.sort { $0.rssi > $1.rssi } } }