How BitChat Implements BLE Central and Peripheral Roles for Mesh Messaging

BitChat’s BLEService class operates simultaneously as both a Bluetooth Low Energy peripheral (advertising and accepting subscriptions) and central (scanning and initiating connections) to enable direct peer-to-peer mesh networking with deterministic, thread-safe event handling.

BitChat is an open-source messaging protocol designed for permissionless, offline-first communication using Bluetooth Low Energy (BLE). The iOS implementation achieves this through a dual-role BLE architecture where every device can broadcast availability while actively seeking peers. The BLEService class manages these concurrent responsibilities by delegating physical layer operations to specialized extensions that emit pure BLELinkEvent objects consumed by a separate mesh engine.

BitChat’s BLE transport follows the BLE‑ARCHITECTURE‑V3 design documented in BLE-ARCHITECTURE-V3.md. This architecture isolates CoreBluetooth interactions from protocol logic to ensure deterministic, lock‑free operation.

  • BLELinkLayer (implemented in BLEService.swift and its extensions) is the sole module importing CoreBluetooth. It owns both the CBCentralManager and CBPeripheralManager, handling scanning, advertising, duty cycles, and raw back‑pressure buffers.
  • bleQueue is a dedicated serial dispatch queue where all peripheral and central callbacks execute. This confines physical layer mutations to a single thread, preventing race conditions during link‑state updates.
  • Mesh Engine runs on a separate engineQueue and processes pure protocol logic (packet fragmentation, deduplication, Noise handshakes, and routing). It receives events via emitLinkEvent() and never interacts with CoreBluetooth directly.

This separation allows the mesh engine to remain pure and testable while the link layer manages complex BLE lifecycle callbacks.

Peripheral Role Implementation

The peripheral role implementation resides in BLEService+LinkLayerPeripheralRole.swift and conforms to CBPeripheralManagerDelegate. It advertises the BitChat service UUID, exposes a writable GATT characteristic, and forwards inbound packets to the mesh engine.

Initialization and Advertising

When the peripheral manager state transitions to .poweredOn, the service constructs the GATT database and begins advertising.

// In BLEService+LinkLayerPeripheralRole.swift
func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
    switch peripheral.state {
    case .poweredOn:
        peripheral.removeAllServices()
        let characteristic = CBMutableCharacteristic(
            type: BLEService.characteristicUUID,
            properties: [.notify, .write, .writeWithoutResponse, .read],
            value: nil,
            permissions: [.readable, .writeable]
        )
        let service = CBMutableService(type: BLEService.serviceUUID, primary: true)
        service.characteristics = [characteristic]
        peripheral.add(service)               // <-- triggers didAdd delegate
    default: /* handle other states */ 
    }
}

// Called after the service is added
func peripheralManager(_ peripheral: CBPeripheralManager,
                       didAdd service: CBService,
                       error: Error?) {
    guard error == nil else { return }
    peripheral.startAdvertising(BLERadioController.advertisementData())
}

Managing Central Subscriptions

When a remote central subscribes to the characteristic, BitChat records the connection in the link‑state store, performs rate‑limiting via BLESubscriptionAnnounceLimiter, and schedules an announce packet.

func peripheralManager(_ peripheral: CBPeripheralManager,
                       central: CBCentral,
                       didSubscribeTo characteristic: CBCharacteristic) {
    let uuid = central.identifier.uuidString
    linkStateStore.addSubscribedCentral(central)

    // Rate‑limit announce packets to prevent flooding
    switch subscriptionAnnounceLimiter.decision(for: uuid, now: Date()) {
    case .allowed:
        engineScheduler.schedule(after: TransportConfig.blePostAnnounceDelaySeconds) {
            self.sendAnnounce(forceSend: true)
            self.flushDirectedSpool()
        }
    case .rateLimited(let backoff, _, let suppress):
        if suppress { return }
        // Still flush pending directed packets
        engineScheduler.schedule(after: TransportConfig.blePostAnnounceDelaySeconds) {
            self.flushDirectedSpool()
        }
    }
}

Handling Unsubscribes

When a central unsubscribes (peripheralManager(_:didUnsubscribeFrom:characteristic:)), BitChat clears the central’s link state, drops pending notifications, and emits a centralLinkEnded event to trigger route recalculation in the mesh engine.

Central Role Implementation

The central role implementation in BLEService+LinkLayerCentralRole.swift conforms to CBCentralManagerDelegate. It manages device discovery, connection lifecycle, and outbound packet transmission.

Scanning and Discovery

Upon .poweredOn state, the central begins scanning for the BitChat service UUID.

// In BLEService+LinkLayerCentralRole.swift
func centralManagerDidUpdateState(_ central: CBCentralManager) {
    switch central.state {
    case .poweredOn:
        radio.startScanning()                // starts scanning for BLEService.serviceUUID
    default: /* handle other states */
    }
}

// Discovered a peripheral
func centralManager(_ central: CBCentralManager,
                    didDiscover peripheral: CBPeripheral,
                    advertisementData: [String: Any],
                    rssi RSSI: NSNumber) {
    radio.handleDiscovery(peripheral, advertisementData: advertisementData, rssi: RSSI)
}

Connection Lifecycle

When the user or mesh engine initiates a connection, the central manager delegates the result to centralManager(_:didConnect:), which stores the peripheral in the link‑state store, resets connection back‑off timers, and triggers service discovery.

func centralManager(_ central: CBCentralManager,
                    didConnect peripheral: CBPeripheral) {
    linkStateStore.markConnected(peripheral)
    peripheral.discoverServices([BLEService.serviceUUID])
}

On disconnection (centralManager(_:didDisconnectPeripheral:error:)), BitChat logs the peripheral ID, records any back‑off state for exponential retry logic, and caches the device identifier to enable wake‑on‑proximity reconnects when the peripheral re‑enters range.

Sending Outbound Packets

The mesh engine schedules outbound packets on the engineQueue, which are then marshaled to the bleQueue for actual transmission:

// Engine creates a packet and pushes it to the BLE queue
engineScheduler.schedule {
    let data = packet.toBinaryData()
    // BLEQueue handles back‑pressure and writes
    pendingPeripheralWrites.enqueue(data, for: peripheral.identifier.uuidString)
}

This two‑queue approach prevents the mesh engine from blocking on BLE back‑pressure while ensuring thread‑safe access to CoreBluetooth APIs.

Key Implementation Files

File Responsibility
BLEService.swift Core service class, BLE constants, admission registry, and high‑level event routing between link layer and mesh engine.
BLEService+LinkLayerPeripheralRole.swift Implements CBPeripheralManagerDelegate for advertising, subscription management, and notification dispatch.
BLEService+LinkLayerCentralRole.swift Implements CBCentralManagerDelegate for scanning, connection handling, and disconnection recovery.
BLERadioController.swift Low‑level radio abstraction responsible for building advertisement payloads, duty‑cycle management, and tracking recent peripherals for wake‑on‑proximity.
BLE-ARCHITECTURE-V3.md Comprehensive design document detailing concurrency contracts, MTU negotiation strategies, and the separation of concerns between physical and protocol layers.

Summary

  • Dual‑role concurrency: BitChat’s BLEService operates as both peripheral (via CBPeripheralManager) and central (via CBCentralManager) simultaneously, enabling true peer‑to‑peer mesh topology.
  • Thread safety: All CoreBluetooth callbacks execute on a dedicated bleQueue, while the mesh engine processes events on engineQueue, ensuring lock‑free, deterministic packet handling.
  • State management: The linkStateStore tracks subscriptions and connections, while BLESubscriptionAnnounceLimiter prevents announce‑packet flooding during high‑churn scenarios.
  • Resilient connections: The central role implements exponential back‑off and wake‑on‑proximity caching, while the peripheral role handles dynamic subscription lifecycles with proper cleanup on unsubscribe.

Frequently Asked Questions

Can a BitChat device act as both BLE central and peripheral at the same time?

Yes. BitChat initializes both a CBCentralManager and CBPeripheralManager within the BLEService class. According to the source code in BLEService.swift, both managers operate concurrently on the bleQueue, allowing the device to advertise its presence while simultaneously scanning for and connecting to other peers. This dual‑role capability is essential for building resilient mesh networks where no single point of failure exists.

What happens when a remote device subscribes to BitChat’s BLE characteristic?

When a central subscribes, the peripheral delegate in BLEService+LinkLayerPeripheralRole.swift invokes peripheralManager(_:didSubscribeTo:characteristic:). This stores the central’s UUID in the link‑state store, checks the BLESubscriptionAnnounceLimiter to prevent rate‑limit violations, and schedules an announce packet via sendAnnounce(forceSend:). If the rate limiter suppresses the announce, BitChat still flushes any pending directed packets from the spool to ensure message delivery.

How does BitChat handle unexpected BLE disconnections?

The central role implements centralManager(_:didDisconnectPeripheral:error:) in BLEService+LinkLayerCentralRole.swift to handle disconnections. It records the peripheral ID in a recent‑peripheral cache for wake‑on‑proximity reconnects, updates the exponential back‑off state if the disconnect resulted from an error, and emits a link‑ended event to the mesh engine so routes can be recalculated. This recovery mechanism ensures temporary radio interference does not permanently sever mesh connectivity.

Why does BitChat use separate queues for BLE operations and mesh engine processing?

BitChat uses bleQueue for all CoreBluetooth interactions and engineQueue for protocol logic to prevent thread starvation and race conditions. According to BLE-ARCHITECTURE-V3.md, this separation allows the mesh engine to remain a pure, testable state machine that consumes BLELinkEvent objects, while the link layer handles asynchronous BLE callbacks and back‑pressure management on a dedicated serial queue. This architecture guarantees deterministic behavior during high‑throughput peer‑to‑peer communication.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →