Compare commits

...

1 commit

Author SHA1 Message Date
6965fe4ca8 refactor: strip all non-CP-28 beacon code
Remove BlueCharmProvisioner, KBeaconProvisioner, and FallbackProvisioner.
Simplify BeaconType enum to DX-Smart only. Simplify BLE detection to only
show CP-28 beacons. Remove multi-type provisioner factory from ScanView.

-989 lines of dead code removed. Other beacon types will be re-added
when we start using different hardware.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 02:55:22 +00:00
10 changed files with 51 additions and 1001 deletions

View file

@ -63,15 +63,12 @@ PayfritBeacon/
│ └── PayfritBeaconApp.swift App entry point
├── Models/
│ ├── BeaconConfig.swift Provisioning config (UUID, major, minor, txPower, etc.)
│ ├── BeaconType.swift Enum: DXSmart, BlueCharm, KBeacon, Unknown
│ ├── BeaconType.swift Enum: DXSmart (CP-28 only)
│ ├── Business.swift Business model
│ └── ServicePoint.swift Service point model
├── Provisioners/
│ ├── ProvisionerProtocol.swift Protocol — all provisioners implement this
│ ├── DXSmartProvisioner.swift DX-Smart CP28 GATT provisioner (24-step write sequence)
│ ├── BlueCharmProvisioner.swift BlueCharm BC037 provisioner
│ ├── KBeaconProvisioner.swift KBeacon provisioner
│ ├── FallbackProvisioner.swift Unknown device fallback
│ ├── ProvisionerProtocol.swift Protocol + CP-28 GATT constants
│ ├── DXSmartProvisioner.swift DX-Smart CP-28 GATT provisioner (24-step write sequence)
│ └── ProvisionError.swift Shared error types
├── Services/
│ ├── APIClient.swift Actor-based REST client, all API calls
@ -93,7 +90,7 @@ PayfritBeacon/
## Key Architecture Notes
- **Modular provisioners**: Each beacon manufacturer has its own provisioner conforming to `ProvisionerProtocol`. No more monolithic `BeaconProvisioner.swift`.
- **CP-28 only**: Only DX-Smart CP-28 beacons are supported. Other beacon types (KBeacon, BlueCharm) were removed — will be re-added when needed.
- **Actor-based API**: `APIClient` is a Swift actor (thread-safe by design).
- **Secure storage**: Auth tokens stored in Keychain via `SecureStorage`, not UserDefaults.
- **BLE scanning**: `BLEManager` handles CoreBluetooth device discovery and beacon type identification.

View file

@ -16,9 +16,6 @@
A01000000013 /* ServicePoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000013 /* ServicePoint.swift */; };
A01000000020 /* ProvisionerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000020 /* ProvisionerProtocol.swift */; };
A01000000021 /* DXSmartProvisioner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000021 /* DXSmartProvisioner.swift */; };
A01000000022 /* BlueCharmProvisioner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000022 /* BlueCharmProvisioner.swift */; };
A01000000023 /* KBeaconProvisioner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000023 /* KBeaconProvisioner.swift */; };
A01000000024 /* FallbackProvisioner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000024 /* FallbackProvisioner.swift */; };
A01000000025 /* ProvisionError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000025 /* ProvisionError.swift */; };
A01000000030 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000030 /* APIClient.swift */; };
A01000000031 /* APIConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02000000031 /* APIConfig.swift */; };
@ -50,9 +47,6 @@
A02000000013 /* ServicePoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServicePoint.swift; sourceTree = "<group>"; };
A02000000020 /* ProvisionerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProvisionerProtocol.swift; sourceTree = "<group>"; };
A02000000021 /* DXSmartProvisioner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DXSmartProvisioner.swift; sourceTree = "<group>"; };
A02000000022 /* BlueCharmProvisioner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlueCharmProvisioner.swift; sourceTree = "<group>"; };
A02000000023 /* KBeaconProvisioner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KBeaconProvisioner.swift; sourceTree = "<group>"; };
A02000000024 /* FallbackProvisioner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FallbackProvisioner.swift; sourceTree = "<group>"; };
A02000000025 /* ProvisionError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProvisionError.swift; sourceTree = "<group>"; };
A02000000030 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
A02000000031 /* APIConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIConfig.swift; sourceTree = "<group>"; };
@ -144,10 +138,7 @@
A05000000003 /* Provisioners */ = {
isa = PBXGroup;
children = (
A02000000022 /* BlueCharmProvisioner.swift */,
A02000000021 /* DXSmartProvisioner.swift */,
A02000000024 /* FallbackProvisioner.swift */,
A02000000023 /* KBeaconProvisioner.swift */,
A02000000025 /* ProvisionError.swift */,
A02000000020 /* ProvisionerProtocol.swift */,
);
@ -271,9 +262,6 @@
A01000000013 /* ServicePoint.swift in Sources */,
A01000000020 /* ProvisionerProtocol.swift in Sources */,
A01000000021 /* DXSmartProvisioner.swift in Sources */,
A01000000022 /* BlueCharmProvisioner.swift in Sources */,
A01000000023 /* KBeaconProvisioner.swift in Sources */,
A01000000024 /* FallbackProvisioner.swift in Sources */,
A01000000025 /* ProvisionError.swift in Sources */,
A01000000030 /* APIClient.swift in Sources */,
A01000000031 /* APIConfig.swift in Sources */,

View file

@ -1,12 +1,10 @@
import Foundation
import CoreBluetooth
/// Supported beacon hardware types
/// Beacon hardware type CP-28 (DX-Smart) only for now.
/// Other types will be added when we start using different beacon hardware.
enum BeaconType: String, CaseIterable {
case kbeacon = "KBeacon"
case dxsmart = "DX-Smart"
case bluecharm = "BlueCharm"
case unknown = "Unknown"
}
/// A BLE beacon discovered during scanning

View file

@ -1,416 +0,0 @@
import Foundation
import CoreBluetooth
/// Provisioner for BlueCharm / BC04P hardware
///
/// Supports two service variants:
/// - FEA0 service (BC04P): FEA1 write, FEA2 notify, FEA3 config
/// - FFF0 service (legacy): FFF1 password, FFF2 UUID, FFF3 major, FFF4 minor
///
/// BC04P write methods (tried in order):
/// 1. Direct config write to FEA3: [0x01] + UUID + Major + Minor + TxPower
/// 2. Raw data write to FEA1: UUID + Major + Minor + TxPower + Interval, then save commands
/// 3. Indexed parameter writes to FEA1: [index] + data, then [0xFF] save
///
/// Legacy write: individual characteristics per parameter (FFF1-FFF4)
final class BlueCharmProvisioner: NSObject, BeaconProvisioner {
// MARK: - Constants
// 5 passwords matching Android (16 bytes each)
private static let passwords: [Data] = [
Data(repeating: 0, count: 16), // All zeros
"0000000000000000".data(using: .utf8)!, // ASCII zeros
"1234567890123456".data(using: .utf8)!, // Common
"minew123".data(using: .utf8)!.padded(to: 16), // Minew default
"bc04p".data(using: .utf8)!.padded(to: 16), // Model name
]
// Legacy FFF0 passwords
private static let legacyPasswords = ["000000", "123456", "bc0000"]
// Legacy characteristic UUIDs
private static let fff1Password = CBUUID(string: "0000FFF1-0000-1000-8000-00805F9B34FB")
private static let fff2UUID = CBUUID(string: "0000FFF2-0000-1000-8000-00805F9B34FB")
private static let fff3Major = CBUUID(string: "0000FFF3-0000-1000-8000-00805F9B34FB")
private static let fff4Minor = CBUUID(string: "0000FFF4-0000-1000-8000-00805F9B34FB")
// FEA0 characteristic UUIDs
private static let fea1Write = CBUUID(string: "0000FEA1-0000-1000-8000-00805F9B34FB")
private static let fea2Notify = CBUUID(string: "0000FEA2-0000-1000-8000-00805F9B34FB")
private static let fea3Config = CBUUID(string: "0000FEA3-0000-1000-8000-00805F9B34FB")
// MARK: - State
private let peripheral: CBPeripheral
private let centralManager: CBCentralManager
private var discoveredService: CBService?
private var writeChar: CBCharacteristic? // FEA1 or first writable
private var notifyChar: CBCharacteristic? // FEA2
private var configChar: CBCharacteristic? // FEA3
private var isLegacy = false // Using FFF0 service
private var connectionContinuation: CheckedContinuation<Void, Error>?
private var serviceContinuation: CheckedContinuation<Void, Error>?
private var writeContinuation: CheckedContinuation<Data, Error>?
private var writeOKContinuation: CheckedContinuation<Void, Error>?
private(set) var isConnected = false
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
func connect() async throws {
for attempt in 1...GATTConstants.maxRetries {
do {
try await connectOnce()
try await discoverServices()
if !isLegacy {
try await authenticateBC04P()
}
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 else {
throw ProvisionError.notConnected
}
let uuidBytes = config.uuid.hexToBytes
guard uuidBytes.count == 16 else {
throw ProvisionError.writeFailed("Invalid UUID length")
}
if isLegacy {
try await writeLegacy(config, uuidBytes: uuidBytes)
} else {
try await writeBC04P(config, uuidBytes: uuidBytes)
}
}
func disconnect() {
if peripheral.state == .connected || peripheral.state == .connecting {
centralManager.cancelPeripheralConnection(peripheral)
}
isConnected = false
}
// MARK: - BC04P Write (3 fallback methods, matching Android)
private func writeBC04P(_ config: BeaconConfig, uuidBytes: [UInt8]) async throws {
let majorBytes = Data([UInt8(config.major >> 8), UInt8(config.major & 0xFF)])
let minorBytes = Data([UInt8(config.minor >> 8), UInt8(config.minor & 0xFF)])
let txPowerByte = config.txPower
let intervalUnits = UInt16(Double(config.advInterval) * 100.0 / 0.625)
let intervalBytes = Data([UInt8(intervalUnits >> 8), UInt8(intervalUnits & 0xFF)])
// Method 1: Write directly to FEA3 (config characteristic)
if let fea3 = configChar {
var iBeaconData = Data([0x01]) // iBeacon frame type
iBeaconData.append(contentsOf: uuidBytes)
iBeaconData.append(majorBytes)
iBeaconData.append(minorBytes)
iBeaconData.append(txPowerByte)
if let _ = try? await writeDirectAndWait(fea3, data: iBeaconData) {
try await Task.sleep(nanoseconds: 500_000_000)
return // Success
}
}
// Method 2: Raw data write to FEA1
if let fea1 = writeChar {
var rawData = Data(uuidBytes)
rawData.append(majorBytes)
rawData.append(minorBytes)
rawData.append(txPowerByte)
rawData.append(intervalBytes)
if let _ = try? await writeDirectAndWait(fea1, data: rawData) {
try await Task.sleep(nanoseconds: 300_000_000)
// Send save/apply commands (matching Android)
let _ = try? await writeDirectAndWait(fea1, data: Data([0xFF])) // Save variant 1
try await Task.sleep(nanoseconds: 200_000_000)
let _ = try? await writeDirectAndWait(fea1, data: Data([0x00])) // Apply/commit
try await Task.sleep(nanoseconds: 200_000_000)
let _ = try? await writeDirectAndWait(fea1, data: Data([0x57])) // KBeacon save
try await Task.sleep(nanoseconds: 200_000_000)
let _ = try? await writeDirectAndWait(fea1, data: Data([0x01, 0x00])) // Enable slot 0
try await Task.sleep(nanoseconds: 500_000_000)
return
}
}
// Method 3: Indexed parameter writes to FEA1
if let fea1 = writeChar {
// Index 0 = UUID
let _ = try? await writeDirectAndWait(fea1, data: Data([0x00]) + Data(uuidBytes))
try await Task.sleep(nanoseconds: 100_000_000)
// Index 1 = Major
let _ = try? await writeDirectAndWait(fea1, data: Data([0x01]) + majorBytes)
try await Task.sleep(nanoseconds: 100_000_000)
// Index 2 = Minor
let _ = try? await writeDirectAndWait(fea1, data: Data([0x02]) + minorBytes)
try await Task.sleep(nanoseconds: 100_000_000)
// Index 3 = TxPower
let _ = try? await writeDirectAndWait(fea1, data: Data([0x03, txPowerByte]))
try await Task.sleep(nanoseconds: 100_000_000)
// Index 4 = Interval
let _ = try? await writeDirectAndWait(fea1, data: Data([0x04]) + intervalBytes)
try await Task.sleep(nanoseconds: 100_000_000)
// Save command
let _ = try? await writeDirectAndWait(fea1, data: Data([0xFF]))
try await Task.sleep(nanoseconds: 500_000_000)
return
}
throw ProvisionError.writeFailed("No write characteristic available")
}
// MARK: - Legacy FFF0 Write
private func writeLegacy(_ config: BeaconConfig, uuidBytes: [UInt8]) async throws {
guard let service = discoveredService else {
throw ProvisionError.serviceNotFound
}
// Try passwords
for password in Self.legacyPasswords {
if let char = service.characteristics?.first(where: { $0.uuid == Self.fff1Password }),
let data = password.data(using: .utf8) {
let _ = try? await writeDirectAndWait(char, data: data)
try await Task.sleep(nanoseconds: 200_000_000)
}
}
// Write UUID
if let char = service.characteristics?.first(where: { $0.uuid == Self.fff2UUID }) {
let _ = try await writeDirectAndWait(char, data: Data(uuidBytes))
}
// Write Major
let majorBytes = Data([UInt8(config.major >> 8), UInt8(config.major & 0xFF)])
if let char = service.characteristics?.first(where: { $0.uuid == Self.fff3Major }) {
let _ = try await writeDirectAndWait(char, data: majorBytes)
}
// Write Minor
let minorBytes = Data([UInt8(config.minor >> 8), UInt8(config.minor & 0xFF)])
if let char = service.characteristics?.first(where: { $0.uuid == Self.fff4Minor }) {
let _ = try await writeDirectAndWait(char, data: minorBytes)
}
}
// MARK: - Auth (BC04P)
private func authenticateBC04P() async throws {
guard let fea1 = writeChar else {
throw ProvisionError.characteristicNotFound
}
// Enable notifications on FEA2 if available
if let fea2 = notifyChar {
peripheral.setNotifyValue(true, for: fea2)
try await Task.sleep(nanoseconds: 200_000_000)
}
// No explicit auth command needed for BC04P the write methods
// handle auth implicitly. Android's BlueCharm provisioner also
// doesn't do a CMD_CONNECT auth for the FEA0 path.
}
// 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.fea0Service, GATTConstants.fff0Service])
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 writeDirectAndWait(_ char: CBCharacteristic, data: Data) async throws -> Void {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
writeOKContinuation = cont
peripheral.writeValue(data, for: char, type: .withResponse)
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { [weak self] in
if let c = self?.writeOKContinuation {
self?.writeOKContinuation = nil
c.resume(throwing: ProvisionError.operationTimeout)
}
}
}
}
}
// MARK: - CBPeripheralDelegate
extension BlueCharmProvisioner: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
if let error {
serviceContinuation?.resume(throwing: error)
serviceContinuation = nil
return
}
// Prefer FEA0 (BC04P), fallback to FFF0 (legacy)
if let fea0Service = peripheral.services?.first(where: { $0.uuid == GATTConstants.fea0Service }) {
discoveredService = fea0Service
isLegacy = false
peripheral.discoverCharacteristics(nil, for: fea0Service)
} else if let fff0Service = peripheral.services?.first(where: { $0.uuid == GATTConstants.fff0Service }) {
discoveredService = fff0Service
isLegacy = true
peripheral.discoverCharacteristics(nil, for: fff0Service)
} else {
serviceContinuation?.resume(throwing: ProvisionError.serviceNotFound)
serviceContinuation = nil
}
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
if let error {
serviceContinuation?.resume(throwing: error)
serviceContinuation = nil
return
}
if isLegacy {
// Legacy: just need the service with characteristics
serviceContinuation?.resume()
serviceContinuation = nil
return
}
// BC04P: map specific characteristics
for char in service.characteristics ?? [] {
switch char.uuid {
case Self.fea1Write:
writeChar = char
case Self.fea2Notify:
notifyChar = char
case Self.fea3Config:
configChar = char
default:
// Also grab any writable char as fallback
if writeChar == nil && (char.properties.contains(.write) || char.properties.contains(.writeWithoutResponse)) {
writeChar = char
}
if notifyChar == nil && char.properties.contains(.notify) {
notifyChar = char
}
}
}
if writeChar != nil || configChar != 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 = writeContinuation {
writeContinuation = nil
cont.resume(returning: data)
}
}
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
if let cont = writeOKContinuation {
writeOKContinuation = nil
if let error {
cont.resume(throwing: error)
} else {
cont.resume()
}
return
}
if let error, let cont = writeContinuation {
writeContinuation = nil
cont.resume(throwing: ProvisionError.writeFailed(error.localizedDescription))
}
}
}
// MARK: - Data Extension
private extension Data {
/// Pad data to target length with zero bytes
func padded(to length: Int) -> Data {
if count >= length { return self }
var padded = self
padded.append(contentsOf: [UInt8](repeating: 0, count: length - count))
return padded
}
}

View file

@ -173,7 +173,7 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
for (index, (name, packet)) in commands.enumerated() {
await diagnosticLog?.log("write", "[\(index + 1)/\(commands.count)] \(name) (\(packet.count) bytes)")
// Retry each command up to 2 times beacon BLE stack can be flaky after KBeacon fallback
// Retry each command up to 2 times beacon BLE stack can be flaky
var lastError: Error?
for writeAttempt in 1...2 {
do {
@ -193,8 +193,7 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
throw lastError
}
// 500ms between commands beacon needs time to process, especially after
// prior KBeacon auth attempts that may have stressed the BLE stack
// 500ms between commands beacon needs time to process
try await Task.sleep(nanoseconds: 500_000_000)
}
}

View file

@ -1,83 +0,0 @@
import Foundation
import CoreBluetooth
/// Tries KBeacon DXSmart BlueCharm in sequence for unknown beacon types.
/// Matches Android's fallback behavior when beacon type can't be determined.
final class FallbackProvisioner: BeaconProvisioner {
private let peripheral: CBPeripheral
private let centralManager: CBCentralManager
private var activeProvisioner: (any BeaconProvisioner)?
private(set) var isConnected: Bool = false
var diagnosticLog: ProvisionLog?
var bleManager: BLEManager?
init(peripheral: CBPeripheral, centralManager: CBCentralManager) {
self.peripheral = peripheral
self.centralManager = centralManager
}
func connect() async throws {
let provisioners: [() -> any BeaconProvisioner] = [
{ [self] in
var p = KBeaconProvisioner(peripheral: peripheral, centralManager: centralManager)
p.diagnosticLog = diagnosticLog
p.bleManager = bleManager
return p
},
{ [self] in
var p = DXSmartProvisioner(peripheral: peripheral, centralManager: centralManager)
p.diagnosticLog = diagnosticLog
p.bleManager = bleManager
return p
},
{ [self] in
var p = BlueCharmProvisioner(peripheral: peripheral, centralManager: centralManager)
p.diagnosticLog = diagnosticLog
p.bleManager = bleManager
return p
},
]
let typeNames = ["KBeacon", "DXSmart", "BlueCharm"]
var lastError: Error = ProvisionError.connectionTimeout
for (index, makeProvisioner) in provisioners.enumerated() {
await diagnosticLog?.log("fallback", "Trying \(typeNames[index]) provisioner…")
let provisioner = makeProvisioner()
do {
try await provisioner.connect()
activeProvisioner = provisioner
isConnected = true
await diagnosticLog?.log("fallback", "\(typeNames[index]) connected successfully")
return
} catch {
provisioner.disconnect()
lastError = error
await diagnosticLog?.log("fallback", "\(typeNames[index]) failed: \(error.localizedDescription)", isError: true)
// 2s cooldown between provisioner attempts let the beacon's BLE stack recover
// from failed auth/connection before the next provisioner hammers it
if index < provisioners.count - 1 {
await diagnosticLog?.log("fallback", "Cooling down 2s before next provisioner…")
try? await Task.sleep(nanoseconds: 2_000_000_000)
}
}
}
throw lastError
}
func writeConfig(_ config: BeaconConfig) async throws {
guard let provisioner = activeProvisioner else {
throw ProvisionError.notConnected
}
try await provisioner.writeConfig(config)
}
func disconnect() {
activeProvisioner?.disconnect()
activeProvisioner = nil
isConnected = false
}
}

View file

@ -1,311 +0,0 @@
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)
// Known KBeacon default passwords (tried in order)
// Note: password 5 was previously a duplicate of password 3 replaced with "000000" (6-char variant)
private static let passwords: [Data] = [
"kd1234".data(using: .utf8)!, // KBeacon factory default
Data(repeating: 0, count: 16), // Binary zeros (16 bytes)
Data([0x31,0x32,0x33,0x34,0x35,0x36,0x37,0x38,0x39,0x30,0x31,0x32,0x33,0x34,0x35,0x36]), // "1234567890123456"
"0000000000000000".data(using: .utf8)!, // ASCII zeros (16 bytes)
"000000".data(using: .utf8)!, // Short zero default (6 bytes)
]
// 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
var diagnosticLog: ProvisionLog?
var bleManager: BLEManager?
/// Status callback provisioner reports what phase it's in so UI can update
var onStatusUpdate: ((String) -> Void)?
// 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 {
let attemptLabel = GATTConstants.maxRetries > 1 ? " (attempt \(attempt)/\(GATTConstants.maxRetries))" : ""
await MainActor.run { onStatusUpdate?("Connecting to beacon…\(attemptLabel)") }
await diagnosticLog?.log("connect", "Attempt \(attempt)/\(GATTConstants.maxRetries)")
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 — write:\(writeChar != nil) notify:\(notifyChar != nil)")
await MainActor.run { onStatusUpdate?("Authenticating…") }
await diagnosticLog?.log("auth", "Trying \(Self.passwords.count) passwords…")
try await authenticate()
await diagnosticLog?.log("auth", "Auth success")
isConnected = 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))") }
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
await MainActor.run { onStatusUpdate?("Writing beacon parameters…") }
await diagnosticLog?.log("write", "Sending CMD_WRITE_PARAMS (\(params.count) bytes)…")
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")
}
await diagnosticLog?.log("write", "Params written OK — saving to flash…")
// Send CMD_SAVE to flash
await MainActor.run { onStatusUpdate?("Saving to flash…") }
let saveResp = try await sendCommand(Data([CMD.save.rawValue]))
guard saveResp.first == CMD.save.rawValue else {
throw ProvisionError.saveFailed
}
await MainActor.run { onStatusUpdate?("Config saved ✓") }
await diagnosticLog?.log("write", "Save confirmed")
}
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
// 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)
// 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 (index, password) in Self.passwords.enumerated() {
let passwordLabel = String(data: password.prefix(6), encoding: .utf8) ?? "binary"
await MainActor.run { onStatusUpdate?("Authenticating… (\(index + 1)/\(Self.passwords.count))") }
await diagnosticLog?.log("auth", "Trying password \(index + 1)/\(Self.passwords.count): \(passwordLabel)")
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 {
await MainActor.run { onStatusUpdate?("Authenticated ✓") }
return // Auth success
}
await diagnosticLog?.log("auth", "Password \(index + 1) rejected (response: \(resp.map { String(format: "%02X", $0) }.joined()))")
} catch {
await diagnosticLog?.log("auth", "Password \(index + 1) timeout: \(error.localizedDescription)")
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)
}
}
}
}

View file

@ -22,23 +22,19 @@ protocol BeaconProvisioner {
var bleManager: BLEManager? { get set }
}
/// GATT UUIDs shared across provisioner types
/// GATT UUIDs for CP-28 (DX-Smart) beacons
/// FFE0 service with FFE1 (notify), FFE2 (write), FFE3 (password)
enum GATTConstants {
// FFE0 service (KBeacon, DXSmart)
static let ffe0Service = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB")
static let ffe1Char = CBUUID(string: "0000FFE1-0000-1000-8000-00805F9B34FB")
static let ffe2Char = CBUUID(string: "0000FFE2-0000-1000-8000-00805F9B34FB")
static let ffe3Char = CBUUID(string: "0000FFE3-0000-1000-8000-00805F9B34FB")
// FFF0 service (BlueCharm)
static let fff0Service = CBUUID(string: "0000FFF0-0000-1000-8000-00805F9B34FB")
static let fea0Service = CBUUID(string: "0000FEA0-0000-1000-8000-00805F9B34FB")
// CCCD for enabling notifications
static let cccd = CBUUID(string: "00002902-0000-1000-8000-00805F9B34FB")
// Timeouts (matching Android)
static let connectionTimeout: TimeInterval = 10.0 // Increased from 5s BLE connections can be slow
// Timeouts
static let connectionTimeout: TimeInterval = 10.0
static let operationTimeout: TimeInterval = 5.0
static let maxRetries = 3
static let retryDelay: TimeInterval = 1.0

View file

@ -2,8 +2,7 @@ import Foundation
import CoreBluetooth
import Combine
/// Central BLE manager handles scanning and beacon type detection
/// Matches Android's BeaconScanner.kt behavior
/// Central BLE manager handles scanning and CP-28 beacon detection
@MainActor
final class BLEManager: NSObject, ObservableObject {
@ -13,23 +12,19 @@ final class BLEManager: NSObject, ObservableObject {
@Published var discoveredBeacons: [DiscoveredBeacon] = []
@Published var bluetoothState: CBManagerState = .unknown
// MARK: - Constants (matching Android)
// MARK: - Constants
static let scanDuration: TimeInterval = 5.0
static let verifyScanDuration: TimeInterval = 15.0
static let verifyPollInterval: TimeInterval = 0.5
// GATT Service UUIDs
// CP-28 uses FFE0 service
static let ffe0Service = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB")
static let fff0Service = CBUUID(string: "0000FFF0-0000-1000-8000-00805F9B34FB")
static let fea0Service = CBUUID(string: "0000FEA0-0000-1000-8000-00805F9B34FB")
// DX-Smart factory default iBeacon UUID
static let dxSmartDefaultUUID = "E2C56DB5-DFFB-48D2-B060-D0F5A71096E0"
// MARK: - Connection Callbacks (used by provisioners)
// Provisioners call centralManager.connect() but BLEManager is the delegate,
// so we need to forward connection events back to provisioners via closures.
var onPeripheralConnected: ((CBPeripheral) -> Void)?
var onPeripheralFailedToConnect: ((CBPeripheral, Error?) -> Void)?
@ -88,11 +83,8 @@ final class BLEManager: NSObject, ObservableObject {
}
/// Verify a beacon is broadcasting expected iBeacon values.
/// Scans for up to `verifyScanDuration` looking for matching UUID/Major/Minor.
func verifyBroadcast(uuid: String, major: UInt16, minor: UInt16) async -> VerifyResult {
// TODO: Implement iBeacon region monitoring via CLLocationManager
// CoreLocation's CLBeaconRegion is the iOS-native way to detect iBeacon broadcasts
// For now, return a placeholder that prompts manual verification
return VerifyResult(
found: false,
rssi: nil,
@ -103,40 +95,32 @@ final class BLEManager: NSObject, ObservableObject {
// MARK: - iBeacon Manufacturer Data Parsing
/// Parse iBeacon data from manufacturer data (Apple company ID 0x4C00)
/// Returns (uuid, major, minor) if valid iBeacon advertisement found
private func parseIBeaconData(_ mfgData: Data) -> (uuid: String, major: UInt16, minor: UInt16)? {
// iBeacon format in manufacturer data:
// [0x4C 0x00] (Apple company ID) [0x02 0x15] (iBeacon type+length)
// [UUID 16 bytes] [Major 2 bytes] [Minor 2 bytes] [TX Power 1 byte]
guard mfgData.count >= 25 else { return nil }
guard mfgData[0] == 0x4C && mfgData[1] == 0x00 &&
mfgData[2] == 0x02 && mfgData[3] == 0x15 else { return nil }
// Extract UUID (bytes 4-19)
let uuidBytes = mfgData.subdata(in: 4..<20)
let hex = uuidBytes.map { String(format: "%02X", $0) }.joined()
let uuid = "\(hex.prefix(8))-\(hex.dropFirst(8).prefix(4))-\(hex.dropFirst(12).prefix(4))-\(hex.dropFirst(16).prefix(4))-\(hex.dropFirst(20))"
// Extract Major (bytes 20-21) and Minor (bytes 22-23)
let major = UInt16(mfgData[20]) << 8 | UInt16(mfgData[21])
let minor = UInt16(mfgData[22]) << 8 | UInt16(mfgData[23])
return (uuid: uuid, major: major, minor: minor)
}
// MARK: - Beacon Type Detection (matches Android BeaconScanner.kt)
// NOTE: Android also detects DX-Smart by MAC OUI prefix 48:87:2D.
// CoreBluetooth does not expose raw MAC addresses, so we compensate
// with broader iBeacon UUID detection and more permissive inclusion.
// MARK: - CP-28 Detection
// Only detect DX-Smart / CP-28 beacons. Everything else is ignored.
func detectBeaconType(
name: String?,
serviceUUIDs: [CBUUID]?,
manufacturerData: Data?
) -> BeaconType {
) -> BeaconType? {
let deviceName = (name ?? "").lowercased()
// Parse iBeacon data if available (needed for UUID-based detection)
// Parse iBeacon data if available
let iBeaconData: (uuid: String, major: UInt16, minor: UInt16)?
if let mfgData = manufacturerData {
iBeaconData = parseIBeaconData(mfgData)
@ -144,89 +128,60 @@ final class BLEManager: NSObject, ObservableObject {
iBeaconData = nil
}
// 1. Service UUID matching (matches Android lines 122-126)
// 1. Service UUID: CP-28 uses FFE0
if let services = serviceUUIDs {
let serviceStrings = services.map { $0.uuidString.uppercased() }
// Android: KBeacon uses FFE0 as primary service
if serviceStrings.contains(where: { $0.hasPrefix("0000FFE0") }) {
// Could be KBeacon or DXSmart check name to differentiate
// FFE0 with DX name patterns definitely CP-28
if deviceName.contains("cp28") || deviceName.contains("cp-28") ||
deviceName.contains("dx") || deviceName.contains("pddaxlque") {
return .dxsmart
}
return .kbeacon
}
// Android: DXSmart also uses FFF0 (line 125)
// FIXED: Was incorrectly mapping FFF0 BlueCharm only.
// Android maps DXSmartProvisioner.SERVICE_UUID_FFF0 DXSMART
if serviceStrings.contains(where: { $0.hasPrefix("0000FFF0") }) {
// Check name patterns to decide: DXSmart or BlueCharm
if deviceName.contains("cp28") || deviceName.contains("cp-28") ||
deviceName.contains("dx") || deviceName.contains("pddaxlque") ||
deviceName.isEmpty {
// DX beacons often have no name or DX-prefixed names
return .dxsmart
}
if deviceName.contains("bluecharm") || deviceName.hasPrefix("bc") ||
deviceName.hasPrefix("table-") {
return .bluecharm
}
// Default FFF0 to DXSmart (matching Android behavior)
// FFE0 without a specific name still likely CP-28
return .dxsmart
}
// Android: BlueCharm uses FEA0 (line 124)
if serviceStrings.contains(where: { $0.hasPrefix("0000FEA0") }) {
return .bluecharm
// CP-28 also advertises FFF0 on some firmware
if serviceStrings.contains(where: { $0.hasPrefix("0000FFF0") }) {
if deviceName.contains("cp28") || deviceName.contains("cp-28") ||
deviceName.contains("dx") || deviceName.contains("pddaxlque") ||
deviceName.isEmpty {
return .dxsmart
}
}
}
// 2. Detect DX-Smart by factory default iBeacon UUID (Android line 130)
// This is critical catches DX beacons that don't advertise service UUIDs
// 2. DX-Smart factory default iBeacon UUID
if let ibeacon = iBeaconData {
if ibeacon.uuid.caseInsensitiveCompare(Self.dxSmartDefaultUUID) == .orderedSame {
return .dxsmart
}
// Check if broadcasting a Payfrit shard UUID (already provisioned DX beacon)
// Already provisioned with a Payfrit shard UUID
if BeaconShardPool.isPayfrit(ibeacon.uuid) {
return .dxsmart
}
}
// 3. Device name patterns (Android lines 131-147)
if deviceName.contains("kbeacon") || deviceName.contains("kbpro") ||
deviceName.hasPrefix("kb") {
return .kbeacon
}
if deviceName.contains("bluecharm") || deviceName.hasPrefix("bc") ||
deviceName.hasPrefix("table-") {
return .bluecharm
}
// 3. Device name patterns for CP-28
if deviceName.contains("cp28") || deviceName.contains("cp-28") ||
deviceName.contains("dx-cp") || deviceName.contains("dx-smart") ||
deviceName.contains("dxsmart") || deviceName.contains("pddaxlque") {
return .dxsmart
}
// 4. Detect by iBeacon minor in high range (Android line 143)
// 4. iBeacon minor in high range (factory default DX pattern)
if let ibeacon = iBeaconData, ibeacon.minor > 10000 {
return .dxsmart
}
// 5. Generic beacon patterns (Android lines 145-147)
if deviceName.contains("ibeacon") || deviceName.contains("beacon") ||
deviceName.hasPrefix("ble") {
return .dxsmart // Default to DXSmart like Android
}
// 6. Any remaining iBeacon advertisement still a beacon we should show
// 5. Any iBeacon advertisement likely a CP-28 in the field
if iBeaconData != nil {
return .dxsmart
}
return .unknown
// Not a CP-28 don't show it
return nil
}
}
@ -265,37 +220,24 @@ extension BLEManager: CBCentralManagerDelegate {
advertisementData: [String: Any],
rssi RSSI: NSNumber
) {
// Capture values in nonisolated context before hopping to main
let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? ""
let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID]
let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
let isConnectable = advertisementData[CBAdvertisementDataIsConnectable] as? Bool ?? false
let peripheralId = peripheral.identifier
let rssiValue = RSSI.intValue
DispatchQueue.main.async { [weak self] in
guard let self else { return }
let type = self.detectBeaconType(name: name, serviceUUIDs: serviceUUIDs, manufacturerData: mfgData)
// Match Android behavior (lines 164-169):
// Include devices that have a recognized type, OR
// are broadcasting iBeacon data, OR
// are connectable with a name (potential configurable beacon)
if type == .unknown {
let hasName = !name.isEmpty
let hasIBeaconData = mfgData.flatMap { self.parseIBeaconData($0) } != nil
if !hasIBeaconData && !(isConnectable && hasName) {
return
}
// Only show CP-28 beacons everything else is filtered out
guard let type = self.detectBeaconType(name: name, serviceUUIDs: serviceUUIDs, manufacturerData: mfgData) else {
return
}
if let idx = self.discoveredBeacons.firstIndex(where: { $0.id == peripheralId }) {
// Update existing
self.discoveredBeacons[idx].rssi = rssiValue
self.discoveredBeacons[idx].lastSeen = Date()
} else {
// New beacon
let beacon = DiscoveredBeacon(
id: peripheralId,
peripheral: peripheral,
@ -307,7 +249,6 @@ extension BLEManager: CBCentralManagerDelegate {
self.discoveredBeacons.append(beacon)
}
// Keep list sorted by RSSI (strongest/closest first)
self.discoveredBeacons.sort { $0.rssi > $1.rssi }
}
}

View file

@ -619,10 +619,6 @@ struct ScanView: View {
dxProvisioner.onStatusUpdate = { [weak self] status in
self?.statusMessage = status
}
} else if let kbProvisioner = provisioner as? KBeaconProvisioner {
kbProvisioner.onStatusUpdate = { [weak self] status in
self?.statusMessage = status
}
}
statusMessage = "Connecting to \(beacon.displayName)"
@ -635,10 +631,10 @@ struct ScanView: View {
let reason = error?.localizedDescription ?? "beacon timed out"
provisionLog?.log("disconnect", "Unexpected disconnect: \(reason)", isError: true)
guard let self = self else { return }
// DXSmart: disconnect during .connected is expected beacon keeps
// CP-28: disconnect during .connected is expected beacon keeps
// flashing after BLE drops. We'll reconnect when user taps Write Config.
if self.provisioningState == .connected && beacon.type == .dxsmart {
provisionLog?.log("disconnect", "DXSmart idle disconnect — beacon still flashing, ignoring")
if self.provisioningState == .connected {
provisionLog?.log("disconnect", "CP-28 idle disconnect — beacon still flashing, ignoring")
return
}
// For all other active states, treat disconnect as failure
@ -654,53 +650,10 @@ struct ScanView: View {
try await provisioner.connect()
provisionLog.log("connect", "Connected and authenticated successfully")
// DXSmart: stop at connected state, wait for user to confirm flashing
if beacon.type == .dxsmart {
provisioningState = .connected
// Store config and provisioner for later use
pendingConfig = config
pendingProvisioner = provisioner
return
}
// KBeacon / BlueCharm: write immediately
provisioningState = .writing
statusMessage = "Writing \(config.formattedUUID.prefix(8))… Major:\(config.major) Minor:\(config.minor)"
provisionLog.log("write", "Writing config…")
try await provisioner.writeConfig(config)
provisionLog.log("write", "Config written — disconnecting")
provisioner.disconnect()
// Register with backend
try await APIClient.shared.registerBeaconHardware(
businessId: business.id,
servicePointId: sp.id,
uuid: ns.uuid,
major: ns.major,
minor: minor,
macAddress: nil,
beaconType: beacon.type.rawValue,
token: token
)
// Verify broadcast
provisioningState = .verifying
statusMessage = "Waiting for beacon to restart…"
try await Task.sleep(nanoseconds: UInt64(GATTConstants.postFlashDelay * 1_000_000_000))
statusMessage = "Scanning for broadcast…"
let verifyResult = await bleManager.verifyBroadcast(
uuid: ns.uuid, major: config.major, minor: config.minor
)
if verifyResult.found {
try await APIClient.shared.verifyBeaconBroadcast(
uuid: ns.uuid, major: ns.major, minor: minor, token: token
)
}
provisioningState = .done
statusMessage = "\(sp.name)\(beacon.type.rawValue)\nUUID: \(config.formattedUUID.prefix(13))\nMajor: \(config.major) Minor: \(config.minor)"
// CP-28: stop at connected state, wait for user to confirm flashing
provisioningState = .connected
pendingConfig = config
pendingProvisioner = provisioner
} catch {
provisionLog.log("error", "Provisioning failed after \(provisionLog.elapsed): \(error.localizedDescription)", isError: true)
@ -848,17 +801,10 @@ struct ScanView: View {
}
private func makeProvisioner(for beacon: DiscoveredBeacon) -> any BeaconProvisioner {
var provisioner: any BeaconProvisioner
switch beacon.type {
case .kbeacon:
provisioner = KBeaconProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager)
case .dxsmart:
provisioner = DXSmartProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager)
case .bluecharm:
provisioner = BlueCharmProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager)
case .unknown:
provisioner = FallbackProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager)
}
var provisioner: any BeaconProvisioner = DXSmartProvisioner(
peripheral: beacon.peripheral,
centralManager: bleManager.centralManager
)
provisioner.bleManager = bleManager
provisioner.diagnosticLog = provisionLog
return provisioner
@ -927,12 +873,7 @@ struct BeaconRow: View {
}
private var typeColor: Color {
switch beacon.type {
case .kbeacon: return .payfritGreen
case .dxsmart: return .warningOrange
case .bluecharm: return .infoBlue
case .unknown: return .gray
}
return .payfritGreen
}
}