Compare commits
4 commits
640bc32f92
...
66cf65f803
| Author | SHA1 | Date | |
|---|---|---|---|
| 66cf65f803 | |||
| 38600193b7 | |||
| 3c41ecb49d | |||
| a08d3db893 |
2 changed files with 68 additions and 101 deletions
|
|
@ -41,6 +41,7 @@ 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
|
||||
private var disconnected = false // Set true when BLE link drops unexpectedly
|
||||
var diagnosticLog: ProvisionLog?
|
||||
var bleManager: BLEManager?
|
||||
|
||||
|
|
@ -76,6 +77,14 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
|
|||
await diagnosticLog?.log("auth", "Authenticating (trigger + password)…")
|
||||
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
|
||||
isFlashing = true
|
||||
return
|
||||
|
|
@ -125,6 +134,8 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
|
|||
}
|
||||
|
||||
func disconnect() {
|
||||
// Unregister disconnect handler so intentional disconnect doesn't trigger error path
|
||||
bleManager?.onPeripheralDisconnected = nil
|
||||
if peripheral.state == .connected || peripheral.state == .connecting {
|
||||
centralManager.cancelPeripheralConnection(peripheral)
|
||||
}
|
||||
|
|
@ -171,6 +182,12 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
|
|||
]
|
||||
|
||||
for (index, (name, packet)) in commands.enumerated() {
|
||||
// Bail immediately if BLE link dropped between commands
|
||||
if disconnected {
|
||||
await diagnosticLog?.log("write", "Aborting — BLE disconnected", isError: true)
|
||||
throw ProvisionError.writeFailed("BLE disconnected during write sequence")
|
||||
}
|
||||
|
||||
await diagnosticLog?.log("write", "[\(index + 1)/\(commands.count)] \(name) (\(packet.count) bytes)")
|
||||
|
||||
// SaveConfig (last command) causes beacon MCU to reboot — it never sends an ACK.
|
||||
|
|
@ -205,8 +222,8 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
|
|||
throw lastError
|
||||
}
|
||||
|
||||
// 150ms between commands — aggressive speedup (was 300ms, originally 500ms)
|
||||
try await Task.sleep(nanoseconds: 150_000_000)
|
||||
// 50ms between commands — beacon handles fast writes fine (was 150ms, 300ms, 500ms)
|
||||
try await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -282,6 +299,32 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
|
|||
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
|
||||
|
||||
private func connectOnce() async throws {
|
||||
|
|
@ -341,15 +384,13 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
|
|||
// Step 1: Trigger — fire and forget (matches Android's WRITE_TYPE_NO_RESPONSE)
|
||||
if let triggerData = Self.triggerPassword.data(using: .utf8) {
|
||||
peripheral.writeValue(triggerData, for: ffe3, type: .withoutResponse)
|
||||
try await Task.sleep(nanoseconds: 100_000_000) // 100ms (matches Android SDK timer)
|
||||
try await Task.sleep(nanoseconds: 50_000_000) // 50ms settle
|
||||
}
|
||||
|
||||
// Step 2: Auth password — fire and forget
|
||||
if let authData = Self.defaultPassword.data(using: .utf8) {
|
||||
peripheral.writeValue(authData, for: ffe3, type: .withoutResponse)
|
||||
// 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)
|
||||
try await Task.sleep(nanoseconds: 50_000_000) // 50ms settle
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -209,8 +209,8 @@ struct ScanView: View {
|
|||
progressView(title: "Connecting…", message: statusMessage)
|
||||
|
||||
case .connected:
|
||||
// DXSmart: beacon is flashing, show write button
|
||||
dxsmartConnectedView
|
||||
// Legacy — auto-write skips this state now
|
||||
progressView(title: "Connected…", message: statusMessage)
|
||||
|
||||
case .writing:
|
||||
progressView(title: "Writing Config…", message: statusMessage)
|
||||
|
|
@ -319,51 +319,7 @@ struct ScanView: View {
|
|||
|
||||
// MARK: - DXSmart Connected View
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
// dxsmartConnectedView removed — auto-write skips the manual confirmation step
|
||||
|
||||
// MARK: - Progress / Success / Failed Views
|
||||
|
||||
|
|
@ -614,6 +570,8 @@ struct ScanView: View {
|
|||
|
||||
// Create appropriate provisioner
|
||||
let provisioner = makeProvisioner(for: beacon)
|
||||
pendingProvisioner = provisioner
|
||||
pendingConfig = config
|
||||
|
||||
// Wire up real-time status updates from provisioner
|
||||
if let dxProvisioner = provisioner as? DXSmartProvisioner {
|
||||
|
|
@ -640,14 +598,8 @@ struct ScanView: View {
|
|||
|
||||
provisionLog?.log("disconnect", "Unexpected disconnect: \(reason)", isError: true)
|
||||
|
||||
// CP-28: disconnect during .connected is expected — beacon keeps
|
||||
// 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 ||
|
||||
// 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)"
|
||||
|
|
@ -659,46 +611,18 @@ struct ScanView: View {
|
|||
try await provisioner.connect()
|
||||
provisionLog.log("connect", "Connected and authenticated successfully")
|
||||
|
||||
// CP-28: stop at connected state, wait for user to confirm flashing
|
||||
provisioningState = .connected
|
||||
pendingConfig = config
|
||||
pendingProvisioner = provisioner
|
||||
|
||||
} catch {
|
||||
provisionLog.log("error", "Provisioning failed after \(provisionLog.elapsed): \(error.localizedDescription)", isError: true)
|
||||
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 }
|
||||
|
||||
// Auto-fire write immediately — no pause needed
|
||||
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…"
|
||||
}
|
||||
provisionLog.log("write", "Auto-writing config (no user tap needed)…")
|
||||
|
||||
try await provisioner.writeConfig(config)
|
||||
writesCompleted = true
|
||||
// No explicit disconnect needed — succeed() already disconnects
|
||||
|
||||
// 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,
|
||||
|
|
@ -715,14 +639,16 @@ struct ScanView: View {
|
|||
statusMessage = "\(sp.name) — DX-Smart\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
|
||||
}
|
||||
|
||||
pendingConfig = nil
|
||||
pendingProvisioner = nil
|
||||
}
|
||||
|
||||
// Kept for cancel/reset and registerAnywayAfterFailure fallback
|
||||
@State private var pendingConfig: BeaconConfig?
|
||||
@State private var pendingProvisioner: (any BeaconProvisioner)?
|
||||
|
||||
private func registerAnywayAfterFailure() async {
|
||||
guard let sp = selectedServicePoint,
|
||||
let ns = namespace,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue