Skip to content

dart-libp2p-ble

Skill dart-libp2p-ble. Use when a Flutter app must connect dart_libp2p hosts over Bluetooth LE without a network with package:dart_libp2p_ble: BleTransport, /ble/ addresses, BleDiscovery mesh and link budget, gossipsub over GATT, Android/iOS/macOS permissions, and FakeBleNetwork tests without Bluetooth.

SKILL.md (Claude Code, Codex, Cursor) · dart-libp2p-ble.md (Boubou) · install: curl -fsSL https://cryptopeer.net/skills/install.sh | sh -s -- dart-libp2p-ble

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.
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.

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.

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.

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).

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),
);
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.

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

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.

  • 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.

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

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).
  • 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.

Built by Stephan Februarygithub.com/stephanfebLinkedIn