fix: add real-time status updates during beacon provisioning

- DXSmartProvisioner now reports each phase (connecting, discovering
  services, authenticating, retrying) via onStatusUpdate callback
- ScanView shows live diagnostic log during connecting/writing states,
  not just on failure — so you can see exactly where it stalls
- Unexpected BLE disconnects now properly update provisioningState to
  .failed instead of silently logging
- Added cancel button to connecting progress view
- "Connected" screen title changed to "Connected — Beacon is Flashing"
  for clearer status indication

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Schwifty 2026-03-23 00:01:01 +00:00
parent 349dab1b75
commit c3f2b4faab
2 changed files with 88 additions and 34 deletions

View file

@ -55,15 +55,24 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
// MARK: - BeaconProvisioner
/// Status callback provisioner reports what phase it's in so UI can update
var onStatusUpdate: ((String) -> Void)?
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 {
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()
await MainActor.run { onStatusUpdate?("Discovering services…") }
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 MainActor.run { onStatusUpdate?("Authenticating…") }
await diagnosticLog?.log("auth", "Authenticating (trigger + password)…")
try await authenticate()
await diagnosticLog?.log("auth", "Auth complete — SDK: \(useNewSDK ? "new (FFE2)" : "old (FFE1)")")
@ -74,6 +83,7 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
await diagnosticLog?.log("connect", "Attempt \(attempt) failed: \(error.localizedDescription)", isError: true)
disconnect()
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))
} else {

View file

@ -327,10 +327,10 @@ struct ScanView: View {
.foregroundStyle(Color.payfritGreen)
.modifier(PulseEffectModifier())
Text("Beacon is Flashing")
Text("Connected — Beacon is Flashing")
.font(.title2.bold())
Text("Confirm the beacon LED is flashing, then tap Write Config to program it.")
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)
@ -355,6 +355,11 @@ struct ScanView: View {
}
.foregroundStyle(.secondary)
// Show diagnostic log
if !provisionLog.entries.isEmpty {
diagnosticLogView
}
Spacer()
}
}
@ -371,7 +376,58 @@ struct ScanView: View {
Text(message)
.font(.subheadline)
.foregroundStyle(.secondary)
// Show live diagnostic log during connecting/writing
if !provisionLog.entries.isEmpty {
diagnosticLogView
}
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)
}
}
@ -411,33 +467,7 @@ struct ScanView: View {
// 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)
}
diagnosticLogView
}
HStack(spacing: 16) {
@ -556,7 +586,7 @@ struct ScanView: View {
let token = appState.token else { return }
provisioningState = .connecting
statusMessage = "Connecting to \(beacon.displayName)"
statusMessage = "Allocating beacon config"
errorMessage = nil
provisionLog.reset()
provisionLog.log("init", "Starting provisioning: \(beacon.displayName) (\(beacon.type.rawValue)) RSSI:\(beacon.rssi)")
@ -584,20 +614,34 @@ struct ScanView: View {
// Create appropriate provisioner
let provisioner = makeProvisioner(for: beacon)
statusMessage = "Authenticating with \(beacon.type.rawValue)"
// 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 {
Task { @MainActor in
provisionLog?.log("disconnect", "Unexpected disconnect: \(error?.localizedDescription ?? "no error")", isError: true)
Task { @MainActor [weak self] in
let reason = error?.localizedDescription ?? "beacon timed out"
provisionLog?.log("disconnect", "Unexpected disconnect: \(reason)", isError: true)
// If we're still in connecting or connected state, show failure
if let self = self,
self.provisioningState == .connecting || self.provisioningState == .connected {
self.provisioningState = .failed
self.errorMessage = "Beacon disconnected: \(reason)"
}
}
}
}
try await provisioner.connect()
provisionLog.log("connect", "Connected successfully")
provisionLog.log("connect", "Connected and authenticated successfully")
// DXSmart: stop at connected state, wait for user to confirm flashing
if beacon.type == .dxsmart {