From cd4b04769775c88fa2f4cfb8a3340dd58d266c80 Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Wed, 5 Aug 2026 15:02:46 -0400 Subject: [PATCH 1/2] Fix a number of BLE workflow and bonding 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 --- devices/ble_hci/common-hal/_bleio/Adapter.c | 18 +- .../ble_hci/common-hal/_bleio/PacketBuffer.c | 2 + docs/workflows.md | 5 +- ports/espressif/common-hal/_bleio/Adapter.c | 32 +- .../espressif/common-hal/_bleio/ble_events.c | 9 +- ports/nordic/bluetooth/ble_drv.c | 9 +- .../feather_nrf52840_express/mpconfigboard.h | 4 + ports/nordic/common-hal/_bleio/Adapter.c | 77 ++- ports/nordic/common-hal/_bleio/Connection.c | 1 + ports/nordic/common-hal/_bleio/Connection.h | 4 + ports/nordic/common-hal/_bleio/PacketBuffer.c | 8 +- ports/nordic/common-hal/_bleio/__init__.c | 14 + ports/nordic/common-hal/_bleio/bonding.c | 67 +++ ports/nordic/common-hal/_bleio/bonding.h | 8 + ports/silabs/common-hal/_bleio/Adapter.c | 15 +- ports/silabs/common-hal/_bleio/PacketBuffer.c | 2 + shared-bindings/_bleio/Adapter.h | 6 +- shared-bindings/_bleio/__init__.c | 14 + shared-bindings/_bleio/__init__.h | 10 + shared-module/_bleio/Address.c | 7 + shared-module/_bleio/Address.h | 13 + supervisor/shared/bluetooth/serial.c | 2 +- tools/workflow/ble_terminal.py | 512 ++++++++++++++++++ 23 files changed, 801 insertions(+), 38 deletions(-) create mode 100755 tools/workflow/ble_terminal.py diff --git a/devices/ble_hci/common-hal/_bleio/Adapter.c b/devices/ble_hci/common-hal/_bleio/Adapter.c index aaff47f42a6..bb4acfbda48 100644 --- a/devices/ble_hci/common-hal/_bleio/Adapter.c +++ b/devices/ble_hci/common-hal/_bleio/Adapter.c @@ -643,7 +643,7 @@ uint32_t _common_hal_bleio_adapter_start_advertising(bleio_adapter_obj_t *self, bool connectable, bool anonymous, uint32_t timeout, float interval, const uint8_t *advertising_data, uint16_t advertising_data_len, const uint8_t *scan_response_data, uint16_t scan_response_data_len, - mp_int_t tx_power, const bleio_address_obj_t *directed_to) { + mp_int_t tx_power, const bleio_raw_address_t *directed_to) { check_enabled(self); if (self->now_advertising) { @@ -662,11 +662,8 @@ uint32_t _common_hal_bleio_adapter_start_advertising(bleio_adapter_obj_t *self, // Copy peer address, if supplied. if (directed_to) { - mp_buffer_info_t bufinfo; - if (mp_get_buffer(directed_to->bytes, &bufinfo, MP_BUFFER_READ)) { - peer_addr.type = directed_to->type; - memcpy(&peer_addr.a.val, bufinfo.buf, sizeof(peer_addr.a.val)); - } + peer_addr.type = directed_to->type; + memcpy(&peer_addr.a.val, directed_to->bytes, sizeof(peer_addr.a.val)); } bool extended = @@ -808,13 +805,20 @@ void common_hal_bleio_adapter_start_advertising(bleio_adapter_obj_t *self, mp_raise_NotImplementedError(MP_ERROR_TEXT("Only tx_power=0 supported")); } + // Convert here, where raising is allowed. The internal call must stay raise-free + // because supervisor/shared uses it too. + bleio_raw_address_t raw_directed_to; + if (directed_to != NULL) { + bleio_address_to_raw(directed_to, &raw_directed_to); + } + const uint32_t result = _common_hal_bleio_adapter_start_advertising( self, connectable, anonymous, timeout, interval, advertising_data_bufinfo->buf, advertising_data_bufinfo->len, scan_response_data_bufinfo->buf, scan_response_data_bufinfo->len, - tx_power, directed_to); + tx_power, directed_to != NULL ? &raw_directed_to : NULL); if (result) { mp_raise_bleio_BluetoothError(MP_ERROR_TEXT("Already advertising")); diff --git a/devices/ble_hci/common-hal/_bleio/PacketBuffer.c b/devices/ble_hci/common-hal/_bleio/PacketBuffer.c index 771a1509f39..f29f4dd88a7 100644 --- a/devices/ble_hci/common-hal/_bleio/PacketBuffer.c +++ b/devices/ble_hci/common-hal/_bleio/PacketBuffer.c @@ -245,6 +245,8 @@ void common_hal_bleio_packet_buffer_deinit(bleio_packet_buffer_obj_t *self) { if (!common_hal_bleio_packet_buffer_deinited(self)) { bleio_characteristic_clear_observer(self->characteristic); ringbuf_deinit(&self->ringbuf); + // Mark as deinited, so common_hal_bleio_packet_buffer_deinited() reports it. + self->characteristic = NULL; } } diff --git a/docs/workflows.md b/docs/workflows.md index ad676d5678e..628e20388c3 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -95,13 +95,14 @@ to enable file system access. ### CircuitPython Service -The base UUID for the CircuitPython service is `ADAFXXXX-4369-7263-7569-7450794686e`. The `XXXX` is +The base UUID for the CircuitPython service is `ADAFXXXX-4369-7263-7569-74507974686e`. The `XXXX` is replaced by the four specific digits below. The service itself is `0001`. -#### TX - `0002` / RX - `0003` +#### RX - `0002` / TX - `0003` The TX and RX characteristics for the CircuitPython service work just like the Nordic Uart Service (NUS) but have different UUIDs to prevent conflicts with user-created NUS services. +They are named from the NUS peripheral's point of view: a client writes to RX and subscribes to TX. #### Version - `0100` The Version characteristic is read-only and returns the UTF-8 encoded version string. diff --git a/ports/espressif/common-hal/_bleio/Adapter.c b/ports/espressif/common-hal/_bleio/Adapter.c index 4feb6b2b96b..3807f7fd8c9 100644 --- a/ports/espressif/common-hal/_bleio/Adapter.c +++ b/ports/espressif/common-hal/_bleio/Adapter.c @@ -323,6 +323,13 @@ static void _convert_address(const bleio_address_obj_t *address, ble_addr_t *nim memcpy(nimble_address->val, (uint8_t *)address_buf_info.buf, NUM_BLEIO_ADDRESS_BYTES); } +// Same, from a raw address. Unlike _convert_address() this cannot raise, so it is safe +// on the path used by supervisor/shared. +static void _convert_raw_address(const bleio_raw_address_t *address, ble_addr_t *nimble_address) { + nimble_address->type = address->type; + memcpy(nimble_address->val, address->bytes, NUM_BLEIO_ADDRESS_BYTES); +} + static int _mtu_reply(uint16_t conn_handle, const struct ble_gatt_error *error, uint16_t mtu, void *arg) { @@ -535,7 +542,7 @@ uint32_t _common_hal_bleio_adapter_start_advertising(bleio_adapter_obj_t *self, bool connectable, bool anonymous, uint32_t timeout, float interval, const uint8_t *advertising_data, uint16_t advertising_data_len, const uint8_t *scan_response_data, uint16_t scan_response_data_len, - mp_int_t tx_power, const bleio_address_obj_t *directed_to) { + mp_int_t tx_power, const bleio_raw_address_t *directed_to) { if (ble_gap_adv_active() && !self->user_advertising) { return BLE_HS_EBUSY; @@ -547,7 +554,7 @@ uint32_t _common_hal_bleio_adapter_start_advertising(bleio_adapter_obj_t *self, ble_addr_t peer; if (directed_to != NULL) { - _convert_address(directed_to, &peer); + _convert_raw_address(directed_to, &peer); } uint8_t own_addr_type; @@ -558,7 +565,11 @@ uint32_t _common_hal_bleio_adapter_start_advertising(bleio_adapter_obj_t *self, return rc; } - bool high_duty_directed = directed_to != NULL && interval <= 3.5 && timeout <= 1; // Really 1.3, but it's an int + // High duty cycle directed advertising is capped at 1.28 seconds by the spec, so it + // only suits a short, finite window. An unlimited timeout is encoded as zero, which + // would otherwise satisfy "timeout <= 1" and pick a type that stops almost at once. + bool high_duty_directed = directed_to != NULL && interval <= 3.5 && + timeout != 0 && timeout <= 1; // Really 1.3, but it's an int uint32_t timeout_ms = timeout * 1000; @@ -706,13 +717,20 @@ void common_hal_bleio_adapter_start_advertising(bleio_adapter_obj_t *self, bool BLE_HS_FOREVER / 1000 - 1); } + // Convert here, where raising is allowed. The internal call must stay raise-free + // because supervisor/shared uses it too. + bleio_raw_address_t raw_directed_to; + if (directed_to != NULL) { + bleio_address_to_raw(directed_to, &raw_directed_to); + } + CHECK_NIMBLE_ERROR(_common_hal_bleio_adapter_start_advertising(self, connectable, anonymous, timeout, interval, advertising_data_bufinfo->buf, advertising_data_bufinfo->len, scan_response_data_bufinfo->buf, scan_response_data_bufinfo->len, tx_power, - directed_to)); + directed_to != NULL ? &raw_directed_to : NULL)); self->user_advertising = true; } @@ -837,13 +855,13 @@ void bleio_adapter_reset(bleio_adapter_obj_t *adapter) { // Wait up to 125 ms (128 ticks) for disconnect to complete. This should be // greater than most connection intervals. - bool any_connected = false; + bool any_connected; uint64_t start_ticks = supervisor_ticks_ms64(); - while (any_connected && supervisor_ticks_ms64() - start_ticks < 128) { + do { any_connected = false; for (size_t i = 0; i < BLEIO_TOTAL_CONNECTION_COUNT; i++) { bleio_connection_internal_t *connection = &bleio_connections[i]; any_connected |= connection->conn_handle != BLEIO_HANDLE_INVALID; } - } + } while (any_connected && supervisor_ticks_ms64() - start_ticks < 128); } diff --git a/ports/espressif/common-hal/_bleio/ble_events.c b/ports/espressif/common-hal/_bleio/ble_events.c index b57362fe007..fe14e8ef027 100644 --- a/ports/espressif/common-hal/_bleio/ble_events.c +++ b/ports/espressif/common-hal/_bleio/ble_events.c @@ -28,11 +28,14 @@ void ble_event_reset(void) { void ble_event_remove_heap_handlers(void) { ble_event_handler_entry_t *it = MP_STATE_VM(ble_event_handler_entries); while (it != NULL) { - // If the param is on the heap, then delete the handler. - if (gc_ptr_on_heap(it->param)) { + // Capture next before removing, because removing clears the entry's next. + ble_event_handler_entry_t *next = it->next; + // If the entry or its param is on the heap, then delete the handler. + // Both are checked because the heap they live on is about to go away. + if (gc_ptr_on_heap(it) || gc_ptr_on_heap(it->param)) { ble_event_remove_handler(it->func, it->param); } - it = it->next; + it = next; } } diff --git a/ports/nordic/bluetooth/ble_drv.c b/ports/nordic/bluetooth/ble_drv.c index 7085aa477b5..e13f637f4e9 100644 --- a/ports/nordic/bluetooth/ble_drv.c +++ b/ports/nordic/bluetooth/ble_drv.c @@ -141,11 +141,14 @@ void ble_drv_reset(void) { void ble_drv_remove_heap_handlers(void) { ble_drv_evt_handler_entry_t *it = MP_STATE_VM(ble_drv_evt_handler_entries); while (it != NULL) { - // If the param is on the heap, then delete the handler. - if (gc_ptr_on_heap(it->param)) { + // Capture next before removing, because removing clears the entry's next. + ble_drv_evt_handler_entry_t *next = it->next; + // If the entry or its param is on the heap, then delete the handler. + // Both are checked because the heap they live on is about to go away. + if (gc_ptr_on_heap(it) || gc_ptr_on_heap(it->param)) { ble_drv_remove_event_handler(it->func, it->param); } - it = it->next; + it = next; } } diff --git a/ports/nordic/boards/feather_nrf52840_express/mpconfigboard.h b/ports/nordic/boards/feather_nrf52840_express/mpconfigboard.h index 6f3d13d9682..e59645177c1 100644 --- a/ports/nordic/boards/feather_nrf52840_express/mpconfigboard.h +++ b/ports/nordic/boards/feather_nrf52840_express/mpconfigboard.h @@ -46,3 +46,7 @@ #define DEFAULT_UART_BUS_RX (&pin_P0_24) #define DEFAULT_UART_BUS_TX (&pin_P0_25) + +// Uncomment to get a serial console on the TX and RX pins, in addition to USB. +// #define CIRCUITPY_CONSOLE_UART_TX (DEFAULT_UART_BUS_TX) +// #define CIRCUITPY_CONSOLE_UART_RX (DEFAULT_UART_BUS_RX) diff --git a/ports/nordic/common-hal/_bleio/Adapter.c b/ports/nordic/common-hal/_bleio/Adapter.c index 2ca9df89710..80b41083aa2 100644 --- a/ports/nordic/common-hal/_bleio/Adapter.c +++ b/ports/nordic/common-hal/_bleio/Adapter.c @@ -248,6 +248,14 @@ static bool adapter_on_ble_evt(ble_evt_t *ble_evt, void *self_in) { connection->connection_obj = mp_const_none; connection->pair_status = PAIR_NOT_PAIRED; connection->mtu = 0; + // Remember where the peer connected from. A central that does not + // distribute an identity address during bonding still has to reach us + // somehow, and this is the only address we will ever learn for it. + connection->peer_addr = connected->peer_addr; + // Start from a clean keyset. The SoftDevice only fills in the keys the + // peer actually distributes, so a recycled connection slot would other- + // wise carry the previous peer's keys into this peer's stored bond. + bonding_clear_keys(&connection->bonding_keys); ble_drv_add_event_handler_entry(&connection->handler_entry, connection_on_ble_evt, connection); self->connection_objs = NULL; @@ -615,6 +623,13 @@ static void _convert_address(const bleio_address_obj_t *address, ble_gap_addr_t memcpy(sd_address->addr, (uint8_t *)address_buf_info.buf, NUM_BLEIO_ADDRESS_BYTES); } +// Same, from a raw address. Unlike _convert_address() this cannot raise, so it is safe +// on the path used by supervisor/shared. +static void _convert_raw_address(const bleio_raw_address_t *address, ble_gap_addr_t *sd_address) { + sd_address->addr_type = address->type; + memcpy(sd_address->addr, address->bytes, NUM_BLEIO_ADDRESS_BYTES); +} + mp_obj_t common_hal_bleio_adapter_connect(bleio_adapter_obj_t *self, bleio_address_obj_t *address, mp_float_t timeout) { ble_gap_addr_t addr; _convert_address(address, &addr); @@ -735,7 +750,7 @@ uint32_t _common_hal_bleio_adapter_start_advertising(bleio_adapter_obj_t *self, bool connectable, bool anonymous, uint32_t timeout, float interval, const uint8_t *advertising_data, uint16_t advertising_data_len, const uint8_t *scan_response_data, uint16_t scan_response_data_len, - mp_int_t tx_power, const bleio_address_obj_t *directed_to) { + mp_int_t tx_power, const bleio_raw_address_t *directed_to) { if (self->current_advertising_data != NULL && self->current_advertising_data == self->advertising_data) { return NRF_ERROR_BUSY; } @@ -753,6 +768,26 @@ uint32_t _common_hal_bleio_adapter_start_advertising(bleio_adapter_obj_t *self, if (timeout == 0) { timeout = BLE_GAP_ADV_TIMEOUT_GENERAL_UNLIMITED; } + // Anonymous advertising means the BLE workflow is trying to reconnect to a bond + // without being trackable by anyone else. That works for a central that uses privacy, + // because it holds our IRK and can resolve the private address we advertise under. + // + // A central that distributed no IRK does not use privacy. It cannot resolve us, so it + // will never recognize an undirected private advertisement as us -- but it does + // connect from a stable address, which we stored with the bond and can aim a directed + // advertisement at. That is the only thing such a host will act on, and it is much + // faster besides. Centrals that do use privacy keep the undirected path, which is + // also what Apple's accessory guidelines require of us. + ble_gap_addr_t reconnect_peer; + bool directed_reconnect = anonymous && directed_to == NULL && + bonding_load_directed_reconnect_address(&reconnect_peer); + if (directed_reconnect) { + anonymous = false; + // ADV_DIRECT_IND carries no advertising data, so drop whatever we were given. + advertising_data_len = 0; + scan_response_data_len = 0; + } + uint32_t err_code; bool extended = advertising_data_len > BLE_GAP_ADV_SET_DATA_SIZE_MAX || scan_response_data_len > BLE_GAP_ADV_SET_DATA_SIZE_MAX; @@ -771,13 +806,18 @@ uint32_t _common_hal_bleio_adapter_start_advertising(bleio_adapter_obj_t *self, } else if (connectable) { if (directed_to == NULL) { adv_type = BLE_GAP_ADV_TYPE_CONNECTABLE_SCANNABLE_UNDIRECTED; - } else if (interval <= 3.5 && timeout <= 1.3) { + // High duty cycle directed advertising is capped at 1.28 seconds by the + // spec, so it only suits a short, finite window. An unlimited timeout is + // encoded as zero, which would otherwise satisfy "timeout <= 1.3" and pick + // a type that stops almost immediately. + } else if (interval <= 3.5 && + timeout != BLE_GAP_ADV_TIMEOUT_GENERAL_UNLIMITED && timeout <= 1.3) { adv_type = BLE_GAP_ADV_TYPE_CONNECTABLE_NONSCANNABLE_DIRECTED_HIGH_DUTY_CYCLE; - _convert_address(directed_to, &peer_address); + _convert_raw_address(directed_to, &peer_address); peer = &peer_address; } else { adv_type = BLE_GAP_ADV_TYPE_CONNECTABLE_NONSCANNABLE_DIRECTED; - _convert_address(directed_to, &peer_address); + _convert_raw_address(directed_to, &peer_address); peer = &peer_address; } } else if (scan_response_data_len > 0) { @@ -786,6 +826,12 @@ uint32_t _common_hal_bleio_adapter_start_advertising(bleio_adapter_obj_t *self, adv_type = BLE_GAP_ADV_TYPE_NONCONNECTABLE_NONSCANNABLE_UNDIRECTED; } + // Low duty cycle, so it can run for as long as the workflow keeps advertising. + if (directed_reconnect) { + adv_type = BLE_GAP_ADV_TYPE_CONNECTABLE_NONSCANNABLE_DIRECTED; + peer = &reconnect_peer; + } + if (anonymous) { ble_gap_privacy_params_t privacy = { .privacy_mode = BLE_GAP_PRIVACY_MODE_DEVICE_PRIVACY, @@ -794,7 +840,13 @@ uint32_t _common_hal_bleio_adapter_start_advertising(bleio_adapter_obj_t *self, // advertising. This prevents a potential race condition where we // fire off a beacon with the same advertising data but a new MAC // address just as we tear down the connection. - .private_addr_cycle_s = timeout + 1, + // + // Unlimited advertising has no such moment, and timeout + 1 would then + // rotate every second, too fast for a central to resolve an address and + // still connect to it before it changes. Zero asks the SoftDevice for its + // default of BLE_GAP_DEFAULT_PRIVATE_ADDR_CYCLE_INTERVAL_S (15 minutes). + .private_addr_cycle_s = + timeout == BLE_GAP_ADV_TIMEOUT_GENERAL_UNLIMITED ? 0 : timeout + 1, .p_device_irk = NULL, }; err_code = sd_ble_gap_privacy_set(&privacy); @@ -905,13 +957,20 @@ void common_hal_bleio_adapter_start_advertising(bleio_adapter_obj_t *self, bool memcpy(self->advertising_data, advertising_data_bufinfo->buf, advertising_data_bufinfo->len); memcpy(self->scan_response_data, scan_response_data_bufinfo->buf, scan_response_data_bufinfo->len); + // Convert here, where raising is allowed. The internal call must stay raise-free + // because supervisor/shared uses it too. + bleio_raw_address_t raw_directed_to; + if (directed_to != NULL) { + bleio_address_to_raw(directed_to, &raw_directed_to); + } + check_nrf_error(_common_hal_bleio_adapter_start_advertising(self, connectable, anonymous, timeout, interval, self->advertising_data, advertising_data_bufinfo->len, self->scan_response_data, scan_response_data_bufinfo->len, tx_power, - directed_to)); + directed_to != NULL ? &raw_directed_to : NULL)); self->user_advertising = true; } @@ -998,13 +1057,13 @@ void bleio_adapter_reset(bleio_adapter_obj_t *adapter) { // Wait up to 125 ms (128 ticks) for disconnect to complete. This should be // greater than most connection intervals. - bool any_connected = false; + bool any_connected; uint64_t start_ticks = supervisor_ticks_ms64(); - while (any_connected && supervisor_ticks_ms64() - start_ticks < 128) { + do { any_connected = false; for (size_t i = 0; i < BLEIO_TOTAL_CONNECTION_COUNT; i++) { bleio_connection_internal_t *connection = &bleio_connections[i]; any_connected |= connection->conn_handle != BLE_CONN_HANDLE_INVALID; } - } + } while (any_connected && supervisor_ticks_ms64() - start_ticks < 128); } diff --git a/ports/nordic/common-hal/_bleio/Connection.c b/ports/nordic/common-hal/_bleio/Connection.c index f32034582b4..e4d3d01ea69 100644 --- a/ports/nordic/common-hal/_bleio/Connection.c +++ b/ports/nordic/common-hal/_bleio/Connection.c @@ -314,6 +314,7 @@ void bleio_connection_clear(bleio_connection_internal_t *self) { self->conn_handle = BLE_CONN_HANDLE_INVALID; self->pair_status = PAIR_NOT_PAIRED; self->is_central = false; + memset(&self->peer_addr, 0, sizeof(self->peer_addr)); bonding_clear_keys(&self->bonding_keys); } diff --git a/ports/nordic/common-hal/_bleio/Connection.h b/ports/nordic/common-hal/_bleio/Connection.h index ea1edf17603..053051fcc26 100644 --- a/ports/nordic/common-hal/_bleio/Connection.h +++ b/ports/nordic/common-hal/_bleio/Connection.h @@ -31,6 +31,10 @@ typedef enum { typedef struct { uint16_t conn_handle; bool is_central; + // Address the peer used to establish this connection. Not necessarily an identity + // address: a peer using privacy connects from a resolvable private address, which + // is of no use once the connection is gone. Check the type before relying on it. + ble_gap_addr_t peer_addr; // Remote services discovered when this peripheral is acting as a client. mp_obj_list_t *remote_service_list; // The advertising data and scan response buffers are held by us, not by the SD, so we must diff --git a/ports/nordic/common-hal/_bleio/PacketBuffer.c b/ports/nordic/common-hal/_bleio/PacketBuffer.c index 6b3e86e3b7a..5be9896b706 100644 --- a/ports/nordic/common-hal/_bleio/PacketBuffer.c +++ b/ports/nordic/common-hal/_bleio/PacketBuffer.c @@ -481,8 +481,14 @@ bool common_hal_bleio_packet_buffer_deinited(bleio_packet_buffer_obj_t *self) { void common_hal_bleio_packet_buffer_deinit(bleio_packet_buffer_obj_t *self) { if (!common_hal_bleio_packet_buffer_deinited(self)) { - ble_drv_remove_event_handler(packet_buffer_on_ble_client_evt, self); + if (self->client) { + ble_drv_remove_event_handler(packet_buffer_on_ble_client_evt, self); + } else { + ble_drv_remove_event_handler(packet_buffer_on_ble_server_evt, self); + } ringbuf_deinit(&self->ringbuf); + // Mark as deinited, so common_hal_bleio_packet_buffer_deinited() reports it. + self->characteristic = NULL; } } diff --git a/ports/nordic/common-hal/_bleio/__init__.c b/ports/nordic/common-hal/_bleio/__init__.c index 9dc58d7687c..a0bfe211ccc 100644 --- a/ports/nordic/common-hal/_bleio/__init__.c +++ b/ports/nordic/common-hal/_bleio/__init__.c @@ -101,11 +101,25 @@ void bleio_reset(void) { return; } + // If user code never imported _bleio, then it cannot have added anything to the + // GATT attribute table or created any connections, so there is nothing to tear + // down. Skipping matters: the disable/enable cycle below drops any BLE workflow + // session that is in progress. The cycle exists only because the SoftDevice has + // no way to remove a GATT service once it has been added. + if (!bleio_user_imported()) { + return; + } + + // A BLE workflow connection will be dropped by the cycle below. That is accepted: + // bonds survive, so the peripheral re-advertises immediately and a client that + // kept its bond can reconnect. Deferring the cycle instead would let the attribute + // table fill up as re-run user code re-adds its services. supervisor_stop_bluetooth(); bleio_adapter_reset(&common_hal_bleio_adapter_obj); common_hal_bleio_adapter_set_enabled(&common_hal_bleio_adapter_obj, false); bonding_reset(); supervisor_start_bluetooth(); + bleio_clear_user_imported(); } // The singleton _bleio.Adapter object, bound to _bleio.adapter diff --git a/ports/nordic/common-hal/_bleio/bonding.c b/ports/nordic/common-hal/_bleio/bonding.c index a1cf0b94f0c..3f4b9d7c567 100644 --- a/ports/nordic/common-hal/_bleio/bonding.c +++ b/ports/nordic/common-hal/_bleio/bonding.c @@ -214,7 +214,50 @@ static void write_sys_attr_block(bleio_connection_internal_t *connection) { return; } +// True if this identity carries an actual IRK. A peer that did not distribute one +// leaves peer_id zeroed, because bonding_clear_keys() clears the whole keyset. +static bool identity_has_irk(const ble_gap_id_key_t *identity) { + for (size_t i = 0; i < BLE_GAP_SEC_KEY_LEN; i++) { + if (identity->id_info.irk[i] != 0) { + return true; + } + } + return false; +} + +// True if any of the six address bytes is set. A peer that distributed no identity +// address leaves them all zero, because bonding_clear_keys() clears the whole keyset. +static bool address_is_set(const ble_gap_addr_t *address) { + for (size_t i = 0; i < BLE_GAP_ADDR_LEN; i++) { + if (address->addr[i] != 0) { + return true; + } + } + return false; +} + static void write_keys_block(bleio_connection_internal_t *connection) { + // A peer that uses privacy distributes an identity address, and an IRK with it. + // One that does not — BlueZ with its default Privacy=off, and Windows — sends + // neither, leaving id_addr_info zeroed, and reconnecting to such a peer later + // still needs an address for it. Fall back to the address it connected from, but + // only when that is an identity type: a resolvable private address tells us + // nothing once the connection is over. + // + // This gives id_addr_info two meanings, distinguished by whether peer_id.id_info + // holds an IRK. With an IRK it is an identity address the peer distributed, and is + // resolvable. Without one it is merely the address the peer connected from, which + // we assume is stable because a peer not using privacy has no reason to change it. + // Readers must not treat the second kind as resolvable, which is why + // bonding_load_identities() skips entries that have no IRK. + ble_gap_addr_t *stored_peer = &connection->bonding_keys.peer_id.id_addr_info; + if (!address_is_set(stored_peer) && + (connection->peer_addr.addr_type == BLE_GAP_ADDR_TYPE_PUBLIC || + connection->peer_addr.addr_type == BLE_GAP_ADDR_TYPE_RANDOM_STATIC)) { + // Also updates the live connection, so its keys match what we are storing. + *stored_peer = connection->peer_addr; + } + uint16_t const ediv = connection->is_central ? connection->bonding_keys.peer_enc.master_id.ediv : connection->bonding_keys.own_enc.master_id.ediv; @@ -258,6 +301,24 @@ static void write_keys_block(bleio_connection_internal_t *connection) { write_block_data(new_block, (uint8_t *)&connection->bonding_keys, sizeof(bonding_keys_t)); } +bool bonding_load_directed_reconnect_address(ble_gap_addr_t *address) { + bonding_block_t *block = next_block(NULL); + while (block != NULL) { + // Peripheral-role bonds only: this is about a central reconnecting to us. + if (block->type == BLOCK_KEYS && !block->is_central && + block->data_length == sizeof(bonding_keys_t)) { + const bonding_keys_t *key_set = (const bonding_keys_t *)block->data; + if (!identity_has_irk(&key_set->peer_id) && + address_is_set(&key_set->peer_id.id_addr_info)) { + *address = key_set->peer_id.id_addr_info; + return true; + } + } + block = next_block(block); + } + return false; +} + void bonding_clear_keys(bonding_keys_t *bonding_keys) { memset((uint8_t *)bonding_keys, 0, sizeof(bonding_keys_t)); } @@ -334,6 +395,12 @@ size_t bonding_load_identities(bool is_central, const ble_gap_id_key_t **keys, s return len; } const bonding_keys_t *key_set = (const bonding_keys_t *)block->data; + // Skip peers that distributed no IRK. An entry with a zero IRK resolves + // nothing, and handing it to sd_ble_gap_device_identities_set() only risks + // crowding out or invalidating the identities that are real. + if (!identity_has_irk(&key_set->peer_id)) { + continue; + } keys[len] = &key_set->peer_id; len++; } diff --git a/ports/nordic/common-hal/_bleio/bonding.h b/ports/nordic/common-hal/_bleio/bonding.h index 584b04e561f..cde70b98489 100644 --- a/ports/nordic/common-hal/_bleio/bonding.h +++ b/ports/nordic/common-hal/_bleio/bonding.h @@ -62,6 +62,14 @@ bool bonding_load_cccd_info(bool is_central, uint16_t conn_handle, uint16_t ediv bool bonding_load_keys(bool is_central, uint16_t ediv, bonding_keys_t *bonding_keys); const ble_gap_enc_key_t *bonding_load_peer_encryption_key(bool is_central, const ble_gap_addr_t *peer); size_t bonding_load_identities(bool is_central, const ble_gap_id_key_t **keys, size_t max_length); + +// Finds the address of a bonded central to aim directed advertisements at, in order to +// reconnect to it. Only peers that distributed no IRK qualify: a peer that distributed +// one uses privacy, so it connects from a resolvable private address that is useless to +// us later, and it can resolve our private address, so undirected advertising reaches it. +// Returns true and fills in *address on success. Returns false and leaves *address +// untouched when there is no such peer. Does not allocate and cannot raise. +bool bonding_load_directed_reconnect_address(ble_gap_addr_t *address); size_t bonding_peripheral_bond_count(void); #if BONDING_DEBUG diff --git a/ports/silabs/common-hal/_bleio/Adapter.c b/ports/silabs/common-hal/_bleio/Adapter.c index 14b1dac8207..10f65dcb074 100644 --- a/ports/silabs/common-hal/_bleio/Adapter.c +++ b/ports/silabs/common-hal/_bleio/Adapter.c @@ -276,7 +276,7 @@ uint32_t _common_hal_bleio_adapter_start_advertising( const uint8_t *scan_response_data, uint16_t scan_response_data_len, mp_int_t tx_power, - const bleio_address_obj_t *directed_to) { + const bleio_raw_address_t *directed_to) { sl_status_t sc = SL_STATUS_FAIL; int16_t power = tx_power * 10; // TX power in 0.1 dBm steps @@ -425,6 +425,13 @@ void common_hal_bleio_adapter_start_advertising( MP_ERROR_TEXT("Maximum timeout length is %d seconds"), INT32_MAX / 1000); } + // Convert here, where raising is allowed. The internal call must stay raise-free + // because supervisor/shared uses it too. + bleio_raw_address_t raw_directed_to; + if (directed_to != NULL) { + bleio_address_to_raw(directed_to, &raw_directed_to); + } + _common_hal_bleio_adapter_start_advertising(self, connectable, anonymous, timeout, interval, advertising_data_bufinfo->buf, @@ -432,7 +439,7 @@ void common_hal_bleio_adapter_start_advertising( scan_response_data_bufinfo->buf, scan_response_data_bufinfo->len, tx_power, - directed_to); + directed_to != NULL ? &raw_directed_to : NULL); } // Stop advertising @@ -640,11 +647,11 @@ void bleio_adapter_reset(bleio_adapter_obj_t *adapter) { // Wait up to 125 ms (128 ticks) for disconnect to complete. This should be // greater than most connection intervals. start_ticks = supervisor_ticks_ms64(); - while (any_connected && supervisor_ticks_ms64() - start_ticks < 128) { + do { any_connected = false; for (conn_index = 0; conn_index < BLEIO_TOTAL_CONNECTION_COUNT; conn_index++) { connection = &bleio_connections[conn_index]; any_connected |= connection->conn_handle != BLEIO_HANDLE_INVALID; } - } + } while (any_connected && supervisor_ticks_ms64() - start_ticks < 128); } diff --git a/ports/silabs/common-hal/_bleio/PacketBuffer.c b/ports/silabs/common-hal/_bleio/PacketBuffer.c index 881cf1622eb..291523e0a6d 100644 --- a/ports/silabs/common-hal/_bleio/PacketBuffer.c +++ b/ports/silabs/common-hal/_bleio/PacketBuffer.c @@ -387,6 +387,8 @@ bool common_hal_bleio_packet_buffer_deinited(bleio_packet_buffer_obj_t *self) { void common_hal_bleio_packet_buffer_deinit(bleio_packet_buffer_obj_t *self) { if (!common_hal_bleio_packet_buffer_deinited(self)) { ringbuf_deinit(&self->ringbuf); + // Mark as deinited, so common_hal_bleio_packet_buffer_deinited() reports it. + self->characteristic = NULL; } } diff --git a/shared-bindings/_bleio/Adapter.h b/shared-bindings/_bleio/Adapter.h index 0bc6f051ff3..6618d95ac23 100644 --- a/shared-bindings/_bleio/Adapter.h +++ b/shared-bindings/_bleio/Adapter.h @@ -36,11 +36,15 @@ extern mp_obj_str_t *common_hal_bleio_adapter_get_name(bleio_adapter_obj_t *self extern void common_hal_bleio_adapter_set_name(bleio_adapter_obj_t *self, const char *name); // Returns 0 if ok, otherwise a BLE stack specific error code. +// +// `directed_to` is a raw address rather than a bleio_address_obj_t so that this can be +// called from supervisor/shared, which must not allocate or raise. NULL means advertise +// undirected. Callers holding a bleio_address_obj_t convert with bleio_address_to_raw(). extern uint32_t _common_hal_bleio_adapter_start_advertising(bleio_adapter_obj_t *self, bool connectable, bool anonymous, uint32_t timeout, float interval, const uint8_t *advertising_data, uint16_t advertising_data_len, const uint8_t *scan_response_data, uint16_t scan_response_data_len, - mp_int_t tx_power, const bleio_address_obj_t *directed_to); + mp_int_t tx_power, const bleio_raw_address_t *directed_to); extern void common_hal_bleio_adapter_start_advertising(bleio_adapter_obj_t *self, bool connectable, bool anonymous, uint32_t timeout, mp_float_t interval, diff --git a/shared-bindings/_bleio/__init__.c b/shared-bindings/_bleio/__init__.c index 1ec9fd96e76..08816c6da14 100644 --- a/shared-bindings/_bleio/__init__.c +++ b/shared-bindings/_bleio/__init__.c @@ -91,8 +91,22 @@ MP_NORETURN void mp_raise_bleio_SecurityError(mp_rom_error_text_t fmt, ...) { nlr_raise(exception); } +// Set when user code imports _bleio, cleared once a full bleio_reset() has run. +// The supervisor's own BLE workflow setup calls common_hal_bleio_init() directly +// rather than going through here, so it deliberately does not set this. +static bool _user_imported; + +bool bleio_user_imported(void) { + return _user_imported; +} + +void bleio_clear_user_imported(void) { + _user_imported = false; +} + // Called when _bleio is imported. static mp_obj_t bleio___init__(void) { + _user_imported = true; // HCI cannot be enabled on import, because we need to setup the HCI adapter first. common_hal_bleio_init(); #if !CIRCUITPY_BLEIO_HCI diff --git a/shared-bindings/_bleio/__init__.h b/shared-bindings/_bleio/__init__.h index 0c5a5fec06b..acc65767e43 100644 --- a/shared-bindings/_bleio/__init__.h +++ b/shared-bindings/_bleio/__init__.h @@ -39,6 +39,16 @@ void bleio_user_reset(void); // Completely resets the BLE stack including BLE connections. void bleio_reset(void); +// True if user code imported _bleio during the current VM run. If it did not, +// the VM cannot have created any BLE state, so a port's bleio_reset() may skip +// a full stack reset and leave an active BLE workflow session running. +// Conservative: a bare `import _bleio` sets it, whether or not anything was created. +bool bleio_user_imported(void); + +// Clears the flag reported by bleio_user_imported(). Call after a full reset, +// so the next VM starts out clean. +void bleio_clear_user_imported(void); + // Init any state needed before calling any bleio functions including those // having to do with bonding. This doesn't enable the BLE adapter though. void common_hal_bleio_init(void); diff --git a/shared-module/_bleio/Address.c b/shared-module/_bleio/Address.c index b40abb3d560..4e5dde41d87 100644 --- a/shared-module/_bleio/Address.c +++ b/shared-module/_bleio/Address.c @@ -11,6 +11,13 @@ #include "shared-bindings/_bleio/Address.h" #include "shared-module/_bleio/Address.h" +void bleio_address_to_raw(const bleio_address_obj_t *address, bleio_raw_address_t *raw) { + mp_buffer_info_t buf_info; + mp_get_buffer_raise(address->bytes, &buf_info, MP_BUFFER_READ); + memcpy(raw->bytes, buf_info.buf, NUM_BLEIO_ADDRESS_BYTES); + raw->type = address->type; +} + void common_hal_bleio_address_construct(bleio_address_obj_t *self, uint8_t *bytes, uint8_t address_type) { self->bytes = mp_obj_new_bytes(bytes, NUM_BLEIO_ADDRESS_BYTES); self->type = address_type; diff --git a/shared-module/_bleio/Address.h b/shared-module/_bleio/Address.h index 76e7a1177fc..3da95bab9d1 100644 --- a/shared-module/_bleio/Address.h +++ b/shared-module/_bleio/Address.h @@ -16,3 +16,16 @@ typedef struct { uint8_t type; mp_obj_t bytes; // a bytes() object } bleio_address_obj_t; + +// A BLE address without the object wrapper, for code that must not allocate or raise -- +// notably supervisor/shared, which runs outside the VM. A NULL pointer to one of these +// means "no address", just as a NULL bleio_address_obj_t * does. +typedef struct { + uint8_t bytes[NUM_BLEIO_ADDRESS_BYTES]; + uint8_t type; // one of BLEIO_ADDRESS_TYPE_* +} bleio_raw_address_t; + +// Copies `address` into `*raw`. Raises if `address->bytes` is not a readable buffer, in +// which case `*raw` is left partly or wholly untouched, so only call this where raising +// is acceptable: not from supervisor/shared. +void bleio_address_to_raw(const bleio_address_obj_t *address, bleio_raw_address_t *raw); diff --git a/supervisor/shared/bluetooth/serial.c b/supervisor/shared/bluetooth/serial.c index 86ff1738a2a..f35e640e516 100644 --- a/supervisor/shared/bluetooth/serial.c +++ b/supervisor/shared/bluetooth/serial.c @@ -133,7 +133,7 @@ void supervisor_start_bluetooth_serial(void) { _common_hal_bleio_characteristic_buffer_construct(&_rx_buffer, &supervisor_ble_circuitpython_rx_characteristic, 0.1f, - (uint8_t *)_incoming, sizeof(_incoming) * sizeof(uint32_t), + (uint8_t *)_incoming, sizeof(_incoming), &rx_static_handler_entry, true /* watch for interrupt character */); diff --git a/tools/workflow/ble_terminal.py b/tools/workflow/ble_terminal.py new file mode 100755 index 00000000000..6dc64ed29cc --- /dev/null +++ b/tools/workflow/ble_terminal.py @@ -0,0 +1,512 @@ +#! /usr/bin/env python3 + +# SPDX-FileCopyrightText: 2026 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: MIT + +"""A command-line BLE terminal for the CircuitPython BLE workflow REPL. + +Linux only. It drives BlueZ over D-Bus to register a Just Works pairing agent, +which bleak does not provide and the workflow's encrypted characteristics +require, and it puts the terminal in raw mode with termios. + +Peripheral output goes to stdout verbatim; this program's own status messages go +to stderr prefixed with "[ble]", so you can separate the two: + + python3 ble_terminal.py 2>/dev/null # REPL output only + python3 ble_terminal.py >/dev/null # status only + +stdin is put in raw mode and every byte is forwarded as typed, so Ctrl-C, Tab and +arrow keys reach the REPL. Press Ctrl-] to quit. + +The board cycles its SoftDevice whenever it switches VMs -- entering the REPL from +the "press any key" prompt, reloading, running code that imports _bleio -- and that +drops the link. Bonds survive it, so this reconnects rather than exiting, the same +way the web editor's js/workflows/ble.js does. Keys typed while the link is down +are held and flushed once it is back. +""" + +import argparse +import asyncio +import collections +import os +import sys +import termios +import time +import tty + +from bleak import BleakClient, BleakScanner +from bleak.exc import BleakDeviceNotFoundError, BleakError +from dbus_fast import BusType, DBusError, Message +from dbus_fast.aio import MessageBus +from dbus_fast.service import ServiceInterface, method + +# Advertised by the peripheral (Adafruit's 16-bit 0xFEBB, expanded). +ADAFRUIT_SERVICE_UUID = "0000febb-0000-1000-8000-00805f9b34fb" +# Peripheral's RX: we write here. Peripheral's TX: we subscribe here. +UART_RX_UUID = "adaf0002-4369-7263-7569-74507974686e" +UART_TX_UUID = "adaf0003-4369-7263-7569-74507974686e" + +AGENT_PATH = "/org/adafruit/ble_terminal_agent" +QUIT_KEY = 0x1D # Ctrl-] +SLOW_RESPONSE = 2.0 # seconds to wait before reporting silence +# The 23 byte default ATT MTU, less the 3 byte write header. Learning the real +# negotiated MTU means calling bleak's private _acquire_mtu(), which acquires and +# immediately closes a characteristic fd -- if it picks AcquireNotify on the TX +# characteristic that toggles its CCCD off and on again before start_notify, which +# is a candidate for the peripheral dropping the link. Not worth it for typing. +WRITE_CHUNK = 20 +# The web editor's ladder (js/workflows/ble.js RECONNECT_DELAYS_MS). The first +# delay also gives the peripheral time to start advertising again. +RECONNECT_DELAYS = (1.5, 2.5, 4.0) +# BlueZ's Connect() will chase a device that is not answering for a long time, so +# bound it rather than letting one attempt swallow the whole reconnect ladder. +CONNECT_TIMEOUT = 20.0 +DISCONNECT_TIMEOUT = 5.0 + + +# Set from the command line before the event loop starts. +_quiet = False +_quiet_writes = False + + +def status(message): + """Report our own state on stderr. \r for raw mode, which eats plain \n.""" + if _quiet: + return + sys.stderr.write(f"[ble] {message}\r\n") + sys.stderr.flush() + + +def write_status(message): + """Per-packet and per-line chatter, suppressible on its own. + + This is the noisiest output -- a line per packet written and a latency report + per line submitted -- and the least useful once things are working. + """ + if not _quiet_writes: + status(message) + + +class JustWorksAgent(ServiceInterface): + """NoInputNoOutput pairing agent. + + bleak only calls Device1.Pair(); it provides no org.bluez.Agent1. With no + agent on the bus, bluetoothd negative-replies the pairing confirmation and the + peripheral aborts with SMP "Passkey entry failed". NoInputNoOutput tells BlueZ + not to ask, so pairing completes as Just Works -- encrypted, but with no MITM + protection, since neither end can display or confirm a passkey. + """ + + def __init__(self): + super().__init__("org.bluez.Agent1") + + @method() + def Release(self): # noqa: N802 - D-Bus method names + pass + + @method() + def RequestPinCode(self, device: "o") -> "s": # noqa: N802, F821 + raise DBusError("org.bluez.Error.Rejected", "no input capability") + + @method() + def RequestPasskey(self, device: "o") -> "u": # noqa: N802, F821 + raise DBusError("org.bluez.Error.Rejected", "no input capability") + + @method() + def DisplayPinCode(self, device: "o", pincode: "s"): # noqa: N802, F821 + pass + + @method() + def DisplayPasskey(self, device: "o", passkey: "u", entered: "q"): # noqa: N802, F821 + pass + + @method() + def RequestConfirmation(self, device: "o", passkey: "u"): # noqa: N802, F821 + pass # not reached with NoInputNoOutput; accept if it ever is + + @method() + def RequestAuthorization(self, device: "o"): # noqa: N802, F821 + pass + + @method() + def Cancel(self): # noqa: N802 + pass + + +async def register_agent(): + bus = await MessageBus(bus_type=BusType.SYSTEM).connect() + bus.export(AGENT_PATH, JustWorksAgent()) + for member, body in ( + ("RegisterAgent", [AGENT_PATH, "NoInputNoOutput"]), + ("RequestDefaultAgent", [AGENT_PATH]), + ): + reply = await bus.call( + Message( + destination="org.bluez", + path="/org/bluez", + interface="org.bluez.AgentManager1", + member=member, + signature="os" if body[1:] else "o", + body=body, + ) + ) + if reply.message_type.name == "ERROR": + raise RuntimeError(f"{member} failed: {reply.error_name}: {reply.body}") + return bus + + +async def find_peripheral(name_prefix, timeout): + """Scan for the peripheral. + + Matches on name as well as service UUID: once BlueZ has the device cached it + reports only RSSI/TxPower changes, with no UUIDs, so a UUID-only filter never + matches an already-paired device. + """ + status(f"scanning for {name_prefix}* ({timeout:.0f}s)") + + def match(device, advertisement): + return ADAFRUIT_SERVICE_UUID in advertisement.service_uuids or ( + device.name or "" + ).startswith(name_prefix) + + device = await BleakScanner.find_device_by_filter(match, timeout=timeout) + if device is None: + status("no peripheral found") + else: + status(f"found {device.name} {device.address}") + return device + + +async def clear_bond(address): + """Drop BlueZ's bond for the device. Returns True if it had a device object. + + Only worth doing after a connect has actually failed. CircuitPython keeps its + bonds across a BLE stack reset and erases them only when it boots into + discovery mode, so a stored bond is usually still good -- and it is what lets + BlueZ resolve the private address the board advertises under once bonded. + Dropping it unconditionally makes the board unrecognizable. + + When a bond really has gone stale, though, bleak skips pairing because BlueZ + reports the device as already paired, the stored key is reused, the link never + encrypts, and the connect hangs until it times out. Hence this escape hatch. + """ + try: + await BleakClient(address).unpair() + except BleakDeviceNotFoundError: + return False + status(f"cleared stored bond for {address}") + return True + + +class KeyReader: + """Buffers raw stdin so that typing survives a reconnect. + + The reader stays installed across connections, so keys typed while the link is + down accumulate here and are flushed to the peripheral once it comes back. + """ + + def __init__(self, fd): + self._fd = fd + self._chunks = collections.deque() + self._ready = asyncio.Event() + self._closed = False + + def on_readable(self): + data = os.read(self._fd, 256) + if data: + self._chunks.append(data) + else: + self._closed = True + self._ready.set() + + async def get(self): + """Next chunk of typed bytes; b"" once stdin has closed.""" + while not self._chunks: + if self._closed: + return b"" + # There is no await between the clear and the wait, and add_reader + # callbacks only run at await points, so no chunk can slip past here. + self._ready.clear() + await self._ready.wait() + return self._chunks.popleft() + + async def get_until(self, event): + """Like get(), but returns None if event fires first.""" + getter = asyncio.ensure_future(self.get()) + waiter = asyncio.ensure_future(event.wait()) + try: + await asyncio.wait({getter, waiter}, return_when=asyncio.FIRST_COMPLETED) + finally: + waiter.cancel() + if getter.done(): + return getter.result() + # get() pops its chunk synchronously after its only await returns, so + # cancelling it while it is still waiting cannot drop anything. + getter.cancel() + return None + + def buffered(self): + return sum(len(chunk) for chunk in self._chunks) + + def quit_typed(self): + """True if Ctrl-] is sitting unread in the buffer.""" + return any(QUIT_KEY in chunk for chunk in self._chunks) + + +async def wait_before_reconnect(keys, delay): + """Wait out a reconnect delay. True if Ctrl-] was typed while waiting.""" + deadline = time.monotonic() + delay + while time.monotonic() < deadline: + if keys.quit_typed(): + return True + await asyncio.sleep(0.1) + return keys.quit_typed() + + +async def watch_for_quit(keys): + """Resolve once Ctrl-] shows up in the buffer. Only peeks, never consumes.""" + while not keys.quit_typed(): + await asyncio.sleep(0.1) + + +def describe(error): + """BlueZ errors routinely stringify to nothing; never report an empty reason.""" + text = str(error).strip() + return f"{type(error).__name__}: {text}" if text else repr(error) + + +async def watchdog(pending): + """Say something when a submitted line has produced no reply.""" + while True: + await asyncio.sleep(0.5) + since = pending.get("since") + if since is not None and not pending["warned"]: + waited = time.monotonic() - since + if waited >= SLOW_RESPONSE: + status(f"no response for {waited:.1f}s ({pending['bytes']} bytes sent)") + pending["warned"] = True + + +async def terminal(client, keys, disconnected): + """Pump keystrokes until the peripheral goes away or Ctrl-] is typed. + + Returns "quit" or "disconnected". + """ + pending = {"since": None, "warned": False, "bytes": 0} + + def on_rx(_characteristic, data): + if pending["since"] is not None: + write_status(f"response after {time.monotonic() - pending['since']:.2f}s") + pending["since"] = None + sys.stdout.write(data.decode("utf-8", "replace")) + sys.stdout.flush() + + await client.start_notify(UART_TX_UUID, on_rx) + held = keys.buffered() + if held: + status(f"flushing {held} bytes typed while disconnected") + status("ready -- type to send, Ctrl-] to quit") + + guard = asyncio.create_task(watchdog(pending)) + try: + while True: + # Waiting on the disconnect event too, so a link that drops while + # nothing is being typed is noticed immediately rather than at the + # next keystroke. + data = await keys.get_until(disconnected) + if data is None: + return "disconnected" + if not data: + return "quit" # stdin closed + quitting = QUIT_KEY in data + if quitting: + data = data[: data.index(QUIT_KEY)] + if data: + try: + await send(client, data, WRITE_CHUNK, pending) + except BleakError as error: + status(f"send failed, link is gone: {error}") + return "disconnected" + if quitting: + return "quit" + finally: + guard.cancel() + + +async def send(client, data, chunk, pending): + # Write without response, as the web editor does. Nothing here needs the ack, + # and an acknowledged write that lands while the peripheral is tearing down its + # BLE stack surfaces as an aborted request rather than a clean failure. + # Announce before writing, not after, so a write that kills the peripheral + # still leaves a record of what was sent. + for i in range(0, len(data), chunk): + packet = data[i : i + chunk] + write_status(f"writing {len(packet)} bytes to ADAF0002 {packet!r}") + await client.write_gatt_char(UART_RX_UUID, packet, response=False) + pending["bytes"] += len(data) + if b"\r" in data or b"\n" in data: + write_status(f"line submitted, {pending['bytes']} bytes total, waiting for response") + pending["since"] = time.monotonic() + pending["warned"] = False + pending["bytes"] = 0 + + +async def session(device, keys): + """Connect once and run the terminal. + + Returns "quit", "disconnected", or "failed" if the link never came up. + + Takes the BLEDevice, never a bare address string. bleak only skips its own + BleakScanner.find_device_by_address() when it can read a D-Bus object path out + of a BLEDevice; given a string it scans instead, and that scan runs with the + controller's address resolution disabled. A bonded board advertises under a + resolvable private address, so such a scan never matches it and the connect + fails even though BlueZ could have connected to its existing device object. + """ + disconnected = asyncio.Event() + # pair=True bonds during connect, before any GATT access. bleak skips it if + # BlueZ already holds a bond, which is the normal case after the first run: + # the stored key is reused and no pairing happens. + client = BleakClient(device, disconnected_callback=lambda _: disconnected.set(), pair=True) + + # Race the connect against Ctrl-] and a timeout. A blocked Connect() must not + # be able to hold the ladder, nor sit through a quit the user has already typed. + connecting = asyncio.ensure_future(client.connect()) + quitting = asyncio.ensure_future(watch_for_quit(keys)) + try: + done, _ = await asyncio.wait( + {connecting, quitting}, + timeout=CONNECT_TIMEOUT, + return_when=asyncio.FIRST_COMPLETED, + ) + finally: + quitting.cancel() + + if connecting not in done: + connecting.cancel() + await hang_up(client) + if quitting in done: + return "quit" + status(f"connect timed out after {CONNECT_TIMEOUT:.0f}s") + return "failed" + try: + connecting.result() + except (BleakError, OSError) as error: + status(f"connect failed: {describe(error)}") + await hang_up(client) + return "failed" + + try: + status(f"connected to {device.address}") + return await terminal(client, keys, disconnected) + finally: + await hang_up(client) + + +async def hang_up(client): + """Best-effort disconnect. An abandoned connect can leave one pending.""" + try: + await asyncio.wait_for(client.disconnect(), timeout=DISCONNECT_TIMEOUT) + except (BleakError, OSError, asyncio.TimeoutError): + pass + + +async def main(name_prefix): + await register_agent() + status(f"pairing agent registered ({AGENT_PATH}, NoInputNoOutput)") + + device = await find_peripheral(name_prefix, timeout=15.0) + if device is None: + return 1 + + loop = asyncio.get_running_loop() + fd = sys.stdin.fileno() + saved = termios.tcgetattr(fd) + tty.setraw(fd) + keys = KeyReader(fd) + loop.add_reader(fd, keys.on_readable) + status("connecting and pairing") + try: + rebonded = False + attempt = 0 + while True: + outcome = await session(device, keys) + if outcome == "quit": + status("quit") + return 0 + if outcome == "disconnected": + # Expected whenever the board switches VMs. A session that + # actually ran earns a fresh ladder. + status("peripheral disconnected") + attempt = 0 + elif not rebonded: + # Only now is a stale host bond worth suspecting. Dropping it is + # destructive -- it takes the IRK that lets BlueZ recognize the + # board, and leaves it findable only in discovery mode -- so do it + # once, and only after a real failure. + rebonded = True + if await clear_bond(device.address): + # RemoveDevice discards BlueZ's device object, so it has to be + # rediscovered before a connect can use it. This only works if + # the board is advertising its name, i.e. booted into discovery + # mode -- a bonded board advertises anonymously and cannot be + # found by name at all. + device = await find_peripheral(name_prefix, timeout=15.0) + if device is None: + status("double-tap reset for discovery mode, then run again") + return 1 + continue + + if attempt >= len(RECONNECT_DELAYS): + status("reconnect attempts exhausted; run again") + return 1 + delay = RECONNECT_DELAYS[attempt] + attempt += 1 + held = keys.buffered() + extra = f", {held} bytes held" if held else "" + status(f"retrying in {delay:.1f}s ({attempt}/{len(RECONNECT_DELAYS)}{extra})") + if await wait_before_reconnect(keys, delay): + status("quit") + return 0 + finally: + loop.remove_reader(fd) + termios.tcsetattr(fd, termios.TCSADRAIN, saved) + + +def parse_args(): + parser = argparse.ArgumentParser( + prog="ble_terminal.py", + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "name_prefix", + nargs="?", + default="CIRCUITPY", + help="advertised name prefix to scan for (default: %(default)s)", + ) + parser.add_argument( + "-q", + "--quiet", + action="store_true", + help="suppress every [ble] status message, leaving only REPL output", + ) + parser.add_argument( + "-w", + "--quiet-writes", + action="store_true", + help="suppress the per-packet write and per-line timing messages, " + "keeping connection and reconnection status (does not affect writing)", + ) + return parser.parse_args() + + +if __name__ == "__main__": + _args = parse_args() + _quiet = _args.quiet + _quiet_writes = _args.quiet_writes + try: + sys.exit(asyncio.run(main(_args.name_prefix))) + except KeyboardInterrupt: + pass From d88f6f728b341e4d95670130ea678f276f45f00e Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Thu, 6 Aug 2026 13:16:59 -0400 Subject: [PATCH 2/2] shrink pca10100; maybe need lto next time --- ports/nordic/boards/pca10100/mpconfigboard.mk | 1 + 1 file changed, 1 insertion(+) diff --git a/ports/nordic/boards/pca10100/mpconfigboard.mk b/ports/nordic/boards/pca10100/mpconfigboard.mk index 34bcc47cf9f..7dc41eed200 100644 --- a/ports/nordic/boards/pca10100/mpconfigboard.mk +++ b/ports/nordic/boards/pca10100/mpconfigboard.mk @@ -9,4 +9,5 @@ INTERNAL_FLASH_FILESYSTEM = 1 CIRCUITPY_ONEWIREIO = 0 CIRCUITPY_AUDIOMIXER = 0 +CIRCUITPY_NVM = 0 CIRCUITPY_RAINBOWIO = 0