payfrit-beacon-ios/PayfritBeacon/Provisioners/KBeaconProvisioner.swift
Schwifty 7089224244 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>
2026-03-23 02:29:16 +00:00

311 lines
13 KiB
Swift

import Foundation
import CoreBluetooth
/// Provisioner for KBeacon / KBPro hardware
/// Protocol: FFE0 service, FFE1 write, FFE2 notify
/// Auth via CMD_AUTH (0x01), config via CMD_WRITE_PARAMS (0x03), save via CMD_SAVE (0x04)
final class KBeaconProvisioner: NSObject, BeaconProvisioner {
// MARK: - Protocol Commands
private enum CMD: UInt8 {
case auth = 0x01
case readParams = 0x02
case writeParams = 0x03
case save = 0x04
}
// MARK: - Parameter IDs
private enum ParamID: UInt8 {
case uuid = 0x10
case major = 0x11
case minor = 0x12
case txPower = 0x13
case advInterval = 0x14
}
// 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] = [
"kd1234".data(using: .utf8)!, // KBeacon factory default
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]), // "1234567890123456"
"0000000000000000".data(using: .utf8)!, // ASCII zeros (16 bytes)
"000000".data(using: .utf8)!, // Short zero default (6 bytes)
]
// MARK: - State
private let peripheral: CBPeripheral
private let centralManager: CBCentralManager
private var writeChar: CBCharacteristic?
private var notifyChar: CBCharacteristic?
private var connectionContinuation: CheckedContinuation<Void, Error>?
private var serviceContinuation: CheckedContinuation<Void, Error>?
private var writeContinuation: CheckedContinuation<Data, Error>?
private(set) var isConnected = false
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) {
self.peripheral = peripheral
self.centralManager = centralManager
super.init()
self.peripheral.delegate = self
}
// MARK: - BeaconProvisioner
func connect() async throws {
// 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
}
}
}
}
func writeConfig(_ config: BeaconConfig) async throws {
guard isConnected, let writeChar else {
throw ProvisionError.notConnected
}
// Build parameter payload
var params = Data()
// UUID (16 bytes)
params.append(ParamID.uuid.rawValue)
let uuidBytes = config.uuid.hexToBytes
params.append(contentsOf: uuidBytes)
// Major (2 bytes BE)
params.append(ParamID.major.rawValue)
params.append(UInt8(config.major >> 8))
params.append(UInt8(config.major & 0xFF))
// Minor (2 bytes BE)
params.append(ParamID.minor.rawValue)
params.append(UInt8(config.minor >> 8))
params.append(UInt8(config.minor & 0xFF))
// TX Power
params.append(ParamID.txPower.rawValue)
params.append(config.txPower)
// Adv Interval
params.append(ParamID.advInterval.rawValue)
params.append(UInt8(config.advInterval >> 8))
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() {
if peripheral.state == .connected || peripheral.state == .connecting {
centralManager.cancelPeripheralConnection(peripheral)
}
isConnected = false
}
// MARK: - Private: Connection
private func connectOnce() async throws {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
connectionContinuation = cont
// Register for connection callbacks via BLEManager (the CBCentralManagerDelegate)
bleManager?.onPeripheralConnected = { [weak self] connectedPeripheral in
guard connectedPeripheral.identifier == self?.peripheral.identifier else { return }
if let c = self?.connectionContinuation {
self?.connectionContinuation = nil
c.resume()
}
}
bleManager?.onPeripheralFailedToConnect = { [weak self] failedPeripheral, error in
guard failedPeripheral.identifier == self?.peripheral.identifier else { return }
if let c = self?.connectionContinuation {
self?.connectionContinuation = nil
c.resume(throwing: error ?? ProvisionError.connectionTimeout)
}
}
centralManager.connect(peripheral, options: nil)
// Timeout
DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.connectionTimeout) { [weak self] in
if let c = self?.connectionContinuation {
self?.connectionContinuation = nil
c.resume(throwing: ProvisionError.connectionTimeout)
}
}
}
}
private func discoverServices() async throws {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
serviceContinuation = cont
peripheral.discoverServices([GATTConstants.ffe0Service])
DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in
if let c = self?.serviceContinuation {
self?.serviceContinuation = nil
c.resume(throwing: ProvisionError.serviceDiscoveryTimeout)
}
}
}
}
private func authenticate() async throws {
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
}
}
throw ProvisionError.authFailed
}
private func sendCommand(_ data: Data) async throws -> Data {
guard let writeChar else { throw ProvisionError.notConnected }
return try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Data, Error>) in
writeContinuation = cont
peripheral.writeValue(data, for: writeChar, type: .withResponse)
DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in
if let c = self?.writeContinuation {
self?.writeContinuation = nil
c.resume(throwing: ProvisionError.operationTimeout)
}
}
}
}
}
// MARK: - CBPeripheralDelegate
extension KBeaconProvisioner: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
if let error {
serviceContinuation?.resume(throwing: error)
serviceContinuation = nil
return
}
guard let service = peripheral.services?.first(where: { $0.uuid == GATTConstants.ffe0Service }) else {
serviceContinuation?.resume(throwing: ProvisionError.serviceNotFound)
serviceContinuation = nil
return
}
peripheral.discoverCharacteristics([GATTConstants.ffe1Char, GATTConstants.ffe2Char], for: service)
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
if let error {
serviceContinuation?.resume(throwing: error)
serviceContinuation = nil
return
}
for char in service.characteristics ?? [] {
switch char.uuid {
case GATTConstants.ffe1Char:
writeChar = char
case GATTConstants.ffe2Char:
notifyChar = char
peripheral.setNotifyValue(true, for: char)
default:
break
}
}
if writeChar != nil {
serviceContinuation?.resume()
serviceContinuation = nil
} else {
serviceContinuation?.resume(throwing: ProvisionError.characteristicNotFound)
serviceContinuation = nil
}
}
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
guard characteristic.uuid == GATTConstants.ffe2Char,
let data = characteristic.value else { return }
if let cont = writeContinuation {
writeContinuation = nil
cont.resume(returning: data)
}
}
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
// Write acknowledgment actual response comes via notify on FFE2
if let error {
if let cont = writeContinuation {
writeContinuation = nil
cont.resume(throwing: error)
}
}
}
}