---
name: dart-libp2p-ble
description: Use when a Flutter app must connect dart_libp2p hosts over Bluetooth LE without a network with package:dart_libp2p_ble: BleTransport, /ble/<token> addresses, BleDiscovery mesh and link budget, gossipsub over GATT, Android/iOS/macOS permissions, and FakeBleNetwork tests without Bluetooth.
triggers: dart_libp2p_ble, BleTransport, BleDiscovery, BluetoothLowEnergyRadio, FakeBleNetwork, FakeBleRadio, BleConn, /ble/, bluetooth_low_energy, libp2p bluetooth
---

# dart_libp2p_ble

## What it is

A Bluetooth LE transport (`/ble/<token>`) for dart_libp2p. Phones and Macs
nearby connect without Wi-Fi or cellular: each libp2p connection is one GATT
link (RX write, TX notify), and the normal upgrader runs multistream-select,
Noise and yamux on it, as on TCP. `BleDiscovery` scans, reads each nearby
node's peer ID and connects to a few of them, so gossipsub and other
protocols relay across a partial mesh. Runs on Android, iOS and macOS
through the `bluetooth_low_energy` plugin (6.2).

Use something else when:
- Devices share an IP network: TCP or UDX are about 1000x faster (BLE is about 5.7 KB/s each way).
- You need large transfers: BLE is for messages (chat, presence, small records).
- Plain Dart (not Flutter), Linux, Windows or web: only the in-memory radios work there.
- General host setup (identity, transports, services): see the `dart-libp2p-host` skill.

## Install

```yaml
dependencies:
  dart_libp2p: ^4.10.0
  dart_libp2p_ble: ^0.2.0
```

Needs Flutter 3.24+ and dart_libp2p 4.10.0+ (it knows the `/ble` multiaddr
protocol, code `0x300b1e`, and `PushbackStream`). dart_libp2p 4.11.0+ also
fails a dial when Noise proves a different peer than expected.

```dart
import 'package:dart_libp2p_ble/dart_libp2p_ble.dart'; // transport, discovery, real radio
import 'package:dart_libp2p_ble/testing.dart';         // FakeBleNetwork, FakeBleRadio
```

Then add the platform permissions (see Gotchas, and
https://cryptopeer.net/libp2p/ble/platforms/) before the first run.

## Core workflow

### A host on BLE with discovery

```dart
import 'package:dart_libp2p/config/config.dart';
import 'package:dart_libp2p/core/crypto/ed25519.dart';
import 'package:dart_libp2p/core/host/host.dart';   // Host
import 'package:dart_libp2p/core/peer/peer_id.dart';
import 'package:dart_libp2p/p2p/protocol/ping/ping.dart';
import 'package:dart_libp2p_ble/dart_libp2p_ble.dart';

final keyPair = await generateEd25519KeyPair();
final peerId = PeerId.fromPublicKey(keyPair.publicKey);
final ble = BleTransport(radio: BluetoothLowEnergyRadio(), localPeer: peerId);
final host = await Libp2p.new_([
  Libp2p.identity(keyPair),          // must match localPeer
  Libp2p.transport(ble),
  Libp2p.listenAddrs([ble.listenAddr]), // /ble/<token>: serves GATT and advertises
  // No IP network: turn off the NAT and relay services.
  Libp2p.autoNAT(false),
  Libp2p.relay(false),
  Libp2p.autoRelay(false),
  Libp2p.holePunching(false),
]);
await host.start();

final discovery = BleDiscovery(host: host, transport: ble);
discovery.peerFound.listen((peer) async {
  final result = await PingService(host).ping(peer).first;
  print('ping ${peer.toBase58()}: ${result.rtt?.inMilliseconds} ms');
});
await discovery.start(); // scans and connects to nearby nodes

// Shutdown
await discovery.dispose();
await host.close();
```

BLE can sit next to TCP or UDX on the same host: `/ble` addresses go to
`BleTransport`, IP addresses to the others. Only turn the NAT/relay services
off when BLE is the only transport.

### Dial a known token (no discovery)

```dart
import 'package:dart_libp2p/core/multiaddr.dart';
import 'package:dart_libp2p/core/peer/addr_info.dart';

// Token and peer ID from a QR code, say. The device must be advertising.
await host.connect(AddrInfo(peerId, [MultiAddr('/ble/k3vq7m')]));
```

The radio scans until it sees the token, then connects (dial timeout from
`TransportConfig.dialTimeout`).

### Tune discovery

```dart
final discovery = BleDiscovery(
  host: host,
  transport: ble,
  maxLinks: 4,                 // BLE conns kept, inbound included
  minRssi: -90,                // do not dial devices heard weaker than this
  swapMargin: 15,              // when full, replace weakest link with one 15 dB stronger
  minLinkAge: const Duration(minutes: 1),
);
```

### Gossipsub across the mesh

```dart
import 'dart:convert';
import 'dart:typed_data';
import 'package:dart_libp2p_pubsub/dart_libp2p_pubsub.dart';

final pubsub = PubSub(host, GossipSubRouter());
await pubsub.start();
final sub = pubsub.subscribe('/chat/1');
sub.stream.cast<PubSubMessage>().listen((m) => print(utf8.decode(m.data)));
await discovery.start();
await pubsub.publish('/chat/1', Uint8List.fromList(utf8.encode('hello')));
```

A message reaches nodes out of range of the sender through intermediate
nodes. Gossipsub builds its mesh on heartbeats, so the first publishes after
connecting can go nowhere; retry or wait for the mesh.

### Quiet the plugin's logs

```dart
import 'package:logging/logging.dart';

hierarchicalLoggingEnabled = true;
for (final name in ['CentralManager', 'PeripheralManager']) {
  Logger(name).level = Level.WARNING; // plugin logs every advert and GATT value at INFO
}
// Transport loggers at FINE: BleTransport, BleConn, BleDiscovery, BluetoothLowEnergyRadio
```

## Key concepts / API map

`package:dart_libp2p_ble/dart_libp2p_ble.dart`:
- `BleTransport({required BleRadio radio, required PeerId localPeer, String? token, TransportConfig? config})`: the transport; `token`, `listenAddr` (`/ble/<token>`), `canDial`, `canListen`, `dial`, `listen`, `dispose()` (also disposes the radio).
- `BleListener`: accepts links that centrals open (created by `listen`).
- `BleConn`: raw `TransportConn` over one `BleLink`, type-byte framing, TCP-style reads, `PushbackStream`; the swarm upgrades it (Noise + yamux).
- `BleDiscovery({required host, required transport, maxLinks = 4, yieldFor = 8 s, retryAfter = 30 s, addrTtl = 10 min, recheckEvery = 3 s, minRssi, swapMargin, minLinkAge = 1 min})`: `start()`, `stop()`, `dispose()`, `peerFound`, `knownTokens`, `bleConnections`.
- `BluetoothLowEnergyRadio({CentralManager? central, PeripheralManager? peripheral, Duration sessionReuse = 10 s})`: the real radio on the plugin.
- `BleRadio`: radio interface (`startAdvertising(token, identity)`, `stopAdvertising`, `incoming`, `startScan`, `stopScan`, `sightings`, `readIdentity(token)`, `connect(token)`, `dispose`). Implement it to plug in another BLE stack.
- `BleLink`: one GATT link (`isCentral`, `remoteId`, `maxValueLength`, `values`, `send`, `close`, `done`).
- `BleSighting(token, {rssi})`: one scan result.
- Helpers: `bleProtocolName` (`ble`), `bleAddr(token)`, `tokenOf(addr)`, `isBleAddr(addr)`, `newToken([Random])`, `isValidToken(token)`, `serviceUuid` (`b1e5f00d-0000-4c1b-9d6e-5d1e0a9f2b01`).

`package:dart_libp2p_ble/testing.dart`:
- `FakeBleNetwork({maxValueLength = 512, latency = Duration.zero})`: `newRadio()`, `setInRange(a, b, bool)`, `setRssi(a, b, dBm)` (default -50), `inRange(a, b)`.
- `FakeBleRadio`: a `BleRadio` in memory; `openLinks` counts its links in both roles.

Wire facts: token = 6 chars of `a-z2-7` (30 bits), new each time the
transport is made. GATT service has RX (write), TX (notify) and identity
(read: peer ID multihash bytes). Every GATT value starts with a type byte:
`0x00` data, `0x01` close.

## Gotchas

- **Android manifest** (`android/app/src/main/AndroidManifest.xml`):
  `BLUETOOTH_SCAN` with `android:usesPermissionFlags="neverForLocation"`,
  `BLUETOOTH_CONNECT`, `BLUETOOTH_ADVERTISE`, plus `BLUETOOTH`,
  `BLUETOOTH_ADMIN`, `ACCESS_FINE_LOCATION`, `ACCESS_COARSE_LOCATION` with
  `android:maxSdkVersion="30"` for Android 11 and earlier. Without
  `neverForLocation`, Android 12+ asks for location. The radio requests the
  runtime permissions itself the first time it needs Bluetooth.
- **iOS `Info.plist`**: `NSBluetoothAlwaysUsageDescription` and
  `NSBluetoothPeripheralUsageDescription`; iOS shows them in the Bluetooth
  permission prompt and refuses access without them.
- **macOS**: `NSBluetoothAlwaysUsageDescription` in `macos/Runner/Info.plist`
  and `com.apple.security.device.bluetooth` = true in **both**
  `DebugProfile.entitlements` and `Release.entitlements` (the sandbox
  blocks Bluetooth otherwise, and debug and release use different files).
- **Deployment targets**: current Xcode builds for iOS 15.0+ and macOS
  12.0+; set them in the Podfile and the Xcode project or the build fails.
- **Bluetooth must be on.** The radio waits up to 30 s for `poweredOn` (and
  on Android for the permission prompt), then throws `StateError`
  (`Bluetooth permissions denied`, `Bluetooth LE is not supported on this device`,
  or a timeout).
- **`localPeer` must be the host's own peer ID.** It is published in the
  identity characteristic; a mismatch makes every dial land on "a different
  peer" and discovery drops it.
- **The identity characteristic is not authenticated.** Any device can
  publish someone else's peer ID. Noise proves who answered; since 0.2.0
  discovery checks the dialed peer really is connected and forgets the token
  otherwise. On dart_libp2p < 4.11.0, a manual `host.connect` to a spoofed
  token can succeed to the impostor, so check `connectedness(peer)` after
  dialing, or require 4.11.0+.
- **Discovery is open, not consent.** Any device in range can read your peer
  ID and connect. To know you met someone, compare something out of band
  (QR code, short code on both screens).
- **Tokens change on every start** (and `newToken` is used if you pass no
  `token`). Never store a token as a peer's address; store the peer ID.
  `addrTtl` (10 min) bounds stale `/ble` addresses in the peerstore.
- **`listen` takes its token from the address.** Pass `ble.listenAddr`;
  listening on another `/ble/<token>` changes the transport's token. One
  listener per transport (`StateError` otherwise). A transport never dials
  its own token.
- **Inbound remote addresses are not dialable.** A peripheral does not know
  the central's token, so inbound `BleConn.remoteMultiaddr` is made from the
  platform ID (`/ble/c<id>`), which fails `isValidToken`. Do not dial it.
- **`peerFound` only reports peers this node dialed.** Peers that dialed in
  do not appear; watch `host.network` (`EvtPeerConnectednessChanged`) for all
  peers.
- **Who dials:** the lower token dials; the higher token waits `yieldFor`
  (8 s) then dials too. In tests shorten `yieldFor` (1 s); on devices keep
  the default or pairs get duplicate links.
- **Link budget includes inbound.** Past `maxLinks`, discovery closes the
  newest inbound connections first. After any connection or failure to a
  token it waits `retryAfter` (30 s) before dialing it again.
- **`swapMargin` can partition the mesh**: the weak link may be the only
  bridge. It is off by default; enable it only with a bridging plan.
- **iOS reports a device once per scan**, so discovery re-examines known
  tokens every `recheckEvery` (3 s); do not set it very high.
- **iOS peripherals cannot disconnect a central.** The transport sends a
  close frame and the central disconnects when it reads it; a closed inbound
  link can linger on the other side until its streams time out. Darwin also
  has no peripheral connection events: an unsubscribe is the only signal.
- **Peripheral IDs change.** Android often reconnects from a new address, so
  iOS gives it a new ID. The radio tries a cached peripheral for at most 4 s,
  then scans for the token again. Never key anything on `BleLink.remoteId`.
- **Advertising: Android uses manufacturer data, iOS/macOS the local name**
  (`p2<token>`). The plugin can only set an Android name by renaming the
  whole phone, which the radio never does. The 31-byte legacy advert leaves
  exactly 10 bytes after flags and the 128-bit UUID; do not add more data.
- **Company ID `0xFFFF`** (Bluetooth SIG test ID) carries the Android token.
  A store release should use an assigned ID; that needs a package change.
- **Do not restart advertising on connect.** On Android, stop+start of the
  advertising set breaks the plugin (start never answers); the stack
  re-enables it by itself. The radio deliberately keeps advertising.
- **MTU**: on Android the central requests MTU 517; the value size is what
  the platform reports for write with response, at most 512 bytes. Writes
  are split into values of `maxValueLength - 1` bytes (type byte).
- **Slow:** write with response, about 5.7 KB/s, connect + discover +
  subscribe about 4.9 s median, 256-byte round trip about 93 ms. Set
  protocol timeouts accordingly; do not ship files over BLE.
- **`readIdentity` keeps the GATT connection for `sessionReuse` (10 s)** so
  the following `connect` reuses it. A slow dial after that reconnects.
- **Background is untested.** iOS restricts background advertising and
  scanning; Android can stop a background app's scans. Expect links to drop
  when the app leaves the foreground; restart discovery on resume. No
  background modes are configured by the package.
- **Only two real devices tested at once** (Pixel 10a and iPhone). Meshes of
  three or more are tested only in memory.

## Testing

Use the in-memory radios; everything above the radio (transport, Noise,
yamux, discovery, gossipsub) is the real code. Run with `flutter test`.

```dart
import 'package:dart_libp2p_ble/testing.dart';

final network = FakeBleNetwork(maxValueLength: 20, latency: const Duration(milliseconds: 5));
final radioA = network.newRadio();
final radioB = network.newRadio();
final radioC = network.newRadio();
network.setInRange(radioA, radioC, false); // A - B - C line
network.setRssi(radioA, radioB, -95);      // for minRssi / swapMargin tests

Future<(Host, BleTransport)> node(BleRadio radio, {String? token}) async {
  final keyPair = await generateEd25519KeyPair();
  final ble = BleTransport(radio: radio, localPeer: PeerId.fromPublicKey(keyPair.publicKey), token: token);
  final host = await Libp2p.new_([
    Libp2p.identity(keyPair),
    Libp2p.transport(ble),
    Libp2p.listenAddrs([ble.listenAddr]),
    Libp2p.autoNAT(false),
    Libp2p.relay(false),
    Libp2p.autoRelay(false),
    Libp2p.holePunching(false),
  ]);
  await host.start();
  return (host, ble);
}
// Direct dial: from.connect(AddrInfo(to.id, [toBle.listenAddr]))
// Mesh: BleDiscovery(host: h, transport: ble, yieldFor: const Duration(seconds: 1))
```

- Fix tokens (`token: 'aaaaaa'`, `'bbbbbb'`) to control who dials first.
- `maxValueLength: 20` or `182` exercises small-MTU splitting; `latency` keeps order.
- Wait for state with polling and a timeout (discovery and gossipsub
  heartbeats take seconds); 30 s timeouts are typical in the repo's tests.
- Clean up in order: `discovery.dispose()`, `pubsub.stop()`, `host.close()`.
- The fakes have no permissions, advertising limits, changing IDs or lost
  links: test on two devices before shipping (the repo's `spike/` app runs
  a host with discovery, ping and gossip and shows the log).

## Red lines

- Never treat the identity characteristic, a token, or a BLE sighting as
  proof of who a peer is; only the Noise-authenticated connection is.
- Never persist tokens as identifiers or use them to track devices.
- Never rename the Android device or stop/start advertising around
  connections in a custom radio.
- Never change the GATT UUIDs, the type-byte framing, the `p2` name prefix
  or the manufacturer data layout: other nodes would not find or understand
  yours.
- Never leave scanning running unbounded in production without a way to
  `stop()` it (battery); always `dispose()` discovery and the transport.
- Do not raise `maxLinks` much above 4 on phones: they hold few BLE links and
  all share the same radio time.

## Reference

- Guides: https://cryptopeer.net/libp2p/ble/
  - https://cryptopeer.net/libp2p/ble/getting-started/
  - https://cryptopeer.net/libp2p/ble/platforms/
  - https://cryptopeer.net/libp2p/ble/discovery/
  - https://cryptopeer.net/libp2p/ble/testing/
  - https://cryptopeer.net/libp2p/ble/how-it-works/
- Repository: https://github.com/stephanfeb/dart-libp2p-ble (`example/`, `test/`, `spike/`)
- Package: https://pub.dev/packages/dart_libp2p_ble
- Radio plugin: https://pub.dev/packages/bluetooth_low_energy
- Host setup: the `dart-libp2p-host` skill.
