payfrit-beacon-ios/PayfritBeacon/Provisioners/DXSmartProvisioner.swift
Schwifty cfa78679be feat: complete rebuild of PayfritBeacon iOS from scratch
100% fresh codebase — no legacy code carried over. Built against
the Android beacon app as the behavioral spec.

Architecture:
- App: SwiftUI @main, AppState-driven navigation, Keychain storage
- Views: LoginView (OTP + biometric), BusinessListView, ScanView (provisioning hub)
- Models: Business, ServicePoint, BeaconConfig, BeaconType, DiscoveredBeacon
- Services: APIClient (actor, async/await), BLEManager (CoreBluetooth scanner)
- Provisioners: KBeacon, DXSmart (2-step auth + flashing), BlueCharm
- Utils: UUIDFormatting, BeaconBanList, BeaconShardPool (64 shards)

Matches Android feature parity:
- 4-screen flow: Login → Business Select → Scan/Provision
- 3 beacon types with correct GATT protocols and timeouts
- Namespace allocation via beacon-sharding API
- Smart service point naming (Table N auto-increment)
- DXSmart special flow (connect → flash → user confirms → write)
- Biometric auth, dev/prod build configs, DEV banner overlay

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

253 lines
9.1 KiB
Swift

import Foundation
import CoreBluetooth
/// Provisioner for DXSmart / CP28 hardware
/// Special two-step auth: "555555" FFE3 (starts flashing), then password FFE3
/// Config written as 10 iBeacon commands to FFE1, ACK'd by beacon, final 0x60 save
final class DXSmartProvisioner: NSObject, BeaconProvisioner {
// MARK: - Constants
private static let triggerPassword = "555555"
private static let defaultPassword = "dx1234"
// DXSmart iBeacon config command IDs
private enum ConfigCmd: UInt8 {
case setUUID1 = 0x50 // UUID bytes 0-7
case setUUID2 = 0x51 // UUID bytes 8-15
case setMajor = 0x52
case setMinor = 0x53
case setTxPower = 0x54
case setInterval = 0x55
case setMeasured = 0x56
case setName = 0x57
case reserved1 = 0x58
case reserved2 = 0x59
case save = 0x60
}
// MARK: - State
private let peripheral: CBPeripheral
private let centralManager: CBCentralManager
private var writeChar: CBCharacteristic? // FFE1 TX/RX
private var passwordChar: CBCharacteristic? // FFE3 Password
private var notifyChar: CBCharacteristic? // FFE1 also used for notify
private var connectionContinuation: CheckedContinuation<Void, Error>?
private var serviceContinuation: CheckedContinuation<Void, Error>?
private var responseContinuation: CheckedContinuation<Data, Error>?
private(set) var isConnected = false
private(set) var isFlashing = false // Beacon LED flashing after trigger
// 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 {
for attempt in 1...GATTConstants.maxRetries {
do {
try await connectOnce()
try await discoverServices()
try await authenticate()
isConnected = true
isFlashing = true
return
} catch {
disconnect()
if 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
}
let uuidBytes = config.uuid.hexToBytes
guard uuidBytes.count == 16 else {
throw ProvisionError.writeFailed("Invalid UUID length")
}
// Send 10 config commands, each ACK'd
let commands: [(UInt8, Data)] = [
(ConfigCmd.setUUID1.rawValue, Data(uuidBytes[0..<8])),
(ConfigCmd.setUUID2.rawValue, Data(uuidBytes[8..<16])),
(ConfigCmd.setMajor.rawValue, Data([UInt8(config.major >> 8), UInt8(config.major & 0xFF)])),
(ConfigCmd.setMinor.rawValue, Data([UInt8(config.minor >> 8), UInt8(config.minor & 0xFF)])),
(ConfigCmd.setTxPower.rawValue, Data([config.txPower])),
(ConfigCmd.setInterval.rawValue, Data([UInt8(config.advInterval >> 8), UInt8(config.advInterval & 0xFF)])),
(ConfigCmd.setMeasured.rawValue, Data([UInt8(bitPattern: config.measuredPower)])),
]
for (cmdId, payload) in commands {
let packet = Data([cmdId]) + payload
try await sendAndWaitAck(packet)
}
// Save to flash
let savePacket = Data([ConfigCmd.save.rawValue])
try await sendAndWaitAck(savePacket)
isFlashing = false
}
func disconnect() {
if peripheral.state == .connected || peripheral.state == .connecting {
centralManager.cancelPeripheralConnection(peripheral)
}
isConnected = false
isFlashing = false
}
// MARK: - Private
private func connectOnce() async throws {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
connectionContinuation = cont
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 (beacon starts flashing)
/// 2. Send "dx1234" to FFE3 (actual auth)
private func authenticate() async throws {
guard let passwordChar else {
throw ProvisionError.characteristicNotFound
}
// Step 1: Trigger fire and forget (WRITE_NO_RESPONSE)
if let triggerData = Self.triggerPassword.data(using: .utf8) {
peripheral.writeValue(triggerData, for: passwordChar, type: .withoutResponse)
try await Task.sleep(nanoseconds: 500_000_000) // 500ms settle
}
// Step 2: Auth password fire and forget
if let authData = Self.defaultPassword.data(using: .utf8) {
peripheral.writeValue(authData, for: passwordChar, type: .withoutResponse)
try await Task.sleep(nanoseconds: 500_000_000) // 500ms settle
}
}
private func sendAndWaitAck(_ data: Data) async throws {
guard let writeChar else { throw ProvisionError.notConnected }
let _ = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Data, Error>) in
responseContinuation = cont
peripheral.writeValue(data, for: writeChar, 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)
}
}
}
}
}
// 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:
writeChar = char
// FFE1 is also used for notify on DXSmart
if char.properties.contains(.notify) {
peripheral.setNotifyValue(true, for: char)
}
case GATTConstants.ffe3Char:
passwordChar = char
default:
break
}
}
if writeChar != nil && passwordChar != 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?) {
if let error, let cont = responseContinuation {
responseContinuation = nil
cont.resume(throwing: error)
}
}
}