Migrate API endpoints from CFML to PHP

- Replace all .cfm endpoints with .php (PHP backend migration)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John Pinkyfloyd 2026-03-14 17:17:02 -07:00
parent 5283d2d265
commit 2ec195243c
7 changed files with 427 additions and 359 deletions

View file

@ -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 = "<group>"; };
D02000000007 /* BusinessListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusinessListView.swift; sourceTree = "<group>"; };
D02000000008 /* ScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanView.swift; sourceTree = "<group>"; };
D02000000009 /* QrScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QrScanView.swift; sourceTree = "<group>"; };
D0200000000A /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
D02000000010 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
D02000000060 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@ -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;

View file

@ -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 {

View file

@ -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

View file

@ -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
}

View file

@ -22,6 +22,8 @@
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Payfrit Beacon uses Bluetooth to discover and configure nearby beacons.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Payfrit Beacon uses your location to detect nearby iBeacons and verify beacon ownership.</string>
<key>NSFaceIDUsageDescription</key>
<string>Payfrit Beacon uses Face ID for quick sign-in.</string>
<key>UIApplicationSceneManifest</key>

View file

@ -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..<next]))
idx = next
}
return result.joined(separator: ":")
}
private func formatUuid(_ hex: String) -> 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()
}
}

View file

@ -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)