🔴 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>
202 lines
7.3 KiB
Swift
202 lines
7.3 KiB
Swift
import Foundation
|
|
import CoreBluetooth
|
|
import Combine
|
|
|
|
/// Central BLE manager — handles scanning and beacon type detection
|
|
/// Matches Android's BeaconScanner.kt behavior
|
|
@MainActor
|
|
final class BLEManager: NSObject, ObservableObject {
|
|
|
|
// MARK: - Published State
|
|
|
|
@Published var isScanning = false
|
|
@Published var discoveredBeacons: [DiscoveredBeacon] = []
|
|
@Published var bluetoothState: CBManagerState = .unknown
|
|
|
|
// MARK: - Constants (matching Android)
|
|
|
|
static let scanDuration: TimeInterval = 5.0
|
|
static let verifyScanDuration: TimeInterval = 15.0
|
|
static let verifyPollInterval: TimeInterval = 0.5
|
|
|
|
// GATT Service UUIDs
|
|
static let ffe0Service = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB")
|
|
static let fff0Service = CBUUID(string: "0000FFF0-0000-1000-8000-00805F9B34FB")
|
|
|
|
// MARK: - Private
|
|
|
|
private(set) var centralManager: CBCentralManager!
|
|
private var scanTimer: Timer?
|
|
private var scanContinuation: CheckedContinuation<[DiscoveredBeacon], Never>?
|
|
|
|
// MARK: - Init
|
|
|
|
override init() {
|
|
super.init()
|
|
centralManager = CBCentralManager(delegate: self, queue: .main)
|
|
}
|
|
|
|
// MARK: - Scanning
|
|
|
|
/// Scan for beacons for the given duration. Returns discovered beacons sorted by RSSI.
|
|
func scan(duration: TimeInterval = scanDuration) async -> [DiscoveredBeacon] {
|
|
guard bluetoothState == .poweredOn else { return [] }
|
|
|
|
discoveredBeacons = []
|
|
isScanning = true
|
|
|
|
let results = await withCheckedContinuation { (continuation: CheckedContinuation<[DiscoveredBeacon], Never>) in
|
|
scanContinuation = continuation
|
|
|
|
centralManager.scanForPeripherals(withServices: nil, options: [
|
|
CBCentralManagerScanOptionAllowDuplicatesKey: true
|
|
])
|
|
|
|
scanTimer = Timer.scheduledTimer(withTimeInterval: duration, repeats: false) { [weak self] _ in
|
|
Task { @MainActor in
|
|
self?.stopScan()
|
|
}
|
|
}
|
|
}
|
|
|
|
return results.sorted { $0.rssi > $1.rssi }
|
|
}
|
|
|
|
func stopScan() {
|
|
centralManager.stopScan()
|
|
scanTimer?.invalidate()
|
|
scanTimer = nil
|
|
isScanning = false
|
|
|
|
let results = discoveredBeacons
|
|
if let cont = scanContinuation {
|
|
scanContinuation = nil
|
|
cont.resume(returning: results)
|
|
}
|
|
}
|
|
|
|
/// Verify a beacon is broadcasting expected iBeacon values.
|
|
/// Scans for up to `verifyScanDuration` looking for matching UUID/Major/Minor.
|
|
func verifyBroadcast(uuid: String, major: UInt16, minor: UInt16) async -> VerifyResult {
|
|
// TODO: Implement iBeacon region monitoring via CLLocationManager
|
|
// CoreLocation's CLBeaconRegion is the iOS-native way to detect iBeacon broadcasts
|
|
// For now, return a placeholder that prompts manual verification
|
|
return VerifyResult(
|
|
found: false,
|
|
rssi: nil,
|
|
message: "iBeacon verification requires CLLocationManager — coming soon"
|
|
)
|
|
}
|
|
|
|
// 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?,
|
|
serviceUUIDs: [CBUUID]?,
|
|
manufacturerData: Data?
|
|
) -> BeaconType {
|
|
let deviceName = (name ?? "").lowercased()
|
|
|
|
// 1. Service UUID matching
|
|
if let services = serviceUUIDs {
|
|
let serviceStrings = services.map { $0.uuidString.uppercased() }
|
|
if serviceStrings.contains(where: { $0.hasPrefix("0000FFF0") }) {
|
|
return .bluecharm
|
|
}
|
|
if serviceStrings.contains(where: { $0.hasPrefix("0000FFE0") }) {
|
|
// Could be KBeacon or DXSmart — check name to differentiate
|
|
if deviceName.contains("cp28") || deviceName.contains("cp-28") ||
|
|
deviceName.contains("dx") || deviceName.contains("pddaxlque") {
|
|
return .dxsmart
|
|
}
|
|
return .kbeacon
|
|
}
|
|
}
|
|
|
|
// 2. Device name patterns
|
|
if deviceName.contains("kbeacon") || deviceName.contains("kbpro") ||
|
|
deviceName.hasPrefix("kb") {
|
|
return .kbeacon
|
|
}
|
|
if deviceName.contains("bluecharm") || deviceName.hasPrefix("bc") ||
|
|
deviceName.hasPrefix("table-") {
|
|
return .bluecharm
|
|
}
|
|
if deviceName.contains("cp28") || deviceName.contains("cp-28") ||
|
|
deviceName.contains("dx-smart") || deviceName.contains("dxsmart") ||
|
|
deviceName.contains("pddaxlque") {
|
|
return .dxsmart
|
|
}
|
|
|
|
// 3. Generic beacon patterns
|
|
if deviceName.contains("ibeacon") || deviceName.contains("beacon") ||
|
|
deviceName.hasPrefix("ble") {
|
|
return .dxsmart // Default to DXSmart like Android
|
|
}
|
|
|
|
// 4. Check manufacturer data for iBeacon advertisement
|
|
if let mfgData = manufacturerData, mfgData.count >= 23 {
|
|
// Apple iBeacon prefix: 0x4C00 0215
|
|
if mfgData.count >= 4 && mfgData[0] == 0x4C && mfgData[1] == 0x00 &&
|
|
mfgData[2] == 0x02 && mfgData[3] == 0x15 {
|
|
// Extract minor (bytes 22-23) — high minors suggest DXSmart factory defaults
|
|
if mfgData.count >= 24 {
|
|
let minorVal = UInt16(mfgData[22]) << 8 | UInt16(mfgData[23])
|
|
if minorVal > 10000 { return .dxsmart }
|
|
}
|
|
return .kbeacon
|
|
}
|
|
}
|
|
|
|
return .unknown
|
|
}
|
|
}
|
|
|
|
// MARK: - CBCentralManagerDelegate
|
|
|
|
extension BLEManager: CBCentralManagerDelegate {
|
|
|
|
nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
|
Task { @MainActor in
|
|
bluetoothState = central.state
|
|
}
|
|
}
|
|
|
|
nonisolated func centralManager(
|
|
_ central: CBCentralManager,
|
|
didDiscover peripheral: CBPeripheral,
|
|
advertisementData: [String: Any],
|
|
rssi RSSI: NSNumber
|
|
) {
|
|
Task { @MainActor in
|
|
let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? ""
|
|
let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID]
|
|
let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
|
|
|
|
let type = detectBeaconType(name: name, serviceUUIDs: serviceUUIDs, manufacturerData: mfgData)
|
|
|
|
// Only show recognized beacons
|
|
guard type != .unknown else { return }
|
|
|
|
if let idx = discoveredBeacons.firstIndex(where: { $0.id == peripheral.identifier }) {
|
|
// Update existing
|
|
discoveredBeacons[idx].rssi = RSSI.intValue
|
|
discoveredBeacons[idx].lastSeen = Date()
|
|
} else {
|
|
// New beacon
|
|
let beacon = DiscoveredBeacon(
|
|
id: peripheral.identifier,
|
|
peripheral: peripheral,
|
|
name: name,
|
|
type: type,
|
|
rssi: RSSI.intValue,
|
|
lastSeen: Date()
|
|
)
|
|
discoveredBeacons.append(beacon)
|
|
}
|
|
}
|
|
}
|
|
}
|