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
What it is
Section titled “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-hostskill.
Install
Section titled “Install”dependencies: dart_libp2p: ^4.10.0 dart_libp2p_ble: ^0.2.0Needs 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 radioimport 'package:dart_libp2p_ble/testing.dart'; // FakeBleNetwork, FakeBleRadioThen add the platform permissions (see Gotchas, and https://cryptopeer.net/libp2p/ble/platforms/) before the first run.
Core workflow
Section titled “Core workflow”A host on BLE with discovery
Section titled “A host on BLE with discovery”import 'package:dart_libp2p/config/config.dart';import 'package:dart_libp2p/core/crypto/ed25519.dart';import 'package:dart_libp2p/core/host/host.dart'; // Hostimport '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
// Shutdownawait 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)
Section titled “Dial a known token (no discovery)”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
Section titled “Tune discovery”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
Section titled “Gossipsub across the mesh”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
Section titled “Quiet the plugin’s logs”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, BluetoothLowEnergyRadioKey concepts / API map
Section titled “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 bylisten).BleConn: rawTransportConnover oneBleLink, 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: aBleRadioin memory;openLinkscounts 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
Section titled “Gotchas”- Android manifest (
android/app/src/main/AndroidManifest.xml):BLUETOOTH_SCANwithandroid:usesPermissionFlags="neverForLocation",BLUETOOTH_CONNECT,BLUETOOTH_ADVERTISE, plusBLUETOOTH,BLUETOOTH_ADMIN,ACCESS_FINE_LOCATION,ACCESS_COARSE_LOCATIONwithandroid:maxSdkVersion="30"for Android 11 and earlier. WithoutneverForLocation, Android 12+ asks for location. The radio requests the runtime permissions itself the first time it needs Bluetooth. - iOS
Info.plist:NSBluetoothAlwaysUsageDescriptionandNSBluetoothPeripheralUsageDescription; iOS shows them in the Bluetooth permission prompt and refuses access without them. - macOS:
NSBluetoothAlwaysUsageDescriptioninmacos/Runner/Info.plistandcom.apple.security.device.bluetooth= true in bothDebugProfile.entitlementsandRelease.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 throwsStateError(Bluetooth permissions denied,Bluetooth LE is not supported on this device, or a timeout). localPeermust 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.connectto a spoofed token can succeed to the impostor, so checkconnectedness(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
newTokenis used if you pass notoken). Never store a token as a peer’s address; store the peer ID.addrTtl(10 min) bounds stale/bleaddresses in the peerstore. listentakes its token from the address. Passble.listenAddr; listening on another/ble/<token>changes the transport’s token. One listener per transport (StateErrorotherwise). 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.remoteMultiaddris made from the platform ID (/ble/c<id>), which failsisValidToken. Do not dial it. peerFoundonly reports peers this node dialed. Peers that dialed in do not appear; watchhost.network(EvtPeerConnectednessChanged) for all peers.- Who dials: the lower token dials; the higher token waits
yieldFor(8 s) then dials too. In tests shortenyieldFor(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 waitsretryAfter(30 s) before dialing it again. swapMargincan 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 - 1bytes (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.
readIdentitykeeps the GATT connection forsessionReuse(10 s) so the followingconnectreuses 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
Section titled “Testing”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 linenetwork.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: 20or182exercises small-MTU splitting;latencykeeps 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
Section titled “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
p2name 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); alwaysdispose()discovery and the transport. - Do not raise
maxLinksmuch above 4 on phones: they hold few BLE links and all share the same radio time.
Reference
Section titled “Reference”- Guides: https://cryptopeer.net/libp2p/ble/
- 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-hostskill.