Compare commits

..

No commits in common. "main" and "schwifty/fix-otp-auth" have entirely different histories.

19 changed files with 1098 additions and 739 deletions

View file

@ -63,12 +63,15 @@ 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 (CP-28 only) │ ├── BeaconType.swift Enum: DXSmart, BlueCharm, KBeacon, Unknown
│ ├── Business.swift Business model │ ├── Business.swift Business model
│ └── ServicePoint.swift Service point model │ └── ServicePoint.swift Service point model
├── Provisioners/ ├── Provisioners/
│ ├── ProvisionerProtocol.swift Protocol + CP-28 GATT constants │ ├── ProvisionerProtocol.swift Protocol — all provisioners implement this
│ ├── DXSmartProvisioner.swift DX-Smart CP-28 GATT provisioner (24-step write sequence) │ ├── DXSmartProvisioner.swift DX-Smart CP28 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
@ -90,7 +93,7 @@ PayfritBeacon/
## Key Architecture Notes ## Key Architecture Notes
- **CP-28 only**: Only DX-Smart CP-28 beacons are supported. Other beacon types (KBeacon, BlueCharm) were removed — will be re-added when needed. - **Modular provisioners**: Each beacon manufacturer has its own provisioner conforming to `ProvisionerProtocol`. No more monolithic `BeaconProvisioner.swift`.
- **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.

View file

@ -16,12 +16,14 @@
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 */; };
@ -47,12 +49,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>"; }; 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>"; };
@ -138,7 +142,10 @@
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 */,
); );
@ -152,7 +159,6 @@
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>";
@ -262,12 +268,14 @@
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 */,

View file

@ -14,9 +14,6 @@ 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() {
@ -39,8 +36,6 @@ final class AppState: ObservableObject {
} }
func backToBusinessList() { func backToBusinessList() {
AppPrefs.lastBusinessId = nil
skipAutoNav = true
currentScreen = .businessList currentScreen = .businessList
} }

View file

@ -365,10 +365,8 @@ class BeaconProvisioner: NSObject, ObservableObject {
isTerminating = true isTerminating = true
DebugLog.shared.log("BLE: Provisioning success!") DebugLog.shared.log("BLE: Provisioning success!")
state = .success state = .success
// Signal completion BEFORE disconnecting the disconnect delegate fires
// synchronously and ScanView needs writesCompleted=true before it sees it
completion?(.success(macAddress: nil))
disconnectPeripheral() disconnectPeripheral()
completion?(.success(macAddress: nil))
cleanup() cleanup()
} }

View file

@ -22,8 +22,6 @@
<string>$(CURRENT_PROJECT_VERSION)</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSCameraUsageDescription</key>
<string>Payfrit Beacon needs camera access to scan QR codes on beacon labels for provisioning.</string>
<key>NSBluetoothAlwaysUsageDescription</key> <key>NSBluetoothAlwaysUsageDescription</key>
<string>Payfrit Beacon needs Bluetooth to detect and configure nearby beacons.</string> <string>Payfrit Beacon needs Bluetooth to detect and configure nearby beacons.</string>
<key>NSBluetoothPeripheralUsageDescription</key> <key>NSBluetoothPeripheralUsageDescription</key>

View file

@ -1,10 +1,12 @@
import Foundation import Foundation
import CoreBluetooth import CoreBluetooth
/// Beacon hardware type CP-28 (DX-Smart) only for now. /// Supported beacon hardware types
/// 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

View file

@ -6,42 +6,11 @@ struct Business: Identifiable, Codable, Hashable {
let imageExtension: String? let imageExtension: String?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case businessId = "BusinessID" case id = "ID"
case name = "Name" case name = "BusinessName"
// 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)")

View file

@ -6,38 +6,8 @@ struct ServicePoint: Identifiable, Codable, Hashable {
let businessId: String let businessId: String
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case servicePointId = "ServicePointID" case id = "ID"
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)
}
} }

View file

@ -0,0 +1,397 @@
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
}
}

View file

@ -41,9 +41,6 @@ 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
@ -56,47 +53,20 @@ 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
} }
} }
@ -105,37 +75,27 @@ 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)
} }
@ -181,49 +141,10 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
("SaveConfig", buildProtocolPacket(cmd: 0x60, data: Data())), ("SaveConfig", buildProtocolPacket(cmd: 0x60, data: Data())),
] ]
for (index, (name, packet)) in commands.enumerated() { for (name, packet) in commands {
// Bail immediately if BLE link dropped between commands try await writeToCharAndWaitACK(writeChar, data: packet, label: name)
if disconnected { // 200ms between commands (matches Android SDK timer interval)
await diagnosticLog?.log("write", "Aborting — BLE disconnected", isError: true) try await Task.sleep(nanoseconds: 200_000_000)
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)
lastError = nil
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)
} }
} }
@ -299,54 +220,11 @@ 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
@ -384,13 +262,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: 50_000_000) // 50ms settle try await Task.sleep(nanoseconds: 100_000_000) // 100ms (matches Android SDK timer)
} }
// 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: 50_000_000) // 50ms settle try await Task.sleep(nanoseconds: 100_000_000) // 100ms settle
} }
} }
@ -518,18 +396,10 @@ extension DXSmartProvisioner: CBPeripheralDelegate {
return return
} }
// For command writes (FFE1/FFE2): the .withResponse write confirmation // Handle write errors for command writes
// IS the ACK. Some commands (e.g. 0x61 Frame1_DevInfo) don't send a if let error, let cont = responseContinuation {
// 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())
}
} }
} }
} }

View file

@ -0,0 +1,56 @@
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
}
}

View file

@ -0,0 +1,262 @@
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)
}
}
}
}

View file

@ -14,27 +14,25 @@ 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 for CP-28 (DX-Smart) beacons /// GATT UUIDs shared across provisioner types
/// 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 // Timeouts (matching Android)
static let connectionTimeout: TimeInterval = 10.0 static let connectionTimeout: TimeInterval = 5.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

View file

@ -110,108 +110,72 @@ actor APIClient {
// 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(BusinessListResponse.self, from: data) let resp = try JSONDecoder().decode(APIResponse<[Business]>.self, from: data)
guard resp.OK else { guard resp.success else {
throw APIError.serverError(resp.ERROR ?? "Failed to load businesses") throw APIError.serverError(resp.message ?? "Failed to load businesses")
} }
return resp.businesses return resp.data ?? []
} }
// 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(ServicePointListResponse.self, from: data) let resp = try JSONDecoder().decode(APIResponse<[ServicePoint]>.self, from: data)
guard resp.OK else { guard resp.success else {
throw APIError.serverError(resp.ERROR ?? "Failed to load service points") throw APIError.serverError(resp.message ?? "Failed to load service points")
} }
return resp.SERVICEPOINTS ?? [] return resp.data ?? []
}
/// 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(ServicePointSaveResponse.self, from: data) let resp = try JSONDecoder().decode(APIResponse<ServicePoint>.self, from: data)
guard resp.OK, let sp = resp.SERVICEPOINT else { guard resp.success, let sp = resp.data else {
throw APIError.serverError(resp.ERROR ?? "Failed to create service point") throw APIError.serverError(resp.message ?? "Failed to create service point")
} }
return sp return sp
} }
// MARK: - Beacon Sharding // MARK: - Beacon Sharding
/// API returns: { "OK": true, "BeaconShardUUID": "...", "BeaconMajor": 5 } struct NamespaceResponse: Codable {
private struct AllocateNamespaceResponse: Codable { let uuid: String?
let OK: Bool let UUID: String?
let ERROR: String? let major: Int?
let MESSAGE: String? let Major: Int?
let BeaconShardUUID: String? var shardUUID: String { uuid ?? UUID ?? "" }
let BeaconMajor: Int? var shardMajor: Int { major ?? Major ?? 0 }
} }
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(AllocateNamespaceResponse.self, from: data) let resp = try JSONDecoder().decode(APIResponse<NamespaceResponse>.self, from: data)
guard resp.OK else { guard resp.success, let ns = resp.data else {
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to allocate namespace") throw APIError.serverError(resp.message ?? "Failed to allocate namespace")
} }
return (resp.BeaconShardUUID ?? "", resp.BeaconMajor ?? 0) return (ns.shardUUID, ns.shardMajor)
} }
/// API returns: { "OK": true, "BeaconMinor": 3 } struct MinorResponse: Codable {
private struct AllocateMinorResponse: Codable { let minor: Int?
let OK: Bool let Minor: Int?
let ERROR: String? var allocated: Int { minor ?? Minor ?? 0 }
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(AllocateMinorResponse.self, from: data) let resp = try JSONDecoder().decode(APIResponse<MinorResponse>.self, from: data)
guard resp.OK else { guard resp.success, let m = resp.data else {
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to allocate minor") throw APIError.serverError(resp.message ?? "Failed to allocate minor")
} }
guard let minor = resp.BeaconMinor, minor >= 0 else { return m.allocated
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(
@ -220,7 +184,7 @@ actor APIClient {
uuid: String, uuid: String,
major: Int, major: Int,
minor: Int, minor: Int,
hardwareId: String, macAddress: String?,
beaconType: String, beaconType: String,
token: String token: String
) async throws { ) async throws {
@ -230,43 +194,41 @@ 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(OKResponse.self, from: data) let resp = try JSONDecoder().decode(APIResponse<EmptyData>.self, from: data)
guard resp.OK else { guard resp.success else {
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to register beacon") throw APIError.serverError(resp.message ?? "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] = ["HardwareId": hardwareId, "UUID": uuid, "Major": major, "Minor": minor] let body: [String: Any] = ["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(OKResponse.self, from: data) let resp = try JSONDecoder().decode(APIResponse<EmptyData>.self, from: data)
guard resp.OK else { guard resp.success else {
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to verify broadcast") throw APIError.serverError(resp.message ?? "Failed to verify broadcast")
} }
} }
/// API returns: { "OK": true, "BusinessName": "...", "BusinessID": 5 } struct ResolveResponse: Codable {
private struct ResolveBusinessResponse: Codable { let businessName: String?
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(ResolveBusinessResponse.self, from: data) let resp = try JSONDecoder().decode(APIResponse<ResolveResponse>.self, from: data)
return resp.BusinessName ?? "Unknown" return resp.data?.name ?? "Unknown"
} }
// MARK: - Service Point Management // MARK: - Service Point Management
@ -274,24 +236,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(OKResponse.self, from: data) let resp = try JSONDecoder().decode(APIResponse<EmptyData>.self, from: data)
guard resp.OK else { guard resp.success else {
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to delete service point") throw APIError.serverError(resp.message ?? "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(OKResponse.self, from: data) let resp = try JSONDecoder().decode(APIResponse<EmptyData>.self, from: data)
guard resp.OK else { guard resp.success else {
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to update service point") throw APIError.serverError(resp.message ?? "Failed to update service point")
} }
} }
// MARK: - Beacon Management // MARK: - Beacon Management
struct BeaconListItem: Codable { struct BeaconListResponse: Codable {
let id: String? let id: String?
let ID: String? let ID: String?
let uuid: String? let uuid: String?
@ -310,135 +272,125 @@ actor APIClient {
let IsVerified: Bool? let IsVerified: Bool?
} }
/// API returns: { "OK": true, "BEACONS": [...] } func listBeacons(businessId: String, token: String) async throws -> [BeaconListResponse] {
private struct BeaconListAPIResponse: Codable {
let OK: Bool
let ERROR: String?
let BEACONS: [BeaconListItem]?
}
func listBeacons(businessId: String, token: String) async throws -> [BeaconListItem] {
let body: [String: Any] = ["BusinessID": businessId] let body: [String: Any] = ["BusinessID": businessId]
let data = try await post(path: "/beacons/list.php", body: body, token: token, businessId: businessId) let data = try await post(path: "/beacon-sharding/list_beacons.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(BeaconListAPIResponse.self, from: data) let resp = try JSONDecoder().decode(APIResponse<[BeaconListResponse]>.self, from: data)
guard resp.OK else { guard resp.success else {
throw APIError.serverError(resp.ERROR ?? "Failed to list beacons") throw APIError.serverError(resp.message ?? "Failed to list beacons")
} }
return resp.BEACONS ?? [] return resp.data ?? []
} }
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: "/beacons/wipe.php", body: body, token: token, businessId: businessId) let data = try await post(path: "/beacon-sharding/decommission_beacon.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(OKResponse.self, from: data) let resp = try JSONDecoder().decode(APIResponse<EmptyData>.self, from: data)
guard resp.OK else { guard resp.success else {
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to decommission beacon") throw APIError.serverError(resp.message ?? "Failed to decommission beacon")
} }
} }
func lookupByMac(macAddress: String, token: String) async throws -> BeaconListItem? { func lookupByMac(macAddress: String, token: String) async throws -> BeaconListResponse? {
let body: [String: Any] = ["MacAddress": macAddress] let body: [String: Any] = ["MacAddress": macAddress]
let data = try await post(path: "/beacons/lookupByMac.php", body: body, token: token) let data = try await post(path: "/beacon-sharding/lookup_by_mac.php", body: body, token: token)
// This may return a single beacon object or OK: false let resp = try JSONDecoder().decode(APIResponse<BeaconListResponse>.self, from: data)
struct LookupResponse: Codable { return resp.data
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 -> BeaconListItem? { func getBeaconStatus(uuid: String, major: Int, minor: Int, token: String) async throws -> BeaconListResponse? {
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/verify_beacon_broadcast.php", body: body, token: token) let data = try await post(path: "/beacon-sharding/beacon_status.php", body: body, token: token)
let resp = try JSONDecoder().decode(OKResponse.self, from: data) let resp = try JSONDecoder().decode(APIResponse<BeaconListResponse>.self, from: data)
guard resp.OK else { return nil } return resp.data
// 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 OK: Bool let uuid: String?
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 ?? "" } var configUUID: String { uuid ?? UUID ?? "" }
var configMajor: Int { Major ?? 0 } var configMajor: Int { major ?? Major ?? 0 }
var configMinor: Int { Minor ?? 0 } var configMinor: Int { minor ?? Minor ?? 0 }
var configTxPower: Int { TxPower ?? 1 } var configTxPower: Int { txPower ?? TxPower ?? 1 }
var configMeasuredPower: Int { MeasuredPower ?? -100 } var configMeasuredPower: Int { measuredPower ?? MeasuredPower ?? -100 }
var configAdvInterval: Int { AdvInterval ?? 2 } var configAdvInterval: Int { advInterval ?? 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(BeaconConfigResponse.self, from: data) let resp = try JSONDecoder().decode(APIResponse<BeaconConfigResponse>.self, from: data)
guard resp.OK else { guard resp.success, let config = resp.data else {
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to get beacon config") throw APIError.serverError(resp.message ?? "Failed to get beacon config")
} }
return resp return config
} }
// 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 OK: Bool? let id: String?
let ID: IntOrString? let ID: String?
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(UserProfile.self, from: data) let resp = try JSONDecoder().decode(APIResponse<UserProfile>.self, from: data)
return resp guard resp.success, let profile = resp.data else {
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],

View file

@ -2,7 +2,8 @@ import Foundation
import CoreBluetooth import CoreBluetooth
import Combine import Combine
/// Central BLE manager handles scanning and CP-28 beacon detection /// Central BLE manager handles scanning and beacon type detection
/// Matches Android's BeaconScanner.kt behavior
@MainActor @MainActor
final class BLEManager: NSObject, ObservableObject { final class BLEManager: NSObject, ObservableObject {
@ -12,23 +13,15 @@ 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 // MARK: - Constants (matching Android)
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
// CP-28 uses FFE0 service // GATT Service UUIDs
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
@ -60,7 +53,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
DispatchQueue.main.async { Task { @MainActor in
self?.stopScan() self?.stopScan()
} }
} }
@ -83,8 +76,11 @@ 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,
@ -92,94 +88,70 @@ final class BLEManager: NSObject, ObservableObject {
) )
} }
// MARK: - iBeacon Manufacturer Data Parsing // MARK: - Beacon Type Detection (matches Android BeaconScanner.kt)
// NOTE: Android also detects DX-Smart by MAC OUI prefix 48:87:2D.
/// Parse iBeacon data from manufacturer data (Apple company ID 0x4C00) // CoreBluetooth does not expose raw MAC addresses, so this detection
private func parseIBeaconData(_ mfgData: Data) -> (uuid: String, major: UInt16, minor: UInt16)? { // path is unavailable on iOS. We rely on service UUID + device name instead.
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()
// Parse iBeacon data if available // 1. Service UUID matching
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") }) {
// FFE0 with DX name patterns definitely CP-28 // Could be KBeacon or DXSmart check name to differentiate
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
} }
// FFE0 without a specific name still likely CP-28 return .kbeacon
return .dxsmart
}
// CP-28 also advertises FFF0 on some firmware
if serviceStrings.contains(where: { $0.hasPrefix("0000FFF0") }) {
// Any FFF0 device is likely CP-28 don't filter by name
return .dxsmart
} }
} }
// 2. DX-Smart factory default iBeacon UUID // 2. Device name patterns
if let ibeacon = iBeaconData { if deviceName.contains("kbeacon") || deviceName.contains("kbpro") ||
if ibeacon.uuid.caseInsensitiveCompare(Self.dxSmartDefaultUUID) == .orderedSame { deviceName.hasPrefix("kb") {
return .dxsmart return .kbeacon
} }
// Already provisioned with a Payfrit shard UUID if deviceName.contains("bluecharm") || deviceName.hasPrefix("bc") ||
if BeaconShardPool.isPayfrit(ibeacon.uuid) { deviceName.hasPrefix("table-") {
return .dxsmart return .bluecharm
}
} }
// 3. Device name patterns for CP-28 (includes "payfrit" our own provisioned name)
if deviceName.contains("cp28") || deviceName.contains("cp-28") || if deviceName.contains("cp28") || deviceName.contains("cp-28") ||
deviceName.contains("dx-cp") || deviceName.contains("dx-smart") || deviceName.contains("dx-smart") || deviceName.contains("dxsmart") ||
deviceName.contains("dxsmart") || deviceName.contains("pddaxlque") || deviceName.contains("pddaxlque") {
deviceName.contains("payfrit") {
return .dxsmart return .dxsmart
} }
// 4. iBeacon minor in high range (factory default DX pattern) // 3. Generic beacon patterns
if let ibeacon = iBeaconData, ibeacon.minor > 10000 { if deviceName.contains("ibeacon") || deviceName.contains("beacon") ||
return .dxsmart deviceName.hasPrefix("ble") {
return .dxsmart // Default to DXSmart like Android
} }
// 5. Any iBeacon advertisement likely a CP-28 in the field // 4. Check manufacturer data for iBeacon advertisement
if iBeaconData != nil { if let mfgData = manufacturerData, mfgData.count >= 23 {
return .dxsmart // 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
}
} }
// Not a CP-28 don't show it return .unknown
return nil
} }
} }
@ -188,27 +160,8 @@ final class BLEManager: NSObject, ObservableObject {
extension BLEManager: CBCentralManagerDelegate { extension BLEManager: CBCentralManagerDelegate {
nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) { nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) {
let state = central.state Task { @MainActor in
DispatchQueue.main.async { [weak self] in bluetoothState = central.state
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)
} }
} }
@ -218,34 +171,32 @@ extension BLEManager: CBCentralManagerDelegate {
advertisementData: [String: Any], advertisementData: [String: Any],
rssi RSSI: NSNumber rssi RSSI: NSNumber
) { ) {
let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "" Task { @MainActor in
let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? ""
let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID]
let peripheralId = peripheral.identifier let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
let rssiValue = RSSI.intValue
DispatchQueue.main.async { [weak self] in let type = detectBeaconType(name: name, serviceUUIDs: serviceUUIDs, manufacturerData: mfgData)
guard let self else { return }
// Detect beacon type default to .dxsmart so ALL devices show up in scan // Only show recognized beacons
let type = self.detectBeaconType(name: name, serviceUUIDs: serviceUUIDs, manufacturerData: mfgData) ?? .dxsmart guard type != .unknown else { return }
if let idx = self.discoveredBeacons.firstIndex(where: { $0.id == peripheralId }) { if let idx = discoveredBeacons.firstIndex(where: { $0.id == peripheral.identifier }) {
self.discoveredBeacons[idx].rssi = rssiValue // Update existing
self.discoveredBeacons[idx].lastSeen = Date() discoveredBeacons[idx].rssi = RSSI.intValue
discoveredBeacons[idx].lastSeen = Date()
} else { } else {
// New beacon
let beacon = DiscoveredBeacon( let beacon = DiscoveredBeacon(
id: peripheralId, id: peripheral.identifier,
peripheral: peripheral, peripheral: peripheral,
name: name, name: name,
type: type, type: type,
rssi: rssiValue, rssi: RSSI.intValue,
lastSeen: Date() lastSeen: Date()
) )
self.discoveredBeacons.append(beacon) discoveredBeacons.append(beacon)
} }
self.discoveredBeacons.sort { $0.rssi > $1.rssi }
} }
} }
} }

View file

@ -1,56 +0,0 @@
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")
}
}

View file

@ -94,22 +94,18 @@ 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 // Auto-navigate if only one business (like Android)
if !appState.skipAutoNav { if list.count == 1, let only = list.first {
// Auto-navigate if only one business (like Android) appState.selectBusiness(only)
if list.count == 1, let only = list.first { return
appState.selectBusiness(only) }
return
} // Auto-navigate to last used business
if let lastId = AppPrefs.lastBusinessId,
// Auto-navigate to last used business let last = list.first(where: { $0.id == lastId }) {
if let lastId = AppPrefs.lastBusinessId, appState.selectBusiness(last)
let last = list.first(where: { $0.id == lastId }) { return
appState.selectBusiness(last)
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 {

View file

@ -241,31 +241,17 @@ 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 }
do { try? device.lockForConfiguration()
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) else { guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
NSLog("[QRScanner] ERROR: No back camera available") let input = try? AVCaptureDeviceInput(device: device) else { return }
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)

View file

@ -23,12 +23,10 @@ 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
@ -209,8 +207,8 @@ struct ScanView: View {
progressView(title: "Connecting…", message: statusMessage) progressView(title: "Connecting…", message: statusMessage)
case .connected: case .connected:
// Legacy auto-write skips this state now // DXSmart: beacon is flashing, show write button
progressView(title: "Connected…", message: statusMessage) dxsmartConnectedView
case .writing: case .writing:
progressView(title: "Writing Config…", message: statusMessage) progressView(title: "Writing Config…", message: statusMessage)
@ -303,7 +301,7 @@ struct ScanView: View {
.padding() .padding()
} }
} else { } else {
List(bleManager.discoveredBeacons.sorted { $0.rssi > $1.rssi }) { beacon in List(bleManager.discoveredBeacons) { beacon in
Button { Button {
selectedBeacon = beacon selectedBeacon = beacon
Task { await startProvisioning(beacon) } Task { await startProvisioning(beacon) }
@ -319,7 +317,46 @@ struct ScanView: View {
// MARK: - DXSmart Connected View // MARK: - DXSmart Connected View
// dxsmartConnectedView removed auto-write skips the manual confirmation step private var dxsmartConnectedView: some View {
VStack(spacing: 24) {
Spacer()
Image(systemName: "light.beacon.max")
.font(.system(size: 64))
.foregroundStyle(Color.payfritGreen)
.modifier(PulseEffectModifier())
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(Color.payfritGreen)
.controlSize(.large)
.padding(.horizontal, 32)
Button("Cancel") {
cancelProvisioning()
}
.foregroundStyle(.secondary)
Spacer()
}
}
// MARK: - Progress / Success / Failed Views // MARK: - Progress / Success / Failed Views
@ -333,58 +370,7 @@ 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)
} }
} }
@ -410,9 +396,10 @@ struct ScanView: View {
} }
private var failedView: some View { private var failedView: some View {
VStack(spacing: 16) { VStack(spacing: 24) {
Spacer()
Image(systemName: "xmark.circle.fill") Image(systemName: "xmark.circle.fill")
.font(.system(size: 48)) .font(.system(size: 64))
.foregroundStyle(Color.errorRed) .foregroundStyle(Color.errorRed)
Text("Provisioning Failed") Text("Provisioning Failed")
.font(.title2.bold()) .font(.title2.bold())
@ -422,11 +409,6 @@ 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 {
@ -445,8 +427,8 @@ struct ScanView: View {
resetProvisioningState() resetProvisioningState()
} }
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Spacer()
} }
.padding(.vertical, 8)
} }
// MARK: - Create Service Point Sheet // MARK: - Create Service Point Sheet
@ -543,19 +525,14 @@ struct ScanView: View {
let token = appState.token else { return } let token = appState.token else { return }
provisioningState = .connecting provisioningState = .connecting
statusMessage = "Allocating beacon config" statusMessage = "Connecting to \(beacon.displayName)"
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,
@ -570,58 +547,78 @@ struct ScanView: View {
// Create appropriate provisioner // Create appropriate provisioner
let provisioner = makeProvisioner(for: beacon) let provisioner = makeProvisioner(for: beacon)
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
}
provisionLog?.log("disconnect", "Unexpected disconnect: \(reason)", isError: true)
// For all active states, treat disconnect as failure
if self.provisioningState == .connecting ||
self.provisioningState == .writing || self.provisioningState == .verifying {
self.provisioningState = .failed
self.errorMessage = "Beacon disconnected: \(reason)"
}
}
}
}
statusMessage = "Authenticating with \(beacon.type.rawValue)"
try await provisioner.connect() try await provisioner.connect()
provisionLog.log("connect", "Connected and authenticated successfully")
// Auto-fire write immediately no pause needed // 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
return
}
// KBeacon / BlueCharm: write immediately
provisioningState = .writing provisioningState = .writing
writesCompleted = false statusMessage = "Writing \(config.formattedUUID.prefix(8))… Major:\(config.major) Minor:\(config.minor)"
statusMessage = "Writing config to DX-Smart…"
provisionLog.log("write", "Auto-writing config (no user tap needed)…")
try await provisioner.writeConfig(config) try await provisioner.writeConfig(config)
writesCompleted = true provisioner.disconnect()
// Brief settle after SaveConfig before dropping the BLE link. // Register with backend
try? await Task.sleep(nanoseconds: 50_000_000) try await APIClient.shared.registerBeaconHardware(
businessId: business.id,
servicePointId: sp.id,
uuid: ns.uuid,
major: ns.major,
minor: minor,
macAddress: nil,
beaconType: beacon.type.rawValue,
token: token
)
// 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
statusMessage = "Writing config to DX-Smart…"
do {
try await provisioner.writeConfig(config)
provisioner.disconnect() provisioner.disconnect()
try await APIClient.shared.registerBeaconHardware( try await APIClient.shared.registerBeaconHardware(
@ -630,7 +627,7 @@ struct ScanView: View {
uuid: ns.uuid, uuid: ns.uuid,
major: ns.major, major: ns.major,
minor: Int(config.minor), minor: Int(config.minor),
hardwareId: selectedBeacon?.id.uuidString ?? "unknown-ios", macAddress: nil,
beaconType: BeaconType.dxsmart.rawValue, beaconType: BeaconType.dxsmart.rawValue,
token: token token: token
) )
@ -639,15 +636,13 @@ 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
} }
}
// Kept for cancel/reset and registerAnywayAfterFailure fallback pendingConfig = nil
@State private var pendingConfig: BeaconConfig? pendingProvisioner = nil
@State private var pendingProvisioner: (any BeaconProvisioner)? }
private func registerAnywayAfterFailure() async { private func registerAnywayAfterFailure() async {
guard let sp = selectedServicePoint, guard let sp = selectedServicePoint,
@ -665,7 +660,7 @@ struct ScanView: View {
uuid: ns.uuid, uuid: ns.uuid,
major: ns.major, major: ns.major,
minor: Int(config.minor), minor: Int(config.minor),
hardwareId: selectedBeacon?.id.uuidString ?? "unknown-ios", macAddress: nil,
beaconType: selectedBeacon?.type.rawValue ?? "Unknown", beaconType: selectedBeacon?.type.rawValue ?? "Unknown",
token: token token: token
) )
@ -738,13 +733,17 @@ struct ScanView: View {
} }
private func makeProvisioner(for beacon: DiscoveredBeacon) -> any BeaconProvisioner { private func makeProvisioner(for beacon: DiscoveredBeacon) -> any BeaconProvisioner {
var provisioner: any BeaconProvisioner = DXSmartProvisioner( switch beacon.type {
peripheral: beacon.peripheral, case .kbeacon:
centralManager: bleManager.centralManager return KBeaconProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager)
) case .dxsmart:
provisioner.bleManager = bleManager return DXSmartProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager)
provisioner.diagnosticLog = provisionLog case .bluecharm:
return provisioner return BlueCharmProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager)
case .unknown:
// Try all provisioners in sequence (matches Android fallback behavior)
return FallbackProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager)
}
} }
} }
@ -810,7 +809,12 @@ struct BeaconRow: View {
} }
private var typeColor: Color { private var typeColor: Color {
return .payfritGreen switch beacon.type {
case .kbeacon: return .payfritGreen
case .dxsmart: return .warningOrange
case .bluecharm: return .infoBlue
case .unknown: return .gray
}
} }
} }