Compare commits

..

No commits in common. "main" and "schwifty/fix-write-ack-handling" have entirely different histories.

12 changed files with 1093 additions and 161 deletions

View file

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

View file

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

View file

@ -365,10 +365,8 @@ class BeaconProvisioner: NSObject, ObservableObject {
isTerminating = true isTerminating = true
DebugLog.shared.log("BLE: Provisioning success!") DebugLog.shared.log("BLE: Provisioning success!")
state = .success state = .success
// Signal completion BEFORE disconnecting the disconnect delegate fires
// synchronously and ScanView needs writesCompleted=true before it sees it
completion?(.success(macAddress: nil))
disconnectPeripheral() disconnectPeripheral()
completion?(.success(macAddress: nil))
cleanup() cleanup()
} }

View file

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

View file

@ -0,0 +1,416 @@
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

@ -41,7 +41,6 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
private(set) var isConnected = false private(set) var isConnected = false
private(set) var isFlashing = false // Beacon LED flashing after trigger private(set) var isFlashing = false // Beacon LED flashing after trigger
private var useNewSDK = true // Prefer new SDK, fallback to old private var useNewSDK = true // Prefer new SDK, fallback to old
private var disconnected = false // Set true when BLE link drops unexpectedly
var diagnosticLog: ProvisionLog? var diagnosticLog: ProvisionLog?
var bleManager: BLEManager? var bleManager: BLEManager?
@ -77,14 +76,6 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
await diagnosticLog?.log("auth", "Authenticating (trigger + password)…") await diagnosticLog?.log("auth", "Authenticating (trigger + password)…")
try await authenticate() try await authenticate()
await diagnosticLog?.log("auth", "Auth complete — SDK: \(useNewSDK ? "new (FFE2)" : "old (FFE1)")") await diagnosticLog?.log("auth", "Auth complete — SDK: \(useNewSDK ? "new (FFE2)" : "old (FFE1)")")
// Register for unexpected disconnects so we fail fast instead of
// waiting for per-command ACK timeouts (5s × 2 = 10s of dead air).
bleManager?.onPeripheralDisconnected = { [weak self] disconnectedPeripheral, error in
guard disconnectedPeripheral.identifier == self?.peripheral.identifier else { return }
self?.handleUnexpectedDisconnect(error: error)
}
isConnected = true isConnected = true
isFlashing = true isFlashing = true
return return
@ -134,8 +125,6 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
} }
func disconnect() { func disconnect() {
// Unregister disconnect handler so intentional disconnect doesn't trigger error path
bleManager?.onPeripheralDisconnected = nil
if peripheral.state == .connected || peripheral.state == .connecting { if peripheral.state == .connected || peripheral.state == .connecting {
centralManager.cancelPeripheralConnection(peripheral) centralManager.cancelPeripheralConnection(peripheral)
} }
@ -182,48 +171,15 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
] ]
for (index, (name, packet)) in commands.enumerated() { for (index, (name, packet)) in commands.enumerated() {
// Bail immediately if BLE link dropped between commands
if disconnected {
await diagnosticLog?.log("write", "Aborting — BLE disconnected", isError: true)
throw ProvisionError.writeFailed("BLE disconnected during write sequence")
}
await diagnosticLog?.log("write", "[\(index + 1)/\(commands.count)] \(name) (\(packet.count) bytes)") await diagnosticLog?.log("write", "[\(index + 1)/\(commands.count)] \(name) (\(packet.count) bytes)")
do {
// SaveConfig (last command) causes beacon MCU to reboot it never sends an ACK. try await writeToCharAndWaitACK(writeChar, data: packet, label: name)
// Use .withoutResponse so CoreBluetooth fires the bytes immediately into the } catch {
// BLE radio buffer without waiting for a GATT round-trip. With .withResponse, await diagnosticLog?.log("write", "[\(index + 1)/\(commands.count)] \(name) FAILED: \(error.localizedDescription)", isError: true)
// the beacon reboots before the ACK arrives, and CoreBluetooth may silently throw error
// drop the write leaving the config unsaved and the beacon still flashing.
if name == "SaveConfig" {
peripheral.writeValue(packet, for: writeChar, type: .withoutResponse)
await diagnosticLog?.log("write", "✅ [\(index + 1)/\(commands.count)] SaveConfig sent — beacon will reboot")
await diagnosticLog?.log("write", "✅ All commands written successfully")
return
} }
// 200ms between commands (matches Android SDK timer interval)
// Retry each command up to 2 times beacon BLE stack can be flaky try await Task.sleep(nanoseconds: 200_000_000)
var lastError: Error?
for writeAttempt in 1...2 {
do {
try await writeToCharAndWaitACK(writeChar, data: packet, label: name)
lastError = nil
break
} catch {
lastError = error
if writeAttempt == 1 {
await diagnosticLog?.log("write", "[\(index + 1)/\(commands.count)] \(name) retry after: \(error.localizedDescription)")
try await Task.sleep(nanoseconds: 500_000_000) // 500ms before retry
}
}
}
if let lastError {
await diagnosticLog?.log("write", "[\(index + 1)/\(commands.count)] \(name) FAILED: \(lastError.localizedDescription)", isError: true)
throw lastError
}
// 50ms between commands beacon handles fast writes fine (was 150ms, 300ms, 500ms)
try await Task.sleep(nanoseconds: 50_000_000)
} }
} }
@ -299,32 +255,6 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
return packet return packet
} }
// MARK: - Disconnect Detection
/// Called when BLE link drops unexpectedly during provisioning.
/// Immediately resolves any pending continuations so we fail fast
/// instead of waiting for the 5s operationTimeout.
private func handleUnexpectedDisconnect(error: Error?) {
disconnected = true
isConnected = false
let disconnectError = ProvisionError.writeFailed("BLE disconnected unexpectedly: \(error?.localizedDescription ?? "unknown")")
Task { await diagnosticLog?.log("ble", "⚠️ Unexpected disconnect during provisioning", isError: true) }
// Cancel any pending write/response continuation immediately
if let cont = responseContinuation {
responseContinuation = nil
cont.resume(throwing: disconnectError)
}
if let cont = writeContinuation {
writeContinuation = nil
cont.resume(throwing: disconnectError)
}
if let cont = connectionContinuation {
connectionContinuation = nil
cont.resume(throwing: disconnectError)
}
}
// MARK: - Private Helpers // MARK: - Private Helpers
private func connectOnce() async throws { private func connectOnce() async throws {
@ -384,13 +314,13 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
// Step 1: Trigger fire and forget (matches Android's WRITE_TYPE_NO_RESPONSE) // Step 1: Trigger fire and forget (matches Android's WRITE_TYPE_NO_RESPONSE)
if let triggerData = Self.triggerPassword.data(using: .utf8) { if let triggerData = Self.triggerPassword.data(using: .utf8) {
peripheral.writeValue(triggerData, for: ffe3, type: .withoutResponse) peripheral.writeValue(triggerData, for: ffe3, type: .withoutResponse)
try await Task.sleep(nanoseconds: 50_000_000) // 50ms settle try await Task.sleep(nanoseconds: 100_000_000) // 100ms (matches Android SDK timer)
} }
// Step 2: Auth password fire and forget // Step 2: Auth password fire and forget
if let authData = Self.defaultPassword.data(using: .utf8) { if let authData = Self.defaultPassword.data(using: .utf8) {
peripheral.writeValue(authData, for: ffe3, type: .withoutResponse) peripheral.writeValue(authData, for: ffe3, type: .withoutResponse)
try await Task.sleep(nanoseconds: 50_000_000) // 50ms settle try await Task.sleep(nanoseconds: 100_000_000) // 100ms settle
} }
} }

View file

@ -0,0 +1,77 @@
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)
}
}
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

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

View file

@ -201,10 +201,7 @@ actor APIClient {
guard resp.OK else { guard resp.OK else {
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to allocate minor") throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to allocate minor")
} }
guard let minor = resp.BeaconMinor, minor >= 0 else { return resp.BeaconMinor ?? 0
throw APIError.serverError("API returned invalid minor value: \(resp.BeaconMinor.map(String.init) ?? "nil"). Service point may not be configured correctly.")
}
return minor
} }
/// API returns: { "OK": true, "BeaconHardwareID": 42, ... } /// API returns: { "OK": true, "BeaconHardwareID": 42, ... }
@ -220,7 +217,7 @@ actor APIClient {
uuid: String, uuid: String,
major: Int, major: Int,
minor: Int, minor: Int,
hardwareId: String, macAddress: String?,
beaconType: String, beaconType: String,
token: String token: String
) async throws { ) async throws {
@ -230,9 +227,9 @@ actor APIClient {
"UUID": uuid, "UUID": uuid,
"Major": major, "Major": major,
"Minor": minor, "Minor": minor,
"HardwareId": hardwareId,
"BeaconType": beaconType "BeaconType": beaconType
] ]
if let mac = macAddress { body["MacAddress"] = mac }
let data = try await post(path: "/beacon-sharding/register_beacon_hardware.php", body: body, token: token, businessId: businessId) let data = try await post(path: "/beacon-sharding/register_beacon_hardware.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(OKResponse.self, from: data) let resp = try JSONDecoder().decode(OKResponse.self, from: data)
guard resp.OK else { guard resp.OK else {
@ -241,13 +238,12 @@ actor APIClient {
} }
func verifyBeaconBroadcast( func verifyBeaconBroadcast(
hardwareId: String,
uuid: String, uuid: String,
major: Int, major: Int,
minor: Int, minor: Int,
token: String token: String
) async throws { ) async throws {
let body: [String: Any] = ["HardwareId": hardwareId, "UUID": uuid, "Major": major, "Minor": minor] let body: [String: Any] = ["UUID": uuid, "Major": major, "Minor": minor]
let data = try await post(path: "/beacon-sharding/verify_beacon_broadcast.php", body: body, token: token) let data = try await post(path: "/beacon-sharding/verify_beacon_broadcast.php", body: body, token: token)
let resp = try JSONDecoder().decode(OKResponse.self, from: data) let resp = try JSONDecoder().decode(OKResponse.self, from: data)
guard resp.OK else { guard resp.OK else {

View file

@ -2,7 +2,8 @@ import Foundation
import CoreBluetooth import CoreBluetooth
import Combine import Combine
/// Central BLE manager handles scanning and CP-28 beacon detection /// Central BLE manager handles scanning and beacon type detection
/// Matches Android's BeaconScanner.kt behavior
@MainActor @MainActor
final class BLEManager: NSObject, ObservableObject { final class BLEManager: NSObject, ObservableObject {
@ -12,19 +13,23 @@ final class BLEManager: NSObject, ObservableObject {
@Published var discoveredBeacons: [DiscoveredBeacon] = [] @Published var discoveredBeacons: [DiscoveredBeacon] = []
@Published var bluetoothState: CBManagerState = .unknown @Published var bluetoothState: CBManagerState = .unknown
// MARK: - Constants // MARK: - Constants (matching Android)
static let scanDuration: TimeInterval = 5.0 static let scanDuration: TimeInterval = 5.0
static let verifyScanDuration: TimeInterval = 15.0 static let verifyScanDuration: TimeInterval = 15.0
static let verifyPollInterval: TimeInterval = 0.5 static let verifyPollInterval: TimeInterval = 0.5
// CP-28 uses FFE0 service // GATT Service UUIDs
static let ffe0Service = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB") 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 // DX-Smart factory default iBeacon UUID
static let dxSmartDefaultUUID = "E2C56DB5-DFFB-48D2-B060-D0F5A71096E0" static let dxSmartDefaultUUID = "E2C56DB5-DFFB-48D2-B060-D0F5A71096E0"
// MARK: - Connection Callbacks (used by provisioners) // 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 onPeripheralConnected: ((CBPeripheral) -> Void)?
var onPeripheralFailedToConnect: ((CBPeripheral, Error?) -> Void)? var onPeripheralFailedToConnect: ((CBPeripheral, Error?) -> Void)?
@ -83,8 +88,11 @@ final class BLEManager: NSObject, ObservableObject {
} }
/// Verify a beacon is broadcasting expected iBeacon values. /// 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 { func verifyBroadcast(uuid: String, major: UInt16, minor: UInt16) async -> VerifyResult {
// TODO: Implement iBeacon region monitoring via CLLocationManager // 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( return VerifyResult(
found: false, found: false,
rssi: nil, rssi: nil,
@ -95,32 +103,40 @@ final class BLEManager: NSObject, ObservableObject {
// MARK: - iBeacon Manufacturer Data Parsing // MARK: - iBeacon Manufacturer Data Parsing
/// Parse iBeacon data from manufacturer data (Apple company ID 0x4C00) /// 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)? { 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.count >= 25 else { return nil }
guard mfgData[0] == 0x4C && mfgData[1] == 0x00 && guard mfgData[0] == 0x4C && mfgData[1] == 0x00 &&
mfgData[2] == 0x02 && mfgData[3] == 0x15 else { return nil } mfgData[2] == 0x02 && mfgData[3] == 0x15 else { return nil }
// Extract UUID (bytes 4-19)
let uuidBytes = mfgData.subdata(in: 4..<20) let uuidBytes = mfgData.subdata(in: 4..<20)
let hex = uuidBytes.map { String(format: "%02X", $0) }.joined() 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))" 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 major = UInt16(mfgData[20]) << 8 | UInt16(mfgData[21])
let minor = UInt16(mfgData[22]) << 8 | UInt16(mfgData[23]) let minor = UInt16(mfgData[22]) << 8 | UInt16(mfgData[23])
return (uuid: uuid, major: major, minor: minor) return (uuid: uuid, major: major, minor: minor)
} }
// MARK: - CP-28 Detection // MARK: - Beacon Type Detection (matches Android BeaconScanner.kt)
// Only detect DX-Smart / CP-28 beacons. Everything else is ignored. // 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.
func detectBeaconType( func detectBeaconType(
name: String?, name: String?,
serviceUUIDs: [CBUUID]?, serviceUUIDs: [CBUUID]?,
manufacturerData: Data? manufacturerData: Data?
) -> BeaconType? { ) -> BeaconType {
let deviceName = (name ?? "").lowercased() let deviceName = (name ?? "").lowercased()
// Parse iBeacon data if available // Parse iBeacon data if available (needed for UUID-based detection)
let iBeaconData: (uuid: String, major: UInt16, minor: UInt16)? let iBeaconData: (uuid: String, major: UInt16, minor: UInt16)?
if let mfgData = manufacturerData { if let mfgData = manufacturerData {
iBeaconData = parseIBeaconData(mfgData) iBeaconData = parseIBeaconData(mfgData)
@ -128,58 +144,89 @@ final class BLEManager: NSObject, ObservableObject {
iBeaconData = nil iBeaconData = nil
} }
// 1. Service UUID: CP-28 uses FFE0 // 1. Service UUID matching (matches Android lines 122-126)
if let services = serviceUUIDs { if let services = serviceUUIDs {
let serviceStrings = services.map { $0.uuidString.uppercased() } let serviceStrings = services.map { $0.uuidString.uppercased() }
// Android: KBeacon uses FFE0 as primary service
if serviceStrings.contains(where: { $0.hasPrefix("0000FFE0") }) { if serviceStrings.contains(where: { $0.hasPrefix("0000FFE0") }) {
// FFE0 with DX name patterns definitely CP-28 // Could be KBeacon or DXSmart check name to differentiate
if deviceName.contains("cp28") || deviceName.contains("cp-28") || if deviceName.contains("cp28") || deviceName.contains("cp-28") ||
deviceName.contains("dx") || deviceName.contains("pddaxlque") { deviceName.contains("dx") || deviceName.contains("pddaxlque") {
return .dxsmart return .dxsmart
} }
// FFE0 without a specific name still likely CP-28 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)
return .dxsmart return .dxsmart
} }
// CP-28 also advertises FFF0 on some firmware // Android: BlueCharm uses FEA0 (line 124)
if serviceStrings.contains(where: { $0.hasPrefix("0000FFF0") }) { if serviceStrings.contains(where: { $0.hasPrefix("0000FEA0") }) {
// Any FFF0 device is likely CP-28 don't filter by name return .bluecharm
return .dxsmart
} }
} }
// 2. DX-Smart factory default iBeacon UUID // 2. Detect DX-Smart by factory default iBeacon UUID (Android line 130)
// This is critical catches DX beacons that don't advertise service UUIDs
if let ibeacon = iBeaconData { if let ibeacon = iBeaconData {
if ibeacon.uuid.caseInsensitiveCompare(Self.dxSmartDefaultUUID) == .orderedSame { if ibeacon.uuid.caseInsensitiveCompare(Self.dxSmartDefaultUUID) == .orderedSame {
return .dxsmart return .dxsmart
} }
// Already provisioned with a Payfrit shard UUID // Check if broadcasting a Payfrit shard UUID (already provisioned DX beacon)
if BeaconShardPool.isPayfrit(ibeacon.uuid) { if BeaconShardPool.isPayfrit(ibeacon.uuid) {
return .dxsmart return .dxsmart
} }
} }
// 3. Device name patterns for CP-28 (includes "payfrit" our own provisioned name) // 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
}
if deviceName.contains("cp28") || deviceName.contains("cp-28") || if deviceName.contains("cp28") || deviceName.contains("cp-28") ||
deviceName.contains("dx-cp") || deviceName.contains("dx-smart") || deviceName.contains("dx-cp") || deviceName.contains("dx-smart") ||
deviceName.contains("dxsmart") || deviceName.contains("pddaxlque") || deviceName.contains("dxsmart") || deviceName.contains("pddaxlque") {
deviceName.contains("payfrit") {
return .dxsmart return .dxsmart
} }
// 4. iBeacon minor in high range (factory default DX pattern) // 4. Detect by iBeacon minor in high range (Android line 143)
if let ibeacon = iBeaconData, ibeacon.minor > 10000 { if let ibeacon = iBeaconData, ibeacon.minor > 10000 {
return .dxsmart return .dxsmart
} }
// 5. Any iBeacon advertisement likely a CP-28 in the field // 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
if iBeaconData != nil { if iBeaconData != nil {
return .dxsmart return .dxsmart
} }
// Not a CP-28 don't show it return .unknown
return nil
} }
} }
@ -218,22 +265,37 @@ extension BLEManager: CBCentralManagerDelegate {
advertisementData: [String: Any], advertisementData: [String: Any],
rssi RSSI: NSNumber rssi RSSI: NSNumber
) { ) {
// Capture values in nonisolated context before hopping to main
let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "" let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? ""
let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID]
let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
let isConnectable = advertisementData[CBAdvertisementDataIsConnectable] as? Bool ?? false
let peripheralId = peripheral.identifier let peripheralId = peripheral.identifier
let rssiValue = RSSI.intValue let rssiValue = RSSI.intValue
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self else { return } guard let self else { return }
// Detect beacon type default to .dxsmart so ALL devices show up in scan let type = self.detectBeaconType(name: name, serviceUUIDs: serviceUUIDs, manufacturerData: mfgData)
let type = self.detectBeaconType(name: name, serviceUUIDs: serviceUUIDs, manufacturerData: mfgData) ?? .dxsmart
// 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
}
}
if let idx = self.discoveredBeacons.firstIndex(where: { $0.id == peripheralId }) { if let idx = self.discoveredBeacons.firstIndex(where: { $0.id == peripheralId }) {
// Update existing
self.discoveredBeacons[idx].rssi = rssiValue self.discoveredBeacons[idx].rssi = rssiValue
self.discoveredBeacons[idx].lastSeen = Date() self.discoveredBeacons[idx].lastSeen = Date()
} else { } else {
// New beacon
let beacon = DiscoveredBeacon( let beacon = DiscoveredBeacon(
id: peripheralId, id: peripheralId,
peripheral: peripheral, peripheral: peripheral,
@ -245,6 +307,7 @@ extension BLEManager: CBCentralManagerDelegate {
self.discoveredBeacons.append(beacon) self.discoveredBeacons.append(beacon)
} }
// Keep list sorted by RSSI (strongest/closest first)
self.discoveredBeacons.sort { $0.rssi > $1.rssi } self.discoveredBeacons.sort { $0.rssi > $1.rssi }
} }
} }

View file

@ -23,7 +23,6 @@ struct ScanView: View {
// Provisioning flow // Provisioning flow
@State private var selectedBeacon: DiscoveredBeacon? @State private var selectedBeacon: DiscoveredBeacon?
@State private var provisioningState: ProvisioningState = .idle @State private var provisioningState: ProvisioningState = .idle
@State private var writesCompleted = false
@State private var statusMessage = "" @State private var statusMessage = ""
@State private var errorMessage: String? @State private var errorMessage: String?
@State private var showQRScanner = false @State private var showQRScanner = false
@ -209,8 +208,8 @@ struct ScanView: View {
progressView(title: "Connecting…", message: statusMessage) progressView(title: "Connecting…", message: statusMessage)
case .connected: case .connected:
// Legacy auto-write skips this state now // DXSmart: beacon is flashing, show write button
progressView(title: "Connected…", message: statusMessage) dxsmartConnectedView
case .writing: case .writing:
progressView(title: "Writing Config…", message: statusMessage) progressView(title: "Writing Config…", message: statusMessage)
@ -319,7 +318,51 @@ struct ScanView: View {
// MARK: - DXSmart Connected View // MARK: - DXSmart Connected View
// dxsmartConnectedView removed auto-write skips the manual confirmation step private var dxsmartConnectedView: some View {
VStack(spacing: 24) {
Spacer()
Image(systemName: "light.beacon.max")
.font(.system(size: 64))
.foregroundStyle(Color.payfritGreen)
.modifier(PulseEffectModifier())
Text("Connected — Beacon is Flashing")
.font(.title2.bold())
Text("Confirm the beacon LED is flashing, then tap Write Config to program it.\n\nThe beacon will timeout if you wait too long.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
Button {
Task { await writeConfigToConnectedBeacon() }
} label: {
HStack {
Image(systemName: "arrow.down.doc")
Text("Write Config")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(Color.payfritGreen)
.controlSize(.large)
.padding(.horizontal, 32)
Button("Cancel") {
cancelProvisioning()
}
.foregroundStyle(.secondary)
// Show diagnostic log
if !provisionLog.entries.isEmpty {
diagnosticLogView
}
Spacer()
}
}
// MARK: - Progress / Success / Failed Views // MARK: - Progress / Success / Failed Views
@ -570,14 +613,16 @@ struct ScanView: View {
// Create appropriate provisioner // Create appropriate provisioner
let provisioner = makeProvisioner(for: beacon) let provisioner = makeProvisioner(for: beacon)
pendingProvisioner = provisioner
pendingConfig = config
// Wire up real-time status updates from provisioner // Wire up real-time status updates from provisioner
if let dxProvisioner = provisioner as? DXSmartProvisioner { if let dxProvisioner = provisioner as? DXSmartProvisioner {
dxProvisioner.onStatusUpdate = { [weak self] status in dxProvisioner.onStatusUpdate = { [weak self] status in
self?.statusMessage = status self?.statusMessage = status
} }
} else if let kbProvisioner = provisioner as? KBeaconProvisioner {
kbProvisioner.onStatusUpdate = { [weak self] status in
self?.statusMessage = status
}
} }
statusMessage = "Connecting to \(beacon.displayName)" statusMessage = "Connecting to \(beacon.displayName)"
@ -587,19 +632,17 @@ struct ScanView: View {
bleManager.onPeripheralDisconnected = { [weak provisionLog] peripheral, error in bleManager.onPeripheralDisconnected = { [weak provisionLog] peripheral, error in
if peripheral.identifier == beacon.peripheral.identifier { if peripheral.identifier == beacon.peripheral.identifier {
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
let reason = error?.localizedDescription ?? "beacon timed out" let reason = error?.localizedDescription ?? "beacon timed out"
provisionLog?.log("disconnect", "Unexpected disconnect: \(reason)", isError: true)
// Writes already finished beacon rebooted after SaveConfig, this is expected guard let self = self else { return }
if self.writesCompleted { // DXSmart: disconnect during .connected is expected beacon keeps
provisionLog?.log("disconnect", "Post-write disconnect (expected — beacon rebooted after save)") // 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")
return return
} }
// For all other active states, treat disconnect as failure
provisionLog?.log("disconnect", "Unexpected disconnect: \(reason)", isError: true) if self.provisioningState == .connecting || self.provisioningState == .connected ||
// For all active states, treat disconnect as failure
if self.provisioningState == .connecting ||
self.provisioningState == .writing || self.provisioningState == .verifying { self.provisioningState == .writing || self.provisioningState == .verifying {
self.provisioningState = .failed self.provisioningState = .failed
self.errorMessage = "Beacon disconnected: \(reason)" self.errorMessage = "Beacon disconnected: \(reason)"
@ -611,32 +654,53 @@ struct ScanView: View {
try await provisioner.connect() try await provisioner.connect()
provisionLog.log("connect", "Connected and authenticated successfully") provisionLog.log("connect", "Connected and authenticated successfully")
// Auto-fire write immediately no pause needed // 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 provisioningState = .writing
writesCompleted = false statusMessage = "Writing \(config.formattedUUID.prefix(8))… Major:\(config.major) Minor:\(config.minor)"
statusMessage = "Writing config to DX-Smart…" provisionLog.log("write", "Writing config…")
provisionLog.log("write", "Auto-writing config (no user tap needed)…")
try await provisioner.writeConfig(config) try await provisioner.writeConfig(config)
writesCompleted = true provisionLog.log("write", "Config written — disconnecting")
// Brief settle after SaveConfig before dropping the BLE link.
try? await Task.sleep(nanoseconds: 50_000_000)
provisioner.disconnect() provisioner.disconnect()
// Register with backend
try await APIClient.shared.registerBeaconHardware( try await APIClient.shared.registerBeaconHardware(
businessId: business.id, businessId: business.id,
servicePointId: sp.id, servicePointId: sp.id,
uuid: ns.uuid, uuid: ns.uuid,
major: ns.major, major: ns.major,
minor: Int(config.minor), minor: minor,
hardwareId: selectedBeacon?.id.uuidString ?? "unknown-ios", macAddress: nil,
beaconType: BeaconType.dxsmart.rawValue, beaconType: beacon.type.rawValue,
token: token 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 provisioningState = .done
statusMessage = "\(sp.name) — DX-Smart\nUUID: \(config.formattedUUID.prefix(13))\nMajor: \(config.major) Minor: \(config.minor)" statusMessage = "\(sp.name)\(beacon.type.rawValue)\nUUID: \(config.formattedUUID.prefix(13))\nMajor: \(config.major) Minor: \(config.minor)"
} catch { } catch {
provisionLog.log("error", "Provisioning failed after \(provisionLog.elapsed): \(error.localizedDescription)", isError: true) provisionLog.log("error", "Provisioning failed after \(provisionLog.elapsed): \(error.localizedDescription)", isError: true)
@ -645,10 +709,56 @@ struct ScanView: View {
} }
} }
// Kept for cancel/reset and registerAnywayAfterFailure fallback // Store for DXSmart two-phase flow
@State private var pendingConfig: BeaconConfig? @State private var pendingConfig: BeaconConfig?
@State private var pendingProvisioner: (any BeaconProvisioner)? @State private var pendingProvisioner: (any BeaconProvisioner)?
private func writeConfigToConnectedBeacon() async {
guard let config = pendingConfig,
let provisioner = pendingProvisioner,
let sp = selectedServicePoint,
let ns = namespace,
let token = appState.token else { return }
provisioningState = .writing
statusMessage = "Writing config to DX-Smart…"
do {
// Reconnect if the beacon dropped BLE during the "confirm flashing" wait
if !provisioner.isConnected {
provisionLog.log("write", "Beacon disconnected while waiting — reconnecting…")
statusMessage = "Reconnecting to beacon…"
try await provisioner.connect()
provisionLog.log("write", "Reconnected — writing config…")
statusMessage = "Writing config to DX-Smart…"
}
try await provisioner.writeConfig(config)
provisioner.disconnect()
try await APIClient.shared.registerBeaconHardware(
businessId: business.id,
servicePointId: sp.id,
uuid: ns.uuid,
major: ns.major,
minor: Int(config.minor),
macAddress: nil,
beaconType: BeaconType.dxsmart.rawValue,
token: token
)
provisioningState = .done
statusMessage = "\(sp.name) — DX-Smart\nUUID: \(config.formattedUUID.prefix(13))\nMajor: \(config.major) Minor: \(config.minor)"
} catch {
provisioningState = .failed
errorMessage = error.localizedDescription
}
pendingConfig = nil
pendingProvisioner = nil
}
private func registerAnywayAfterFailure() async { private func registerAnywayAfterFailure() async {
guard let sp = selectedServicePoint, guard let sp = selectedServicePoint,
let ns = namespace, let ns = namespace,
@ -665,7 +775,7 @@ struct ScanView: View {
uuid: ns.uuid, uuid: ns.uuid,
major: ns.major, major: ns.major,
minor: Int(config.minor), minor: Int(config.minor),
hardwareId: selectedBeacon?.id.uuidString ?? "unknown-ios", macAddress: nil,
beaconType: selectedBeacon?.type.rawValue ?? "Unknown", beaconType: selectedBeacon?.type.rawValue ?? "Unknown",
token: token token: token
) )
@ -738,10 +848,17 @@ struct ScanView: View {
} }
private func makeProvisioner(for beacon: DiscoveredBeacon) -> any BeaconProvisioner { private func makeProvisioner(for beacon: DiscoveredBeacon) -> any BeaconProvisioner {
var provisioner: any BeaconProvisioner = DXSmartProvisioner( var provisioner: any BeaconProvisioner
peripheral: beacon.peripheral, switch beacon.type {
centralManager: bleManager.centralManager 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)
}
provisioner.bleManager = bleManager provisioner.bleManager = bleManager
provisioner.diagnosticLog = provisionLog provisioner.diagnosticLog = provisionLog
return provisioner return provisioner
@ -810,7 +927,12 @@ struct BeaconRow: View {
} }
private var typeColor: Color { private var typeColor: Color {
return .payfritGreen switch beacon.type {
case .kbeacon: return .payfritGreen
case .dxsmart: return .warningOrange
case .bluecharm: return .infoBlue
case .unknown: return .gray
}
} }
} }