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) <noreply@anthropic.com>
This commit is contained in:
parent
237ac38557
commit
ee366870ea
3 changed files with 166 additions and 33 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue