diff --git a/PayfritBeacon.xcodeproj/project.pbxproj b/PayfritBeacon.xcodeproj/project.pbxproj index d06c555..430da25 100644 --- a/PayfritBeacon.xcodeproj/project.pbxproj +++ b/PayfritBeacon.xcodeproj/project.pbxproj @@ -17,7 +17,6 @@ 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 /* QrScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000009 /* QrScanView.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 */; }; @@ -43,7 +42,6 @@ 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 /* QrScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QrScanView.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 = ""; }; @@ -106,7 +104,6 @@ D02000000006 /* LoginView.swift */, D02000000007 /* BusinessListView.swift */, D02000000008 /* ScanView.swift */, - D02000000009 /* QrScanView.swift */, D02000000010 /* Info.plist */, D02000000060 /* Assets.xcassets */, D02000000070 /* payfrit-favicon-light-outlines.svg */, @@ -250,7 +247,6 @@ D01000000006 /* LoginView.swift in Sources */, D01000000007 /* BusinessListView.swift in Sources */, D01000000008 /* ScanView.swift in Sources */, - D01000000009 /* QrScanView.swift in Sources */, D0100000000A /* RootView.swift in Sources */, D010000000B1 /* BLEBeaconScanner.swift in Sources */, D010000000B2 /* BeaconProvisioner.swift in Sources */, @@ -282,7 +278,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = U83YL8VRF3; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = PayfritBeacon/Info.plist; @@ -434,7 +430,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = U83YL8VRF3; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = PayfritBeacon/Info.plist; @@ -586,7 +582,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = U83YL8VRF3; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = PayfritBeacon/Info.plist; @@ -621,7 +617,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = U83YL8VRF3; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = PayfritBeacon/Info.plist; diff --git a/PayfritBeacon/Api.swift b/PayfritBeacon/Api.swift index 53ec069..9fa2dc5 100644 --- a/PayfritBeacon/Api.swift +++ b/PayfritBeacon/Api.swift @@ -92,7 +92,7 @@ class Api { // ========================================================================= func sendLoginOtp(phone: String) async throws -> OtpResponse { - let json = try await postRequest(endpoint: "/auth/loginOTP.cfm", body: ["Phone": phone]) + 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") @@ -102,7 +102,7 @@ class Api { } func verifyLoginOtp(uuid: String, otp: String) async throws -> LoginResponse { - let json = try await postRequest(endpoint: "/auth/verifyLoginOTP.cfm", body: [ + let json = try await postRequest(endpoint: "/auth/verifyLoginOTP.php", body: [ "UUID": uuid, "OTP": otp ]) @@ -125,7 +125,7 @@ class Api { // ========================================================================= func listBusinesses() async throws -> [Business] { - let json = try await postRequest(endpoint: "/businesses/list.cfm", body: [:]) + 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 [] @@ -146,7 +146,7 @@ class Api { // ========================================================================= func listAllBeacons() async throws -> [String: Int] { - let json = try await getRequest(endpoint: "/beacons/list_all.cfm") + let json = try await getRequest(endpoint: "/beacons/list_all.php") guard let items = (json["ITEMS"] ?? json["items"]) as? [[String: Any]] else { return [:] @@ -167,7 +167,7 @@ class Api { func lookupBeacons(uuids: [String]) async throws -> [BeaconLookupResult] { if uuids.isEmpty { return [] } - let json = try await postRequest(endpoint: "/beacons/lookup.cfm", body: ["UUIDs": uuids]) + let json = try await postRequest(endpoint: "/beacons/lookup.php", body: ["UUIDs": uuids]) guard let beacons = (json["BEACONS"] ?? json["beacons"]) as? [[String: Any]] else { return [] @@ -191,7 +191,7 @@ class Api { func listBeacons(businessId: Int) async throws -> [BeaconInfo] { let json = try await postRequest( - endpoint: "/beacons/list.cfm", + endpoint: "/beacons/list.php", body: ["BusinessID": businessId], extraHeaders: ["X-Business-Id": String(businessId)] ) @@ -221,7 +221,7 @@ class Api { } let json = try await postRequest( - endpoint: "/beacons/save.cfm", + endpoint: "/beacons/save.php", body: params, extraHeaders: ["X-Business-Id": String(businessId)] ) @@ -241,7 +241,7 @@ class Api { } func lookupByMac(macAddress: String) async throws -> MacLookupResult? { - let json = try await postRequest(endpoint: "/beacons/lookupByMac.cfm", body: ["MACAddress": macAddress]) + let json = try await postRequest(endpoint: "/beacons/lookupByMac.php", body: ["MACAddress": macAddress]) if !parseBool(json["OK"] ?? json["ok"]) { return nil @@ -264,7 +264,7 @@ class Api { func wipeBeacon(businessId: Int, beaconId: Int) async throws -> Bool { let json = try await postRequest( - endpoint: "/beacons/wipe.cfm", + endpoint: "/beacons/wipe.php", body: ["BusinessID": businessId, "BeaconID": beaconId], extraHeaders: ["X-Business-Id": String(businessId)] ) @@ -287,7 +287,7 @@ class Api { DebugLog.shared.log("[API] getBeaconConfig businessId=\(businessId) servicePointId=\(servicePointId)") let json = try await postRequest( - endpoint: "/beacon-sharding/get_beacon_config.cfm", + endpoint: "/beacon-sharding/get_beacon_config.php", body: ["BusinessID": businessId, "ServicePointID": servicePointId], extraHeaders: ["X-Business-Id": String(businessId)] ) @@ -312,14 +312,24 @@ class Api { throw ApiException("Invalid beacon config response - no Minor. Keys: \(json.keys.sorted())") } - DebugLog.shared.log("[API] getBeaconConfig parsed: uuid=\(uuid) major=\(major) minor=\(minor)") + // 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.replacingOccurrences(of: "-", with: "").uppercased(), major: UInt16(major), minor: UInt16(minor), - txPower: parseIntValue(json["TXPOWER"] ?? json["txPower"] ?? json["TxPower"]) ?? -59, - interval: parseIntValue(json["INTERVAL"] ?? json["interval"] ?? json["Interval"]) ?? 350 + measuredPower: Int8(clamping: measuredPower), + advInterval: UInt8(clamping: advInterval), + txPower: UInt8(clamping: txPower), + servicePointName: servicePointName, + businessName: businessName ) } @@ -340,7 +350,7 @@ class Api { DebugLog.shared.log("[API] registerBeaconHardware body: \(body)") let json = try await postRequest( - endpoint: "/beacon-sharding/register_beacon_hardware.cfm", + endpoint: "/beacon-sharding/register_beacon_hardware.php", body: body, extraHeaders: ["X-Business-Id": String(businessId)] ) @@ -355,10 +365,31 @@ class Api { 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.cfm", + endpoint: "/beacon-sharding/verify_beacon_broadcast.php", body: [ "BusinessID": businessId, "UUID": uuid, @@ -374,7 +405,7 @@ class Api { /// 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.cfm", + endpoint: "/beacon-sharding/allocate_business_namespace.php", body: ["BusinessID": businessId], extraHeaders: ["X-Business-Id": String(businessId)] ) @@ -408,7 +439,7 @@ class Api { /// List service points for a business (for beacon assignment) func listServicePoints(businessId: Int) async throws -> [ServicePoint] { let json = try await postRequest( - endpoint: "/servicepoints/list.cfm", + endpoint: "/servicepoints/list.php", body: ["BusinessID": businessId], extraHeaders: ["X-Business-Id": String(businessId)] ) @@ -438,7 +469,7 @@ class Api { DebugLog.shared.log("[API] saveServicePoint businessId=\(businessId) name=\(name) servicePointId=\(String(describing: servicePointId))") let json = try await postRequest( - endpoint: "/servicepoints/save.cfm", + endpoint: "/servicepoints/save.php", body: body, extraHeaders: ["X-Business-Id": String(businessId)] ) @@ -478,7 +509,7 @@ class Api { /// Delete a service point func deleteServicePoint(businessId: Int, servicePointId: Int) async throws { let json = try await postRequest( - endpoint: "/servicepoints/delete.cfm", + endpoint: "/servicepoints/delete.php", body: ["BusinessID": businessId, "ServicePointID": servicePointId], extraHeaders: ["X-Business-Id": String(businessId)] ) @@ -583,8 +614,16 @@ struct BeaconConfigResponse { let uuid: String // 32-char hex, no dashes let major: UInt16 let minor: UInt16 - let txPower: Int - let interval: Int + 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 { diff --git a/PayfritBeacon/BLEBeaconScanner.swift b/PayfritBeacon/BLEBeaconScanner.swift index f38417a..dfea0ff 100644 --- a/PayfritBeacon/BLEBeaconScanner.swift +++ b/PayfritBeacon/BLEBeaconScanner.swift @@ -108,8 +108,10 @@ extension BLEBeaconScanner: CBCentralManagerDelegate { 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 (informational only) + // 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) { @@ -119,6 +121,11 @@ extension BLEBeaconScanner: CBCentralManagerDelegate { } } + // 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 diff --git a/PayfritBeacon/BeaconProvisioner.swift b/PayfritBeacon/BeaconProvisioner.swift index ce8ab69..6464548 100644 --- a/PayfritBeacon/BeaconProvisioner.swift +++ b/PayfritBeacon/BeaconProvisioner.swift @@ -9,19 +9,21 @@ enum ProvisioningResult { /// Configuration to write to a beacon struct BeaconConfig { - let uuid: String // 32-char hex, no dashes + let uuid: String // 32-char hex, no dashes let major: UInt16 let minor: UInt16 - let txPower: Int8 // Typically -59 - let interval: UInt16 // Advertising interval in ms, typically 350 - let deviceName: String? // Optional device name (used by DX-Smart) + 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, txPower: Int8, interval: UInt16, deviceName: String? = nil) { + 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.interval = interval self.deviceName = deviceName } } @@ -62,19 +64,25 @@ class BeaconProvisioner: NSObject, ObservableObject { // DX-Smart packet header private static let DXSMART_HEADER: [UInt8] = [0x4E, 0x4F] - // DX-Smart default connection password (from SDK — "555555", NOT "dx1234" which is factory reset only) - private static let DXSMART_PASSWORD = "555555" + // DX-Smart connection password + private static let DXSMART_PASSWORD = "dx1234" // DX-Smart command codes private enum DXCmd: UInt8 { case frameTable = 0x10 - case frameSelectSlot0 = 0x11 - case frameSelectSlot1 = 0x12 + 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 + case deviceName = 0x43 // Read device name case saveConfig = 0x60 - case iBeaconType = 0x62 + 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 @@ -132,6 +140,11 @@ class BeaconProvisioner: NSObject, ObservableObject { private var dxReadQueryIndex = 0 private var responseBuffer: [UInt8] = [] + // Connection retry state + private var connectionRetryCount = 0 + private static let MAX_CONNECTION_RETRIES = 3 + private var currentBeacon: DiscoveredBeacon? + override init() { super.init() centralManager = CBCentralManager(delegate: self, queue: .main) @@ -165,6 +178,8 @@ class BeaconProvisioner: NSObject, ObservableObject { self.dxSmartNotifySubscribed = false self.dxSmartCommandQueue.removeAll() self.dxSmartWriteIndex = 0 + self.connectionRetryCount = 0 + self.currentBeacon = beacon state = .connecting progress = "Connecting to \(beacon.displayName)..." @@ -209,6 +224,8 @@ class BeaconProvisioner: NSObject, ObservableObject { self.dxReadQueries.removeAll() self.dxReadQueryIndex = 0 self.allDiscoveredServices.removeAll() + self.connectionRetryCount = 0 + self.currentBeacon = beacon self.servicesToExplore.removeAll() state = .connecting @@ -239,6 +256,8 @@ class BeaconProvisioner: NSObject, ObservableObject { dxSmartNotifySubscribed = false dxSmartCommandQueue.removeAll() dxSmartWriteIndex = 0 + connectionRetryCount = 0 + currentBeacon = nil state = .idle progress = "" } @@ -309,6 +328,24 @@ class BeaconProvisioner: NSObject, ObservableObject { } /// Build the full command sequence and start writing + /// New 24-step 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-23. Frames 3-6 select + 0xFF (disable each) + /// 24. SaveConfig 0x60 — persist to flash private func dxSmartWriteConfig() { guard let config = config else { fail("No config provided") @@ -321,39 +358,73 @@ class BeaconProvisioner: NSObject, ObservableObject { dxSmartCommandQueue.removeAll() dxSmartWriteIndex = 0 - // --- Frame Slot 0: Configure iBeacon --- - // SDK sends NO data for frame select and frame type commands + // Convert measuredPower (signed Int8) to unsigned byte for transmission + let measuredPowerByte = UInt8(bitPattern: config.measuredPower) + + // 1. DeviceName (0x71) — service point name (max 20 ASCII chars) + if let name = config.deviceName, !name.isEmpty { + let truncatedName = String(name.prefix(20)) + let nameBytes = Array(truncatedName.utf8) + dxSmartCommandQueue.append(buildDXPacket(cmd: .deviceNameWrite, data: nameBytes)) + } + + // --- Frame 1: Device Info (broadcasts name) --- + // 2. Frame1_Select (0x11) dxSmartCommandQueue.append(buildDXPacket(cmd: .frameSelectSlot0, data: [])) + // 3. Frame1_Type (0x61) — device info + dxSmartCommandQueue.append(buildDXPacket(cmd: .deviceInfoType, data: [])) + // 4. Frame1_RSSI (0x77) + dxSmartCommandQueue.append(buildDXPacket(cmd: .rssiAt1m, data: [measuredPowerByte])) + // 5. Frame1_AdvInt (0x78) + dxSmartCommandQueue.append(buildDXPacket(cmd: .advInterval, data: [config.advInterval])) + // 6. Frame1_TxPow (0x79) + dxSmartCommandQueue.append(buildDXPacket(cmd: .txPower, data: [config.txPower])) + + // --- Frame 2: iBeacon --- + // 7. Frame2_Select (0x12) + dxSmartCommandQueue.append(buildDXPacket(cmd: .frameSelectSlot1, data: [])) + // 8. Frame2_Type (0x62) — iBeacon dxSmartCommandQueue.append(buildDXPacket(cmd: .iBeaconType, data: [])) + // 9. UUID (0x74) [16 bytes] if let uuidData = hexStringToData(config.uuid) { dxSmartCommandQueue.append(buildDXPacket(cmd: .uuid, data: Array(uuidData))) } + // 10. Major (0x75) [2 bytes big-endian] let majorHi = UInt8((config.major >> 8) & 0xFF) let majorLo = UInt8(config.major & 0xFF) dxSmartCommandQueue.append(buildDXPacket(cmd: .major, data: [majorHi, majorLo])) + // 11. Minor (0x76) [2 bytes big-endian] let minorHi = UInt8((config.minor >> 8) & 0xFF) let minorLo = UInt8(config.minor & 0xFF) dxSmartCommandQueue.append(buildDXPacket(cmd: .minor, data: [minorHi, minorLo])) - dxSmartCommandQueue.append(buildDXPacket(cmd: .rssiAt1m, data: [0xC5])) // -59 dBm (matches SDK) - dxSmartCommandQueue.append(buildDXPacket(cmd: .advInterval, data: [0x02])) // 200ms - dxSmartCommandQueue.append(buildDXPacket(cmd: .txPower, data: [0x01])) // -13.5 dBm + // 12. RSSI@1m (0x77) + dxSmartCommandQueue.append(buildDXPacket(cmd: .rssiAt1m, data: [measuredPowerByte])) + // 13. AdvInterval (0x78) + dxSmartCommandQueue.append(buildDXPacket(cmd: .advInterval, data: [config.advInterval])) + // 14. TxPower (0x79) + dxSmartCommandQueue.append(buildDXPacket(cmd: .txPower, data: [config.txPower])) + // 15. TriggerOff (0xA0) dxSmartCommandQueue.append(buildDXPacket(cmd: .triggerOff, data: [])) - // --- Frame Slot 1: Disable --- - dxSmartCommandQueue.append(buildDXPacket(cmd: .frameSelectSlot1, data: [])) + // --- Frames 3-6: Disable each --- + // 16-17. Frame 3: select (0x13) + disable (0xFF) + dxSmartCommandQueue.append(buildDXPacket(cmd: .frameSelectSlot2, data: [])) + dxSmartCommandQueue.append(buildDXPacket(cmd: .frameDisable, data: [])) + // 18-19. Frame 4: select (0x14) + disable (0xFF) + dxSmartCommandQueue.append(buildDXPacket(cmd: .frameSelectSlot3, data: [])) + dxSmartCommandQueue.append(buildDXPacket(cmd: .frameDisable, data: [])) + // 20-21. Frame 5: select (0x15) + disable (0xFF) + dxSmartCommandQueue.append(buildDXPacket(cmd: .frameSelectSlot4, data: [])) + dxSmartCommandQueue.append(buildDXPacket(cmd: .frameDisable, data: [])) + // 22-23. Frame 6: select (0x16) + disable (0xFF) + dxSmartCommandQueue.append(buildDXPacket(cmd: .frameSelectSlot5, data: [])) dxSmartCommandQueue.append(buildDXPacket(cmd: .frameDisable, data: [])) - // --- Device Name (optional) --- - if let name = config.deviceName, !name.isEmpty { - let nameBytes = Array(name.utf8) - dxSmartCommandQueue.append(buildDXPacket(cmd: .deviceName, data: nameBytes)) - } - - // --- Save --- + // 24. SaveConfig (0x60) — persist to flash dxSmartCommandQueue.append(buildDXPacket(cmd: .saveConfig, data: [])) DebugLog.shared.log("BLE: DX-Smart command queue built with \(dxSmartCommandQueue.count) commands") @@ -712,6 +783,8 @@ class BeaconProvisioner: NSObject, ObservableObject { servicesToExplore.removeAll() configService = nil characteristics.removeAll() + connectionRetryCount = 0 + currentBeacon = nil operationMode = .provisioning state = .idle progress = "" @@ -768,11 +841,31 @@ extension BeaconProvisioner: CBCentralManagerDelegate { } func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { - let msg = "Failed to connect: \(error?.localizedDescription ?? "unknown error")" - if operationMode == .readingConfig { - readFail(msg) + let errorMsg = error?.localizedDescription ?? "unknown error" + DebugLog.shared.log("BLE: Connection failed (attempt \(connectionRetryCount + 1)): \(errorMsg)") + + // Retry logic: up to 3 retries with increasing delay (1s, 2s, 3s) + if connectionRetryCount < BeaconProvisioner.MAX_CONNECTION_RETRIES { + connectionRetryCount += 1 + let delay = Double(connectionRetryCount) // 1s, 2s, 3s + progress = "Connection failed, retrying (\(connectionRetryCount)/\(BeaconProvisioner.MAX_CONNECTION_RETRIES))..." + DebugLog.shared.log("BLE: Retrying connection 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 } // Don't retry if cancelled + + let resolvedPeripheral = self.resolvePeripheral(beacon) + self.peripheral = resolvedPeripheral + self.centralManager.connect(resolvedPeripheral, options: nil) + } } else { - fail(msg) + let msg = "Failed to connect after \(BeaconProvisioner.MAX_CONNECTION_RETRIES) attempts: \(errorMsg)" + if operationMode == .readingConfig { + readFail(msg) + } else { + fail(msg) + } } } @@ -905,7 +998,18 @@ extension BeaconProvisioner: CBPeripheralDelegate { self?.dxSmartSendNextReadQuery() } } else { - fail("Command write failed at step \(dxSmartWriteIndex + 1): \(error.localizedDescription)") + // Device name (0x71) and Frame 1 commands (steps 1-6) may be rejected by some firmware + // Treat these as non-fatal: log and continue to next command + let isNonFatalCommand = dxSmartWriteIndex < 6 // First 6 commands are optional + if isNonFatalCommand { + DebugLog.shared.log("BLE: Non-fatal command failed at step \(dxSmartWriteIndex + 1), continuing...") + dxSmartWriteIndex += 1 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in + self?.dxSmartSendNextCommand() + } + } else { + fail("Command write failed at step \(dxSmartWriteIndex + 1): \(error.localizedDescription)") + } } return } diff --git a/PayfritBeacon/Info.plist b/PayfritBeacon/Info.plist index 3ee1cbc..544965a 100644 --- a/PayfritBeacon/Info.plist +++ b/PayfritBeacon/Info.plist @@ -22,6 +22,8 @@ $(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 diff --git a/PayfritBeacon/QrScanView.swift b/PayfritBeacon/QrScanView.swift deleted file mode 100644 index 21f5f40..0000000 --- a/PayfritBeacon/QrScanView.swift +++ /dev/null @@ -1,192 +0,0 @@ -import SwiftUI -import AVFoundation - -struct QrScanView: View { - @Environment(\.dismiss) var dismiss - var onResult: (String, String) -> Void // (value, type) - - static let TYPE_MAC = "mac" - static let TYPE_UUID = "uuid" - static let TYPE_UNKNOWN = "unknown" - - var body: some View { - ZStack { - CameraPreviewView(onQrDetected: { rawValue in - let parsed = parseQrData(rawValue) - onResult(parsed.value, parsed.type) - dismiss() - }) - .ignoresSafeArea() - - // Scan frame overlay - VStack { - Spacer() - - RoundedRectangle(cornerRadius: 12) - .stroke(Color.white.opacity(0.7), lineWidth: 2) - .frame(width: 250, height: 250) - - Spacer() - } - - // Toolbar overlay - VStack { - HStack { - Button(action: { dismiss() }) { - Image(systemName: "xmark") - .font(.title2) - .foregroundColor(.white) - .padding() - } - Spacer() - Text("Scan QR Code") - .font(.headline) - .foregroundColor(.white) - Spacer() - // Balance spacer - Color.clear.frame(width: 44, height: 44) - } - .background(Color.black.opacity(0.3)) - - Spacer() - - Text("Point camera at beacon sticker QR code") - .foregroundColor(.white) - .font(.callout) - .padding() - .background(Color.black.opacity(0.5)) - .cornerRadius(8) - .padding(.bottom, 60) - - Button("Cancel") { - dismiss() - } - .foregroundColor(.white) - .padding() - .padding(.bottom, 20) - } - } - .modifier(DevBanner()) - } - - private func parseQrData(_ raw: String) -> (value: String, type: String) { - let trimmed = raw.trimmingCharacters(in: .whitespaces).uppercased() - - // MAC address with colons: AA:BB:CC:DD:EE:FF - if trimmed.range(of: "^[0-9A-F]{2}(:[0-9A-F]{2}){5}$", options: .regularExpression) != nil { - return (trimmed, QrScanView.TYPE_MAC) - } - // MAC address without separators: AABBCCDDEEFF (12 hex chars) - if trimmed.range(of: "^[0-9A-F]{12}$", options: .regularExpression) != nil { - return (formatMac(trimmed), QrScanView.TYPE_MAC) - } - // MAC address with dashes: AA-BB-CC-DD-EE-FF - if trimmed.range(of: "^[0-9A-F]{2}(-[0-9A-F]{2}){5}$", options: .regularExpression) != nil { - return (trimmed.replacingOccurrences(of: "-", with: ":"), QrScanView.TYPE_MAC) - } - // UUID with dashes - if trimmed.range(of: "^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$", options: .regularExpression) != nil { - return (trimmed, QrScanView.TYPE_UUID) - } - // UUID without dashes (32 hex chars) - if trimmed.range(of: "^[0-9A-F]{32}$", options: .regularExpression) != nil { - return (formatUuid(trimmed), QrScanView.TYPE_UUID) - } - return (trimmed, QrScanView.TYPE_UNKNOWN) - } - - private func formatMac(_ hex: String) -> String { - var result: [String] = [] - var idx = hex.startIndex - for _ in 0..<6 { - let next = hex.index(idx, offsetBy: 2) - result.append(String(hex[idx.. String { - return BeaconBanList.formatUuid(hex) - } -} - -// MARK: - Camera Preview UIKit wrapper - -struct CameraPreviewView: UIViewControllerRepresentable { - var onQrDetected: (String) -> Void - - func makeUIViewController(context: Context) -> QrCameraViewController { - let vc = QrCameraViewController() - vc.onQrDetected = onQrDetected - return vc - } - - func updateUIViewController(_ uiViewController: QrCameraViewController, context: Context) {} -} - -class QrCameraViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { - var onQrDetected: ((String) -> Void)? - private var captureSession: AVCaptureSession? - private var hasScanned = false - - override func viewDidLoad() { - super.viewDidLoad() - - let session = AVCaptureSession() - captureSession = session - - guard let device = AVCaptureDevice.default(for: .video), - let input = try? AVCaptureDeviceInput(device: device) else { - return - } - - if session.canAddInput(input) { - session.addInput(input) - } - - let output = AVCaptureMetadataOutput() - if session.canAddOutput(output) { - session.addOutput(output) - output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) - output.metadataObjectTypes = [.qr] - } - - let previewLayer = AVCaptureVideoPreviewLayer(session: session) - previewLayer.frame = view.bounds - previewLayer.videoGravity = .resizeAspectFill - view.layer.addSublayer(previewLayer) - - DispatchQueue.global(qos: .userInitiated).async { - session.startRunning() - } - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - if let layer = view.layer.sublayers?.first as? AVCaptureVideoPreviewLayer { - layer.frame = view.bounds - } - } - - func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], - from connection: AVCaptureConnection) { - guard !hasScanned else { return } - - for object in metadataObjects { - guard let readableObject = object as? AVMetadataMachineReadableCodeObject, - let value = readableObject.stringValue, !value.isEmpty else { - continue - } - hasScanned = true - captureSession?.stopRunning() - onQrDetected?(value) - return - } - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - captureSession?.stopRunning() - } -} diff --git a/PayfritBeacon/ScanView.swift b/PayfritBeacon/ScanView.swift index 23a3070..51c2cd3 100644 --- a/PayfritBeacon/ScanView.swift +++ b/PayfritBeacon/ScanView.swift @@ -10,12 +10,18 @@ struct ScanView: View { @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 @@ -89,43 +95,68 @@ struct ScanView: View { bluetoothWarning } - // Beacon list - if bleScanner.discoveredBeacons.isEmpty { - Spacer() - if bleScanner.isScanning { - VStack(spacing: 12) { - ProgressView() - Text("Scanning for beacons...") - .foregroundColor(.secondary) - } - } else { - 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) - } - } - Spacer() - } else { - ScrollView { - LazyVStack(spacing: 8) { - ForEach(bleScanner.discoveredBeacons) { beacon in - beaconRow(beacon) - .onTapGesture { - selectedBeacon = beacon - showBeaconActionSheet = true - } + // Beacon lists + ScrollView { + LazyVStack(spacing: 8) { + // 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(.top, 8) + + Divider() + .padding(.vertical, 8) + } + + // BLE devices section (for provisioning) + 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 + } + } + } + } 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) } - .padding(.horizontal) - .padding(.top, 8) } + .padding(.horizontal) } // Bottom action bar @@ -269,6 +300,48 @@ struct ScanView: View { 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 { @@ -431,12 +504,12 @@ struct ScanView: View { Text("iBeacon Configuration") .font(.subheadline.weight(.semibold)) - if let uuid = data.uuid { - configRow("UUID", uuid) - } if let major = data.major { configRow("Major", "\(major)") } + if let ns = namespace { + configRow("Shard", "\(ns.shardId)") + } if let minor = data.minor { configRow("Minor", "\(minor)") } @@ -656,15 +729,18 @@ struct ScanView: View { 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 { - // Load namespace (UUID + Major) and service points in parallel - async let nsTask = Api.shared.allocateBusinessNamespace(businessId: businessId) - async let spTask = Api.shared.listServicePoints(businessId: businessId) - - namespace = try await nsTask - servicePoints = try await spTask - + 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 @@ -678,7 +754,7 @@ struct ScanView: View { }.max() ?? 0 nextTableNumber = maxNumber + 1 } catch { - DebugLog.shared.log("[ScanView] loadServicePoints error: \(error)") + DebugLog.shared.log("[ScanView] listServicePoints error: \(error)") } // Auto-start scan @@ -691,7 +767,66 @@ struct ScanView: View { 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 = formatUuidWithDashes(beacon.uuid) + 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) { @@ -719,11 +854,6 @@ struct ScanView: View { private func reprovisionBeacon() { guard let beacon = selectedBeacon, let sp = reprovisionServicePoint else { return } - guard let ns = namespace else { - failProvisioning("Namespace not loaded — go back and try again") - return - } - // Stop scanning bleScanner.stopScanning() @@ -733,35 +863,28 @@ struct ScanView: View { showAssignSheet = true // Reuse assign sheet to show progress assignName = sp.name - DebugLog.shared.log("[ScanView] reprovisionBeacon: sp=\(sp.name) spId=\(sp.servicePointId) minor=\(String(describing: sp.beaconMinor)) beacon=\(beacon.displayName)") + DebugLog.shared.log("[ScanView] reprovisionBeacon: sp=\(sp.name) spId=\(sp.servicePointId) beacon=\(beacon.displayName)") Task { do { - // If SP has no minor, re-fetch to get it - var minor = sp.beaconMinor - if minor == nil { - DebugLog.shared.log("[ScanView] reprovisionBeacon: SP has no minor, re-fetching...") - let refreshed = try await Api.shared.listServicePoints(businessId: businessId) - minor = refreshed.first(where: { $0.servicePointId == sp.servicePointId })?.beaconMinor - } + // Use the new unified get_beacon_config endpoint + provisioningProgress = "Getting beacon config..." + let config = try await Api.shared.getBeaconConfig(businessId: businessId, servicePointId: sp.servicePointId) - guard let beaconMinor = minor else { - failProvisioning("Service point has no beacon minor assigned") - return - } + 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 from namespace + service point (uuidClean for BLE) - let deviceName = "PF-\(sp.name)" + // Build config using server-provided values (NOT hardcoded) + let deviceName = config.servicePointName.isEmpty ? sp.name : config.servicePointName let beaconConfig = BeaconConfig( - uuid: ns.uuidClean, - major: ns.major, - minor: beaconMinor, - txPower: -59, - interval: 350, + uuid: config.uuid, + major: config.major, + minor: config.minor, + measuredPower: config.measuredPower, + advInterval: config.advInterval, + txPower: config.txPower, deviceName: deviceName ) - DebugLog.shared.log("[ScanView] reprovisionBeacon: BLE uuid=\(ns.uuidClean) API uuid=\(ns.uuid) major=\(ns.major) minor=\(beaconMinor)") provisioningProgress = "Provisioning beacon..." let hardwareId = beacon.id.uuidString // BLE peripheral identifier @@ -770,12 +893,14 @@ struct ScanView: View { switch result { case .success: do { + // Register with the UUID format expected by API (with dashes) + let uuidWithDashes = formatUuidWithDashes(config.uuid) try await Api.shared.registerBeaconHardware( businessId: businessId, servicePointId: sp.servicePointId, - uuid: ns.uuid, - major: ns.major, - minor: beaconMinor, + uuid: uuidWithDashes, + major: config.major, + minor: config.minor, hardwareId: hardwareId ) finishProvisioning(name: sp.name) @@ -798,11 +923,6 @@ struct ScanView: View { let name = assignName.trimmingCharacters(in: .whitespaces) guard !name.isEmpty else { return } - guard let ns = namespace else { - failProvisioning("Namespace not loaded — go back and try again") - return - } - isProvisioning = true provisioningProgress = "Preparing..." provisioningError = nil @@ -817,58 +937,50 @@ struct ScanView: View { // 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) minor=\(String(describing: existing.beaconMinor))") + 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) minor=\(String(describing: servicePoint.beaconMinor))") + DebugLog.shared.log("[ScanView] saveBeacon: created SP id=\(servicePoint.servicePointId)") } - // If SP has no minor yet, re-fetch service points to get the allocated minor - if servicePoint.beaconMinor == nil { - DebugLog.shared.log("[ScanView] saveBeacon: SP has no minor, re-fetching service points...") - let refreshed = try await Api.shared.listServicePoints(businessId: businessId) - if let updated = refreshed.first(where: { $0.servicePointId == servicePoint.servicePointId }) { - servicePoint = updated - DebugLog.shared.log("[ScanView] saveBeacon: refreshed SP minor=\(String(describing: servicePoint.beaconMinor))") - } - } + // 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) - guard let minor = servicePoint.beaconMinor else { - failProvisioning("Service point has no beacon minor assigned") - return - } + DebugLog.shared.log("[ScanView] saveBeacon: config uuid=\(config.uuid) major=\(config.major) minor=\(config.minor) measuredPower=\(config.measuredPower) advInterval=\(config.advInterval) txPower=\(config.txPower)") - // 2. Build config from namespace + service point (uuidClean = no dashes, for BLE) - let deviceName = "PF-\(name)" + // 3. Build config using server-provided values (NOT hardcoded) + let deviceName = config.servicePointName.isEmpty ? name : config.servicePointName let beaconConfig = BeaconConfig( - uuid: ns.uuidClean, - major: ns.major, - minor: minor, - txPower: -59, - interval: 350, + uuid: config.uuid, + major: config.major, + minor: config.minor, + measuredPower: config.measuredPower, + advInterval: config.advInterval, + txPower: config.txPower, deviceName: deviceName ) - DebugLog.shared.log("[ScanView] saveBeacon: BLE uuid=\(ns.uuidClean) API uuid=\(ns.uuid) major=\(ns.major) minor=\(minor)") provisioningProgress = "Provisioning beacon..." - // 3. Provision the beacon via GATT + // 4. Provision the beacon via GATT let hardwareId = beacon.id.uuidString // BLE peripheral identifier provisioner.provision(beacon: beacon, config: beaconConfig) { result in Task { @MainActor in switch result { case .success: - // Register in backend (use original UUID from API, not cleaned) + // Register in backend (use UUID with dashes for API) do { + let uuidWithDashes = formatUuidWithDashes(config.uuid) try await Api.shared.registerBeaconHardware( businessId: businessId, servicePointId: servicePoint.servicePointId, - uuid: ns.uuid, - major: ns.major, - minor: minor, + uuid: uuidWithDashes, + major: config.major, + minor: config.minor, hardwareId: hardwareId ) finishProvisioning(name: name)