fix: add real-time status updates to KBeacon provisioner + fix disconnect handler

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) <noreply@anthropic.com>
This commit is contained in:
Schwifty 2026-03-23 00:40:43 +00:00
parent c3f2b4faab
commit 61862adfa8
2 changed files with 36 additions and 3 deletions

View file

@ -47,6 +47,9 @@ final class KBeaconProvisioner: NSObject, BeaconProvisioner {
var diagnosticLog: ProvisionLog? var diagnosticLog: ProvisionLog?
var bleManager: BLEManager? var bleManager: BLEManager?
/// Status callback provisioner reports what phase it's in so UI can update
var onStatusUpdate: ((String) -> Void)?
// MARK: - Init // MARK: - Init
init(peripheral: CBPeripheral, centralManager: CBCentralManager) { init(peripheral: CBPeripheral, centralManager: CBCentralManager) {
@ -62,14 +65,27 @@ final class KBeaconProvisioner: NSObject, BeaconProvisioner {
// Connect with retry // Connect with retry
for attempt in 1...GATTConstants.maxRetries { for attempt in 1...GATTConstants.maxRetries {
do { 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() try await connectOnce()
await MainActor.run { onStatusUpdate?("Discovering services…") }
await diagnosticLog?.log("connect", "Connected — discovering services…")
try await discoverServices() 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() try await authenticate()
await diagnosticLog?.log("auth", "Auth success")
isConnected = true isConnected = true
return return
} catch { } catch {
await diagnosticLog?.log("connect", "Attempt \(attempt) failed: \(error.localizedDescription)", isError: true)
disconnect() disconnect()
if attempt < GATTConstants.maxRetries { if attempt < GATTConstants.maxRetries {
await MainActor.run { onStatusUpdate?("Retrying… (\(attempt)/\(GATTConstants.maxRetries))") }
try await Task.sleep(nanoseconds: UInt64(GATTConstants.retryDelay * 1_000_000_000)) try await Task.sleep(nanoseconds: UInt64(GATTConstants.retryDelay * 1_000_000_000))
} else { } else {
throw error throw error
@ -111,17 +127,23 @@ final class KBeaconProvisioner: NSObject, BeaconProvisioner {
params.append(UInt8(config.advInterval & 0xFF)) params.append(UInt8(config.advInterval & 0xFF))
// Send CMD_WRITE_PARAMS // 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 writeCmd = Data([CMD.writeParams.rawValue]) + params
let writeResp = try await sendCommand(writeCmd) let writeResp = try await sendCommand(writeCmd)
guard writeResp.first == CMD.writeParams.rawValue else { guard writeResp.first == CMD.writeParams.rawValue else {
throw ProvisionError.writeFailed("Unexpected write response") throw ProvisionError.writeFailed("Unexpected write response")
} }
await diagnosticLog?.log("write", "Params written OK — saving to flash…")
// Send CMD_SAVE to flash // Send CMD_SAVE to flash
await MainActor.run { onStatusUpdate?("Saving to flash…") }
let saveResp = try await sendCommand(Data([CMD.save.rawValue])) let saveResp = try await sendCommand(Data([CMD.save.rawValue]))
guard saveResp.first == CMD.save.rawValue else { guard saveResp.first == CMD.save.rawValue else {
throw ProvisionError.saveFailed throw ProvisionError.saveFailed
} }
await MainActor.run { onStatusUpdate?("Config saved ✓") }
await diagnosticLog?.log("write", "Save confirmed")
} }
func disconnect() { func disconnect() {
@ -180,14 +202,20 @@ final class KBeaconProvisioner: NSObject, BeaconProvisioner {
} }
private func authenticate() async throws { 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 let cmd = Data([CMD.auth.rawValue]) + password
do { do {
let resp = try await sendCommand(cmd) let resp = try await sendCommand(cmd)
if resp.first == CMD.auth.rawValue && resp.count > 1 && resp[1] == 0x00 { if resp.first == CMD.auth.rawValue && resp.count > 1 && resp[1] == 0x00 {
await MainActor.run { onStatusUpdate?("Authenticated ✓") }
return // Auth success return // Auth success
} }
await diagnosticLog?.log("auth", "Password \(index + 1) rejected (response: \(resp.map { String(format: "%02X", $0) }.joined()))")
} catch { } catch {
await diagnosticLog?.log("auth", "Password \(index + 1) timeout: \(error.localizedDescription)")
continue continue
} }
} }

View file

@ -619,6 +619,10 @@ struct ScanView: View {
dxProvisioner.onStatusUpdate = { [weak self] status in dxProvisioner.onStatusUpdate = { [weak self] status in
self?.statusMessage = status self?.statusMessage = status
} }
} else if let kbProvisioner = provisioner as? KBeaconProvisioner {
kbProvisioner.onStatusUpdate = { [weak self] status in
self?.statusMessage = status
}
} }
statusMessage = "Connecting to \(beacon.displayName)" statusMessage = "Connecting to \(beacon.displayName)"
@ -630,9 +634,10 @@ struct ScanView: View {
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
let reason = error?.localizedDescription ?? "beacon timed out" let reason = error?.localizedDescription ?? "beacon timed out"
provisionLog?.log("disconnect", "Unexpected disconnect: \(reason)", isError: true) 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, 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.provisioningState = .failed
self.errorMessage = "Beacon disconnected: \(reason)" self.errorMessage = "Beacon disconnected: \(reason)"
} }