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/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 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_agent.py b/tools/workflow/ble_agent.py new file mode 100755 index 00000000000..924a9f184b6 --- /dev/null +++ b/tools/workflow/ble_agent.py @@ -0,0 +1,178 @@ +#! /usr/bin/env python3 + +# SPDX-FileCopyrightText: 2026 Dan Halbert for Adafruit Industries +# +# SPDX-License-Identifier: MIT + +"""A Just Works BLE pairing agent, so a browser can pair with the BLE workflow. + +Linux only. Leave it running in a terminal while you connect. + +BlueZ has no pairing UI of its own. Every pairing interaction is delegated over +D-Bus to an org.bluez.Agent1 that some application must register, and whichever +one calls RequestDefaultAgent receives the requests. Chromium registers no agent, +so with nothing else providing one, bluetoothd refuses the pairing outright: + + src/device.c:new_auth() No agent available for request type 2 + device_confirm_passkey: Operation not permitted + +The link then drops, and since every BLE workflow characteristic is encrypted +(SECURITY_MODE_ENC_NO_MITM), nothing works. macOS and Windows do not need this -- +there the operating system owns pairing and shows its own dialog. + +This registers a NoInputNoOutput agent, which tells BlueZ not to ask anything, so +pairing completes as Just Works: encrypted, but with no MITM protection, since +neither end can display or confirm a passkey. That is the same security the +workflow already assumes; running this does not weaken it. It is the same agent +ble_terminal.py registers for itself, split out so it can be used with a browser. + + python3 ble_agent.py + +Press Ctrl-C to unregister and exit. Requires the dbus-fast package. + +Note that an agent only fixes pairing. It does not help with the separate Linux +kernel problem where a connection attempt to an unbonded peripheral often times +out; for that, just retry. Once the board is bonded, both problems go away. +""" + +import argparse +import asyncio +import contextlib +import sys + +from dbus_fast import BusType, DBusError, Message +from dbus_fast.aio import MessageBus +from dbus_fast.service import ServiceInterface, method + +AGENT_PATH = "/org/adafruit/circuitpython_ble_agent" +BLUEZ = "org.bluez" + + +def status(message): + print(f"[agent] {message}", file=sys.stderr, flush=True) + + +class JustWorksAgent(ServiceInterface): + """NoInputNoOutput pairing agent. + + With NoInputNoOutput capability BlueZ selects Just Works and does not call + RequestConfirmation or RequestPasskey at all. The methods are still + implemented because BlueZ requires the full org.bluez.Agent1 interface to be + present, and because another agent's capability could put us on a path that + does call them. + """ + + def __init__(self, verbose=False): + super().__init__("org.bluez.Agent1") + self._verbose = verbose + + def _trace(self, message): + if self._verbose: + status(message) + + @method() + def Release(self): # noqa: N802 - D-Bus method names + self._trace("released by bluetoothd") + + @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 + self._trace(f"display pin code {pincode} for {device}") + + @method() + def DisplayPasskey(self, device: "o", passkey: "u", entered: "q"): # noqa: N802, F821 + self._trace(f"display passkey {passkey:06d} for {device}") + + @method() + def RequestConfirmation(self, device: "o", passkey: "u"): # noqa: N802, F821 + # Not reached with NoInputNoOutput. Accept if it ever is: refusing here + # is exactly the failure this script exists to prevent. + status(f"confirming pairing with {device} (passkey {passkey:06d})") + + @method() + def RequestAuthorization(self, device: "o"): # noqa: N802, F821 + status(f"authorizing pairing with {device}") + + @method() + def AuthorizeService(self, device: "o", uuid: "s"): # noqa: N802, F821 + self._trace(f"authorizing service {uuid} for {device}") + + @method() + def Cancel(self): # noqa: N802 + status("pairing cancelled by the remote device") + + +async def call_agent_manager(bus, member, body, signature): + reply = await bus.call( + Message( + destination=BLUEZ, + path="/org/bluez", + interface="org.bluez.AgentManager1", + member=member, + signature=signature, + body=body, + ) + ) + if reply.message_type.name == "ERROR": + raise RuntimeError(f"{member} failed: {reply.error_name}: {reply.body}") + + +async def run(verbose): + try: + bus = await MessageBus(bus_type=BusType.SYSTEM).connect() + except Exception as e: + status(f"could not connect to the system bus: {e}") + return 1 + + bus.export(AGENT_PATH, JustWorksAgent(verbose)) + + try: + await call_agent_manager(bus, "RegisterAgent", [AGENT_PATH, "NoInputNoOutput"], "os") + except RuntimeError as e: + status(str(e)) + status("is another agent already registered? bluetoothctl registers one while it runs.") + return 1 + + try: + await call_agent_manager(bus, "RequestDefaultAgent", [AGENT_PATH], "o") + except RuntimeError as e: + # Registered but not default: another agent will get the requests first. + status(str(e)) + status("registered, but could not become the default agent; pairing may still fail.") + else: + status(f"registered as the default agent ({AGENT_PATH}, NoInputNoOutput)") + + status("leave this running while you pair, then press Ctrl-C") + try: + await asyncio.get_running_loop().create_future() # run until cancelled + finally: + with contextlib.suppress(Exception): + await call_agent_manager(bus, "UnregisterAgent", [AGENT_PATH], "o") + status("unregistered") + bus.disconnect() + return 0 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="log every agent callback, not just the ones that matter", + ) + args = parser.parse_args() + try: + return asyncio.run(run(args.verbose)) + except KeyboardInterrupt: + return 0 + + +sys.exit(main()) 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