SaveConfig (0x60) causes the beacon MCU to reboot and save to flash. It never sends an ACK, so writeToCharAndWaitACK would wait for the 5s timeout, during which the beacon disconnects. The disconnect handler fires while writesCompleted is still false, causing a false "Unexpected disconnect: beacon timed out" error. Fix: fire-and-forget the SaveConfig write and return immediately. The BLE-level write (.withResponse) confirms delivery. writeConfig() returns before the disconnect callback runs, so writesCompleted gets set to true in time. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
492 lines
22 KiB
Swift
492 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)")
|
|
|
|
// SaveConfig (last command) causes beacon MCU to reboot — it never sends an ACK.
|
|
// Fire the BLE write and return immediately; the disconnect is expected.
|
|
if name == "SaveConfig" {
|
|
peripheral.writeValue(packet, for: writeChar, type: .withResponse)
|
|
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
|
|
}
|
|
|
|
// 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())
|
|
}
|
|
}
|
|
}
|
|
}
|