From bce006b5b20859c01e1ee5aa04a181875d7abab8 Mon Sep 17 00:00:00 2001 From: Chitlange Sahas Date: Sun, 31 May 2026 08:26:49 -0700 Subject: [PATCH 1/7] feat(sound): wire sound buttons, pause scan during NI, invalidate sessions and stop accessory on cancel --- FindMyCat/Bluetooth/BluetoothLECentral.swift | 11 +++++++++++ .../DeviceBottomDrawerViewController.swift | 11 +++++++++++ .../PreciseFinderViewController.swift | 18 +++++++++++++++++- FindMyCat/Views/DeviceTableViewCell.swift | 7 +++++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/FindMyCat/Bluetooth/BluetoothLECentral.swift b/FindMyCat/Bluetooth/BluetoothLECentral.swift index 2bebbb5..2bf82bc 100644 --- a/FindMyCat/Bluetooth/BluetoothLECentral.swift +++ b/FindMyCat/Bluetooth/BluetoothLECentral.swift @@ -171,6 +171,17 @@ class BLEDataCommunicationChannel: NSObject { centralManager.stopScan() } + func pauseScanning() { + centralManager.stopScan() + logger.info("Scanning paused for active NI session.") + } + + func resumeScanning() { + if centralManager.state == .poweredOn { + startScan() + } + } + func connectPeripheral(_ uniqueID: Int) throws { if let deviceToConnect = getDeviceFromUniqueID(uniqueID) { diff --git a/FindMyCat/Controllers/DeviceBottomDrawerViewController.swift b/FindMyCat/Controllers/DeviceBottomDrawerViewController.swift index 701da81..0b9d984 100644 --- a/FindMyCat/Controllers/DeviceBottomDrawerViewController.swift +++ b/FindMyCat/Controllers/DeviceBottomDrawerViewController.swift @@ -529,6 +529,17 @@ extension DeviceBottomDrawerController: DeviceCellDelegate { parentVc.present(vc, animated: true) } + func playSound() { + guard selectedDeviceIndex != nil else { return } + let selectedDevice = SharedData.getDevices()[selectedDeviceIndex!] + guard let selectedDeviceUniqueBLEId = Int(selectedDevice.uniqueId) else { return } + do { + try BLEDataCommunicationChannel.shared.sendData(Data([MessageId.playSound.rawValue]), selectedDeviceUniqueBLEId) + } catch { + logger.error("Failed to send playSound: \(error.localizedDescription)") + } + } + func activateLostMode(currentMode: String) { updateProgressOnLocationFetchButtonTimer() diff --git a/FindMyCat/Controllers/PreciseFinder/PreciseFinderViewController.swift b/FindMyCat/Controllers/PreciseFinder/PreciseFinderViewController.swift index bec0b0f..8a8e875 100644 --- a/FindMyCat/Controllers/PreciseFinder/PreciseFinderViewController.swift +++ b/FindMyCat/Controllers/PreciseFinder/PreciseFinderViewController.swift @@ -73,6 +73,8 @@ class PreciseFinderViewContoller: UIViewController { configureDataChannel() + BLEDataCommunicationChannel.shared.pauseScanning() + bleReadoutTimer = Timer.scheduledTimer(timeInterval: 0.5, target: self, selector: #selector(refreshBLEReadout), userInfo: nil, repeats: true) } @@ -86,6 +88,8 @@ class PreciseFinderViewContoller: UIViewController { // Pause the ARSession before it gets deallocated pauseARSession() + + BLEDataCommunicationChannel.shared.resumeScanning() } // MARK: - Setup subviews @@ -259,12 +263,18 @@ class PreciseFinderViewContoller: UIViewController { soundButton.tintColor = viewLayerColor soundButton.translatesAutoresizingMaskIntoConstraints = false + soundButton.addTarget(self, action: #selector(soundButtonPressed), for: .touchUpInside) + NSLayoutConstraint.activate([ soundButton.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -30), soundButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -30) ]) } + @objc private func soundButtonPressed() { + sendDataToAccessory(Data([MessageId.playSound.rawValue]), deviceUniqueBLEId) + } + func setupDistanceLabel() { view.addSubview(distanceLabel) @@ -307,7 +317,13 @@ class PreciseFinderViewContoller: UIViewController { } @objc private func cancelButtonPressed() { - // cleanup -- stop the data channel and disconnect from Device. + sendDataToAccessory(Data([MessageId.stop.rawValue]), deviceUniqueBLEId) + + for (_, session) in referenceDict { + session.invalidate() + } + referenceDict.removeAll() + deinitDataCommunicationChannel() disconnectFromAccessory(deviceUniqueBLEId) dismiss(animated: true, completion: nil) diff --git a/FindMyCat/Views/DeviceTableViewCell.swift b/FindMyCat/Views/DeviceTableViewCell.swift index 9785c74..300bbe5 100644 --- a/FindMyCat/Views/DeviceTableViewCell.swift +++ b/FindMyCat/Views/DeviceTableViewCell.swift @@ -26,6 +26,7 @@ class Button: UIButton { protocol DeviceCellDelegate: AnyObject { func launchPreciseFindScreen() func activateLostMode(currentMode: String) + func playSound() } class ButtonWithProgressBar: UIButton { @@ -247,12 +248,18 @@ class DeviceTableViewCell: UITableViewCell { soundButton.translatesAutoresizingMaskIntoConstraints = false + soundButton.addTarget(self, action: #selector(self.playSoundTapped), for: .touchUpInside) + NSLayoutConstraint.activate([ soundButton.leadingAnchor.constraint(equalTo: findButton.trailingAnchor, constant: 16), soundButton.bottomAnchor.constraint(equalTo: findButton.bottomAnchor) ]) } + @objc private func playSoundTapped() { + delegate?.playSound() + } + private func configureFindButton() { findButton.tintColor = UIColor(cgColor: grayColor) From b4e7c685fae81717deb487623ef21f759a402731 Mon Sep 17 00:00:00 2001 From: Chitlange Sahas Date: Sun, 31 May 2026 09:09:00 -0700 Subject: [PATCH 2/7] feat(ble): filter scan list to FMC accessories via manuf-data magic --- FindMyCat/Bluetooth/BluetoothLECentral.swift | 23 +++++++++++++++---- FindMyCat/Constants.swift | 3 +++ .../ScanDevicesViewController.swift | 5 ++-- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/FindMyCat/Bluetooth/BluetoothLECentral.swift b/FindMyCat/Bluetooth/BluetoothLECentral.swift index 2bf82bc..84e2827 100644 --- a/FindMyCat/Bluetooth/BluetoothLECentral.swift +++ b/FindMyCat/Bluetooth/BluetoothLECentral.swift @@ -119,7 +119,7 @@ class BLEDataCommunicationChannel: NSObject { logger.info("Scanning stopped.") } - // Clear peripherals in qorvoDevices[] if not responding for more than one second + // Clear peripherals in qorvoDevices[] if not responding for more than 10 seconds @objc func timerHandler() { var index = 0 @@ -129,8 +129,8 @@ class BLEDataCommunicationChannel: NSObject { // Get current timestamp let timeStamp = Int64((Date().timeIntervalSince1970 * 1000.0).rounded()) - // Remove device if timestamp is bigger than 5000 msec - if timeStamp > (preciseFindableDevice!.bleTimestamp + 5000) { + // Remove device if timestamp is bigger than 10000 msec + if timeStamp > (preciseFindableDevice!.bleTimestamp + 10000) { let deviceID = preciseFindableDevice?.bleUniqueID logger.info("Device \(preciseFindableDevice?.blePeripheralName ?? "Unknown") timed-out removed at index \(index)") @@ -349,6 +349,14 @@ extension BLEDataCommunicationChannel: CBCentralManagerDelegate { let timeStamp = Int64((Date().timeIntervalSince1970 * 1000.0).rounded()) + guard let manuf = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data, + manuf.count >= Constants.FMCAdvManufPrefix.count, + Array(manuf.prefix(Constants.FMCAdvManufPrefix.count)) == Constants.FMCAdvManufPrefix else { + return + } + + let advertisedName = (advertisementData[CBAdvertisementDataLocalNameKey] as? String) ?? peripheral.name + // Check if peripheral is already discovered if let preciseFindableDevice = getDeviceFromUniqueID(peripheral.hashValue) { @@ -356,14 +364,19 @@ extension BLEDataCommunicationChannel: CBCentralManagerDelegate { preciseFindableDevice.bleTimestamp = timeStamp preciseFindableDevice.bleRSSI = RSSI.intValue + // sticky-update name: once we see a real name, keep it; never overwrite with empty + if let newName = advertisedName, !newName.isEmpty, + preciseFindableDevice.blePeripheralName.isEmpty || preciseFindableDevice.blePeripheralName == "Unknown" { + preciseFindableDevice.blePeripheralName = newName + } + return } // If not discovered, include peripheral to preciseFindableDevices - let name = advertisementData[CBAdvertisementDataLocalNameKey] as? String preciseFindableDevices.append(PreciseFindableDevice(peripheral: peripheral, uniqueID: peripheral.hashValue, - peripheralName: name ?? "Unknown", + peripheralName: advertisedName ?? "Unknown", timeStamp: timeStamp, rssi: RSSI.intValue)) diff --git a/FindMyCat/Constants.swift b/FindMyCat/Constants.swift index 2a39898..b0a2487 100644 --- a/FindMyCat/Constants.swift +++ b/FindMyCat/Constants.swift @@ -20,4 +20,7 @@ struct Constants { // Confirmation Messages static let RemoveDeviceConfirmationMessage = "Are you sure you want to remove the device from your account?" + + // BLE advertising filter (must match embedded fmc_adv_magic.h) + static let FMCAdvManufPrefix: [UInt8] = [0xFF, 0xFF, 0x46, 0x4D, 0x43] } diff --git a/FindMyCat/Controllers/ScanDevices/ScanDevicesViewController.swift b/FindMyCat/Controllers/ScanDevices/ScanDevicesViewController.swift index e0bd875..ce12b18 100644 --- a/FindMyCat/Controllers/ScanDevices/ScanDevicesViewController.swift +++ b/FindMyCat/Controllers/ScanDevices/ScanDevicesViewController.swift @@ -82,10 +82,11 @@ class ScanDevicesViewController: UIViewController { let device = ScannedDeviceView(frame: CGRect(x: 0, y: 0, width: circleSize, height: circleSize)) if let bleUniqueID = scannedDevice?.bleUniqueID { - device.numberLabel.text = String(bleUniqueID) + let name = scannedDevice?.blePeripheralName ?? "" + device.numberLabel.text = name.isEmpty ? "Unknown device" : name device.tag = bleUniqueID } else { - device.numberLabel.text = "unknown" + device.numberLabel.text = "Unknown device" } let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap)) From 45ae76db0032cac626c007ba4d6b808f02d13549 Mon Sep 17 00:00:00 2001 From: Chitlange Sahas Date: Sun, 31 May 2026 09:16:22 -0700 Subject: [PATCH 3/7] feat(debug): persist local debug devices across app launches via UserDefaults --- FindMyCat/Models/Devices.swift | 4 ++-- FindMyCat/Models/SharedData.swift | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/FindMyCat/Models/Devices.swift b/FindMyCat/Models/Devices.swift index fee7bd8..a0d44d4 100644 --- a/FindMyCat/Models/Devices.swift +++ b/FindMyCat/Models/Devices.swift @@ -7,9 +7,9 @@ import Foundation -class Device: Decodable { +class Device: Codable { - struct Attributes: Decodable { + struct Attributes: Codable { var emoji: String? } diff --git a/FindMyCat/Models/SharedData.swift b/FindMyCat/Models/SharedData.swift index 50ece9e..8dcf68f 100644 --- a/FindMyCat/Models/SharedData.swift +++ b/FindMyCat/Models/SharedData.swift @@ -30,6 +30,8 @@ class SharedData { private let webSocketManager = WebSocketManager() private var cancellables = Set() + private static let localDevicesKey = "LocalDebugDevices" + // MARK: Initialize function private init() { // Initialize WebSocket connection and handle incoming data updates @@ -38,6 +40,7 @@ class SharedData { // Sequentially exeute API calls and finally register websockets. fetchDevicesFromRestAPI { + SharedData.mergeInPersistedLocalDevices() self.fetchPositionsFromRestAPI { self.configureWebsocket { // Fetched complete. @@ -62,6 +65,26 @@ class SharedData { public static func addLocalDevice(_ device: Device) { devices.append(device) + persistLocalDevices() + } + + private static func loadPersistedLocalDevices() -> [Device] { + guard let data = UserDefaults.standard.data(forKey: localDevicesKey) else { return [] } + return (try? JSONDecoder().decode([Device].self, from: data)) ?? [] + } + + private static func persistLocalDevices() { + let locals = devices.filter { $0.id == 0 } + if let data = try? JSONEncoder().encode(locals) { + UserDefaults.standard.set(data, forKey: localDevicesKey) + } + } + + private static func mergeInPersistedLocalDevices() { + let locals = loadPersistedLocalDevices() + for local in locals where !devices.contains(where: { $0.uniqueId == local.uniqueId }) { + devices.append(local) + } } public static func getDevicesCount() -> Int { From 9574426d5c84dba5f9386ef214c4f309f30d220b Mon Sep 17 00:00:00 2001 From: Chitlange Sahas Date: Sun, 31 May 2026 09:18:50 -0700 Subject: [PATCH 4/7] fix(add-edit-device): make uniqueId field truly read-only --- .../Controllers/AddEditDevice/AddEditDeviceViewController.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/FindMyCat/Controllers/AddEditDevice/AddEditDeviceViewController.swift b/FindMyCat/Controllers/AddEditDevice/AddEditDeviceViewController.swift index 648ffb8..bd28dd1 100644 --- a/FindMyCat/Controllers/AddEditDevice/AddEditDeviceViewController.swift +++ b/FindMyCat/Controllers/AddEditDevice/AddEditDeviceViewController.swift @@ -151,6 +151,8 @@ class AddEditDeviceViewController: UIViewController, UITextFieldDelegate { uniqueIdReadOnlyTextField.cornerRadius = 11 uniqueIdReadOnlyTextField.returnKeyType = .done + uniqueIdReadOnlyTextField.isUserInteractionEnabled = false + uniqueIdReadOnlyTextField.textColor = .systemGray sheetView.addSubview(uniqueIdReadOnlyTextField) From 64ed80eb775d6ae82a73e3873171696c5c55baaf Mon Sep 17 00:00:00 2001 From: Chitlange Sahas Date: Sun, 31 May 2026 09:30:14 -0700 Subject: [PATCH 5/7] feat(scan): haptic pulse in sync with scanning animation --- .../ScanDevices/ScanningAnimationView.swift | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/FindMyCat/Views/ScanDevices/ScanningAnimationView.swift b/FindMyCat/Views/ScanDevices/ScanningAnimationView.swift index 8b684c0..4575ed3 100644 --- a/FindMyCat/Views/ScanDevices/ScanningAnimationView.swift +++ b/FindMyCat/Views/ScanDevices/ScanningAnimationView.swift @@ -15,6 +15,9 @@ class ScanningAnimationView: UIView { let strokeColor = UIColor.gray private var animationTimer: Timer? + private let hapticGenerator = UIImpactFeedbackGenerator(style: .heavy) + private let pulseDuration: CFTimeInterval = 0.7 + private let hapticEveryNPulses = 2 override init(frame: CGRect) { super.init(frame: frame) @@ -49,6 +52,7 @@ class ScanningAnimationView: UIView { } func startAnimation() { + let centerX = bounds.width / 2 let centerY = (4 * bounds.height) / 5 let spread = 100 @@ -84,10 +88,28 @@ class ScanningAnimationView: UIView { let animation = CAAnimationGroup() animation.animations = [scaleAnim, opacityAnim] animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut) - animation.duration = CFTimeInterval(1) + animation.duration = pulseDuration animation.repeatCount = .infinity rippleShape.add(animation, forKey: "rippleEffect") + + hapticGenerator.prepare() + animationTimer?.invalidate() + self.hapticGenerator.impactOccurred() + self.hapticGenerator.prepare() + animationTimer = Timer.scheduledTimer(withTimeInterval: pulseDuration * Double(hapticEveryNPulses), repeats: true) { [weak self] _ in + self?.hapticGenerator.impactOccurred() + self?.hapticGenerator.prepare() + } + } + + func stopAnimation() { + animationTimer?.invalidate() + animationTimer = nil + } + + deinit { + animationTimer?.invalidate() } } From 7dd6a6811401f86d84cd706fa67b69130f66e5e3 Mon Sep 17 00:00:00 2001 From: Chitlange Sahas Date: Sun, 31 May 2026 09:44:17 -0700 Subject: [PATCH 6/7] feat(precise-finder): proximity haptic with log ramp and medium/heavy step at 3ft --- .../PreciseFinderViewController.swift | 9 +++++++++ .../extensions/NI_AR_SessionDelegates.swift | 17 ++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/FindMyCat/Controllers/PreciseFinder/PreciseFinderViewController.swift b/FindMyCat/Controllers/PreciseFinder/PreciseFinderViewController.swift index 8a8e875..cf29155 100644 --- a/FindMyCat/Controllers/PreciseFinder/PreciseFinderViewController.swift +++ b/FindMyCat/Controllers/PreciseFinder/PreciseFinderViewController.swift @@ -48,6 +48,15 @@ class PreciseFinderViewContoller: UIViewController { internal var isUWBDistanceAvailable = false internal var lastUWBDistanceTimestamp: Date? + internal let proximityHapticMedium = UIImpactFeedbackGenerator(style: .medium) + internal let proximityHapticHeavy = UIImpactFeedbackGenerator(style: .heavy) + internal var lastProximityHapticAt: Date? + internal let proximityHapticThresholdFt: Float = 6.0 + internal let proximityHapticHeavyBelowFt: Float = 3.0 + internal let proximityHapticFloorFt: Float = 0.5 + internal let proximityHapticIntervalFar: TimeInterval = 0.6 + internal let proximityHapticIntervalNear: TimeInterval = 0.05 + // MARK: - Util managers let uwbUtilManager = UWBUtils() diff --git a/FindMyCat/Controllers/PreciseFinder/extensions/NI_AR_SessionDelegates.swift b/FindMyCat/Controllers/PreciseFinder/extensions/NI_AR_SessionDelegates.swift index 3ffcb54..6b9b952 100644 --- a/FindMyCat/Controllers/PreciseFinder/extensions/NI_AR_SessionDelegates.swift +++ b/FindMyCat/Controllers/PreciseFinder/extensions/NI_AR_SessionDelegates.swift @@ -110,7 +110,22 @@ extension PreciseFinderViewContoller: NISessionDelegate { isUWBDistanceAvailable = true lastUWBDistanceTimestamp = Date() - distanceLabel.text = String(format: "%.1f ft", convertMetersToFeet(meters: distance!)) + let distanceFt = convertMetersToFeet(meters: distance!) + distanceLabel.text = String(format: "%.1f ft", distanceFt) + + if distanceFt < proximityHapticThresholdFt { + let clamped = max(proximityHapticFloorFt, min(distanceFt, proximityHapticThresholdFt)) + let tLinear = TimeInterval((clamped - proximityHapticFloorFt) / (proximityHapticThresholdFt - proximityHapticFloorFt)) + let tCurved = log(1 + 9 * tLinear) / log(10) + let interval = proximityHapticIntervalNear + (proximityHapticIntervalFar - proximityHapticIntervalNear) * tCurved + let now = Date() + if lastProximityHapticAt == nil || now.timeIntervalSince(lastProximityHapticAt!) >= interval { + let generator = distanceFt <= proximityHapticHeavyBelowFt ? proximityHapticHeavy : proximityHapticMedium + generator.impactOccurred() + generator.prepare() + lastProximityHapticAt = now + } + } // Update arrow let radians: CGFloat = CGFloat(azimuth) * (.pi / 180) From afb4dd60718c3fd43711c151c4618c5d2cdf9c76 Mon Sep 17 00:00:00 2001 From: Chitlange Sahas Date: Sun, 31 May 2026 10:06:40 -0700 Subject: [PATCH 7/7] fix(precise-finder): cancellable connect retries and resume scan on failure --- FindMyCat/Bluetooth/BluetoothLECentral.swift | 4 +++- .../PreciseFinder/PreciseFinderViewController.swift | 2 ++ .../extensions/DataCommunicationExtension.swift | 2 ++ .../PreciseFinder/extensions/HelpersExtension.swift | 6 ++++-- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/FindMyCat/Bluetooth/BluetoothLECentral.swift b/FindMyCat/Bluetooth/BluetoothLECentral.swift index 84e2827..11fa819 100644 --- a/FindMyCat/Bluetooth/BluetoothLECentral.swift +++ b/FindMyCat/Bluetooth/BluetoothLECentral.swift @@ -215,7 +215,9 @@ class BLEDataCommunicationChannel: NSObject { } func requestRSSI(_ uniqueID: Int) { - getDeviceFromUniqueID(uniqueID)?.blePeripheral.readRSSI() + guard let peripheral = getDeviceFromUniqueID(uniqueID)?.blePeripheral, + peripheral.state == .connected else { return } + peripheral.readRSSI() } func sendData(_ data: Data, _ uniqueID: Int) throws { diff --git a/FindMyCat/Controllers/PreciseFinder/PreciseFinderViewController.swift b/FindMyCat/Controllers/PreciseFinder/PreciseFinderViewController.swift index cf29155..35053ec 100644 --- a/FindMyCat/Controllers/PreciseFinder/PreciseFinderViewController.swift +++ b/FindMyCat/Controllers/PreciseFinder/PreciseFinderViewController.swift @@ -47,6 +47,7 @@ class PreciseFinderViewContoller: UIViewController { internal var NIAlgorithmHasConverged = false internal var isUWBDistanceAvailable = false internal var lastUWBDistanceTimestamp: Date? + internal var connectRetriesCancelled = false internal let proximityHapticMedium = UIImpactFeedbackGenerator(style: .medium) internal let proximityHapticHeavy = UIImpactFeedbackGenerator(style: .heavy) @@ -326,6 +327,7 @@ class PreciseFinderViewContoller: UIViewController { } @objc private func cancelButtonPressed() { + connectRetriesCancelled = true sendDataToAccessory(Data([MessageId.stop.rawValue]), deviceUniqueBLEId) for (_, session) in referenceDict { diff --git a/FindMyCat/Controllers/PreciseFinder/extensions/DataCommunicationExtension.swift b/FindMyCat/Controllers/PreciseFinder/extensions/DataCommunicationExtension.swift index a4cd6c8..587d04d 100644 --- a/FindMyCat/Controllers/PreciseFinder/extensions/DataCommunicationExtension.swift +++ b/FindMyCat/Controllers/PreciseFinder/extensions/DataCommunicationExtension.swift @@ -63,6 +63,8 @@ extension PreciseFinderViewContoller { } internal func accessoryConnected(deviceID: Int) { + BLEDataCommunicationChannel.shared.pauseScanning() + // Create a NISession for the new device referenceDict[deviceID] = NISession() referenceDict[deviceID]?.delegate = self diff --git a/FindMyCat/Controllers/PreciseFinder/extensions/HelpersExtension.swift b/FindMyCat/Controllers/PreciseFinder/extensions/HelpersExtension.swift index c0c8849..4082ac1 100644 --- a/FindMyCat/Controllers/PreciseFinder/extensions/HelpersExtension.swift +++ b/FindMyCat/Controllers/PreciseFinder/extensions/HelpersExtension.swift @@ -27,14 +27,16 @@ extension PreciseFinderViewContoller { var retries = 0 func connectWithDelay() { + if connectRetriesCancelled { return } do { try BLEDataCommunicationChannel.shared.connectPeripheral(deviceID) } catch { logger.error("Failed to connect to accessory: \(error)") + BLEDataCommunicationChannel.shared.resumeScanning() retries += 1 - // Retry after delay if retries < maxRetries { - DispatchQueue.main.asyncAfter(deadline: .now() + retryDelay) { + DispatchQueue.main.asyncAfter(deadline: .now() + retryDelay) { [weak self] in + guard let self = self, !self.connectRetriesCancelled else { return } connectWithDelay() } }