fix: connection callback bug + add provisioning diagnostics

BIG FIX: Provisioners were calling centralManager.connect() but
BLEManager is the CBCentralManagerDelegate — provisioners never
received didConnect/didFailToConnect callbacks, so connections
ALWAYS timed out after 5s regardless. This is why provisioning
kept failing. Fixed by:
1. Adding didConnect/didFailToConnect/didDisconnect to BLEManager
2. Provisioners register connection callbacks via bleManager
3. Increased connection timeout from 5s to 10s

DIAGNOSTICS: Added ProvisionLog system so failures show a timestamped
step-by-step log of what happened (with Share button). Every phase
is logged: init, API calls, connect attempts, service discovery,
auth, write commands, and errors.
This commit is contained in:
Schwifty 2026-03-22 23:12:06 +00:00
parent c879ecd442
commit c243235237
9 changed files with 263 additions and 16 deletions

View file

@ -24,6 +24,7 @@
A01000000031 /* APIConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000031 /* APIConfig.swift */; };
A01000000032 /* BLEManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000032 /* BLEManager.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 */; };
A01000000041 /* BeaconShardPool.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000041 /* BeaconShardPool.swift */; };
A01000000042 /* UUIDFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000042 /* UUIDFormatting.swift */; };
@ -57,6 +58,7 @@
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>"; };
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>"; };
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>"; };
@ -159,6 +161,7 @@
A02000000031 /* APIConfig.swift */,
A02000000032 /* BLEManager.swift */,
A02000000033 /* SecureStorage.swift */,
A02000000034 /* ProvisionLog.swift */,
);
path = Services;
sourceTree = "<group>";
@ -276,6 +279,7 @@
A01000000031 /* APIConfig.swift in Sources */,
A01000000032 /* BLEManager.swift in Sources */,
A01000000033 /* SecureStorage.swift in Sources */,
A01000000034 /* ProvisionLog.swift in Sources */,
A01000000040 /* BeaconBanList.swift in Sources */,
A01000000041 /* BeaconShardPool.swift in Sources */,
A01000000042 /* UUIDFormatting.swift in Sources */,

View file

@ -57,6 +57,8 @@ final class BlueCharmProvisioner: NSObject, BeaconProvisioner {
private var writeOKContinuation: CheckedContinuation<Void, Error>?
private(set) var isConnected = false
var diagnosticLog: ProvisionLog?
var bleManager: BLEManager?
// MARK: - Init
@ -249,6 +251,23 @@ final class BlueCharmProvisioner: NSObject, BeaconProvisioner {
private func connectOnce() async throws {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
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)
DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.connectionTimeout) { [weak self] in

View file

@ -41,6 +41,8 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
private(set) var isConnected = false
private(set) var isFlashing = false // Beacon LED flashing after trigger
private var useNewSDK = true // Prefer new SDK, fallback to old
var diagnosticLog: ProvisionLog?
var bleManager: BLEManager?
// MARK: - Init
@ -55,18 +57,27 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
func connect() async throws {
for attempt in 1...GATTConstants.maxRetries {
await diagnosticLog?.log("connect", "Attempt \(attempt)/\(GATTConstants.maxRetries) — peripheral: \(peripheral.name ?? "unnamed"), state: \(peripheral.state.rawValue)")
do {
await diagnosticLog?.log("connect", "Connecting (timeout: \(GATTConstants.connectionTimeout)s)…")
try await connectOnce()
await diagnosticLog?.log("connect", "Connected — discovering services…")
try await discoverServices()
await diagnosticLog?.log("connect", "Services found — FFE1:\(ffe1Char != nil) FFE2:\(ffe2Char != nil) FFE3:\(ffe3Char != nil)")
await diagnosticLog?.log("auth", "Authenticating (trigger + password)…")
try await authenticate()
await diagnosticLog?.log("auth", "Auth complete — SDK: \(useNewSDK ? "new (FFE2)" : "old (FFE1)")")
isConnected = true
isFlashing = true
return
} catch {
await diagnosticLog?.log("connect", "Attempt \(attempt) failed: \(error.localizedDescription)", isError: true)
disconnect()
if attempt < GATTConstants.maxRetries {
await diagnosticLog?.log("connect", "Retrying in \(GATTConstants.retryDelay)s…")
try await Task.sleep(nanoseconds: UInt64(GATTConstants.retryDelay * 1_000_000_000))
} else {
await diagnosticLog?.log("connect", "All \(GATTConstants.maxRetries) attempts exhausted", isError: true)
throw error
}
}
@ -75,23 +86,31 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
func writeConfig(_ config: BeaconConfig) async throws {
guard isConnected else {
await diagnosticLog?.log("write", "Not connected — aborting write", isError: true)
throw ProvisionError.notConnected
}
let uuidBytes = config.uuid.hexToBytes
guard uuidBytes.count == 16 else {
await diagnosticLog?.log("write", "Invalid UUID length: \(uuidBytes.count) bytes", isError: true)
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)
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)
} 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)
} else {
await diagnosticLog?.log("write", "No write characteristic available", isError: true)
throw ProvisionError.characteristicNotFound
}
await diagnosticLog?.log("write", "All commands written successfully")
isFlashing = false
}
@ -141,8 +160,14 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
("SaveConfig", buildProtocolPacket(cmd: 0x60, data: Data())),
]
for (name, packet) in commands {
for (index, (name, packet)) in commands.enumerated() {
await diagnosticLog?.log("write", "[\(index + 1)/\(commands.count)] \(name) (\(packet.count) bytes)")
do {
try await writeToCharAndWaitACK(writeChar, data: packet, label: name)
} catch {
await diagnosticLog?.log("write", "[\(index + 1)/\(commands.count)] \(name) FAILED: \(error.localizedDescription)", isError: true)
throw error
}
// 200ms between commands (matches Android SDK timer interval)
try await Task.sleep(nanoseconds: 200_000_000)
}
@ -225,6 +250,23 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
private func connectOnce() async throws {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
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)
DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.connectionTimeout) { [weak self] in

View file

@ -10,6 +10,8 @@ final class FallbackProvisioner: BeaconProvisioner {
private var activeProvisioner: (any BeaconProvisioner)?
private(set) var isConnected: Bool = false
var diagnosticLog: ProvisionLog?
var bleManager: BLEManager?
init(peripheral: CBPeripheral, centralManager: CBCentralManager) {
self.peripheral = peripheral
@ -18,23 +20,42 @@ final class FallbackProvisioner: BeaconProvisioner {
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) },
{ [self] in
var p = KBeaconProvisioner(peripheral: peripheral, centralManager: centralManager)
p.diagnosticLog = diagnosticLog
p.bleManager = bleManager
return p
},
{ [self] in
var p = DXSmartProvisioner(peripheral: peripheral, centralManager: centralManager)
p.diagnosticLog = diagnosticLog
p.bleManager = bleManager
return p
},
{ [self] in
var p = BlueCharmProvisioner(peripheral: peripheral, centralManager: centralManager)
p.diagnosticLog = diagnosticLog
p.bleManager = bleManager
return p
},
]
let typeNames = ["KBeacon", "DXSmart", "BlueCharm"]
var lastError: Error = ProvisionError.connectionTimeout
for makeProvisioner in provisioners {
for (index, makeProvisioner) in provisioners.enumerated() {
await diagnosticLog?.log("fallback", "Trying \(typeNames[index]) provisioner…")
let provisioner = makeProvisioner()
do {
try await provisioner.connect()
activeProvisioner = provisioner
isConnected = true
await diagnosticLog?.log("fallback", "\(typeNames[index]) connected successfully")
return
} catch {
provisioner.disconnect()
lastError = error
await diagnosticLog?.log("fallback", "\(typeNames[index]) failed: \(error.localizedDescription)", isError: true)
}
}

View file

@ -44,6 +44,8 @@ final class KBeaconProvisioner: NSObject, BeaconProvisioner {
private var writeContinuation: CheckedContinuation<Data, Error>?
private(set) var isConnected = false
var diagnosticLog: ProvisionLog?
var bleManager: BLEManager?
// MARK: - Init
@ -134,6 +136,23 @@ final class KBeaconProvisioner: NSObject, BeaconProvisioner {
private func connectOnce() async throws {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
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)
// Timeout

View file

@ -14,6 +14,12 @@ protocol BeaconProvisioner {
/// Whether we're currently connected
var isConnected: Bool { get }
/// Optional diagnostic log for tracing provisioning steps
var diagnosticLog: ProvisionLog? { get set }
/// BLE manager reference for connection callbacks
var bleManager: BLEManager? { get set }
}
/// GATT UUIDs shared across provisioner types
@ -32,7 +38,7 @@ enum GATTConstants {
static let cccd = CBUUID(string: "00002902-0000-1000-8000-00805F9B34FB")
// Timeouts (matching Android)
static let connectionTimeout: TimeInterval = 5.0
static let connectionTimeout: TimeInterval = 10.0 // Increased from 5s BLE connections can be slow
static let operationTimeout: TimeInterval = 5.0
static let maxRetries = 3
static let retryDelay: TimeInterval = 1.0

View file

@ -27,6 +27,14 @@ final class BLEManager: NSObject, ObservableObject {
// DX-Smart factory default iBeacon UUID
static let dxSmartDefaultUUID = "E2C56DB5-DFFB-48D2-B060-D0F5A71096E0"
// MARK: - Connection Callbacks (used by provisioners)
// Provisioners call centralManager.connect() but BLEManager is the delegate,
// so we need to forward connection events back to provisioners via closures.
var onPeripheralConnected: ((CBPeripheral) -> Void)?
var onPeripheralFailedToConnect: ((CBPeripheral, Error?) -> Void)?
var onPeripheralDisconnected: ((CBPeripheral, Error?) -> Void)?
// MARK: - Private
private(set) var centralManager: CBCentralManager!
@ -232,6 +240,24 @@ extension BLEManager: CBCentralManagerDelegate {
}
}
nonisolated func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
Task { @MainActor in
onPeripheralConnected?(peripheral)
}
}
nonisolated func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
Task { @MainActor in
onPeripheralFailedToConnect?(peripheral, error)
}
}
nonisolated func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
Task { @MainActor in
onPeripheralDisconnected?(peripheral, error)
}
}
nonisolated func centralManager(
_ central: CBCentralManager,
didDiscover peripheral: CBPeripheral,

View file

@ -0,0 +1,56 @@
import Foundation
/// Timestamped diagnostic log for beacon provisioning.
/// Captures every step so we can diagnose failures.
@MainActor
final class ProvisionLog: ObservableObject {
struct Entry: Identifiable {
let id = UUID()
let timestamp: Date
let phase: String // "connect", "discover", "auth", "write", "verify"
let message: String
let isError: Bool
var formatted: String {
let t = Self.formatter.string(from: timestamp)
let prefix = isError ? "" : ""
return "\(t) [\(phase)] \(prefix) \(message)"
}
private static let formatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "HH:mm:ss.SSS"
return f
}()
}
@Published private(set) var entries: [Entry] = []
private var startTime: Date?
/// Clear log for a new provisioning attempt
func reset() {
entries = []
startTime = Date()
}
/// Add a log entry
func log(_ phase: String, _ message: String, isError: Bool = false) {
let entry = Entry(timestamp: Date(), phase: phase, message: message, isError: isError)
entries.append(entry)
}
/// Elapsed time since reset
var elapsed: String {
guard let start = startTime else { return "0.0s" }
let seconds = Date().timeIntervalSince(start)
return String(format: "%.1fs", seconds)
}
/// Full log as shareable text
var fullText: String {
let header = "Payfrit Beacon Diagnostic Log"
let time = "Session: \(elapsed)"
let lines = entries.map { $0.formatted }
return ([header, time, "---"] + lines).joined(separator: "\n")
}
}

View file

@ -27,6 +27,7 @@ struct ScanView: View {
@State private var errorMessage: String?
@State private var showQRScanner = false
@State private var scannedMAC: String?
@StateObject private var provisionLog = ProvisionLog()
enum ProvisioningState {
case idle
@ -396,10 +397,9 @@ struct ScanView: View {
}
private var failedView: some View {
VStack(spacing: 24) {
Spacer()
VStack(spacing: 16) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 64))
.font(.system(size: 48))
.foregroundStyle(Color.errorRed)
Text("Provisioning Failed")
.font(.title2.bold())
@ -409,6 +409,37 @@ struct ScanView: View {
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
// Diagnostic log
if !provisionLog.entries.isEmpty {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("Diagnostic Log (\(provisionLog.elapsed))")
.font(.caption.bold())
Spacer()
ShareLink(item: provisionLog.fullText) {
Label("Share", systemImage: "square.and.arrow.up")
.font(.caption)
}
}
.padding(.horizontal, 16)
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)
}
}
.padding(.horizontal, 16)
}
.frame(maxHeight: 200)
.background(Color(.systemGray6))
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding(.horizontal, 16)
}
}
HStack(spacing: 16) {
Button("Try Again") {
if let beacon = selectedBeacon {
@ -427,8 +458,8 @@ struct ScanView: View {
resetProvisioningState()
}
.foregroundStyle(.secondary)
Spacer()
}
.padding(.vertical, 8)
}
// MARK: - Create Service Point Sheet
@ -527,12 +558,17 @@ struct ScanView: View {
provisioningState = .connecting
statusMessage = "Connecting to \(beacon.displayName)"
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 {
// Allocate minor for this service point
provisionLog.log("api", "Allocating minor for service point \(sp.id)")
let minor = try await APIClient.shared.allocateMinor(
businessId: business.id, servicePointId: sp.id, token: token
)
provisionLog.log("api", "Minor allocated: \(minor)")
let config = BeaconConfig(
uuid: ns.uuid.normalizedUUID,
@ -549,7 +585,19 @@ struct ScanView: View {
let provisioner = makeProvisioner(for: beacon)
statusMessage = "Authenticating with \(beacon.type.rawValue)"
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 {
Task { @MainActor in
provisionLog?.log("disconnect", "Unexpected disconnect: \(error?.localizedDescription ?? "no error")", isError: true)
}
}
}
try await provisioner.connect()
provisionLog.log("connect", "Connected successfully")
// DXSmart: stop at connected state, wait for user to confirm flashing
if beacon.type == .dxsmart {
@ -563,7 +611,9 @@ struct ScanView: View {
// KBeacon / BlueCharm: write immediately
provisioningState = .writing
statusMessage = "Writing \(config.formattedUUID.prefix(8))… Major:\(config.major) Minor:\(config.minor)"
provisionLog.log("write", "Writing config…")
try await provisioner.writeConfig(config)
provisionLog.log("write", "Config written — disconnecting")
provisioner.disconnect()
// Register with backend
@ -598,6 +648,7 @@ struct ScanView: View {
statusMessage = "\(sp.name)\(beacon.type.rawValue)\nUUID: \(config.formattedUUID.prefix(13))\nMajor: \(config.major) Minor: \(config.minor)"
} catch {
provisionLog.log("error", "Provisioning failed after \(provisionLog.elapsed): \(error.localizedDescription)", isError: true)
provisioningState = .failed
errorMessage = error.localizedDescription
}
@ -733,17 +784,20 @@ struct ScanView: View {
}
private func makeProvisioner(for beacon: DiscoveredBeacon) -> any BeaconProvisioner {
var provisioner: any BeaconProvisioner
switch beacon.type {
case .kbeacon:
return KBeaconProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager)
provisioner = KBeaconProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager)
case .dxsmart:
return DXSmartProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager)
provisioner = DXSmartProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager)
case .bluecharm:
return BlueCharmProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager)
provisioner = 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)
provisioner = FallbackProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager)
}
provisioner.bleManager = bleManager
provisioner.diagnosticLog = provisionLog
return provisioner
}
}