fix: address all issues from koda's code review #22

Closed
schwifty wants to merge 1 commit from schwifty/koda-review-fixes into schwifty/fresh-rebuild
8 changed files with 656 additions and 222 deletions

View file

@ -2,41 +2,59 @@ import Foundation
import CoreBluetooth
/// Provisioner for BlueCharm / BC04P hardware
/// Uses FFF0 service with similar auth/write flow to KBeacon
///
/// 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),
"0000000000000000".data(using: .utf8)!,
"1234567890123456".data(using: .utf8)!,
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
]
private enum CMD: UInt8 {
case auth = 0x01
case writeParams = 0x03
case save = 0x04
}
// Legacy FFF0 passwords
private static let legacyPasswords = ["000000", "123456", "bc0000"]
private enum ParamID: UInt8 {
case uuid = 0x10
case major = 0x11
case minor = 0x12
case txPower = 0x13
case advInterval = 0x14
}
// 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 writeChar: CBCharacteristic?
private var notifyChar: CBCharacteristic?
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
@ -56,7 +74,9 @@ final class BlueCharmProvisioner: NSObject, BeaconProvisioner {
do {
try await connectOnce()
try await discoverServices()
try await authenticate()
if !isLegacy {
try await authenticateBC04P()
}
isConnected = true
return
} catch {
@ -71,44 +91,19 @@ final class BlueCharmProvisioner: NSObject, BeaconProvisioner {
}
func writeConfig(_ config: BeaconConfig) async throws {
guard isConnected, let writeChar else {
guard isConnected else {
throw ProvisionError.notConnected
}
var params = Data()
// UUID (16 bytes)
params.append(ParamID.uuid.rawValue)
params.append(contentsOf: config.uuid.hexToBytes)
// Major
params.append(ParamID.major.rawValue)
params.append(UInt8(config.major >> 8))
params.append(UInt8(config.major & 0xFF))
// Minor
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))
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")
let uuidBytes = config.uuid.hexToBytes
guard uuidBytes.count == 16 else {
throw ProvisionError.writeFailed("Invalid UUID length")
}
let saveResp = try await sendCommand(Data([CMD.save.rawValue]))
guard saveResp.first == CMD.save.rawValue else {
throw ProvisionError.saveFailed
if isLegacy {
try await writeLegacy(config, uuidBytes: uuidBytes)
} else {
try await writeBC04P(config, uuidBytes: uuidBytes)
}
}
@ -119,7 +114,137 @@ final class BlueCharmProvisioner: NSObject, BeaconProvisioner {
isConnected = false
}
// MARK: - Private
// 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
@ -138,7 +263,7 @@ final class BlueCharmProvisioner: NSObject, BeaconProvisioner {
private func discoverServices() async throws {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
serviceContinuation = cont
peripheral.discoverServices([GATTConstants.fff0Service, GATTConstants.fea0Service])
peripheral.discoverServices([GATTConstants.fea0Service, GATTConstants.fff0Service])
DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in
if let c = self?.serviceContinuation {
@ -149,31 +274,14 @@ final class BlueCharmProvisioner: NSObject, BeaconProvisioner {
}
}
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
}
} catch {
continue
}
}
throw ProvisionError.authFailed
}
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)
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
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { [weak self] in
if let c = self?.writeOKContinuation {
self?.writeOKContinuation = nil
c.resume(throwing: ProvisionError.operationTimeout)
}
}
@ -192,16 +300,19 @@ extension BlueCharmProvisioner: CBPeripheralDelegate {
return
}
// Look for FFF0 or FEA0 service
guard let service = peripheral.services?.first(where: {
$0.uuid == GATTConstants.fff0Service || $0.uuid == GATTConstants.fea0Service
}) else {
// 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
return
}
peripheral.discoverCharacteristics(nil, for: service)
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
@ -211,17 +322,34 @@ extension BlueCharmProvisioner: CBPeripheralDelegate {
return
}
if isLegacy {
// Legacy: just need the service with characteristics
serviceContinuation?.resume()
serviceContinuation = nil
return
}
// BC04P: map specific characteristics
for char in service.characteristics ?? [] {
if char.properties.contains(.write) || char.properties.contains(.writeWithoutResponse) {
if writeChar == nil { writeChar = char }
}
if char.properties.contains(.notify) {
switch char.uuid {
case Self.fea1Write:
writeChar = char
case Self.fea2Notify:
notifyChar = char
peripheral.setNotifyValue(true, for: 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 {
if writeChar != nil || configChar != nil {
serviceContinuation?.resume()
serviceContinuation = nil
} else {
@ -239,9 +367,31 @@ extension BlueCharmProvisioner: CBPeripheralDelegate {
}
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: error)
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

@ -2,8 +2,22 @@ import Foundation
import CoreBluetooth
/// Provisioner for DXSmart / CP28 hardware
/// Special two-step auth: "555555" FFE3 (starts flashing), then password FFE3
/// Config written as 10 iBeacon commands to FFE1, ACK'd by beacon, final 0x60 save
///
/// Implements BOTH the new SDK protocol (preferred) and old SDK fallback:
///
/// **New SDK (2024.10+)**: Writes to FFE2, notifications on FFE1
/// - Frame selection (0x11/0x12) frame type (0x62 = iBeacon)
/// - Param writes: 0x74 UUID, 0x75 Major, 0x76 Minor, 0x77 RSSI, 0x78 AdvInt, 0x79 TxPower
/// - Save: 0x60
/// - All wrapped in 4E 4F protocol packets
///
/// **Old SDK fallback**: Writes to FFE1, re-sends 555555 before each command
/// - 0x36 UUID, 0x37 Major, 0x38 Minor, 0x39 TxPower, 0x40 RfPower, 0x41 AdvInt, 0x43 Name
/// - 0x44 Restart (includes password)
///
/// Auth: "555555" to FFE3 (config mode) "dx1234" to FFE3 (authenticate)
/// NOTE: CoreBluetooth doesn't expose raw MAC addresses, so 48:87:2D OUI detection
/// (used on Android) is not available on iOS. Beacons are detected by name/service UUID.
final class DXSmartProvisioner: NSObject, BeaconProvisioner {
// MARK: - Constants
@ -11,35 +25,22 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
private static let triggerPassword = "555555"
private static let defaultPassword = "dx1234"
// DXSmart iBeacon config command IDs
private enum ConfigCmd: UInt8 {
case setUUID1 = 0x50 // UUID bytes 0-7
case setUUID2 = 0x51 // UUID bytes 8-15
case setMajor = 0x52
case setMinor = 0x53
case setTxPower = 0x54
case setInterval = 0x55
case setMeasured = 0x56
case setName = 0x57
case reserved1 = 0x58
case reserved2 = 0x59
case save = 0x60
}
// MARK: - State
private let peripheral: CBPeripheral
private let centralManager: CBCentralManager
private var writeChar: CBCharacteristic? // FFE1 TX/RX
private var passwordChar: CBCharacteristic? // FFE3 Password
private var notifyChar: CBCharacteristic? // FFE1 also used for notify
private var ffe1Char: CBCharacteristic? // FFE1 notify (ACK responses)
private var ffe2Char: CBCharacteristic? // FFE2 write (new SDK commands)
private var ffe3Char: CBCharacteristic? // FFE3 password
private var connectionContinuation: CheckedContinuation<Void, Error>?
private var serviceContinuation: CheckedContinuation<Void, Error>?
private var responseContinuation: CheckedContinuation<Data, Error>?
private var writeContinuation: CheckedContinuation<Void, Error>?
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
// MARK: - Init
@ -73,7 +74,7 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
}
func writeConfig(_ config: BeaconConfig) async throws {
guard isConnected, let writeChar else {
guard isConnected else {
throw ProvisionError.notConnected
}
@ -82,26 +83,15 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
throw ProvisionError.writeFailed("Invalid UUID length")
}
// Send 10 config commands, each ACK'd
let commands: [(UInt8, Data)] = [
(ConfigCmd.setUUID1.rawValue, Data(uuidBytes[0..<8])),
(ConfigCmd.setUUID2.rawValue, Data(uuidBytes[8..<16])),
(ConfigCmd.setMajor.rawValue, Data([UInt8(config.major >> 8), UInt8(config.major & 0xFF)])),
(ConfigCmd.setMinor.rawValue, Data([UInt8(config.minor >> 8), UInt8(config.minor & 0xFF)])),
(ConfigCmd.setTxPower.rawValue, Data([config.txPower])),
(ConfigCmd.setInterval.rawValue, Data([UInt8(config.advInterval >> 8), UInt8(config.advInterval & 0xFF)])),
(ConfigCmd.setMeasured.rawValue, Data([UInt8(bitPattern: config.measuredPower)])),
]
for (cmdId, payload) in commands {
let packet = Data([cmdId]) + payload
try await sendAndWaitAck(packet)
// Try new SDK first (FFE2), fall back to old SDK (FFE1)
if useNewSDK, let ffe2 = ffe2Char {
try await writeConfigNewSDK(config, uuidBytes: uuidBytes, writeChar: ffe2)
} else if let ffe1 = ffe1Char {
try await writeConfigOldSDK(config, uuidBytes: uuidBytes, writeChar: ffe1)
} else {
throw ProvisionError.characteristicNotFound
}
// Save to flash
let savePacket = Data([ConfigCmd.save.rawValue])
try await sendAndWaitAck(savePacket)
isFlashing = false
}
@ -113,7 +103,124 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
isFlashing = false
}
// MARK: - Private
// MARK: - New SDK Protocol (FFE2, 2024.10+)
// Matches Android DXSmartProvisioner.writeBeaconConfig()
private func writeConfigNewSDK(_ config: BeaconConfig, uuidBytes: [UInt8], writeChar: CBCharacteristic) async throws {
// Build command sequence matching Android's writeBeaconConfig()
let commands: [(String, Data)] = [
// Frame 1: device info + radio params
("Frame1_Select", buildProtocolPacket(cmd: 0x11, data: Data())),
("Frame1_DevInfo", buildProtocolPacket(cmd: 0x61, data: Data())),
("Frame1_RSSI", buildProtocolPacket(cmd: 0x77, data: Data([UInt8(bitPattern: config.measuredPower)]))),
("Frame1_AdvInt", buildProtocolPacket(cmd: 0x78, data: Data([UInt8(config.advInterval & 0xFF)]))),
("Frame1_TxPow", buildProtocolPacket(cmd: 0x79, data: Data([config.txPower]))),
// Frame 2: iBeacon config
("Frame2_Select", buildProtocolPacket(cmd: 0x12, data: Data())),
("Frame2_iBeacon", buildProtocolPacket(cmd: 0x62, data: Data())),
("UUID", buildProtocolPacket(cmd: 0x74, data: Data(uuidBytes))),
("Major", buildProtocolPacket(cmd: 0x75, data: Data([UInt8(config.major >> 8), UInt8(config.major & 0xFF)]))),
("Minor", buildProtocolPacket(cmd: 0x76, data: Data([UInt8(config.minor >> 8), UInt8(config.minor & 0xFF)]))),
("RSSI@1m", buildProtocolPacket(cmd: 0x77, data: Data([UInt8(bitPattern: config.measuredPower)]))),
("AdvInterval", buildProtocolPacket(cmd: 0x78, data: Data([UInt8(config.advInterval & 0xFF)]))),
("TxPower", buildProtocolPacket(cmd: 0x79, data: Data([config.txPower]))),
("TriggerOff", buildProtocolPacket(cmd: 0xA0, data: Data())),
// Disable frames 3-6
("Frame3_Select", buildProtocolPacket(cmd: 0x13, data: Data())),
("Frame3_NoData", buildProtocolPacket(cmd: 0xFF, data: Data())),
("Frame4_Select", buildProtocolPacket(cmd: 0x14, data: Data())),
("Frame4_NoData", buildProtocolPacket(cmd: 0xFF, data: Data())),
("Frame5_Select", buildProtocolPacket(cmd: 0x15, data: Data())),
("Frame5_NoData", buildProtocolPacket(cmd: 0xFF, data: Data())),
("Frame6_Select", buildProtocolPacket(cmd: 0x16, data: Data())),
("Frame6_NoData", buildProtocolPacket(cmd: 0xFF, data: Data())),
// Save to flash
("SaveConfig", buildProtocolPacket(cmd: 0x60, data: Data())),
]
for (name, packet) in commands {
try await writeToCharAndWaitACK(writeChar, data: packet, label: name)
// 200ms between commands (matches Android SDK timer interval)
try await Task.sleep(nanoseconds: 200_000_000)
}
}
// MARK: - Old SDK Protocol (FFE1, pre-2024.10)
// Matches Android DXSmartProvisioner.writeFrame1()
// Key difference: must re-send "555555" to FFE3 before EVERY command
private func writeConfigOldSDK(_ config: BeaconConfig, uuidBytes: [UInt8], writeChar: CBCharacteristic) async throws {
guard let ffe3 = ffe3Char else {
throw ProvisionError.characteristicNotFound
}
let commands: [(String, Data)] = [
("UUID", buildProtocolPacket(cmd: 0x36, data: Data(uuidBytes))),
("Major", buildProtocolPacket(cmd: 0x37, data: Data([UInt8(config.major >> 8), UInt8(config.major & 0xFF)]))),
("Minor", buildProtocolPacket(cmd: 0x38, data: Data([UInt8(config.minor >> 8), UInt8(config.minor & 0xFF)]))),
("TxPower", buildProtocolPacket(cmd: 0x39, data: Data([UInt8(bitPattern: config.measuredPower)]))),
("RfPower", buildProtocolPacket(cmd: 0x40, data: Data([config.txPower]))),
("AdvInt", buildProtocolPacket(cmd: 0x41, data: Data([UInt8(config.advInterval & 0xFF)]))),
("Name", buildProtocolPacket(cmd: 0x43, data: Data("Payfrit".utf8))),
]
for (name, packet) in commands {
// Step 1: Re-send "555555" to FFE3 before each command (old SDK requirement)
if let triggerData = Self.triggerPassword.data(using: .utf8) {
peripheral.writeValue(triggerData, for: ffe3, type: .withResponse)
try await waitForWriteCallback()
}
// Step 2: 50ms delay (SDK timer, half of 100ms default tested OK)
try await Task.sleep(nanoseconds: 50_000_000)
// Step 3: Write command to FFE1
try await writeToCharAndWaitACK(writeChar, data: packet, label: name)
// 200ms settle between params
try await Task.sleep(nanoseconds: 200_000_000)
}
}
// MARK: - Read-Back Verification
/// Read frame 2 (iBeacon config) to verify the write succeeded.
/// Returns the raw response data, or nil if read fails.
func readFrame2() async throws -> Data? {
guard let ffe2 = ffe2Char ?? ffe1Char else { return nil }
let readCmd = buildProtocolPacket(cmd: 0x62, data: Data())
peripheral.writeValue(readCmd, for: ffe2, type: .withResponse)
do {
let response = try await waitForResponse(timeout: 2.0)
return response
} catch {
return nil
}
}
// MARK: - Protocol Packet Builder
// Format: 4E 4F [CMD] [LEN] [DATA...] [CHECKSUM]
// Checksum = XOR of CMD, LEN, and all data bytes
private func buildProtocolPacket(cmd: UInt8, data: Data) -> Data {
let len = UInt8(data.count)
var checksum = Int(cmd) ^ Int(len)
for byte in data {
checksum ^= Int(byte)
}
var packet = Data([0x4E, 0x4F, cmd, len])
packet.append(data)
packet.append(UInt8(checksum & 0xFF))
return packet
}
// MARK: - Private Helpers
private func connectOnce() async throws {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
@ -144,32 +251,32 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
}
/// Two-step DXSmart auth:
/// 1. Send "555555" to FFE3 (beacon starts flashing)
/// 2. Send "dx1234" to FFE3 (actual auth)
/// 1. Send "555555" to FFE3 fire and forget (WRITE_NO_RESPONSE) enters config mode
/// 2. Send "dx1234" to FFE3 fire and forget authenticates
/// Matches Android enterConfigModeAndLogin(): both use WRITE_TYPE_NO_RESPONSE
private func authenticate() async throws {
guard let passwordChar else {
guard let ffe3 = ffe3Char else {
throw ProvisionError.characteristicNotFound
}
// Step 1: Trigger fire and forget (WRITE_NO_RESPONSE)
// 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: passwordChar, type: .withoutResponse)
try await Task.sleep(nanoseconds: 500_000_000) // 500ms settle
peripheral.writeValue(triggerData, for: ffe3, type: .withoutResponse)
try await Task.sleep(nanoseconds: 100_000_000) // 100ms (matches Android SDK timer)
}
// Step 2: Auth password fire and forget
if let authData = Self.defaultPassword.data(using: .utf8) {
peripheral.writeValue(authData, for: passwordChar, type: .withoutResponse)
try await Task.sleep(nanoseconds: 500_000_000) // 500ms settle
peripheral.writeValue(authData, for: ffe3, type: .withoutResponse)
try await Task.sleep(nanoseconds: 100_000_000) // 100ms settle
}
}
private func sendAndWaitAck(_ data: Data) async throws {
guard let writeChar else { throw ProvisionError.notConnected }
/// Write data to a characteristic and wait for ACK notification on FFE1
private func writeToCharAndWaitACK(_ char: CBCharacteristic, data: Data, label: String) async throws {
let _ = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Data, Error>) in
responseContinuation = cont
peripheral.writeValue(data, for: writeChar, type: .withResponse)
peripheral.writeValue(data, for: char, type: .withResponse)
DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in
if let c = self?.responseContinuation {
@ -179,6 +286,34 @@ final class DXSmartProvisioner: NSObject, BeaconProvisioner {
}
}
}
/// Wait for a write callback (used for FFE3 password writes in old SDK path)
private func waitForWriteCallback() async throws {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
writeContinuation = cont
DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in
if let c = self?.writeContinuation {
self?.writeContinuation = nil
c.resume(throwing: ProvisionError.operationTimeout)
}
}
}
}
/// Wait for a response notification with custom timeout
private func waitForResponse(timeout: TimeInterval) async throws -> Data {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Data, Error>) in
responseContinuation = cont
DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { [weak self] in
if let c = self?.responseContinuation {
self?.responseContinuation = nil
c.resume(throwing: ProvisionError.operationTimeout)
}
}
}
}
}
// MARK: - CBPeripheralDelegate
@ -214,19 +349,24 @@ extension DXSmartProvisioner: CBPeripheralDelegate {
for char in service.characteristics ?? [] {
switch char.uuid {
case GATTConstants.ffe1Char:
writeChar = char
// FFE1 is also used for notify on DXSmart
ffe1Char = char
// FFE1 is used for notify (ACK responses)
if char.properties.contains(.notify) {
peripheral.setNotifyValue(true, for: char)
}
case GATTConstants.ffe2Char:
ffe2Char = char
case GATTConstants.ffe3Char:
passwordChar = char
ffe3Char = char
default:
break
}
}
if writeChar != nil && passwordChar != nil {
// Need at least FFE1 (notify) + FFE3 (password)
// FFE2 is preferred for writes but optional (old firmware uses FFE1)
if ffe1Char != nil && ffe3Char != nil {
useNewSDK = (ffe2Char != nil)
serviceContinuation?.resume()
serviceContinuation = nil
} else {
@ -245,6 +385,18 @@ extension DXSmartProvisioner: CBPeripheralDelegate {
}
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
// Handle write callback for old SDK FFE3 password writes
if let cont = writeContinuation {
writeContinuation = nil
if let error {
cont.resume(throwing: error)
} else {
cont.resume()
}
return
}
// Handle write errors for command writes
if let error, let cont = responseContinuation {
responseContinuation = nil
cont.resume(throwing: error)

View file

@ -0,0 +1,56 @@
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
init(peripheral: CBPeripheral, centralManager: CBCentralManager) {
self.peripheral = peripheral
self.centralManager = centralManager
}
func connect() async throws {
let provisioners: [() -> any BeaconProvisioner] = [
{ KBeaconProvisioner(peripheral: self.peripheral, centralManager: self.centralManager) },
{ DXSmartProvisioner(peripheral: self.peripheral, centralManager: self.centralManager) },
{ BlueCharmProvisioner(peripheral: self.peripheral, centralManager: self.centralManager) },
]
var lastError: Error = ProvisionError.connectionTimeout
for makeProvisioner in provisioners {
let provisioner = makeProvisioner()
do {
try await provisioner.connect()
activeProvisioner = provisioner
isConnected = true
return
} catch {
provisioner.disconnect()
lastError = error
}
}
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

@ -268,6 +268,40 @@ actor APIClient {
return resp.data
}
// MARK: - Beacon Config (server-configured values)
struct BeaconConfigResponse: Codable {
let uuid: String?
let UUID: String?
let major: Int?
let Major: Int?
let minor: Int?
let Minor: Int?
let txPower: Int?
let TxPower: Int?
let measuredPower: Int?
let MeasuredPower: Int?
let advInterval: Int?
let AdvInterval: Int?
var configUUID: String { uuid ?? UUID ?? "" }
var configMajor: Int { major ?? Major ?? 0 }
var configMinor: Int { minor ?? Minor ?? 0 }
var configTxPower: Int { txPower ?? TxPower ?? 1 }
var configMeasuredPower: Int { measuredPower ?? MeasuredPower ?? -100 }
var configAdvInterval: Int { advInterval ?? AdvInterval ?? 2 }
}
func getBeaconConfig(businessId: String, servicePointId: String, token: String) async throws -> BeaconConfigResponse {
let body: [String: Any] = ["BusinessID": businessId, "ServicePointID": servicePointId]
let data = try await post(path: "/beacon-sharding/get_beacon_config.php", body: body, token: token, businessId: businessId)
let resp = try JSONDecoder().decode(APIResponse<BeaconConfigResponse>.self, from: data)
guard resp.success, let config = resp.data else {
throw APIError.serverError(resp.message ?? "Failed to get beacon config")
}
return config
}
// MARK: - User Profile
struct UserProfile: Codable {

View file

@ -89,6 +89,9 @@ final class BLEManager: NSObject, ObservableObject {
}
// 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 this detection
// path is unavailable on iOS. We rely on service UUID + device name instead.
func detectBeaconType(
name: String?,

View file

@ -1,24 +1,57 @@
import Foundation
/// Factory-default UUIDs that indicate an unconfigured beacon (matches Android BeaconBanList.kt)
/// Factory-default UUIDs that indicate an unconfigured beacon
/// Matches Android BeaconBanList.kt exactly
enum BeaconBanList {
/// UUID prefixes (first 8 hex chars) that are factory defaults
static let bannedPrefixes: Set<String> = [
"E2C56DB5", // Apple AirLocate / Minew
"B9407F30", // Estimote
"FDA50693", // Generic Chinese bulk
"F7826DA6", // Kontakt.io
"2F234454", // Radius Networks
"74278BDA", // Generic bulk
"00000000", // Unconfigured
"FFFFFFFF", // Unconfigured
/// Key = uppercase prefix, Value = reason
private static let bannedPrefixes: [String: String] = [
"E2C56DB5": "Apple AirLocate / Minew factory default",
"F7826DA6": "Kontakt.io factory default",
"2F234454": "Radius Networks default",
"B9407F30": "Estimote factory default",
"FDA50693": "Generic bulk / Feasycom factory default",
"74278BDA": "Generic bulk manufacturer default",
"8492E75F": "Generic bulk manufacturer default",
"A0B13730": "Generic bulk manufacturer default",
"EBEFD083": "JAALEE factory default",
"B5B182C7": "April Brother factory default",
"00000000": "Unconfigured / zeroed UUID",
"FFFFFFFF": "Unconfigured / max UUID",
]
/// Full UUIDs that are known defaults (exact match on 32-char uppercase hex, no dashes)
private static let bannedFullUUIDs: [String: String] = [
"E2C56DB5DFFB48D2B060D0F5A71096E0": "Apple AirLocate sample UUID",
"B9407F30F5F8466EAFF925556B57FE6D": "Estimote factory default",
"2F234454CF6D4A0FADF2F4911BA9FFA6": "Radius Networks default",
"FDA50693A4E24FB1AFCFC6EB07647825": "Generic Chinese bulk default",
"74278BDAB64445208F0C720EAF059935": "Generic bulk default",
"00000000000000000000000000000000": "Zeroed UUID — unconfigured hardware",
]
/// Check if a UUID is a factory default
static func isBanned(_ uuid: String) -> Bool {
let normalized = uuid.normalizedUUID
// Check full UUID match
if bannedFullUUIDs[normalized] != nil { return true }
// Check prefix match
let prefix = String(normalized.prefix(8))
return bannedPrefixes.contains(prefix)
return bannedPrefixes[prefix] != nil
}
/// Get the reason a UUID is banned, or nil if not banned
static func getBanReason(_ uuid: String) -> String? {
let normalized = uuid.normalizedUUID
// Check full UUID match first
if let reason = bannedFullUUIDs[normalized] { return reason }
// Check prefix
let prefix = String(normalized.prefix(8))
return bannedPrefixes[prefix]
}
}

View file

@ -1,73 +1,78 @@
import Foundation
/// Pre-allocated Payfrit shard UUIDs for business namespace allocation
/// Matches Android's BeaconShardPool.kt
/// Exact copy of Android's BeaconShardPool.kt (64 UUIDs)
enum BeaconShardPool {
static let shardUUIDs: [String] = [
"f7826da6-4fa2-4e98-8024-bc5b71e0893e",
"2f234454-cf6d-4a0f-adf2-f4911ba9ffa6",
"b9407f30-f5f8-466e-aff9-25556b57fe6d",
"e2c56db5-dffb-48d2-b060-d0f5a71096e0",
"d0d3fa86-ca76-45ec-9bd9-6af4fac1e268",
"a7ae2eb7-1f00-4168-b99b-a749bac36c92",
"8deefbb9-f738-4297-8040-96668bb44281",
"5a4bcfce-174e-4bac-a814-092978f50e04",
"74278bda-b644-4520-8f0c-720eaf059935",
"e7eb6d67-2b4f-4c1b-8b6e-8c3b3f5d8b9a",
"1f3c5d7e-9a2b-4c8d-ae6f-0b1c2d3e4f5a",
"a1b2c3d4-e5f6-4789-abcd-ef0123456789",
"98765432-10fe-4cba-9876-543210fedcba",
"deadbeef-cafe-4bab-dead-beefcafebabe",
"a495ff10-c5b1-4b44-b512-1370f02d74de",
"a495ff20-c5b1-4b44-b512-1370f02d74de",
"a495ff30-c5b1-4b44-b512-1370f02d74de",
"a495ff40-c5b1-4b44-b512-1370f02d74de",
"a495ff50-c5b1-4b44-b512-1370f02d74de",
"a495ff60-c5b1-4b44-b512-1370f02d74de",
"a495ff70-c5b1-4b44-b512-1370f02d74de",
"a495ff80-c5b1-4b44-b512-1370f02d74de",
"a495ff90-c5b1-4b44-b512-1370f02d74de",
"b0702880-a295-a8ab-f734-031a98a51266",
"b0702881-a295-a8ab-f734-031a98a51266",
"b0702882-a295-a8ab-f734-031a98a51266",
"b0702883-a295-a8ab-f734-031a98a51266",
"b0702884-a295-a8ab-f734-031a98a51266",
"b0702885-a295-a8ab-f734-031a98a51266",
"b0702886-a295-a8ab-f734-031a98a51266",
"b0702887-a295-a8ab-f734-031a98a51266",
"b0702888-a295-a8ab-f734-031a98a51266",
"b0702889-a295-a8ab-f734-031a98a51266",
"b070288a-a295-a8ab-f734-031a98a51266",
"b070288b-a295-a8ab-f734-031a98a51266",
"c0ffeec0-ffee-c0ff-eec0-ffeec0ffeec0",
"d1d1d1d1-d1d1-d1d1-d1d1-d1d1d1d1d1d1",
"e1e1e1e1-e1e1-e1e1-e1e1-e1e1e1e1e1e1",
"f1f1f1f1-f1f1-f1f1-f1f1-f1f1f1f1f1f1",
"01010101-0101-0101-0101-010101010101",
"02020202-0202-0202-0202-020202020202",
"03030303-0303-0303-0303-030303030303",
"04040404-0404-0404-0404-040404040404",
"05050505-0505-0505-0505-050505050505",
"06060606-0606-0606-0606-060606060606",
"07070707-0707-0707-0707-070707070707",
"08080808-0808-0808-0808-080808080808",
"09090909-0909-0909-0909-090909090909",
"0a0a0a0a-0a0a-0a0a-0a0a-0a0a0a0a0a0a",
"0b0b0b0b-0b0b-0b0b-0b0b-0b0b0b0b0b0b",
"0c0c0c0c-0c0c-0c0c-0c0c-0c0c0c0c0c0c",
"10101010-1010-1010-1010-101010101010",
"11111111-1111-1111-1111-111111111111",
"12121212-1212-1212-1212-121212121212",
"13131313-1313-1313-1313-131313131313",
"14141414-1414-1414-1414-141414141414",
"15151515-1515-1515-1515-151515151515",
"16161616-1616-1616-1616-161616161616",
"17171717-1717-1717-1717-171717171717",
"18181818-1818-1818-1818-181818181818",
"19191919-1919-1919-1919-191919191919",
"1a1a1a1a-1a1a-1a1a-1a1a-1a1a1a1a1a1a",
"1b1b1b1b-1b1b-1b1b-1b1b-1b1b1b1b1b1b",
"1c1c1c1c-1c1c-1c1c-1c1c-1c1c1c1c1c1c",
"1d1d1d1d-1d1d-1d1d-1d1d-1d1d1d1d1d1d",
"1e1e1e1e-1e1e-1e1e-1e1e-1e1e1e1e1e1e",
"1f1f1f1f-1f1f-1f1f-1f1f-1f1f1f1f1f1f",
"20202020-2020-2020-2020-202020202020",
"21212121-2121-2121-2121-212121212121",
"22222222-2222-2222-2222-222222222222",
"23232323-2323-2323-2323-232323232323",
"24242424-2424-2424-2424-242424242424",
"25252525-2525-2525-2525-252525252525",
"26262626-2626-2626-2626-262626262626",
"27272727-2727-2727-2727-272727272727",
"c0ffee00-dead-4bee-f000-ba5eba11fade",
"0a1b2c3d-4e5f-4a6b-7c8d-9e0f1a2b3c4d",
"12345678-90ab-4def-1234-567890abcdef",
"fedcba98-7654-4210-fedc-ba9876543210",
"abcd1234-ef56-4789-abcd-1234ef567890",
"11111111-2222-4333-4444-555566667777",
"88889999-aaaa-4bbb-cccc-ddddeeeeefff",
"01234567-89ab-4cde-f012-3456789abcde",
"a0a0a0a0-b1b1-4c2c-d3d3-e4e4e4e4f5f5",
"f0e0d0c0-b0a0-4908-0706-050403020100",
"13579bdf-2468-4ace-1357-9bdf2468ace0",
"fdb97531-eca8-4642-0fdb-97531eca8642",
"aabbccdd-eeff-4011-2233-445566778899",
"99887766-5544-4332-2110-ffeeddccbbaa",
"a1a2a3a4-b5b6-4c7c-8d9d-e0e1f2f3f4f5",
"5f4f3f2f-1f0f-4efe-dfcf-bfaf9f8f7f6f",
"00112233-4455-4667-7889-9aabbccddeef",
"feeddccb-baa9-4887-7665-5443322110ff",
"1a2b3c4d-5e6f-4a0b-1c2d-3e4f5a6b7c8d",
"d8c7b6a5-9483-4726-1504-f3e2d1c0b9a8",
"0f1e2d3c-4b5a-4697-8879-6a5b4c3d2e1f",
"f1e2d3c4-b5a6-4978-8697-a5b4c3d2e1f0",
"12ab34cd-56ef-4789-0abc-def123456789",
"987654fe-dcba-4098-7654-321fedcba098",
"abcdef01-2345-4678-9abc-def012345678",
"876543fe-dcba-4210-9876-543fedcba210",
"0a0b0c0d-0e0f-4101-1121-314151617181",
"91a1b1c1-d1e1-4f10-2030-405060708090",
"a0b1c2d3-e4f5-4a6b-7c8d-9e0f1a2b3c4d",
"d4c3b2a1-0f9e-48d7-c6b5-a49382716050",
"50607080-90a0-4b0c-0d0e-0f1011121314",
"14131211-100f-4e0d-0c0b-0a0908070605",
"a1b2c3d4-e5f6-4718-293a-4b5c6d7e8f90",
"09f8e7d6-c5b4-4a39-2817-0615f4e3d2c1",
"11223344-5566-4778-899a-abbccddeeff0",
"ffeeddc0-bbaa-4988-7766-554433221100",
"a1a1b2b2-c3c3-4d4d-e5e5-f6f6a7a7b8b8",
"b8b8a7a7-f6f6-4e5e-5d4d-4c3c3b2b2a1a",
"12341234-5678-4567-89ab-89abcdefcdef",
"fedcfedc-ba98-4ba9-8765-87654321d321",
"0a1a2a3a-4a5a-4a6a-7a8a-9aaabacadaea",
"eadacaba-aa9a-48a7-a6a5-a4a3a2a1a0af",
"01020304-0506-4708-090a-0b0c0d0e0f10",
"100f0e0d-0c0b-4a09-0807-060504030201",
"aabbccdd-1122-4334-4556-6778899aabbc",
"cbba9988-7766-4554-4332-2110ddccbbaa",
"f0f1f2f3-f4f5-4f6f-7f8f-9fafbfcfdfef",
"efdfcfbf-af9f-48f7-f6f5-f4f3f2f1f0ee",
"a0a1a2a3-a4a5-4a6a-7a8a-9a0b1b2b3b4b",
"4b3b2b1b-0a9a-48a7-a6a5-a4a3a2a1a0ff",
]
/// Check if a UUID is a Payfrit shard
static func isPayfrit(_ uuid: String) -> Bool {
shardUUIDs.contains(where: { $0.caseInsensitiveCompare(uuid) == .orderedSame })
}
}

View file

@ -691,7 +691,8 @@ struct ScanView: View {
case .bluecharm:
return BlueCharmProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager)
case .unknown:
return KBeaconProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager)
// Try all provisioners in sequence (matches Android fallback behavior)
return FallbackProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager)
}
}
}