From ee366870eac93078e6a546f578443c1b996e435f Mon Sep 17 00:00:00 2001 From: Schwifty Date: Sat, 21 Mar 2026 10:31:39 +0000 Subject: [PATCH] feat: multi-password auth, structured error codes, missing API endpoints - DX-Smart auth now tries multiple passwords in sequence (555555, dx1234, 000000) instead of hardcoding a single password. Matches Android behavior for better compatibility across firmware versions. - Added ProvisioningError enum with structured error codes (CONNECTION_FAILED, AUTH_FAILED, SERVICE_NOT_FOUND, WRITE_FAILED, etc.) matching Android's BeaconConfig error codes. All fail() calls now tagged with codes for better debugging and error reporting. - Added ProvisioningResult.failureWithCode case and handling in ScanView. - Added missing API endpoints that Android has: - getBusiness() - single business fetch - getBusinessName() - cached business name lookup - allocateServicePointMinor() - minor value allocation - Fixed stray print() in Api.swift to use DebugLog.shared.log() for consistency. Co-Authored-By: Claude Opus 4.6 (1M context) --- PayfritBeacon/Api.swift | 61 +++++++++++- PayfritBeacon/BeaconProvisioner.swift | 130 +++++++++++++++++++------- PayfritBeacon/ScanView.swift | 8 ++ 3 files changed, 166 insertions(+), 33 deletions(-) diff --git a/PayfritBeacon/Api.swift b/PayfritBeacon/Api.swift index 41c6fb2..a229556 100644 --- a/PayfritBeacon/Api.swift +++ b/PayfritBeacon/Api.swift @@ -141,6 +141,43 @@ class Api { } } + /// Get a single business by ID + func getBusiness(businessId: Int) async throws -> Business? { + let json = try await postRequest( + endpoint: "/businesses/get.php", + body: ["BusinessID": businessId], + extraHeaders: ["X-Business-Id": String(businessId)] + ) + + guard parseBool(json["OK"] ?? json["ok"]) else { + return nil + } + + guard let b = (json["BUSINESS"] ?? json["business"]) as? [String: Any], + let id = parseIntValue(b["BusinessID"] ?? b["BUSINESSID"]) else { + return nil + } + + let name = ((b["BusinessName"] ?? b["BUSINESSNAME"] ?? b["Name"] ?? b["NAME"]) as? String) ?? "" + let headerImageExtension = (b["HeaderImageExtension"] ?? b["HEADERIMAGEEXTENSION"]) as? String + return Business(businessId: id, name: name, headerImageExtension: headerImageExtension) + } + + // Business name cache (matches Android's caching behavior) + private var businessNameCache: [Int: String] = [:] + + /// Get business name with caching (avoids repeated API calls) + func getBusinessName(businessId: Int) async throws -> String { + if let cached = businessNameCache[businessId] { + return cached + } + if let business = try await getBusiness(businessId: businessId) { + businessNameCache[business.businessId] = business.name + return business.name + } + return "" + } + // ========================================================================= // BEACONS // ========================================================================= @@ -411,7 +448,7 @@ class Api { ) // Debug log - print("[API] allocateBusinessNamespace response: \(json)") + DebugLog.shared.log("[API] allocateBusinessNamespace response: \(json)") if !parseBool(json["OK"] ?? json["ok"]) { let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to allocate namespace" @@ -436,6 +473,28 @@ class Api { ) } + /// Allocate a minor value for a service point (auto-assigns next available minor) + func allocateServicePointMinor(businessId: Int, servicePointId: Int) async throws -> UInt16 { + let json = try await postRequest( + endpoint: "/beacon-sharding/allocate_servicepoint_minor.php", + body: ["BusinessID": businessId, "ServicePointID": servicePointId], + extraHeaders: ["X-Business-Id": String(businessId)] + ) + + DebugLog.shared.log("[API] allocateServicePointMinor response: \(json)") + + if !parseBool(json["OK"] ?? json["ok"]) { + let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to allocate minor" + throw ApiException(error) + } + + guard let minor = parseIntValue(json["MINOR"] ?? json["minor"] ?? json["Minor"] ?? json["BeaconMinor"] ?? json["BEACONMINOR"]) else { + throw ApiException("Invalid response - no Minor value") + } + + return UInt16(minor) + } + /// List service points for a business (for beacon assignment) func listServicePoints(businessId: Int) async throws -> [ServicePoint] { let json = try await postRequest( diff --git a/PayfritBeacon/BeaconProvisioner.swift b/PayfritBeacon/BeaconProvisioner.swift index cd247bf..7189c1d 100644 --- a/PayfritBeacon/BeaconProvisioner.swift +++ b/PayfritBeacon/BeaconProvisioner.swift @@ -1,10 +1,42 @@ import Foundation import CoreBluetooth +/// Structured provisioning error codes (matches Android's BeaconConfig error codes) +enum ProvisioningError: String, LocalizedError { + case bluetoothUnavailable = "BLUETOOTH_UNAVAILABLE" + case connectionFailed = "CONNECTION_FAILED" + case connectionTimeout = "CONNECTION_TIMEOUT" + case serviceNotFound = "SERVICE_NOT_FOUND" + case authFailed = "AUTH_FAILED" + case writeFailed = "WRITE_FAILED" + case verificationFailed = "VERIFICATION_FAILED" + case disconnected = "DISCONNECTED" + case noConfig = "NO_CONFIG" + case timeout = "TIMEOUT" + case unknown = "UNKNOWN" + + var errorDescription: String? { + switch self { + case .bluetoothUnavailable: return "Bluetooth not available" + case .connectionFailed: return "Failed to connect to beacon" + case .connectionTimeout: return "Connection timed out" + case .serviceNotFound: return "Config service not found on device" + case .authFailed: return "Authentication failed - all passwords rejected" + case .writeFailed: return "Failed to write configuration" + case .verificationFailed: return "Beacon not broadcasting expected values" + case .disconnected: return "Unexpected disconnect" + case .noConfig: return "No configuration provided" + case .timeout: return "Operation timed out" + case .unknown: return "Unknown error" + } + } +} + /// Result of a provisioning operation enum ProvisioningResult { case success(macAddress: String?) case failure(String) + case failureWithCode(ProvisioningError, detail: String? = nil) } /// Configuration to write to a beacon @@ -64,8 +96,8 @@ class BeaconProvisioner: NSObject, ObservableObject { // DX-Smart packet header private static let DXSMART_HEADER: [UInt8] = [0x4E, 0x4F] - // DX-Smart connection password - private static let DXSMART_PASSWORD = "dx1234" + // DX-Smart connection passwords (tried in order until one works) + private static let DXSMART_PASSWORDS = ["555555", "dx1234", "000000"] // DX-Smart command codes private enum DXCmd: UInt8 { @@ -163,7 +195,7 @@ class BeaconProvisioner: NSObject, ObservableObject { /// Provision a beacon with the given configuration func provision(beacon: DiscoveredBeacon, config: BeaconConfig, completion: @escaping (ProvisioningResult) -> Void) { guard centralManager.state == .poweredOn else { - completion(.failure("Bluetooth not available")) + completion(.failureWithCode(.bluetoothUnavailable)) return } @@ -193,7 +225,7 @@ class BeaconProvisioner: NSObject, ObservableObject { // Timeout after 30 seconds DispatchQueue.main.asyncAfter(deadline: .now() + 30) { [weak self] in if self?.state != .success && self?.state != .idle { - self?.fail("Connection timeout") + self?.fail("Connection timeout", code: .connectionTimeout) } } } @@ -268,13 +300,17 @@ class BeaconProvisioner: NSObject, ObservableObject { progress = "" } - private func fail(_ message: String) { - DebugLog.shared.log("BLE: Failed - \(message)") + private func fail(_ message: String, code: ProvisioningError? = nil) { + DebugLog.shared.log("BLE: Failed [\(code?.rawValue ?? "UNTYPED")] - \(message)") state = .failed(message) if let peripheral = peripheral { centralManager.cancelPeripheralConnection(peripheral) } - completion?(.failure(message)) + if let code = code { + completion?(.failureWithCode(code, detail: message)) + } else { + completion?(.failure(message)) + } cleanup() } @@ -293,7 +329,7 @@ class BeaconProvisioner: NSObject, ObservableObject { private func provisionDXSmart() { guard let service = configService else { - fail("DX-Smart config service not found") + fail("DX-Smart config service not found", code: .serviceNotFound) return } @@ -319,21 +355,40 @@ class BeaconProvisioner: NSObject, ObservableObject { } } - /// Write password to FFE3 + /// Write password to FFE3 (tries multiple passwords in sequence) private func dxSmartAuthenticate() { guard let passwordChar = characteristics[BeaconProvisioner.DXSMART_PASSWORD_CHAR] else { - fail("DX-Smart password characteristic (FFE3) not found") + fail("DX-Smart password characteristic (FFE3) not found", code: .serviceNotFound) + return + } + + guard passwordIndex < BeaconProvisioner.DXSMART_PASSWORDS.count else { + fail("Authentication failed - all passwords rejected", code: .authFailed) return } state = .authenticating - progress = "Authenticating..." + let currentPassword = BeaconProvisioner.DXSMART_PASSWORDS[passwordIndex] + progress = "Authenticating (attempt \(passwordIndex + 1)/\(BeaconProvisioner.DXSMART_PASSWORDS.count))..." - let passwordData = Data(BeaconProvisioner.DXSMART_PASSWORD.utf8) - DebugLog.shared.log("BLE: Writing password to FFE3 (\(passwordData.count) bytes)") + let passwordData = Data(currentPassword.utf8) + DebugLog.shared.log("BLE: Writing password \(passwordIndex + 1)/\(BeaconProvisioner.DXSMART_PASSWORDS.count) to FFE3 (\(passwordData.count) bytes)") peripheral?.writeValue(passwordData, for: passwordChar, type: .withResponse) } + /// Called when a password attempt fails — tries the next one + private func dxSmartRetryNextPassword() { + passwordIndex += 1 + if passwordIndex < BeaconProvisioner.DXSMART_PASSWORDS.count { + DebugLog.shared.log("BLE: Password rejected, trying next (\(passwordIndex + 1)/\(BeaconProvisioner.DXSMART_PASSWORDS.count))") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.dxSmartAuthenticate() + } + } else { + fail("Authentication failed - all \(BeaconProvisioner.DXSMART_PASSWORDS.count) passwords rejected", code: .authFailed) + } + } + /// Read device info (MAC address) before writing config private func dxSmartReadDeviceInfoBeforeWrite() { guard let commandChar = characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] else { @@ -381,7 +436,7 @@ class BeaconProvisioner: NSObject, ObservableObject { /// 24. SaveConfig 0x60 — persist to flash private func dxSmartWriteConfig() { guard let config = config else { - fail("No config provided") + fail("No config provided", code: .noConfig) return } @@ -479,7 +534,7 @@ class BeaconProvisioner: NSObject, ObservableObject { progress = "Writing config (\(current)/\(total))..." guard let commandChar = characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] else { - fail("DX-Smart command characteristic (FFE2) not found") + fail("DX-Smart command characteristic (FFE2) not found", code: .serviceNotFound) return } @@ -568,7 +623,7 @@ class BeaconProvisioner: NSObject, ObservableObject { } } - /// Authenticate on FFE3 for read mode + /// Authenticate on FFE3 for read mode (uses same multi-password fallback) private func dxSmartReadAuth() { guard let passwordChar = characteristics[BeaconProvisioner.DXSMART_PASSWORD_CHAR] else { DebugLog.shared.log("BLE: No FFE3 for auth, finishing") @@ -576,11 +631,18 @@ class BeaconProvisioner: NSObject, ObservableObject { return } - state = .authenticating - progress = "Authenticating..." + guard passwordIndex < BeaconProvisioner.DXSMART_PASSWORDS.count else { + DebugLog.shared.log("BLE: All passwords exhausted in read mode") + finishRead() + return + } - let passwordData = Data(BeaconProvisioner.DXSMART_PASSWORD.utf8) - DebugLog.shared.log("BLE: Read mode — writing password to FFE3") + state = .authenticating + let currentPassword = BeaconProvisioner.DXSMART_PASSWORDS[passwordIndex] + progress = "Authenticating (attempt \(passwordIndex + 1)/\(BeaconProvisioner.DXSMART_PASSWORDS.count))..." + + let passwordData = Data(currentPassword.utf8) + DebugLog.shared.log("BLE: Read mode — writing password \(passwordIndex + 1)/\(BeaconProvisioner.DXSMART_PASSWORDS.count) to FFE3") peripheral?.writeValue(passwordData, for: passwordChar, type: .withResponse) } @@ -897,7 +959,7 @@ extension BeaconProvisioner: CBCentralManagerDelegate { if operationMode == .readingConfig { readFail(msg) } else { - fail(msg) + fail(msg, code: .connectionFailed) } } } @@ -912,7 +974,7 @@ extension BeaconProvisioner: CBCentralManagerDelegate { if case .failed = state { // Already failed } else { - fail("Unexpected disconnect") + fail("Unexpected disconnect", code: .disconnected) } } } @@ -927,7 +989,7 @@ extension BeaconProvisioner: CBPeripheralDelegate { if operationMode == .readingConfig { readFail("Service discovery failed: \(error.localizedDescription)") } else { - fail("Service discovery failed: \(error.localizedDescription)") + fail("Service discovery failed: \(error.localizedDescription)", code: .serviceNotFound) } return } @@ -936,7 +998,7 @@ extension BeaconProvisioner: CBPeripheralDelegate { if operationMode == .readingConfig { readFail("No services found") } else { - fail("No services found") + fail("No services found", code: .serviceNotFound) } return } @@ -959,7 +1021,7 @@ extension BeaconProvisioner: CBPeripheralDelegate { return } } - fail("Config service not found on device") + fail("Config service not found on device", code: .serviceNotFound) } func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { @@ -969,7 +1031,7 @@ extension BeaconProvisioner: CBPeripheralDelegate { DebugLog.shared.log("BLE: Char discovery failed for \(service.uuid): \(error.localizedDescription)") exploreNextService() } else { - fail("Characteristic discovery failed: \(error.localizedDescription)") + fail("Characteristic discovery failed: \(error.localizedDescription)", code: .serviceNotFound) } return } @@ -978,7 +1040,7 @@ extension BeaconProvisioner: CBPeripheralDelegate { if operationMode == .readingConfig { exploreNextService() } else { - fail("No characteristics found") + fail("No characteristics found", code: .serviceNotFound) } return } @@ -1015,10 +1077,14 @@ extension BeaconProvisioner: CBPeripheralDelegate { DebugLog.shared.log("BLE: Write failed for \(characteristic.uuid): \(error.localizedDescription)") if characteristic.uuid == BeaconProvisioner.DXSMART_PASSWORD_CHAR { - if operationMode == .readingConfig { - readFail("Authentication failed: \(error.localizedDescription)") + // Password rejected — try next password in the list + if passwordIndex + 1 < BeaconProvisioner.DXSMART_PASSWORDS.count { + DebugLog.shared.log("BLE: Password \(passwordIndex + 1) rejected, trying next...") + dxSmartRetryNextPassword() + } else if operationMode == .readingConfig { + readFail("Authentication failed - all passwords rejected") } else { - fail("Authentication failed: \(error.localizedDescription)") + fail("Authentication failed - all \(BeaconProvisioner.DXSMART_PASSWORDS.count) passwords rejected", code: .authFailed) } return } @@ -1041,7 +1107,7 @@ extension BeaconProvisioner: CBPeripheralDelegate { self?.dxSmartSendNextCommand() } } else { - fail("Command write failed at step \(dxSmartWriteIndex + 1): \(error.localizedDescription)") + fail("Command write failed at step \(dxSmartWriteIndex + 1): \(error.localizedDescription)", code: .writeFailed) } } return @@ -1051,7 +1117,7 @@ extension BeaconProvisioner: CBPeripheralDelegate { DebugLog.shared.log("BLE: Write failed in read mode, ignoring: \(error.localizedDescription)") return } - fail("Write failed: \(error.localizedDescription)") + fail("Write failed: \(error.localizedDescription)", code: .writeFailed) return } diff --git a/PayfritBeacon/ScanView.swift b/PayfritBeacon/ScanView.swift index 209cc84..09df0c9 100644 --- a/PayfritBeacon/ScanView.swift +++ b/PayfritBeacon/ScanView.swift @@ -913,6 +913,10 @@ struct ScanView: View { } case .failure(let error): failProvisioning(error) + case .failureWithCode(let code, let detail): + let msg = detail ?? code.errorDescription ?? code.rawValue + DebugLog.shared.log("[ScanView] Provisioning failed [\(code.rawValue)]: \(msg)") + failProvisioning(msg) } } } @@ -996,6 +1000,10 @@ struct ScanView: View { } case .failure(let error): failProvisioning(error) + case .failureWithCode(let code, let detail): + let msg = detail ?? code.errorDescription ?? code.rawValue + DebugLog.shared.log("[ScanView] Provisioning failed [\(code.rawValue)]: \(msg)") + failProvisioning(msg) } } }