diff --git a/.periphery.baseline.json b/.periphery.baseline.json index 0b826869..af3885fb 100644 --- a/.periphery.baseline.json +++ b/.periphery.baseline.json @@ -1 +1 @@ -{"v1":{"usrs":["param-buf-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-dataDir-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","param-len-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-socksPort-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","s:13BitFoundation16PeerCapabilitiesV8wifiBulkACvpZ","s:13BitFoundation18KeychainReadResultO18isRecoverableErrorSbvp","s:13BitFoundation23KeychainManagerProtocolP11secureClearyySSzF","s:18bitchatTests_macOS12MockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC11resetCountsyyF","s:18bitchatTests_macOS20TrackingMockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC25totalSecureClearCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC26secureClearStringCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC27_secureClearStringCallCount06_AB6D1M24FD239F2969C82F4108818260LLSivp","s:18bitchatTests_macOS24FailingCacheSaveKeychain33_22380C7A11A569A0B83FA83F34C498A7LLC11secureClearyySSzF","s:18bitchatTests_macOS24MockGeohashPresenceTimer33_483587EFB96650EE130EFB09BBA2A1AALLC7handleryycvp","s:3Tor0A7ManagerC21goDormantOnBackgroundyyF","s:7bitchat10AppRuntimeC24handleScreenshotCaptured33_C8B369AD8BC1D9963A50CEDA77A4332ALLyyF","s:7bitchat10AppRuntimeC33handleDidBecomeActiveNotificationyyF","s:7bitchat10BLEServiceC18logBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LLyySSF","s:7bitchat10BLEServiceC20centralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC22captureBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LL7contextySS_tF","s:7bitchat10BLEServiceC23peripheralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC29scheduleBluetoothStatusSample33_69191C53E68500C17D98DBCF2BDA7100LL5after7contextySd_SStF","s:7bitchat10QRScanViewV8isActiveSbvp","s:7bitchat15BLEPeerRegistryV5countSivp","s:7bitchat15KeychainManagerC11secureClearyySSzF","s:7bitchat15PaymentChipViewV7openURL33_10AC50641B1EBCD52E5092A2E521D236LL7SwiftUI13OpenURLActionVvp","s:7bitchat15TransportConfigO29uiBatchDispatchStaggerSecondsSdvpZ","s:7bitchat15TransportConfigO35uiShareExtensionDismissDelaySecondsSdvpZ","s:7bitchat15TransportConfigO38bleBackgroundPendingConnectSlotReserveSivpZ","s:7bitchat17GossipSyncManagerC10persistNowyyF","s:7bitchat17NostrRelayManagerC15InboundEventKey33_E4160FE8A9A2C9D6308EAAD5A8B5CB07LLV7eventIDSSvp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}} \ No newline at end of file +{"v1":{"usrs":["param-buf-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-dataDir-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","param-len-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-socksPort-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","s:13BitFoundation16PeerCapabilitiesV8wifiBulkACvpZ","s:13BitFoundation18KeychainReadResultO18isRecoverableErrorSbvp","s:13BitFoundation23KeychainManagerProtocolP11secureClearyySSzF","s:18bitchatTests_macOS12MockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC11resetCountsyyF","s:18bitchatTests_macOS20TrackingMockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC25totalSecureClearCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC26secureClearStringCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC27_secureClearStringCallCount06_AB6D1M24FD239F2969C82F4108818260LLSivp","s:18bitchatTests_macOS24FailingCacheSaveKeychain33_22380C7A11A569A0B83FA83F34C498A7LLC11secureClearyySSzF","s:18bitchatTests_macOS24MockGeohashPresenceTimer33_483587EFB96650EE130EFB09BBA2A1AALLC7handleryycvp","s:3Tor0A7ManagerC21goDormantOnBackgroundyyF","s:7bitchat10AppRuntimeC24handleScreenshotCaptured33_C8B369AD8BC1D9963A50CEDA77A4332ALLyyF","s:7bitchat10AppRuntimeC33handleDidBecomeActiveNotificationyyF","s:7bitchat10BLEServiceC18logBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LLyySSF","s:7bitchat10BLEServiceC20centralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC22captureBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LL7contextySS_tF","s:7bitchat10BLEServiceC23peripheralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC29scheduleBluetoothStatusSample33_69191C53E68500C17D98DBCF2BDA7100LL5after7contextySd_SStF","s:7bitchat10QRScanViewV8isActiveSbvp","s:7bitchat15BLEPeerRegistryV5countSivp","s:7bitchat15KeychainManagerC11secureClearyySSzF","s:7bitchat15PaymentChipViewV7openURL33_10AC50641B1EBCD52E5092A2E521D236LL7SwiftUI13OpenURLActionVvp","s:7bitchat15TransportConfigO29uiBatchDispatchStaggerSecondsSdvpZ","s:7bitchat15TransportConfigO35uiShareExtensionDismissDelaySecondsSdvpZ","s:7bitchat15TransportConfigO38bleBackgroundPendingConnectSlotReserveSivpZ","s:7bitchat17GossipSyncManagerC10persistNowyyF","s:7bitchat17NostrRelayManagerC15InboundEventKey33_E4160FE8A9A2C9D6308EAAD5A8B5CB07LLV7eventIDSSvp","s:7bitchat18BLERadioControllerC14candidateCountSivp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}} \ No newline at end of file diff --git a/bitchat/Services/BLE/BLERadioController.swift b/bitchat/Services/BLE/BLERadioController.swift new file mode 100644 index 00000000..8c7813e1 --- /dev/null +++ b/bitchat/Services/BLE/BLERadioController.swift @@ -0,0 +1,412 @@ +import BitLogger +import CoreBluetooth +import Foundation + +/// The radio's contact points back into the transport. All calls arrive on +/// bleQueue. +protocol BLERadioControllerDelegate: AnyObject { + /// Whether a panic wipe has quiesced the radio. + func radioIsPanicSuspended() -> Bool + /// iOS app-active snapshot (drives allow-duplicates scanning and + /// background connect deferral); always true on macOS. + func radioIsAppActive() -> Bool + /// A connect attempt died (timeout or foreground stale-reclaim): retire + /// the link's transport bookkeeping — write buffers, link-auth proof, + /// reconnect epoch, and the link-state entry itself. + func radioTearDownPeripheralLink(_ peripheralID: String) +} + +/// bleQueue-confined owner of the central-role radio policy: discovery +/// admission, the connection budget and queue, connect timeouts, +/// wake-on-proximity background connects, scan duty-cycling, RSSI +/// adaptation, and the advertising payload. +/// +/// First slice of the link layer (docs/BLE-ARCHITECTURE-V3.md): this type +/// makes no peer decisions and owns no bindings or security state — it +/// shares the bleQueue-confined link-state store for admission reads and +/// asks its delegate to tear down transport bookkeeping when an attempt +/// dies. +final class BLERadioController { + weak var delegate: BLERadioControllerDelegate? + /// The transport is every peripheral's CBPeripheralDelegate; connects + /// initiated here must point new peripherals at it. + weak var peripheralDelegate: CBPeripheralDelegate? + /// Attached when the transport creates (or restores) its managers. + weak var central: CBCentralManager? + + private let queue: DispatchQueue + private let linkStateStore: BLELinkStateStore + private let recentTraffic: BLERecentTrafficMonitor + + // Connection budget & scheduling (central role) + private var scheduler = BLEConnectionScheduler() + // Recently seen peripherals retained for background wake-on-proximity + // connects + private let recentPeripheralCache = BLERecentPeripheralCache() + + // Adaptive scanning duty-cycle + private var scanDutyTimer: DispatchSourceTimer? + private var dutyEnabled: Bool = true + private var dutyOnDuration: TimeInterval = TransportConfig.bleDutyOnDuration + private var dutyOffDuration: TimeInterval = TransportConfig.bleDutyOffDuration + private var dutyActive: Bool = false + + init( + queue: DispatchQueue, + linkStateStore: BLELinkStateStore, + recentTraffic: BLERecentTrafficMonitor + ) { + self.queue = queue + self.linkStateStore = linkStateStore + self.recentTraffic = recentTraffic + } + + // MARK: - Advertising + + static func advertisementData() -> [String: Any] { + // No Local Name for privacy. + [CBAdvertisementDataServiceUUIDsKey: [BLEService.serviceUUID]] + } + + // MARK: - Scanning + + func startScanning() { + guard delegate?.radioIsPanicSuspended() == false, + let central, + central.state == .poweredOn, + !central.isScanning else { return } + + // Allow duplicates while active for faster discovery: immediate + // discovery events instead of coalesced ones. + let allowDuplicates = delegate?.radioIsAppActive() ?? true + central.scanForPeripherals( + withServices: [BLEService.serviceUUID], + options: [CBCentralManagerScanOptionAllowDuplicatesKey: allowDuplicates] + ) + } + + func updateScanningDutyCycle(connectedCount: Int) { + guard let central, central.state == .poweredOn else { return } + // Duty cycle only when the app is active and at least one peer is + // connected; force full-time scanning with few neighbors or very + // recent traffic. + let hasRecentTraffic = recentTraffic.hasTraffic( + within: TransportConfig.bleRecentTrafficForceScanSeconds, + now: Date() + ) + let scanPlan = BLEScanDutyPolicy.plan( + dutyEnabled: dutyEnabled, + appIsActive: delegate?.radioIsAppActive() ?? true, + connectedCount: connectedCount, + hasRecentTraffic: hasRecentTraffic + ) + + switch scanPlan { + case .dutyCycle(let onDuration, let offDuration): + let durationsChanged = dutyOnDuration != onDuration || dutyOffDuration != offDuration + dutyOnDuration = onDuration + dutyOffDuration = offDuration + + if scanDutyTimer == nil { + // Start with scanning ON; turn OFF after onDuration. + let t = DispatchSource.makeTimerSource(queue: queue) + if !central.isScanning { startScanning() } + dutyActive = true + t.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration) + t.setEventHandler { [weak self] in + guard let self, let c = self.central else { return } + if self.dutyActive { + if c.isScanning { c.stopScan() } + self.dutyActive = false + self.queue.asyncAfter(deadline: .now() + self.dutyOffDuration) { + if self.central?.state == .poweredOn { self.startScanning() } + self.dutyActive = true + } + } + } + t.resume() + scanDutyTimer = t + } else if durationsChanged { + scanDutyTimer?.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration) + if !central.isScanning { startScanning() } + dutyActive = true + } + case .continuous: + // Cancel duty cycle and ensure scanning is ON for discovery. + scanDutyTimer?.cancel() + scanDutyTimer = nil + if !central.isScanning { startScanning() } + } + } + + func stopDutyCycle() { + scanDutyTimer?.cancel() + scanDutyTimer = nil + } + + func updateRSSIThreshold(connectedCount: Int) { + scheduler.updateRSSIThreshold( + connectedCount: connectedCount, + connectedOrConnectingLinkCount: linkStateStore.connectedOrConnectingPeripheralCount, + now: Date() + ) + } + + // MARK: - Discovery & connection budget + + func handleDiscovery( + _ peripheral: CBPeripheral, + advertisementData: [String: Any], + rssi: NSNumber + ) { + guard delegate?.radioIsPanicSuspended() == false, let central else { return } + let peripheralID = peripheral.identifier.uuidString + let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? (peripheralID.prefix(6) + "…") + let isConnectable = (advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? true + + let candidate = BLEConnectionCandidate( + peripheral: peripheral, + peripheralID: peripheralID, + rssi: rssi.intValue, + name: String(advertisedName), + isConnectable: isConnectable, + discoveredAt: Date() + ) + if isConnectable { + recentPeripheralCache.record(peripheral, peripheralID: peripheralID, at: candidate.discoveredAt) + } + let existingState = linkStateStore.state(forPeripheralID: peripheralID).map(BLEExistingConnectionState.init) + + switch scheduler.handleDiscovery( + candidate, + connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount, + existingState: existingState, + peripheralState: peripheral.state.connectionSchedulerState, + now: candidate.discoveredAt + ) { + case .ignore, .queued: + return + case .scheduleRetry(let delay): + queue.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.tryConnectFromQueue() + } + return + case .cancelStaleConnection: + central.cancelPeripheralConnection(peripheral) + return + case .connectNow: + beginCentralConnection(candidate, using: central, logPrefix: "📱 Connect") + } + } + + func tryConnectFromQueue() { + guard delegate?.radioIsPanicSuspended() == false, + let central, + central.state == .poweredOn else { return } + + let decision = scheduler.nextCandidate( + connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount, + isAlreadyConnectingOrConnected: { [linkStateStore] peripheralID in + let state = linkStateStore.state(forPeripheralID: peripheralID) + return state?.isConnected == true || state?.isConnecting == true + }, + now: Date() + ) + + switch decision { + case .none: + return + case .retryAfter(let delay): + queue.asyncAfter(deadline: .now() + delay) { [weak self] in self?.tryConnectFromQueue() } + case .connect(let candidate): + beginCentralConnection(candidate, using: central, logPrefix: "⏩ Queue connect") + } + } + + private func beginCentralConnection( + _ candidate: BLEConnectionCandidate, + using central: CBCentralManager, + logPrefix: String + ) { + guard delegate?.radioIsPanicSuspended() == false else { return } + let peripheral = candidate.peripheral + let peripheralID = candidate.peripheralID + linkStateStore.beginConnecting(to: peripheral, at: Date()) + peripheral.delegate = peripheralDelegate + let options: [String: Any] = [ + CBConnectPeripheralOptionNotifyOnConnectionKey: true, + CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, + CBConnectPeripheralOptionNotifyOnNotificationKey: true + ] + central.connect(peripheral, options: options) + scheduler.recordConnectionAttempt(at: Date()) + SecureLogger.debug("\(logPrefix): \(candidate.name) [RSSI:\(candidate.rssi)]", category: .session) + + queue.asyncAfter(deadline: .now() + TransportConfig.bleConnectTimeoutSeconds) { [weak self] in + guard let self, + let state = self.linkStateStore.state(forPeripheralID: peripheralID), + state.isConnecting && !state.isConnected else { return } + + guard peripheral.state != .connected else { + SecureLogger.debug("⏱️ Timeout fired but peripheral already connected: \(candidate.name)", category: .session) + return + } + + if self.delegate?.radioIsAppActive() == false { + // Backgrounded: leave the connect pending. iOS never expires + // it — the controller completes it whenever the peer comes + // back into range, waking the app (state restoration + // relaunches us if we were terminated). Foreground return + // cancels stale pendings via cancelStalePendingConnects(). + SecureLogger.info("🌙 Connect timeout deferred while backgrounded, left pending for wake-on-proximity: \(candidate.name)", category: .session) + return + } + + SecureLogger.debug("⏱️ Timeout: \(candidate.name)", category: .session) + central.cancelPeripheralConnection(peripheral) + self.delegate?.radioTearDownPeripheralLink(peripheralID) + self.scheduler.recordConnectionTimeout(peripheralID: peripheralID, at: Date()) + self.tryConnectFromQueue() + } + } + + // MARK: - Scheduler bookkeeping (called from the transport's delegates) + + var candidateCount: Int { scheduler.candidateCount } + + func recordConnectionSuccess(peripheralID: String) { + scheduler.recordConnectionSuccess(peripheralID: peripheralID) + } + + func recordConnectionFailure(peripheralID: String) { + scheduler.recordConnectionFailure(peripheralID: peripheralID) + } + + func recordDisconnectError(peripheralID: String, at date: Date) { + scheduler.recordDisconnectError(peripheralID: peripheralID, at: date) + } + + func recordRecentPeripheral(_ peripheral: CBPeripheral, peripheralID: String, at date: Date) { + recentPeripheralCache.record(peripheral, peripheralID: peripheralID, at: date) + } + + func pruneConnectionTimeouts(before cutoff: Date) { + scheduler.pruneConnectionTimeouts(before: cutoff) + } + + /// Panic wipe: drop the candidate queue, backoff state, and RSSI + /// adaptation with the identity they served. + func reset() { + scheduler.reset() + } + + #if os(iOS) + // MARK: - Background wake-on-proximity + + /// Backgrounding hands the freed connection budget to iOS as pending + /// connects against recently seen peers: the controller completes one + /// whenever its peer comes into range, waking (or relaunching) the app. + /// A couple of central slots stay reserved for connects driven by live + /// background discovery — except on the disconnect re-arm path, which + /// may consume the slot the disconnect itself just freed (a dense mesh + /// with 4+ remaining links would otherwise compute a zero budget and + /// never re-arm the lost peer). + func armPendingBackgroundConnects( + slotReserve: Int = TransportConfig.bleBackgroundPendingConnectSlotReserve + ) { + queue.async { [weak self] in + guard let self, + self.delegate?.radioIsPanicSuspended() == false, + let central = self.central, + central.state == .poweredOn else { return } + let budget = TransportConfig.bleMaxCentralLinks + - slotReserve + - self.linkStateStore.connectedOrConnectingPeripheralCount + let now = Date() + let targets = self.recentPeripheralCache.reconnectTargets(now: now, limit: budget) { peripheralID in + let state = self.linkStateStore.state(forPeripheralID: peripheralID) + return state?.isConnected == true || state?.isConnecting == true + } + guard !targets.isEmpty else { return } + for target in targets { + // lastConnectionAttempt stays nil: an indefinite pending + // connect has no attempt clock, and nil marks it always-stale + // so cancelStalePendingConnects() reclaims it on foreground + // even after a quick background→foreground bounce. + self.linkStateStore.setPeripheralState( + BLEPeripheralLinkState( + peripheral: target.peripheral, + characteristic: nil, + peerID: nil, + isConnecting: true, + isConnected: false, + lastConnectionAttempt: nil, + assembler: NotificationStreamAssembler() + ), + for: target.peripheralID + ) + target.peripheral.delegate = self.peripheralDelegate + central.connect(target.peripheral, options: [ + CBConnectPeripheralOptionNotifyOnConnectionKey: true, + CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, + CBConnectPeripheralOptionNotifyOnNotificationKey: true + ]) + } + SecureLogger.info("🌙 Armed \(targets.count) pending background connect(s) for wake-on-proximity", category: .session) + } + } + + /// Foreground restores normal connection management: pending connects + /// older than the connect timeout (including ones rebuilt by state + /// restoration after a relaunch) are cancelled so live scanning and the + /// scheduler take over. Anything still nearby is rediscovered within + /// seconds by the allow-duplicates foreground scan. + func cancelStalePendingConnects() { + queue.async { [weak self] in + guard let self, let central = self.central else { return } + let now = Date() + var cancelled = 0 + for state in self.linkStateStore.peripheralStates where state.isConnecting && !state.isConnected { + let age = state.lastConnectionAttempt.map { now.timeIntervalSince($0) } ?? .infinity + guard age > TransportConfig.bleConnectTimeoutSeconds else { continue } + let peripheralID = state.peripheral.identifier.uuidString + central.cancelPeripheralConnection(state.peripheral) + self.delegate?.radioTearDownPeripheralLink(peripheralID) + cancelled += 1 + } + if cancelled > 0 { + SecureLogger.info("🌅 Cancelled \(cancelled) stale pending connect(s) on foreground", category: .session) + self.tryConnectFromQueue() + } + } + } + #endif +} + + +// MARK: - Connection scheduling helpers + +private extension BLEExistingConnectionState { + init(_ state: BLEPeripheralLinkState) { + self.init( + isConnecting: state.isConnecting, + isConnected: state.isConnected, + lastConnectionAttempt: state.lastConnectionAttempt + ) + } +} + +private extension CBPeripheralState { + var connectionSchedulerState: BLEPeripheralConnectionState { + switch self { + case .connected: + return .connected + case .connecting: + return .connecting + case .disconnected, .disconnecting: + return .disconnected + @unknown default: + return .disconnected + } + } +} diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 488fb098..6b0dc932 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -459,18 +459,13 @@ final class BLEService: NSObject { /// churn that aggravates flaky exit hangs. private var meshBackgroundEnabled = false - // MARK: - Connection budget & scheduling (central role) - private var connectionScheduler = BLEConnectionScheduler() - // Recently seen peripherals retained for background wake-on-proximity - // connects (bleQueue-confined, like the link state store) - private let recentPeripheralCache = BLERecentPeripheralCache() - - // MARK: - Adaptive scanning duty-cycle - private var scanDutyTimer: DispatchSourceTimer? - private var dutyEnabled: Bool = true - private var dutyOnDuration: TimeInterval = TransportConfig.bleDutyOnDuration - private var dutyOffDuration: TimeInterval = TransportConfig.bleDutyOffDuration - private var dutyActive: Bool = false + // MARK: - Radio (central-role policy: discovery admission, connection + // budget, connect timeouts, background connects, scan duty, advertising) + private lazy var radio = BLERadioController( + queue: bleQueue, + linkStateStore: linkStateStore, + recentTraffic: recentTrafficTracker + ) // Debounced publish to coalesce rapid changes private var peerPublishCoalescer = BLEPeerPublishCoalescer() @@ -522,6 +517,8 @@ final class BLEService: NSObject { // Set queue key for identification messageQueue.setSpecific(key: messageQueueKey, value: ()) engineScheduler.activate(engineQueue: messageQueue) + radio.delegate = self + radio.peripheralDelegate = self // Set up application state tracking (iOS only) #if os(iOS) @@ -645,6 +642,7 @@ final class BLEService: NSObject { centralManager = CBCentralManager(delegate: self, queue: bleQueue) peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue) #endif + radio.central = centralManager } private func restartGossipManager() { @@ -698,8 +696,7 @@ final class BLEService: NSObject { deinit { maintenanceTimer?.cancel() - scanDutyTimer?.cancel() - scanDutyTimer = nil + radio.stopDutyCycle() centralManager?.stopScan() peripheralManager?.stopAdvertising() #if os(iOS) @@ -786,7 +783,7 @@ final class BLEService: NSObject { pendingWriteBuffers.removeAll() noiseAuthenticatedLinkOwners.removeAll() noiseReconnectPolicy.removeAll() - connectionScheduler.reset() + radio.reset() } disconnectNotifyDebouncer.removeAll() @@ -1008,8 +1005,7 @@ final class BLEService: NSObject { // Stop timer maintenanceTimer?.cancel() maintenanceTimer = nil - scanDutyTimer?.cancel() - scanDutyTimer = nil + radio.stopDutyCycle() centralManager?.stopScan() peripheralManager?.stopAdvertising() @@ -1031,8 +1027,7 @@ final class BLEService: NSObject { maintenanceTimer?.cancel() maintenanceTimer = nil - scanDutyTimer?.cancel() - scanDutyTimer = nil + radio.stopDutyCycle() centralManager?.stopScan() peripheralManager?.stopAdvertising() @@ -1080,7 +1075,7 @@ final class BLEService: NSObject { linkStateStore.clearAll() noiseAuthenticatedLinkOwners.removeAll() noiseReconnectPolicy.removeAll() - connectionScheduler.reset() + radio.reset() subscriptionAnnounceLimiter.removeAll() } meshTopology.reset() @@ -2988,7 +2983,7 @@ extension BLEService: CBCentralManagerDelegate { // nothing. Service rediscovery for restored-connected links waits // for poweredOn: CoreBluetooth drops commands issued during // restoration (API MISUSE warnings). - recentPeripheralCache.record(peripheral, peripheralID: identifier, at: Date()) + radio.recordRecentPeripheral(peripheral, peripheralID: identifier, at: Date()) } // Via the sampler (not a direct capture): it refreshes the cached @@ -2997,7 +2992,7 @@ extension BLEService: CBCentralManagerDelegate { logBluetoothStatus("central-restore") if central.state == .poweredOn { - startScanning() + radio.startScanning() } } #endif @@ -3023,7 +3018,7 @@ extension BLEService: CBCentralManagerDelegate { } // Start scanning - use allow duplicates for faster discovery when active - startScanning() + radio.startScanning() case .poweredOff: // CoreBluetooth has already transitioned out of poweredOn. Do @@ -3070,70 +3065,11 @@ extension BLEService: CBCentralManagerDelegate { } } - private func startScanning() { - guard !isPanicSuspended, - let central = centralManager, - central.state == .poweredOn, - !central.isScanning else { return } - - // Use allow duplicates = true for faster discovery in foreground - // This gives us discovery events immediately instead of coalesced - #if os(iOS) - let allowDuplicates = isAppActive // Use our tracked state (thread-safe) - #else - let allowDuplicates = true // macOS doesn't have background restrictions - #endif - - central.scanForPeripherals( - withServices: [BLEService.serviceUUID], - options: [CBCentralManagerScanOptionAllowDuplicatesKey: allowDuplicates] - ) - - // Started BLE scanning - } func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { - guard !isPanicSuspended else { return } - let peripheralID = peripheral.identifier.uuidString - let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? (peripheralID.prefix(6) + "…") - let isConnectable = (advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? true - let rssiValue = RSSI.intValue - - let candidate = BLEConnectionCandidate( - peripheral: peripheral, - peripheralID: peripheralID, - rssi: rssiValue, - name: String(advertisedName), - isConnectable: isConnectable, - discoveredAt: Date() - ) - if isConnectable { - recentPeripheralCache.record(peripheral, peripheralID: peripheralID, at: candidate.discoveredAt) - } - let existingState = linkStateStore.state(forPeripheralID: peripheralID).map(BLEExistingConnectionState.init) - - switch connectionScheduler.handleDiscovery( - candidate, - connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount, - existingState: existingState, - peripheralState: peripheral.state.connectionSchedulerState, - now: candidate.discoveredAt - ) { - case .ignore, .queued: - return - case .scheduleRetry(let delay): - bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in - self?.tryConnectFromQueue() - } - return - case .cancelStaleConnection: - central.cancelPeripheralConnection(peripheral) - return - case .connectNow: - beginCentralConnection(candidate, using: central, logPrefix: "📱 Connect") - } + radio.handleDiscovery(peripheral, advertisementData: advertisementData, rssi: RSSI) } - + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { guard !isPanicSuspended else { central.cancelPeripheralConnection(peripheral) @@ -3153,7 +3089,7 @@ extension BLEService: CBCentralManagerDelegate { linkStateStore.markConnected(peripheral) // Reset backoff state on success - connectionScheduler.recordConnectionSuccess(peripheralID: peripheralID) + radio.recordConnectionSuccess(peripheralID: peripheralID) SecureLogger.debug("✅ Connected: \(peripheral.name ?? "Unknown") [\(peripheralID)]", category: .session) @@ -3171,12 +3107,12 @@ extension BLEService: CBCentralManagerDelegate { // If disconnect carried an error (often timeout), apply short backoff to avoid thrash if error != nil { - connectionScheduler.recordDisconnectError(peripheralID: peripheralID, at: Date()) + radio.recordDisconnectError(peripheralID: peripheralID, at: Date()) } // Retain the handle: a dropped link is the best wake-on-proximity // candidate if the app backgrounds before the peer returns. - recentPeripheralCache.record(peripheral, peripheralID: peripheralID, at: Date()) + radio.recordRecentPeripheral(peripheral, peripheralID: peripheralID, at: Date()) #if os(iOS) // Link lost while backgrounded (peer walked away): re-arm a pending @@ -3188,16 +3124,13 @@ extension BLEService: CBCentralManagerDelegate { guard let self, !self.isAppActive else { return } // Reserve 0: use the slot this disconnect freed even in a // dense mesh, so the lost peer can wake us when it returns. - self.armPendingBackgroundConnects(slotReserve: 0) + self.radio.armPendingBackgroundConnects(slotReserve: 0) } } #endif // Clean up references and peer mappings - pendingPeripheralWrites.discardAll(for: peripheralID) - noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) - noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) - _ = linkStateStore.removePeripheral(peripheralID) + tearDownPeripheralLink(peripheralID) // A duplicate link can drop while the peer stays live on another // (the dual-role central link, or a second bound link after a // restore): peer-disconnect bookkeeping only runs once the peer's @@ -3220,11 +3153,11 @@ extension BLEService: CBCentralManagerDelegate { // Stop and restart scanning to ensure we get fresh discovery events centralManager?.stopScan() bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleRestartScanDelaySeconds) { [weak self] in - self?.startScanning() + self?.radio.startScanning() } } // Attempt to fill freed slot from queue - bleQueue.async { [weak self] in self?.tryConnectFromQueue() } + bleQueue.async { [weak self] in self?.radio.tryConnectFromQueue() } // Notify delegate about disconnection on main thread (direct link dropped) notifyUI { [weak self] in @@ -3245,119 +3178,46 @@ extension BLEService: CBCentralManagerDelegate { let peripheralID = peripheral.identifier.uuidString // Clean up the references - pendingPeripheralWrites.discardAll(for: peripheralID) - noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) - noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) - _ = linkStateStore.removePeripheral(peripheralID) + tearDownPeripheralLink(peripheralID) SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session) - connectionScheduler.recordConnectionFailure(peripheralID: peripheralID) + radio.recordConnectionFailure(peripheralID: peripheralID) // Try next candidate - bleQueue.async { [weak self] in self?.tryConnectFromQueue() } - } -} - -// MARK: - Connection scheduling helpers -private extension BLEExistingConnectionState { - init(_ state: BLEPeripheralLinkState) { - self.init( - isConnecting: state.isConnecting, - isConnected: state.isConnected, - lastConnectionAttempt: state.lastConnectionAttempt - ) - } -} - -private extension CBPeripheralState { - var connectionSchedulerState: BLEPeripheralConnectionState { - switch self { - case .connected: - return .connected - case .connecting: - return .connecting - case .disconnected, .disconnecting: - return .disconnected - @unknown default: - return .disconnected - } + bleQueue.async { [weak self] in self?.radio.tryConnectFromQueue() } } } extension BLEService { - private func tryConnectFromQueue() { - guard !isPanicSuspended, - let central = centralManager, - central.state == .poweredOn else { return } +} - let decision = connectionScheduler.nextCandidate( - connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount, - isAlreadyConnectingOrConnected: { [linkStateStore] peripheralID in - let state = linkStateStore.state(forPeripheralID: peripheralID) - return state?.isConnected == true || state?.isConnecting == true - }, - now: Date() - ) +// MARK: - Radio controller integration - switch decision { - case .none: - return - case .retryAfter(let delay): - bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in self?.tryConnectFromQueue() } - case .connect(let candidate): - beginCentralConnection(candidate, using: central, logPrefix: "⏩ Queue connect") - } +extension BLEService: BLERadioControllerDelegate { + func radioIsPanicSuspended() -> Bool { + isPanicSuspended } - private func beginCentralConnection( - _ candidate: BLEConnectionCandidate, - using central: CBCentralManager, - logPrefix: String - ) { - guard !isPanicSuspended else { return } - let peripheral = candidate.peripheral - let peripheralID = candidate.peripheralID - linkStateStore.beginConnecting(to: peripheral, at: Date()) - peripheral.delegate = self - let options: [String: Any] = [ - CBConnectPeripheralOptionNotifyOnConnectionKey: true, - CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, - CBConnectPeripheralOptionNotifyOnNotificationKey: true - ] - central.connect(peripheral, options: options) - connectionScheduler.recordConnectionAttempt(at: Date()) - SecureLogger.debug("\(logPrefix): \(candidate.name) [RSSI:\(candidate.rssi)]", category: .session) + func radioIsAppActive() -> Bool { + #if os(iOS) + return isAppActive + #else + return true + #endif + } - bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleConnectTimeoutSeconds) { [weak self] in - guard let self = self, - let state = self.linkStateStore.state(forPeripheralID: peripheralID), - state.isConnecting && !state.isConnected else { return } + func radioTearDownPeripheralLink(_ peripheralID: String) { + tearDownPeripheralLink(peripheralID) + } - guard peripheral.state != .connected else { - SecureLogger.debug("⏱️ Timeout fired but peripheral already connected: \(candidate.name)", category: .session) - return - } - - #if os(iOS) - if !self.isAppActive { - // Backgrounded: leave the connect pending. iOS never expires - // it — the controller completes it whenever the peer comes - // back into range, waking the app (state restoration relaunches - // us if we were terminated). Foreground return cancels stale - // pendings via cancelStalePendingConnects(). - SecureLogger.info("🌙 Connect timeout deferred while backgrounded, left pending for wake-on-proximity: \(candidate.name)", category: .session) - return - } - #endif - - SecureLogger.debug("⏱️ Timeout: \(candidate.name)", category: .session) - central.cancelPeripheralConnection(peripheral) - self.pendingPeripheralWrites.discardAll(for: peripheralID) - self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) - self.noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) - _ = self.linkStateStore.removePeripheral(peripheralID) - self.connectionScheduler.recordConnectionTimeout(peripheralID: peripheralID, at: Date()) - self.tryConnectFromQueue() - } + /// Retires one peripheral link's transport bookkeeping: its write + /// backpressure, its Noise link proof and reconnect epoch, and the + /// link-state entry (which repairs the peer's reverse mapping onto a + /// surviving duplicate link). bleQueue-confined. + func tearDownPeripheralLink(_ peripheralID: String) { + pendingPeripheralWrites.discardAll(for: peripheralID) + noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) + noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) + _ = linkStateStore.removePeripheral(peripheralID) } } @@ -4042,7 +3902,7 @@ extension BLEService: CBPeripheralManagerDelegate { logBluetoothStatus("peripheral-restore") if peripheral.state == .poweredOn && !peripheral.isAdvertising { - peripheral.startAdvertising(buildAdvertisementData()) + peripheral.startAdvertising(BLERadioController.advertisementData()) } } #endif @@ -4060,7 +3920,7 @@ extension BLEService: CBPeripheralManagerDelegate { SecureLogger.debug("✅ Service added successfully, starting advertising", category: .session) // Start advertising after service is confirmed added - let adData = buildAdvertisementData() + let adData = BLERadioController.advertisementData() peripheral.startAdvertising(adData) SecureLogger.debug("📡 Started advertising (LocalName: \((adData[CBAdvertisementDataLocalNameKey] as? String) != nil ? "on" : "off"), ID: \(myPeerID.id.prefix(8))…)", category: .session) @@ -4110,7 +3970,7 @@ extension BLEService: CBPeripheralManagerDelegate { // Ensure we're still advertising for other devices to find us if !isPanicSuspended, peripheral.isAdvertising == false { SecureLogger.debug("📡 Restarting advertising after central unsubscribed", category: .session) - peripheral.startAdvertising(buildAdvertisementData()) + peripheral.startAdvertising(BLERadioController.advertisementData()) } // Find and disconnect the peer associated with this central @@ -4297,15 +4157,7 @@ extension BLEService: CBPeripheralManagerDelegate { // MARK: - Advertising Builders & Alias Rotation extension BLEService { - private func buildAdvertisementData() -> [String: Any] { - let data: [String: Any] = [ - CBAdvertisementDataServiceUUIDsKey: [BLEService.serviceUUID] - ] - // No Local Name for privacy - return data - } - - // No alias rotation or advertising restarts required. + // Advertising payload and alias policy live on BLERadioController. } // MARK: - Private Media Deletion @@ -4542,11 +4394,12 @@ extension BLEService { let peripheralState = peripheralManager?.state ?? .unknown let isAdvertising = peripheralManager?.isAdvertising ?? false + let candidateCount = radio.candidateCount let peerSummary = peerRegistry.read { ( connected: $0.connectedCount, known: $0.count, - candidates: connectionScheduler.candidateCount + candidates: candidateCount ) } @@ -6094,9 +5947,9 @@ extension BLEService { // Restart scanning with allow duplicates when app becomes active if centralManager?.state == .poweredOn { centralManager?.stopScan() - startScanning() + radio.startScanning() } - cancelStalePendingConnects() + radio.cancelStalePendingConnects() logBluetoothStatus("became-active") scheduleBluetoothStatusSample(after: 5.0, context: "active-5s") // No Local Name; nothing to refresh for advertising policy @@ -6108,9 +5961,9 @@ extension BLEService { // Restart scanning without allow duplicates in background if centralManager?.state == .poweredOn { centralManager?.stopScan() - startScanning() + radio.startScanning() } - armPendingBackgroundConnects() + radio.armPendingBackgroundConnects() // Backgrounding may precede a kill; flush the public-history archive // outside its 30s maintenance cadence. gossipSyncManager?.persistNow() @@ -6118,87 +5971,6 @@ extension BLEService { scheduleBluetoothStatusSample(after: 15.0, context: "background-15s") // No Local Name; nothing to refresh for advertising policy } - - /// Issue indefinite `connect()` requests to recently seen peripherals on - /// backgrounding. Pending connects live in the Bluetooth controller's - /// allowlist — no scanning and no app CPU — and complete whenever a peer - /// comes into range, waking (or relaunching) the app. A couple of central - /// slots stay reserved for connects driven by live background discovery — - /// except on the disconnect re-arm path, which may consume the slot the - /// disconnect itself just freed (a dense mesh with 4+ remaining links - /// would otherwise compute a zero budget and never re-arm the lost peer). - private func armPendingBackgroundConnects( - slotReserve: Int = TransportConfig.bleBackgroundPendingConnectSlotReserve - ) { - bleQueue.async { [weak self] in - guard let self, - !self.isPanicSuspended, - let central = self.centralManager, - central.state == .poweredOn else { return } - let budget = TransportConfig.bleMaxCentralLinks - - slotReserve - - self.linkStateStore.connectedOrConnectingPeripheralCount - let now = Date() - let targets = self.recentPeripheralCache.reconnectTargets(now: now, limit: budget) { peripheralID in - let state = self.linkStateStore.state(forPeripheralID: peripheralID) - return state?.isConnected == true || state?.isConnecting == true - } - guard !targets.isEmpty else { return } - for target in targets { - // lastConnectionAttempt stays nil: an indefinite pending connect - // has no attempt clock, and nil marks it always-stale so - // cancelStalePendingConnects() reclaims it on foreground even - // after a quick background→foreground bounce. - self.linkStateStore.setPeripheralState( - BLEPeripheralLinkState( - peripheral: target.peripheral, - characteristic: nil, - peerID: nil, - isConnecting: true, - isConnected: false, - lastConnectionAttempt: nil, - assembler: NotificationStreamAssembler() - ), - for: target.peripheralID - ) - target.peripheral.delegate = self - central.connect(target.peripheral, options: [ - CBConnectPeripheralOptionNotifyOnConnectionKey: true, - CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, - CBConnectPeripheralOptionNotifyOnNotificationKey: true - ]) - } - SecureLogger.info("🌙 Armed \(targets.count) pending background connect(s) for wake-on-proximity", category: .session) - } - } - - /// Foreground restores normal connection management: pending connects - /// older than the connect timeout (including ones rebuilt by state - /// restoration after a relaunch) are cancelled so live scanning and the - /// scheduler take over. Anything still nearby is rediscovered within - /// seconds by the allow-duplicates foreground scan. - private func cancelStalePendingConnects() { - bleQueue.async { [weak self] in - guard let self, let central = self.centralManager else { return } - let now = Date() - var cancelled = 0 - for state in self.linkStateStore.peripheralStates where state.isConnecting && !state.isConnected { - let age = state.lastConnectionAttempt.map { now.timeIntervalSince($0) } ?? .infinity - guard age > TransportConfig.bleConnectTimeoutSeconds else { continue } - let peripheralID = state.peripheral.identifier.uuidString - central.cancelPeripheralConnection(state.peripheral) - self.pendingPeripheralWrites.discardAll(for: peripheralID) - self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) - self.noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) - _ = self.linkStateStore.removePeripheral(peripheralID) - cancelled += 1 - } - if cancelled > 0 { - SecureLogger.info("🌅 Cancelled \(cancelled) stale pending connect(s) on foreground", category: .session) - self.tryConnectFromQueue() - } - } - } #endif // MARK: Private Message Handling @@ -6505,7 +6277,7 @@ extension BLEService { let totalFragments = plan.totalFragments let expectedMs = min(TransportConfig.bleExpectedWriteMaxMs, totalFragments * TransportConfig.bleExpectedWritePerFragmentMs) self.bleQueue.asyncAfter(deadline: .now() + .milliseconds(expectedMs)) { [weak self] in - self?.startScanning() + self?.radio.startScanning() } } } @@ -7115,10 +6887,7 @@ extension BLEService { ) for uuid in retiring { guard let state = linkStateStore.state(forPeripheralID: uuid) else { continue } - pendingPeripheralWrites.discardAll(for: uuid) - noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(uuid)) - noiseReconnectPolicy.endLinkEpoch(.peripheral(uuid)) - _ = linkStateStore.removePeripheral(uuid) + tearDownPeripheralLink(uuid) SecureLogger.info( "🔗 Retiring redundant link \(uuid.prefix(8))… bound to \(peerID.id.prefix(8))…\(keptUUID.map { " (keeping \($0.prefix(8))…)" } ?? "")", category: .session @@ -7765,20 +7534,20 @@ extension BLEService { if plan.shouldEnsureAdvertising { // Ensure we're advertising as peripheral if let pm = peripheralManager, pm.state == .poweredOn && !pm.isAdvertising { - pm.startAdvertising(buildAdvertisementData()) + pm.startAdvertising(BLERadioController.advertisementData()) } } // Update scanning duty-cycle based on connectivity - updateScanningDutyCycle(connectedCount: connectedCount) - updateRSSIThreshold(connectedCount: connectedCount) + radio.updateScanningDutyCycle(connectedCount: connectedCount) + radio.updateRSSIThreshold(connectedCount: connectedCount) // Drain the connection candidate queue. Weak-RSSI discoveries are // enqueued rather than connected immediately, and the event-driven // drains (disconnect/failure/timeout) never fire when we're idle — // without this, an isolated node surrounded only by weak (distant) // peers would queue them all and never connect to anyone. - tryConnectFromQueue() + radio.tryConnectFromQueue() // Check peer connectivity every cycle for snappier UI updates checkPeerConnectivity() @@ -7893,7 +7662,7 @@ extension BLEService { // Clean old connection timeout backoff entries (> window) let timeoutCutoff = now.addingTimeInterval(-TransportConfig.bleConnectTimeoutBackoffWindowSeconds) - connectionScheduler.pruneConnectionTimeouts(before: timeoutCutoff) + radio.pruneConnectionTimeouts(before: timeoutCutoff) // Clean up stale scheduled relays that somehow persisted (> 2s) messageQueue.async { [weak self] in @@ -7924,72 +7693,4 @@ extension BLEService { } } - private func updateScanningDutyCycle(connectedCount: Int) { - guard let central = centralManager, central.state == .poweredOn else { return } - // Duty cycle only when app is active and at least one peer connected - #if os(iOS) - let active = isAppActive - #else - let active = true - #endif - // Force full-time scanning if we have very few neighbors or very recent traffic - let hasRecentTraffic = recentTrafficTracker.hasTraffic( - within: TransportConfig.bleRecentTrafficForceScanSeconds, - now: Date() - ) - let scanPlan = BLEScanDutyPolicy.plan( - dutyEnabled: dutyEnabled, - appIsActive: active, - connectedCount: connectedCount, - hasRecentTraffic: hasRecentTraffic - ) - - switch scanPlan { - case .dutyCycle(let onDuration, let offDuration): - let durationsChanged = dutyOnDuration != onDuration || dutyOffDuration != offDuration - dutyOnDuration = onDuration - dutyOffDuration = offDuration - - if scanDutyTimer == nil { - // Start timer to toggle scanning on/off - let t = DispatchSource.makeTimerSource(queue: bleQueue) - // Start with scanning ON; we'll turn OFF after onDuration - if !central.isScanning { startScanning() } - dutyActive = true - t.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration) - t.setEventHandler { [weak self] in - guard let self = self, let c = self.centralManager else { return } - if self.dutyActive { - // Turn OFF scanning for offDuration - if c.isScanning { c.stopScan() } - self.dutyActive = false - // Schedule turning back ON after offDuration - self.bleQueue.asyncAfter(deadline: .now() + self.dutyOffDuration) { - if self.centralManager?.state == .poweredOn { self.startScanning() } - self.dutyActive = true - } - } - } - t.resume() - scanDutyTimer = t - } else if durationsChanged { - scanDutyTimer?.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration) - if !central.isScanning { startScanning() } - dutyActive = true - } - case .continuous: - // Cancel duty cycle and ensure scanning is ON for discovery - scanDutyTimer?.cancel() - scanDutyTimer = nil - if !central.isScanning { startScanning() } - } - } - - private func updateRSSIThreshold(connectedCount: Int) { - connectionScheduler.updateRSSIThreshold( - connectedCount: connectedCount, - connectedOrConnectingLinkCount: linkStateStore.connectedOrConnectingPeripheralCount, - now: Date() - ) - } }