payfrit-beacon-ios/PayfritBeacon/Provisioners/DXSmartProvisioner.swift
Schwifty f082eeadad fix: skip ACK wait on SaveConfig — beacon reboots, never ACKs
SaveConfig (0x60) causes the beacon MCU to reboot and save to flash.
It never sends an ACK, so writeToCharAndWaitACK would wait for the
5s timeout, during which the beacon disconnects. The disconnect
handler fires while writesCompleted is still false, causing a false
"Unexpected disconnect: beacon timed out" error.

Fix: fire-and-forget the SaveConfig write and return immediately.
The BLE-level write (.withResponse) confirms delivery. writeConfig()
returns before the disconnect callback runs, so writesCompleted gets
set to true in time.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 03:44:36 +00:00

492 lines
22 KiB
Swift

import Foundation
import CoreBluetooth
/// Provisioner for DXSmart / CP28 hardware
///
/// Implements BOTH the new SDK protocol (preferred) and old SDK fallback:
///
/// **New SDK (2024.10+)**: Writes to FFE2, notifications on FFE1
/// - Frame selection (0x11/0x12) frame type (0x62 = iBeacon)
/// - Param writes: 0x74 UUID, 0x75 Major, 0x76 Minor, 0x77 RSSI, 0x78 AdvInt, 0x79 TxPower
/// - Save: 0x60
/// - All wrapped in 4E 4F protocol packets
///
/// **Old SDK fallback**: Writes to FFE1, re-sends 555555 before each command
/// - 0x36 UUID, 0x37 Major, 0x38 Minor, 0x39 TxPower, 0x40 RfPower, 0x41 AdvInt, 0x43 Name
/// - 0x44 Restart (includes password)
///
/// Auth: "555555" to FFE3 (config mode) "dx1234" to FFE3 (authenticate)
/// NOTE: CoreBluetooth doesn't expose raw MAC addresses, so 48:87:2D OUI detection
/// (used on Android) is not available on iOS. Beacons are detected by name/service UUID.
final class DXSmartProvisioner: NSObject, BeaconProvisioner {
// MARK: - Constants
private static let triggerPassword = "555555"
private static let defaultPassword = "dx1234"
// MARK: - State
private let peripheral: CBPeripheral
private let centralManager: CBCentralManager
private var ffe1Char: CBCharacteristic? // FFE1 notify (ACK responses)
private var ffe2Char: CBCharacteristic? // FFE2 write (new SDK commands)
private var ffe3Char: CBCharacteristic? // FFE3 password
private var connectionContinuation: CheckedContinuation<Void, Error>?
private var serviceContinuation: CheckedContinuation<Void, Error>?
private var responseContinuation: CheckedContinuation<Data, Error>?
private var writeContinuation: CheckedContinuation<Void, Error>?
private(set) var isConnected = false
private(set) var isFlashing = false // Beacon LED flashing after trigger
private var useNewSDK = true // Prefer new SDK, fallback to old
var diagnosticLog: ProvisionLog?
var bleManager: BLEManager?
// MARK: - Init
init(peripheral: CBPeripheral, centralManager: CBCentralManager) {
self.peripheral = peripheral
self.centralManager = centralManager
super.init()
self.peripheral.delegate = self
}
// 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)")")
isConnected = true
isFlashing = 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))") }
await diagnosticLog?.log("connect", "Retrying in \(GATTConstants.retryDelay)s…")
try await Task.sleep(nanoseconds: UInt64(GATTConstants.retryDelay * 1_000_000_000))
} else {
await diagnosticLog?.log("connect", "All \(GATTConstants.maxRetries) attempts exhausted", isError: true)
throw error
}
}
}
}
func writeConfig(_ config: BeaconConfig) async throws {
guard isConnected else {
await diagnosticLog?.log("write", "Not connected — aborting write", isError: true)
throw ProvisionError.notConnected
}
let uuidBytes = config.uuid.hexToBytes
guard uuidBytes.count == 16 else {
await diagnosticLog?.log("write", "Invalid UUID length: \(uuidBytes.count) bytes", isError: true)
throw ProvisionError.writeFailed("Invalid UUID length")
}
await diagnosticLog?.log("write", "Config: UUID=\(config.uuid.prefix(8))… Major=\(config.major) Minor=\(config.minor) TxPower=\(config.txPower) AdvInt=\(config.advInterval)")
// Try new SDK first (FFE2), fall back to old SDK (FFE1)
if useNewSDK, let ffe2 = ffe2Char {
await diagnosticLog?.log("write", "Using new SDK (FFE2) — 22 commands to write")
try await writeConfigNewSDK(config, uuidBytes: uuidBytes, writeChar: ffe2)
} else if let ffe1 = ffe1Char {
await diagnosticLog?.log("write", "Using old SDK (FFE1) — 7 commands to write")
try await writeConfigOldSDK(config, uuidBytes: uuidBytes, writeChar: ffe1)
} else {
await diagnosticLog?.log("write", "No write characteristic available", isError: true)
throw ProvisionError.characteristicNotFound
}
await diagnosticLog?.log("write", "All commands written successfully")
isFlashing = false
}
func disconnect() {
if peripheral.state == .connected || peripheral.state == .connecting {
centralManager.cancelPeripheralConnection(peripheral)
}
isConnected = false
isFlashing = false
}
// MARK: - New SDK Protocol (FFE2, 2024.10+)
// Matches Android DXSmartProvisioner.writeBeaconConfig()
private func writeConfigNewSDK(_ config: BeaconConfig, uuidBytes: [UInt8], writeChar: CBCharacteristic) async throws {
// Build command sequence matching Android's writeBeaconConfig()
let commands: [(String, Data)] = [
// Frame 1: device info + radio params
("Frame1_Select", buildProtocolPacket(cmd: 0x11, data: Data())),
("Frame1_DevInfo", buildProtocolPacket(cmd: 0x61, data: Data())),
("Frame1_RSSI", buildProtocolPacket(cmd: 0x77, data: Data([UInt8(bitPattern: config.measuredPower)]))),
("Frame1_AdvInt", buildProtocolPacket(cmd: 0x78, data: Data([UInt8(config.advInterval & 0xFF)]))),
("Frame1_TxPow", buildProtocolPacket(cmd: 0x79, data: Data([config.txPower]))),
// Frame 2: iBeacon config
("Frame2_Select", buildProtocolPacket(cmd: 0x12, data: Data())),
("Frame2_iBeacon", buildProtocolPacket(cmd: 0x62, data: Data())),
("UUID", buildProtocolPacket(cmd: 0x74, data: Data(uuidBytes))),
("Major", buildProtocolPacket(cmd: 0x75, data: Data([UInt8(config.major >> 8), UInt8(config.major & 0xFF)]))),
("Minor", buildProtocolPacket(cmd: 0x76, data: Data([UInt8(config.minor >> 8), UInt8(config.minor & 0xFF)]))),
("RSSI@1m", buildProtocolPacket(cmd: 0x77, data: Data([UInt8(bitPattern: config.measuredPower)]))),
("AdvInterval", buildProtocolPacket(cmd: 0x78, data: Data([UInt8(config.advInterval & 0xFF)]))),
("TxPower", buildProtocolPacket(cmd: 0x79, data: Data([config.txPower]))),
("TriggerOff", buildProtocolPacket(cmd: 0xA0, data: Data())),
// Disable frames 3-6
("Frame3_Select", buildProtocolPacket(cmd: 0x13, data: Data())),
("Frame3_NoData", buildProtocolPacket(cmd: 0xFF, data: Data())),
("Frame4_Select", buildProtocolPacket(cmd: 0x14, data: Data())),
("Frame4_NoData", buildProtocolPacket(cmd: 0xFF, data: Data())),
("Frame5_Select", buildProtocolPacket(cmd: 0x15, data: Data())),
("Frame5_NoData", buildProtocolPacket(cmd: 0xFF, data: Data())),
("Frame6_Select", buildProtocolPacket(cmd: 0x16, data: Data())),
("Frame6_NoData", buildProtocolPacket(cmd: 0xFF, data: Data())),
// Save to flash
("SaveConfig", buildProtocolPacket(cmd: 0x60, data: Data())),
]
for (index, (name, packet)) in commands.enumerated() {
await diagnosticLog?.log("write", "[\(index + 1)/\(commands.count)] \(name) (\(packet.count) bytes)")
// SaveConfig (last command) causes beacon MCU to reboot it never sends an ACK.
// Fire the BLE write and return immediately; the disconnect is expected.
if name == "SaveConfig" {
peripheral.writeValue(packet, for: writeChar, type: .withResponse)
await diagnosticLog?.log("write", "✅ [\(index + 1)/\(commands.count)] SaveConfig sent — beacon will reboot")
await diagnosticLog?.log("write", "✅ All commands written successfully")
return
}
// Retry each command up to 2 times beacon BLE stack can be flaky
var lastError: Error?
for writeAttempt in 1...2 {
do {
try await writeToCharAndWaitACK(writeChar, data: packet, label: name)
lastError = nil
break
} catch {
lastError = 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
}
}
}
if let lastError {
await diagnosticLog?.log("write", "[\(index + 1)/\(commands.count)] \(name) FAILED: \(lastError.localizedDescription)", isError: true)
throw lastError
}
// 300ms between commands conservative speedup (was 500ms)
// Beacon needs time to process each GATT write; 300ms tested safe
try await Task.sleep(nanoseconds: 300_000_000)
}
}
// MARK: - Old SDK Protocol (FFE1, pre-2024.10)
// Matches Android DXSmartProvisioner.writeFrame1()
// Key difference: must re-send "555555" to FFE3 before EVERY command
private func writeConfigOldSDK(_ config: BeaconConfig, uuidBytes: [UInt8], writeChar: CBCharacteristic) async throws {
guard let ffe3 = ffe3Char else {
throw ProvisionError.characteristicNotFound
}
let commands: [(String, Data)] = [
("UUID", buildProtocolPacket(cmd: 0x36, data: Data(uuidBytes))),
("Major", buildProtocolPacket(cmd: 0x37, data: Data([UInt8(config.major >> 8), UInt8(config.major & 0xFF)]))),
("Minor", buildProtocolPacket(cmd: 0x38, data: Data([UInt8(config.minor >> 8), UInt8(config.minor & 0xFF)]))),
("TxPower", buildProtocolPacket(cmd: 0x39, data: Data([UInt8(bitPattern: config.measuredPower)]))),
("RfPower", buildProtocolPacket(cmd: 0x40, data: Data([config.txPower]))),
("AdvInt", buildProtocolPacket(cmd: 0x41, data: Data([UInt8(config.advInterval & 0xFF)]))),
("Name", buildProtocolPacket(cmd: 0x43, data: Data("Payfrit".utf8))),
]
for (name, packet) in commands {
// Step 1: Re-send "555555" to FFE3 before each command (old SDK requirement)
if let triggerData = Self.triggerPassword.data(using: .utf8) {
peripheral.writeValue(triggerData, for: ffe3, type: .withResponse)
try await waitForWriteCallback()
}
// Step 2: 50ms delay (SDK timer, half of 100ms default tested OK)
try await Task.sleep(nanoseconds: 50_000_000)
// Step 3: Write command to FFE1
try await writeToCharAndWaitACK(writeChar, data: packet, label: name)
// 200ms settle between params
try await Task.sleep(nanoseconds: 200_000_000)
}
}
// MARK: - Read-Back Verification
/// Read frame 2 (iBeacon config) to verify the write succeeded.
/// Returns the raw response data, or nil if read fails.
func readFrame2() async throws -> Data? {
guard let ffe2 = ffe2Char ?? ffe1Char else { return nil }
let readCmd = buildProtocolPacket(cmd: 0x62, data: Data())
peripheral.writeValue(readCmd, for: ffe2, type: .withResponse)
do {
let response = try await waitForResponse(timeout: 2.0)
return response
} catch {
return nil
}
}
// MARK: - Protocol Packet Builder
// Format: 4E 4F [CMD] [LEN] [DATA...] [CHECKSUM]
// Checksum = XOR of CMD, LEN, and all data bytes
private func buildProtocolPacket(cmd: UInt8, data: Data) -> Data {
let len = UInt8(data.count)
var checksum = Int(cmd) ^ Int(len)
for byte in data {
checksum ^= Int(byte)
}
var packet = Data([0x4E, 0x4F, cmd, len])
packet.append(data)
packet.append(UInt8(checksum & 0xFF))
return packet
}
// MARK: - Private Helpers
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)
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)
}
}
}
}
/// Two-step DXSmart auth:
/// 1. Send "555555" to FFE3 fire and forget (WRITE_NO_RESPONSE) enters config mode
/// 2. Send "dx1234" to FFE3 fire and forget authenticates
/// Matches Android enterConfigModeAndLogin(): both use WRITE_TYPE_NO_RESPONSE
private func authenticate() async throws {
guard let ffe3 = ffe3Char else {
throw ProvisionError.characteristicNotFound
}
// Step 1: Trigger fire and forget (matches Android's WRITE_TYPE_NO_RESPONSE)
if let triggerData = Self.triggerPassword.data(using: .utf8) {
peripheral.writeValue(triggerData, for: ffe3, type: .withoutResponse)
try await Task.sleep(nanoseconds: 100_000_000) // 100ms (matches Android SDK timer)
}
// Step 2: Auth password fire and forget
if let authData = Self.defaultPassword.data(using: .utf8) {
peripheral.writeValue(authData, for: ffe3, type: .withoutResponse)
// 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)
}
}
/// Write data to a characteristic and wait for ACK notification on FFE1
private func writeToCharAndWaitACK(_ char: CBCharacteristic, data: Data, label: String) async throws {
let _ = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Data, Error>) in
responseContinuation = cont
peripheral.writeValue(data, for: char, type: .withResponse)
DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in
if let c = self?.responseContinuation {
self?.responseContinuation = nil
c.resume(throwing: ProvisionError.operationTimeout)
}
}
}
}
/// Wait for a write callback (used for FFE3 password writes in old SDK path)
private func waitForWriteCallback() async throws {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
writeContinuation = cont
DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in
if let c = self?.writeContinuation {
self?.writeContinuation = nil
c.resume(throwing: ProvisionError.operationTimeout)
}
}
}
}
/// Wait for a response notification with custom timeout
private func waitForResponse(timeout: TimeInterval) async throws -> Data {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Data, Error>) in
responseContinuation = cont
DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { [weak self] in
if let c = self?.responseContinuation {
self?.responseContinuation = nil
c.resume(throwing: ProvisionError.operationTimeout)
}
}
}
}
}
// MARK: - CBPeripheralDelegate
extension DXSmartProvisioner: 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, GATTConstants.ffe3Char],
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:
ffe1Char = char
// FFE1 is used for notify (ACK responses)
if char.properties.contains(.notify) {
peripheral.setNotifyValue(true, for: char)
}
case GATTConstants.ffe2Char:
ffe2Char = char
case GATTConstants.ffe3Char:
ffe3Char = char
default:
break
}
}
// Need at least FFE1 (notify) + FFE3 (password)
// FFE2 is preferred for writes but optional (old firmware uses FFE1)
if ffe1Char != nil && ffe3Char != nil {
useNewSDK = (ffe2Char != nil)
serviceContinuation?.resume()
serviceContinuation = nil
} else {
serviceContinuation?.resume(throwing: ProvisionError.characteristicNotFound)
serviceContinuation = nil
}
}
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
guard let data = characteristic.value else { return }
if let cont = responseContinuation {
responseContinuation = nil
cont.resume(returning: data)
}
}
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
// Handle write callback for old SDK FFE3 password writes
if let cont = writeContinuation {
writeContinuation = nil
if let error {
cont.resume(throwing: error)
} else {
cont.resume()
}
return
}
// For command writes (FFE1/FFE2): the .withResponse write confirmation
// IS the ACK. Some commands (e.g. 0x61 Frame1_DevInfo) don't send a
// separate FFE1 notification, so we must resolve here on success too.
// If a notification also arrives later, responseContinuation will already
// be nil harmless.
if let cont = responseContinuation {
responseContinuation = nil
if let error {
cont.resume(throwing: error)
} else {
cont.resume(returning: Data())
}
}
}
}