BIG FIX: Provisioners were calling centralManager.connect() but BLEManager is the CBCentralManagerDelegate — provisioners never received didConnect/didFailToConnect callbacks, so connections ALWAYS timed out after 5s regardless. This is why provisioning kept failing. Fixed by: 1. Adding didConnect/didFailToConnect/didDisconnect to BLEManager 2. Provisioners register connection callbacks via bleManager 3. Increased connection timeout from 5s to 10s DIAGNOSTICS: Added ProvisionLog system so failures show a timestamped step-by-step log of what happened (with Share button). Every phase is logged: init, API calls, connect attempts, service discovery, auth, write commands, and errors.
416 lines
16 KiB
Swift
416 lines
16 KiB
Swift
import Foundation
|
|
import CoreBluetooth
|
|
|
|
/// Provisioner for BlueCharm / BC04P hardware
|
|
///
|
|
/// Supports two service variants:
|
|
/// - FEA0 service (BC04P): FEA1 write, FEA2 notify, FEA3 config
|
|
/// - FFF0 service (legacy): FFF1 password, FFF2 UUID, FFF3 major, FFF4 minor
|
|
///
|
|
/// BC04P write methods (tried in order):
|
|
/// 1. Direct config write to FEA3: [0x01] + UUID + Major + Minor + TxPower
|
|
/// 2. Raw data write to FEA1: UUID + Major + Minor + TxPower + Interval, then save commands
|
|
/// 3. Indexed parameter writes to FEA1: [index] + data, then [0xFF] save
|
|
///
|
|
/// Legacy write: individual characteristics per parameter (FFF1-FFF4)
|
|
final class BlueCharmProvisioner: NSObject, BeaconProvisioner {
|
|
|
|
// MARK: - Constants
|
|
|
|
// 5 passwords matching Android (16 bytes each)
|
|
private static let passwords: [Data] = [
|
|
Data(repeating: 0, count: 16), // All zeros
|
|
"0000000000000000".data(using: .utf8)!, // ASCII zeros
|
|
"1234567890123456".data(using: .utf8)!, // Common
|
|
"minew123".data(using: .utf8)!.padded(to: 16), // Minew default
|
|
"bc04p".data(using: .utf8)!.padded(to: 16), // Model name
|
|
]
|
|
|
|
// Legacy FFF0 passwords
|
|
private static let legacyPasswords = ["000000", "123456", "bc0000"]
|
|
|
|
// Legacy characteristic UUIDs
|
|
private static let fff1Password = CBUUID(string: "0000FFF1-0000-1000-8000-00805F9B34FB")
|
|
private static let fff2UUID = CBUUID(string: "0000FFF2-0000-1000-8000-00805F9B34FB")
|
|
private static let fff3Major = CBUUID(string: "0000FFF3-0000-1000-8000-00805F9B34FB")
|
|
private static let fff4Minor = CBUUID(string: "0000FFF4-0000-1000-8000-00805F9B34FB")
|
|
|
|
// FEA0 characteristic UUIDs
|
|
private static let fea1Write = CBUUID(string: "0000FEA1-0000-1000-8000-00805F9B34FB")
|
|
private static let fea2Notify = CBUUID(string: "0000FEA2-0000-1000-8000-00805F9B34FB")
|
|
private static let fea3Config = CBUUID(string: "0000FEA3-0000-1000-8000-00805F9B34FB")
|
|
|
|
// MARK: - State
|
|
|
|
private let peripheral: CBPeripheral
|
|
private let centralManager: CBCentralManager
|
|
|
|
private var discoveredService: CBService?
|
|
private var writeChar: CBCharacteristic? // FEA1 or first writable
|
|
private var notifyChar: CBCharacteristic? // FEA2
|
|
private var configChar: CBCharacteristic? // FEA3
|
|
private var isLegacy = false // Using FFF0 service
|
|
|
|
private var connectionContinuation: CheckedContinuation<Void, Error>?
|
|
private var serviceContinuation: CheckedContinuation<Void, Error>?
|
|
private var writeContinuation: CheckedContinuation<Data, Error>?
|
|
private var writeOKContinuation: CheckedContinuation<Void, Error>?
|
|
|
|
private(set) var isConnected = false
|
|
var diagnosticLog: ProvisionLog?
|
|
var bleManager: BLEManager?
|
|
|
|
// MARK: - Init
|
|
|
|
init(peripheral: CBPeripheral, centralManager: CBCentralManager) {
|
|
self.peripheral = peripheral
|
|
self.centralManager = centralManager
|
|
super.init()
|
|
self.peripheral.delegate = self
|
|
}
|
|
|
|
// MARK: - BeaconProvisioner
|
|
|
|
func connect() async throws {
|
|
for attempt in 1...GATTConstants.maxRetries {
|
|
do {
|
|
try await connectOnce()
|
|
try await discoverServices()
|
|
if !isLegacy {
|
|
try await authenticateBC04P()
|
|
}
|
|
isConnected = true
|
|
return
|
|
} catch {
|
|
disconnect()
|
|
if attempt < GATTConstants.maxRetries {
|
|
try await Task.sleep(nanoseconds: UInt64(GATTConstants.retryDelay * 1_000_000_000))
|
|
} else {
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func writeConfig(_ config: BeaconConfig) async throws {
|
|
guard isConnected else {
|
|
throw ProvisionError.notConnected
|
|
}
|
|
|
|
let uuidBytes = config.uuid.hexToBytes
|
|
guard uuidBytes.count == 16 else {
|
|
throw ProvisionError.writeFailed("Invalid UUID length")
|
|
}
|
|
|
|
if isLegacy {
|
|
try await writeLegacy(config, uuidBytes: uuidBytes)
|
|
} else {
|
|
try await writeBC04P(config, uuidBytes: uuidBytes)
|
|
}
|
|
}
|
|
|
|
func disconnect() {
|
|
if peripheral.state == .connected || peripheral.state == .connecting {
|
|
centralManager.cancelPeripheralConnection(peripheral)
|
|
}
|
|
isConnected = false
|
|
}
|
|
|
|
// MARK: - BC04P Write (3 fallback methods, matching Android)
|
|
|
|
private func writeBC04P(_ config: BeaconConfig, uuidBytes: [UInt8]) async throws {
|
|
let majorBytes = Data([UInt8(config.major >> 8), UInt8(config.major & 0xFF)])
|
|
let minorBytes = Data([UInt8(config.minor >> 8), UInt8(config.minor & 0xFF)])
|
|
let txPowerByte = config.txPower
|
|
let intervalUnits = UInt16(Double(config.advInterval) * 100.0 / 0.625)
|
|
let intervalBytes = Data([UInt8(intervalUnits >> 8), UInt8(intervalUnits & 0xFF)])
|
|
|
|
// Method 1: Write directly to FEA3 (config characteristic)
|
|
if let fea3 = configChar {
|
|
var iBeaconData = Data([0x01]) // iBeacon frame type
|
|
iBeaconData.append(contentsOf: uuidBytes)
|
|
iBeaconData.append(majorBytes)
|
|
iBeaconData.append(minorBytes)
|
|
iBeaconData.append(txPowerByte)
|
|
|
|
if let _ = try? await writeDirectAndWait(fea3, data: iBeaconData) {
|
|
try await Task.sleep(nanoseconds: 500_000_000)
|
|
return // Success
|
|
}
|
|
}
|
|
|
|
// Method 2: Raw data write to FEA1
|
|
if let fea1 = writeChar {
|
|
var rawData = Data(uuidBytes)
|
|
rawData.append(majorBytes)
|
|
rawData.append(minorBytes)
|
|
rawData.append(txPowerByte)
|
|
rawData.append(intervalBytes)
|
|
|
|
if let _ = try? await writeDirectAndWait(fea1, data: rawData) {
|
|
try await Task.sleep(nanoseconds: 300_000_000)
|
|
|
|
// Send save/apply commands (matching Android)
|
|
let _ = try? await writeDirectAndWait(fea1, data: Data([0xFF])) // Save variant 1
|
|
try await Task.sleep(nanoseconds: 200_000_000)
|
|
let _ = try? await writeDirectAndWait(fea1, data: Data([0x00])) // Apply/commit
|
|
try await Task.sleep(nanoseconds: 200_000_000)
|
|
let _ = try? await writeDirectAndWait(fea1, data: Data([0x57])) // KBeacon save
|
|
try await Task.sleep(nanoseconds: 200_000_000)
|
|
let _ = try? await writeDirectAndWait(fea1, data: Data([0x01, 0x00])) // Enable slot 0
|
|
try await Task.sleep(nanoseconds: 500_000_000)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Method 3: Indexed parameter writes to FEA1
|
|
if let fea1 = writeChar {
|
|
// Index 0 = UUID
|
|
let _ = try? await writeDirectAndWait(fea1, data: Data([0x00]) + Data(uuidBytes))
|
|
try await Task.sleep(nanoseconds: 100_000_000)
|
|
|
|
// Index 1 = Major
|
|
let _ = try? await writeDirectAndWait(fea1, data: Data([0x01]) + majorBytes)
|
|
try await Task.sleep(nanoseconds: 100_000_000)
|
|
|
|
// Index 2 = Minor
|
|
let _ = try? await writeDirectAndWait(fea1, data: Data([0x02]) + minorBytes)
|
|
try await Task.sleep(nanoseconds: 100_000_000)
|
|
|
|
// Index 3 = TxPower
|
|
let _ = try? await writeDirectAndWait(fea1, data: Data([0x03, txPowerByte]))
|
|
try await Task.sleep(nanoseconds: 100_000_000)
|
|
|
|
// Index 4 = Interval
|
|
let _ = try? await writeDirectAndWait(fea1, data: Data([0x04]) + intervalBytes)
|
|
try await Task.sleep(nanoseconds: 100_000_000)
|
|
|
|
// Save command
|
|
let _ = try? await writeDirectAndWait(fea1, data: Data([0xFF]))
|
|
try await Task.sleep(nanoseconds: 500_000_000)
|
|
return
|
|
}
|
|
|
|
throw ProvisionError.writeFailed("No write characteristic available")
|
|
}
|
|
|
|
// MARK: - Legacy FFF0 Write
|
|
|
|
private func writeLegacy(_ config: BeaconConfig, uuidBytes: [UInt8]) async throws {
|
|
guard let service = discoveredService else {
|
|
throw ProvisionError.serviceNotFound
|
|
}
|
|
|
|
// Try passwords
|
|
for password in Self.legacyPasswords {
|
|
if let char = service.characteristics?.first(where: { $0.uuid == Self.fff1Password }),
|
|
let data = password.data(using: .utf8) {
|
|
let _ = try? await writeDirectAndWait(char, data: data)
|
|
try await Task.sleep(nanoseconds: 200_000_000)
|
|
}
|
|
}
|
|
|
|
// Write UUID
|
|
if let char = service.characteristics?.first(where: { $0.uuid == Self.fff2UUID }) {
|
|
let _ = try await writeDirectAndWait(char, data: Data(uuidBytes))
|
|
}
|
|
|
|
// Write Major
|
|
let majorBytes = Data([UInt8(config.major >> 8), UInt8(config.major & 0xFF)])
|
|
if let char = service.characteristics?.first(where: { $0.uuid == Self.fff3Major }) {
|
|
let _ = try await writeDirectAndWait(char, data: majorBytes)
|
|
}
|
|
|
|
// Write Minor
|
|
let minorBytes = Data([UInt8(config.minor >> 8), UInt8(config.minor & 0xFF)])
|
|
if let char = service.characteristics?.first(where: { $0.uuid == Self.fff4Minor }) {
|
|
let _ = try await writeDirectAndWait(char, data: minorBytes)
|
|
}
|
|
}
|
|
|
|
// MARK: - Auth (BC04P)
|
|
|
|
private func authenticateBC04P() async throws {
|
|
guard let fea1 = writeChar else {
|
|
throw ProvisionError.characteristicNotFound
|
|
}
|
|
|
|
// Enable notifications on FEA2 if available
|
|
if let fea2 = notifyChar {
|
|
peripheral.setNotifyValue(true, for: fea2)
|
|
try await Task.sleep(nanoseconds: 200_000_000)
|
|
}
|
|
|
|
// No explicit auth command needed for BC04P — the write methods
|
|
// handle auth implicitly. Android's BlueCharm provisioner also
|
|
// doesn't do a CMD_CONNECT auth for the FEA0 path.
|
|
}
|
|
|
|
// MARK: - Private Helpers
|
|
|
|
private func connectOnce() async throws {
|
|
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
|
connectionContinuation = cont
|
|
|
|
// Register for connection callbacks via BLEManager (the CBCentralManagerDelegate)
|
|
bleManager?.onPeripheralConnected = { [weak self] connectedPeripheral in
|
|
guard connectedPeripheral.identifier == self?.peripheral.identifier else { return }
|
|
if let c = self?.connectionContinuation {
|
|
self?.connectionContinuation = nil
|
|
c.resume()
|
|
}
|
|
}
|
|
bleManager?.onPeripheralFailedToConnect = { [weak self] failedPeripheral, error in
|
|
guard failedPeripheral.identifier == self?.peripheral.identifier else { return }
|
|
if let c = self?.connectionContinuation {
|
|
self?.connectionContinuation = nil
|
|
c.resume(throwing: error ?? ProvisionError.connectionTimeout)
|
|
}
|
|
}
|
|
|
|
centralManager.connect(peripheral, options: nil)
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.connectionTimeout) { [weak self] in
|
|
if let c = self?.connectionContinuation {
|
|
self?.connectionContinuation = nil
|
|
c.resume(throwing: ProvisionError.connectionTimeout)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func discoverServices() async throws {
|
|
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
|
serviceContinuation = cont
|
|
peripheral.discoverServices([GATTConstants.fea0Service, GATTConstants.fff0Service])
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in
|
|
if let c = self?.serviceContinuation {
|
|
self?.serviceContinuation = nil
|
|
c.resume(throwing: ProvisionError.serviceDiscoveryTimeout)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func writeDirectAndWait(_ char: CBCharacteristic, data: Data) async throws -> Void {
|
|
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
|
writeOKContinuation = cont
|
|
peripheral.writeValue(data, for: char, type: .withResponse)
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { [weak self] in
|
|
if let c = self?.writeOKContinuation {
|
|
self?.writeOKContinuation = nil
|
|
c.resume(throwing: ProvisionError.operationTimeout)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - CBPeripheralDelegate
|
|
|
|
extension BlueCharmProvisioner: CBPeripheralDelegate {
|
|
|
|
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
|
if let error {
|
|
serviceContinuation?.resume(throwing: error)
|
|
serviceContinuation = nil
|
|
return
|
|
}
|
|
|
|
// Prefer FEA0 (BC04P), fallback to FFF0 (legacy)
|
|
if let fea0Service = peripheral.services?.first(where: { $0.uuid == GATTConstants.fea0Service }) {
|
|
discoveredService = fea0Service
|
|
isLegacy = false
|
|
peripheral.discoverCharacteristics(nil, for: fea0Service)
|
|
} else if let fff0Service = peripheral.services?.first(where: { $0.uuid == GATTConstants.fff0Service }) {
|
|
discoveredService = fff0Service
|
|
isLegacy = true
|
|
peripheral.discoverCharacteristics(nil, for: fff0Service)
|
|
} else {
|
|
serviceContinuation?.resume(throwing: ProvisionError.serviceNotFound)
|
|
serviceContinuation = nil
|
|
}
|
|
}
|
|
|
|
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
|
if let error {
|
|
serviceContinuation?.resume(throwing: error)
|
|
serviceContinuation = nil
|
|
return
|
|
}
|
|
|
|
if isLegacy {
|
|
// Legacy: just need the service with characteristics
|
|
serviceContinuation?.resume()
|
|
serviceContinuation = nil
|
|
return
|
|
}
|
|
|
|
// BC04P: map specific characteristics
|
|
for char in service.characteristics ?? [] {
|
|
switch char.uuid {
|
|
case Self.fea1Write:
|
|
writeChar = char
|
|
case Self.fea2Notify:
|
|
notifyChar = char
|
|
case Self.fea3Config:
|
|
configChar = char
|
|
default:
|
|
// Also grab any writable char as fallback
|
|
if writeChar == nil && (char.properties.contains(.write) || char.properties.contains(.writeWithoutResponse)) {
|
|
writeChar = char
|
|
}
|
|
if notifyChar == nil && char.properties.contains(.notify) {
|
|
notifyChar = char
|
|
}
|
|
}
|
|
}
|
|
|
|
if writeChar != nil || configChar != nil {
|
|
serviceContinuation?.resume()
|
|
serviceContinuation = nil
|
|
} else {
|
|
serviceContinuation?.resume(throwing: ProvisionError.characteristicNotFound)
|
|
serviceContinuation = nil
|
|
}
|
|
}
|
|
|
|
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
|
guard let data = characteristic.value else { return }
|
|
if let cont = writeContinuation {
|
|
writeContinuation = nil
|
|
cont.resume(returning: data)
|
|
}
|
|
}
|
|
|
|
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
|
|
if let cont = writeOKContinuation {
|
|
writeOKContinuation = nil
|
|
if let error {
|
|
cont.resume(throwing: error)
|
|
} else {
|
|
cont.resume()
|
|
}
|
|
return
|
|
}
|
|
|
|
if let error, let cont = writeContinuation {
|
|
writeContinuation = nil
|
|
cont.resume(throwing: ProvisionError.writeFailed(error.localizedDescription))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Data Extension
|
|
|
|
private extension Data {
|
|
/// Pad data to target length with zero bytes
|
|
func padded(to length: Int) -> Data {
|
|
if count >= length { return self }
|
|
var padded = self
|
|
padded.append(contentsOf: [UInt8](repeating: 0, count: length - count))
|
|
return padded
|
|
}
|
|
}
|