payfrit-beacon-ios/PayfritBeacon/Provisioners/DXSmartProvisioner.swift
Schwifty c3f2b4faab fix: add real-time status updates during beacon provisioning
- DXSmartProvisioner now reports each phase (connecting, discovering
  services, authenticating, retrying) via onStatusUpdate callback
- ScanView shows live diagnostic log during connecting/writing states,
  not just on failure — so you can see exactly where it stalls
- Unexpected BLE disconnects now properly update provisioningState to
  .failed instead of silently logging
- Added cancel button to connecting progress view
- "Connected" screen title changed to "Connected — Beacon is Flashing"
  for clearer status indication

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

457 lines
20 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)")
do {
try await writeToCharAndWaitACK(writeChar, data: packet, label: name)
} catch {
await diagnosticLog?.log("write", "[\(index + 1)/\(commands.count)] \(name) FAILED: \(error.localizedDescription)", isError: true)
throw error
}
// 200ms between commands (matches Android SDK timer interval)
try await Task.sleep(nanoseconds: 200_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)
try await Task.sleep(nanoseconds: 100_000_000) // 100ms 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
}
// Handle write errors for command writes
if let error, let cont = responseContinuation {
responseContinuation = nil
cont.resume(throwing: error)
}
}
}