payfrit-beacon-ios/PayfritBeacon/Provisioners/DXSmartProvisioner.swift
Schwifty 66cf65f803 fix: trim auth and post-write delays from 600ms down to 100ms total
- Auth trigger settle: 100ms → 50ms
- Auth password settle: 500ms → 50ms
- Post-write reboot settle: 200ms → 50ms

Beacon handles 50ms inter-command just fine, no reason for the
beginning and end to be slower.

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

535 lines
24 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
private var disconnected = false // Set true when BLE link drops unexpectedly
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)")")
// Register for unexpected disconnects so we fail fast instead of
// waiting for per-command ACK timeouts (5s × 2 = 10s of dead air).
bleManager?.onPeripheralDisconnected = { [weak self] disconnectedPeripheral, error in
guard disconnectedPeripheral.identifier == self?.peripheral.identifier else { return }
self?.handleUnexpectedDisconnect(error: error)
}
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() {
// Unregister disconnect handler so intentional disconnect doesn't trigger error path
bleManager?.onPeripheralDisconnected = nil
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() {
// Bail immediately if BLE link dropped between commands
if disconnected {
await diagnosticLog?.log("write", "Aborting — BLE disconnected", isError: true)
throw ProvisionError.writeFailed("BLE disconnected during write sequence")
}
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.
// Use .withoutResponse so CoreBluetooth fires the bytes immediately into the
// BLE radio buffer without waiting for a GATT round-trip. With .withResponse,
// the beacon reboots before the ACK arrives, and CoreBluetooth may silently
// drop the write leaving the config unsaved and the beacon still flashing.
if name == "SaveConfig" {
peripheral.writeValue(packet, for: writeChar, type: .withoutResponse)
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
}
// 50ms between commands beacon handles fast writes fine (was 150ms, 300ms, 500ms)
try await Task.sleep(nanoseconds: 50_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: - Disconnect Detection
/// Called when BLE link drops unexpectedly during provisioning.
/// Immediately resolves any pending continuations so we fail fast
/// instead of waiting for the 5s operationTimeout.
private func handleUnexpectedDisconnect(error: Error?) {
disconnected = true
isConnected = false
let disconnectError = ProvisionError.writeFailed("BLE disconnected unexpectedly: \(error?.localizedDescription ?? "unknown")")
Task { await diagnosticLog?.log("ble", "⚠️ Unexpected disconnect during provisioning", isError: true) }
// Cancel any pending write/response continuation immediately
if let cont = responseContinuation {
responseContinuation = nil
cont.resume(throwing: disconnectError)
}
if let cont = writeContinuation {
writeContinuation = nil
cont.resume(throwing: disconnectError)
}
if let cont = connectionContinuation {
connectionContinuation = nil
cont.resume(throwing: disconnectError)
}
}
// 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: 50_000_000) // 50ms settle
}
// Step 2: Auth password fire and forget
if let authData = Self.defaultPassword.data(using: .utf8) {
peripheral.writeValue(authData, for: ffe3, type: .withoutResponse)
try await Task.sleep(nanoseconds: 50_000_000) // 50ms settle
}
}
/// 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())
}
}
}
}