Compare commits

..

No commits in common. "main" and "schwifty/faster-provisioning" have entirely different histories.

3 changed files with 120 additions and 91 deletions

View file

@ -41,7 +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 diagnosticLog: ProvisionLog?
var bleManager: BLEManager? var bleManager: BLEManager?
@ -77,14 +76,6 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
await diagnosticLog?.log("auth", "Authenticating (trigger + password)…") 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)")") 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
@ -134,8 +125,6 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
} }
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)
} }
@ -182,21 +171,12 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
] ]
for (index, (name, packet)) in commands.enumerated() { for (index, (name, packet)) in commands.enumerated() {
// Bail immediately if BLE link dropped between commands
if disconnected {
await diagnosticLog?.log("write", "Aborting — BLE disconnected", isError: true)
throw ProvisionError.writeFailed("BLE disconnected during write sequence")
}
await diagnosticLog?.log("write", "[\(index + 1)/\(commands.count)] \(name) (\(packet.count) bytes)") 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. // SaveConfig (last command) causes beacon MCU to reboot it never sends an ACK.
// Use .withoutResponse so CoreBluetooth fires the bytes immediately into the // Fire the BLE write and return immediately; the disconnect is expected.
// 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" { if name == "SaveConfig" {
peripheral.writeValue(packet, for: writeChar, type: .withoutResponse) peripheral.writeValue(packet, for: writeChar, type: .withResponse)
await diagnosticLog?.log("write", "✅ [\(index + 1)/\(commands.count)] SaveConfig sent — beacon will reboot") await diagnosticLog?.log("write", "✅ [\(index + 1)/\(commands.count)] SaveConfig sent — beacon will reboot")
await diagnosticLog?.log("write", "✅ All commands written successfully") await diagnosticLog?.log("write", "✅ All commands written successfully")
return return
@ -222,8 +202,8 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
throw lastError throw lastError
} }
// 50ms between commands beacon handles fast writes fine (was 150ms, 300ms, 500ms) // 150ms between commands aggressive speedup (was 300ms, originally 500ms)
try await Task.sleep(nanoseconds: 50_000_000) try await Task.sleep(nanoseconds: 150_000_000)
} }
} }
@ -299,32 +279,6 @@ 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 {
@ -384,13 +338,15 @@ 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 // 500ms settle after auth beacon needs time to enter config mode,
// especially if BLE stack was stressed by prior provisioner attempts
try await Task.sleep(nanoseconds: 500_000_000)
} }
} }

View file

@ -220,7 +220,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,9 +230,9 @@ 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(OKResponse.self, from: data)
guard resp.OK else { guard resp.OK else {
@ -241,13 +241,12 @@ actor APIClient {
} }
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(OKResponse.self, from: data)
guard resp.OK else { guard resp.OK else {

View file

@ -209,8 +209,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)
@ -319,7 +319,51 @@ 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("Connected — Beacon is Flashing")
.font(.title2.bold())
Text("Confirm the beacon LED is flashing, then tap Write Config to program it.\n\nThe beacon will timeout if you wait too long.")
.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)
// Show diagnostic log
if !provisionLog.entries.isEmpty {
diagnosticLogView
}
Spacer()
}
}
// MARK: - Progress / Success / Failed Views // MARK: - Progress / Success / Failed Views
@ -570,8 +614,6 @@ 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 // Wire up real-time status updates from provisioner
if let dxProvisioner = provisioner as? DXSmartProvisioner { if let dxProvisioner = provisioner as? DXSmartProvisioner {
@ -598,8 +640,14 @@ struct ScanView: View {
provisionLog?.log("disconnect", "Unexpected disconnect: \(reason)", isError: true) provisionLog?.log("disconnect", "Unexpected disconnect: \(reason)", isError: true)
// For all active states, treat disconnect as failure // CP-28: disconnect during .connected is expected beacon keeps
if self.provisioningState == .connecting || // flashing after BLE drops. We'll reconnect when user taps Write Config.
if self.provisioningState == .connected {
provisionLog?.log("disconnect", "CP-28 idle disconnect — beacon still flashing, ignoring")
return
}
// For all other active states, treat disconnect as failure
if self.provisioningState == .connecting || self.provisioningState == .connected ||
self.provisioningState == .writing || self.provisioningState == .verifying { self.provisioningState == .writing || self.provisioningState == .verifying {
self.provisioningState = .failed self.provisioningState = .failed
self.errorMessage = "Beacon disconnected: \(reason)" self.errorMessage = "Beacon disconnected: \(reason)"
@ -611,32 +659,10 @@ struct ScanView: View {
try await provisioner.connect() try await provisioner.connect()
provisionLog.log("connect", "Connected and authenticated successfully") provisionLog.log("connect", "Connected and authenticated successfully")
// Auto-fire write immediately no pause needed // CP-28: stop at connected state, wait for user to confirm flashing
provisioningState = .writing provisioningState = .connected
writesCompleted = false pendingConfig = config
statusMessage = "Writing config to DX-Smart…" pendingProvisioner = provisioner
provisionLog.log("write", "Auto-writing config (no user tap needed)…")
try await provisioner.writeConfig(config)
writesCompleted = true
// Brief settle after SaveConfig before dropping the BLE link.
try? await Task.sleep(nanoseconds: 50_000_000)
provisioner.disconnect()
try await APIClient.shared.registerBeaconHardware(
businessId: business.id,
servicePointId: sp.id,
uuid: ns.uuid,
major: ns.major,
minor: Int(config.minor),
hardwareId: selectedBeacon?.id.uuidString ?? "unknown-ios",
beaconType: BeaconType.dxsmart.rawValue,
token: token
)
provisioningState = .done
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) provisionLog.log("error", "Provisioning failed after \(provisionLog.elapsed): \(error.localizedDescription)", isError: true)
@ -645,10 +671,58 @@ struct ScanView: View {
} }
} }
// Kept for cancel/reset and registerAnywayAfterFailure fallback // Store for DXSmart two-phase flow
@State private var pendingConfig: BeaconConfig? @State private var pendingConfig: BeaconConfig?
@State private var pendingProvisioner: (any BeaconProvisioner)? @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
writesCompleted = false
statusMessage = "Writing config to DX-Smart…"
do {
// Reconnect if the beacon dropped BLE during the "confirm flashing" wait
if !provisioner.isConnected {
provisionLog.log("write", "Beacon disconnected while waiting — reconnecting…")
statusMessage = "Reconnecting to beacon…"
try await provisioner.connect()
provisionLog.log("write", "Reconnected — writing config…")
statusMessage = "Writing config to DX-Smart…"
}
try await provisioner.writeConfig(config)
writesCompleted = true
// No explicit disconnect needed succeed() already disconnects
try await APIClient.shared.registerBeaconHardware(
businessId: business.id,
servicePointId: sp.id,
uuid: ns.uuid,
major: ns.major,
minor: Int(config.minor),
macAddress: nil,
beaconType: BeaconType.dxsmart.rawValue,
token: token
)
provisioningState = .done
statusMessage = "\(sp.name) — DX-Smart\nUUID: \(config.formattedUUID.prefix(13))\nMajor: \(config.major) Minor: \(config.minor)"
} catch {
provisioningState = .failed
errorMessage = error.localizedDescription
}
pendingConfig = nil
pendingProvisioner = nil
}
private func registerAnywayAfterFailure() async { private func registerAnywayAfterFailure() async {
guard let sp = selectedServicePoint, guard let sp = selectedServicePoint,
let ns = namespace, let ns = namespace,
@ -665,7 +739,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
) )