From Raw Bytes to Reliable APIs: A Dart SDK Approach for BLE

Once a BLE connection is established, the real challenge begins: reliably transmitting commands, handling fragmented responses, managing timeouts, and preventing race conditions. This post focuses on the data transmission layer, the architecture that turns raw bytes into a clean, reliable API.

💡 What You’ll Learn?

Build a bulletproof BLE command queue that handles fragmented responses, prevents race conditions, manages timeouts, and keeps your app responsive.

Part 1: The Five Challenges

BLE communication faces critical problems that naive implementations ignore:

🔴 Challenge 1: Sequential Execution

BLE devices process one command at a time. Concurrent writes cause byte interleaving → device receives garbage.

Thread A: write([0x10, 0x00])
Thread B: write([0x20, 0x00])
         ↓
Device receives: [0x10, 0x20, 0x10, 0x20]  ❌

Solution: A lock ensuring only one command writes at a time.

🟣 Challenge 2: Fragmented Responses

Large responses split across multiple BLE packets (23–512 bytes each). App must buffer and reassemble.

write([0x10, 0x00])  ← Query data log
          ↓
Device: [0x01, 0x02, 0x03, ...]  ← Packet 1
        [0x04, 0x05, 0x06, ...]  ← Packet 2
        [0xFF]                    ← End marker

Solution: Handlers that decide: “Is response complete or do I need more packets?”

🟢 Challenge 3: Response Correlation

Without tracking which command awaits which response, packets meant for Command A get parsed by Command B.

Solution: Only one command executes at a time. Tight packet validation ensures packets belong to current command.

🟡 Challenge 4: Timeout Without Deadlock

Device stops responding → app waits forever unless timeouts prevent it.

Solution: Start timeout on write, restart after each packet, fail gracefully if device unresponsive.

🔵 Challenge 5: Asynchronous Notifications

Device sends unsolicited packets: real-time heart rate, battery status, charging state, device events. These don’t match current command execution.

Solution: Route asynchronous notifications to separate broadcast stream. App can listen independently while command queue remains focused on request-response cycles.

// Command queue processes request-response
final response = await queue.enqueueCommand(...);

// Meanwhile, device sends asynchronous notifications
// Heart rate: [0x78]  ← Doesn't match current command
// Battery: [0x2A]     ← Doesn't match current command
// These are broadcast to separate listener:
queue.unhandledPackets.listen((packet) {
  handleAsyncNotification(packet.bytes);  // Route to listeners
});

Part 2: BLE Physical Transport Layer

Most BLE devices use two characteristics for request-response communication:

Service: 0000fff0-0000-1000-8000-00805f9b34fb
  ├─ Write Char (0000fff6)   → Commands: App → Device
  └─ Notify Char (0000fff7)  → Responses: Device → App

How Writes Work?

App sends command bytes to write characteristic:

// Send command to device
await characteristic.write(
  [0x10, 0x00],  // Battery query
  withoutResponse: false,  // Wait for device ACK
);
How No

How Notifications Work?

Device sends response bytes via notify characteristic. App subscribes and listens:

// Enable notifications
await characteristic.setNotifyValue(true);

// Receive all responses from device
characteristic.lastValueStream.listen((packet) {
  commandQueue.handleIncomingPacket(packet);
});

Complete Request-Response Cycle

Complete Request Response Cycle

Key insight: Write and Notify are independent channels:

  1. Device pushes unsolicited notifications anytime — Heart rate, battery status, device events stream independently (no write needed)
  2. Every write gets a response — Commands always return one or more notification packets
  3. One write can trigger multiple notifications — Data requests stream multiple packets until complete

The command queue must handle both:

  • Request-response cycles: Write command → Wait for notification(s)
  • Asynchronous streams: Device sends notifications anytime, whether you requested or not

Part 3: The Command Queue Architecture

Command Queue Architecture

The queue ensures only one command executes at a time while handling all the complexity:

Core Implementation

class CommandQueue {
  final CommandWriter commandWriter;
  final _lock = Lock();  // Synchronized lock
  
  CommandExecution? _currentExecution;
  final _timeout = _TimeoutController();

  Future<List<T>> enqueueCommand<T>(
    Command command,
    ResponseHandler<T> handler, {
    Duration timeout = const Duration(seconds: 10),
  }) {
    return _lock.synchronized(() async {
      // 1. Create execution for this command
      final execution = handler.createExecution(command);
      _currentExecution = execution;

      // 2. Send command bytes to device
      await commandWriter(command.bytes);

      // 3. Start timeout timer
      _timeout.start(execution, timeout: timeout);

      try {
        // 4. Wait for response(s) to complete
        return await execution.completer.future;
      } finally {
        // 5. Cleanup
        _timeout.cancel();
        execution.dispose();
        _currentExecution = null;
      }
    });
  }

  void handleIncomingPacket(List<int> packet) async {
    final execution = _currentExecution;
    
    if (execution == null) return;  // No command waiting
    if (execution.completer.isCompleted) return;  // Already done

    try {
      await execution.handlePacket(packet);
      
      if (!execution.completer.isCompleted) {
        _timeout.start(execution);  // Restart for next packet
      }
    } catch (e) {
      execution.completer.completeError(e);
    }
  }
}

Execution Timeline

Execution Timeline

Read this: Flutter BLE Deep Dive: Platform Channels for Native Bluetooth Integration

Part 4: Response Handlers

Different commands expect different response patterns. Handlers encapsulate this logic:

Single Response Handler

Expects exactly one packet response:

final battery = await queue.enqueueCommand<int>(
  Command([0x10, 0x00]),  // Battery query
  SingleResponseHandler(
    parser: (packet) => packet[0],  // First byte = battery %
  ),
);

Completes immediately after first valid packet arrives.

Multi-Response Handler

Expects multiple packets, decides when complete:

final samples = await queue.enqueueCommand<int>(
  Command([0x20, 0x00]),  // Start data stream
  MultiResponseHandler<int>(
    parser: (packet) => packet[0],  // Each byte is a sample
    decideCompletion: (value, packetCount) {
      if (value == 0xFF) {  // End marker?
        return PacketDecision.complete();
      } else if (packetCount >= 100) {  // Got enough?
        return PacketDecision.complete();
      } else {
        return PacketDecision.resume([0x21]);  // Ask for more
      }
    },
  ),
);

Accumulates all packets, returns complete list when done.

Packet Validation State Machine

Packet Validation State Machine

Part 5: Lock-Based Serialization

The lock ensures commands never interleave:

Without Lock (Corruption):

Thread A: write([0x10])
Thread B: write([0x20])        ← Concurrent writes
        ↓
Device receives: [0x10, 0x20, 0x10, 0x20, ...]  ❌

With Lock (Sequential):

With Lock (Sequential)

Part 6: Timeout Management

Timeouts prevent deadlocks when devices don’t respond:

class _TimeoutController {
  Timer? _timer;
  
  void start(
    CommandExecution execution, {
    Duration timeout = const Duration(seconds: 10),
  }) {
    _timer?.cancel();
    _timer = Timer(timeout, () {
      if (!execution.completer.isCompleted) {
        execution.completer.completeError(
          TimeoutException('Device did not respond within $timeout'),
        );
      }
    });
  }
  
  void cancel() {
    _timer?.cancel();
    _timer = null;
  }
}

Three scenarios:

Scenario Behavior
Device responds fast ✓ Timeout cancelled, completes normally
Device offline ✗ Timeout fires after 10s, fails command
Multi-packet (slow) ✓ Timeout restarts after each packet, no false positives

Part 7: Public API

Wrap the queue in a clean interface:

class DataTransmissionLayer {
  final CommandQueue _queue;

  Future<T> sendSingleResponseCommand<T>(
    List<int> bytes,
    T Function(List<int>) parser, {
    Duration timeout = const Duration(seconds: 10),
  }) async {
    final responses = await _queue.enqueueCommand<T>(
      Command(bytes),
      SingleResponseHandler(parser: parser),
      timeout: timeout,
    );
    return responses.first;
  }

  Future<List<T>> sendMultiResponseCommand<T>(
    List<int> bytes,
    T Function(List<int>) parser, {
    required PacketDecisionMaker<T> decideCompletion,
    Duration timeout = const Duration(seconds: 10),
  }) =>
    _queue.enqueueCommand<T>(
      Command(bytes),
      MultiResponseHandler(
        parser: parser,
        decideCompletion: decideCompletion,
      ),
      timeout: timeout,
    );

  Stream<UnhandledPacket> get unhandledPackets => _queue.unhandledPackets;
}

Usage:

// Single query
final battery = await layer.sendSingleResponseCommand<int>(
  [0x10, 0x00],
  (packet) => packet[0],
);
print('Battery: $battery%');

// Data stream
final samples = await layer.sendMultiResponseCommand<int>(
  [0x20, 0x00],
  (packet) => packet[0],
  decideCompletion: (value, count) =>
    value == 0xFF 
      ? PacketDecision.complete()
      : PacketDecision.resume([0x21]),
);
print('Collected ${samples.length} samples');

Key Takeaways

✅ Build Robust BLE Communication with:

  • 🔒 Lock: Serialize writes to prevent byte corruption
  • 📦 Handlers: Support single and multi-packet response patterns
  • ⏱️ Timeout: Prevent deadlocks, restart per packet
  • 📡 Validation: Route packets only to current command
  • 📢 Broadcasting: Capture unexpected packets for debugging

This architecture scales to any BLE device protocol while preventing the common pitfalls of device communication that plague naive implementations.

Get started

Every great project starts with a productive talk.

Book a 30-minute discovery call. We'll clarify scope, recommend the right engagement model, and outline a realistic path to delivery.