From 38b4c987c951259a9964e310c792dae1f884ca0a Mon Sep 17 00:00:00 2001 From: Schwifty Date: Sun, 22 Mar 2026 17:25:55 +0000 Subject: [PATCH] fix: address all issues from koda's code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ”΄ Critical: - DXSmartProvisioner: complete rewrite to match Android's new SDK protocol - Writes to FFE2 (not FFE1) using 4E4F protocol packets - Correct command IDs: 0x74 UUID, 0x75 Major, 0x76 Minor, 0x77 RSSI, 0x78 AdvInt, 0x79 TxPower, 0x60 Save - Frame selection (0x11/0x12) + frame type (0x62 iBeacon) - Old SDK fallback (0x36-0x43 via FFE1 with 555555 re-auth per command) - Auth timing: 100ms delays (was 500ms, matches Android SDK) - BeaconShardPool: replaced 71 pattern UUIDs with exact 64 from Android 🟑 Warnings: - BlueCharmProvisioner: 3 fallback write methods matching Android (FEA3 direct β†’ FEA1 raw β†’ FEA1 indexed), legacy FFF0 support, added "minew123" and "bc04p" passwords (5 total, was 3) - BeaconBanList: added 4 missing prefixes (8492E75F, A0B13730, EBEFD083, B5B182C7), full UUID ban list, getBanReason() helper - BLEManager: documented MAC OUI limitation (48:87:2D not available on iOS via CoreBluetooth) πŸ”΅ Info: - APIClient: added get_beacon_config endpoint for server-configured values - ScanView: unknown beacon type now tries KBeaconβ†’DXSmartβ†’BlueCharm fallback chain via new FallbackProvisioner - DXSmartProvisioner: added readFrame2() for post-write verification Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Provisioners/BlueCharmProvisioner.swift | 334 +++++++++++++----- .../Provisioners/DXSmartProvisioner.swift | 264 +++++++++++--- .../Provisioners/FallbackProvisioner.swift | 56 +++ PayfritBeacon/Services/APIClient.swift | 34 ++ PayfritBeacon/Services/BLEManager.swift | 3 + PayfritBeacon/Utils/BeaconBanList.swift | 55 ++- PayfritBeacon/Utils/BeaconShardPool.swift | 129 +++---- PayfritBeacon/Views/ScanView.swift | 3 +- 8 files changed, 656 insertions(+), 222 deletions(-) create mode 100644 PayfritBeacon/Provisioners/FallbackProvisioner.swift diff --git a/PayfritBeacon/Provisioners/BlueCharmProvisioner.swift b/PayfritBeacon/Provisioners/BlueCharmProvisioner.swift index fccca65..7e497b1 100644 --- a/PayfritBeacon/Provisioners/BlueCharmProvisioner.swift +++ b/PayfritBeacon/Provisioners/BlueCharmProvisioner.swift @@ -2,41 +2,59 @@ import Foundation import CoreBluetooth /// Provisioner for BlueCharm / BC04P hardware -/// Uses FFF0 service with similar auth/write flow to KBeacon +/// +/// 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), - "0000000000000000".data(using: .utf8)!, - "1234567890123456".data(using: .utf8)!, + 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 ] - private enum CMD: UInt8 { - case auth = 0x01 - case writeParams = 0x03 - case save = 0x04 - } + // Legacy FFF0 passwords + private static let legacyPasswords = ["000000", "123456", "bc0000"] - private enum ParamID: UInt8 { - case uuid = 0x10 - case major = 0x11 - case minor = 0x12 - case txPower = 0x13 - case advInterval = 0x14 - } + // 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 writeChar: CBCharacteristic? - private var notifyChar: CBCharacteristic? + + 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 @@ -56,7 +74,9 @@ final class BlueCharmProvisioner: NSObject, BeaconProvisioner { do { try await connectOnce() try await discoverServices() - try await authenticate() + if !isLegacy { + try await authenticateBC04P() + } isConnected = true return } catch { @@ -71,44 +91,19 @@ final class BlueCharmProvisioner: NSObject, BeaconProvisioner { } func writeConfig(_ config: BeaconConfig) async throws { - guard isConnected, let writeChar else { + guard isConnected else { throw ProvisionError.notConnected } - var params = Data() - - // UUID (16 bytes) - params.append(ParamID.uuid.rawValue) - params.append(contentsOf: config.uuid.hexToBytes) - - // Major - params.append(ParamID.major.rawValue) - params.append(UInt8(config.major >> 8)) - params.append(UInt8(config.major & 0xFF)) - - // Minor - 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)) - - 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") + let uuidBytes = config.uuid.hexToBytes + guard uuidBytes.count == 16 else { + throw ProvisionError.writeFailed("Invalid UUID length") } - let saveResp = try await sendCommand(Data([CMD.save.rawValue])) - guard saveResp.first == CMD.save.rawValue else { - throw ProvisionError.saveFailed + if isLegacy { + try await writeLegacy(config, uuidBytes: uuidBytes) + } else { + try await writeBC04P(config, uuidBytes: uuidBytes) } } @@ -119,7 +114,137 @@ final class BlueCharmProvisioner: NSObject, BeaconProvisioner { isConnected = false } - // MARK: - Private + // 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 @@ -138,7 +263,7 @@ final class BlueCharmProvisioner: NSObject, BeaconProvisioner { private func discoverServices() async throws { try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in serviceContinuation = cont - peripheral.discoverServices([GATTConstants.fff0Service, GATTConstants.fea0Service]) + peripheral.discoverServices([GATTConstants.fea0Service, GATTConstants.fff0Service]) DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in if let c = self?.serviceContinuation { @@ -149,31 +274,14 @@ final class BlueCharmProvisioner: NSObject, BeaconProvisioner { } } - private func authenticate() async throws { - for password in Self.passwords { - 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 { - return - } - } catch { - continue - } - } - throw ProvisionError.authFailed - } + 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) - 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 + DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { [weak self] in + if let c = self?.writeOKContinuation { + self?.writeOKContinuation = nil c.resume(throwing: ProvisionError.operationTimeout) } } @@ -192,16 +300,19 @@ extension BlueCharmProvisioner: CBPeripheralDelegate { return } - // Look for FFF0 or FEA0 service - guard let service = peripheral.services?.first(where: { - $0.uuid == GATTConstants.fff0Service || $0.uuid == GATTConstants.fea0Service - }) else { + // 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 - return } - - peripheral.discoverCharacteristics(nil, for: service) } func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { @@ -211,17 +322,34 @@ extension BlueCharmProvisioner: CBPeripheralDelegate { return } + if isLegacy { + // Legacy: just need the service with characteristics + serviceContinuation?.resume() + serviceContinuation = nil + return + } + + // BC04P: map specific characteristics for char in service.characteristics ?? [] { - if char.properties.contains(.write) || char.properties.contains(.writeWithoutResponse) { - if writeChar == nil { writeChar = char } - } - if char.properties.contains(.notify) { + switch char.uuid { + case Self.fea1Write: + writeChar = char + case Self.fea2Notify: notifyChar = char - peripheral.setNotifyValue(true, for: 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 { + if writeChar != nil || configChar != nil { serviceContinuation?.resume() serviceContinuation = nil } else { @@ -239,9 +367,31 @@ extension BlueCharmProvisioner: CBPeripheralDelegate { } 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: error) + 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 2336b10..bb2acdb 100644 --- a/PayfritBeacon/Provisioners/DXSmartProvisioner.swift +++ b/PayfritBeacon/Provisioners/DXSmartProvisioner.swift @@ -2,8 +2,22 @@ import Foundation import CoreBluetooth /// Provisioner for DXSmart / CP28 hardware -/// Special two-step auth: "555555" β†’ FFE3 (starts flashing), then password β†’ FFE3 -/// Config written as 10 iBeacon commands to FFE1, ACK'd by beacon, final 0x60 save +/// +/// Implements BOTH the new SDK protocol (preferred) and old SDK fallback: +/// +/// **New SDK (2024.10+)**: Writes to FFE2, notifications on FFE1 +/// - Frame selection (0x11/0x12) β†’ frame type (0x62 = iBeacon) +/// - Param writes: 0x74 UUID, 0x75 Major, 0x76 Minor, 0x77 RSSI, 0x78 AdvInt, 0x79 TxPower +/// - Save: 0x60 +/// - All wrapped in 4E 4F protocol packets +/// +/// **Old SDK fallback**: Writes to FFE1, re-sends 555555 before each command +/// - 0x36 UUID, 0x37 Major, 0x38 Minor, 0x39 TxPower, 0x40 RfPower, 0x41 AdvInt, 0x43 Name +/// - 0x44 Restart (includes password) +/// +/// Auth: "555555" to FFE3 (config mode) β†’ "dx1234" to FFE3 (authenticate) +/// NOTE: CoreBluetooth doesn't expose raw MAC addresses, so 48:87:2D OUI detection +/// (used on Android) is not available on iOS. Beacons are detected by name/service UUID. final class DXSmartProvisioner: NSObject, BeaconProvisioner { // MARK: - Constants @@ -11,35 +25,22 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner { private static let triggerPassword = "555555" private static let defaultPassword = "dx1234" - // DXSmart iBeacon config command IDs - private enum ConfigCmd: UInt8 { - case setUUID1 = 0x50 // UUID bytes 0-7 - case setUUID2 = 0x51 // UUID bytes 8-15 - case setMajor = 0x52 - case setMinor = 0x53 - case setTxPower = 0x54 - case setInterval = 0x55 - case setMeasured = 0x56 - case setName = 0x57 - case reserved1 = 0x58 - case reserved2 = 0x59 - case save = 0x60 - } - // MARK: - State private let peripheral: CBPeripheral private let centralManager: CBCentralManager - private var writeChar: CBCharacteristic? // FFE1 β€” TX/RX - private var passwordChar: CBCharacteristic? // FFE3 β€” Password - private var notifyChar: CBCharacteristic? // FFE1 also used for notify + private var ffe1Char: CBCharacteristic? // FFE1 β€” notify (ACK responses) + private var ffe2Char: CBCharacteristic? // FFE2 β€” write (new SDK commands) + private var ffe3Char: CBCharacteristic? // FFE3 β€” password private var connectionContinuation: CheckedContinuation? private var serviceContinuation: CheckedContinuation? private var responseContinuation: CheckedContinuation? + private var writeContinuation: CheckedContinuation? private(set) var isConnected = false private(set) var isFlashing = false // Beacon LED flashing after trigger + private var useNewSDK = true // Prefer new SDK, fallback to old // MARK: - Init @@ -73,7 +74,7 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner { } func writeConfig(_ config: BeaconConfig) async throws { - guard isConnected, let writeChar else { + guard isConnected else { throw ProvisionError.notConnected } @@ -82,26 +83,15 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner { throw ProvisionError.writeFailed("Invalid UUID length") } - // Send 10 config commands, each ACK'd - let commands: [(UInt8, Data)] = [ - (ConfigCmd.setUUID1.rawValue, Data(uuidBytes[0..<8])), - (ConfigCmd.setUUID2.rawValue, Data(uuidBytes[8..<16])), - (ConfigCmd.setMajor.rawValue, Data([UInt8(config.major >> 8), UInt8(config.major & 0xFF)])), - (ConfigCmd.setMinor.rawValue, Data([UInt8(config.minor >> 8), UInt8(config.minor & 0xFF)])), - (ConfigCmd.setTxPower.rawValue, Data([config.txPower])), - (ConfigCmd.setInterval.rawValue, Data([UInt8(config.advInterval >> 8), UInt8(config.advInterval & 0xFF)])), - (ConfigCmd.setMeasured.rawValue, Data([UInt8(bitPattern: config.measuredPower)])), - ] - - for (cmdId, payload) in commands { - let packet = Data([cmdId]) + payload - try await sendAndWaitAck(packet) + // Try new SDK first (FFE2), fall back to old SDK (FFE1) + if useNewSDK, let ffe2 = ffe2Char { + try await writeConfigNewSDK(config, uuidBytes: uuidBytes, writeChar: ffe2) + } else if let ffe1 = ffe1Char { + try await writeConfigOldSDK(config, uuidBytes: uuidBytes, writeChar: ffe1) + } else { + throw ProvisionError.characteristicNotFound } - // Save to flash - let savePacket = Data([ConfigCmd.save.rawValue]) - try await sendAndWaitAck(savePacket) - isFlashing = false } @@ -113,7 +103,124 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner { isFlashing = false } - // MARK: - Private + // MARK: - New SDK Protocol (FFE2, 2024.10+) + // Matches Android DXSmartProvisioner.writeBeaconConfig() + + private func writeConfigNewSDK(_ config: BeaconConfig, uuidBytes: [UInt8], writeChar: CBCharacteristic) async throws { + // Build command sequence matching Android's writeBeaconConfig() + let commands: [(String, Data)] = [ + // Frame 1: device info + radio params + ("Frame1_Select", buildProtocolPacket(cmd: 0x11, data: Data())), + ("Frame1_DevInfo", buildProtocolPacket(cmd: 0x61, data: Data())), + ("Frame1_RSSI", buildProtocolPacket(cmd: 0x77, data: Data([UInt8(bitPattern: config.measuredPower)]))), + ("Frame1_AdvInt", buildProtocolPacket(cmd: 0x78, data: Data([UInt8(config.advInterval & 0xFF)]))), + ("Frame1_TxPow", buildProtocolPacket(cmd: 0x79, data: Data([config.txPower]))), + + // Frame 2: iBeacon config + ("Frame2_Select", buildProtocolPacket(cmd: 0x12, data: Data())), + ("Frame2_iBeacon", buildProtocolPacket(cmd: 0x62, data: Data())), + ("UUID", buildProtocolPacket(cmd: 0x74, data: Data(uuidBytes))), + ("Major", buildProtocolPacket(cmd: 0x75, data: Data([UInt8(config.major >> 8), UInt8(config.major & 0xFF)]))), + ("Minor", buildProtocolPacket(cmd: 0x76, data: Data([UInt8(config.minor >> 8), UInt8(config.minor & 0xFF)]))), + ("RSSI@1m", buildProtocolPacket(cmd: 0x77, data: Data([UInt8(bitPattern: config.measuredPower)]))), + ("AdvInterval", buildProtocolPacket(cmd: 0x78, data: Data([UInt8(config.advInterval & 0xFF)]))), + ("TxPower", buildProtocolPacket(cmd: 0x79, data: Data([config.txPower]))), + ("TriggerOff", buildProtocolPacket(cmd: 0xA0, data: Data())), + + // Disable frames 3-6 + ("Frame3_Select", buildProtocolPacket(cmd: 0x13, data: Data())), + ("Frame3_NoData", buildProtocolPacket(cmd: 0xFF, data: Data())), + ("Frame4_Select", buildProtocolPacket(cmd: 0x14, data: Data())), + ("Frame4_NoData", buildProtocolPacket(cmd: 0xFF, data: Data())), + ("Frame5_Select", buildProtocolPacket(cmd: 0x15, data: Data())), + ("Frame5_NoData", buildProtocolPacket(cmd: 0xFF, data: Data())), + ("Frame6_Select", buildProtocolPacket(cmd: 0x16, data: Data())), + ("Frame6_NoData", buildProtocolPacket(cmd: 0xFF, data: Data())), + + // Save to flash + ("SaveConfig", buildProtocolPacket(cmd: 0x60, data: Data())), + ] + + for (name, packet) in commands { + try await writeToCharAndWaitACK(writeChar, data: packet, label: name) + // 200ms between commands (matches Android SDK timer interval) + try await Task.sleep(nanoseconds: 200_000_000) + } + } + + // MARK: - Old SDK Protocol (FFE1, pre-2024.10) + // Matches Android DXSmartProvisioner.writeFrame1() + // Key difference: must re-send "555555" to FFE3 before EVERY command + + private func writeConfigOldSDK(_ config: BeaconConfig, uuidBytes: [UInt8], writeChar: CBCharacteristic) async throws { + guard let ffe3 = ffe3Char else { + throw ProvisionError.characteristicNotFound + } + + let commands: [(String, Data)] = [ + ("UUID", buildProtocolPacket(cmd: 0x36, data: Data(uuidBytes))), + ("Major", buildProtocolPacket(cmd: 0x37, data: Data([UInt8(config.major >> 8), UInt8(config.major & 0xFF)]))), + ("Minor", buildProtocolPacket(cmd: 0x38, data: Data([UInt8(config.minor >> 8), UInt8(config.minor & 0xFF)]))), + ("TxPower", buildProtocolPacket(cmd: 0x39, data: Data([UInt8(bitPattern: config.measuredPower)]))), + ("RfPower", buildProtocolPacket(cmd: 0x40, data: Data([config.txPower]))), + ("AdvInt", buildProtocolPacket(cmd: 0x41, data: Data([UInt8(config.advInterval & 0xFF)]))), + ("Name", buildProtocolPacket(cmd: 0x43, data: Data("Payfrit".utf8))), + ] + + for (name, packet) in commands { + // Step 1: Re-send "555555" to FFE3 before each command (old SDK requirement) + if let triggerData = Self.triggerPassword.data(using: .utf8) { + peripheral.writeValue(triggerData, for: ffe3, type: .withResponse) + try await waitForWriteCallback() + } + + // Step 2: 50ms delay (SDK timer, half of 100ms default β€” tested OK) + try await Task.sleep(nanoseconds: 50_000_000) + + // Step 3: Write command to FFE1 + try await writeToCharAndWaitACK(writeChar, data: packet, label: name) + + // 200ms settle between params + try await Task.sleep(nanoseconds: 200_000_000) + } + } + + // MARK: - Read-Back Verification + + /// Read frame 2 (iBeacon config) to verify the write succeeded. + /// Returns the raw response data, or nil if read fails. + func readFrame2() async throws -> Data? { + guard let ffe2 = ffe2Char ?? ffe1Char else { return nil } + + let readCmd = buildProtocolPacket(cmd: 0x62, data: Data()) + peripheral.writeValue(readCmd, for: ffe2, type: .withResponse) + + do { + let response = try await waitForResponse(timeout: 2.0) + return response + } catch { + return nil + } + } + + // MARK: - Protocol Packet Builder + // Format: 4E 4F [CMD] [LEN] [DATA...] [CHECKSUM] + // Checksum = XOR of CMD, LEN, and all data bytes + + private func buildProtocolPacket(cmd: UInt8, data: Data) -> Data { + let len = UInt8(data.count) + var checksum = Int(cmd) ^ Int(len) + for byte in data { + checksum ^= Int(byte) + } + + var packet = Data([0x4E, 0x4F, cmd, len]) + packet.append(data) + packet.append(UInt8(checksum & 0xFF)) + return packet + } + + // MARK: - Private Helpers private func connectOnce() async throws { try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in @@ -144,32 +251,32 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner { } /// Two-step DXSmart auth: - /// 1. Send "555555" to FFE3 (beacon starts flashing) - /// 2. Send "dx1234" to FFE3 (actual auth) + /// 1. Send "555555" to FFE3 β€” fire and forget (WRITE_NO_RESPONSE) β€” enters config mode + /// 2. Send "dx1234" to FFE3 β€” fire and forget β€” authenticates + /// Matches Android enterConfigModeAndLogin(): both use WRITE_TYPE_NO_RESPONSE private func authenticate() async throws { - guard let passwordChar else { + guard let ffe3 = ffe3Char else { throw ProvisionError.characteristicNotFound } - // Step 1: Trigger β€” fire and forget (WRITE_NO_RESPONSE) + // Step 1: Trigger β€” fire and forget (matches Android's WRITE_TYPE_NO_RESPONSE) if let triggerData = Self.triggerPassword.data(using: .utf8) { - peripheral.writeValue(triggerData, for: passwordChar, type: .withoutResponse) - try await Task.sleep(nanoseconds: 500_000_000) // 500ms settle + peripheral.writeValue(triggerData, for: ffe3, type: .withoutResponse) + try await Task.sleep(nanoseconds: 100_000_000) // 100ms (matches Android SDK timer) } // Step 2: Auth password β€” fire and forget if let authData = Self.defaultPassword.data(using: .utf8) { - peripheral.writeValue(authData, for: passwordChar, type: .withoutResponse) - try await Task.sleep(nanoseconds: 500_000_000) // 500ms settle + peripheral.writeValue(authData, for: ffe3, type: .withoutResponse) + try await Task.sleep(nanoseconds: 100_000_000) // 100ms settle } } - private func sendAndWaitAck(_ data: Data) async throws { - guard let writeChar else { throw ProvisionError.notConnected } - + /// Write data to a characteristic and wait for ACK notification on FFE1 + private func writeToCharAndWaitACK(_ char: CBCharacteristic, data: Data, label: String) async throws { let _ = try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in responseContinuation = cont - peripheral.writeValue(data, for: writeChar, type: .withResponse) + peripheral.writeValue(data, for: char, type: .withResponse) DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in if let c = self?.responseContinuation { @@ -179,6 +286,34 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner { } } } + + /// Wait for a write callback (used for FFE3 password writes in old SDK path) + private func waitForWriteCallback() async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + writeContinuation = cont + + DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in + if let c = self?.writeContinuation { + self?.writeContinuation = nil + c.resume(throwing: ProvisionError.operationTimeout) + } + } + } + } + + /// Wait for a response notification with custom timeout + private func waitForResponse(timeout: TimeInterval) async throws -> Data { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + responseContinuation = cont + + DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { [weak self] in + if let c = self?.responseContinuation { + self?.responseContinuation = nil + c.resume(throwing: ProvisionError.operationTimeout) + } + } + } + } } // MARK: - CBPeripheralDelegate @@ -214,19 +349,24 @@ extension DXSmartProvisioner: CBPeripheralDelegate { for char in service.characteristics ?? [] { switch char.uuid { case GATTConstants.ffe1Char: - writeChar = char - // FFE1 is also used for notify on DXSmart + ffe1Char = char + // FFE1 is used for notify (ACK responses) if char.properties.contains(.notify) { peripheral.setNotifyValue(true, for: char) } + case GATTConstants.ffe2Char: + ffe2Char = char case GATTConstants.ffe3Char: - passwordChar = char + ffe3Char = char default: break } } - if writeChar != nil && passwordChar != nil { + // Need at least FFE1 (notify) + FFE3 (password) + // FFE2 is preferred for writes but optional (old firmware uses FFE1) + if ffe1Char != nil && ffe3Char != nil { + useNewSDK = (ffe2Char != nil) serviceContinuation?.resume() serviceContinuation = nil } else { @@ -245,6 +385,18 @@ extension DXSmartProvisioner: CBPeripheralDelegate { } func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { + // Handle write callback for old SDK FFE3 password writes + if let cont = writeContinuation { + writeContinuation = nil + if let error { + cont.resume(throwing: error) + } else { + cont.resume() + } + return + } + + // Handle write errors for command writes if let error, let cont = responseContinuation { responseContinuation = nil cont.resume(throwing: error) diff --git a/PayfritBeacon/Provisioners/FallbackProvisioner.swift b/PayfritBeacon/Provisioners/FallbackProvisioner.swift new file mode 100644 index 0000000..d9580de --- /dev/null +++ b/PayfritBeacon/Provisioners/FallbackProvisioner.swift @@ -0,0 +1,56 @@ +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 + + init(peripheral: CBPeripheral, centralManager: CBCentralManager) { + self.peripheral = peripheral + self.centralManager = centralManager + } + + func connect() async throws { + let provisioners: [() -> any BeaconProvisioner] = [ + { KBeaconProvisioner(peripheral: self.peripheral, centralManager: self.centralManager) }, + { DXSmartProvisioner(peripheral: self.peripheral, centralManager: self.centralManager) }, + { BlueCharmProvisioner(peripheral: self.peripheral, centralManager: self.centralManager) }, + ] + + var lastError: Error = ProvisionError.connectionTimeout + + for makeProvisioner in provisioners { + let provisioner = makeProvisioner() + do { + try await provisioner.connect() + activeProvisioner = provisioner + isConnected = true + return + } catch { + provisioner.disconnect() + lastError = error + } + } + + 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/Services/APIClient.swift b/PayfritBeacon/Services/APIClient.swift index 1642b29..1c04081 100644 --- a/PayfritBeacon/Services/APIClient.swift +++ b/PayfritBeacon/Services/APIClient.swift @@ -268,6 +268,40 @@ actor APIClient { return resp.data } + // MARK: - Beacon Config (server-configured values) + + struct BeaconConfigResponse: Codable { + let uuid: String? + let UUID: String? + let major: Int? + let Major: Int? + let minor: Int? + let Minor: Int? + let txPower: Int? + let TxPower: Int? + let measuredPower: Int? + let MeasuredPower: Int? + let advInterval: Int? + let AdvInterval: Int? + + var configUUID: String { uuid ?? UUID ?? "" } + var configMajor: Int { major ?? Major ?? 0 } + var configMinor: Int { minor ?? Minor ?? 0 } + var configTxPower: Int { txPower ?? TxPower ?? 1 } + var configMeasuredPower: Int { measuredPower ?? MeasuredPower ?? -100 } + var configAdvInterval: Int { advInterval ?? AdvInterval ?? 2 } + } + + func getBeaconConfig(businessId: String, servicePointId: String, token: String) async throws -> BeaconConfigResponse { + let body: [String: Any] = ["BusinessID": businessId, "ServicePointID": servicePointId] + let data = try await post(path: "/beacon-sharding/get_beacon_config.php", body: body, token: token, businessId: businessId) + let resp = try JSONDecoder().decode(APIResponse.self, from: data) + guard resp.success, let config = resp.data else { + throw APIError.serverError(resp.message ?? "Failed to get beacon config") + } + return config + } + // MARK: - User Profile struct UserProfile: Codable { diff --git a/PayfritBeacon/Services/BLEManager.swift b/PayfritBeacon/Services/BLEManager.swift index b00ec1a..b633bfb 100644 --- a/PayfritBeacon/Services/BLEManager.swift +++ b/PayfritBeacon/Services/BLEManager.swift @@ -89,6 +89,9 @@ final class BLEManager: NSObject, ObservableObject { } // 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 this detection + // path is unavailable on iOS. We rely on service UUID + device name instead. func detectBeaconType( name: String?, diff --git a/PayfritBeacon/Utils/BeaconBanList.swift b/PayfritBeacon/Utils/BeaconBanList.swift index 38c94d8..1d39e64 100644 --- a/PayfritBeacon/Utils/BeaconBanList.swift +++ b/PayfritBeacon/Utils/BeaconBanList.swift @@ -1,24 +1,57 @@ import Foundation -/// Factory-default UUIDs that indicate an unconfigured beacon (matches Android BeaconBanList.kt) +/// Factory-default UUIDs that indicate an unconfigured beacon +/// Matches Android BeaconBanList.kt exactly enum BeaconBanList { /// UUID prefixes (first 8 hex chars) that are factory defaults - static let bannedPrefixes: Set = [ - "E2C56DB5", // Apple AirLocate / Minew - "B9407F30", // Estimote - "FDA50693", // Generic Chinese bulk - "F7826DA6", // Kontakt.io - "2F234454", // Radius Networks - "74278BDA", // Generic bulk - "00000000", // Unconfigured - "FFFFFFFF", // Unconfigured + /// Key = uppercase prefix, Value = reason + private static let bannedPrefixes: [String: String] = [ + "E2C56DB5": "Apple AirLocate / Minew factory default", + "F7826DA6": "Kontakt.io factory default", + "2F234454": "Radius Networks default", + "B9407F30": "Estimote factory default", + "FDA50693": "Generic bulk / Feasycom factory default", + "74278BDA": "Generic bulk manufacturer default", + "8492E75F": "Generic bulk manufacturer default", + "A0B13730": "Generic bulk manufacturer default", + "EBEFD083": "JAALEE factory default", + "B5B182C7": "April Brother factory default", + "00000000": "Unconfigured / zeroed UUID", + "FFFFFFFF": "Unconfigured / max UUID", + ] + + /// Full UUIDs that are known defaults (exact match on 32-char uppercase hex, no dashes) + private static let bannedFullUUIDs: [String: String] = [ + "E2C56DB5DFFB48D2B060D0F5A71096E0": "Apple AirLocate sample UUID", + "B9407F30F5F8466EAFF925556B57FE6D": "Estimote factory default", + "2F234454CF6D4A0FADF2F4911BA9FFA6": "Radius Networks default", + "FDA50693A4E24FB1AFCFC6EB07647825": "Generic Chinese bulk default", + "74278BDAB64445208F0C720EAF059935": "Generic bulk default", + "00000000000000000000000000000000": "Zeroed UUID β€” unconfigured hardware", ] /// Check if a UUID is a factory default static func isBanned(_ uuid: String) -> Bool { let normalized = uuid.normalizedUUID + + // Check full UUID match + if bannedFullUUIDs[normalized] != nil { return true } + + // Check prefix match let prefix = String(normalized.prefix(8)) - return bannedPrefixes.contains(prefix) + return bannedPrefixes[prefix] != nil + } + + /// Get the reason a UUID is banned, or nil if not banned + static func getBanReason(_ uuid: String) -> String? { + let normalized = uuid.normalizedUUID + + // Check full UUID match first + if let reason = bannedFullUUIDs[normalized] { return reason } + + // Check prefix + let prefix = String(normalized.prefix(8)) + return bannedPrefixes[prefix] } } diff --git a/PayfritBeacon/Utils/BeaconShardPool.swift b/PayfritBeacon/Utils/BeaconShardPool.swift index 61e3871..eff8b53 100644 --- a/PayfritBeacon/Utils/BeaconShardPool.swift +++ b/PayfritBeacon/Utils/BeaconShardPool.swift @@ -1,73 +1,78 @@ import Foundation /// Pre-allocated Payfrit shard UUIDs for business namespace allocation -/// Matches Android's BeaconShardPool.kt +/// Exact copy of Android's BeaconShardPool.kt (64 UUIDs) enum BeaconShardPool { static let shardUUIDs: [String] = [ "f7826da6-4fa2-4e98-8024-bc5b71e0893e", "2f234454-cf6d-4a0f-adf2-f4911ba9ffa6", + "b9407f30-f5f8-466e-aff9-25556b57fe6d", + "e2c56db5-dffb-48d2-b060-d0f5a71096e0", + "d0d3fa86-ca76-45ec-9bd9-6af4fac1e268", + "a7ae2eb7-1f00-4168-b99b-a749bac36c92", + "8deefbb9-f738-4297-8040-96668bb44281", + "5a4bcfce-174e-4bac-a814-092978f50e04", + "74278bda-b644-4520-8f0c-720eaf059935", + "e7eb6d67-2b4f-4c1b-8b6e-8c3b3f5d8b9a", + "1f3c5d7e-9a2b-4c8d-ae6f-0b1c2d3e4f5a", + "a1b2c3d4-e5f6-4789-abcd-ef0123456789", + "98765432-10fe-4cba-9876-543210fedcba", "deadbeef-cafe-4bab-dead-beefcafebabe", - "a495ff10-c5b1-4b44-b512-1370f02d74de", - "a495ff20-c5b1-4b44-b512-1370f02d74de", - "a495ff30-c5b1-4b44-b512-1370f02d74de", - "a495ff40-c5b1-4b44-b512-1370f02d74de", - "a495ff50-c5b1-4b44-b512-1370f02d74de", - "a495ff60-c5b1-4b44-b512-1370f02d74de", - "a495ff70-c5b1-4b44-b512-1370f02d74de", - "a495ff80-c5b1-4b44-b512-1370f02d74de", - "a495ff90-c5b1-4b44-b512-1370f02d74de", - "b0702880-a295-a8ab-f734-031a98a51266", - "b0702881-a295-a8ab-f734-031a98a51266", - "b0702882-a295-a8ab-f734-031a98a51266", - "b0702883-a295-a8ab-f734-031a98a51266", - "b0702884-a295-a8ab-f734-031a98a51266", - "b0702885-a295-a8ab-f734-031a98a51266", - "b0702886-a295-a8ab-f734-031a98a51266", - "b0702887-a295-a8ab-f734-031a98a51266", - "b0702888-a295-a8ab-f734-031a98a51266", - "b0702889-a295-a8ab-f734-031a98a51266", - "b070288a-a295-a8ab-f734-031a98a51266", - "b070288b-a295-a8ab-f734-031a98a51266", - "c0ffeec0-ffee-c0ff-eec0-ffeec0ffeec0", - "d1d1d1d1-d1d1-d1d1-d1d1-d1d1d1d1d1d1", - "e1e1e1e1-e1e1-e1e1-e1e1-e1e1e1e1e1e1", - "f1f1f1f1-f1f1-f1f1-f1f1-f1f1f1f1f1f1", - "01010101-0101-0101-0101-010101010101", - "02020202-0202-0202-0202-020202020202", - "03030303-0303-0303-0303-030303030303", - "04040404-0404-0404-0404-040404040404", - "05050505-0505-0505-0505-050505050505", - "06060606-0606-0606-0606-060606060606", - "07070707-0707-0707-0707-070707070707", - "08080808-0808-0808-0808-080808080808", - "09090909-0909-0909-0909-090909090909", - "0a0a0a0a-0a0a-0a0a-0a0a-0a0a0a0a0a0a", - "0b0b0b0b-0b0b-0b0b-0b0b-0b0b0b0b0b0b", - "0c0c0c0c-0c0c-0c0c-0c0c-0c0c0c0c0c0c", - "10101010-1010-1010-1010-101010101010", - "11111111-1111-1111-1111-111111111111", - "12121212-1212-1212-1212-121212121212", - "13131313-1313-1313-1313-131313131313", - "14141414-1414-1414-1414-141414141414", - "15151515-1515-1515-1515-151515151515", - "16161616-1616-1616-1616-161616161616", - "17171717-1717-1717-1717-171717171717", - "18181818-1818-1818-1818-181818181818", - "19191919-1919-1919-1919-191919191919", - "1a1a1a1a-1a1a-1a1a-1a1a-1a1a1a1a1a1a", - "1b1b1b1b-1b1b-1b1b-1b1b-1b1b1b1b1b1b", - "1c1c1c1c-1c1c-1c1c-1c1c-1c1c1c1c1c1c", - "1d1d1d1d-1d1d-1d1d-1d1d-1d1d1d1d1d1d", - "1e1e1e1e-1e1e-1e1e-1e1e-1e1e1e1e1e1e", - "1f1f1f1f-1f1f-1f1f-1f1f-1f1f1f1f1f1f", - "20202020-2020-2020-2020-202020202020", - "21212121-2121-2121-2121-212121212121", - "22222222-2222-2222-2222-222222222222", - "23232323-2323-2323-2323-232323232323", - "24242424-2424-2424-2424-242424242424", - "25252525-2525-2525-2525-252525252525", - "26262626-2626-2626-2626-262626262626", - "27272727-2727-2727-2727-272727272727", + "c0ffee00-dead-4bee-f000-ba5eba11fade", + "0a1b2c3d-4e5f-4a6b-7c8d-9e0f1a2b3c4d", + "12345678-90ab-4def-1234-567890abcdef", + "fedcba98-7654-4210-fedc-ba9876543210", + "abcd1234-ef56-4789-abcd-1234ef567890", + "11111111-2222-4333-4444-555566667777", + "88889999-aaaa-4bbb-cccc-ddddeeeeefff", + "01234567-89ab-4cde-f012-3456789abcde", + "a0a0a0a0-b1b1-4c2c-d3d3-e4e4e4e4f5f5", + "f0e0d0c0-b0a0-4908-0706-050403020100", + "13579bdf-2468-4ace-1357-9bdf2468ace0", + "fdb97531-eca8-4642-0fdb-97531eca8642", + "aabbccdd-eeff-4011-2233-445566778899", + "99887766-5544-4332-2110-ffeeddccbbaa", + "a1a2a3a4-b5b6-4c7c-8d9d-e0e1f2f3f4f5", + "5f4f3f2f-1f0f-4efe-dfcf-bfaf9f8f7f6f", + "00112233-4455-4667-7889-9aabbccddeef", + "feeddccb-baa9-4887-7665-5443322110ff", + "1a2b3c4d-5e6f-4a0b-1c2d-3e4f5a6b7c8d", + "d8c7b6a5-9483-4726-1504-f3e2d1c0b9a8", + "0f1e2d3c-4b5a-4697-8879-6a5b4c3d2e1f", + "f1e2d3c4-b5a6-4978-8697-a5b4c3d2e1f0", + "12ab34cd-56ef-4789-0abc-def123456789", + "987654fe-dcba-4098-7654-321fedcba098", + "abcdef01-2345-4678-9abc-def012345678", + "876543fe-dcba-4210-9876-543fedcba210", + "0a0b0c0d-0e0f-4101-1121-314151617181", + "91a1b1c1-d1e1-4f10-2030-405060708090", + "a0b1c2d3-e4f5-4a6b-7c8d-9e0f1a2b3c4d", + "d4c3b2a1-0f9e-48d7-c6b5-a49382716050", + "50607080-90a0-4b0c-0d0e-0f1011121314", + "14131211-100f-4e0d-0c0b-0a0908070605", + "a1b2c3d4-e5f6-4718-293a-4b5c6d7e8f90", + "09f8e7d6-c5b4-4a39-2817-0615f4e3d2c1", + "11223344-5566-4778-899a-abbccddeeff0", + "ffeeddc0-bbaa-4988-7766-554433221100", + "a1a1b2b2-c3c3-4d4d-e5e5-f6f6a7a7b8b8", + "b8b8a7a7-f6f6-4e5e-5d4d-4c3c3b2b2a1a", + "12341234-5678-4567-89ab-89abcdefcdef", + "fedcfedc-ba98-4ba9-8765-87654321d321", + "0a1a2a3a-4a5a-4a6a-7a8a-9aaabacadaea", + "eadacaba-aa9a-48a7-a6a5-a4a3a2a1a0af", + "01020304-0506-4708-090a-0b0c0d0e0f10", + "100f0e0d-0c0b-4a09-0807-060504030201", + "aabbccdd-1122-4334-4556-6778899aabbc", + "cbba9988-7766-4554-4332-2110ddccbbaa", + "f0f1f2f3-f4f5-4f6f-7f8f-9fafbfcfdfef", + "efdfcfbf-af9f-48f7-f6f5-f4f3f2f1f0ee", + "a0a1a2a3-a4a5-4a6a-7a8a-9a0b1b2b3b4b", + "4b3b2b1b-0a9a-48a7-a6a5-a4a3a2a1a0ff", ] + + /// Check if a UUID is a Payfrit shard + static func isPayfrit(_ uuid: String) -> Bool { + shardUUIDs.contains(where: { $0.caseInsensitiveCompare(uuid) == .orderedSame }) + } } diff --git a/PayfritBeacon/Views/ScanView.swift b/PayfritBeacon/Views/ScanView.swift index f5e690d..86bfc41 100644 --- a/PayfritBeacon/Views/ScanView.swift +++ b/PayfritBeacon/Views/ScanView.swift @@ -691,7 +691,8 @@ struct ScanView: View { case .bluecharm: return BlueCharmProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager) case .unknown: - return KBeaconProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager) + // Try all provisioners in sequence (matches Android fallback behavior) + return FallbackProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager) } } }