fix: three provisioning bugs causing beacon write failures

1. Minor allocation: reject minor=0 from API instead of silently using it.
   API returning null/0 means the service point isn't configured right.

2. DXSmart write reliability:
   - Add per-command retry (1 retry with 500ms backoff)
   - Increase inter-command delay from 200ms to 500ms
   - Increase post-auth settle from 100ms to 500ms
   - Add 2s cooldown in FallbackProvisioner between provisioner attempts
   The beacon's BLE stack gets hammered by KBeacon's 15 failed auth
   attempts before DXSmart even gets a chance. These timings give it
   breathing room.

3. KBeacon passwords: password 5 was a duplicate of password 3
   (both "1234567890123456"). Replaced with "000000" (6-char variant).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Schwifty 2026-03-23 02:29:16 +00:00
parent b88dded928
commit 7089224244
4 changed files with 43 additions and 14 deletions

View file

@ -172,14 +172,30 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
for (index, (name, packet)) in commands.enumerated() { for (index, (name, packet)) in commands.enumerated() {
await diagnosticLog?.log("write", "[\(index + 1)/\(commands.count)] \(name) (\(packet.count) bytes)") await diagnosticLog?.log("write", "[\(index + 1)/\(commands.count)] \(name) (\(packet.count) bytes)")
// Retry each command up to 2 times beacon BLE stack can be flaky after KBeacon fallback
var lastError: Error?
for writeAttempt in 1...2 {
do { do {
try await writeToCharAndWaitACK(writeChar, data: packet, label: name) try await writeToCharAndWaitACK(writeChar, data: packet, label: name)
lastError = nil
break
} catch { } catch {
await diagnosticLog?.log("write", "[\(index + 1)/\(commands.count)] \(name) FAILED: \(error.localizedDescription)", isError: true) lastError = error
throw error if writeAttempt == 1 {
await diagnosticLog?.log("write", "[\(index + 1)/\(commands.count)] \(name) retry after: \(error.localizedDescription)")
try await Task.sleep(nanoseconds: 500_000_000) // 500ms before retry
} }
// 200ms between commands (matches Android SDK timer interval) }
try await Task.sleep(nanoseconds: 200_000_000) }
if let lastError {
await diagnosticLog?.log("write", "[\(index + 1)/\(commands.count)] \(name) FAILED: \(lastError.localizedDescription)", isError: true)
throw lastError
}
// 500ms between commands beacon needs time to process, especially after
// prior KBeacon auth attempts that may have stressed the BLE stack
try await Task.sleep(nanoseconds: 500_000_000)
} }
} }
@ -320,7 +336,9 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
// Step 2: Auth password fire and forget // Step 2: Auth password fire and forget
if let authData = Self.defaultPassword.data(using: .utf8) { if let authData = Self.defaultPassword.data(using: .utf8) {
peripheral.writeValue(authData, for: ffe3, type: .withoutResponse) peripheral.writeValue(authData, for: ffe3, type: .withoutResponse)
try await Task.sleep(nanoseconds: 100_000_000) // 100ms settle // 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)
} }
} }

View file

@ -56,6 +56,12 @@ final class FallbackProvisioner: BeaconProvisioner {
provisioner.disconnect() provisioner.disconnect()
lastError = error lastError = error
await diagnosticLog?.log("fallback", "\(typeNames[index]) failed: \(error.localizedDescription)", isError: true) await diagnosticLog?.log("fallback", "\(typeNames[index]) failed: \(error.localizedDescription)", isError: true)
// 2s cooldown between provisioner attempts let the beacon's BLE stack recover
// from failed auth/connection before the next provisioner hammers it
if index < provisioners.count - 1 {
await diagnosticLog?.log("fallback", "Cooling down 2s before next provisioner…")
try? await Task.sleep(nanoseconds: 2_000_000_000)
}
} }
} }

View file

@ -24,12 +24,14 @@ final class KBeaconProvisioner: NSObject, BeaconProvisioner {
} }
// MARK: - Known passwords (tried in order, matching Android) // MARK: - Known passwords (tried in order, matching Android)
// Known KBeacon default passwords (tried in order)
// Note: password 5 was previously a duplicate of password 3 replaced with "000000" (6-char variant)
private static let passwords: [Data] = [ private static let passwords: [Data] = [
"kd1234".data(using: .utf8)!, "kd1234".data(using: .utf8)!, // KBeacon factory default
Data(repeating: 0, count: 16), Data(repeating: 0, count: 16), // Binary zeros (16 bytes)
Data([0x31,0x32,0x33,0x34,0x35,0x36,0x37,0x38,0x39,0x30,0x31,0x32,0x33,0x34,0x35,0x36]), Data([0x31,0x32,0x33,0x34,0x35,0x36,0x37,0x38,0x39,0x30,0x31,0x32,0x33,0x34,0x35,0x36]), // "1234567890123456"
"0000000000000000".data(using: .utf8)!, "0000000000000000".data(using: .utf8)!, // ASCII zeros (16 bytes)
"1234567890123456".data(using: .utf8)! "000000".data(using: .utf8)!, // Short zero default (6 bytes)
] ]
// MARK: - State // MARK: - State

View file

@ -201,7 +201,10 @@ actor APIClient {
guard resp.OK else { guard resp.OK else {
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to allocate minor") throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to allocate minor")
} }
return resp.BeaconMinor ?? 0 guard let minor = resp.BeaconMinor, minor > 0 else {
throw APIError.serverError("API returned invalid minor value: \(resp.BeaconMinor ?? 0). Service point may not be configured correctly.")
}
return minor
} }
/// API returns: { "OK": true, "BeaconHardwareID": 42, ... } /// API returns: { "OK": true, "BeaconHardwareID": 42, ... }