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>
262 lines
8.8 KiB
Swift
262 lines
8.8 KiB
Swift
import Foundation
|
|
import CoreBluetooth
|
|
|
|
/// Provisioner for KBeacon / KBPro hardware
|
|
/// Protocol: FFE0 service, FFE1 write, FFE2 notify
|
|
/// Auth via CMD_AUTH (0x01), config via CMD_WRITE_PARAMS (0x03), save via CMD_SAVE (0x04)
|
|
final class KBeaconProvisioner: NSObject, BeaconProvisioner {
|
|
|
|
// MARK: - Protocol Commands
|
|
private enum CMD: UInt8 {
|
|
case auth = 0x01
|
|
case readParams = 0x02
|
|
case writeParams = 0x03
|
|
case save = 0x04
|
|
}
|
|
|
|
// MARK: - Parameter IDs
|
|
private enum ParamID: UInt8 {
|
|
case uuid = 0x10
|
|
case major = 0x11
|
|
case minor = 0x12
|
|
case txPower = 0x13
|
|
case advInterval = 0x14
|
|
}
|
|
|
|
// MARK: - Known passwords (tried in order, matching Android)
|
|
private static let passwords: [Data] = [
|
|
"kd1234".data(using: .utf8)!,
|
|
Data(repeating: 0, count: 16),
|
|
Data([0x31,0x32,0x33,0x34,0x35,0x36,0x37,0x38,0x39,0x30,0x31,0x32,0x33,0x34,0x35,0x36]),
|
|
"0000000000000000".data(using: .utf8)!,
|
|
"1234567890123456".data(using: .utf8)!
|
|
]
|
|
|
|
// MARK: - State
|
|
|
|
private let peripheral: CBPeripheral
|
|
private let centralManager: CBCentralManager
|
|
private var writeChar: CBCharacteristic?
|
|
private var notifyChar: CBCharacteristic?
|
|
|
|
private var connectionContinuation: CheckedContinuation<Void, Error>?
|
|
private var serviceContinuation: CheckedContinuation<Void, Error>?
|
|
private var writeContinuation: CheckedContinuation<Data, Error>?
|
|
|
|
private(set) var isConnected = false
|
|
|
|
// 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 {
|
|
// Connect with retry
|
|
for attempt in 1...GATTConstants.maxRetries {
|
|
do {
|
|
try await connectOnce()
|
|
try await discoverServices()
|
|
try await authenticate()
|
|
isConnected = 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
|
|
}
|
|
|
|
// Build parameter payload
|
|
var params = Data()
|
|
|
|
// UUID (16 bytes)
|
|
params.append(ParamID.uuid.rawValue)
|
|
let uuidBytes = config.uuid.hexToBytes
|
|
params.append(contentsOf: uuidBytes)
|
|
|
|
// Major (2 bytes BE)
|
|
params.append(ParamID.major.rawValue)
|
|
params.append(UInt8(config.major >> 8))
|
|
params.append(UInt8(config.major & 0xFF))
|
|
|
|
// Minor (2 bytes BE)
|
|
params.append(ParamID.minor.rawValue)
|
|
params.append(UInt8(config.minor >> 8))
|
|
params.append(UInt8(config.minor & 0xFF))
|
|
|
|
// TX Power
|
|
params.append(ParamID.txPower.rawValue)
|
|
params.append(config.txPower)
|
|
|
|
// Adv Interval
|
|
params.append(ParamID.advInterval.rawValue)
|
|
params.append(UInt8(config.advInterval >> 8))
|
|
params.append(UInt8(config.advInterval & 0xFF))
|
|
|
|
// Send CMD_WRITE_PARAMS
|
|
let writeCmd = Data([CMD.writeParams.rawValue]) + params
|
|
let writeResp = try await sendCommand(writeCmd)
|
|
guard writeResp.first == CMD.writeParams.rawValue else {
|
|
throw ProvisionError.writeFailed("Unexpected write response")
|
|
}
|
|
|
|
// Send CMD_SAVE to flash
|
|
let saveResp = try await sendCommand(Data([CMD.save.rawValue]))
|
|
guard saveResp.first == CMD.save.rawValue else {
|
|
throw ProvisionError.saveFailed
|
|
}
|
|
}
|
|
|
|
func disconnect() {
|
|
if peripheral.state == .connected || peripheral.state == .connecting {
|
|
centralManager.cancelPeripheralConnection(peripheral)
|
|
}
|
|
isConnected = false
|
|
}
|
|
|
|
// MARK: - Private: Connection
|
|
|
|
private func connectOnce() async throws {
|
|
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
|
connectionContinuation = cont
|
|
centralManager.connect(peripheral, options: nil)
|
|
|
|
// Timeout
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func authenticate() async throws {
|
|
for password in Self.passwords {
|
|
let cmd = Data([CMD.auth.rawValue]) + password
|
|
do {
|
|
let resp = try await sendCommand(cmd)
|
|
if resp.first == CMD.auth.rawValue && resp.count > 1 && resp[1] == 0x00 {
|
|
return // Auth success
|
|
}
|
|
} catch {
|
|
continue
|
|
}
|
|
}
|
|
throw ProvisionError.authFailed
|
|
}
|
|
|
|
private func sendCommand(_ data: Data) async throws -> Data {
|
|
guard let writeChar else { throw ProvisionError.notConnected }
|
|
|
|
return try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Data, Error>) in
|
|
writeContinuation = cont
|
|
peripheral.writeValue(data, for: writeChar, type: .withResponse)
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in
|
|
if let c = self?.writeContinuation {
|
|
self?.writeContinuation = nil
|
|
c.resume(throwing: ProvisionError.operationTimeout)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - CBPeripheralDelegate
|
|
|
|
extension KBeaconProvisioner: 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], 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
|
|
case GATTConstants.ffe2Char:
|
|
notifyChar = char
|
|
peripheral.setNotifyValue(true, for: char)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
if writeChar != nil {
|
|
serviceContinuation?.resume()
|
|
serviceContinuation = nil
|
|
} else {
|
|
serviceContinuation?.resume(throwing: ProvisionError.characteristicNotFound)
|
|
serviceContinuation = nil
|
|
}
|
|
}
|
|
|
|
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
|
guard characteristic.uuid == GATTConstants.ffe2Char,
|
|
let data = characteristic.value else { return }
|
|
|
|
if let cont = writeContinuation {
|
|
writeContinuation = nil
|
|
cont.resume(returning: data)
|
|
}
|
|
}
|
|
|
|
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
|
|
// Write acknowledgment — actual response comes via notify on FFE2
|
|
if let error {
|
|
if let cont = writeContinuation {
|
|
writeContinuation = nil
|
|
cont.resume(throwing: error)
|
|
}
|
|
}
|
|
}
|
|
}
|