How BitChat's BLE Peer Discovery Protocol Works: CoreBluetooth Implementation Guide
BitChat discovers nearby peers using a layered BLE transport that separates the CoreBluetooth radio from the mesh engine, employing service UUID filtering, RSSI-based candidate evaluation, and a budget-aware scheduler to manage connections.
The BitChat iOS application, hosted in the permissionlesstech/bitchat repository, implements a custom Bluetooth Low Energy (BLE) peer discovery protocol to locate and connect with nearby devices without internet infrastructure. This protocol functions through a strict architectural separation between the radio link layer and the mesh engine, enabling efficient background scanning while preventing radio deadlocks.
BLE Radio Initialization and Scanning
The discovery process begins when the BLEService initializes the CoreBluetooth central manager. Once the Bluetooth radio powers on, the service transitions into scanning mode by invoking BLERadioController.startScanning().
In BLEService+LinkLayerCentralRole.swift, the centralManagerDidUpdateState delegate method monitors the radio state and triggers scanning immediately upon reaching .poweredOn status:
// BLEService+LinkLayerCentralRole.swift#L84-L100
func centralManagerDidUpdateState(_ central: CBCentralManager) {
if central.state == .poweredOn {
radio.startScanning()
}
}
The startScanning() implementation in BLERadioController.swift configures the CBCentralManager to scan for peripherals advertising the BitChat service UUID. It explicitly enables allow-duplicates scanning to ensure rapid discovery of peers even if they advertise frequently:
// BLERadioController.swift#L73-L85
func startScanning() {
centralManager.scanForPeripherals(
withServices: [BLEService.serviceUUID],
options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
)
}
Advertisement Reception and Candidate Creation
When the central manager discovers a peripheral advertising the target service UUID, CoreBluetooth delivers the advertisement through the centralManager(_:didDiscover:advertisementData:rssi:) delegate method.
This method immediately forwards the discovery data to the radio controller without processing logic:
// BLEService+LinkLayerCentralRole.swift#L39-L41
func centralManager(_ central: CBCentralManager,
didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any],
rssi RSSI: NSNumber) {
radio.handleDiscovery(peripheral, advertisementData: advertisementData, rssi: RSSI)
}
The BLERadioController.handleDiscovery method constructs a BLEConnectionCandidate struct encapsulating the peripheral's identity, signal strength, name, connectable status, and timestamp. This candidate object serves as the unit of work for the connection scheduler:
// BLERadioController.swift#L57-L74
struct BLEConnectionCandidate {
let peripheral: CBPeripheral
let rssi: Int
let name: String?
let isConnectable: Bool
let timestamp: Date
}
func handleDiscovery(_ peripheral: CBPeripheral,
advertisementData: [String: Any],
rssi: NSNumber) {
let candidate = BLEConnectionCandidate(
peripheral: peripheral,
rssi: rssi.intValue,
name: advertisementData[CBAdvertisementDataLocalNameKey] as? String,
isConnectable: advertisementData[CBAdvertisementDataIsConnectable] as? Bool ?? false,
timestamp: Date()
)
// Pass to scheduler...
}
Connection Scheduling and Budget Enforcement
BitChat's BLE peer discovery protocol implements intelligent connection throttling through the BLEConnectionScheduler. When handleDiscovery receives a candidate, it delegates the connection decision to BLEConnectionScheduler.handleDiscovery, which evaluates multiple policy constraints:
- Connection budget: Current count of active BLE links versus maximum allowed concurrent connections
- State deduplication: Whether the peripheral already exists in the connection pool
- RSSI thresholds: Minimum signal strength requirements for reliable mesh participation
- Traffic patterns: Recent connection attempt history to prevent connection flapping
The scheduler returns an enum value that determines the candidate's fate. In BLERadioController.swift, a switch statement handles these outcomes:
// BLERadioController.swift#L80-L100
switch scheduler.handleDiscovery(candidate) {
case .ignore:
return // Peripheral filtered by policy
case .retryLater:
scheduleRetry(for: candidate) // Queue for future attempt
case .cancelStale:
cancelExistingConnection(to: candidate.peripheral)
case .connectNow:
beginCentralConnection(candidate) // Initiate BLE connection
}
Connection Establishment and Service Discovery
When the scheduler returns .connectNow, BLERadioController.beginCentralConnection initiates the low-level BLE connection by calling CBCentralManager.connect(peripheral). This transitions the peripheral from the discovered state to the connecting state.
Upon successful connection, CoreBluetooth invokes centralManager(_:didConnect:) in the link layer delegate. This callback updates the internal BLELinkStateStore and immediately triggers GATT service discovery to locate the BitChat characteristic:
// BLEService+LinkLayerCentralRole.swift#L62-L68
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
linkStateStore.updateState(for: peripheral, to: .connected)
peripheral.discoverServices([BLEService.serviceUUID])
}
The subsequent characteristic discovery transforms the raw BLE connection into a writable mesh link. Once the service and characteristic UUIDs are verified, the link layer emits a LinkEvent to the mesh engine via the emitLinkEvent port, signaling that the peer is ready for Noise handshakes and packet transmission. This architectural separation is documented in docs/BLE-ARCHITECTURE-V3.md (lines 31-36), which defines the BLELinkLayer responsibilities versus the mesh engine duties.
Background Wake-on-Proximity
BitChat maintains a recent-peripheral cache to support background operation. When the application enters the background, disconnected peripherals remain cached with timestamps. If a cached peripheral returns to proximity and begins advertising, the protocol consults this cache to re-arm pending connections after a short ignore window expires.
This mechanism allows iOS to wake the application via Bluetooth background modes when previously-seen peers reappear, without requiring constant foreground scanning. The logic resides in the state update handlers within BLEService+LinkLayerCentralRole.swift around lines 84-96.
Practical Implementation Examples
The following snippets demonstrate the public entry points for interacting with BitChat's BLE peer discovery protocol:
Initiating scan mode:
// Called from UI or app launch lifecycle
bleService.startScanning() // Internally invokes BLERadioController.startScanning()
Manual discovery injection (useful for unit testing):
let peripheral: CBPeripheral = // ... obtained from CoreBluetooth
let advData: [String: Any] = [
CBAdvertisementDataServiceUUIDsKey: [BLEService.serviceUUID],
CBAdvertisementDataLocalNameKey: "BitChat-Demo"
]
let rssi: NSNumber = -45
// Manually feed discovery to bypass actual radio
bleRadioController.handleDiscovery(peripheral,
advertisementData: advData,
rssi: rssi)
Post-connection service discovery (handled automatically by the SDK):
// This occurs internally after centralManager(_:didConnect:)
peripheral.discoverServices([BLEService.serviceUUID])
// Followed by characteristic discovery once services are found
Summary
- BitChat's BLE peer discovery protocol operates through a layered architecture separating the CoreBluetooth radio (
BLERadioController) from the mesh engine via the link layer abstraction. - Discovery relies on
CBCentralManagerscanning for specific service UUIDs with duplicate advertisement filtering enabled for rapid detection. - The
BLEConnectionSchedulerenforces connection budgets and RSSI policies to prevent resource exhaustion and connection flapping. - Successful candidates trigger GATT service discovery to establish writable mesh links, after which the engine handles Noise handshakes via
emitLinkEvent. - Background operation is supported through a recent-peripheral cache that enables wake-on-proximity for returning peers.
Frequently Asked Questions
How does BitChat filter BLE advertisements to find valid peers?
BitChat filters advertisements by scanning exclusively for peripherals broadcasting the BitChat service UUID defined in BLEService.serviceUUID. When centralManager(_:didDiscover:advertisementData:rssi:) receives a packet, it validates the presence of this UUID in the advertisement data before creating a BLEConnectionCandidate, ensuring only compatible BitChat nodes enter the connection pipeline.
What is the role of the BLEConnectionScheduler in peer discovery?
The BLEConnectionScheduler acts as a policy gatekeeper that determines whether a discovered peripheral should be ignored, queued for later, or connected immediately. It evaluates the current connection budget against BLELinkStateStore data, checks RSSI thresholds for signal quality, and analyzes recent traffic patterns to prevent duplicate connections to the same peer within short time windows.
How does BitChat handle peer discovery when the app is in the background?
BitChat utilizes a recent-peripheral cache stored in BLERadioController that retains metadata about recently disconnected peers. When the app transitions to the background, this cache persists peripheral identifiers and timestamps. If a cached peer begins advertising again, the protocol recognizes the returning device and re-arms the connection attempt after a configurable ignore window, allowing iOS background Bluetooth triggers to wake the app.
Where does the link layer send events once a peer is fully discovered?
Once service discovery completes and the BitChat GATT characteristic is verified, the link layer emits structured LinkEvent objects through the emitLinkEvent port. These events flow to the mesh engine, which then manages packet fragmentation, performs Noise protocol handshakes, and registers the peer in the active mesh table. This separation is architecturally defined in docs/BLE-ARCHITECTURE-V3.md.
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 →