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>
253 lines
9.1 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|