From 61862adfa8bb3daf3c7fdf42b357f704ba283ad9 Mon Sep 17 00:00:00 2001 From: Schwifty Date: Mon, 23 Mar 2026 00:40:43 +0000 Subject: [PATCH 1/2] fix: add real-time status updates to KBeacon provisioner + fix disconnect handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KBeaconProvisioner had no onStatusUpdate callback, so the UI showed a static "Connecting..." message during the entire auth cycle (5 passwords × 5s timeout × 3 retries = 75s of dead silence). Now reports each phase: connecting, discovering services, authenticating (with password attempt count), writing, saving. Also fixed ScanView disconnect handler to cover .writing and .verifying states — previously only handled .connecting/.connected, so a mid-write disconnect left the UI permanently stuck. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Provisioners/KBeaconProvisioner.swift | 30 ++++++++++++++++++- PayfritBeacon/Views/ScanView.swift | 9 ++++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/PayfritBeacon/Provisioners/KBeaconProvisioner.swift b/PayfritBeacon/Provisioners/KBeaconProvisioner.swift index f2e702e..7f693ee 100644 --- a/PayfritBeacon/Provisioners/KBeaconProvisioner.swift +++ b/PayfritBeacon/Provisioners/KBeaconProvisioner.swift @@ -47,6 +47,9 @@ final class KBeaconProvisioner: NSObject, BeaconProvisioner { var diagnosticLog: ProvisionLog? var bleManager: BLEManager? + /// Status callback — provisioner reports what phase it's in so UI can update + var onStatusUpdate: ((String) -> Void)? + // MARK: - Init init(peripheral: CBPeripheral, centralManager: CBCentralManager) { @@ -62,14 +65,27 @@ final class KBeaconProvisioner: NSObject, BeaconProvisioner { // Connect with retry for attempt in 1...GATTConstants.maxRetries { do { + let attemptLabel = GATTConstants.maxRetries > 1 ? " (attempt \(attempt)/\(GATTConstants.maxRetries))" : "" + await MainActor.run { onStatusUpdate?("Connecting to beacon…\(attemptLabel)") } + await diagnosticLog?.log("connect", "Attempt \(attempt)/\(GATTConstants.maxRetries)") 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 — write:\(writeChar != nil) notify:\(notifyChar != nil)") + + await MainActor.run { onStatusUpdate?("Authenticating…") } + await diagnosticLog?.log("auth", "Trying \(Self.passwords.count) passwords…") try await authenticate() + await diagnosticLog?.log("auth", "Auth success") isConnected = true return } catch { + await diagnosticLog?.log("connect", "Attempt \(attempt) failed: \(error.localizedDescription)", isError: true) disconnect() if attempt < GATTConstants.maxRetries { + await MainActor.run { onStatusUpdate?("Retrying… (\(attempt)/\(GATTConstants.maxRetries))") } try await Task.sleep(nanoseconds: UInt64(GATTConstants.retryDelay * 1_000_000_000)) } else { throw error @@ -111,17 +127,23 @@ final class KBeaconProvisioner: NSObject, BeaconProvisioner { params.append(UInt8(config.advInterval & 0xFF)) // Send CMD_WRITE_PARAMS + await MainActor.run { onStatusUpdate?("Writing beacon parameters…") } + await diagnosticLog?.log("write", "Sending CMD_WRITE_PARAMS (\(params.count) bytes)…") let writeCmd = Data([CMD.writeParams.rawValue]) + params let writeResp = try await sendCommand(writeCmd) guard writeResp.first == CMD.writeParams.rawValue else { throw ProvisionError.writeFailed("Unexpected write response") } + await diagnosticLog?.log("write", "Params written OK — saving to flash…") // Send CMD_SAVE to flash + await MainActor.run { onStatusUpdate?("Saving to flash…") } let saveResp = try await sendCommand(Data([CMD.save.rawValue])) guard saveResp.first == CMD.save.rawValue else { throw ProvisionError.saveFailed } + await MainActor.run { onStatusUpdate?("Config saved ✓") } + await diagnosticLog?.log("write", "Save confirmed") } func disconnect() { @@ -180,14 +202,20 @@ final class KBeaconProvisioner: NSObject, BeaconProvisioner { } private func authenticate() async throws { - for password in Self.passwords { + for (index, password) in Self.passwords.enumerated() { + let passwordLabel = String(data: password.prefix(6), encoding: .utf8) ?? "binary" + await MainActor.run { onStatusUpdate?("Authenticating… (\(index + 1)/\(Self.passwords.count))") } + await diagnosticLog?.log("auth", "Trying password \(index + 1)/\(Self.passwords.count): \(passwordLabel)…") let cmd = Data([CMD.auth.rawValue]) + password do { let resp = try await sendCommand(cmd) if resp.first == CMD.auth.rawValue && resp.count > 1 && resp[1] == 0x00 { + await MainActor.run { onStatusUpdate?("Authenticated ✓") } return // Auth success } + await diagnosticLog?.log("auth", "Password \(index + 1) rejected (response: \(resp.map { String(format: "%02X", $0) }.joined()))") } catch { + await diagnosticLog?.log("auth", "Password \(index + 1) timeout: \(error.localizedDescription)") continue } } diff --git a/PayfritBeacon/Views/ScanView.swift b/PayfritBeacon/Views/ScanView.swift index bfb1698..6a02741 100644 --- a/PayfritBeacon/Views/ScanView.swift +++ b/PayfritBeacon/Views/ScanView.swift @@ -619,6 +619,10 @@ struct ScanView: View { dxProvisioner.onStatusUpdate = { [weak self] status in self?.statusMessage = status } + } else if let kbProvisioner = provisioner as? KBeaconProvisioner { + kbProvisioner.onStatusUpdate = { [weak self] status in + self?.statusMessage = status + } } statusMessage = "Connecting to \(beacon.displayName)…" @@ -630,9 +634,10 @@ struct ScanView: View { 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 we're in any active provisioning state, show failure if let self = self, - self.provisioningState == .connecting || self.provisioningState == .connected { + self.provisioningState == .connecting || self.provisioningState == .connected || + self.provisioningState == .writing || self.provisioningState == .verifying { self.provisioningState = .failed self.errorMessage = "Beacon disconnected: \(reason)" } From 8f8fcba9c015adec07db9ee56be180b5bf77f287 Mon Sep 17 00:00:00 2001 From: Schwifty Date: Mon, 23 Mar 2026 00:48:09 +0000 Subject: [PATCH 2/2] fix: prevent DXSmart disconnect race killing the "Write Config" screen After DXSmart auth completes, the beacon often drops BLE connection due to aggressive timeouts. The disconnect handler was treating this as a failure, stomping the .connected state before the user could see the "Write Config" button. Changes: - Ignore BLE disconnects during .connected state for DXSmart beacons (the LED keeps flashing regardless of BLE connection) - Auto-reconnect in writeConfigToConnectedBeacon() if BLE dropped while waiting for user confirmation Co-Authored-By: Claude Opus 4.6 (1M context) --- PayfritBeacon/Views/ScanView.swift | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/PayfritBeacon/Views/ScanView.swift b/PayfritBeacon/Views/ScanView.swift index 6a02741..416e0ec 100644 --- a/PayfritBeacon/Views/ScanView.swift +++ b/PayfritBeacon/Views/ScanView.swift @@ -634,9 +634,15 @@ struct ScanView: View { Task { @MainActor [weak self] in let reason = error?.localizedDescription ?? "beacon timed out" provisionLog?.log("disconnect", "Unexpected disconnect: \(reason)", isError: true) - // If we're in any active provisioning state, show failure - if let self = self, - self.provisioningState == .connecting || self.provisioningState == .connected || + guard let self = self else { return } + // DXSmart: disconnect during .connected is expected — beacon keeps + // flashing after BLE drops. We'll reconnect when user taps Write Config. + if self.provisioningState == .connected && beacon.type == .dxsmart { + provisionLog?.log("disconnect", "DXSmart 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 = .failed self.errorMessage = "Beacon disconnected: \(reason)" @@ -718,6 +724,15 @@ struct ScanView: View { 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) provisioner.disconnect()