How BLEService Manages Simultaneous Bluetooth Peripheral and Central Roles in Bitchat
BLEService runs both Bluetooth Low Energy roles concurrently by maintaining separate CoreBluetooth managers on a dedicated serial queue, consolidating all link state in a unified store, and routing decoded packets through a single engine queue.
The BLEService class in permissionlesstech/bitchat is the core mesh transport component that enables a single iOS device to act as both a BLE peripheral (advertising, accepting connections) and a BLE central (scanning, initiating connections) simultaneously. This dual-role architecture is essential for building a peer-to-peer mesh network where every node can both broadcast and receive messages. This article explains how BLEService implements simultaneous Bluetooth peripheral and central roles without race conditions or resource contention.
Core Architecture: Separate Managers, Unified State
BLEService maintains two distinct CoreBluetooth objects that coexist on the same process:
CBCentralManager— handles scanning for peripherals and initiating connections (central role)CBPeripheralManager— handles advertising services and managing subscriptions (peripheral role)
Both managers are created on the same serial dispatch queue (bleQueue) defined in [BLEService.swift](https://github.com/permissionlesstech/bitchat/blob/main/bitchat/Services/BLE/BLEService.swift) at lines 71–78. This queue serialization guarantees that peripheral and central callback events never race against each other:
// BLEService.swift (lines 71-78)
private lazy var bleQueue: DispatchQueue = {
return DispatchQueue(label: "com.permissionlesstech.bitchat.ble", qos: .userInitiated)
}()
private lazy var messageQueue: DispatchQueue = {
return DispatchQueue(label: "com.permissionlesstech.bitchat.engine", qos: .userInitiated)
}()
The messageQueue (also called the "engine queue") owns the mesh protocol state and consumes higher-level events emitted from bleQueue. This two-queue design separates Bluetooth hardware events from protocol logic, preventing deadlock while maintaining determinism.
Link State Store: Treating All Connections Uniformly
All physical links—whether originated as a peripheral connection (central role) or accepted as a subscribed central (peripheral role)—are stored in a single BLELinkStateStore:
// BLEService.swift (lines 106-108)
private let linkStateStore = BLELinkStateStore()
This unified store records:
- Peripheral-side connections: centrals that have subscribed to the local characteristic
- Central-side connections: peripherals the local device has connected to
By treating both connection types identically, the mesh engine can route packets without knowing which role originated the link. The linkStateStore property appears at lines 106–108 in BLEService.swift.
Radio Controller: Shared Admission Policies
The BLERadioController (instantiated as the radio lazy property at lines 76–80) implements admission policies common to both roles:
- Discovery throttling
- Connection-slot budgeting
- Background-connect back-off
- Duty-cycle control
This controller is consulted from both the central-side callbacks (e.g., didDiscover) and the peripheral-side callbacks (e.g., didAdd service), ensuring consistent resource management regardless of role.
// BLEService.swift (lines 76-80)
private lazy var radio: BLERadioController = {
return BLERadioController(queue: bleQueue, config: radioConfig)
}()
Peripheral Role Implementation
The peripheral role logic lives in BLEService+LinkLayerPeripheralRole.swift. Key responsibilities include:
State handling: When peripheralManagerDidUpdateState reports .poweredOn, the service creates the characteristic, adds the service, and starts advertising.
Subscription tracking: didSubscribeTo and didUnsubscribeFrom update linkStateStore.addSubscribedCentral and trigger a rate-limited announce to new centrals.
Outbound back-pressure: Packets are queued in BLEOutboundNotificationBuffer. The peripheralManagerIsReady(toUpdateSubscribers:) callback drains this buffer, applying back-pressure detection to prevent buffer overruns.
Central Role Implementation
The central role logic lives in BLEService+LinkLayerCentralRole.swift. Key responsibilities include:
Power management: centralManagerDidUpdateState starts scanning when powered on and tears down the peripheral-link store on power-off.
Discovery: didDiscover forwards advertisements to radio.handleDiscovery for admission control.
Connection lifecycle:
didConnectmarks links as connected, discovers services, and records successful connections in the radio controllerdidDisconnectPeripheralrecords disconnects, releases slots, and restarts scanning
Inbound data: didUpdateValueFor buffers fragments, assembles them with NotificationStreamAssembler, and emits decoded frames via emitLinkEvent.
Event Routing to the Engine
Both role extensions call emitLinkEvent(.frameDecoded(...)) (or other BLELinkEvent variants). The engine queue picks these up, performs authentication and deduplication, and forwards the resulting BitchatPacket to the app via BitchatDelegate. This single entry point simplifies the higher-level protocol logic.
Practical Usage Examples
Starting the Dual-Role Transport
let bleService = BLEService(
keychain: KeychainManager(),
idBridge: NostrIdentityBridge(),
identityManager: SecureIdentityStateManager()
)
bleService.startServices() // Starts advertising AND scanning
The startServices() call forces both CBCentralManager and CBPeripheralManager instantiation via initializeBluetoothManagersIfNeeded() (lines 526–531 in BLEService.swift).
Sending a Public Broadcast
bleService.sendMessage(
"Hello mesh!",
mentions: [],
to: nil, // nil → public broadcast
messageID: "msg-001",
timestamp: Date()
)
This builds a BitchatPacket, signs it with the noise service, marks it as self-broadcast (to prevent re-processing), and calls broadcastPacket(_:) which writes to all connected peripherals and subscribed centrals (lines 845–886).
Updating Identity Across Both Roles
bleService.setNickname("NewName")
The setNickname method (lines 1187–1192) updates the local identity store and forces an announce. This announce transmits over both active central links and any newly-subscribed centrals (peripheral role), ensuring identity propagation regardless of connection direction.
Graceful Shutdown
bleService.stopServices()
stopServices (lines 1010–1045) sends a signed leave packet, drains pending notifications, cancels the maintenance timer, stops scanning and advertising, and disconnects all peripherals cleanly.
Safety and Isolation Guarantees
All public APIs (sendMessage, setNickname, startServices, stopServices, etc.) schedule work on the appropriate queue and never block the UI. The service also guards against panic-recovery states via isPanicSuspended — a crash-reset will not leave the Bluetooth stacks in an inconsistent state.
Summary
- Dual CoreBluetooth managers (
CBCentralManager+CBPeripheralManager) on a single serial queue prevent race conditions - Unified
BLELinkStateStoretreats peripheral and central links identically for routing simplicity BLERadioControllerenforces shared admission policies across both roles- Separate
bleQueueandmessageQueueisolate hardware events from protocol logic - Rate-limited announces and back-pressure buffers ensure stable performance under load
Frequently Asked Questions
Can BLEService run both roles on any iOS device?
Yes. BLEService uses standard CoreBluetooth APIs available on all iOS devices that support Bluetooth Low Energy. The dual-role capability is implemented entirely in software—no special hardware is required. iOS allows simultaneous central and peripheral operation, though practical limits apply to connection count and advertising intervals.
What happens if Bluetooth is turned off while BLEService is running?
Both CBCentralManager and CBPeripheralManager emit state update callbacks. The central role tears down the peripheral-link store on power-off, and the peripheral role stops advertising. The service enters a suspended state and will automatically restart both roles when Bluetooth powers back on, as handled by centralManagerDidUpdateState and peripheralManagerDidUpdateState.
How does BLEService prevent the two roles from interfering with each other?
All CoreBluetooth callbacks execute on bleQueue, a serial dispatch queue. This serialization guarantees that peripheral events (subscriptions, ready-to-update signals) and central events (discoveries, connections, disconnections) are processed atomically with respect to each other. The BLERadioController provides additional coordination for radio resource allocation.
What is the maximum number of simultaneous connections?
The practical limit depends on iOS Bluetooth stack constraints and the BLERadioController connection-slot budgeting. The radio controller actively manages available slots and applies back-off strategies for background connections. Exact limits are configurable via radioConfig passed to BLERadioController initialization.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →