Skip to content

Fix a number of BLE workflow and bonding issues - #11178

Draft
dhalbert wants to merge 2 commits into
adafruit:mainfrom
dhalbert:nordic-ble-workflow-reconnect
Draft

Fix a number of BLE workflow and bonding issues#11178
dhalbert wants to merge 2 commits into
adafruit:mainfrom
dhalbert:nordic-ble-workflow-reconnect

Conversation

@dhalbert

@dhalbert dhalbert commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

🤖 Generated with Claude Code

Draft: I want to test espressif and make further commits before this is ready for review.

The original bug

Typing at the "Press any key to enter the REPL" prompt over the BLE workflow serial ended the session after exactly one character. The first byte breaks main.c's wait loop, which then calls bleio_reset(), and on nordic that restarts the SoftDevice. The bonding survives that restart; the link does not.

bleio_reset() now returns early when user code never imported _bleio. The stack restart is only there to drop leftover user-created GATT services, which the SoftDevice offers no way to remove individually, so it is unnecessary when the VM cannot have created any.

Reconnecting when the restart does have to happen

The peripheral now chooses its reconnect advertising strategy from what the bonded central distributed at pairing time:

Central Distributes IRK + identity address Strategy
iOS, macOS, Android yes undirected, private address (unchanged)
BlueZ (default Privacy=off), Windows no ADV_DIRECT_IND aimed at the address it connected from

A central that distributed no IRK is not using privacy. It cannot resolve our private address, so it will never recognise an undirected advertisement as us — but it does connect from a stable address, which we now store with the bond and can target. Centrals that do use privacy keep undirected advertising, which is what works for them and what Apple's accessory guidelines require.

Bugs found along the way

The commit message has the full list with details. In brief:

  • the disconnect wait in bleio_adapter_reset() waited zero milliseconds, so the stack was torn down before the disconnect went out and the central saw a supervision timeout
  • the resolvable private address rotated every second instead of the SoftDevice default of 15 minutes
  • directed advertising selected the high duty cycle type, capped by the spec at 1.28 seconds, for unlimited timeouts
  • ble_drv_remove_heap_handlers() stopped after the first handler it removed, leaving the rest pointing into a heap about to be freed
  • common_hal_bleio_packet_buffer_deinited() could never return true, so the guards in supervisor/shared/bluetooth/serial.c never took effect
  • the BLE serial RX ringbuf was given a size four times the buffer it actually had
  • connection slots retained the previous peer's keys, so a recycled slot could store one peer's IRK with another's LTK

Several of these had been copied into other ports and are fixed there too.

Testing

On a Feather nRF52840 Express:

  • Linux with tools/workflow/ble_terminal.py, added in this PR: reconnects in about 140 ms by directed advertising.
  • macOS Chrome with https://code.circuitpython.org: serial works, and reconnects by undirected private advertising. Before this PR it accepted no serial input at all on a first connection.

Not tested: espressif, silabs, ble_hci. Those changes are the copied bug fixes plus a mechanical signature change.

Still broken, and verified as pre-existing against a stock 10.3.0-alpha.4 build, so not regressions from this PR: Chrome on Windows spins while reading boot_out.txt for board information, and Chrome on Linux stops with the initial BLE workflow popup still visible.

Where to look hardest

_common_hal_bleio_adapter_start_advertising() now takes directed_to as a raw bleio_raw_address_t instead of a bleio_address_obj_t, moving mp_get_buffer_raise() up into the user-facing wrapper. That touches all four implementations. The reason is that supervisor/shared calls the internal function and must not raise.

peer_id.id_addr_info now carries two meanings, distinguished by whether peer_id.id_info holds an IRK: either an identity address the peer distributed, or merely the address the peer connected from. bonding_load_identities() skips entries with no IRK so the two cannot be confused.

Related issues

Original motivation was to fix BLE workflow serial dropping the link
on the first keystroke. Typing at the "Press any key to enter the REPL"
prompt over the BLE workflow serial ended the session after exactly
one character. The first byte breaks `main.c`'s wait loop, which then
calls `bleio_reset()`, and on nordic restarts the SoftDevice.
The BLE bonding survives that restart, but the link does not.

The main debugging was done in the nordic port.

Behaviour changes:

* `bleio_reset()` now returns early when user code never imported `_bleio`. The
  SoftDevice restart was there only to drop leftover user-created GATT
  services. The SoftDevice offers no way to remove those individually, so it
  is unnecessary when the VM cannot have created any in this VM
  instantiation. Tracked by a flag set in `bleio___init__()`.

* A bonded central may distribute no identity address; this is true for BlueZ
  with its default `Privacy=off`, and on Windows. In that case, store the
  address it connected from in `peer_id.id_addr_info` and reconnect by aiming
  `ADV_DIRECT_IND` at it. Such a central does not use privacy, so it cannot
  resolve a private address and will never recognise our undirected
  advertisement, but it does connect from a stable address we can
  target. Centrals that do distribute an IRK (iOS, macOS, Android) keep
  undirected private advertising, which is what works for them and what
  Apple's accessory guidelines require.

Various bugs found during debugging are now fixed. Similar bugs in other
ports (due to code copying) were fixed after the fixes were vetted in
nordic. They have not been tested yet.

* `bleio_adapter_reset()` mistakenly waited zero milliseconds for disconnects to complete:
  the loop read `while (any_connected && ...)` with `any_connected` initialised
  to `false`. The SoftDevice was then disabled before the disconnect PDU went
  out, so the central saw a link supervision timeout rather than a disconnect
  reason. Now a `do/while`. Same bug was also fixed in espressif and silabs.

* Anonymous advertising set `private_addr_cycle_s` to `timeout + 1`, and the
  workflow advertises with an unlimited timeout encoded as zero, so the
  resolvable private address rotated every second -- way too fast for a central to
  resolve an address and still connect to it. Passing zero selects the
  SoftDevice default of 15 minutes, which is also the maximum rotation period
  Microsoft's accessory guidelines allow.

* Directed advertising selected the high duty cycle type for an unlimited
  timeout, for the same "zero means unlimited" reason. The spec caps high duty
  cycle at 1.28 seconds. Also fixed in espressif.

* `ble_drv_remove_heap_handlers()` mistakenly stopped after the first handler
  it removed, because `ble_drv_remove_event_handler()` clears the removed
  entry's next pointer. Same bug fixed in espressif's
  `ble_event_remove_heap_handlers()`.

* `common_hal_bleio_packet_buffer_deinit()` never cleared `self->characteristic`,
  so `common_hal_bleio_packet_buffer_deinited()` always reported false and the
  guards in `supervisor/shared/bluetooth/serial.c` never took effect. It also
  removed the client event handler for server-side buffers. Same bug fixed in
  silabs and ble_hci.

* The BLE serial RX ringbuf was mistakenly given a size of `sizeof(_incoming) *
  sizeof(uint32_t)`, four times the 256 bytes it actually has.

* `bonding_load_identities()` returned peers that had distributed no IRK,
  mistakenly handing an all-zero identity to `sd_ble_gap_device_identities_set()`.

* Connection slots mistakenly retained the previous peer's keys, because
  `bonding_keys` was cleared only on adapter enable and not per connection, so a
  recycled slot could accidentally store one peer's IRK with another's LTK. Now
  they are cleared.

`_common_hal_bleio_adapter_start_advertising()` now takes `directed_to` as a raw
`bleio_raw_address_t` instead of a `bleio_address_obj_t`, which allows moving
`mp_get_buffer_raise()` up into `shared-bindings`. Now the code in
`supervisor/shared` calls the internal function and cannot raise an exception.

Some typos about the workflow UUIDs were fixed in `docs/workflows.md`.

Tested on a Feather nRF52840 Express. A simple terminal program using bleak was
developed for testing. It is now in `tools/workflow/ble_terminal.py`.
Linux with the bleak terminal reconnects in about 140 ms by directed advertising.
https://code.circuitpython.org only works properly on macOS Chrome now. It
reconnects by undirected private advertising.

Still to fix: Chrome on Windows spins on startup while trying to read
`boot_out.txt` for board information. Chrome on Linux doesn't even get that
far: it stops with the initial BLE workflow popup visible.

Commit message edited by @dhalbert.

Co-Authored-By: Claude Opus 5 <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