FlutterBLEcharacteristicsflutter_blue_plusGATT

Flutter BLE Read & Write Characteristics: Complete Guide with flutter_blue_plus

April 1, 2026·7 min read

TL;DR: In Flutter BLE, you read data with characteristic.read(), write with characteristic.write([bytes]), and stream real-time updates with characteristic.setNotifyValue(true) + lastValueStream. Always check characteristic.properties before operating, handle timeouts, and parse raw bytes carefully. This guide covers every data operation you need in production.

Flutter BLE Read & Write Characteristics: Complete Guide with flutter_blue_plus

Once you've scanned, connected, and discovered services, the real work begins: reading sensor data, sending commands, and subscribing to real-time updates. BLE characteristics are the data containers you'll interact with constantly. This guide covers every read/write/notify pattern, data parsing technique, and error handling strategy you need.


Prerequisites

Before reading or writing, you need a connected device with discovered services:


Finding the Right Characteristic

Always find characteristics by UUID rather than index:

BluetoothCharacteristic? findChar(
  List<BluetoothService> services,
  String serviceUuid,
  String charUuid,
) {
  for (final svc in services) {
    if (svc.uuid.str128.toLowerCase() == serviceUuid.toLowerCase()) {
      for (final c in svc.characteristics) {
        if (c.uuid.str128.toLowerCase() == charUuid.toLowerCase()) return c;
      }
    }
  }
  return null;
}

final services = await device.discoverServices();
final char = findChar(services, SERVICE_UUID, CHAR_UUID);

Reading Characteristics

if (char.properties.read) {
  final List<int> bytes = await char.read().timeout(const Duration(seconds: 5));
  print('Raw bytes: $bytes');
}

Parsing Raw Bytes

List<int> bytes = await char.read();

// String
String text = String.fromCharCodes(bytes);

// Little-endian integers (most common in BLE)
int uint8  = bytes[0];
int uint16 = bytes[0] | (bytes[1] << 8);
int uint32 = bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24);

// Floating point
ByteData bd = ByteData.sublistView(Uint8List.fromList(bytes));
double temperature = bd.getFloat32(0, Endian.little);

// Bit flags (status byte)
bool isRunning  = (bytes[0] & 0x01) != 0;
bool hasError   = (bytes[0] & 0x02) != 0;
bool isBatLow   = (bytes[0] & 0x04) != 0;

// Heart rate (standard BLE format)
bool is16bit   = (bytes[0] & 0x01) != 0;
int heartRate  = is16bit ? bytes[1] | (bytes[2] << 8) : bytes[1];

Writing Characteristics

Write With Response

if (char.properties.write) {
  await char.write([0x01, 0x02], withoutResponse: false);
}

Write Without Response (Higher Throughput)

if (char.properties.writeWithoutResponse) {
  await char.write([0xFF], withoutResponse: true);
}

Building Payloads

// String command
await char.write('SET_MODE:1'.codeUnits);

// Structured binary command
List<int> buildCmd(int cmdId, int param) => [
  cmdId,
  param & 0xFF,
  (param >> 8) & 0xFF,
];
await char.write(buildCmd(0x10, 1000));

// Float payload
final bd = ByteData(4)..setFloat32(0, 98.6, Endian.little);
await char.write(bd.buffer.asUint8List());

Chunked Write for Large Data

BLE MTU limits how much you can send at once (default 20 bytes, up to 512 with negotiation):

Future<void> writeChunked(BluetoothCharacteristic char, List<int> data) async {
  final mtu = (await char.device.mtu.first) - 3; // subtract ATT header
  for (int i = 0; i < data.length; i += mtu) {
    final chunk = data.sublist(i, (i + mtu).clamp(0, data.length));
    await char.write(chunk, withoutResponse: true);
    await Future.delayed(const Duration(milliseconds: 10));
  }
}

// First negotiate a larger MTU
await device.requestMtu(512);
await writeChunked(txChar, firmwareBytes);

Subscribing to Notifications (Real-Time Data)

Notifications let the device push data to you — far more efficient than polling.

if (char.properties.notify) {
  await char.setNotifyValue(true);

  final sub = char.lastValueStream.listen((bytes) {
    if (bytes.isEmpty) return;
    final reading = parseSensorData(bytes);
    updateUI(reading);
  });

  // Later: clean up
  await char.setNotifyValue(false);
  await sub.cancel();
}

Notifications vs Polling

Approach Battery Latency Use When
Notify Low Immediate Device supports it (preferred)
Poll (repeated read) High On-demand No notify support
Indicate Low Reliable ACK Critical commands

Always prefer notifications — polling constantly wakes the radio and drains battery fast.


Managing Notification State in a Widget

class SensorPage extends StatefulWidget {
  final BluetoothDevice device;
  const SensorPage({required this.device});
  @override
  State<SensorPage> createState() => _SensorPageState();
}

class _SensorPageState extends State<SensorPage> {
  BluetoothCharacteristic? _char;
  StreamSubscription? _sub;
  double _temperature = 0;

  @override
  void initState() {
    super.initState();
    _setup();
  }

  Future<void> _setup() async {
    final services = await widget.device.discoverServices();
    _char = findChar(services, TEMP_SERVICE, TEMP_CHAR);
    if (_char == null) return;

    await _char!.setNotifyValue(true);
    _sub = _char!.lastValueStream.listen((bytes) {
      setState(() => _temperature = parseTemp(bytes));
    });
  }

  double parseTemp(List<int> bytes) {
    if (bytes.length < 2) return 0;
    return bytes[0] + bytes[1] / 100;
  }

  @override
  void dispose() {
    _sub?.cancel();
    _char?.setNotifyValue(false);
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => Scaffold(
    body: Center(child: Text('${_temperature.toStringAsFixed(1)}°C',
      style: const TextStyle(fontSize: 48))),
  );
}

Error Handling

Future<List<int>?> safeRead(BluetoothCharacteristic char) async {
  try {
    if (!char.properties.read) return null;
    return await char.read().timeout(const Duration(seconds: 5));
  } on TimeoutException {
    print('Read timed out — device may be busy');
    return null;
  } on FlutterBluePlusException catch (e) {
    print('BLE error ${e.errorCode}: ${e.description}');
    return null;
  }
}
Error Code Meaning Action
133 GATT_ERROR Retry after short delay
8 Connection timeout Check device proximity
19 Peer terminated Re-scan and reconnect
257 Not connected Connect first

Related Guides


Frequently Asked Questions

Why does characteristic.read() return an empty list?

This usually means: (1) the characteristic doesn't support read — always check properties.read first, (2) the device hasn't populated the value yet — try a short delay after connection, or (3) you need to write a trigger command first before the device fills the value.

What's the difference between lastValueStream and onValueReceived?

lastValueStream replays the last known value to new subscribers and emits on both reads and notifications — ideal for UI binding. onValueReceived only fires when new data arrives from the peripheral. Use lastValueStream in most cases.

How do I send a large file over BLE?

First call device.requestMtu(512) to negotiate the largest MTU possible, then split your payload into chunks of (mtu - 3) bytes and write sequentially with withoutResponse: true. Add small inter-chunk delays to avoid buffer overflow on the peripheral side.

Why does my notification stream stop after a while?

Most likely the device disconnected. Always listen to device.connectionState and re-subscribe to notifications after reconnecting. Also ensure you're storing your StreamSubscription in a field and not letting it get garbage collected.

Can I read and write simultaneously?

BLE ATT operations are serialized — flutter_blue_plus queues them internally. Don't try to fire parallel operations on the same characteristic; use sequential async/await chains.

What's the best way to learn BLE data operations with real devices?

The BLE Flutter Course teaches every read/write/notify pattern in this guide using actual BLE hardware — ESP32, Arduino, and commercial sensors — so theory immediately becomes working code.


Build Production BLE Apps

You now have everything needed for robust BLE data pipelines: reading sensor values, writing commands, streaming real-time notifications, handling errors, and transferring large payloads.

Next step: See Build a Complete Flutter BLE App to put every pattern together, or enroll in the BLE Flutter Course for structured hands-on learning.

👉 Enroll in the BLE Flutter Course →

Full Course Available

Ready to master BLE with Flutter?

Go from zero to production-ready BLE apps. The complete course covers scanning, connecting, GATT communication, custom hardware, background processing, and deploying real BLE-powered Flutter apps.

Enroll in the Course