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:
Schwifty 2026-03-21 10:31:39 +00:00
parent 237ac38557
commit ee366870ea
3 changed files with 166 additions and 33 deletions

View file

@ -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 // BEACONS
// ========================================================================= // =========================================================================
@ -411,7 +448,7 @@ class Api {
) )
// Debug log // Debug log
print("[API] allocateBusinessNamespace response: \(json)") DebugLog.shared.log("[API] allocateBusinessNamespace response: \(json)")
if !parseBool(json["OK"] ?? json["ok"]) { if !parseBool(json["OK"] ?? json["ok"]) {
let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to allocate namespace" 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) /// List service points for a business (for beacon assignment)
func listServicePoints(businessId: Int) async throws -> [ServicePoint] { func listServicePoints(businessId: Int) async throws -> [ServicePoint] {
let json = try await postRequest( let json = try await postRequest(

View file

@ -1,10 +1,42 @@
import Foundation import Foundation
import CoreBluetooth 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 /// Result of a provisioning operation
enum ProvisioningResult { enum ProvisioningResult {
case success(macAddress: String?) case success(macAddress: String?)
case failure(String) case failure(String)
case failureWithCode(ProvisioningError, detail: String? = nil)
} }
/// Configuration to write to a beacon /// Configuration to write to a beacon
@ -64,8 +96,8 @@ class BeaconProvisioner: NSObject, ObservableObject {
// DX-Smart packet header // DX-Smart packet header
private static let DXSMART_HEADER: [UInt8] = [0x4E, 0x4F] private static let DXSMART_HEADER: [UInt8] = [0x4E, 0x4F]
// DX-Smart connection password // DX-Smart connection passwords (tried in order until one works)
private static let DXSMART_PASSWORD = "dx1234" private static let DXSMART_PASSWORDS = ["555555", "dx1234", "000000"]
// DX-Smart command codes // DX-Smart command codes
private enum DXCmd: UInt8 { private enum DXCmd: UInt8 {
@ -163,7 +195,7 @@ class BeaconProvisioner: NSObject, ObservableObject {
/// Provision a beacon with the given configuration /// Provision a beacon with the given configuration
func provision(beacon: DiscoveredBeacon, config: BeaconConfig, completion: @escaping (ProvisioningResult) -> Void) { func provision(beacon: DiscoveredBeacon, config: BeaconConfig, completion: @escaping (ProvisioningResult) -> Void) {
guard centralManager.state == .poweredOn else { guard centralManager.state == .poweredOn else {
completion(.failure("Bluetooth not available")) completion(.failureWithCode(.bluetoothUnavailable))
return return
} }
@ -193,7 +225,7 @@ class BeaconProvisioner: NSObject, ObservableObject {
// Timeout after 30 seconds // Timeout after 30 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 30) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + 30) { [weak self] in
if self?.state != .success && self?.state != .idle { 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 = "" progress = ""
} }
private func fail(_ message: String) { private func fail(_ message: String, code: ProvisioningError? = nil) {
DebugLog.shared.log("BLE: Failed - \(message)") DebugLog.shared.log("BLE: Failed [\(code?.rawValue ?? "UNTYPED")] - \(message)")
state = .failed(message) state = .failed(message)
if let peripheral = peripheral { if let peripheral = peripheral {
centralManager.cancelPeripheralConnection(peripheral) centralManager.cancelPeripheralConnection(peripheral)
} }
completion?(.failure(message)) if let code = code {
completion?(.failureWithCode(code, detail: message))
} else {
completion?(.failure(message))
}
cleanup() cleanup()
} }
@ -293,7 +329,7 @@ class BeaconProvisioner: NSObject, ObservableObject {
private func provisionDXSmart() { private func provisionDXSmart() {
guard let service = configService else { guard let service = configService else {
fail("DX-Smart config service not found") fail("DX-Smart config service not found", code: .serviceNotFound)
return 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() { private func dxSmartAuthenticate() {
guard let passwordChar = characteristics[BeaconProvisioner.DXSMART_PASSWORD_CHAR] else { 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 return
} }
state = .authenticating 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) let passwordData = Data(currentPassword.utf8)
DebugLog.shared.log("BLE: Writing password to FFE3 (\(passwordData.count) bytes)") DebugLog.shared.log("BLE: Writing password \(passwordIndex + 1)/\(BeaconProvisioner.DXSMART_PASSWORDS.count) to FFE3 (\(passwordData.count) bytes)")
peripheral?.writeValue(passwordData, for: passwordChar, type: .withResponse) 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 /// Read device info (MAC address) before writing config
private func dxSmartReadDeviceInfoBeforeWrite() { private func dxSmartReadDeviceInfoBeforeWrite() {
guard let commandChar = characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] else { guard let commandChar = characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] else {
@ -381,7 +436,7 @@ class BeaconProvisioner: NSObject, ObservableObject {
/// 24. SaveConfig 0x60 persist to flash /// 24. SaveConfig 0x60 persist to flash
private func dxSmartWriteConfig() { private func dxSmartWriteConfig() {
guard let config = config else { guard let config = config else {
fail("No config provided") fail("No config provided", code: .noConfig)
return return
} }
@ -479,7 +534,7 @@ class BeaconProvisioner: NSObject, ObservableObject {
progress = "Writing config (\(current)/\(total))..." progress = "Writing config (\(current)/\(total))..."
guard let commandChar = characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] else { 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 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() { private func dxSmartReadAuth() {
guard let passwordChar = characteristics[BeaconProvisioner.DXSMART_PASSWORD_CHAR] else { guard let passwordChar = characteristics[BeaconProvisioner.DXSMART_PASSWORD_CHAR] else {
DebugLog.shared.log("BLE: No FFE3 for auth, finishing") DebugLog.shared.log("BLE: No FFE3 for auth, finishing")
@ -576,11 +631,18 @@ class BeaconProvisioner: NSObject, ObservableObject {
return return
} }
state = .authenticating guard passwordIndex < BeaconProvisioner.DXSMART_PASSWORDS.count else {
progress = "Authenticating..." DebugLog.shared.log("BLE: All passwords exhausted in read mode")
finishRead()
return
}
let passwordData = Data(BeaconProvisioner.DXSMART_PASSWORD.utf8) state = .authenticating
DebugLog.shared.log("BLE: Read mode — writing password to FFE3") 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) peripheral?.writeValue(passwordData, for: passwordChar, type: .withResponse)
} }
@ -897,7 +959,7 @@ extension BeaconProvisioner: CBCentralManagerDelegate {
if operationMode == .readingConfig { if operationMode == .readingConfig {
readFail(msg) readFail(msg)
} else { } else {
fail(msg) fail(msg, code: .connectionFailed)
} }
} }
} }
@ -912,7 +974,7 @@ extension BeaconProvisioner: CBCentralManagerDelegate {
if case .failed = state { if case .failed = state {
// Already failed // Already failed
} else { } else {
fail("Unexpected disconnect") fail("Unexpected disconnect", code: .disconnected)
} }
} }
} }
@ -927,7 +989,7 @@ extension BeaconProvisioner: CBPeripheralDelegate {
if operationMode == .readingConfig { if operationMode == .readingConfig {
readFail("Service discovery failed: \(error.localizedDescription)") readFail("Service discovery failed: \(error.localizedDescription)")
} else { } else {
fail("Service discovery failed: \(error.localizedDescription)") fail("Service discovery failed: \(error.localizedDescription)", code: .serviceNotFound)
} }
return return
} }
@ -936,7 +998,7 @@ extension BeaconProvisioner: CBPeripheralDelegate {
if operationMode == .readingConfig { if operationMode == .readingConfig {
readFail("No services found") readFail("No services found")
} else { } else {
fail("No services found") fail("No services found", code: .serviceNotFound)
} }
return return
} }
@ -959,7 +1021,7 @@ extension BeaconProvisioner: CBPeripheralDelegate {
return 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?) { 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)") DebugLog.shared.log("BLE: Char discovery failed for \(service.uuid): \(error.localizedDescription)")
exploreNextService() exploreNextService()
} else { } else {
fail("Characteristic discovery failed: \(error.localizedDescription)") fail("Characteristic discovery failed: \(error.localizedDescription)", code: .serviceNotFound)
} }
return return
} }
@ -978,7 +1040,7 @@ extension BeaconProvisioner: CBPeripheralDelegate {
if operationMode == .readingConfig { if operationMode == .readingConfig {
exploreNextService() exploreNextService()
} else { } else {
fail("No characteristics found") fail("No characteristics found", code: .serviceNotFound)
} }
return return
} }
@ -1015,10 +1077,14 @@ extension BeaconProvisioner: CBPeripheralDelegate {
DebugLog.shared.log("BLE: Write failed for \(characteristic.uuid): \(error.localizedDescription)") DebugLog.shared.log("BLE: Write failed for \(characteristic.uuid): \(error.localizedDescription)")
if characteristic.uuid == BeaconProvisioner.DXSMART_PASSWORD_CHAR { if characteristic.uuid == BeaconProvisioner.DXSMART_PASSWORD_CHAR {
if operationMode == .readingConfig { // Password rejected try next password in the list
readFail("Authentication failed: \(error.localizedDescription)") 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 { } else {
fail("Authentication failed: \(error.localizedDescription)") fail("Authentication failed - all \(BeaconProvisioner.DXSMART_PASSWORDS.count) passwords rejected", code: .authFailed)
} }
return return
} }
@ -1041,7 +1107,7 @@ extension BeaconProvisioner: CBPeripheralDelegate {
self?.dxSmartSendNextCommand() self?.dxSmartSendNextCommand()
} }
} else { } else {
fail("Command write failed at step \(dxSmartWriteIndex + 1): \(error.localizedDescription)") fail("Command write failed at step \(dxSmartWriteIndex + 1): \(error.localizedDescription)", code: .writeFailed)
} }
} }
return return
@ -1051,7 +1117,7 @@ extension BeaconProvisioner: CBPeripheralDelegate {
DebugLog.shared.log("BLE: Write failed in read mode, ignoring: \(error.localizedDescription)") DebugLog.shared.log("BLE: Write failed in read mode, ignoring: \(error.localizedDescription)")
return return
} }
fail("Write failed: \(error.localizedDescription)") fail("Write failed: \(error.localizedDescription)", code: .writeFailed)
return return
} }

View file

@ -913,6 +913,10 @@ struct ScanView: View {
} }
case .failure(let error): case .failure(let error):
failProvisioning(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): case .failure(let error):
failProvisioning(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)
} }
} }
} }