flutter_classic_bluetooth 0.1.3 copy "flutter_classic_bluetooth: ^0.1.3" to clipboard
flutter_classic_bluetooth: ^0.1.3 copied to clipboard

Bluetooth Classic (RFCOMM/SPP) serial plugin for Flutter. Discover, pair and connect to ESP32, HC-05 and serial devices on Android, Windows, macOS, Linux, iOS.

pub version pub points pub likes GitHub stars GitHub forks GitHub issues CI status Last commit License: MIT Dart Flutter

Bluetooth Classic Serial (RFCOMM/SPP) Plugin for Flutter #

flutter_classic_bluetooth is a Flutter plugin for Bluetooth Classic serial communication over RFCOMM (the Serial Port Profile, SPP). It lets you discover, pair, connect to, and exchange data with Bluetooth Classic devices (ESP32, ESP8266, Arduino boards, HC-05 / HC-06 modules, barcode scanners, thermal printers, OBD-II adapters, and other serial / UART peripherals) from a single Dart API on Android, Windows, macOS, Linux, and iOS (MFi). Connections are exposed as Dart streams, so reading and writing bytes feels like any other Stream/Sink.

โญ Find this useful? Star it on GitHub and ๐Ÿ‘ like it on pub.dev. It helps other Flutter developers find a maintained Bluetooth Classic plugin.

Overview #

flutter_classic_bluetooth speaks RFCOMM/SPP, the classic Bluetooth serial transport, not Bluetooth Low Energy (BLE). It wraps each platform's native stack (Android BluetoothSocket, Windows Winsock2 AF_BTH, Linux BlueZ RFCOMM sockets, macOS IOBluetooth, iOS ExternalAccessory) behind one consistent Dart interface. You can act as a client (connect out to a device) or as a server (advertise an SDP service and accept incoming connections), run several connections at once, and observe adapter, discovery, and bond state through broadcast streams.

What you can do with it:

  • Discover nearby devices and list paired/bonded devices.
  • Connect over RFCOMM by MAC address + service UUID, then read and write a byte stream.
  • Run an RFCOMM server that accepts incoming client connections.
  • Pair/unpair devices, toggle the adapter, and make the device discoverable (where the platform allows).
  • Query per-platform capabilities at runtime so your UI only offers what works.

Table of contents #

Key features #

A complete Bluetooth Classic (RFCOMM/SPP) client + server toolkit behind one Dart API. Expand a group for details:

๐Ÿ“ก Connectivity
  • RFCOMM/SPP client: connect by address + service UUID, secure or insecure
  • RFCOMM server: advertise an SDP service and accept incoming clients
  • Multiple simultaneous connections, each with its own id
  • Optional connection timeout
  • Optional auto-reconnect with exponential backoff (connectWithReconnect)
  • Request/response: sendAndReceive() writes a command and awaits its reply line
๐Ÿ” Discovery & pairing
  • Device discovery with results and start/stop state streams, plus a one-shot scan()
  • Paired/bonded device listing
  • Bond / unbond devices and observe bond-state changes
  • Make discoverable (where supported)
๐Ÿ”€ Streamed I/O
  • Incoming bytes as a Dart Stream<Uint8List>
  • Line/frame reader: input.lines() / input.frames() reassemble delimited messages across chunks
  • Ordered write sink: add, writeBytes, writeString, writeLine, addStream, allSent
  • Connection-state stream (connecting โ†’ connected โ†’ disconnecting โ†’ disconnected)
๐Ÿงฉ Adapter & capabilities
  • Adapter state stream, name, and address
  • Enable/disable the adapter (Android)
  • Runtime platform-capability matrix so the UI adapts per platform
๐Ÿ›ก๏ธ Reliability
  • Typed exception hierarchy: BtcException + subtypes
  • Main-thread-safe event delivery on every platform
  • Honest per-platform capability reporting (no dead code paths)

Platform support #

Bluetooth Classic capabilities differ by OS, so the plugin reports what each one can actually do (also queryable at runtime via getPlatformCapabilities()):

Feature Android Windows macOS Linux iOS
Adapter state stream โœ… โœ… โœ… โœ… โœ…
Discover devices โœ… โœ… โœ… โœ… โŒ
Get paired devices โœ… โœ… โœ… โœ… โœ…ยน
Pair (bond) โœ… โœ… โœ…ยฒ โœ…ยณ โŒ
Unpair (unbond) โœ… โœ… โŒโด โœ…ยณ โŒ
Connect (RFCOMM) โœ… โœ… โœ… โœ… โœ…ยน
Server mode โœ… โœ… โœ… โœ… โŒ
Enable / Disable โœ… โŒ โŒ โœ…ยณ โŒ
Set discoverable โœ… โœ… โŒ โœ… โŒ

ยน iOS uses the ExternalAccessory framework, so only MFi-certified accessories are supported, and the uuid argument is treated as the MFi protocol string.
ยฒ macOS pairs via IOBluetoothDevicePair and may show a system pairing prompt.
ยณ Linux uses the BlueZ D-Bus API (org.bluez); pairing a device that needs a PIN/passkey requires a system pairing agent.
โด macOS has no public API to remove an existing pairing; unpair via System Settings.

Roadmap #

What's shipped and what's next. Completed items are checked; the rest is on the list; contributions welcome.

Shipped

  • โœ… RFCOMM/SPP client: connect by address + UUID, secure/insecure, optional timeout
  • โœ… RFCOMM server: advertise an SDP service and accept incoming clients
  • โœ… Multiple simultaneous connections, each with its own id
  • โœ… Device discovery with results and start/stop state streams
  • โœ… Paired/bonded device listing
  • โœ… Pair / unpair with a bond-state stream
  • โœ… Adapter state stream, enable/disable, and set discoverable
  • โœ… Streamed byte I/O: ordered write sink (writeString / writeLine / writeBytes / addStream)
  • โœ… Line/frame reader: split serial input on a delimiter (input.lines() / input.frames())
  • โœ… Request/response helper: sendAndReceive() for AT-command / line protocols
  • โœ… Connection-state lifecycle stream
  • โœ… Runtime platform-capability matrix
  • โœ… Typed exception hierarchy (BtcException + subtypes)
  • โœ… BtcUuid.spp default: connect(address: ...) just works for serial devices
  • โœ… Optional auto-reconnect with exponential backoff (connectWithReconnect)
  • โœ… Linux SSP pairing agent: pair "just works" devices from bondDevice() with no desktop dialog
  • โœ… Five platforms: Android, Windows, macOS, Linux, iOS (MFi)
  • โœ… Linux via BlueZ D-Bus: discovery, adapter and pairing work without root
  • โœ… Connection RSSI on macOS: read the live link signal strength with connection.readRssi()

Planned

  • โฌœ Expanded on-device integration tests

Not currently possible (platform limits, tracked but blocked)

  • โ›” Connection RSSI on Android, iOS & Windows: no public Bluetooth Classic API (macOS is supported; Linux would require privileged raw HCI). Discovery-time RSSI is available everywhere via BtcDevice.rssi.
  • โ›” macOS programmatic unpair: no public Apple API

Out of scope, use a dedicated package instead: Bluetooth Low Energy (BLE), and Web (Bluetooth Classic is not available in browsers).

Example #

A complete, runnable demo app lives in the example/ directory, with screens for adapter control, device discovery, paired devices, RFCOMM client/server, and the platform-capabilities matrix. Clone the repository and run it, or copy any snippet from Getting started below.

Installation #

flutter pub add flutter_classic_bluetooth

Then import it:

import 'package:flutter_classic_bluetooth/flutter_classic_bluetooth.dart';

Platform setup #

Android: add the Bluetooth permissions to android/app/src/main/AndroidManifest.xml:

<!-- Android 11 (API 30) and below -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
<!-- Android 12 (API 31) and above -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />

iOS: declare the MFi protocol(s) and a usage string in ios/Runner/Info.plist:

<key>UISupportedExternalAccessoryProtocols</key>
<array>
  <string>com.example.spp</string>
</array>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>This app communicates with Bluetooth accessories.</string>

macOS: add the Bluetooth entitlement to macos/Runner/*.entitlements and a usage string to Info.plist:

<key>com.apple.security.device.bluetooth</key>
<true/>

Linux: install the GTK and BlueZ development packages the native plugin builds against (Debian/Ubuntu shown; the build fails with a gtk+-3.0 or bluetooth/bluetooth.h CMake error if they're missing):

sudo apt-get install -y libgtk-3-dev libbluetooth-dev ninja-build cmake pkg-config clang

On Fedora use gtk3-devel bluez-libs-devel ninja-build cmake clang; on Arch, gtk3 bluez-libs ninja cmake clang.

Getting started #

Initialize and check support #

final bluetooth = FlutterClassicBluetooth();

final supported = await bluetooth.isSupported();
final enabled = await bluetooth.isEnabled();
final caps = await bluetooth.getPlatformCapabilities();

if (caps.canDiscoverDevices) {
  // safe to call startDiscovery() on this platform
}

Discover nearby devices #

One-shot: scan for a fixed window and get the de-duplicated list back, sorted by signal strength:

final devices = await bluetooth.scan(timeout: const Duration(seconds: 8));
for (final d in devices) {
  print('${d.displayName} (${d.address}) ${d.rssi ?? ''}');
}

Live: stream results as they arrive:

final sub = bluetooth.discoveryResults.listen((device) {
  print('Found: ${device.displayName} (${device.address})');
});

await bluetooth.startDiscovery();
// ...later
await bluetooth.stopDiscovery();
await sub.cancel();

List paired devices #

final devices = await bluetooth.getPairedDevices();
for (final device in devices) {
  print('${device.displayName} - ${device.address} [${device.bondState.name}]');
}

Connect to a device #

// SPP is the default. For HC-05/06, ESP32, Arduino, etc. this is all you need:
final connection = await bluetooth.connect(address: 'AA:BB:CC:DD:EE:FF');
print('Connected: id=${connection.id}');

// Override the UUID and tune the attempt only when you need to:
final custom = await bluetooth.connect(
  address: 'AA:BB:CC:DD:EE:FF',
  uuid: BtcUuid.spp, // or any service UUID string
  secure: true,
  timeout: const Duration(seconds: 15), // optional
);

Receive data #

connection.input.listen(
  (Uint8List data) => print('Received ${data.length} bytes: $data'),
  onDone: () => print('Remote closed the connection'),
);

Read line-by-line #

Serial devices send delimited text, but RFCOMM (like any stream) doesn't preserve message boundaries. lines() reassembles complete lines across chunks. It splits on \n, strips a trailing \r, and decodes to String:

connection.input.lines().listen((line) => print('> $line'));

// Custom framing: split on any delimiter, keep raw bytes.
connection.input
    .frames(delimiter: const [0x03]) // e.g. ETX-terminated frames
    .listen((frame) => print('frame: ${frame.length} bytes'));

Works the same on an auto-reconnecting link: link.input.lines().

Send data #

// Raw bytes
await connection.output.add(Uint8List.fromList([0x01, 0x02, 0x03]));

// Convenience helpers
await connection.output.writeBytes([0x04, 0x05]);
await connection.output.writeString('AT+RESET\r\n');

// A whole line (appends CRLF by default)
await connection.output.writeLine('AT');

// Wait until everything queued so far has been written
await connection.output.allSent;

Request / response (AT commands) #

sendAndReceive writes a command and returns the first response line (framing and timeout handled for you), the usual pattern for AT-command modules:

final version = await connection.sendAndReceive('AT+GMR');
final ok = await connection.sendAndReceive(
  'AT',
  where: (line) => line == 'OK', // skip echoes; return the line you want
  timeout: const Duration(seconds: 2),
);
// Throws BtcTimeoutException if nothing matches in time.

Watch the connection state #

connection.stateStream.listen((state) {
  print('State: ${state.name}'); // connected, disconnecting, disconnected, ...
});

print(connection.isConnected); // true while connected

Disconnect and dispose #

await connection.finish(); // flush pending writes, then disconnect
// or: await connection.close(); // disconnect immediately

connection.dispose(); // always release resources when done

Reconnect automatically #

For long-lived links to a flaky device, connectWithReconnect keeps the connection alive across drops. Its input and state streams are stable: subscribe once and keep receiving data as the underlying connection is replaced.

final link = bluetooth.connectWithReconnect(
  address: 'AA:BB:CC:DD:EE:FF',
  policy: const BtcReconnectPolicy(
    maxAttempts: null, // retry forever (default)
    initialBackoff: Duration(seconds: 1),
    maxBackoff: Duration(seconds: 30),
  ),
);

link.state.listen((s) => print('Link: ${s.name}')); // connecting/connected/reconnecting/...
link.input.listen((bytes) => print('RX ${bytes.length} bytes'));

if (link.isConnected) await link.sendString('PING\r\n');

// ...when done
await link.close(); // stop reconnecting and release everything

Run an RFCOMM server #

final server = await bluetooth.startServer(
  serviceName: 'MyService',
  uuid: BtcUuid.spp, // optional, SPP is the default
  secure: true,
);

server.connections.listen((client) {
  print('Client connected: ${client.address}');
  client.input.listen((data) => client.output.writeString('echo: '));
});

// ...later
await server.close();

Pair and unpair #

if (caps.canBondDevices) {
  final ok = await bluetooth.bondDevice('AA:BB:CC:DD:EE:FF');
  print('Bonded: $ok');
}

bluetooth.bondState('AA:BB:CC:DD:EE:FF').listen((state) {
  print('Bond state: ${state.name}');
});

await bluetooth.unbondDevice('AA:BB:CC:DD:EE:FF');

Adapter state and control #

bluetooth.adapterState.listen((state) {
  print('Adapter: ${state.name}'); // on, off, turningOn, ...
});

if (caps.canEnableBluetooth) {
  await bluetooth.enableBluetooth(); // Android: shows the system dialog
}

if (caps.canSetDiscoverable) {
  await bluetooth.setDiscoverable(120); // seconds
}

Handle errors #

try {
  await bluetooth.connect(address: addr, uuid: uuid);
} on BtcUnsupportedException catch (e) {
  print('${e.feature} not supported on ${e.platform}');
} on BtcDisabledException {
  print('Turn on Bluetooth first');
} on BtcTimeoutException {
  print('Connection timed out');
} on BtcConnectionException catch (e) {
  print('Connection failed: ${e.message}');
} on BtcException catch (e) {
  print('Bluetooth error: ${e.message}');
}

Every failure throws a typed BtcException (or a subtype): BtcUnsupportedException, BtcPermissionException, BtcDisabledException, BtcConnectionException, BtcWriteException, BtcDiscoveryException, BtcTimeoutException, BtcAddressException, and BtcUuidException.

FAQ #

Is this Bluetooth Classic or Bluetooth Low Energy (BLE)? Bluetooth Classic: RFCOMM/SPP serial communication. For BLE, use a BLE-specific package; this plugin targets classic serial peripherals like ESP32, HC-05/HC-06, printers, and scanners.

Does it work with ESP32, ESP8266, Arduino, and HC-05/HC-06 modules? Yes. Any device that exposes a Bluetooth Classic RFCOMM/SPP serial profile works: an ESP32 using BluetoothSerial, an Arduino or ESP8266 wired to an HC-05/HC-06 module, and other UART-over-Bluetooth peripherals (thermal printers, barcode scanners, OBD-II adapters). Pair the device, then just connect(address: ...); the SPP UUID (BtcUuid.spp) is used by default.

Which platforms are supported? Android, Windows, macOS, and Linux for full client/server RFCOMM; iOS supports only MFi-certified accessories via the ExternalAccessory framework (no discovery or server mode). See Platform support.

Why does iOS behave differently? Apple restricts general Bluetooth Classic access to MFi-certified accessories. On iOS the uuid you pass to connect() is treated as the MFi protocol string, and discovery/pairing/server features are unavailable by platform design.

Can I have several connections open at once? Yes. Each connect() (and each accepted server client) returns an independent BtcConnection with its own input/output streams.

How do I know if a feature works on the current device? Call getPlatformCapabilities() and check the matching flag (e.g. canDiscoverDevices, canCreateServer) before invoking it. The plugin reports capabilities honestly per platform.

How does pairing work on macOS and Linux? On macOS, bondDevice pairs via IOBluetoothDevicePair (which may show a system prompt for PIN/passkey devices); removing a pairing has no public API, so unpair through System Settings. On Linux, bondDevice/unbondDevice use the BlueZ D-Bus API directly; devices that require a PIN or passkey additionally need a system pairing agent (e.g. a running desktop Bluetooth applet).

Support and feedback #

  • Found a bug or want a feature? Open an issue on the issue tracker.
  • Questions and ideas are welcome via GitHub Discussions.
  • Pull requests are welcome; see the repository for contribution guidelines.

About #

flutter_classic_bluetooth is an open-source, MIT-licensed Flutter plugin for Bluetooth Classic (RFCOMM/SPP) serial communication across Android, Windows, macOS, Linux, and iOS (MFi), exposing native Bluetooth stacks through one stream-based Dart API.

flutter_classic_bluetooth is created and owned by Nurullah Al Masum.

Contributors #

flutter_classic_bluetooth grows with its community, and every contributor is listed here:

flutter_classic_bluetooth contributors

Want to help? Pull requests are welcome; see Support and feedback.

32
likes
160
points
512
downloads
screenshot

Documentation

API reference

Publisher

verified publisheralmasum.dev

Weekly Downloads

Bluetooth Classic (RFCOMM/SPP) serial plugin for Flutter. Discover, pair and connect to ESP32, HC-05 and serial devices on Android, Windows, macOS, Linux, iOS.

Repository (GitHub)
View/report issues

Topics

#bluetooth #bluetooth-classic #rfcomm #serial #iot

License

MIT (license)

Dependencies

flutter, plugin_platform_interface

More

Packages that depend on flutter_classic_bluetooth

Packages that implement flutter_classic_bluetooth