Compare commits
27 commits
schwifty/p
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 66cf65f803 | |||
| 38600193b7 | |||
| 3c41ecb49d | |||
| a08d3db893 | |||
| 640bc32f92 | |||
| 157ab6d008 | |||
| ce81a1a3d8 | |||
| fcf427ee57 | |||
| 4bf4435feb | |||
| f082eeadad | |||
| 3720f496bd | |||
| a82a3697da | |||
| 9ce7b9571a | |||
| 734a18356f | |||
| f0fdb04e0e | |||
| 12048e5c88 | |||
| 5eebf00aa0 | |||
| 5678256356 | |||
| 7089224244 | |||
| b88dded928 | |||
| 37c7c72052 | |||
| 6eaccb6bf6 | |||
| 8f8fcba9c0 | |||
| 61862adfa8 | |||
| ed9a57a938 | |||
| c3f2b4faab | |||
| 349dab1b75 |
12 changed files with 277 additions and 1093 deletions
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 */,
|
||||
|
|
|
|||
|
|
@ -365,8 +365,10 @@ class BeaconProvisioner: NSObject, ObservableObject {
|
|||
isTerminating = true
|
||||
DebugLog.shared.log("BLE: Provisioning success!")
|
||||
state = .success
|
||||
disconnectPeripheral()
|
||||
// Signal completion BEFORE disconnecting — the disconnect delegate fires
|
||||
// synchronously and ScanView needs writesCompleted=true before it sees it
|
||||
completion?(.success(macAddress: nil))
|
||||
disconnectPeripheral()
|
||||
cleanup()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -41,6 +41,7 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
|
|||
private(set) var isConnected = false
|
||||
private(set) var isFlashing = false // Beacon LED flashing after trigger
|
||||
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 bleManager: BLEManager?
|
||||
|
||||
|
|
@ -55,18 +56,35 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
|
|||
|
||||
// MARK: - BeaconProvisioner
|
||||
|
||||
/// Status callback — provisioner reports what phase it's in so UI can update
|
||||
var onStatusUpdate: ((String) -> Void)?
|
||||
|
||||
func connect() async throws {
|
||||
for attempt in 1...GATTConstants.maxRetries {
|
||||
await diagnosticLog?.log("connect", "Attempt \(attempt)/\(GATTConstants.maxRetries) — peripheral: \(peripheral.name ?? "unnamed"), state: \(peripheral.state.rawValue)")
|
||||
do {
|
||||
let attemptLabel = GATTConstants.maxRetries > 1 ? " (attempt \(attempt)/\(GATTConstants.maxRetries))" : ""
|
||||
await MainActor.run { onStatusUpdate?("Connecting to beacon…\(attemptLabel)") }
|
||||
await diagnosticLog?.log("connect", "Connecting (timeout: \(GATTConstants.connectionTimeout)s)…")
|
||||
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 — FFE1:\(ffe1Char != nil) FFE2:\(ffe2Char != nil) FFE3:\(ffe3Char != nil)")
|
||||
|
||||
await MainActor.run { onStatusUpdate?("Authenticating…") }
|
||||
await diagnosticLog?.log("auth", "Authenticating (trigger + password)…")
|
||||
try await authenticate()
|
||||
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
|
||||
isFlashing = true
|
||||
return
|
||||
|
|
@ -74,6 +92,7 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
|
|||
await diagnosticLog?.log("connect", "Attempt \(attempt) failed: \(error.localizedDescription)", isError: true)
|
||||
disconnect()
|
||||
if attempt < GATTConstants.maxRetries {
|
||||
await MainActor.run { onStatusUpdate?("Retrying… (\(attempt)/\(GATTConstants.maxRetries))") }
|
||||
await diagnosticLog?.log("connect", "Retrying in \(GATTConstants.retryDelay)s…")
|
||||
try await Task.sleep(nanoseconds: UInt64(GATTConstants.retryDelay * 1_000_000_000))
|
||||
} else {
|
||||
|
|
@ -115,6 +134,8 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
|
|||
}
|
||||
|
||||
func disconnect() {
|
||||
// Unregister disconnect handler so intentional disconnect doesn't trigger error path
|
||||
bleManager?.onPeripheralDisconnected = nil
|
||||
if peripheral.state == .connected || peripheral.state == .connecting {
|
||||
centralManager.cancelPeripheralConnection(peripheral)
|
||||
}
|
||||
|
|
@ -161,15 +182,48 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
|
|||
]
|
||||
|
||||
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)")
|
||||
|
||||
// SaveConfig (last command) causes beacon MCU to reboot — it never sends an ACK.
|
||||
// Use .withoutResponse so CoreBluetooth fires the bytes immediately into the
|
||||
// BLE radio buffer without waiting for a GATT round-trip. With .withResponse,
|
||||
// the beacon reboots before the ACK arrives, and CoreBluetooth may silently
|
||||
// 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
|
||||
}
|
||||
|
||||
// Retry each command up to 2 times — beacon BLE stack can be flaky
|
||||
var lastError: Error?
|
||||
for writeAttempt in 1...2 {
|
||||
do {
|
||||
try await writeToCharAndWaitACK(writeChar, data: packet, label: name)
|
||||
lastError = nil
|
||||
break
|
||||
} catch {
|
||||
await diagnosticLog?.log("write", "[\(index + 1)/\(commands.count)] \(name) FAILED: \(error.localizedDescription)", isError: true)
|
||||
throw error
|
||||
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
|
||||
}
|
||||
// 200ms between commands (matches Android SDK timer interval)
|
||||
try await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -245,6 +299,32 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
|
|||
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
|
||||
|
||||
private func connectOnce() async throws {
|
||||
|
|
@ -304,13 +384,13 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
|
|||
// Step 1: Trigger — fire and forget (matches Android's WRITE_TYPE_NO_RESPONSE)
|
||||
if let triggerData = Self.triggerPassword.data(using: .utf8) {
|
||||
peripheral.writeValue(triggerData, for: ffe3, type: .withoutResponse)
|
||||
try await Task.sleep(nanoseconds: 100_000_000) // 100ms (matches Android SDK timer)
|
||||
try await Task.sleep(nanoseconds: 50_000_000) // 50ms settle
|
||||
}
|
||||
|
||||
// Step 2: Auth password — fire and forget
|
||||
if let authData = Self.defaultPassword.data(using: .utf8) {
|
||||
peripheral.writeValue(authData, for: ffe3, type: .withoutResponse)
|
||||
try await Task.sleep(nanoseconds: 100_000_000) // 100ms settle
|
||||
try await Task.sleep(nanoseconds: 50_000_000) // 50ms settle
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -438,10 +518,18 @@ extension DXSmartProvisioner: CBPeripheralDelegate {
|
|||
return
|
||||
}
|
||||
|
||||
// Handle write errors for command writes
|
||||
if let error, let cont = responseContinuation {
|
||||
// For command writes (FFE1/FFE2): the .withResponse write confirmation
|
||||
// IS the ACK. Some commands (e.g. 0x61 Frame1_DevInfo) don't send a
|
||||
// separate FFE1 notification, so we must resolve here on success too.
|
||||
// If a notification also arrives later, responseContinuation will already
|
||||
// be nil — harmless.
|
||||
if let cont = responseContinuation {
|
||||
responseContinuation = nil
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
} else {
|
||||
cont.resume(returning: Data())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,77 +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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,281 +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)
|
||||
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?
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
init(peripheral: CBPeripheral, centralManager: CBCentralManager) {
|
||||
self.peripheral = peripheral
|
||||
self.centralManager = centralManager
|
||||
super.init()
|
||||
self.peripheral.delegate = self
|
||||
}
|
||||
|
||||
// MARK: - BeaconProvisioner
|
||||
|
||||
func connect() async throws {
|
||||
// Connect with retry
|
||||
for attempt in 1...GATTConstants.maxRetries {
|
||||
do {
|
||||
try await connectOnce()
|
||||
try await discoverServices()
|
||||
try await authenticate()
|
||||
isConnected = true
|
||||
return
|
||||
} catch {
|
||||
disconnect()
|
||||
if attempt < GATTConstants.maxRetries {
|
||||
try await Task.sleep(nanoseconds: UInt64(GATTConstants.retryDelay * 1_000_000_000))
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeConfig(_ config: BeaconConfig) async throws {
|
||||
guard isConnected, let writeChar else {
|
||||
throw ProvisionError.notConnected
|
||||
}
|
||||
|
||||
// Build parameter payload
|
||||
var params = Data()
|
||||
|
||||
// UUID (16 bytes)
|
||||
params.append(ParamID.uuid.rawValue)
|
||||
let uuidBytes = config.uuid.hexToBytes
|
||||
params.append(contentsOf: uuidBytes)
|
||||
|
||||
// Major (2 bytes BE)
|
||||
params.append(ParamID.major.rawValue)
|
||||
params.append(UInt8(config.major >> 8))
|
||||
params.append(UInt8(config.major & 0xFF))
|
||||
|
||||
// Minor (2 bytes BE)
|
||||
params.append(ParamID.minor.rawValue)
|
||||
params.append(UInt8(config.minor >> 8))
|
||||
params.append(UInt8(config.minor & 0xFF))
|
||||
|
||||
// TX Power
|
||||
params.append(ParamID.txPower.rawValue)
|
||||
params.append(config.txPower)
|
||||
|
||||
// Adv Interval
|
||||
params.append(ParamID.advInterval.rawValue)
|
||||
params.append(UInt8(config.advInterval >> 8))
|
||||
params.append(UInt8(config.advInterval & 0xFF))
|
||||
|
||||
// Send CMD_WRITE_PARAMS
|
||||
let writeCmd = Data([CMD.writeParams.rawValue]) + params
|
||||
let writeResp = try await sendCommand(writeCmd)
|
||||
guard writeResp.first == CMD.writeParams.rawValue else {
|
||||
throw ProvisionError.writeFailed("Unexpected write response")
|
||||
}
|
||||
|
||||
// Send CMD_SAVE to flash
|
||||
let saveResp = try await sendCommand(Data([CMD.save.rawValue]))
|
||||
guard saveResp.first == CMD.save.rawValue else {
|
||||
throw ProvisionError.saveFailed
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
if peripheral.state == .connected || peripheral.state == .connecting {
|
||||
centralManager.cancelPeripheralConnection(peripheral)
|
||||
}
|
||||
isConnected = false
|
||||
}
|
||||
|
||||
// MARK: - Private: Connection
|
||||
|
||||
private func connectOnce() async throws {
|
||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||||
connectionContinuation = cont
|
||||
|
||||
// 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 password in Self.passwords {
|
||||
let cmd = Data([CMD.auth.rawValue]) + password
|
||||
do {
|
||||
let resp = try await sendCommand(cmd)
|
||||
if resp.first == CMD.auth.rawValue && resp.count > 1 && resp[1] == 0x00 {
|
||||
return // Auth success
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
throw ProvisionError.authFailed
|
||||
}
|
||||
|
||||
private func sendCommand(_ data: Data) async throws -> Data {
|
||||
guard let writeChar else { throw ProvisionError.notConnected }
|
||||
|
||||
return try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Data, Error>) in
|
||||
writeContinuation = cont
|
||||
peripheral.writeValue(data, for: writeChar, type: .withResponse)
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in
|
||||
if let c = self?.writeContinuation {
|
||||
self?.writeContinuation = nil
|
||||
c.resume(throwing: ProvisionError.operationTimeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CBPeripheralDelegate
|
||||
|
||||
extension KBeaconProvisioner: CBPeripheralDelegate {
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
||||
if let error {
|
||||
serviceContinuation?.resume(throwing: error)
|
||||
serviceContinuation = nil
|
||||
return
|
||||
}
|
||||
|
||||
guard let service = peripheral.services?.first(where: { $0.uuid == GATTConstants.ffe0Service }) else {
|
||||
serviceContinuation?.resume(throwing: ProvisionError.serviceNotFound)
|
||||
serviceContinuation = nil
|
||||
return
|
||||
}
|
||||
|
||||
peripheral.discoverCharacteristics([GATTConstants.ffe1Char, GATTConstants.ffe2Char], for: service)
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
||||
if let error {
|
||||
serviceContinuation?.resume(throwing: error)
|
||||
serviceContinuation = nil
|
||||
return
|
||||
}
|
||||
|
||||
for char in service.characteristics ?? [] {
|
||||
switch char.uuid {
|
||||
case GATTConstants.ffe1Char:
|
||||
writeChar = char
|
||||
case GATTConstants.ffe2Char:
|
||||
notifyChar = char
|
||||
peripheral.setNotifyValue(true, for: char)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if writeChar != nil {
|
||||
serviceContinuation?.resume()
|
||||
serviceContinuation = nil
|
||||
} else {
|
||||
serviceContinuation?.resume(throwing: ProvisionError.characteristicNotFound)
|
||||
serviceContinuation = nil
|
||||
}
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
||||
guard characteristic.uuid == GATTConstants.ffe2Char,
|
||||
let data = characteristic.value else { return }
|
||||
|
||||
if let cont = writeContinuation {
|
||||
writeContinuation = nil
|
||||
cont.resume(returning: data)
|
||||
}
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
|
||||
// Write acknowledgment — actual response comes via notify on FFE2
|
||||
if let error {
|
||||
if let cont = writeContinuation {
|
||||
writeContinuation = nil
|
||||
cont.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -201,7 +201,10 @@ actor APIClient {
|
|||
guard resp.OK else {
|
||||
throw APIError.serverError(resp.MESSAGE ?? resp.ERROR ?? "Failed to allocate minor")
|
||||
}
|
||||
return resp.BeaconMinor ?? 0
|
||||
guard let minor = resp.BeaconMinor, minor >= 0 else {
|
||||
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, ... }
|
||||
|
|
@ -217,7 +220,7 @@ actor APIClient {
|
|||
uuid: String,
|
||||
major: Int,
|
||||
minor: Int,
|
||||
macAddress: String?,
|
||||
hardwareId: String,
|
||||
beaconType: String,
|
||||
token: String
|
||||
) async throws {
|
||||
|
|
@ -227,9 +230,9 @@ actor APIClient {
|
|||
"UUID": uuid,
|
||||
"Major": major,
|
||||
"Minor": minor,
|
||||
"HardwareId": hardwareId,
|
||||
"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 resp = try JSONDecoder().decode(OKResponse.self, from: data)
|
||||
guard resp.OK else {
|
||||
|
|
@ -238,12 +241,13 @@ actor APIClient {
|
|||
}
|
||||
|
||||
func verifyBeaconBroadcast(
|
||||
hardwareId: String,
|
||||
uuid: String,
|
||||
major: Int,
|
||||
minor: Int,
|
||||
token: String
|
||||
) async throws {
|
||||
let body: [String: Any] = ["UUID": uuid, "Major": major, "Minor": minor]
|
||||
let body: [String: Any] = ["HardwareId": hardwareId, "UUID": uuid, "Major": major, "Minor": minor]
|
||||
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)
|
||||
guard resp.OK else {
|
||||
|
|
|
|||
|
|
@ -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)?
|
||||
|
|
@ -65,7 +60,7 @@ final class BLEManager: NSObject, ObservableObject {
|
|||
])
|
||||
|
||||
scanTimer = Timer.scheduledTimer(withTimeInterval: duration, repeats: false) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
DispatchQueue.main.async {
|
||||
self?.stopScan()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,58 @@ 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
|
||||
// FFE0 without a specific name — still likely CP-28
|
||||
return .dxsmart
|
||||
}
|
||||
|
||||
// Android: DXSmart also uses FFF0 (line 125)
|
||||
// FIXED: Was incorrectly mapping FFF0 → BlueCharm only.
|
||||
// Android maps DXSmartProvisioner.SERVICE_UUID_FFF0 → DXSMART
|
||||
// CP-28 also advertises FFF0 on some firmware
|
||||
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
|
||||
// Any FFF0 device is likely CP-28 — don't filter by name
|
||||
return .dxsmart
|
||||
}
|
||||
if deviceName.contains("bluecharm") || deviceName.hasPrefix("bc") ||
|
||||
deviceName.hasPrefix("table-") {
|
||||
return .bluecharm
|
||||
}
|
||||
// Default FFF0 to DXSmart (matching Android behavior)
|
||||
return .dxsmart
|
||||
}
|
||||
|
||||
// Android: BlueCharm uses FEA0 (line 124)
|
||||
if serviceStrings.contains(where: { $0.hasPrefix("0000FEA0") }) {
|
||||
return .bluecharm
|
||||
}
|
||||
}
|
||||
|
||||
// 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 (includes "payfrit" — our own provisioned name)
|
||||
if deviceName.contains("cp28") || deviceName.contains("cp-28") ||
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -235,26 +188,27 @@ final class BLEManager: NSObject, ObservableObject {
|
|||
extension BLEManager: CBCentralManagerDelegate {
|
||||
|
||||
nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||
Task { @MainActor in
|
||||
bluetoothState = central.state
|
||||
let state = central.state
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.bluetoothState = state
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||||
Task { @MainActor in
|
||||
onPeripheralConnected?(peripheral)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.onPeripheralConnected?(peripheral)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
|
||||
Task { @MainActor in
|
||||
onPeripheralFailedToConnect?(peripheral, error)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.onPeripheralFailedToConnect?(peripheral, error)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
||||
Task { @MainActor in
|
||||
onPeripheralDisconnected?(peripheral, error)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.onPeripheralDisconnected?(peripheral, error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -264,45 +218,34 @@ extension BLEManager: CBCentralManagerDelegate {
|
|||
advertisementData: [String: Any],
|
||||
rssi RSSI: NSNumber
|
||||
) {
|
||||
Task { @MainActor in
|
||||
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
|
||||
|
||||
let type = detectBeaconType(name: name, serviceUUIDs: serviceUUIDs, manufacturerData: mfgData)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
// 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 { parseIBeaconData($0) } != nil
|
||||
if !hasIBeaconData && !(isConnectable && hasName) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// Detect beacon type — default to .dxsmart so ALL devices show up in scan
|
||||
let type = self.detectBeaconType(name: name, serviceUUIDs: serviceUUIDs, manufacturerData: mfgData) ?? .dxsmart
|
||||
|
||||
if let idx = discoveredBeacons.firstIndex(where: { $0.id == peripheral.identifier }) {
|
||||
// Update existing
|
||||
discoveredBeacons[idx].rssi = RSSI.intValue
|
||||
discoveredBeacons[idx].lastSeen = Date()
|
||||
if let idx = self.discoveredBeacons.firstIndex(where: { $0.id == peripheralId }) {
|
||||
self.discoveredBeacons[idx].rssi = rssiValue
|
||||
self.discoveredBeacons[idx].lastSeen = Date()
|
||||
} else {
|
||||
// New beacon
|
||||
let beacon = DiscoveredBeacon(
|
||||
id: peripheral.identifier,
|
||||
id: peripheralId,
|
||||
peripheral: peripheral,
|
||||
name: name,
|
||||
type: type,
|
||||
rssi: RSSI.intValue,
|
||||
rssi: rssiValue,
|
||||
lastSeen: Date()
|
||||
)
|
||||
discoveredBeacons.append(beacon)
|
||||
self.discoveredBeacons.append(beacon)
|
||||
}
|
||||
|
||||
// Keep list sorted by RSSI (strongest/closest first)
|
||||
discoveredBeacons.sort { $0.rssi > $1.rssi }
|
||||
self.discoveredBeacons.sort { $0.rssi > $1.rssi }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ struct ScanView: View {
|
|||
// Provisioning flow
|
||||
@State private var selectedBeacon: DiscoveredBeacon?
|
||||
@State private var provisioningState: ProvisioningState = .idle
|
||||
@State private var writesCompleted = false
|
||||
@State private var statusMessage = ""
|
||||
@State private var errorMessage: String?
|
||||
@State private var showQRScanner = false
|
||||
|
|
@ -208,8 +209,8 @@ struct ScanView: View {
|
|||
progressView(title: "Connecting…", message: statusMessage)
|
||||
|
||||
case .connected:
|
||||
// DXSmart: beacon is flashing, show write button
|
||||
dxsmartConnectedView
|
||||
// Legacy — auto-write skips this state now
|
||||
progressView(title: "Connected…", message: statusMessage)
|
||||
|
||||
case .writing:
|
||||
progressView(title: "Writing Config…", message: statusMessage)
|
||||
|
|
@ -318,46 +319,7 @@ struct ScanView: View {
|
|||
|
||||
// MARK: - DXSmart Connected View
|
||||
|
||||
private var dxsmartConnectedView: some View {
|
||||
VStack(spacing: 24) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "light.beacon.max")
|
||||
.font(.system(size: 64))
|
||||
.foregroundStyle(Color.payfritGreen)
|
||||
.modifier(PulseEffectModifier())
|
||||
|
||||
Text("Beacon is Flashing")
|
||||
.font(.title2.bold())
|
||||
|
||||
Text("Confirm the beacon LED is flashing, then tap Write Config to program it.")
|
||||
.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)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
// dxsmartConnectedView removed — auto-write skips the manual confirmation step
|
||||
|
||||
// MARK: - Progress / Success / Failed Views
|
||||
|
||||
|
|
@ -371,7 +333,58 @@ struct ScanView: View {
|
|||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
// Show live diagnostic log during connecting/writing
|
||||
if !provisionLog.entries.isEmpty {
|
||||
diagnosticLogView
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Cancel") {
|
||||
cancelProvisioning()
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reusable diagnostic log view
|
||||
private var diagnosticLogView: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text("Log (\(provisionLog.elapsed))")
|
||||
.font(.caption.bold())
|
||||
Spacer()
|
||||
ShareLink(item: provisionLog.fullText) {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(provisionLog.entries) { entry in
|
||||
Text(entry.formatted)
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(entry.isError ? Color.errorRed : .primary)
|
||||
.id(entry.id)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
.onChange(of: provisionLog.entries.count) { _ in
|
||||
if let last = provisionLog.entries.last {
|
||||
proxy.scrollTo(last.id, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: 160)
|
||||
.background(Color(.systemGray6))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -411,33 +424,7 @@ struct ScanView: View {
|
|||
|
||||
// Diagnostic log
|
||||
if !provisionLog.entries.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text("Diagnostic Log (\(provisionLog.elapsed))")
|
||||
.font(.caption.bold())
|
||||
Spacer()
|
||||
ShareLink(item: provisionLog.fullText) {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(provisionLog.entries) { entry in
|
||||
Text(entry.formatted)
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(entry.isError ? Color.errorRed : .primary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
.frame(maxHeight: 200)
|
||||
.background(Color(.systemGray6))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
diagnosticLogView
|
||||
}
|
||||
|
||||
HStack(spacing: 16) {
|
||||
|
|
@ -556,7 +543,7 @@ struct ScanView: View {
|
|||
let token = appState.token else { return }
|
||||
|
||||
provisioningState = .connecting
|
||||
statusMessage = "Connecting to \(beacon.displayName)…"
|
||||
statusMessage = "Allocating beacon config…"
|
||||
errorMessage = nil
|
||||
provisionLog.reset()
|
||||
provisionLog.log("init", "Starting provisioning: \(beacon.displayName) (\(beacon.type.rawValue)) RSSI:\(beacon.rssi)")
|
||||
|
|
@ -583,93 +570,58 @@ struct ScanView: View {
|
|||
|
||||
// Create appropriate provisioner
|
||||
let provisioner = makeProvisioner(for: beacon)
|
||||
pendingProvisioner = provisioner
|
||||
pendingConfig = config
|
||||
|
||||
statusMessage = "Authenticating with \(beacon.type.rawValue)…"
|
||||
// Wire up real-time status updates from provisioner
|
||||
if let dxProvisioner = provisioner as? DXSmartProvisioner {
|
||||
dxProvisioner.onStatusUpdate = { [weak self] status in
|
||||
self?.statusMessage = status
|
||||
}
|
||||
}
|
||||
|
||||
statusMessage = "Connecting to \(beacon.displayName)…"
|
||||
provisionLog.log("connect", "Connecting to \(beacon.type.rawValue) provisioner…")
|
||||
|
||||
// Monitor for unexpected disconnects during provisioning
|
||||
bleManager.onPeripheralDisconnected = { [weak provisionLog] peripheral, error in
|
||||
if peripheral.identifier == beacon.peripheral.identifier {
|
||||
Task { @MainActor in
|
||||
provisionLog?.log("disconnect", "Unexpected disconnect: \(error?.localizedDescription ?? "no error")", isError: true)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let reason = error?.localizedDescription ?? "beacon timed out"
|
||||
|
||||
// Writes already finished — beacon rebooted after SaveConfig, this is expected
|
||||
if self.writesCompleted {
|
||||
provisionLog?.log("disconnect", "Post-write disconnect (expected — beacon rebooted after save)")
|
||||
return
|
||||
}
|
||||
|
||||
provisionLog?.log("disconnect", "Unexpected disconnect: \(reason)", isError: true)
|
||||
|
||||
// For all active states, treat disconnect as failure
|
||||
if self.provisioningState == .connecting ||
|
||||
self.provisioningState == .writing || self.provisioningState == .verifying {
|
||||
self.provisioningState = .failed
|
||||
self.errorMessage = "Beacon disconnected: \(reason)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try await provisioner.connect()
|
||||
provisionLog.log("connect", "Connected 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)"
|
||||
|
||||
} catch {
|
||||
provisionLog.log("error", "Provisioning failed after \(provisionLog.elapsed): \(error.localizedDescription)", isError: true)
|
||||
provisioningState = .failed
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
// Store for DXSmart two-phase flow
|
||||
@State private var pendingConfig: BeaconConfig?
|
||||
@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 }
|
||||
provisionLog.log("connect", "Connected and authenticated successfully")
|
||||
|
||||
// Auto-fire write immediately — no pause needed
|
||||
provisioningState = .writing
|
||||
writesCompleted = false
|
||||
statusMessage = "Writing config to DX-Smart…"
|
||||
provisionLog.log("write", "Auto-writing config (no user tap needed)…")
|
||||
|
||||
do {
|
||||
try await provisioner.writeConfig(config)
|
||||
writesCompleted = true
|
||||
|
||||
// Brief settle after SaveConfig before dropping the BLE link.
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
provisioner.disconnect()
|
||||
|
||||
try await APIClient.shared.registerBeaconHardware(
|
||||
|
|
@ -678,7 +630,7 @@ struct ScanView: View {
|
|||
uuid: ns.uuid,
|
||||
major: ns.major,
|
||||
minor: Int(config.minor),
|
||||
macAddress: nil,
|
||||
hardwareId: selectedBeacon?.id.uuidString ?? "unknown-ios",
|
||||
beaconType: BeaconType.dxsmart.rawValue,
|
||||
token: token
|
||||
)
|
||||
|
|
@ -687,14 +639,16 @@ struct ScanView: View {
|
|||
statusMessage = "\(sp.name) — DX-Smart\nUUID: \(config.formattedUUID.prefix(13))…\nMajor: \(config.major) Minor: \(config.minor)"
|
||||
|
||||
} catch {
|
||||
provisionLog.log("error", "Provisioning failed after \(provisionLog.elapsed): \(error.localizedDescription)", isError: true)
|
||||
provisioningState = .failed
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
pendingConfig = nil
|
||||
pendingProvisioner = nil
|
||||
}
|
||||
|
||||
// Kept for cancel/reset and registerAnywayAfterFailure fallback
|
||||
@State private var pendingConfig: BeaconConfig?
|
||||
@State private var pendingProvisioner: (any BeaconProvisioner)?
|
||||
|
||||
private func registerAnywayAfterFailure() async {
|
||||
guard let sp = selectedServicePoint,
|
||||
let ns = namespace,
|
||||
|
|
@ -711,7 +665,7 @@ struct ScanView: View {
|
|||
uuid: ns.uuid,
|
||||
major: ns.major,
|
||||
minor: Int(config.minor),
|
||||
macAddress: nil,
|
||||
hardwareId: selectedBeacon?.id.uuidString ?? "unknown-ios",
|
||||
beaconType: selectedBeacon?.type.rawValue ?? "Unknown",
|
||||
token: token
|
||||
)
|
||||
|
|
@ -784,17 +738,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
|
||||
|
|
@ -863,12 +810,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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue