diff --git a/PayfritBeacon.xcodeproj/project.pbxproj b/PayfritBeacon.xcodeproj/project.pbxproj index 6063c3c..d06c555 100644 --- a/PayfritBeacon.xcodeproj/project.pbxproj +++ b/PayfritBeacon.xcodeproj/project.pbxproj @@ -11,13 +11,21 @@ 7D757E1341A0143A2E9EBDF4 /* Pods_PayfritBeacon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7F6819A0F2BD2E9D84E7EDB4 /* Pods_PayfritBeacon.framework */; }; D01000000001 /* PayfritBeaconApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000001 /* PayfritBeaconApp.swift */; }; D01000000002 /* Api.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000002 /* Api.swift */; }; + D01000000003 /* BeaconBanList.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000003 /* BeaconBanList.swift */; }; + D01000000004 /* BeaconScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000004 /* BeaconScanner.swift */; }; D01000000005 /* DevBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000005 /* DevBanner.swift */; }; D01000000006 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000006 /* LoginView.swift */; }; D01000000007 /* BusinessListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000007 /* BusinessListView.swift */; }; + D01000000008 /* ScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000008 /* ScanView.swift */; }; + D01000000009 /* QrScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000009 /* QrScanView.swift */; }; D0100000000A /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0200000000A /* RootView.swift */; }; D01000000060 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D02000000060 /* Assets.xcassets */; }; D01000000070 /* payfrit-favicon-light-outlines.svg in Resources */ = {isa = PBXBuildFile; fileRef = D02000000070 /* payfrit-favicon-light-outlines.svg */; }; D01000000080 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = D02000000080 /* InfoPlist.strings */; }; + D010000000B1 /* BLEBeaconScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8710F334FDFE10F0625EB86D /* BLEBeaconScanner.swift */; }; + D010000000B2 /* BeaconProvisioner.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7C275738594E225BE7D5740 /* BeaconProvisioner.swift */; }; + D010000000B3 /* BeaconShardPool.swift in Sources */ = {isa = PBXBuildFile; fileRef = 964B63D1857877BBEE73F1D1 /* BeaconShardPool.swift */; }; + F1575ED0F871FE8806035906 /* DebugLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = F100AA6D1E41596FCB1C1A39 /* DebugLog.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -42,6 +50,7 @@ D02000000070 /* payfrit-favicon-light-outlines.svg */ = {isa = PBXFileReference; lastKnownFileType = text; path = "payfrit-favicon-light-outlines.svg"; sourceTree = ""; }; D02000000081 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; E1775119CBC98A753AE26D84 /* ServicePointListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ServicePointListView.swift; sourceTree = ""; }; + F100AA6D1E41596FCB1C1A39 /* DebugLog.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DebugLog.swift; path = PayfritBeacon/DebugLog.swift; sourceTree = ""; }; F1B84CA677516C50F6BE2294 /* Pods-PayfritBeacon.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-PayfritBeacon.release.xcconfig"; path = "Target Support Files/Pods-PayfritBeacon/Pods-PayfritBeacon.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -73,6 +82,7 @@ C05000000009 /* Products */, 04996117E2F5D5BB2D86CD46 /* Pods */, EEC06FED6BE78CF9357F3158 /* Frameworks */, + F100AA6D1E41596FCB1C1A39 /* DebugLog.swift */, ); sourceTree = ""; }; @@ -234,11 +244,19 @@ files = ( D01000000001 /* PayfritBeaconApp.swift in Sources */, D01000000002 /* Api.swift in Sources */, + D01000000003 /* BeaconBanList.swift in Sources */, + D01000000004 /* BeaconScanner.swift in Sources */, D01000000005 /* DevBanner.swift in Sources */, D01000000006 /* LoginView.swift in Sources */, D01000000007 /* BusinessListView.swift in Sources */, + D01000000008 /* ScanView.swift in Sources */, + D01000000009 /* QrScanView.swift in Sources */, D0100000000A /* RootView.swift in Sources */, + D010000000B1 /* BLEBeaconScanner.swift in Sources */, + D010000000B2 /* BeaconProvisioner.swift in Sources */, + D010000000B3 /* BeaconShardPool.swift in Sources */, 281CC856DD918C4CFA00EB67 /* ServicePointListView.swift in Sources */, + F1575ED0F871FE8806035906 /* DebugLog.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -256,6 +274,193 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 064DDD2A9238EC6900250593 /* Release-Dev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F1B84CA677516C50F6BE2294 /* Pods-PayfritBeacon.release.xcconfig */; + buildSettings = { + APP_DISPLAY_NAME = "Payfrit Beacon BETA"; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 2; + DEVELOPMENT_TEAM = U83YL8VRF3; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = PayfritBeacon/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Payfrit Beacon"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.business"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.payfrit.beacon.dev; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEV; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = "Release-Dev"; + }; + 672BF8AE9DE36DEAE34D6DA0 /* Debug-Dev */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = "Debug-Dev"; + }; + 99070AFE15F557412BACA2C9 /* Release-Dev */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = "Release-Dev"; + }; + B0D496FEA252D8DDA33F57A0 /* Debug-Dev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AFF542870BA2862FAB429A86 /* Pods-PayfritBeacon.debug.xcconfig */; + buildSettings = { + APP_DISPLAY_NAME = "Payfrit Beacon BETA"; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 2; + DEVELOPMENT_TEAM = U83YL8VRF3; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = PayfritBeacon/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Payfrit Beacon"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.business"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.payfrit.beacon.dev; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG DEV"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = "Debug-Dev"; + }; C0B000000001 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -377,6 +582,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = AFF542870BA2862FAB429A86 /* Pods-PayfritBeacon.debug.xcconfig */; buildSettings = { + APP_DISPLAY_NAME = "Payfrit Beacon"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; @@ -400,6 +606,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -410,6 +617,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = F1B84CA677516C50F6BE2294 /* Pods-PayfritBeacon.release.xcconfig */; buildSettings = { + APP_DISPLAY_NAME = "Payfrit Beacon"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; @@ -433,6 +641,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -447,6 +656,8 @@ buildConfigurations = ( C0B000000001 /* Debug */, C0B000000002 /* Release */, + 672BF8AE9DE36DEAE34D6DA0 /* Debug-Dev */, + 99070AFE15F557412BACA2C9 /* Release-Dev */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -456,6 +667,8 @@ buildConfigurations = ( C0B000000003 /* Debug */, C0B000000004 /* Release */, + B0D496FEA252D8DDA33F57A0 /* Debug-Dev */, + 064DDD2A9238EC6900250593 /* Release-Dev */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; diff --git a/PayfritBeacon.xcodeproj/xcshareddata/xcschemes/PayfritBeacon Dev.xcscheme b/PayfritBeacon.xcodeproj/xcshareddata/xcschemes/PayfritBeacon Dev.xcscheme new file mode 100644 index 0000000..6ac00a6 --- /dev/null +++ b/PayfritBeacon.xcodeproj/xcshareddata/xcschemes/PayfritBeacon Dev.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PayfritBeacon/Api.swift b/PayfritBeacon/Api.swift index 87f65e8..53ec069 100644 --- a/PayfritBeacon/Api.swift +++ b/PayfritBeacon/Api.swift @@ -3,8 +3,12 @@ import Foundation class Api { static let shared = Api() - // ── DEV toggle: flip to false for production ── + // ── DEV toggle: driven by DEV compiler flag (set in build configuration) ── + #if DEV + static let IS_DEV = true + #else static let IS_DEV = false + #endif private static var BASE_URL: String { IS_DEV ? "https://dev.payfrit.com/api" : "https://biz.payfrit.com/api" @@ -280,51 +284,69 @@ class Api { /// Get beacon config for a service point (UUID, Major, Minor to write to beacon) func getBeaconConfig(businessId: Int, servicePointId: Int) async throws -> BeaconConfigResponse { + DebugLog.shared.log("[API] getBeaconConfig businessId=\(businessId) servicePointId=\(servicePointId)") + let json = try await postRequest( endpoint: "/beacon-sharding/get_beacon_config.cfm", body: ["BusinessID": businessId, "ServicePointID": servicePointId], extraHeaders: ["X-Business-Id": String(businessId)] ) - if !parseBool(json["OK"] ?? json["ok"]) { - let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to get beacon config" + DebugLog.shared.log("[API] getBeaconConfig response keys: \(json.keys.sorted())") + DebugLog.shared.log("[API] getBeaconConfig full response: \(json)") + + if !parseBool(json["OK"] ?? json["ok"] ?? json["Ok"]) { + let error = ((json["ERROR"] ?? json["error"] ?? json["Error"]) as? String) ?? "Failed to get beacon config" throw ApiException(error) } - guard let uuid = (json["UUID"] ?? json["uuid"]) as? String, - let major = parseIntValue(json["MAJOR"] ?? json["major"]), - let minor = parseIntValue(json["MINOR"] ?? json["minor"]) else { - throw ApiException("Invalid beacon config response") + guard let uuid = (json["UUID"] ?? json["uuid"] ?? json["Uuid"] ?? json["BeaconUUID"] ?? json["BEACONUUID"]) as? String else { + throw ApiException("Invalid beacon config response - no UUID. Keys: \(json.keys.sorted())") } + guard let major = parseIntValue(json["MAJOR"] ?? json["major"] ?? json["Major"] ?? json["BeaconMajor"] ?? json["BEACONMAJOR"]) else { + throw ApiException("Invalid beacon config response - no Major. Keys: \(json.keys.sorted())") + } + + guard let minor = parseIntValue(json["MINOR"] ?? json["minor"] ?? json["Minor"] ?? json["BeaconMinor"] ?? json["BEACONMINOR"]) else { + throw ApiException("Invalid beacon config response - no Minor. Keys: \(json.keys.sorted())") + } + + DebugLog.shared.log("[API] getBeaconConfig parsed: uuid=\(uuid) major=\(major) minor=\(minor)") + return BeaconConfigResponse( uuid: uuid.replacingOccurrences(of: "-", with: "").uppercased(), major: UInt16(major), minor: UInt16(minor), - txPower: parseIntValue(json["TXPOWER"] ?? json["txPower"]) ?? -59, - interval: parseIntValue(json["INTERVAL"] ?? json["interval"]) ?? 350 + txPower: parseIntValue(json["TXPOWER"] ?? json["txPower"] ?? json["TxPower"]) ?? -59, + interval: parseIntValue(json["INTERVAL"] ?? json["interval"] ?? json["Interval"]) ?? 350 ) } /// Register beacon hardware after provisioning - func registerBeaconHardware(businessId: Int, servicePointId: Int, uuid: String, major: UInt16, minor: UInt16, macAddress: String?) async throws -> Bool { + func registerBeaconHardware(businessId: Int, servicePointId: Int, uuid: String, major: UInt16, minor: UInt16, hardwareId: String, macAddress: String? = nil) async throws -> Bool { var body: [String: Any] = [ "BusinessID": businessId, "ServicePointID": servicePointId, "UUID": uuid, "Major": major, - "Minor": minor + "Minor": minor, + "HardwareID": hardwareId ] if let mac = macAddress, !mac.isEmpty { body["MACAddress"] = mac } + DebugLog.shared.log("[API] registerBeaconHardware body: \(body)") + let json = try await postRequest( endpoint: "/beacon-sharding/register_beacon_hardware.cfm", body: body, extraHeaders: ["X-Business-Id": String(businessId)] ) + DebugLog.shared.log("[API] registerBeaconHardware response: \(json)") + if !parseBool(json["OK"] ?? json["ok"]) { let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to register beacon" throw ApiException(error) @@ -376,7 +398,8 @@ class Api { return BusinessNamespace( shardId: parseIntValue(json["ShardID"] ?? json["SHARDID"]) ?? 0, - uuid: uuid.replacingOccurrences(of: "-", with: "").uppercased(), + uuid: uuid, + uuidClean: uuid.replacingOccurrences(of: "-", with: "").uppercased(), major: UInt16(major), alreadyAllocated: parseBool(json["AlreadyAllocated"] ?? json["ALREADYALLOCATED"]) ) @@ -412,12 +435,16 @@ class Api { body["ServicePointID"] = spId } + DebugLog.shared.log("[API] saveServicePoint businessId=\(businessId) name=\(name) servicePointId=\(String(describing: servicePointId))") + let json = try await postRequest( endpoint: "/servicepoints/save.cfm", body: body, extraHeaders: ["X-Business-Id": String(businessId)] ) + DebugLog.shared.log("[API] saveServicePoint response: \(json)") + if !parseBool(json["OK"] ?? json["ok"]) { let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to save service point" throw ApiException(error) @@ -429,6 +456,12 @@ class Api { let spId = parseIntValue(sp["ServicePointID"] ?? sp["SERVICEPOINTID"]) ?? servicePointId ?? 0 let beaconMinor = parseIntValue(sp["BeaconMinor"] ?? sp["BEACONMINOR"]) + DebugLog.shared.log("[API] saveServicePoint parsed: spId=\(spId) beaconMinor=\(String(describing: beaconMinor))") + + if spId == 0 { + DebugLog.shared.log("[API] WARNING: servicePointId is 0! Full sp dict: \(sp)") + } + return ServicePoint( servicePointId: spId, name: name, @@ -442,6 +475,20 @@ class Api { return try await saveServicePoint(businessId: businessId, name: name) } + /// Delete a service point + func deleteServicePoint(businessId: Int, servicePointId: Int) async throws { + let json = try await postRequest( + endpoint: "/servicepoints/delete.cfm", + body: ["BusinessID": businessId, "ServicePointID": servicePointId], + extraHeaders: ["X-Business-Id": String(businessId)] + ) + + if !parseBool(json["OK"] ?? json["ok"]) { + let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to delete service point" + throw ApiException(error) + } + } + // ========================================================================= // HELPERS // ========================================================================= @@ -550,7 +597,8 @@ struct ServicePoint: Identifiable { struct BusinessNamespace { let shardId: Int - let uuid: String // 32-char hex, no dashes + let uuid: String // Original UUID from API (with dashes, as-is) + let uuidClean: String // 32-char hex, no dashes, uppercase (for BLE provisioning) let major: UInt16 let alreadyAllocated: Bool } diff --git a/PayfritBeacon/BLEBeaconScanner.swift b/PayfritBeacon/BLEBeaconScanner.swift index f072189..f38417a 100644 --- a/PayfritBeacon/BLEBeaconScanner.swift +++ b/PayfritBeacon/BLEBeaconScanner.swift @@ -1,9 +1,10 @@ import Foundation import CoreBluetooth -/// Beacon type detected by service UUID +/// Beacon type detected by service UUID or name enum BeaconType: String { case kbeacon = "KBeacon" + case dxsmart = "DX-Smart" case bluecharm = "BlueCharm" case unknown = "Unknown" } @@ -18,8 +19,8 @@ struct DiscoveredBeacon: Identifiable { var lastSeen: Date var displayName: String { - if name.isEmpty || name == "Unknown" { - return "\(type.rawValue) (\(id.uuidString.prefix(8))...)" + if name.isEmpty { + return id.uuidString.prefix(8) + "..." } return name } @@ -64,9 +65,9 @@ class BLEBeaconScanner: NSObject, ObservableObject { options: [CBCentralManagerScanOptionAllowDuplicatesKey: true] ) - // Auto-stop after 10 seconds + // Auto-stop after 1 second (beacons advertise every ~200ms so 1s is plenty) scanTimer?.invalidate() - scanTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: false) { [weak self] _ in + scanTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { [weak self] _ in self?.stopScanning() } } @@ -104,35 +105,20 @@ extension BLEBeaconScanner: CBCentralManagerDelegate { advertisementData: [String: Any], rssi RSSI: NSNumber) { let rssiValue = RSSI.intValue - guard rssiValue > -90 && rssiValue < 0 else { return } // Filter weak signals + guard rssiValue > -70 && rssiValue < 0 else { return } // Only show nearby devices let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "" - // Determine beacon type from name or advertised services + // Best-effort type hint from advertised services (informational only) var beaconType: BeaconType = .unknown - - // Check advertised service UUIDs if let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] { if serviceUUIDs.contains(BLEBeaconScanner.KBEACON_SERVICE) { - beaconType = .kbeacon + beaconType = .dxsmart } else if serviceUUIDs.contains(BLEBeaconScanner.BLUECHARM_SERVICE) { beaconType = .bluecharm } } - // Also check by name patterns - if beaconType == .unknown { - let lowerName = name.lowercased() - if lowerName.contains("kbeacon") || lowerName.contains("kbpro") || lowerName.hasPrefix("kb") { - beaconType = .kbeacon - } else if lowerName.contains("bluecharm") || lowerName.contains("bc") || lowerName.hasPrefix("bc") { - beaconType = .bluecharm - } - } - - // Only track beacons we can identify - guard beaconType != .unknown else { return } - // Update or add beacon if let index = discoveredBeacons.firstIndex(where: { $0.id == peripheral.identifier }) { discoveredBeacons[index].rssi = rssiValue diff --git a/PayfritBeacon/BeaconProvisioner.swift b/PayfritBeacon/BeaconProvisioner.swift index c26d9e5..ce8ab69 100644 --- a/PayfritBeacon/BeaconProvisioner.swift +++ b/PayfritBeacon/BeaconProvisioner.swift @@ -14,30 +14,76 @@ struct BeaconConfig { let minor: UInt16 let txPower: Int8 // Typically -59 let interval: UInt16 // Advertising interval in ms, typically 350 + let deviceName: String? // Optional device name (used by DX-Smart) + + init(uuid: String, major: UInt16, minor: UInt16, txPower: Int8, interval: UInt16, deviceName: String? = nil) { + self.uuid = uuid + self.major = major + self.minor = minor + self.txPower = txPower + self.interval = interval + self.deviceName = deviceName + } +} + +/// Result of reading a beacon's current configuration +struct BeaconCheckResult { + // Parsed DX-Smart iBeacon config + var uuid: String? // iBeacon UUID (formatted with dashes) + var major: UInt16? + var minor: UInt16? + var rssiAt1m: Int8? + var advInterval: UInt16? // Raw value (multiply by 100 for ms) + var txPower: UInt8? + var deviceName: String? + var battery: UInt8? + var macAddress: String? + var frameSlots: [UInt8]? + + // Discovery info + var servicesFound: [String] = [] + var characteristicsFound: [String] = [] + var rawResponses: [String] = [] // Raw response hex for debugging + + var hasConfig: Bool { + uuid != nil || major != nil || minor != nil || deviceName != nil + } } /// Handles GATT connection and provisioning of beacons class BeaconProvisioner: NSObject, ObservableObject { - // MARK: - BlueCharm GATT Characteristics - private static let BLUECHARM_SERVICE = CBUUID(string: "0000FFF0-0000-1000-8000-00805F9B34FB") - private static let BLUECHARM_PASSWORD_CHAR = CBUUID(string: "0000FFF1-0000-1000-8000-00805F9B34FB") - private static let BLUECHARM_UUID_CHAR = CBUUID(string: "0000FFF2-0000-1000-8000-00805F9B34FB") - private static let BLUECHARM_MAJOR_CHAR = CBUUID(string: "0000FFF3-0000-1000-8000-00805F9B34FB") - private static let BLUECHARM_MINOR_CHAR = CBUUID(string: "0000FFF4-0000-1000-8000-00805F9B34FB") - private static let BLUECHARM_TXPOWER_CHAR = CBUUID(string: "0000FFF5-0000-1000-8000-00805F9B34FB") + // MARK: - DX-Smart CP28 GATT Characteristics + private static let DXSMART_SERVICE = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB") + private static let DXSMART_NOTIFY_CHAR = CBUUID(string: "0000FFE1-0000-1000-8000-00805F9B34FB") // Notifications (RX) + private static let DXSMART_COMMAND_CHAR = CBUUID(string: "0000FFE2-0000-1000-8000-00805F9B34FB") // Commands (TX) + private static let DXSMART_PASSWORD_CHAR = CBUUID(string: "0000FFE3-0000-1000-8000-00805F9B34FB") // Password auth - // MARK: - KBeacon GATT (basic - for full support use their SDK) - private static let KBEACON_SERVICE = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB") + // DX-Smart packet header + private static let DXSMART_HEADER: [UInt8] = [0x4E, 0x4F] - // BlueCharm default passwords to try - private static let BLUECHARM_PASSWORDS = ["000000", "FFFF", "123456"] + // DX-Smart default connection password (from SDK — "555555", NOT "dx1234" which is factory reset only) + private static let DXSMART_PASSWORD = "555555" - // KBeacon default passwords - private static let KBEACON_PASSWORDS = [ - "0000000000000000", // 16 zeros - "31323334353637383930313233343536" // ASCII "1234567890123456" - ] + // DX-Smart command codes + private enum DXCmd: UInt8 { + case frameTable = 0x10 + case frameSelectSlot0 = 0x11 + case frameSelectSlot1 = 0x12 + case authCheck = 0x25 + case deviceInfo = 0x30 + case deviceName = 0x43 + case saveConfig = 0x60 + case iBeaconType = 0x62 + case uuid = 0x74 + case major = 0x75 + case minor = 0x76 + case rssiAt1m = 0x77 + case advInterval = 0x78 + case txPower = 0x79 + case triggerOff = 0xA0 + case frameDisable = 0xFF + } @Published var state: ProvisioningState = .idle @Published var progress: String = "" @@ -64,11 +110,41 @@ class BeaconProvisioner: NSObject, ObservableObject { private var passwordIndex = 0 private var writeQueue: [(CBCharacteristic, Data)] = [] + // DX-Smart provisioning state + private var dxSmartAuthenticated = false + private var dxSmartNotifySubscribed = false + private var dxSmartCommandQueue: [Data] = [] + private var dxSmartWriteIndex = 0 + + // Read config mode + private enum OperationMode { case provisioning, readingConfig } + private var operationMode: OperationMode = .provisioning + private var readCompletion: ((BeaconCheckResult?, String?) -> Void)? + private var readResult = BeaconCheckResult() + private var readTimeout: DispatchWorkItem? + + // Read config exploration state + private var allDiscoveredServices: [CBService] = [] + private var servicesToExplore: [CBService] = [] + + // DX-Smart read query state + private var dxReadQueries: [Data] = [] + private var dxReadQueryIndex = 0 + private var responseBuffer: [UInt8] = [] + override init() { super.init() centralManager = CBCentralManager(delegate: self, queue: .main) } + /// Re-retrieve peripheral from our own CBCentralManager (the one from scanner may not work) + private func resolvePeripheral(_ beacon: DiscoveredBeacon) -> CBPeripheral { + let retrieved = centralManager.retrievePeripherals(withIdentifiers: [beacon.peripheral.identifier]) + return retrieved.first ?? beacon.peripheral + } + + // MARK: - Provision + /// Provision a beacon with the given configuration func provision(beacon: DiscoveredBeacon, config: BeaconConfig, completion: @escaping (ProvisioningResult) -> Void) { guard centralManager.state == .poweredOn else { @@ -76,18 +152,24 @@ class BeaconProvisioner: NSObject, ObservableObject { return } - self.peripheral = beacon.peripheral + let resolvedPeripheral = resolvePeripheral(beacon) + self.peripheral = resolvedPeripheral self.beaconType = beacon.type self.config = config self.completion = completion + self.operationMode = .provisioning self.passwordIndex = 0 self.characteristics.removeAll() self.writeQueue.removeAll() + self.dxSmartAuthenticated = false + self.dxSmartNotifySubscribed = false + self.dxSmartCommandQueue.removeAll() + self.dxSmartWriteIndex = 0 state = .connecting progress = "Connecting to \(beacon.displayName)..." - centralManager.connect(beacon.peripheral, options: nil) + centralManager.connect(resolvedPeripheral, options: nil) // Timeout after 30 seconds DispatchQueue.main.asyncAfter(deadline: .now() + 30) { [weak self] in @@ -105,6 +187,47 @@ class BeaconProvisioner: NSObject, ObservableObject { cleanup() } + // MARK: - Read Config + + /// Read the current configuration from a beacon + func readConfig(beacon: DiscoveredBeacon, completion: @escaping (BeaconCheckResult?, String?) -> Void) { + guard centralManager.state == .poweredOn else { + completion(nil, "Bluetooth not available") + return + } + + let resolvedPeripheral = resolvePeripheral(beacon) + self.peripheral = resolvedPeripheral + self.beaconType = beacon.type + self.operationMode = .readingConfig + self.readCompletion = completion + self.readResult = BeaconCheckResult() + self.characteristics.removeAll() + self.dxSmartAuthenticated = false + self.dxSmartNotifySubscribed = false + self.responseBuffer.removeAll() + self.dxReadQueries.removeAll() + self.dxReadQueryIndex = 0 + self.allDiscoveredServices.removeAll() + self.servicesToExplore.removeAll() + + state = .connecting + progress = "Connecting to \(beacon.displayName)..." + + centralManager.connect(resolvedPeripheral, options: nil) + + // 15-second timeout for read operations + let timeout = DispatchWorkItem { [weak self] in + guard let self = self, self.operationMode == .readingConfig else { return } + DebugLog.shared.log("BLE: Read timeout reached") + self.finishRead() + } + readTimeout = timeout + DispatchQueue.main.asyncAfter(deadline: .now() + 15, execute: timeout) + } + + // MARK: - Cleanup + private func cleanup() { peripheral = nil config = nil @@ -112,12 +235,16 @@ class BeaconProvisioner: NSObject, ObservableObject { configService = nil characteristics.removeAll() writeQueue.removeAll() + dxSmartAuthenticated = false + dxSmartNotifySubscribed = false + dxSmartCommandQueue.removeAll() + dxSmartWriteIndex = 0 state = .idle progress = "" } private func fail(_ message: String) { - NSLog("BeaconProvisioner: Failed - \(message)") + DebugLog.shared.log("BLE: Failed - \(message)") state = .failed(message) if let peripheral = peripheral { centralManager.cancelPeripheralConnection(peripheral) @@ -127,7 +254,7 @@ class BeaconProvisioner: NSObject, ObservableObject { } private func succeed() { - NSLog("BeaconProvisioner: Success!") + DebugLog.shared.log("BLE: Success!") state = .success if let peripheral = peripheral { centralManager.cancelPeripheralConnection(peripheral) @@ -136,117 +263,458 @@ class BeaconProvisioner: NSObject, ObservableObject { cleanup() } - // MARK: - BlueCharm Provisioning + // MARK: - DX-Smart CP28 Provisioning - private func provisionBlueCharm() { + private func provisionDXSmart() { guard let service = configService else { - fail("Config service not found") + fail("DX-Smart config service not found") return } - // Discover characteristics + state = .discoveringServices + progress = "Discovering DX-Smart characteristics..." + peripheral?.discoverCharacteristics([ - BeaconProvisioner.BLUECHARM_PASSWORD_CHAR, - BeaconProvisioner.BLUECHARM_UUID_CHAR, - BeaconProvisioner.BLUECHARM_MAJOR_CHAR, - BeaconProvisioner.BLUECHARM_MINOR_CHAR, - BeaconProvisioner.BLUECHARM_TXPOWER_CHAR + BeaconProvisioner.DXSMART_NOTIFY_CHAR, + BeaconProvisioner.DXSMART_COMMAND_CHAR, + BeaconProvisioner.DXSMART_PASSWORD_CHAR ], for: service) } - private func authenticateBlueCharm() { - guard let passwordChar = characteristics[BeaconProvisioner.BLUECHARM_PASSWORD_CHAR] else { - fail("Password characteristic not found") - return + /// Subscribe to FFE1 notifications, then authenticate on FFE3 + private func dxSmartStartAuth() { + if let notifyChar = characteristics[BeaconProvisioner.DXSMART_NOTIFY_CHAR] { + DebugLog.shared.log("BLE: Subscribing to DX-Smart FFE1 notifications") + peripheral?.setNotifyValue(true, for: notifyChar) + } else { + DebugLog.shared.log("BLE: FFE1 not found, proceeding to auth directly") + dxSmartNotifySubscribed = true + dxSmartAuthenticate() } + } - let passwords = BeaconProvisioner.BLUECHARM_PASSWORDS - guard passwordIndex < passwords.count else { - fail("Authentication failed - tried all passwords") + /// Write password to FFE3 + private func dxSmartAuthenticate() { + guard let passwordChar = characteristics[BeaconProvisioner.DXSMART_PASSWORD_CHAR] else { + fail("DX-Smart password characteristic (FFE3) not found") return } state = .authenticating progress = "Authenticating..." - let password = passwords[passwordIndex] - if let data = password.data(using: .utf8) { - NSLog("BeaconProvisioner: Trying BlueCharm password \(passwordIndex + 1)/\(passwords.count)") - peripheral?.writeValue(data, for: passwordChar, type: .withResponse) - } + let passwordData = Data(BeaconProvisioner.DXSMART_PASSWORD.utf8) + DebugLog.shared.log("BLE: Writing password to FFE3 (\(passwordData.count) bytes)") + peripheral?.writeValue(passwordData, for: passwordChar, type: .withResponse) } - private func writeBlueCharmConfig() { + /// Build the full command sequence and start writing + private func dxSmartWriteConfig() { guard let config = config else { fail("No config provided") return } state = .writing - progress = "Writing configuration..." + progress = "Writing DX-Smart configuration..." - // Build write queue - writeQueue.removeAll() + dxSmartCommandQueue.removeAll() + dxSmartWriteIndex = 0 - // UUID - 16 bytes, no dashes - if let uuidChar = characteristics[BeaconProvisioner.BLUECHARM_UUID_CHAR] { - if let uuidData = hexStringToData(config.uuid) { - writeQueue.append((uuidChar, uuidData)) - } + // --- Frame Slot 0: Configure iBeacon --- + // SDK sends NO data for frame select and frame type commands + dxSmartCommandQueue.append(buildDXPacket(cmd: .frameSelectSlot0, data: [])) + dxSmartCommandQueue.append(buildDXPacket(cmd: .iBeaconType, data: [])) + + if let uuidData = hexStringToData(config.uuid) { + dxSmartCommandQueue.append(buildDXPacket(cmd: .uuid, data: Array(uuidData))) } - // Major - 2 bytes big-endian - if let majorChar = characteristics[BeaconProvisioner.BLUECHARM_MAJOR_CHAR] { - var major = config.major.bigEndian - let majorData = Data(bytes: &major, count: 2) - writeQueue.append((majorChar, majorData)) + let majorHi = UInt8((config.major >> 8) & 0xFF) + let majorLo = UInt8(config.major & 0xFF) + dxSmartCommandQueue.append(buildDXPacket(cmd: .major, data: [majorHi, majorLo])) + + let minorHi = UInt8((config.minor >> 8) & 0xFF) + let minorLo = UInt8(config.minor & 0xFF) + dxSmartCommandQueue.append(buildDXPacket(cmd: .minor, data: [minorHi, minorLo])) + + dxSmartCommandQueue.append(buildDXPacket(cmd: .rssiAt1m, data: [0xC5])) // -59 dBm (matches SDK) + dxSmartCommandQueue.append(buildDXPacket(cmd: .advInterval, data: [0x02])) // 200ms + dxSmartCommandQueue.append(buildDXPacket(cmd: .txPower, data: [0x01])) // -13.5 dBm + dxSmartCommandQueue.append(buildDXPacket(cmd: .triggerOff, data: [])) + + // --- Frame Slot 1: Disable --- + dxSmartCommandQueue.append(buildDXPacket(cmd: .frameSelectSlot1, data: [])) + dxSmartCommandQueue.append(buildDXPacket(cmd: .frameDisable, data: [])) + + // --- Device Name (optional) --- + if let name = config.deviceName, !name.isEmpty { + let nameBytes = Array(name.utf8) + dxSmartCommandQueue.append(buildDXPacket(cmd: .deviceName, data: nameBytes)) } - // Minor - 2 bytes big-endian - if let minorChar = characteristics[BeaconProvisioner.BLUECHARM_MINOR_CHAR] { - var minor = config.minor.bigEndian - let minorData = Data(bytes: &minor, count: 2) - writeQueue.append((minorChar, minorData)) - } + // --- Save --- + dxSmartCommandQueue.append(buildDXPacket(cmd: .saveConfig, data: [])) - // TxPower - 1 byte signed - if let txChar = characteristics[BeaconProvisioner.BLUECHARM_TXPOWER_CHAR] { - var txPower = config.txPower - let txData = Data(bytes: &txPower, count: 1) - writeQueue.append((txChar, txData)) - } - - // Start writing - processWriteQueue() + DebugLog.shared.log("BLE: DX-Smart command queue built with \(dxSmartCommandQueue.count) commands") + dxSmartSendNextCommand() } - private func processWriteQueue() { - guard !writeQueue.isEmpty else { - // All writes complete - progress = "Configuration complete!" + /// Send the next command in the DX-Smart queue + private func dxSmartSendNextCommand() { + guard dxSmartWriteIndex < dxSmartCommandQueue.count else { + DebugLog.shared.log("BLE: All DX-Smart commands written!") + progress = "Configuration saved!" succeed() return } - let (characteristic, data) = writeQueue.removeFirst() - NSLog("BeaconProvisioner: Writing \(data.count) bytes to \(characteristic.uuid)") - peripheral?.writeValue(data, for: characteristic, type: .withResponse) + let packet = dxSmartCommandQueue[dxSmartWriteIndex] + let total = dxSmartCommandQueue.count + let current = dxSmartWriteIndex + 1 + progress = "Writing config (\(current)/\(total))..." + + guard let commandChar = characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] else { + fail("DX-Smart command characteristic (FFE2) not found") + return + } + + DebugLog.shared.log("BLE: Writing command \(current)/\(total): \(packet.map { String(format: "%02X", $0) }.joined(separator: " "))") + peripheral?.writeValue(packet, for: commandChar, type: .withResponse) } - // MARK: - KBeacon Provisioning + // MARK: - DX-Smart Packet Builder - private func provisionKBeacon() { - // KBeacon uses a more complex protocol - // For now, we'll just try basic GATT writes - // Full support would require their SDK + /// Build a DX-Smart CP28 packet: [4E][4F][CMD][LEN][DATA...][XOR_CHECKSUM] + private func buildDXPacket(cmd: DXCmd, data: [UInt8]) -> Data { + var packet: [UInt8] = [] + packet.append(contentsOf: BeaconProvisioner.DXSMART_HEADER) // 4E 4F + packet.append(cmd.rawValue) + packet.append(UInt8(data.count)) + packet.append(contentsOf: data) - state = .writing - progress = "KBeacon requires their SDK for full support.\nUse clipboard to copy config." - - // For now, just succeed and let user use clipboard - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in - self?.fail("KBeacon provisioning requires their SDK. Please use the KBeacon app with the copied config.") + // XOR checksum: CMD ^ LEN ^ each data byte + var checksum: UInt8 = cmd.rawValue ^ UInt8(data.count) + for byte in data { + checksum ^= byte } + packet.append(checksum) + + return Data(packet) + } + + // MARK: - Read Config: Service Exploration + + /// Explore all services on the device, then attempt DX-Smart read protocol + private func startReadExplore() { + guard let services = peripheral?.services, !services.isEmpty else { + readFail("No services found on device") + return + } + + allDiscoveredServices = services + servicesToExplore = services + state = .discoveringServices + progress = "Exploring \(services.count) services..." + + DebugLog.shared.log("BLE: Read mode — found \(services.count) services") + for s in services { + readResult.servicesFound.append(s.uuid.uuidString) + } + + exploreNextService() + } + + private func exploreNextService() { + guard !servicesToExplore.isEmpty else { + // All services explored — start DX-Smart read protocol if FFE0 is present + DebugLog.shared.log("BLE: All services explored, starting DX-Smart read") + startDXSmartRead() + return + } + + let service = servicesToExplore.removeFirst() + DebugLog.shared.log("BLE: Discovering chars for service \(service.uuid)") + progress = "Exploring \(service.uuid.uuidString.prefix(8))..." + peripheral?.discoverCharacteristics(nil, for: service) + } + + // MARK: - Read Config: DX-Smart Protocol + + /// After exploration, start DX-Smart read if FFE0 chars are present + private func startDXSmartRead() { + guard characteristics[BeaconProvisioner.DXSMART_PASSWORD_CHAR] != nil, + characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] != nil else { + // Not a DX-Smart beacon — finish with just the service/char listing + DebugLog.shared.log("BLE: No FFE0 service — not a DX-Smart beacon") + progress = "No DX-Smart service found" + finishRead() + return + } + + // Subscribe to FFE1 for responses + if let notifyChar = characteristics[BeaconProvisioner.DXSMART_NOTIFY_CHAR] { + DebugLog.shared.log("BLE: Read mode — subscribing to FFE1 notifications") + progress = "Subscribing to notifications..." + peripheral?.setNotifyValue(true, for: notifyChar) + } else { + // No FFE1 — try auth anyway + DebugLog.shared.log("BLE: FFE1 not found, attempting auth without notifications") + dxSmartReadAuth() + } + } + + /// Authenticate on FFE3 for read mode + private func dxSmartReadAuth() { + guard let passwordChar = characteristics[BeaconProvisioner.DXSMART_PASSWORD_CHAR] else { + DebugLog.shared.log("BLE: No FFE3 for auth, finishing") + finishRead() + return + } + + state = .authenticating + progress = "Authenticating..." + + let passwordData = Data(BeaconProvisioner.DXSMART_PASSWORD.utf8) + DebugLog.shared.log("BLE: Read mode — writing password to FFE3") + peripheral?.writeValue(passwordData, for: passwordChar, type: .withResponse) + } + + /// After auth, send read query commands + private func dxSmartReadQueryAfterAuth() { + dxReadQueries.removeAll() + dxReadQueryIndex = 0 + responseBuffer.removeAll() + + // Read commands: send with LEN=0 (no data) to request current config values + dxReadQueries.append(buildDXPacket(cmd: .frameTable, data: [])) // 0x10: frame assignment table + dxReadQueries.append(buildDXPacket(cmd: .iBeaconType, data: [])) // 0x62: iBeacon UUID/Major/Minor/etc + dxReadQueries.append(buildDXPacket(cmd: .deviceInfo, data: [])) // 0x30: battery, MAC, firmware + dxReadQueries.append(buildDXPacket(cmd: .deviceName, data: [])) // 0x43: device name + + DebugLog.shared.log("BLE: Sending \(dxReadQueries.count) DX-Smart read queries") + state = .verifying + progress = "Reading config..." + dxSmartSendNextReadQuery() + } + + private func dxSmartSendNextReadQuery() { + guard dxReadQueryIndex < dxReadQueries.count else { + DebugLog.shared.log("BLE: All read queries sent, waiting 2s for final responses") + progress = "Collecting responses..." + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + guard let self = self, self.operationMode == .readingConfig else { return } + self.finishRead() + } + return + } + + guard let commandChar = characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] else { + DebugLog.shared.log("BLE: FFE2 not found, finishing read") + finishRead() + return + } + + let packet = dxReadQueries[dxReadQueryIndex] + let current = dxReadQueryIndex + 1 + let total = dxReadQueries.count + progress = "Reading \(current)/\(total)..." + DebugLog.shared.log("BLE: Read query \(current)/\(total): \(packet.map { String(format: "%02X", $0) }.joined(separator: " "))") + peripheral?.writeValue(packet, for: commandChar, type: .withResponse) + } + + // MARK: - Read Config: Response Parsing + + /// Process incoming FFE1 notification data — accumulate and parse DX-Smart response frames + private func processFFE1Response(_ data: Data) { + let hex = data.map { String(format: "%02X", $0) }.joined(separator: " ") + DebugLog.shared.log("BLE: FFE1 raw: \(hex)") + + responseBuffer.append(contentsOf: data) + + // Try to parse complete frames from buffer + while responseBuffer.count >= 5 { // Minimum frame: 4E 4F CMD 00 XOR = 5 bytes + // Find 4E 4F header + guard let headerIdx = findDXHeader() else { + responseBuffer.removeAll() + break + } + + // Discard bytes before header + if headerIdx > 0 { + responseBuffer.removeFirst(headerIdx) + } + + guard responseBuffer.count >= 5 else { break } + + let cmd = responseBuffer[2] + let len = Int(responseBuffer[3]) + let frameLen = 4 + len + 1 // header(2) + cmd(1) + len(1) + data(len) + xor(1) + + guard responseBuffer.count >= frameLen else { + // Incomplete frame — wait for more data + break + } + + // Extract frame + let frame = Array(responseBuffer[0.. Int? { + guard responseBuffer.count >= 2 else { return nil } + for i in 0..<(responseBuffer.count - 1) { + if responseBuffer[i] == 0x4E && responseBuffer[i + 1] == 0x4F { + return i + } + } + return nil + } + + /// Parse a complete DX-Smart response by command type + private func parseResponseCmd(cmd: UInt8, data: [UInt8]) { + let dataHex = data.map { String(format: "%02X", $0) }.joined(separator: " ") + DebugLog.shared.log("BLE: Response cmd=0x\(String(format: "%02X", cmd)) len=\(data.count) data=[\(dataHex)]") + readResult.rawResponses.append("0x\(String(format: "%02X", cmd)): \(dataHex)") + + switch DXCmd(rawValue: cmd) { + + case .frameTable: // 0x10: Frame assignment table (one byte per slot) + readResult.frameSlots = data + DebugLog.shared.log("BLE: Frame slots: \(data.map { String(format: "0x%02X", $0) })") + + case .iBeaconType: // 0x62: iBeacon config data + guard data.count >= 2 else { return } + var offset = 1 // Skip type echo byte + + // UUID: 16 bytes + if data.count >= offset + 16 { + let uuidBytes = Array(data[offset..<(offset + 16)]) + let uuidHex = uuidBytes.map { String(format: "%02X", $0) }.joined() + readResult.uuid = formatUUID(uuidHex) + offset += 16 + } + + // Major: 2 bytes big-endian + if data.count >= offset + 2 { + readResult.major = UInt16(data[offset]) << 8 | UInt16(data[offset + 1]) + offset += 2 + } + + // Minor: 2 bytes big-endian + if data.count >= offset + 2 { + readResult.minor = UInt16(data[offset]) << 8 | UInt16(data[offset + 1]) + offset += 2 + } + + // RSSI@1m: 1 byte signed + if data.count >= offset + 1 { + readResult.rssiAt1m = Int8(bitPattern: data[offset]) + offset += 1 + } + + // Advertising interval: 1 byte (raw value) + if data.count >= offset + 1 { + readResult.advInterval = UInt16(data[offset]) + offset += 1 + } + + // TX power: 1 byte + if data.count >= offset + 1 { + readResult.txPower = data[offset] + offset += 1 + } + + DebugLog.shared.log("BLE: Parsed iBeacon — UUID=\(readResult.uuid ?? "?") Major=\(readResult.major ?? 0) Minor=\(readResult.minor ?? 0)") + + case .deviceInfo: // 0x30: Device info (battery, MAC, manufacturer, firmware) + if data.count >= 1 { + readResult.battery = data[0] + } + if data.count >= 7 { + let macBytes = Array(data[1..<7]) + readResult.macAddress = macBytes.map { String(format: "%02X", $0) }.joined(separator: ":") + } + DebugLog.shared.log("BLE: Device info — battery=\(readResult.battery ?? 0)% MAC=\(readResult.macAddress ?? "?")") + + case .deviceName: // 0x43: Device name + readResult.deviceName = String(bytes: data, encoding: .utf8)?.trimmingCharacters(in: .controlCharacters) + DebugLog.shared.log("BLE: Device name = \(readResult.deviceName ?? "?")") + + case .authCheck: // 0x25: Auth check response + if data.count >= 1 { + let authRequired = data[0] != 0x00 + DebugLog.shared.log("BLE: Auth required: \(authRequired)") + } + + default: + DebugLog.shared.log("BLE: Unhandled response cmd 0x\(String(format: "%02X", cmd))") + } + } + + // MARK: - Read Config: Finish + + private func finishRead() { + readTimeout?.cancel() + readTimeout = nil + + if let peripheral = peripheral { + centralManager.cancelPeripheralConnection(peripheral) + } + + let result = readResult + state = .success + progress = "" + readCompletion?(result, nil) + cleanupRead() + } + + private func readFail(_ message: String) { + DebugLog.shared.log("BLE: Read failed - \(message)") + readTimeout?.cancel() + readTimeout = nil + + if let peripheral = peripheral { + centralManager.cancelPeripheralConnection(peripheral) + } + state = .failed(message) + readCompletion?(nil, message) + cleanupRead() + } + + private func cleanupRead() { + peripheral = nil + readCompletion = nil + readResult = BeaconCheckResult() + readTimeout = nil + dxReadQueries.removeAll() + dxReadQueryIndex = 0 + responseBuffer.removeAll() + allDiscoveredServices.removeAll() + servicesToExplore.removeAll() + configService = nil + characteristics.removeAll() + operationMode = .provisioning + state = .idle + progress = "" } // MARK: - Helpers @@ -269,6 +737,13 @@ class BeaconProvisioner: NSObject, ObservableObject { } return data } + + private func formatUUID(_ hex: String) -> String { + let clean = hex.uppercased() + guard clean.count == 32 else { return hex } + let c = Array(clean) + return "\(String(c[0..<8]))-\(String(c[8..<12]))-\(String(c[12..<16]))-\(String(c[16..<20]))-\(String(c[20..<32]))" + } } // MARK: - CBCentralManagerDelegate @@ -276,36 +751,40 @@ class BeaconProvisioner: NSObject, ObservableObject { extension BeaconProvisioner: CBCentralManagerDelegate { func centralManagerDidUpdateState(_ central: CBCentralManager) { - NSLog("BeaconProvisioner: Central state = \(central.state.rawValue)") + DebugLog.shared.log("BLE: Central state = \(central.state.rawValue)") } func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { - NSLog("BeaconProvisioner: Connected to \(peripheral.name ?? "unknown")") + DebugLog.shared.log("BLE: Connected to \(peripheral.name ?? "unknown")") peripheral.delegate = self state = .discoveringServices progress = "Discovering services..." - // Discover the config service based on beacon type - switch beaconType { - case .bluecharm: - peripheral.discoverServices([BeaconProvisioner.BLUECHARM_SERVICE]) - case .kbeacon: - peripheral.discoverServices([BeaconProvisioner.KBEACON_SERVICE]) - case .unknown: - peripheral.discoverServices(nil) // Discover all + if operationMode == .readingConfig { + peripheral.discoverServices(nil) // Discover all for exploration + } else { + peripheral.discoverServices([BeaconProvisioner.DXSMART_SERVICE]) } } func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { - fail("Failed to connect: \(error?.localizedDescription ?? "unknown error")") + let msg = "Failed to connect: \(error?.localizedDescription ?? "unknown error")" + if operationMode == .readingConfig { + readFail(msg) + } else { + fail(msg) + } } func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { - NSLog("BeaconProvisioner: Disconnected from \(peripheral.name ?? "unknown")") - if state != .success && state != .idle { - // Unexpected disconnect + DebugLog.shared.log("BLE: Disconnected from \(peripheral.name ?? "unknown")") + if operationMode == .readingConfig { + if state != .success && state != .idle { + finishRead() + } + } else if state != .success && state != .idle { if case .failed = state { - // Already failed, don't report again + // Already failed } else { fail("Unexpected disconnect") } @@ -319,80 +798,199 @@ extension BeaconProvisioner: CBPeripheralDelegate { func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { if let error = error { - fail("Service discovery failed: \(error.localizedDescription)") + if operationMode == .readingConfig { + readFail("Service discovery failed: \(error.localizedDescription)") + } else { + fail("Service discovery failed: \(error.localizedDescription)") + } return } guard let services = peripheral.services else { - fail("No services found") + if operationMode == .readingConfig { + readFail("No services found") + } else { + fail("No services found") + } return } - NSLog("BeaconProvisioner: Discovered \(services.count) services") - + DebugLog.shared.log("BLE: Discovered \(services.count) services") for service in services { NSLog(" Service: \(service.uuid)") - if service.uuid == BeaconProvisioner.BLUECHARM_SERVICE { + } + + if operationMode == .readingConfig { + startReadExplore() + return + } + + // Provisioning: look for DX-Smart service + for service in services { + if service.uuid == BeaconProvisioner.DXSMART_SERVICE { configService = service - provisionBlueCharm() - return - } else if service.uuid == BeaconProvisioner.KBEACON_SERVICE { - configService = service - provisionKBeacon() + provisionDXSmart() return } } - fail("Config service not found on device") } func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { if let error = error { - fail("Characteristic discovery failed: \(error.localizedDescription)") + if operationMode == .readingConfig { + // Don't fail entirely — skip this service + DebugLog.shared.log("BLE: Char discovery failed for \(service.uuid): \(error.localizedDescription)") + exploreNextService() + } else { + fail("Characteristic discovery failed: \(error.localizedDescription)") + } return } guard let chars = service.characteristics else { - fail("No characteristics found") + if operationMode == .readingConfig { + exploreNextService() + } else { + fail("No characteristics found") + } return } - NSLog("BeaconProvisioner: Discovered \(chars.count) characteristics") + DebugLog.shared.log("BLE: Discovered \(chars.count) characteristics for \(service.uuid)") for char in chars { - NSLog(" Char: \(char.uuid)") + let props = char.properties + let propStr = [ + props.contains(.read) ? "R" : "", + props.contains(.write) ? "W" : "", + props.contains(.writeWithoutResponse) ? "Wn" : "", + props.contains(.notify) ? "N" : "", + props.contains(.indicate) ? "I" : "" + ].filter { !$0.isEmpty }.joined(separator: ",") + NSLog(" Char: \(char.uuid) [\(propStr)]") characteristics[char.uuid] = char + + if operationMode == .readingConfig { + readResult.characteristicsFound.append("\(char.uuid.uuidString)[\(propStr)]") + } } - // Start authentication for BlueCharm - if beaconType == .bluecharm { - authenticateBlueCharm() + if operationMode == .readingConfig { + // Continue exploring next service + exploreNextService() + } else { + // Provisioning: DX-Smart auth flow + dxSmartStartAuth() } } func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { if let error = error { - NSLog("BeaconProvisioner: Write failed for \(characteristic.uuid): \(error.localizedDescription)") + DebugLog.shared.log("BLE: Write failed for \(characteristic.uuid): \(error.localizedDescription)") - // If this was a password attempt, try next password - if characteristic.uuid == BeaconProvisioner.BLUECHARM_PASSWORD_CHAR { - passwordIndex += 1 - authenticateBlueCharm() + if characteristic.uuid == BeaconProvisioner.DXSMART_PASSWORD_CHAR { + if operationMode == .readingConfig { + readFail("Authentication failed: \(error.localizedDescription)") + } else { + fail("Authentication failed: \(error.localizedDescription)") + } return } + if characteristic.uuid == BeaconProvisioner.DXSMART_COMMAND_CHAR { + if operationMode == .readingConfig { + DebugLog.shared.log("BLE: Read query failed, skipping") + dxReadQueryIndex += 1 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in + self?.dxSmartSendNextReadQuery() + } + } else { + fail("Command write failed at step \(dxSmartWriteIndex + 1): \(error.localizedDescription)") + } + return + } + + if operationMode == .readingConfig { + DebugLog.shared.log("BLE: Write failed in read mode, ignoring: \(error.localizedDescription)") + return + } fail("Write failed: \(error.localizedDescription)") return } - NSLog("BeaconProvisioner: Write succeeded for \(characteristic.uuid)") + DebugLog.shared.log("BLE: Write succeeded for \(characteristic.uuid)") - // If password write succeeded, proceed to config - if characteristic.uuid == BeaconProvisioner.BLUECHARM_PASSWORD_CHAR { - writeBlueCharmConfig() + // Password auth succeeded + if characteristic.uuid == BeaconProvisioner.DXSMART_PASSWORD_CHAR { + DebugLog.shared.log("BLE: Authenticated!") + dxSmartAuthenticated = true + if operationMode == .readingConfig { + dxSmartReadQueryAfterAuth() + } else { + dxSmartWriteConfig() + } return } - // Process next write in queue - processWriteQueue() + // Command write succeeded → send next + if characteristic.uuid == BeaconProvisioner.DXSMART_COMMAND_CHAR { + if operationMode == .readingConfig { + dxReadQueryIndex += 1 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in + self?.dxSmartSendNextReadQuery() + } + } else { + dxSmartWriteIndex += 1 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in + self?.dxSmartSendNextCommand() + } + } + return + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { + if let error = error { + DebugLog.shared.log("BLE: Notification state failed for \(characteristic.uuid): \(error.localizedDescription)") + } else { + DebugLog.shared.log("BLE: Notifications \(characteristic.isNotifying ? "enabled" : "disabled") for \(characteristic.uuid)") + } + + if characteristic.uuid == BeaconProvisioner.DXSMART_NOTIFY_CHAR { + dxSmartNotifySubscribed = true + if operationMode == .readingConfig { + // After subscribing FFE1 in read mode → authenticate + dxSmartReadAuth() + } else { + // Provisioning mode → authenticate + dxSmartAuthenticate() + } + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + if let error = error { + DebugLog.shared.log("BLE: Read error for \(characteristic.uuid): \(error.localizedDescription)") + return + } + + let data = characteristic.value ?? Data() + + if operationMode == .readingConfig { + if characteristic.uuid == BeaconProvisioner.DXSMART_NOTIFY_CHAR { + // DX-Smart response data — parse protocol frames + processFFE1Response(data) + } else { + // Log other characteristic updates + let hex = data.map { String(format: "%02X", $0) }.joined(separator: " ") + DebugLog.shared.log("BLE: Read \(characteristic.uuid): \(hex)") + } + } else { + // Provisioning mode — just log FFE1 notifications + if characteristic.uuid == BeaconProvisioner.DXSMART_NOTIFY_CHAR { + let hex = data.map { String(format: "%02X", $0) }.joined(separator: " ") + DebugLog.shared.log("BLE: FFE1 notification: \(hex)") + } + } } } diff --git a/PayfritBeacon/DebugLog.swift b/PayfritBeacon/DebugLog.swift new file mode 100644 index 0000000..9725262 --- /dev/null +++ b/PayfritBeacon/DebugLog.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Simple in-app debug log — viewable from ScanView +class DebugLog: ObservableObject { + static let shared = DebugLog() + + @Published var entries: [String] = [] + + private let maxEntries = 200 + + func log(_ message: String) { + let ts = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium) + let entry = "[\(ts)] \(message)" + NSLog("[DebugLog] \(message)") + DispatchQueue.main.async { + self.entries.append(entry) + if self.entries.count > self.maxEntries { + self.entries.removeFirst(self.entries.count - self.maxEntries) + } + } + } + + func clear() { + DispatchQueue.main.async { + self.entries.removeAll() + } + } + + /// Get all entries as a single string for clipboard + var allText: String { + entries.joined(separator: "\n") + } +} diff --git a/PayfritBeacon/Info.plist b/PayfritBeacon/Info.plist index 6296130..3ee1cbc 100644 --- a/PayfritBeacon/Info.plist +++ b/PayfritBeacon/Info.plist @@ -11,15 +11,17 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - Payfrit Beacon + $(APP_DISPLAY_NAME) CFBundleDisplayName - Payfrit Beacon + $(APP_DISPLAY_NAME) CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString $(MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) + NSBluetoothAlwaysUsageDescription + Payfrit Beacon uses Bluetooth to discover and configure nearby beacons. NSFaceIDUsageDescription Payfrit Beacon uses Face ID for quick sign-in. UIApplicationSceneManifest diff --git a/PayfritBeacon/PayfritBeaconApp.swift b/PayfritBeacon/PayfritBeaconApp.swift index 8080d9c..e1e0736 100644 --- a/PayfritBeacon/PayfritBeaconApp.swift +++ b/PayfritBeacon/PayfritBeaconApp.swift @@ -5,6 +5,7 @@ struct PayfritBeaconApp: App { var body: some Scene { WindowGroup { RootView() + .preferredColorScheme(.light) } } } diff --git a/PayfritBeacon/ScanView.swift b/PayfritBeacon/ScanView.swift index f91b3c6..23a3070 100644 --- a/PayfritBeacon/ScanView.swift +++ b/PayfritBeacon/ScanView.swift @@ -5,11 +5,13 @@ import SwiftUI struct ScanView: View { let businessId: Int let businessName: String + var reprovisionServicePoint: ServicePoint? = nil // If set, we're re-provisioning an existing SP var onBack: () -> Void @StateObject private var bleScanner = BLEBeaconScanner() @StateObject private var provisioner = BeaconProvisioner() + @State private var namespace: BusinessNamespace? @State private var servicePoints: [ServicePoint] = [] @State private var nextTableNumber: Int = 1 @State private var provisionedCount: Int = 0 @@ -21,6 +23,18 @@ struct ScanView: View { @State private var assignName = "" @State private var isProvisioning = false @State private var provisioningProgress = "" + @State private var provisioningError: String? + + // Action sheet + check config + @State private var showBeaconActionSheet = false + @State private var showCheckConfigSheet = false + @State private var checkConfigData: BeaconCheckResult? + @State private var isCheckingConfig = false + @State private var checkConfigError: String? + + // Debug log + @State private var showDebugLog = false + @ObservedObject private var debugLog = DebugLog.shared var body: some View { VStack(spacing: 0) { @@ -38,6 +52,13 @@ struct ScanView: View { .font(.caption) .foregroundColor(.payfritGreen) } + Button { + showDebugLog = true + } label: { + Image(systemName: "ladybug") + .font(.caption) + .foregroundColor(.secondary) + } } .padding() .background(Color(.systemBackground)) @@ -48,9 +69,15 @@ struct ScanView: View { .font(.subheadline) .foregroundColor(.secondary) Spacer() - Text("Next: Table \(nextTableNumber)") - .font(.subheadline.bold()) - .foregroundColor(.payfritGreen) + if let sp = reprovisionServicePoint { + Text("Re-provision: \(sp.name)") + .font(.subheadline.bold()) + .foregroundColor(.orange) + } else { + Text("Next: Table \(nextTableNumber)") + .font(.subheadline.bold()) + .foregroundColor(.payfritGreen) + } } .padding(.horizontal) .padding(.vertical, 8) @@ -90,7 +117,10 @@ struct ScanView: View { LazyVStack(spacing: 8) { ForEach(bleScanner.discoveredBeacons) { beacon in beaconRow(beacon) - .onTapGesture { selectBeacon(beacon) } + .onTapGesture { + selectedBeacon = beacon + showBeaconActionSheet = true + } } } .padding(.horizontal) @@ -133,6 +163,33 @@ struct ScanView: View { .modifier(DevBanner()) .overlay(snackOverlay, alignment: .bottom) .sheet(isPresented: $showAssignSheet) { assignSheet } + .sheet(isPresented: $showCheckConfigSheet) { checkConfigSheet } + .sheet(isPresented: $showDebugLog) { debugLogSheet } + .confirmationDialog( + selectedBeacon?.displayName ?? "Beacon", + isPresented: $showBeaconActionSheet, + titleVisibility: .visible + ) { + if let sp = reprovisionServicePoint { + Button("Provision for \(sp.name)") { + guard selectedBeacon != nil else { return } + reprovisionBeacon() + } + } else { + Button("Configure (Assign & Provision)") { + guard selectedBeacon != nil else { return } + assignName = "Table \(nextTableNumber)" + showAssignSheet = true + } + } + Button("Check Current Config") { + guard let beacon = selectedBeacon else { return } + checkConfig(beacon) + } + Button("Cancel", role: .cancel) { + selectedBeacon = nil + } + } .onAppear { loadServicePoints() } } @@ -179,13 +236,15 @@ struct ScanView: View { .lineLimit(1) HStack(spacing: 8) { - Text(beacon.type.rawValue) - .font(.caption2.weight(.medium)) - .foregroundColor(.white) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(beacon.type == .kbeacon ? Color.blue : Color.purple) - .cornerRadius(4) + if beacon.type != .unknown { + Text(beacon.type.rawValue) + .font(.caption2.weight(.medium)) + .foregroundColor(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(beacon.type == .kbeacon ? Color.blue : beacon.type == .dxsmart ? Color.orange : Color.purple) + .cornerRadius(4) + } Text("\(beacon.rssi) dBm") .font(.caption) @@ -230,7 +289,7 @@ struct ScanView: View { .foregroundColor(.white) .padding(.horizontal, 6) .padding(.vertical, 2) - .background(beacon.type == .kbeacon ? Color.blue : Color.purple) + .background(beacon.type == .kbeacon ? Color.blue : beacon.type == .dxsmart ? Color.orange : Color.purple) .cornerRadius(4) } Text("Signal: \(beacon.rssi) dBm") @@ -251,19 +310,6 @@ struct ScanView: View { .font(.title3) } - // KBeacon warning - if beacon.type == .kbeacon { - HStack { - Image(systemName: "info.circle.fill") - .foregroundColor(.infoBlue) - Text("KBeacon requires their app for provisioning. Config will be copied to clipboard.") - .font(.caption) - } - .padding() - .background(Color.infoBlue.opacity(0.1)) - .cornerRadius(8) - } - // Provisioning progress if isProvisioning { HStack { @@ -277,6 +323,20 @@ struct ScanView: View { .cornerRadius(8) } + // Provisioning error + if let error = provisioningError { + HStack(alignment: .top) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red) + Text(error) + .font(.callout) + .foregroundColor(.red) + } + .padding() + .background(Color.red.opacity(0.1)) + .cornerRadius(8) + } + Spacer() } } @@ -303,6 +363,270 @@ struct ScanView: View { .interactiveDismissDisabled(isProvisioning) } + // MARK: - Check Config Sheet + + private var checkConfigSheet: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if let beacon = selectedBeacon { + // Beacon identity + VStack(alignment: .leading, spacing: 8) { + Text("Beacon") + .font(.caption) + .foregroundColor(.secondary) + HStack { + Text(beacon.displayName) + .font(.headline) + Spacer() + if beacon.type != .unknown { + Text(beacon.type.rawValue) + .font(.caption2.weight(.medium)) + .foregroundColor(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(beacon.type == .kbeacon ? Color.blue : beacon.type == .dxsmart ? Color.orange : Color.purple) + .cornerRadius(4) + } + } + Text("Signal: \(beacon.rssi) dBm") + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + .background(Color(.systemGray6)) + .cornerRadius(8) + + // Loading state + if isCheckingConfig { + HStack { + ProgressView() + Text(provisioner.progress.isEmpty ? "Connecting..." : provisioner.progress) + .font(.callout) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.systemGray6)) + .cornerRadius(8) + } + + // Error state + if let error = checkConfigError { + HStack(alignment: .top) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + Text(error) + .font(.callout) + } + .padding() + .background(Color.orange.opacity(0.1)) + .cornerRadius(8) + } + + // Parsed config data + if let data = checkConfigData { + // iBeacon configuration + if data.hasConfig { + VStack(alignment: .leading, spacing: 10) { + Text("iBeacon Configuration") + .font(.subheadline.weight(.semibold)) + + if let uuid = data.uuid { + configRow("UUID", uuid) + } + if let major = data.major { + configRow("Major", "\(major)") + } + if let minor = data.minor { + configRow("Minor", "\(minor)") + } + if let name = data.deviceName { + configRow("Name", name) + } + if let rssi = data.rssiAt1m { + configRow("RSSI@1m", "\(rssi) dBm") + } + if let interval = data.advInterval { + configRow("Interval", "\(interval)00 ms") + } + if let tx = data.txPower { + configRow("TX Power", "\(tx)") + } + } + .padding() + .background(Color(.systemGray6)) + .cornerRadius(8) + } + + // Device info + if data.battery != nil || data.macAddress != nil { + VStack(alignment: .leading, spacing: 10) { + Text("Device Info") + .font(.subheadline.weight(.semibold)) + + if let battery = data.battery { + configRow("Battery", "\(battery)%") + } + if let mac = data.macAddress { + configRow("MAC", mac) + } + if let slots = data.frameSlots { + let slotStr = slots.enumerated().map { i, s in + "Slot\(i): 0x\(String(format: "%02X", s))" + }.joined(separator: " ") + configRow("Frames", slotStr) + } + } + .padding() + .background(Color(.systemGray6)) + .cornerRadius(8) + } + + // No config found + if !data.hasConfig && data.battery == nil && data.macAddress == nil && data.rawResponses.isEmpty { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "info.circle") + .foregroundColor(.secondary) + Text("No DX-Smart config data received") + .font(.callout) + .foregroundColor(.secondary) + } + + if !data.servicesFound.isEmpty { + Text("Services: \(data.servicesFound.joined(separator: ", "))") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding() + .background(Color(.systemGray6)) + .cornerRadius(8) + } + + // Raw responses (debug section) + if !data.rawResponses.isEmpty { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Raw Responses") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Button { + let raw = data.rawResponses.joined(separator: "\n") + UIPasteboard.general.string = raw + showSnack("Copied to clipboard") + } label: { + Image(systemName: "doc.on.doc") + .font(.caption) + } + } + Text(data.rawResponses.joined(separator: "\n")) + .font(.system(.caption2, design: .monospaced)) + .textSelection(.enabled) + } + .padding() + .background(Color(.systemGray6)) + .cornerRadius(8) + } + + // Services/chars discovery info + if !data.characteristicsFound.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("BLE Discovery") + .font(.caption) + .foregroundColor(.secondary) + Text(data.characteristicsFound.joined(separator: "\n")) + .font(.system(.caption2, design: .monospaced)) + .textSelection(.enabled) + } + .padding() + .background(Color(.systemGray6)) + .cornerRadius(8) + } + } + } else { + Text("No beacon selected") + .foregroundColor(.secondary) + } + } + .padding() + } + .navigationTitle("Check Config") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + showCheckConfigSheet = false + selectedBeacon = nil + checkConfigData = nil + checkConfigError = nil + } + .disabled(isCheckingConfig) + } + } + } + .presentationDetents([.medium, .large]) + .interactiveDismissDisabled(isCheckingConfig) + } + + private func configRow(_ label: String, _ value: String) -> some View { + HStack(alignment: .top) { + Text(label) + .font(.caption) + .foregroundColor(.secondary) + .frame(width: 80, alignment: .leading) + Text(value) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + } + + // MARK: - Debug Log Sheet + + private var debugLogSheet: some View { + NavigationStack { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 2) { + ForEach(Array(debugLog.entries.enumerated()), id: \.offset) { idx, entry in + Text(entry) + .font(.system(.caption2, design: .monospaced)) + .textSelection(.enabled) + .id(idx) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + } + .onAppear { + if let last = debugLog.entries.indices.last { + proxy.scrollTo(last, anchor: .bottom) + } + } + } + .navigationTitle("Debug Log") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { showDebugLog = false } + } + ToolbarItemGroup(placement: .navigationBarTrailing) { + Button { + UIPasteboard.general.string = debugLog.allText + } label: { + Image(systemName: "doc.on.doc") + } + Button { + debugLog.clear() + } label: { + Image(systemName: "trash") + } + } + } + } + } + // MARK: - Snack Overlay @ViewBuilder @@ -333,7 +657,16 @@ struct ScanView: View { private func loadServicePoints() { Task { do { - servicePoints = try await Api.shared.listServicePoints(businessId: businessId) + // Load namespace (UUID + Major) and service points in parallel + async let nsTask = Api.shared.allocateBusinessNamespace(businessId: businessId) + async let spTask = Api.shared.listServicePoints(businessId: businessId) + + namespace = try await nsTask + servicePoints = try await spTask + + DebugLog.shared.log("[ScanView] Loaded namespace: uuid=\(namespace?.uuid ?? "nil") major=\(namespace?.major ?? 0)") + DebugLog.shared.log("[ScanView] Loaded \(servicePoints.count) service points") + // Find next table number let maxNumber = servicePoints.compactMap { sp -> Int? in guard let match = sp.name.range(of: #"Table\s+(\d+)"#, @@ -345,7 +678,7 @@ struct ScanView: View { }.max() ?? 0 nextTableNumber = maxNumber + 1 } catch { - // Silently continue + DebugLog.shared.log("[ScanView] loadServicePoints error: \(error)") } // Auto-start scan @@ -361,10 +694,103 @@ struct ScanView: View { bleScanner.startScanning() } - private func selectBeacon(_ beacon: DiscoveredBeacon) { - selectedBeacon = beacon - assignName = "Table \(nextTableNumber)" - showAssignSheet = true + private func checkConfig(_ beacon: DiscoveredBeacon) { + isCheckingConfig = true + checkConfigError = nil + checkConfigData = nil + showCheckConfigSheet = true + + // Stop scanning to avoid BLE interference + bleScanner.stopScanning() + + provisioner.readConfig(beacon: beacon) { data, error in + Task { @MainActor in + isCheckingConfig = false + if let data = data { + checkConfigData = data + } + if let error = error { + checkConfigError = error + } + } + } + } + + private func reprovisionBeacon() { + guard let beacon = selectedBeacon, let sp = reprovisionServicePoint else { return } + + guard let ns = namespace else { + failProvisioning("Namespace not loaded — go back and try again") + return + } + + // Stop scanning + bleScanner.stopScanning() + + isProvisioning = true + provisioningProgress = "Preparing..." + provisioningError = nil + showAssignSheet = true // Reuse assign sheet to show progress + assignName = sp.name + + DebugLog.shared.log("[ScanView] reprovisionBeacon: sp=\(sp.name) spId=\(sp.servicePointId) minor=\(String(describing: sp.beaconMinor)) beacon=\(beacon.displayName)") + + Task { + do { + // If SP has no minor, re-fetch to get it + var minor = sp.beaconMinor + if minor == nil { + DebugLog.shared.log("[ScanView] reprovisionBeacon: SP has no minor, re-fetching...") + let refreshed = try await Api.shared.listServicePoints(businessId: businessId) + minor = refreshed.first(where: { $0.servicePointId == sp.servicePointId })?.beaconMinor + } + + guard let beaconMinor = minor else { + failProvisioning("Service point has no beacon minor assigned") + return + } + + // Build config from namespace + service point (uuidClean for BLE) + let deviceName = "PF-\(sp.name)" + let beaconConfig = BeaconConfig( + uuid: ns.uuidClean, + major: ns.major, + minor: beaconMinor, + txPower: -59, + interval: 350, + deviceName: deviceName + ) + + DebugLog.shared.log("[ScanView] reprovisionBeacon: BLE uuid=\(ns.uuidClean) API uuid=\(ns.uuid) major=\(ns.major) minor=\(beaconMinor)") + provisioningProgress = "Provisioning beacon..." + + let hardwareId = beacon.id.uuidString // BLE peripheral identifier + provisioner.provision(beacon: beacon, config: beaconConfig) { result in + Task { @MainActor in + switch result { + case .success: + do { + try await Api.shared.registerBeaconHardware( + businessId: businessId, + servicePointId: sp.servicePointId, + uuid: ns.uuid, + major: ns.major, + minor: beaconMinor, + hardwareId: hardwareId + ) + finishProvisioning(name: sp.name) + } catch { + failProvisioning(error.localizedDescription) + } + case .failure(let error): + failProvisioning(error) + } + } + } + } catch { + failProvisioning(error.localizedDescription) + } + } } private func saveBeacon() { @@ -372,73 +798,85 @@ struct ScanView: View { let name = assignName.trimmingCharacters(in: .whitespaces) guard !name.isEmpty else { return } + guard let ns = namespace else { + failProvisioning("Namespace not loaded — go back and try again") + return + } + isProvisioning = true - provisioningProgress = "Creating service point..." + provisioningProgress = "Preparing..." + provisioningError = nil + + // Stop scanning to avoid BLE interference + bleScanner.stopScanning() + + DebugLog.shared.log("[ScanView] saveBeacon: name=\(name) beacon=\(beacon.displayName) businessId=\(businessId)") Task { do { - // 1. Create or get service point - let servicePoint = try await Api.shared.createServicePoint(businessId: businessId, name: name) - provisioningProgress = "Getting beacon config..." + // 1. Reuse existing service point if name matches, otherwise create new + var servicePoint: ServicePoint + if let existing = servicePoints.first(where: { $0.name.caseInsensitiveCompare(name) == .orderedSame }) { + DebugLog.shared.log("[ScanView] saveBeacon: found existing SP id=\(existing.servicePointId) minor=\(String(describing: existing.beaconMinor))") + servicePoint = existing + } else { + provisioningProgress = "Creating service point..." + DebugLog.shared.log("[ScanView] saveBeacon: creating new SP...") + servicePoint = try await Api.shared.createServicePoint(businessId: businessId, name: name) + DebugLog.shared.log("[ScanView] saveBeacon: created SP id=\(servicePoint.servicePointId) minor=\(String(describing: servicePoint.beaconMinor))") + } - // 2. Get beacon config from backend - let config = try await Api.shared.getBeaconConfig(businessId: businessId, servicePointId: servicePoint.servicePointId) - provisioningProgress = "Provisioning beacon..." + // If SP has no minor yet, re-fetch service points to get the allocated minor + if servicePoint.beaconMinor == nil { + DebugLog.shared.log("[ScanView] saveBeacon: SP has no minor, re-fetching service points...") + let refreshed = try await Api.shared.listServicePoints(businessId: businessId) + if let updated = refreshed.first(where: { $0.servicePointId == servicePoint.servicePointId }) { + servicePoint = updated + DebugLog.shared.log("[ScanView] saveBeacon: refreshed SP minor=\(String(describing: servicePoint.beaconMinor))") + } + } - // 3. Provision the beacon + guard let minor = servicePoint.beaconMinor else { + failProvisioning("Service point has no beacon minor assigned") + return + } + + // 2. Build config from namespace + service point (uuidClean = no dashes, for BLE) + let deviceName = "PF-\(name)" let beaconConfig = BeaconConfig( - uuid: config.uuid, - major: config.major, - minor: config.minor, - txPower: Int8(config.txPower), - interval: UInt16(config.interval) + uuid: ns.uuidClean, + major: ns.major, + minor: minor, + txPower: -59, + interval: 350, + deviceName: deviceName ) - if beacon.type == .kbeacon { - // Copy config to clipboard for KBeacon app - let clipboardText = """ - UUID: \(formatUuidWithDashes(config.uuid)) - Major: \(config.major) - Minor: \(config.minor) - TxPower: \(config.txPower) - """ - UIPasteboard.general.string = clipboardText - showSnack("Config copied! Use KBeacon app to program.") + DebugLog.shared.log("[ScanView] saveBeacon: BLE uuid=\(ns.uuidClean) API uuid=\(ns.uuid) major=\(ns.major) minor=\(minor)") + provisioningProgress = "Provisioning beacon..." - // Register in backend - try await Api.shared.registerBeaconHardware( - businessId: businessId, - servicePointId: servicePoint.servicePointId, - uuid: config.uuid, - major: config.major, - minor: config.minor, - macAddress: nil - ) - - finishProvisioning(name: name) - } else { - // BlueCharm - provision directly via GATT - provisioner.provision(beacon: beacon, config: beaconConfig) { result in - Task { @MainActor in - switch result { - case .success: - // Register in backend - do { - try await Api.shared.registerBeaconHardware( - businessId: businessId, - servicePointId: servicePoint.servicePointId, - uuid: config.uuid, - major: config.major, - minor: config.minor, - macAddress: nil - ) - finishProvisioning(name: name) - } catch { - failProvisioning(error.localizedDescription) - } - case .failure(let error): - failProvisioning(error) + // 3. Provision the beacon via GATT + let hardwareId = beacon.id.uuidString // BLE peripheral identifier + provisioner.provision(beacon: beacon, config: beaconConfig) { result in + Task { @MainActor in + switch result { + case .success: + // Register in backend (use original UUID from API, not cleaned) + do { + try await Api.shared.registerBeaconHardware( + businessId: businessId, + servicePointId: servicePoint.servicePointId, + uuid: ns.uuid, + major: ns.major, + minor: minor, + hardwareId: hardwareId + ) + finishProvisioning(name: name) + } catch { + failProvisioning(error.localizedDescription) } + case .failure(let error): + failProvisioning(error) } } } @@ -476,9 +914,10 @@ struct ScanView: View { } private func failProvisioning(_ error: String) { + DebugLog.shared.log("[ScanView] Provisioning failed: \(error)") isProvisioning = false provisioningProgress = "" - showSnack("Error: \(error)") + provisioningError = error } private func formatUuidWithDashes(_ raw: String) -> String { diff --git a/PayfritBeacon/ServicePointListView.swift b/PayfritBeacon/ServicePointListView.swift index 1244cbb..4346083 100644 --- a/PayfritBeacon/ServicePointListView.swift +++ b/PayfritBeacon/ServicePointListView.swift @@ -15,6 +15,13 @@ struct ServicePointListView: View { @State private var newServicePointName = "" @State private var isAdding = false + // Beacon scan + @State private var showScanView = false + + // Re-provision existing service point + @State private var tappedServicePoint: ServicePoint? + @State private var reprovisionServicePoint: ServicePoint? + var body: some View { NavigationStack { @@ -94,11 +101,19 @@ struct ServicePointListView: View { .padding(.vertical, 20) } else { ForEach(servicePoints) { sp in - HStack { - Text(sp.name) - Spacer() - if let minor = sp.beaconMinor { - Text("Minor: \(minor)") + Button { + tappedServicePoint = sp + } label: { + HStack { + Text(sp.name) + .foregroundColor(.primary) + Spacer() + if let minor = sp.beaconMinor { + Text("Minor: \(minor)") + .font(.caption) + .foregroundColor(.secondary) + } + Image(systemName: "chevron.right") .font(.caption) .foregroundColor(.secondary) } @@ -117,7 +132,12 @@ struct ServicePointListView: View { ToolbarItem(placement: .navigationBarLeading) { Button("Back", action: onBack) } - ToolbarItem(placement: .navigationBarTrailing) { + ToolbarItemGroup(placement: .navigationBarTrailing) { + Button { + showScanView = true + } label: { + Image(systemName: "antenna.radiowaves.left.and.right") + } Button { showAddSheet = true } label: { @@ -128,6 +148,49 @@ struct ServicePointListView: View { } .onAppear { loadData() } .sheet(isPresented: $showAddSheet) { addServicePointSheet } + .fullScreenCover(isPresented: $showScanView) { + ScanView( + businessId: businessId, + businessName: businessName, + onBack: { + showScanView = false + loadData() + } + ) + } + .fullScreenCover(item: $reprovisionServicePoint) { sp in + ScanView( + businessId: businessId, + businessName: businessName, + reprovisionServicePoint: sp, + onBack: { + reprovisionServicePoint = nil + loadData() + } + ) + } + .confirmationDialog( + tappedServicePoint?.name ?? "Service Point", + isPresented: Binding( + get: { tappedServicePoint != nil }, + set: { if !$0 { tappedServicePoint = nil } } + ), + titleVisibility: .visible + ) { + Button("Re-provision Beacon") { + reprovisionServicePoint = tappedServicePoint + tappedServicePoint = nil + } + Button("Delete", role: .destructive) { + if let sp = tappedServicePoint { + deleteServicePoint(sp) + } + tappedServicePoint = nil + } + Button("Cancel", role: .cancel) { + tappedServicePoint = nil + } + } } // MARK: - Add Sheet @@ -227,6 +290,17 @@ struct ServicePointListView: View { } } + private func deleteServicePoint(_ sp: ServicePoint) { + Task { + do { + try await Api.shared.deleteServicePoint(businessId: businessId, servicePointId: sp.servicePointId) + servicePoints.removeAll { $0.servicePointId == sp.servicePointId } + } catch { + errorMessage = error.localizedDescription + } + } + } + private func addServicePoint() { let name = newServicePointName.trimmingCharacters(in: .whitespaces) guard !name.isEmpty else { return }