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:
parent
5283d2d265
commit
2ec195243c
7 changed files with 427 additions and 359 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue