import Foundation import CoreBluetooth /// Beacon type detected by service UUID or name enum BeaconType: String { case kbeacon = "KBeacon" case dxsmart = "DX-Smart" 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 { return 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 1 second (beacons advertise every ~200ms so 1s is plenty) scanTimer?.invalidate() scanTimer = Timer.scheduledTimer(withTimeInterval: 1.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 > -70 && rssiValue < 0 else { return } // Only show nearby devices let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "" let nameUpper = name.uppercased() // Best-effort type hint from advertised services OR device name // Note: Some DX-Smart beacons don't advertise FFE0 in scan response, so also filter by name var beaconType: BeaconType = .unknown if let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] { if serviceUUIDs.contains(BLEBeaconScanner.KBEACON_SERVICE) { beaconType = .dxsmart } else if serviceUUIDs.contains(BLEBeaconScanner.BLUECHARM_SERVICE) { beaconType = .bluecharm } } // Also detect DX-Smart by name pattern (some beacons don't advertise FFE0) if beaconType == .unknown && (nameUpper.contains("DX") || nameUpper.contains("SMART")) { beaconType = .dxsmart } // 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 } } }