Skip to content

feat: add writeCharacteristicWithoutResponseBatch for bulk transfers - #944

Open
minhtri1401 wants to merge 3 commits into
PhilipsHue:masterfrom
minhtri1401:feat/write-without-response-batch
Open

minhtri1401 wants to merge 3 commits into
PhilipsHue:masterfrom
minhtri1401:feat/write-without-response-batch

Conversation

@minhtri1401

@minhtri1401 minhtri1401 commented Oct 6, 2026 •

Copy link
Copy Markdown

Context

Firmware updates over BLE with writeCharacteristicWithoutResponse are much slower than the same transfer in a native app. Each packet costs a full Dart ↔ native round trip before the next one can start:

  • The high-level API runs resolveSingle() → getDiscoveredServices() before every write. That is an extra method-channel call, and on Android it re-serializes the whole GATT table to protobuf for every packet.
  • The write is then encoded to protobuf, handled on the Android main thread, queued in RxAndroidBle, and the result goes back through the main thread to Dart. Only then can the app send the next packet.

The link sits idle during that round trip, so the phone sends fewer packets per connection event than a native app that writes the next packet straight from the onCharacteristicWrite callback.

Related upstream issue: #940

Change

This PR has two commits.

1. fix(ios): respect write-without-response flow control

On iOS/macOS the plugin called writeValue(_:for:type: .withoutResponse) without checking canSendWriteWithoutResponse. CoreBluetooth silently drops packets written while its transmit queue is full. On fast centrals (e.g. iPhone 17) this corrupts OTA images.

  • Writes that can't be sent right away go into a per-peripheral FIFO. That queue is flushed from peripheralIsReady(toSendWriteWithoutResponse:).
  • The Flutter call completes once the packet has been handed to CoreBluetooth, so its Future applies real backpressure.
  • If the peripheral disconnects, queued writes fail with the disconnect error instead of hanging.

Behaviour change: on iOS/macOS, the Future returned by the existing writeCharacteristicWithoutResponse may now take longer to complete while the transmit queue is full. Before, it completed immediately even if the packet was dropped. Android is unchanged.

2. feat: add writeCharacteristicWithoutResponseBatch for bulk transfers

New API: one call sends a whole chunk. The native side splits it into MTU − 3 packets and sends them in order with no Dart round trip between packets.

await ble.requestMtu(deviceId: id, mtu: 247);
// Peripherals that expect a fixed frame per ATT write:
await ble.writeCharacteristicWithoutResponseBatch(characteristic, value: chunk, packetSize: 253);
  • Android: RxBleConnection.writeCharWithoutResponseBatch uses RxAndroidBle's createNewLongWriteBuilder(). The whole chunk is a single queued operation. Each packet is written as soon as the previous onCharacteristicWrite arrives, with a timeout per packet.
  • iOS/macOS: the chunk is split by maximumWriteValueLength(for: .withoutResponse) and the packets go into the per-peripheral flow-control queue from commit 1. The Flutter call completes once, after the last packet is accepted or on the first error.
  • Dart: new method on ReactiveBlePlatform (throws UnimplementedError by default, like the other methods), ReactiveBleMobilePlatform, ConnectedDeviceOperation and FlutterReactiveBle. The characteristic is resolved once per batch. An empty value returns immediately.
  • packetSize (optional): size of each packet. Defaults to MTU − 3. Use it when the peripheral treats one ATT write as one protocol frame. Must be positive and no larger than MTU − 3; iOS/macOS clamp it to maximumWriteValueLength, Android does not check it. Sent as packet_size = 3 in WriteCharacteristicRequest; 0 means default, so existing callers are unchanged.
  • No protobuf changes: the batch reuses WriteCharacteristicRequest / WriteCharacteristicInfo with a new method-channel name, writeCharacteristicWithoutResponseBatch.

Behaviour:

  • Packets are sent in order.
  • The future completes when the last packet is accepted by the platform BLE stack. That does not mean the device has processed it, so callers should still confirm reception between batches (for example with a notification from the device).
  • On failure, packets before the error may already have been sent. Resume from the offset the device reports.
  • On Android the batch holds the operation queue until it finishes, so other GATT operations wait. Notifications still arrive.
  • The gain is reduced per-write overhead (no Dart ↔ platform round trip per packet, and packets filled to the MTU). The BLE stack may still send only one packet per connection event, so protocols with a frame size below MTU − 3 gain only the saved round trips.

Test results

Same 207 KB application image (206,922–207,114 bytes across runs), custom STM32 peripheral, debug builds, one phone per platform. Speeds are KB = 1000 bytes.

Platform Master, one writeCharacteristicWithoutResponse per packet Batch, MTU 23 (20-byte packets) Batch, MTU 251 (248-byte packets)
Android 420 s, ~0.5 KB/s 56.0 s, 3.7 KB/s (2 runs, ±0.3 %) 5.6 s, 36.8 KB/s (1 run)
iOS 210 s, ~1.0 KB/s n/a, iOS negotiates MTU itself 7.5 s, 27.8 KB/s (2 runs, ±1.5 %); MTU not captured

What the Android columns show:

  • Same 20-byte packets, master vs batch: ~40 ms per write → ~5.4 ms per packet. That is the removed Dart ↔ platform round trip and per-write characteristic resolution, ~7×.
  • Batch at MTU 23 vs MTU 251: packets/s fell slightly (186 → 153) while bytes per packet rose 12×, so the ~10× there is payload per packet, not more packets per connection event. The app has to call requestMtu first; on Android 14+ the stack negotiated 251 regardless of the 247 requested.

Not yet done: second Android MTU-251 run, iOS re-run with the MTU logged, flash verification on the device after the MTU-251 run.

Automated (Flutter 3.47.5):

  • reactive_ble_platform_interface, reactive_ble_mobile and flutter_reactive_ble Dart tests pass, including a new test for the batch method-channel call, plus packetSize tests (converter, platform, ArgumentError)
  • flutter analyze reports no issues in all three packages
  • Android unit tests (./gradlew testDebugUnitTest) pass: 66 tests, including the new ReactiveBleClientTest batch test
  • ./gradlew detekt passes
  • flutter build ios --debug --no-codesign succeeds for the example app

minhtri1401 and others added 2 commits October 6, 2026 22:16
CoreBluetooth silently drops write-without-response packets when they are
written while `canSendWriteWithoutResponse` is false. Queue such writes per
peripheral and flush them from `peripheralIsReady(toSendWriteWithoutResponse:)`,
completing the Flutter call only once the packet is accepted. Pending writes
fail when the peripheral disconnects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sends a whole chunk in one call. The native side splits it into MTU - 3
packets and sends them back to back without a Dart round trip per packet:
RxAndroidBle's long-write builder on Android, the write-without-response
flow-control queue on iOS/macOS. The characteristic is resolved once per
batch.

Refs PhilipsHue#940

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@minhtri1401
minhtri1401 marked this pull request as draft October 6, 2026 15:35
@minhtri1401
minhtri1401 marked this pull request as ready for review October 6, 2026 19:01
@minhtri1401 minhtri1401 changed the title feat: add writeCharacteristicWithoutResponseBatch for bulk transfers (OTA) feat: add writeCharacteristicWithoutResponseBatch for bulk transfers Oct 6, 2026
Peripherals that treat one ATT write as one protocol frame need packets
smaller than MTU - 3. packetSize (0/null = platform default) sets
RxAndroidBle's maxBatchSize on Android and the slice size on iOS/macOS,
clamped to maximumWriteValueLength. Doc comments now describe the gain as
reduced per-write overhead rather than more packets per connection event.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant