From 06af5d4988d95ea680fb607ee727a811549f4b9f Mon Sep 17 00:00:00 2001 From: tyeth Date: Wed, 9 Sep 2026 15:26:38 +0100 Subject: [PATCH 1/2] zephyr-cp: enforce the _bleio scan timeout on legacy-scan controllers start_scan(timeout=...) never ended on the Pico 2 W: the ScanResults loop ran until Ctrl-C, and the KeyboardInterrupt then surfaced on the first statement after the loop (stop_scan() in one run, the print() after it in another), which looked like a stall in scan teardown or in CDC output. gdb on the running board showed nothing blocked. The main thread was in common_hal_bleio_scanresults_next() waiting for the next entry with done still false; bt_dev.flags had BT_DEV_SCANNING set; ncmd_sem.count was 1 and sent_cmd 0 (no HCI command outstanding); the gSPI bus mutex was free; the BT RX poll thread was in its 4 ms k_msleep; every work queue and USB thread was pended idle. stop_scan() itself took 5-6 ms when called explicitly. The cause is in Zephyr's host: bt_le_scan_param.timeout is only passed to the controller on the extended-scanning path (LE Set Extended Scan Enable carries a duration and the controller reports LE Scan Timeout). start_le_scan_legacy() never reads it, and the legacy path is what CONFIG_BT_EXT_ADV=n selects -- which a controller without extended advertising, such as the CYW43439, forces. So the timeout was silently ignored and the scan ran forever. Keep the deadline in the adapter and enforce it from bleio_background(), called from port_background_task() on the main thread, where bt_le_scan_stop() is safe to call (it blocks on an HCI round-trip, which must not happen on the system work queue that also runs the USB CDC console). The ScanResults iterator finishes at the deadline as it does on nRF and ESP32. Measured on hardware, Pico 2 W: timeout=3 s -> loop exited after 3.0 s; timeout=1 -> 1.0 s; timeout=0.5 -> 0.53 s; timeout=2 -> 2.05 s (30 reports). Before the change a timeout=3 scan was still yielding at 57 s (1600 reports). Co-Authored-By: Claude Opus 5 --- ports/zephyr-cp/background.c | 7 +++++ ports/zephyr-cp/common-hal/_bleio/Adapter.c | 32 ++++++++++++++++++++ ports/zephyr-cp/common-hal/_bleio/__init__.h | 4 +++ 3 files changed, 43 insertions(+) diff --git a/ports/zephyr-cp/background.c b/ports/zephyr-cp/background.c index 56e9e98f1f2..4b8fd06cd37 100644 --- a/ports/zephyr-cp/background.c +++ b/ports/zephyr-cp/background.c @@ -9,6 +9,10 @@ #include "py/runtime.h" #include "supervisor/port.h" +#if CIRCUITPY_BLEIO +#include "common-hal/_bleio/__init__.h" +#endif + #include void port_start_background_tick(void) { @@ -26,4 +30,7 @@ void port_background_task(void) { #if defined(CONFIG_ARCH_POSIX) k_busy_wait(100); #endif + #if CIRCUITPY_BLEIO + bleio_background(); + #endif } diff --git a/ports/zephyr-cp/common-hal/_bleio/Adapter.c b/ports/zephyr-cp/common-hal/_bleio/Adapter.c index c3684a3b148..a30628fca62 100644 --- a/ports/zephyr-cp/common-hal/_bleio/Adapter.c +++ b/ports/zephyr-cp/common-hal/_bleio/Adapter.c @@ -51,6 +51,9 @@ void bleio_request_bluetooth_background(void) { static bool scan_callbacks_registered = false; static bleio_scanresults_obj_t *active_scan_results = NULL; static struct bt_le_scan_cb scan_callbacks; +// supervisor_ticks_ms64() value at which the running scan must stop, or 0 for +// no timeout. See bleio_background() for why the host side enforces this. +static uint64_t scan_deadline_ms; static bool ble_advertising = false; // True when advertising was started by the BLE workflow (supervisor) rather // than user code. Lets the workflow restart its own adverts without disturbing @@ -277,6 +280,7 @@ static void scan_recv_cb(const struct bt_le_scan_recv_info *info, struct net_buf } static void scan_timeout_cb(void) { + scan_deadline_ms = 0; if (active_scan_results == NULL) { return; } @@ -702,10 +706,21 @@ mp_obj_t common_hal_bleio_adapter_start_scan(bleio_adapter_obj_t *self, uint8_t raise_zephyr_error(err); } + // Zephyr hands scan_params.timeout to the controller only on the extended + // scanning path (LE Set Extended Scan Enable carries a duration and the + // controller reports LE Scan Timeout). start_le_scan_legacy() never reads + // it, and the legacy path is what CONFIG_BT_EXT_ADV=n selects -- which a + // controller without extended advertising, like the CYW43439, forces. So + // on those builds the scan would run until stop_scan() and the ScanResults + // iterator would never finish. Keep the deadline here and enforce it from + // bleio_background(), on the main thread, where stopping is safe. + scan_deadline_ms = timeout > 0 ? supervisor_ticks_ms64() + (uint64_t)(timeout * 1000.0f) : 0; + return MP_OBJ_FROM_PTR(self->scan_results); } void common_hal_bleio_adapter_stop_scan(bleio_adapter_obj_t *self) { + scan_deadline_ms = 0; if (self->scan_results == NULL) { return; } @@ -715,6 +730,22 @@ void common_hal_bleio_adapter_stop_scan(bleio_adapter_obj_t *self) { self->scan_results = NULL; } +// Called from port_background_task(), i.e. from RUN_BACKGROUND_TASKS on the +// main thread. This is where the scan timeout is enforced when the controller +// cannot do it (see common_hal_bleio_adapter_start_scan). Stopping from here +// rather than from a k_timer keeps the blocking HCI round-trip in +// bt_le_scan_stop() off the system work queue, which the USB CDC console also +// runs on. +void bleio_background(void) { + if (active_scan_results == NULL || scan_deadline_ms == 0) { + return; + } + if (supervisor_ticks_ms64() < scan_deadline_ms) { + return; + } + common_hal_bleio_adapter_stop_scan(&common_hal_bleio_adapter_obj); +} + bool common_hal_bleio_adapter_get_connected(bleio_adapter_obj_t *self) { if (!ble_adapter_enabled) { return false; @@ -907,6 +938,7 @@ void bleio_adapter_reset(bleio_adapter_obj_t *adapter) { adapter->scan_results = NULL; adapter->connection_objs = NULL; active_scan_results = NULL; + scan_deadline_ms = 0; ble_advertising = false; ble_advertising_internal = false; ble_adapter_enabled = bt_is_ready(); diff --git a/ports/zephyr-cp/common-hal/_bleio/__init__.h b/ports/zephyr-cp/common-hal/_bleio/__init__.h index 72dc249d142..0982f835032 100644 --- a/ports/zephyr-cp/common-hal/_bleio/__init__.h +++ b/ports/zephyr-cp/common-hal/_bleio/__init__.h @@ -45,3 +45,7 @@ void bleio_gattc_write_sync(struct bt_conn *conn, uint16_t handle, // callback so discover_remote_services() fails cleanly instead of hanging // or NULL-dereferencing the cleared connection. void bleio_connection_discovery_abort(void); + +// Main-thread housekeeping, run from port_background_task(): enforces the scan +// timeout on controllers whose legacy scan path cannot. +void bleio_background(void); From 3c6020d4d82c05139bdcb197b38a54bee820440e Mon Sep 17 00:00:00 2001 From: tyeth Date: Wed, 9 Sep 2026 15:26:38 +0100 Subject: [PATCH 2/2] zephyr-cp: wake the main thread when console input arrives The busio UART receive callback -- which also serves the USB CDC console -- queued bytes without waking the main thread. While the REPL is reading it spins, so that path was fine, but after code.py finishes the supervisor parks in port_idle_until_interrupt() waiting for "any key", and nothing woke it for a keypress until the next timed wake-up. Signal the main task from the callback, as the other ports' console receive paths do. Co-Authored-By: Claude Opus 5 --- ports/zephyr-cp/common-hal/busio/UART.c | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/ports/zephyr-cp/common-hal/busio/UART.c b/ports/zephyr-cp/common-hal/busio/UART.c index af1de0e9023..4f004a0f2d0 100644 --- a/ports/zephyr-cp/common-hal/busio/UART.c +++ b/ports/zephyr-cp/common-hal/busio/UART.c @@ -13,6 +13,7 @@ #include "py/mperrno.h" #include "py/runtime.h" #include "py/stream.h" +#include "supervisor/port.h" #include #include @@ -37,7 +38,9 @@ static void serial_cb(const struct device *dev, void *user_data) { } /* read until FIFO empty */ + bool received = false; while (uart_fifo_read(dev, &c, 1) == 1) { + received = true; if (mp_interrupt_char == c) { common_hal_busio_uart_clear_rx_buffer(self); mp_sched_keyboard_interrupt(); @@ -47,6 +50,14 @@ static void serial_cb(const struct device *dev, void *user_data) { } } } + + // The console is one of these UARTs (USB CDC is presented as one). When + // the main thread is parked in port_idle_until_interrupt() -- after code.py + // ends, waiting for a key -- nothing else wakes it for input; without this + // a keypress is only noticed at the next timed wake-up. + if (received) { + port_wake_main_task_from_isr(); + } } void common_hal_busio_uart_never_reset(busio_uart_obj_t *self) {