payfrit-beacon-ios/PayfritBeacon/Services/BLEManager.swift
Schwifty 734a18356f fix: show all BLE devices in scan, no filtering
Remove the guard that dropped non-CP-28 devices. All discovered
BLE peripherals now appear in the scan list (defaulting to .dxsmart
type). detectBeaconType still classifies known CP-28 patterns but
unknown devices are no longer hidden.
2026-03-23 03:18:11 +00:00

251 lines
9 KiB
Swift

import Foundation
import CoreBluetooth
import Combine
/// Central BLE manager handles scanning and CP-28 beacon detection
@MainActor
final class BLEManager: NSObject, ObservableObject {
// MARK: - Published State
@Published var isScanning = false
@Published var discoveredBeacons: [DiscoveredBeacon] = []
@Published var bluetoothState: CBManagerState = .unknown
// MARK: - Constants
static let scanDuration: TimeInterval = 5.0
static let verifyScanDuration: TimeInterval = 15.0
static let verifyPollInterval: TimeInterval = 0.5
// CP-28 uses FFE0 service
static let ffe0Service = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB")
// DX-Smart factory default iBeacon UUID
static let dxSmartDefaultUUID = "E2C56DB5-DFFB-48D2-B060-D0F5A71096E0"
// MARK: - Connection Callbacks (used by provisioners)
var onPeripheralConnected: ((CBPeripheral) -> Void)?
var onPeripheralFailedToConnect: ((CBPeripheral, Error?) -> Void)?
var onPeripheralDisconnected: ((CBPeripheral, Error?) -> Void)?
// MARK: - Private
private(set) var centralManager: CBCentralManager!
private var scanTimer: Timer?
private var scanContinuation: CheckedContinuation<[DiscoveredBeacon], Never>?
// MARK: - Init
override init() {
super.init()
centralManager = CBCentralManager(delegate: self, queue: .main)
}
// MARK: - Scanning
/// Scan for beacons for the given duration. Returns discovered beacons sorted by RSSI.
func scan(duration: TimeInterval = scanDuration) async -> [DiscoveredBeacon] {
guard bluetoothState == .poweredOn else { return [] }
discoveredBeacons = []
isScanning = true
let results = await withCheckedContinuation { (continuation: CheckedContinuation<[DiscoveredBeacon], Never>) in
scanContinuation = continuation
centralManager.scanForPeripherals(withServices: nil, options: [
CBCentralManagerScanOptionAllowDuplicatesKey: true
])
scanTimer = Timer.scheduledTimer(withTimeInterval: duration, repeats: false) { [weak self] _ in
DispatchQueue.main.async {
self?.stopScan()
}
}
}
return results.sorted { $0.rssi > $1.rssi }
}
func stopScan() {
centralManager.stopScan()
scanTimer?.invalidate()
scanTimer = nil
isScanning = false
let results = discoveredBeacons
if let cont = scanContinuation {
scanContinuation = nil
cont.resume(returning: results)
}
}
/// Verify a beacon is broadcasting expected iBeacon values.
func verifyBroadcast(uuid: String, major: UInt16, minor: UInt16) async -> VerifyResult {
// TODO: Implement iBeacon region monitoring via CLLocationManager
return VerifyResult(
found: false,
rssi: nil,
message: "iBeacon verification requires CLLocationManager — coming soon"
)
}
// MARK: - iBeacon Manufacturer Data Parsing
/// Parse iBeacon data from manufacturer data (Apple company ID 0x4C00)
private func parseIBeaconData(_ mfgData: Data) -> (uuid: String, major: UInt16, minor: UInt16)? {
guard mfgData.count >= 25 else { return nil }
guard mfgData[0] == 0x4C && mfgData[1] == 0x00 &&
mfgData[2] == 0x02 && mfgData[3] == 0x15 else { return nil }
let uuidBytes = mfgData.subdata(in: 4..<20)
let hex = uuidBytes.map { String(format: "%02X", $0) }.joined()
let uuid = "\(hex.prefix(8))-\(hex.dropFirst(8).prefix(4))-\(hex.dropFirst(12).prefix(4))-\(hex.dropFirst(16).prefix(4))-\(hex.dropFirst(20))"
let major = UInt16(mfgData[20]) << 8 | UInt16(mfgData[21])
let minor = UInt16(mfgData[22]) << 8 | UInt16(mfgData[23])
return (uuid: uuid, major: major, minor: minor)
}
// MARK: - CP-28 Detection
// Only detect DX-Smart / CP-28 beacons. Everything else is ignored.
func detectBeaconType(
name: String?,
serviceUUIDs: [CBUUID]?,
manufacturerData: Data?
) -> BeaconType? {
let deviceName = (name ?? "").lowercased()
// Parse iBeacon data if available
let iBeaconData: (uuid: String, major: UInt16, minor: UInt16)?
if let mfgData = manufacturerData {
iBeaconData = parseIBeaconData(mfgData)
} else {
iBeaconData = nil
}
// 1. Service UUID: CP-28 uses FFE0
if let services = serviceUUIDs {
let serviceStrings = services.map { $0.uuidString.uppercased() }
if serviceStrings.contains(where: { $0.hasPrefix("0000FFE0") }) {
// FFE0 with DX name patterns definitely CP-28
if deviceName.contains("cp28") || deviceName.contains("cp-28") ||
deviceName.contains("dx") || deviceName.contains("pddaxlque") {
return .dxsmart
}
// FFE0 without a specific name still likely CP-28
return .dxsmart
}
// CP-28 also advertises FFF0 on some firmware
if serviceStrings.contains(where: { $0.hasPrefix("0000FFF0") }) {
// Any FFF0 device is likely CP-28 don't filter by name
return .dxsmart
}
}
// 2. DX-Smart factory default iBeacon UUID
if let ibeacon = iBeaconData {
if ibeacon.uuid.caseInsensitiveCompare(Self.dxSmartDefaultUUID) == .orderedSame {
return .dxsmart
}
// Already provisioned with a Payfrit shard UUID
if BeaconShardPool.isPayfrit(ibeacon.uuid) {
return .dxsmart
}
}
// 3. Device name patterns for CP-28 (includes "payfrit" our own provisioned name)
if deviceName.contains("cp28") || deviceName.contains("cp-28") ||
deviceName.contains("dx-cp") || deviceName.contains("dx-smart") ||
deviceName.contains("dxsmart") || deviceName.contains("pddaxlque") ||
deviceName.contains("payfrit") {
return .dxsmart
}
// 4. iBeacon minor in high range (factory default DX pattern)
if let ibeacon = iBeaconData, ibeacon.minor > 10000 {
return .dxsmart
}
// 5. Any iBeacon advertisement likely a CP-28 in the field
if iBeaconData != nil {
return .dxsmart
}
// Not a CP-28 don't show it
return nil
}
}
// MARK: - CBCentralManagerDelegate
extension BLEManager: CBCentralManagerDelegate {
nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) {
let state = central.state
DispatchQueue.main.async { [weak self] in
self?.bluetoothState = state
}
}
nonisolated func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
DispatchQueue.main.async { [weak self] in
self?.onPeripheralConnected?(peripheral)
}
}
nonisolated func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
DispatchQueue.main.async { [weak self] in
self?.onPeripheralFailedToConnect?(peripheral, error)
}
}
nonisolated func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
DispatchQueue.main.async { [weak self] in
self?.onPeripheralDisconnected?(peripheral, error)
}
}
nonisolated func centralManager(
_ central: CBCentralManager,
didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any],
rssi RSSI: NSNumber
) {
let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? ""
let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID]
let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
let peripheralId = peripheral.identifier
let rssiValue = RSSI.intValue
DispatchQueue.main.async { [weak self] in
guard let self else { return }
// Detect beacon type default to .dxsmart so ALL devices show up in scan
let type = self.detectBeaconType(name: name, serviceUUIDs: serviceUUIDs, manufacturerData: mfgData) ?? .dxsmart
if let idx = self.discoveredBeacons.firstIndex(where: { $0.id == peripheralId }) {
self.discoveredBeacons[idx].rssi = rssiValue
self.discoveredBeacons[idx].lastSeen = Date()
} else {
let beacon = DiscoveredBeacon(
id: peripheralId,
peripheral: peripheral,
name: name,
type: type,
rssi: rssiValue,
lastSeen: Date()
)
self.discoveredBeacons.append(beacon)
}
self.discoveredBeacons.sort { $0.rssi > $1.rssi }
}
}
}