Compare commits
52 commits
schwifty/f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 66cf65f803 | |||
| 38600193b7 | |||
| 3c41ecb49d | |||
| a08d3db893 | |||
| 640bc32f92 | |||
| 157ab6d008 | |||
| ce81a1a3d8 | |||
| fcf427ee57 | |||
| 4bf4435feb | |||
| f082eeadad | |||
| 3720f496bd | |||
| a82a3697da | |||
| 9ce7b9571a | |||
| 734a18356f | |||
| f0fdb04e0e | |||
| 12048e5c88 | |||
| 5eebf00aa0 | |||
| 5678256356 | |||
| 7089224244 | |||
| b88dded928 | |||
| 37c7c72052 | |||
| 6eaccb6bf6 | |||
| 8f8fcba9c0 | |||
| 61862adfa8 | |||
| ed9a57a938 | |||
| c3f2b4faab | |||
| 349dab1b75 | |||
| c243235237 | |||
| c879ecd442 | |||
| 2306c10d32 | |||
| df2a03f15a | |||
| 174240c13e | |||
| a6ba88803c | |||
| 81d4cad030 | |||
| f60c70f32a | |||
| 1624e0e59d | |||
| a1c215eb42 | |||
| 2242260f5a | |||
| fd4b1bf8ca | |||
| 1b3b16478c | |||
| 09db3e8ec7 | |||
| 3fbb44d22c | |||
| 8ecd533429 | |||
| 2496cab7f3 | |||
| fe2ee59930 | |||
| 9986937f66 | |||
| 6f4dba1804 | |||
| 21a40a0a28 | |||
| 9ffd890a62 | |||
| 58facfda47 | |||
| d123d2561a | |||
| f0d2b2ae90 |
26 changed files with 2532 additions and 1167 deletions
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -63,15 +63,12 @@ PayfritBeacon/
|
||||||
│ └── PayfritBeaconApp.swift App entry point
|
│ └── PayfritBeaconApp.swift App entry point
|
||||||
├── Models/
|
├── Models/
|
||||||
│ ├── BeaconConfig.swift Provisioning config (UUID, major, minor, txPower, etc.)
|
│ ├── BeaconConfig.swift Provisioning config (UUID, major, minor, txPower, etc.)
|
||||||
│ ├── BeaconType.swift Enum: DXSmart, BlueCharm, KBeacon, Unknown
|
│ ├── BeaconType.swift Enum: DXSmart (CP-28 only)
|
||||||
│ ├── Business.swift Business model
|
│ ├── Business.swift Business model
|
||||||
│ └── ServicePoint.swift Service point model
|
│ └── ServicePoint.swift Service point model
|
||||||
├── Provisioners/
|
├── Provisioners/
|
||||||
│ ├── ProvisionerProtocol.swift Protocol — all provisioners implement this
|
│ ├── ProvisionerProtocol.swift Protocol + CP-28 GATT constants
|
||||||
│ ├── DXSmartProvisioner.swift DX-Smart CP28 GATT provisioner (24-step write sequence)
|
│ ├── DXSmartProvisioner.swift DX-Smart CP-28 GATT provisioner (24-step write sequence)
|
||||||
│ ├── BlueCharmProvisioner.swift BlueCharm BC037 provisioner
|
|
||||||
│ ├── KBeaconProvisioner.swift KBeacon provisioner
|
|
||||||
│ ├── FallbackProvisioner.swift Unknown device fallback
|
|
||||||
│ └── ProvisionError.swift Shared error types
|
│ └── ProvisionError.swift Shared error types
|
||||||
├── Services/
|
├── Services/
|
||||||
│ ├── APIClient.swift Actor-based REST client, all API calls
|
│ ├── APIClient.swift Actor-based REST client, all API calls
|
||||||
|
|
@ -93,7 +90,7 @@ PayfritBeacon/
|
||||||
|
|
||||||
## Key Architecture Notes
|
## Key Architecture Notes
|
||||||
|
|
||||||
- **Modular provisioners**: Each beacon manufacturer has its own provisioner conforming to `ProvisionerProtocol`. No more monolithic `BeaconProvisioner.swift`.
|
- **CP-28 only**: Only DX-Smart CP-28 beacons are supported. Other beacon types (KBeacon, BlueCharm) were removed — will be re-added when needed.
|
||||||
- **Actor-based API**: `APIClient` is a Swift actor (thread-safe by design).
|
- **Actor-based API**: `APIClient` is a Swift actor (thread-safe by design).
|
||||||
- **Secure storage**: Auth tokens stored in Keychain via `SecureStorage`, not UserDefaults.
|
- **Secure storage**: Auth tokens stored in Keychain via `SecureStorage`, not UserDefaults.
|
||||||
- **BLE scanning**: `BLEManager` handles CoreBluetooth device discovery and beacon type identification.
|
- **BLE scanning**: `BLEManager` handles CoreBluetooth device discovery and beacon type identification.
|
||||||
|
|
|
||||||
|
|
@ -16,17 +16,16 @@
|
||||||
A01000000013 /* ServicePoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000013 /* ServicePoint.swift */; };
|
A01000000013 /* ServicePoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000013 /* ServicePoint.swift */; };
|
||||||
A01000000020 /* ProvisionerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000020 /* ProvisionerProtocol.swift */; };
|
A01000000020 /* ProvisionerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000020 /* ProvisionerProtocol.swift */; };
|
||||||
A01000000021 /* DXSmartProvisioner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000021 /* DXSmartProvisioner.swift */; };
|
A01000000021 /* DXSmartProvisioner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000021 /* DXSmartProvisioner.swift */; };
|
||||||
A01000000022 /* BlueCharmProvisioner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000022 /* BlueCharmProvisioner.swift */; };
|
|
||||||
A01000000023 /* KBeaconProvisioner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000023 /* KBeaconProvisioner.swift */; };
|
|
||||||
A01000000024 /* FallbackProvisioner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000024 /* FallbackProvisioner.swift */; };
|
|
||||||
A01000000025 /* ProvisionError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000025 /* ProvisionError.swift */; };
|
A01000000025 /* ProvisionError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000025 /* ProvisionError.swift */; };
|
||||||
A01000000030 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000030 /* APIClient.swift */; };
|
A01000000030 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000030 /* APIClient.swift */; };
|
||||||
A01000000031 /* APIConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000031 /* APIConfig.swift */; };
|
A01000000031 /* APIConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000031 /* APIConfig.swift */; };
|
||||||
A01000000032 /* BLEManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000032 /* BLEManager.swift */; };
|
A01000000032 /* BLEManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000032 /* BLEManager.swift */; };
|
||||||
A01000000033 /* SecureStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000033 /* SecureStorage.swift */; };
|
A01000000033 /* SecureStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000033 /* SecureStorage.swift */; };
|
||||||
|
A01000000034 /* ProvisionLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000034 /* ProvisionLog.swift */; };
|
||||||
A01000000040 /* BeaconBanList.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000040 /* BeaconBanList.swift */; };
|
A01000000040 /* BeaconBanList.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000040 /* BeaconBanList.swift */; };
|
||||||
A01000000041 /* BeaconShardPool.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000041 /* BeaconShardPool.swift */; };
|
A01000000041 /* BeaconShardPool.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000041 /* BeaconShardPool.swift */; };
|
||||||
A01000000042 /* UUIDFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000042 /* UUIDFormatting.swift */; };
|
A01000000042 /* UUIDFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000042 /* UUIDFormatting.swift */; };
|
||||||
|
A01000000043 /* BrandColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000043 /* BrandColors.swift */; };
|
||||||
A01000000050 /* BusinessListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000050 /* BusinessListView.swift */; };
|
A01000000050 /* BusinessListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000050 /* BusinessListView.swift */; };
|
||||||
A01000000051 /* DevBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000051 /* DevBanner.swift */; };
|
A01000000051 /* DevBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000051 /* DevBanner.swift */; };
|
||||||
A01000000052 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000052 /* LoginView.swift */; };
|
A01000000052 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000052 /* LoginView.swift */; };
|
||||||
|
|
@ -48,15 +47,14 @@
|
||||||
A02000000013 /* ServicePoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServicePoint.swift; sourceTree = "<group>"; };
|
A02000000013 /* ServicePoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServicePoint.swift; sourceTree = "<group>"; };
|
||||||
A02000000020 /* ProvisionerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProvisionerProtocol.swift; sourceTree = "<group>"; };
|
A02000000020 /* ProvisionerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProvisionerProtocol.swift; sourceTree = "<group>"; };
|
||||||
A02000000021 /* DXSmartProvisioner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DXSmartProvisioner.swift; sourceTree = "<group>"; };
|
A02000000021 /* DXSmartProvisioner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DXSmartProvisioner.swift; sourceTree = "<group>"; };
|
||||||
A02000000022 /* BlueCharmProvisioner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlueCharmProvisioner.swift; sourceTree = "<group>"; };
|
|
||||||
A02000000023 /* KBeaconProvisioner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KBeaconProvisioner.swift; sourceTree = "<group>"; };
|
|
||||||
A02000000024 /* FallbackProvisioner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FallbackProvisioner.swift; sourceTree = "<group>"; };
|
|
||||||
A02000000025 /* ProvisionError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProvisionError.swift; sourceTree = "<group>"; };
|
A02000000025 /* ProvisionError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProvisionError.swift; sourceTree = "<group>"; };
|
||||||
A02000000030 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
|
A02000000030 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
|
||||||
A02000000031 /* APIConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIConfig.swift; sourceTree = "<group>"; };
|
A02000000031 /* APIConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIConfig.swift; sourceTree = "<group>"; };
|
||||||
A02000000032 /* BLEManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEManager.swift; sourceTree = "<group>"; };
|
A02000000032 /* BLEManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEManager.swift; sourceTree = "<group>"; };
|
||||||
A02000000033 /* SecureStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureStorage.swift; sourceTree = "<group>"; };
|
A02000000033 /* SecureStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureStorage.swift; sourceTree = "<group>"; };
|
||||||
|
A02000000034 /* ProvisionLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProvisionLog.swift; sourceTree = "<group>"; };
|
||||||
A02000000040 /* BeaconBanList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconBanList.swift; sourceTree = "<group>"; };
|
A02000000040 /* BeaconBanList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconBanList.swift; sourceTree = "<group>"; };
|
||||||
|
A02000000043 /* BrandColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrandColors.swift; sourceTree = "<group>"; };
|
||||||
A02000000041 /* BeaconShardPool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconShardPool.swift; sourceTree = "<group>"; };
|
A02000000041 /* BeaconShardPool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconShardPool.swift; sourceTree = "<group>"; };
|
||||||
A02000000042 /* UUIDFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UUIDFormatting.swift; sourceTree = "<group>"; };
|
A02000000042 /* UUIDFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UUIDFormatting.swift; sourceTree = "<group>"; };
|
||||||
A02000000050 /* BusinessListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusinessListView.swift; sourceTree = "<group>"; };
|
A02000000050 /* BusinessListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusinessListView.swift; sourceTree = "<group>"; };
|
||||||
|
|
@ -140,10 +138,7 @@
|
||||||
A05000000003 /* Provisioners */ = {
|
A05000000003 /* Provisioners */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
A02000000022 /* BlueCharmProvisioner.swift */,
|
|
||||||
A02000000021 /* DXSmartProvisioner.swift */,
|
A02000000021 /* DXSmartProvisioner.swift */,
|
||||||
A02000000024 /* FallbackProvisioner.swift */,
|
|
||||||
A02000000023 /* KBeaconProvisioner.swift */,
|
|
||||||
A02000000025 /* ProvisionError.swift */,
|
A02000000025 /* ProvisionError.swift */,
|
||||||
A02000000020 /* ProvisionerProtocol.swift */,
|
A02000000020 /* ProvisionerProtocol.swift */,
|
||||||
);
|
);
|
||||||
|
|
@ -157,6 +152,7 @@
|
||||||
A02000000031 /* APIConfig.swift */,
|
A02000000031 /* APIConfig.swift */,
|
||||||
A02000000032 /* BLEManager.swift */,
|
A02000000032 /* BLEManager.swift */,
|
||||||
A02000000033 /* SecureStorage.swift */,
|
A02000000033 /* SecureStorage.swift */,
|
||||||
|
A02000000034 /* ProvisionLog.swift */,
|
||||||
);
|
);
|
||||||
path = Services;
|
path = Services;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
|
@ -167,6 +163,7 @@
|
||||||
A02000000040 /* BeaconBanList.swift */,
|
A02000000040 /* BeaconBanList.swift */,
|
||||||
A02000000041 /* BeaconShardPool.swift */,
|
A02000000041 /* BeaconShardPool.swift */,
|
||||||
A02000000042 /* UUIDFormatting.swift */,
|
A02000000042 /* UUIDFormatting.swift */,
|
||||||
|
A02000000043 /* BrandColors.swift */,
|
||||||
);
|
);
|
||||||
path = Utils;
|
path = Utils;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
|
@ -265,17 +262,16 @@
|
||||||
A01000000013 /* ServicePoint.swift in Sources */,
|
A01000000013 /* ServicePoint.swift in Sources */,
|
||||||
A01000000020 /* ProvisionerProtocol.swift in Sources */,
|
A01000000020 /* ProvisionerProtocol.swift in Sources */,
|
||||||
A01000000021 /* DXSmartProvisioner.swift in Sources */,
|
A01000000021 /* DXSmartProvisioner.swift in Sources */,
|
||||||
A01000000022 /* BlueCharmProvisioner.swift in Sources */,
|
|
||||||
A01000000023 /* KBeaconProvisioner.swift in Sources */,
|
|
||||||
A01000000024 /* FallbackProvisioner.swift in Sources */,
|
|
||||||
A01000000025 /* ProvisionError.swift in Sources */,
|
A01000000025 /* ProvisionError.swift in Sources */,
|
||||||
A01000000030 /* APIClient.swift in Sources */,
|
A01000000030 /* APIClient.swift in Sources */,
|
||||||
A01000000031 /* APIConfig.swift in Sources */,
|
A01000000031 /* APIConfig.swift in Sources */,
|
||||||
A01000000032 /* BLEManager.swift in Sources */,
|
A01000000032 /* BLEManager.swift in Sources */,
|
||||||
A01000000033 /* SecureStorage.swift in Sources */,
|
A01000000033 /* SecureStorage.swift in Sources */,
|
||||||
|
A01000000034 /* ProvisionLog.swift in Sources */,
|
||||||
A01000000040 /* BeaconBanList.swift in Sources */,
|
A01000000040 /* BeaconBanList.swift in Sources */,
|
||||||
A01000000041 /* BeaconShardPool.swift in Sources */,
|
A01000000041 /* BeaconShardPool.swift in Sources */,
|
||||||
A01000000042 /* UUIDFormatting.swift in Sources */,
|
A01000000042 /* UUIDFormatting.swift in Sources */,
|
||||||
|
A01000000043 /* BrandColors.swift in Sources */,
|
||||||
A01000000050 /* BusinessListView.swift in Sources */,
|
A01000000050 /* BusinessListView.swift in Sources */,
|
||||||
A01000000051 /* DevBanner.swift in Sources */,
|
A01000000051 /* DevBanner.swift in Sources */,
|
||||||
A01000000052 /* LoginView.swift in Sources */,
|
A01000000052 /* LoginView.swift in Sources */,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ final class AppState: ObservableObject {
|
||||||
@Published var token: String?
|
@Published var token: String?
|
||||||
@Published var userId: String?
|
@Published var userId: String?
|
||||||
|
|
||||||
|
/// When true, skip auto-navigation in BusinessListView (user explicitly went back)
|
||||||
|
var skipAutoNav = false
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Restore saved session
|
// Restore saved session
|
||||||
if let saved = SecureStorage.loadSession() {
|
if let saved = SecureStorage.loadSession() {
|
||||||
|
|
@ -36,6 +39,8 @@ final class AppState: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
func backToBusinessList() {
|
func backToBusinessList() {
|
||||||
|
AppPrefs.lastBusinessId = nil
|
||||||
|
skipAutoNav = true
|
||||||
currentScreen = .businessList
|
currentScreen = .businessList
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.294",
|
||||||
|
"green" : "0.698",
|
||||||
|
"red" : "0.133"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
PayfritBeacon/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
BIN
PayfritBeacon/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "AppIcon.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
6
PayfritBeacon/Assets.xcassets/Contents.json
Normal file
6
PayfritBeacon/Assets.xcassets/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
1443
PayfritBeacon/BeaconProvisioner.swift
Normal file
1443
PayfritBeacon/BeaconProvisioner.swift
Normal file
File diff suppressed because it is too large
Load diff
56
PayfritBeacon/Info.plist
Normal file
56
PayfritBeacon/Info.plist
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Payfrit Beacon</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>Payfrit Beacon needs camera access to scan QR codes on beacon labels for provisioning.</string>
|
||||||
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
|
<string>Payfrit Beacon needs Bluetooth to detect and configure nearby beacons.</string>
|
||||||
|
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||||
|
<string>Payfrit Beacon needs Bluetooth to detect and configure nearby beacons.</string>
|
||||||
|
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||||
|
<string>Payfrit Beacon needs your location to detect nearby beacons and provide proximity-based services.</string>
|
||||||
|
<key>NSLocationAlwaysUsageDescription</key>
|
||||||
|
<string>Payfrit Beacon needs your location to detect nearby beacons in the background.</string>
|
||||||
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
|
<string>Payfrit Beacon needs your location to detect nearby beacons.</string>
|
||||||
|
<key>UIApplicationSceneManifest</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
<key>UILaunchScreen</key>
|
||||||
|
<dict/>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
|
|
||||||
/// Supported beacon hardware types
|
/// Beacon hardware type — CP-28 (DX-Smart) only for now.
|
||||||
|
/// Other types will be added when we start using different beacon hardware.
|
||||||
enum BeaconType: String, CaseIterable {
|
enum BeaconType: String, CaseIterable {
|
||||||
case kbeacon = "KBeacon"
|
|
||||||
case dxsmart = "DX-Smart"
|
case dxsmart = "DX-Smart"
|
||||||
case bluecharm = "BlueCharm"
|
|
||||||
case unknown = "Unknown"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A BLE beacon discovered during scanning
|
/// A BLE beacon discovered during scanning
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,42 @@ struct Business: Identifiable, Codable, Hashable {
|
||||||
let imageExtension: String?
|
let imageExtension: String?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id = "ID"
|
case businessId = "BusinessID"
|
||||||
case name = "BusinessName"
|
case name = "Name"
|
||||||
|
// Fallbacks for alternate API shapes
|
||||||
|
case altId = "ID"
|
||||||
|
case altName = "BusinessName"
|
||||||
case imageExtension = "ImageExtension"
|
case imageExtension = "ImageExtension"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
// BusinessID (from list endpoint) or ID (from other endpoints), always as String
|
||||||
|
if let bid = try? container.decode(Int.self, forKey: .businessId) {
|
||||||
|
id = String(bid)
|
||||||
|
} else if let bid = try? container.decode(String.self, forKey: .businessId) {
|
||||||
|
id = bid
|
||||||
|
} else if let aid = try? container.decode(Int.self, forKey: .altId) {
|
||||||
|
id = String(aid)
|
||||||
|
} else if let aid = try? container.decode(String.self, forKey: .altId) {
|
||||||
|
id = aid
|
||||||
|
} else {
|
||||||
|
id = ""
|
||||||
|
}
|
||||||
|
// Name (from list endpoint) or BusinessName (from other endpoints)
|
||||||
|
name = (try? container.decode(String.self, forKey: .name))
|
||||||
|
?? (try? container.decode(String.self, forKey: .altName))
|
||||||
|
?? ""
|
||||||
|
imageExtension = try? container.decode(String.self, forKey: .imageExtension)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .businessId)
|
||||||
|
try container.encode(name, forKey: .name)
|
||||||
|
try container.encodeIfPresent(imageExtension, forKey: .imageExtension)
|
||||||
|
}
|
||||||
|
|
||||||
var headerImageURL: URL? {
|
var headerImageURL: URL? {
|
||||||
guard let ext = imageExtension, !ext.isEmpty else { return nil }
|
guard let ext = imageExtension, !ext.isEmpty else { return nil }
|
||||||
return URL(string: "\(APIConfig.imageBaseURL)/businesses/\(id)/header.\(ext)")
|
return URL(string: "\(APIConfig.imageBaseURL)/businesses/\(id)/header.\(ext)")
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,38 @@ struct ServicePoint: Identifiable, Codable, Hashable {
|
||||||
let businessId: String
|
let businessId: String
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id = "ID"
|
case servicePointId = "ServicePointID"
|
||||||
|
case altId = "ID"
|
||||||
case name = "Name"
|
case name = "Name"
|
||||||
case businessId = "BusinessID"
|
case businessId = "BusinessID"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
// ServicePointID (from list/save endpoints) or ID (fallback)
|
||||||
|
if let sid = try? container.decode(Int.self, forKey: .servicePointId) {
|
||||||
|
id = String(sid)
|
||||||
|
} else if let sid = try? container.decode(String.self, forKey: .servicePointId) {
|
||||||
|
id = sid
|
||||||
|
} else if let aid = try? container.decode(Int.self, forKey: .altId) {
|
||||||
|
id = String(aid)
|
||||||
|
} else if let aid = try? container.decode(String.self, forKey: .altId) {
|
||||||
|
id = aid
|
||||||
|
} else {
|
||||||
|
id = ""
|
||||||
|
}
|
||||||
|
name = (try? container.decode(String.self, forKey: .name)) ?? ""
|
||||||
|
if let bid = try? container.decode(Int.self, forKey: .businessId) {
|
||||||
|
businessId = String(bid)
|
||||||
|
} else {
|
||||||
|
businessId = (try? container.decode(String.self, forKey: .businessId)) ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .servicePointId)
|
||||||
|
try container.encode(name, forKey: .name)
|
||||||
|
try container.encode(businessId, forKey: .businessId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,397 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import CoreBluetooth
|
|
||||||
|
|
||||||
/// Provisioner for BlueCharm / BC04P hardware
|
|
||||||
///
|
|
||||||
/// Supports two service variants:
|
|
||||||
/// - FEA0 service (BC04P): FEA1 write, FEA2 notify, FEA3 config
|
|
||||||
/// - FFF0 service (legacy): FFF1 password, FFF2 UUID, FFF3 major, FFF4 minor
|
|
||||||
///
|
|
||||||
/// BC04P write methods (tried in order):
|
|
||||||
/// 1. Direct config write to FEA3: [0x01] + UUID + Major + Minor + TxPower
|
|
||||||
/// 2. Raw data write to FEA1: UUID + Major + Minor + TxPower + Interval, then save commands
|
|
||||||
/// 3. Indexed parameter writes to FEA1: [index] + data, then [0xFF] save
|
|
||||||
///
|
|
||||||
/// Legacy write: individual characteristics per parameter (FFF1-FFF4)
|
|
||||||
final class BlueCharmProvisioner: NSObject, BeaconProvisioner {
|
|
||||||
|
|
||||||
// MARK: - Constants
|
|
||||||
|
|
||||||
// 5 passwords matching Android (16 bytes each)
|
|
||||||
private static let passwords: [Data] = [
|
|
||||||
Data(repeating: 0, count: 16), // All zeros
|
|
||||||
"0000000000000000".data(using: .utf8)!, // ASCII zeros
|
|
||||||
"1234567890123456".data(using: .utf8)!, // Common
|
|
||||||
"minew123".data(using: .utf8)!.padded(to: 16), // Minew default
|
|
||||||
"bc04p".data(using: .utf8)!.padded(to: 16), // Model name
|
|
||||||
]
|
|
||||||
|
|
||||||
// Legacy FFF0 passwords
|
|
||||||
private static let legacyPasswords = ["000000", "123456", "bc0000"]
|
|
||||||
|
|
||||||
// Legacy characteristic UUIDs
|
|
||||||
private static let fff1Password = CBUUID(string: "0000FFF1-0000-1000-8000-00805F9B34FB")
|
|
||||||
private static let fff2UUID = CBUUID(string: "0000FFF2-0000-1000-8000-00805F9B34FB")
|
|
||||||
private static let fff3Major = CBUUID(string: "0000FFF3-0000-1000-8000-00805F9B34FB")
|
|
||||||
private static let fff4Minor = CBUUID(string: "0000FFF4-0000-1000-8000-00805F9B34FB")
|
|
||||||
|
|
||||||
// FEA0 characteristic UUIDs
|
|
||||||
private static let fea1Write = CBUUID(string: "0000FEA1-0000-1000-8000-00805F9B34FB")
|
|
||||||
private static let fea2Notify = CBUUID(string: "0000FEA2-0000-1000-8000-00805F9B34FB")
|
|
||||||
private static let fea3Config = CBUUID(string: "0000FEA3-0000-1000-8000-00805F9B34FB")
|
|
||||||
|
|
||||||
// MARK: - State
|
|
||||||
|
|
||||||
private let peripheral: CBPeripheral
|
|
||||||
private let centralManager: CBCentralManager
|
|
||||||
|
|
||||||
private var discoveredService: CBService?
|
|
||||||
private var writeChar: CBCharacteristic? // FEA1 or first writable
|
|
||||||
private var notifyChar: CBCharacteristic? // FEA2
|
|
||||||
private var configChar: CBCharacteristic? // FEA3
|
|
||||||
private var isLegacy = false // Using FFF0 service
|
|
||||||
|
|
||||||
private var connectionContinuation: CheckedContinuation<Void, Error>?
|
|
||||||
private var serviceContinuation: CheckedContinuation<Void, Error>?
|
|
||||||
private var writeContinuation: CheckedContinuation<Data, Error>?
|
|
||||||
private var writeOKContinuation: CheckedContinuation<Void, Error>?
|
|
||||||
|
|
||||||
private(set) var isConnected = false
|
|
||||||
|
|
||||||
// MARK: - Init
|
|
||||||
|
|
||||||
init(peripheral: CBPeripheral, centralManager: CBCentralManager) {
|
|
||||||
self.peripheral = peripheral
|
|
||||||
self.centralManager = centralManager
|
|
||||||
super.init()
|
|
||||||
self.peripheral.delegate = self
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - BeaconProvisioner
|
|
||||||
|
|
||||||
func connect() async throws {
|
|
||||||
for attempt in 1...GATTConstants.maxRetries {
|
|
||||||
do {
|
|
||||||
try await connectOnce()
|
|
||||||
try await discoverServices()
|
|
||||||
if !isLegacy {
|
|
||||||
try await authenticateBC04P()
|
|
||||||
}
|
|
||||||
isConnected = true
|
|
||||||
return
|
|
||||||
} catch {
|
|
||||||
disconnect()
|
|
||||||
if attempt < GATTConstants.maxRetries {
|
|
||||||
try await Task.sleep(nanoseconds: UInt64(GATTConstants.retryDelay * 1_000_000_000))
|
|
||||||
} else {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeConfig(_ config: BeaconConfig) async throws {
|
|
||||||
guard isConnected else {
|
|
||||||
throw ProvisionError.notConnected
|
|
||||||
}
|
|
||||||
|
|
||||||
let uuidBytes = config.uuid.hexToBytes
|
|
||||||
guard uuidBytes.count == 16 else {
|
|
||||||
throw ProvisionError.writeFailed("Invalid UUID length")
|
|
||||||
}
|
|
||||||
|
|
||||||
if isLegacy {
|
|
||||||
try await writeLegacy(config, uuidBytes: uuidBytes)
|
|
||||||
} else {
|
|
||||||
try await writeBC04P(config, uuidBytes: uuidBytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func disconnect() {
|
|
||||||
if peripheral.state == .connected || peripheral.state == .connecting {
|
|
||||||
centralManager.cancelPeripheralConnection(peripheral)
|
|
||||||
}
|
|
||||||
isConnected = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - BC04P Write (3 fallback methods, matching Android)
|
|
||||||
|
|
||||||
private func writeBC04P(_ config: BeaconConfig, uuidBytes: [UInt8]) async throws {
|
|
||||||
let majorBytes = Data([UInt8(config.major >> 8), UInt8(config.major & 0xFF)])
|
|
||||||
let minorBytes = Data([UInt8(config.minor >> 8), UInt8(config.minor & 0xFF)])
|
|
||||||
let txPowerByte = config.txPower
|
|
||||||
let intervalUnits = UInt16(Double(config.advInterval) * 100.0 / 0.625)
|
|
||||||
let intervalBytes = Data([UInt8(intervalUnits >> 8), UInt8(intervalUnits & 0xFF)])
|
|
||||||
|
|
||||||
// Method 1: Write directly to FEA3 (config characteristic)
|
|
||||||
if let fea3 = configChar {
|
|
||||||
var iBeaconData = Data([0x01]) // iBeacon frame type
|
|
||||||
iBeaconData.append(contentsOf: uuidBytes)
|
|
||||||
iBeaconData.append(majorBytes)
|
|
||||||
iBeaconData.append(minorBytes)
|
|
||||||
iBeaconData.append(txPowerByte)
|
|
||||||
|
|
||||||
if let _ = try? await writeDirectAndWait(fea3, data: iBeaconData) {
|
|
||||||
try await Task.sleep(nanoseconds: 500_000_000)
|
|
||||||
return // Success
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method 2: Raw data write to FEA1
|
|
||||||
if let fea1 = writeChar {
|
|
||||||
var rawData = Data(uuidBytes)
|
|
||||||
rawData.append(majorBytes)
|
|
||||||
rawData.append(minorBytes)
|
|
||||||
rawData.append(txPowerByte)
|
|
||||||
rawData.append(intervalBytes)
|
|
||||||
|
|
||||||
if let _ = try? await writeDirectAndWait(fea1, data: rawData) {
|
|
||||||
try await Task.sleep(nanoseconds: 300_000_000)
|
|
||||||
|
|
||||||
// Send save/apply commands (matching Android)
|
|
||||||
let _ = try? await writeDirectAndWait(fea1, data: Data([0xFF])) // Save variant 1
|
|
||||||
try await Task.sleep(nanoseconds: 200_000_000)
|
|
||||||
let _ = try? await writeDirectAndWait(fea1, data: Data([0x00])) // Apply/commit
|
|
||||||
try await Task.sleep(nanoseconds: 200_000_000)
|
|
||||||
let _ = try? await writeDirectAndWait(fea1, data: Data([0x57])) // KBeacon save
|
|
||||||
try await Task.sleep(nanoseconds: 200_000_000)
|
|
||||||
let _ = try? await writeDirectAndWait(fea1, data: Data([0x01, 0x00])) // Enable slot 0
|
|
||||||
try await Task.sleep(nanoseconds: 500_000_000)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method 3: Indexed parameter writes to FEA1
|
|
||||||
if let fea1 = writeChar {
|
|
||||||
// Index 0 = UUID
|
|
||||||
let _ = try? await writeDirectAndWait(fea1, data: Data([0x00]) + Data(uuidBytes))
|
|
||||||
try await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
// Index 1 = Major
|
|
||||||
let _ = try? await writeDirectAndWait(fea1, data: Data([0x01]) + majorBytes)
|
|
||||||
try await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
// Index 2 = Minor
|
|
||||||
let _ = try? await writeDirectAndWait(fea1, data: Data([0x02]) + minorBytes)
|
|
||||||
try await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
// Index 3 = TxPower
|
|
||||||
let _ = try? await writeDirectAndWait(fea1, data: Data([0x03, txPowerByte]))
|
|
||||||
try await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
// Index 4 = Interval
|
|
||||||
let _ = try? await writeDirectAndWait(fea1, data: Data([0x04]) + intervalBytes)
|
|
||||||
try await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
|
|
||||||
// Save command
|
|
||||||
let _ = try? await writeDirectAndWait(fea1, data: Data([0xFF]))
|
|
||||||
try await Task.sleep(nanoseconds: 500_000_000)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
throw ProvisionError.writeFailed("No write characteristic available")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Legacy FFF0 Write
|
|
||||||
|
|
||||||
private func writeLegacy(_ config: BeaconConfig, uuidBytes: [UInt8]) async throws {
|
|
||||||
guard let service = discoveredService else {
|
|
||||||
throw ProvisionError.serviceNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try passwords
|
|
||||||
for password in Self.legacyPasswords {
|
|
||||||
if let char = service.characteristics?.first(where: { $0.uuid == Self.fff1Password }),
|
|
||||||
let data = password.data(using: .utf8) {
|
|
||||||
let _ = try? await writeDirectAndWait(char, data: data)
|
|
||||||
try await Task.sleep(nanoseconds: 200_000_000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write UUID
|
|
||||||
if let char = service.characteristics?.first(where: { $0.uuid == Self.fff2UUID }) {
|
|
||||||
let _ = try await writeDirectAndWait(char, data: Data(uuidBytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write Major
|
|
||||||
let majorBytes = Data([UInt8(config.major >> 8), UInt8(config.major & 0xFF)])
|
|
||||||
if let char = service.characteristics?.first(where: { $0.uuid == Self.fff3Major }) {
|
|
||||||
let _ = try await writeDirectAndWait(char, data: majorBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write Minor
|
|
||||||
let minorBytes = Data([UInt8(config.minor >> 8), UInt8(config.minor & 0xFF)])
|
|
||||||
if let char = service.characteristics?.first(where: { $0.uuid == Self.fff4Minor }) {
|
|
||||||
let _ = try await writeDirectAndWait(char, data: minorBytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Auth (BC04P)
|
|
||||||
|
|
||||||
private func authenticateBC04P() async throws {
|
|
||||||
guard let fea1 = writeChar else {
|
|
||||||
throw ProvisionError.characteristicNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enable notifications on FEA2 if available
|
|
||||||
if let fea2 = notifyChar {
|
|
||||||
peripheral.setNotifyValue(true, for: fea2)
|
|
||||||
try await Task.sleep(nanoseconds: 200_000_000)
|
|
||||||
}
|
|
||||||
|
|
||||||
// No explicit auth command needed for BC04P — the write methods
|
|
||||||
// handle auth implicitly. Android's BlueCharm provisioner also
|
|
||||||
// doesn't do a CMD_CONNECT auth for the FEA0 path.
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Helpers
|
|
||||||
|
|
||||||
private func connectOnce() async throws {
|
|
||||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
|
||||||
connectionContinuation = cont
|
|
||||||
centralManager.connect(peripheral, options: nil)
|
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.connectionTimeout) { [weak self] in
|
|
||||||
if let c = self?.connectionContinuation {
|
|
||||||
self?.connectionContinuation = nil
|
|
||||||
c.resume(throwing: ProvisionError.connectionTimeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func discoverServices() async throws {
|
|
||||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
|
||||||
serviceContinuation = cont
|
|
||||||
peripheral.discoverServices([GATTConstants.fea0Service, GATTConstants.fff0Service])
|
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in
|
|
||||||
if let c = self?.serviceContinuation {
|
|
||||||
self?.serviceContinuation = nil
|
|
||||||
c.resume(throwing: ProvisionError.serviceDiscoveryTimeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func writeDirectAndWait(_ char: CBCharacteristic, data: Data) async throws -> Void {
|
|
||||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
|
||||||
writeOKContinuation = cont
|
|
||||||
peripheral.writeValue(data, for: char, type: .withResponse)
|
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { [weak self] in
|
|
||||||
if let c = self?.writeOKContinuation {
|
|
||||||
self?.writeOKContinuation = nil
|
|
||||||
c.resume(throwing: ProvisionError.operationTimeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - CBPeripheralDelegate
|
|
||||||
|
|
||||||
extension BlueCharmProvisioner: CBPeripheralDelegate {
|
|
||||||
|
|
||||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
|
||||||
if let error {
|
|
||||||
serviceContinuation?.resume(throwing: error)
|
|
||||||
serviceContinuation = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prefer FEA0 (BC04P), fallback to FFF0 (legacy)
|
|
||||||
if let fea0Service = peripheral.services?.first(where: { $0.uuid == GATTConstants.fea0Service }) {
|
|
||||||
discoveredService = fea0Service
|
|
||||||
isLegacy = false
|
|
||||||
peripheral.discoverCharacteristics(nil, for: fea0Service)
|
|
||||||
} else if let fff0Service = peripheral.services?.first(where: { $0.uuid == GATTConstants.fff0Service }) {
|
|
||||||
discoveredService = fff0Service
|
|
||||||
isLegacy = true
|
|
||||||
peripheral.discoverCharacteristics(nil, for: fff0Service)
|
|
||||||
} else {
|
|
||||||
serviceContinuation?.resume(throwing: ProvisionError.serviceNotFound)
|
|
||||||
serviceContinuation = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
|
||||||
if let error {
|
|
||||||
serviceContinuation?.resume(throwing: error)
|
|
||||||
serviceContinuation = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if isLegacy {
|
|
||||||
// Legacy: just need the service with characteristics
|
|
||||||
serviceContinuation?.resume()
|
|
||||||
serviceContinuation = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// BC04P: map specific characteristics
|
|
||||||
for char in service.characteristics ?? [] {
|
|
||||||
switch char.uuid {
|
|
||||||
case Self.fea1Write:
|
|
||||||
writeChar = char
|
|
||||||
case Self.fea2Notify:
|
|
||||||
notifyChar = char
|
|
||||||
case Self.fea3Config:
|
|
||||||
configChar = char
|
|
||||||
default:
|
|
||||||
// Also grab any writable char as fallback
|
|
||||||
if writeChar == nil && (char.properties.contains(.write) || char.properties.contains(.writeWithoutResponse)) {
|
|
||||||
writeChar = char
|
|
||||||
}
|
|
||||||
if notifyChar == nil && char.properties.contains(.notify) {
|
|
||||||
notifyChar = char
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if writeChar != nil || configChar != nil {
|
|
||||||
serviceContinuation?.resume()
|
|
||||||
serviceContinuation = nil
|
|
||||||
} else {
|
|
||||||
serviceContinuation?.resume(throwing: ProvisionError.characteristicNotFound)
|
|
||||||
serviceContinuation = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
|
||||||
guard let data = characteristic.value else { return }
|
|
||||||
if let cont = writeContinuation {
|
|
||||||
writeContinuation = nil
|
|
||||||
cont.resume(returning: data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
|
|
||||||
if let cont = writeOKContinuation {
|
|
||||||
writeOKContinuation = nil
|
|
||||||
if let error {
|
|
||||||
cont.resume(throwing: error)
|
|
||||||
} else {
|
|
||||||
cont.resume()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if let error, let cont = writeContinuation {
|
|
||||||
writeContinuation = nil
|
|
||||||
cont.resume(throwing: ProvisionError.writeFailed(error.localizedDescription))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Data Extension
|
|
||||||
|
|
||||||
private extension Data {
|
|
||||||
/// Pad data to target length with zero bytes
|
|
||||||
func padded(to length: Int) -> Data {
|
|
||||||
if count >= length { return self }
|
|
||||||
var padded = self
|
|
||||||
padded.append(contentsOf: [UInt8](repeating: 0, count: length - count))
|
|
||||||
return padded
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -41,6 +41,9 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
|
||||||
private(set) var isConnected = false
|
private(set) var isConnected = false
|
||||||
private(set) var isFlashing = false // Beacon LED flashing after trigger
|
private(set) var isFlashing = false // Beacon LED flashing after trigger
|
||||||
private var useNewSDK = true // Prefer new SDK, fallback to old
|
private var useNewSDK = true // Prefer new SDK, fallback to old
|
||||||
|
private var disconnected = false // Set true when BLE link drops unexpectedly
|
||||||
|
var diagnosticLog: ProvisionLog?
|
||||||
|
var bleManager: BLEManager?
|
||||||
|
|
||||||
// MARK: - Init
|
// MARK: - Init
|
||||||
|
|
||||||
|
|
@ -53,20 +56,47 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
|
||||||
|
|
||||||
// MARK: - BeaconProvisioner
|
// MARK: - BeaconProvisioner
|
||||||
|
|
||||||
|
/// Status callback — provisioner reports what phase it's in so UI can update
|
||||||
|
var onStatusUpdate: ((String) -> Void)?
|
||||||
|
|
||||||
func connect() async throws {
|
func connect() async throws {
|
||||||
for attempt in 1...GATTConstants.maxRetries {
|
for attempt in 1...GATTConstants.maxRetries {
|
||||||
|
await diagnosticLog?.log("connect", "Attempt \(attempt)/\(GATTConstants.maxRetries) — peripheral: \(peripheral.name ?? "unnamed"), state: \(peripheral.state.rawValue)")
|
||||||
do {
|
do {
|
||||||
|
let attemptLabel = GATTConstants.maxRetries > 1 ? " (attempt \(attempt)/\(GATTConstants.maxRetries))" : ""
|
||||||
|
await MainActor.run { onStatusUpdate?("Connecting to beacon…\(attemptLabel)") }
|
||||||
|
await diagnosticLog?.log("connect", "Connecting (timeout: \(GATTConstants.connectionTimeout)s)…")
|
||||||
try await connectOnce()
|
try await connectOnce()
|
||||||
|
|
||||||
|
await MainActor.run { onStatusUpdate?("Discovering services…") }
|
||||||
|
await diagnosticLog?.log("connect", "Connected — discovering services…")
|
||||||
try await discoverServices()
|
try await discoverServices()
|
||||||
|
await diagnosticLog?.log("connect", "Services found — FFE1:\(ffe1Char != nil) FFE2:\(ffe2Char != nil) FFE3:\(ffe3Char != nil)")
|
||||||
|
|
||||||
|
await MainActor.run { onStatusUpdate?("Authenticating…") }
|
||||||
|
await diagnosticLog?.log("auth", "Authenticating (trigger + password)…")
|
||||||
try await authenticate()
|
try await authenticate()
|
||||||
|
await diagnosticLog?.log("auth", "Auth complete — SDK: \(useNewSDK ? "new (FFE2)" : "old (FFE1)")")
|
||||||
|
|
||||||
|
// Register for unexpected disconnects so we fail fast instead of
|
||||||
|
// waiting for per-command ACK timeouts (5s × 2 = 10s of dead air).
|
||||||
|
bleManager?.onPeripheralDisconnected = { [weak self] disconnectedPeripheral, error in
|
||||||
|
guard disconnectedPeripheral.identifier == self?.peripheral.identifier else { return }
|
||||||
|
self?.handleUnexpectedDisconnect(error: error)
|
||||||
|
}
|
||||||
|
|
||||||
isConnected = true
|
isConnected = true
|
||||||
isFlashing = true
|
isFlashing = true
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
|
await diagnosticLog?.log("connect", "Attempt \(attempt) failed: \(error.localizedDescription)", isError: true)
|
||||||
disconnect()
|
disconnect()
|
||||||
if attempt < GATTConstants.maxRetries {
|
if attempt < GATTConstants.maxRetries {
|
||||||
|
await MainActor.run { onStatusUpdate?("Retrying… (\(attempt)/\(GATTConstants.maxRetries))") }
|
||||||
|
await diagnosticLog?.log("connect", "Retrying in \(GATTConstants.retryDelay)s…")
|
||||||
try await Task.sleep(nanoseconds: UInt64(GATTConstants.retryDelay * 1_000_000_000))
|
try await Task.sleep(nanoseconds: UInt64(GATTConstants.retryDelay * 1_000_000_000))
|
||||||
} else {
|
} else {
|
||||||
|
await diagnosticLog?.log("connect", "All \(GATTConstants.maxRetries) attempts exhausted", isError: true)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -75,27 +105,37 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
|
||||||
|
|
||||||
func writeConfig(_ config: BeaconConfig) async throws {
|
func writeConfig(_ config: BeaconConfig) async throws {
|
||||||
guard isConnected else {
|
guard isConnected else {
|
||||||
|
await diagnosticLog?.log("write", "Not connected — aborting write", isError: true)
|
||||||
throw ProvisionError.notConnected
|
throw ProvisionError.notConnected
|
||||||
}
|
}
|
||||||
|
|
||||||
let uuidBytes = config.uuid.hexToBytes
|
let uuidBytes = config.uuid.hexToBytes
|
||||||
guard uuidBytes.count == 16 else {
|
guard uuidBytes.count == 16 else {
|
||||||
|
await diagnosticLog?.log("write", "Invalid UUID length: \(uuidBytes.count) bytes", isError: true)
|
||||||
throw ProvisionError.writeFailed("Invalid UUID length")
|
throw ProvisionError.writeFailed("Invalid UUID length")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await diagnosticLog?.log("write", "Config: UUID=\(config.uuid.prefix(8))… Major=\(config.major) Minor=\(config.minor) TxPower=\(config.txPower) AdvInt=\(config.advInterval)")
|
||||||
|
|
||||||
// Try new SDK first (FFE2), fall back to old SDK (FFE1)
|
// Try new SDK first (FFE2), fall back to old SDK (FFE1)
|
||||||
if useNewSDK, let ffe2 = ffe2Char {
|
if useNewSDK, let ffe2 = ffe2Char {
|
||||||
|
await diagnosticLog?.log("write", "Using new SDK (FFE2) — 22 commands to write")
|
||||||
try await writeConfigNewSDK(config, uuidBytes: uuidBytes, writeChar: ffe2)
|
try await writeConfigNewSDK(config, uuidBytes: uuidBytes, writeChar: ffe2)
|
||||||
} else if let ffe1 = ffe1Char {
|
} else if let ffe1 = ffe1Char {
|
||||||
|
await diagnosticLog?.log("write", "Using old SDK (FFE1) — 7 commands to write")
|
||||||
try await writeConfigOldSDK(config, uuidBytes: uuidBytes, writeChar: ffe1)
|
try await writeConfigOldSDK(config, uuidBytes: uuidBytes, writeChar: ffe1)
|
||||||
} else {
|
} else {
|
||||||
|
await diagnosticLog?.log("write", "No write characteristic available", isError: true)
|
||||||
throw ProvisionError.characteristicNotFound
|
throw ProvisionError.characteristicNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await diagnosticLog?.log("write", "All commands written successfully")
|
||||||
isFlashing = false
|
isFlashing = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func disconnect() {
|
func disconnect() {
|
||||||
|
// Unregister disconnect handler so intentional disconnect doesn't trigger error path
|
||||||
|
bleManager?.onPeripheralDisconnected = nil
|
||||||
if peripheral.state == .connected || peripheral.state == .connecting {
|
if peripheral.state == .connected || peripheral.state == .connecting {
|
||||||
centralManager.cancelPeripheralConnection(peripheral)
|
centralManager.cancelPeripheralConnection(peripheral)
|
||||||
}
|
}
|
||||||
|
|
@ -141,10 +181,49 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
|
||||||
("SaveConfig", buildProtocolPacket(cmd: 0x60, data: Data())),
|
("SaveConfig", buildProtocolPacket(cmd: 0x60, data: Data())),
|
||||||
]
|
]
|
||||||
|
|
||||||
for (name, packet) in commands {
|
for (index, (name, packet)) in commands.enumerated() {
|
||||||
|
// Bail immediately if BLE link dropped between commands
|
||||||
|
if disconnected {
|
||||||
|
await diagnosticLog?.log("write", "Aborting — BLE disconnected", isError: true)
|
||||||
|
throw ProvisionError.writeFailed("BLE disconnected during write sequence")
|
||||||
|
}
|
||||||
|
|
||||||
|
await diagnosticLog?.log("write", "[\(index + 1)/\(commands.count)] \(name) (\(packet.count) bytes)")
|
||||||
|
|
||||||
|
// SaveConfig (last command) causes beacon MCU to reboot — it never sends an ACK.
|
||||||
|
// Use .withoutResponse so CoreBluetooth fires the bytes immediately into the
|
||||||
|
// BLE radio buffer without waiting for a GATT round-trip. With .withResponse,
|
||||||
|
// the beacon reboots before the ACK arrives, and CoreBluetooth may silently
|
||||||
|
// drop the write — leaving the config unsaved and the beacon still flashing.
|
||||||
|
if name == "SaveConfig" {
|
||||||
|
peripheral.writeValue(packet, for: writeChar, type: .withoutResponse)
|
||||||
|
await diagnosticLog?.log("write", "✅ [\(index + 1)/\(commands.count)] SaveConfig sent — beacon will reboot")
|
||||||
|
await diagnosticLog?.log("write", "✅ All commands written successfully")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry each command up to 2 times — beacon BLE stack can be flaky
|
||||||
|
var lastError: Error?
|
||||||
|
for writeAttempt in 1...2 {
|
||||||
|
do {
|
||||||
try await writeToCharAndWaitACK(writeChar, data: packet, label: name)
|
try await writeToCharAndWaitACK(writeChar, data: packet, label: name)
|
||||||
// 200ms between commands (matches Android SDK timer interval)
|
lastError = nil
|
||||||
try await Task.sleep(nanoseconds: 200_000_000)
|
break
|
||||||
|
} catch {
|
||||||
|
lastError = error
|
||||||
|
if writeAttempt == 1 {
|
||||||
|
await diagnosticLog?.log("write", "[\(index + 1)/\(commands.count)] \(name) retry after: \(error.localizedDescription)")
|
||||||
|
try await Task.sleep(nanoseconds: 500_000_000) // 500ms before retry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let lastError {
|
||||||
|
await diagnosticLog?.log("write", "[\(index + 1)/\(commands.count)] \(name) FAILED: \(lastError.localizedDescription)", isError: true)
|
||||||
|
throw lastError
|
||||||
|
}
|
||||||
|
|
||||||
|
// 50ms between commands — beacon handles fast writes fine (was 150ms, 300ms, 500ms)
|
||||||
|
try await Task.sleep(nanoseconds: 50_000_000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -220,11 +299,54 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
|
||||||
return packet
|
return packet
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Disconnect Detection
|
||||||
|
|
||||||
|
/// Called when BLE link drops unexpectedly during provisioning.
|
||||||
|
/// Immediately resolves any pending continuations so we fail fast
|
||||||
|
/// instead of waiting for the 5s operationTimeout.
|
||||||
|
private func handleUnexpectedDisconnect(error: Error?) {
|
||||||
|
disconnected = true
|
||||||
|
isConnected = false
|
||||||
|
let disconnectError = ProvisionError.writeFailed("BLE disconnected unexpectedly: \(error?.localizedDescription ?? "unknown")")
|
||||||
|
Task { await diagnosticLog?.log("ble", "⚠️ Unexpected disconnect during provisioning", isError: true) }
|
||||||
|
|
||||||
|
// Cancel any pending write/response continuation immediately
|
||||||
|
if let cont = responseContinuation {
|
||||||
|
responseContinuation = nil
|
||||||
|
cont.resume(throwing: disconnectError)
|
||||||
|
}
|
||||||
|
if let cont = writeContinuation {
|
||||||
|
writeContinuation = nil
|
||||||
|
cont.resume(throwing: disconnectError)
|
||||||
|
}
|
||||||
|
if let cont = connectionContinuation {
|
||||||
|
connectionContinuation = nil
|
||||||
|
cont.resume(throwing: disconnectError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Private Helpers
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
private func connectOnce() async throws {
|
private func connectOnce() async throws {
|
||||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||||||
connectionContinuation = cont
|
connectionContinuation = cont
|
||||||
|
|
||||||
|
// Register for connection callbacks via BLEManager (the CBCentralManagerDelegate)
|
||||||
|
bleManager?.onPeripheralConnected = { [weak self] connectedPeripheral in
|
||||||
|
guard connectedPeripheral.identifier == self?.peripheral.identifier else { return }
|
||||||
|
if let c = self?.connectionContinuation {
|
||||||
|
self?.connectionContinuation = nil
|
||||||
|
c.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bleManager?.onPeripheralFailedToConnect = { [weak self] failedPeripheral, error in
|
||||||
|
guard failedPeripheral.identifier == self?.peripheral.identifier else { return }
|
||||||
|
if let c = self?.connectionContinuation {
|
||||||
|
self?.connectionContinuation = nil
|
||||||
|
c.resume(throwing: error ?? ProvisionError.connectionTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
centralManager.connect(peripheral, options: nil)
|
centralManager.connect(peripheral, options: nil)
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.connectionTimeout) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.connectionTimeout) { [weak self] in
|
||||||
|
|
@ -262,13 +384,13 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
|
||||||
// Step 1: Trigger — fire and forget (matches Android's WRITE_TYPE_NO_RESPONSE)
|
// Step 1: Trigger — fire and forget (matches Android's WRITE_TYPE_NO_RESPONSE)
|
||||||
if let triggerData = Self.triggerPassword.data(using: .utf8) {
|
if let triggerData = Self.triggerPassword.data(using: .utf8) {
|
||||||
peripheral.writeValue(triggerData, for: ffe3, type: .withoutResponse)
|
peripheral.writeValue(triggerData, for: ffe3, type: .withoutResponse)
|
||||||
try await Task.sleep(nanoseconds: 100_000_000) // 100ms (matches Android SDK timer)
|
try await Task.sleep(nanoseconds: 50_000_000) // 50ms settle
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Auth password — fire and forget
|
// Step 2: Auth password — fire and forget
|
||||||
if let authData = Self.defaultPassword.data(using: .utf8) {
|
if let authData = Self.defaultPassword.data(using: .utf8) {
|
||||||
peripheral.writeValue(authData, for: ffe3, type: .withoutResponse)
|
peripheral.writeValue(authData, for: ffe3, type: .withoutResponse)
|
||||||
try await Task.sleep(nanoseconds: 100_000_000) // 100ms settle
|
try await Task.sleep(nanoseconds: 50_000_000) // 50ms settle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -396,10 +518,18 @@ extension DXSmartProvisioner: CBPeripheralDelegate {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle write errors for command writes
|
// For command writes (FFE1/FFE2): the .withResponse write confirmation
|
||||||
if let error, let cont = responseContinuation {
|
// IS the ACK. Some commands (e.g. 0x61 Frame1_DevInfo) don't send a
|
||||||
|
// separate FFE1 notification, so we must resolve here on success too.
|
||||||
|
// If a notification also arrives later, responseContinuation will already
|
||||||
|
// be nil — harmless.
|
||||||
|
if let cont = responseContinuation {
|
||||||
responseContinuation = nil
|
responseContinuation = nil
|
||||||
|
if let error {
|
||||||
cont.resume(throwing: error)
|
cont.resume(throwing: error)
|
||||||
|
} else {
|
||||||
|
cont.resume(returning: Data())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import CoreBluetooth
|
|
||||||
|
|
||||||
/// Tries KBeacon → DXSmart → BlueCharm in sequence for unknown beacon types.
|
|
||||||
/// Matches Android's fallback behavior when beacon type can't be determined.
|
|
||||||
final class FallbackProvisioner: BeaconProvisioner {
|
|
||||||
|
|
||||||
private let peripheral: CBPeripheral
|
|
||||||
private let centralManager: CBCentralManager
|
|
||||||
private var activeProvisioner: (any BeaconProvisioner)?
|
|
||||||
|
|
||||||
private(set) var isConnected: Bool = false
|
|
||||||
|
|
||||||
init(peripheral: CBPeripheral, centralManager: CBCentralManager) {
|
|
||||||
self.peripheral = peripheral
|
|
||||||
self.centralManager = centralManager
|
|
||||||
}
|
|
||||||
|
|
||||||
func connect() async throws {
|
|
||||||
let provisioners: [() -> any BeaconProvisioner] = [
|
|
||||||
{ KBeaconProvisioner(peripheral: self.peripheral, centralManager: self.centralManager) },
|
|
||||||
{ DXSmartProvisioner(peripheral: self.peripheral, centralManager: self.centralManager) },
|
|
||||||
{ BlueCharmProvisioner(peripheral: self.peripheral, centralManager: self.centralManager) },
|
|
||||||
]
|
|
||||||
|
|
||||||
var lastError: Error = ProvisionError.connectionTimeout
|
|
||||||
|
|
||||||
for makeProvisioner in provisioners {
|
|
||||||
let provisioner = makeProvisioner()
|
|
||||||
do {
|
|
||||||
try await provisioner.connect()
|
|
||||||
activeProvisioner = provisioner
|
|
||||||
isConnected = true
|
|
||||||
return
|
|
||||||
} catch {
|
|
||||||
provisioner.disconnect()
|
|
||||||
lastError = error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw lastError
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeConfig(_ config: BeaconConfig) async throws {
|
|
||||||
guard let provisioner = activeProvisioner else {
|
|
||||||
throw ProvisionError.notConnected
|
|
||||||
}
|
|
||||||
try await provisioner.writeConfig(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
func disconnect() {
|
|
||||||
activeProvisioner?.disconnect()
|
|
||||||
activeProvisioner = nil
|
|
||||||
isConnected = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,262 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import CoreBluetooth
|
|
||||||
|
|
||||||
/// Provisioner for KBeacon / KBPro hardware
|
|
||||||
/// Protocol: FFE0 service, FFE1 write, FFE2 notify
|
|
||||||
/// Auth via CMD_AUTH (0x01), config via CMD_WRITE_PARAMS (0x03), save via CMD_SAVE (0x04)
|
|
||||||
final class KBeaconProvisioner: NSObject, BeaconProvisioner {
|
|
||||||
|
|
||||||
// MARK: - Protocol Commands
|
|
||||||
private enum CMD: UInt8 {
|
|
||||||
case auth = 0x01
|
|
||||||
case readParams = 0x02
|
|
||||||
case writeParams = 0x03
|
|
||||||
case save = 0x04
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Parameter IDs
|
|
||||||
private enum ParamID: UInt8 {
|
|
||||||
case uuid = 0x10
|
|
||||||
case major = 0x11
|
|
||||||
case minor = 0x12
|
|
||||||
case txPower = 0x13
|
|
||||||
case advInterval = 0x14
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Known passwords (tried in order, matching Android)
|
|
||||||
private static let passwords: [Data] = [
|
|
||||||
"kd1234".data(using: .utf8)!,
|
|
||||||
Data(repeating: 0, count: 16),
|
|
||||||
Data([0x31,0x32,0x33,0x34,0x35,0x36,0x37,0x38,0x39,0x30,0x31,0x32,0x33,0x34,0x35,0x36]),
|
|
||||||
"0000000000000000".data(using: .utf8)!,
|
|
||||||
"1234567890123456".data(using: .utf8)!
|
|
||||||
]
|
|
||||||
|
|
||||||
// MARK: - State
|
|
||||||
|
|
||||||
private let peripheral: CBPeripheral
|
|
||||||
private let centralManager: CBCentralManager
|
|
||||||
private var writeChar: CBCharacteristic?
|
|
||||||
private var notifyChar: CBCharacteristic?
|
|
||||||
|
|
||||||
private var connectionContinuation: CheckedContinuation<Void, Error>?
|
|
||||||
private var serviceContinuation: CheckedContinuation<Void, Error>?
|
|
||||||
private var writeContinuation: CheckedContinuation<Data, Error>?
|
|
||||||
|
|
||||||
private(set) var isConnected = false
|
|
||||||
|
|
||||||
// MARK: - Init
|
|
||||||
|
|
||||||
init(peripheral: CBPeripheral, centralManager: CBCentralManager) {
|
|
||||||
self.peripheral = peripheral
|
|
||||||
self.centralManager = centralManager
|
|
||||||
super.init()
|
|
||||||
self.peripheral.delegate = self
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - BeaconProvisioner
|
|
||||||
|
|
||||||
func connect() async throws {
|
|
||||||
// Connect with retry
|
|
||||||
for attempt in 1...GATTConstants.maxRetries {
|
|
||||||
do {
|
|
||||||
try await connectOnce()
|
|
||||||
try await discoverServices()
|
|
||||||
try await authenticate()
|
|
||||||
isConnected = true
|
|
||||||
return
|
|
||||||
} catch {
|
|
||||||
disconnect()
|
|
||||||
if attempt < GATTConstants.maxRetries {
|
|
||||||
try await Task.sleep(nanoseconds: UInt64(GATTConstants.retryDelay * 1_000_000_000))
|
|
||||||
} else {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeConfig(_ config: BeaconConfig) async throws {
|
|
||||||
guard isConnected, let writeChar else {
|
|
||||||
throw ProvisionError.notConnected
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build parameter payload
|
|
||||||
var params = Data()
|
|
||||||
|
|
||||||
// UUID (16 bytes)
|
|
||||||
params.append(ParamID.uuid.rawValue)
|
|
||||||
let uuidBytes = config.uuid.hexToBytes
|
|
||||||
params.append(contentsOf: uuidBytes)
|
|
||||||
|
|
||||||
// Major (2 bytes BE)
|
|
||||||
params.append(ParamID.major.rawValue)
|
|
||||||
params.append(UInt8(config.major >> 8))
|
|
||||||
params.append(UInt8(config.major & 0xFF))
|
|
||||||
|
|
||||||
// Minor (2 bytes BE)
|
|
||||||
params.append(ParamID.minor.rawValue)
|
|
||||||
params.append(UInt8(config.minor >> 8))
|
|
||||||
params.append(UInt8(config.minor & 0xFF))
|
|
||||||
|
|
||||||
// TX Power
|
|
||||||
params.append(ParamID.txPower.rawValue)
|
|
||||||
params.append(config.txPower)
|
|
||||||
|
|
||||||
// Adv Interval
|
|
||||||
params.append(ParamID.advInterval.rawValue)
|
|
||||||
params.append(UInt8(config.advInterval >> 8))
|
|
||||||
params.append(UInt8(config.advInterval & 0xFF))
|
|
||||||
|
|
||||||
// Send CMD_WRITE_PARAMS
|
|
||||||
let writeCmd = Data([CMD.writeParams.rawValue]) + params
|
|
||||||
let writeResp = try await sendCommand(writeCmd)
|
|
||||||
guard writeResp.first == CMD.writeParams.rawValue else {
|
|
||||||
throw ProvisionError.writeFailed("Unexpected write response")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send CMD_SAVE to flash
|
|
||||||
let saveResp = try await sendCommand(Data([CMD.save.rawValue]))
|
|
||||||
guard saveResp.first == CMD.save.rawValue else {
|
|
||||||
throw ProvisionError.saveFailed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func disconnect() {
|
|
||||||
if peripheral.state == .connected || peripheral.state == .connecting {
|
|
||||||
centralManager.cancelPeripheralConnection(peripheral)
|
|
||||||
}
|
|
||||||
isConnected = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private: Connection
|
|
||||||
|
|
||||||
private func connectOnce() async throws {
|
|
||||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
|
||||||
connectionContinuation = cont
|
|
||||||
centralManager.connect(peripheral, options: nil)
|
|
||||||
|
|
||||||
// Timeout
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.connectionTimeout) { [weak self] in
|
|
||||||
if let c = self?.connectionContinuation {
|
|
||||||
self?.connectionContinuation = nil
|
|
||||||
c.resume(throwing: ProvisionError.connectionTimeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func discoverServices() async throws {
|
|
||||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
|
||||||
serviceContinuation = cont
|
|
||||||
peripheral.discoverServices([GATTConstants.ffe0Service])
|
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in
|
|
||||||
if let c = self?.serviceContinuation {
|
|
||||||
self?.serviceContinuation = nil
|
|
||||||
c.resume(throwing: ProvisionError.serviceDiscoveryTimeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func authenticate() async throws {
|
|
||||||
for password in Self.passwords {
|
|
||||||
let cmd = Data([CMD.auth.rawValue]) + password
|
|
||||||
do {
|
|
||||||
let resp = try await sendCommand(cmd)
|
|
||||||
if resp.first == CMD.auth.rawValue && resp.count > 1 && resp[1] == 0x00 {
|
|
||||||
return // Auth success
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw ProvisionError.authFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sendCommand(_ data: Data) async throws -> Data {
|
|
||||||
guard let writeChar else { throw ProvisionError.notConnected }
|
|
||||||
|
|
||||||
return try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Data, Error>) in
|
|
||||||
writeContinuation = cont
|
|
||||||
peripheral.writeValue(data, for: writeChar, type: .withResponse)
|
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in
|
|
||||||
if let c = self?.writeContinuation {
|
|
||||||
self?.writeContinuation = nil
|
|
||||||
c.resume(throwing: ProvisionError.operationTimeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - CBPeripheralDelegate
|
|
||||||
|
|
||||||
extension KBeaconProvisioner: CBPeripheralDelegate {
|
|
||||||
|
|
||||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
|
||||||
if let error {
|
|
||||||
serviceContinuation?.resume(throwing: error)
|
|
||||||
serviceContinuation = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let service = peripheral.services?.first(where: { $0.uuid == GATTConstants.ffe0Service }) else {
|
|
||||||
serviceContinuation?.resume(throwing: ProvisionError.serviceNotFound)
|
|
||||||
serviceContinuation = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
peripheral.discoverCharacteristics([GATTConstants.ffe1Char, GATTConstants.ffe2Char], for: service)
|
|
||||||
}
|
|
||||||
|
|
||||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
|
||||||
if let error {
|
|
||||||
serviceContinuation?.resume(throwing: error)
|
|
||||||
serviceContinuation = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for char in service.characteristics ?? [] {
|
|
||||||
switch char.uuid {
|
|
||||||
case GATTConstants.ffe1Char:
|
|
||||||
writeChar = char
|
|
||||||
case GATTConstants.ffe2Char:
|
|
||||||
notifyChar = char
|
|
||||||
peripheral.setNotifyValue(true, for: char)
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if writeChar != nil {
|
|
||||||
serviceContinuation?.resume()
|
|
||||||
serviceContinuation = nil
|
|
||||||
} else {
|
|
||||||
serviceContinuation?.resume(throwing: ProvisionError.characteristicNotFound)
|
|
||||||
serviceContinuation = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
|
||||||
guard characteristic.uuid == GATTConstants.ffe2Char,
|
|
||||||
let data = characteristic.value else { return }
|
|
||||||
|
|
||||||
if let cont = writeContinuation {
|
|
||||||
writeContinuation = nil
|
|
||||||
cont.resume(returning: data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
|
|
||||||
// Write acknowledgment — actual response comes via notify on FFE2
|
|
||||||
if let error {
|
|
||||||
if let cont = writeContinuation {
|
|
||||||
writeContinuation = nil
|
|
||||||
cont.resume(throwing: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -14,25 +14,27 @@ protocol BeaconProvisioner {
|
||||||
|
|
||||||
/// Whether we're currently connected
|
/// Whether we're currently connected
|
||||||
var isConnected: Bool { get }
|
var isConnected: Bool { get }
|
||||||
|
|
||||||
|
/// Optional diagnostic log for tracing provisioning steps
|
||||||
|
var diagnosticLog: ProvisionLog? { get set }
|
||||||
|
|
||||||
|
/// BLE manager reference for connection callbacks
|
||||||
|
var bleManager: BLEManager? { get set }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GATT UUIDs shared across provisioner types
|
/// GATT UUIDs for CP-28 (DX-Smart) beacons
|
||||||
|
/// FFE0 service with FFE1 (notify), FFE2 (write), FFE3 (password)
|
||||||
enum GATTConstants {
|
enum GATTConstants {
|
||||||
// FFE0 service (KBeacon, DXSmart)
|
|
||||||
static let ffe0Service = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB")
|
static let ffe0Service = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB")
|
||||||
static let ffe1Char = CBUUID(string: "0000FFE1-0000-1000-8000-00805F9B34FB")
|
static let ffe1Char = CBUUID(string: "0000FFE1-0000-1000-8000-00805F9B34FB")
|
||||||
static let ffe2Char = CBUUID(string: "0000FFE2-0000-1000-8000-00805F9B34FB")
|
static let ffe2Char = CBUUID(string: "0000FFE2-0000-1000-8000-00805F9B34FB")
|
||||||
static let ffe3Char = CBUUID(string: "0000FFE3-0000-1000-8000-00805F9B34FB")
|
static let ffe3Char = CBUUID(string: "0000FFE3-0000-1000-8000-00805F9B34FB")
|
||||||
|
|
||||||
// FFF0 service (BlueCharm)
|
|
||||||
static let fff0Service = CBUUID(string: "0000FFF0-0000-1000-8000-00805F9B34FB")
|
|
||||||
static let fea0Service = CBUUID(string: "0000FEA0-0000-1000-8000-00805F9B34FB")
|
|
||||||
|
|
||||||
// CCCD for enabling notifications
|
// CCCD for enabling notifications
|
||||||
static let cccd = CBUUID(string: "00002902-0000-1000-8000-00805F9B34FB")
|
static let cccd = CBUUID(string: "00002902-0000-1000-8000-00805F9B34FB")
|
||||||
|
|
||||||
// Timeouts (matching Android)
|
// Timeouts
|
||||||
static let connectionTimeout: TimeInterval = 5.0
|
static let connectionTimeout: TimeInterval = 10.0
|
||||||
static let operationTimeout: TimeInterval = 5.0
|
static let operationTimeout: TimeInterval = 5.0
|
||||||
static let maxRetries = 3
|
static let maxRetries = 3
|
||||||
static let retryDelay: TimeInterval = 1.0
|
static let retryDelay: TimeInterval = 1.0
|
||||||
|
|
|
||||||
|
|
@ -36,109 +36,182 @@ actor APIClient {
|
||||||
|
|
||||||
// MARK: - Auth
|
// MARK: - Auth
|
||||||
|
|
||||||
struct OTPResponse: Codable {
|
/// Raw response from /auth/loginOTP.php
|
||||||
let uuid: String?
|
/// API returns: { "OK": true, "UUID": "...", "MESSAGE": "..." }
|
||||||
|
private struct OTPRawResponse: Codable {
|
||||||
|
let OK: Bool
|
||||||
let UUID: String?
|
let UUID: String?
|
||||||
var otpUUID: String { uuid ?? UUID ?? "" }
|
let MESSAGE: String?
|
||||||
|
let ERROR: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendOTP(phone: String) async throws -> String {
|
func sendOTP(phone: String) async throws -> String {
|
||||||
let body: [String: Any] = ["ContactNumber": phone]
|
let body: [String: Any] = ["Phone": phone]
|
||||||
let data = try await post(path: "/auth/loginOTP.php", body: body)
|
let data = try await post(path: "/auth/loginOTP.php", body: body)
|
||||||
let resp = try JSONDecoder().decode(APIResponse<OTPResponse>.self, from: data)
|
let resp = try JSONDecoder().decode(OTPRawResponse.self, from: data)
|
||||||
guard resp.success, let payload = resp.data else {
|
guard resp.OK, let uuid = resp.UUID, !uuid.isEmpty else {
|
||||||
throw APIError.serverError(resp.message ?? "Failed to send OTP")
|
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to send OTP")
|
||||||
}
|
}
|
||||||
return payload.otpUUID
|
return uuid
|
||||||
}
|
}
|
||||||
|
|
||||||
struct LoginResponse: Codable {
|
/// Raw response from /auth/verifyLoginOTP.php
|
||||||
let token: String?
|
/// API returns: { "OK": true, "UserID": 123, "Token": "...", "FirstName": "..." }
|
||||||
|
private struct VerifyOTPRawResponse: Codable {
|
||||||
|
let OK: Bool
|
||||||
let Token: String?
|
let Token: String?
|
||||||
let userId: String?
|
let UserID: IntOrString?
|
||||||
let UserID: String?
|
let MESSAGE: String?
|
||||||
var authToken: String { token ?? Token ?? "" }
|
let ERROR: String?
|
||||||
var authUserId: String { userId ?? UserID ?? "" }
|
}
|
||||||
|
|
||||||
|
/// Handles UserID coming as either int or string from API
|
||||||
|
enum IntOrString: Codable {
|
||||||
|
case int(Int)
|
||||||
|
case string(String)
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
if let i = try? container.decode(Int.self) {
|
||||||
|
self = .int(i)
|
||||||
|
} else if let s = try? container.decode(String.self) {
|
||||||
|
self = .string(s)
|
||||||
|
} else {
|
||||||
|
self = .string("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
switch self {
|
||||||
|
case .int(let i): try container.encode(i)
|
||||||
|
case .string(let s): try container.encode(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var stringValue: String {
|
||||||
|
switch self {
|
||||||
|
case .int(let i): return String(i)
|
||||||
|
case .string(let s): return s
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func verifyOTP(uuid: String, code: String) async throws -> (token: String, userId: String) {
|
func verifyOTP(uuid: String, code: String) async throws -> (token: String, userId: String) {
|
||||||
let body: [String: Any] = ["UUID": uuid, "Code": code]
|
let body: [String: Any] = ["UUID": uuid, "OTP": code]
|
||||||
let data = try await post(path: "/auth/verifyLoginOTP.php", body: body)
|
let data = try await post(path: "/auth/verifyLoginOTP.php", body: body)
|
||||||
let resp = try JSONDecoder().decode(APIResponse<LoginResponse>.self, from: data)
|
let resp = try JSONDecoder().decode(VerifyOTPRawResponse.self, from: data)
|
||||||
guard resp.success, let payload = resp.data else {
|
guard resp.OK, let token = resp.Token, !token.isEmpty else {
|
||||||
throw APIError.serverError(resp.message ?? "Invalid OTP")
|
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Invalid OTP")
|
||||||
}
|
}
|
||||||
return (payload.authToken, payload.authUserId)
|
let userId = resp.UserID?.stringValue ?? ""
|
||||||
|
return (token, userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Businesses
|
// MARK: - Businesses
|
||||||
|
|
||||||
|
/// API returns: { "OK": true, "BUSINESSES": [...], "Businesses": [...] }
|
||||||
|
private struct BusinessListResponse: Codable {
|
||||||
|
let OK: Bool
|
||||||
|
let ERROR: String?
|
||||||
|
let BUSINESSES: [Business]?
|
||||||
|
let Businesses: [Business]?
|
||||||
|
|
||||||
|
var businesses: [Business] { BUSINESSES ?? Businesses ?? [] }
|
||||||
|
}
|
||||||
|
|
||||||
func listBusinesses(token: String) async throws -> [Business] {
|
func listBusinesses(token: String) async throws -> [Business] {
|
||||||
let data = try await post(path: "/businesses/list.php", body: [:], token: token)
|
let data = try await post(path: "/businesses/list.php", body: [:], token: token)
|
||||||
let resp = try JSONDecoder().decode(APIResponse<[Business]>.self, from: data)
|
let resp = try JSONDecoder().decode(BusinessListResponse.self, from: data)
|
||||||
guard resp.success else {
|
guard resp.OK else {
|
||||||
throw APIError.serverError(resp.message ?? "Failed to load businesses")
|
throw APIError.serverError(resp.ERROR ?? "Failed to load businesses")
|
||||||
}
|
}
|
||||||
return resp.data ?? []
|
return resp.businesses
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Service Points
|
// MARK: - Service Points
|
||||||
|
|
||||||
|
/// API returns: { "OK": true, "SERVICEPOINTS": [...], "GRANTED_SERVICEPOINTS": [...] }
|
||||||
|
private struct ServicePointListResponse: Codable {
|
||||||
|
let OK: Bool
|
||||||
|
let ERROR: String?
|
||||||
|
let SERVICEPOINTS: [ServicePoint]?
|
||||||
|
}
|
||||||
|
|
||||||
func listServicePoints(businessId: String, token: String) async throws -> [ServicePoint] {
|
func listServicePoints(businessId: String, token: String) async throws -> [ServicePoint] {
|
||||||
let body: [String: Any] = ["BusinessID": businessId]
|
let body: [String: Any] = ["BusinessID": businessId]
|
||||||
let data = try await post(path: "/servicepoints/list.php", body: body, token: token, businessId: businessId)
|
let data = try await post(path: "/servicepoints/list.php", body: body, token: token, businessId: businessId)
|
||||||
let resp = try JSONDecoder().decode(APIResponse<[ServicePoint]>.self, from: data)
|
let resp = try JSONDecoder().decode(ServicePointListResponse.self, from: data)
|
||||||
guard resp.success else {
|
guard resp.OK else {
|
||||||
throw APIError.serverError(resp.message ?? "Failed to load service points")
|
throw APIError.serverError(resp.ERROR ?? "Failed to load service points")
|
||||||
}
|
}
|
||||||
return resp.data ?? []
|
return resp.SERVICEPOINTS ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API returns: { "OK": true, "SERVICEPOINT": { ... } }
|
||||||
|
private struct ServicePointSaveResponse: Codable {
|
||||||
|
let OK: Bool
|
||||||
|
let ERROR: String?
|
||||||
|
let SERVICEPOINT: ServicePoint?
|
||||||
}
|
}
|
||||||
|
|
||||||
func createServicePoint(name: String, businessId: String, token: String) async throws -> ServicePoint {
|
func createServicePoint(name: String, businessId: String, token: String) async throws -> ServicePoint {
|
||||||
let body: [String: Any] = ["Name": name, "BusinessID": businessId]
|
let body: [String: Any] = ["Name": name, "BusinessID": businessId]
|
||||||
let data = try await post(path: "/servicepoints/save.php", body: body, token: token, businessId: businessId)
|
let data = try await post(path: "/servicepoints/save.php", body: body, token: token, businessId: businessId)
|
||||||
let resp = try JSONDecoder().decode(APIResponse<ServicePoint>.self, from: data)
|
let resp = try JSONDecoder().decode(ServicePointSaveResponse.self, from: data)
|
||||||
guard resp.success, let sp = resp.data else {
|
guard resp.OK, let sp = resp.SERVICEPOINT else {
|
||||||
throw APIError.serverError(resp.message ?? "Failed to create service point")
|
throw APIError.serverError(resp.ERROR ?? "Failed to create service point")
|
||||||
}
|
}
|
||||||
return sp
|
return sp
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Beacon Sharding
|
// MARK: - Beacon Sharding
|
||||||
|
|
||||||
struct NamespaceResponse: Codable {
|
/// API returns: { "OK": true, "BeaconShardUUID": "...", "BeaconMajor": 5 }
|
||||||
let uuid: String?
|
private struct AllocateNamespaceResponse: Codable {
|
||||||
let UUID: String?
|
let OK: Bool
|
||||||
let major: Int?
|
let ERROR: String?
|
||||||
let Major: Int?
|
let MESSAGE: String?
|
||||||
var shardUUID: String { uuid ?? UUID ?? "" }
|
let BeaconShardUUID: String?
|
||||||
var shardMajor: Int { major ?? Major ?? 0 }
|
let BeaconMajor: Int?
|
||||||
}
|
}
|
||||||
|
|
||||||
func allocateBusinessNamespace(businessId: String, token: String) async throws -> (uuid: String, major: Int) {
|
func allocateBusinessNamespace(businessId: String, token: String) async throws -> (uuid: String, major: Int) {
|
||||||
let body: [String: Any] = ["BusinessID": businessId]
|
let body: [String: Any] = ["BusinessID": businessId]
|
||||||
let data = try await post(path: "/beacon-sharding/allocate_business_namespace.php", body: body, token: token, businessId: businessId)
|
let data = try await post(path: "/beacon-sharding/allocate_business_namespace.php", body: body, token: token, businessId: businessId)
|
||||||
let resp = try JSONDecoder().decode(APIResponse<NamespaceResponse>.self, from: data)
|
let resp = try JSONDecoder().decode(AllocateNamespaceResponse.self, from: data)
|
||||||
guard resp.success, let ns = resp.data else {
|
guard resp.OK else {
|
||||||
throw APIError.serverError(resp.message ?? "Failed to allocate namespace")
|
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to allocate namespace")
|
||||||
}
|
}
|
||||||
return (ns.shardUUID, ns.shardMajor)
|
return (resp.BeaconShardUUID ?? "", resp.BeaconMajor ?? 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MinorResponse: Codable {
|
/// API returns: { "OK": true, "BeaconMinor": 3 }
|
||||||
let minor: Int?
|
private struct AllocateMinorResponse: Codable {
|
||||||
let Minor: Int?
|
let OK: Bool
|
||||||
var allocated: Int { minor ?? Minor ?? 0 }
|
let ERROR: String?
|
||||||
|
let MESSAGE: String?
|
||||||
|
let BeaconMinor: Int?
|
||||||
}
|
}
|
||||||
|
|
||||||
func allocateMinor(businessId: String, servicePointId: String, token: String) async throws -> Int {
|
func allocateMinor(businessId: String, servicePointId: String, token: String) async throws -> Int {
|
||||||
let body: [String: Any] = ["BusinessID": businessId, "ServicePointID": servicePointId]
|
let body: [String: Any] = ["BusinessID": businessId, "ServicePointID": servicePointId]
|
||||||
let data = try await post(path: "/beacon-sharding/allocate_servicepoint_minor.php", body: body, token: token, businessId: businessId)
|
let data = try await post(path: "/beacon-sharding/allocate_servicepoint_minor.php", body: body, token: token, businessId: businessId)
|
||||||
let resp = try JSONDecoder().decode(APIResponse<MinorResponse>.self, from: data)
|
let resp = try JSONDecoder().decode(AllocateMinorResponse.self, from: data)
|
||||||
guard resp.success, let m = resp.data else {
|
guard resp.OK else {
|
||||||
throw APIError.serverError(resp.message ?? "Failed to allocate minor")
|
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to allocate minor")
|
||||||
}
|
}
|
||||||
return m.allocated
|
guard let minor = resp.BeaconMinor, minor >= 0 else {
|
||||||
|
throw APIError.serverError("API returned invalid minor value: \(resp.BeaconMinor.map(String.init) ?? "nil"). Service point may not be configured correctly.")
|
||||||
|
}
|
||||||
|
return minor
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API returns: { "OK": true, "BeaconHardwareID": 42, ... }
|
||||||
|
private struct OKResponse: Codable {
|
||||||
|
let OK: Bool
|
||||||
|
let ERROR: String?
|
||||||
|
let MESSAGE: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerBeaconHardware(
|
func registerBeaconHardware(
|
||||||
|
|
@ -147,7 +220,7 @@ actor APIClient {
|
||||||
uuid: String,
|
uuid: String,
|
||||||
major: Int,
|
major: Int,
|
||||||
minor: Int,
|
minor: Int,
|
||||||
macAddress: String?,
|
hardwareId: String,
|
||||||
beaconType: String,
|
beaconType: String,
|
||||||
token: String
|
token: String
|
||||||
) async throws {
|
) async throws {
|
||||||
|
|
@ -157,41 +230,43 @@ actor APIClient {
|
||||||
"UUID": uuid,
|
"UUID": uuid,
|
||||||
"Major": major,
|
"Major": major,
|
||||||
"Minor": minor,
|
"Minor": minor,
|
||||||
|
"HardwareId": hardwareId,
|
||||||
"BeaconType": beaconType
|
"BeaconType": beaconType
|
||||||
]
|
]
|
||||||
if let mac = macAddress { body["MacAddress"] = mac }
|
|
||||||
let data = try await post(path: "/beacon-sharding/register_beacon_hardware.php", body: body, token: token, businessId: businessId)
|
let data = try await post(path: "/beacon-sharding/register_beacon_hardware.php", body: body, token: token, businessId: businessId)
|
||||||
let resp = try JSONDecoder().decode(APIResponse<EmptyData>.self, from: data)
|
let resp = try JSONDecoder().decode(OKResponse.self, from: data)
|
||||||
guard resp.success else {
|
guard resp.OK else {
|
||||||
throw APIError.serverError(resp.message ?? "Failed to register beacon")
|
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to register beacon")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func verifyBeaconBroadcast(
|
func verifyBeaconBroadcast(
|
||||||
|
hardwareId: String,
|
||||||
uuid: String,
|
uuid: String,
|
||||||
major: Int,
|
major: Int,
|
||||||
minor: Int,
|
minor: Int,
|
||||||
token: String
|
token: String
|
||||||
) async throws {
|
) async throws {
|
||||||
let body: [String: Any] = ["UUID": uuid, "Major": major, "Minor": minor]
|
let body: [String: Any] = ["HardwareId": hardwareId, "UUID": uuid, "Major": major, "Minor": minor]
|
||||||
let data = try await post(path: "/beacon-sharding/verify_beacon_broadcast.php", body: body, token: token)
|
let data = try await post(path: "/beacon-sharding/verify_beacon_broadcast.php", body: body, token: token)
|
||||||
let resp = try JSONDecoder().decode(APIResponse<EmptyData>.self, from: data)
|
let resp = try JSONDecoder().decode(OKResponse.self, from: data)
|
||||||
guard resp.success else {
|
guard resp.OK else {
|
||||||
throw APIError.serverError(resp.message ?? "Failed to verify broadcast")
|
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to verify broadcast")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ResolveResponse: Codable {
|
/// API returns: { "OK": true, "BusinessName": "...", "BusinessID": 5 }
|
||||||
let businessName: String?
|
private struct ResolveBusinessResponse: Codable {
|
||||||
|
let OK: Bool
|
||||||
|
let ERROR: String?
|
||||||
let BusinessName: String?
|
let BusinessName: String?
|
||||||
var name: String { businessName ?? BusinessName ?? "Unknown" }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveBusiness(uuid: String, major: Int, token: String) async throws -> String {
|
func resolveBusiness(uuid: String, major: Int, token: String) async throws -> String {
|
||||||
let body: [String: Any] = ["UUID": uuid, "Major": major]
|
let body: [String: Any] = ["UUID": uuid, "Major": major]
|
||||||
let data = try await post(path: "/beacon-sharding/resolve_business.php", body: body, token: token)
|
let data = try await post(path: "/beacon-sharding/resolve_business.php", body: body, token: token)
|
||||||
let resp = try JSONDecoder().decode(APIResponse<ResolveResponse>.self, from: data)
|
let resp = try JSONDecoder().decode(ResolveBusinessResponse.self, from: data)
|
||||||
return resp.data?.name ?? "Unknown"
|
return resp.BusinessName ?? "Unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Service Point Management
|
// MARK: - Service Point Management
|
||||||
|
|
@ -199,24 +274,24 @@ actor APIClient {
|
||||||
func deleteServicePoint(servicePointId: String, businessId: String, token: String) async throws {
|
func deleteServicePoint(servicePointId: String, businessId: String, token: String) async throws {
|
||||||
let body: [String: Any] = ["ID": servicePointId, "BusinessID": businessId]
|
let body: [String: Any] = ["ID": servicePointId, "BusinessID": businessId]
|
||||||
let data = try await post(path: "/servicepoints/delete.php", body: body, token: token, businessId: businessId)
|
let data = try await post(path: "/servicepoints/delete.php", body: body, token: token, businessId: businessId)
|
||||||
let resp = try JSONDecoder().decode(APIResponse<EmptyData>.self, from: data)
|
let resp = try JSONDecoder().decode(OKResponse.self, from: data)
|
||||||
guard resp.success else {
|
guard resp.OK else {
|
||||||
throw APIError.serverError(resp.message ?? "Failed to delete service point")
|
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to delete service point")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateServicePoint(servicePointId: String, name: String, businessId: String, token: String) async throws {
|
func updateServicePoint(servicePointId: String, name: String, businessId: String, token: String) async throws {
|
||||||
let body: [String: Any] = ["ID": servicePointId, "Name": name, "BusinessID": businessId]
|
let body: [String: Any] = ["ID": servicePointId, "Name": name, "BusinessID": businessId]
|
||||||
let data = try await post(path: "/servicepoints/save.php", body: body, token: token, businessId: businessId)
|
let data = try await post(path: "/servicepoints/save.php", body: body, token: token, businessId: businessId)
|
||||||
let resp = try JSONDecoder().decode(APIResponse<EmptyData>.self, from: data)
|
let resp = try JSONDecoder().decode(OKResponse.self, from: data)
|
||||||
guard resp.success else {
|
guard resp.OK else {
|
||||||
throw APIError.serverError(resp.message ?? "Failed to update service point")
|
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to update service point")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Beacon Management
|
// MARK: - Beacon Management
|
||||||
|
|
||||||
struct BeaconListResponse: Codable {
|
struct BeaconListItem: Codable {
|
||||||
let id: String?
|
let id: String?
|
||||||
let ID: String?
|
let ID: String?
|
||||||
let uuid: String?
|
let uuid: String?
|
||||||
|
|
@ -235,125 +310,135 @@ actor APIClient {
|
||||||
let IsVerified: Bool?
|
let IsVerified: Bool?
|
||||||
}
|
}
|
||||||
|
|
||||||
func listBeacons(businessId: String, token: String) async throws -> [BeaconListResponse] {
|
/// API returns: { "OK": true, "BEACONS": [...] }
|
||||||
let body: [String: Any] = ["BusinessID": businessId]
|
private struct BeaconListAPIResponse: Codable {
|
||||||
let data = try await post(path: "/beacon-sharding/list_beacons.php", body: body, token: token, businessId: businessId)
|
let OK: Bool
|
||||||
let resp = try JSONDecoder().decode(APIResponse<[BeaconListResponse]>.self, from: data)
|
let ERROR: String?
|
||||||
guard resp.success else {
|
let BEACONS: [BeaconListItem]?
|
||||||
throw APIError.serverError(resp.message ?? "Failed to list beacons")
|
|
||||||
}
|
}
|
||||||
return resp.data ?? []
|
|
||||||
|
func listBeacons(businessId: String, token: String) async throws -> [BeaconListItem] {
|
||||||
|
let body: [String: Any] = ["BusinessID": businessId]
|
||||||
|
let data = try await post(path: "/beacons/list.php", body: body, token: token, businessId: businessId)
|
||||||
|
let resp = try JSONDecoder().decode(BeaconListAPIResponse.self, from: data)
|
||||||
|
guard resp.OK else {
|
||||||
|
throw APIError.serverError(resp.ERROR ?? "Failed to list beacons")
|
||||||
|
}
|
||||||
|
return resp.BEACONS ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
func decommissionBeacon(beaconId: String, businessId: String, token: String) async throws {
|
func decommissionBeacon(beaconId: String, businessId: String, token: String) async throws {
|
||||||
let body: [String: Any] = ["ID": beaconId, "BusinessID": businessId]
|
let body: [String: Any] = ["ID": beaconId, "BusinessID": businessId]
|
||||||
let data = try await post(path: "/beacon-sharding/decommission_beacon.php", body: body, token: token, businessId: businessId)
|
let data = try await post(path: "/beacons/wipe.php", body: body, token: token, businessId: businessId)
|
||||||
let resp = try JSONDecoder().decode(APIResponse<EmptyData>.self, from: data)
|
let resp = try JSONDecoder().decode(OKResponse.self, from: data)
|
||||||
guard resp.success else {
|
guard resp.OK else {
|
||||||
throw APIError.serverError(resp.message ?? "Failed to decommission beacon")
|
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to decommission beacon")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func lookupByMac(macAddress: String, token: String) async throws -> BeaconListResponse? {
|
func lookupByMac(macAddress: String, token: String) async throws -> BeaconListItem? {
|
||||||
let body: [String: Any] = ["MacAddress": macAddress]
|
let body: [String: Any] = ["MacAddress": macAddress]
|
||||||
let data = try await post(path: "/beacon-sharding/lookup_by_mac.php", body: body, token: token)
|
let data = try await post(path: "/beacons/lookupByMac.php", body: body, token: token)
|
||||||
let resp = try JSONDecoder().decode(APIResponse<BeaconListResponse>.self, from: data)
|
// This may return a single beacon object or OK: false
|
||||||
return resp.data
|
struct LookupResponse: Codable {
|
||||||
|
let OK: Bool
|
||||||
|
let ID: String?
|
||||||
|
let UUID: String?
|
||||||
|
let Major: Int?
|
||||||
|
let Minor: Int?
|
||||||
|
let MacAddress: String?
|
||||||
|
let BeaconType: String?
|
||||||
|
let ServicePointID: String?
|
||||||
|
let IsVerified: Bool?
|
||||||
|
}
|
||||||
|
let resp = try JSONDecoder().decode(LookupResponse.self, from: data)
|
||||||
|
guard resp.OK, resp.ID != nil else { return nil }
|
||||||
|
return BeaconListItem(
|
||||||
|
id: resp.ID, ID: resp.ID,
|
||||||
|
uuid: resp.UUID, UUID: resp.UUID,
|
||||||
|
major: resp.Major, Major: resp.Major,
|
||||||
|
minor: resp.Minor, Minor: resp.Minor,
|
||||||
|
macAddress: resp.MacAddress, MacAddress: resp.MacAddress,
|
||||||
|
beaconType: resp.BeaconType, BeaconType: resp.BeaconType,
|
||||||
|
servicePointId: resp.ServicePointID, ServicePointID: resp.ServicePointID,
|
||||||
|
isVerified: resp.IsVerified, IsVerified: resp.IsVerified
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getBeaconStatus(uuid: String, major: Int, minor: Int, token: String) async throws -> BeaconListResponse? {
|
func getBeaconStatus(uuid: String, major: Int, minor: Int, token: String) async throws -> BeaconListItem? {
|
||||||
let body: [String: Any] = ["UUID": uuid, "Major": major, "Minor": minor]
|
let body: [String: Any] = ["UUID": uuid, "Major": major, "Minor": minor]
|
||||||
let data = try await post(path: "/beacon-sharding/beacon_status.php", body: body, token: token)
|
let data = try await post(path: "/beacon-sharding/verify_beacon_broadcast.php", body: body, token: token)
|
||||||
let resp = try JSONDecoder().decode(APIResponse<BeaconListResponse>.self, from: data)
|
let resp = try JSONDecoder().decode(OKResponse.self, from: data)
|
||||||
return resp.data
|
guard resp.OK else { return nil }
|
||||||
|
// The verify endpoint confirms status but doesn't return full beacon details
|
||||||
|
return BeaconListItem(
|
||||||
|
id: nil, ID: nil,
|
||||||
|
uuid: uuid, UUID: uuid,
|
||||||
|
major: major, Major: major,
|
||||||
|
minor: minor, Minor: minor,
|
||||||
|
macAddress: nil, MacAddress: nil,
|
||||||
|
beaconType: nil, BeaconType: nil,
|
||||||
|
servicePointId: nil, ServicePointID: nil,
|
||||||
|
isVerified: true, IsVerified: true
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Beacon Config (server-configured values)
|
// MARK: - Beacon Config (server-configured values)
|
||||||
|
|
||||||
|
/// API returns: { "OK": true, "UUID": "...", "Major": 5, "Minor": 3, ... }
|
||||||
struct BeaconConfigResponse: Codable {
|
struct BeaconConfigResponse: Codable {
|
||||||
let uuid: String?
|
let OK: Bool
|
||||||
|
let ERROR: String?
|
||||||
|
let MESSAGE: String?
|
||||||
let UUID: String?
|
let UUID: String?
|
||||||
let major: Int?
|
|
||||||
let Major: Int?
|
let Major: Int?
|
||||||
let minor: Int?
|
|
||||||
let Minor: Int?
|
let Minor: Int?
|
||||||
let txPower: Int?
|
|
||||||
let TxPower: Int?
|
let TxPower: Int?
|
||||||
let measuredPower: Int?
|
|
||||||
let MeasuredPower: Int?
|
let MeasuredPower: Int?
|
||||||
let advInterval: Int?
|
|
||||||
let AdvInterval: Int?
|
let AdvInterval: Int?
|
||||||
|
|
||||||
var configUUID: String { uuid ?? UUID ?? "" }
|
var configUUID: String { UUID ?? "" }
|
||||||
var configMajor: Int { major ?? Major ?? 0 }
|
var configMajor: Int { Major ?? 0 }
|
||||||
var configMinor: Int { minor ?? Minor ?? 0 }
|
var configMinor: Int { Minor ?? 0 }
|
||||||
var configTxPower: Int { txPower ?? TxPower ?? 1 }
|
var configTxPower: Int { TxPower ?? 1 }
|
||||||
var configMeasuredPower: Int { measuredPower ?? MeasuredPower ?? -100 }
|
var configMeasuredPower: Int { MeasuredPower ?? -100 }
|
||||||
var configAdvInterval: Int { advInterval ?? AdvInterval ?? 2 }
|
var configAdvInterval: Int { AdvInterval ?? 2 }
|
||||||
}
|
}
|
||||||
|
|
||||||
func getBeaconConfig(businessId: String, servicePointId: String, token: String) async throws -> BeaconConfigResponse {
|
func getBeaconConfig(businessId: String, servicePointId: String, token: String) async throws -> BeaconConfigResponse {
|
||||||
let body: [String: Any] = ["BusinessID": businessId, "ServicePointID": servicePointId]
|
let body: [String: Any] = ["BusinessID": businessId, "ServicePointID": servicePointId]
|
||||||
let data = try await post(path: "/beacon-sharding/get_beacon_config.php", body: body, token: token, businessId: businessId)
|
let data = try await post(path: "/beacon-sharding/get_beacon_config.php", body: body, token: token, businessId: businessId)
|
||||||
let resp = try JSONDecoder().decode(APIResponse<BeaconConfigResponse>.self, from: data)
|
let resp = try JSONDecoder().decode(BeaconConfigResponse.self, from: data)
|
||||||
guard resp.success, let config = resp.data else {
|
guard resp.OK else {
|
||||||
throw APIError.serverError(resp.message ?? "Failed to get beacon config")
|
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to get beacon config")
|
||||||
}
|
}
|
||||||
return config
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - User Profile
|
// MARK: - User Profile
|
||||||
|
|
||||||
|
/// Note: /users/profile.php endpoint may not exist on server.
|
||||||
|
/// Using a flat response decoder matching the standard API format.
|
||||||
struct UserProfile: Codable {
|
struct UserProfile: Codable {
|
||||||
let id: String?
|
let OK: Bool?
|
||||||
let ID: String?
|
let ID: IntOrString?
|
||||||
let firstName: String?
|
|
||||||
let FirstName: String?
|
let FirstName: String?
|
||||||
let lastName: String?
|
|
||||||
let LastName: String?
|
let LastName: String?
|
||||||
let contactNumber: String?
|
|
||||||
let ContactNumber: String?
|
let ContactNumber: String?
|
||||||
|
|
||||||
|
var userId: String { ID?.stringValue ?? "" }
|
||||||
|
var firstName: String { FirstName ?? "" }
|
||||||
|
var lastName: String { LastName ?? "" }
|
||||||
}
|
}
|
||||||
|
|
||||||
func getProfile(token: String) async throws -> UserProfile {
|
func getProfile(token: String) async throws -> UserProfile {
|
||||||
let data = try await post(path: "/users/profile.php", body: [:], token: token)
|
let data = try await post(path: "/users/profile.php", body: [:], token: token)
|
||||||
let resp = try JSONDecoder().decode(APIResponse<UserProfile>.self, from: data)
|
let resp = try JSONDecoder().decode(UserProfile.self, from: data)
|
||||||
guard resp.success, let profile = resp.data else {
|
return resp
|
||||||
throw APIError.serverError(resp.message ?? "Failed to load profile")
|
|
||||||
}
|
|
||||||
return profile
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Internal
|
// MARK: - Internal
|
||||||
|
|
||||||
private struct EmptyData: Codable {}
|
|
||||||
|
|
||||||
private struct APIResponse<T: Codable>: Codable {
|
|
||||||
let success: Bool
|
|
||||||
let message: String?
|
|
||||||
let data: T?
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case success = "Success"
|
|
||||||
case message = "Message"
|
|
||||||
case data = "Data"
|
|
||||||
}
|
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
// Handle both bool and int/string for Success
|
|
||||||
if let b = try? container.decode(Bool.self, forKey: .success) {
|
|
||||||
success = b
|
|
||||||
} else if let i = try? container.decode(Int.self, forKey: .success) {
|
|
||||||
success = i != 0
|
|
||||||
} else {
|
|
||||||
success = false
|
|
||||||
}
|
|
||||||
message = try? container.decode(String.self, forKey: .message)
|
|
||||||
data = try? container.decode(T.self, forKey: .data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func post(
|
private func post(
|
||||||
path: String,
|
path: String,
|
||||||
body: [String: Any],
|
body: [String: Any],
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,7 @@ import Foundation
|
||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
/// Central BLE manager — handles scanning and beacon type detection
|
/// Central BLE manager — handles scanning and CP-28 beacon detection
|
||||||
/// Matches Android's BeaconScanner.kt behavior
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class BLEManager: NSObject, ObservableObject {
|
final class BLEManager: NSObject, ObservableObject {
|
||||||
|
|
||||||
|
|
@ -13,15 +12,23 @@ final class BLEManager: NSObject, ObservableObject {
|
||||||
@Published var discoveredBeacons: [DiscoveredBeacon] = []
|
@Published var discoveredBeacons: [DiscoveredBeacon] = []
|
||||||
@Published var bluetoothState: CBManagerState = .unknown
|
@Published var bluetoothState: CBManagerState = .unknown
|
||||||
|
|
||||||
// MARK: - Constants (matching Android)
|
// MARK: - Constants
|
||||||
|
|
||||||
static let scanDuration: TimeInterval = 5.0
|
static let scanDuration: TimeInterval = 5.0
|
||||||
static let verifyScanDuration: TimeInterval = 15.0
|
static let verifyScanDuration: TimeInterval = 15.0
|
||||||
static let verifyPollInterval: TimeInterval = 0.5
|
static let verifyPollInterval: TimeInterval = 0.5
|
||||||
|
|
||||||
// GATT Service UUIDs
|
// CP-28 uses FFE0 service
|
||||||
static let ffe0Service = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB")
|
static let ffe0Service = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB")
|
||||||
static let fff0Service = CBUUID(string: "0000FFF0-0000-1000-8000-00805F9B34FB")
|
|
||||||
|
// DX-Smart factory default iBeacon UUID
|
||||||
|
static let dxSmartDefaultUUID = "E2C56DB5-DFFB-48D2-B060-D0F5A71096E0"
|
||||||
|
|
||||||
|
// MARK: - Connection Callbacks (used by provisioners)
|
||||||
|
|
||||||
|
var onPeripheralConnected: ((CBPeripheral) -> Void)?
|
||||||
|
var onPeripheralFailedToConnect: ((CBPeripheral, Error?) -> Void)?
|
||||||
|
var onPeripheralDisconnected: ((CBPeripheral, Error?) -> Void)?
|
||||||
|
|
||||||
// MARK: - Private
|
// MARK: - Private
|
||||||
|
|
||||||
|
|
@ -53,7 +60,7 @@ final class BLEManager: NSObject, ObservableObject {
|
||||||
])
|
])
|
||||||
|
|
||||||
scanTimer = Timer.scheduledTimer(withTimeInterval: duration, repeats: false) { [weak self] _ in
|
scanTimer = Timer.scheduledTimer(withTimeInterval: duration, repeats: false) { [weak self] _ in
|
||||||
Task { @MainActor in
|
DispatchQueue.main.async {
|
||||||
self?.stopScan()
|
self?.stopScan()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -76,11 +83,8 @@ final class BLEManager: NSObject, ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify a beacon is broadcasting expected iBeacon values.
|
/// Verify a beacon is broadcasting expected iBeacon values.
|
||||||
/// Scans for up to `verifyScanDuration` looking for matching UUID/Major/Minor.
|
|
||||||
func verifyBroadcast(uuid: String, major: UInt16, minor: UInt16) async -> VerifyResult {
|
func verifyBroadcast(uuid: String, major: UInt16, minor: UInt16) async -> VerifyResult {
|
||||||
// TODO: Implement iBeacon region monitoring via CLLocationManager
|
// TODO: Implement iBeacon region monitoring via CLLocationManager
|
||||||
// CoreLocation's CLBeaconRegion is the iOS-native way to detect iBeacon broadcasts
|
|
||||||
// For now, return a placeholder that prompts manual verification
|
|
||||||
return VerifyResult(
|
return VerifyResult(
|
||||||
found: false,
|
found: false,
|
||||||
rssi: nil,
|
rssi: nil,
|
||||||
|
|
@ -88,70 +92,94 @@ final class BLEManager: NSObject, ObservableObject {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Beacon Type Detection (matches Android BeaconScanner.kt)
|
// MARK: - iBeacon Manufacturer Data Parsing
|
||||||
// NOTE: Android also detects DX-Smart by MAC OUI prefix 48:87:2D.
|
|
||||||
// CoreBluetooth does not expose raw MAC addresses, so this detection
|
/// Parse iBeacon data from manufacturer data (Apple company ID 0x4C00)
|
||||||
// path is unavailable on iOS. We rely on service UUID + device name instead.
|
private func parseIBeaconData(_ mfgData: Data) -> (uuid: String, major: UInt16, minor: UInt16)? {
|
||||||
|
guard mfgData.count >= 25 else { return nil }
|
||||||
|
guard mfgData[0] == 0x4C && mfgData[1] == 0x00 &&
|
||||||
|
mfgData[2] == 0x02 && mfgData[3] == 0x15 else { return nil }
|
||||||
|
|
||||||
|
let uuidBytes = mfgData.subdata(in: 4..<20)
|
||||||
|
let hex = uuidBytes.map { String(format: "%02X", $0) }.joined()
|
||||||
|
let uuid = "\(hex.prefix(8))-\(hex.dropFirst(8).prefix(4))-\(hex.dropFirst(12).prefix(4))-\(hex.dropFirst(16).prefix(4))-\(hex.dropFirst(20))"
|
||||||
|
|
||||||
|
let major = UInt16(mfgData[20]) << 8 | UInt16(mfgData[21])
|
||||||
|
let minor = UInt16(mfgData[22]) << 8 | UInt16(mfgData[23])
|
||||||
|
|
||||||
|
return (uuid: uuid, major: major, minor: minor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CP-28 Detection
|
||||||
|
// Only detect DX-Smart / CP-28 beacons. Everything else is ignored.
|
||||||
|
|
||||||
func detectBeaconType(
|
func detectBeaconType(
|
||||||
name: String?,
|
name: String?,
|
||||||
serviceUUIDs: [CBUUID]?,
|
serviceUUIDs: [CBUUID]?,
|
||||||
manufacturerData: Data?
|
manufacturerData: Data?
|
||||||
) -> BeaconType {
|
) -> BeaconType? {
|
||||||
let deviceName = (name ?? "").lowercased()
|
let deviceName = (name ?? "").lowercased()
|
||||||
|
|
||||||
// 1. Service UUID matching
|
// Parse iBeacon data if available
|
||||||
|
let iBeaconData: (uuid: String, major: UInt16, minor: UInt16)?
|
||||||
|
if let mfgData = manufacturerData {
|
||||||
|
iBeaconData = parseIBeaconData(mfgData)
|
||||||
|
} else {
|
||||||
|
iBeaconData = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Service UUID: CP-28 uses FFE0
|
||||||
if let services = serviceUUIDs {
|
if let services = serviceUUIDs {
|
||||||
let serviceStrings = services.map { $0.uuidString.uppercased() }
|
let serviceStrings = services.map { $0.uuidString.uppercased() }
|
||||||
if serviceStrings.contains(where: { $0.hasPrefix("0000FFF0") }) {
|
|
||||||
return .bluecharm
|
|
||||||
}
|
|
||||||
if serviceStrings.contains(where: { $0.hasPrefix("0000FFE0") }) {
|
if serviceStrings.contains(where: { $0.hasPrefix("0000FFE0") }) {
|
||||||
// Could be KBeacon or DXSmart — check name to differentiate
|
// FFE0 with DX name patterns → definitely CP-28
|
||||||
if deviceName.contains("cp28") || deviceName.contains("cp-28") ||
|
if deviceName.contains("cp28") || deviceName.contains("cp-28") ||
|
||||||
deviceName.contains("dx") || deviceName.contains("pddaxlque") {
|
deviceName.contains("dx") || deviceName.contains("pddaxlque") {
|
||||||
return .dxsmart
|
return .dxsmart
|
||||||
}
|
}
|
||||||
return .kbeacon
|
// FFE0 without a specific name — still likely CP-28
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Device name patterns
|
|
||||||
if deviceName.contains("kbeacon") || deviceName.contains("kbpro") ||
|
|
||||||
deviceName.hasPrefix("kb") {
|
|
||||||
return .kbeacon
|
|
||||||
}
|
|
||||||
if deviceName.contains("bluecharm") || deviceName.hasPrefix("bc") ||
|
|
||||||
deviceName.hasPrefix("table-") {
|
|
||||||
return .bluecharm
|
|
||||||
}
|
|
||||||
if deviceName.contains("cp28") || deviceName.contains("cp-28") ||
|
|
||||||
deviceName.contains("dx-smart") || deviceName.contains("dxsmart") ||
|
|
||||||
deviceName.contains("pddaxlque") {
|
|
||||||
return .dxsmart
|
return .dxsmart
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Generic beacon patterns
|
// CP-28 also advertises FFF0 on some firmware
|
||||||
if deviceName.contains("ibeacon") || deviceName.contains("beacon") ||
|
if serviceStrings.contains(where: { $0.hasPrefix("0000FFF0") }) {
|
||||||
deviceName.hasPrefix("ble") {
|
// Any FFF0 device is likely CP-28 — don't filter by name
|
||||||
return .dxsmart // Default to DXSmart like Android
|
return .dxsmart
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Check manufacturer data for iBeacon advertisement
|
|
||||||
if let mfgData = manufacturerData, mfgData.count >= 23 {
|
|
||||||
// Apple iBeacon prefix: 0x4C00 0215
|
|
||||||
if mfgData.count >= 4 && mfgData[0] == 0x4C && mfgData[1] == 0x00 &&
|
|
||||||
mfgData[2] == 0x02 && mfgData[3] == 0x15 {
|
|
||||||
// Extract minor (bytes 22-23) — high minors suggest DXSmart factory defaults
|
|
||||||
if mfgData.count >= 24 {
|
|
||||||
let minorVal = UInt16(mfgData[22]) << 8 | UInt16(mfgData[23])
|
|
||||||
if minorVal > 10000 { return .dxsmart }
|
|
||||||
}
|
|
||||||
return .kbeacon
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return .unknown
|
// 2. DX-Smart factory default iBeacon UUID
|
||||||
|
if let ibeacon = iBeaconData {
|
||||||
|
if ibeacon.uuid.caseInsensitiveCompare(Self.dxSmartDefaultUUID) == .orderedSame {
|
||||||
|
return .dxsmart
|
||||||
|
}
|
||||||
|
// Already provisioned with a Payfrit shard UUID
|
||||||
|
if BeaconShardPool.isPayfrit(ibeacon.uuid) {
|
||||||
|
return .dxsmart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Device name patterns for CP-28 (includes "payfrit" — our own provisioned name)
|
||||||
|
if deviceName.contains("cp28") || deviceName.contains("cp-28") ||
|
||||||
|
deviceName.contains("dx-cp") || deviceName.contains("dx-smart") ||
|
||||||
|
deviceName.contains("dxsmart") || deviceName.contains("pddaxlque") ||
|
||||||
|
deviceName.contains("payfrit") {
|
||||||
|
return .dxsmart
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. iBeacon minor in high range (factory default DX pattern)
|
||||||
|
if let ibeacon = iBeaconData, ibeacon.minor > 10000 {
|
||||||
|
return .dxsmart
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Any iBeacon advertisement — likely a CP-28 in the field
|
||||||
|
if iBeaconData != nil {
|
||||||
|
return .dxsmart
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not a CP-28 — don't show it
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -160,8 +188,27 @@ final class BLEManager: NSObject, ObservableObject {
|
||||||
extension BLEManager: CBCentralManagerDelegate {
|
extension BLEManager: CBCentralManagerDelegate {
|
||||||
|
|
||||||
nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||||
Task { @MainActor in
|
let state = central.state
|
||||||
bluetoothState = central.state
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.bluetoothState = state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.onPeripheralConnected?(peripheral)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.onPeripheralFailedToConnect?(peripheral, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.onPeripheralDisconnected?(peripheral, error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -171,32 +218,34 @@ extension BLEManager: CBCentralManagerDelegate {
|
||||||
advertisementData: [String: Any],
|
advertisementData: [String: Any],
|
||||||
rssi RSSI: NSNumber
|
rssi RSSI: NSNumber
|
||||||
) {
|
) {
|
||||||
Task { @MainActor in
|
|
||||||
let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? ""
|
let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? ""
|
||||||
let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID]
|
let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID]
|
||||||
let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
|
let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
|
||||||
|
let peripheralId = peripheral.identifier
|
||||||
|
let rssiValue = RSSI.intValue
|
||||||
|
|
||||||
let type = detectBeaconType(name: name, serviceUUIDs: serviceUUIDs, manufacturerData: mfgData)
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
// Only show recognized beacons
|
// Detect beacon type — default to .dxsmart so ALL devices show up in scan
|
||||||
guard type != .unknown else { return }
|
let type = self.detectBeaconType(name: name, serviceUUIDs: serviceUUIDs, manufacturerData: mfgData) ?? .dxsmart
|
||||||
|
|
||||||
if let idx = discoveredBeacons.firstIndex(where: { $0.id == peripheral.identifier }) {
|
if let idx = self.discoveredBeacons.firstIndex(where: { $0.id == peripheralId }) {
|
||||||
// Update existing
|
self.discoveredBeacons[idx].rssi = rssiValue
|
||||||
discoveredBeacons[idx].rssi = RSSI.intValue
|
self.discoveredBeacons[idx].lastSeen = Date()
|
||||||
discoveredBeacons[idx].lastSeen = Date()
|
|
||||||
} else {
|
} else {
|
||||||
// New beacon
|
|
||||||
let beacon = DiscoveredBeacon(
|
let beacon = DiscoveredBeacon(
|
||||||
id: peripheral.identifier,
|
id: peripheralId,
|
||||||
peripheral: peripheral,
|
peripheral: peripheral,
|
||||||
name: name,
|
name: name,
|
||||||
type: type,
|
type: type,
|
||||||
rssi: RSSI.intValue,
|
rssi: rssiValue,
|
||||||
lastSeen: Date()
|
lastSeen: Date()
|
||||||
)
|
)
|
||||||
discoveredBeacons.append(beacon)
|
self.discoveredBeacons.append(beacon)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.discoveredBeacons.sort { $0.rssi > $1.rssi }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
56
PayfritBeacon/Services/ProvisionLog.swift
Normal file
56
PayfritBeacon/Services/ProvisionLog.swift
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Timestamped diagnostic log for beacon provisioning.
|
||||||
|
/// Captures every step so we can diagnose failures.
|
||||||
|
@MainActor
|
||||||
|
final class ProvisionLog: ObservableObject {
|
||||||
|
struct Entry: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let timestamp: Date
|
||||||
|
let phase: String // "connect", "discover", "auth", "write", "verify"
|
||||||
|
let message: String
|
||||||
|
let isError: Bool
|
||||||
|
|
||||||
|
var formatted: String {
|
||||||
|
let t = Self.formatter.string(from: timestamp)
|
||||||
|
let prefix = isError ? "❌" : "✅"
|
||||||
|
return "\(t) [\(phase)] \(prefix) \(message)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let formatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "HH:mm:ss.SSS"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published private(set) var entries: [Entry] = []
|
||||||
|
private var startTime: Date?
|
||||||
|
|
||||||
|
/// Clear log for a new provisioning attempt
|
||||||
|
func reset() {
|
||||||
|
entries = []
|
||||||
|
startTime = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a log entry
|
||||||
|
func log(_ phase: String, _ message: String, isError: Bool = false) {
|
||||||
|
let entry = Entry(timestamp: Date(), phase: phase, message: message, isError: isError)
|
||||||
|
entries.append(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Elapsed time since reset
|
||||||
|
var elapsed: String {
|
||||||
|
guard let start = startTime else { return "0.0s" }
|
||||||
|
let seconds = Date().timeIntervalSince(start)
|
||||||
|
return String(format: "%.1fs", seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full log as shareable text
|
||||||
|
var fullText: String {
|
||||||
|
let header = "Payfrit Beacon Diagnostic Log"
|
||||||
|
let time = "Session: \(elapsed)"
|
||||||
|
let lines = entries.map { $0.formatted }
|
||||||
|
return ([header, time, "---"] + lines).joined(separator: "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
31
PayfritBeacon/Utils/BrandColors.swift
Normal file
31
PayfritBeacon/Utils/BrandColors.swift
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Payfrit brand colors — matches Android colors.xml exactly
|
||||||
|
/// Primary: #22B24B (Payfrit Green)
|
||||||
|
/// Dark: #1A8A3A
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
// MARK: - Brand
|
||||||
|
static let payfritGreen = Color(red: 0x22/255.0, green: 0xB2/255.0, blue: 0x4B/255.0)
|
||||||
|
static let payfritGreenDark = Color(red: 0x1A/255.0, green: 0x8A/255.0, blue: 0x3A/255.0)
|
||||||
|
|
||||||
|
// MARK: - Status (matching Android)
|
||||||
|
static let successGreen = Color(red: 0x4C/255.0, green: 0xAF/255.0, blue: 0x50/255.0)
|
||||||
|
static let errorRed = Color(red: 0xBA/255.0, green: 0x1A/255.0, blue: 0x1A/255.0)
|
||||||
|
static let warningOrange = Color(red: 0xFF/255.0, green: 0x98/255.0, blue: 0x00/255.0)
|
||||||
|
static let infoBlue = Color(red: 0x21/255.0, green: 0x96/255.0, blue: 0xF3/255.0)
|
||||||
|
|
||||||
|
// MARK: - Signal Strength (matching Android)
|
||||||
|
static let signalStrong = Color(red: 0x4C/255.0, green: 0xAF/255.0, blue: 0x50/255.0)
|
||||||
|
static let signalMedium = Color(red: 0xF9/255.0, green: 0xA8/255.0, blue: 0x25/255.0)
|
||||||
|
static let signalWeak = Color(red: 0xBA/255.0, green: 0x1A/255.0, blue: 0x1A/255.0)
|
||||||
|
|
||||||
|
// MARK: - Surfaces (matching Android)
|
||||||
|
static let surfaceBg = Color(red: 0xFC/255.0, green: 0xFD/255.0, blue: 0xF7/255.0)
|
||||||
|
static let surfaceCard = Color(red: 0xF0/255.0, green: 0xF1/255.0, blue: 0xEB/255.0)
|
||||||
|
|
||||||
|
// MARK: - Text (matching Android)
|
||||||
|
static let textPrimary = Color(red: 0x1A/255.0, green: 0x1C/255.0, blue: 0x19/255.0)
|
||||||
|
static let textSecondary = Color(red: 0x42/255.0, green: 0x49/255.0, blue: 0x40/255.0)
|
||||||
|
static let textHint = Color(red: 0x72/255.0, green: 0x79/255.0, blue: 0x70/255.0)
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ struct BusinessListView: View {
|
||||||
if isLoading {
|
if isLoading {
|
||||||
ProgressView("Loading businesses…")
|
ProgressView("Loading businesses…")
|
||||||
} else if let error = errorMessage {
|
} else if let error = errorMessage {
|
||||||
|
if #available(iOS 17.0, *) {
|
||||||
ContentUnavailableView {
|
ContentUnavailableView {
|
||||||
Label("Error", systemImage: "exclamationmark.triangle")
|
Label("Error", systemImage: "exclamationmark.triangle")
|
||||||
} description: {
|
} description: {
|
||||||
|
|
@ -21,12 +22,43 @@ struct BusinessListView: View {
|
||||||
} actions: {
|
} actions: {
|
||||||
Button("Retry") { Task { await loadBusinesses() } }
|
Button("Retry") { Task { await loadBusinesses() } }
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "exclamationmark.triangle")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(Color.warningOrange)
|
||||||
|
Text("Error")
|
||||||
|
.font(.headline)
|
||||||
|
Text(error)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
Button("Retry") { Task { await loadBusinesses() } }
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
} else if businesses.isEmpty {
|
} else if businesses.isEmpty {
|
||||||
|
if #available(iOS 17.0, *) {
|
||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
"No Businesses",
|
"No Businesses",
|
||||||
systemImage: "building.2",
|
systemImage: "building.2",
|
||||||
description: Text("You don't have any businesses yet.")
|
description: Text("You don't have any businesses yet.")
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "building.2")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("No Businesses")
|
||||||
|
.font(.headline)
|
||||||
|
Text("You don't have any businesses yet.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxHeight: .infinity)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
List(businesses) { business in
|
List(businesses) { business in
|
||||||
Button {
|
Button {
|
||||||
|
|
@ -39,13 +71,13 @@ struct BusinessListView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Select Business")
|
.navigationTitle("Select Business")
|
||||||
.toolbar {
|
.toolbar(content: {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Button("Logout") {
|
Button("Logout") {
|
||||||
appState.logout()
|
appState.logout()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
await loadBusinesses()
|
await loadBusinesses()
|
||||||
|
|
@ -62,6 +94,8 @@ struct BusinessListView: View {
|
||||||
let list = try await APIClient.shared.listBusinesses(token: token)
|
let list = try await APIClient.shared.listBusinesses(token: token)
|
||||||
businesses = list
|
businesses = list
|
||||||
|
|
||||||
|
// Skip auto-navigation if user explicitly tapped Back
|
||||||
|
if !appState.skipAutoNav {
|
||||||
// Auto-navigate if only one business (like Android)
|
// Auto-navigate if only one business (like Android)
|
||||||
if list.count == 1, let only = list.first {
|
if list.count == 1, let only = list.first {
|
||||||
appState.selectBusiness(only)
|
appState.selectBusiness(only)
|
||||||
|
|
@ -74,6 +108,8 @@ struct BusinessListView: View {
|
||||||
appState.selectBusiness(last)
|
appState.selectBusiness(last)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
appState.skipAutoNav = false
|
||||||
} catch let e as APIError where e.errorDescription == APIError.unauthorized.errorDescription {
|
} catch let e as APIError where e.errorDescription == APIError.unauthorized.errorDescription {
|
||||||
appState.logout()
|
appState.logout()
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ struct DevBanner: View {
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
.background(.orange, in: Capsule())
|
.background(Color.warningOrange, in: Capsule())
|
||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ struct LoginView: View {
|
||||||
// Logo
|
// Logo
|
||||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||||
.font(.system(size: 64))
|
.font(.system(size: 64))
|
||||||
.foregroundStyle(.blue)
|
.foregroundStyle(Color.payfritGreen)
|
||||||
|
|
||||||
Text("Payfrit Beacon")
|
Text("Payfrit Beacon")
|
||||||
.font(.title.bold())
|
.font(.title.bold())
|
||||||
|
|
@ -49,7 +49,7 @@ struct LoginView: View {
|
||||||
if let error = errorMessage {
|
if let error = errorMessage {
|
||||||
Text(error)
|
Text(error)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(Color.errorRed)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(action: handleAction) {
|
Button(action: handleAction) {
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ struct QRScannerView: View {
|
||||||
p.addLine(to: CGPoint(x: 0, y: 0))
|
p.addLine(to: CGPoint(x: 0, y: 0))
|
||||||
p.addLine(to: CGPoint(x: corner, y: 0))
|
p.addLine(to: CGPoint(x: corner, y: 0))
|
||||||
}
|
}
|
||||||
.stroke(.blue, lineWidth: thickness)
|
.stroke(Color.payfritGreen, lineWidth: thickness)
|
||||||
|
|
||||||
// Top-right
|
// Top-right
|
||||||
Path { p in
|
Path { p in
|
||||||
|
|
@ -109,7 +109,7 @@ struct QRScannerView: View {
|
||||||
p.addLine(to: CGPoint(x: w, y: 0))
|
p.addLine(to: CGPoint(x: w, y: 0))
|
||||||
p.addLine(to: CGPoint(x: w, y: corner))
|
p.addLine(to: CGPoint(x: w, y: corner))
|
||||||
}
|
}
|
||||||
.stroke(.blue, lineWidth: thickness)
|
.stroke(Color.payfritGreen, lineWidth: thickness)
|
||||||
|
|
||||||
// Bottom-left
|
// Bottom-left
|
||||||
Path { p in
|
Path { p in
|
||||||
|
|
@ -117,7 +117,7 @@ struct QRScannerView: View {
|
||||||
p.addLine(to: CGPoint(x: 0, y: h))
|
p.addLine(to: CGPoint(x: 0, y: h))
|
||||||
p.addLine(to: CGPoint(x: corner, y: h))
|
p.addLine(to: CGPoint(x: corner, y: h))
|
||||||
}
|
}
|
||||||
.stroke(.blue, lineWidth: thickness)
|
.stroke(Color.payfritGreen, lineWidth: thickness)
|
||||||
|
|
||||||
// Bottom-right
|
// Bottom-right
|
||||||
Path { p in
|
Path { p in
|
||||||
|
|
@ -125,7 +125,7 @@ struct QRScannerView: View {
|
||||||
p.addLine(to: CGPoint(x: w, y: h))
|
p.addLine(to: CGPoint(x: w, y: h))
|
||||||
p.addLine(to: CGPoint(x: w, y: h - corner))
|
p.addLine(to: CGPoint(x: w, y: h - corner))
|
||||||
}
|
}
|
||||||
.stroke(.blue, lineWidth: thickness)
|
.stroke(Color.payfritGreen, lineWidth: thickness)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -141,7 +141,9 @@ struct QRScannerView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
private var cameraPermissionDenied: some View {
|
private var cameraPermissionDenied: some View {
|
||||||
|
if #available(iOS 17.0, *) {
|
||||||
ContentUnavailableView {
|
ContentUnavailableView {
|
||||||
Label("Camera Access Required", systemImage: "camera.fill")
|
Label("Camera Access Required", systemImage: "camera.fill")
|
||||||
} description: {
|
} description: {
|
||||||
|
|
@ -154,6 +156,26 @@ struct QRScannerView: View {
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "camera.fill")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Camera Access Required")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Open Settings and enable camera access for Payfrit Beacon.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
Button("Open Settings") {
|
||||||
|
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
|
|
@ -219,17 +241,31 @@ final class CameraPreviewUIView: UIView {
|
||||||
func setFlash(_ on: Bool) {
|
func setFlash(_ on: Bool) {
|
||||||
guard let device = AVCaptureDevice.default(for: .video),
|
guard let device = AVCaptureDevice.default(for: .video),
|
||||||
device.hasTorch else { return }
|
device.hasTorch else { return }
|
||||||
try? device.lockForConfiguration()
|
do {
|
||||||
|
try device.lockForConfiguration()
|
||||||
device.torchMode = on ? .on : .off
|
device.torchMode = on ? .on : .off
|
||||||
device.unlockForConfiguration()
|
device.unlockForConfiguration()
|
||||||
|
} catch {
|
||||||
|
NSLog("[QRScanner] Failed to set torch: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupCamera() {
|
private func setupCamera() {
|
||||||
let session = AVCaptureSession()
|
let session = AVCaptureSession()
|
||||||
session.sessionPreset = .high
|
session.sessionPreset = .high
|
||||||
|
|
||||||
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
|
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else {
|
||||||
let input = try? AVCaptureDeviceInput(device: device) else { return }
|
NSLog("[QRScanner] ERROR: No back camera available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let input: AVCaptureDeviceInput
|
||||||
|
do {
|
||||||
|
input = try AVCaptureDeviceInput(device: device)
|
||||||
|
} catch {
|
||||||
|
NSLog("[QRScanner] ERROR: Failed to create camera input: \(error.localizedDescription)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if session.canAddInput(input) {
|
if session.canAddInput(input) {
|
||||||
session.addInput(input)
|
session.addInput(input)
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,12 @@ struct ScanView: View {
|
||||||
// Provisioning flow
|
// Provisioning flow
|
||||||
@State private var selectedBeacon: DiscoveredBeacon?
|
@State private var selectedBeacon: DiscoveredBeacon?
|
||||||
@State private var provisioningState: ProvisioningState = .idle
|
@State private var provisioningState: ProvisioningState = .idle
|
||||||
|
@State private var writesCompleted = false
|
||||||
@State private var statusMessage = ""
|
@State private var statusMessage = ""
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
@State private var showQRScanner = false
|
@State private var showQRScanner = false
|
||||||
@State private var scannedMAC: String?
|
@State private var scannedMAC: String?
|
||||||
|
@StateObject private var provisionLog = ProvisionLog()
|
||||||
|
|
||||||
enum ProvisioningState {
|
enum ProvisioningState {
|
||||||
case idle
|
case idle
|
||||||
|
|
@ -116,7 +118,7 @@ struct ScanView: View {
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.background(
|
.background(
|
||||||
selectedServicePoint?.id == sp.id ? Color.blue : Color(.systemGray5),
|
selectedServicePoint?.id == sp.id ? Color.payfritGreen : Color(.systemGray5),
|
||||||
in: Capsule()
|
in: Capsule()
|
||||||
)
|
)
|
||||||
.foregroundStyle(selectedServicePoint?.id == sp.id ? .white : .primary)
|
.foregroundStyle(selectedServicePoint?.id == sp.id ? .white : .primary)
|
||||||
|
|
@ -129,21 +131,40 @@ struct ScanView: View {
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
private var selectServicePointPrompt: some View {
|
private var selectServicePointPrompt: some View {
|
||||||
|
if #available(iOS 17.0, *) {
|
||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
"Select a Service Point",
|
"Select a Service Point",
|
||||||
systemImage: "mappin.and.ellipse",
|
systemImage: "mappin.and.ellipse",
|
||||||
description: Text("Choose or create a service point (table) to provision a beacon for.")
|
description: Text("Choose or create a service point (table) to provision a beacon for.")
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "mappin.and.ellipse")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Select a Service Point")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Choose or create a service point (table) to provision a beacon for.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.frame(maxHeight: .infinity)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Namespace Loading
|
// MARK: - Namespace Loading
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
private var namespaceLoadingView: some View {
|
private var namespaceLoadingView: some View {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
if isLoadingNamespace {
|
if isLoadingNamespace {
|
||||||
ProgressView("Allocating beacon namespace…")
|
ProgressView("Allocating beacon namespace…")
|
||||||
} else {
|
} else {
|
||||||
|
if #available(iOS 17.0, *) {
|
||||||
ContentUnavailableView {
|
ContentUnavailableView {
|
||||||
Label("Namespace Error", systemImage: "exclamationmark.triangle")
|
Label("Namespace Error", systemImage: "exclamationmark.triangle")
|
||||||
} description: {
|
} description: {
|
||||||
|
|
@ -151,6 +172,22 @@ struct ScanView: View {
|
||||||
} actions: {
|
} actions: {
|
||||||
Button("Retry") { Task { await loadNamespace() } }
|
Button("Retry") { Task { await loadNamespace() } }
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "exclamationmark.triangle")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(Color.warningOrange)
|
||||||
|
Text("Namespace Error")
|
||||||
|
.font(.headline)
|
||||||
|
Text(errorMessage ?? "Failed to allocate beacon namespace")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
Button("Retry") { Task { await loadNamespace() } }
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxHeight: .infinity)
|
.frame(maxHeight: .infinity)
|
||||||
|
|
@ -172,8 +209,8 @@ struct ScanView: View {
|
||||||
progressView(title: "Connecting…", message: statusMessage)
|
progressView(title: "Connecting…", message: statusMessage)
|
||||||
|
|
||||||
case .connected:
|
case .connected:
|
||||||
// DXSmart: beacon is flashing, show write button
|
// Legacy — auto-write skips this state now
|
||||||
dxsmartConnectedView
|
progressView(title: "Connected…", message: statusMessage)
|
||||||
|
|
||||||
case .writing:
|
case .writing:
|
||||||
progressView(title: "Writing Config…", message: statusMessage)
|
progressView(title: "Writing Config…", message: statusMessage)
|
||||||
|
|
@ -205,7 +242,7 @@ struct ScanView: View {
|
||||||
if bleManager.bluetoothState != .poweredOn {
|
if bleManager.bluetoothState != .poweredOn {
|
||||||
Label("Bluetooth Off", systemImage: "bluetooth.slash")
|
Label("Bluetooth Off", systemImage: "bluetooth.slash")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(Color.errorRed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
@ -245,13 +282,28 @@ struct ScanView: View {
|
||||||
.padding()
|
.padding()
|
||||||
|
|
||||||
if bleManager.discoveredBeacons.isEmpty && !bleManager.isScanning {
|
if bleManager.discoveredBeacons.isEmpty && !bleManager.isScanning {
|
||||||
|
if #available(iOS 17.0, *) {
|
||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
"No Beacons Found",
|
"No Beacons Found",
|
||||||
systemImage: "antenna.radiowaves.left.and.right.slash",
|
systemImage: "antenna.radiowaves.left.and.right.slash",
|
||||||
description: Text("Tap Scan to search for nearby beacons")
|
description: Text("Tap Scan to search for nearby beacons")
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
List(bleManager.discoveredBeacons) { beacon in
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "antenna.radiowaves.left.and.right.slash")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("No Beacons Found")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Tap Scan to search for nearby beacons")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxHeight: .infinity)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
List(bleManager.discoveredBeacons.sorted { $0.rssi > $1.rssi }) { beacon in
|
||||||
Button {
|
Button {
|
||||||
selectedBeacon = beacon
|
selectedBeacon = beacon
|
||||||
Task { await startProvisioning(beacon) }
|
Task { await startProvisioning(beacon) }
|
||||||
|
|
@ -267,46 +319,7 @@ struct ScanView: View {
|
||||||
|
|
||||||
// MARK: - DXSmart Connected View
|
// MARK: - DXSmart Connected View
|
||||||
|
|
||||||
private var dxsmartConnectedView: some View {
|
// dxsmartConnectedView removed — auto-write skips the manual confirmation step
|
||||||
VStack(spacing: 24) {
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Image(systemName: "light.beacon.max")
|
|
||||||
.font(.system(size: 64))
|
|
||||||
.foregroundStyle(.orange)
|
|
||||||
.symbolEffect(.pulse)
|
|
||||||
|
|
||||||
Text("Beacon is Flashing")
|
|
||||||
.font(.title2.bold())
|
|
||||||
|
|
||||||
Text("Confirm the beacon LED is flashing, then tap Write Config to program it.")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.padding(.horizontal, 32)
|
|
||||||
|
|
||||||
Button {
|
|
||||||
Task { await writeConfigToConnectedBeacon() }
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "arrow.down.doc")
|
|
||||||
Text("Write Config")
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.tint(.orange)
|
|
||||||
.controlSize(.large)
|
|
||||||
.padding(.horizontal, 32)
|
|
||||||
|
|
||||||
Button("Cancel") {
|
|
||||||
cancelProvisioning()
|
|
||||||
}
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Progress / Success / Failed Views
|
// MARK: - Progress / Success / Failed Views
|
||||||
|
|
||||||
|
|
@ -320,7 +333,58 @@ struct ScanView: View {
|
||||||
Text(message)
|
Text(message)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
// Show live diagnostic log during connecting/writing
|
||||||
|
if !provisionLog.entries.isEmpty {
|
||||||
|
diagnosticLogView
|
||||||
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
Button("Cancel") {
|
||||||
|
cancelProvisioning()
|
||||||
|
}
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reusable diagnostic log view
|
||||||
|
private var diagnosticLogView: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Text("Log (\(provisionLog.elapsed))")
|
||||||
|
.font(.caption.bold())
|
||||||
|
Spacer()
|
||||||
|
ShareLink(item: provisionLog.fullText) {
|
||||||
|
Label("Share", systemImage: "square.and.arrow.up")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 2) {
|
||||||
|
ForEach(provisionLog.entries) { entry in
|
||||||
|
Text(entry.formatted)
|
||||||
|
.font(.system(.caption2, design: .monospaced))
|
||||||
|
.foregroundStyle(entry.isError ? Color.errorRed : .primary)
|
||||||
|
.id(entry.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
.onChange(of: provisionLog.entries.count) { _ in
|
||||||
|
if let last = provisionLog.entries.last {
|
||||||
|
proxy.scrollTo(last.id, anchor: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxHeight: 160)
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -329,7 +393,7 @@ struct ScanView: View {
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.font(.system(size: 64))
|
.font(.system(size: 64))
|
||||||
.foregroundStyle(.green)
|
.foregroundStyle(Color.successGreen)
|
||||||
Text("Beacon Provisioned!")
|
Text("Beacon Provisioned!")
|
||||||
.font(.title2.bold())
|
.font(.title2.bold())
|
||||||
Text(statusMessage)
|
Text(statusMessage)
|
||||||
|
|
@ -346,11 +410,10 @@ struct ScanView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private var failedView: some View {
|
private var failedView: some View {
|
||||||
VStack(spacing: 24) {
|
VStack(spacing: 16) {
|
||||||
Spacer()
|
|
||||||
Image(systemName: "xmark.circle.fill")
|
Image(systemName: "xmark.circle.fill")
|
||||||
.font(.system(size: 64))
|
.font(.system(size: 48))
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(Color.errorRed)
|
||||||
Text("Provisioning Failed")
|
Text("Provisioning Failed")
|
||||||
.font(.title2.bold())
|
.font(.title2.bold())
|
||||||
Text(errorMessage ?? "Unknown error")
|
Text(errorMessage ?? "Unknown error")
|
||||||
|
|
@ -359,6 +422,11 @@ struct ScanView: View {
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.padding(.horizontal, 32)
|
.padding(.horizontal, 32)
|
||||||
|
|
||||||
|
// Diagnostic log
|
||||||
|
if !provisionLog.entries.isEmpty {
|
||||||
|
diagnosticLogView
|
||||||
|
}
|
||||||
|
|
||||||
HStack(spacing: 16) {
|
HStack(spacing: 16) {
|
||||||
Button("Try Again") {
|
Button("Try Again") {
|
||||||
if let beacon = selectedBeacon {
|
if let beacon = selectedBeacon {
|
||||||
|
|
@ -377,8 +445,8 @@ struct ScanView: View {
|
||||||
resetProvisioningState()
|
resetProvisioningState()
|
||||||
}
|
}
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
Spacer()
|
|
||||||
}
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Create Service Point Sheet
|
// MARK: - Create Service Point Sheet
|
||||||
|
|
@ -475,14 +543,19 @@ struct ScanView: View {
|
||||||
let token = appState.token else { return }
|
let token = appState.token else { return }
|
||||||
|
|
||||||
provisioningState = .connecting
|
provisioningState = .connecting
|
||||||
statusMessage = "Connecting to \(beacon.displayName)…"
|
statusMessage = "Allocating beacon config…"
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
provisionLog.reset()
|
||||||
|
provisionLog.log("init", "Starting provisioning: \(beacon.displayName) (\(beacon.type.rawValue)) RSSI:\(beacon.rssi)")
|
||||||
|
provisionLog.log("init", "Service point: \(sp.name), Business: \(business.name)")
|
||||||
|
|
||||||
do {
|
do {
|
||||||
// Allocate minor for this service point
|
// Allocate minor for this service point
|
||||||
|
provisionLog.log("api", "Allocating minor for service point \(sp.id)…")
|
||||||
let minor = try await APIClient.shared.allocateMinor(
|
let minor = try await APIClient.shared.allocateMinor(
|
||||||
businessId: business.id, servicePointId: sp.id, token: token
|
businessId: business.id, servicePointId: sp.id, token: token
|
||||||
)
|
)
|
||||||
|
provisionLog.log("api", "Minor allocated: \(minor)")
|
||||||
|
|
||||||
let config = BeaconConfig(
|
let config = BeaconConfig(
|
||||||
uuid: ns.uuid.normalizedUUID,
|
uuid: ns.uuid.normalizedUUID,
|
||||||
|
|
@ -497,78 +570,58 @@ struct ScanView: View {
|
||||||
|
|
||||||
// Create appropriate provisioner
|
// Create appropriate provisioner
|
||||||
let provisioner = makeProvisioner(for: beacon)
|
let provisioner = makeProvisioner(for: beacon)
|
||||||
|
|
||||||
statusMessage = "Authenticating with \(beacon.type.rawValue)…"
|
|
||||||
try await provisioner.connect()
|
|
||||||
|
|
||||||
// DXSmart: stop at connected state, wait for user to confirm flashing
|
|
||||||
if beacon.type == .dxsmart {
|
|
||||||
provisioningState = .connected
|
|
||||||
// Store config and provisioner for later use
|
|
||||||
pendingConfig = config
|
|
||||||
pendingProvisioner = provisioner
|
pendingProvisioner = provisioner
|
||||||
|
pendingConfig = config
|
||||||
|
|
||||||
|
// Wire up real-time status updates from provisioner
|
||||||
|
if let dxProvisioner = provisioner as? DXSmartProvisioner {
|
||||||
|
dxProvisioner.onStatusUpdate = { [weak self] status in
|
||||||
|
self?.statusMessage = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statusMessage = "Connecting to \(beacon.displayName)…"
|
||||||
|
provisionLog.log("connect", "Connecting to \(beacon.type.rawValue) provisioner…")
|
||||||
|
|
||||||
|
// Monitor for unexpected disconnects during provisioning
|
||||||
|
bleManager.onPeripheralDisconnected = { [weak provisionLog] peripheral, error in
|
||||||
|
if peripheral.identifier == beacon.peripheral.identifier {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
let reason = error?.localizedDescription ?? "beacon timed out"
|
||||||
|
|
||||||
|
// Writes already finished — beacon rebooted after SaveConfig, this is expected
|
||||||
|
if self.writesCompleted {
|
||||||
|
provisionLog?.log("disconnect", "Post-write disconnect (expected — beacon rebooted after save)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// KBeacon / BlueCharm: write immediately
|
provisionLog?.log("disconnect", "Unexpected disconnect: \(reason)", isError: true)
|
||||||
provisioningState = .writing
|
|
||||||
statusMessage = "Writing \(config.formattedUUID.prefix(8))… Major:\(config.major) Minor:\(config.minor)"
|
// For all active states, treat disconnect as failure
|
||||||
try await provisioner.writeConfig(config)
|
if self.provisioningState == .connecting ||
|
||||||
provisioner.disconnect()
|
self.provisioningState == .writing || self.provisioningState == .verifying {
|
||||||
|
self.provisioningState = .failed
|
||||||
// Register with backend
|
self.errorMessage = "Beacon disconnected: \(reason)"
|
||||||
try await APIClient.shared.registerBeaconHardware(
|
}
|
||||||
businessId: business.id,
|
}
|
||||||
servicePointId: sp.id,
|
}
|
||||||
uuid: ns.uuid,
|
}
|
||||||
major: ns.major,
|
|
||||||
minor: minor,
|
try await provisioner.connect()
|
||||||
macAddress: nil,
|
provisionLog.log("connect", "Connected and authenticated successfully")
|
||||||
beaconType: beacon.type.rawValue,
|
|
||||||
token: token
|
// Auto-fire write immediately — no pause needed
|
||||||
)
|
|
||||||
|
|
||||||
// Verify broadcast
|
|
||||||
provisioningState = .verifying
|
|
||||||
statusMessage = "Waiting for beacon to restart…"
|
|
||||||
try await Task.sleep(nanoseconds: UInt64(GATTConstants.postFlashDelay * 1_000_000_000))
|
|
||||||
|
|
||||||
statusMessage = "Scanning for broadcast…"
|
|
||||||
let verifyResult = await bleManager.verifyBroadcast(
|
|
||||||
uuid: ns.uuid, major: config.major, minor: config.minor
|
|
||||||
)
|
|
||||||
|
|
||||||
if verifyResult.found {
|
|
||||||
try await APIClient.shared.verifyBeaconBroadcast(
|
|
||||||
uuid: ns.uuid, major: ns.major, minor: minor, token: token
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
provisioningState = .done
|
|
||||||
statusMessage = "\(sp.name) — \(beacon.type.rawValue)\nUUID: \(config.formattedUUID.prefix(13))…\nMajor: \(config.major) Minor: \(config.minor)"
|
|
||||||
|
|
||||||
} catch {
|
|
||||||
provisioningState = .failed
|
|
||||||
errorMessage = error.localizedDescription
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store for DXSmart two-phase flow
|
|
||||||
@State private var pendingConfig: BeaconConfig?
|
|
||||||
@State private var pendingProvisioner: (any BeaconProvisioner)?
|
|
||||||
|
|
||||||
private func writeConfigToConnectedBeacon() async {
|
|
||||||
guard let config = pendingConfig,
|
|
||||||
let provisioner = pendingProvisioner,
|
|
||||||
let sp = selectedServicePoint,
|
|
||||||
let ns = namespace,
|
|
||||||
let token = appState.token else { return }
|
|
||||||
|
|
||||||
provisioningState = .writing
|
provisioningState = .writing
|
||||||
|
writesCompleted = false
|
||||||
statusMessage = "Writing config to DX-Smart…"
|
statusMessage = "Writing config to DX-Smart…"
|
||||||
|
provisionLog.log("write", "Auto-writing config (no user tap needed)…")
|
||||||
|
|
||||||
do {
|
|
||||||
try await provisioner.writeConfig(config)
|
try await provisioner.writeConfig(config)
|
||||||
|
writesCompleted = true
|
||||||
|
|
||||||
|
// Brief settle after SaveConfig before dropping the BLE link.
|
||||||
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||||
provisioner.disconnect()
|
provisioner.disconnect()
|
||||||
|
|
||||||
try await APIClient.shared.registerBeaconHardware(
|
try await APIClient.shared.registerBeaconHardware(
|
||||||
|
|
@ -577,7 +630,7 @@ struct ScanView: View {
|
||||||
uuid: ns.uuid,
|
uuid: ns.uuid,
|
||||||
major: ns.major,
|
major: ns.major,
|
||||||
minor: Int(config.minor),
|
minor: Int(config.minor),
|
||||||
macAddress: nil,
|
hardwareId: selectedBeacon?.id.uuidString ?? "unknown-ios",
|
||||||
beaconType: BeaconType.dxsmart.rawValue,
|
beaconType: BeaconType.dxsmart.rawValue,
|
||||||
token: token
|
token: token
|
||||||
)
|
)
|
||||||
|
|
@ -586,14 +639,16 @@ struct ScanView: View {
|
||||||
statusMessage = "\(sp.name) — DX-Smart\nUUID: \(config.formattedUUID.prefix(13))…\nMajor: \(config.major) Minor: \(config.minor)"
|
statusMessage = "\(sp.name) — DX-Smart\nUUID: \(config.formattedUUID.prefix(13))…\nMajor: \(config.major) Minor: \(config.minor)"
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
|
provisionLog.log("error", "Provisioning failed after \(provisionLog.elapsed): \(error.localizedDescription)", isError: true)
|
||||||
provisioningState = .failed
|
provisioningState = .failed
|
||||||
errorMessage = error.localizedDescription
|
errorMessage = error.localizedDescription
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingConfig = nil
|
|
||||||
pendingProvisioner = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kept for cancel/reset and registerAnywayAfterFailure fallback
|
||||||
|
@State private var pendingConfig: BeaconConfig?
|
||||||
|
@State private var pendingProvisioner: (any BeaconProvisioner)?
|
||||||
|
|
||||||
private func registerAnywayAfterFailure() async {
|
private func registerAnywayAfterFailure() async {
|
||||||
guard let sp = selectedServicePoint,
|
guard let sp = selectedServicePoint,
|
||||||
let ns = namespace,
|
let ns = namespace,
|
||||||
|
|
@ -610,7 +665,7 @@ struct ScanView: View {
|
||||||
uuid: ns.uuid,
|
uuid: ns.uuid,
|
||||||
major: ns.major,
|
major: ns.major,
|
||||||
minor: Int(config.minor),
|
minor: Int(config.minor),
|
||||||
macAddress: nil,
|
hardwareId: selectedBeacon?.id.uuidString ?? "unknown-ios",
|
||||||
beaconType: selectedBeacon?.type.rawValue ?? "Unknown",
|
beaconType: selectedBeacon?.type.rawValue ?? "Unknown",
|
||||||
token: token
|
token: token
|
||||||
)
|
)
|
||||||
|
|
@ -683,17 +738,13 @@ struct ScanView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeProvisioner(for beacon: DiscoveredBeacon) -> any BeaconProvisioner {
|
private func makeProvisioner(for beacon: DiscoveredBeacon) -> any BeaconProvisioner {
|
||||||
switch beacon.type {
|
var provisioner: any BeaconProvisioner = DXSmartProvisioner(
|
||||||
case .kbeacon:
|
peripheral: beacon.peripheral,
|
||||||
return KBeaconProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager)
|
centralManager: bleManager.centralManager
|
||||||
case .dxsmart:
|
)
|
||||||
return DXSmartProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager)
|
provisioner.bleManager = bleManager
|
||||||
case .bluecharm:
|
provisioner.diagnosticLog = provisionLog
|
||||||
return BlueCharmProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager)
|
return provisioner
|
||||||
case .unknown:
|
|
||||||
// Try all provisioners in sequence (matches Android fallback behavior)
|
|
||||||
return FallbackProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -751,19 +802,27 @@ struct BeaconRow: View {
|
||||||
|
|
||||||
private var signalColor: Color {
|
private var signalColor: Color {
|
||||||
switch beacon.rssi {
|
switch beacon.rssi {
|
||||||
case -50...0: return .green
|
case -50...0: return .signalStrong
|
||||||
case -65 ... -51: return .blue
|
case -65 ... -51: return .payfritGreen
|
||||||
case -80 ... -66: return .orange
|
case -80 ... -66: return .signalMedium
|
||||||
default: return .red
|
default: return .signalWeak
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var typeColor: Color {
|
private var typeColor: Color {
|
||||||
switch beacon.type {
|
return .payfritGreen
|
||||||
case .kbeacon: return .blue
|
}
|
||||||
case .dxsmart: return .orange
|
}
|
||||||
case .bluecharm: return .purple
|
|
||||||
case .unknown: return .gray
|
// MARK: - iOS 16/17 Compatibility
|
||||||
|
|
||||||
|
/// Applies `.symbolEffect(.pulse)` on iOS 17+, no-op on iOS 16
|
||||||
|
private struct PulseEffectModifier: ViewModifier {
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
if #available(iOS 17.0, *) {
|
||||||
|
content.symbolEffect(.pulse)
|
||||||
|
} else {
|
||||||
|
content
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue