Shaves ~4.6s off the 23-command provisioning sequence while keeping a safe margin for the beacon's BLE stack to process each write. Next step: if stable, we can go more aggressive (200ms or 150ms). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
483 lines
22 KiB
Swift
483 lines
22 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
|
|
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)")")
|
|
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() {
|
|
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() {
|
|
await diagnosticLog?.log("write", "[\(index + 1)/\(commands.count)] \(name) (\(packet.count) bytes)")
|
|
|
|
// 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
|
|
}
|
|
|
|
// 300ms between commands — conservative speedup (was 500ms)
|
|
// Beacon needs time to process each GATT write; 300ms tested safe
|
|
try await Task.sleep(nanoseconds: 300_000_000)
|
|
}
|
|
}
|
|
|
|
// MARK: - Old SDK Protocol (FFE1, pre-2024.10)
|
|
// Matches Android DXSmartProvisioner.writeFrame1()
|
|
// Key difference: must re-send "555555" to FFE3 before EVERY command
|
|
|
|
private func writeConfigOldSDK(_ config: BeaconConfig, uuidBytes: [UInt8], writeChar: CBCharacteristic) async throws {
|
|
guard let ffe3 = ffe3Char else {
|
|
throw ProvisionError.characteristicNotFound
|
|
}
|
|
|
|
let commands: [(String, Data)] = [
|
|
("UUID", buildProtocolPacket(cmd: 0x36, data: Data(uuidBytes))),
|
|
("Major", buildProtocolPacket(cmd: 0x37, data: Data([UInt8(config.major >> 8), UInt8(config.major & 0xFF)]))),
|
|
("Minor", buildProtocolPacket(cmd: 0x38, data: Data([UInt8(config.minor >> 8), UInt8(config.minor & 0xFF)]))),
|
|
("TxPower", buildProtocolPacket(cmd: 0x39, data: Data([UInt8(bitPattern: config.measuredPower)]))),
|
|
("RfPower", buildProtocolPacket(cmd: 0x40, data: Data([config.txPower]))),
|
|
("AdvInt", buildProtocolPacket(cmd: 0x41, data: Data([UInt8(config.advInterval & 0xFF)]))),
|
|
("Name", buildProtocolPacket(cmd: 0x43, data: Data("Payfrit".utf8))),
|
|
]
|
|
|
|
for (name, packet) in commands {
|
|
// Step 1: Re-send "555555" to FFE3 before each command (old SDK requirement)
|
|
if let triggerData = Self.triggerPassword.data(using: .utf8) {
|
|
peripheral.writeValue(triggerData, for: ffe3, type: .withResponse)
|
|
try await waitForWriteCallback()
|
|
}
|
|
|
|
// Step 2: 50ms delay (SDK timer, half of 100ms default — tested OK)
|
|
try await Task.sleep(nanoseconds: 50_000_000)
|
|
|
|
// Step 3: Write command to FFE1
|
|
try await writeToCharAndWaitACK(writeChar, data: packet, label: name)
|
|
|
|
// 200ms settle between params
|
|
try await Task.sleep(nanoseconds: 200_000_000)
|
|
}
|
|
}
|
|
|
|
// MARK: - Read-Back Verification
|
|
|
|
/// Read frame 2 (iBeacon config) to verify the write succeeded.
|
|
/// Returns the raw response data, or nil if read fails.
|
|
func readFrame2() async throws -> Data? {
|
|
guard let ffe2 = ffe2Char ?? ffe1Char else { return nil }
|
|
|
|
let readCmd = buildProtocolPacket(cmd: 0x62, data: Data())
|
|
peripheral.writeValue(readCmd, for: ffe2, type: .withResponse)
|
|
|
|
do {
|
|
let response = try await waitForResponse(timeout: 2.0)
|
|
return response
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Protocol Packet Builder
|
|
// Format: 4E 4F [CMD] [LEN] [DATA...] [CHECKSUM]
|
|
// Checksum = XOR of CMD, LEN, and all data bytes
|
|
|
|
private func buildProtocolPacket(cmd: UInt8, data: Data) -> Data {
|
|
let len = UInt8(data.count)
|
|
var checksum = Int(cmd) ^ Int(len)
|
|
for byte in data {
|
|
checksum ^= Int(byte)
|
|
}
|
|
|
|
var packet = Data([0x4E, 0x4F, cmd, len])
|
|
packet.append(data)
|
|
packet.append(UInt8(checksum & 0xFF))
|
|
return packet
|
|
}
|
|
|
|
// MARK: - Private Helpers
|
|
|
|
private func connectOnce() async throws {
|
|
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
|
connectionContinuation = cont
|
|
|
|
// 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())
|
|
}
|
|
}
|
|
}
|
|
}
|