The CP-28-only refactor accidentally over-filtered the BLE scan: 1. FFF0 service detection was gated on name patterns — CP-28 beacons advertising FFF0 with non-matching names (e.g. already provisioned as "Payfrit") were silently filtered out. Restored unconditional FFF0 → dxsmart mapping (matching old behavior). 2. Already-provisioned beacons broadcast with name "Payfrit" (set by old SDK cmd 0x43), but that name wasn't in the detection patterns. Added "payfrit" to the name check. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
253 lines
9.1 KiB
Swift
253 lines
9.1 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 }
|
|
|
|
// Only show CP-28 beacons — everything else is filtered out
|
|
guard let type = self.detectBeaconType(name: name, serviceUUIDs: serviceUUIDs, manufacturerData: mfgData) else {
|
|
return
|
|
}
|
|
|
|
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 }
|
|
}
|
|
}
|
|
}
|