🔴 Critical: - DXSmartProvisioner: complete rewrite to match Android's new SDK protocol - Writes to FFE2 (not FFE1) using 4E4F protocol packets - Correct command IDs: 0x74 UUID, 0x75 Major, 0x76 Minor, 0x77 RSSI, 0x78 AdvInt, 0x79 TxPower, 0x60 Save - Frame selection (0x11/0x12) + frame type (0x62 iBeacon) - Old SDK fallback (0x36-0x43 via FFE1 with 555555 re-auth per command) - Auth timing: 100ms delays (was 500ms, matches Android SDK) - BeaconShardPool: replaced 71 pattern UUIDs with exact 64 from Android 🟡 Warnings: - BlueCharmProvisioner: 3 fallback write methods matching Android (FEA3 direct → FEA1 raw → FEA1 indexed), legacy FFF0 support, added "minew123" and "bc04p" passwords (5 total, was 3) - BeaconBanList: added 4 missing prefixes (8492E75F, A0B13730, EBEFD083, B5B182C7), full UUID ban list, getBanReason() helper - BLEManager: documented MAC OUI limitation (48:87:2D not available on iOS via CoreBluetooth) 🔵 Info: - APIClient: added get_beacon_config endpoint for server-configured values - ScanView: unknown beacon type now tries KBeacon→DXSmart→BlueCharm fallback chain via new FallbackProvisioner - DXSmartProvisioner: added readFrame2() for post-write verification Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
405 lines
17 KiB
Swift
405 lines
17 KiB
Swift
import Foundation
|
|
import CoreBluetooth
|
|
|
|
/// Provisioner for DXSmart / CP28 hardware
|
|
///
|
|
/// 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
|
|
|
|
private static let triggerPassword = "555555"
|
|
private static let defaultPassword = "dx1234"
|
|
|
|
// MARK: - State
|
|
|
|
private let peripheral: CBPeripheral
|
|
private let centralManager: CBCentralManager
|
|
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
|
|
|
|
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()
|
|
try await authenticate()
|
|
isConnected = true
|
|
isFlashing = 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")
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
isFlashing = false
|
|
}
|
|
|
|
func disconnect() {
|
|
if peripheral.state == .connected || peripheral.state == .connecting {
|
|
centralManager.cancelPeripheralConnection(peripheral)
|
|
}
|
|
isConnected = false
|
|
isFlashing = false
|
|
}
|
|
|
|
// 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
|
|
connectionContinuation = cont
|
|
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.ffe0Service])
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in
|
|
if let c = self?.serviceContinuation {
|
|
self?.serviceContinuation = nil
|
|
c.resume(throwing: ProvisionError.serviceDiscoveryTimeout)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Two-step DXSmart 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 ffe3 = ffe3Char else {
|
|
throw ProvisionError.characteristicNotFound
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
/// 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: char, type: .withResponse)
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in
|
|
if let c = self?.responseContinuation {
|
|
self?.responseContinuation = nil
|
|
c.resume(throwing: ProvisionError.operationTimeout)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
|
|
extension DXSmartProvisioner: 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, GATTConstants.ffe3Char],
|
|
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:
|
|
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:
|
|
ffe3Char = char
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
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 = responseContinuation {
|
|
responseContinuation = nil
|
|
cont.resume(returning: data)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|