Two changes: 1. DXSmartProvisioner now registers for BLE disconnect callbacks. Previously if the beacon dropped the link mid-write, the provisioner would sit waiting for ACK timeouts (5s × 2 retries = 10s of dead air). Now it fails immediately with a clear error. 2. Inter-command delay reduced from 150ms → 50ms since beacon handles fast writes fine. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
537 lines
24 KiB
Swift
537 lines
24 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
|
||
private var disconnected = false // Set true when BLE link drops unexpectedly
|
||
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
|
||
|
||
/// Status callback — provisioner reports what phase it's in so UI can update
|
||
var onStatusUpdate: ((String) -> Void)?
|
||
|
||
func connect() async throws {
|
||
for attempt in 1...GATTConstants.maxRetries {
|
||
await diagnosticLog?.log("connect", "Attempt \(attempt)/\(GATTConstants.maxRetries) — peripheral: \(peripheral.name ?? "unnamed"), state: \(peripheral.state.rawValue)")
|
||
do {
|
||
let attemptLabel = GATTConstants.maxRetries > 1 ? " (attempt \(attempt)/\(GATTConstants.maxRetries))" : ""
|
||
await MainActor.run { onStatusUpdate?("Connecting to beacon…\(attemptLabel)") }
|
||
await diagnosticLog?.log("connect", "Connecting (timeout: \(GATTConstants.connectionTimeout)s)…")
|
||
try await connectOnce()
|
||
|
||
await MainActor.run { onStatusUpdate?("Discovering services…") }
|
||
await diagnosticLog?.log("connect", "Connected — discovering services…")
|
||
try await discoverServices()
|
||
await diagnosticLog?.log("connect", "Services found — FFE1:\(ffe1Char != nil) FFE2:\(ffe2Char != nil) FFE3:\(ffe3Char != nil)")
|
||
|
||
await MainActor.run { onStatusUpdate?("Authenticating…") }
|
||
await diagnosticLog?.log("auth", "Authenticating (trigger + password)…")
|
||
try await authenticate()
|
||
await diagnosticLog?.log("auth", "Auth complete — SDK: \(useNewSDK ? "new (FFE2)" : "old (FFE1)")")
|
||
|
||
// Register for unexpected disconnects so we fail fast instead of
|
||
// waiting for per-command ACK timeouts (5s × 2 = 10s of dead air).
|
||
bleManager?.onPeripheralDisconnected = { [weak self] disconnectedPeripheral, error in
|
||
guard disconnectedPeripheral.identifier == self?.peripheral.identifier else { return }
|
||
self?.handleUnexpectedDisconnect(error: error)
|
||
}
|
||
|
||
isConnected = true
|
||
isFlashing = true
|
||
return
|
||
} catch {
|
||
await diagnosticLog?.log("connect", "Attempt \(attempt) failed: \(error.localizedDescription)", isError: true)
|
||
disconnect()
|
||
if attempt < GATTConstants.maxRetries {
|
||
await MainActor.run { onStatusUpdate?("Retrying… (\(attempt)/\(GATTConstants.maxRetries))") }
|
||
await diagnosticLog?.log("connect", "Retrying in \(GATTConstants.retryDelay)s…")
|
||
try await Task.sleep(nanoseconds: UInt64(GATTConstants.retryDelay * 1_000_000_000))
|
||
} else {
|
||
await diagnosticLog?.log("connect", "All \(GATTConstants.maxRetries) attempts exhausted", isError: true)
|
||
throw error
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func writeConfig(_ config: BeaconConfig) async throws {
|
||
guard isConnected else {
|
||
await diagnosticLog?.log("write", "Not connected — aborting write", isError: true)
|
||
throw ProvisionError.notConnected
|
||
}
|
||
|
||
let uuidBytes = config.uuid.hexToBytes
|
||
guard uuidBytes.count == 16 else {
|
||
await diagnosticLog?.log("write", "Invalid UUID length: \(uuidBytes.count) bytes", isError: true)
|
||
throw ProvisionError.writeFailed("Invalid UUID length")
|
||
}
|
||
|
||
await diagnosticLog?.log("write", "Config: UUID=\(config.uuid.prefix(8))… Major=\(config.major) Minor=\(config.minor) TxPower=\(config.txPower) AdvInt=\(config.advInterval)")
|
||
|
||
// Try new SDK first (FFE2), fall back to old SDK (FFE1)
|
||
if useNewSDK, let ffe2 = ffe2Char {
|
||
await diagnosticLog?.log("write", "Using new SDK (FFE2) — 22 commands to write")
|
||
try await writeConfigNewSDK(config, uuidBytes: uuidBytes, writeChar: ffe2)
|
||
} else if let ffe1 = ffe1Char {
|
||
await diagnosticLog?.log("write", "Using old SDK (FFE1) — 7 commands to write")
|
||
try await writeConfigOldSDK(config, uuidBytes: uuidBytes, writeChar: ffe1)
|
||
} else {
|
||
await diagnosticLog?.log("write", "No write characteristic available", isError: true)
|
||
throw ProvisionError.characteristicNotFound
|
||
}
|
||
|
||
await diagnosticLog?.log("write", "All commands written successfully")
|
||
isFlashing = false
|
||
}
|
||
|
||
func disconnect() {
|
||
// Unregister disconnect handler so intentional disconnect doesn't trigger error path
|
||
bleManager?.onPeripheralDisconnected = nil
|
||
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 (index, (name, packet)) in commands.enumerated() {
|
||
// Bail immediately if BLE link dropped between commands
|
||
if disconnected {
|
||
await diagnosticLog?.log("write", "Aborting — BLE disconnected", isError: true)
|
||
throw ProvisionError.writeFailed("BLE disconnected during write sequence")
|
||
}
|
||
|
||
await diagnosticLog?.log("write", "[\(index + 1)/\(commands.count)] \(name) (\(packet.count) bytes)")
|
||
|
||
// SaveConfig (last command) causes beacon MCU to reboot — it never sends an ACK.
|
||
// Use .withoutResponse so CoreBluetooth fires the bytes immediately into the
|
||
// BLE radio buffer without waiting for a GATT round-trip. With .withResponse,
|
||
// the beacon reboots before the ACK arrives, and CoreBluetooth may silently
|
||
// drop the write — leaving the config unsaved and the beacon still flashing.
|
||
if name == "SaveConfig" {
|
||
peripheral.writeValue(packet, for: writeChar, type: .withoutResponse)
|
||
await diagnosticLog?.log("write", "✅ [\(index + 1)/\(commands.count)] SaveConfig sent — beacon will reboot")
|
||
await diagnosticLog?.log("write", "✅ All commands written successfully")
|
||
return
|
||
}
|
||
|
||
// Retry each command up to 2 times — beacon BLE stack can be flaky
|
||
var lastError: Error?
|
||
for writeAttempt in 1...2 {
|
||
do {
|
||
try await writeToCharAndWaitACK(writeChar, data: packet, label: name)
|
||
lastError = nil
|
||
break
|
||
} catch {
|
||
lastError = error
|
||
if writeAttempt == 1 {
|
||
await diagnosticLog?.log("write", "[\(index + 1)/\(commands.count)] \(name) retry after: \(error.localizedDescription)")
|
||
try await Task.sleep(nanoseconds: 500_000_000) // 500ms before retry
|
||
}
|
||
}
|
||
}
|
||
if let lastError {
|
||
await diagnosticLog?.log("write", "[\(index + 1)/\(commands.count)] \(name) FAILED: \(lastError.localizedDescription)", isError: true)
|
||
throw lastError
|
||
}
|
||
|
||
// 50ms between commands — beacon handles fast writes fine (was 150ms, 300ms, 500ms)
|
||
try await Task.sleep(nanoseconds: 50_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: - Disconnect Detection
|
||
|
||
/// Called when BLE link drops unexpectedly during provisioning.
|
||
/// Immediately resolves any pending continuations so we fail fast
|
||
/// instead of waiting for the 5s operationTimeout.
|
||
private func handleUnexpectedDisconnect(error: Error?) {
|
||
disconnected = true
|
||
isConnected = false
|
||
let disconnectError = ProvisionError.writeFailed("BLE disconnected unexpectedly: \(error?.localizedDescription ?? "unknown")")
|
||
Task { await diagnosticLog?.log("ble", "⚠️ Unexpected disconnect during provisioning", isError: true) }
|
||
|
||
// Cancel any pending write/response continuation immediately
|
||
if let cont = responseContinuation {
|
||
responseContinuation = nil
|
||
cont.resume(throwing: disconnectError)
|
||
}
|
||
if let cont = writeContinuation {
|
||
writeContinuation = nil
|
||
cont.resume(throwing: disconnectError)
|
||
}
|
||
if let cont = connectionContinuation {
|
||
connectionContinuation = nil
|
||
cont.resume(throwing: disconnectError)
|
||
}
|
||
}
|
||
|
||
// 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.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)
|
||
// 500ms settle after auth — beacon needs time to enter config mode,
|
||
// especially if BLE stack was stressed by prior provisioner attempts
|
||
try await Task.sleep(nanoseconds: 500_000_000)
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
|
||
// For command writes (FFE1/FFE2): the .withResponse write confirmation
|
||
// IS the ACK. Some commands (e.g. 0x61 Frame1_DevInfo) don't send a
|
||
// separate FFE1 notification, so we must resolve here on success too.
|
||
// If a notification also arrives later, responseContinuation will already
|
||
// be nil — harmless.
|
||
if let cont = responseContinuation {
|
||
responseContinuation = nil
|
||
if let error {
|
||
cont.resume(throwing: error)
|
||
} else {
|
||
cont.resume(returning: Data())
|
||
}
|
||
}
|
||
}
|
||
}
|