From 5eebf00aa03984c3e60e56526e220acf4e474ae1 Mon Sep 17 00:00:00 2001 From: Schwifty Date: Mon, 23 Mar 2026 02:55:22 +0000 Subject: [PATCH] refactor: strip all non-CP-28 beacon code Remove BlueCharmProvisioner, KBeaconProvisioner, and FallbackProvisioner. Simplify BeaconType enum to DX-Smart only. Simplify BLE detection to only show CP-28 beacons. Remove multi-type provisioner factory from ScanView. -989 lines of dead code removed. Other beacon types will be re-added when we start using different hardware. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 11 +- PayfritBeacon.xcodeproj/project.pbxproj | 12 - PayfritBeacon/Models/BeaconType.swift | 6 +- .../Provisioners/BlueCharmProvisioner.swift | 416 ------------------ .../Provisioners/DXSmartProvisioner.swift | 5 +- .../Provisioners/FallbackProvisioner.swift | 83 ---- .../Provisioners/KBeaconProvisioner.swift | 311 ------------- .../Provisioners/ProvisionerProtocol.swift | 12 +- PayfritBeacon/Services/BLEManager.swift | 113 ++--- PayfritBeacon/Views/ScanView.swift | 83 +--- 10 files changed, 51 insertions(+), 1001 deletions(-) delete mode 100644 PayfritBeacon/Provisioners/BlueCharmProvisioner.swift delete mode 100644 PayfritBeacon/Provisioners/FallbackProvisioner.swift delete mode 100644 PayfritBeacon/Provisioners/KBeaconProvisioner.swift diff --git a/CLAUDE.md b/CLAUDE.md index 60fd29b..55a02fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,15 +63,12 @@ PayfritBeacon/ │ └── PayfritBeaconApp.swift App entry point ├── Models/ │ ├── BeaconConfig.swift Provisioning config (UUID, major, minor, txPower, etc.) -│ ├── BeaconType.swift Enum: DXSmart, BlueCharm, KBeacon, Unknown +│ ├── BeaconType.swift Enum: DXSmart (CP-28 only) │ ├── Business.swift Business model │ └── ServicePoint.swift Service point model ├── Provisioners/ -│ ├── ProvisionerProtocol.swift Protocol — all provisioners implement this -│ ├── DXSmartProvisioner.swift DX-Smart CP28 GATT provisioner (24-step write sequence) -│ ├── BlueCharmProvisioner.swift BlueCharm BC037 provisioner -│ ├── KBeaconProvisioner.swift KBeacon provisioner -│ ├── FallbackProvisioner.swift Unknown device fallback +│ ├── ProvisionerProtocol.swift Protocol + CP-28 GATT constants +│ ├── DXSmartProvisioner.swift DX-Smart CP-28 GATT provisioner (24-step write sequence) │ └── ProvisionError.swift Shared error types ├── Services/ │ ├── APIClient.swift Actor-based REST client, all API calls @@ -93,7 +90,7 @@ PayfritBeacon/ ## Key Architecture Notes -- **Modular provisioners**: Each beacon manufacturer has its own provisioner conforming to `ProvisionerProtocol`. No more monolithic `BeaconProvisioner.swift`. +- **CP-28 only**: Only DX-Smart CP-28 beacons are supported. Other beacon types (KBeacon, BlueCharm) were removed — will be re-added when needed. - **Actor-based API**: `APIClient` is a Swift actor (thread-safe by design). - **Secure storage**: Auth tokens stored in Keychain via `SecureStorage`, not UserDefaults. - **BLE scanning**: `BLEManager` handles CoreBluetooth device discovery and beacon type identification. diff --git a/PayfritBeacon.xcodeproj/project.pbxproj b/PayfritBeacon.xcodeproj/project.pbxproj index 83c1d66..3824415 100644 --- a/PayfritBeacon.xcodeproj/project.pbxproj +++ b/PayfritBeacon.xcodeproj/project.pbxproj @@ -16,9 +16,6 @@ A01000000013 /* ServicePoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000013 /* ServicePoint.swift */; }; A01000000020 /* ProvisionerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000020 /* ProvisionerProtocol.swift */; }; A01000000021 /* DXSmartProvisioner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000021 /* DXSmartProvisioner.swift */; }; - A01000000022 /* BlueCharmProvisioner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000022 /* BlueCharmProvisioner.swift */; }; - A01000000023 /* KBeaconProvisioner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000023 /* KBeaconProvisioner.swift */; }; - A01000000024 /* FallbackProvisioner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000024 /* FallbackProvisioner.swift */; }; A01000000025 /* ProvisionError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000025 /* ProvisionError.swift */; }; A01000000030 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000030 /* APIClient.swift */; }; A01000000031 /* APIConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000031 /* APIConfig.swift */; }; @@ -50,9 +47,6 @@ A02000000013 /* ServicePoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServicePoint.swift; sourceTree = ""; }; A02000000020 /* ProvisionerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProvisionerProtocol.swift; sourceTree = ""; }; A02000000021 /* DXSmartProvisioner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DXSmartProvisioner.swift; sourceTree = ""; }; - A02000000022 /* BlueCharmProvisioner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlueCharmProvisioner.swift; sourceTree = ""; }; - A02000000023 /* KBeaconProvisioner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KBeaconProvisioner.swift; sourceTree = ""; }; - A02000000024 /* FallbackProvisioner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FallbackProvisioner.swift; sourceTree = ""; }; A02000000025 /* ProvisionError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProvisionError.swift; sourceTree = ""; }; A02000000030 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = ""; }; A02000000031 /* APIConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIConfig.swift; sourceTree = ""; }; @@ -144,10 +138,7 @@ A05000000003 /* Provisioners */ = { isa = PBXGroup; children = ( - A02000000022 /* BlueCharmProvisioner.swift */, A02000000021 /* DXSmartProvisioner.swift */, - A02000000024 /* FallbackProvisioner.swift */, - A02000000023 /* KBeaconProvisioner.swift */, A02000000025 /* ProvisionError.swift */, A02000000020 /* ProvisionerProtocol.swift */, ); @@ -271,9 +262,6 @@ A01000000013 /* ServicePoint.swift in Sources */, A01000000020 /* ProvisionerProtocol.swift in Sources */, A01000000021 /* DXSmartProvisioner.swift in Sources */, - A01000000022 /* BlueCharmProvisioner.swift in Sources */, - A01000000023 /* KBeaconProvisioner.swift in Sources */, - A01000000024 /* FallbackProvisioner.swift in Sources */, A01000000025 /* ProvisionError.swift in Sources */, A01000000030 /* APIClient.swift in Sources */, A01000000031 /* APIConfig.swift in Sources */, diff --git a/PayfritBeacon/Models/BeaconType.swift b/PayfritBeacon/Models/BeaconType.swift index 8c0bf4c..f34a85b 100644 --- a/PayfritBeacon/Models/BeaconType.swift +++ b/PayfritBeacon/Models/BeaconType.swift @@ -1,12 +1,10 @@ import Foundation import CoreBluetooth -/// Supported beacon hardware types +/// Beacon hardware type — CP-28 (DX-Smart) only for now. +/// Other types will be added when we start using different beacon hardware. enum BeaconType: String, CaseIterable { - case kbeacon = "KBeacon" case dxsmart = "DX-Smart" - case bluecharm = "BlueCharm" - case unknown = "Unknown" } /// A BLE beacon discovered during scanning diff --git a/PayfritBeacon/Provisioners/BlueCharmProvisioner.swift b/PayfritBeacon/Provisioners/BlueCharmProvisioner.swift deleted file mode 100644 index a25091c..0000000 --- a/PayfritBeacon/Provisioners/BlueCharmProvisioner.swift +++ /dev/null @@ -1,416 +0,0 @@ -import Foundation -import CoreBluetooth - -/// Provisioner for BlueCharm / BC04P hardware -/// -/// Supports two service variants: -/// - FEA0 service (BC04P): FEA1 write, FEA2 notify, FEA3 config -/// - FFF0 service (legacy): FFF1 password, FFF2 UUID, FFF3 major, FFF4 minor -/// -/// BC04P write methods (tried in order): -/// 1. Direct config write to FEA3: [0x01] + UUID + Major + Minor + TxPower -/// 2. Raw data write to FEA1: UUID + Major + Minor + TxPower + Interval, then save commands -/// 3. Indexed parameter writes to FEA1: [index] + data, then [0xFF] save -/// -/// Legacy write: individual characteristics per parameter (FFF1-FFF4) -final class BlueCharmProvisioner: NSObject, BeaconProvisioner { - - // MARK: - Constants - - // 5 passwords matching Android (16 bytes each) - private static let passwords: [Data] = [ - Data(repeating: 0, count: 16), // All zeros - "0000000000000000".data(using: .utf8)!, // ASCII zeros - "1234567890123456".data(using: .utf8)!, // Common - "minew123".data(using: .utf8)!.padded(to: 16), // Minew default - "bc04p".data(using: .utf8)!.padded(to: 16), // Model name - ] - - // Legacy FFF0 passwords - private static let legacyPasswords = ["000000", "123456", "bc0000"] - - // Legacy characteristic UUIDs - private static let fff1Password = CBUUID(string: "0000FFF1-0000-1000-8000-00805F9B34FB") - private static let fff2UUID = CBUUID(string: "0000FFF2-0000-1000-8000-00805F9B34FB") - private static let fff3Major = CBUUID(string: "0000FFF3-0000-1000-8000-00805F9B34FB") - private static let fff4Minor = CBUUID(string: "0000FFF4-0000-1000-8000-00805F9B34FB") - - // FEA0 characteristic UUIDs - private static let fea1Write = CBUUID(string: "0000FEA1-0000-1000-8000-00805F9B34FB") - private static let fea2Notify = CBUUID(string: "0000FEA2-0000-1000-8000-00805F9B34FB") - private static let fea3Config = CBUUID(string: "0000FEA3-0000-1000-8000-00805F9B34FB") - - // MARK: - State - - private let peripheral: CBPeripheral - private let centralManager: CBCentralManager - - private var discoveredService: CBService? - private var writeChar: CBCharacteristic? // FEA1 or first writable - private var notifyChar: CBCharacteristic? // FEA2 - private var configChar: CBCharacteristic? // FEA3 - private var isLegacy = false // Using FFF0 service - - private var connectionContinuation: CheckedContinuation? - private var serviceContinuation: CheckedContinuation? - private var writeContinuation: CheckedContinuation? - private var writeOKContinuation: CheckedContinuation? - - private(set) var isConnected = false - var diagnosticLog: ProvisionLog? - var bleManager: BLEManager? - - // MARK: - Init - - init(peripheral: CBPeripheral, centralManager: CBCentralManager) { - self.peripheral = peripheral - self.centralManager = centralManager - super.init() - self.peripheral.delegate = self - } - - // MARK: - BeaconProvisioner - - func connect() async throws { - for attempt in 1...GATTConstants.maxRetries { - do { - try await connectOnce() - try await discoverServices() - if !isLegacy { - try await authenticateBC04P() - } - isConnected = true - return - } catch { - disconnect() - if attempt < GATTConstants.maxRetries { - try await Task.sleep(nanoseconds: UInt64(GATTConstants.retryDelay * 1_000_000_000)) - } else { - throw error - } - } - } - } - - func writeConfig(_ config: BeaconConfig) async throws { - guard isConnected else { - throw ProvisionError.notConnected - } - - let uuidBytes = config.uuid.hexToBytes - guard uuidBytes.count == 16 else { - throw ProvisionError.writeFailed("Invalid UUID length") - } - - if isLegacy { - try await writeLegacy(config, uuidBytes: uuidBytes) - } else { - try await writeBC04P(config, uuidBytes: uuidBytes) - } - } - - func disconnect() { - if peripheral.state == .connected || peripheral.state == .connecting { - centralManager.cancelPeripheralConnection(peripheral) - } - isConnected = false - } - - // MARK: - BC04P Write (3 fallback methods, matching Android) - - private func writeBC04P(_ config: BeaconConfig, uuidBytes: [UInt8]) async throws { - let majorBytes = Data([UInt8(config.major >> 8), UInt8(config.major & 0xFF)]) - let minorBytes = Data([UInt8(config.minor >> 8), UInt8(config.minor & 0xFF)]) - let txPowerByte = config.txPower - let intervalUnits = UInt16(Double(config.advInterval) * 100.0 / 0.625) - let intervalBytes = Data([UInt8(intervalUnits >> 8), UInt8(intervalUnits & 0xFF)]) - - // Method 1: Write directly to FEA3 (config characteristic) - if let fea3 = configChar { - var iBeaconData = Data([0x01]) // iBeacon frame type - iBeaconData.append(contentsOf: uuidBytes) - iBeaconData.append(majorBytes) - iBeaconData.append(minorBytes) - iBeaconData.append(txPowerByte) - - if let _ = try? await writeDirectAndWait(fea3, data: iBeaconData) { - try await Task.sleep(nanoseconds: 500_000_000) - return // Success - } - } - - // Method 2: Raw data write to FEA1 - if let fea1 = writeChar { - var rawData = Data(uuidBytes) - rawData.append(majorBytes) - rawData.append(minorBytes) - rawData.append(txPowerByte) - rawData.append(intervalBytes) - - if let _ = try? await writeDirectAndWait(fea1, data: rawData) { - try await Task.sleep(nanoseconds: 300_000_000) - - // Send save/apply commands (matching Android) - let _ = try? await writeDirectAndWait(fea1, data: Data([0xFF])) // Save variant 1 - try await Task.sleep(nanoseconds: 200_000_000) - let _ = try? await writeDirectAndWait(fea1, data: Data([0x00])) // Apply/commit - try await Task.sleep(nanoseconds: 200_000_000) - let _ = try? await writeDirectAndWait(fea1, data: Data([0x57])) // KBeacon save - try await Task.sleep(nanoseconds: 200_000_000) - let _ = try? await writeDirectAndWait(fea1, data: Data([0x01, 0x00])) // Enable slot 0 - try await Task.sleep(nanoseconds: 500_000_000) - return - } - } - - // Method 3: Indexed parameter writes to FEA1 - if let fea1 = writeChar { - // Index 0 = UUID - let _ = try? await writeDirectAndWait(fea1, data: Data([0x00]) + Data(uuidBytes)) - try await Task.sleep(nanoseconds: 100_000_000) - - // Index 1 = Major - let _ = try? await writeDirectAndWait(fea1, data: Data([0x01]) + majorBytes) - try await Task.sleep(nanoseconds: 100_000_000) - - // Index 2 = Minor - let _ = try? await writeDirectAndWait(fea1, data: Data([0x02]) + minorBytes) - try await Task.sleep(nanoseconds: 100_000_000) - - // Index 3 = TxPower - let _ = try? await writeDirectAndWait(fea1, data: Data([0x03, txPowerByte])) - try await Task.sleep(nanoseconds: 100_000_000) - - // Index 4 = Interval - let _ = try? await writeDirectAndWait(fea1, data: Data([0x04]) + intervalBytes) - try await Task.sleep(nanoseconds: 100_000_000) - - // Save command - let _ = try? await writeDirectAndWait(fea1, data: Data([0xFF])) - try await Task.sleep(nanoseconds: 500_000_000) - return - } - - throw ProvisionError.writeFailed("No write characteristic available") - } - - // MARK: - Legacy FFF0 Write - - private func writeLegacy(_ config: BeaconConfig, uuidBytes: [UInt8]) async throws { - guard let service = discoveredService else { - throw ProvisionError.serviceNotFound - } - - // Try passwords - for password in Self.legacyPasswords { - if let char = service.characteristics?.first(where: { $0.uuid == Self.fff1Password }), - let data = password.data(using: .utf8) { - let _ = try? await writeDirectAndWait(char, data: data) - try await Task.sleep(nanoseconds: 200_000_000) - } - } - - // Write UUID - if let char = service.characteristics?.first(where: { $0.uuid == Self.fff2UUID }) { - let _ = try await writeDirectAndWait(char, data: Data(uuidBytes)) - } - - // Write Major - let majorBytes = Data([UInt8(config.major >> 8), UInt8(config.major & 0xFF)]) - if let char = service.characteristics?.first(where: { $0.uuid == Self.fff3Major }) { - let _ = try await writeDirectAndWait(char, data: majorBytes) - } - - // Write Minor - let minorBytes = Data([UInt8(config.minor >> 8), UInt8(config.minor & 0xFF)]) - if let char = service.characteristics?.first(where: { $0.uuid == Self.fff4Minor }) { - let _ = try await writeDirectAndWait(char, data: minorBytes) - } - } - - // MARK: - Auth (BC04P) - - private func authenticateBC04P() async throws { - guard let fea1 = writeChar else { - throw ProvisionError.characteristicNotFound - } - - // Enable notifications on FEA2 if available - if let fea2 = notifyChar { - peripheral.setNotifyValue(true, for: fea2) - try await Task.sleep(nanoseconds: 200_000_000) - } - - // No explicit auth command needed for BC04P — the write methods - // handle auth implicitly. Android's BlueCharm provisioner also - // doesn't do a CMD_CONNECT auth for the FEA0 path. - } - - // MARK: - Private Helpers - - private func connectOnce() async throws { - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - connectionContinuation = cont - - // Register for connection callbacks via BLEManager (the CBCentralManagerDelegate) - bleManager?.onPeripheralConnected = { [weak self] connectedPeripheral in - guard connectedPeripheral.identifier == self?.peripheral.identifier else { return } - if let c = self?.connectionContinuation { - self?.connectionContinuation = nil - c.resume() - } - } - bleManager?.onPeripheralFailedToConnect = { [weak self] failedPeripheral, error in - guard failedPeripheral.identifier == self?.peripheral.identifier else { return } - if let c = self?.connectionContinuation { - self?.connectionContinuation = nil - c.resume(throwing: error ?? ProvisionError.connectionTimeout) - } - } - - centralManager.connect(peripheral, options: nil) - - DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.connectionTimeout) { [weak self] in - if let c = self?.connectionContinuation { - self?.connectionContinuation = nil - c.resume(throwing: ProvisionError.connectionTimeout) - } - } - } - } - - private func discoverServices() async throws { - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - serviceContinuation = cont - peripheral.discoverServices([GATTConstants.fea0Service, GATTConstants.fff0Service]) - - DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in - if let c = self?.serviceContinuation { - self?.serviceContinuation = nil - c.resume(throwing: ProvisionError.serviceDiscoveryTimeout) - } - } - } - } - - private func writeDirectAndWait(_ char: CBCharacteristic, data: Data) async throws -> Void { - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - writeOKContinuation = cont - peripheral.writeValue(data, for: char, type: .withResponse) - - DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { [weak self] in - if let c = self?.writeOKContinuation { - self?.writeOKContinuation = nil - c.resume(throwing: ProvisionError.operationTimeout) - } - } - } - } -} - -// MARK: - CBPeripheralDelegate - -extension BlueCharmProvisioner: CBPeripheralDelegate { - - func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { - if let error { - serviceContinuation?.resume(throwing: error) - serviceContinuation = nil - return - } - - // Prefer FEA0 (BC04P), fallback to FFF0 (legacy) - if let fea0Service = peripheral.services?.first(where: { $0.uuid == GATTConstants.fea0Service }) { - discoveredService = fea0Service - isLegacy = false - peripheral.discoverCharacteristics(nil, for: fea0Service) - } else if let fff0Service = peripheral.services?.first(where: { $0.uuid == GATTConstants.fff0Service }) { - discoveredService = fff0Service - isLegacy = true - peripheral.discoverCharacteristics(nil, for: fff0Service) - } else { - serviceContinuation?.resume(throwing: ProvisionError.serviceNotFound) - serviceContinuation = nil - } - } - - func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { - if let error { - serviceContinuation?.resume(throwing: error) - serviceContinuation = nil - return - } - - if isLegacy { - // Legacy: just need the service with characteristics - serviceContinuation?.resume() - serviceContinuation = nil - return - } - - // BC04P: map specific characteristics - for char in service.characteristics ?? [] { - switch char.uuid { - case Self.fea1Write: - writeChar = char - case Self.fea2Notify: - notifyChar = char - case Self.fea3Config: - configChar = char - default: - // Also grab any writable char as fallback - if writeChar == nil && (char.properties.contains(.write) || char.properties.contains(.writeWithoutResponse)) { - writeChar = char - } - if notifyChar == nil && char.properties.contains(.notify) { - notifyChar = char - } - } - } - - if writeChar != nil || configChar != nil { - serviceContinuation?.resume() - serviceContinuation = nil - } else { - serviceContinuation?.resume(throwing: ProvisionError.characteristicNotFound) - serviceContinuation = nil - } - } - - func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { - guard let data = characteristic.value else { return } - if let cont = writeContinuation { - writeContinuation = nil - cont.resume(returning: data) - } - } - - func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { - if let cont = writeOKContinuation { - writeOKContinuation = nil - if let error { - cont.resume(throwing: error) - } else { - cont.resume() - } - return - } - - if let error, let cont = writeContinuation { - writeContinuation = nil - cont.resume(throwing: ProvisionError.writeFailed(error.localizedDescription)) - } - } -} - -// MARK: - Data Extension - -private extension Data { - /// Pad data to target length with zero bytes - func padded(to length: Int) -> Data { - if count >= length { return self } - var padded = self - padded.append(contentsOf: [UInt8](repeating: 0, count: length - count)) - return padded - } -} diff --git a/PayfritBeacon/Provisioners/DXSmartProvisioner.swift b/PayfritBeacon/Provisioners/DXSmartProvisioner.swift index 9abe1ab..eda53bc 100644 --- a/PayfritBeacon/Provisioners/DXSmartProvisioner.swift +++ b/PayfritBeacon/Provisioners/DXSmartProvisioner.swift @@ -173,7 +173,7 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner { for (index, (name, packet)) in commands.enumerated() { await diagnosticLog?.log("write", "[\(index + 1)/\(commands.count)] \(name) (\(packet.count) bytes)") - // Retry each command up to 2 times — beacon BLE stack can be flaky after KBeacon fallback + // Retry each command up to 2 times — beacon BLE stack can be flaky var lastError: Error? for writeAttempt in 1...2 { do { @@ -193,8 +193,7 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner { throw lastError } - // 500ms between commands — beacon needs time to process, especially after - // prior KBeacon auth attempts that may have stressed the BLE stack + // 500ms between commands — beacon needs time to process try await Task.sleep(nanoseconds: 500_000_000) } } diff --git a/PayfritBeacon/Provisioners/FallbackProvisioner.swift b/PayfritBeacon/Provisioners/FallbackProvisioner.swift deleted file mode 100644 index d723831..0000000 --- a/PayfritBeacon/Provisioners/FallbackProvisioner.swift +++ /dev/null @@ -1,83 +0,0 @@ -import Foundation -import CoreBluetooth - -/// Tries KBeacon → DXSmart → BlueCharm in sequence for unknown beacon types. -/// Matches Android's fallback behavior when beacon type can't be determined. -final class FallbackProvisioner: BeaconProvisioner { - - private let peripheral: CBPeripheral - private let centralManager: CBCentralManager - private var activeProvisioner: (any BeaconProvisioner)? - - private(set) var isConnected: Bool = false - var diagnosticLog: ProvisionLog? - var bleManager: BLEManager? - - init(peripheral: CBPeripheral, centralManager: CBCentralManager) { - self.peripheral = peripheral - self.centralManager = centralManager - } - - func connect() async throws { - let provisioners: [() -> any BeaconProvisioner] = [ - { [self] in - var p = KBeaconProvisioner(peripheral: peripheral, centralManager: centralManager) - p.diagnosticLog = diagnosticLog - p.bleManager = bleManager - return p - }, - { [self] in - var p = DXSmartProvisioner(peripheral: peripheral, centralManager: centralManager) - p.diagnosticLog = diagnosticLog - p.bleManager = bleManager - return p - }, - { [self] in - var p = BlueCharmProvisioner(peripheral: peripheral, centralManager: centralManager) - p.diagnosticLog = diagnosticLog - p.bleManager = bleManager - return p - }, - ] - - let typeNames = ["KBeacon", "DXSmart", "BlueCharm"] - var lastError: Error = ProvisionError.connectionTimeout - - for (index, makeProvisioner) in provisioners.enumerated() { - await diagnosticLog?.log("fallback", "Trying \(typeNames[index]) provisioner…") - let provisioner = makeProvisioner() - do { - try await provisioner.connect() - activeProvisioner = provisioner - isConnected = true - await diagnosticLog?.log("fallback", "\(typeNames[index]) connected successfully") - return - } catch { - provisioner.disconnect() - lastError = error - await diagnosticLog?.log("fallback", "\(typeNames[index]) failed: \(error.localizedDescription)", isError: true) - // 2s cooldown between provisioner attempts — let the beacon's BLE stack recover - // from failed auth/connection before the next provisioner hammers it - if index < provisioners.count - 1 { - await diagnosticLog?.log("fallback", "Cooling down 2s before next provisioner…") - try? await Task.sleep(nanoseconds: 2_000_000_000) - } - } - } - - throw lastError - } - - func writeConfig(_ config: BeaconConfig) async throws { - guard let provisioner = activeProvisioner else { - throw ProvisionError.notConnected - } - try await provisioner.writeConfig(config) - } - - func disconnect() { - activeProvisioner?.disconnect() - activeProvisioner = nil - isConnected = false - } -} diff --git a/PayfritBeacon/Provisioners/KBeaconProvisioner.swift b/PayfritBeacon/Provisioners/KBeaconProvisioner.swift deleted file mode 100644 index 242a2fc..0000000 --- a/PayfritBeacon/Provisioners/KBeaconProvisioner.swift +++ /dev/null @@ -1,311 +0,0 @@ -import Foundation -import CoreBluetooth - -/// Provisioner for KBeacon / KBPro hardware -/// Protocol: FFE0 service, FFE1 write, FFE2 notify -/// Auth via CMD_AUTH (0x01), config via CMD_WRITE_PARAMS (0x03), save via CMD_SAVE (0x04) -final class KBeaconProvisioner: NSObject, BeaconProvisioner { - - // MARK: - Protocol Commands - private enum CMD: UInt8 { - case auth = 0x01 - case readParams = 0x02 - case writeParams = 0x03 - case save = 0x04 - } - - // MARK: - Parameter IDs - private enum ParamID: UInt8 { - case uuid = 0x10 - case major = 0x11 - case minor = 0x12 - case txPower = 0x13 - case advInterval = 0x14 - } - - // MARK: - Known passwords (tried in order, matching Android) - // Known KBeacon default passwords (tried in order) - // Note: password 5 was previously a duplicate of password 3 — replaced with "000000" (6-char variant) - private static let passwords: [Data] = [ - "kd1234".data(using: .utf8)!, // KBeacon factory default - Data(repeating: 0, count: 16), // Binary zeros (16 bytes) - Data([0x31,0x32,0x33,0x34,0x35,0x36,0x37,0x38,0x39,0x30,0x31,0x32,0x33,0x34,0x35,0x36]), // "1234567890123456" - "0000000000000000".data(using: .utf8)!, // ASCII zeros (16 bytes) - "000000".data(using: .utf8)!, // Short zero default (6 bytes) - ] - - // MARK: - State - - private let peripheral: CBPeripheral - private let centralManager: CBCentralManager - private var writeChar: CBCharacteristic? - private var notifyChar: CBCharacteristic? - - private var connectionContinuation: CheckedContinuation? - private var serviceContinuation: CheckedContinuation? - private var writeContinuation: CheckedContinuation? - - private(set) var isConnected = false - var diagnosticLog: ProvisionLog? - var bleManager: BLEManager? - - /// Status callback — provisioner reports what phase it's in so UI can update - var onStatusUpdate: ((String) -> Void)? - - // MARK: - Init - - init(peripheral: CBPeripheral, centralManager: CBCentralManager) { - self.peripheral = peripheral - self.centralManager = centralManager - super.init() - self.peripheral.delegate = self - } - - // MARK: - BeaconProvisioner - - func connect() async throws { - // Connect with retry - for attempt in 1...GATTConstants.maxRetries { - do { - let attemptLabel = GATTConstants.maxRetries > 1 ? " (attempt \(attempt)/\(GATTConstants.maxRetries))" : "" - await MainActor.run { onStatusUpdate?("Connecting to beacon…\(attemptLabel)") } - await diagnosticLog?.log("connect", "Attempt \(attempt)/\(GATTConstants.maxRetries)") - try await connectOnce() - - await MainActor.run { onStatusUpdate?("Discovering services…") } - await diagnosticLog?.log("connect", "Connected — discovering services…") - try await discoverServices() - await diagnosticLog?.log("connect", "Services found — write:\(writeChar != nil) notify:\(notifyChar != nil)") - - await MainActor.run { onStatusUpdate?("Authenticating…") } - await diagnosticLog?.log("auth", "Trying \(Self.passwords.count) passwords…") - try await authenticate() - await diagnosticLog?.log("auth", "Auth success") - isConnected = true - return - } catch { - await diagnosticLog?.log("connect", "Attempt \(attempt) failed: \(error.localizedDescription)", isError: true) - disconnect() - if attempt < GATTConstants.maxRetries { - await MainActor.run { onStatusUpdate?("Retrying… (\(attempt)/\(GATTConstants.maxRetries))") } - try await Task.sleep(nanoseconds: UInt64(GATTConstants.retryDelay * 1_000_000_000)) - } else { - throw error - } - } - } - } - - func writeConfig(_ config: BeaconConfig) async throws { - guard isConnected, let writeChar else { - throw ProvisionError.notConnected - } - - // Build parameter payload - var params = Data() - - // UUID (16 bytes) - params.append(ParamID.uuid.rawValue) - let uuidBytes = config.uuid.hexToBytes - params.append(contentsOf: uuidBytes) - - // Major (2 bytes BE) - params.append(ParamID.major.rawValue) - params.append(UInt8(config.major >> 8)) - params.append(UInt8(config.major & 0xFF)) - - // Minor (2 bytes BE) - params.append(ParamID.minor.rawValue) - params.append(UInt8(config.minor >> 8)) - params.append(UInt8(config.minor & 0xFF)) - - // TX Power - params.append(ParamID.txPower.rawValue) - params.append(config.txPower) - - // Adv Interval - params.append(ParamID.advInterval.rawValue) - params.append(UInt8(config.advInterval >> 8)) - params.append(UInt8(config.advInterval & 0xFF)) - - // Send CMD_WRITE_PARAMS - await MainActor.run { onStatusUpdate?("Writing beacon parameters…") } - await diagnosticLog?.log("write", "Sending CMD_WRITE_PARAMS (\(params.count) bytes)…") - let writeCmd = Data([CMD.writeParams.rawValue]) + params - let writeResp = try await sendCommand(writeCmd) - guard writeResp.first == CMD.writeParams.rawValue else { - throw ProvisionError.writeFailed("Unexpected write response") - } - await diagnosticLog?.log("write", "Params written OK — saving to flash…") - - // Send CMD_SAVE to flash - await MainActor.run { onStatusUpdate?("Saving to flash…") } - let saveResp = try await sendCommand(Data([CMD.save.rawValue])) - guard saveResp.first == CMD.save.rawValue else { - throw ProvisionError.saveFailed - } - await MainActor.run { onStatusUpdate?("Config saved ✓") } - await diagnosticLog?.log("write", "Save confirmed") - } - - func disconnect() { - if peripheral.state == .connected || peripheral.state == .connecting { - centralManager.cancelPeripheralConnection(peripheral) - } - isConnected = false - } - - // MARK: - Private: Connection - - private func connectOnce() async throws { - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - connectionContinuation = cont - - // Register for connection callbacks via BLEManager (the CBCentralManagerDelegate) - bleManager?.onPeripheralConnected = { [weak self] connectedPeripheral in - guard connectedPeripheral.identifier == self?.peripheral.identifier else { return } - if let c = self?.connectionContinuation { - self?.connectionContinuation = nil - c.resume() - } - } - bleManager?.onPeripheralFailedToConnect = { [weak self] failedPeripheral, error in - guard failedPeripheral.identifier == self?.peripheral.identifier else { return } - if let c = self?.connectionContinuation { - self?.connectionContinuation = nil - c.resume(throwing: error ?? ProvisionError.connectionTimeout) - } - } - - centralManager.connect(peripheral, options: nil) - - // Timeout - DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.connectionTimeout) { [weak self] in - if let c = self?.connectionContinuation { - self?.connectionContinuation = nil - c.resume(throwing: ProvisionError.connectionTimeout) - } - } - } - } - - private func discoverServices() async throws { - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - serviceContinuation = cont - peripheral.discoverServices([GATTConstants.ffe0Service]) - - DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in - if let c = self?.serviceContinuation { - self?.serviceContinuation = nil - c.resume(throwing: ProvisionError.serviceDiscoveryTimeout) - } - } - } - } - - private func authenticate() async throws { - for (index, password) in Self.passwords.enumerated() { - let passwordLabel = String(data: password.prefix(6), encoding: .utf8) ?? "binary" - await MainActor.run { onStatusUpdate?("Authenticating… (\(index + 1)/\(Self.passwords.count))") } - await diagnosticLog?.log("auth", "Trying password \(index + 1)/\(Self.passwords.count): \(passwordLabel)…") - let cmd = Data([CMD.auth.rawValue]) + password - do { - let resp = try await sendCommand(cmd) - if resp.first == CMD.auth.rawValue && resp.count > 1 && resp[1] == 0x00 { - await MainActor.run { onStatusUpdate?("Authenticated ✓") } - return // Auth success - } - await diagnosticLog?.log("auth", "Password \(index + 1) rejected (response: \(resp.map { String(format: "%02X", $0) }.joined()))") - } catch { - await diagnosticLog?.log("auth", "Password \(index + 1) timeout: \(error.localizedDescription)") - continue - } - } - throw ProvisionError.authFailed - } - - private func sendCommand(_ data: Data) async throws -> Data { - guard let writeChar else { throw ProvisionError.notConnected } - - return try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - writeContinuation = cont - peripheral.writeValue(data, for: writeChar, type: .withResponse) - - DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in - if let c = self?.writeContinuation { - self?.writeContinuation = nil - c.resume(throwing: ProvisionError.operationTimeout) - } - } - } - } -} - -// MARK: - CBPeripheralDelegate - -extension KBeaconProvisioner: CBPeripheralDelegate { - - func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { - if let error { - serviceContinuation?.resume(throwing: error) - serviceContinuation = nil - return - } - - guard let service = peripheral.services?.first(where: { $0.uuid == GATTConstants.ffe0Service }) else { - serviceContinuation?.resume(throwing: ProvisionError.serviceNotFound) - serviceContinuation = nil - return - } - - peripheral.discoverCharacteristics([GATTConstants.ffe1Char, GATTConstants.ffe2Char], for: service) - } - - func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { - if let error { - serviceContinuation?.resume(throwing: error) - serviceContinuation = nil - return - } - - for char in service.characteristics ?? [] { - switch char.uuid { - case GATTConstants.ffe1Char: - writeChar = char - case GATTConstants.ffe2Char: - notifyChar = char - peripheral.setNotifyValue(true, for: char) - default: - break - } - } - - if writeChar != nil { - serviceContinuation?.resume() - serviceContinuation = nil - } else { - serviceContinuation?.resume(throwing: ProvisionError.characteristicNotFound) - serviceContinuation = nil - } - } - - func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { - guard characteristic.uuid == GATTConstants.ffe2Char, - let data = characteristic.value else { return } - - if let cont = writeContinuation { - writeContinuation = nil - cont.resume(returning: data) - } - } - - func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { - // Write acknowledgment — actual response comes via notify on FFE2 - if let error { - if let cont = writeContinuation { - writeContinuation = nil - cont.resume(throwing: error) - } - } - } -} diff --git a/PayfritBeacon/Provisioners/ProvisionerProtocol.swift b/PayfritBeacon/Provisioners/ProvisionerProtocol.swift index 7f0118d..a7295ac 100644 --- a/PayfritBeacon/Provisioners/ProvisionerProtocol.swift +++ b/PayfritBeacon/Provisioners/ProvisionerProtocol.swift @@ -22,23 +22,19 @@ protocol BeaconProvisioner { var bleManager: BLEManager? { get set } } -/// GATT UUIDs shared across provisioner types +/// GATT UUIDs for CP-28 (DX-Smart) beacons +/// FFE0 service with FFE1 (notify), FFE2 (write), FFE3 (password) enum GATTConstants { - // FFE0 service (KBeacon, DXSmart) static let ffe0Service = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB") static let ffe1Char = CBUUID(string: "0000FFE1-0000-1000-8000-00805F9B34FB") static let ffe2Char = CBUUID(string: "0000FFE2-0000-1000-8000-00805F9B34FB") static let ffe3Char = CBUUID(string: "0000FFE3-0000-1000-8000-00805F9B34FB") - // FFF0 service (BlueCharm) - static let fff0Service = CBUUID(string: "0000FFF0-0000-1000-8000-00805F9B34FB") - static let fea0Service = CBUUID(string: "0000FEA0-0000-1000-8000-00805F9B34FB") - // CCCD for enabling notifications static let cccd = CBUUID(string: "00002902-0000-1000-8000-00805F9B34FB") - // Timeouts (matching Android) - static let connectionTimeout: TimeInterval = 10.0 // Increased from 5s — BLE connections can be slow + // Timeouts + static let connectionTimeout: TimeInterval = 10.0 static let operationTimeout: TimeInterval = 5.0 static let maxRetries = 3 static let retryDelay: TimeInterval = 1.0 diff --git a/PayfritBeacon/Services/BLEManager.swift b/PayfritBeacon/Services/BLEManager.swift index 3bcc522..7e9fa12 100644 --- a/PayfritBeacon/Services/BLEManager.swift +++ b/PayfritBeacon/Services/BLEManager.swift @@ -2,8 +2,7 @@ import Foundation import CoreBluetooth import Combine -/// Central BLE manager — handles scanning and beacon type detection -/// Matches Android's BeaconScanner.kt behavior +/// Central BLE manager — handles scanning and CP-28 beacon detection @MainActor final class BLEManager: NSObject, ObservableObject { @@ -13,23 +12,19 @@ final class BLEManager: NSObject, ObservableObject { @Published var discoveredBeacons: [DiscoveredBeacon] = [] @Published var bluetoothState: CBManagerState = .unknown - // MARK: - Constants (matching Android) + // MARK: - Constants static let scanDuration: TimeInterval = 5.0 static let verifyScanDuration: TimeInterval = 15.0 static let verifyPollInterval: TimeInterval = 0.5 - // GATT Service UUIDs + // CP-28 uses FFE0 service static let ffe0Service = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB") - static let fff0Service = CBUUID(string: "0000FFF0-0000-1000-8000-00805F9B34FB") - static let fea0Service = CBUUID(string: "0000FEA0-0000-1000-8000-00805F9B34FB") // DX-Smart factory default iBeacon UUID static let dxSmartDefaultUUID = "E2C56DB5-DFFB-48D2-B060-D0F5A71096E0" // MARK: - Connection Callbacks (used by provisioners) - // Provisioners call centralManager.connect() but BLEManager is the delegate, - // so we need to forward connection events back to provisioners via closures. var onPeripheralConnected: ((CBPeripheral) -> Void)? var onPeripheralFailedToConnect: ((CBPeripheral, Error?) -> Void)? @@ -88,11 +83,8 @@ final class BLEManager: NSObject, ObservableObject { } /// Verify a beacon is broadcasting expected iBeacon values. - /// Scans for up to `verifyScanDuration` looking for matching UUID/Major/Minor. func verifyBroadcast(uuid: String, major: UInt16, minor: UInt16) async -> VerifyResult { // TODO: Implement iBeacon region monitoring via CLLocationManager - // CoreLocation's CLBeaconRegion is the iOS-native way to detect iBeacon broadcasts - // For now, return a placeholder that prompts manual verification return VerifyResult( found: false, rssi: nil, @@ -103,40 +95,32 @@ final class BLEManager: NSObject, ObservableObject { // MARK: - iBeacon Manufacturer Data Parsing /// Parse iBeacon data from manufacturer data (Apple company ID 0x4C00) - /// Returns (uuid, major, minor) if valid iBeacon advertisement found private func parseIBeaconData(_ mfgData: Data) -> (uuid: String, major: UInt16, minor: UInt16)? { - // iBeacon format in manufacturer data: - // [0x4C 0x00] (Apple company ID) [0x02 0x15] (iBeacon type+length) - // [UUID 16 bytes] [Major 2 bytes] [Minor 2 bytes] [TX Power 1 byte] guard mfgData.count >= 25 else { return nil } guard mfgData[0] == 0x4C && mfgData[1] == 0x00 && mfgData[2] == 0x02 && mfgData[3] == 0x15 else { return nil } - // Extract UUID (bytes 4-19) 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))" - // Extract Major (bytes 20-21) and Minor (bytes 22-23) 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: - Beacon Type Detection (matches Android BeaconScanner.kt) - // NOTE: Android also detects DX-Smart by MAC OUI prefix 48:87:2D. - // CoreBluetooth does not expose raw MAC addresses, so we compensate - // with broader iBeacon UUID detection and more permissive inclusion. + // MARK: - CP-28 Detection + // Only detect DX-Smart / CP-28 beacons. Everything else is ignored. func detectBeaconType( name: String?, serviceUUIDs: [CBUUID]?, manufacturerData: Data? - ) -> BeaconType { + ) -> BeaconType? { let deviceName = (name ?? "").lowercased() - // Parse iBeacon data if available (needed for UUID-based detection) + // Parse iBeacon data if available let iBeaconData: (uuid: String, major: UInt16, minor: UInt16)? if let mfgData = manufacturerData { iBeaconData = parseIBeaconData(mfgData) @@ -144,89 +128,60 @@ final class BLEManager: NSObject, ObservableObject { iBeaconData = nil } - // 1. Service UUID matching (matches Android lines 122-126) + // 1. Service UUID: CP-28 uses FFE0 if let services = serviceUUIDs { let serviceStrings = services.map { $0.uuidString.uppercased() } - // Android: KBeacon uses FFE0 as primary service if serviceStrings.contains(where: { $0.hasPrefix("0000FFE0") }) { - // Could be KBeacon or DXSmart — check name to differentiate + // FFE0 with DX name patterns → definitely CP-28 if deviceName.contains("cp28") || deviceName.contains("cp-28") || deviceName.contains("dx") || deviceName.contains("pddaxlque") { return .dxsmart } - return .kbeacon - } - - // Android: DXSmart also uses FFF0 (line 125) - // FIXED: Was incorrectly mapping FFF0 → BlueCharm only. - // Android maps DXSmartProvisioner.SERVICE_UUID_FFF0 → DXSMART - if serviceStrings.contains(where: { $0.hasPrefix("0000FFF0") }) { - // Check name patterns to decide: DXSmart or BlueCharm - if deviceName.contains("cp28") || deviceName.contains("cp-28") || - deviceName.contains("dx") || deviceName.contains("pddaxlque") || - deviceName.isEmpty { - // DX beacons often have no name or DX-prefixed names - return .dxsmart - } - if deviceName.contains("bluecharm") || deviceName.hasPrefix("bc") || - deviceName.hasPrefix("table-") { - return .bluecharm - } - // Default FFF0 to DXSmart (matching Android behavior) + // FFE0 without a specific name — still likely CP-28 return .dxsmart } - // Android: BlueCharm uses FEA0 (line 124) - if serviceStrings.contains(where: { $0.hasPrefix("0000FEA0") }) { - return .bluecharm + // CP-28 also advertises FFF0 on some firmware + if serviceStrings.contains(where: { $0.hasPrefix("0000FFF0") }) { + if deviceName.contains("cp28") || deviceName.contains("cp-28") || + deviceName.contains("dx") || deviceName.contains("pddaxlque") || + deviceName.isEmpty { + return .dxsmart + } } } - // 2. Detect DX-Smart by factory default iBeacon UUID (Android line 130) - // This is critical — catches DX beacons that don't advertise service UUIDs + // 2. DX-Smart factory default iBeacon UUID if let ibeacon = iBeaconData { if ibeacon.uuid.caseInsensitiveCompare(Self.dxSmartDefaultUUID) == .orderedSame { return .dxsmart } - // Check if broadcasting a Payfrit shard UUID (already provisioned DX beacon) + // Already provisioned with a Payfrit shard UUID if BeaconShardPool.isPayfrit(ibeacon.uuid) { return .dxsmart } } - // 3. Device name patterns (Android lines 131-147) - if deviceName.contains("kbeacon") || deviceName.contains("kbpro") || - deviceName.hasPrefix("kb") { - return .kbeacon - } - if deviceName.contains("bluecharm") || deviceName.hasPrefix("bc") || - deviceName.hasPrefix("table-") { - return .bluecharm - } + // 3. Device name patterns for CP-28 if deviceName.contains("cp28") || deviceName.contains("cp-28") || deviceName.contains("dx-cp") || deviceName.contains("dx-smart") || deviceName.contains("dxsmart") || deviceName.contains("pddaxlque") { return .dxsmart } - // 4. Detect by iBeacon minor in high range (Android line 143) + // 4. iBeacon minor in high range (factory default DX pattern) if let ibeacon = iBeaconData, ibeacon.minor > 10000 { return .dxsmart } - // 5. Generic beacon patterns (Android lines 145-147) - if deviceName.contains("ibeacon") || deviceName.contains("beacon") || - deviceName.hasPrefix("ble") { - return .dxsmart // Default to DXSmart like Android - } - - // 6. Any remaining iBeacon advertisement — still a beacon we should show + // 5. Any iBeacon advertisement — likely a CP-28 in the field if iBeaconData != nil { return .dxsmart } - return .unknown + // Not a CP-28 — don't show it + return nil } } @@ -265,37 +220,24 @@ extension BLEManager: CBCentralManagerDelegate { advertisementData: [String: Any], rssi RSSI: NSNumber ) { - // Capture values in nonisolated context before hopping to main let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "" let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data - let isConnectable = advertisementData[CBAdvertisementDataIsConnectable] as? Bool ?? false let peripheralId = peripheral.identifier let rssiValue = RSSI.intValue DispatchQueue.main.async { [weak self] in guard let self else { return } - let type = self.detectBeaconType(name: name, serviceUUIDs: serviceUUIDs, manufacturerData: mfgData) - - // Match Android behavior (lines 164-169): - // Include devices that have a recognized type, OR - // are broadcasting iBeacon data, OR - // are connectable with a name (potential configurable beacon) - if type == .unknown { - let hasName = !name.isEmpty - let hasIBeaconData = mfgData.flatMap { self.parseIBeaconData($0) } != nil - if !hasIBeaconData && !(isConnectable && hasName) { - 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 }) { - // Update existing self.discoveredBeacons[idx].rssi = rssiValue self.discoveredBeacons[idx].lastSeen = Date() } else { - // New beacon let beacon = DiscoveredBeacon( id: peripheralId, peripheral: peripheral, @@ -307,7 +249,6 @@ extension BLEManager: CBCentralManagerDelegate { self.discoveredBeacons.append(beacon) } - // Keep list sorted by RSSI (strongest/closest first) self.discoveredBeacons.sort { $0.rssi > $1.rssi } } } diff --git a/PayfritBeacon/Views/ScanView.swift b/PayfritBeacon/Views/ScanView.swift index e92dc8a..a259bf1 100644 --- a/PayfritBeacon/Views/ScanView.swift +++ b/PayfritBeacon/Views/ScanView.swift @@ -619,10 +619,6 @@ struct ScanView: View { dxProvisioner.onStatusUpdate = { [weak self] status in self?.statusMessage = status } - } else if let kbProvisioner = provisioner as? KBeaconProvisioner { - kbProvisioner.onStatusUpdate = { [weak self] status in - self?.statusMessage = status - } } statusMessage = "Connecting to \(beacon.displayName)…" @@ -635,10 +631,10 @@ struct ScanView: View { let reason = error?.localizedDescription ?? "beacon timed out" provisionLog?.log("disconnect", "Unexpected disconnect: \(reason)", isError: true) guard let self = self else { return } - // DXSmart: disconnect during .connected is expected — beacon keeps + // CP-28: disconnect during .connected is expected — beacon keeps // flashing after BLE drops. We'll reconnect when user taps Write Config. - if self.provisioningState == .connected && beacon.type == .dxsmart { - provisionLog?.log("disconnect", "DXSmart idle disconnect — beacon still flashing, ignoring") + if self.provisioningState == .connected { + provisionLog?.log("disconnect", "CP-28 idle disconnect — beacon still flashing, ignoring") return } // For all other active states, treat disconnect as failure @@ -654,53 +650,10 @@ struct ScanView: View { try await provisioner.connect() provisionLog.log("connect", "Connected and authenticated successfully") - // DXSmart: stop at connected state, wait for user to confirm flashing - if beacon.type == .dxsmart { - provisioningState = .connected - // Store config and provisioner for later use - pendingConfig = config - pendingProvisioner = provisioner - return - } - - // KBeacon / BlueCharm: write immediately - provisioningState = .writing - statusMessage = "Writing \(config.formattedUUID.prefix(8))… Major:\(config.major) Minor:\(config.minor)" - provisionLog.log("write", "Writing config…") - try await provisioner.writeConfig(config) - provisionLog.log("write", "Config written — disconnecting") - provisioner.disconnect() - - // Register with backend - try await APIClient.shared.registerBeaconHardware( - businessId: business.id, - servicePointId: sp.id, - uuid: ns.uuid, - major: ns.major, - minor: minor, - macAddress: nil, - beaconType: beacon.type.rawValue, - token: token - ) - - // Verify broadcast - provisioningState = .verifying - statusMessage = "Waiting for beacon to restart…" - try await Task.sleep(nanoseconds: UInt64(GATTConstants.postFlashDelay * 1_000_000_000)) - - statusMessage = "Scanning for broadcast…" - let verifyResult = await bleManager.verifyBroadcast( - uuid: ns.uuid, major: config.major, minor: config.minor - ) - - if verifyResult.found { - try await APIClient.shared.verifyBeaconBroadcast( - uuid: ns.uuid, major: ns.major, minor: minor, token: token - ) - } - - provisioningState = .done - statusMessage = "\(sp.name) — \(beacon.type.rawValue)\nUUID: \(config.formattedUUID.prefix(13))…\nMajor: \(config.major) Minor: \(config.minor)" + // CP-28: stop at connected state, wait for user to confirm flashing + provisioningState = .connected + pendingConfig = config + pendingProvisioner = provisioner } catch { provisionLog.log("error", "Provisioning failed after \(provisionLog.elapsed): \(error.localizedDescription)", isError: true) @@ -848,17 +801,10 @@ struct ScanView: View { } private func makeProvisioner(for beacon: DiscoveredBeacon) -> any BeaconProvisioner { - var provisioner: any BeaconProvisioner - switch beacon.type { - case .kbeacon: - provisioner = KBeaconProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager) - case .dxsmart: - provisioner = DXSmartProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager) - case .bluecharm: - provisioner = BlueCharmProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager) - case .unknown: - provisioner = FallbackProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager) - } + var provisioner: any BeaconProvisioner = DXSmartProvisioner( + peripheral: beacon.peripheral, + centralManager: bleManager.centralManager + ) provisioner.bleManager = bleManager provisioner.diagnosticLog = provisionLog return provisioner @@ -927,12 +873,7 @@ struct BeaconRow: View { } private var typeColor: Color { - switch beacon.type { - case .kbeacon: return .payfritGreen - case .dxsmart: return .warningOrange - case .bluecharm: return .infoBlue - case .unknown: return .gray - } + return .payfritGreen } }