From cfa78679be4de9e676c8e109a3f61e04d317615d Mon Sep 17 00:00:00 2001 From: Schwifty Date: Sun, 22 Mar 2026 17:13:36 +0000 Subject: [PATCH 1/3] feat: complete rebuild of PayfritBeacon iOS from scratch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 100% fresh codebase — no legacy code carried over. Built against the Android beacon app as the behavioral spec. Architecture: - App: SwiftUI @main, AppState-driven navigation, Keychain storage - Views: LoginView (OTP + biometric), BusinessListView, ScanView (provisioning hub) - Models: Business, ServicePoint, BeaconConfig, BeaconType, DiscoveredBeacon - Services: APIClient (actor, async/await), BLEManager (CoreBluetooth scanner) - Provisioners: KBeacon, DXSmart (2-step auth + flashing), BlueCharm - Utils: UUIDFormatting, BeaconBanList, BeaconShardPool (64 shards) Matches Android feature parity: - 4-screen flow: Login → Business Select → Scan/Provision - 3 beacon types with correct GATT protocols and timeouts - Namespace allocation via beacon-sharding API - Smart service point naming (Table N auto-increment) - DXSmart special flow (connect → flash → user confirms → write) - Biometric auth, dev/prod build configs, DEV banner overlay Co-Authored-By: Claude Opus 4.6 (1M context) --- PayfritBeacon/Api.swift | 702 -------- PayfritBeacon/App/AppPrefs.swift | 11 + PayfritBeacon/App/AppState.swift | 48 + .../App}/PayfritBeaconApp.swift | 4 - .../AccentColor.colorset/Contents.json | 20 - .../AppIcon.appiconset/Contents.json | 20 - .../AppIcon.appiconset/appicon.png | Bin 53199 -> 0 bytes PayfritBeacon/Assets.xcassets/Contents.json | 6 - PayfritBeacon/BLEBeaconScanner.swift | 149 -- PayfritBeacon/BeaconBanList.swift | 72 - PayfritBeacon/BeaconProvisioner.swift | 1441 ----------------- PayfritBeacon/BeaconScanner.swift | 153 -- PayfritBeacon/BeaconShardPool.swift | 107 -- PayfritBeacon/BusinessListView.swift | 144 -- PayfritBeacon/DebugLog.swift | 33 - PayfritBeacon/DevBanner.swift | 29 - PayfritBeacon/Info.plist | 51 - PayfritBeacon/LoginView.swift | 236 --- PayfritBeacon/Models/BeaconConfig.swift | 34 + PayfritBeacon/Models/BeaconType.swift | 33 + PayfritBeacon/Models/Business.swift | 18 + PayfritBeacon/Models/ServicePoint.swift | 13 + PayfritBeacon/PayfritBeaconApp.swift | 25 - .../Provisioners/BlueCharmProvisioner.swift | 247 +++ .../Provisioners/DXSmartProvisioner.swift | 253 +++ .../Provisioners/KBeaconProvisioner.swift | 262 +++ .../Provisioners/ProvisionError.swift | 30 + .../Provisioners/ProvisionerProtocol.swift | 40 + PayfritBeacon/RootView.swift | 58 - PayfritBeacon/ScanView.swift | 1051 ------------ PayfritBeacon/ServicePointListView.swift | 338 ---- PayfritBeacon/Services/APIClient.swift | 262 +++ PayfritBeacon/Services/APIConfig.swift | 17 + PayfritBeacon/Services/BLEManager.swift | 199 +++ PayfritBeacon/Services/SecureStorage.swift | 68 + PayfritBeacon/UUIDFormatting.swift | 27 - PayfritBeacon/Utils/BeaconBanList.swift | 24 + PayfritBeacon/Utils/BeaconShardPool.swift | 73 + PayfritBeacon/Utils/UUIDFormatting.swift | 32 + PayfritBeacon/Views/BusinessListView.swift | 120 ++ PayfritBeacon/Views/DevBanner.swift | 14 + PayfritBeacon/Views/LoginView.swift | 141 ++ PayfritBeacon/Views/RootView.swift | 39 + PayfritBeacon/Views/ScanView.swift | 709 ++++++++ PayfritBeacon/en.lproj/InfoPlist.strings | 2 - .../payfrit-favicon-light-outlines.svg | 28 - Podfile | 15 - Podfile.lock | 26 - QA_WALKTHROUGH.md | 329 ---- _backup/Models/Beacon.swift | 68 - _backup/Models/Employment.swift | 32 - _backup/Models/ServicePoint.swift | 47 - _backup/Services/APIService.swift | 416 ----- _backup/Services/AuthStorage.swift | 95 -- _backup/Services/BeaconScanner.swift | 262 --- _backup/ViewModels/AppState.swift | 49 - _backup/Views/BeaconDashboard.swift | 39 - _backup/Views/BeaconDetailScreen.swift | 116 -- _backup/Views/BeaconEditSheet.swift | 83 - _backup/Views/BeaconListScreen.swift | 154 -- _backup/Views/BusinessSelectionScreen.swift | 196 --- _backup/Views/LoginScreen.swift | 136 -- _backup/Views/RootView.swift | 88 - _backup/Views/ScannerScreen.swift | 225 --- _backup/Views/ServicePointListScreen.swift | 232 --- 65 files changed, 2687 insertions(+), 7304 deletions(-) delete mode 100644 PayfritBeacon/Api.swift create mode 100644 PayfritBeacon/App/AppPrefs.swift create mode 100644 PayfritBeacon/App/AppState.swift rename {_backup => PayfritBeacon/App}/PayfritBeaconApp.swift (71%) delete mode 100644 PayfritBeacon/Assets.xcassets/AccentColor.colorset/Contents.json delete mode 100644 PayfritBeacon/Assets.xcassets/AppIcon.appiconset/Contents.json delete mode 100644 PayfritBeacon/Assets.xcassets/AppIcon.appiconset/appicon.png delete mode 100644 PayfritBeacon/Assets.xcassets/Contents.json delete mode 100644 PayfritBeacon/BLEBeaconScanner.swift delete mode 100644 PayfritBeacon/BeaconBanList.swift delete mode 100644 PayfritBeacon/BeaconProvisioner.swift delete mode 100644 PayfritBeacon/BeaconScanner.swift delete mode 100644 PayfritBeacon/BeaconShardPool.swift delete mode 100644 PayfritBeacon/BusinessListView.swift delete mode 100644 PayfritBeacon/DebugLog.swift delete mode 100644 PayfritBeacon/DevBanner.swift delete mode 100644 PayfritBeacon/Info.plist delete mode 100644 PayfritBeacon/LoginView.swift create mode 100644 PayfritBeacon/Models/BeaconConfig.swift create mode 100644 PayfritBeacon/Models/BeaconType.swift create mode 100644 PayfritBeacon/Models/Business.swift create mode 100644 PayfritBeacon/Models/ServicePoint.swift delete mode 100644 PayfritBeacon/PayfritBeaconApp.swift create mode 100644 PayfritBeacon/Provisioners/BlueCharmProvisioner.swift create mode 100644 PayfritBeacon/Provisioners/DXSmartProvisioner.swift create mode 100644 PayfritBeacon/Provisioners/KBeaconProvisioner.swift create mode 100644 PayfritBeacon/Provisioners/ProvisionError.swift create mode 100644 PayfritBeacon/Provisioners/ProvisionerProtocol.swift delete mode 100644 PayfritBeacon/RootView.swift delete mode 100644 PayfritBeacon/ScanView.swift delete mode 100644 PayfritBeacon/ServicePointListView.swift create mode 100644 PayfritBeacon/Services/APIClient.swift create mode 100644 PayfritBeacon/Services/APIConfig.swift create mode 100644 PayfritBeacon/Services/BLEManager.swift create mode 100644 PayfritBeacon/Services/SecureStorage.swift delete mode 100644 PayfritBeacon/UUIDFormatting.swift create mode 100644 PayfritBeacon/Utils/BeaconBanList.swift create mode 100644 PayfritBeacon/Utils/BeaconShardPool.swift create mode 100644 PayfritBeacon/Utils/UUIDFormatting.swift create mode 100644 PayfritBeacon/Views/BusinessListView.swift create mode 100644 PayfritBeacon/Views/DevBanner.swift create mode 100644 PayfritBeacon/Views/LoginView.swift create mode 100644 PayfritBeacon/Views/RootView.swift create mode 100644 PayfritBeacon/Views/ScanView.swift delete mode 100644 PayfritBeacon/en.lproj/InfoPlist.strings delete mode 100644 PayfritBeacon/payfrit-favicon-light-outlines.svg delete mode 100644 Podfile delete mode 100644 Podfile.lock delete mode 100644 QA_WALKTHROUGH.md delete mode 100644 _backup/Models/Beacon.swift delete mode 100644 _backup/Models/Employment.swift delete mode 100644 _backup/Models/ServicePoint.swift delete mode 100644 _backup/Services/APIService.swift delete mode 100644 _backup/Services/AuthStorage.swift delete mode 100644 _backup/Services/BeaconScanner.swift delete mode 100644 _backup/ViewModels/AppState.swift delete mode 100644 _backup/Views/BeaconDashboard.swift delete mode 100644 _backup/Views/BeaconDetailScreen.swift delete mode 100644 _backup/Views/BeaconEditSheet.swift delete mode 100644 _backup/Views/BeaconListScreen.swift delete mode 100644 _backup/Views/BusinessSelectionScreen.swift delete mode 100644 _backup/Views/LoginScreen.swift delete mode 100644 _backup/Views/RootView.swift delete mode 100644 _backup/Views/ScannerScreen.swift delete mode 100644 _backup/Views/ServicePointListScreen.swift diff --git a/PayfritBeacon/Api.swift b/PayfritBeacon/Api.swift deleted file mode 100644 index a229556..0000000 --- a/PayfritBeacon/Api.swift +++ /dev/null @@ -1,702 +0,0 @@ -import Foundation - -class Api { - static let shared = Api() - - // ── DEV toggle: driven by DEV compiler flag (set in build configuration) ── - #if DEV - static let IS_DEV = true - #else - static let IS_DEV = false - #endif - - private static var BASE_URL: String { - IS_DEV ? "https://dev.payfrit.com/api" : "https://biz.payfrit.com/api" - } - - private let session: URLSession - private var authToken: String? - - private init() { - let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = 30 - config.timeoutIntervalForResource = 30 - session = URLSession(configuration: config) - } - - func setAuthToken(_ token: String?) { - authToken = token - } - - func getAuthToken() -> String? { - return authToken - } - - private func buildRequest(endpoint: String) -> URLRequest { - let url = URL(string: "\(Api.BASE_URL)\(endpoint)")! - var request = URLRequest(url: url) - if let token = authToken { - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - request.setValue(token, forHTTPHeaderField: "X-User-Token") - } - return request - } - - private func postRequest(endpoint: String, body: [String: Any], extraHeaders: [String: String] = [:]) async throws -> [String: Any] { - var request = buildRequest(endpoint: endpoint) - request.httpMethod = "POST" - request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONSerialization.data(withJSONObject: body) - for (key, value) in extraHeaders { - request.setValue(value, forHTTPHeaderField: key) - } - - let (data, response) = try await session.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw ApiException("Invalid response") - } - guard httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else { - throw ApiException("Request failed: \(httpResponse.statusCode)") - } - guard !data.isEmpty else { - throw ApiException("Empty response") - } - guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { - throw ApiException("Invalid JSON response") - } - return json - } - - private func getRequest(endpoint: String) async throws -> [String: Any] { - var request = buildRequest(endpoint: endpoint) - request.httpMethod = "GET" - - let (data, response) = try await session.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw ApiException("Invalid response") - } - guard httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else { - throw ApiException("Request failed: \(httpResponse.statusCode)") - } - guard !data.isEmpty else { - throw ApiException("Empty response") - } - guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { - throw ApiException("Invalid JSON response") - } - return json - } - - // ========================================================================= - // AUTH - // ========================================================================= - - func sendLoginOtp(phone: String) async throws -> OtpResponse { - let json = try await postRequest(endpoint: "/auth/loginOTP.php", body: ["Phone": phone]) - - guard let uuid = (json["UUID"] as? String) ?? (json["uuid"] as? String), !uuid.isEmpty else { - throw ApiException("Server error - please try again") - } - - return OtpResponse(uuid: uuid) - } - - func verifyLoginOtp(uuid: String, otp: String) async throws -> LoginResponse { - let json = try await postRequest(endpoint: "/auth/verifyLoginOTP.php", body: [ - "UUID": uuid, - "OTP": otp - ]) - - let ok = parseBool(json["OK"] ?? json["ok"]) - if !ok { - let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Invalid code" - throw ApiException(error) - } - - return LoginResponse( - userId: parseIntValue(json["UserID"] ?? json["USERID"]) ?? 0, - token: ((json["Token"] ?? json["TOKEN"] ?? json["token"]) as? String) ?? "", - userFirstName: ((json["UserFirstName"] ?? json["USERFIRSTNAME"]) as? String) ?? "" - ) - } - - // ========================================================================= - // BUSINESSES - // ========================================================================= - - func listBusinesses() async throws -> [Business] { - let json = try await postRequest(endpoint: "/businesses/list.php", body: [:]) - - guard let businesses = (json["BUSINESSES"] ?? json["businesses"] ?? json["Items"] ?? json["ITEMS"]) as? [[String: Any]] else { - return [] - } - - return businesses.compactMap { b in - guard let businessId = 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: businessId, name: name, headerImageExtension: headerImageExtension) - } - } - - /// 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 - // ========================================================================= - - func listAllBeacons() async throws -> [String: Int] { - let json = try await getRequest(endpoint: "/beacons/list_all.php") - - guard let items = (json["ITEMS"] ?? json["items"]) as? [[String: Any]] else { - return [:] - } - - var result: [String: Int] = [:] - for item in items { - guard let uuid = ((item["UUID"] ?? item["uuid"] ?? item["BeaconUUID"] ?? item["BEACONUUID"]) as? String)? - .normalizedUUID, - let beaconId = parseIntValue(item["BeaconID"] ?? item["BEACONID"]) else { - continue - } - result[uuid] = beaconId - } - return result - } - - func lookupBeacons(uuids: [String]) async throws -> [BeaconLookupResult] { - if uuids.isEmpty { return [] } - - let json = try await postRequest(endpoint: "/beacons/lookup.php", body: ["UUIDs": uuids]) - - guard let beacons = (json["BEACONS"] ?? json["beacons"]) as? [[String: Any]] else { - return [] - } - - return beacons.compactMap { b in - guard let uuid = ((b["UUID"] ?? b["uuid"]) as? String)? - .normalizedUUID else { - return nil - } - return BeaconLookupResult( - uuid: uuid, - beaconId: parseIntValue(b["BeaconID"] ?? b["BEACONID"]) ?? 0, - businessId: parseIntValue(b["BusinessID"] ?? b["BUSINESSID"]) ?? 0, - businessName: ((b["BusinessName"] ?? b["BUSINESSNAME"]) as? String) ?? "", - servicePointName: ((b["ServicePointName"] ?? b["SERVICEPOINTNAME"]) as? String) ?? "", - beaconName: ((b["BeaconName"] ?? b["BEACONNAME"] ?? b["Name"] ?? b["NAME"]) as? String) ?? "" - ) - } - } - - func listBeacons(businessId: Int) async throws -> [BeaconInfo] { - let json = try await postRequest( - endpoint: "/beacons/list.php", - body: ["BusinessID": businessId], - extraHeaders: ["X-Business-Id": String(businessId)] - ) - - guard let beacons = (json["BEACONS"] ?? json["beacons"]) as? [[String: Any]] else { - return [] - } - - return beacons.compactMap { b in - let beaconId = parseIntValue(b["BeaconID"] ?? b["BEACONID"] ?? b["ID"]) ?? 0 - let name = ((b["Name"] ?? b["NAME"] ?? b["BeaconName"] ?? b["BEACONNAME"]) as? String) ?? "" - let uuid = ((b["UUID"] ?? b["uuid"]) as? String)? - .normalizedUUID ?? "" - let isActive = parseBool(b["IsActive"] ?? b["ISACTIVE"] ?? true) - return BeaconInfo(beaconId: beaconId, name: name, uuid: uuid, isActive: isActive) - } - } - - func saveBeacon(businessId: Int, name: String, uuid: String, macAddress: String? = nil) async throws -> SavedBeacon { - var params: [String: Any] = [ - "BusinessID": businessId, - "Name": name, - "UUID": uuid - ] - if let mac = macAddress, !mac.isEmpty { - params["MACAddress"] = mac - } - - let json = try await postRequest( - endpoint: "/beacons/save.php", - body: params, - extraHeaders: ["X-Business-Id": String(businessId)] - ) - - if !parseBool(json["OK"] ?? json["ok"]) { - throw ApiException(((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to save beacon") - } - - let beacon = (json["BEACON"] ?? json["beacon"]) as? [String: Any] - - return SavedBeacon( - beaconId: parseIntValue(beacon?["BeaconID"] ?? beacon?["BEACONID"] ?? beacon?["ID"]) ?? 0, - name: (beacon?["Name"] ?? beacon?["NAME"]) as? String ?? name, - uuid: uuid, - macAddress: (beacon?["MACAddress"] ?? beacon?["MACADDRESS"]) as? String - ) - } - - func lookupByMac(macAddress: String) async throws -> MacLookupResult? { - let json = try await postRequest(endpoint: "/beacons/lookupByMac.php", body: ["MACAddress": macAddress]) - - if !parseBool(json["OK"] ?? json["ok"]) { - return nil - } - - guard let beacon = (json["BEACON"] ?? json["beacon"]) as? [String: Any] else { - return nil - } - - return MacLookupResult( - beaconId: parseIntValue(beacon["BeaconID"] ?? beacon["BEACONID"]) ?? 0, - businessId: parseIntValue(beacon["BusinessID"] ?? beacon["BUSINESSID"]) ?? 0, - businessName: ((beacon["BusinessName"] ?? beacon["BUSINESSNAME"]) as? String) ?? "", - beaconName: ((beacon["BeaconName"] ?? beacon["BEACONNAME"]) as? String) ?? "", - uuid: ((beacon["UUID"] ?? beacon["uuid"]) as? String) ?? "", - macAddress: ((beacon["MACAddress"] ?? beacon["MACADDRESS"]) as? String) ?? "", - servicePointName: ((beacon["ServicePointName"] ?? beacon["SERVICEPOINTNAME"]) as? String) ?? "" - ) - } - - func wipeBeacon(businessId: Int, beaconId: Int) async throws -> Bool { - let json = try await postRequest( - endpoint: "/beacons/wipe.php", - body: ["BusinessID": businessId, "BeaconID": beaconId], - extraHeaders: ["X-Business-Id": String(businessId)] - ) - - if !parseBool(json["OK"] ?? json["ok"]) { - let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to wipe beacon" - let message = (json["MESSAGE"] ?? json["message"]) as? String - throw ApiException(message ?? error) - } - - return true - } - - // ========================================================================= - // BEACON SHARDING / PROVISIONING - // ========================================================================= - - /// Get beacon config for a service point (UUID, Major, Minor to write to beacon) - func getBeaconConfig(businessId: Int, servicePointId: Int) async throws -> BeaconConfigResponse { - DebugLog.shared.log("[API] getBeaconConfig businessId=\(businessId) servicePointId=\(servicePointId)") - - let json = try await postRequest( - endpoint: "/beacon-sharding/get_beacon_config.php", - body: ["BusinessID": businessId, "ServicePointID": servicePointId], - extraHeaders: ["X-Business-Id": String(businessId)] - ) - - DebugLog.shared.log("[API] getBeaconConfig response keys: \(json.keys.sorted())") - DebugLog.shared.log("[API] getBeaconConfig full response: \(json)") - - if !parseBool(json["OK"] ?? json["ok"] ?? json["Ok"]) { - let error = ((json["ERROR"] ?? json["error"] ?? json["Error"]) as? String) ?? "Failed to get beacon config" - throw ApiException(error) - } - - guard let uuid = (json["UUID"] ?? json["uuid"] ?? json["Uuid"] ?? json["BeaconUUID"] ?? json["BEACONUUID"]) as? String else { - throw ApiException("Invalid beacon config response - no UUID. Keys: \(json.keys.sorted())") - } - - guard let major = parseIntValue(json["MAJOR"] ?? json["major"] ?? json["Major"] ?? json["BeaconMajor"] ?? json["BEACONMAJOR"]) else { - throw ApiException("Invalid beacon config response - no Major. Keys: \(json.keys.sorted())") - } - - guard let minor = parseIntValue(json["MINOR"] ?? json["minor"] ?? json["Minor"] ?? json["BeaconMinor"] ?? json["BEACONMINOR"]) else { - throw ApiException("Invalid beacon config response - no Minor. Keys: \(json.keys.sorted())") - } - - // Parse new fields from updated endpoint - let measuredPower = parseIntValue(json["MeasuredPower"] ?? json["MEASUREDPOWER"] ?? json["measuredPower"]) ?? -59 - let advInterval = parseIntValue(json["AdvInterval"] ?? json["ADVINTERVAL"] ?? json["advInterval"]) ?? 2 - let txPower = parseIntValue(json["TxPower"] ?? json["TXPOWER"] ?? json["txPower"]) ?? 1 - let servicePointName = (json["ServicePointName"] ?? json["SERVICEPOINTNAME"] ?? json["servicePointName"]) as? String ?? "" - let businessName = (json["BusinessName"] ?? json["BUSINESSNAME"] ?? json["businessName"]) as? String ?? "" - - DebugLog.shared.log("[API] getBeaconConfig parsed: uuid=\(uuid) major=\(major) minor=\(minor) measuredPower=\(measuredPower) advInterval=\(advInterval) txPower=\(txPower)") - - return BeaconConfigResponse( - uuid: uuid.normalizedUUID, - major: UInt16(major), - minor: UInt16(minor), - measuredPower: Int8(clamping: measuredPower), - advInterval: UInt8(clamping: advInterval), - txPower: UInt8(clamping: txPower), - servicePointName: servicePointName, - businessName: businessName - ) - } - - /// Register beacon hardware after provisioning - func registerBeaconHardware(businessId: Int, servicePointId: Int, uuid: String, major: UInt16, minor: UInt16, hardwareId: String, macAddress: String? = nil) async throws -> Bool { - var body: [String: Any] = [ - "BusinessID": businessId, - "ServicePointID": servicePointId, - "UUID": uuid, - "Major": major, - "Minor": minor, - "HardwareId": hardwareId - ] - if let mac = macAddress, !mac.isEmpty { - body["MACAddress"] = mac - } - - DebugLog.shared.log("[API] registerBeaconHardware body: \(body)") - - let json = try await postRequest( - endpoint: "/beacon-sharding/register_beacon_hardware.php", - body: body, - extraHeaders: ["X-Business-Id": String(businessId)] - ) - - DebugLog.shared.log("[API] registerBeaconHardware response: \(json)") - - if !parseBool(json["OK"] ?? json["ok"]) { - let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to register beacon" - throw ApiException(error) - } - - return true - } - - /// Resolve beacon ownership by UUID and Major - func resolveBusiness(uuid: String, major: UInt16) async throws -> ResolveBusinessResponse { - let json = try await postRequest( - endpoint: "/beacon-sharding/resolve_business.php", - body: ["UUID": uuid, "Major": Int(major)] - ) - - if !parseBool(json["OK"] ?? json["ok"]) { - let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to resolve business" - throw ApiException(error) - } - - guard let businessId = parseIntValue(json["BusinessID"] ?? json["BUSINESSID"] ?? json["businessId"]) else { - throw ApiException("Invalid resolve response - no BusinessID") - } - - let businessName = (json["BusinessName"] ?? json["BUSINESSNAME"] ?? json["businessName"]) as? String ?? "" - - return ResolveBusinessResponse(businessId: businessId, businessName: businessName) - } - - /// Verify beacon is broadcasting expected values - func verifyBeaconBroadcast(businessId: Int, uuid: String, major: UInt16, minor: UInt16) async throws -> Bool { - let json = try await postRequest( - endpoint: "/beacon-sharding/verify_beacon_broadcast.php", - body: [ - "BusinessID": businessId, - "UUID": uuid, - "Major": major, - "Minor": minor - ], - extraHeaders: ["X-Business-Id": String(businessId)] - ) - - return parseBool(json["OK"] ?? json["ok"]) - } - - /// Allocate beacon namespace for a business (shard + major) - func allocateBusinessNamespace(businessId: Int) async throws -> BusinessNamespace { - let json = try await postRequest( - endpoint: "/beacon-sharding/allocate_business_namespace.php", - body: ["BusinessID": businessId], - extraHeaders: ["X-Business-Id": String(businessId)] - ) - - // Debug log - 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" - throw ApiException(error) - } - - // API returns BeaconShardUUID and BeaconMajor - guard let uuid = (json["BeaconShardUUID"] ?? json["BEACONSHARDUUID"] ?? json["UUID"] ?? json["uuid"]) as? String else { - throw ApiException("Invalid namespace response - no UUID") - } - - guard let major = parseIntValue(json["BeaconMajor"] ?? json["BEACONMAJOR"] ?? json["MAJOR"] ?? json["Major"]) else { - throw ApiException("Invalid namespace response - no Major") - } - - return BusinessNamespace( - shardId: parseIntValue(json["ShardID"] ?? json["SHARDID"]) ?? 0, - uuid: uuid, - uuidClean: uuid.normalizedUUID, - major: UInt16(major), - alreadyAllocated: parseBool(json["AlreadyAllocated"] ?? json["ALREADYALLOCATED"]) - ) - } - - /// 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( - endpoint: "/servicepoints/list.php", - body: ["BusinessID": businessId], - extraHeaders: ["X-Business-Id": String(businessId)] - ) - - guard let items = (json["SERVICEPOINTS"] ?? json["servicepoints"] ?? json["ITEMS"] ?? json["items"]) as? [[String: Any]] else { - return [] - } - - return items.compactMap { sp in - guard let spId = parseIntValue(sp["ServicePointID"] ?? sp["SERVICEPOINTID"] ?? sp["ID"]) else { - return nil - } - let name = ((sp["Name"] ?? sp["NAME"] ?? sp["ServicePointName"] ?? sp["SERVICEPOINTNAME"]) as? String) ?? "Table \(spId)" - let hasBeacon = parseBool(sp["HasBeacon"] ?? sp["HASBEACON"]) - let beaconMinor = parseIntValue(sp["BeaconMinor"] ?? sp["BEACONMINOR"]) - return ServicePoint(servicePointId: spId, name: name, hasBeacon: hasBeacon, beaconMinor: beaconMinor != nil ? UInt16(beaconMinor!) : nil) - } - } - - /// Create/save a service point (auto-allocates minor) - func saveServicePoint(businessId: Int, name: String, servicePointId: Int? = nil) async throws -> ServicePoint { - var body: [String: Any] = ["BusinessID": businessId, "Name": name] - if let spId = servicePointId { - body["ServicePointID"] = spId - } - - DebugLog.shared.log("[API] saveServicePoint businessId=\(businessId) name=\(name) servicePointId=\(String(describing: servicePointId))") - - let json = try await postRequest( - endpoint: "/servicepoints/save.php", - body: body, - extraHeaders: ["X-Business-Id": String(businessId)] - ) - - DebugLog.shared.log("[API] saveServicePoint response: \(json)") - - if !parseBool(json["OK"] ?? json["ok"]) { - let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to save service point" - throw ApiException(error) - } - - // Response has SERVICEPOINT object containing the data - let sp = (json["SERVICEPOINT"] ?? json["servicepoint"]) as? [String: Any] ?? json - - let spId = parseIntValue(sp["ServicePointID"] ?? sp["SERVICEPOINTID"]) ?? servicePointId ?? 0 - let beaconMinor = parseIntValue(sp["BeaconMinor"] ?? sp["BEACONMINOR"]) - - DebugLog.shared.log("[API] saveServicePoint parsed: spId=\(spId) beaconMinor=\(String(describing: beaconMinor))") - - if spId == 0 { - DebugLog.shared.log("[API] WARNING: servicePointId is 0! Full sp dict: \(sp)") - } - - return ServicePoint( - servicePointId: spId, - name: name, - hasBeacon: beaconMinor != nil, - beaconMinor: beaconMinor != nil ? UInt16(beaconMinor!) : nil - ) - } - - /// Create a new service point (legacy - calls save) - func createServicePoint(businessId: Int, name: String) async throws -> ServicePoint { - return try await saveServicePoint(businessId: businessId, name: name) - } - - /// Delete a service point - func deleteServicePoint(businessId: Int, servicePointId: Int) async throws { - let json = try await postRequest( - endpoint: "/servicepoints/delete.php", - body: ["BusinessID": businessId, "ServicePointID": servicePointId], - extraHeaders: ["X-Business-Id": String(businessId)] - ) - - if !parseBool(json["OK"] ?? json["ok"]) { - let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to delete service point" - throw ApiException(error) - } - } - - // ========================================================================= - // HELPERS - // ========================================================================= - - private func parseBool(_ value: Any?) -> Bool { - switch value { - case nil: - return false - case let b as Bool: - return b - case let n as NSNumber: - return n.intValue != 0 - case let s as String: - return ["true", "1"].contains(s.lowercased()) - default: - return false - } - } - - private func parseIntValue(_ value: Any?) -> Int? { - if let n = value as? NSNumber { - return n.intValue - } - if let s = value as? String, let i = Int(s) { - return i - } - return nil - } -} - -struct ApiException: LocalizedError { - let message: String - init(_ message: String) { self.message = message } - var errorDescription: String? { message } -} - -// ========================================================================= -// DATA MODELS -// ========================================================================= - -struct OtpResponse { - let uuid: String -} - -struct LoginResponse { - let userId: Int - let token: String - let userFirstName: String -} - -struct Business: Identifiable { - var id: Int { businessId } - let businessId: Int - let name: String - let headerImageExtension: String? -} - -struct BeaconLookupResult { - let uuid: String - let beaconId: Int - let businessId: Int - let businessName: String - let servicePointName: String - let beaconName: String -} - -struct BeaconInfo { - let beaconId: Int - let name: String - let uuid: String - let isActive: Bool -} - -struct SavedBeacon { - let beaconId: Int - let name: String - let uuid: String - let macAddress: String? -} - -struct MacLookupResult { - let beaconId: Int - let businessId: Int - let businessName: String - let beaconName: String - let uuid: String - let macAddress: String - let servicePointName: String -} - -struct BeaconConfigResponse { - let uuid: String // 32-char hex, no dashes - let major: UInt16 - let minor: UInt16 - let measuredPower: Int8 // RSSI@1m (e.g., -59) - let advInterval: UInt8 // Advertising interval (raw value, e.g., 2 = 200ms) - let txPower: UInt8 // TX power level - let servicePointName: String - let businessName: String -} - -struct ResolveBusinessResponse { - let businessId: Int - let businessName: String -} - -struct ServicePoint: Identifiable { - var id: Int { servicePointId } - let servicePointId: Int - let name: String - let hasBeacon: Bool - var beaconMinor: UInt16? -} - -struct BusinessNamespace { - let shardId: Int - let uuid: String // Original UUID from API (with dashes, as-is) - let uuidClean: String // 32-char hex, no dashes, uppercase (for BLE provisioning) - let major: UInt16 - let alreadyAllocated: Bool -} diff --git a/PayfritBeacon/App/AppPrefs.swift b/PayfritBeacon/App/AppPrefs.swift new file mode 100644 index 0000000..2e5013d --- /dev/null +++ b/PayfritBeacon/App/AppPrefs.swift @@ -0,0 +1,11 @@ +import Foundation + +/// Non-sensitive preferences (UserDefaults) +enum AppPrefs { + private static let defaults = UserDefaults.standard + + static var lastBusinessId: String? { + get { defaults.string(forKey: "lastBusinessId") } + set { defaults.set(newValue, forKey: "lastBusinessId") } + } +} diff --git a/PayfritBeacon/App/AppState.swift b/PayfritBeacon/App/AppState.swift new file mode 100644 index 0000000..72db257 --- /dev/null +++ b/PayfritBeacon/App/AppState.swift @@ -0,0 +1,48 @@ +import SwiftUI + +/// Central app state — drives navigation between Login → Business Select → Scan +@MainActor +final class AppState: ObservableObject { + + enum Screen { + case login + case businessList + case scan(business: Business) + } + + @Published var currentScreen: Screen = .login + @Published var token: String? + @Published var userId: String? + + init() { + // Restore saved session + if let saved = SecureStorage.loadSession() { + self.token = saved.token + self.userId = saved.userId + self.currentScreen = .businessList + } + } + + func didLogin(token: String, userId: String) { + self.token = token + self.userId = userId + SecureStorage.saveSession(token: token, userId: userId) + currentScreen = .businessList + } + + func selectBusiness(_ business: Business) { + AppPrefs.lastBusinessId = business.id + currentScreen = .scan(business: business) + } + + func backToBusinessList() { + currentScreen = .businessList + } + + func logout() { + token = nil + userId = nil + SecureStorage.clearSession() + currentScreen = .login + } +} diff --git a/_backup/PayfritBeaconApp.swift b/PayfritBeacon/App/PayfritBeaconApp.swift similarity index 71% rename from _backup/PayfritBeaconApp.swift rename to PayfritBeacon/App/PayfritBeaconApp.swift index 4a67203..cf7fe39 100644 --- a/_backup/PayfritBeaconApp.swift +++ b/PayfritBeacon/App/PayfritBeaconApp.swift @@ -11,7 +11,3 @@ struct PayfritBeaconApp: App { } } } - -extension Color { - static let payfritGreen = Color(red: 0.133, green: 0.698, blue: 0.294) -} diff --git a/PayfritBeacon/Assets.xcassets/AccentColor.colorset/Contents.json b/PayfritBeacon/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index 650bdd4..0000000 --- a/PayfritBeacon/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.294", - "green" : "0.698", - "red" : "0.133" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/PayfritBeacon/Assets.xcassets/AppIcon.appiconset/Contents.json b/PayfritBeacon/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 421ff78..0000000 --- a/PayfritBeacon/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "images" : [ - { - "filename" : "appicon.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "filename" : "appicon.png", - "idiom" : "ios-marketing", - "scale" : "1x", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/PayfritBeacon/Assets.xcassets/AppIcon.appiconset/appicon.png b/PayfritBeacon/Assets.xcassets/AppIcon.appiconset/appicon.png deleted file mode 100644 index d295e5003973e9f28633a746c905d81c30b5d206..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 53199 zcmeFZ`8$+-{|7vzQWT}4B5jIfDHYi(N+U~Dk}Y*fvSr_w3XviaC0jH}wvgRWQHt!L zvJ(bbVr&!Ud7a(AJ%7P-JjZvA`#z?+m^shS@_xUz&l#kxd1}q-Evrc+(wft1Dmo+* z8~(^fVrRwMgipyg5~(iow92t_t}(wmT%K$=U;62f!|S5Gj~rI4T20ng-eB77q>*5G z<-CgBI{gaYb<6f8Y~Pofn8?Sy?m)2q25rghkEBl2a-LWlo}66#x^}To&7?&}(x~IG zZdaXyprCl_`R0`2lD&&BbjbKZ(!|PL4ZOr3wtKS^Z@!`2#2ZO)$Go&5;Y^N+G!XkRr!u1FpvPh*}@69!2k#flw z5&h$-G#SnI&J!XOi|A9+@w8(7d5^Y^O;fg3^Go}^r_)5DGXLmB=;T=!)5)RpL)tG5 zB+R~@?Fg?H|H7MkMpRr}adM1*(fr))OfHhPG5>r;km8jW5drV5SQGC5Q73)Rx?0JY z9-+F0H|kNwUliTh$Hy6ccXL2kObkCe+oSOC@JEkszrTFr#*OXTjnB7MXMbK+6r7Qe zaCxL9z1U^kS)^TEB>3JvS*PK1>6Z>mhcE}}mYn{_<(z+i&$Bk*mzCUKeWM{!wI=#- zQ)6S5{$5&!QSpVei}?88!}@fpw1NWFsMuNPXjGp}fa!nNu8sZ3*{h_~)odvmWz}X= zpKvUEj|Q3fWw>sqsHkOKoI+t?;eo;uwu7AIhia6!C&bGzxL#VkeDUH%a&j_-LP<_$ z_8&{^7&M!;%dzb$rKm^k*}J#0#Ar8#^KQ9$jNQr8onK7!i=9VHz5ZIYg^UXIgsO?u z$&RF{n5fFnnXRrr!KLul>c>Ib&PO37dc#U1eB6u4^JT2Jl(%!MX=v2c)SM@u&oC_1 zp^zW%JO3JQxi^|$>t!0}T&r=P9zJZ_X~_O3^;JT=!7A6VRc87ZB>7_G-6l^7dcC!7 z%TwOoeR6CK8>_#+|Ki`-@up-gd`NHmo|a^Te4CD|p$d7SB&c z@vr^+VtmhqGflW{a!>w zB+~5TOgCdh`s%0qNvlZ=16iB)0{xt89~T}?oZ>9MnEl`B#LS)B7S@xleSMOJ`M@aO zAL?AhU~;aQR+{EMrlX@{Z*Pymw0CeAY0d1)Hmkmne#zjh}BVK7RaY6Mt6Z zadfn-=?A}SHPOv!`Z;M_th80V#{;z#=l{dUhpFven=V@}dGf)Q^Az&NKY9*n)BXC9 zxwy#lsTWkuuO$3>^ypFKetjKAz&Uzqws~FL{rj9so|_o00zoQ{`|8UZzI^$@;5vQ! z^kY#y>^{}tO?zW*9-kVhsZ;#>8zZ+k*7vDe`X0v8_D8nR`k(^Gh0(VB)(oS+*O{qa ze`nG!W*wj9EwDR3Ay&rbOQ+L2ie2_H_Fd`zY>-?^{h7CT>du`!Ys|w;a=bSgFMO4{ z`bhor@^`nE$vF)>{@Pv;HU610C=+54jjuJxx%R-i&D|?meej;jPR2E_PPf4!y8zeH ztUT+Lcit;{G4>mmxDI@M8q!ywaD#vC-Mt#o^iZFLzTwo&mb&EZzp9P5tpD_W6!clh zGSkoS*7DE)NkFRyD${V+bMrIcdA>f09TATigxc#dm8HiT!j|MBM!<6aeE7fF=P zC1Tp5jGTXTxO5p|=iS>VI?Q}iQoFX1D;={TZdg#CXRRmL9BH*FARZR_BJ#RH>6Y*EJoA_niFFq$!tvI($#3=c_2cKABs} z@9bjgl$f4N3-cH>hC+i>x|}0zV%&#mkk3gjy>?<;zVGhVOIAaL!dZ()=jLqA4PuP>dCTe zY@~_{N3AC2BN-;OwzM*AUVVN2tCb(jq~NTEoKQr6*J|vc!EVZTa`WO6ZT!=0 zmyeQNuL?D?q1oZ-%cqf%&TZEDxi%fLKeJXx>?reU=bx`{m}ObiU46Iw?pgu&*$x*> ze>;XPWUI7If7N5gb{(>8-Yu0Kq&YSEHL)k$#ujrnN{KRN z{HYWat8SbXK1kyBVI#%g3$fo4kahX}ZDXpt0=@o3(@TvemtQI-Mqg>I*;qx(R<12j z5Iotm`6?;~3Yzm@!?Nl_8kKfvWsV(At!O5NRE|@0F#*ZTzHzeSorQ+Eu77%6y1ev6 z`iFlBQa_S0pMPgYiMdp`TSaYR*|ttVP?B>O|Jp9M!PqDh?|Ia3MUNS)vtzPxz6WoS zx-E+elUAB`x%NE}%`C}AsX@sLUCIoimgqD-Xu!%15mYiJZ_f93C#NhdEEGBn-YYjy z_%v8j)|jjnkKVAHl_lS!-zhs<&Sgwpf_m;`RM>xJX450Bbq@;5%C7JQ#BnXomNAXS zHAjYrNSO^<{5w>PjXQ}xf=SkhkvYJ>Hr(%)jA*!)WkqD4Oqg0lcG~URxAQyQM`qe= znM;4irlyimNJC1I_DIoh63(HO>NoOB7 zyF7DY3Jq+d*@UGORw*v$)XsJJ(o0b~&cok^GK$C6d;hd#oqMddJ8}GrWoyQ0R~fTe z&xC5%_esTML$`$dt%+dTwmNL&qCcOuns`m0m!7E!+)CA(eSYFD%D2$ky01ZlKW4`| zQcvac95?qZV=j)PkFB#?wIk1Z{Cid(=HLFW%7J{xp~jVhp4w$s$6{5P(XY>?*bm0K zprITtijwcvBBy*d#jKdcD|#s|Oq3Jfy$j#Xq-!hhUjOCmSI>#}D?Mh%1XlZ3jb&@@ zsF)dTBj##uKU3jRXXjW}<+k9#(>|*C5_i{ZZFy_aBx9AIJXq(s@TaoNb4ue!bc3Ah zM0a9hq6{bZX%S+<_W1MOJ6L>aOQ4vM*hTiNn>`;`maVwIRa%qjwRzN)wThdSc=7Lp3MoleL1V`bI1PWhDHHSRaSA$doRUwe8m&AH170UTb>G-ycsR(B z^cmN?INdt^X=a-Vcbdfb;%ryEf`_xGY#$c$(l=eL2Kvg77joD4S66-cQaF-PZ0y#* zF|58>*H??1G$BXt42d-J`a7DhXK3f&*vBD8lZV>tE0RBU^I>4ps(?hD8r*|55Bk!6T9gPUl1eZIPxhs7+y zo3>%&#tzgLqFf6b{n=0Q2?8(R6&ugSji4LRj(;$tsGpQpFt3Sz{PgMO_zoqXD4pR- zL7%T*zMMX18>yIS5uM-V{nxHiV9+_se9@n{L|zlmEU&-L60- zb+lCK5@V!8jzhaeb!0_5ehr;C?ecT^?mgZ{a~!}e(T7Jufpgr+LU zT1EL*@{LTjPoOf0NDZKJ3o- zqM*PPnC0Jo9QSEDbmx8d+@C&!x7T|*^?fHR@~q1xZ3_D%RKRro$XHp-` zaxXYo>F@6xRHDL!1Z6D?HM-N{j*eUOMs65MTZ2<7NGTZ2Di7Qwfuce+#(E(1p zly4(Oam*bE>63{xL!lfU$t)8W7M}PyWuV3NOvz`7OKG8dhSe@M*PTwY8!H?va@mxy z5Tvrx&CSgPqh1>;M|I|6sanZOT8i$Z^mMh>_M}8GV;9gJMsRzAVeyS2g2a&}ud02kBzuC5IG0(bfOjSjMqS%^n?2fF<*zZPtU;8uT zYh=9_=5WVS4XvyZC)h|45oY>fYX5bXxY?HcIVrqhazJH!N5&#r9&j)9rd!d+vg-|r zU&8ltNdJAtd}PW8_=2gx|JQ6ebhG%Xu~x20iR+&ir^DOjs?Rob-PL$@gt|D>rmCun z5>B-Zw1^Hcjqw;OB(}%IN$%xU{2~-H{Y>i2PmnvF(#NdYwcMne8$R^Bv2K$nnfS0U zKc#Ru_e4|ZU9;-Qbd$2qUCTDOZ_>tHMjy6Zy?Qk$A8XrfSpg&aFG6}oPLFSvoxhzsPUEbq-YR?XplP5ntIdGMJ&}_EWj4!|tSm2MIK=-c9M)N!&%-ybC>Zc)3~8a}Hx#;vf;Cvh#Rg zs3PO~$9o$;Toq7d2s{Hdta>b}nwY6Ep4^ac+hv(ixM!`=q6MFB*V4@#V zDUUtO!*pI!DAVIz%opLB+53ZaUMhE(k{MSd&SY@rV&$6qryuQYwc_?c^Y>cN)7_P^99=N3##`(&`_nDcc$e${2W6#g>b zHfa`*=CxV1IeQd0KWTCb=JDE?A*s=Hs7B_DNATl|3Kz(<{IcL(@p3`bB-xOwZ8MQ# z29XjborMnOW&Sse&eS*CRj&sk8~q?+JmDZPOuZ;_*kRyI=VOO$v>IUI5!bW}}q(@?=2@P^o>w`#U0e+>_LdpMU%V%<=HxPIo14!e{~-p4eAZaGY5-s_MH zQ+I4GYwW6$R}1|hAagSzE$vIo33EFSpZi^bioS_;36~|GygC)y*)@-aXOMg2u^N5* zjhKxaHdIVKo7mg#-Nv)X@a}m(b4R_(++3@jHcp-%ZniDx30Sdai(k2^{_ST;aVIm! z$IpT<{d?UlH(k7|EgTUQ74_`dGw|DI&x!$)AaBTNJGC93t=c_&;Z;OcU7Zhean`o5 ze^aI1dbwPqvQf<15+NZTQZPD0DR^(us6q6yr5jfI%*JUcwb`LzjjWwv zflKBxAerS@EqKk(&x0+Z-_%xBH43~Xi!>eTYu}MBr;RO?W7l_boHMaVqwK2z+8Dh_ zi_8D`D$i!X3trx`saiRr?PNF1wkTs9?M?AABN^zSS z5Q>@nn|kcL)00w;Q?`{29(ME)!{^8Sk2(xM-SEKj0K)v4V;(w^IiZE=@|^i$p5Jb- ze#J+!D0mia3f}|H6F17QL+&3KsEt=Dg9u{m>N|AulYmS6ACOHT*RE!-Khslx2jhLb zF&jjMy(z;V-&?@&^2TzT9c>%DF=E1GLoY~kSvvjsOtQ{F&LrQ5Q{C)V8G@_c7HhZ5WqHYi;dK+p9=M^VMn2kkNwO|jT_jO zuPb=Ej;a6V^83B1@f@RV{fkCm0|JNv znHv?2o-m;p-|Ox?%*J}_+qZLJvUbyBMr%@h zjl$;~)A9hI!d13sj@OSjgL$|&sPYbpl#Rt!XYa4Hd#MF_!Eo$ZGBWl#z+dTx9q4r2 zc|7=eapuYUe0EhL$pw!0<^nb2r+$aNqE}U^%h_jWdTQ!DTB(h`u=Vf_$0iMFF{2_n zV5DV3qQCJ*?z_)7F23K$%R92DtombKxLS5mv3SwyJbB~BjjQ6tCe-$`DqB^&I52xk zUB|mPLGovGn9c>D2dHF#H9kOS!!7BlQSUy9E%Phyfdn|#mM;PEZb$xAuSUosP|*fI zOwF$K%0*>xx3jZL%v235m)g1Ih?Of?Bjx4G;V;*si=tjbU6sSV0ry13TyRui8N ztTcsU*|lXtUY%)aY1q<%Dx9n;Thlydem_<6@pcscltbU&hSfn!ylk8hpJG(xSO_wy ztE+1?`lQt%STF4&NYy!N+kN%T@A5`3OBOhdv|vGPlXd(|B_)_&hkQK^3T)f>BB*WE zYPB>I1s4>$$%%5VvgsCsT+7x?W9Foe{9NF)+qYTuwAkiY>RwN7mt9_nB4JCNZh2!` zPB)~^{TjmN-J3Ua!Nz6`EWg*-@?ai)aPq5E-EPKlZ&SViGkpko%%#~fErscG)Guis z$xYRqaqhz@1bnAtzS5~WR0D+;GSnizysF0NIlZkp=~O6iHOIj2cUp-`oi-gs8qtSR zplm?r+&fS8m>uZvUnP70R^tv*gkVD~zaXfrd7RtHI(6siO)1ah0L`I!uMcW7y$bb7 z_vIP8+*sZdjLkD>i$ifkDkdS^$7zL==?3|wpim$Jz0Qtr_lrtP&-(Kkf3B-@5h1bf zXvs8D`o~LucjkXMq){$egsK5u0r3HD#E=7cg>+UIH9u_eJ1VxeS%RL+H zvp8ZRAnSP3DRre?!OJ?V0tM@zZ=VPn+Af%!J9o&eDguh^?3L$n3%Z5&{XR=m@yRjv z_d4x9Kj2|8-bB@l!NTT}pHSwvxlT?&n=16$l+eD56d~OpTOi%x*fg5om7ynGa3sn$t)+ww2N82_0La?d`sD7SSm^VIEULS z0YCb_P2Xguz0d*M&00Uwc=hi%z_BtP=F;u-5z>woYx!L#zwE6v{qFYu_VTe<)`?ZD z%11Y(H`%osxfr?j_-Bmm7BE^_f*u$aNW#{*{q^ga(61Dcqg0W9Oe^*niO^jYr@!e! zn;3OA&l`KvRpwK+*7ksWTErx-h}gBp#&I5$8hVQ`#Fw)ENCW8VRg79C<&S=OO;=t3fkvaKZ#GhhxC{;F~J zY3YF*-(EGmI=gFjPs(^l@hlJzkMWk``p{A6EE7-}9+VGpmd`=;bJF*1OirMUqwKtr9k&x<+AM>baBu|Eamr?>v!ED zT_TgKCVI;I-ULVJyngfMCY{#c-s>$A`ZDu~l#~NH2SJivf4T&P3gTu(3POH>TQ)ImHpNvD6#Q`|Rez z@6GFz`yvf&EYQ`?Fb0^0rlvjU=h>#^E2vwC4hWti16e3(#mJm3m>2rSVP%7AMzAQ? zYip#tmoo;n;m_!59#t)vuRtb;a*8BeN8hgO?(PoS1hi_Tpl51odiHEsYH}cOyySf- zK075OBnImE7CGe>=}mxr;(oN|)F|~or=@iUYh(WcwBFO1^{F((zJr){A?u2&o=aHc zt{udjpkWb=&*P$iSf$<0u+QaOO23EOUbQw!-}rVNj^J4Aa^WU;ZW+Am3b&A+u(Cz0 zXGw@@%J0Si4BJ@EM!wwyEG|Z(>HUH3E+Fr!T~So5M}8U|{j{hg3`q5e}j6C&*SlM4#wptRSImmeru<^JT#!rUMA z$o&mLAN)l(9%rYfK&UP?DD(D;(qxG+V${aO#dRjCa`a3-ad39d^H{lZ!Iz|6J7h5N z)I1K(qji+#pHxz}v*?BVnIo{v(1xDaPOXn+IuQ3={&&g|Py@c=2miSjQt!Y^_0lhy zMZa{(Th?f?8|F!sXy)&4DdDMdUxwdgq5ObQ3d_mKUHY2wi&<#O2ji7b+9oD--8tI_ z?m!Ylu|6f?Eu_qpi&=@Om3rhM>%DWESu5`omvoSdw&v^+>DK;|G<0DSN zim4H=Q4p|OtlL_qUS>e?f-?4TW`DGG8&ovFE@0^oSBp;%zjc7%GfivQrJ&H!Tt$K! zlDCL%?ge3z@lA-CF+Adl9d^XFGp$QYI-_3lzVh~)@aa0Cs>U&%khpGimK3(N1;V!F z*wjO!ybJ#-0W6qSoNpxSeeBpV;SKA>UB1YK@UXK%lYlw-G%AX>?anE^H?hz<8cxQI zu`7X)7!I@Qk%5mS^s{$kAM57$I{If{mUP8c%+eZ8kcW&{Xp>cUhJE<(0rTnt6fPtY zat#`gJ$%J@ubHPyjKX8mW)V6k0yr%?ik#>YMzR9h&O>XXVaXL|DH%jWWMcrv1Oz9R zvHD5~Q?U5PjCn4wpVih*aAC23EM6!JJ`E^!iep>l1vtXcyk+eQl#KJIGEkhOE}R`v zO&u>|E+wi4`&G^?Yc4+pjS*DM9E_y>``W|%Wcd!dP5xJDN1il#u!-Lb%mUias7#3Z zc$`J_E#(9>7xybqT#j$)KFrt@_UKXX@RR2e*;vB=yC~)PItJ5IT@)ri@3L%3o3Hjc${6y2(hW7@ai5HMdP4n(AY=_^{N#lc zosZf38B4d65`6@Ul0P+fxUM28i>xCp>?EveQumE4Q;qzIg9i`7eC%s%5)u-k03`R$ zzU+f;UL&qhM;~DT>OlVxmv4E`?-l=ddc$eYOf2JjrU>U;A@gVdT8oP70fEp)51OO71lm`DIW%h1pHL={mcr$EApjvoH> zo`t!c6ya0U{3tdS;AN}Je;q5siD(A9uVOWz0c;`EV8h`*&_TN67cv2-eB;`-Oic|R z^M(3(LCa_M+y3o#D$;H62n>=n1@e>M%Y@{h48Yq7Qt}p!(2+8)HO#u=4@bFniTSsS z=?!n~M_$b@!S1@Dt*SEoaniPN9>J6T+*T}X5!+D6b;+34)&5sXEe0}QfP0X-8=s^p z{)TDpu|V2#hLNqusvN!b#tzwt(!*CBm=$Db3I;M9tR0HRucYWm>M!No7r-6%pA_|Yt)%s6k=Q8W#1>U?!pOW|Wb!0&ugRD#J> zsAXeCBjQoc6|Gk}fbzdBE&jdgJ#q0Y8J4vf2xCgB!R^&M_$$BE)^0m$|6-2%DPLz8 zOcEeRd-r;~=`8^;n9}s6ViP;$)@iS$4S1ttqwJE6tNOkZxMWO#e_~bKjsBQYj=EU6 zSq#UAnnIxrO_oNDcfc%PW99V5pKWftGm?EUInMp{TwnfY!X$vSAB$;k-0rJ04BTPj z`A79@D8Hyq)n&=K4;&I0?V>|l$-3mDAyPg|3z)*wB1vn!7EP%T;0dw&ruF0Jiq$YT zU9jy}7cp<4Y*@EJ<>j9$c8pj$Km;;u`1vO`0^%k|y%oRoR#;lPm7+_Nvxfu*TCua; zLhQ82iRbm-Qgo-q4`eA6jI~oIt3;t5i!RMErn{sqTYmQjOE_mT4ai5Ge_xai;Xi>J z;#Y8Y)X{mA+SKdB?>>^A?>ycqz_}tR-ox8_$%pyZM7nfUv~kIkxVR;3MrOmko20VD z@rST~tb{k%kLQc{T?Yn6lE`nih0J2c!IB`~)RcsJj6%~|{MI@|##0wcm_@9tlOG3* zvdIj%)?UpR+NTA5J87Mmw@;byWuW`$Hodw zxof!})WZ1TV)iZ3#qQPKu!hZmx?~i+e7B$QzJ2bPD?hzuzDp5k5C>qP!PqU5RWkO; z-2)P7UdzA8%(#?03^wFE9@~)4+@|18`=Uf5U&psXa4SyLWlpCLAX^lwRkJz469meD zak9kfM>g75asK(lNK`P}8ga3zuMj9vPyIMHe>$W=IR}X)TH4n5k&ixm1LPraVIB4! zoxyzTHprHWH@kh!dET(E)&7ay=}cHj%GBp;i6s3{q`D8~-yC0VvzictYMj$wC+ zYUKTQ6mc4tT{W!zIu|aqIJFw(U#r=YKVlDw7_!i+%$ZPP!HK4BrFBh_{>#Mois zjah$5+nR#53<42$)4K2%VQps`3rt;$kE=3C(V?KWYza8~=zMxe9{@!D^@b0W*YAE} z5tzU;wR2q`)yfxO+UBgI(|EYm5ZHc@FQBK>PguZk@0lkP(;%i(HPRSy2iw8;%aYfl z2f&hWTL#BCmd)ul2!6))2X&*O%j)?Yl#P$KuM%<3+BDBEEJSXLI8pSRKS%{`l9Wxm zOnx!bpBu)<->HpnDf9{+E!qlqE3VXdVaOQ`L#h$ojHFnsm%m+QFd>nmYCBT2^m}r0 z(i@%m*qs$_d2h~Z zQ{3LV-D<4wU}9&v_dnKK%P+${_GQ>F(BJonv|Y*#ERLj;tV3pwh#iu{%H ztz}D;L1x6SUX7-=`$uq#FaR^)qMcVD{38s5coA(UTGr-ebW0%Nzc7W0EL+8Ak1RE9 zeFMDKL>>BAly?F(R-8(2#H_o`D7YAG=YC~an5|?YN8b69-pcVc&b2oP70bEjgTMQi zQjfvr*u#KlN<%NgD|%jwjK!{W zGt+RbPq{qXze*Ey^xWek;8665uW(wILVb2AQNWfg3#A(L?Cv8H+cBrG$8THsx~#E_L7nQM3wM?r==b4pfRGyiI|9doi4t50 z$O8W}Gc!9aGR=I=JJ86l4Bt)TgLg1 zsC(QZ@-7H`U81^A>tq~ml;&B?`<|E1>xMATVx6||xpU`UF!i;yAHt)Gic0oqPfF?j z?8i-gR6wn37-NbX6|wK|ELL0@c+^|XEP!DG)9z=tF~&m=oBN6gPU8@Ft*kH0=$gmJVHR(q2ibh}YSo*D2=Fx?Rb;Ae z)|JG4JjVH_1@P>)BUVp@b;Z=mCHGxObM5k)9THljIKISd<~G%|>)GpmW#A@gGCcOf zO7n}Kn4`^7#kKNNXY$qGEb~4=3S^A_YQ zy+mg!DUqkDUuw6hXIue>nI*!1{Bf(-i!#o8V!xp6+V?#dr=)?hqb&F#1Sx1OB1)yz z9I8QV(go2$M3%Ft@^%F)E?m1dfMrq9!zkWXN6JSy2Dv?;5D)1YE2le4{Dg{3h&WwZ zOSf7p=4Z#=|9%ngjsnOK%WqHA<6lSey@=f%8FBD1@oGM#p*ODaT$=_chyK!$YpI8( zh-T9Y=!kd}6e#^jGrp4x!6)H)BYTL}dn8XlszH1nUDO=zt~`n6Y>dX`F+D64a=3_g zhd$Qf(OzJOE4JZey%_m8B7%-h?Fe-Q>)3cLiSZNRFFKJ`y7cx{LBaP2PHkC~woOE2 z11{sx-@7Z_-l%TVV(?yq@3!Ma0HV~7#(cKt5mudxr9Na@AQ(Sx7=l72Csd;hh90T%npDF{2z9F8Kx(cStY0;)X`t~@#z z6Hc>asoKG+oI>046YJzI=Vl$OW3}Bnlg@9(zPB7umiT&4ybe}k5m)XT;w8CZwb0(Z z%-3P*OoO&Oo@Vvk6sv~BkN7B_w*ImPn>eV`Ky61En?Bz2<`O;s+7T-&Sm#CX^(Y?{ z@Z>X3dmHyWmhDCh8^;dF#MN(24iqbwL;~jzWN1&sGG1B~QRU_iaImqGQ!w&0i_0W= z)vn7#wjFx96hnFyb!S4!4pN10gguO=&6_ui3n;;n`7G9RrE`zsk&r%`+fzub7SUMJ z1X1u%--7k-(tKVos8P(QK6CLlhxn_rNuNNCuQ=PCdb3}hd=x1c0*!QXkr=Bw;PSzd z5Hl>0l#r-<#_~>*v*{3#wlG?ANVDRFa9SRG9ZW+i6RD&inXrmUx5Y?S_Mh=^YZ34NStN%L^OckdpAizL0n z1%K8;fg8%`x@c6=x$=upp0&D2JSY!5@%YDsh(Izm|LApM|R1yCEjN|4SB{BY3J2}q;dLmA?zb$Q@>{1_C3WH@ciyT^dXCbaB65Ye$p)BaIhtQ{rUyRmPn97 zKtG^VYBPZH;()4ioIR?DiBlrtNQy)IE#lS2N+hJV&h5yf0?9!fCeN*Y_^AH82oO9G zTPDPr{KLQ3JDq=N;XKsXe@`@nJ8f}!$@@bZ9UTtvWxa5G0v?hYeIrXLqF|?o$mTHO z>&&8zHi;eMp2|rxDf33(wfV|ym})@OdjTgM#Pd#VIu5>g6pyYjXQoM+bf;?)I(1N~ z@H+`twnP>BH=<2)^27}eIAOA&M*+>hAsZu9U|6qPRcS&+dQ9wP+AS_6m4M$pd+d&r*O$mIGVH8r!3Q2a} zB02Wu;M0oX?o&Es1SLv9c-`kdKG3a*^sXa9&j5CXhn|nTf{bzn?#gX1RbRh*ArU|w zMDiI$dbA0}RbeV7Te^2GbWZS|X(AjuqymwEOSY{S&y9zdfD;Hk#&G}Uy-1ncaRlA3C>Eqko<$@5`_5F?%M@QrhTg*+FV1|O-5H&E}BdQWfL zvsU6G@)y-e997=m+=L*;+qZ|jrgldzvqSb$uyOGvW)N9UK3T`>l6;OSv*%pq>*AGs zPM|gGvI^9s5odw z26GH8S;qbOmdc+Cv6`+bl6)JEf6FvsGDb3eprJZ7by4MV{~4ITZL#Vrr8$$pGu&UM znCXijR<>?i%f`AFG!AR=2t{&M1R=d-!*3$Nr1stum396t+-`XHwM*$Z zbS%u$wYoTKzJRSP$|8@$!;ueJ5i5Pg!*iCl(g(2;8^q(#knz>My;t<=UIl04l$a;d zKPf3GZ{sfF_PwT?U} zLWVcrFDE)HK7D_+eeQJT-#3qS2!|1BSKo#JQ^+Cs7?juM(MhdHGjy_CloNm{zRyZx8>UsE`=ONw}Cr< zCcu+)`^`H#ud(Z}E#&5Zg@=|@;sA|;K;TQI?!#sYL%uTgBVJC)(@k1RUJ!fFztY~Z z_JKNex{X+c4uhJ~A(?`zVg`BgVB>y&m(?>yi!i9T;29*W3J-Cpi4a$dJ!R}Y-(O^E zv}ap}yH7H9@$7K3<^Md5z)0=DqefxrFiF5x$eNmp_TH>ZhEG3-mx*$-7wGvfD^piG z2>HqT<3>IH`Pncv73PX*D;?6diWBR~&NN&g10tLUVr)izK<;OIZ{vn~u~tGbggt5V zo0;^?%N)+o5A%4jb|2LMQz0U-H_2Uv>{q@>iZETYrDkL)dvu$#syX zr@M&5ZkM0Z9+MW*(wt|u2Sn(AxRw&czG&zruvq12Ns$SpPb$D0?&xFDbY^_W`Pb(x z+6%I{cdierNSAvJA^@fPb5)gnQn*28)f{16piLjK{u%MSC4Czk4lzQ(@xZ_-xQrP} zcM5h)(podSkmTK8b*|wpTce-~iWCx{-vH;>E?CK23o!lF-~TxI2G;?mMMVr6;xVEh zObynJ|Hv7H@?706y>enNtM53!o67|0YkD#oL-t(F`)>TPbF1ySm}FS zY+Rf%XiHA5>C9UR9D`nvm{8S6ZvrB29E9qiBn z!ExGvZ+4xqqBotro3B+rbrXj;YHEzK|NDZqMNA#?%wTu@gM;vlhU?Bb4Bd9}@ryX9 zq{PIgGs4iF;8Jj`Iow*{C`&qUj3gW%60_~~-Rsj=-$~v_0mLLNPQ^3%5B{fy9zs_N za*)V2(|>&I7oFQZ%mw(*yf?dJWvCieEu71LeXCPCF0)WzCgd>*sKd^MFKqK*P&h+^un8*zA zez@|V+S`ou7VZVrs2reUwoT4HCVrOTh*|{&BUhcOn`b@P-*5Kce`o$3d#XH2Lf{)Y zslxo@dgBd;a59t#7@>hC7Oseps^tDe_t{A~gY5y(OE7VqzJdoAjL#!7qjA22y&DBf zk;Nh0NTPZHhrpTm)^^$QSD@@q|v7o-}V?40K?%$$#pNL!Eo%!Nu~ z4dvQnFRS(i{&~ZC>3Mtv25vqb=eAJE2?@cg%*kU!(rXxG&;cj$pp6hTimjaNTggqj zqO6r3WWLfthF?=%T^;f!S|$$iiNb1UYUA4QM~)pt|AA(Yjf5^;2KnsN(p~p5W<|)C z;mUW2m%IcEl?h2*))eGUK{jHv_J~5J#oR;>$_=-?2@C(RE(YBw5i?9283GemLSkH@ z@N;u<2KP?>wU{$>4BX5}E$HY#v}J)DOWbi%AhMd@$`(g$p-3p%7Y8J*w~jb9q+*KW zM%-$+R=hMcaC8z_E#pG^Px!nDoPZZONO1I{CT+l|KsXu4-zA z+;o#INbSGIqwdm2%m8v%Fm`(9S9j~thb1cQu*7C?fF%0mQ5=Iba%~vpOsKvkhQuWy zi>a##TG>AMq67Im5LCGX?NdD83D^=t1YG2I+Z zeO)3=hr}$AYgaV=z;-rcEA7v^AQeS)M;xan9<$&YHa6LMf(5(Qx<27OU=;GYy^_3Y z<9c~GBZ^&7HuKXG8b)qv@-l6EHs8jB8abE)EKe{XV_>i38vepxv!8w3pFd-2(eA&+ z(ue3L7or=m`iJ{N?^mIMN=rDt2W{X+qQpwyQ$wTOFCw4<9nc^GP~zwJEo`i`b8Nni zD6)Q}6JhYX%aVjt9(-+V^pJ$zGNRE!*2!MtTNr-v4?w_OU}p=7IT-Q1YC`DwAd_F_ zCUIEFj^;DDjB-v>vlbv(tm^oXHYsR*2PXrlt_<7>y_7ruwS-O;@HF@!$d_ZB0P!JZSh+NymP%kxH8@$4yh`)r@D{RysjWJ1Os10b%tXl1Im z-aYN$`XDexI?n3woLOR9|A6E>*H8m=qBPgNEc$g;qDQMU_5oDok+VxGChJIAP5E67 zNvA-H47j#e_PiieSSpkr{F{Vvi44NY2c~3Vw!E`lN8+}Hq(pd`YzoR*N#-n6P~hA8 z%A_T-<2fQS2g@bMe8o+EbF3enH}pYJVlXycge15VPb#A8ngjpiM&9aPtP2NU z(3;Yq_mhN|0cjzwr-hwGme>(ozFDl=o_`QqLhy*yMMqH%(w|c#<(OCHW}{9*TduvX zc<61LZ}nsHr)B18kq&X?6$zdw9$-(~R8@Y~16&xJ=t%|d)a+pXG5Jk~Y_H zHee@{nGr{*BJHqCdjqLKqzV=m(w~H_i?}OIZ*T9!OiFvB*b+5ZqVa_0M0^g=s1iEf95#%}RS zJ4i;x#+~s0k3Z*5_t6^~dkYqWoWXhi&faAt@=iWZ*$@XrM?nYGMK8kIn*+>OyBKQbH@O#|la+Y^qA$@>CVPTLIR=?lBopkEN_-Z6u zt{PpCw~^*X?!B5Q0C>DaRD8T`^d2sbcjxgT|1t-ga2nsDY0oiR#}Zm?4z{Dq!^0ul9exucmY~Qrrk9DGYJ)zCrI7N1nAP zg*dwp9h74SXL*9e8Z47zq#YGN@%S+-zLV{nmR(pJ^p0`(apTN_1NL6ATpE_XlU_sIMaM{GDl7B#plrqjacU9Ha4e1xd zEqrGY!yukv0vV(PZ}KWE{2N6m)@?5>X263`eSpVuEu!t7eM*TaLg^)P` zAHZ_--$sSEGKEy1KxM&F;G;{PMwD4#8KN?sKR)syH`nl` z1)etIKN#Z>vUR140h}w)%_3~8S@iL)^;BJ4)U+GI0xbFQsN_A4owWXB%hw#0)L z;>KR%y!QgutWh*GNvi=V)E2A8S$mrc7dHL1Ajzz7 z|EUGOIabgYg0Z+KGsga(ehX4TP#;DMVmC~1v68Nw32oj-A)eZ@u;4k{vb1^!;*&S1 z(yC8Aj?g%4@GJ+koV{y#&jcP_)c=KZuuyv>h8+6#ld7aUP7@oz2&@;l*RHs0ALaP- z`mMGlSx63yT~@YQN9w-U-rkNxPgS!4G%Q@c%TTvXlU6;h#@J&t_d_lzfjGH@0>}>e znU9Th)9OXUu2UhzR%ow%N$^3e6F|r=E`MPa4eIrNuv$G%(|@$sB1?${Zu zx~w0mGmN4}9G+y$y>;vR4$`}geU+gu zqm72|)!JlsWIv0Vj90ZRRuGY?El6L{a)$&$OT-~T@ob{^Y$gS+BF0z*&#NecfT5qs z7C8Th5}{LtiU}lrHlly+j>RSDA5eFaU%V)A*{Q%z9H>H5S$XH!3KmktC-7Rr1Eieu zJm_ntk34J4Q3srt>Ya#{T06MBN*bSkI#bpQd4~4X1&VY{$ukEapJf!`Z+P;QE6VlA%wq%?!DNPqvf>e1B7FiJ zB>eH?T>E}TQZU_RG=&uQ~Cb74>krhj1hknd#r*ZlbIbs>Ff zB0#x#AjC-sPnVWN{a!&Yz)q{^VY&MxUTDeDX93)r$e~{!kKT%d3}`Ah(E{Cv!?v2U zbgyKr^=Z*J?||Xj8)(XsueBveg^>4v@{7F0i9;XTk2s3XAmY@SXZ`GE@?Vp>N8_L( zBFQm3tE&0yX?Lce?blL{5}Dh zYNMGOugvxyKrj}{IUKe?4h_yGLds}Wh}RmgonaouF>Z7h)Cx=JGpRzioK`%+$uJy~ z`*CuavdE7-BXm>ZhN7ny(|Eejr7Lg7=>!kIc3H7pwd$8uPmmhe96ZLaTMq zya;DGEHJDe5YP?BAxb@o-+9Zin9x)+&!6VI5Em6Fo6UC%qEt0p2)c@&kc$Z@DwM$>a9HgK!L z4&u2~kjCxCcf8Kg$Kr)dwJR(zHE9iLLD^-t9Z|gGSm#UfQpz&?ae#Rkob6B8+4y-_ zN#y^By{~?YvWwasFh~_7Bt?)KQc_ZoX6T`%98$VF)uELfx+Dgr8>EpG5Ky`i5a}-I zv*-Q(iSIh+{Ni;vGwkQtvFcv;T3c!8+5`X=fN;%XxT%{6&;R_n4wNH^iyQ6Ytaj@^ zRG3h2+-oNQ?ZswABj?@SKz0`xXmYUXthC~I7{wATcA$Xik2OM9)|djqC5(mx$#@-p ztezl9(D1PRK5%`xOJT3D??k#o*li$VoOR>2F{;>EWc96f zyQ!%Ov>pOImnQjD#GCLwA#p)GDSl%G01rR~Zz&yre5ko*r3m8iAyBii_+FnKSgoR> zGL7AAOxqfeBdPZ!hXlj}QX>HIaIoKgy#wiS4gdxc#Q1zHEb>X=A3D6>9=wGF!GSf8 zS}r!McL93dM|m7gG6r}AAQWdXwpbCnmR3PR$%@K||1R=y%Pd~_uQQ#W@gjoYd znMLgmNT)Pb2Y-4A$T^69Sl^BUin2#l?Q|C8wLoU|m`j{1<2(NV*kRBjLiT|W%8`ss z3&4h1PG<;a^d;c3U~2mPo_)c7a{nmf55P2=06qDrdjK?uv7Z6ZIR;n-e#C1Yh`K2# zix+J^u?1@jsKIVDJ*4f0^t2&i?clT$fgV=2nM+134%K##k8vwz84A1hm0hUP<&|P&ycTm*eJ)ipL zVhD4XHU{_0?s~jO9RRE{27aEMQD|Zb{s?6KXYWI#!Z78!fC89reU5M-gVf17j$*q1 z02Uxc52Z1vRLOVlRtQa`vXmiodzCWTF$DCup ziW|MiYH%jM^2YSa0YS9Tfg|J-zuR3*0v?b-;Gm9#ILTVrhadt2`-A+%i*-(H7-+C! z21>{=`8m9ah8y?Ksu(^G=;z`0>_&d%K|hcKKwwq}{fjKAKyMF_a|Nl6eCvbnw~3@N zfikG$2WZ3NP%@QN>r?<$7}Yt@QbPVIfu(2!Xc6F0DSjRIE+%Ng1Z@s9vsFg{FnAx( z?SR{6^!dqi)x1zD3~w1gAoRB_+#sDY)mI?XwF?eTaz15NO+Bmt_%C2N#qO_S_laOb zqUOHGK6R&_`}lq{#1UW}AbZa4C;V~+;IIeDMM;FTvCr?-5>aDXwTj>pX+2P&>KR-nG7 z;d{7PUl+u|#6Z92LNl}T*gH!y(W0IQN5k`z98mmpQ~iV;PBg1vr2xp$ykHNypaAA5 z5UZ|5qxo5(NUQbxRRDYf>f0J1`olHbK{P#hci&_3r=W6NO?ZWLrgnKmcU4FgNPljm z>J4MR?NC-rLcO!J8SdJfsoL#%D}Z73z0@%9UjWHCOcNK0>-}hOF1ofyF;O=#`k0;Vq1>oph zY5J=#U<0(L5r{Q#n!dVe0`3Y3hB1-?XkUOZ&-)Tac|R0b2;e1Q%GtpYt>>3FfvpP- z0+y3I#EGV78}e!9HmGF=`VGp)7P0-!{XNHdJV#)C1xS)(`uHd+hTEGN1>OV2%wHBN z4o+LT)Q<+7cdG!xg0Z`MRepZtU|g{HYk9$?fs z*rz+S;{gve`s`b?;*AX82%fdwdVneMLz@7;4fqx4)DXT?52cH4%OxYc8%zY$hc{TG z=l0WiHzv=MF(4jsb5T%3p9RXT=@y-Y{?4U0?7M*?tZn=)I0G ztBJ_@@*(v5O(v3+gMQ#R8iU>r-~7GU+3&rG%q3fuWtR6<(}d|KuFU&O@2>nF{Z%DAJd4JMiHM< zLub2ijKrVbc_|4Jla;2MoTUc7fwYyaVg8%7Mi5TYdK&^o6P@3HkAt2n(&q(Ehqvgh z6N3LrKE?d@|EX>GU)_uzkMSIPNgkh`#Qz@HmOZN0nuk{pAv9>a6fOd{^#9%Mng3Vs zDLrNv{=bO*Z@KRQBVv$Gm+Dfqhk;P^hxHmDXBT-E8l?cl~CBbTBK+Qh%V-2W(P z>hQ6>@I7d@8hv90oguwD@)iQw$u7c!m`dYA+xt-Y<)a!`S@^;@#j^%UFZRa+$CWt1q0An zcX6{jdhTB~kre(qF3YcpI}p}TaHGa=JkS*c%JR_L<52YJM3aMXB0b>@Jx~&+;BO(u zi@1xdOp)8+qOE?fSCajTzVj1~W4??8U(PS#(i3bCo!;;}?Z@LpxH8VvU_neHz&Dv@ z1WW`OQ{Md-oPLZ|PSL11XYeA$;Nph1NIOJiOom4FS;(36#2>p@sp*JgU~DL!fPdtL zVZG&50!@v(@8th(8#ur^;DVSXLCb(KcPnX`A~O6>vJDcWQyXmWTeSbzr2sUk*M#?m z#ZwP9axd4P)~X1gp?8!(^ZK4B@JGg{N-5<|zj8N?eJz&d-|-?q7!95zN%p2i~d zshVTJX1IXkWS<}sA?lG+MgUPi0dM)q&Izd=6@&1V8ee^{+oJwDSh>TTiwN@j4d`V5 z>4!R881|jvV#Hr!zQuer+ldSFF7lWqvG~A6BFOG&-Fhc_hhthk$R$^~Ddm$`_8*m_J~W2UF(l)u|;EP3vfM(A!&kcZ(8*cMAf#IYA(OY%>};->1yB zTfKXi7q(I{V@WDv-i=T*Bo4vS(?^Zs@p1WEm(L$eWDXWK3p1~&zqs_A+7w(=4JRuT zg8mG_dCC-!-=G&LcxzW=OjHDtAy=^FY_vhK)`buu#6fTuatRV15EKqL1$dMdJr)56 zdwq{0(@mwRe%!%^T#{p6CHzfFdGtwOV9%1qRIFGZ5(^T5ne0jgCyK+VWk~-ZEBL7g z+4g75vvk3kQlFn}Po%FYb~4HWo4g8VCWP!sU_QS;)tA^c_}58iAvl^ttULm=N8?k7| zkBAH@^nBI$=e&oP+BeBk)xpPpVc_Fy zQ?Ql7k@yp{<-cP;de1;f?m|GB2?Vn6%c5NnS$Ar*x3+Yv2=j4^6!=)8k`|4hkuiRq zmMHeZ_0$hDfk2ETd`4xPPY%|);8=)V^q!<(b{?~bL4iPC`+K>$Xev)u!VOwJVdem` zpb);z#EU?zqPOYp8=SdRkYJ{bG6OC^8@ENf zO!n3FHKiwafXH*WBsfH@WnkJtCPMJ<*sE!?cW#kK{j_LLCb$joLNL_cjay22aIoz; z7ey_TM1Ra(uuz}U=`r&TEwQ6?84u;V;Gk&O`Y8hDAt{WR_ho7c3RhS^_UbvO_g3g! zB=&H1LPzCm2rC`tK?{qxOdTh0od%8Av#$Mm9$MieE@QD0wT0Hx!&p*2%v6 z3a&~4It$(%0fR=_Qc+J)T2ja{_7u7Y24``(DAIRKSSx848ng*H4duA~33(npLv-ux|acz0;JaT1lk zqPJiFnz0iIj?{^PZ~pyPA|eZHIpAn|QDQ;bb~U9G=~X%?Qh0XORUE0|3i3iswxi&n zP4-qCI2xV*`JA$F=L2LJvXc1}$x+=hnCW`>uIImb$bor?(qtdK8}vTupz}w}!2mgoyudq{-O+IGjpa87X)2D}IFJVnKx71z zrb1$bW1FZSiJf<~=(81%ayMSl(aPgmv-j6NHW0n8ci&Y&2=!$v>z(EIBTMevw;;Zi zf7tqn(J%jvK-VxjQTxuwe^vZIbC6eii#0nc`?w4!9r>^@ALonEXv?NTBoR!5;Yr9J zjgS4E!QhMmn|!lGEU{}K2c%<<3he96XC?oVAM-y07sFO$sm&*$S9!Ed2{G-s!n->u z9;b1i?N`*9IYLk=YwZ8T8ZaxvS2 zg9Qmvm(q{2fVrNR3g3DpUf(0~Mkb(jRM%QB$^tohd8w^C@VJ`WEsPg|{;a%Tm&y|D zAi?_PHj4h&*o@@!1xeqKrfn21D-$%B{)5tH)>V7&sjf-uvKks9kGq1)VjewzdGcL* z`M=!!6!A;QF{mAVPG;6}A*IB1r1Qb}jZ9JMVp>_?a z4BgYCM>BH^&uXGz#34P2cW&U!#gmy|FN$yy#Z6vg%+y2RqWzoTMz`ec|0%?~nG;f^ zlGoTM(pnv}Xy1C5mLzd)F&>S=V`qZqp|?hCswrnq-X<2P$V6Df94#Q(r|;P+TZE2A z>?0pbPz+*>1|#r1_Mk=^N_g%ImA}fY0xX4=-YEGegv3d`Qbl}Sn@<>0nLc2$XovL@ z4Z}~uwNs&()34ny?ug#_Ud^(x-c2U`z0ikPJunz$O#aH`NA^+Lnd`SzY?(Q5P~Xs` z!_)@6Bw_YQ!MSIFlN(zq`TPt~U2abxVSLz--=ejZw?b0%%NrNo$p?PdhxdV1b^fZH z>=iI`@szh4j~@Bt7ENOiLIkF*R?3@yb(N$ep~OTTvR(oxUQiNC$MCI<)}xC03!U2^ zf#gDntZst{nPxG0*f61LU4kHln^W&mJQ$RiljLMPv*3MVlm{z`jz|a!_N7{~?@Rpd zn&yRR0wemf$DM~#YwK2BCp&v@-?C?=wzy)vR1AkJD0(uAGdMl)^-4*koC9G`B{)mK z&Ea5d5fi%Tz7tLUioOq-5Mn%yfGuYWUhO!zWZ+VWkb6ZuMH1^wWv9O^%Diy8c#g3S zUO)>3AlPSOx|iPDIv9xAgu1s?o0@DC@pJU>MvM-5vzMKpwOn;g<@0mY-WAai0kjVX z2j>+tmbr(|l!^C%FPF2F6FSj!xse}z2lIZQ%qYl2fgX)w(RT3Bzp7G!250B>Ouqu^ zwW6lf%OHB{)UI!a_>;08REAG-Nbb=G;jorm^%Dq`Go~Vaxf6Qf1j?*vNny%QPBsx?L^N9~P-ZfY? zIUQWx8@4Ghe~^er0%IkvW!bu3`etE#2YS32-;R{CkUwB0 zIgt;OgP6Vpq7z9f1w*hmS}hY~ih8RzC}=AV+djuOV&>q6JMH$ZF3brg0G(kD!VuNX zz5!#Avu_^j1KJVt{m5{JW3&0;fWybVY_lN3Sb$3@?gl@HSj0|bUO)_4g87k)tgB#N zA2yGiRB|I=g7*;2=MC4PTqNX`5uv4^aNr(Y>TsU2p7rfKgs?|EzPri#p3_9cwp+@x zG7E_O6t6k=1N*JUz1SLIgCi-Y^CkfUVYoshBjFQruv>JB(0kUbXw{;5%XaJN$B(|S z!}AK71gO6gw-ZU}+Z-i&%9T=a1Lt24qp<|yikc>!_qxU!3e7wC#ZPg|6CToAX*%oN zprbj3Vu)k%IVo_6qJY0Z`bzdenU`~S=~=ByGQ*uu{9>M`I7H9kFea@cSdWU()h~>! zyLH&I%g}--SFVE3`4r`w^r9#)1|EYImBK}m=#Vew-e=Uj>2~UpQl8OuahHqkc)Qda z%UXo(+seknew`@#kJ5{?7fI1)UJIvBCz>BuMo#gPNm>IvVKTxlL!CbsP;qrDuxwZ( z?+zhUyhb1<4tR+dm%odtSMRacJ_Y*hUbFZc$yDiAH>-o)%`)ov(f&(I!0o?n zrYi$!suoTXOJU%=7-iq8<9sZz-A2R5SXJylRN~vS@63R$4)VWkyUNy+eFd?G7H%_* zWna7#6?o9=_3agjW|Z^-qK+%t5(}QJ=~l1~mr1HMigm&#!A-~iibCs}ruebjNke*c zlBT67YuQ-Yyu1dKmY)Z*1x)pfn=L3M$o8-qy z?PC!Z1O~w1$mwv#vC9Xs4H zD3^E-4m{9vic7j;;oLdS9nY`36%4PKc?%HZ$3u+N8$-Gp@47jBI;-tT=n{~@~tC29ZmlrVoG4G zeIVoF_)5fddN2#42Y&G)jBGP6O=m9BK0n`cNGXrG+P~i2)jL>7R|u%*Cdo%WLtfhT zoYryrAE-0RBNNWI)5Q#$S}o2aw(h~b>{}iO@dg2}`3AUuQBI}y!&g(a@8K44_&yFf z6>;t5+QxZKKl^^s*=4Dwl*2)IF|A|2f6)7&R?+eAA}?a_)LZF%h_0pRO`ta?$zN9a z2fj@$XH^%861r>1yhGoTKzd|JanMy~;lwfBvVS(DPpC|M3X3tB6u$d)xWqWg`Sbk( zF7`t65raF}$npeU8VyoBiRAsIB5H{opfh!Zo+40_~i>D=B+%KiiyNkY&`p8ZgB%SC-&^QM-s{JZFm~W}O62K=391%}p8UZX zEF7NCwP;82N-d4}FPvsch%M>;ryPbX)$fMXP&yfO6v@WXBlkvGxb9=3oIc9xjVU!x zKi%VRn8lFL zdggT;>1cOth??R>0JnSmGUMve3qcmodO`xw2JrD$nrw5u$I5K!)n^3q(8bRpco5io zaE!T=+shM-c#^MWX5Kb$dOUh*?Ny^Z8PWbR>BjB!gEoNKO`8Qam}X9gXuOxsBDpA-*FI)ub}9yz)BCVL36FY6Xta zANlyG?{%x>rJ40sZ|g#=AzleMj(;IyY_FJ$Pn{o$kL(PRl94KAa*$XSq$^y{^~H%*pDwY(UgeO|RfybT6`_<@`Yl^v#{hcr&LuNb6!JIVmsP7!J+bi{wFjh2; zbl>w@Kj+KborLL*~k1=Vr+_<$HiI4G@Ao8n8 zgrBq~QdwGmoyCTHdo|BN!knNUb@(SFT&*lXlRGNU;D~}1ZP%m7`|j|x%+GRp=1es^ zLI}NE5hXl=@=!O_+CRckYtq17EGf$+l1e&{tNf1lV93jU>+geLRG=}~;b?9XC`~lep1S{XKN5>L0*?DntGR+N#BREOXJbCoU z*EGMsux10mrX#(=_d0_;Yo2Nv%}{$V?<&n!5ck8@)@bht>ZwbZ&%RRs(6SYE9zjZI zeBCZ8`k5*!&f`PmxG+pXskE-ja@*(+YmeaI?d9{24ZW4<>enKrzJiCCTlT-qQ?Ddt;%vR z-kmqIhq1NK(}uNRiqYDfKa~PbgYVDP|0yUt9eB}zS#*Z=Fow-uZIX6XJzNbPK^bh! zg=BzGm4sIm5BGPQ@#QKVm(2q=ws_h5gstfD=2`ybSQqwmYSDg!3%he8z=V3tmr{Oq z7&`u80B=Q6$(vI&1&*(ooWHM7l;$AX@)-cK4{EPzp`C*{{#WGpvoe~GKrBW}#)NX} zRZIX0*DLfJ)g-)_IaB zSswkI5nC=29#vKUX0^`uZ{-GU8|H@aFf4%gL7gY?CdA#OB%o+35Hx?Jxg3fk=JVwK z$%OsTUt>PU;3l-)+mqYd=HM-5<>bll5Qz{Xwyrm+uWrWT>BMrodwrymtUfm?(9@#h z2J#+%yw=dw*2L;@x`ExJH_?}viiy+Sg#e?FwkR;o|&%%XYa2zhgAG)+ejmqsO4%> z39*~;-3_s(VC!1bsW~j7r7O)eoFb0W_jru(nLBeP+c47E-3qd1ONxXlFeRuLRXyhx zukMZs89L+)T43YzR0AFSxJbrJ`}WJS1dH8Y@o^{N`3fH{hw6R+`5Qld<}IfJ?&Zcg zg1e>vc2AbXD<}6~Z<^q*Gxlz782+=n@YTcyoU7bo@mB1RMOOEhb6lL}{;_{|Y+f@Z zlxwvRpF3UD#rZp8UCL25zQtx`cdN?P558+QBk>b$+6eq6?{F>>=EzZ;iAcIUsf{VEHzLDH+ksrcP>oo&Kc8!F!4 zH7XW%h<=7+`;#2ofPhaw@hRK)6g{ooCN7`X`SExSL{7=p&vP83^Pc6=%y0aCYs`79 z?b1LW;GTOJtyIb#uiP=fQVy32-lFYllo&dHStjMNZw~1Lm>lF$3UC`V1qy}(XXbxZ zop>`SQeX`w;E!9hCz6&nF0D3tl5g{&5ecW`aSm5AeU!~R+r*p34?-iv4K|8QpIn{Y zKRvaXRq?7D)m2=l&j%qsm(AXDN@u(03rn@KOQUtiRFk z7N@m6N@XTWxMN&bluI*|crL=0&!6B_>fjb|l{d}X>vkOaTL?xvwRun%Q0M!Wlviqu z01}FMB2cg-QiL|{SGNWn)uS+aE~5+Qob&O1M`kdV{tMvRjR&eS2L2rWwYz&YUWIN+ zC;i1=^R0+Q!b|R>2S|Haqvld1|47`Lukx!+2$7gT#2NQu+ zTY1t)Y*1t(Nb=w(OWJE71L*u^4gIyIR9c=bL)6BBEQoruQc$1Bh4`wU( z_Sc0E&0}?y{L#0k^Xn)U5DL|!x;flziEelsX=z6KQ(stBJd2VQ*T0m%a(iZG7LZSW zRoQZ<&4f+YQyXGT&Pn1S+u>@wA@nszLHFg5{k^-8KQ2$w5%Uz*6m#fQPAWYnn*->s?8zfPJg7x9V(|=q?T{qe0$9hl|DA!&4L7>E0QHCz(B#w=-2zi(S^P^zm1&$?GQ+oss+5Tp zkPtOf86;oqxABAo6wS^XV|k^_zps=HmNQeM1nu4xf!p&JmnpKd(Kr8|;bq<^=V;}) zS486ZIqznVFK_0YX7xfpx@E_1*i|nrjS#^aZIrEqQh%@3^s=Gp$-G88k=#odz^r@# z0DX0Eg8h#KEL}KoF~-zN&Ss&fChEs-v6fWBN7Etv054Ne5f|E>&cb;#)n#EzX#s?~ zt~TJft?<$PU>A!{-?S^Gg1i{meMj#@`<_!R7#_5*CqyDp5q~NNo|~_D9K7goSG`X| zcHFb^(PP5Z4R@iP33#2-(WEaF3e+R}*TyOhSHtJ0lX@|YsIM)(T?}1LHEiX}xRlQ1 zf6eXKC!t6u_c1KY-KCHxvA~i8P;;-g?(*}ul_;qBPgf>v@Y!+pkD}H2tk`CrCo0k4 zij;G14~7oimIKqjIBZJ_OwEEW2%w$Ar+WpuYw2d2jdjJF`*ceJnNai}2{zpezQ9rH zJO0}VT5u``@$$VkHhHh|J%D9`v!U?Ji1|lMw|~jCze!afySz`WvYw8)No!a1;B+J7 z6U@OJDY+3V`aO@%U6>LzA{Ra9Ch<4A=^z?b6ih`6P)sal^VwwDu{X}5&e?=BJlO6I zbA6f8XG|$=@j;`Eh*CfU6PEPPx{HloypT-bqQ-bNtSsYx4m^CUR@*$M9s=+@+0kpK z!8UYm{#N?Rz<;4y=Fe|4$NZz&UQT0lB#)Lwr2O&5_s{`8NVkxr+fOmZ`Y{24{yG-&N0*fi0=O zc$&ryc`#g(L&7HDz5WMBMEd~1e=%ORrxTuBk1b%*j97Av?2 zVLp$c|LRk313MlKTx=UgUGIiDBXOI|r*UY6PnlMErN#GG>g5|7!lzc`JLLOwF7m=T zKe-;)tuM=uH^H_1zK$j(%`<4GL_yh-$-3SAey4Un+K4o}v*E}JHLp6l`_{0>#g#lJ z4hH2pZ951vt9QePSiIAkPh-J{Xhgw7P>Gc{jXq0AS`&pCjl@+Lr6PR-J>0@2mgHen zn8Ae(n?8>oy&Cx%+{D<|KpC15R7_iZ?bg^RGVx}URUUu6CPf0qDlZ&97dlD|rX1*e zvK^Ues9rse3!Q0(xx#VjU+jpS|6%KCbjOEe_JE|U8lZ3}Tfguc{`fxC_;cQ@@)Y8jG`(#(SwGgcpU=kHllj zxNR6;E!+N_{)$r?mSS10Vteh=g8(^@jRCj`fZ|XF2UVGdS8{qxUa${wFg$ltv}G^- zx)9eO_CqNJ>A|TwE!=OUkhVH7lfED@ei60Fo@vjwb%v?;7VQs*advB>0-b>jP4uy} z&O3j3uH+BR=CDW`{{DU5Ya}Oj*)yO*JW$uPu><`qFJ`-tWk;L_nK zxSq_TpL6?~x-u#S7eA;~RofaiZ~lo^4E7md<43)ehCa_4gptWeN$i@~D5S(R+hQ>S z3W;P2J$lrlT|^rQ@#kz#O=|CQQ54tdSe*9a2 z+x*cg6{1H6CT!!l|1HJ$FG1}0J7hk%IYwj4MQLPOZC%=2!^8}%NskTLk?QxKg7gT` zhA5D9)&qPDv*|fc)v+js0e-@v1iZuO4s=Ur7>Q&3_P@;ebnTqy^xClI>iv#IY7=f; zGDW2+pF6=1L#zX;?L!pSx`px)tl87r!Y!6A?v(tq8FA*{?SmT`qAV2NHwR&QL_k>M zl@WSTMttZEKKtF4;_fMi-bjn~%Qn({vk3mD_R68(Cir*qk#TUJlF_7GU=7P@z5NC@ zbDy`Gkp-idWg_zxnG&*f{@Tsz`h8b6e6NRZE$k7IGFvU>zvvN1?X+BCtl zP)MPMQZ)Jf=rq!l`A!bI9C{K=+OBivab0cXicBjN#IVPU%c@5Iu zr|f6hQxvI-=5-Vnf!q(Mp*3l^#Li|SEbLyrK^@r+FFNH5x-I0=;;rvko|`q*aE*L5 z6s^o#_h^S0K8T9ozFDl;r_9n}O zMpPt^3?7r+s^qWiEhpojxX%c1_o%_)Kaj+PE^b9LD>Wmb+6arZi0XMQH(grL}_ zwv3zmziNi+;OSJ;_JF7bzyoXJXf=oMtvLFb>k+}`xIXoEm@_%8Arn&Jb-1B;#Bk~K zK11;=$00{TuFKOi1N_Ro*ZX3>MZun}ARUCE*{WspCpDU(0&3Qb!>lCaN^k{f{8-cZ z>E9+p8N95$pfi_phrtjr6Y?R$lYt?=)>f;PDqjYi52ig_FHIUqrSR*816Q-1GNB)l z?h)+ZdIf`(f96l07^cC|H1u`}B)v1~thayA*DKfuoC0Wy()8Hu9uK^%#C!SSwv|d#j(9b@Owhgv7zjJR3x&coE4g8h$N>Dbr zYW1seHi0C)AhnS%Ohc`iqN=WQUnhCg`%kd@P?!)%!FkzN1jS=MM6S&gn?+bq1&_ptj;iF{hxK${P(8X195gHQ0t!%6?tP>yP4>eJQ>;!pppVZ$ z;HOQPFX4SaJ2AHY_jWKyzfWrL1sA|&-j7a`h~AUPwG{2IcqjJ+fUCA3Z3C@(>V3-T zd$Mh$sr}0l=AZBZ6@@#aAM9I4zQv!XJZkx=0Xko3s+ojCPlG7fTACG?y|@Q0z#;sH z4<1+V4%`~yYWEw_)FU@SdX+k6?Ia}tpJ~??|1`gZN>6K915y6{YrM6h>dkHmL1ucY z_%4Xh5{}fPoEQcZXMd=irAXl>e9@N#c0Q_XQbDAz*|F#$Ojxy4`)l357@~IJ#LnBF ziVtQ>XdtEv_}O{tuzOe^?Y`>xCTNv6W{QM^MwDR z_}E=aO07cljQln!k*v%ph1rMHWQT_;XDexMOmp8WBr(W}*riOR_zoUkEH5!r5tNIH zdY%T%-}G*-49=xY`3|klyAHCXpLTfCJsRkPcRTC|(nTbj`Y5PoO_Al!bVe9%o;x3@ z<>-}&S9>4DrR<_>opN4M|09U3WJ~Btv+?-(Yv8esmQ?2`Q=PbPbn)<^5m~?KpCIvS z_KKu1g<%RoNSmXdryvgGjiKLHMoF$szW{`g<5rx$`}7-W`LDvt&!CnO8+`a7KT2sb zR%HCWc3T6gZh|j#J{k%77EKM{om~!GN8e_bm7y-I)2p~|OK*KHFE@|9`NG?XwCHF) z`8j+bJR{tHz9@e`7}93-><%vERm*v5`Qc1vlmfSZ?9xC@kqy?7Rnx1omNyAm^|4}nL$~mUlT?Cd3Ti{(sDp} zaHqjvhVgp`k>p7YAP?D&SRX-z@_MAjc||EaKsf~&e+>Syg2@<3a3 z9BZ4jPM_CRu&-`a;AkC$6}Cj-8Yrf43efEoib|$rQZ5KI+P_-xwOk^asE76)IpRZ9 z2(cg#Y-GyE$%A)~qwl4w$IeAoYLJ}Jg%-8Y2%s-&>P{Md)HvivuW63z`is|GPiX2$ z7$+2p9~727MFfBUZtUBZMwvCmEq}2!{ztqO_JE%J>BXG13^GF!3;PZj{9RWO-e&5^ zPHZ0!%c?d!ar{GSd30e*7#$a#uXj>ezF+;Hi7*S(iB$cfEdSTbRpDAvhOU3KP2{B% zXzpt`ya_xYvhXogo_Pc1`Ru62rSm;uF8r#7xl|uPP9*8{1n{hXdkG*cDwc#3a))dM zlyhE|;f>^!bkvJ%rjYCimu>ed#vd$O-Y}+Wm!y5&oI5+LZTT~f{!ZgQHWAw#lYlbP ztWi{;N$9;;+BRjfzB>DnSgeJ%d5~Y&e51PA5BR~DXApS{AiT5FDOaAGyz)_vv2EwK z4p+F)TaS)z_0>~E+s>8Ww>bawqBnwd$=@Bv(L`jk%P270tmIteAp>LSoJQlOdL^@X znNwb$Dbso;l2NX3cH@1oP2bV-eaIUauz^JC#$&d6!8>fN2$`N;Fr$+wDPOg58_VZ*}PN$)c91+a%$(p3En@X|G@tT$l2BnjZSE z0$XBYDztl3H?+1FZ*B*@-kS@54EfYcV|>EqqO`k9hIHN4KfY~k`TXO1B@>Mwq}uOY zeTGUYjL6wftj3avi}XuGz9P2bgl%Uz(}~jlit4t@5$-B)6?oW&^VSmy_4Gb`x}Kig z!GL;J@U{7%X;?WCr2!z|KO<+{hm=*w?v_fp{C z35(5vj#YsJ8w29~j<38HsLIyrGz2ehE+=Y=Wjt6;wY^Wf_)c*p6dYHD9oDUZp&+YHV zDp~ryK5;R6s_pN;s8q!|p8iPnE9GNycAXNbvpgaHBF#yc2qSLD>wA#@4ie<@GxOHy z@XJG-xhbacTJ$)wqTIBSzNKQWEhPmynk^4Z@Dqn5uB z`!m&a=U+D1Ej0ObZoK|p3^8jex}+V=x5|gf=Jl2A|6Q=MaUdjFS)<@VJ30;K=s_p) zt(CL^`w+MGkQ?;Or##PLIW0&@e)Hj27S zGO7Q{gIB&MTOKZtL@D&5J424|2J@ULM4L+ZA;^2KJ|29zs;EPP*Q|mPN8n`NWAoJ zA?w!4(7<(bVcBU>-8~bpg@yZ^iLZ3^cqhBt?7HAJieFVKcYj)Gbn2^q2pg$Wm7a`9 zpn9$@Ub%XJHsA}o#}d48?ESm2KMvv4{epO>nyLP4OZd~a}@uW{+5QZHOW z{$}1ItN%eUZPmZ*j~;2TnEkG+H$2?~UcwFv!q!iFvu4{X#JMY3>Z zHA_j0$>6uyL zz>D8n&a?mgWP{!;m7nXZQ;x975ywgtwJ0c5`FPJr?7TYuhh;E#G~*x8f>N1Mri;!U zz9{f+&9XH-FDny)RC^7$s;QB8XBq}USFYa5(xv`c< zB2pl})#1y88N!qee)nb0ZjJVul$Ph0`p96U$M|;R&UT{!J9F(d z^*B_gJGJG^DAe=nJ`d%|#8_BL_>_ife(24gh?(2H-_sud{fv7Tvc6KJ$pvCqJUkMT zUuuHQVwPvc;_es4y?>Y!6I;K zM4D-3f2KNw6q4ac^rM{qtw2x}C5*DYY{6j`=qndMK)u3#NE zBr3u-38A&Rt`bCVFK8zILnb+PdMVrZI=y74TUV$uLbVg zPFRg3ChJ!TnsVC(c0Wy6K9CkB)d{dRw8VF1UB>!ktwX;&FCqC8m_LZ0h*^oL`}Y#s zD)W;ekLEu20mpPk4mJykut2YlCEuHR+t^&cB&;xnDp$1k+y&je`WYcX-GEgCbc>;FHC&X$SDv%sMh$2FHyuiotbZ|UMjyZPO+X|eU)!)uB6^co>HbN{|D@debb zM@PB9yZ>?CdNW=sU|$&#=-s#VcB+}Nkl(7h5bhkn%2M2(B6c@!Lo1#2qpfN&Qvsg_ zZGQAQ7vu@ zMmwWwOO|}qGS9*eH?tWz-QZ9*XSB{yMdDrr zVgJuLK`<~drjBhkCVeoxZVQ>VCw<<+_QeJlK}@bX4LFcO-e+Q=0wfolw^(&_81vHQ zLudChmiX+Eql?Zq@um-TP@8{fu7?QcsOrp%G;3rStcz|{$!xi3ErMG^%Q5>)$H%kz67!Yj zf3q9dRw9n)uJ*eTolfnS$3JJ1E^W86%|AY{Z6~cbbu)EaVDI*5g@>ZFM^XLnf;rK4 zi6r)wx|c^CL{nKJ3JmL!59n8FuZYPlBGj1bRjCu&@QbG+*WNts~9kla_1YIoN>MuSN!SD-pq- zoO5;Ep=4Oc0*Hkjr}6Xy9`9^Bd;wV%3U=ZF`>Kcosj2mVlA+J~2J*uf?CgVh78aM! z?*uV}c9@WF7P6#8Y;_)6aiIYXhid6ZbKOGLOdM?15(-;Qi1pD456*qW-6v(tKN_rR zg?Cc2Pf`aAx1cy|H$y?2IK}9M5ypCB}SnfyAyT`3!-j=KCwh?R@ zgG+4mrUNBUgM~#Rc|~|E%?4_#14xUL1y+MyT+%rliT$WdKylt}jEn!h=-j8(W=2J6 zH?Y}1EqpbQLM z6b~S`aj$=4Jz)i>(s=5uJIrFFZYN6zr2A_$(=qyJBP-~CVZ`^WuGL$b07k&Mj9Rw#RyaLlMfl08Dk zL1ph`9;6Q<&ar29GO}04UYXhR7{@sGb^6|a!u{jzrygDJbB)*a+OOwzT_0ge;Kr=$ zcK~7fcfGRwZ>3aiE`NgcxV1X$fLQ~#M-c6`6(`y5R5DU&#hjEq$imOPbqZRV)((Dv=3Bh3|Fc2R4gDj4?pDtSK`=+wjHXbQ# zrxdx7IP}h#siXVabEiRka6V<|Q1vy%P<^$8ws1|3AQk)DI$1;zUJ(dwVmAtx?_t+| zH)XH8S3W;`OQLE=P&YH~;KNZbbD}l^QB`Eu-0SEzC)z&s?C*kjF*xiRRyr2u+>Vxr z|9%D9;>Y>*NN6g()`SE!XqQ7u6+2y8m4Vj$!VZGMh(!b7picOA%@O<6Ze{#H1C>z3 zZSM)6iHgWQf8D<3S4e^qSjVotD3b|f)~9J}VS%xhO&t8%P)eC(3<+A8`oL`Lf}6J2 zgv~kHIgzrHU4e+M`0qje_W+xu=2uZ#_~PaB6I~V1CCSHbV zv?4X8j>OG4i-1{zQllqG?qO5#N6p;3g~pmLBWe#L{Fr-VV_)iYEL6nwm>$B7q`Ozr z()_eozD6a#SDSYGnUkI<`FAw`eTs5irH`ZPu7_f0e^YXp-DJz>7QZ<$N5vN8p2GKM zuH#fxS(hO8=i8E$ke~(vGm`3)7-J+iUq7x*dpO0qX02+ZL^U$7UeJekKDJa(wSqSm zswhOtn7WFF|c_B|R-Ro6WUz3B3 zMIIN`9p{%8X&AA3X*1HGidU1TtkqC{3YiAhg)!!66Zhwe>3KA%y@rEkjGeq>i#h1j zu?A7d#+x7?3LqPD=G&E_DwN{E?iKLq8_w1$k*vK(9+hZ@J$MHE8rrg%$}79J&7psz z{YEB-u|DXrdwI7RXReVLuYMf5H4#=LVY%n8BtRu7TRd!=Q@+Tr$*DfXTkC-IlY@>} zfZ{8^gw4VqubCr@T^&1bXppp>uRK@cbW9pX*Ut5H*r6n(HF8^e_8%d%5Za?rQ;mB+ z8zfZ}U+A75_q{V3y{8(tT0M6men7dTyE7CdIZ{{?+ES#$6S~fJYpIs-&?RaxhL{g5 zFzlIKPQ%IX4_oq@usZ+uVpQ}RqvY-re%l9>Gt;&ATB83e?}bU*0^644ao*XzI{}x$ z9gDv}D+L+6M*sai{2|JyAo?ZEf5ByNaa51^1nQ{)oKssbz$lq+QO5X;)cY+yHZkb} z#72jv2T_btC;wzvOYZ`M5m)>EmGTm&&JA0!ClaK8|C> zuY#x3i^}A4xwQm7CYXDrYu+xfSCNFcGQSd!+|(Klt$59nVwZp_!!3GN{DrPu_*f_0eW=3an(uqyS;{1%YaCWC-nI8@O;v{v)jDIpfyB-S6YMcY zU34NuQ?87=`$f9`a$-6sXUZC$!$}t#huFfrPI{&>Bx!7KK|JBNyzPq3JMvanLx$6P#xY4`olG`rV-hC@B$bVIzk8%4oAtIR9=jX=R` zg_|nx_Szzd)Pt8>YKQH=jd%Wev#HRc|4j`Uevu#veIrVz= z*9~yAtDzkwDUuNCr%uqU)NgFrXi8q1}I|ubj z!U*q32X}_{Fi`+EA(-e1Hr&@#YQ)Vt-KN)`NR@fW8Unp_dSIc=SOS095xwrRvmiGj zJ+4dWS-wn_%PQsV*ts}!zJ%s869yEV#rH4#M?=F;rvEN7_|;Ey+c+~s2-1Db&}FBRbPI#VL9hOiK{rs zof=-dmpl{mP>!=OSqp1q1KE!qKL^V_3W<@mjv4g~(cKPZ1wXYrV=Pw@TK= zDCk}-)D$~_hOv3$H|qJd-8-Mw(CZX)xc==PRunxsA$r*MhZbbL7Q-=Uxo>CAzXw1r_xo}vGQ&1 z{JuAWe;-QA-xws>BQqbefUrSPI7~8x+38B8hh&+#d#^m<*qx9ofcoPB7dPbsQOATx zL>bAP^(b!PidQ3o5;&MKN%5a$-TEDVFYGxdhb8yEb{o7+H?Lu-ObzR_ZF~t;cEfH? zZV%;c$*p*Ur0z{;e+6sHPq8+`7{}8nS^kcS_)4$i@G(A_{|xW~JSs*&^zDIE&fW)} zX~aRjr0dwer=7&p$s>oKY)<(xGL*t!zz{ByQSIXzycHK#IoC`kR4qu03L@m0|SJaknC z@b@-@lg+Ode3jjw?UzFm7{{z?tSKgmgI+mvB}BG}4Md*mYqqeIny;NLymnVASI=w~ zt9Q|xv_s?mlAcjldAgkr&)kkOPbjkUlMz)n+4X2esRZS#3;AHV#$WSfqry_q+1)$J z7n2c(*RMcGMqqf3LJhI4-HiZhluAF{$WxLL2OqQW#EX~MP<#~4b?rWs$yg3w`$m?b z$@ z&cqeRRX)j1R&rDJh>yN8vBOc%hUP1bdR!ECWHdvKW+Il)*_DAs zas{=a2f4oe)x3X6Gd^oM3&(24~{7&vUB{@Vy3SuMQAK}6!R422m8Iz6Yuj#EdUr$ji zGTz~4*4@bZ`j{;JjqM8i$c+$Iyg<%AckDult2M<91jvX|b1xn(j;h-xLtwprVl}$xf3uafhHsgpe|2*#N<%<+{7; zwO4iil&Ji)xC0_)o`=PsH>G2d1?Jsv@N7NS)Kq{?$+TP@rYUig!e%@ed?stpyQd=*4~g!0in;AG3h zqHok&4M)`C^WzrU^Y2&8e0ySV3#&S8IPLvxNK^LMt~ruL z3pUl@dq8Sx{u0hM9QZZ$P+f4m$~JXfk8`C~q{t}m+g=4lE`9JW+Nc|U0cw0k3$YuS zi6Ait<5O9Qy>GEirk#xj7ORo(Z}p`+`@57kg<{r+1{!nyiq80JeG#z54G~tlkTWB2 zv`B)f(XDl|efYSXTVO-!^Ta-nMvlmicUh#XZSz#={d*@Xk4d3>#Hkl9K5Szwp;&V_ zFl{S%`&QXkwl(V34u(A;DB-C@Cqq)2mqn|ZjM>C*GRJh5X}gS-_a3>+A+9`iC%+08 z{0vlKO!&x>s8BflZP6M0%K5?%5kw&wFV7QA{i;?^yyYQB7V7ZeRO>I zGm$^57@$FN9wO)$89%Jdq{hD|lxqm~8=Y&?2tx$DRtj;NF%%gLrFyMnspsTg%H`v| zGOOCz?$KeZDKdizG^T7>QEphanaGC- zoScbtgN277*dCHFt~nWRuNgZ@!8C7nW4gc`zPnoIu4^+b8n`9!qV!13YUSBpEzQS+ zwPrfV22OA-vg`8ia^R*8{3pw|8>TwW;>*v`pg3b~7 zanm1m?$S_55>eE+o8Ga-I{CNQF^}nCsF{eo7yeLVZ69%FJX+inr5a3U=)Bic1jSw< ztVby@G)%B#9V1`wxuK`jQg|2D(47CQ+Um!wdTtVhOM#0~Rb7L62sNo-!#5=Y-hkq!Yi69 z*&t_vh7`NZ->jsbiYz@XQLSodX)BE_syyK=5*E*_Z6BAFezQ9ZL!268G1q}}rXRE= z)VNaP!4N(_k1SsJ{HH6ou_mi^IPlDBzN?MOb*N(eT~LrZ*GhccJ7b;haO_As8N>w6 z|3R;Ts$kW?M4I1QJAUR5Q>Rk&V;%(2XM*vRzw2bZtn2;b@2jX=b>UD5Hd)Rva_jse zoNL_EkVrH8scZ3O{{}xB?s!{QLaNtaBn+Z*LqEtnh;FmSUq%g?Q z*bO;%pL&cF2PC$kaN{YXK6`OY{^R)I3{-*`gK%${#+gw8pR5GP|Rs!(|=A1v4s$p z1WMx$-_D=6%&im|3T9P{z)!_-3^264zCUUqk|#QK{Z8(8LJp~Fq#@L|?2O5cPnAK{ zR9%f1g%H;ee25+7GFEF9LWR+F!lJN9Eb%#jG!u={=!?To)Q8S-7xz-eR@C5U9!x#+2R@vzHbK-DAV;yUUVc>mANmh*opE>ldrwv31B|x#MTc zL#yC`+J@u}t9)N|*(Os0zX=Oal578J|(rT1b|onM@YnyCCwh*?V~ z9>}~s1$jPoeY=gLb;aAan@MUpN_L31w2a-KXK`a9ZJU*pL>eo7cH|H4W+G`_yoVwx zJoL9TQ+yc~pe7zdTr%+1z@kO$6}E5Fr2b)i@mlRkxjDvg!luLW1JCC%+)<)FV8;-< zW(6-w@|!*{c2=ml3lN^~gyCO@8!W2kk*$YyC9l5!91q3fOP_0I8X0`G{p^pAagXNG zU8z4S(#M}}{tV~uFkJx`xybu^`hkG16NLj6lEco+G4^ND%w5B|1{PEB4SKSgz3R+! zK~O}!1*j>&nr8LTbs~QJ;Nsh@S^UX%y8lSw zmm8KtOyP7?A(#cf5fXLLXM*IAHbmIEcZOJlgnXvj{r_Hho_6#ZSS%V(Wq`uW)~QgT zy9f>+7-QRK^}TWqvmcSAS5ncF^9By+)SFrvU5p?6~@>R&@deQ3I*{VZUq7J_OzgAk$GZL95{BMFj84N z`>t~WZFK9$8MyqpTLIqsd1~n>gumV6m+ftl4IpSQ{>bcg9{~3|g;mlIO|mzwe(Tse zL^Su12_u7g!l5B~aDU}&PGd)WN~qhVl;UnXF?2*$eT}H81f;IKEw^@=lP0SQJtBZ+ zY;&)Nn`N+NqkmNXRgDg#;~zoJM_lO^WSh7@90oTz-s&k+qD`9F@;S@#+?=rRj2-i5 z3P=t#v+EVQ@8Z!ouyMP?59Mm%o4VR(w_drpq7WjGm;J=SY zY)p7=p(ixm8_>9?*(o`PjmP7TGi0tn)}%CQ;U{!&Qca~jY;-K>2IVxpl#~+5xVqO2 z_zMxspUE-c)cYpDs>Hmo?&+lS@!bhHPMG%zzOSZb=a?q`SD;%%>e0-aw_hcVs1=>i zCba$CIT{Mcnm0g*?Da|1-*f$QMv^xl{}|Qas%Y#MqIC)jDFZ&<#b-eDkW#Pp(%r@p zMQ7i~_##+aT ze>d_xCM?z{uJ~GgrQJ>>xqM!!c--y?{o|Vj2_!?8zw+n_gnBdG z9|q{kDdITm{iY}UcHFYc-3?#IM4BJe43HGHls44#aW}sqGTFyaD7k*ct8!r?zewm~ zDNnGLOi48hA*rsNJ%Bzh-oTm~c2CmJXsh!#)H#j{TVR&#hWMO%DOyh@@XIuH%@wde zry*CO>x=)s30nzfDGH@4Xc*Yk{6KobC=w-J05E70_8-V0)s|2U+II3Nelh0EdP`nk z15MQ!bxtR$>^@_Ybo_xQ{$)b-aH}=G9k>9ST=G>bkB5fkpQT3f&v)g@YN5Tfmb;3! zb{sgUcgOs`X0%O|h;@bO2(k^>x=(*%JJne=qn8=aGe8=1bNQ-3XJOtp3C8c;J>2KS?T;-3Z@mz`HiUE2=p^Y1*}o(;}GT4{9XqRpSfHCuSU zS1(J0%<@+>b2GzDD4I-%9@@T4W*mZDLbA>qKBy~Rk_~58oU77Jw06ik#lm>{Zipe} z`0o7Lez)^8fBo^j0A;i@gU5@?UDm_L z?B+~)Hv#maRYJ=!0pYIDXotQk`hs;qWG{F?-0`$`F{Z(H)?K68&*!(|^k4bRaaE0+ z!`kDcxeG7>QOTl#M%5B(YO3$w=EuBrhU3La4>&3RW{X?w8@4sma%>F#y2-FCW!!cn zXbcZIw*Uh+yLSiXv}G!7eXKa~sh0N^?AY!dIJGY@{Ab{qQ0;&1%Dihb*O<>)?r#O} zPcQr!h+y-rx4ztZCY5JKHI}@3b49rJv=W$Za`CNP+368X#Zp@3&)u}N`!LzlcjKtF z$m3HPQEm>Is`Z7kP90&mIIQMk(|}DIk2a*nwpGYU1>!{JkvjLj$`+v+7=kzF1Q%Tm zhAn))Kf_)q9*$W;rjInZAFhe>O9{cWawu2l1I6ysx?O4WmJ_joXPg!mGR|xsc`X1) zmySR|vZS-Zq0Px%G>K(T5Z~5$_{T(avnSZ*r)Ba^L=Us}RPf!WbBTtk0%<0i6#(Yd z=~BwZ3))ZQs6ppHbw51QOeM$&0&IYgMxWQ)t!%$MbkcIn3S9e1&Q^AAIO@gxM_m4>O}J993#>UI-{E zFE8VP#9CAhEId|gAIlz{yJ;!40T<}S)UBsA&$qKbh4X_P^L3EJ@E<=fN;T}AG_MMw zZu7MC=in%P(NNlREXwE9GdMv1U>j@O9G(>vL&oI!=+Eo8#qD;(7iBq&%9}Twv*;$C z&Rha`DFLD@0Y5=j%S6&|q_f?>{$xw85cjk`D}9;r^QaJt7A#NK0*h^4GR!UX$>&aa zqJr)Vjt}pkE`e3KH|*!!WY8-cU&oIEe8ZTGhkxrY7|JbU`utuic~~fic67^8V)V>L z$^`FF2np1~D3k_0R9cu)oiX)%KG*r(f1)w=)9{T9fP+oKY<0}%wxnInZ@7`!uxfXX zOR_HTAFd8=^>nKjD>0yj{a1@K_jGA@6@OisD1l7?&u@u!FL=aPH^q{G&N2G=HVfv~kuVds<`Rdv!jHj1LEIJfj-o!hr z-Nvkvi#}=ck@xdH>53}{IY=N0GFzuldVl-f?&ZB7hI|36;O~*ZXEPg_@1IPUyUQ1r z>~JKwsm`!1NExdXA5((IbuK{xUjRH{Sl{7hJ$!g|ya%9XPsPfgzxet~<><%hyX#UrfpZV8 z(pjrOex;Qhe#}4_dJ4(7W6`Ya!h#dk+*=|^cEu>ZQxcBner9WFQpzhswx$tlg zWR9*sQBY9mJ_V_Ia6@RfZ8VCO8E=3v=C1VhELsDm?K8iS#EH{Yr?2h91e z(@FNL75kO<sSkDOJLIah}R>){-K6)_=AH2O3abE-fayiwr z(EE+k?)BVLBW!E4L}tUx5BxP@6qTAzX`mqsHQ%77}jg`sJ1lin;p3dz8&4Lx)w&>=J?cK~OwZjN!Y}sRnlPFF>(&H_fS8L9GQiq@AB-2I?XmLfV^x!nFoKzpt zr69?|{5yY-k6b?SSxz~q#XBiLq;}TKi(MueqE5e9U5_>^?nW0#Q&VwAr`C{kEz4D$ zdFsCk@|7IXq?stTW#vbTKHv9pcuW6JbLqz zk02kOF4SPx6jjLBtg}o$o3OO`7&cXBgDW~bX9LfmHU0y87M7H@^KD9ffAfW^E0A31 z0)zzcyNAU#!)?ex8`Sp?JZTob_kvPF?;Wu`^r5^(tG(jB@$K^rNCl&X$sSk}OxeNF zCNur?-eG<#Fg%&bqU^)iop+vz37s}y5Om?aH~{KrB~)^Z>Dc#vDQVrJe^9n~ zICqUy@{E>B+n%od&0AaiM+-YoqW9-bz6ToPG4V!dyX$pHE+> z%a+{fqg_Rfql#62jpZda`GQ?~eZ&RG3cN7-@S&IFc+_aBx7i?{negINyBR%~RVpVD zh=4bmTPY19nMU&^Rq*6o>vsr;6tqpwMmHDy63XRR&U_3kk}Bk0!Ds9gzkxgWf3bjx zJsd(m9X1U^W2;xP*v zWuQWKY&VVnlV4PPW9&ThW4(~8!8a9i@FL|TI>=9<9#4;z=HS}*DdE@7jO`bxRgjnp@-x(CnwOllB8I$CG z%=h0aDa1aseMHCpH(5HnoXQRE(mD@30P>`<>#udXzL+)MMta(9wwjyo?=O9ohw6KhOEL%RQT&l ztS9~|Qwlw{U&yAr^*Vc^Lkpd3uVgWZTleV-A*HJLGSM_GxKU8S_IYQRT;D-Ze1OcC z9)Q0LP?s>qjac6SVCVttX6Pj9!B?x;!)LZ4Y%$@+;MdOK{CGB1eE2UFgLa!#)vosRr}wTJSb*Z#I}qRD zfpb$4Mkg<0)$FDG^GzF%D<~m1*S&fQ>Km6$z$S4^EC82P862)a?|u5fO>U35 z|MD4uZH|b0e?i+KCa*Q)=B;EOyq#Qpxm*Gx?;!BsZ<0QA-C3uQg($p20b^*n3n2YH zCYh_%>kRN2At?mNAhw;3GjyRP;79}Tsrk@Wf|7xmFEp$WI@G{`5AQ>m` z30~jF1$? zP7!!;f_^WX>#HQSiM}Edy=({l3}fM=pieoA!KH%#t)hbVhP{?mO4%5lBP4OE7zQYy z?-nRDGVE0n7u;FLj>eLl!JpY1(fW6O-b5zDPdDO3Z?ef4f25`kdiOjcC(I#Pv-H|4 zN=C$^lc6A`t9$2w-W0ju)m#pc5>OW5fVDIn4{kdG1 zY}A3{NAXc2hJw)_0Co$-39@ZNPavI|SKkG2OeN-~jbvG>Gow9cpdQ{p#I= z=^4)g6kUFUiBwN`rcQ>@^5u&>6nv{6^6x?c)|Zy;AgOC$@WM~nU$HJpiRnM1pN1>D zb(3h{Z9HBrWXN3Qzba-ImiJ?D(JHCWIY@Rhj3O^W(K;Y&IkosRq8a4J z&b@BFdd$y;U6kN~m*Ls_yQ$|z-9jD+Zx2sCksxdok`*}VQnu6Ne*CU(TU!-o{keCJ5oGOCk;=vP!9e>lu076S7aT*)v! zzU8b*KfV z=fQbW_<6*9^p6%tDO_Za82WPDoWM-LUvpR95(=ZAo;b-7onX1ksT4RM4?Qd+6z8Cf zUKGxjjnD53-a#?q1_!h0^zX z!}EV0&3D3Es}Cum{y28f(9{l8cO^4b5cJUytE`~w?ojI#xrz909YxqUdXfvEMU5(qW8<61RAF`Hge1woo)|z+5Bq`0 z8Gjs&Hy`mw$r1=}A_WNl4-gyP>~(m+>f(_~^KXPX!U&g=46zs?{nOb1(-3;BXTbnk zc#94C@)CGe0Er4xoIcqb>)|4Z-P&yQT5;Q7(AN5|jibi|Zbr2s@v6VtqvYubGParS z0)RO>*APQjS)BI$y*HbP`JDK#E}JL-S$P=1P8ci#UMg~~g3{X(WV?x>A`tA~p&Q^K z8CXzb43iKJT3h_AL4;MZD-dRINtsh49+p*W?uV#yq9H8jJvej;fMdXchyNK}Fds4h z{m1@)BM_ET?hN$Z3r_s>wB%Jh_}8O)l{CvA3V2RfrcDYEfZYJKs*o#DE6G!Bp~_$}&~*Z#BS7II zJxx!Xtgk?7Q|LaZKC|Qj&r^w!Ix?0XK zD4rmP(9tEr{?IE>8FOi%CS)u;EDt08S%q!F#lDaU5%5M{DreI8m?PBN-Tv+LHWQ%* zDa&Q`GmWjYzN#^O0@N=6T6kKoblfI{EBo7DNtyj88!3348Qa%1-u={zeTDLs&uor%`ptpwpO`Yk?T9pTL0HT zP$shsfYHQgnMU?Hw>;1P`907eA^YFNKw*doa?9PXJ6PLz%)@c3`-B)Ay&%-b`fZYv zY0G45nC>gg86Xk@ujOFb+)Y-VgZf=_Hyh9c#02WKWp#)P-B*=%W=I5}fcWnGYQz)N zbRnZQ%VGTRzNQ{CzT1|?dvnvdF#s&^upitP@$BCsNjXN>!T3m`9Iyg0WLC`~ zekPd&NDgFk<1OF<@OPa(+5X!9{z2dt@OOd01mN#F`~UwB0v-N8JqT3zf5Z-WwkGv0 U_Z_`pIzpf7$~sDg3eVsFA7{xq@Bjb+ diff --git a/PayfritBeacon/Assets.xcassets/Contents.json b/PayfritBeacon/Assets.xcassets/Contents.json deleted file mode 100644 index 73c0059..0000000 --- a/PayfritBeacon/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/PayfritBeacon/BLEBeaconScanner.swift b/PayfritBeacon/BLEBeaconScanner.swift deleted file mode 100644 index dfea0ff..0000000 --- a/PayfritBeacon/BLEBeaconScanner.swift +++ /dev/null @@ -1,149 +0,0 @@ -import Foundation -import CoreBluetooth - -/// Beacon type detected by service UUID or name -enum BeaconType: String { - case kbeacon = "KBeacon" - case dxsmart = "DX-Smart" - case bluecharm = "BlueCharm" - case unknown = "Unknown" -} - -/// A discovered BLE beacon that can be provisioned -struct DiscoveredBeacon: Identifiable { - let id: UUID // CoreBluetooth peripheral identifier - let peripheral: CBPeripheral - let name: String - let type: BeaconType - var rssi: Int - var lastSeen: Date - - var displayName: String { - if name.isEmpty { - return id.uuidString.prefix(8) + "..." - } - return name - } -} - -/// Scans for BLE beacons that can be configured (KBeacon and BlueCharm) -class BLEBeaconScanner: NSObject, ObservableObject { - - // KBeacon config service - static let KBEACON_SERVICE = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB") - // BlueCharm config service - static let BLUECHARM_SERVICE = CBUUID(string: "0000FFF0-0000-1000-8000-00805F9B34FB") - - @Published var isScanning = false - @Published var discoveredBeacons: [DiscoveredBeacon] = [] - @Published var bluetoothState: CBManagerState = .unknown - - private var centralManager: CBCentralManager! - private var scanTimer: Timer? - - override init() { - super.init() - centralManager = CBCentralManager(delegate: self, queue: .main) - } - - /// Start scanning for configurable beacons - func startScanning() { - guard centralManager.state == .poweredOn else { - NSLog("BLEBeaconScanner: Bluetooth not ready, state=\(centralManager.state.rawValue)") - return - } - - NSLog("BLEBeaconScanner: Starting scan for configurable beacons") - discoveredBeacons.removeAll() - isScanning = true - - // Scan for devices advertising our config services - // Note: We scan for all devices and filter by service after connection - // because some beacons don't advertise their config service UUID - centralManager.scanForPeripherals( - withServices: nil, // Scan all - we'll filter by name/characteristics - options: [CBCentralManagerScanOptionAllowDuplicatesKey: true] - ) - - // Auto-stop after 1 second (beacons advertise every ~200ms so 1s is plenty) - scanTimer?.invalidate() - scanTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { [weak self] _ in - self?.stopScanning() - } - } - - /// Stop scanning - func stopScanning() { - NSLog("BLEBeaconScanner: Stopping scan, found \(discoveredBeacons.count) beacons") - centralManager.stopScan() - isScanning = false - scanTimer?.invalidate() - scanTimer = nil - } - - /// Check if Bluetooth is available - var isBluetoothReady: Bool { - centralManager.state == .poweredOn - } -} - -// MARK: - CBCentralManagerDelegate - -extension BLEBeaconScanner: CBCentralManagerDelegate { - - func centralManagerDidUpdateState(_ central: CBCentralManager) { - bluetoothState = central.state - NSLog("BLEBeaconScanner: Bluetooth state changed to \(central.state.rawValue)") - - if central.state == .poweredOn && isScanning { - // Resume scanning if we were trying to scan - startScanning() - } - } - - func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, - advertisementData: [String: Any], rssi RSSI: NSNumber) { - - let rssiValue = RSSI.intValue - guard rssiValue > -70 && rssiValue < 0 else { return } // Only show nearby devices - - let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "" - let nameUpper = name.uppercased() - - // Best-effort type hint from advertised services OR device name - // Note: Some DX-Smart beacons don't advertise FFE0 in scan response, so also filter by name - var beaconType: BeaconType = .unknown - if let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] { - if serviceUUIDs.contains(BLEBeaconScanner.KBEACON_SERVICE) { - beaconType = .dxsmart - } else if serviceUUIDs.contains(BLEBeaconScanner.BLUECHARM_SERVICE) { - beaconType = .bluecharm - } - } - - // Also detect DX-Smart by name pattern (some beacons don't advertise FFE0) - if beaconType == .unknown && (nameUpper.contains("DX") || nameUpper.contains("SMART")) { - beaconType = .dxsmart - } - - // Update or add beacon - if let index = discoveredBeacons.firstIndex(where: { $0.id == peripheral.identifier }) { - discoveredBeacons[index].rssi = rssiValue - discoveredBeacons[index].lastSeen = Date() - } else { - let beacon = DiscoveredBeacon( - id: peripheral.identifier, - peripheral: peripheral, - name: name, - type: beaconType, - rssi: rssiValue, - lastSeen: Date() - ) - discoveredBeacons.append(beacon) - NSLog("BLEBeaconScanner: Discovered \(beaconType.rawValue) beacon: \(name) RSSI=\(rssiValue)") - } - - // Sort by RSSI (strongest first) - discoveredBeacons.sort { $0.rssi > $1.rssi } - } -} diff --git a/PayfritBeacon/BeaconBanList.swift b/PayfritBeacon/BeaconBanList.swift deleted file mode 100644 index facfec6..0000000 --- a/PayfritBeacon/BeaconBanList.swift +++ /dev/null @@ -1,72 +0,0 @@ -import Foundation - -enum BeaconBanList { - - /// Known default UUID prefixes (first 8 hex chars of the 32-char UUID). - private static let BANNED_PREFIXES: [String: String] = [ - // Apple AirLocate / Minew factory default - "E2C56DB5": "Apple AirLocate / Minew factory default", - // Kontakt.io default - "F7826DA6": "Kontakt.io factory default", - // Radius Networks default - "2F234454": "Radius Networks default", - // Estimote default - "B9407F30": "Estimote factory default", - // Generic Chinese bulk manufacturer defaults (also Feasycom) - "FDA50693": "Generic bulk / Feasycom factory default", - "74278BDA": "Generic bulk manufacturer default", - "8492E75F": "Generic bulk manufacturer default", - "A0B13730": "Generic bulk manufacturer default", - // JAALEE default - "EBEFD083": "JAALEE factory default", - // April Brother default - "B5B182C7": "April Brother factory default", - // BlueCharm / unconfigured - "00000000": "Unconfigured / zeroed UUID", - "FFFFFFFF": "Unconfigured / max UUID", - ] - - /// Full UUIDs that are known defaults (exact match on 32-char uppercase hex). - private static let BANNED_FULL_UUIDS: [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 \u{2014} unconfigured hardware", - ] - - /// Check if a UUID is on the ban list. - static func isBanned(_ uuid: String) -> Bool { - let normalized = uuid.normalizedUUID - - // Check full UUID match - if BANNED_FULL_UUIDS[normalized] != nil { return true } - - // Check prefix match (first 8 chars) - let prefix = String(normalized.prefix(8)) - if BANNED_PREFIXES[prefix] != nil { return true } - - return false - } - - /// 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 = BANNED_FULL_UUIDS[normalized] { return reason } - - // Check prefix - let prefix = String(normalized.prefix(8)) - if let reason = BANNED_PREFIXES[prefix] { return reason } - - return nil - } - - /// Format a raw UUID string into standard UUID format with dashes. - /// Delegates to String.uuidWithDashes (UUIDFormatting.swift). - static func formatUuid(_ uuid: String) -> String { - uuid.uuidWithDashes - } -} diff --git a/PayfritBeacon/BeaconProvisioner.swift b/PayfritBeacon/BeaconProvisioner.swift deleted file mode 100644 index 7215149..0000000 --- a/PayfritBeacon/BeaconProvisioner.swift +++ /dev/null @@ -1,1441 +0,0 @@ -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 -struct BeaconConfig { - let uuid: String // 32-char hex, no dashes - let major: UInt16 - let minor: UInt16 - let measuredPower: Int8 // RSSI@1m (e.g., -59) - from server, NOT hardcoded - let advInterval: UInt8 // Advertising interval raw value (e.g., 2 = 200ms) - from server - let txPower: UInt8 // TX power level - from server - let deviceName: String? // Service point name (max 20 ASCII chars for DX-Smart) - - init(uuid: String, major: UInt16, minor: UInt16, measuredPower: Int8, advInterval: UInt8, txPower: UInt8, deviceName: String? = nil) { - self.uuid = uuid - self.major = major - self.minor = minor - self.measuredPower = measuredPower - self.advInterval = advInterval - self.txPower = txPower - self.deviceName = deviceName - } -} - -/// Result of reading a beacon's current configuration -struct BeaconCheckResult { - // Parsed DX-Smart iBeacon config - var uuid: String? // iBeacon UUID (formatted with dashes) - var major: UInt16? - var minor: UInt16? - var rssiAt1m: Int8? - var advInterval: UInt16? // Raw value (multiply by 100 for ms) - var txPower: UInt8? - var deviceName: String? - var battery: UInt8? - var macAddress: String? - var frameSlots: [UInt8]? - - // Discovery info - var servicesFound: [String] = [] - var characteristicsFound: [String] = [] - var rawResponses: [String] = [] // Raw response hex for debugging - - var hasConfig: Bool { - uuid != nil || major != nil || minor != nil || deviceName != nil - } -} - -// MARK: - BeaconProvisioner - -/// Handles GATT connection and provisioning of DX-Smart CP28 beacons. -/// -/// v3: Clean refactor based on working pre-refactor code + Android reference. -/// -/// Architecture: -/// - Callback-driven state machine (CoreBluetooth delegates) -/// - Response gating: wait for FFE1 notification after each FFE2 write (matches Android) -/// - Disconnect recovery: reconnect + re-auth + resume from saved write index -/// - 16-step write sequence: DeviceName, Frame1 (device info), Frame2 (iBeacon), Save -/// -/// Key learnings preserved from previous iterations: -/// 1. Skip device info read (0x30) — causes disconnects, MAC is optional -/// 2. Skip extra frame disables (3-6) — fewer writes = fewer disconnects -/// 3. Full reconnect on FFE2 miss (CoreBluetooth caches stale GATT) -/// 4. SaveConfig write-error = success (beacon reboots immediately) -/// 5. Response gating between writes prevents MCU overload -/// 6. Adaptive delays: heavy commands (frame select/type) need 1s, others 0.5s -class BeaconProvisioner: NSObject, ObservableObject { - - // MARK: - Constants - - private static let DXSMART_SERVICE = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB") - private static let DXSMART_NOTIFY_CHAR = CBUUID(string: "0000FFE1-0000-1000-8000-00805F9B34FB") // Notifications (RX) - private static let DXSMART_COMMAND_CHAR = CBUUID(string: "0000FFE2-0000-1000-8000-00805F9B34FB") // Commands (TX) - private static let DXSMART_PASSWORD_CHAR = CBUUID(string: "0000FFE3-0000-1000-8000-00805F9B34FB") // Password auth - - private static let DXSMART_HEADER: [UInt8] = [0x4E, 0x4F] - private static let DXSMART_PASSWORDS = ["555555", "dx1234", "000000"] - - private enum DXCmd: UInt8 { - case frameTable = 0x10 - case frameSelectSlot0 = 0x11 // Frame 1 (device info) - case frameSelectSlot1 = 0x12 // Frame 2 (iBeacon) - case frameSelectSlot2 = 0x13 // Frame 3 - case frameSelectSlot3 = 0x14 // Frame 4 - case frameSelectSlot4 = 0x15 // Frame 5 - case frameSelectSlot5 = 0x16 // Frame 6 - case authCheck = 0x25 - case deviceInfo = 0x30 - case deviceName = 0x43 // Read device name - case saveConfig = 0x60 - case deviceInfoType = 0x61 // Set frame as device info (broadcasts name) - case iBeaconType = 0x62 // Set frame as iBeacon - case deviceNameWrite = 0x71 // Write device name (max 20 ASCII chars) - case uuid = 0x74 - case major = 0x75 - case minor = 0x76 - case rssiAt1m = 0x77 - case advInterval = 0x78 - case txPower = 0x79 - case triggerOff = 0xA0 - case frameDisable = 0xFF - } - - // Timing constants (tuned from extensive testing) - private static let BASE_WRITE_DELAY: Double = 0.5 // Default delay between commands - private static let HEAVY_WRITE_DELAY: Double = 1.0 // After frame select/type commands (MCU state change) - private static let LARGE_PAYLOAD_DELAY: Double = 0.8 // After UUID/large payload writes - private static let RESPONSE_GATE_TIMEOUT: Double = 1.0 // 1s matches Android's withTimeoutOrNull(1000L) - private static let WRITE_TIMEOUT_SECONDS: Double = 5.0 // Per-write timeout (matches Android) - private static let GLOBAL_TIMEOUT: Double = 90.0 // Overall provisioning timeout - - // Retry limits - private static let MAX_CONNECTION_RETRIES = 3 - private static let MAX_DISCONNECT_RETRIES = 5 - private static let MAX_FFE2_RECONNECTS = 2 - private static let MAX_WRITE_RETRIES = 1 - - // MARK: - Published State - - @Published var state: ProvisioningState = .idle - @Published var progress: String = "" - - enum ProvisioningState: Equatable { - case idle - case connecting - case discoveringServices - case authenticating - case writing - case verifying - case success - case failed(String) - } - - // MARK: - Core State - - private var centralManager: CBCentralManager! - private var peripheral: CBPeripheral? - private var config: BeaconConfig? - private var completion: ((ProvisioningResult) -> Void)? - private var currentBeacon: DiscoveredBeacon? - - // GATT state - private var configService: CBService? - private var characteristics: [CBUUID: CBCharacteristic] = [:] - - // Auth state - private var passwordIndex = 0 - private var authenticated = false - - // Write queue state - private var commandQueue: [Data] = [] - private var writeIndex = 0 - private var resumeAfterReconnect = false // When true, skip queue rebuild on reconnect - - // Retry counters - private var connectionRetryCount = 0 - private var disconnectRetryCount = 0 - private var ffe2ReconnectCount = 0 - private var writeRetryCount = 0 - - // Timers - private var writeTimeoutTimer: DispatchWorkItem? - private var responseGateTimer: DispatchWorkItem? - private var globalTimeoutTimer: DispatchWorkItem? - - // Response gating — wait for FFE1 notification after each FFE2 write - private var awaitingResponse = false - - // Guard against re-entrant disconnect/success/fail - private var isTerminating = false - - // Read config state - private enum OperationMode { case provisioning, readingConfig } - private var operationMode: OperationMode = .provisioning - private var readCompletion: ((BeaconCheckResult?, String?) -> Void)? - private var readResult = BeaconCheckResult() - private var readTimeout: DispatchWorkItem? - private var allDiscoveredServices: [CBService] = [] - private var servicesToExplore: [CBService] = [] - private var dxReadQueries: [Data] = [] - private var dxReadQueryIndex = 0 - private var responseBuffer: [UInt8] = [] - - // MARK: - Init - - override init() { - super.init() - centralManager = CBCentralManager(delegate: self, queue: .main) - } - - // MARK: - Public: Provision - - /// Provision a beacon with the given configuration - func provision(beacon: DiscoveredBeacon, config: BeaconConfig, completion: @escaping (ProvisioningResult) -> Void) { - guard centralManager.state == .poweredOn else { - completion(.failureWithCode(.bluetoothUnavailable)) - return - } - - resetAllState() - - let resolvedPeripheral = resolvePeripheral(beacon) - self.peripheral = resolvedPeripheral - self.config = config - self.completion = completion - self.operationMode = .provisioning - self.currentBeacon = beacon - - state = .connecting - progress = "Connecting to \(beacon.displayName)..." - DebugLog.shared.log("BLE: Starting provision for \(beacon.displayName)") - - centralManager.connect(resolvedPeripheral, options: nil) - scheduleGlobalTimeout() - } - - /// Cancel current provisioning - func cancel() { - if let peripheral = peripheral { - centralManager.cancelPeripheralConnection(peripheral) - } - cleanup() - } - - // MARK: - Public: Read Config - - /// Read the current configuration from a beacon - func readConfig(beacon: DiscoveredBeacon, completion: @escaping (BeaconCheckResult?, String?) -> Void) { - guard centralManager.state == .poweredOn else { - completion(nil, "Bluetooth not available") - return - } - - resetAllState() - - let resolvedPeripheral = resolvePeripheral(beacon) - self.peripheral = resolvedPeripheral - self.operationMode = .readingConfig - self.readCompletion = completion - self.readResult = BeaconCheckResult() - self.currentBeacon = beacon - - state = .connecting - progress = "Connecting to \(beacon.displayName)..." - - centralManager.connect(resolvedPeripheral, options: nil) - - // 15-second timeout for read operations - let timeout = DispatchWorkItem { [weak self] in - guard let self = self, self.operationMode == .readingConfig else { return } - DebugLog.shared.log("BLE: Read timeout reached") - self.finishRead() - } - readTimeout = timeout - DispatchQueue.main.asyncAfter(deadline: .now() + 15, execute: timeout) - } - - // MARK: - State Reset - - private func resetAllState() { - cancelAllTimers() - characteristics.removeAll() - commandQueue.removeAll() - writeIndex = 0 - passwordIndex = 0 - authenticated = false - resumeAfterReconnect = false - awaitingResponse = false - isTerminating = false - connectionRetryCount = 0 - disconnectRetryCount = 0 - ffe2ReconnectCount = 0 - writeRetryCount = 0 - responseBuffer.removeAll() - configService = nil - } - - private func cleanup() { - cancelAllTimers() - peripheral = nil - config = nil - completion = nil - currentBeacon = nil - configService = nil - characteristics.removeAll() - commandQueue.removeAll() - writeIndex = 0 - passwordIndex = 0 - authenticated = false - resumeAfterReconnect = false - awaitingResponse = false - isTerminating = false - connectionRetryCount = 0 - disconnectRetryCount = 0 - ffe2ReconnectCount = 0 - writeRetryCount = 0 - responseBuffer.removeAll() - state = .idle - progress = "" - } - - // MARK: - Terminal States - - private func fail(_ message: String, code: ProvisioningError? = nil) { - guard !isTerminating else { - DebugLog.shared.log("BLE: fail() called but already terminating, ignoring") - return - } - isTerminating = true - DebugLog.shared.log("BLE: Failed [\(code?.rawValue ?? "UNTYPED")] - \(message)") - state = .failed(message) - disconnectPeripheral() - if let code = code { - completion?(.failureWithCode(code, detail: message)) - } else { - completion?(.failure(message)) - } - cleanup() - } - - private func succeed() { - guard !isTerminating else { - DebugLog.shared.log("BLE: succeed() called but already terminating, ignoring") - return - } - isTerminating = true - DebugLog.shared.log("BLE: Provisioning success!") - state = .success - disconnectPeripheral() - completion?(.success(macAddress: nil)) - cleanup() - } - - private func disconnectPeripheral() { - if let peripheral = peripheral, peripheral.state == .connected { - centralManager.cancelPeripheralConnection(peripheral) - } - } - - // MARK: - Peripheral Resolution - - /// Re-retrieve peripheral from our own CBCentralManager (the one from scanner may not work) - private func resolvePeripheral(_ beacon: DiscoveredBeacon) -> CBPeripheral { - let retrieved = centralManager.retrievePeripherals(withIdentifiers: [beacon.peripheral.identifier]) - return retrieved.first ?? beacon.peripheral - } - - // MARK: - DX-Smart: Authentication - - /// Start auth flow: subscribe to FFE1 notifications, then write password to FFE3 - private func startAuth() { - if let notifyChar = characteristics[BeaconProvisioner.DXSMART_NOTIFY_CHAR] { - DebugLog.shared.log("BLE: Subscribing to FFE1 notifications") - peripheral?.setNotifyValue(true, for: notifyChar) - } else { - DebugLog.shared.log("BLE: FFE1 not found, proceeding to auth directly") - authenticate() - } - } - - /// Write password to FFE3 (tries multiple passwords in sequence) - private func authenticate() { - guard let passwordChar = characteristics[BeaconProvisioner.DXSMART_PASSWORD_CHAR] else { - fail("FFE3 password characteristic not found", code: .serviceNotFound) - return - } - - guard passwordIndex < BeaconProvisioner.DXSMART_PASSWORDS.count else { - if operationMode == .readingConfig { - DebugLog.shared.log("BLE: All passwords exhausted in read mode") - finishRead() - } else { - fail("Authentication failed - all passwords rejected", code: .authFailed) - } - return - } - - 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: Writing password \(passwordIndex + 1)/\(BeaconProvisioner.DXSMART_PASSWORDS.count) to FFE3 (\(passwordData.count) bytes)") - peripheral?.writeValue(passwordData, for: passwordChar, type: .withResponse) - } - - /// Try next password after rejection - private func retryNextPassword() { - 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?.authenticate() - } - } else if operationMode == .readingConfig { - finishRead() - } else { - fail("Authentication failed - all \(BeaconProvisioner.DXSMART_PASSWORDS.count) passwords rejected", code: .authFailed) - } - } - - // MARK: - DX-Smart: Write Config - - /// Build the 16-step command sequence and start writing. - /// - /// Write sequence for DX-Smart CP28: - /// 1. DeviceName 0x71 [name bytes] — service point name (max 20 ASCII chars) - /// 2. Frame1_Select 0x11 — select frame 1 - /// 3. Frame1_Type 0x61 — enable as device info (broadcasts name) - /// 4. Frame1_RSSI 0x77 [measuredPower] — RSSI@1m for frame 1 - /// 5. Frame1_AdvInt 0x78 [advInterval] — adv interval for frame 1 - /// 6. Frame1_TxPow 0x79 [txPower] — tx power for frame 1 - /// 7. Frame2_Select 0x12 — select frame 2 - /// 8. Frame2_Type 0x62 — set as iBeacon - /// 9. UUID 0x74 [16 bytes] - /// 10. Major 0x75 [2 bytes BE] - /// 11. Minor 0x76 [2 bytes BE] - /// 12. RSSI@1m 0x77 [measuredPower] - /// 13. AdvInterval 0x78 [advInterval] - /// 14. TxPower 0x79 [txPower] - /// 15. TriggerOff 0xA0 - /// 16. SaveConfig 0x60 — persist to flash - /// - /// Frames 3-6 intentionally left untouched — fewer writes = fewer disconnects. - private func buildAndStartWriting() { - guard let config = config else { - fail("No config provided", code: .noConfig) - return - } - - state = .writing - progress = "Writing DX-Smart configuration..." - - commandQueue.removeAll() - writeIndex = 0 - - let measuredPowerByte = UInt8(bitPattern: config.measuredPower) - - // 1. DeviceName (0x71) - if let name = config.deviceName, !name.isEmpty { - let truncatedName = String(name.prefix(20)) - let nameBytes = Array(truncatedName.utf8) - commandQueue.append(buildDXPacket(cmd: .deviceNameWrite, data: nameBytes)) - } - - // --- Frame 1: Device Info --- - commandQueue.append(buildDXPacket(cmd: .frameSelectSlot0, data: [])) // 2. Select - commandQueue.append(buildDXPacket(cmd: .deviceInfoType, data: [])) // 3. Type - commandQueue.append(buildDXPacket(cmd: .rssiAt1m, data: [measuredPowerByte])) // 4. RSSI - commandQueue.append(buildDXPacket(cmd: .advInterval, data: [config.advInterval])) // 5. AdvInt - commandQueue.append(buildDXPacket(cmd: .txPower, data: [config.txPower])) // 6. TxPower - - // --- Frame 2: iBeacon --- - commandQueue.append(buildDXPacket(cmd: .frameSelectSlot1, data: [])) // 7. Select - commandQueue.append(buildDXPacket(cmd: .iBeaconType, data: [])) // 8. Type - - if let uuidData = hexStringToData(config.uuid) { // 9. UUID - commandQueue.append(buildDXPacket(cmd: .uuid, data: Array(uuidData))) - } - - let majorHi = UInt8((config.major >> 8) & 0xFF) - let majorLo = UInt8(config.major & 0xFF) - commandQueue.append(buildDXPacket(cmd: .major, data: [majorHi, majorLo])) // 10. Major - - let minorHi = UInt8((config.minor >> 8) & 0xFF) - let minorLo = UInt8(config.minor & 0xFF) - commandQueue.append(buildDXPacket(cmd: .minor, data: [minorHi, minorLo])) // 11. Minor - - commandQueue.append(buildDXPacket(cmd: .rssiAt1m, data: [measuredPowerByte])) // 12. RSSI - commandQueue.append(buildDXPacket(cmd: .advInterval, data: [config.advInterval])) // 13. AdvInt - commandQueue.append(buildDXPacket(cmd: .txPower, data: [config.txPower])) // 14. TxPower - commandQueue.append(buildDXPacket(cmd: .triggerOff, data: [])) // 15. TriggerOff - commandQueue.append(buildDXPacket(cmd: .saveConfig, data: [])) // 16. Save - - DebugLog.shared.log("BLE: Command queue built with \(commandQueue.count) commands") - sendNextCommand() - } - - /// Send the next command in the queue - private func sendNextCommand() { - guard writeIndex < commandQueue.count else { - cancelWriteTimeout() - DebugLog.shared.log("BLE: All commands written!") - progress = "Configuration saved!" - succeed() - return - } - - let packet = commandQueue[writeIndex] - let current = writeIndex + 1 - let total = commandQueue.count - progress = "Writing config (\(current)/\(total))..." - - guard let commandChar = characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] else { - // FFE2 missing — CoreBluetooth returns cached stale GATT after disconnect. - // Need full disconnect → reconnect → re-discover → re-auth → resume. - handleFFE2Missing() - return - } - - DebugLog.shared.log("BLE: Writing command \(current)/\(total): \(packet.map { String(format: "%02X", $0) }.joined(separator: " "))") - writeRetryCount = 0 - scheduleWriteTimeout() - peripheral?.writeValue(packet, for: commandChar, type: .withResponse) - } - - /// Handle FFE2 characteristic missing (stale GATT cache) - private func handleFFE2Missing() { - guard ffe2ReconnectCount < BeaconProvisioner.MAX_FFE2_RECONNECTS else { - fail("FFE2 not found after \(ffe2ReconnectCount) reconnect attempts", code: .serviceNotFound) - return - } - ffe2ReconnectCount += 1 - DebugLog.shared.log("BLE: FFE2 missing — full reconnect (attempt \(ffe2ReconnectCount)/\(BeaconProvisioner.MAX_FFE2_RECONNECTS))") - progress = "FFE2 missing, reconnecting..." - - // Preserve write position, clear connection state - resumeAfterReconnect = true - authenticated = false - passwordIndex = 0 - characteristics.removeAll() - responseBuffer.removeAll() - awaitingResponse = false - cancelResponseGateTimeout() - state = .connecting - - disconnectPeripheral() - - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in - guard let self = self, let beacon = self.currentBeacon else { return } - guard self.state == .connecting else { return } - let resolvedPeripheral = self.resolvePeripheral(beacon) - self.peripheral = resolvedPeripheral - resolvedPeripheral.delegate = self - DebugLog.shared.log("BLE: Reconnecting after FFE2 miss...") - self.centralManager.connect(resolvedPeripheral, options: nil) - } - } - - // MARK: - Response Gating - - /// After a successful write, advance with the appropriate delay. - /// Called when FFE1 response arrives or when 1s gate timeout fires. - private func advanceToNextCommand() { - let justWritten = writeIndex - writeIndex += 1 - let delay = delayForCommand(at: justWritten) - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - self?.sendNextCommand() - } - } - - /// Schedule 1s response gate timeout (matches Android's withTimeoutOrNull(1000L)) - private func scheduleResponseGateTimeout() { - cancelResponseGateTimeout() - let timer = DispatchWorkItem { [weak self] in - guard let self = self else { return } - guard self.awaitingResponse else { return } - self.awaitingResponse = false - DebugLog.shared.log("BLE: No FFE1 response within 1s for command \(self.writeIndex + 1) — advancing (OK)") - self.advanceToNextCommand() - } - responseGateTimer = timer - DispatchQueue.main.asyncAfter(deadline: .now() + BeaconProvisioner.RESPONSE_GATE_TIMEOUT, execute: timer) - } - - private func cancelResponseGateTimeout() { - responseGateTimer?.cancel() - responseGateTimer = nil - } - - // MARK: - Write Timeout - - /// Per-write timeout — if callback doesn't arrive, retry once or handle gracefully - private func scheduleWriteTimeout() { - cancelWriteTimeout() - let timer = DispatchWorkItem { [weak self] in - guard let self = self else { return } - guard self.state == .writing else { return } - - let current = self.writeIndex + 1 - let total = self.commandQueue.count - let isNonFatal = self.writeIndex < 6 // First 6 commands are optional - let isSaveConfig = self.writeIndex >= self.commandQueue.count - 1 - - if isSaveConfig { - // SaveConfig: beacon reboots, no callback expected - DebugLog.shared.log("BLE: SaveConfig write timeout (beacon rebooted) — treating as success") - self.succeed() - } else if self.writeRetryCount < BeaconProvisioner.MAX_WRITE_RETRIES { - // Retry once - self.writeRetryCount += 1 - DebugLog.shared.log("BLE: Write timeout for command \(current)/\(total) — retrying (\(self.writeRetryCount))") - if let commandChar = self.characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] { - let packet = self.commandQueue[self.writeIndex] - self.scheduleWriteTimeout() - self.peripheral?.writeValue(packet, for: commandChar, type: .withResponse) - } - } else if isNonFatal { - // Non-fatal: skip - DebugLog.shared.log("BLE: Write timeout for non-fatal command \(current)/\(total) — skipping") - self.writeIndex += 1 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - self?.sendNextCommand() - } - } else { - // Fatal timeout - DebugLog.shared.log("BLE: Write timeout for critical command \(current)/\(total) — failing") - self.fail("Write timeout at step \(current)/\(total)", code: .writeFailed) - } - } - writeTimeoutTimer = timer - DispatchQueue.main.asyncAfter(deadline: .now() + BeaconProvisioner.WRITE_TIMEOUT_SECONDS, execute: timer) - } - - private func cancelWriteTimeout() { - writeTimeoutTimer?.cancel() - writeTimeoutTimer = nil - } - - // MARK: - Global Timeout - - private func scheduleGlobalTimeout() { - cancelGlobalTimeout() - let timer = DispatchWorkItem { [weak self] in - guard let self = self else { return } - if self.state != .success && self.state != .idle { - self.fail("Provisioning timeout after \(Int(BeaconProvisioner.GLOBAL_TIMEOUT))s", code: .connectionTimeout) - } - } - globalTimeoutTimer = timer - DispatchQueue.main.asyncAfter(deadline: .now() + BeaconProvisioner.GLOBAL_TIMEOUT, execute: timer) - } - - private func cancelGlobalTimeout() { - globalTimeoutTimer?.cancel() - globalTimeoutTimer = nil - } - - private func cancelAllTimers() { - cancelWriteTimeout() - cancelResponseGateTimeout() - cancelGlobalTimeout() - readTimeout?.cancel() - readTimeout = nil - } - - // MARK: - Adaptive Delays - - /// Calculate delay after writing a command. Frame selection (0x11-0x16) and type - /// commands (0x61, 0x62) trigger MCU state changes that need extra processing time. - private func delayForCommand(at index: Int) -> Double { - guard index < commandQueue.count else { return BeaconProvisioner.BASE_WRITE_DELAY } - - let packet = commandQueue[index] - guard packet.count >= 3 else { return BeaconProvisioner.BASE_WRITE_DELAY } - - let cmd = packet[2] // Command byte at offset 2 (after 4E 4F header) - - switch DXCmd(rawValue: cmd) { - case .frameSelectSlot0, .frameSelectSlot1, .frameSelectSlot2, - .frameSelectSlot3, .frameSelectSlot4, .frameSelectSlot5: - return BeaconProvisioner.HEAVY_WRITE_DELAY - case .deviceInfoType, .iBeaconType: - return BeaconProvisioner.HEAVY_WRITE_DELAY - case .uuid: - return BeaconProvisioner.LARGE_PAYLOAD_DELAY - default: - return BeaconProvisioner.BASE_WRITE_DELAY - } - } - - // MARK: - DX-Smart Packet Builder - - /// Build a DX-Smart CP28 packet: [4E][4F][CMD][LEN][DATA...][XOR_CHECKSUM] - private func buildDXPacket(cmd: DXCmd, data: [UInt8]) -> Data { - var packet: [UInt8] = [] - packet.append(contentsOf: BeaconProvisioner.DXSMART_HEADER) - packet.append(cmd.rawValue) - packet.append(UInt8(data.count)) - packet.append(contentsOf: data) - - var checksum: UInt8 = cmd.rawValue ^ UInt8(data.count) - for byte in data { - checksum ^= byte - } - packet.append(checksum) - - return Data(packet) - } - - // MARK: - Read Config: Service Exploration - - private func startReadExplore() { - guard let services = peripheral?.services, !services.isEmpty else { - readFail("No services found on device") - return - } - - allDiscoveredServices = services - servicesToExplore = services - state = .discoveringServices - progress = "Exploring \(services.count) services..." - - DebugLog.shared.log("BLE: Read mode — found \(services.count) services") - for s in services { - readResult.servicesFound.append(s.uuid.uuidString) - } - - exploreNextService() - } - - private func exploreNextService() { - guard !servicesToExplore.isEmpty else { - DebugLog.shared.log("BLE: All services explored, starting DX-Smart read") - startDXSmartRead() - return - } - - let service = servicesToExplore.removeFirst() - DebugLog.shared.log("BLE: Discovering chars for service \(service.uuid)") - progress = "Exploring \(service.uuid.uuidString.prefix(8))..." - peripheral?.discoverCharacteristics(nil, for: service) - } - - // MARK: - Read Config: DX-Smart Protocol - - private func startDXSmartRead() { - guard characteristics[BeaconProvisioner.DXSMART_PASSWORD_CHAR] != nil, - characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] != nil else { - DebugLog.shared.log("BLE: No FFE0 service — not a DX-Smart beacon") - progress = "No DX-Smart service found" - finishRead() - return - } - - // Subscribe to FFE1 for responses - if let notifyChar = characteristics[BeaconProvisioner.DXSMART_NOTIFY_CHAR] { - DebugLog.shared.log("BLE: Read mode — subscribing to FFE1") - progress = "Subscribing to notifications..." - peripheral?.setNotifyValue(true, for: notifyChar) - } else { - DebugLog.shared.log("BLE: FFE1 not found, attempting auth without notifications") - readAuth() - } - } - - private func readAuth() { - guard let passwordChar = characteristics[BeaconProvisioner.DXSMART_PASSWORD_CHAR] else { - DebugLog.shared.log("BLE: No FFE3 for auth, finishing") - finishRead() - return - } - - guard passwordIndex < BeaconProvisioner.DXSMART_PASSWORDS.count else { - DebugLog.shared.log("BLE: All passwords exhausted in read mode") - finishRead() - return - } - - 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) to FFE3") - peripheral?.writeValue(passwordData, for: passwordChar, type: .withResponse) - } - - private func readQueryAfterAuth() { - dxReadQueries.removeAll() - dxReadQueryIndex = 0 - responseBuffer.removeAll() - - dxReadQueries.append(buildDXPacket(cmd: .frameTable, data: [])) // 0x10 - dxReadQueries.append(buildDXPacket(cmd: .iBeaconType, data: [])) // 0x62 - dxReadQueries.append(buildDXPacket(cmd: .deviceInfo, data: [])) // 0x30 - dxReadQueries.append(buildDXPacket(cmd: .deviceName, data: [])) // 0x43 - - DebugLog.shared.log("BLE: Sending \(dxReadQueries.count) read queries") - state = .verifying - progress = "Reading config..." - sendNextReadQuery() - } - - private func sendNextReadQuery() { - guard dxReadQueryIndex < dxReadQueries.count else { - DebugLog.shared.log("BLE: All read queries sent, waiting 2s for final responses") - progress = "Collecting responses..." - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in - guard let self = self, self.operationMode == .readingConfig else { return } - self.finishRead() - } - return - } - - guard let commandChar = characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] else { - DebugLog.shared.log("BLE: FFE2 not found, finishing read") - finishRead() - return - } - - let packet = dxReadQueries[dxReadQueryIndex] - let current = dxReadQueryIndex + 1 - let total = dxReadQueries.count - progress = "Reading \(current)/\(total)..." - DebugLog.shared.log("BLE: Read query \(current)/\(total): \(packet.map { String(format: "%02X", $0) }.joined(separator: " "))") - peripheral?.writeValue(packet, for: commandChar, type: .withResponse) - } - - // MARK: - Read Config: Response Parsing - - private func processFFE1Response(_ data: Data) { - let hex = data.map { String(format: "%02X", $0) }.joined(separator: " ") - DebugLog.shared.log("BLE: FFE1 raw: \(hex)") - - responseBuffer.append(contentsOf: data) - - while responseBuffer.count >= 5 { - guard let headerIdx = findDXHeader() else { - responseBuffer.removeAll() - break - } - - if headerIdx > 0 { - responseBuffer.removeFirst(headerIdx) - } - - guard responseBuffer.count >= 5 else { break } - - let cmd = responseBuffer[2] - let len = Int(responseBuffer[3]) - let frameLen = 4 + len + 1 - - guard responseBuffer.count >= frameLen else { break } - - let frame = Array(responseBuffer[0.. Int? { - guard responseBuffer.count >= 2 else { return nil } - for i in 0..<(responseBuffer.count - 1) { - if responseBuffer[i] == 0x4E && responseBuffer[i + 1] == 0x4F { - return i - } - } - return nil - } - - private func parseResponseCmd(cmd: UInt8, data: [UInt8]) { - let dataHex = data.map { String(format: "%02X", $0) }.joined(separator: " ") - DebugLog.shared.log("BLE: Response cmd=0x\(String(format: "%02X", cmd)) len=\(data.count) data=[\(dataHex)]") - readResult.rawResponses.append("0x\(String(format: "%02X", cmd)): \(dataHex)") - - switch DXCmd(rawValue: cmd) { - - case .frameTable: - readResult.frameSlots = data - DebugLog.shared.log("BLE: Frame slots: \(data.map { String(format: "0x%02X", $0) })") - - case .iBeaconType: - guard data.count >= 2 else { return } - var offset = 1 // Skip type echo byte - - if data.count >= offset + 16 { - let uuidBytes = Array(data[offset..<(offset + 16)]) - let uuidHex = uuidBytes.map { String(format: "%02X", $0) }.joined() - readResult.uuid = formatUUID(uuidHex) - offset += 16 - } - if data.count >= offset + 2 { - readResult.major = UInt16(data[offset]) << 8 | UInt16(data[offset + 1]) - offset += 2 - } - if data.count >= offset + 2 { - readResult.minor = UInt16(data[offset]) << 8 | UInt16(data[offset + 1]) - offset += 2 - } - if data.count >= offset + 1 { - readResult.rssiAt1m = Int8(bitPattern: data[offset]) - offset += 1 - } - if data.count >= offset + 1 { - readResult.advInterval = UInt16(data[offset]) - offset += 1 - } - if data.count >= offset + 1 { - readResult.txPower = data[offset] - offset += 1 - } - DebugLog.shared.log("BLE: Parsed iBeacon — UUID=\(readResult.uuid ?? "?") Major=\(readResult.major ?? 0) Minor=\(readResult.minor ?? 0)") - - case .deviceInfo: - if data.count >= 1 { - readResult.battery = data[0] - } - if data.count >= 7 { - let macBytes = Array(data[1..<7]) - readResult.macAddress = macBytes.map { String(format: "%02X", $0) }.joined(separator: ":") - } - DebugLog.shared.log("BLE: Device info — battery=\(readResult.battery ?? 0)% MAC=\(readResult.macAddress ?? "?")") - - case .deviceName: - readResult.deviceName = String(bytes: data, encoding: .utf8)?.trimmingCharacters(in: .controlCharacters) - DebugLog.shared.log("BLE: Device name = \(readResult.deviceName ?? "?")") - - case .authCheck: - if data.count >= 1 { - let authRequired = data[0] != 0x00 - DebugLog.shared.log("BLE: Auth required: \(authRequired)") - } - - default: - DebugLog.shared.log("BLE: Unhandled response cmd 0x\(String(format: "%02X", cmd))") - } - } - - // MARK: - Read Config: Finish - - private func finishRead() { - readTimeout?.cancel() - readTimeout = nil - - disconnectPeripheral() - - let result = readResult - state = .success - progress = "" - readCompletion?(result, nil) - cleanupRead() - } - - private func readFail(_ message: String) { - DebugLog.shared.log("BLE: Read failed - \(message)") - readTimeout?.cancel() - readTimeout = nil - - disconnectPeripheral() - state = .failed(message) - readCompletion?(nil, message) - cleanupRead() - } - - private func cleanupRead() { - peripheral = nil - readCompletion = nil - readResult = BeaconCheckResult() - readTimeout = nil - dxReadQueries.removeAll() - dxReadQueryIndex = 0 - responseBuffer.removeAll() - allDiscoveredServices.removeAll() - servicesToExplore.removeAll() - configService = nil - characteristics.removeAll() - connectionRetryCount = 0 - disconnectRetryCount = 0 - currentBeacon = nil - operationMode = .provisioning - state = .idle - progress = "" - } - - // MARK: - Helpers - - private func hexStringToData(_ hex: String) -> Data? { - let clean = hex.normalizedUUID - guard clean.count == 32 else { return nil } - - var data = Data() - var index = clean.startIndex - while index < clean.endIndex { - let nextIndex = clean.index(index, offsetBy: 2) - let byteString = String(clean[index.. String { - let clean = hex.uppercased() - guard clean.count == 32 else { return hex } - let c = Array(clean) - return "\(String(c[0..<8]))-\(String(c[8..<12]))-\(String(c[12..<16]))-\(String(c[16..<20]))-\(String(c[20..<32]))" - } -} - -// MARK: - CBCentralManagerDelegate - -extension BeaconProvisioner: CBCentralManagerDelegate { - - func centralManagerDidUpdateState(_ central: CBCentralManager) { - DebugLog.shared.log("BLE: Central state = \(central.state.rawValue)") - } - - func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { - DebugLog.shared.log("BLE: Connected to \(peripheral.name ?? "unknown")") - peripheral.delegate = self - - let maxWriteLen = peripheral.maximumWriteValueLength(for: .withResponse) - DebugLog.shared.log("BLE: Max write length: \(maxWriteLen) bytes") - if maxWriteLen < 21 { - DebugLog.shared.log("BLE: WARNING — max write length \(maxWriteLen) may be too small for UUID packet (21 bytes)") - } - - state = .discoveringServices - progress = "Discovering services..." - - if operationMode == .readingConfig { - peripheral.discoverServices(nil) - } else { - peripheral.discoverServices([BeaconProvisioner.DXSMART_SERVICE]) - } - } - - func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { - let errorMsg = error?.localizedDescription ?? "unknown error" - DebugLog.shared.log("BLE: Connection failed (attempt \(connectionRetryCount + 1)): \(errorMsg)") - - if connectionRetryCount < BeaconProvisioner.MAX_CONNECTION_RETRIES { - connectionRetryCount += 1 - let delay = Double(connectionRetryCount) - progress = "Connection failed, retrying (\(connectionRetryCount)/\(BeaconProvisioner.MAX_CONNECTION_RETRIES))..." - DebugLog.shared.log("BLE: Retrying in \(delay)s...") - - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self = self, let beacon = self.currentBeacon else { return } - guard self.state == .connecting else { return } - let resolvedPeripheral = self.resolvePeripheral(beacon) - self.peripheral = resolvedPeripheral - self.centralManager.connect(resolvedPeripheral, options: nil) - } - } else { - let msg = "Failed to connect after \(BeaconProvisioner.MAX_CONNECTION_RETRIES) attempts: \(errorMsg)" - if operationMode == .readingConfig { - readFail(msg) - } else { - fail(msg, code: .connectionFailed) - } - } - } - - func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { - DebugLog.shared.log("BLE: Disconnected | state=\(state) mode=\(operationMode) writeIdx=\(writeIndex)/\(commandQueue.count) terminating=\(isTerminating) error=\(error?.localizedDescription ?? "none")") - - // Already terminating (succeed/fail called) — expected cleanup disconnect - if isTerminating { - DebugLog.shared.log("BLE: Disconnect during termination, ignoring") - return - } - - // Read mode - if operationMode == .readingConfig { - if state != .success && state != .idle { - finishRead() - } - return - } - - // Terminal states - if state == .success || state == .idle { - return - } - if case .failed = state { - DebugLog.shared.log("BLE: Disconnect after failure, ignoring") - return - } - - // Intentional disconnect for FFE2 reconnect — already handling - if state == .connecting && resumeAfterReconnect { - DebugLog.shared.log("BLE: Intentional disconnect for FFE2 reconnect, ignoring") - return - } - - // SaveConfig was the last command — beacon rebooted to apply - if state == .writing && commandQueue.count > 0 && writeIndex >= commandQueue.count - 1 { - DebugLog.shared.log("BLE: Disconnect after SaveConfig (idx=\(writeIndex)/\(commandQueue.count)) — treating as success") - succeed() - return - } - - // Cancel pending timers - cancelWriteTimeout() - cancelResponseGateTimeout() - awaitingResponse = false - - // Unexpected disconnect during active phase — retry with reconnect - let isActivePhase = (state == .discoveringServices || state == .authenticating || state == .writing || state == .verifying) - if isActivePhase && disconnectRetryCount < BeaconProvisioner.MAX_DISCONNECT_RETRIES { - disconnectRetryCount += 1 - let wasWriting = (state == .writing && !commandQueue.isEmpty) - DebugLog.shared.log("BLE: Disconnect during \(state) — reconnecting (\(disconnectRetryCount)/\(BeaconProvisioner.MAX_DISCONNECT_RETRIES)) wasWriting=\(wasWriting) writeIdx=\(writeIndex)") - progress = "Beacon disconnected, reconnecting (\(disconnectRetryCount)/\(BeaconProvisioner.MAX_DISCONNECT_RETRIES))..." - - // Reset connection state, preserve write position if writing - authenticated = false - passwordIndex = 0 - characteristics.removeAll() - responseBuffer.removeAll() - - if wasWriting { - resumeAfterReconnect = true - DebugLog.shared.log("BLE: Will resume from command \(writeIndex + 1)/\(commandQueue.count) after reconnect") - } else { - commandQueue.removeAll() - writeIndex = 0 - resumeAfterReconnect = false - } - state = .connecting - - let delay = Double(disconnectRetryCount) + 2.0 // 3s, 4s, 5s... backoff - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self = self, let beacon = self.currentBeacon else { return } - guard self.state == .connecting else { return } - let resolvedPeripheral = self.resolvePeripheral(beacon) - self.peripheral = resolvedPeripheral - resolvedPeripheral.delegate = self - self.centralManager.connect(resolvedPeripheral, options: nil) - } - return - } - - // All retries exhausted - DebugLog.shared.log("BLE: UNEXPECTED disconnect — all retries exhausted") - fail("Beacon disconnected \(disconnectRetryCount + 1) times during \(state). Move closer to the beacon and try again.", code: .disconnected) - } -} - -// MARK: - CBPeripheralDelegate - -extension BeaconProvisioner: CBPeripheralDelegate { - - func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { - if let error = error { - if operationMode == .readingConfig { - readFail("Service discovery failed: \(error.localizedDescription)") - } else { - fail("Service discovery failed: \(error.localizedDescription)", code: .serviceNotFound) - } - return - } - - guard let services = peripheral.services else { - if operationMode == .readingConfig { - readFail("No services found") - } else { - fail("No services found", code: .serviceNotFound) - } - return - } - - DebugLog.shared.log("BLE: Discovered \(services.count) services") - for service in services { - NSLog(" Service: \(service.uuid)") - } - - if operationMode == .readingConfig { - startReadExplore() - return - } - - // Provisioning: look for DX-Smart service - for service in services { - if service.uuid == BeaconProvisioner.DXSMART_SERVICE { - configService = service - state = .discoveringServices - progress = "Discovering characteristics..." - peripheral.discoverCharacteristics([ - BeaconProvisioner.DXSMART_NOTIFY_CHAR, - BeaconProvisioner.DXSMART_COMMAND_CHAR, - BeaconProvisioner.DXSMART_PASSWORD_CHAR - ], for: service) - return - } - } - fail("Config service not found on device", code: .serviceNotFound) - } - - func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { - if let error = error { - if operationMode == .readingConfig { - DebugLog.shared.log("BLE: Char discovery failed for \(service.uuid): \(error.localizedDescription)") - exploreNextService() - } else { - fail("Characteristic discovery failed: \(error.localizedDescription)", code: .serviceNotFound) - } - return - } - - guard let chars = service.characteristics else { - if operationMode == .readingConfig { - exploreNextService() - } else { - fail("No characteristics found", code: .serviceNotFound) - } - return - } - - DebugLog.shared.log("BLE: Discovered \(chars.count) characteristics for \(service.uuid)") - for char in chars { - let props = char.properties - let propStr = [ - props.contains(.read) ? "R" : "", - props.contains(.write) ? "W" : "", - props.contains(.writeWithoutResponse) ? "Wn" : "", - props.contains(.notify) ? "N" : "", - props.contains(.indicate) ? "I" : "" - ].filter { !$0.isEmpty }.joined(separator: ",") - NSLog(" Char: \(char.uuid) [\(propStr)]") - characteristics[char.uuid] = char - - if operationMode == .readingConfig { - readResult.characteristicsFound.append("\(char.uuid.uuidString)[\(propStr)]") - } - } - - if operationMode == .readingConfig { - exploreNextService() - } else { - // Provisioning: start auth flow - startAuth() - } - } - - func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { - cancelWriteTimeout() - - if let error = error { - DebugLog.shared.log("BLE: Write failed for \(characteristic.uuid): \(error.localizedDescription)") - - // Password rejected - if characteristic.uuid == BeaconProvisioner.DXSMART_PASSWORD_CHAR { - if passwordIndex + 1 < BeaconProvisioner.DXSMART_PASSWORDS.count { - retryNextPassword() - } else if operationMode == .readingConfig { - readFail("Authentication failed - all passwords rejected") - } else { - fail("Authentication failed - all passwords rejected", code: .authFailed) - } - return - } - - // Command write failed - if characteristic.uuid == BeaconProvisioner.DXSMART_COMMAND_CHAR { - if operationMode == .readingConfig { - DebugLog.shared.log("BLE: Read query failed, skipping") - dxReadQueryIndex += 1 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in - self?.sendNextReadQuery() - } - } else { - let isNonFatal = writeIndex < 6 - let isSaveConfig = writeIndex >= commandQueue.count - 1 - - if isSaveConfig { - // SaveConfig write error = beacon rebooted = success - DebugLog.shared.log("BLE: SaveConfig write error (beacon rebooted) — treating as success") - succeed() - } else if isNonFatal { - DebugLog.shared.log("BLE: Non-fatal command failed at step \(writeIndex + 1), continuing...") - writeIndex += 1 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - self?.sendNextCommand() - } - } else { - fail("Command write failed at step \(writeIndex + 1)/\(commandQueue.count): \(error.localizedDescription)", code: .writeFailed) - } - } - return - } - - if operationMode == .readingConfig { - DebugLog.shared.log("BLE: Write failed in read mode, ignoring") - return - } - fail("Write failed: \(error.localizedDescription)", code: .writeFailed) - return - } - - DebugLog.shared.log("BLE: Write succeeded for \(characteristic.uuid)") - - // Password auth succeeded - if characteristic.uuid == BeaconProvisioner.DXSMART_PASSWORD_CHAR { - DebugLog.shared.log("BLE: Authenticated!") - authenticated = true - if operationMode == .readingConfig { - readQueryAfterAuth() - } else if resumeAfterReconnect { - // Resume writing from saved position - resumeAfterReconnect = false - state = .writing - DebugLog.shared.log("BLE: Resuming write from command \(writeIndex + 1)/\(commandQueue.count)") - progress = "Resuming config write..." - // 1.5s delay after reconnect for BLE stability - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in - self?.sendNextCommand() - } - } else { - // Fresh provisioning: build queue and start writing - DebugLog.shared.log("BLE: Auth complete, proceeding to config write") - buildAndStartWriting() - } - return - } - - // Command write succeeded - if characteristic.uuid == BeaconProvisioner.DXSMART_COMMAND_CHAR { - if operationMode == .readingConfig { - dxReadQueryIndex += 1 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in - self?.sendNextReadQuery() - } - } else { - // Gate on FFE1 response before next command - awaitingResponse = true - scheduleResponseGateTimeout() - } - return - } - } - - func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { - if let error = error { - DebugLog.shared.log("BLE: Notification state failed for \(characteristic.uuid): \(error.localizedDescription)") - } else { - DebugLog.shared.log("BLE: Notifications \(characteristic.isNotifying ? "enabled" : "disabled") for \(characteristic.uuid)") - } - - if characteristic.uuid == BeaconProvisioner.DXSMART_NOTIFY_CHAR { - if operationMode == .readingConfig { - readAuth() - } else { - authenticate() - } - } - } - - func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { - if let error = error { - DebugLog.shared.log("BLE: Read error for \(characteristic.uuid): \(error.localizedDescription)") - return - } - - let data = characteristic.value ?? Data() - - if operationMode == .readingConfig { - if characteristic.uuid == BeaconProvisioner.DXSMART_NOTIFY_CHAR { - processFFE1Response(data) - } else { - let hex = data.map { String(format: "%02X", $0) }.joined(separator: " ") - DebugLog.shared.log("BLE: Read \(characteristic.uuid): \(hex)") - } - } else { - // Provisioning: FFE1 notification = beacon response to our command - if characteristic.uuid == BeaconProvisioner.DXSMART_NOTIFY_CHAR { - let hex = data.map { String(format: "%02X", $0) }.joined(separator: " ") - DebugLog.shared.log("BLE: FFE1 notification: \(hex)") - - if awaitingResponse { - awaitingResponse = false - cancelResponseGateTimeout() - - // Check for rejection: 4E 4F 00 = command rejected - let bytes = [UInt8](data) - if bytes.count >= 3 && bytes[0] == 0x4E && bytes[1] == 0x4F && bytes[2] == 0x00 { - let isNonFatal = writeIndex < 6 - if isNonFatal { - DebugLog.shared.log("BLE: Command \(writeIndex + 1) rejected by beacon (non-fatal, continuing)") - } else { - DebugLog.shared.log("BLE: Command \(writeIndex + 1) REJECTED by beacon") - } - } - - advanceToNextCommand() - } - } - } - } -} diff --git a/PayfritBeacon/BeaconScanner.swift b/PayfritBeacon/BeaconScanner.swift deleted file mode 100644 index 83ac47c..0000000 --- a/PayfritBeacon/BeaconScanner.swift +++ /dev/null @@ -1,153 +0,0 @@ -import Foundation -import CoreLocation - -/// Native beacon scanner using CoreLocation for iBeacon detection. -/// Based on the proven BeaconManager from payfrit-user-ios. -class BeaconScanner: NSObject, ObservableObject, CLLocationManagerDelegate { - - private static let TAG = "BeaconScanner" - private static let MIN_RSSI: Int = -90 - - @Published var isScanning = false - @Published var authorizationStatus: CLAuthorizationStatus = .notDetermined - - private var locationManager: CLLocationManager! - private var activeRegions: [CLBeaconRegion] = [] - // Key: "UUID|Major|Minor", Value: beacon sample data - private var beaconSamples: [String: BeaconSampleData] = [:] - - private struct BeaconSampleData { - let uuid: String - let major: UInt16 - let minor: UInt16 - var rssiSamples: [Int] - } - - override init() { - super.init() - if Thread.isMainThread { - setupLocationManager() - } else { - DispatchQueue.main.sync { - setupLocationManager() - } - } - } - - private func setupLocationManager() { - locationManager = CLLocationManager() - locationManager.delegate = self - authorizationStatus = locationManager.authorizationStatus - } - - func requestPermission() { - locationManager.requestWhenInUseAuthorization() - } - - func hasPermissions() -> Bool { - let status = locationManager.authorizationStatus - return status == .authorizedWhenInUse || status == .authorizedAlways - } - - /// Start ranging for the given UUIDs. Call stopAndCollect() after your desired duration. - func startRanging(uuids: [UUID]) { - NSLog("\(BeaconScanner.TAG): startRanging called with \(uuids.count) UUIDs") - - stopRanging() - beaconSamples.removeAll() - - guard !uuids.isEmpty else { - NSLog("\(BeaconScanner.TAG): No target UUIDs provided") - return - } - - isScanning = true - - for uuid in uuids { - let constraint = CLBeaconIdentityConstraint(uuid: uuid) - let region = CLBeaconRegion(beaconIdentityConstraint: constraint, identifier: uuid.uuidString) - activeRegions.append(region) - - NSLog("\(BeaconScanner.TAG): Starting ranging for UUID: \(uuid.uuidString)") - locationManager.startRangingBeacons(satisfying: constraint) - } - - NSLog("\(BeaconScanner.TAG): Ranging started for \(activeRegions.count) regions") - } - - /// Stop ranging and return collected results sorted by signal strength. - func stopAndCollect() -> [DetectedBeacon] { - NSLog("\(BeaconScanner.TAG): stopAndCollect - beaconSamples has \(beaconSamples.count) entries") - - stopRanging() - - var results: [DetectedBeacon] = [] - for (_, data) in beaconSamples { - let avgRssi = data.rssiSamples.reduce(0, +) / max(data.rssiSamples.count, 1) - NSLog("\(BeaconScanner.TAG): Beacon \(data.uuid) major=\(data.major) minor=\(data.minor) - avgRssi=\(avgRssi), samples=\(data.rssiSamples.count)") - results.append(DetectedBeacon( - uuid: data.uuid, - major: data.major, - minor: data.minor, - rssi: avgRssi, - samples: data.rssiSamples.count - )) - } - - results.sort { $0.rssi > $1.rssi } - return results - } - - private func stopRanging() { - for region in activeRegions { - let constraint = CLBeaconIdentityConstraint(uuid: region.uuid) - locationManager.stopRangingBeacons(satisfying: constraint) - } - activeRegions.removeAll() - isScanning = false - } - - // MARK: - CLLocationManagerDelegate - - func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { - let status = manager.authorizationStatus - NSLog("\(BeaconScanner.TAG): Authorization changed to \(status.rawValue)") - authorizationStatus = status - } - - func locationManager(_ manager: CLLocationManager, didRange beacons: [CLBeacon], - satisfying constraint: CLBeaconIdentityConstraint) { - for beacon in beacons { - let rssiValue = beacon.rssi - guard rssiValue >= BeaconScanner.MIN_RSSI && rssiValue < 0 else { continue } - - let uuid = beacon.uuid.uuidString.normalizedUUID - let major = beacon.major.uint16Value - let minor = beacon.minor.uint16Value - let key = "\(uuid)|\(major)|\(minor)" - - if beaconSamples[key] == nil { - beaconSamples[key] = BeaconSampleData(uuid: uuid, major: major, minor: minor, rssiSamples: []) - } - beaconSamples[key]?.rssiSamples.append(rssiValue) - } - } - - func locationManager(_ manager: CLLocationManager, didFailRangingFor constraint: CLBeaconIdentityConstraint, error: Error) { - NSLog("\(BeaconScanner.TAG): Ranging FAILED for \(constraint.uuid): \(error.localizedDescription)") - } -} - -struct DetectedBeacon { - let uuid: String // 32-char uppercase hex, no dashes - let major: UInt16 - let minor: UInt16 - let rssi: Int - let samples: Int - - /// Format for clipboard (for pasting into manufacturer beacon config apps) - func copyableConfig() -> String { - // Format UUID with dashes for standard display - return "UUID: \(uuid.uuidWithDashes)\nMajor: \(major)\nMinor: \(minor)" - } -} diff --git a/PayfritBeacon/BeaconShardPool.swift b/PayfritBeacon/BeaconShardPool.swift deleted file mode 100644 index dc0cf0a..0000000 --- a/PayfritBeacon/BeaconShardPool.swift +++ /dev/null @@ -1,107 +0,0 @@ -import Foundation -import CoreLocation - -/// Static list of Payfrit shard UUIDs for iBeacon scanning. -/// -/// Apps scan for ALL these UUIDs. The backend handles resolution. -/// If we need more capacity, ship more UUIDs in a future app version. -enum BeaconShardPool { - - /// All Payfrit shard UUIDs as strings (from BeaconShards database table). - static let uuidStrings: [String] = [ - "34b8cd87-1905-47a9-a7b7-fad8a4c011a1", - "5ee62089-8599-46f7-a399-d40c2f398712", - "fd3790ac-33eb-4091-b1c7-1e0615e68a87", - "bfce1bc4-ad2a-462a-918b-df26752c378d", - "845b64c7-0c91-41cd-9c30-ac56b6ae5ca1", - "7de0b2fb-69a3-4dbb-9808-8f33e2566661", - "54c34c7e-5a9b-4738-a4b4-2e228337ae3c", - "70f9fc09-25e6-46ec-8395-72f487877a1a", - "f8cef0af-6bef-4ba6-8a5d-599d647b628c", - "41868a10-7fd6-41c6-9b14-b5ca33c11471", - "25e1044d-446b-4403-9abd-1e15f806dfe9", - "cdeefaf0-bf95-4dab-8bd5-f7b261f9935d", - "bf3b156a-a0fb-4bad-b3fd-0b408ffc9d6e", - "11b7c63e-a61d-4530-a447-2cb8e6a30a45", - "d0519f2d-97a1-4484-a2c2-57135affe427", - "d2d1caa9-aa89-4c9d-93b1-d6fe1527f622", - "6f65071f-e060-44e7-a6f9-5dae49cbf095", - "4492bbbb-8584-421a-8f26-cb20c7727ead", - "73bc2b23-9cf8-4a93-8bfc-5cf44f03a973", - "70129c14-78ed-447e-ab9e-638243f8bdae", - "6956f91b-e581-48a5-b364-181662cb2f9f", - "39fc9b45-d1b3-4d97-aa82-a52457bf808f", - "ef150f40-5e24-4267-a1d0-b5ea5ce66c99", - "ac504bbd-fbdb-46d0-83c0-cdf53e82cdf8", - "bbecefd2-7317-4fd4-93d9-2661df8c4762", - "b252557a-e998-4b28-9d7d-bc9a8c907441", - "527b504c-d363-438a-bb65-2db1db6cb487", - "ea5eef55-b7e9-4866-a4a1-8b4a1f6ea79d", - "40a5d0c8-727a-47db-8ffd-154bfc36e03d", - "4d90467e-5f68-41ef-b4ec-2b2c8ec1adce", - "1cc513ee-627a-4cfe-b162-7cea3cb1374e", - "2913ab6e-ab0d-4666-bff1-7fe3169c4f55", - "7371381a-f2aa-4a40-b497-b06e66d51a31", - "e890450f-0b8d-4a5a-973e-5af37233c13b", - "d190eef0-59ee-44bc-a459-e0d5b767b26f", - "76ebe90f-f4b2-45d4-9887-841b1ddd3ca9", - "7fbed5b0-a212-4497-9b54-9831e433491b", - "3d41e7b0-5d91-4178-81c1-de42ab6b3466", - "5befd90a-7967-4fe5-89ba-7a9de617d507", - "e033235c-f69d-4018-a197-78e7df59dfa3", - "71edc8b9-b120-415a-a1d4-77bdd8e30f14", - "521de568-a0e6-4ec9-bf1c-bdb7b9f9cae2", - "a28db91b-c18f-4b4b-804f-38664d3456cc", - "5738e431-25bc-4cc1-a4e2-da8c562075b3", - "f90b7c87-324b-4fd5-b2ff-acf6800f6bd0", - "bd4ea89c-f99d-4440-8e27-d295836fd09d", - "b5c2d016-1143-4bb2-992b-f08fb073ef2c", - "0bb16d1a-f970-4baf-b410-76e5f1ff7c9e", - "b4f22e62-4052-4c58-a18b-38e2a5c04b9a", - "b8150be6-0fbd-4bb9-9993-a8a2992d5003", - "50d2d4c6-1907-4789-afe2-3b28baa3c679", - "ee42778d-53c9-42c9-8dfa-87a509799990", - "6001ee07-fc35-45f7-8ef6-afc30371bd73", - "0761bede-deb6-4b08-bfbb-10675060164a", - "c03ac1de-a7ea-490a-b3a9-7cc5e8ab4dd1", - "57ecd21d-76b1-4016-8c86-7c5a861aae67", - "f119066c-a4e2-4b2e-aef3-6a0bf6b288bc", - "e2c2ccff-d651-488f-9d74-4ecf4a0487e0", - "7d5ba66c-d8f8-4d54-9900-4c52b5667682", - "1b0f57f9-0c02-43a5-9740-63acbc9574a0", - "314fdc08-fbfd-4bd8-aaae-e579d9ef567d", - "5835398b-95ac-44ba-af78-a5d3dc4fc0ad", - "3eb1baca-84bb-4d85-8860-42a9df3b820e", - "da73ba99-976c-4e81-894a-d799e05f9186" - ] - - /// All Payfrit shard UUIDs as UUID objects. - static let uuids: [UUID] = uuidStrings.compactMap { UUID(uuidString: $0) } - - /// Check if a UUID is a Payfrit shard. - static func isPayfrit(_ uuid: UUID) -> Bool { - return uuids.contains(uuid) - } - - /// Check if a UUID string is a Payfrit shard. - static func isPayfrit(_ uuidString: String) -> Bool { - guard let uuid = UUID(uuidString: uuidString) else { return false } - return isPayfrit(uuid) - } - - /// Create CLBeaconRegions for all Payfrit shards (for ranging). - static func createRegions() -> [CLBeaconRegion] { - return uuids.enumerated().map { index, uuid in - CLBeaconRegion(uuid: uuid, identifier: "payfrit-shard-\(index)") - } - } - - /// Create a CLBeaconRegion for a specific business (for background monitoring). - static func createBusinessRegion(uuid: UUID, major: UInt16) -> CLBeaconRegion { - return CLBeaconRegion( - uuid: uuid, - major: CLBeaconMajorValue(major), - identifier: "payfrit-business-\(major)" - ) - } -} diff --git a/PayfritBeacon/BusinessListView.swift b/PayfritBeacon/BusinessListView.swift deleted file mode 100644 index b88cd4b..0000000 --- a/PayfritBeacon/BusinessListView.swift +++ /dev/null @@ -1,144 +0,0 @@ -import SwiftUI -import Kingfisher - -struct BusinessListView: View { - @Binding var hasAutoSelected: Bool - var onBusinessSelected: (Business) -> Void - var onLogout: () -> Void - - @State private var businesses: [Business] = [] - @State private var isLoading = false - @State private var errorMessage: String? - - var body: some View { - NavigationStack { - VStack { - if isLoading { - Spacer() - ProgressView("Loading businesses...") - Spacer() - } else if let error = errorMessage { - Spacer() - Text(error) - .foregroundColor(.errorRed) - .multilineTextAlignment(.center) - .padding() - addBusinessButton - Spacer() - } else if businesses.isEmpty { - Spacer() - Text("No businesses found") - .foregroundColor(.secondary) - addBusinessButton - Spacer() - } else { - List(businesses) { business in - Button { - onBusinessSelected(business) - } label: { - businessRow(business) - } - .buttonStyle(.plain) - } - .listStyle(.plain) - - addBusinessButton - } - } - .navigationTitle("Select Business") - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Log Out") { - logout() - } - } - } - } - .onAppear { - loadBusinesses() - } - } - - private func businessRow(_ business: Business) -> some View { - HStack(spacing: 12) { - if let ext = business.headerImageExtension, !ext.isEmpty { - let baseDomain = Api.IS_DEV ? "https://dev.payfrit.com" : "https://biz.payfrit.com" - let imageUrl = URL(string: "\(baseDomain)/uploads/businesses/\(business.businessId)/header.\(ext)") - KFImage(imageUrl) - .resizable() - .placeholder { - Image(systemName: "building.2") - .font(.title2) - .foregroundColor(.secondary) - } - .scaledToFill() - .frame(width: 48, height: 48) - .clipShape(Circle()) - } else { - Image(systemName: "building.2") - .font(.title2) - .foregroundColor(.secondary) - .frame(width: 48, height: 48) - } - - VStack(alignment: .leading, spacing: 2) { - Text(business.name) - .font(.body.weight(.medium)) - .foregroundColor(.primary) - Text("Tap to configure beacons") - .font(.caption) - .foregroundColor(.secondary) - } - - Spacer() - - Image(systemName: "chevron.right") - .foregroundColor(.secondary) - .font(.caption) - } - .padding(.vertical, 4) - } - - private var addBusinessButton: some View { - Button { - let baseDomain = Api.IS_DEV ? "https://dev.payfrit.com" : "https://biz.payfrit.com" - if let url = URL(string: "\(baseDomain)/portal/index.html") { - UIApplication.shared.open(url) - } - } label: { - Text("Add Business via Portal") - .font(.callout) - .foregroundColor(.payfritGreen) - } - .padding() - } - - private func loadBusinesses() { - isLoading = true - errorMessage = nil - - Task { - do { - let result = try await Api.shared.listBusinesses() - businesses = result - isLoading = false - - if businesses.count == 1 && !hasAutoSelected { - hasAutoSelected = true - onBusinessSelected(businesses[0]) - } - } catch { - isLoading = false - errorMessage = error.localizedDescription - } - } - } - - private func logout() { - UserDefaults.standard.removeObject(forKey: "token") - UserDefaults.standard.removeObject(forKey: "userId") - UserDefaults.standard.removeObject(forKey: "firstName") - Api.shared.setAuthToken(nil) - onLogout() - } -} diff --git a/PayfritBeacon/DebugLog.swift b/PayfritBeacon/DebugLog.swift deleted file mode 100644 index 9725262..0000000 --- a/PayfritBeacon/DebugLog.swift +++ /dev/null @@ -1,33 +0,0 @@ -import Foundation - -/// Simple in-app debug log — viewable from ScanView -class DebugLog: ObservableObject { - static let shared = DebugLog() - - @Published var entries: [String] = [] - - private let maxEntries = 200 - - func log(_ message: String) { - let ts = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium) - let entry = "[\(ts)] \(message)" - NSLog("[DebugLog] \(message)") - DispatchQueue.main.async { - self.entries.append(entry) - if self.entries.count > self.maxEntries { - self.entries.removeFirst(self.entries.count - self.maxEntries) - } - } - } - - func clear() { - DispatchQueue.main.async { - self.entries.removeAll() - } - } - - /// Get all entries as a single string for clipboard - var allText: String { - entries.joined(separator: "\n") - } -} diff --git a/PayfritBeacon/DevBanner.swift b/PayfritBeacon/DevBanner.swift deleted file mode 100644 index 4ca5245..0000000 --- a/PayfritBeacon/DevBanner.swift +++ /dev/null @@ -1,29 +0,0 @@ -import SwiftUI - -struct DevRibbon: View { - var body: some View { - if Api.IS_DEV { - GeometryReader { geo in - ZStack { - Text("DEV") - .font(.system(size: 11, weight: .bold)) - .tracking(2) - .foregroundColor(.white) - .frame(width: 140, height: 22) - .background(Color(red: 255/255, green: 152/255, blue: 0/255)) - .rotationEffect(.degrees(45)) - .position(x: 35, y: geo.size.height - 35) - } - } - .allowsHitTesting(false) - } - } -} - -struct DevBanner: ViewModifier { - func body(content: Content) -> some View { - content.overlay { - DevRibbon() - } - } -} diff --git a/PayfritBeacon/Info.plist b/PayfritBeacon/Info.plist deleted file mode 100644 index 544965a..0000000 --- a/PayfritBeacon/Info.plist +++ /dev/null @@ -1,51 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(APP_DISPLAY_NAME) - CFBundleDisplayName - $(APP_DISPLAY_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - $(MARKETING_VERSION) - CFBundleVersion - $(CURRENT_PROJECT_VERSION) - NSBluetoothAlwaysUsageDescription - Payfrit Beacon uses Bluetooth to discover and configure nearby beacons. - NSLocationWhenInUseUsageDescription - Payfrit Beacon uses your location to detect nearby iBeacons and verify beacon ownership. - NSFaceIDUsageDescription - Payfrit Beacon uses Face ID for quick sign-in. - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UILaunchScreen - - - diff --git a/PayfritBeacon/LoginView.swift b/PayfritBeacon/LoginView.swift deleted file mode 100644 index 1e41bf4..0000000 --- a/PayfritBeacon/LoginView.swift +++ /dev/null @@ -1,236 +0,0 @@ -import SwiftUI -import LocalAuthentication -import SVGKit - -struct LoginView: View { - var onLoginSuccess: (String, Int) -> Void - - @State private var otpUuid: String? - @State private var phone = "" - @State private var otp = "" - @State private var isLoading = false - @State private var errorMessage: String? - @State private var showPhoneInput = false - @State private var showOtpInput = false - @State private var hasCheckedAuth = false - - var body: some View { - ScrollView { - VStack(spacing: 24) { - Spacer(minLength: 80) - - // SVG Logo - SVGLogoView(width: 200) - .frame(width: 200, height: 117) - - Text("Payfrit Beacon") - .font(.title2.bold()) - .foregroundColor(.payfritGreen) - - if isLoading { - ProgressView() - .padding() - } - - if let error = errorMessage { - Text(error) - .foregroundColor(.errorRed) - .font(.callout) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - - if showPhoneInput { - VStack(spacing: 12) { - TextField("Phone number", text: $phone) - .keyboardType(.phonePad) - .textContentType(.telephoneNumber) - .foregroundColor(.primary) - .padding() - .background(Color(.systemGray6)) - .cornerRadius(8) - .padding(.horizontal) - - Button(action: sendOtp) { - Text("Send Code") - .frame(maxWidth: .infinity) - .padding() - .background(Color.payfritGreen) - .foregroundColor(.white) - .cornerRadius(8) - } - .disabled(isLoading) - .padding(.horizontal) - } - } - - if showOtpInput { - VStack(spacing: 12) { - Text("Please enter the 6-digit code sent to your phone") - .font(.callout) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - - TextField("Verification code", text: $otp) - .keyboardType(.numberPad) - .textContentType(.oneTimeCode) - .foregroundColor(.primary) - .padding() - .background(Color(.systemGray6)) - .cornerRadius(8) - .padding(.horizontal) - - Button(action: verifyOtp) { - Text("Verify") - .frame(maxWidth: .infinity) - .padding() - .background(Color.payfritGreen) - .foregroundColor(.white) - .cornerRadius(8) - } - .disabled(isLoading) - .padding(.horizontal) - } - } - - Spacer(minLength: 40) - } - .frame(maxWidth: .infinity) - } - .onAppear { - guard !hasCheckedAuth else { return } - hasCheckedAuth = true - checkSavedAuth() - } - } - - private func checkSavedAuth() { - let defaults = UserDefaults.standard - let savedToken = defaults.string(forKey: "token") - let savedUserId = defaults.integer(forKey: "userId") - - if let token = savedToken, !token.isEmpty, savedUserId > 0 { - if canUseBiometrics() { - showBiometricPrompt(token: token, userId: savedUserId) - } else { - loginSuccess(token: token, userId: savedUserId) - } - } else { - showPhoneInput = true - } - } - - private func canUseBiometrics() -> Bool { - let context = LAContext() - var error: NSError? - return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) - } - - private func showBiometricPrompt(token: String, userId: Int) { - let context = LAContext() - context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, - localizedReason: "Sign in to Payfrit Beacon") { success, _ in - DispatchQueue.main.async { - if success { - loginSuccess(token: token, userId: userId) - } else { - showPhoneInput = true - } - } - } - } - - private func sendOtp() { - let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.count >= 10 else { - errorMessage = "Enter a valid phone number" - return - } - - isLoading = true - errorMessage = nil - - Task { - do { - let response = try await Api.shared.sendLoginOtp(phone: trimmed) - otpUuid = response.uuid - - showPhoneInput = false - showOtpInput = true - isLoading = false - } catch { - errorMessage = error.localizedDescription - isLoading = false - } - } - } - - private func verifyOtp() { - guard let uuid = otpUuid else { return } - let trimmed = otp.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.count >= 4 else { - errorMessage = "Enter the verification code" - return - } - - isLoading = true - errorMessage = nil - - Task { - do { - let response = try await Api.shared.verifyLoginOtp(uuid: uuid, otp: trimmed) - - let defaults = UserDefaults.standard - defaults.set(response.token, forKey: "token") - defaults.set(response.userId, forKey: "userId") - defaults.set(response.userFirstName, forKey: "firstName") - - loginSuccess(token: response.token, userId: response.userId) - } catch { - errorMessage = error.localizedDescription - isLoading = false - } - } - } - - private func loginSuccess(token: String, userId: Int) { - Api.shared.setAuthToken(token) - onLoginSuccess(token, userId) - } -} - -// MARK: - SVG Logo UIKit wrapper - -struct SVGLogoView: UIViewRepresentable { - var width: CGFloat = 200 - - func makeUIView(context: Context) -> UIView { - let container = UIView() - container.clipsToBounds = true - - let svgPath = Bundle.main.path(forResource: "payfrit-favicon-light-outlines", ofType: "svg") ?? "" - guard let svgImage = SVGKImage(contentsOfFile: svgPath) else { return container } - - // Scale SVG proportionally based on width - let nativeW = svgImage.size.width - let nativeH = svgImage.size.height - let scale = width / nativeW - svgImage.size = CGSize(width: width, height: nativeH * scale) - - let imageView = SVGKLayeredImageView(svgkImage: svgImage) ?? UIImageView() - imageView.translatesAutoresizingMaskIntoConstraints = false - container.addSubview(imageView) - - NSLayoutConstraint.activate([ - imageView.widthAnchor.constraint(equalToConstant: svgImage.size.width), - imageView.heightAnchor.constraint(equalToConstant: svgImage.size.height), - imageView.centerXAnchor.constraint(equalTo: container.centerXAnchor), - imageView.centerYAnchor.constraint(equalTo: container.centerYAnchor), - ]) - - return container - } - - func updateUIView(_ uiView: UIView, context: Context) {} -} diff --git a/PayfritBeacon/Models/BeaconConfig.swift b/PayfritBeacon/Models/BeaconConfig.swift new file mode 100644 index 0000000..8332828 --- /dev/null +++ b/PayfritBeacon/Models/BeaconConfig.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Configuration to write to a physical beacon +struct BeaconConfig { + let uuid: String // 32 hex chars, no dashes + let major: UInt16 + let minor: UInt16 + let measuredPower: Int8 // RSSI @ 1m, typically -100 + let advInterval: UInt16 // in units of 100ms (2 = 200ms) + let txPower: UInt8 // 0-7 + let servicePointName: String + let businessName: String + + /// UUID formatted with dashes (8-4-4-4-12) + var formattedUUID: String { uuid.uuidWithDashes } + + static let defaultMeasuredPower: Int8 = -100 + static let defaultAdvInterval: UInt16 = 2 + static let defaultTxPower: UInt8 = 1 +} + +/// Result from provisioning a beacon +struct ProvisionResult { + let success: Bool + let message: String + let config: BeaconConfig? +} + +/// Result from verifying a beacon broadcast +struct VerifyResult { + let found: Bool + let rssi: Int? + let message: String +} diff --git a/PayfritBeacon/Models/BeaconType.swift b/PayfritBeacon/Models/BeaconType.swift new file mode 100644 index 0000000..8c0bf4c --- /dev/null +++ b/PayfritBeacon/Models/BeaconType.swift @@ -0,0 +1,33 @@ +import Foundation +import CoreBluetooth + +/// Supported beacon hardware types +enum BeaconType: String, CaseIterable { + case kbeacon = "KBeacon" + case dxsmart = "DX-Smart" + case bluecharm = "BlueCharm" + case unknown = "Unknown" +} + +/// A BLE beacon discovered during scanning +struct DiscoveredBeacon: Identifiable { + let id: UUID // CBPeripheral identifier + let peripheral: CBPeripheral + let name: String + let type: BeaconType + var rssi: Int + var lastSeen: Date + + var displayName: String { + name.isEmpty ? "\(id.uuidString.prefix(8))…" : name + } + + var signalDescription: String { + switch rssi { + case -50...0: return "Excellent" + case -65 ... -51: return "Good" + case -80 ... -66: return "Fair" + default: return "Weak" + } + } +} diff --git a/PayfritBeacon/Models/Business.swift b/PayfritBeacon/Models/Business.swift new file mode 100644 index 0000000..9988954 --- /dev/null +++ b/PayfritBeacon/Models/Business.swift @@ -0,0 +1,18 @@ +import Foundation + +struct Business: Identifiable, Codable, Hashable { + let id: String + let name: String + let imageExtension: String? + + enum CodingKeys: String, CodingKey { + case id = "ID" + case name = "BusinessName" + case imageExtension = "ImageExtension" + } + + var headerImageURL: URL? { + guard let ext = imageExtension, !ext.isEmpty else { return nil } + return URL(string: "\(APIConfig.imageBaseURL)/businesses/\(id)/header.\(ext)") + } +} diff --git a/PayfritBeacon/Models/ServicePoint.swift b/PayfritBeacon/Models/ServicePoint.swift new file mode 100644 index 0000000..e9a5291 --- /dev/null +++ b/PayfritBeacon/Models/ServicePoint.swift @@ -0,0 +1,13 @@ +import Foundation + +struct ServicePoint: Identifiable, Codable, Hashable { + let id: String + let name: String + let businessId: String + + enum CodingKeys: String, CodingKey { + case id = "ID" + case name = "Name" + case businessId = "BusinessID" + } +} diff --git a/PayfritBeacon/PayfritBeaconApp.swift b/PayfritBeacon/PayfritBeaconApp.swift deleted file mode 100644 index e1e0736..0000000 --- a/PayfritBeacon/PayfritBeaconApp.swift +++ /dev/null @@ -1,25 +0,0 @@ -import SwiftUI - -@main -struct PayfritBeaconApp: App { - var body: some Scene { - WindowGroup { - RootView() - .preferredColorScheme(.light) - } - } -} - -extension Color { - static let payfritGreen = Color(red: 34/255, green: 178/255, blue: 75/255) // #22B24B - static let signalStrong = Color(red: 76/255, green: 175/255, blue: 80/255) // #4CAF50 - static let signalMedium = Color(red: 249/255, green: 168/255, blue: 37/255) // #F9A825 - static let signalWeak = Color(red: 186/255, green: 26/255, blue: 26/255) // #BA1A1A - static let infoBlue = Color(red: 33/255, green: 150/255, blue: 243/255) // #2196F3 - static let warningOrange = Color(red: 255/255, green: 152/255, blue: 0/255) // #FF9800 - static let errorRed = Color(red: 186/255, green: 26/255, blue: 26/255) // #BA1A1A - static let successGreen = Color(red: 76/255, green: 175/255, blue: 80/255) // #4CAF50 - static let newBg = Color(red: 76/255, green: 175/255, blue: 80/255).opacity(0.12) - static let assignedBg = Color(red: 33/255, green: 150/255, blue: 243/255).opacity(0.12) - static let bannedBg = Color(red: 186/255, green: 26/255, blue: 26/255).opacity(0.12) -} diff --git a/PayfritBeacon/Provisioners/BlueCharmProvisioner.swift b/PayfritBeacon/Provisioners/BlueCharmProvisioner.swift new file mode 100644 index 0000000..fccca65 --- /dev/null +++ b/PayfritBeacon/Provisioners/BlueCharmProvisioner.swift @@ -0,0 +1,247 @@ +import Foundation +import CoreBluetooth + +/// Provisioner for BlueCharm / BC04P hardware +/// Uses FFF0 service with similar auth/write flow to KBeacon +final class BlueCharmProvisioner: NSObject, BeaconProvisioner { + + // MARK: - Constants + + private static let passwords: [Data] = [ + Data(repeating: 0, count: 16), + "0000000000000000".data(using: .utf8)!, + "1234567890123456".data(using: .utf8)!, + ] + + private enum CMD: UInt8 { + case auth = 0x01 + case writeParams = 0x03 + case save = 0x04 + } + + private enum ParamID: UInt8 { + case uuid = 0x10 + case major = 0x11 + case minor = 0x12 + case txPower = 0x13 + case advInterval = 0x14 + } + + // MARK: - State + + private let peripheral: CBPeripheral + private let centralManager: CBCentralManager + private var writeChar: CBCharacteristic? + private var notifyChar: CBCharacteristic? + + private var connectionContinuation: CheckedContinuation? + private var serviceContinuation: CheckedContinuation? + private var writeContinuation: CheckedContinuation? + + private(set) var isConnected = false + + // MARK: - Init + + init(peripheral: CBPeripheral, centralManager: CBCentralManager) { + self.peripheral = peripheral + self.centralManager = centralManager + super.init() + self.peripheral.delegate = self + } + + // MARK: - BeaconProvisioner + + func connect() async throws { + for attempt in 1...GATTConstants.maxRetries { + do { + try await connectOnce() + try await discoverServices() + try await authenticate() + isConnected = true + return + } catch { + disconnect() + if attempt < GATTConstants.maxRetries { + try await Task.sleep(nanoseconds: UInt64(GATTConstants.retryDelay * 1_000_000_000)) + } else { + throw error + } + } + } + } + + func writeConfig(_ config: BeaconConfig) async throws { + guard isConnected, let writeChar 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 saveResp = try await sendCommand(Data([CMD.save.rawValue])) + guard saveResp.first == CMD.save.rawValue else { + throw ProvisionError.saveFailed + } + } + + func disconnect() { + if peripheral.state == .connected || peripheral.state == .connecting { + centralManager.cancelPeripheralConnection(peripheral) + } + isConnected = false + } + + // MARK: - Private + + private func connectOnce() async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + connectionContinuation = cont + centralManager.connect(peripheral, options: nil) + + DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.connectionTimeout) { [weak self] in + if let c = self?.connectionContinuation { + self?.connectionContinuation = nil + c.resume(throwing: ProvisionError.connectionTimeout) + } + } + } + } + + private func discoverServices() async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + serviceContinuation = cont + peripheral.discoverServices([GATTConstants.fff0Service, GATTConstants.fea0Service]) + + DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in + if let c = self?.serviceContinuation { + self?.serviceContinuation = nil + c.resume(throwing: ProvisionError.serviceDiscoveryTimeout) + } + } + } + } + + 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 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 + c.resume(throwing: ProvisionError.operationTimeout) + } + } + } + } +} + +// MARK: - CBPeripheralDelegate + +extension BlueCharmProvisioner: CBPeripheralDelegate { + + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + if let error { + serviceContinuation?.resume(throwing: error) + serviceContinuation = nil + return + } + + // Look for FFF0 or FEA0 service + guard let service = peripheral.services?.first(where: { + $0.uuid == GATTConstants.fff0Service || $0.uuid == GATTConstants.fea0Service + }) else { + serviceContinuation?.resume(throwing: ProvisionError.serviceNotFound) + serviceContinuation = nil + return + } + + peripheral.discoverCharacteristics(nil, for: service) + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + if let error { + serviceContinuation?.resume(throwing: error) + serviceContinuation = nil + return + } + + for char in service.characteristics ?? [] { + if char.properties.contains(.write) || char.properties.contains(.writeWithoutResponse) { + if writeChar == nil { writeChar = char } + } + if char.properties.contains(.notify) { + notifyChar = char + peripheral.setNotifyValue(true, for: char) + } + } + + if writeChar != nil { + serviceContinuation?.resume() + serviceContinuation = nil + } else { + serviceContinuation?.resume(throwing: ProvisionError.characteristicNotFound) + serviceContinuation = nil + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + guard let data = characteristic.value else { return } + if let cont = writeContinuation { + writeContinuation = nil + cont.resume(returning: data) + } + } + + func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { + if let error, let cont = writeContinuation { + writeContinuation = nil + cont.resume(throwing: error) + } + } +} diff --git a/PayfritBeacon/Provisioners/DXSmartProvisioner.swift b/PayfritBeacon/Provisioners/DXSmartProvisioner.swift new file mode 100644 index 0000000..2336b10 --- /dev/null +++ b/PayfritBeacon/Provisioners/DXSmartProvisioner.swift @@ -0,0 +1,253 @@ +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 +final class DXSmartProvisioner: NSObject, BeaconProvisioner { + + // MARK: - Constants + + 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 connectionContinuation: CheckedContinuation? + private var serviceContinuation: CheckedContinuation? + private var responseContinuation: CheckedContinuation? + + private(set) var isConnected = false + private(set) var isFlashing = false // Beacon LED flashing after trigger + + // MARK: - Init + + init(peripheral: CBPeripheral, centralManager: CBCentralManager) { + self.peripheral = peripheral + self.centralManager = centralManager + super.init() + self.peripheral.delegate = self + } + + // MARK: - BeaconProvisioner + + func connect() async throws { + for attempt in 1...GATTConstants.maxRetries { + do { + try await connectOnce() + try await discoverServices() + try await authenticate() + isConnected = true + isFlashing = true + return + } catch { + disconnect() + if attempt < GATTConstants.maxRetries { + try await Task.sleep(nanoseconds: UInt64(GATTConstants.retryDelay * 1_000_000_000)) + } else { + throw error + } + } + } + } + + func writeConfig(_ config: BeaconConfig) async throws { + guard isConnected, let writeChar else { + throw ProvisionError.notConnected + } + + let uuidBytes = config.uuid.hexToBytes + guard uuidBytes.count == 16 else { + 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) + } + + // Save to flash + let savePacket = Data([ConfigCmd.save.rawValue]) + try await sendAndWaitAck(savePacket) + + isFlashing = false + } + + func disconnect() { + if peripheral.state == .connected || peripheral.state == .connecting { + centralManager.cancelPeripheralConnection(peripheral) + } + isConnected = false + isFlashing = false + } + + // MARK: - Private + + private func connectOnce() async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + connectionContinuation = cont + centralManager.connect(peripheral, options: nil) + + DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.connectionTimeout) { [weak self] in + if let c = self?.connectionContinuation { + self?.connectionContinuation = nil + c.resume(throwing: ProvisionError.connectionTimeout) + } + } + } + } + + private func discoverServices() async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + serviceContinuation = cont + peripheral.discoverServices([GATTConstants.ffe0Service]) + + DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in + if let c = self?.serviceContinuation { + self?.serviceContinuation = nil + c.resume(throwing: ProvisionError.serviceDiscoveryTimeout) + } + } + } + } + + /// Two-step DXSmart auth: + /// 1. Send "555555" to FFE3 (beacon starts flashing) + /// 2. Send "dx1234" to FFE3 (actual auth) + private func authenticate() async throws { + guard let passwordChar else { + throw ProvisionError.characteristicNotFound + } + + // Step 1: Trigger — fire and forget (WRITE_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 + } + + // 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 + } + } + + private func sendAndWaitAck(_ data: Data) async throws { + guard let writeChar else { throw ProvisionError.notConnected } + + let _ = try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + responseContinuation = cont + peripheral.writeValue(data, for: writeChar, type: .withResponse) + + DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in + if let c = self?.responseContinuation { + self?.responseContinuation = nil + c.resume(throwing: ProvisionError.operationTimeout) + } + } + } + } +} + +// MARK: - CBPeripheralDelegate + +extension DXSmartProvisioner: CBPeripheralDelegate { + + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + if let error { + serviceContinuation?.resume(throwing: error) + serviceContinuation = nil + return + } + + guard let service = peripheral.services?.first(where: { $0.uuid == GATTConstants.ffe0Service }) else { + serviceContinuation?.resume(throwing: ProvisionError.serviceNotFound) + serviceContinuation = nil + return + } + + peripheral.discoverCharacteristics( + [GATTConstants.ffe1Char, GATTConstants.ffe2Char, GATTConstants.ffe3Char], + for: service + ) + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + if let error { + serviceContinuation?.resume(throwing: error) + serviceContinuation = nil + return + } + + for char in service.characteristics ?? [] { + switch char.uuid { + case GATTConstants.ffe1Char: + writeChar = char + // FFE1 is also used for notify on DXSmart + if char.properties.contains(.notify) { + peripheral.setNotifyValue(true, for: char) + } + case GATTConstants.ffe3Char: + passwordChar = char + default: + break + } + } + + if writeChar != nil && passwordChar != nil { + serviceContinuation?.resume() + serviceContinuation = nil + } else { + serviceContinuation?.resume(throwing: ProvisionError.characteristicNotFound) + serviceContinuation = nil + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + guard let data = characteristic.value else { return } + + if let cont = responseContinuation { + responseContinuation = nil + cont.resume(returning: data) + } + } + + func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { + if let error, let cont = responseContinuation { + responseContinuation = nil + cont.resume(throwing: error) + } + } +} diff --git a/PayfritBeacon/Provisioners/KBeaconProvisioner.swift b/PayfritBeacon/Provisioners/KBeaconProvisioner.swift new file mode 100644 index 0000000..398bc4b --- /dev/null +++ b/PayfritBeacon/Provisioners/KBeaconProvisioner.swift @@ -0,0 +1,262 @@ +import Foundation +import CoreBluetooth + +/// Provisioner for KBeacon / KBPro hardware +/// Protocol: FFE0 service, FFE1 write, FFE2 notify +/// Auth via CMD_AUTH (0x01), config via CMD_WRITE_PARAMS (0x03), save via CMD_SAVE (0x04) +final class KBeaconProvisioner: NSObject, BeaconProvisioner { + + // MARK: - Protocol Commands + private enum CMD: UInt8 { + case auth = 0x01 + case readParams = 0x02 + case writeParams = 0x03 + case save = 0x04 + } + + // MARK: - Parameter IDs + private enum ParamID: UInt8 { + case uuid = 0x10 + case major = 0x11 + case minor = 0x12 + case txPower = 0x13 + case advInterval = 0x14 + } + + // MARK: - Known passwords (tried in order, matching Android) + private static let passwords: [Data] = [ + "kd1234".data(using: .utf8)!, + Data(repeating: 0, count: 16), + Data([0x31,0x32,0x33,0x34,0x35,0x36,0x37,0x38,0x39,0x30,0x31,0x32,0x33,0x34,0x35,0x36]), + "0000000000000000".data(using: .utf8)!, + "1234567890123456".data(using: .utf8)! + ] + + // MARK: - State + + private let peripheral: CBPeripheral + private let centralManager: CBCentralManager + private var writeChar: CBCharacteristic? + private var notifyChar: CBCharacteristic? + + private var connectionContinuation: CheckedContinuation? + private var serviceContinuation: CheckedContinuation? + private var writeContinuation: CheckedContinuation? + + private(set) var isConnected = false + + // MARK: - Init + + init(peripheral: CBPeripheral, centralManager: CBCentralManager) { + self.peripheral = peripheral + self.centralManager = centralManager + super.init() + self.peripheral.delegate = self + } + + // MARK: - BeaconProvisioner + + func connect() async throws { + // Connect with retry + for attempt in 1...GATTConstants.maxRetries { + do { + try await connectOnce() + try await discoverServices() + try await authenticate() + isConnected = true + return + } catch { + disconnect() + if attempt < GATTConstants.maxRetries { + try await Task.sleep(nanoseconds: UInt64(GATTConstants.retryDelay * 1_000_000_000)) + } else { + throw error + } + } + } + } + + func writeConfig(_ config: BeaconConfig) async throws { + guard isConnected, let writeChar else { + throw ProvisionError.notConnected + } + + // Build parameter payload + var params = Data() + + // UUID (16 bytes) + params.append(ParamID.uuid.rawValue) + let uuidBytes = config.uuid.hexToBytes + params.append(contentsOf: uuidBytes) + + // Major (2 bytes BE) + params.append(ParamID.major.rawValue) + params.append(UInt8(config.major >> 8)) + params.append(UInt8(config.major & 0xFF)) + + // Minor (2 bytes BE) + 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)) + + // Send CMD_WRITE_PARAMS + 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") + } + + // Send CMD_SAVE to flash + let saveResp = try await sendCommand(Data([CMD.save.rawValue])) + guard saveResp.first == CMD.save.rawValue else { + throw ProvisionError.saveFailed + } + } + + func disconnect() { + if peripheral.state == .connected || peripheral.state == .connecting { + centralManager.cancelPeripheralConnection(peripheral) + } + isConnected = false + } + + // MARK: - Private: Connection + + private func connectOnce() async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + connectionContinuation = cont + centralManager.connect(peripheral, options: nil) + + // Timeout + DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.connectionTimeout) { [weak self] in + if let c = self?.connectionContinuation { + self?.connectionContinuation = nil + c.resume(throwing: ProvisionError.connectionTimeout) + } + } + } + } + + private func discoverServices() async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + serviceContinuation = cont + peripheral.discoverServices([GATTConstants.ffe0Service]) + + DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in + if let c = self?.serviceContinuation { + self?.serviceContinuation = nil + c.resume(throwing: ProvisionError.serviceDiscoveryTimeout) + } + } + } + } + + 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 // Auth success + } + } catch { + continue + } + } + throw ProvisionError.authFailed + } + + 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 + c.resume(throwing: ProvisionError.operationTimeout) + } + } + } + } +} + +// MARK: - CBPeripheralDelegate + +extension KBeaconProvisioner: CBPeripheralDelegate { + + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + if let error { + serviceContinuation?.resume(throwing: error) + serviceContinuation = nil + return + } + + guard let service = peripheral.services?.first(where: { $0.uuid == GATTConstants.ffe0Service }) else { + serviceContinuation?.resume(throwing: ProvisionError.serviceNotFound) + serviceContinuation = nil + return + } + + peripheral.discoverCharacteristics([GATTConstants.ffe1Char, GATTConstants.ffe2Char], for: service) + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + if let error { + serviceContinuation?.resume(throwing: error) + serviceContinuation = nil + return + } + + for char in service.characteristics ?? [] { + switch char.uuid { + case GATTConstants.ffe1Char: + writeChar = char + case GATTConstants.ffe2Char: + notifyChar = char + peripheral.setNotifyValue(true, for: char) + default: + break + } + } + + if writeChar != nil { + serviceContinuation?.resume() + serviceContinuation = nil + } else { + serviceContinuation?.resume(throwing: ProvisionError.characteristicNotFound) + serviceContinuation = nil + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + guard characteristic.uuid == GATTConstants.ffe2Char, + let data = characteristic.value else { return } + + if let cont = writeContinuation { + writeContinuation = nil + cont.resume(returning: data) + } + } + + func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { + // Write acknowledgment — actual response comes via notify on FFE2 + if let error { + if let cont = writeContinuation { + writeContinuation = nil + cont.resume(throwing: error) + } + } + } +} diff --git a/PayfritBeacon/Provisioners/ProvisionError.swift b/PayfritBeacon/Provisioners/ProvisionError.swift new file mode 100644 index 0000000..23053b3 --- /dev/null +++ b/PayfritBeacon/Provisioners/ProvisionError.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Errors during beacon provisioning +enum ProvisionError: LocalizedError { + case connectionTimeout + case serviceDiscoveryTimeout + case operationTimeout + case serviceNotFound + case characteristicNotFound + case authFailed + case notConnected + case writeFailed(String) + case saveFailed + case verifyFailed + + var errorDescription: String? { + switch self { + case .connectionTimeout: return "Connection timed out" + case .serviceDiscoveryTimeout: return "Service discovery timed out" + case .operationTimeout: return "Operation timed out" + case .serviceNotFound: return "GATT service not found" + case .characteristicNotFound: return "GATT characteristic not found" + case .authFailed: return "Beacon authentication failed" + case .notConnected: return "Not connected to beacon" + case .writeFailed(let msg): return "Write failed: \(msg)" + case .saveFailed: return "Failed to save config to flash" + case .verifyFailed: return "Beacon broadcast verification failed" + } + } +} diff --git a/PayfritBeacon/Provisioners/ProvisionerProtocol.swift b/PayfritBeacon/Provisioners/ProvisionerProtocol.swift new file mode 100644 index 0000000..da93d77 --- /dev/null +++ b/PayfritBeacon/Provisioners/ProvisionerProtocol.swift @@ -0,0 +1,40 @@ +import Foundation +import CoreBluetooth + +/// Common protocol for all beacon provisioners +protocol BeaconProvisioner { + /// Connect to the beacon and authenticate + func connect() async throws + + /// Write the full beacon configuration + func writeConfig(_ config: BeaconConfig) async throws + + /// Disconnect from the beacon + func disconnect() + + /// Whether we're currently connected + var isConnected: Bool { get } +} + +/// GATT UUIDs shared across provisioner types +enum GATTConstants { + // FFE0 service (KBeacon, DXSmart) + static let ffe0Service = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB") + static let ffe1Char = CBUUID(string: "0000FFE1-0000-1000-8000-00805F9B34FB") + static let ffe2Char = CBUUID(string: "0000FFE2-0000-1000-8000-00805F9B34FB") + static let ffe3Char = CBUUID(string: "0000FFE3-0000-1000-8000-00805F9B34FB") + + // FFF0 service (BlueCharm) + static let fff0Service = CBUUID(string: "0000FFF0-0000-1000-8000-00805F9B34FB") + static let fea0Service = CBUUID(string: "0000FEA0-0000-1000-8000-00805F9B34FB") + + // CCCD for enabling notifications + static let cccd = CBUUID(string: "00002902-0000-1000-8000-00805F9B34FB") + + // Timeouts (matching Android) + static let connectionTimeout: TimeInterval = 5.0 + static let operationTimeout: TimeInterval = 5.0 + static let maxRetries = 3 + static let retryDelay: TimeInterval = 1.0 + static let postFlashDelay: TimeInterval = 3.0 +} diff --git a/PayfritBeacon/RootView.swift b/PayfritBeacon/RootView.swift deleted file mode 100644 index 941120f..0000000 --- a/PayfritBeacon/RootView.swift +++ /dev/null @@ -1,58 +0,0 @@ -import SwiftUI - -struct RootView: View { - @State private var isAuthenticated = false - @State private var isCheckingAuth = true - @State private var userId: Int = 0 - @State private var selectedBusiness: Business? - @State private var hasAutoSelected = false - - var body: some View { - Group { - if isCheckingAuth { - VStack { - ProgressView() - } - } else if !isAuthenticated { - LoginView { token, uid in - userId = uid - isAuthenticated = true - } - } else { - BusinessListView( - hasAutoSelected: $hasAutoSelected, - onBusinessSelected: { business in - selectedBusiness = business - }, - onLogout: { - isAuthenticated = false - selectedBusiness = nil - hasAutoSelected = false - } - ) - .fullScreenCover(item: $selectedBusiness) { business in - ServicePointListView( - businessId: business.businessId, - businessName: business.name, - onBack: { selectedBusiness = nil } - ) - } - } - } - .modifier(DevBanner()) - .onAppear { - checkAuth() - } - } - - private func checkAuth() { - let token = UserDefaults.standard.string(forKey: "token") - let savedUserId = UserDefaults.standard.integer(forKey: "userId") - - if let token = token, !token.isEmpty, savedUserId > 0 { - isCheckingAuth = false - } else { - isCheckingAuth = false - } - } -} diff --git a/PayfritBeacon/ScanView.swift b/PayfritBeacon/ScanView.swift deleted file mode 100644 index 16fb357..0000000 --- a/PayfritBeacon/ScanView.swift +++ /dev/null @@ -1,1051 +0,0 @@ -import SwiftUI - -// MARK: - ScanView (Beacon Provisioning) - -struct ScanView: View { - let businessId: Int - let businessName: String - var reprovisionServicePoint: ServicePoint? = nil // If set, we're re-provisioning an existing SP - var onBack: () -> Void - - @StateObject private var bleScanner = BLEBeaconScanner() - @StateObject private var provisioner = BeaconProvisioner() - @StateObject private var iBeaconScanner = BeaconScanner() - - @State private var namespace: BusinessNamespace? - @State private var servicePoints: [ServicePoint] = [] - @State private var nextTableNumber: Int = 1 - @State private var provisionedCount: Int = 0 - - // iBeacon ownership tracking - // Key: "UUID|Major" → (businessId, businessName) - @State private var detectedIBeacons: [DetectedBeacon] = [] - @State private var beaconOwnership: [String: (businessId: Int, businessName: String)] = [:] - - // UI State - @State private var snackMessage: String? - @State private var showAssignSheet = false - @State private var selectedBeacon: DiscoveredBeacon? - @State private var assignName = "" - @State private var isProvisioning = false - @State private var provisioningProgress = "" - @State private var provisioningError: String? - - // Action sheet + check config - @State private var showBeaconActionSheet = false - @State private var showCheckConfigSheet = false - @State private var checkConfigData: BeaconCheckResult? - @State private var isCheckingConfig = false - @State private var checkConfigError: String? - - // Debug log - @State private var showDebugLog = false - @ObservedObject private var debugLog = DebugLog.shared - - var body: some View { - VStack(spacing: 0) { - // Toolbar - HStack { - Button(action: onBack) { - Image(systemName: "chevron.left") - .font(.title3) - } - Text("Beacon Setup") - .font(.headline) - Spacer() - if provisionedCount > 0 { - Text("\(provisionedCount) done") - .font(.caption) - .foregroundColor(.payfritGreen) - } - Button { - showDebugLog = true - } label: { - Image(systemName: "ladybug") - .font(.caption) - .foregroundColor(.secondary) - } - } - .padding() - .background(Color(.systemBackground)) - - // Info bar - HStack { - Text(businessName) - .font(.subheadline) - .foregroundColor(.secondary) - Spacer() - if let sp = reprovisionServicePoint { - Text("Re-provision: \(sp.name)") - .font(.subheadline.bold()) - .foregroundColor(.orange) - } else { - Text("Next: Table \(nextTableNumber)") - .font(.subheadline.bold()) - .foregroundColor(.payfritGreen) - } - } - .padding(.horizontal) - .padding(.vertical, 8) - - Divider() - - // Bluetooth status - if bleScanner.bluetoothState != .poweredOn { - bluetoothWarning - } - - // Beacon lists - ScrollView { - LazyVStack(spacing: 8) { - // BLE devices section (for provisioning) - shown first - if !bleScanner.discoveredBeacons.isEmpty { - VStack(alignment: .leading, spacing: 8) { - Text("Configurable Devices") - .font(.caption.weight(.semibold)) - .foregroundColor(.secondary) - .padding(.horizontal) - - ForEach(bleScanner.discoveredBeacons) { beacon in - beaconRow(beacon) - .onTapGesture { - selectedBeacon = beacon - showBeaconActionSheet = true - } - } - } - .padding(.top, 8) - - if !detectedIBeacons.isEmpty { - Divider() - .padding(.vertical, 8) - } - } else if bleScanner.isScanning { - VStack(spacing: 12) { - ProgressView() - Text("Scanning for beacons...") - .foregroundColor(.secondary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 40) - } else if detectedIBeacons.isEmpty { - VStack(spacing: 12) { - Image(systemName: "antenna.radiowaves.left.and.right") - .font(.largeTitle) - .foregroundColor(.secondary) - Text("No beacons found") - .foregroundColor(.secondary) - Text("Make sure beacons are powered on\nand in configuration mode") - .font(.caption) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 40) - } - - // Detected iBeacons section (shows ownership status) - if !detectedIBeacons.isEmpty { - VStack(alignment: .leading, spacing: 8) { - Text("Detected Beacons") - .font(.caption.weight(.semibold)) - .foregroundColor(.secondary) - .padding(.horizontal) - - ForEach(detectedIBeacons, id: \.minor) { ibeacon in - iBeaconRow(ibeacon) - } - } - } - } - .padding(.horizontal) - } - - // Bottom action bar - VStack(spacing: 8) { - Button(action: startScan) { - HStack { - if bleScanner.isScanning { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - .scaleEffect(0.8) - } - Text(bleScanner.isScanning ? "Scanning..." : "Scan for Beacons") - } - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(bleScanner.isScanning ? Color.gray : Color.payfritGreen) - .foregroundColor(.white) - .cornerRadius(8) - } - .disabled(bleScanner.isScanning || bleScanner.bluetoothState != .poweredOn) - - Button(action: onBack) { - Text("Done") - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.payfritGreen, lineWidth: 1) - ) - .foregroundColor(.payfritGreen) - } - } - .padding() - } - .modifier(DevBanner()) - .overlay(snackOverlay, alignment: .bottom) - .sheet(isPresented: $showAssignSheet) { assignSheet } - .sheet(isPresented: $showCheckConfigSheet) { checkConfigSheet } - .sheet(isPresented: $showDebugLog) { debugLogSheet } - .confirmationDialog( - selectedBeacon?.displayName ?? "Beacon", - isPresented: $showBeaconActionSheet, - titleVisibility: .visible - ) { - if let sp = reprovisionServicePoint { - Button("Provision for \(sp.name)") { - guard selectedBeacon != nil else { return } - reprovisionBeacon() - } - } else { - Button("Configure (Assign & Provision)") { - guard selectedBeacon != nil else { return } - assignName = "Table \(nextTableNumber)" - showAssignSheet = true - } - } - Button("Check Current Config") { - guard let beacon = selectedBeacon else { return } - checkConfig(beacon) - } - Button("Cancel", role: .cancel) { - selectedBeacon = nil - } - } - .onAppear { loadServicePoints() } - } - - // MARK: - Bluetooth Warning - - private var bluetoothWarning: some View { - HStack { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.warningOrange) - Text(bluetoothMessage) - .font(.caption) - Spacer() - } - .padding() - .background(Color.warningOrange.opacity(0.1)) - } - - private var bluetoothMessage: String { - switch bleScanner.bluetoothState { - case .poweredOff: - return "Bluetooth is turned off" - case .unauthorized: - return "Bluetooth permission denied" - case .unsupported: - return "Bluetooth not supported" - default: - return "Bluetooth not ready" - } - } - - // MARK: - Beacon Row - - private func beaconRow(_ beacon: DiscoveredBeacon) -> some View { - HStack(spacing: 12) { - // Signal strength indicator - Rectangle() - .fill(signalColor(beacon.rssi)) - .frame(width: 4) - .cornerRadius(2) - - VStack(alignment: .leading, spacing: 4) { - Text(beacon.displayName) - .font(.system(.body, design: .default)) - .lineLimit(1) - - HStack(spacing: 8) { - if beacon.type != .unknown { - Text(beacon.type.rawValue) - .font(.caption2.weight(.medium)) - .foregroundColor(.white) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(beacon.type == .kbeacon ? Color.blue : beacon.type == .dxsmart ? Color.orange : Color.purple) - .cornerRadius(4) - } - - Text("\(beacon.rssi) dBm") - .font(.caption) - .foregroundColor(.secondary) - } - } - - Spacer() - - Image(systemName: "chevron.right") - .foregroundColor(.secondary) - } - .padding(12) - .background(Color(.systemBackground)) - .cornerRadius(8) - .shadow(color: .black.opacity(0.05), radius: 2, y: 1) - } - - private func signalColor(_ rssi: Int) -> Color { - if rssi >= -60 { return .signalStrong } - if rssi >= -75 { return .signalMedium } - return .signalWeak - } - - // MARK: - iBeacon Row (shows ownership status) - - private func iBeaconRow(_ beacon: DetectedBeacon) -> some View { - let ownership = getOwnershipStatus(for: beacon) - - return HStack(spacing: 12) { - // Signal strength indicator - Rectangle() - .fill(signalColor(beacon.rssi)) - .frame(width: 4) - .cornerRadius(2) - - VStack(alignment: .leading, spacing: 4) { - // Ownership status - all green (own business shows name, others show "Unconfigured") - Text(ownership.displayText) - .font(.system(.body, design: .default).weight(.medium)) - .foregroundColor(.payfritGreen) - .lineLimit(1) - - HStack(spacing: 8) { - Text("Major: \(beacon.major)") - .font(.caption) - .foregroundColor(.secondary) - - Text("Minor: \(beacon.minor)") - .font(.caption) - .foregroundColor(.secondary) - - Text("\(beacon.rssi) dBm") - .font(.caption) - .foregroundColor(.secondary) - } - } - - Spacer() - } - .padding(12) - .background(Color(.systemBackground)) - .cornerRadius(8) - .shadow(color: .black.opacity(0.05), radius: 2, y: 1) - } - - // MARK: - Assignment Sheet - - private var assignSheet: some View { - NavigationStack { - VStack(alignment: .leading, spacing: 16) { - if let beacon = selectedBeacon { - // Beacon info - VStack(alignment: .leading, spacing: 8) { - Text("Beacon") - .font(.caption) - .foregroundColor(.secondary) - HStack { - Text(beacon.displayName) - .font(.headline) - Spacer() - Text(beacon.type.rawValue) - .font(.caption2.weight(.medium)) - .foregroundColor(.white) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(beacon.type == .kbeacon ? Color.blue : beacon.type == .dxsmart ? Color.orange : Color.purple) - .cornerRadius(4) - } - Text("Signal: \(beacon.rssi) dBm") - .font(.caption) - .foregroundColor(.secondary) - } - .padding() - .background(Color(.systemGray6)) - .cornerRadius(8) - - // Service point name - VStack(alignment: .leading, spacing: 8) { - Text("Service Point Name") - .font(.caption) - .foregroundColor(.secondary) - TextField("e.g., Table 1", text: $assignName) - .textFieldStyle(.roundedBorder) - .font(.title3) - } - - // Provisioning progress — show live updates from provisioner - if isProvisioning { - HStack { - ProgressView() - Text(provisioner.progress.isEmpty ? provisioningProgress : provisioner.progress) - .font(.callout) - } - .padding() - .frame(maxWidth: .infinity) - .background(Color(.systemGray6)) - .cornerRadius(8) - } - - // Provisioning error - if let error = provisioningError { - HStack(alignment: .top) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.red) - Text(error) - .font(.callout) - .foregroundColor(.red) - } - .padding() - .background(Color.red.opacity(0.1)) - .cornerRadius(8) - } - - Spacer() - } - } - .padding() - .navigationTitle("Assign Beacon") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - showAssignSheet = false - selectedBeacon = nil - } - .disabled(isProvisioning) - } - ToolbarItem(placement: .confirmationAction) { - Button("Save Beacon") { - saveBeacon() - } - .disabled(assignName.trimmingCharacters(in: .whitespaces).isEmpty || isProvisioning) - } - } - } - .presentationDetents([.medium]) - .interactiveDismissDisabled(isProvisioning) - } - - // MARK: - Check Config Sheet - - private var checkConfigSheet: some View { - NavigationStack { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - if let beacon = selectedBeacon { - // Beacon identity - VStack(alignment: .leading, spacing: 8) { - Text("Beacon") - .font(.caption) - .foregroundColor(.secondary) - HStack { - Text(beacon.displayName) - .font(.headline) - Spacer() - if beacon.type != .unknown { - Text(beacon.type.rawValue) - .font(.caption2.weight(.medium)) - .foregroundColor(.white) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(beacon.type == .kbeacon ? Color.blue : beacon.type == .dxsmart ? Color.orange : Color.purple) - .cornerRadius(4) - } - } - Text("Signal: \(beacon.rssi) dBm") - .font(.caption) - .foregroundColor(.secondary) - } - .padding() - .background(Color(.systemGray6)) - .cornerRadius(8) - - // Loading state - if isCheckingConfig { - HStack { - ProgressView() - Text(provisioner.progress.isEmpty ? "Connecting..." : provisioner.progress) - .font(.callout) - } - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(.systemGray6)) - .cornerRadius(8) - } - - // Error state - if let error = checkConfigError { - HStack(alignment: .top) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.orange) - Text(error) - .font(.callout) - } - .padding() - .background(Color.orange.opacity(0.1)) - .cornerRadius(8) - } - - // Parsed config data - if let data = checkConfigData { - // iBeacon configuration - if data.hasConfig { - VStack(alignment: .leading, spacing: 10) { - Text("iBeacon Configuration") - .font(.subheadline.weight(.semibold)) - - if let major = data.major { - configRow("Major", "\(major)") - } - if let ns = namespace { - configRow("Shard", "\(ns.shardId)") - } - if let minor = data.minor { - configRow("Minor", "\(minor)") - } - if let name = data.deviceName { - configRow("Name", name) - } - if let rssi = data.rssiAt1m { - configRow("RSSI@1m", "\(rssi) dBm") - } - if let interval = data.advInterval { - configRow("Interval", "\(interval)00 ms") - } - if let tx = data.txPower { - configRow("TX Power", "\(tx)") - } - } - .padding() - .background(Color(.systemGray6)) - .cornerRadius(8) - } - - // Device info - if data.battery != nil || data.macAddress != nil { - VStack(alignment: .leading, spacing: 10) { - Text("Device Info") - .font(.subheadline.weight(.semibold)) - - if let battery = data.battery { - configRow("Battery", "\(battery)%") - } - if let mac = data.macAddress { - configRow("MAC", mac) - } - if let slots = data.frameSlots { - let slotStr = slots.enumerated().map { i, s in - "Slot\(i): 0x\(String(format: "%02X", s))" - }.joined(separator: " ") - configRow("Frames", slotStr) - } - } - .padding() - .background(Color(.systemGray6)) - .cornerRadius(8) - } - - // No config found - if !data.hasConfig && data.battery == nil && data.macAddress == nil && data.rawResponses.isEmpty { - VStack(alignment: .leading, spacing: 8) { - HStack { - Image(systemName: "info.circle") - .foregroundColor(.secondary) - Text("No DX-Smart config data received") - .font(.callout) - .foregroundColor(.secondary) - } - - if !data.servicesFound.isEmpty { - Text("Services: \(data.servicesFound.joined(separator: ", "))") - .font(.caption) - .foregroundColor(.secondary) - } - } - .padding() - .background(Color(.systemGray6)) - .cornerRadius(8) - } - - // Raw responses (debug section) - if !data.rawResponses.isEmpty { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text("Raw Responses") - .font(.caption) - .foregroundColor(.secondary) - Spacer() - Button { - let raw = data.rawResponses.joined(separator: "\n") - UIPasteboard.general.string = raw - showSnack("Copied to clipboard") - } label: { - Image(systemName: "doc.on.doc") - .font(.caption) - } - } - Text(data.rawResponses.joined(separator: "\n")) - .font(.system(.caption2, design: .monospaced)) - .textSelection(.enabled) - } - .padding() - .background(Color(.systemGray6)) - .cornerRadius(8) - } - - // Services/chars discovery info - if !data.characteristicsFound.isEmpty { - VStack(alignment: .leading, spacing: 4) { - Text("BLE Discovery") - .font(.caption) - .foregroundColor(.secondary) - Text(data.characteristicsFound.joined(separator: "\n")) - .font(.system(.caption2, design: .monospaced)) - .textSelection(.enabled) - } - .padding() - .background(Color(.systemGray6)) - .cornerRadius(8) - } - } - } else { - Text("No beacon selected") - .foregroundColor(.secondary) - } - } - .padding() - } - .navigationTitle("Check Config") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Done") { - showCheckConfigSheet = false - selectedBeacon = nil - checkConfigData = nil - checkConfigError = nil - } - .disabled(isCheckingConfig) - } - } - } - .presentationDetents([.medium, .large]) - .interactiveDismissDisabled(isCheckingConfig) - } - - private func configRow(_ label: String, _ value: String) -> some View { - HStack(alignment: .top) { - Text(label) - .font(.caption) - .foregroundColor(.secondary) - .frame(width: 80, alignment: .leading) - Text(value) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - } - } - - // MARK: - Debug Log Sheet - - private var debugLogSheet: some View { - NavigationStack { - ScrollViewReader { proxy in - ScrollView { - LazyVStack(alignment: .leading, spacing: 2) { - ForEach(Array(debugLog.entries.enumerated()), id: \.offset) { idx, entry in - Text(entry) - .font(.system(.caption2, design: .monospaced)) - .textSelection(.enabled) - .id(idx) - } - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - } - .onAppear { - if let last = debugLog.entries.indices.last { - proxy.scrollTo(last, anchor: .bottom) - } - } - } - .navigationTitle("Debug Log") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Done") { showDebugLog = false } - } - ToolbarItemGroup(placement: .navigationBarTrailing) { - Button { - UIPasteboard.general.string = debugLog.allText - } label: { - Image(systemName: "doc.on.doc") - } - Button { - debugLog.clear() - } label: { - Image(systemName: "trash") - } - } - } - } - } - - // MARK: - Snack Overlay - - @ViewBuilder - private var snackOverlay: some View { - if let message = snackMessage { - Text(message) - .font(.callout) - .foregroundColor(.white) - .padding() - .background(Color(.darkGray)) - .cornerRadius(8) - .padding(.bottom, 100) - .transition(.move(edge: .bottom).combined(with: .opacity)) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { - withAnimation { snackMessage = nil } - } - } - } - } - - private func showSnack(_ message: String) { - withAnimation { snackMessage = message } - } - - // MARK: - Actions - - private func loadServicePoints() { - Task { - // Load namespace (for display/debug) and service points - // Note: Namespace is no longer required for provisioning - we use get_beacon_config instead - do { - namespace = try await Api.shared.allocateBusinessNamespace(businessId: businessId) - DebugLog.shared.log("[ScanView] Loaded namespace: uuid=\(namespace?.uuid ?? "nil") major=\(namespace?.major ?? 0)") - } catch { - DebugLog.shared.log("[ScanView] allocateBusinessNamespace error (non-critical): \(error)") - // Non-critical - provisioning will use get_beacon_config endpoint - } - - do { - servicePoints = try await Api.shared.listServicePoints(businessId: businessId) - DebugLog.shared.log("[ScanView] Loaded \(servicePoints.count) service points") - - // Find next table number - let maxNumber = servicePoints.compactMap { sp -> Int? in - guard let match = sp.name.range(of: #"Table\s+(\d+)"#, - options: [.regularExpression, .caseInsensitive]) else { - return nil - } - let numberStr = sp.name[match].split(separator: " ").last.flatMap { String($0) } ?? "" - return Int(numberStr) - }.max() ?? 0 - nextTableNumber = maxNumber + 1 - } catch { - DebugLog.shared.log("[ScanView] listServicePoints error: \(error)") - } - - // Auto-start scan - startScan() - } - } - - private func startScan() { - guard bleScanner.isBluetoothReady else { - showSnack("Bluetooth not available") - return - } - - // Start BLE scan for DX-Smart devices - bleScanner.startScanning() - - // Also start iBeacon ranging to detect configured beacons and their ownership - startIBeaconScan() - } - - private func startIBeaconScan() { - guard iBeaconScanner.hasPermissions() else { - // Request permission if needed - iBeaconScanner.requestPermission() - return - } - - // Start ranging for all Payfrit shard UUIDs - iBeaconScanner.startRanging(uuids: BeaconShardPool.uuids) - - // Collect results after 2 seconds - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [self] in - let detected = iBeaconScanner.stopAndCollect() - detectedIBeacons = detected - DebugLog.shared.log("[ScanView] Detected \(detected.count) iBeacons") - - // Look up ownership for each detected iBeacon - Task { - await resolveBeaconOwnership(detected) - } - } - } - - private func resolveBeaconOwnership(_ beacons: [DetectedBeacon]) async { - for beacon in beacons { - let key = "\(beacon.uuid)|\(beacon.major)" - // Skip if we already have ownership info for this beacon - guard beaconOwnership[key] == nil else { continue } - - do { - // Format UUID with dashes for API call - let uuidWithDashes = beacon.uuid.uuidWithDashes - let result = try await Api.shared.resolveBusiness(uuid: uuidWithDashes, major: beacon.major) - await MainActor.run { - beaconOwnership[key] = (businessId: result.businessId, businessName: result.businessName) - DebugLog.shared.log("[ScanView] Resolved beacon \(beacon.major): \(result.businessName)") - } - } catch { - DebugLog.shared.log("[ScanView] Failed to resolve beacon \(beacon.major): \(error.localizedDescription)") - } - } - } - - /// Get ownership status for a detected iBeacon - private func getOwnershipStatus(for beacon: DetectedBeacon) -> (isOwned: Bool, displayText: String) { - let key = "\(beacon.uuid)|\(beacon.major)" - if let ownership = beaconOwnership[key] { - if ownership.businessId == businessId { - return (true, ownership.businessName) - } - } - return (false, "Unconfigured") - } - - private func checkConfig(_ beacon: DiscoveredBeacon) { - isCheckingConfig = true - checkConfigError = nil - checkConfigData = nil - showCheckConfigSheet = true - - // Stop scanning to avoid BLE interference - bleScanner.stopScanning() - - provisioner.readConfig(beacon: beacon) { data, error in - Task { @MainActor in - isCheckingConfig = false - if let data = data { - checkConfigData = data - } - if let error = error { - checkConfigError = error - } - } - } - } - - private func reprovisionBeacon() { - guard let beacon = selectedBeacon, let sp = reprovisionServicePoint else { return } - - // Stop scanning - bleScanner.stopScanning() - - isProvisioning = true - provisioningProgress = "Preparing..." - provisioningError = nil - showAssignSheet = true // Reuse assign sheet to show progress - assignName = sp.name - - DebugLog.shared.log("[ScanView] reprovisionBeacon: sp=\(sp.name) spId=\(sp.servicePointId) beacon=\(beacon.displayName)") - - Task { - do { - // Use the new unified get_beacon_config endpoint - provisioningProgress = "Getting beacon config..." - let config = try await Api.shared.getBeaconConfig(businessId: businessId, servicePointId: sp.servicePointId) - - DebugLog.shared.log("[ScanView] reprovisionBeacon: config uuid=\(config.uuid) major=\(config.major) minor=\(config.minor) measuredPower=\(config.measuredPower) advInterval=\(config.advInterval) txPower=\(config.txPower)") - - // Build config using server-provided values (NOT hardcoded) - let deviceName = config.servicePointName.isEmpty ? sp.name : config.servicePointName - let beaconConfig = BeaconConfig( - uuid: config.uuid, - major: config.major, - minor: config.minor, - measuredPower: config.measuredPower, - advInterval: config.advInterval, - txPower: config.txPower, - deviceName: deviceName - ) - - provisioningProgress = "Provisioning beacon..." - - provisioner.provision(beacon: beacon, config: beaconConfig) { result in - Task { @MainActor in - switch result { - case .success(let macAddress): - do { - // Use MAC address as hardware ID, fallback to iBeacon UUID if unavailable - let uuidWithDashes = config.uuid.uuidWithDashes - let hardwareId = macAddress ?? uuidWithDashes - DebugLog.shared.log("[ScanView] Registering beacon - MAC: \(macAddress ?? "nil"), hardwareId: \(hardwareId), uuid: \(uuidWithDashes)") - try await Api.shared.registerBeaconHardware( - businessId: businessId, - servicePointId: sp.servicePointId, - uuid: uuidWithDashes, - major: config.major, - minor: config.minor, - hardwareId: hardwareId, - macAddress: macAddress - ) - finishProvisioning(name: sp.name) - } catch { - failProvisioning(error.localizedDescription) - } - 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) - } - } - } - } catch { - failProvisioning(error.localizedDescription) - } - } - } - - private func saveBeacon() { - guard let beacon = selectedBeacon else { return } - let name = assignName.trimmingCharacters(in: .whitespaces) - guard !name.isEmpty else { return } - - isProvisioning = true - provisioningProgress = "Preparing..." - provisioningError = nil - - // Stop scanning to avoid BLE interference - bleScanner.stopScanning() - - DebugLog.shared.log("[ScanView] saveBeacon: name=\(name) beacon=\(beacon.displayName) businessId=\(businessId)") - - Task { - do { - // 1. Reuse existing service point if name matches, otherwise create new - var servicePoint: ServicePoint - if let existing = servicePoints.first(where: { $0.name.caseInsensitiveCompare(name) == .orderedSame }) { - DebugLog.shared.log("[ScanView] saveBeacon: found existing SP id=\(existing.servicePointId)") - servicePoint = existing - } else { - provisioningProgress = "Creating service point..." - DebugLog.shared.log("[ScanView] saveBeacon: creating new SP...") - servicePoint = try await Api.shared.createServicePoint(businessId: businessId, name: name) - DebugLog.shared.log("[ScanView] saveBeacon: created SP id=\(servicePoint.servicePointId)") - } - - // 2. Use the new unified get_beacon_config endpoint (replaces allocate_business_namespace + allocate_servicepoint_minor) - provisioningProgress = "Getting beacon config..." - let config = try await Api.shared.getBeaconConfig(businessId: businessId, servicePointId: servicePoint.servicePointId) - - DebugLog.shared.log("[ScanView] saveBeacon: config uuid=\(config.uuid) major=\(config.major) minor=\(config.minor) measuredPower=\(config.measuredPower) advInterval=\(config.advInterval) txPower=\(config.txPower)") - - // 3. Build config using server-provided values (NOT hardcoded) - let deviceName = config.servicePointName.isEmpty ? name : config.servicePointName - let beaconConfig = BeaconConfig( - uuid: config.uuid, - major: config.major, - minor: config.minor, - measuredPower: config.measuredPower, - advInterval: config.advInterval, - txPower: config.txPower, - deviceName: deviceName - ) - - provisioningProgress = "Provisioning beacon..." - - // 4. Provision the beacon via GATT - provisioner.provision(beacon: beacon, config: beaconConfig) { result in - Task { @MainActor in - switch result { - case .success(let macAddress): - // Register in backend (use UUID with dashes for API) - do { - // Use MAC address as hardware ID, fallback to iBeacon UUID if unavailable - let uuidWithDashes = config.uuid.uuidWithDashes - let hardwareId = macAddress ?? uuidWithDashes - DebugLog.shared.log("[ScanView] Registering beacon - MAC: \(macAddress ?? "nil"), hardwareId: \(hardwareId), uuid: \(uuidWithDashes)") - try await Api.shared.registerBeaconHardware( - businessId: businessId, - servicePointId: servicePoint.servicePointId, - uuid: uuidWithDashes, - major: config.major, - minor: config.minor, - hardwareId: hardwareId, - macAddress: macAddress - ) - finishProvisioning(name: name) - } catch { - failProvisioning(error.localizedDescription) - } - 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) - } - } - } - } catch { - failProvisioning(error.localizedDescription) - } - } - } - - private func finishProvisioning(name: String) { - isProvisioning = false - provisioningProgress = "" - showAssignSheet = false - selectedBeacon = nil - - // Update table number - if let match = name.range(of: #"Table\s+(\d+)"#, options: [.regularExpression, .caseInsensitive]) { - let numberStr = name[match].split(separator: " ").last.flatMap { String($0) } ?? "" - if let savedNumber = Int(numberStr), savedNumber >= nextTableNumber { - nextTableNumber = savedNumber + 1 - } else { - nextTableNumber += 1 - } - } else { - nextTableNumber += 1 - } - - provisionedCount += 1 - showSnack("Saved \"\(name)\"") - - // Remove beacon from list - if let beacon = selectedBeacon { - bleScanner.discoveredBeacons.removeAll { $0.id == beacon.id } - } - } - - private func failProvisioning(_ error: String) { - DebugLog.shared.log("[ScanView] Provisioning failed: \(error)") - isProvisioning = false - provisioningProgress = "" - provisioningError = error - } - - // UUID formatting now handled by String.uuidWithDashes (UUIDFormatting.swift) -} diff --git a/PayfritBeacon/ServicePointListView.swift b/PayfritBeacon/ServicePointListView.swift deleted file mode 100644 index d70f663..0000000 --- a/PayfritBeacon/ServicePointListView.swift +++ /dev/null @@ -1,338 +0,0 @@ -import SwiftUI - -struct ServicePointListView: View { - let businessId: Int - let businessName: String - var onBack: () -> Void - - @State private var namespace: BusinessNamespace? - @State private var servicePoints: [ServicePoint] = [] - @State private var isLoading = true - @State private var errorMessage: String? - - // Add service point - @State private var showAddSheet = false - @State private var newServicePointName = "" - @State private var isAdding = false - - // Beacon scan - @State private var showScanView = false - - // Re-provision existing service point - @State private var tappedServicePoint: ServicePoint? - @State private var reprovisionServicePoint: ServicePoint? - - - var body: some View { - NavigationStack { - Group { - if isLoading { - VStack { - Spacer() - ProgressView("Loading...") - Spacer() - } - } else if let error = errorMessage { - VStack(spacing: 16) { - Spacer() - Image(systemName: "exclamationmark.triangle") - .font(.largeTitle) - .foregroundColor(.orange) - Text(error) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - Button("Retry") { loadData() } - .buttonStyle(.borderedProminent) - .tint(.payfritGreen) - Spacer() - } - .padding() - } else { - List { - // Namespace section - if let ns = namespace { - Section { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("UUID") - .font(.caption) - .foregroundColor(.secondary) - Spacer() - Text(ns.uuid.uuidWithDashes) - .font(.system(.caption, design: .monospaced)) - Button { - UIPasteboard.general.string = ns.uuid.uuidWithDashes - } label: { - Image(systemName: "doc.on.doc") - .font(.caption) - } - } - HStack { - Text("Major") - .font(.caption) - .foregroundColor(.secondary) - Spacer() - Text("\(ns.major)") - .font(.system(.body, design: .monospaced).weight(.semibold)) - Button { - UIPasteboard.general.string = "\(ns.major)" - } label: { - Image(systemName: "doc.on.doc") - .font(.caption) - } - } - } - } header: { - Label("Beacon Namespace", systemImage: "antenna.radiowaves.left.and.right") - } - } - - // Service points section - Section { - if servicePoints.isEmpty { - VStack(spacing: 8) { - Text("No service points yet") - .foregroundColor(.secondary) - Text("Tap + to add one") - .font(.caption) - .foregroundColor(.secondary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 20) - } else { - ForEach(servicePoints) { sp in - Button { - tappedServicePoint = sp - } label: { - HStack { - Text(sp.name) - .foregroundColor(.primary) - Spacer() - if let minor = sp.beaconMinor { - Text("Minor: \(minor)") - .font(.caption) - .foregroundColor(.secondary) - } - Image(systemName: "chevron.right") - .font(.caption) - .foregroundColor(.secondary) - } - } - } - } - } header: { - Text("Service Points") - } - } - } - } - .navigationTitle(businessName) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button("Back", action: onBack) - } - ToolbarItemGroup(placement: .navigationBarTrailing) { - Button { - showScanView = true - } label: { - Image(systemName: "antenna.radiowaves.left.and.right") - } - Button { - showAddSheet = true - } label: { - Image(systemName: "plus") - } - } - } - } - .onAppear { loadData() } - .sheet(isPresented: $showAddSheet) { addServicePointSheet } - .fullScreenCover(isPresented: $showScanView) { - ScanView( - businessId: businessId, - businessName: businessName, - onBack: { - showScanView = false - loadData() - } - ) - } - .fullScreenCover(item: $reprovisionServicePoint) { sp in - ScanView( - businessId: businessId, - businessName: businessName, - reprovisionServicePoint: sp, - onBack: { - reprovisionServicePoint = nil - loadData() - } - ) - } - .confirmationDialog( - tappedServicePoint?.name ?? "Service Point", - isPresented: Binding( - get: { tappedServicePoint != nil }, - set: { if !$0 { tappedServicePoint = nil } } - ), - titleVisibility: .visible - ) { - Button("Re-provision Beacon") { - reprovisionServicePoint = tappedServicePoint - tappedServicePoint = nil - } - Button("Delete", role: .destructive) { - if let sp = tappedServicePoint { - deleteServicePoint(sp) - } - tappedServicePoint = nil - } - Button("Cancel", role: .cancel) { - tappedServicePoint = nil - } - } - } - - // MARK: - Add Sheet - - private var addServicePointSheet: some View { - NavigationStack { - VStack(spacing: 20) { - VStack(alignment: .leading, spacing: 8) { - Text("Service Point Name") - .font(.subheadline) - .foregroundColor(.secondary) - TextField("e.g., Table 1", text: $newServicePointName) - .textFieldStyle(.roundedBorder) - .font(.title3) - } - .padding(.horizontal) - - if let ns = namespace { - VStack(alignment: .leading, spacing: 8) { - Text("Beacon config will be assigned automatically:") - .font(.caption) - .foregroundColor(.secondary) - - HStack { - VStack(alignment: .leading) { - Text("UUID").font(.caption2).foregroundColor(.secondary) - Text(ns.uuid.uuidWithDashes) - .font(.system(.caption2, design: .monospaced)) - } - } - - HStack(spacing: 24) { - VStack(alignment: .leading) { - Text("Major").font(.caption2).foregroundColor(.secondary) - Text("\(ns.major)").font(.system(.caption, design: .monospaced).weight(.semibold)) - } - VStack(alignment: .leading) { - Text("Minor").font(.caption2).foregroundColor(.secondary) - Text("Auto").font(.caption.italic()).foregroundColor(.secondary) - } - } - } - .padding() - .background(Color(.systemGray6)) - .cornerRadius(8) - .padding(.horizontal) - } - - Spacer() - } - .padding(.top) - .navigationTitle("Add Service Point") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { showAddSheet = false } - .disabled(isAdding) - } - ToolbarItem(placement: .confirmationAction) { - if isAdding { - ProgressView() - } else { - Button("Add") { addServicePoint() } - .disabled(newServicePointName.trimmingCharacters(in: .whitespaces).isEmpty) - } - } - } - } - .presentationDetents([.medium]) - .interactiveDismissDisabled(isAdding) - .onAppear { - newServicePointName = "Table \(nextTableNumber)" - } - } - - // MARK: - Actions - - private func loadData() { - isLoading = true - errorMessage = nil - - Task { - do { - // Get namespace - let ns = try await Api.shared.allocateBusinessNamespace(businessId: businessId) - namespace = ns - - // Get service points - let sps = try await Api.shared.listServicePoints(businessId: businessId) - servicePoints = sps.sorted { $0.name.localizedCompare($1.name) == .orderedAscending } - - isLoading = false - } catch { - errorMessage = error.localizedDescription - isLoading = false - } - } - } - - private func deleteServicePoint(_ sp: ServicePoint) { - Task { - do { - try await Api.shared.deleteServicePoint(businessId: businessId, servicePointId: sp.servicePointId) - servicePoints.removeAll { $0.servicePointId == sp.servicePointId } - } catch { - errorMessage = error.localizedDescription - } - } - } - - private func addServicePoint() { - let name = newServicePointName.trimmingCharacters(in: .whitespaces) - guard !name.isEmpty else { return } - - isAdding = true - - Task { - do { - let sp = try await Api.shared.saveServicePoint(businessId: businessId, name: name) - servicePoints.append(sp) - servicePoints.sort { $0.name.localizedCompare($1.name) == .orderedAscending } - showAddSheet = false - newServicePointName = "" - isAdding = false - } catch { - // Show error somehow - isAdding = false - } - } - } - - private var nextTableNumber: Int { - let maxNumber = servicePoints.compactMap { sp -> Int? in - guard let match = sp.name.range(of: #"Table\s+(\d+)"#, options: [.regularExpression, .caseInsensitive]) else { - return nil - } - let numberStr = sp.name[match].split(separator: " ").last.flatMap { String($0) } ?? "" - return Int(numberStr) - }.max() ?? 0 - return maxNumber + 1 - } - - // UUID formatting now handled by String.uuidWithDashes (UUIDFormatting.swift) -} - diff --git a/PayfritBeacon/Services/APIClient.swift b/PayfritBeacon/Services/APIClient.swift new file mode 100644 index 0000000..f2303e2 --- /dev/null +++ b/PayfritBeacon/Services/APIClient.swift @@ -0,0 +1,262 @@ +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.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.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.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.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.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.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.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.self, from: data) + return resp.data?.name ?? "Unknown" + } + + // MARK: - Internal + + private struct EmptyData: Codable {} + + private struct APIResponse: 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 + } +} diff --git a/PayfritBeacon/Services/APIConfig.swift b/PayfritBeacon/Services/APIConfig.swift new file mode 100644 index 0000000..44e1884 --- /dev/null +++ b/PayfritBeacon/Services/APIConfig.swift @@ -0,0 +1,17 @@ +import Foundation + +/// API configuration — matches Android's dev/prod flavor setup +enum APIConfig { + #if DEBUG + static let baseURL = "https://dev.payfrit.com/api" + static let imageBaseURL = "https://dev.payfrit.com" + static let isDev = true + #else + static let baseURL = "https://biz.payfrit.com/api" + static let imageBaseURL = "https://biz.payfrit.com" + static let isDev = false + #endif + + static let connectTimeout: TimeInterval = 10 + static let readTimeout: TimeInterval = 30 +} diff --git a/PayfritBeacon/Services/BLEManager.swift b/PayfritBeacon/Services/BLEManager.swift new file mode 100644 index 0000000..b00ec1a --- /dev/null +++ b/PayfritBeacon/Services/BLEManager.swift @@ -0,0 +1,199 @@ +import Foundation +import CoreBluetooth +import Combine + +/// Central BLE manager — handles scanning and beacon type detection +/// Matches Android's BeaconScanner.kt behavior +@MainActor +final class BLEManager: NSObject, ObservableObject { + + // MARK: - Published State + + @Published var isScanning = false + @Published var discoveredBeacons: [DiscoveredBeacon] = [] + @Published var bluetoothState: CBManagerState = .unknown + + // MARK: - Constants (matching Android) + + static let scanDuration: TimeInterval = 5.0 + static let verifyScanDuration: TimeInterval = 15.0 + static let verifyPollInterval: TimeInterval = 0.5 + + // GATT Service UUIDs + static let ffe0Service = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB") + static let fff0Service = CBUUID(string: "0000FFF0-0000-1000-8000-00805F9B34FB") + + // MARK: - Private + + private(set) var centralManager: CBCentralManager! + private var scanTimer: Timer? + private var scanContinuation: CheckedContinuation<[DiscoveredBeacon], Never>? + + // MARK: - Init + + override init() { + super.init() + centralManager = CBCentralManager(delegate: self, queue: .main) + } + + // MARK: - Scanning + + /// Scan for beacons for the given duration. Returns discovered beacons sorted by RSSI. + func scan(duration: TimeInterval = scanDuration) async -> [DiscoveredBeacon] { + guard bluetoothState == .poweredOn else { return [] } + + discoveredBeacons = [] + isScanning = true + + let results = await withCheckedContinuation { (continuation: CheckedContinuation<[DiscoveredBeacon], Never>) in + scanContinuation = continuation + + centralManager.scanForPeripherals(withServices: nil, options: [ + CBCentralManagerScanOptionAllowDuplicatesKey: true + ]) + + scanTimer = Timer.scheduledTimer(withTimeInterval: duration, repeats: false) { [weak self] _ in + Task { @MainActor in + self?.stopScan() + } + } + } + + return results.sorted { $0.rssi > $1.rssi } + } + + func stopScan() { + centralManager.stopScan() + scanTimer?.invalidate() + scanTimer = nil + isScanning = false + + let results = discoveredBeacons + if let cont = scanContinuation { + scanContinuation = nil + cont.resume(returning: results) + } + } + + /// Verify a beacon is broadcasting expected iBeacon values. + /// Scans for up to `verifyScanDuration` looking for matching UUID/Major/Minor. + func verifyBroadcast(uuid: String, major: UInt16, minor: UInt16) async -> VerifyResult { + // TODO: Implement iBeacon region monitoring via CLLocationManager + // CoreLocation's CLBeaconRegion is the iOS-native way to detect iBeacon broadcasts + // For now, return a placeholder that prompts manual verification + return VerifyResult( + found: false, + rssi: nil, + message: "iBeacon verification requires CLLocationManager — coming soon" + ) + } + + // MARK: - Beacon Type Detection (matches Android BeaconScanner.kt) + + func detectBeaconType( + name: String?, + serviceUUIDs: [CBUUID]?, + manufacturerData: Data? + ) -> BeaconType { + let deviceName = (name ?? "").lowercased() + + // 1. Service UUID matching + if let services = serviceUUIDs { + let serviceStrings = services.map { $0.uuidString.uppercased() } + if serviceStrings.contains(where: { $0.hasPrefix("0000FFF0") }) { + return .bluecharm + } + if serviceStrings.contains(where: { $0.hasPrefix("0000FFE0") }) { + // Could be KBeacon or DXSmart — check name to differentiate + if deviceName.contains("cp28") || deviceName.contains("cp-28") || + deviceName.contains("dx") || deviceName.contains("pddaxlque") { + return .dxsmart + } + return .kbeacon + } + } + + // 2. Device name patterns + if deviceName.contains("kbeacon") || deviceName.contains("kbpro") || + deviceName.hasPrefix("kb") { + return .kbeacon + } + if deviceName.contains("bluecharm") || deviceName.hasPrefix("bc") || + deviceName.hasPrefix("table-") { + return .bluecharm + } + if deviceName.contains("cp28") || deviceName.contains("cp-28") || + deviceName.contains("dx-smart") || deviceName.contains("dxsmart") || + deviceName.contains("pddaxlque") { + return .dxsmart + } + + // 3. Generic beacon patterns + if deviceName.contains("ibeacon") || deviceName.contains("beacon") || + deviceName.hasPrefix("ble") { + return .dxsmart // Default to DXSmart like Android + } + + // 4. Check manufacturer data for iBeacon advertisement + if let mfgData = manufacturerData, mfgData.count >= 23 { + // Apple iBeacon prefix: 0x4C00 0215 + if mfgData.count >= 4 && mfgData[0] == 0x4C && mfgData[1] == 0x00 && + mfgData[2] == 0x02 && mfgData[3] == 0x15 { + // Extract minor (bytes 22-23) — high minors suggest DXSmart factory defaults + if mfgData.count >= 24 { + let minorVal = UInt16(mfgData[22]) << 8 | UInt16(mfgData[23]) + if minorVal > 10000 { return .dxsmart } + } + return .kbeacon + } + } + + return .unknown + } +} + +// MARK: - CBCentralManagerDelegate + +extension BLEManager: CBCentralManagerDelegate { + + nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) { + Task { @MainActor in + bluetoothState = central.state + } + } + + nonisolated func centralManager( + _ central: CBCentralManager, + didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], + rssi RSSI: NSNumber + ) { + Task { @MainActor in + let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "" + let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] + let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data + + let type = detectBeaconType(name: name, serviceUUIDs: serviceUUIDs, manufacturerData: mfgData) + + // Only show recognized beacons + guard type != .unknown else { return } + + if let idx = discoveredBeacons.firstIndex(where: { $0.id == peripheral.identifier }) { + // Update existing + discoveredBeacons[idx].rssi = RSSI.intValue + discoveredBeacons[idx].lastSeen = Date() + } else { + // New beacon + let beacon = DiscoveredBeacon( + id: peripheral.identifier, + peripheral: peripheral, + name: name, + type: type, + rssi: RSSI.intValue, + lastSeen: Date() + ) + discoveredBeacons.append(beacon) + } + } + } +} diff --git a/PayfritBeacon/Services/SecureStorage.swift b/PayfritBeacon/Services/SecureStorage.swift new file mode 100644 index 0000000..83b792a --- /dev/null +++ b/PayfritBeacon/Services/SecureStorage.swift @@ -0,0 +1,68 @@ +import Foundation +import Security + +/// Keychain-backed secure storage for auth tokens +enum SecureStorage { + + private static let service = "com.payfrit.beacon" + + struct Session { + let token: String + let userId: String + } + + static func saveSession(token: String, userId: String) { + save(key: "authToken", value: token) + save(key: "userId", value: userId) + } + + static func loadSession() -> Session? { + guard let token = load(key: "authToken"), + let userId = load(key: "userId") else { return nil } + return Session(token: token, userId: userId) + } + + static func clearSession() { + delete(key: "authToken") + delete(key: "userId") + } + + // MARK: - Keychain Helpers + + private static func save(key: String, value: String) { + guard let data = value.data(using: .utf8) else { return } + delete(key: key) // Remove old value first + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly + ] + SecItemAdd(query as CFDictionary, nil) + } + + private static func load(key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + private static func delete(key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + SecItemDelete(query as CFDictionary) + } +} diff --git a/PayfritBeacon/UUIDFormatting.swift b/PayfritBeacon/UUIDFormatting.swift deleted file mode 100644 index 27d141d..0000000 --- a/PayfritBeacon/UUIDFormatting.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Foundation - -// MARK: - UUID Formatting Utilities -// Consolidated UUID normalization and formatting logic. -// Previously duplicated across Api.swift, BeaconScanner.swift, ScanView.swift, -// ServicePointListView.swift, and BeaconBanList.swift. - -extension String { - - /// Normalize a UUID string: strip dashes, uppercase. - /// Input: "e2c56db5-dffb-48d2-b060-d0f5a71096e0" or "E2C56DB5DFFB48D2B060D0F5A71096E0" - /// Output: "E2C56DB5DFFB48D2B060D0F5A71096E0" - var normalizedUUID: String { - replacingOccurrences(of: "-", with: "").uppercased() - } - - /// Format a 32-char hex string into standard UUID format (8-4-4-4-12). - /// Input: "E2C56DB5DFFB48D2B060D0F5A71096E0" - /// Output: "E2C56DB5-DFFB-48D2-B060-D0F5A71096E0" - /// Returns the original string unchanged if it's not exactly 32 hex chars after normalization. - var uuidWithDashes: String { - let clean = normalizedUUID - guard clean.count == 32 else { return self } - let c = Array(clean) - return "\(String(c[0..<8]))-\(String(c[8..<12]))-\(String(c[12..<16]))-\(String(c[16..<20]))-\(String(c[20..<32]))" - } -} diff --git a/PayfritBeacon/Utils/BeaconBanList.swift b/PayfritBeacon/Utils/BeaconBanList.swift new file mode 100644 index 0000000..38c94d8 --- /dev/null +++ b/PayfritBeacon/Utils/BeaconBanList.swift @@ -0,0 +1,24 @@ +import Foundation + +/// Factory-default UUIDs that indicate an unconfigured beacon (matches Android BeaconBanList.kt) +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 + ] + + /// Check if a UUID is a factory default + static func isBanned(_ uuid: String) -> Bool { + let normalized = uuid.normalizedUUID + let prefix = String(normalized.prefix(8)) + return bannedPrefixes.contains(prefix) + } +} diff --git a/PayfritBeacon/Utils/BeaconShardPool.swift b/PayfritBeacon/Utils/BeaconShardPool.swift new file mode 100644 index 0000000..61e3871 --- /dev/null +++ b/PayfritBeacon/Utils/BeaconShardPool.swift @@ -0,0 +1,73 @@ +import Foundation + +/// Pre-allocated Payfrit shard UUIDs for business namespace allocation +/// Matches Android's BeaconShardPool.kt +enum BeaconShardPool { + + static let shardUUIDs: [String] = [ + "f7826da6-4fa2-4e98-8024-bc5b71e0893e", + "2f234454-cf6d-4a0f-adf2-f4911ba9ffa6", + "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", + ] +} diff --git a/PayfritBeacon/Utils/UUIDFormatting.swift b/PayfritBeacon/Utils/UUIDFormatting.swift new file mode 100644 index 0000000..d486a1a --- /dev/null +++ b/PayfritBeacon/Utils/UUIDFormatting.swift @@ -0,0 +1,32 @@ +import Foundation + +extension String { + + /// Strip dashes, uppercase: "e2c56db5-..." → "E2C56DB5..." + var normalizedUUID: String { + replacingOccurrences(of: "-", with: "").uppercased() + } + + /// Format 32 hex chars → standard UUID (8-4-4-4-12) + var uuidWithDashes: String { + let clean = normalizedUUID + guard clean.count == 32 else { return self } + let c = Array(clean) + return "\(String(c[0..<8]))-\(String(c[8..<12]))-\(String(c[12..<16]))-\(String(c[16..<20]))-\(String(c[20..<32]))" + } + + /// Convert hex string to byte array + var hexToBytes: [UInt8] { + let clean = normalizedUUID + var bytes: [UInt8] = [] + var i = clean.startIndex + while i < clean.endIndex { + let next = clean.index(i, offsetBy: 2, limitedBy: clean.endIndex) ?? clean.endIndex + if let byte = UInt8(clean[i.. some View { + VStack(spacing: 16) { + Spacer() + ProgressView() + .scaleEffect(1.5) + Text(title) + .font(.headline) + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + Spacer() + } + } + + private var successView: some View { + VStack(spacing: 24) { + Spacer() + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 64)) + .foregroundStyle(.green) + Text("Beacon Provisioned!") + .font(.title2.bold()) + Text(statusMessage) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + Button("Provision Another") { + resetProvisioningState() + } + .buttonStyle(.borderedProminent) + Spacer() + } + } + + private var failedView: some View { + VStack(spacing: 24) { + Spacer() + Image(systemName: "xmark.circle.fill") + .font(.system(size: 64)) + .foregroundStyle(.red) + Text("Provisioning Failed") + .font(.title2.bold()) + Text(errorMessage ?? "Unknown error") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + + HStack(spacing: 16) { + Button("Try Again") { + if let beacon = selectedBeacon { + Task { await startProvisioning(beacon) } + } + } + .buttonStyle(.borderedProminent) + + Button("Register Anyway") { + Task { await registerAnywayAfterFailure() } + } + .buttonStyle(.bordered) + } + + Button("Cancel") { + resetProvisioningState() + } + .foregroundStyle(.secondary) + Spacer() + } + } + + // MARK: - Create Service Point Sheet + + private var createServicePointSheet: some View { + NavigationStack { + Form { + TextField("Name (e.g. Table 1)", text: $newServicePointName) + } + .navigationTitle("New Service Point") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { showCreateServicePoint = false } + } + ToolbarItem(placement: .confirmationAction) { + Button("Create") { + Task { await createServicePoint() } + } + .disabled(newServicePointName.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + } + .presentationDetents([.medium]) + } + + // MARK: - Actions + + private func loadServicePoints() async { + guard let token = appState.token else { return } + do { + servicePoints = try await APIClient.shared.listServicePoints( + businessId: business.id, token: token + ) + } catch { + errorMessage = error.localizedDescription + } + } + + private func loadNamespace() async { + guard let token = appState.token else { return } + isLoadingNamespace = true + do { + let ns = try await APIClient.shared.allocateBusinessNamespace( + businessId: business.id, token: token + ) + namespace = ns + } catch { + errorMessage = error.localizedDescription + } + isLoadingNamespace = false + } + + private func createServicePoint() async { + guard let token = appState.token else { return } + let name = newServicePointName.trimmingCharacters(in: .whitespaces) + guard !name.isEmpty else { return } + + do { + let sp = try await APIClient.shared.createServicePoint( + name: name, businessId: business.id, token: token + ) + servicePoints.append(sp) + selectedServicePoint = sp + showCreateServicePoint = false + newServicePointName = "" + } catch { + errorMessage = error.localizedDescription + } + } + + /// Smart-increment: parse existing names, suggest "Table N+1" + private func suggestServicePointName() { + let numbers = servicePoints.compactMap { sp -> Int? in + let parts = sp.name.split(separator: " ") + guard parts.count >= 2, parts[0].lowercased() == "table" else { return nil } + return Int(parts[1]) + } + let next = (numbers.max() ?? 0) + 1 + newServicePointName = "Table \(next)" + } + + private func startScan() async { + provisioningState = .scanning + let _ = await bleManager.scan() + if provisioningState == .scanning { + provisioningState = .idle + } + } + + private func startProvisioning(_ beacon: DiscoveredBeacon) async { + guard let sp = selectedServicePoint, + let ns = namespace, + let token = appState.token else { return } + + provisioningState = .connecting + statusMessage = "Connecting to \(beacon.displayName)…" + errorMessage = nil + + do { + // Allocate minor for this service point + let minor = try await APIClient.shared.allocateMinor( + businessId: business.id, servicePointId: sp.id, token: token + ) + + let config = BeaconConfig( + uuid: ns.uuid.normalizedUUID, + major: UInt16(ns.major), + minor: UInt16(minor), + measuredPower: BeaconConfig.defaultMeasuredPower, + advInterval: BeaconConfig.defaultAdvInterval, + txPower: BeaconConfig.defaultTxPower, + servicePointName: sp.name, + businessName: business.name + ) + + // Create appropriate provisioner + let provisioner = makeProvisioner(for: beacon) + + statusMessage = "Authenticating with \(beacon.type.rawValue)…" + try await provisioner.connect() + + // DXSmart: stop at connected state, wait for user to confirm flashing + if beacon.type == .dxsmart { + provisioningState = .connected + // Store config and provisioner for later use + pendingConfig = config + pendingProvisioner = provisioner + return + } + + // KBeacon / BlueCharm: write immediately + provisioningState = .writing + statusMessage = "Writing \(config.formattedUUID.prefix(8))… Major:\(config.major) Minor:\(config.minor)" + try await provisioner.writeConfig(config) + provisioner.disconnect() + + // Register with backend + try await APIClient.shared.registerBeaconHardware( + businessId: business.id, + servicePointId: sp.id, + uuid: ns.uuid, + major: ns.major, + minor: minor, + macAddress: nil, + beaconType: beacon.type.rawValue, + token: token + ) + + // Verify broadcast + provisioningState = .verifying + statusMessage = "Waiting for beacon to restart…" + try await Task.sleep(nanoseconds: UInt64(GATTConstants.postFlashDelay * 1_000_000_000)) + + statusMessage = "Scanning for broadcast…" + let verifyResult = await bleManager.verifyBroadcast( + uuid: ns.uuid, major: config.major, minor: config.minor + ) + + if verifyResult.found { + try await APIClient.shared.verifyBeaconBroadcast( + uuid: ns.uuid, major: ns.major, minor: minor, token: token + ) + } + + provisioningState = .done + statusMessage = "\(sp.name) — \(beacon.type.rawValue)\nUUID: \(config.formattedUUID.prefix(13))…\nMajor: \(config.major) Minor: \(config.minor)" + + } catch { + provisioningState = .failed + errorMessage = error.localizedDescription + } + } + + // Store for DXSmart two-phase flow + @State private var pendingConfig: BeaconConfig? + @State private var pendingProvisioner: (any BeaconProvisioner)? + + private func writeConfigToConnectedBeacon() async { + guard let config = pendingConfig, + let provisioner = pendingProvisioner, + let sp = selectedServicePoint, + let ns = namespace, + let token = appState.token else { return } + + provisioningState = .writing + statusMessage = "Writing config to DX-Smart…" + + do { + try await provisioner.writeConfig(config) + provisioner.disconnect() + + try await APIClient.shared.registerBeaconHardware( + businessId: business.id, + servicePointId: sp.id, + uuid: ns.uuid, + major: ns.major, + minor: Int(config.minor), + macAddress: nil, + beaconType: BeaconType.dxsmart.rawValue, + token: token + ) + + provisioningState = .done + statusMessage = "\(sp.name) — DX-Smart\nUUID: \(config.formattedUUID.prefix(13))…\nMajor: \(config.major) Minor: \(config.minor)" + + } catch { + provisioningState = .failed + errorMessage = error.localizedDescription + } + + pendingConfig = nil + pendingProvisioner = nil + } + + private func registerAnywayAfterFailure() async { + guard let sp = selectedServicePoint, + let ns = namespace, + let config = pendingConfig ?? makeCurrentConfig(), + let token = appState.token else { + resetProvisioningState() + return + } + + do { + try await APIClient.shared.registerBeaconHardware( + businessId: business.id, + servicePointId: sp.id, + uuid: ns.uuid, + major: ns.major, + minor: Int(config.minor), + macAddress: nil, + beaconType: selectedBeacon?.type.rawValue ?? "Unknown", + token: token + ) + provisioningState = .done + statusMessage = "Registered (broadcast unverified)\n\(sp.name) — Minor: \(config.minor)" + } catch { + errorMessage = "Registration failed: \(error.localizedDescription)" + } + } + + private func makeCurrentConfig() -> BeaconConfig? { + // Only used for "Register Anyway" fallback + return nil + } + + private func cancelProvisioning() { + pendingProvisioner?.disconnect() + pendingProvisioner = nil + pendingConfig = nil + resetProvisioningState() + } + + private func resetProvisioningState() { + provisioningState = .idle + statusMessage = "" + errorMessage = nil + selectedBeacon = nil + pendingConfig = nil + pendingProvisioner = nil + } + + private func makeProvisioner(for beacon: DiscoveredBeacon) -> any BeaconProvisioner { + switch beacon.type { + case .kbeacon: + return KBeaconProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager) + case .dxsmart: + return DXSmartProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager) + case .bluecharm: + return BlueCharmProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager) + case .unknown: + return KBeaconProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager) + } + } +} + +// MARK: - Beacon Row + +struct BeaconRow: View { + let beacon: DiscoveredBeacon + + var body: some View { + HStack(spacing: 12) { + // Signal strength indicator + Image(systemName: signalIcon) + .font(.title2) + .foregroundStyle(signalColor) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 4) { + Text(beacon.displayName) + .font(.headline) + + HStack(spacing: 8) { + Text(beacon.type.rawValue) + .font(.caption.bold()) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(typeColor.opacity(0.15), in: Capsule()) + .foregroundStyle(typeColor) + + Text("\(beacon.rssi) dBm") + .font(.caption) + .foregroundStyle(.secondary) + + Text(beacon.signalDescription) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + Image(systemName: "chevron.right") + .foregroundStyle(.tertiary) + } + .padding(.vertical, 4) + } + + private var signalIcon: String { + switch beacon.rssi { + case -50...0: return "wifi" + case -65 ... -51: return "wifi" + case -80 ... -66: return "wifi.exclamationmark" + default: return "wifi.slash" + } + } + + private var signalColor: Color { + switch beacon.rssi { + case -50...0: return .green + case -65 ... -51: return .blue + case -80 ... -66: return .orange + default: return .red + } + } + + private var typeColor: Color { + switch beacon.type { + case .kbeacon: return .blue + case .dxsmart: return .orange + case .bluecharm: return .purple + case .unknown: return .gray + } + } +} + diff --git a/PayfritBeacon/en.lproj/InfoPlist.strings b/PayfritBeacon/en.lproj/InfoPlist.strings deleted file mode 100644 index ce805e8..0000000 --- a/PayfritBeacon/en.lproj/InfoPlist.strings +++ /dev/null @@ -1,2 +0,0 @@ -CFBundleDisplayName = "Payfrit Beacon"; -CFBundleName = "Payfrit Beacon"; diff --git a/PayfritBeacon/payfrit-favicon-light-outlines.svg b/PayfritBeacon/payfrit-favicon-light-outlines.svg deleted file mode 100644 index 9007140..0000000 --- a/PayfritBeacon/payfrit-favicon-light-outlines.svg +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/Podfile b/Podfile deleted file mode 100644 index 9aad56c..0000000 --- a/Podfile +++ /dev/null @@ -1,15 +0,0 @@ -platform :ios, '16.0' -use_frameworks! - -target 'PayfritBeacon' do - pod 'Kingfisher', '~> 7.0' - pod 'SVGKit', '~> 3.0' -end - -post_install do |installer| - installer.pods_project.targets.each do |target| - target.build_configurations.each do |config| - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.0' - end - end -end diff --git a/Podfile.lock b/Podfile.lock deleted file mode 100644 index 377b246..0000000 --- a/Podfile.lock +++ /dev/null @@ -1,26 +0,0 @@ -PODS: - - CocoaLumberjack (3.9.0): - - CocoaLumberjack/Core (= 3.9.0) - - CocoaLumberjack/Core (3.9.0) - - Kingfisher (7.12.0) - - SVGKit (3.0.0): - - CocoaLumberjack (~> 3.0) - -DEPENDENCIES: - - Kingfisher (~> 7.0) - - SVGKit (~> 3.0) - -SPEC REPOS: - trunk: - - CocoaLumberjack - - Kingfisher - - SVGKit - -SPEC CHECKSUMS: - CocoaLumberjack: 5644158777912b7de7469fa881f8a3f259c2512a - Kingfisher: 53a10ea35051a436b5fb626ca2dd8f3144d755e9 - SVGKit: 1ad7513f8c74d9652f94ed64ddecda1a23864dea - -PODFILE CHECKSUM: 548c7a88f61dd6e8448dba1f369d199e3053e0b6 - -COCOAPODS: 1.16.2 diff --git a/QA_WALKTHROUGH.md b/QA_WALKTHROUGH.md deleted file mode 100644 index 1ce41ba..0000000 --- a/QA_WALKTHROUGH.md +++ /dev/null @@ -1,329 +0,0 @@ -# Payfrit Beacon iOS — QA Walkthrough Test Document - -## Overview - -**App:** Payfrit Beacon iOS (SwiftUI) -**Purpose:** BLE beacon management for business locations -**Auth:** Token-based with Keychain storage + biometric -**Environment:** Dev (`dev.payfrit.com`) / Prod (`biz.payfrit.com`) — hardcoded in `APIService.swift:50` - ---- - -## Pre-Test Setup - -- [ ] Device has iOS 14+ -- [ ] Bluetooth enabled in Settings -- [ ] Location Services enabled in Settings -- [ ] Stable network connection (Wi-Fi or LTE) -- [ ] A physical BLE beacon available for scanner tests -- [ ] Test account with at least 1 business, 1 beacon, 1 service point - ---- - -## 1. Authentication Flow - -### 1.1 First-Time Login - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Launch app (fresh install) | LoginScreen shown | -| 2 | Leave fields empty, tap "Sign In" | Button disabled (name/password empty) | -| 3 | Enter email only, tap "Sign In" | Error: "Please enter username and password" | -| 4 | Enter valid email + wrong password | Error: "Invalid email/phone or password" | -| 5 | Enter valid credentials, tap "Sign In" | Loading spinner on button, navigates to BusinessSelectionScreen | -| 6 | Kill and relaunch app | Saved auth loaded, skips login | - -### 1.2 Biometric Re-Auth (Device Only, Skipped on Simulator) - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Relaunch app with saved auth | Face ID / Touch ID prompt appears | -| 2 | Authenticate successfully | Navigates to BusinessSelectionScreen | -| 3 | Relaunch, cancel biometric | Still loads saved auth (fallback) | - -### 1.3 Logout - -| Step | Action | Expected | -|------|--------|----------| -| 1 | From BusinessSelectionScreen, tap logout (top-right arrow icon) | Clears Keychain + UserDefaults, returns to LoginScreen | -| 2 | Kill and relaunch | LoginScreen shown (no saved auth) | - -### 1.4 Session Expiry (401) - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Login, wait for token to expire (or invalidate server-side) | Next API call returns 401 | -| 2 | Try any action (list beacons, etc.) | Auto-logout, returns to LoginScreen | - -### 1.5 Dev Mode Indicator - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Launch in dev environment | LoginScreen shows red "DEV MODE — password: 123456" | -| 2 | After login, check bottom-left of RootView | Orange "DEV" banner visible | - ---- - -## 2. Business Selection - -### 2.1 Load Businesses - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Login successfully | BusinessSelectionScreen loads with spinner | -| 2 | Wait for load | List of businesses with name + city | -| 3 | Tap a business | Pushes to BeaconDashboard, shows "Loading..." then TabView | - -### 2.2 Edge Cases - -| Scenario | Expected | -|----------|----------| -| User has no businesses | "No businesses found" message, can only logout | -| Network error during load | Error message + "Retry" button | -| Tap Retry | Re-fetches business list | -| 401 during load | Auto-logout to LoginScreen | - ---- - -## 3. Beacon Management (Beacons Tab) - -### 3.1 View Beacon List - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Tap "Beacons" tab | List loads with spinner | -| 2 | Verify beacon rows | Each shows: name, formatted UUID (XXXXXXXX-XXXX-...), active badge (green check / red X) | -| 3 | Pull down to refresh | Spinner appears, list refreshes | -| 4 | Empty list | "No beacons yet" + "Tap + to add" message | - -### 3.2 Create Beacon - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Tap + button | BeaconEditSheet modal appears | -| 2 | Leave fields empty, tap Save | Button disabled | -| 3 | Enter name only | Button disabled (UUID required) | -| 4 | Enter name + valid 32-char hex UUID | Save enabled | -| 5 | Tap Save | Modal dismisses, list refreshes with new beacon | -| 6 | Network error during save | Error shown in sheet, sheet stays open | - -### 3.3 Edit Beacon - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Tap a beacon row | Pushes to BeaconDetailScreen | -| 2 | Verify pre-filled fields | Name, UUID, Active toggle match beacon data | -| 3 | Verify read-only fields | ID, Business ID, Created, Updated dates shown | -| 4 | Change name, tap "Save Changes" | Returns to list, beacon updated | -| 5 | Toggle Active off, save | Badge changes from green check to red X | -| 6 | Clear name, try save | Button disabled | - -### 3.4 Delete Beacon (Detail Screen) - -| Step | Action | Expected | -|------|--------|----------| -| 1 | From BeaconDetailScreen, tap "Delete Beacon" | Alert: "Delete Beacon?" with warning about service points | -| 2 | Tap "Cancel" | Nothing happens | -| 3 | Tap "Delete" | API call, returns to list, beacon removed | -| 4 | Network error during delete | Error shown, stays on detail screen | - -### 3.5 Delete Beacon (Swipe) - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Swipe left on beacon row | Delete action appears | -| 2 | Tap delete | Beacon removed immediately (optimistic) | -| 3 | If API fails | Beacon re-added to list, error shown | - -### 3.6 Rapid Delete Stress Test - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Swipe-delete 3 beacons quickly | All removed from UI | -| 2 | Wait for API responses | Failed deletes restored, successful ones stay removed | - ---- - -## 4. Service Points (Service Points Tab) - -### 4.1 View Service Points - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Tap "Service Points" tab | List loads (fetches both service points AND beacons) | -| 2 | Verify rows | Name, type, active indicator (green/red circle), beacon assignment | -| 3 | Pull down to refresh | Refreshes both lists | -| 4 | Empty list | "No service points" message | - -### 4.2 Assign Beacon - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Find service point with "No beacon assigned" | "Assign" button visible | -| 2 | Tap "Assign" | BeaconPickerSheet opens with all beacons | -| 3 | Tap a beacon | Sheet dismisses, spinner on row, then beacon name in green | - -### 4.3 Change Beacon Assignment - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Find service point with assigned beacon | "Change" button + X button visible | -| 2 | Tap "Change" | Picker opens, current beacon has checkmark | -| 3 | Select different beacon | Assignment updated | - -### 4.4 Unassign Beacon - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Find service point with assigned beacon | X button visible | -| 2 | Tap X button | API called with nil beaconId, shows "No beacon assigned" | - -### 4.5 Edge Cases - -| Scenario | Expected | -|----------|----------| -| No beacons exist | Picker opens with empty list | -| Network error during assign | Error shown, assignment reverted | -| 401 during any operation | Auto-logout | - ---- - -## 5. Scanner (Scanner Tab) - -### 5.1 Basic Scanning Flow - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Tap "Scanner" tab | Beacon picker loads at top | -| 2 | No beacon selected | "Start Scanning" button disabled | -| 3 | Select a beacon from picker | Button becomes enabled | -| 4 | Tap "Start Scanning" | Status: "Scanning..." (blue antenna icon) | -| 5 | Move close to matching beacon | RSSI value appears, samples count up (X/5) | -| 6 | Stay close for 5+ samples with RSSI >= -75 | "Beacon Detected! (avg -XX dBm)" (green checkmark) | -| 7 | Tap "Stop Scanning" | Status resets to "Select a beacon to scan" | - -### 5.2 Signal Strength Visualization - -| RSSI Range | Bar Color | Signal Quality | -|------------|-----------|---------------| -| -30 to -51 | Green | Strong | -| -52 to -72 | Yellow | Medium | -| -73 to -100 | Red | Weak | - -### 5.3 RSSI Threshold Behavior - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Scanning, RSSI >= -75 | Sample appended, count increments | -| 2 | RSSI drops below -75 | All samples cleared, count resets to 0 | -| 3 | RSSI returns above -75 | Samples start accumulating again from 0 | -| 4 | Beacon disappears entirely (RSSI = 0) | Samples cleared, "Searching for beacon signal..." | - -### 5.4 Permission Handling - -| Scenario | Expected | -|----------|----------| -| Location not determined | System prompt shown, scanning waits | -| Location granted after prompt | Scanning starts automatically | -| Location denied | "Location Permission Denied" (red), scanning stops | -| Location denied in Settings | Same as above on next scan attempt | -| Bluetooth off | "Bluetooth is OFF" (orange), scanning stops | -| Bluetooth turned off mid-scan | Detected within 5 seconds, scanning stops | - -### 5.5 Beacon Change During Scan - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Start scanning for Beacon A | Scanning active | -| 2 | Change picker to Beacon B | Scan stops immediately | -| 3 | Tap "Start Scanning" | New scan starts for Beacon B | - -### 5.6 Screen Lock Prevention - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Start scanning | Screen stays awake (idle timer disabled) | -| 2 | Stop scanning | Idle timer re-enabled, screen can lock normally | - -### 5.7 Error Cases - -| Scenario | Expected | -|----------|----------| -| Invalid UUID format on beacon | Error: "Invalid beacon UUID format" | -| Ranging failure (CLLocationManager error) | Error: "Beacon ranging failed: [description]" | -| Beacon list fails to load | Picker empty, scanner still functional if UUID known | - ---- - -## 6. Cross-Cutting Concerns - -### 6.1 Network Error Handling (All Screens) - -| Scenario | Expected | -|----------|----------| -| Airplane mode during API call | Network error displayed | -| Server returns 500 | "HTTP 500" error shown | -| Server returns non-JSON | "Decoding error: Non-JSON response" | -| Server returns `{"OK": false, "ERROR": "..."}` | Error message from server shown | -| 401 on any authenticated endpoint | Auto-logout to LoginScreen | - -### 6.2 Navigation - -| Test | Expected | -|------|----------| -| Back button from BeaconDetailScreen | Returns to BeaconListScreen | -| Back button from BeaconDashboard | Returns to BusinessSelectionScreen | -| Tab switching in BeaconDashboard | Beacons / Service Points / Scanner tabs all functional | -| Deep link: Business > Beacon > Detail > Back > Back | Full nav stack unwinds cleanly | - -### 6.3 Data Consistency - -| Test | Expected | -|------|----------| -| Add beacon, switch to Service Points tab | New beacon available in picker | -| Delete beacon assigned to service point | Service point shows "No beacon assigned" on refresh | -| Edit beacon name | Updated name shows in list and service point rows | - ---- - -## 7. API Endpoints Reference - -| Action | Method | Endpoint | -|--------|--------|----------| -| Login | POST | `/auth/login.cfm` | -| List businesses | POST | `/workers/myBusinesses.cfm` | -| List beacons | POST | `/beacons/list.cfm` | -| Get beacon | POST | `/beacons/get.cfm` | -| Create beacon | POST | `/beacons/create.cfm` | -| Update beacon | POST | `/beacons/update.cfm` | -| Delete beacon | POST | `/beacons/delete.cfm` | -| List service points | POST | `/servicePoints/list.cfm` | -| List SP types | POST | `/servicePoints/types.cfm` | -| Assign beacon to SP | POST | `/servicePoints/assignBeacon.cfm` | - -All requests include headers: -- `Content-Type: application/json` -- `X-User-Token: ` (after login) -- `X-Business-ID: ` (after business selection) - ---- - -## 8. Permissions Checklist - -| Permission | Info.plist Key | When Prompted | -|-----------|----------------|---------------| -| Location (When In Use) | `NSLocationWhenInUseUsageDescription` | First beacon scan | -| Location (Always) | `NSLocationAlwaysAndWhenInUseUsageDescription` | Background scanning | -| Bluetooth | `NSBluetoothAlwaysUsageDescription` | First beacon scan | -| Face ID | `NSFaceIDUsageDescription` | App relaunch with saved auth | - ---- - -## 9. Known Limitations - -1. **Environment switching** requires code change + recompile (`APIService.swift:50`) -2. **Service points are read-only** — can only assign/unassign beacons, not create/edit/delete SPs -3. **No token refresh** — expired tokens force full re-login -4. **Single scanner** — only one scan at a time, changing beacon stops previous -5. **Background scanning** depends on iOS version and background mode support -6. **Photo URLs** resolved but not cached across sessions diff --git a/_backup/Models/Beacon.swift b/_backup/Models/Beacon.swift deleted file mode 100644 index 555d978..0000000 --- a/_backup/Models/Beacon.swift +++ /dev/null @@ -1,68 +0,0 @@ -import Foundation - -struct Beacon: Identifiable { - let id: Int - let businessId: Int - let name: String - let uuid: String - let namespaceId: String - let instanceId: String - let isActive: Bool - let createdAt: Date? - let updatedAt: Date? - - init(json: [String: Any]) { - id = Self.parseInt(json["ID"] ?? json["BeaconID"]) ?? 0 - businessId = Self.parseInt(json["BusinessID"]) ?? 0 - name = (json["Name"] as? String) ?? (json["BeaconName"] as? String) ?? "" - uuid = (json["UUID"] as? String) ?? (json["BeaconUUID"] as? String) ?? "" - namespaceId = (json["NamespaceId"] as? String) ?? "" - instanceId = (json["InstanceId"] as? String) ?? "" - isActive = Self.parseBool(json["IsActive"]) ?? true - createdAt = Self.parseDate(json["CreatedAt"]) - updatedAt = Self.parseDate(json["UpdatedAt"]) - } - - /// Format the raw 32-char hex UUID into standard 8-4-4-4-12 format - var formattedUUID: String { - let clean = uuid.replacingOccurrences(of: "-", with: "").uppercased() - guard clean.count == 32 else { return uuid } - let i = clean.startIndex - let p1 = clean[i.. Int? { - guard let value = value else { return nil } - if let v = value as? Int { return v } - if let v = value as? Double { return Int(v) } - if let v = value as? NSNumber { return v.intValue } - if let v = value as? String, let i = Int(v) { return i } - return nil - } - - static func parseBool(_ value: Any?) -> Bool? { - guard let value = value else { return nil } - if let b = value as? Bool { return b } - if let i = value as? Int { return i == 1 } - if let s = value as? String { - let lower = s.lowercased() - if lower == "true" || lower == "1" || lower == "yes" { return true } - if lower == "false" || lower == "0" || lower == "no" { return false } - } - return nil - } - - static func parseDate(_ value: Any?) -> Date? { - guard let value = value else { return nil } - if let d = value as? Date { return d } - if let s = value as? String { return APIService.parseDate(s) } - return nil - } -} diff --git a/_backup/Models/Employment.swift b/_backup/Models/Employment.swift deleted file mode 100644 index 00b0ded..0000000 --- a/_backup/Models/Employment.swift +++ /dev/null @@ -1,32 +0,0 @@ -import Foundation - -struct Employment: Identifiable { - /// Composite ID to avoid collisions when same employee works at multiple businesses - var id: String { "\(employeeId)-\(businessId)" } - let employeeId: Int - let businessId: Int - let businessName: String - let businessAddress: String - let businessCity: String - let employeeStatusId: Int - let pendingTaskCount: Int - - var statusName: String { - switch employeeStatusId { - case 1: return "Active" - case 2: return "Suspended" - case 3: return "Terminated" - default: return "Unknown" - } - } - - init(json: [String: Any]) { - employeeId = Beacon.parseInt(json["EmployeeID"]) ?? 0 - businessId = Beacon.parseInt(json["BusinessID"]) ?? 0 - businessName = (json["BusinessName"] as? String) ?? (json["Name"] as? String) ?? "" - businessAddress = (json["BusinessAddress"] as? String) ?? (json["Address"] as? String) ?? "" - businessCity = (json["BusinessCity"] as? String) ?? (json["City"] as? String) ?? "" - employeeStatusId = Beacon.parseInt(json["EmployeeStatusID"] ?? json["StatusID"]) ?? 0 - pendingTaskCount = Beacon.parseInt(json["PendingTaskCount"]) ?? 0 - } -} diff --git a/_backup/Models/ServicePoint.swift b/_backup/Models/ServicePoint.swift deleted file mode 100644 index 6229394..0000000 --- a/_backup/Models/ServicePoint.swift +++ /dev/null @@ -1,47 +0,0 @@ -import Foundation - -struct ServicePoint: Identifiable { - let id: Int - let businessId: Int - let name: String - let typeId: Int - let typeName: String - let code: String - let description: String - let sortOrder: Int - let isActive: Bool - let isClosedToNewMembers: Bool - let beaconId: Int? - let assignedByUserId: Int? - let createdAt: Date? - let updatedAt: Date? - - init(json: [String: Any]) { - id = Beacon.parseInt(json["ID"] ?? json["ServicePointID"]) ?? 0 - businessId = Beacon.parseInt(json["BusinessID"]) ?? 0 - name = (json["Name"] as? String) ?? (json["ServicePointName"] as? String) ?? "" - typeId = Beacon.parseInt(json["TypeID"] ?? json["ServicePointTypeID"]) ?? 0 - typeName = (json["TypeName"] as? String) ?? "" - code = (json["Code"] as? String) ?? "" - description = (json["Description"] as? String) ?? "" - sortOrder = Beacon.parseInt(json["SortOrder"]) ?? 0 - isActive = Beacon.parseBool(json["IsActive"]) ?? true - isClosedToNewMembers = Beacon.parseBool(json["IsClosedToNewMembers"]) ?? false - beaconId = Beacon.parseInt(json["BeaconID"]) - assignedByUserId = Beacon.parseInt(json["AssignedByUserID"]) - createdAt = Beacon.parseDate(json["CreatedAt"]) - updatedAt = Beacon.parseDate(json["UpdatedAt"]) - } -} - -struct ServicePointType: Identifiable { - let id: Int - let name: String - let sortOrder: Int - - init(json: [String: Any]) { - id = Beacon.parseInt(json["ID"]) ?? 0 - name = (json["Name"] as? String) ?? "" - sortOrder = Beacon.parseInt(json["SortOrder"]) ?? 0 - } -} diff --git a/_backup/Services/APIService.swift b/_backup/Services/APIService.swift deleted file mode 100644 index b6dfbbb..0000000 --- a/_backup/Services/APIService.swift +++ /dev/null @@ -1,416 +0,0 @@ -import Foundation - -// MARK: - API Errors - -enum APIError: LocalizedError, Equatable { - case invalidURL - case noData - case decodingError(String) - case serverError(String) - case unauthorized - case networkError(String) - - var errorDescription: String? { - switch self { - case .invalidURL: return "Invalid URL" - case .noData: return "No data received" - case .decodingError(let msg): return "Decoding error: \(msg)" - case .serverError(let msg): return msg - case .unauthorized: return "Unauthorized" - case .networkError(let msg): return msg - } - } -} - -// MARK: - Login Response - -struct LoginResponse { - let userId: Int - let userFirstName: String - let token: String - let photoUrl: String -} - -// MARK: - API Service - -actor APIService { - static let shared = APIService() - - private enum Environment { - case development, production - - var baseURL: String { - switch self { - case .development: return "https://dev.payfrit.com/api" - case .production: return "https://biz.payfrit.com/api" - } - } - } - - private let environment: Environment = .development - var isDev: Bool { environment == .development } - private var userToken: String? - private var userId: Int? - private var businessId: Int = 0 - - var baseURL: String { environment.baseURL } - - // MARK: - Configuration - - func setAuth(token: String?, userId: Int?) { - self.userToken = token - self.userId = userId - } - - func setBusinessId(_ id: Int) { - self.businessId = id - } - - func getToken() -> String? { userToken } - func getUserId() -> Int? { userId } - func getBusinessId() -> Int { businessId } - - // MARK: - Core Request - - private func postJSON(_ path: String, payload: [String: Any]) async throws -> [String: Any] { - let urlString = environment.baseURL + (path.hasPrefix("/") ? path : "/\(path)") - guard let url = URL(string: urlString) else { throw APIError.invalidURL } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") - - if let token = userToken, !token.isEmpty { - request.setValue(token, forHTTPHeaderField: "X-User-Token") - } - if businessId > 0 { - request.setValue(String(businessId), forHTTPHeaderField: "X-Business-ID") - } - - request.httpBody = try JSONSerialization.data(withJSONObject: payload) - - let (data, response) = try await URLSession.shared.data(for: request) - - if let httpResponse = response as? HTTPURLResponse { - if httpResponse.statusCode == 401 { throw APIError.unauthorized } - guard (200...299).contains(httpResponse.statusCode) else { - throw APIError.serverError("HTTP \(httpResponse.statusCode)") - } - } - - if let json = tryDecodeJSON(data) { - return json - } - throw APIError.decodingError("Non-JSON response") - } - - private func tryDecodeJSON(_ data: Data) -> [String: Any]? { - if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - return json - } - guard let body = String(data: data, encoding: .utf8), - let start = body.firstIndex(of: "{"), - let end = body.lastIndex(of: "}") else { return nil } - let jsonStr = String(body[start...end]) - guard let jsonData = jsonStr.data(using: .utf8) else { return nil } - return try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] - } - - private func ok(_ json: [String: Any]) -> Bool { - for key in ["OK", "ok", "Ok"] { - if let b = json[key] as? Bool { return b } - if let i = json[key] as? Int { return i == 1 } - if let s = json[key] as? String { - let lower = s.lowercased() - return lower == "true" || lower == "1" || lower == "yes" - } - } - return false - } - - private func err(_ json: [String: Any]) -> String { - let msg = (json["ERROR"] as? String) ?? (json["error"] as? String) - ?? (json["Error"] as? String) ?? (json["message"] as? String) ?? "" - return msg.isEmpty ? "Unknown error" : msg - } - - nonisolated static func findArray(_ json: [String: Any], _ keys: [String]) -> [[String: Any]]? { - for key in keys { - if let arr = json[key] as? [[String: Any]] { return arr } - } - for (_, value) in json { - if let arr = value as? [[String: Any]], !arr.isEmpty { return arr } - } - return nil - } - - // MARK: - Auth - - func login(username: String, password: String) async throws -> LoginResponse { - let json = try await postJSON("/auth/login.cfm", payload: [ - "username": username, - "password": password - ]) - - guard ok(json) else { - let e = err(json) - if e == "bad_credentials" { - throw APIError.serverError("Invalid email/phone or password") - } - throw APIError.serverError("Login failed: \(e)") - } - - let uid = (json["UserID"] as? Int) - ?? Int(json["UserID"] as? String ?? "") - ?? (json["UserId"] as? Int) - ?? 0 - let token = (json["Token"] as? String) - ?? (json["token"] as? String) - ?? "" - - guard uid > 0 else { - throw APIError.serverError("Login failed: no user ID returned") - } - guard !token.isEmpty else { - throw APIError.serverError("Login failed: no token returned") - } - - let firstName = (json["UserFirstName"] as? String) - ?? (json["FirstName"] as? String) - ?? (json["firstName"] as? String) - ?? (json["Name"] as? String) - ?? (json["name"] as? String) - ?? "" - let photoUrl = (json["UserPhotoUrl"] as? String) - ?? (json["PhotoUrl"] as? String) - ?? (json["photoUrl"] as? String) - ?? "" - - self.userToken = token - self.userId = uid - - return LoginResponse(userId: uid, userFirstName: firstName, token: token, photoUrl: photoUrl) - } - - func logout() { - userToken = nil - userId = nil - businessId = 0 - } - - // MARK: - Businesses - - func getMyBusinesses() async throws -> [Employment] { - guard let uid = userId, uid > 0 else { - throw APIError.serverError("User not logged in") - } - - let json = try await postJSON("/workers/myBusinesses.cfm", payload: [ - "UserID": uid - ]) - - guard ok(json) else { - throw APIError.serverError("Failed to load businesses: \(err(json))") - } - - guard let arr = Self.findArray(json, ["BUSINESSES", "Businesses", "businesses"]) else { - return [] - } - return arr.map { Employment(json: $0) } - } - - // MARK: - Beacons - - func listBeacons() async throws -> [Beacon] { - let json = try await postJSON("/beacons/list.cfm", payload: [ - "BusinessID": businessId - ]) - guard ok(json) else { - throw APIError.serverError("Failed to load beacons: \(err(json))") - } - guard let arr = Self.findArray(json, ["BEACONS", "Beacons", "beacons"]) else { return [] } - return arr.map { Beacon(json: $0) } - } - - func getBeacon(beaconId: Int) async throws -> Beacon { - let json = try await postJSON("/beacons/get.cfm", payload: [ - "BeaconID": beaconId, - "BusinessID": businessId - ]) - guard ok(json) else { - throw APIError.serverError("Failed to load beacon: \(err(json))") - } - var beaconJson: [String: Any]? - for key in ["BEACON", "Beacon", "beacon"] { - if let d = json[key] as? [String: Any] { beaconJson = d; break } - } - if beaconJson == nil { - for (_, value) in json { - if let d = value as? [String: Any], d.count > 3 { beaconJson = d; break } - } - } - guard let beaconJson = beaconJson else { - throw APIError.serverError("Invalid beacon response") - } - return Beacon(json: beaconJson) - } - - func createBeacon(name: String, uuid: String) async throws -> Int { - let json = try await postJSON("/beacons/create.cfm", payload: [ - "BusinessID": businessId, - "Name": name, - "UUID": uuid - ]) - guard ok(json) else { - throw APIError.serverError("Failed to create beacon: \(err(json))") - } - return (json["BeaconID"] as? Int) - ?? (json["ID"] as? Int) - ?? Int(json["BeaconID"] as? String ?? "") - ?? 0 - } - - func updateBeacon(beaconId: Int, name: String, uuid: String, isActive: Bool) async throws { - let json = try await postJSON("/beacons/update.cfm", payload: [ - "BeaconID": beaconId, - "BusinessID": businessId, - "Name": name, - "UUID": uuid, - "IsActive": isActive - ]) - guard ok(json) else { - throw APIError.serverError("Failed to update beacon: \(err(json))") - } - } - - func deleteBeacon(beaconId: Int) async throws { - let json = try await postJSON("/beacons/delete.cfm", payload: [ - "BeaconID": beaconId, - "BusinessID": businessId - ]) - guard ok(json) else { - throw APIError.serverError("Failed to delete beacon: \(err(json))") - } - } - - // MARK: - Service Points - - func listServicePoints() async throws -> [ServicePoint] { - let json = try await postJSON("/servicePoints/list.cfm", payload: [ - "BusinessID": businessId - ]) - guard ok(json) else { - throw APIError.serverError("Failed to load service points: \(err(json))") - } - guard let arr = Self.findArray(json, ["SERVICE_POINTS", "ServicePoints", "servicePoints", "SERVICEPOINTS"]) else { return [] } - return arr.map { ServicePoint(json: $0) } - } - - func assignBeaconToServicePoint(servicePointId: Int, beaconId: Int?) async throws { - var payload: [String: Any] = [ - "ServicePointID": servicePointId, - "BusinessID": businessId - ] - if let bid = beaconId { - payload["BeaconID"] = bid - } - let json = try await postJSON("/servicePoints/assignBeacon.cfm", payload: payload) - guard ok(json) else { - throw APIError.serverError("Failed to assign beacon: \(err(json))") - } - } - - func listServicePointTypes() async throws -> [ServicePointType] { - let json = try await postJSON("/servicePoints/types.cfm", payload: [ - "BusinessID": businessId - ]) - guard ok(json) else { - throw APIError.serverError("Failed to load service point types: \(err(json))") - } - guard let arr = Self.findArray(json, ["TYPES", "Types", "types"]) else { return [] } - return arr.map { ServicePointType(json: $0) } - } - - // MARK: - URL Helpers - - func resolvePhotoUrl(_ rawUrl: String) -> String { - let trimmed = rawUrl.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { return "" } - if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") { return trimmed } - let baseDomain = environment == .development ? "https://dev.payfrit.com" : "https://biz.payfrit.com" - if trimmed.hasPrefix("/") { return baseDomain + trimmed } - return baseDomain + "/" + trimmed - } - - // MARK: - Date Parsing - - private static let iso8601Formatter: ISO8601DateFormatter = { - let f = ISO8601DateFormatter() - f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return f - }() - - private static let iso8601NoFrac: ISO8601DateFormatter = { - let f = ISO8601DateFormatter() - f.formatOptions = [.withInternetDateTime] - return f - }() - - private static let simpleDateFormatter: DateFormatter = { - let f = DateFormatter() - f.dateFormat = "yyyy-MM-dd HH:mm:ss" - f.locale = Locale(identifier: "en_US_POSIX") - return f - }() - - private static let cfmlDateFormatter: DateFormatter = { - let f = DateFormatter() - f.dateFormat = "MMMM, dd yyyy HH:mm:ss Z" - f.locale = Locale(identifier: "en_US_POSIX") - return f - }() - - private static let cfmlShortFormatter: DateFormatter = { - let f = DateFormatter() - f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" - f.locale = Locale(identifier: "en_US_POSIX") - return f - }() - - private static let cfmlAltFormatters: [DateFormatter] = { - let formats = [ - "MMM dd, yyyy HH:mm:ss", - "MM/dd/yyyy HH:mm:ss", - "yyyy-MM-dd HH:mm:ss.S", - "yyyy-MM-dd'T'HH:mm:ss.S", - "yyyy-MM-dd'T'HH:mm:ssZ", - "yyyy-MM-dd'T'HH:mm:ss.SZ", - ] - return formats.map { fmt in - let f = DateFormatter() - f.dateFormat = fmt - f.locale = Locale(identifier: "en_US_POSIX") - return f - } - }() - - nonisolated static func parseDate(_ string: String) -> Date? { - let s = string.trimmingCharacters(in: .whitespacesAndNewlines) - if s.isEmpty { return nil } - - if let epoch = Double(s) { - if epoch > 1_000_000_000_000 { return Date(timeIntervalSince1970: epoch / 1000) } - if epoch > 1_000_000_000 { return Date(timeIntervalSince1970: epoch) } - } - - if let d = iso8601Formatter.date(from: s) { return d } - if let d = iso8601NoFrac.date(from: s) { return d } - if let d = simpleDateFormatter.date(from: s) { return d } - if let d = cfmlDateFormatter.date(from: s) { return d } - if let d = cfmlShortFormatter.date(from: s) { return d } - for formatter in cfmlAltFormatters { - if let d = formatter.date(from: s) { return d } - } - return nil - } -} diff --git a/_backup/Services/AuthStorage.swift b/_backup/Services/AuthStorage.swift deleted file mode 100644 index 04a165a..0000000 --- a/_backup/Services/AuthStorage.swift +++ /dev/null @@ -1,95 +0,0 @@ -import Foundation -import Security - -struct AuthCredentials { - let userId: Int - let token: String - let userName: String? - let photoUrl: String? -} - -actor AuthStorage { - static let shared = AuthStorage() - - private let userIdKey = "payfrit_beacon_user_id" - private let userNameKey = "payfrit_beacon_user_name" - private let userPhotoKey = "payfrit_beacon_user_photo" - private let serviceName = "com.payfrit.beacon" - private let tokenAccount = "auth_token" - - // MARK: - Save - - func saveAuth(userId: Int, token: String, userName: String? = nil, photoUrl: String? = nil) { - UserDefaults.standard.set(userId, forKey: userIdKey) - // Always overwrite name/photo to prevent stale data from previous user - if let name = userName, !name.isEmpty { - UserDefaults.standard.set(name, forKey: userNameKey) - } else { - UserDefaults.standard.removeObject(forKey: userNameKey) - } - if let photo = photoUrl, !photo.isEmpty { - UserDefaults.standard.set(photo, forKey: userPhotoKey) - } else { - UserDefaults.standard.removeObject(forKey: userPhotoKey) - } - saveToKeychain(token) - } - - // MARK: - Load - - func loadAuth() -> AuthCredentials? { - let userId = UserDefaults.standard.integer(forKey: userIdKey) - guard userId > 0 else { return nil } - guard let token = loadFromKeychain(), !token.isEmpty else { return nil } - let userName = UserDefaults.standard.string(forKey: userNameKey) - let photoUrl = UserDefaults.standard.string(forKey: userPhotoKey) - return AuthCredentials(userId: userId, token: token, userName: userName, photoUrl: photoUrl) - } - - // MARK: - Clear - - func clearAuth() { - UserDefaults.standard.removeObject(forKey: userIdKey) - UserDefaults.standard.removeObject(forKey: userNameKey) - UserDefaults.standard.removeObject(forKey: userPhotoKey) - deleteFromKeychain() - } - - // MARK: - Keychain - - private func saveToKeychain(_ token: String) { - deleteFromKeychain() - guard let data = token.data(using: .utf8) else { return } - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: serviceName, - kSecAttrAccount as String: tokenAccount, - kSecValueData as String: data, - kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly - ] - SecItemAdd(query as CFDictionary, nil) - } - - private func loadFromKeychain() -> String? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: serviceName, - kSecAttrAccount as String: tokenAccount, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne - ] - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - guard status == errSecSuccess, let data = result as? Data else { return nil } - return String(data: data, encoding: .utf8) - } - - private func deleteFromKeychain() { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: serviceName, - kSecAttrAccount as String: tokenAccount - ] - SecItemDelete(query as CFDictionary) - } -} diff --git a/_backup/Services/BeaconScanner.swift b/_backup/Services/BeaconScanner.swift deleted file mode 100644 index 4363653..0000000 --- a/_backup/Services/BeaconScanner.swift +++ /dev/null @@ -1,262 +0,0 @@ -import UIKit -import CoreBluetooth -import CoreLocation - -/// Beacon scanner for detecting BLE beacons by UUID. -/// Uses CoreLocation iBeacon ranging with RSSI-based dwell time enforcement. -/// All mutable state is confined to the main thread via @MainActor. -@MainActor -final class BeaconScanner: NSObject, ObservableObject { - private let targetUUID: String - private let normalizedTargetUUID: String - private let onBeaconDetected: (Double) -> Void - private let onRSSIUpdate: ((Int, Int) -> Void)? - private let onBluetoothOff: (() -> Void)? - private let onPermissionDenied: (() -> Void)? - private let onError: ((String) -> Void)? - - @Published var isScanning = false - - private var locationManager: CLLocationManager? - private var activeConstraint: CLBeaconIdentityConstraint? - private var checkTimer: Timer? - private var bluetoothManager: CBCentralManager? - - // RSSI samples for dwell time enforcement - private var rssiSamples: [Int] = [] - private let minSamplesToConfirm = 5 // ~5 seconds - private let rssiThreshold = -75 - private var hasConfirmed = false - private var isPendingPermission = false - - init(targetUUID: String, - onBeaconDetected: @escaping (Double) -> Void, - onRSSIUpdate: ((Int, Int) -> Void)? = nil, - onBluetoothOff: (() -> Void)? = nil, - onPermissionDenied: (() -> Void)? = nil, - onError: ((String) -> Void)? = nil) { - self.targetUUID = targetUUID - self.normalizedTargetUUID = targetUUID.replacingOccurrences(of: "-", with: "").uppercased() - self.onBeaconDetected = onBeaconDetected - self.onRSSIUpdate = onRSSIUpdate - self.onBluetoothOff = onBluetoothOff - self.onPermissionDenied = onPermissionDenied - self.onError = onError - super.init() - } - - // MARK: - UUID formatting - - private nonisolated func formatUUID(_ uuid: String) -> String { - let clean = uuid.replacingOccurrences(of: "-", with: "").uppercased() - guard clean.count == 32 else { return uuid } - let i = clean.startIndex - let p1 = clean[i..= rssiThreshold { - rssiSamples.append(rssi) - onRSSIUpdate?(rssi, rssiSamples.count) - - if rssiSamples.count >= minSamplesToConfirm { - let avg = Double(rssiSamples.reduce(0, +)) / Double(rssiSamples.count) - hasConfirmed = true - onBeaconDetected(avg) - return - } - } else { - if !rssiSamples.isEmpty { - rssiSamples.removeAll() - onRSSIUpdate?(rssi, 0) - } - } - } - - if !foundThisCycle && !rssiSamples.isEmpty { - rssiSamples.removeAll() - onRSSIUpdate?(0, 0) - } - } - - fileprivate func handleRangingError(_ error: Error) { - onError?("Beacon ranging failed: \(error.localizedDescription)") - } - - fileprivate func handleAuthorizationChange(_ status: CLAuthorizationStatus) { - if status == .authorizedWhenInUse || status == .authorizedAlways { - // Permission granted — start ranging only if we were waiting for permission - if isPendingPermission && !isScanning { - isPendingPermission = false - let formatted = formatUUID(targetUUID) - if let uuid = UUID(uuidString: formatted) { - beginRanging(uuid: uuid) - } - } - } else if status == .denied || status == .restricted { - isPendingPermission = false - stopScanning() - onPermissionDenied?() - } - } -} - -// MARK: - CLLocationManagerDelegate -// These delegate callbacks arrive on the main thread since CLLocationManager was created on main. -// We forward to @MainActor methods above. - -extension BeaconScanner: CLLocationManagerDelegate { - nonisolated func locationManager(_ manager: CLLocationManager, didRange beacons: [CLBeacon], - satisfying constraint: CLBeaconIdentityConstraint) { - Task { @MainActor in - self.handleRangedBeacons(beacons) - } - } - - nonisolated func locationManager(_ manager: CLLocationManager, didFailRangingFor constraint: CLBeaconIdentityConstraint, error: Error) { - Task { @MainActor in - self.handleRangingError(error) - } - } - - nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { - Task { @MainActor in - self.handleAuthorizationChange(manager.authorizationStatus) - } - } -} - -// MARK: - CBCentralManagerDelegate - -extension BeaconScanner: CBCentralManagerDelegate { - nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) { - Task { @MainActor in - if central.state == .poweredOff { - self.stopScanning() - self.onBluetoothOff?() - } - } - } -} diff --git a/_backup/ViewModels/AppState.swift b/_backup/ViewModels/AppState.swift deleted file mode 100644 index 4fbec6c..0000000 --- a/_backup/ViewModels/AppState.swift +++ /dev/null @@ -1,49 +0,0 @@ -import SwiftUI - -@MainActor -final class AppState: ObservableObject { - @Published var userId: Int? - @Published var userName: String? - @Published var userPhotoUrl: String? - @Published var userToken: String? - @Published var businessId: Int = 0 - @Published var businessName: String = "" - @Published var isAuthenticated = false - - func setAuth(userId: Int, token: String, userName: String? = nil, photoUrl: String? = nil) { - self.userId = userId - self.userToken = token - self.userName = userName - self.userPhotoUrl = photoUrl - self.isAuthenticated = true - } - - func setBusiness(id: Int, name: String) { - self.businessId = id - self.businessName = name - } - - func clearAuth() { - userId = nil - userToken = nil - userName = nil - userPhotoUrl = nil - isAuthenticated = false - businessId = 0 - businessName = "" - } - - /// Handle 401 unauthorized — clear everything and force re-login - func handleUnauthorized() async { - await AuthStorage.shared.clearAuth() - await APIService.shared.logout() - clearAuth() - } - - func loadSavedAuth() async { - let creds = await AuthStorage.shared.loadAuth() - guard let creds = creds else { return } - await APIService.shared.setAuth(token: creds.token, userId: creds.userId) - setAuth(userId: creds.userId, token: creds.token, userName: creds.userName, photoUrl: creds.photoUrl) - } -} diff --git a/_backup/Views/BeaconDashboard.swift b/_backup/Views/BeaconDashboard.swift deleted file mode 100644 index e8e148e..0000000 --- a/_backup/Views/BeaconDashboard.swift +++ /dev/null @@ -1,39 +0,0 @@ -import SwiftUI - -struct BeaconDashboard: View { - @EnvironmentObject var appState: AppState - let business: Employment - @State private var isReady = false - - var body: some View { - Group { - if isReady { - TabView { - BeaconListScreen() - .tabItem { - Label("Beacons", systemImage: "sensor.tag.radiowaves.forward.fill") - } - - ServicePointListScreen() - .tabItem { - Label("Service Points", systemImage: "mappin.and.ellipse") - } - - ScannerScreen() - .tabItem { - Label("Scanner", systemImage: "antenna.radiowaves.left.and.right") - } - } - .tint(.payfritGreen) - } else { - ProgressView("Loading...") - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } - .task { - await APIService.shared.setBusinessId(business.businessId) - appState.setBusiness(id: business.businessId, name: business.businessName) - isReady = true - } - } -} diff --git a/_backup/Views/BeaconDetailScreen.swift b/_backup/Views/BeaconDetailScreen.swift deleted file mode 100644 index 1b4aac9..0000000 --- a/_backup/Views/BeaconDetailScreen.swift +++ /dev/null @@ -1,116 +0,0 @@ -import SwiftUI - -struct BeaconDetailScreen: View { - let beacon: Beacon - var onSaved: () -> Void - - @State private var name: String = "" - @State private var uuid: String = "" - @State private var isActive: Bool = true - @State private var isSaving = false - @State private var isDeleting = false - @State private var error: String? - @State private var showDeleteConfirm = false - @EnvironmentObject var appState: AppState - @Environment(\.dismiss) private var dismiss - - var body: some View { - Form { - Section("Beacon Info") { - TextField("Name", text: $name) - TextField("UUID (32 hex characters)", text: $uuid) - .textInputAutocapitalization(.characters) - .autocorrectionDisabled() - .font(.system(.body, design: .monospaced)) - Toggle("Active", isOn: $isActive) - } - - Section("Details") { - LabeledContent("ID", value: "\(beacon.id)") - LabeledContent("Business ID", value: "\(beacon.businessId)") - if let date = beacon.createdAt { - LabeledContent("Created", value: date.formatted(date: .abbreviated, time: .shortened)) - } - if let date = beacon.updatedAt { - LabeledContent("Updated", value: date.formatted(date: .abbreviated, time: .shortened)) - } - } - - if let error = error { - Section { - HStack { - Image(systemName: "exclamationmark.circle.fill") - .foregroundColor(.red) - Text(error) - .foregroundColor(.red) - } - } - } - - Section { - Button("Save Changes") { save() } - .frame(maxWidth: .infinity) - .disabled(isSaving || isDeleting || name.isEmpty || uuid.isEmpty) - } - - Section { - Button(isDeleting ? "Deleting..." : "Delete Beacon", role: .destructive) { - showDeleteConfirm = true - } - .frame(maxWidth: .infinity) - .disabled(isSaving || isDeleting) - } - } - .navigationTitle(beacon.name) - .onAppear { - name = beacon.name - uuid = beacon.uuid - isActive = beacon.isActive - } - .alert("Delete Beacon?", isPresented: $showDeleteConfirm) { - Button("Delete", role: .destructive) { deleteBeacon() } - Button("Cancel", role: .cancel) {} - } message: { - Text("This will permanently remove \"\(beacon.name)\". Service points using this beacon will be unassigned.") - } - } - - private func save() { - isSaving = true - error = nil - Task { - do { - try await APIService.shared.updateBeacon( - beaconId: beacon.id, - name: name.trimmingCharacters(in: .whitespaces), - uuid: uuid.trimmingCharacters(in: .whitespaces), - isActive: isActive - ) - onSaved() - dismiss() - } catch let apiError as APIError where apiError == .unauthorized { - await appState.handleUnauthorized() - } catch { - self.error = error.localizedDescription - } - isSaving = false - } - } - - private func deleteBeacon() { - isDeleting = true - error = nil - Task { - do { - try await APIService.shared.deleteBeacon(beaconId: beacon.id) - onSaved() - dismiss() - } catch let apiError as APIError where apiError == .unauthorized { - await appState.handleUnauthorized() - } catch { - self.error = error.localizedDescription - } - isDeleting = false - } - } -} diff --git a/_backup/Views/BeaconEditSheet.swift b/_backup/Views/BeaconEditSheet.swift deleted file mode 100644 index d3ffc9b..0000000 --- a/_backup/Views/BeaconEditSheet.swift +++ /dev/null @@ -1,83 +0,0 @@ -import SwiftUI - -struct BeaconEditSheet: View { - var onSaved: () -> Void - - @State private var name = "" - @State private var uuid = "" - @State private var isSaving = false - @State private var error: String? - @EnvironmentObject var appState: AppState - @Environment(\.dismiss) private var dismiss - - var body: some View { - NavigationStack { - Form { - Section("New Beacon") { - TextField("Name (e.g. Table 1 Beacon)", text: $name) - TextField("UUID (32 hex characters)", text: $uuid) - .textInputAutocapitalization(.characters) - .autocorrectionDisabled() - .font(.system(.body, design: .monospaced)) - } - - Section { - Text("The UUID should be a 32-character hexadecimal string that uniquely identifies this beacon. Example: 626C7565636861726D31000000000001") - .font(.caption) - .foregroundColor(.secondary) - } - - if let error = error { - Section { - HStack { - Image(systemName: "exclamationmark.circle.fill") - .foregroundColor(.red) - Text(error) - .foregroundColor(.red) - } - } - } - } - .navigationTitle("Add Beacon") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } - } - ToolbarItem(placement: .confirmationAction) { - Button("Save") { save() } - .disabled(isSaving || name.isEmpty || uuid.isEmpty) - } - } - } - } - - private func save() { - let trimmedName = name.trimmingCharacters(in: .whitespaces) - let trimmedUUID = uuid.trimmingCharacters(in: .whitespaces) - - guard !trimmedName.isEmpty else { - error = "Name is required" - return - } - guard !trimmedUUID.isEmpty else { - error = "UUID is required" - return - } - - isSaving = true - error = nil - Task { - do { - _ = try await APIService.shared.createBeacon(name: trimmedName, uuid: trimmedUUID) - onSaved() - dismiss() - } catch let apiError as APIError where apiError == .unauthorized { - await appState.handleUnauthorized() - } catch { - self.error = error.localizedDescription - } - isSaving = false - } - } -} diff --git a/_backup/Views/BeaconListScreen.swift b/_backup/Views/BeaconListScreen.swift deleted file mode 100644 index 4d9b1c7..0000000 --- a/_backup/Views/BeaconListScreen.swift +++ /dev/null @@ -1,154 +0,0 @@ -import SwiftUI - -struct BeaconListScreen: View { - @EnvironmentObject var appState: AppState - @State private var beacons: [Beacon] = [] - @State private var isLoading = true - @State private var error: String? - @State private var showAddSheet = false - @State private var isDeleting = false - - var body: some View { - NavigationStack { - Group { - if isLoading { - ProgressView("Loading beacons...") - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let error = error { - VStack(spacing: 16) { - Image(systemName: "exclamationmark.triangle") - .font(.largeTitle) - .foregroundColor(.orange) - Text(error) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - Button("Retry") { loadBeacons() } - .buttonStyle(.borderedProminent) - .tint(.payfritGreen) - } - .padding() - } else if beacons.isEmpty { - VStack(spacing: 16) { - Image(systemName: "sensor.tag.radiowaves.forward.fill") - .font(.system(size: 48)) - .foregroundColor(.secondary) - Text("No beacons yet") - .font(.title3) - .foregroundColor(.secondary) - Text("Tap + to add your first beacon") - .font(.subheadline) - .foregroundColor(.secondary) - } - } else { - List { - ForEach(beacons) { beacon in - NavigationLink(value: beacon) { - BeaconRow(beacon: beacon) - } - } - .onDelete(perform: deleteBeacons) - } - } - } - .navigationTitle("Beacons") - .navigationDestination(for: Beacon.self) { beacon in - BeaconDetailScreen(beacon: beacon, onSaved: { loadBeacons() }) - } - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button { - showAddSheet = true - } label: { - Image(systemName: "plus") - } - } - } - .sheet(isPresented: $showAddSheet) { - BeaconEditSheet(onSaved: { loadBeacons() }) - } - .refreshable { - await withCheckedContinuation { continuation in - loadBeacons { continuation.resume() } - } - } - } - .task { loadBeacons() } - } - - private func loadBeacons(completion: (() -> Void)? = nil) { - isLoading = beacons.isEmpty - error = nil - Task { - do { - beacons = try await APIService.shared.listBeacons() - } catch let apiError as APIError where apiError == .unauthorized { - await appState.handleUnauthorized() - } catch { - self.error = error.localizedDescription - } - isLoading = false - completion?() - } - } - - private func deleteBeacons(at offsets: IndexSet) { - guard !isDeleting else { return } - let toDelete = offsets.map { beacons[$0] } - // Optimistic removal - beacons.remove(atOffsets: offsets) - isDeleting = true - - Task { - var failedBeacons: [Beacon] = [] - for beacon in toDelete { - do { - try await APIService.shared.deleteBeacon(beaconId: beacon.id) - } catch { - failedBeacons.append(beacon) - self.error = error.localizedDescription - } - } - // Restore any that failed to delete - if !failedBeacons.isEmpty { - beacons.append(contentsOf: failedBeacons) - } - isDeleting = false - } - } -} - -// MARK: - Beacon Row - -struct BeaconRow: View { - let beacon: Beacon - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(beacon.name) - .font(.headline) - Spacer() - if beacon.isActive { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - .font(.caption) - } else { - Image(systemName: "xmark.circle.fill") - .foregroundColor(.red) - .font(.caption) - } - } - Text(beacon.formattedUUID) - .font(.caption) - .foregroundColor(.secondary) - .lineLimit(1) - } - .padding(.vertical, 2) - } -} - -// Make Beacon Hashable for NavigationLink -extension Beacon: Hashable { - static func == (lhs: Beacon, rhs: Beacon) -> Bool { lhs.id == rhs.id } - func hash(into hasher: inout Hasher) { hasher.combine(id) } -} diff --git a/_backup/Views/BusinessSelectionScreen.swift b/_backup/Views/BusinessSelectionScreen.swift deleted file mode 100644 index c410417..0000000 --- a/_backup/Views/BusinessSelectionScreen.swift +++ /dev/null @@ -1,196 +0,0 @@ -import SwiftUI - -struct BusinessSelectionScreen: View { - @EnvironmentObject var appState: AppState - @State private var businesses: [Employment] = [] - @State private var isLoading = true - @State private var error: String? - - var body: some View { - NavigationStack { - Group { - if isLoading { - ProgressView("Loading businesses...") - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let error = error { - VStack(spacing: 16) { - Image(systemName: "exclamationmark.triangle") - .font(.largeTitle) - .foregroundColor(.orange) - Text(error) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - Button("Retry") { loadBusinesses() } - .buttonStyle(.borderedProminent) - .tint(.payfritGreen) - } - .padding() - } else if businesses.isEmpty { - VStack(spacing: 16) { - Image(systemName: "building.2") - .font(.largeTitle) - .foregroundColor(.secondary) - Text("No businesses found") - .foregroundColor(.secondary) - } - } else { - ScrollView { - LazyVStack(spacing: 12) { - ForEach(businesses) { biz in - NavigationLink(value: biz) { - VStack(spacing: 0) { - BusinessHeaderImage(businessId: biz.businessId) - - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(biz.businessName) - .font(.subheadline.weight(.semibold)) - .foregroundColor(.primary) - if !biz.businessCity.isEmpty { - Text(biz.businessCity) - .font(.caption) - .foregroundColor(.secondary) - } - } - Spacer() - Image(systemName: "chevron.right") - .font(.caption) - .foregroundColor(.secondary) - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - } - .background(Color(.systemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(Color(.systemGray4), lineWidth: 0.5) - ) - .shadow(color: .black.opacity(0.06), radius: 4, x: 0, y: 2) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal, 16) - .padding(.top, 8) - .padding(.bottom, 20) - } - } - } - .navigationTitle("Select Business") - .navigationDestination(for: Employment.self) { biz in - BeaconDashboard(business: biz) - } - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button { - logout() - } label: { - Image(systemName: "rectangle.portrait.and.arrow.right") - } - } - } - } - .task { loadBusinesses() } - } - - private func loadBusinesses() { - isLoading = true - error = nil - Task { - do { - businesses = try await APIService.shared.getMyBusinesses() - isLoading = false - } catch let apiError as APIError where apiError == .unauthorized { - await appState.handleUnauthorized() - } catch { - self.error = error.localizedDescription - isLoading = false - } - } - } - - private func logout() { - Task { - await AuthStorage.shared.clearAuth() - await APIService.shared.logout() - appState.clearAuth() - } - } -} - -// Make Employment Hashable for NavigationLink -extension Employment: Hashable { - static func == (lhs: Employment, rhs: Employment) -> Bool { - lhs.employeeId == rhs.employeeId && lhs.businessId == rhs.businessId - } - func hash(into hasher: inout Hasher) { - hasher.combine(employeeId) - hasher.combine(businessId) - } -} - -// MARK: - Business Header Image - -struct BusinessHeaderImage: View { - let businessId: Int - - @State private var loadedImage: UIImage? - @State private var isLoading = true - - private var imageURLs: [URL] { - [ - "https://dev.payfrit.com/uploads/headers/\(businessId).png", - "https://dev.payfrit.com/uploads/headers/\(businessId).jpg", - ].compactMap { URL(string: $0) } - } - - var body: some View { - ZStack { - Color(.systemGray6) - - if let image = loadedImage { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxWidth: .infinity) - } else if isLoading { - ProgressView() - .tint(.payfritGreen) - .frame(maxWidth: .infinity) - .frame(height: 100) - } else { - Image(systemName: "building.2") - .font(.system(size: 30)) - .foregroundColor(.secondary) - .frame(maxWidth: .infinity) - .frame(height: 100) - } - } - .task { - await loadImage() - } - } - - private func loadImage() async { - for url in imageURLs { - do { - let (data, response) = try await URLSession.shared.data(from: url) - if let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200, - let image = UIImage(data: data) { - await MainActor.run { - loadedImage = image - isLoading = false - } - return - } - } catch { - continue - } - } - await MainActor.run { - isLoading = false - } - } -} diff --git a/_backup/Views/LoginScreen.swift b/_backup/Views/LoginScreen.swift deleted file mode 100644 index af5db0f..0000000 --- a/_backup/Views/LoginScreen.swift +++ /dev/null @@ -1,136 +0,0 @@ -import SwiftUI - -struct LoginScreen: View { - @EnvironmentObject var appState: AppState - @State private var username = "" - @State private var password = "" - @State private var showPassword = false - @State private var isLoading = false - @State private var error: String? - @State private var isDev = false - - var body: some View { - GeometryReader { geo in - ScrollView { - VStack(spacing: 12) { - Image(systemName: "sensor.tag.radiowaves.forward.fill") - .font(.system(size: 60)) - .foregroundColor(.payfritGreen) - .padding(.top, 40) - - Text("Payfrit Beacon") - .font(.system(size: 28, weight: .bold)) - - Text("Sign in to manage beacons") - .foregroundColor(.secondary) - - if isDev { - Text("DEV MODE — password: 123456") - .font(.caption) - .foregroundColor(.red) - .fontWeight(.medium) - } - - VStack(spacing: 12) { - TextField("Email or Phone", text: $username) - .textFieldStyle(.roundedBorder) - .textContentType(.emailAddress) - .autocapitalization(.none) - .disableAutocorrection(true) - - ZStack(alignment: .trailing) { - Group { - if showPassword { - TextField("Password", text: $password) - .textContentType(.password) - } else { - SecureField("Password", text: $password) - .textContentType(.password) - } - } - .textFieldStyle(.roundedBorder) - .onSubmit { login() } - - Button { - showPassword.toggle() - } label: { - Image(systemName: showPassword ? "eye.slash.fill" : "eye.fill") - .foregroundColor(.secondary) - .font(.subheadline) - } - .padding(.trailing, 8) - } - } - .padding(.top, 8) - - if let error = error { - HStack { - Image(systemName: "exclamationmark.circle.fill") - .foregroundColor(.red) - Text(error) - .foregroundColor(.red) - .font(.callout) - Spacer() - } - .padding(12) - .background(Color.red.opacity(0.1)) - .cornerRadius(8) - } - - Button(action: login) { - if isLoading { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - .frame(maxWidth: .infinity, minHeight: 44) - } else { - Text("Sign In") - .font(.headline) - .frame(maxWidth: .infinity, minHeight: 44) - } - } - .buttonStyle(.borderedProminent) - .tint(.payfritGreen) - .disabled(isLoading) - } - .padding(.horizontal, 24) - .frame(minHeight: geo.size.height) - } - } - .background(Color(.systemGroupedBackground)) - .task { isDev = await APIService.shared.isDev } - } - - private func login() { - let user = username.trimmingCharacters(in: .whitespaces) - let pass = password - guard !user.isEmpty, !pass.isEmpty else { - error = "Please enter username and password" - return - } - - isLoading = true - error = nil - - Task { - do { - let response = try await APIService.shared.login(username: user, password: pass) - let resolvedPhoto = await APIService.shared.resolvePhotoUrl(response.photoUrl) - await AuthStorage.shared.saveAuth( - userId: response.userId, - token: response.token, - userName: response.userFirstName, - photoUrl: resolvedPhoto - ) - appState.setAuth( - userId: response.userId, - token: response.token, - userName: response.userFirstName, - photoUrl: resolvedPhoto - ) - } catch { - self.error = error.localizedDescription - } - isLoading = false - } - } -} diff --git a/_backup/Views/RootView.swift b/_backup/Views/RootView.swift deleted file mode 100644 index 0cfd3d6..0000000 --- a/_backup/Views/RootView.swift +++ /dev/null @@ -1,88 +0,0 @@ -import SwiftUI -import LocalAuthentication - -struct RootView: View { - @EnvironmentObject var appState: AppState - @State private var isCheckingAuth = true - @State private var isDev = false - - var body: some View { - Group { - if isCheckingAuth { - loadingView - } else if appState.isAuthenticated { - BusinessSelectionScreen() - } else { - LoginScreen() - } - } - .animation(.easeInOut(duration: 0.3), value: appState.isAuthenticated) - .overlay(alignment: .bottomLeading) { - if isDev { - Text("DEV") - .font(.caption.bold()) - .foregroundColor(.white) - .frame(width: 80, height: 20) - .background(Color.orange) - .rotationEffect(.degrees(45)) - .offset(x: -20, y: -6) - .allowsHitTesting(false) - } - } - .task { - isDev = await APIService.shared.isDev - await checkAuthWithBiometrics() - isCheckingAuth = false - } - } - - private var loadingView: some View { - ZStack { - Color.white.ignoresSafeArea() - VStack(spacing: 16) { - Image(systemName: "sensor.tag.radiowaves.forward.fill") - .font(.system(size: 60)) - .foregroundColor(.payfritGreen) - Text("Payfrit Beacon") - .font(.title2.bold()) - ProgressView() - .tint(.payfritGreen) - } - } - } - - private func checkAuthWithBiometrics() async { - let creds = await AuthStorage.shared.loadAuth() - guard creds != nil else { return } - - #if targetEnvironment(simulator) - await appState.loadSavedAuth() - return - #else - let context = LAContext() - context.localizedCancelTitle = "Use Password" - var error: NSError? - let canUseBiometrics = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) - - guard canUseBiometrics else { - // No biometrics available — allow login with saved credentials - await appState.loadSavedAuth() - return - } - - do { - let success = try await context.evaluatePolicy( - .deviceOwnerAuthenticationWithBiometrics, - localizedReason: "Sign in to Payfrit Beacon" - ) - if success { - await appState.loadSavedAuth() - } - } catch { - // User cancelled biometrics — still allow them in with saved credentials - NSLog("PAYFRIT [biometrics] cancelled/failed: \(error.localizedDescription)") - await appState.loadSavedAuth() - } - #endif - } -} diff --git a/_backup/Views/ScannerScreen.swift b/_backup/Views/ScannerScreen.swift deleted file mode 100644 index 8466cb8..0000000 --- a/_backup/Views/ScannerScreen.swift +++ /dev/null @@ -1,225 +0,0 @@ -import SwiftUI -import CoreLocation - -struct ScannerScreen: View { - @State private var beacons: [Beacon] = [] - @State private var selectedBeacon: Beacon? - @State private var isLoading = true - - // Scanner state - @StateObject private var scanner = ScannerViewModel() - - var body: some View { - NavigationStack { - VStack(spacing: 0) { - // Beacon selector - if isLoading { - ProgressView() - .padding() - } else { - Picker("Select Beacon", selection: $selectedBeacon) { - Text("Choose a beacon...").tag(nil as Beacon?) - ForEach(beacons) { beacon in - Text(beacon.name).tag(beacon as Beacon?) - } - } - .pickerStyle(.menu) - .padding() - } - - Divider() - - // Scanner display - VStack(spacing: 24) { - Spacer() - - // Status indicator - ZStack { - Circle() - .fill(scanner.statusColor.opacity(0.15)) - .frame(width: 160, height: 160) - - Circle() - .fill(scanner.statusColor.opacity(0.3)) - .frame(width: 120, height: 120) - - Image(systemName: scanner.statusIcon) - .font(.system(size: 48)) - .foregroundColor(scanner.statusColor) - } - - Text(scanner.statusText) - .font(.title3.bold()) - - if scanner.isScanning { - VStack(spacing: 8) { - if scanner.rssi != 0 { - HStack { - Text("RSSI:") - .foregroundColor(.secondary) - Text("\(scanner.rssi) dBm") - .font(.system(.body, design: .monospaced)) - .bold() - } - HStack { - Text("Samples:") - .foregroundColor(.secondary) - Text("\(scanner.sampleCount)/\(scanner.requiredSamples)") - .font(.system(.body, design: .monospaced)) - } - // Signal strength bar - SignalStrengthBar(rssi: scanner.rssi) - .frame(height: 20) - .padding(.horizontal, 40) - } else { - Text("Searching for beacon signal...") - .foregroundColor(.secondary) - } - } - } - - Spacer() - - // Start/Stop button - Button { - if scanner.isScanning { - scanner.stop() - } else if let beacon = selectedBeacon { - scanner.start(uuid: beacon.uuid) - } - } label: { - HStack { - Image(systemName: scanner.isScanning ? "stop.fill" : "play.fill") - Text(scanner.isScanning ? "Stop Scanning" : "Start Scanning") - } - .font(.headline) - .frame(maxWidth: .infinity, minHeight: 50) - } - .buttonStyle(.borderedProminent) - .tint(scanner.isScanning ? .red : .payfritGreen) - .disabled(selectedBeacon == nil && !scanner.isScanning) - .padding(.horizontal, 24) - .padding(.bottom, 24) - } - } - .navigationTitle("Beacon Scanner") - } - .task { - do { - beacons = try await APIService.shared.listBeacons() - } catch { - // Silently fail — user can still see the scanner - } - isLoading = false - } - .onChange(of: selectedBeacon) { _ in - if scanner.isScanning { - scanner.stop() - } - } - } -} - -// MARK: - Scanner ViewModel - -@MainActor -final class ScannerViewModel: ObservableObject { - @Published var isScanning = false - @Published var statusText = "Select a beacon to scan" - @Published var statusColor: Color = .secondary - @Published var statusIcon = "sensor.tag.radiowaves.forward.fill" - @Published var rssi: Int = 0 - @Published var sampleCount = 0 - let requiredSamples = 5 - - private var beaconScanner: BeaconScanner? - - func start(uuid: String) { - beaconScanner?.dispose() - - beaconScanner = BeaconScanner( - targetUUID: uuid, - onBeaconDetected: { [weak self] avgRssi in - self?.statusText = "Beacon Detected! (avg \(Int(avgRssi)) dBm)" - self?.statusColor = .green - self?.statusIcon = "checkmark.circle.fill" - }, - onRSSIUpdate: { [weak self] currentRssi, samples in - self?.rssi = currentRssi - self?.sampleCount = samples - }, - onBluetoothOff: { [weak self] in - self?.statusText = "Bluetooth is OFF" - self?.statusColor = .orange - self?.statusIcon = "bluetooth.slash" - }, - onPermissionDenied: { [weak self] in - self?.statusText = "Location Permission Denied" - self?.statusColor = .red - self?.statusIcon = "location.slash.fill" - self?.isScanning = false - }, - onError: { [weak self] message in - self?.statusText = message - self?.statusColor = .red - self?.statusIcon = "exclamationmark.triangle.fill" - self?.isScanning = false - } - ) - - beaconScanner?.startScanning() - isScanning = true - statusText = "Scanning..." - statusColor = .blue - statusIcon = "antenna.radiowaves.left.and.right" - rssi = 0 - sampleCount = 0 - } - - func stop() { - beaconScanner?.dispose() - beaconScanner = nil - isScanning = false - statusText = "Select a beacon to scan" - statusColor = .secondary - statusIcon = "sensor.tag.radiowaves.forward.fill" - rssi = 0 - sampleCount = 0 - } - - deinit { - // Ensure cleanup if view is removed while scanning - // Note: deinit runs on main actor since class is @MainActor - } -} - -// MARK: - Signal Strength Bar - -struct SignalStrengthBar: View { - let rssi: Int - - private var strength: Double { - // Map RSSI from -100..-30 to 0..1 - let clamped = max(-100, min(-30, rssi)) - return Double(clamped + 100) / 70.0 - } - - private var barColor: Color { - if strength > 0.7 { return .green } - if strength > 0.4 { return .yellow } - return .red - } - - var body: some View { - GeometryReader { geo in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 4) - .fill(Color.secondary.opacity(0.2)) - - RoundedRectangle(cornerRadius: 4) - .fill(barColor) - .frame(width: geo.size.width * strength) - } - } - } -} diff --git a/_backup/Views/ServicePointListScreen.swift b/_backup/Views/ServicePointListScreen.swift deleted file mode 100644 index e86bc85..0000000 --- a/_backup/Views/ServicePointListScreen.swift +++ /dev/null @@ -1,232 +0,0 @@ -import SwiftUI - -struct ServicePointListScreen: View { - @EnvironmentObject var appState: AppState - @State private var servicePoints: [ServicePoint] = [] - @State private var beacons: [Beacon] = [] - @State private var isLoading = true - @State private var error: String? - @State private var assigningPointId: Int? - - var body: some View { - NavigationStack { - Group { - if isLoading { - ProgressView("Loading service points...") - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let error = error { - VStack(spacing: 16) { - Image(systemName: "exclamationmark.triangle") - .font(.largeTitle) - .foregroundColor(.orange) - Text(error) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - Button("Retry") { loadData() } - .buttonStyle(.borderedProminent) - .tint(.payfritGreen) - } - .padding() - } else if servicePoints.isEmpty { - VStack(spacing: 16) { - Image(systemName: "mappin.and.ellipse") - .font(.system(size: 48)) - .foregroundColor(.secondary) - Text("No service points") - .font(.title3) - .foregroundColor(.secondary) - } - } else { - List(servicePoints) { sp in - ServicePointRow( - servicePoint: sp, - beacons: beacons, - isAssigning: assigningPointId == sp.id, - onAssignBeacon: { beaconId in - assignBeacon(servicePointId: sp.id, beaconId: beaconId) - } - ) - } - } - } - .navigationTitle("Service Points") - .refreshable { - await withCheckedContinuation { continuation in - loadData { continuation.resume() } - } - } - } - .task { loadData() } - } - - private func loadData(completion: (() -> Void)? = nil) { - isLoading = servicePoints.isEmpty - error = nil - Task { - do { - async let sp = APIService.shared.listServicePoints() - async let b = APIService.shared.listBeacons() - servicePoints = try await sp - beacons = try await b - } catch let apiError as APIError where apiError == .unauthorized { - await appState.handleUnauthorized() - } catch { - self.error = error.localizedDescription - } - isLoading = false - completion?() - } - } - - private func assignBeacon(servicePointId: Int, beaconId: Int?) { - assigningPointId = servicePointId - Task { - do { - try await APIService.shared.assignBeaconToServicePoint( - servicePointId: servicePointId, - beaconId: beaconId - ) - loadData() - } catch let apiError as APIError where apiError == .unauthorized { - await appState.handleUnauthorized() - } catch { - self.error = error.localizedDescription - } - assigningPointId = nil - } - } -} - -// MARK: - Service Point Row - -struct ServicePointRow: View { - let servicePoint: ServicePoint - let beacons: [Beacon] - let isAssigning: Bool - var onAssignBeacon: (Int?) -> Void - - @State private var showBeaconPicker = false - - private var assignedBeacon: Beacon? { - guard let bid = servicePoint.beaconId else { return nil } - return beacons.first { $0.id == bid } - } - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(servicePoint.name) - .font(.headline) - if !servicePoint.typeName.isEmpty { - Text(servicePoint.typeName) - .font(.caption) - .foregroundColor(.secondary) - } - } - Spacer() - if servicePoint.isActive { - Circle() - .fill(.green) - .frame(width: 8, height: 8) - } else { - Circle() - .fill(.red) - .frame(width: 8, height: 8) - } - } - - // Beacon assignment - HStack { - Image(systemName: "sensor.tag.radiowaves.forward.fill") - .font(.caption) - .foregroundColor(.secondary) - - if isAssigning { - ProgressView() - .controlSize(.small) - } else if let beacon = assignedBeacon { - Text(beacon.name) - .font(.subheadline) - .foregroundColor(.payfritGreen) - } else { - Text("No beacon assigned") - .font(.subheadline) - .foregroundColor(.secondary) - } - - Spacer() - - Button { - showBeaconPicker = true - } label: { - Text(assignedBeacon != nil ? "Change" : "Assign") - .font(.caption) - } - .buttonStyle(.bordered) - .controlSize(.small) - - if assignedBeacon != nil { - Button { - onAssignBeacon(nil) - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundColor(.red) - .font(.caption) - } - .buttonStyle(.plain) - } - } - } - .padding(.vertical, 4) - .sheet(isPresented: $showBeaconPicker) { - BeaconPickerSheet(beacons: beacons, currentBeaconId: servicePoint.beaconId) { selectedId in - onAssignBeacon(selectedId) - } - } - } -} - -// MARK: - Beacon Picker Sheet - -struct BeaconPickerSheet: View { - let beacons: [Beacon] - let currentBeaconId: Int? - var onSelect: (Int) -> Void - - @Environment(\.dismiss) private var dismiss - - var body: some View { - NavigationStack { - List(beacons) { beacon in - Button { - onSelect(beacon.id) - dismiss() - } label: { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(beacon.name) - .font(.headline) - .foregroundColor(.primary) - Text(beacon.formattedUUID) - .font(.caption) - .foregroundColor(.secondary) - } - Spacer() - if beacon.id == currentBeaconId { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.payfritGreen) - } - } - } - } - .navigationTitle("Select Beacon") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } - } - } - } - } -} From 6832a8ad537c726c5b54800c371bc09b38ae3212 Mon Sep 17 00:00:00 2001 From: Schwifty Date: Sun, 22 Mar 2026 17:17:49 +0000 Subject: [PATCH 2/3] feat: add QR scanner view + 7 missing API endpoints - QRScannerView: AVFoundation camera + barcode/QR detection with flashlight toggle, viewfinder overlay, MAC/UUID pattern recognition - New API endpoints: deleteServicePoint, updateServicePoint, listBeacons, decommissionBeacon, lookupByMac, getBeaconStatus, getProfile - Wire QR scanner into ScanView with BLE Scan + QR Scan side-by-side - MAC address lookup on scan to check if beacon already registered - Updated Xcode project file with new source --- PayfritBeacon.xcodeproj/project.pbxproj | 4 + PayfritBeacon/Services/APIClient.swift | 96 ++++++++ PayfritBeacon/Views/QRScannerView.swift | 290 ++++++++++++++++++++++++ PayfritBeacon/Views/ScanView.swift | 80 ++++++- 4 files changed, 460 insertions(+), 10 deletions(-) create mode 100644 PayfritBeacon/Views/QRScannerView.swift diff --git a/PayfritBeacon.xcodeproj/project.pbxproj b/PayfritBeacon.xcodeproj/project.pbxproj index c931024..cc5c909 100644 --- a/PayfritBeacon.xcodeproj/project.pbxproj +++ b/PayfritBeacon.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ D01000000006 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000006 /* LoginView.swift */; }; D01000000007 /* BusinessListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000007 /* BusinessListView.swift */; }; D01000000008 /* ScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000008 /* ScanView.swift */; }; + D01000000009 /* QRScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000009 /* QRScannerView.swift */; }; D0100000000A /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0200000000A /* RootView.swift */; }; D01000000060 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D02000000060 /* Assets.xcassets */; }; D01000000070 /* payfrit-favicon-light-outlines.svg in Resources */ = {isa = PBXBuildFile; fileRef = D02000000070 /* payfrit-favicon-light-outlines.svg */; }; @@ -46,6 +47,7 @@ D02000000006 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; D02000000007 /* BusinessListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusinessListView.swift; sourceTree = ""; }; D02000000008 /* ScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanView.swift; sourceTree = ""; }; + D02000000009 /* QRScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRScannerView.swift; sourceTree = ""; }; D0200000000A /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; D02000000010 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; D02000000060 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -110,6 +112,7 @@ D02000000006 /* LoginView.swift */, D02000000007 /* BusinessListView.swift */, D02000000008 /* ScanView.swift */, + D02000000009 /* QRScannerView.swift */, D02000000010 /* Info.plist */, D02000000060 /* Assets.xcassets */, D02000000070 /* payfrit-favicon-light-outlines.svg */, @@ -254,6 +257,7 @@ D01000000006 /* LoginView.swift in Sources */, D01000000007 /* BusinessListView.swift in Sources */, D01000000008 /* ScanView.swift in Sources */, + D01000000009 /* QRScannerView.swift in Sources */, D0100000000A /* RootView.swift in Sources */, D010000000B1 /* BLEBeaconScanner.swift in Sources */, D010000000B2 /* BeaconProvisioner.swift in Sources */, diff --git a/PayfritBeacon/Services/APIClient.swift b/PayfritBeacon/Services/APIClient.swift index f2303e2..1642b29 100644 --- a/PayfritBeacon/Services/APIClient.swift +++ b/PayfritBeacon/Services/APIClient.swift @@ -194,6 +194,102 @@ actor APIClient { 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.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.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.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.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.self, from: data) + return resp.data + } + + // 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.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 {} diff --git a/PayfritBeacon/Views/QRScannerView.swift b/PayfritBeacon/Views/QRScannerView.swift new file mode 100644 index 0000000..b82db83 --- /dev/null +++ b/PayfritBeacon/Views/QRScannerView.swift @@ -0,0 +1,290 @@ +import SwiftUI +import AVFoundation +import Vision + +/// QR/Barcode scanner for reading beacon MAC addresses and UUIDs +/// Matches Android QRScannerActivity — camera preview + barcode detection +struct QRScannerView: View { + @Environment(\.dismiss) private var dismiss + + /// Called with the scanned string (MAC address, UUID, or other barcode) + let onScan: (String) -> Void + + @State private var scannedCode: String? + @State private var isFlashOn = false + @State private var cameraPermission: CameraPermission = .undetermined + + enum CameraPermission { + case undetermined, granted, denied + } + + var body: some View { + NavigationStack { + ZStack { + if cameraPermission == .granted { + CameraPreview(onCodeDetected: handleDetection, isFlashOn: $isFlashOn) + .ignoresSafeArea() + + // Scan overlay + scanOverlay + } else if cameraPermission == .denied { + cameraPermissionDenied + } else { + Color.black.ignoresSafeArea() + .overlay { + ProgressView("Requesting camera…") + .tint(.white) + .foregroundStyle(.white) + } + } + } + .navigationTitle("Scan QR / Barcode") + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.ultraThinMaterial, for: .navigationBar) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + .foregroundStyle(.white) + } + ToolbarItem(placement: .topBarTrailing) { + Button { + isFlashOn.toggle() + } label: { + Image(systemName: isFlashOn ? "flashlight.on.fill" : "flashlight.off.fill") + .foregroundStyle(isFlashOn ? .yellow : .white) + } + } + } + .task { + await checkCameraPermission() + } + .alert("Code Detected", isPresented: .init( + get: { scannedCode != nil }, + set: { if !$0 { scannedCode = nil } } + )) { + Button("Use This") { + if let code = scannedCode { + onScan(code) + dismiss() + } + } + Button("Scan Again", role: .cancel) { + scannedCode = nil + } + } message: { + Text(scannedCode ?? "") + } + } + } + + // MARK: - Scan Overlay + + private var scanOverlay: some View { + VStack { + Spacer() + + // Viewfinder frame + RoundedRectangle(cornerRadius: 16) + .stroke(.white.opacity(0.8), lineWidth: 2) + .frame(width: 280, height: 280) + .overlay { + // Corner accents + GeometryReader { geo in + let w = geo.size.width + let h = geo.size.height + let corner: CGFloat = 30 + let thickness: CGFloat = 4 + + // Top-left + Path { p in + p.move(to: CGPoint(x: 0, y: corner)) + p.addLine(to: CGPoint(x: 0, y: 0)) + p.addLine(to: CGPoint(x: corner, y: 0)) + } + .stroke(.blue, lineWidth: thickness) + + // Top-right + Path { p in + p.move(to: CGPoint(x: w - corner, y: 0)) + p.addLine(to: CGPoint(x: w, y: 0)) + p.addLine(to: CGPoint(x: w, y: corner)) + } + .stroke(.blue, lineWidth: thickness) + + // Bottom-left + Path { p in + p.move(to: CGPoint(x: 0, y: h - corner)) + p.addLine(to: CGPoint(x: 0, y: h)) + p.addLine(to: CGPoint(x: corner, y: h)) + } + .stroke(.blue, lineWidth: thickness) + + // Bottom-right + Path { p in + p.move(to: CGPoint(x: w - corner, y: h)) + p.addLine(to: CGPoint(x: w, y: h)) + p.addLine(to: CGPoint(x: w, y: h - corner)) + } + .stroke(.blue, lineWidth: thickness) + } + } + + Spacer() + + // Instructions + Text("Point camera at beacon QR code or barcode") + .font(.subheadline) + .foregroundStyle(.white) + .padding() + .background(.ultraThinMaterial, in: Capsule()) + .padding(.bottom, 40) + } + } + + private var cameraPermissionDenied: some View { + ContentUnavailableView { + Label("Camera Access Required", systemImage: "camera.fill") + } description: { + Text("Open Settings and enable camera access for Payfrit Beacon.") + } actions: { + Button("Open Settings") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + .buttonStyle(.borderedProminent) + } + } + + // MARK: - Actions + + private func checkCameraPermission() async { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + cameraPermission = .granted + case .notDetermined: + let granted = await AVCaptureDevice.requestAccess(for: .video) + cameraPermission = granted ? .granted : .denied + default: + cameraPermission = .denied + } + } + + private func handleDetection(_ code: String) { + guard scannedCode == nil else { return } // Debounce + // Haptic feedback + let generator = UIImpactFeedbackGenerator(style: .medium) + generator.impactOccurred() + scannedCode = code + } +} + +// MARK: - Camera Preview (UIViewRepresentable) + +/// AVFoundation camera preview with barcode/QR detection via Vision framework +struct CameraPreview: UIViewRepresentable { + let onCodeDetected: (String) -> Void + @Binding var isFlashOn: Bool + + func makeUIView(context: Context) -> CameraPreviewUIView { + let view = CameraPreviewUIView(onCodeDetected: onCodeDetected) + return view + } + + func updateUIView(_ uiView: CameraPreviewUIView, context: Context) { + uiView.setFlash(isFlashOn) + } +} + +final class CameraPreviewUIView: UIView { + private var captureSession: AVCaptureSession? + private var previewLayer: AVCaptureVideoPreviewLayer? + private let onCodeDetected: (String) -> Void + private var lastDetectionTime: Date = .distantPast + private let debounceInterval: TimeInterval = 1.5 + + init(onCodeDetected: @escaping (String) -> Void) { + self.onCodeDetected = onCodeDetected + super.init(frame: .zero) + setupCamera() + } + + required init?(coder: NSCoder) { fatalError() } + + override func layoutSubviews() { + super.layoutSubviews() + previewLayer?.frame = bounds + } + + func setFlash(_ on: Bool) { + guard let device = AVCaptureDevice.default(for: .video), + device.hasTorch else { return } + try? device.lockForConfiguration() + device.torchMode = on ? .on : .off + device.unlockForConfiguration() + } + + private func setupCamera() { + let session = AVCaptureSession() + session.sessionPreset = .high + + guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back), + let input = try? AVCaptureDeviceInput(device: device) else { return } + + if session.canAddInput(input) { + session.addInput(input) + } + + // Use AVCaptureMetadataOutput for barcode detection (more reliable than Vision for barcodes) + let metadataOutput = AVCaptureMetadataOutput() + if session.canAddOutput(metadataOutput) { + session.addOutput(metadataOutput) + metadataOutput.setMetadataObjectsDelegate(self, queue: .main) + metadataOutput.metadataObjectTypes = [ + .qr, + .ean8, .ean13, + .code128, .code39, .code93, + .dataMatrix, + .pdf417, + .aztec + ] + } + + let previewLayer = AVCaptureVideoPreviewLayer(session: session) + previewLayer.videoGravity = .resizeAspectFill + layer.addSublayer(previewLayer) + self.previewLayer = previewLayer + + self.captureSession = session + + DispatchQueue.global(qos: .userInitiated).async { + session.startRunning() + } + } + + deinit { + captureSession?.stopRunning() + setFlash(false) + } +} + +// MARK: - AVCaptureMetadataOutputObjectsDelegate + +extension CameraPreviewUIView: AVCaptureMetadataOutputObjectsDelegate { + func metadataOutput( + _ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection + ) { + guard let metadata = metadataObjects.first as? AVMetadataMachineReadableCodeObject, + let value = metadata.stringValue, + !value.isEmpty else { return } + + // Debounce rapid detections + let now = Date() + guard now.timeIntervalSince(lastDetectionTime) > debounceInterval else { return } + lastDetectionTime = now + + onCodeDetected(value) + } +} diff --git a/PayfritBeacon/Views/ScanView.swift b/PayfritBeacon/Views/ScanView.swift index b1ba176..f5e690d 100644 --- a/PayfritBeacon/Views/ScanView.swift +++ b/PayfritBeacon/Views/ScanView.swift @@ -25,6 +25,8 @@ struct ScanView: View { @State private var provisioningState: ProvisioningState = .idle @State private var statusMessage = "" @State private var errorMessage: String? + @State private var showQRScanner = false + @State private var scannedMAC: String? enum ProvisioningState { case idle @@ -68,6 +70,11 @@ struct ScanView: View { .sheet(isPresented: $showCreateServicePoint) { createServicePointSheet } + .sheet(isPresented: $showQRScanner) { + QRScannerView { code in + handleQRScan(code) + } + } } .task { await loadServicePoints() @@ -210,18 +217,31 @@ struct ScanView: View { private var beaconList: some View { VStack(spacing: 0) { - // Scan button - Button { - Task { await startScan() } - } label: { - HStack { - Image(systemName: bleManager.isScanning ? "antenna.radiowaves.left.and.right" : "magnifyingglass") - Text(bleManager.isScanning ? "Scanning…" : "Scan for Beacons") + // Scan buttons + HStack(spacing: 12) { + Button { + Task { await startScan() } + } label: { + HStack { + Image(systemName: bleManager.isScanning ? "antenna.radiowaves.left.and.right" : "magnifyingglass") + Text(bleManager.isScanning ? "Scanning…" : "BLE Scan") + } + .frame(maxWidth: .infinity) } - .frame(maxWidth: .infinity) + .buttonStyle(.borderedProminent) + .disabled(bleManager.isScanning || bleManager.bluetoothState != .poweredOn) + + Button { + showQRScanner = true + } label: { + HStack { + Image(systemName: "qrcode.viewfinder") + Text("QR Scan") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) } - .buttonStyle(.borderedProminent) - .disabled(bleManager.isScanning || bleManager.bluetoothState != .poweredOn) .padding() if bleManager.discoveredBeacons.isEmpty && !bleManager.isScanning { @@ -613,6 +633,45 @@ struct ScanView: View { resetProvisioningState() } + /// Handle QR/barcode scan result — could be MAC address, UUID, or other identifier + private func handleQRScan(_ code: String) { + let cleaned = code.trimmingCharacters(in: .whitespacesAndNewlines) + + // Check if it looks like a MAC address (AA:BB:CC:DD:EE:FF or AABBCCDDEEFF) + let macPattern = #"^([0-9A-Fa-f]{2}[:-]?){5}[0-9A-Fa-f]{2}$"# + if cleaned.range(of: macPattern, options: .regularExpression) != nil { + scannedMAC = cleaned.replacingOccurrences(of: "-", with: ":").uppercased() + statusMessage = "Scanned MAC: \(scannedMAC ?? cleaned)" + // Look up the beacon by MAC + Task { await lookupScannedMAC() } + return + } + + // Check if it looks like a UUID + let uuidPattern = #"^[0-9A-Fa-f]{8}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{12}$"# + if cleaned.range(of: uuidPattern, options: .regularExpression) != nil { + statusMessage = "Scanned UUID: \(cleaned)" + return + } + + // Generic code — just show it + statusMessage = "Scanned: \(cleaned)" + } + + private func lookupScannedMAC() async { + guard let mac = scannedMAC, let token = appState.token else { return } + do { + if let existing = try await APIClient.shared.lookupByMac(macAddress: mac, token: token) { + let beaconType = existing.beaconType ?? existing.BeaconType ?? "Unknown" + statusMessage = "MAC \(mac) — already registered as \(beaconType)" + } else { + statusMessage = "MAC \(mac) — not yet registered. Scan BLE to find and provision." + } + } catch { + statusMessage = "MAC \(mac) — lookup failed: \(error.localizedDescription)" + } + } + private func resetProvisioningState() { provisioningState = .idle statusMessage = "" @@ -620,6 +679,7 @@ struct ScanView: View { selectedBeacon = nil pendingConfig = nil pendingProvisioner = nil + scannedMAC = nil } private func makeProvisioner(for beacon: DiscoveredBeacon) -> any BeaconProvisioner { From 38b4c987c951259a9964e310c792dae1f884ca0a Mon Sep 17 00:00:00 2001 From: Schwifty Date: Sun, 22 Mar 2026 17:25:55 +0000 Subject: [PATCH 3/3] 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) } } }