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 */; }; D01000000006 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000006 /* LoginView.swift */; };
D01000000007 /* BusinessListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000007 /* BusinessListView.swift */; }; D01000000007 /* BusinessListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000007 /* BusinessListView.swift */; };
D01000000008 /* ScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000008 /* ScanView.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 */; }; D0100000000A /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0200000000A /* RootView.swift */; };
D01000000060 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D02000000060 /* Assets.xcassets */; }; 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 */; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; D02000000060 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@ -106,7 +104,6 @@
D02000000006 /* LoginView.swift */, D02000000006 /* LoginView.swift */,
D02000000007 /* BusinessListView.swift */, D02000000007 /* BusinessListView.swift */,
D02000000008 /* ScanView.swift */, D02000000008 /* ScanView.swift */,
D02000000009 /* QrScanView.swift */,
D02000000010 /* Info.plist */, D02000000010 /* Info.plist */,
D02000000060 /* Assets.xcassets */, D02000000060 /* Assets.xcassets */,
D02000000070 /* payfrit-favicon-light-outlines.svg */, D02000000070 /* payfrit-favicon-light-outlines.svg */,
@ -250,7 +247,6 @@
D01000000006 /* LoginView.swift in Sources */, D01000000006 /* LoginView.swift in Sources */,
D01000000007 /* BusinessListView.swift in Sources */, D01000000007 /* BusinessListView.swift in Sources */,
D01000000008 /* ScanView.swift in Sources */, D01000000008 /* ScanView.swift in Sources */,
D01000000009 /* QrScanView.swift in Sources */,
D0100000000A /* RootView.swift in Sources */, D0100000000A /* RootView.swift in Sources */,
D010000000B1 /* BLEBeaconScanner.swift in Sources */, D010000000B1 /* BLEBeaconScanner.swift in Sources */,
D010000000B2 /* BeaconProvisioner.swift in Sources */, D010000000B2 /* BeaconProvisioner.swift in Sources */,
@ -282,7 +278,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2; CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_TEAM = U83YL8VRF3; DEVELOPMENT_TEAM = U83YL8VRF3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = PayfritBeacon/Info.plist; INFOPLIST_FILE = PayfritBeacon/Info.plist;
@ -434,7 +430,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2; CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_TEAM = U83YL8VRF3; DEVELOPMENT_TEAM = U83YL8VRF3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = PayfritBeacon/Info.plist; INFOPLIST_FILE = PayfritBeacon/Info.plist;
@ -586,7 +582,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2; CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_TEAM = U83YL8VRF3; DEVELOPMENT_TEAM = U83YL8VRF3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = PayfritBeacon/Info.plist; INFOPLIST_FILE = PayfritBeacon/Info.plist;
@ -621,7 +617,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2; CURRENT_PROJECT_VERSION = 5;
DEVELOPMENT_TEAM = U83YL8VRF3; DEVELOPMENT_TEAM = U83YL8VRF3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = PayfritBeacon/Info.plist; INFOPLIST_FILE = PayfritBeacon/Info.plist;

View file

@ -92,7 +92,7 @@ class Api {
// ========================================================================= // =========================================================================
func sendLoginOtp(phone: String) async throws -> OtpResponse { 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 { guard let uuid = (json["UUID"] as? String) ?? (json["uuid"] as? String), !uuid.isEmpty else {
throw ApiException("Server error - please try again") throw ApiException("Server error - please try again")
@ -102,7 +102,7 @@ class Api {
} }
func verifyLoginOtp(uuid: String, otp: String) async throws -> LoginResponse { 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, "UUID": uuid,
"OTP": otp "OTP": otp
]) ])
@ -125,7 +125,7 @@ class Api {
// ========================================================================= // =========================================================================
func listBusinesses() async throws -> [Business] { 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 { guard let businesses = (json["BUSINESSES"] ?? json["businesses"] ?? json["Items"] ?? json["ITEMS"]) as? [[String: Any]] else {
return [] return []
@ -146,7 +146,7 @@ class Api {
// ========================================================================= // =========================================================================
func listAllBeacons() async throws -> [String: Int] { 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 { guard let items = (json["ITEMS"] ?? json["items"]) as? [[String: Any]] else {
return [:] return [:]
@ -167,7 +167,7 @@ class Api {
func lookupBeacons(uuids: [String]) async throws -> [BeaconLookupResult] { func lookupBeacons(uuids: [String]) async throws -> [BeaconLookupResult] {
if uuids.isEmpty { return [] } 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 { guard let beacons = (json["BEACONS"] ?? json["beacons"]) as? [[String: Any]] else {
return [] return []
@ -191,7 +191,7 @@ class Api {
func listBeacons(businessId: Int) async throws -> [BeaconInfo] { func listBeacons(businessId: Int) async throws -> [BeaconInfo] {
let json = try await postRequest( let json = try await postRequest(
endpoint: "/beacons/list.cfm", endpoint: "/beacons/list.php",
body: ["BusinessID": businessId], body: ["BusinessID": businessId],
extraHeaders: ["X-Business-Id": String(businessId)] extraHeaders: ["X-Business-Id": String(businessId)]
) )
@ -221,7 +221,7 @@ class Api {
} }
let json = try await postRequest( let json = try await postRequest(
endpoint: "/beacons/save.cfm", endpoint: "/beacons/save.php",
body: params, body: params,
extraHeaders: ["X-Business-Id": String(businessId)] extraHeaders: ["X-Business-Id": String(businessId)]
) )
@ -241,7 +241,7 @@ class Api {
} }
func lookupByMac(macAddress: String) async throws -> MacLookupResult? { 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"]) { if !parseBool(json["OK"] ?? json["ok"]) {
return nil return nil
@ -264,7 +264,7 @@ class Api {
func wipeBeacon(businessId: Int, beaconId: Int) async throws -> Bool { func wipeBeacon(businessId: Int, beaconId: Int) async throws -> Bool {
let json = try await postRequest( let json = try await postRequest(
endpoint: "/beacons/wipe.cfm", endpoint: "/beacons/wipe.php",
body: ["BusinessID": businessId, "BeaconID": beaconId], body: ["BusinessID": businessId, "BeaconID": beaconId],
extraHeaders: ["X-Business-Id": String(businessId)] extraHeaders: ["X-Business-Id": String(businessId)]
) )
@ -287,7 +287,7 @@ class Api {
DebugLog.shared.log("[API] getBeaconConfig businessId=\(businessId) servicePointId=\(servicePointId)") DebugLog.shared.log("[API] getBeaconConfig businessId=\(businessId) servicePointId=\(servicePointId)")
let json = try await postRequest( let json = try await postRequest(
endpoint: "/beacon-sharding/get_beacon_config.cfm", endpoint: "/beacon-sharding/get_beacon_config.php",
body: ["BusinessID": businessId, "ServicePointID": servicePointId], body: ["BusinessID": businessId, "ServicePointID": servicePointId],
extraHeaders: ["X-Business-Id": String(businessId)] extraHeaders: ["X-Business-Id": String(businessId)]
) )
@ -312,14 +312,24 @@ class Api {
throw ApiException("Invalid beacon config response - no Minor. Keys: \(json.keys.sorted())") 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( return BeaconConfigResponse(
uuid: uuid.replacingOccurrences(of: "-", with: "").uppercased(), uuid: uuid.replacingOccurrences(of: "-", with: "").uppercased(),
major: UInt16(major), major: UInt16(major),
minor: UInt16(minor), minor: UInt16(minor),
txPower: parseIntValue(json["TXPOWER"] ?? json["txPower"] ?? json["TxPower"]) ?? -59, measuredPower: Int8(clamping: measuredPower),
interval: parseIntValue(json["INTERVAL"] ?? json["interval"] ?? json["Interval"]) ?? 350 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)") DebugLog.shared.log("[API] registerBeaconHardware body: \(body)")
let json = try await postRequest( let json = try await postRequest(
endpoint: "/beacon-sharding/register_beacon_hardware.cfm", endpoint: "/beacon-sharding/register_beacon_hardware.php",
body: body, body: body,
extraHeaders: ["X-Business-Id": String(businessId)] extraHeaders: ["X-Business-Id": String(businessId)]
) )
@ -355,10 +365,31 @@ class Api {
return true 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 /// Verify beacon is broadcasting expected values
func verifyBeaconBroadcast(businessId: Int, uuid: String, major: UInt16, minor: UInt16) async throws -> Bool { func verifyBeaconBroadcast(businessId: Int, uuid: String, major: UInt16, minor: UInt16) async throws -> Bool {
let json = try await postRequest( let json = try await postRequest(
endpoint: "/beacon-sharding/verify_beacon_broadcast.cfm", endpoint: "/beacon-sharding/verify_beacon_broadcast.php",
body: [ body: [
"BusinessID": businessId, "BusinessID": businessId,
"UUID": uuid, "UUID": uuid,
@ -374,7 +405,7 @@ class Api {
/// Allocate beacon namespace for a business (shard + major) /// Allocate beacon namespace for a business (shard + major)
func allocateBusinessNamespace(businessId: Int) async throws -> BusinessNamespace { func allocateBusinessNamespace(businessId: Int) async throws -> BusinessNamespace {
let json = try await postRequest( let json = try await postRequest(
endpoint: "/beacon-sharding/allocate_business_namespace.cfm", endpoint: "/beacon-sharding/allocate_business_namespace.php",
body: ["BusinessID": businessId], body: ["BusinessID": businessId],
extraHeaders: ["X-Business-Id": String(businessId)] extraHeaders: ["X-Business-Id": String(businessId)]
) )
@ -408,7 +439,7 @@ class Api {
/// List service points for a business (for beacon assignment) /// List service points for a business (for beacon assignment)
func listServicePoints(businessId: Int) async throws -> [ServicePoint] { func listServicePoints(businessId: Int) async throws -> [ServicePoint] {
let json = try await postRequest( let json = try await postRequest(
endpoint: "/servicepoints/list.cfm", endpoint: "/servicepoints/list.php",
body: ["BusinessID": businessId], body: ["BusinessID": businessId],
extraHeaders: ["X-Business-Id": String(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))") DebugLog.shared.log("[API] saveServicePoint businessId=\(businessId) name=\(name) servicePointId=\(String(describing: servicePointId))")
let json = try await postRequest( let json = try await postRequest(
endpoint: "/servicepoints/save.cfm", endpoint: "/servicepoints/save.php",
body: body, body: body,
extraHeaders: ["X-Business-Id": String(businessId)] extraHeaders: ["X-Business-Id": String(businessId)]
) )
@ -478,7 +509,7 @@ class Api {
/// Delete a service point /// Delete a service point
func deleteServicePoint(businessId: Int, servicePointId: Int) async throws { func deleteServicePoint(businessId: Int, servicePointId: Int) async throws {
let json = try await postRequest( let json = try await postRequest(
endpoint: "/servicepoints/delete.cfm", endpoint: "/servicepoints/delete.php",
body: ["BusinessID": businessId, "ServicePointID": servicePointId], body: ["BusinessID": businessId, "ServicePointID": servicePointId],
extraHeaders: ["X-Business-Id": String(businessId)] extraHeaders: ["X-Business-Id": String(businessId)]
) )
@ -583,8 +614,16 @@ struct BeaconConfigResponse {
let uuid: String // 32-char hex, no dashes let uuid: String // 32-char hex, no dashes
let major: UInt16 let major: UInt16
let minor: UInt16 let minor: UInt16
let txPower: Int let measuredPower: Int8 // RSSI@1m (e.g., -59)
let interval: Int 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 { struct ServicePoint: Identifiable {

View file

@ -108,8 +108,10 @@ extension BLEBeaconScanner: CBCentralManagerDelegate {
guard rssiValue > -70 && rssiValue < 0 else { return } // Only show nearby devices guard rssiValue > -70 && rssiValue < 0 else { return } // Only show nearby devices
let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "" 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 var beaconType: BeaconType = .unknown
if let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] { if let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] {
if serviceUUIDs.contains(BLEBeaconScanner.KBEACON_SERVICE) { 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 // Update or add beacon
if let index = discoveredBeacons.firstIndex(where: { $0.id == peripheral.identifier }) { if let index = discoveredBeacons.firstIndex(where: { $0.id == peripheral.identifier }) {
discoveredBeacons[index].rssi = rssiValue discoveredBeacons[index].rssi = rssiValue

View file

@ -9,19 +9,21 @@ enum ProvisioningResult {
/// Configuration to write to a beacon /// Configuration to write to a beacon
struct BeaconConfig { struct BeaconConfig {
let uuid: String // 32-char hex, no dashes let uuid: String // 32-char hex, no dashes
let major: UInt16 let major: UInt16
let minor: UInt16 let minor: UInt16
let txPower: Int8 // Typically -59 let measuredPower: Int8 // RSSI@1m (e.g., -59) - from server, NOT hardcoded
let interval: UInt16 // Advertising interval in ms, typically 350 let advInterval: UInt8 // Advertising interval raw value (e.g., 2 = 200ms) - from server
let deviceName: String? // Optional device name (used by DX-Smart) 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.uuid = uuid
self.major = major self.major = major
self.minor = minor self.minor = minor
self.measuredPower = measuredPower
self.advInterval = advInterval
self.txPower = txPower self.txPower = txPower
self.interval = interval
self.deviceName = deviceName self.deviceName = deviceName
} }
} }
@ -62,19 +64,25 @@ class BeaconProvisioner: NSObject, ObservableObject {
// DX-Smart packet header // DX-Smart packet header
private static let DXSMART_HEADER: [UInt8] = [0x4E, 0x4F] private static let DXSMART_HEADER: [UInt8] = [0x4E, 0x4F]
// DX-Smart default connection password (from SDK "555555", NOT "dx1234" which is factory reset only) // DX-Smart connection password
private static let DXSMART_PASSWORD = "555555" private static let DXSMART_PASSWORD = "dx1234"
// DX-Smart command codes // DX-Smart command codes
private enum DXCmd: UInt8 { private enum DXCmd: UInt8 {
case frameTable = 0x10 case frameTable = 0x10
case frameSelectSlot0 = 0x11 case frameSelectSlot0 = 0x11 // Frame 1 (device info)
case frameSelectSlot1 = 0x12 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 authCheck = 0x25
case deviceInfo = 0x30 case deviceInfo = 0x30
case deviceName = 0x43 case deviceName = 0x43 // Read device name
case saveConfig = 0x60 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 uuid = 0x74
case major = 0x75 case major = 0x75
case minor = 0x76 case minor = 0x76
@ -132,6 +140,11 @@ class BeaconProvisioner: NSObject, ObservableObject {
private var dxReadQueryIndex = 0 private var dxReadQueryIndex = 0
private var responseBuffer: [UInt8] = [] private var responseBuffer: [UInt8] = []
// Connection retry state
private var connectionRetryCount = 0
private static let MAX_CONNECTION_RETRIES = 3
private var currentBeacon: DiscoveredBeacon?
override init() { override init() {
super.init() super.init()
centralManager = CBCentralManager(delegate: self, queue: .main) centralManager = CBCentralManager(delegate: self, queue: .main)
@ -165,6 +178,8 @@ class BeaconProvisioner: NSObject, ObservableObject {
self.dxSmartNotifySubscribed = false self.dxSmartNotifySubscribed = false
self.dxSmartCommandQueue.removeAll() self.dxSmartCommandQueue.removeAll()
self.dxSmartWriteIndex = 0 self.dxSmartWriteIndex = 0
self.connectionRetryCount = 0
self.currentBeacon = beacon
state = .connecting state = .connecting
progress = "Connecting to \(beacon.displayName)..." progress = "Connecting to \(beacon.displayName)..."
@ -209,6 +224,8 @@ class BeaconProvisioner: NSObject, ObservableObject {
self.dxReadQueries.removeAll() self.dxReadQueries.removeAll()
self.dxReadQueryIndex = 0 self.dxReadQueryIndex = 0
self.allDiscoveredServices.removeAll() self.allDiscoveredServices.removeAll()
self.connectionRetryCount = 0
self.currentBeacon = beacon
self.servicesToExplore.removeAll() self.servicesToExplore.removeAll()
state = .connecting state = .connecting
@ -239,6 +256,8 @@ class BeaconProvisioner: NSObject, ObservableObject {
dxSmartNotifySubscribed = false dxSmartNotifySubscribed = false
dxSmartCommandQueue.removeAll() dxSmartCommandQueue.removeAll()
dxSmartWriteIndex = 0 dxSmartWriteIndex = 0
connectionRetryCount = 0
currentBeacon = nil
state = .idle state = .idle
progress = "" progress = ""
} }
@ -309,6 +328,24 @@ class BeaconProvisioner: NSObject, ObservableObject {
} }
/// Build the full command sequence and start writing /// 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() { private func dxSmartWriteConfig() {
guard let config = config else { guard let config = config else {
fail("No config provided") fail("No config provided")
@ -321,39 +358,73 @@ class BeaconProvisioner: NSObject, ObservableObject {
dxSmartCommandQueue.removeAll() dxSmartCommandQueue.removeAll()
dxSmartWriteIndex = 0 dxSmartWriteIndex = 0
// --- Frame Slot 0: Configure iBeacon --- // Convert measuredPower (signed Int8) to unsigned byte for transmission
// SDK sends NO data for frame select and frame type commands 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: [])) 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: [])) dxSmartCommandQueue.append(buildDXPacket(cmd: .iBeaconType, data: []))
// 9. UUID (0x74) [16 bytes]
if let uuidData = hexStringToData(config.uuid) { if let uuidData = hexStringToData(config.uuid) {
dxSmartCommandQueue.append(buildDXPacket(cmd: .uuid, data: Array(uuidData))) dxSmartCommandQueue.append(buildDXPacket(cmd: .uuid, data: Array(uuidData)))
} }
// 10. Major (0x75) [2 bytes big-endian]
let majorHi = UInt8((config.major >> 8) & 0xFF) let majorHi = UInt8((config.major >> 8) & 0xFF)
let majorLo = UInt8(config.major & 0xFF) let majorLo = UInt8(config.major & 0xFF)
dxSmartCommandQueue.append(buildDXPacket(cmd: .major, data: [majorHi, majorLo])) dxSmartCommandQueue.append(buildDXPacket(cmd: .major, data: [majorHi, majorLo]))
// 11. Minor (0x76) [2 bytes big-endian]
let minorHi = UInt8((config.minor >> 8) & 0xFF) let minorHi = UInt8((config.minor >> 8) & 0xFF)
let minorLo = UInt8(config.minor & 0xFF) let minorLo = UInt8(config.minor & 0xFF)
dxSmartCommandQueue.append(buildDXPacket(cmd: .minor, data: [minorHi, minorLo])) dxSmartCommandQueue.append(buildDXPacket(cmd: .minor, data: [minorHi, minorLo]))
dxSmartCommandQueue.append(buildDXPacket(cmd: .rssiAt1m, data: [0xC5])) // -59 dBm (matches SDK) // 12. RSSI@1m (0x77)
dxSmartCommandQueue.append(buildDXPacket(cmd: .advInterval, data: [0x02])) // 200ms dxSmartCommandQueue.append(buildDXPacket(cmd: .rssiAt1m, data: [measuredPowerByte]))
dxSmartCommandQueue.append(buildDXPacket(cmd: .txPower, data: [0x01])) // -13.5 dBm // 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: [])) dxSmartCommandQueue.append(buildDXPacket(cmd: .triggerOff, data: []))
// --- Frame Slot 1: Disable --- // --- Frames 3-6: Disable each ---
dxSmartCommandQueue.append(buildDXPacket(cmd: .frameSelectSlot1, data: [])) // 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: [])) dxSmartCommandQueue.append(buildDXPacket(cmd: .frameDisable, data: []))
// --- Device Name (optional) --- // 24. SaveConfig (0x60) persist to flash
if let name = config.deviceName, !name.isEmpty {
let nameBytes = Array(name.utf8)
dxSmartCommandQueue.append(buildDXPacket(cmd: .deviceName, data: nameBytes))
}
// --- Save ---
dxSmartCommandQueue.append(buildDXPacket(cmd: .saveConfig, data: [])) dxSmartCommandQueue.append(buildDXPacket(cmd: .saveConfig, data: []))
DebugLog.shared.log("BLE: DX-Smart command queue built with \(dxSmartCommandQueue.count) commands") DebugLog.shared.log("BLE: DX-Smart command queue built with \(dxSmartCommandQueue.count) commands")
@ -712,6 +783,8 @@ class BeaconProvisioner: NSObject, ObservableObject {
servicesToExplore.removeAll() servicesToExplore.removeAll()
configService = nil configService = nil
characteristics.removeAll() characteristics.removeAll()
connectionRetryCount = 0
currentBeacon = nil
operationMode = .provisioning operationMode = .provisioning
state = .idle state = .idle
progress = "" progress = ""
@ -768,11 +841,31 @@ extension BeaconProvisioner: CBCentralManagerDelegate {
} }
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
let msg = "Failed to connect: \(error?.localizedDescription ?? "unknown error")" let errorMsg = error?.localizedDescription ?? "unknown error"
if operationMode == .readingConfig { DebugLog.shared.log("BLE: Connection failed (attempt \(connectionRetryCount + 1)): \(errorMsg)")
readFail(msg)
// 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 { } 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() self?.dxSmartSendNextReadQuery()
} }
} else { } 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 return
} }

View file

@ -22,6 +22,8 @@
<string>$(CURRENT_PROJECT_VERSION)</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSBluetoothAlwaysUsageDescription</key> <key>NSBluetoothAlwaysUsageDescription</key>
<string>Payfrit Beacon uses Bluetooth to discover and configure nearby beacons.</string> <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> <key>NSFaceIDUsageDescription</key>
<string>Payfrit Beacon uses Face ID for quick sign-in.</string> <string>Payfrit Beacon uses Face ID for quick sign-in.</string>
<key>UIApplicationSceneManifest</key> <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 bleScanner = BLEBeaconScanner()
@StateObject private var provisioner = BeaconProvisioner() @StateObject private var provisioner = BeaconProvisioner()
@StateObject private var iBeaconScanner = BeaconScanner()
@State private var namespace: BusinessNamespace? @State private var namespace: BusinessNamespace?
@State private var servicePoints: [ServicePoint] = [] @State private var servicePoints: [ServicePoint] = []
@State private var nextTableNumber: Int = 1 @State private var nextTableNumber: Int = 1
@State private var provisionedCount: Int = 0 @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 // UI State
@State private var snackMessage: String? @State private var snackMessage: String?
@State private var showAssignSheet = false @State private var showAssignSheet = false
@ -89,43 +95,68 @@ struct ScanView: View {
bluetoothWarning bluetoothWarning
} }
// Beacon list // Beacon lists
if bleScanner.discoveredBeacons.isEmpty { ScrollView {
Spacer() LazyVStack(spacing: 8) {
if bleScanner.isScanning { // Detected iBeacons section (shows ownership status)
VStack(spacing: 12) { if !detectedIBeacons.isEmpty {
ProgressView() VStack(alignment: .leading, spacing: 8) {
Text("Scanning for beacons...") Text("Detected Beacons")
.foregroundColor(.secondary) .font(.caption.weight(.semibold))
} .foregroundColor(.secondary)
} else { .padding(.horizontal)
VStack(spacing: 12) {
Image(systemName: "antenna.radiowaves.left.and.right") ForEach(detectedIBeacons, id: \.minor) { ibeacon in
.font(.largeTitle) iBeaconRow(ibeacon)
.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
}
} }
.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 // Bottom action bar
@ -269,6 +300,48 @@ struct ScanView: View {
return .signalWeak 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 // MARK: - Assignment Sheet
private var assignSheet: some View { private var assignSheet: some View {
@ -431,12 +504,12 @@ struct ScanView: View {
Text("iBeacon Configuration") Text("iBeacon Configuration")
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
if let uuid = data.uuid {
configRow("UUID", uuid)
}
if let major = data.major { if let major = data.major {
configRow("Major", "\(major)") configRow("Major", "\(major)")
} }
if let ns = namespace {
configRow("Shard", "\(ns.shardId)")
}
if let minor = data.minor { if let minor = data.minor {
configRow("Minor", "\(minor)") configRow("Minor", "\(minor)")
} }
@ -656,15 +729,18 @@ struct ScanView: View {
private func loadServicePoints() { private func loadServicePoints() {
Task { Task {
// Load namespace (for display/debug) and service points
// Note: Namespace is no longer required for provisioning - we use get_beacon_config instead
do { do {
// Load namespace (UUID + Major) and service points in parallel namespace = try await Api.shared.allocateBusinessNamespace(businessId: businessId)
async let nsTask = Api.shared.allocateBusinessNamespace(businessId: businessId)
async let spTask = Api.shared.listServicePoints(businessId: businessId)
namespace = try await nsTask
servicePoints = try await spTask
DebugLog.shared.log("[ScanView] Loaded namespace: uuid=\(namespace?.uuid ?? "nil") major=\(namespace?.major ?? 0)") 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") DebugLog.shared.log("[ScanView] Loaded \(servicePoints.count) service points")
// Find next table number // Find next table number
@ -678,7 +754,7 @@ struct ScanView: View {
}.max() ?? 0 }.max() ?? 0
nextTableNumber = maxNumber + 1 nextTableNumber = maxNumber + 1
} catch { } catch {
DebugLog.shared.log("[ScanView] loadServicePoints error: \(error)") DebugLog.shared.log("[ScanView] listServicePoints error: \(error)")
} }
// Auto-start scan // Auto-start scan
@ -691,7 +767,66 @@ struct ScanView: View {
showSnack("Bluetooth not available") showSnack("Bluetooth not available")
return return
} }
// Start BLE scan for DX-Smart devices
bleScanner.startScanning() 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) { private func checkConfig(_ beacon: DiscoveredBeacon) {
@ -719,11 +854,6 @@ struct ScanView: View {
private func reprovisionBeacon() { private func reprovisionBeacon() {
guard let beacon = selectedBeacon, let sp = reprovisionServicePoint else { return } 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 // Stop scanning
bleScanner.stopScanning() bleScanner.stopScanning()
@ -733,35 +863,28 @@ struct ScanView: View {
showAssignSheet = true // Reuse assign sheet to show progress showAssignSheet = true // Reuse assign sheet to show progress
assignName = sp.name 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 { Task {
do { do {
// If SP has no minor, re-fetch to get it // Use the new unified get_beacon_config endpoint
var minor = sp.beaconMinor provisioningProgress = "Getting beacon config..."
if minor == nil { let config = try await Api.shared.getBeaconConfig(businessId: businessId, servicePointId: sp.servicePointId)
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
}
guard let beaconMinor = minor else { DebugLog.shared.log("[ScanView] reprovisionBeacon: config uuid=\(config.uuid) major=\(config.major) minor=\(config.minor) measuredPower=\(config.measuredPower) advInterval=\(config.advInterval) txPower=\(config.txPower)")
failProvisioning("Service point has no beacon minor assigned")
return
}
// Build config from namespace + service point (uuidClean for BLE) // Build config using server-provided values (NOT hardcoded)
let deviceName = "PF-\(sp.name)" let deviceName = config.servicePointName.isEmpty ? sp.name : config.servicePointName
let beaconConfig = BeaconConfig( let beaconConfig = BeaconConfig(
uuid: ns.uuidClean, uuid: config.uuid,
major: ns.major, major: config.major,
minor: beaconMinor, minor: config.minor,
txPower: -59, measuredPower: config.measuredPower,
interval: 350, advInterval: config.advInterval,
txPower: config.txPower,
deviceName: deviceName deviceName: deviceName
) )
DebugLog.shared.log("[ScanView] reprovisionBeacon: BLE uuid=\(ns.uuidClean) API uuid=\(ns.uuid) major=\(ns.major) minor=\(beaconMinor)")
provisioningProgress = "Provisioning beacon..." provisioningProgress = "Provisioning beacon..."
let hardwareId = beacon.id.uuidString // BLE peripheral identifier let hardwareId = beacon.id.uuidString // BLE peripheral identifier
@ -770,12 +893,14 @@ struct ScanView: View {
switch result { switch result {
case .success: case .success:
do { do {
// Register with the UUID format expected by API (with dashes)
let uuidWithDashes = formatUuidWithDashes(config.uuid)
try await Api.shared.registerBeaconHardware( try await Api.shared.registerBeaconHardware(
businessId: businessId, businessId: businessId,
servicePointId: sp.servicePointId, servicePointId: sp.servicePointId,
uuid: ns.uuid, uuid: uuidWithDashes,
major: ns.major, major: config.major,
minor: beaconMinor, minor: config.minor,
hardwareId: hardwareId hardwareId: hardwareId
) )
finishProvisioning(name: sp.name) finishProvisioning(name: sp.name)
@ -798,11 +923,6 @@ struct ScanView: View {
let name = assignName.trimmingCharacters(in: .whitespaces) let name = assignName.trimmingCharacters(in: .whitespaces)
guard !name.isEmpty else { return } guard !name.isEmpty else { return }
guard let ns = namespace else {
failProvisioning("Namespace not loaded — go back and try again")
return
}
isProvisioning = true isProvisioning = true
provisioningProgress = "Preparing..." provisioningProgress = "Preparing..."
provisioningError = nil provisioningError = nil
@ -817,58 +937,50 @@ struct ScanView: View {
// 1. Reuse existing service point if name matches, otherwise create new // 1. Reuse existing service point if name matches, otherwise create new
var servicePoint: ServicePoint var servicePoint: ServicePoint
if let existing = servicePoints.first(where: { $0.name.caseInsensitiveCompare(name) == .orderedSame }) { 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 servicePoint = existing
} else { } else {
provisioningProgress = "Creating service point..." provisioningProgress = "Creating service point..."
DebugLog.shared.log("[ScanView] saveBeacon: creating new SP...") DebugLog.shared.log("[ScanView] saveBeacon: creating new SP...")
servicePoint = try await Api.shared.createServicePoint(businessId: businessId, name: name) 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 // 2. Use the new unified get_beacon_config endpoint (replaces allocate_business_namespace + allocate_servicepoint_minor)
if servicePoint.beaconMinor == nil { provisioningProgress = "Getting beacon config..."
DebugLog.shared.log("[ScanView] saveBeacon: SP has no minor, re-fetching service points...") let config = try await Api.shared.getBeaconConfig(businessId: businessId, servicePointId: servicePoint.servicePointId)
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))")
}
}
guard let minor = servicePoint.beaconMinor else { DebugLog.shared.log("[ScanView] saveBeacon: config uuid=\(config.uuid) major=\(config.major) minor=\(config.minor) measuredPower=\(config.measuredPower) advInterval=\(config.advInterval) txPower=\(config.txPower)")
failProvisioning("Service point has no beacon minor assigned")
return
}
// 2. Build config from namespace + service point (uuidClean = no dashes, for BLE) // 3. Build config using server-provided values (NOT hardcoded)
let deviceName = "PF-\(name)" let deviceName = config.servicePointName.isEmpty ? name : config.servicePointName
let beaconConfig = BeaconConfig( let beaconConfig = BeaconConfig(
uuid: ns.uuidClean, uuid: config.uuid,
major: ns.major, major: config.major,
minor: minor, minor: config.minor,
txPower: -59, measuredPower: config.measuredPower,
interval: 350, advInterval: config.advInterval,
txPower: config.txPower,
deviceName: deviceName deviceName: deviceName
) )
DebugLog.shared.log("[ScanView] saveBeacon: BLE uuid=\(ns.uuidClean) API uuid=\(ns.uuid) major=\(ns.major) minor=\(minor)")
provisioningProgress = "Provisioning beacon..." provisioningProgress = "Provisioning beacon..."
// 3. Provision the beacon via GATT // 4. Provision the beacon via GATT
let hardwareId = beacon.id.uuidString // BLE peripheral identifier let hardwareId = beacon.id.uuidString // BLE peripheral identifier
provisioner.provision(beacon: beacon, config: beaconConfig) { result in provisioner.provision(beacon: beacon, config: beaconConfig) { result in
Task { @MainActor in Task { @MainActor in
switch result { switch result {
case .success: case .success:
// Register in backend (use original UUID from API, not cleaned) // Register in backend (use UUID with dashes for API)
do { do {
let uuidWithDashes = formatUuidWithDashes(config.uuid)
try await Api.shared.registerBeaconHardware( try await Api.shared.registerBeaconHardware(
businessId: businessId, businessId: businessId,
servicePointId: servicePoint.servicePointId, servicePointId: servicePoint.servicePointId,
uuid: ns.uuid, uuid: uuidWithDashes,
major: ns.major, major: config.major,
minor: minor, minor: config.minor,
hardwareId: hardwareId hardwareId: hardwareId
) )
finishProvisioning(name: name) finishProvisioning(name: name)