Extract the central-role radio policy into BLERadioController (#1539)

First slice of the link layer: discovery admission, the connection
budget and queue, connect timeouts, wake-on-proximity background
connects, scan duty-cycling, RSSI adaptation, and the advertising
payload move out of BLEService into a bleQueue-confined controller
(~400 lines). It makes no peer decisions and owns no bindings or
security state: it shares the bleQueue-confined link-state store for
admission reads, and when a connect attempt dies it asks its delegate
to retire the transport bookkeeping — which also factors the
four-times-repeated teardown sequence (write backpressure, link-auth
proof, reconnect epoch, link-state entry) into one
tearDownPeripheralLink helper.

The three-method delegate (panic suspended, app active, tear down) is
the radio's entire dependency on the transport; the CoreBluetooth
delegate methods in BLEService shrink toward pure event forwarding
ahead of the LinkEvent/LinkCommand port.

candidateCount joins the Periphery baseline like the rest of the
status-capture path: its only callers are iOS-gated, invisible to the
macOS scheme scan.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack 2026-07-29 16:45:49 +01:00 committed by GitHub
parent d39467f7d3
commit 2c22b117b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 484 additions and 371 deletions

View File

@ -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"]}}
{"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"]}}

View File

@ -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<CBPeripheral>()
// Recently seen peripherals retained for background wake-on-proximity
// connects
private let recentPeripheralCache = BLERecentPeripheralCache<CBPeripheral>()
// 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<CBPeripheral>,
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 backgroundforeground 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
}
}
}

View File

@ -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<CBPeripheral>()
// Recently seen peripherals retained for background wake-on-proximity
// connects (bleQueue-confined, like the link state store)
private let recentPeripheralCache = BLERecentPeripheralCache<CBPeripheral>()
// 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<CBPeripheral>,
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 backgroundforeground 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()
)
}
}