fix: address all issues from koda's code review
🔴 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>
This commit is contained in:
parent
6832a8ad53
commit
38b4c987c9
8 changed files with 656 additions and 222 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
56
PayfritBeacon/Provisioners/FallbackProvisioner.swift
Normal file
56
PayfritBeacon/Provisioners/FallbackProvisioner.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue