diff --git a/PayfritBeacon/Provisioners/DXSmartProvisioner.swift b/PayfritBeacon/Provisioners/DXSmartProvisioner.swift index 03ab017..b6aa21b 100644 --- a/PayfritBeacon/Provisioners/DXSmartProvisioner.swift +++ b/PayfritBeacon/Provisioners/DXSmartProvisioner.swift @@ -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 { diff --git a/PayfritBeacon/Views/ScanView.swift b/PayfritBeacon/Views/ScanView.swift index 776884a..bfb1698 100644 --- a/PayfritBeacon/Views/ScanView.swift +++ b/PayfritBeacon/Views/ScanView.swift @@ -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 {