Fix DX-Smart provisioning protocol and add debug logging

Fix critical packet format bugs matching SDK: frame select/type/trigger/disable
commands now send empty data, RSSI@1m corrected to -59 dBm. Add DebugLog,
read-config mode, service point list, and dev scheme.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
John Pinkyfloyd 2026-03-04 20:01:12 -08:00
parent 8c2320da44
commit 5283d2d265
10 changed files with 1735 additions and 264 deletions

View file

@ -11,13 +11,21 @@
7D757E1341A0143A2E9EBDF4 /* Pods_PayfritBeacon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7F6819A0F2BD2E9D84E7EDB4 /* Pods_PayfritBeacon.framework */; }; 7D757E1341A0143A2E9EBDF4 /* Pods_PayfritBeacon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7F6819A0F2BD2E9D84E7EDB4 /* Pods_PayfritBeacon.framework */; };
D01000000001 /* PayfritBeaconApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000001 /* PayfritBeaconApp.swift */; }; D01000000001 /* PayfritBeaconApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000001 /* PayfritBeaconApp.swift */; };
D01000000002 /* Api.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000002 /* Api.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 */; }; D01000000005 /* DevBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000005 /* DevBanner.swift */; };
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 */; };
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 */; };
D01000000080 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = D02000000080 /* InfoPlist.strings */; }; 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 */ /* End PBXBuildFile section */
/* Begin PBXFileReference 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 = "<group>"; }; D02000000070 /* payfrit-favicon-light-outlines.svg */ = {isa = PBXFileReference; lastKnownFileType = text; path = "payfrit-favicon-light-outlines.svg"; sourceTree = "<group>"; };
D02000000081 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; }; D02000000081 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
E1775119CBC98A753AE26D84 /* ServicePointListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ServicePointListView.swift; sourceTree = "<group>"; }; E1775119CBC98A753AE26D84 /* ServicePointListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ServicePointListView.swift; sourceTree = "<group>"; };
F100AA6D1E41596FCB1C1A39 /* DebugLog.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DebugLog.swift; path = PayfritBeacon/DebugLog.swift; sourceTree = "<group>"; };
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 = "<group>"; }; 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 = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@ -73,6 +82,7 @@
C05000000009 /* Products */, C05000000009 /* Products */,
04996117E2F5D5BB2D86CD46 /* Pods */, 04996117E2F5D5BB2D86CD46 /* Pods */,
EEC06FED6BE78CF9357F3158 /* Frameworks */, EEC06FED6BE78CF9357F3158 /* Frameworks */,
F100AA6D1E41596FCB1C1A39 /* DebugLog.swift */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@ -234,11 +244,19 @@
files = ( files = (
D01000000001 /* PayfritBeaconApp.swift in Sources */, D01000000001 /* PayfritBeaconApp.swift in Sources */,
D01000000002 /* Api.swift in Sources */, D01000000002 /* Api.swift in Sources */,
D01000000003 /* BeaconBanList.swift in Sources */,
D01000000004 /* BeaconScanner.swift in Sources */,
D01000000005 /* DevBanner.swift in Sources */, D01000000005 /* DevBanner.swift in Sources */,
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 */,
D01000000009 /* QrScanView.swift in Sources */,
D0100000000A /* RootView.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 */, 281CC856DD918C4CFA00EB67 /* ServicePointListView.swift in Sources */,
F1575ED0F871FE8806035906 /* DebugLog.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -256,6 +274,193 @@
/* End PBXVariantGroup section */ /* End PBXVariantGroup section */
/* Begin XCBuildConfiguration 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 */ = { C0B000000001 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@ -377,6 +582,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = AFF542870BA2862FAB429A86 /* Pods-PayfritBeacon.debug.xcconfig */; baseConfigurationReference = AFF542870BA2862FAB429A86 /* Pods-PayfritBeacon.debug.xcconfig */;
buildSettings = { buildSettings = {
APP_DISPLAY_NAME = "Payfrit Beacon";
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;
@ -400,6 +606,7 @@
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO; SUPPORTS_MACCATALYST = NO;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
@ -410,6 +617,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = F1B84CA677516C50F6BE2294 /* Pods-PayfritBeacon.release.xcconfig */; baseConfigurationReference = F1B84CA677516C50F6BE2294 /* Pods-PayfritBeacon.release.xcconfig */;
buildSettings = { buildSettings = {
APP_DISPLAY_NAME = "Payfrit Beacon";
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;
@ -433,6 +641,7 @@
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO; SUPPORTS_MACCATALYST = NO;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
@ -447,6 +656,8 @@
buildConfigurations = ( buildConfigurations = (
C0B000000001 /* Debug */, C0B000000001 /* Debug */,
C0B000000002 /* Release */, C0B000000002 /* Release */,
672BF8AE9DE36DEAE34D6DA0 /* Debug-Dev */,
99070AFE15F557412BACA2C9 /* Release-Dev */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
@ -456,6 +667,8 @@
buildConfigurations = ( buildConfigurations = (
C0B000000003 /* Debug */, C0B000000003 /* Debug */,
C0B000000004 /* Release */, C0B000000004 /* Release */,
B0D496FEA252D8DDA33F57A0 /* Debug-Dev */,
064DDD2A9238EC6900250593 /* Release-Dev */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;

View file

@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C06000000001"
BuildableName = "PayfritBeacon.app"
BlueprintName = "PayfritBeacon"
ReferencedContainer = "container:PayfritBeacon.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug-Dev"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug-Dev"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C06000000001"
BuildableName = "PayfritBeacon.app"
BlueprintName = "PayfritBeacon"
ReferencedContainer = "container:PayfritBeacon.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release-Dev"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C06000000001"
BuildableName = "PayfritBeacon.app"
BlueprintName = "PayfritBeacon"
ReferencedContainer = "container:PayfritBeacon.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug-Dev">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release-Dev"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -3,8 +3,12 @@ import Foundation
class Api { class Api {
static let shared = 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 static let IS_DEV = false
#endif
private static var BASE_URL: String { private static var BASE_URL: String {
IS_DEV ? "https://dev.payfrit.com/api" : "https://biz.payfrit.com/api" 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) /// Get beacon config for a service point (UUID, Major, Minor to write to beacon)
func getBeaconConfig(businessId: Int, servicePointId: Int) async throws -> BeaconConfigResponse { func getBeaconConfig(businessId: Int, servicePointId: Int) async throws -> BeaconConfigResponse {
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.cfm",
body: ["BusinessID": businessId, "ServicePointID": servicePointId], body: ["BusinessID": businessId, "ServicePointID": servicePointId],
extraHeaders: ["X-Business-Id": String(businessId)] extraHeaders: ["X-Business-Id": String(businessId)]
) )
if !parseBool(json["OK"] ?? json["ok"]) { DebugLog.shared.log("[API] getBeaconConfig response keys: \(json.keys.sorted())")
let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to get beacon config" 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) throw ApiException(error)
} }
guard let uuid = (json["UUID"] ?? json["uuid"]) as? String, guard let uuid = (json["UUID"] ?? json["uuid"] ?? json["Uuid"] ?? json["BeaconUUID"] ?? json["BEACONUUID"]) as? String else {
let major = parseIntValue(json["MAJOR"] ?? json["major"]), throw ApiException("Invalid beacon config response - no UUID. Keys: \(json.keys.sorted())")
let minor = parseIntValue(json["MINOR"] ?? json["minor"]) else {
throw ApiException("Invalid beacon config response")
} }
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( 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"]) ?? -59, txPower: parseIntValue(json["TXPOWER"] ?? json["txPower"] ?? json["TxPower"]) ?? -59,
interval: parseIntValue(json["INTERVAL"] ?? json["interval"]) ?? 350 interval: parseIntValue(json["INTERVAL"] ?? json["interval"] ?? json["Interval"]) ?? 350
) )
} }
/// Register beacon hardware after provisioning /// 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] = [ var body: [String: Any] = [
"BusinessID": businessId, "BusinessID": businessId,
"ServicePointID": servicePointId, "ServicePointID": servicePointId,
"UUID": uuid, "UUID": uuid,
"Major": major, "Major": major,
"Minor": minor "Minor": minor,
"HardwareID": hardwareId
] ]
if let mac = macAddress, !mac.isEmpty { if let mac = macAddress, !mac.isEmpty {
body["MACAddress"] = mac body["MACAddress"] = mac
} }
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.cfm",
body: body, body: body,
extraHeaders: ["X-Business-Id": String(businessId)] extraHeaders: ["X-Business-Id": String(businessId)]
) )
DebugLog.shared.log("[API] registerBeaconHardware response: \(json)")
if !parseBool(json["OK"] ?? json["ok"]) { if !parseBool(json["OK"] ?? json["ok"]) {
let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to register beacon" let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to register beacon"
throw ApiException(error) throw ApiException(error)
@ -376,7 +398,8 @@ class Api {
return BusinessNamespace( return BusinessNamespace(
shardId: parseIntValue(json["ShardID"] ?? json["SHARDID"]) ?? 0, shardId: parseIntValue(json["ShardID"] ?? json["SHARDID"]) ?? 0,
uuid: uuid.replacingOccurrences(of: "-", with: "").uppercased(), uuid: uuid,
uuidClean: uuid.replacingOccurrences(of: "-", with: "").uppercased(),
major: UInt16(major), major: UInt16(major),
alreadyAllocated: parseBool(json["AlreadyAllocated"] ?? json["ALREADYALLOCATED"]) alreadyAllocated: parseBool(json["AlreadyAllocated"] ?? json["ALREADYALLOCATED"])
) )
@ -412,12 +435,16 @@ class Api {
body["ServicePointID"] = spId body["ServicePointID"] = spId
} }
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.cfm",
body: body, body: body,
extraHeaders: ["X-Business-Id": String(businessId)] extraHeaders: ["X-Business-Id": String(businessId)]
) )
DebugLog.shared.log("[API] saveServicePoint response: \(json)")
if !parseBool(json["OK"] ?? json["ok"]) { if !parseBool(json["OK"] ?? json["ok"]) {
let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to save service point" let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to save service point"
throw ApiException(error) throw ApiException(error)
@ -429,6 +456,12 @@ class Api {
let spId = parseIntValue(sp["ServicePointID"] ?? sp["SERVICEPOINTID"]) ?? servicePointId ?? 0 let spId = parseIntValue(sp["ServicePointID"] ?? sp["SERVICEPOINTID"]) ?? servicePointId ?? 0
let beaconMinor = parseIntValue(sp["BeaconMinor"] ?? sp["BEACONMINOR"]) 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( return ServicePoint(
servicePointId: spId, servicePointId: spId,
name: name, name: name,
@ -442,6 +475,20 @@ class Api {
return try await saveServicePoint(businessId: businessId, name: name) 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 // HELPERS
// ========================================================================= // =========================================================================
@ -550,7 +597,8 @@ struct ServicePoint: Identifiable {
struct BusinessNamespace { struct BusinessNamespace {
let shardId: Int 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 major: UInt16
let alreadyAllocated: Bool let alreadyAllocated: Bool
} }

View file

@ -1,9 +1,10 @@
import Foundation import Foundation
import CoreBluetooth import CoreBluetooth
/// Beacon type detected by service UUID /// Beacon type detected by service UUID or name
enum BeaconType: String { enum BeaconType: String {
case kbeacon = "KBeacon" case kbeacon = "KBeacon"
case dxsmart = "DX-Smart"
case bluecharm = "BlueCharm" case bluecharm = "BlueCharm"
case unknown = "Unknown" case unknown = "Unknown"
} }
@ -18,8 +19,8 @@ struct DiscoveredBeacon: Identifiable {
var lastSeen: Date var lastSeen: Date
var displayName: String { var displayName: String {
if name.isEmpty || name == "Unknown" { if name.isEmpty {
return "\(type.rawValue) (\(id.uuidString.prefix(8))...)" return id.uuidString.prefix(8) + "..."
} }
return name return name
} }
@ -64,9 +65,9 @@ class BLEBeaconScanner: NSObject, ObservableObject {
options: [CBCentralManagerScanOptionAllowDuplicatesKey: true] options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
) )
// Auto-stop after 10 seconds // Auto-stop after 1 second (beacons advertise every ~200ms so 1s is plenty)
scanTimer?.invalidate() 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() self?.stopScanning()
} }
} }
@ -104,35 +105,20 @@ extension BLEBeaconScanner: CBCentralManagerDelegate {
advertisementData: [String: Any], rssi RSSI: NSNumber) { advertisementData: [String: Any], rssi RSSI: NSNumber) {
let rssiValue = RSSI.intValue 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 ?? "" 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 var beaconType: BeaconType = .unknown
// Check advertised service UUIDs
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) {
beaconType = .kbeacon beaconType = .dxsmart
} else if serviceUUIDs.contains(BLEBeaconScanner.BLUECHARM_SERVICE) { } else if serviceUUIDs.contains(BLEBeaconScanner.BLUECHARM_SERVICE) {
beaconType = .bluecharm 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 // 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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -11,15 +11,17 @@
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>Payfrit Beacon</string> <string>$(APP_DISPLAY_NAME)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Payfrit Beacon</string> <string>$(APP_DISPLAY_NAME)</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string> <string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Payfrit Beacon uses Bluetooth to discover and configure nearby beacons.</string>
<key>NSFaceIDUsageDescription</key> <key>NSFaceIDUsageDescription</key>
<string>Payfrit Beacon uses Face ID for quick sign-in.</string> <string>Payfrit Beacon uses Face ID for quick sign-in.</string>
<key>UIApplicationSceneManifest</key> <key>UIApplicationSceneManifest</key>

View file

@ -5,6 +5,7 @@ struct PayfritBeaconApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
RootView() RootView()
.preferredColorScheme(.light)
} }
} }
} }

View file

@ -5,11 +5,13 @@ import SwiftUI
struct ScanView: View { struct ScanView: View {
let businessId: Int let businessId: Int
let businessName: String let businessName: String
var reprovisionServicePoint: ServicePoint? = nil // If set, we're re-provisioning an existing SP
var onBack: () -> Void var onBack: () -> Void
@StateObject private var bleScanner = BLEBeaconScanner() @StateObject private var bleScanner = BLEBeaconScanner()
@StateObject private var provisioner = BeaconProvisioner() @StateObject private var provisioner = BeaconProvisioner()
@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
@ -21,6 +23,18 @@ struct ScanView: View {
@State private var assignName = "" @State private var assignName = ""
@State private var isProvisioning = false @State private var isProvisioning = false
@State private var provisioningProgress = "" @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 { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@ -38,6 +52,13 @@ struct ScanView: View {
.font(.caption) .font(.caption)
.foregroundColor(.payfritGreen) .foregroundColor(.payfritGreen)
} }
Button {
showDebugLog = true
} label: {
Image(systemName: "ladybug")
.font(.caption)
.foregroundColor(.secondary)
}
} }
.padding() .padding()
.background(Color(.systemBackground)) .background(Color(.systemBackground))
@ -48,9 +69,15 @@ struct ScanView: View {
.font(.subheadline) .font(.subheadline)
.foregroundColor(.secondary) .foregroundColor(.secondary)
Spacer() Spacer()
Text("Next: Table \(nextTableNumber)") if let sp = reprovisionServicePoint {
.font(.subheadline.bold()) Text("Re-provision: \(sp.name)")
.foregroundColor(.payfritGreen) .font(.subheadline.bold())
.foregroundColor(.orange)
} else {
Text("Next: Table \(nextTableNumber)")
.font(.subheadline.bold())
.foregroundColor(.payfritGreen)
}
} }
.padding(.horizontal) .padding(.horizontal)
.padding(.vertical, 8) .padding(.vertical, 8)
@ -90,7 +117,10 @@ struct ScanView: View {
LazyVStack(spacing: 8) { LazyVStack(spacing: 8) {
ForEach(bleScanner.discoveredBeacons) { beacon in ForEach(bleScanner.discoveredBeacons) { beacon in
beaconRow(beacon) beaconRow(beacon)
.onTapGesture { selectBeacon(beacon) } .onTapGesture {
selectedBeacon = beacon
showBeaconActionSheet = true
}
} }
} }
.padding(.horizontal) .padding(.horizontal)
@ -133,6 +163,33 @@ struct ScanView: View {
.modifier(DevBanner()) .modifier(DevBanner())
.overlay(snackOverlay, alignment: .bottom) .overlay(snackOverlay, alignment: .bottom)
.sheet(isPresented: $showAssignSheet) { assignSheet } .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() } .onAppear { loadServicePoints() }
} }
@ -179,13 +236,15 @@ struct ScanView: View {
.lineLimit(1) .lineLimit(1)
HStack(spacing: 8) { HStack(spacing: 8) {
Text(beacon.type.rawValue) if beacon.type != .unknown {
.font(.caption2.weight(.medium)) Text(beacon.type.rawValue)
.foregroundColor(.white) .font(.caption2.weight(.medium))
.padding(.horizontal, 6) .foregroundColor(.white)
.padding(.vertical, 2) .padding(.horizontal, 6)
.background(beacon.type == .kbeacon ? Color.blue : Color.purple) .padding(.vertical, 2)
.cornerRadius(4) .background(beacon.type == .kbeacon ? Color.blue : beacon.type == .dxsmart ? Color.orange : Color.purple)
.cornerRadius(4)
}
Text("\(beacon.rssi) dBm") Text("\(beacon.rssi) dBm")
.font(.caption) .font(.caption)
@ -230,7 +289,7 @@ struct ScanView: View {
.foregroundColor(.white) .foregroundColor(.white)
.padding(.horizontal, 6) .padding(.horizontal, 6)
.padding(.vertical, 2) .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) .cornerRadius(4)
} }
Text("Signal: \(beacon.rssi) dBm") Text("Signal: \(beacon.rssi) dBm")
@ -251,19 +310,6 @@ struct ScanView: View {
.font(.title3) .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 // Provisioning progress
if isProvisioning { if isProvisioning {
HStack { HStack {
@ -277,6 +323,20 @@ struct ScanView: View {
.cornerRadius(8) .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() Spacer()
} }
} }
@ -303,6 +363,270 @@ struct ScanView: View {
.interactiveDismissDisabled(isProvisioning) .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 // MARK: - Snack Overlay
@ViewBuilder @ViewBuilder
@ -333,7 +657,16 @@ struct ScanView: View {
private func loadServicePoints() { private func loadServicePoints() {
Task { Task {
do { 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 // Find next table number
let maxNumber = servicePoints.compactMap { sp -> Int? in let maxNumber = servicePoints.compactMap { sp -> Int? in
guard let match = sp.name.range(of: #"Table\s+(\d+)"#, guard let match = sp.name.range(of: #"Table\s+(\d+)"#,
@ -345,7 +678,7 @@ struct ScanView: View {
}.max() ?? 0 }.max() ?? 0
nextTableNumber = maxNumber + 1 nextTableNumber = maxNumber + 1
} catch { } catch {
// Silently continue DebugLog.shared.log("[ScanView] loadServicePoints error: \(error)")
} }
// Auto-start scan // Auto-start scan
@ -361,10 +694,103 @@ struct ScanView: View {
bleScanner.startScanning() bleScanner.startScanning()
} }
private func selectBeacon(_ beacon: DiscoveredBeacon) { private func checkConfig(_ beacon: DiscoveredBeacon) {
selectedBeacon = beacon isCheckingConfig = true
assignName = "Table \(nextTableNumber)" checkConfigError = nil
showAssignSheet = true 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() { private func saveBeacon() {
@ -372,73 +798,85 @@ 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 = "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 { Task {
do { do {
// 1. Create or get service point // 1. Reuse existing service point if name matches, otherwise create new
let servicePoint = try await Api.shared.createServicePoint(businessId: businessId, name: name) var servicePoint: ServicePoint
provisioningProgress = "Getting beacon config..." 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 // If SP has no minor yet, re-fetch service points to get the allocated minor
let config = try await Api.shared.getBeaconConfig(businessId: businessId, servicePointId: servicePoint.servicePointId) if servicePoint.beaconMinor == nil {
provisioningProgress = "Provisioning beacon..." 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( let beaconConfig = BeaconConfig(
uuid: config.uuid, uuid: ns.uuidClean,
major: config.major, major: ns.major,
minor: config.minor, minor: minor,
txPower: Int8(config.txPower), txPower: -59,
interval: UInt16(config.interval) interval: 350,
deviceName: deviceName
) )
if beacon.type == .kbeacon { DebugLog.shared.log("[ScanView] saveBeacon: BLE uuid=\(ns.uuidClean) API uuid=\(ns.uuid) major=\(ns.major) minor=\(minor)")
// Copy config to clipboard for KBeacon app provisioningProgress = "Provisioning beacon..."
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.")
// Register in backend // 3. Provision the beacon via GATT
try await Api.shared.registerBeaconHardware( let hardwareId = beacon.id.uuidString // BLE peripheral identifier
businessId: businessId, provisioner.provision(beacon: beacon, config: beaconConfig) { result in
servicePointId: servicePoint.servicePointId, Task { @MainActor in
uuid: config.uuid, switch result {
major: config.major, case .success:
minor: config.minor, // Register in backend (use original UUID from API, not cleaned)
macAddress: nil do {
) try await Api.shared.registerBeaconHardware(
businessId: businessId,
finishProvisioning(name: name) servicePointId: servicePoint.servicePointId,
} else { uuid: ns.uuid,
// BlueCharm - provision directly via GATT major: ns.major,
provisioner.provision(beacon: beacon, config: beaconConfig) { result in minor: minor,
Task { @MainActor in hardwareId: hardwareId
switch result { )
case .success: finishProvisioning(name: name)
// Register in backend } catch {
do { failProvisioning(error.localizedDescription)
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)
} }
case .failure(let error):
failProvisioning(error)
} }
} }
} }
@ -476,9 +914,10 @@ struct ScanView: View {
} }
private func failProvisioning(_ error: String) { private func failProvisioning(_ error: String) {
DebugLog.shared.log("[ScanView] Provisioning failed: \(error)")
isProvisioning = false isProvisioning = false
provisioningProgress = "" provisioningProgress = ""
showSnack("Error: \(error)") provisioningError = error
} }
private func formatUuidWithDashes(_ raw: String) -> String { private func formatUuidWithDashes(_ raw: String) -> String {

View file

@ -15,6 +15,13 @@ struct ServicePointListView: View {
@State private var newServicePointName = "" @State private var newServicePointName = ""
@State private var isAdding = false @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 { var body: some View {
NavigationStack { NavigationStack {
@ -94,11 +101,19 @@ struct ServicePointListView: View {
.padding(.vertical, 20) .padding(.vertical, 20)
} else { } else {
ForEach(servicePoints) { sp in ForEach(servicePoints) { sp in
HStack { Button {
Text(sp.name) tappedServicePoint = sp
Spacer() } label: {
if let minor = sp.beaconMinor { HStack {
Text("Minor: \(minor)") Text(sp.name)
.foregroundColor(.primary)
Spacer()
if let minor = sp.beaconMinor {
Text("Minor: \(minor)")
.font(.caption)
.foregroundColor(.secondary)
}
Image(systemName: "chevron.right")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
@ -117,7 +132,12 @@ struct ServicePointListView: View {
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .navigationBarLeading) {
Button("Back", action: onBack) Button("Back", action: onBack)
} }
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItemGroup(placement: .navigationBarTrailing) {
Button {
showScanView = true
} label: {
Image(systemName: "antenna.radiowaves.left.and.right")
}
Button { Button {
showAddSheet = true showAddSheet = true
} label: { } label: {
@ -128,6 +148,49 @@ struct ServicePointListView: View {
} }
.onAppear { loadData() } .onAppear { loadData() }
.sheet(isPresented: $showAddSheet) { addServicePointSheet } .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 // 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() { private func addServicePoint() {
let name = newServicePointName.trimmingCharacters(in: .whitespaces) let name = newServicePointName.trimmingCharacters(in: .whitespaces)
guard !name.isEmpty else { return } guard !name.isEmpty else { return }