payfrit-beacon-ios/PayfritBeacon/Services/APIClient.swift
Schwifty 38b4c987c9 fix: address all issues from koda's code review
🔴 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) <noreply@anthropic.com>
2026-03-22 17:25:55 +00:00

392 lines
15 KiB
Swift

import Foundation
/// Errors from API calls
enum APIError: LocalizedError {
case network(Error)
case httpError(Int)
case decodingError(Error)
case serverError(String)
case unauthorized
case noData
var errorDescription: String? {
switch self {
case .network(let e): return "Network error: \(e.localizedDescription)"
case .httpError(let code): return "HTTP \(code)"
case .decodingError(let e): return "Decode error: \(e.localizedDescription)"
case .serverError(let msg): return msg
case .unauthorized: return "Session expired. Please log in again."
case .noData: return "No data received"
}
}
}
/// Lightweight API client all Payfrit backend calls
actor APIClient {
static let shared = APIClient()
private let session: URLSession
private init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForResource = APIConfig.readTimeout
config.timeoutIntervalForRequest = APIConfig.connectTimeout
self.session = URLSession(configuration: config)
}
// MARK: - Auth
struct OTPResponse: Codable {
let uuid: String?
let UUID: String?
var otpUUID: String { uuid ?? UUID ?? "" }
}
func sendOTP(phone: String) async throws -> String {
let body: [String: Any] = ["ContactNumber": phone]
let data = try await post(path: "/auth/loginOTP.php", body: body)
let resp = try JSONDecoder().decode(APIResponse<OTPResponse>.self, from: data)
guard resp.success, let payload = resp.data else {
throw APIError.serverError(resp.message ?? "Failed to send OTP")
}
return payload.otpUUID
}
struct LoginResponse: Codable {
let token: String?
let Token: String?
let userId: String?
let UserID: String?
var authToken: String { token ?? Token ?? "" }
var authUserId: String { userId ?? UserID ?? "" }
}
func verifyOTP(uuid: String, code: String) async throws -> (token: String, userId: String) {
let body: [String: Any] = ["UUID": uuid, "Code": code]
let data = try await post(path: "/auth/verifyLoginOTP.php", body: body)
let resp = try JSONDecoder().decode(APIResponse<LoginResponse>.self, from: data)
guard resp.success, let payload = resp.data else {
throw APIError.serverError(resp.message ?? "Invalid OTP")
}
return (payload.authToken, payload.authUserId)
}
// MARK: - Businesses
func listBusinesses(token: String) async throws -> [Business] {
let data = try await post(path: "/businesses/list.php", body: [:], token: token)
let resp = try JSONDecoder().decode(APIResponse<[Business]>.self, from: data)
guard resp.success else {
throw APIError.serverError(resp.message ?? "Failed to load businesses")
}
return resp.data ?? []
}
// MARK: - Service Points
func listServicePoints(businessId: String, token: String) async throws -> [ServicePoint] {
let body: [String: Any] = ["BusinessID": businessId]
let data = try await post(path: "/servicepoints/list.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(APIResponse<[ServicePoint]>.self, from: data)
guard resp.success else {
throw APIError.serverError(resp.message ?? "Failed to load service points")
}
return resp.data ?? []
}
func createServicePoint(name: String, businessId: String, token: String) async throws -> ServicePoint {
let body: [String: Any] = ["Name": name, "BusinessID": businessId]
let data = try await post(path: "/servicepoints/save.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(APIResponse<ServicePoint>.self, from: data)
guard resp.success, let sp = resp.data else {
throw APIError.serverError(resp.message ?? "Failed to create service point")
}
return sp
}
// MARK: - Beacon Sharding
struct NamespaceResponse: Codable {
let uuid: String?
let UUID: String?
let major: Int?
let Major: Int?
var shardUUID: String { uuid ?? UUID ?? "" }
var shardMajor: Int { major ?? Major ?? 0 }
}
func allocateBusinessNamespace(businessId: String, token: String) async throws -> (uuid: String, major: Int) {
let body: [String: Any] = ["BusinessID": businessId]
let data = try await post(path: "/beacon-sharding/allocate_business_namespace.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(APIResponse<NamespaceResponse>.self, from: data)
guard resp.success, let ns = resp.data else {
throw APIError.serverError(resp.message ?? "Failed to allocate namespace")
}
return (ns.shardUUID, ns.shardMajor)
}
struct MinorResponse: Codable {
let minor: Int?
let Minor: Int?
var allocated: Int { minor ?? Minor ?? 0 }
}
func allocateMinor(businessId: String, servicePointId: String, token: String) async throws -> Int {
let body: [String: Any] = ["BusinessID": businessId, "ServicePointID": servicePointId]
let data = try await post(path: "/beacon-sharding/allocate_servicepoint_minor.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(APIResponse<MinorResponse>.self, from: data)
guard resp.success, let m = resp.data else {
throw APIError.serverError(resp.message ?? "Failed to allocate minor")
}
return m.allocated
}
func registerBeaconHardware(
businessId: String,
servicePointId: String,
uuid: String,
major: Int,
minor: Int,
macAddress: String?,
beaconType: String,
token: String
) async throws {
var body: [String: Any] = [
"BusinessID": businessId,
"ServicePointID": servicePointId,
"UUID": uuid,
"Major": major,
"Minor": minor,
"BeaconType": beaconType
]
if let mac = macAddress { body["MacAddress"] = mac }
let data = try await post(path: "/beacon-sharding/register_beacon_hardware.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(APIResponse<EmptyData>.self, from: data)
guard resp.success else {
throw APIError.serverError(resp.message ?? "Failed to register beacon")
}
}
func verifyBeaconBroadcast(
uuid: String,
major: Int,
minor: Int,
token: String
) async throws {
let body: [String: Any] = ["UUID": uuid, "Major": major, "Minor": minor]
let data = try await post(path: "/beacon-sharding/verify_beacon_broadcast.php", body: body, token: token)
let resp = try JSONDecoder().decode(APIResponse<EmptyData>.self, from: data)
guard resp.success else {
throw APIError.serverError(resp.message ?? "Failed to verify broadcast")
}
}
struct ResolveResponse: Codable {
let businessName: String?
let BusinessName: String?
var name: String { businessName ?? BusinessName ?? "Unknown" }
}
func resolveBusiness(uuid: String, major: Int, token: String) async throws -> String {
let body: [String: Any] = ["UUID": uuid, "Major": major]
let data = try await post(path: "/beacon-sharding/resolve_business.php", body: body, token: token)
let resp = try JSONDecoder().decode(APIResponse<ResolveResponse>.self, from: data)
return resp.data?.name ?? "Unknown"
}
// MARK: - Service Point Management
func deleteServicePoint(servicePointId: String, businessId: String, token: String) async throws {
let body: [String: Any] = ["ID": servicePointId, "BusinessID": businessId]
let data = try await post(path: "/servicepoints/delete.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(APIResponse<EmptyData>.self, from: data)
guard resp.success else {
throw APIError.serverError(resp.message ?? "Failed to delete service point")
}
}
func updateServicePoint(servicePointId: String, name: String, businessId: String, token: String) async throws {
let body: [String: Any] = ["ID": servicePointId, "Name": name, "BusinessID": businessId]
let data = try await post(path: "/servicepoints/save.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(APIResponse<EmptyData>.self, from: data)
guard resp.success else {
throw APIError.serverError(resp.message ?? "Failed to update service point")
}
}
// MARK: - Beacon Management
struct BeaconListResponse: Codable {
let id: String?
let ID: String?
let uuid: String?
let UUID: String?
let major: Int?
let Major: Int?
let minor: Int?
let Minor: Int?
let macAddress: String?
let MacAddress: String?
let beaconType: String?
let BeaconType: String?
let servicePointId: String?
let ServicePointID: String?
let isVerified: Bool?
let IsVerified: Bool?
}
func listBeacons(businessId: String, token: String) async throws -> [BeaconListResponse] {
let body: [String: Any] = ["BusinessID": businessId]
let data = try await post(path: "/beacon-sharding/list_beacons.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(APIResponse<[BeaconListResponse]>.self, from: data)
guard resp.success else {
throw APIError.serverError(resp.message ?? "Failed to list beacons")
}
return resp.data ?? []
}
func decommissionBeacon(beaconId: String, businessId: String, token: String) async throws {
let body: [String: Any] = ["ID": beaconId, "BusinessID": businessId]
let data = try await post(path: "/beacon-sharding/decommission_beacon.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(APIResponse<EmptyData>.self, from: data)
guard resp.success else {
throw APIError.serverError(resp.message ?? "Failed to decommission beacon")
}
}
func lookupByMac(macAddress: String, token: String) async throws -> BeaconListResponse? {
let body: [String: Any] = ["MacAddress": macAddress]
let data = try await post(path: "/beacon-sharding/lookup_by_mac.php", body: body, token: token)
let resp = try JSONDecoder().decode(APIResponse<BeaconListResponse>.self, from: data)
return resp.data
}
func getBeaconStatus(uuid: String, major: Int, minor: Int, token: String) async throws -> BeaconListResponse? {
let body: [String: Any] = ["UUID": uuid, "Major": major, "Minor": minor]
let data = try await post(path: "/beacon-sharding/beacon_status.php", body: body, token: token)
let resp = try JSONDecoder().decode(APIResponse<BeaconListResponse>.self, from: data)
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<BeaconConfigResponse>.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 {
let id: String?
let ID: String?
let firstName: String?
let FirstName: String?
let lastName: String?
let LastName: String?
let contactNumber: String?
let ContactNumber: String?
}
func getProfile(token: String) async throws -> UserProfile {
let data = try await post(path: "/users/profile.php", body: [:], token: token)
let resp = try JSONDecoder().decode(APIResponse<UserProfile>.self, from: data)
guard resp.success, let profile = resp.data else {
throw APIError.serverError(resp.message ?? "Failed to load profile")
}
return profile
}
// MARK: - Internal
private struct EmptyData: Codable {}
private struct APIResponse<T: Codable>: Codable {
let success: Bool
let message: String?
let data: T?
enum CodingKeys: String, CodingKey {
case success = "Success"
case message = "Message"
case data = "Data"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// Handle both bool and int/string for Success
if let b = try? container.decode(Bool.self, forKey: .success) {
success = b
} else if let i = try? container.decode(Int.self, forKey: .success) {
success = i != 0
} else {
success = false
}
message = try? container.decode(String.self, forKey: .message)
data = try? container.decode(T.self, forKey: .data)
}
}
private func post(
path: String,
body: [String: Any],
token: String? = nil,
businessId: String? = nil
) async throws -> Data {
guard let url = URL(string: APIConfig.baseURL + path) else {
throw APIError.serverError("Invalid URL: \(path)")
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let token = token {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue(token, forHTTPHeaderField: "X-User-Token")
}
if let bizId = businessId {
request.setValue(bizId, forHTTPHeaderField: "X-Business-Id")
}
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: request)
if let http = response as? HTTPURLResponse {
if http.statusCode == 401 { throw APIError.unauthorized }
guard (200...299).contains(http.statusCode) else {
throw APIError.httpError(http.statusCode)
}
}
return data
}
}