From 5f0ea1254a9c32c5ef5cbb287badab028d0f9f57 Mon Sep 17 00:00:00 2001 From: tyeth Date: Tue, 8 Sep 2026 17:48:33 +0100 Subject: [PATCH 01/15] zephyr-cp: raise the rpi_pico UDC thread stack in debug builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit debug.conf bumps the DWC2 and nRF UDC thread stacks because verbose logging with LOG_MODE_IMMEDIATE formats in-thread and needs more stack, but the rpi_pico one was missed. It defaults to 512 bytes, which overflows during USB enumeration on a Pico 2 W: ***** USAGE FAULT ***** Stack overflow (context area not valid) >>> ZEPHYR FATAL ERROR 2: Stack overflow on CPU 0 Current thread: 0x200034b0 (usbd@50110000) The board dies partway through configuration, which looks like a bad USB come-up rather than a stack problem. Independent of any Bluetooth work — it affects any RP2040/RP2350 board built with DEBUG=1. Co-Authored-By: Claude Opus 5 --- ports/zephyr-cp/debug.conf | 1 + 1 file changed, 1 insertion(+) diff --git a/ports/zephyr-cp/debug.conf b/ports/zephyr-cp/debug.conf index 2f12db119f8..21e82985247 100644 --- a/ports/zephyr-cp/debug.conf +++ b/ports/zephyr-cp/debug.conf @@ -35,6 +35,7 @@ CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE=4096 CONFIG_MAIN_STACK_SIZE=32768 CONFIG_ISR_STACK_SIZE=4096 CONFIG_UDC_DWC2_STACK_SIZE=4096 +CONFIG_UDC_RPI_PICO_STACK_SIZE=2048 CONFIG_UDC_NRF_THREAD_STACK_SIZE=4096 CONFIG_USBD_THREAD_STACK_SIZE=4096 CONFIG_USBD_MSC_STACK_SIZE=4096 From 8ff2ba3cab92bdb96af7868956864d83cb7424db Mon Sep 17 00:00:00 2001 From: tyeth Date: Tue, 8 Sep 2026 14:55:45 +0100 Subject: [PATCH 02/15] zephyr-cp: enable BLE on the Raspberry Pi Pico 2 W MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CYW43439's Bluetooth controller shares the WiFi gSPI bus rather than sitting on a UART, so it needs the new shared-bus HCI driver (see the companion zephyr branch). With a bt_hci device in the devicetree, the port's existing Kconfig turns CONFIG_BT on by itself and common-hal _bleio needs no board-specific work. Wire it up for the Pico 2 W: - board overlay: add the infineon,cyw43-bt-hci node as a child of the stock infineon,airoc-wifi node, and point zephyr,bt-hci at it. The hyphenated chosen name matters — the port's Kconfig derives CONFIG_BT via dt_chosen_enabled(zephyr,bt-hci). - board conf: deeper system workqueue stack and HW stack protection, as the WHD WiFi, cybt and coexistence call chains are deeper than the port defaults. - compat2driver: map infineon_cyw43_bt_hci to bluetooth/hci, which is what makes zephyr2cp.py set _bleio. Added by hand rather than regenerating the file, which would churn 500+ unrelated lines against the current Zephyr revision. Builds at 67.1% flash and 43.4% RAM, up from 59.7%/38.0% without BLE. Not hardware-tested. Co-Authored-By: Claude Opus 5 --- .../autogen_board_info.toml | 2 +- .../raspberrypi/rpi_pico2_w_zephyr/board.conf | 9 ++++++++ .../rpi_pico2_w_zephyr/board.overlay | 21 +++++++++++++++++++ ports/zephyr-cp/cptools/compat2driver.py | 1 + 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/autogen_board_info.toml b/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/autogen_board_info.toml index ed813ea69c3..39c42174672 100644 --- a/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/autogen_board_info.toml +++ b/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/autogen_board_info.toml @@ -3,7 +3,7 @@ name = "Raspberry Pi Foundation Raspberry Pi Pico 2" [modules] __future__ = true -_bleio = false +_bleio = true # Zephyr board has _bleio _eve = false _pew = false _pixelmap = false diff --git a/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.conf b/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.conf index 8fcc1c0b9ae..87c18f30db6 100644 --- a/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.conf +++ b/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.conf @@ -18,3 +18,12 @@ CONFIG_MBEDTLS_SSL_PROTO_TLS1_2=y CONFIG_MBEDTLS_CIPHERSUITE_TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256=y CONFIG_MBEDTLS_ENTROPY_C=y CONFIG_MBEDTLS_CTR_DRBG_C=y + +# Bluetooth over the shared gSPI bus (CONFIG_BT itself comes from the +# zephyr,bt-hci chosen node in the overlay). +# +# The WHD WiFi, cybt BT and coexistence call chains are deeper than the +# defaults in ../prj.conf; the Cortex-M33 MPU catches an overflow cleanly +# instead of silently corrupting neighbouring stacks. +CONFIG_HW_STACK_PROTECTION=y +CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE=4096 diff --git a/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.overlay b/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.overlay index 436cfc83276..c2eccfee15b 100644 --- a/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.overlay +++ b/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.overlay @@ -30,4 +30,25 @@ }; }; +/* + * Bluetooth. The CYW43439's HCI is not on a UART here — it shares the WiFi + * gSPI bus — so hang the shared-bus HCI transport off the board's stock + * infineon,airoc-wifi node and point the host at it. CONFIG_BT then turns + * itself on via the zephyr,bt-hci chosen (see ../../../Kconfig). + */ +&pio0_spi0 { + airoc-wifi@0 { + cyw43_bt_hci: cyw43_bt_hci { + compatible = "infineon,cyw43-bt-hci"; + status = "okay"; + }; + }; +}; + +/ { + chosen { + zephyr,bt-hci = &cyw43_bt_hci; + }; +}; + #include "../../../app.overlay" diff --git a/ports/zephyr-cp/cptools/compat2driver.py b/ports/zephyr-cp/cptools/compat2driver.py index 8cd3c248265..27f890ff926 100644 --- a/ports/zephyr-cp/cptools/compat2driver.py +++ b/ports/zephyr-cp/cptools/compat2driver.py @@ -166,6 +166,7 @@ "infineon_bless_hci": "bluetooth/hci", "infineon_bt_hci_uart": "bluetooth/hci", "infineon_cyw208xx_hci": "bluetooth/hci", + "infineon_cyw43_bt_hci": "bluetooth/hci", "nxp_bt_hci_uart": "bluetooth/hci", "nxp_hci_ble": "bluetooth/hci", "renesas_bt_hci_da1453x": "bluetooth/hci", From bb410ab1646599d224dfca9acc7d1ea355fdbe8b Mon Sep 17 00:00:00 2001 From: tyeth Date: Tue, 8 Sep 2026 17:49:35 +0100 Subject: [PATCH 03/15] zephyr-cp: fix Pico 2 W BLE bring-up found on hardware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four changes needed to get BLE actually working on a Pico 2 W, all found by bisecting the failures over SWD. The settings partition was 2K at 0x180800 — neither erase-sector aligned nor large enough for one of the RP2350's 4K sectors. flash_area_get_sectors() returned zero sectors, settings_nvs then computed its sector size from an uninitialised struct and failed with -EDOM, so bt_enable() returned -33 before ever opening the HCI driver. That surfaced as a bare "OSError: 33" from "import _bleio", which is where the adapter is enabled (the _bleio module's __init__ calls common_hal_bleio_adapter_set_enabled). main has since grown it to one aligned 4K sector at 0x17f000, which fixes the -EDOM but is still one sector short: nvs_mount() rejects fewer than two sectors with -EINVAL. Give settings two sectors at 0x17e000, taking the extra one from the code partition. nvm and circuitpy stay at 0x180000 and 0x181000, where cptools/check_partitions.py requires them to match ports/raspberrypi, so the CIRCUITPY filesystem is not moved. A board whose settings sectors hold content from a previous layout then fails differently: NVS reads it as "all sectors closed" and refuses to mount with -EDEADLK. CONFIG_NVS_INIT_BAD_MEMORY_REGION lets it reclaim a region it does not recognise, so the first boot after a layout change recovers on its own instead of needing a manual erase over SWD. The CYW43439's BT controller firmware (CYW4343A2_001.003.016.0065.0000) does not implement the Bluetooth 5 extended advertising/scanning commands. It rejects LE Set Extended Scan Parameters (0x2041) with status 0x01 "Unknown HCI Command", so scanning failed with -EIO. The port defaults BT_EXT_ADV on, which is right for the nRF parts but wrong here, so turn it off for this board and let the host use the legacy 0x200B/0x200C commands. Also raise the system workqueue stack and enable HW_STACK_PROTECTION (the MPU turned several silent corruptions into clean, named faults while debugging this), and enable BT HCI driver/host debug logging in debug.conf. Verified on hardware: patchram loads over the shared gSPI bus, the controller reports BD_ADDR 2C:CF:67:B7:62:AC (= WiFi MAC + 1), and a scan returns nearby advertisers by name. That run used the earlier layout with settings at 0x181000; the 0x17e000 placement is build-tested only. Co-Authored-By: Claude Opus 5 --- .../raspberrypi/rpi_pico2_w_zephyr/board.conf | 8 ++++++++ .../rpi_pico2_w_zephyr/board.overlay | 17 ++++++++++++++--- ports/zephyr-cp/debug.conf | 7 +++++++ ports/zephyr-cp/prj.conf | 8 ++++++++ 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.conf b/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.conf index 87c18f30db6..81b1f87c4d2 100644 --- a/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.conf +++ b/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.conf @@ -27,3 +27,11 @@ CONFIG_MBEDTLS_CTR_DRBG_C=y # instead of silently corrupting neighbouring stacks. CONFIG_HW_STACK_PROTECTION=y CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE=4096 + +# The CYW43439's BT controller firmware (CYW4343A2_001.003.016.0065.0000) does +# not implement the Bluetooth 5 extended advertising/scanning commands: it +# rejects LE Set Extended Scan Parameters (0x2041) with status 0x01 "Unknown +# HCI Command", so scanning fails with -EIO. The port defaults BT_EXT_ADV on, +# so turn it off here to make the host use the legacy 0x200B/0x200C scan and +# legacy advertising commands instead. +CONFIG_BT_EXT_ADV=n diff --git a/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.overlay b/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.overlay index c2eccfee15b..e5a871f52f0 100644 --- a/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.overlay +++ b/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.overlay @@ -6,14 +6,25 @@ code_partition: partition@0 { compatible = "zephyr,mapped-partition"; label = "code-partition"; - reg = <0x0 0x17f000>; + reg = <0x0 0x17e000>; read-only; }; - storage_partition: partition@17f000 { + /* + * The settings partition backs Bluetooth bond keys via NVS, so it + * must be erase-sector aligned and hold at least two of the + * RP2350's 4K sectors: nvs_mount() rejects a single sector with + * -EINVAL, which makes bt_enable() fail before ever opening the HCI + * driver and surfaces as a bare OSError from "import _bleio". + * + * nvm and circuitpy must stay where ports/raspberrypi puts them + * (cptools/check_partitions.py enforces this), so the second sector + * comes out of the code partition, not out of the CIRCUITPY drive. + */ + storage_partition: partition@17e000 { compatible = "zephyr,mapped-partition"; label = "storage"; - reg = <0x17f000 0x1000>; + reg = <0x17e000 0x2000>; }; nvm_partition: partition@180000 { diff --git a/ports/zephyr-cp/debug.conf b/ports/zephyr-cp/debug.conf index 21e82985247..1e9c2dfbb76 100644 --- a/ports/zephyr-cp/debug.conf +++ b/ports/zephyr-cp/debug.conf @@ -51,3 +51,10 @@ CONFIG_IDLE_STACK_SIZE=1024 # haven't overflowed — left at their defaults.) CONFIG_BT_RX_STACK_SIZE=4096 CONFIG_BT_LONG_WQ_STACK_SIZE=4096 + +# Bluetooth HCI: driver + host-core debug logging. The CYW43 shared-bus +# transport logs its patchram download and bring-up at INF/DBG, and the host +# core logs the command/event exchange — both invisible at the default +# LOG_MAX_LEVEL. Needed to see how far controller bring-up gets when it fails. +CONFIG_BT_HCI_DRIVER_LOG_LEVEL_DBG=y +CONFIG_BT_HCI_CORE_LOG_LEVEL_DBG=y diff --git a/ports/zephyr-cp/prj.conf b/ports/zephyr-cp/prj.conf index 864af4c6112..fa788887236 100644 --- a/ports/zephyr-cp/prj.conf +++ b/ports/zephyr-cp/prj.conf @@ -76,3 +76,11 @@ CONFIG_PSA_WANT_ALG_SHA_256=y CONFIG_NVS=y CONFIG_SETTINGS_NVS=y CONFIG_SETTINGS_NVS_SECTOR_COUNT=256 + +# A board upgraded from a layout without a settings partition (or one that +# moved) has whatever the previous image left in those sectors. NVS reads that +# as "all sectors closed" and refuses to mount with -EDEADLK, which surfaces as +# bt_enable() failing before the HCI driver is ever opened. Let it reclaim a +# region it does not recognise instead, so first boot after a layout change +# recovers on its own rather than needing a manual erase. +CONFIG_NVS_INIT_BAD_MEMORY_REGION=y From 8d2023c6b5830dead32fa7da3d80fa49c2966802 Mon Sep 17 00:00:00 2001 From: tyeth Date: Tue, 8 Sep 2026 19:07:28 +0100 Subject: [PATCH 04/15] zephyr-cp: enable BLE on the Raspberry Pi Pico W Same CYW43439 as the Pico 2 W, and the shared-bus HCI driver needed no board-specific work, so this is the Pico 2 W change applied to the RP2040 board: - overlay: hang the infineon,cyw43-bt-hci node off the stock infineon,airoc-wifi node and point zephyr,bt-hci at it. - overlay: the settings partition had the same defect as the Pico 2 W's -- originally 2K at 0x180800, neither erase-sector aligned nor big enough for one of the RP2040's 4K sectors; main has since made it one aligned 4K sector at 0x17f000, which is still one short of the two nvs_mount() insists on, so bt_enable() fails before opening the HCI driver. Give settings two sectors at 0x17e000, taken from the code partition. nvm and circuitpy stay at 0x180000 and 0x181000 to match ports/raspberrypi (cptools/check_partitions.py checks this), so CIRCUITPY is not moved. - conf: BT_EXT_ADV=n, since this controller rejects the Bluetooth 5 extended advertising commands, plus a deeper system workqueue stack. HW_STACK_PROTECTION is deliberately left off here: it earned its place on the Cortex-M33 but costs RAM, and this is the constrained board. Builds at 68.67% flash and 84.32% RAM of 264K, so BLE fits with about 42K to spare. Verified on hardware after the "ranges" fix (now on main): the adapter comes up as WiFi MAC + 2 and a legacy LE scan returns nearby advertisers. That run used the earlier layout with settings at 0x181000; the 0x17e000 placement is build-tested only. Co-Authored-By: Claude Opus 5 --- .../rpi_pico_w_zephyr/autogen_board_info.toml | 2 +- .../raspberrypi/rpi_pico_w_zephyr/board.conf | 9 +++++ .../rpi_pico_w_zephyr/board.overlay | 38 +++++++++++++++++-- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/ports/zephyr-cp/boards/raspberrypi/rpi_pico_w_zephyr/autogen_board_info.toml b/ports/zephyr-cp/boards/raspberrypi/rpi_pico_w_zephyr/autogen_board_info.toml index 2cad2417bfa..ab7fa09d123 100644 --- a/ports/zephyr-cp/boards/raspberrypi/rpi_pico_w_zephyr/autogen_board_info.toml +++ b/ports/zephyr-cp/boards/raspberrypi/rpi_pico_w_zephyr/autogen_board_info.toml @@ -3,7 +3,7 @@ name = "Raspberry Pi Foundation Raspberry Pi Pico" [modules] __future__ = true -_bleio = false +_bleio = true # Zephyr board has _bleio _eve = false _pew = false _pixelmap = false diff --git a/ports/zephyr-cp/boards/raspberrypi/rpi_pico_w_zephyr/board.conf b/ports/zephyr-cp/boards/raspberrypi/rpi_pico_w_zephyr/board.conf index 1e2d7ae1cd2..ad03cc6d10e 100644 --- a/ports/zephyr-cp/boards/raspberrypi/rpi_pico_w_zephyr/board.conf +++ b/ports/zephyr-cp/boards/raspberrypi/rpi_pico_w_zephyr/board.conf @@ -20,3 +20,12 @@ CONFIG_MBEDTLS_ENTROPY_C=y CONFIG_MBEDTLS_CTR_DRBG_C=y CONFIG_TEST_RANDOM_GENERATOR=y + +# Bluetooth over the shared gSPI bus (CONFIG_BT itself comes from the +# zephyr,bt-hci chosen node in the overlay). +# +# The CYW43439's BT controller firmware does not implement the Bluetooth 5 +# extended advertising/scanning commands, and the port defaults BT_EXT_ADV on, +# so turn it off here and let the host use the legacy 0x200B/0x200C commands. +CONFIG_BT_EXT_ADV=n +CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE=4096 diff --git a/ports/zephyr-cp/boards/raspberrypi/rpi_pico_w_zephyr/board.overlay b/ports/zephyr-cp/boards/raspberrypi/rpi_pico_w_zephyr/board.overlay index 3130d8dc5aa..f09b0f6af68 100644 --- a/ports/zephyr-cp/boards/raspberrypi/rpi_pico_w_zephyr/board.overlay +++ b/ports/zephyr-cp/boards/raspberrypi/rpi_pico_w_zephyr/board.overlay @@ -17,14 +17,25 @@ code_partition: partition@100 { compatible = "zephyr,mapped-partition"; label = "code-partition"; - reg = <0x100 (0x17f000 - 0x100)>; + reg = <0x100 (0x17e000 - 0x100)>; read-only; }; - storage_partition: partition@17f000 { + /* + * The settings partition backs Bluetooth bond keys via NVS, so it + * must be erase-sector aligned and hold at least two of the + * RP2040's 4K sectors: nvs_mount() rejects a single sector with + * -EINVAL, which makes bt_enable() fail before ever opening the HCI + * driver and surfaces as a bare OSError from "import _bleio". + * + * nvm and circuitpy must stay where ports/raspberrypi puts them + * (cptools/check_partitions.py enforces this), so the second sector + * comes out of the code partition, not out of the CIRCUITPY drive. + */ + storage_partition: partition@17e000 { compatible = "zephyr,mapped-partition"; label = "storage"; - reg = <0x17f000 0x1000>; + reg = <0x17e000 0x2000>; }; nvm_partition: partition@180000 { @@ -41,4 +52,25 @@ }; }; +/* + * Bluetooth. Same CYW43439 as the Pico 2 W: HCI is not on a UART, it shares + * the WiFi gSPI bus, so hang the shared-bus HCI transport off the board's + * stock infineon,airoc-wifi node and point the host at it. CONFIG_BT then + * enables itself via the zephyr,bt-hci chosen (see ../../../Kconfig). + */ +&pio0_spi0 { + airoc-wifi@0 { + cyw43_bt_hci: cyw43_bt_hci { + compatible = "infineon,cyw43-bt-hci"; + status = "okay"; + }; + }; +}; + +/ { + chosen { + zephyr,bt-hci = &cyw43_bt_hci; + }; +}; + #include "../../../app.overlay" From 94bfd47b5fff6802f451da30e5d5332b0dce80fe Mon Sep 17 00:00:00 2001 From: tyeth Date: Tue, 8 Sep 2026 18:41:46 +0100 Subject: [PATCH 05/15] ci: build zephyr-cp boards in the custom board workflow The custom board workflow predates the zephyr-cp port and cannot build its boards: - It relies on make's default goal to produce firmware.*, but the zephyr-cp Makefile's default goal is the Zephyr ELF, so the firmware.* copies the artifact upload globs for are never made and the upload is empty. Name the firmware. targets explicitly instead, the way tools/build_release_files.py already does, via a small helper that resolves CIRCUITPY_BUILD_EXTENSIONS for either kind of port. - The port deps action does not check out hal_rpi_pico's cyw43-driver submodule, which the CYW43 shared-bus Bluetooth transport needs for the controller patchram. Fetch it during port setup. Also point the west manifest at the fork branches carrying the Pico 2 W Bluetooth work so the artifact is BLE-capable. That override is CI-only and is marked as such -- it must be reverted before merging. Co-Authored-By: Claude Opus 5 --- .../actions/deps/ports/zephyr-cp/action.yml | 7 +++ .github/workflows/build-board-custom.yml | 19 +++++- ports/zephyr-cp/zephyr-config/west.yml | 28 ++++++++- tools/board_build_extensions.py | 62 +++++++++++++++++++ 4 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 tools/board_build_extensions.py diff --git a/.github/actions/deps/ports/zephyr-cp/action.yml b/.github/actions/deps/ports/zephyr-cp/action.yml index bd0af847773..067a592e836 100644 --- a/.github/actions/deps/ports/zephyr-cp/action.yml +++ b/.github/actions/deps/ports/zephyr-cp/action.yml @@ -37,6 +37,13 @@ runs: run: west zephyr-export shell: bash working-directory: ports/zephyr-cp + - name: Fetch cyw43-driver submodule + # The CYW43 shared-bus Bluetooth transport needs the georgerobotics + # cyw43-driver headers and the BT controller patchram, which live in a + # submodule of hal_rpi_pico that west does not check out. + run: git -C modules/hal/rpi_pico submodule update --init --depth 1 lib/cyw43-driver + shell: bash + working-directory: ports/zephyr-cp - name: Install Zephyr protobuf dependency run: pip install protobuf==6.33.5 shell: bash diff --git a/.github/workflows/build-board-custom.yml b/.github/workflows/build-board-custom.yml index caeb16cfddf..8cd96b70aa3 100644 --- a/.github/workflows/build-board-custom.yml +++ b/.github/workflows/build-board-custom.yml @@ -79,6 +79,12 @@ jobs: PORT=$(python tools/board_to_port.py "${{ inputs.board }}") echo "port=$PORT" >> $GITHUB_OUTPUT shell: bash + - name: Build extensions + id: build-extensions + run: | + EXT=$(python tools/board_build_extensions.py "${{ inputs.board }}") + echo "extensions=$EXT" >> $GITHUB_OUTPUT + shell: bash - name: Set up port id: set-up-port uses: ./.github/actions/deps/ports @@ -121,7 +127,18 @@ jobs: BOARD: ${{ inputs.board }} FLAGS: ${{ inputs.flags }} DEBUG: ${{ inputs.debug && '1' || '0' }} - run: make -j$(nproc) $FLAGS BOARD="$BOARD" DEBUG=$DEBUG TRANSLATION="$TRANSLATION" + BUILD: build-${{ inputs.board }} + EXTENSIONS: ${{ steps.build-extensions.outputs.extensions }} + run: | + # Name the firmware. targets explicitly rather than relying on the + # default goal. The zephyr-cp port's default goal is the Zephyr ELF, so + # the firmware.* copies the artifact upload expects are never made. + ARTIFACTS="" + for ext in $EXTENSIONS; do + ARTIFACTS="$ARTIFACTS $BUILD/firmware.$ext" + done + make -j4 $FLAGS BOARD="$BOARD" BUILD="$BUILD" DEBUG=$DEBUG \ + TRANSLATION="$TRANSLATION" $ARTIFACTS working-directory: ports/${{ steps.board-to-port.outputs.port }} - name: Upload artifact uses: actions/upload-artifact@v7 diff --git a/ports/zephyr-cp/zephyr-config/west.yml b/ports/zephyr-cp/zephyr-config/west.yml index c7c83954cec..a1d5f4adbf5 100644 --- a/ports/zephyr-cp/zephyr-config/west.yml +++ b/ports/zephyr-cp/zephyr-config/west.yml @@ -6,8 +6,32 @@ manifest: url: https://github.com/tannewt/ext_nRF_hw_models revision: 41cb69cd08848cc31e91ed62d01d397511981919 path: modules/bsim_hw_models/nrf_hw_models + # CI-ONLY OVERRIDES -- do not merge to main. + # + # The Pico 2 W Bluetooth work lives in fork branches that are still in + # review, so point the manifest at them to get a BLE-capable CI artifact. + # A project declared here overrides the same-named project imported from + # Zephyr's own manifest. + # + # tyeth/zephyr#1 CYW43 shared-bus HCI driver (+ #2 USB DPRAM fix) + # tyeth/hal_rpi_pico#1 cybt ring-index hardening + # tyeth/hal_rpi_pico#2 flash helper RAM placement + # tyeth/hal_infineon#1 Murata-1YN BT coex NVRAM + # + # Revert to these upstream revisions before merging: + # zephyr adafruit/zephyr @ 52dc937c7cda06a1c18ff6adec281bbeb096b3d4 + # hal_rpi_pico as imported by Zephyr's own manifest + # hal_infineon as imported by Zephyr's own manifest - name: zephyr - url: https://github.com/adafruit/zephyr - revision: 52dc937c7cda06a1c18ff6adec281bbeb096b3d4 + url: https://github.com/tyeth/zephyr + revision: cyw43-shared-bus-ble clone-depth: 100 import: true + - name: hal_rpi_pico + url: https://github.com/tyeth/hal_rpi_pico + revision: integration-pico2w-ble + path: modules/hal/rpi_pico + - name: hal_infineon + url: https://github.com/tyeth/hal_infineon + revision: cyw43-shared-bus-ble + path: modules/hal/infineon diff --git a/tools/board_build_extensions.py b/tools/board_build_extensions.py new file mode 100644 index 00000000000..d92941dbd74 --- /dev/null +++ b/tools/board_build_extensions.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 + +# SPDX-FileCopyrightText: 2026 CircuitPython contributors (https://github.com/adafruit/circuitpython/graphs/contributors) +# +# SPDX-License-Identifier: MIT + +"""Print a board's CIRCUITPY_BUILD_EXTENSIONS, space separated. + +CI needs these to name the firmware. make targets explicitly. Most ports +produce them via the default goal, but the zephyr-cp port's default goal is the +Zephyr ELF, so the firmware.* copies have to be asked for by name. This mirrors +how tools/build_release_files.py resolves the same setting. +""" + +import sys +import tomllib +from pathlib import Path + +TOP = Path(__file__).parent.parent +sys.path.append(str(TOP / "docs")) + +from shared_bindings_matrix import get_board_mapping # noqa: E402 + + +def main(board: str) -> int: + board_mapping = get_board_mapping() + if board not in board_mapping: + raise ValueError(f"Unknown board {board!r}") + port = board_mapping[board]["port"] + + if port == "zephyr-cp": + # Board ids are vendor_board and the vendor may itself contain an + # underscore, so walk the separators until a circuitpython.toml exists. + next_underscore = board.find("_") + cp_toml = None + while next_underscore != -1: + vendor = board[:next_underscore] + target = board[next_underscore + 1 :] + candidate = TOP / f"ports/zephyr-cp/boards/{vendor}/{target}/circuitpython.toml" + if candidate.exists(): + cp_toml = candidate + break + next_underscore = board.find("_", next_underscore + 1) + if cp_toml is None: + raise ValueError(f"No circuitpython.toml found for zephyr-cp board {board!r}") + with cp_toml.open("rb") as f: + extensions = tomllib.load(f)["CIRCUITPY_BUILD_EXTENSIONS"] + else: + # Imported lazily: build_board_info pulls in adabot, which zephyr-cp + # boards do not need and which is not installed everywhere. + sys.path.append(str(TOP / "tools")) + from build_board_info import get_settings_from_makefile + + settings = get_settings_from_makefile(str(TOP / "ports" / port), board) + extensions = [e.strip() for e in settings["CIRCUITPY_BUILD_EXTENSIONS"].split(",")] + + print(" ".join(extensions)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1])) From 8031983fc93e9f1feaa5b4f93b6dda7bc2dd70cc Mon Sep 17 00:00:00 2001 From: tyeth Date: Wed, 9 Sep 2026 11:42:25 +0100 Subject: [PATCH 06/15] zephyr-cp: give the Pico 2 W twelve network contexts A CircuitPython program serving HTTP over the station link to a browser that opens several connections per page, with an NTP or mDNS UDP socket beside it, sits at Zephyr's default of six net_contexts: the listener, every accepted client and every UDP socket come out of the same pool, and the seventh socket() or accept() fails with ENOMEM. The BLE stack does not compete for these, but the hub use-case this board is aimed at -- WiFi station + HTTP + BLE advertising and scanning at the same time -- does. Double the pool (CONFIG_NET_MAX_CONTEXTS=12) and the connection-handler table that scales with it (CONFIG_NET_MAX_CONN=16); ZVFS_OPEN_ADD_SIZE_NET follows automatically. Pico 2 W only: the RP2040 board has 42 KB left and runs no server. Build (local, same tree as ci/pico2w-ble-assets @ 94bfd47b5f): FLASH 1,146,096 B (unchanged) RAM 229,144 -> 231,112 B (+1,968 B, 43.03% -> 43.40%) .config: NET_MAX_CONTEXTS=12 NET_MAX_CONN=16 ZVFS_OPEN_ADD_SIZE_NET=12 Not run on hardware. Python side that uses it: tyeth/deepsleep_espnow_wifi_and_ble_env_collector "max_sockets" in collector/config.json (caps.MAX_SOCKETS). Co-Authored-By: Claude Opus 5 --- .../boards/raspberrypi/rpi_pico2_w_zephyr/board.conf | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.conf b/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.conf index 81b1f87c4d2..561665a87b6 100644 --- a/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.conf +++ b/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.conf @@ -35,3 +35,13 @@ CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE=4096 # so turn it off here to make the host use the legacy 0x200B/0x200C scan and # legacy advertising commands instead. CONFIG_BT_EXT_ADV=n + +# Sockets. Every Zephyr socket -- a listener, each accepted client, each UDP +# socket -- takes one net_context, and the default pool is 6. A CircuitPython +# program serving HTTP over the station link to a browser that opens several +# connections per page, with NTP and a captive/mDNS UDP socket beside it, sits +# at that ceiling, and the 6th socket() or accept() fails with ENOMEM. A +# net_context is ~150 bytes; the RP2350 has the RAM, so double the pool and +# the connection-handler table that goes with it (NET_MAX_CONN, default 8). +CONFIG_NET_MAX_CONTEXTS=12 +CONFIG_NET_MAX_CONN=16 From e6b7fd1567c834fc82536095c576ecf9a246c0c0 Mon Sep 17 00:00:00 2001 From: tyeth Date: Wed, 9 Sep 2026 12:30:48 +0100 Subject: [PATCH 07/15] zephyr-cp: fix USB loss under BLE load, and allow four BT connections The rpi_pico UDC driver's internal thread stack was only raised in debug.conf, so release builds kept Zephyr's 512-byte default and dropped off USB partway through a _bleio scan -- CDC port and CIRCUITPY volume both disappearing, with a USB device/stack error on reset. Move it to prj.conf so every build gets it. Verified on a Pico 2 W: a 12s active scan yielding 925 reports from 20 distinct devices now completes with USB intact, where the same workload previously killed the bus. Zephyr defaults BT_MAX_CONN to 1, which would confine the Pico 2 W hub to a single peer and force a connectionless node protocol. The CYW43439 is not the constraint, so raise it to 4 and let nodes connect to sync data. Together these cost 5,752 B of RAM (231,112 -> 236,864, 43.40% -> 44.48%) and 408 B of flash. Co-Authored-By: Claude Opus 5 --- .../boards/raspberrypi/rpi_pico2_w_zephyr/board.conf | 6 ++++++ ports/zephyr-cp/prj.conf | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.conf b/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.conf index 561665a87b6..ff1e9baebf0 100644 --- a/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.conf +++ b/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.conf @@ -45,3 +45,9 @@ CONFIG_BT_EXT_ADV=n # the connection-handler table that goes with it (NET_MAX_CONN, default 8). CONFIG_NET_MAX_CONTEXTS=12 CONFIG_NET_MAX_CONN=16 + +# Zephyr defaults BT_MAX_CONN to 1, which would limit the hub to a single +# peer and force a connectionless (advertisement-only) node protocol. The +# CYW43439 controller is not the constraint; raise it so nodes can connect +# and sync data. Costs RAM per connection. +CONFIG_BT_MAX_CONN=4 diff --git a/ports/zephyr-cp/prj.conf b/ports/zephyr-cp/prj.conf index fa788887236..e9a3983105c 100644 --- a/ports/zephyr-cp/prj.conf +++ b/ports/zephyr-cp/prj.conf @@ -84,3 +84,9 @@ CONFIG_SETTINGS_NVS_SECTOR_COUNT=256 # region it does not recognise instead, so first boot after a layout change # recovers on its own rather than needing a manual erase. CONFIG_NVS_INIT_BAD_MEMORY_REGION=y + +# The rpi_pico UDC driver's internal thread defaults to 512 bytes, which +# overflows once USB is servicing traffic alongside the WiFi/BT gSPI bus -- +# the board drops off USB mid-run ("USB device stack error" on reset). This +# was previously only raised in debug.conf, so release builds still crashed. +CONFIG_UDC_RPI_PICO_STACK_SIZE=2048 From 06af5d4988d95ea680fb607ee727a811549f4b9f Mon Sep 17 00:00:00 2001 From: tyeth Date: Wed, 9 Sep 2026 15:26:38 +0100 Subject: [PATCH 08/15] 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 09/15] 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) { From 1e4a8388a36c1f20470347102c7c91a1c152d736 Mon Sep 17 00:00:00 2001 From: tyeth Date: Wed, 9 Sep 2026 15:27:45 +0100 Subject: [PATCH 10/15] zephyr-cp: make TCP sockets work on the Pico W boards Nothing in the port, the SoC defaults or these boards' Kconfig turned CONFIG_NET_TCP on, so every SOCK_STREAM socket failed inside net_context_get() with EPROTOTYPE -- which socketpool reported as a generic "Out of sockets" -- and the web workflow's listener never opened either. Enable TCP on both boards, and raise the fd table to 16: Zephyr sized it from the subsystems' declared needs (13 here), which the DHCPv4 server and the socket-service eventfd eat into. Three socketpool fixes found on the way: - A socket object is allocated with a finaliser and zeroed before zsock_socket() runs. If that failed, num stayed 0 and the finaliser later called zsock_shutdown(0). fd 0 belongs to the socket service's eventfd, whose shorter vtable has no shutdown slot, and the CPU branched into cdc_acm_1's data: "USAGE FAULT / Illegal use of the EPSR", pc 0x200003f8, lr z_impl_zsock_shutdown, system halted. Mark the object closed (num = -1) before attempting creation. - Report errno from a failed zsock_socket() (ENOENT: no net_context left, EPROTOTYPE: protocol off, ENFILE: fd table) instead of the fixed message. - Accepted sockets did not inherit the listener's timeout: the Python-facing accept path only set num, so a fresh object read as timeout 0 and the ssl layer's first recv on an accepted TLS connection raised EAGAIN before the handshake could finish. Inherit it, as the other ports do. Measured on a Pico 2 W with the softAP up: 11 TCP sockets open before ENOENT (12 net_contexts, one held by the DHCPv4 server). Co-Authored-By: Claude Opus 5 --- .../raspberrypi/rpi_pico2_w_zephyr/board.conf | 14 ++++++++++ .../raspberrypi/rpi_pico_w_zephyr/board.conf | 13 +++++++++ .../zephyr-cp/common-hal/socketpool/Socket.c | 28 +++++++++++++++++++ 3 files changed, 55 insertions(+) diff --git a/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.conf b/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.conf index ff1e9baebf0..0a81c068770 100644 --- a/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.conf +++ b/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.conf @@ -3,6 +3,12 @@ CONFIG_NET_IPV4=y CONFIG_NET_DHCPV4=y CONFIG_NET_SOCKETS=y +# TCP. Nothing in the port or the SoC defaults turns it on, so until now every +# SOCK_STREAM socket on these boards failed inside net_context_get() with +# EPROTOTYPE -- surfaced by socketpool as "Out of sockets" -- which also means +# the web workflow's listener never opened. HTTP(S) clients and servers need it. +CONFIG_NET_TCP=y + CONFIG_WIFI=y CONFIG_WIFI_NM_WPA_SUPPLICANT_LEGACY_CRYPTO=n CONFIG_NET_L2_WIFI_MGMT=y @@ -46,8 +52,16 @@ CONFIG_BT_EXT_ADV=n CONFIG_NET_MAX_CONTEXTS=12 CONFIG_NET_MAX_CONN=16 +# File descriptors. Zephyr sizes the fd table from the subsystems' declared +# needs (ZVFS_OPEN_ADD_SIZE_*), which came to 4 here; the DHCPv4 server takes +# one socket plus the socket-service eventfd, leaving a program two, and +# socketpool.socket() then fails with "Out of sockets" at the first TLS +# listener. Give sockets a real ceiling (each fd entry is ~12 bytes). +CONFIG_ZVFS_OPEN_MAX=16 + # Zephyr defaults BT_MAX_CONN to 1, which would limit the hub to a single # peer and force a connectionless (advertisement-only) node protocol. The # CYW43439 controller is not the constraint; raise it so nodes can connect # and sync data. Costs RAM per connection. CONFIG_BT_MAX_CONN=4 + diff --git a/ports/zephyr-cp/boards/raspberrypi/rpi_pico_w_zephyr/board.conf b/ports/zephyr-cp/boards/raspberrypi/rpi_pico_w_zephyr/board.conf index ad03cc6d10e..b0c388aa430 100644 --- a/ports/zephyr-cp/boards/raspberrypi/rpi_pico_w_zephyr/board.conf +++ b/ports/zephyr-cp/boards/raspberrypi/rpi_pico_w_zephyr/board.conf @@ -3,6 +3,12 @@ CONFIG_NET_IPV4=y CONFIG_NET_DHCPV4=y CONFIG_NET_SOCKETS=y +# TCP. Nothing in the port or the SoC defaults turns it on, so until now every +# SOCK_STREAM socket on these boards failed inside net_context_get() with +# EPROTOTYPE -- surfaced by socketpool as "Out of sockets" -- which also means +# the web workflow's listener never opened. HTTP(S) clients and servers need it. +CONFIG_NET_TCP=y + CONFIG_WIFI=y CONFIG_WIFI_NM_WPA_SUPPLICANT_LEGACY_CRYPTO=n CONFIG_NET_L2_WIFI_MGMT=y @@ -19,6 +25,13 @@ CONFIG_MBEDTLS_CIPHERSUITE_TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256=y CONFIG_MBEDTLS_ENTROPY_C=y CONFIG_MBEDTLS_CTR_DRBG_C=y +# File descriptors. Zephyr sizes the fd table from the subsystems' declared +# needs (ZVFS_OPEN_ADD_SIZE_*), which came to 4 here; the DHCPv4 server takes +# one socket plus the socket-service eventfd, leaving a program two, and +# socketpool.socket() then fails with "Out of sockets" at the first TLS +# listener. Give sockets a real ceiling (each fd entry is ~12 bytes). +CONFIG_ZVFS_OPEN_MAX=16 + CONFIG_TEST_RANDOM_GENERATOR=y # Bluetooth over the shared gSPI bus (CONFIG_BT itself comes from the diff --git a/ports/zephyr-cp/common-hal/socketpool/Socket.c b/ports/zephyr-cp/common-hal/socketpool/Socket.c index bb626857a98..4e49b8b11e5 100644 --- a/ports/zephyr-cp/common-hal/socketpool/Socket.c +++ b/ports/zephyr-cp/common-hal/socketpool/Socket.c @@ -9,6 +9,7 @@ #include "shared/runtime/interrupt_char.h" #include "py/mperrno.h" #include "py/runtime.h" +#include "bindings/zephyr_kernel/__init__.h" #include "shared-bindings/socketpool/SocketPool.h" #include "common-hal/socketpool/__init__.h" #include "common-hal/wifi/__init__.h" @@ -156,6 +157,13 @@ static bool _socketpool_socket(socketpool_socketpool_obj_t *self, sock->ipproto = ipproto; sock->pool = self; sock->timeout_ms = (uint)-1; + // The object was allocated with a finaliser and zeroed, so until a socket + // exists it must read as closed (num < 0). Left at 0 after a failed + // zsock_socket(), the finaliser later called zsock_shutdown(0) -- fd 0 + // belongs to the socket service's eventfd, whose shorter vtable has no + // shutdown slot -- and the CPU branched into cdc_acm_1's data (usage + // fault, halt). + sock->num = -1; int socknum = zsock_socket(sock->family, sock->type, sock->ipproto); if (socknum < 0) { @@ -205,6 +213,11 @@ socketpool_socket_obj_t *common_hal_socketpool_socket(socketpool_socketpool_obj_ socketpool_socket_obj_t *sock = mp_obj_malloc_with_finaliser(socketpool_socket_obj_t, &socketpool_socket_type); if (!_socketpool_socket(self, family, type, proto, sock)) { + // Say which limit was hit (ENOMEM: net_contexts, ENFILE/EMFILE: the + // fd table) rather than a generic message. + if (errno != 0) { + raise_zephyr_error(-errno); + } mp_raise_RuntimeError(MP_ERROR_TEXT("Out of sockets")); } return sock; @@ -255,6 +268,13 @@ int socketpool_socket_accept(socketpool_socket_obj_t *self, mp_obj_t *peer_out, accepted->pool = self->pool; accepted->connected = true; accepted->type = self->type; + accepted->family = self->family; + accepted->ipproto = self->ipproto; + // Inherit the listener's timeout, as the other ports do. A freshly + // allocated socket object reads as timeout 0 (non-blocking), which made + // the first ssl recv on an accepted connection raise EAGAIN before the + // TLS handshake had a chance to complete. + accepted->timeout_ms = self->timeout_ms; } if (peer_out) { @@ -277,6 +297,14 @@ socketpool_socket_obj_t *common_hal_socketpool_socket_accept(socketpool_socket_o sock->pool = self->pool; sock->connected = true; sock->type = self->type; + sock->family = self->family; + sock->ipproto = self->ipproto; + // Inherit the listener's timeout, as the other ports do. A freshly + // allocated object reads as timeout 0 (non-blocking), and ssl's + // recv_into relies on the plain socket's recv blocking for it: the + // first read on an accepted TLS connection raised EAGAIN before the + // handshake could complete. + sock->timeout_ms = self->timeout_ms; return sock; } else { From cbcfeec07710805f1eca8edba57cd014839c6798 Mon Sep 17 00:00:00 2001 From: tyeth Date: Wed, 9 Sep 2026 15:27:45 +0100 Subject: [PATCH 11/15] ssl: do not demand a client certificate from a server-side context wrap_socket(server_side=True) configured mbedTLS with VERIFY_REQUIRED whenever the context had the default root bundle attached, which every ssl.SSLContext() does. On a server that means "require a client certificate", so a plain HTTPS server -- load_cert_chain() and nothing else -- failed every handshake with MBEDTLS_ERR_SSL_NO_CLIENT_CERTIFICATE (-0x7480) and the client saw a timeout. Default server-side contexts to VERIFY_NONE, as CPython's do (CERT_NONE for servers); a context that loaded its own CA with load_verify_locations() still verifies clients. Client-side behaviour is unchanged. Seen on a Pico 2 W serving a captive portal over its softAP; the same code runs on every port using the shared mbedTLS module. Co-Authored-By: Claude Opus 5 --- shared-module/ssl/SSLSocket.c | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/shared-module/ssl/SSLSocket.c b/shared-module/ssl/SSLSocket.c index a9969505509..a184b3f2910 100644 --- a/shared-module/ssl/SSLSocket.c +++ b/shared-module/ssl/SSLSocket.c @@ -280,7 +280,16 @@ ssl_sslsocket_obj_t *common_hal_ssl_sslcontext_wrap_socket(ssl_sslcontext_obj_t goto cleanup; } - if (self->crt_bundle_attach != NULL) { + if (server_side && !(self->cacert_buf && self->cacert_bytes)) { + // On a server the CA store is about authenticating *clients*. A + // context that only had load_cert_chain() called on it -- the normal + // HTTPS-server setup -- must not demand a client certificate, which is + // what the default root bundle turned into: every handshake failed + // with MBEDTLS_ERR_SSL_NO_CLIENT_CERTIFICATE. Match CPython, where a + // server-side context defaults to CERT_NONE; a program that loads its + // own CA with load_verify_locations() still gets client verification. + mbedtls_ssl_conf_authmode(&o->conf, MBEDTLS_SSL_VERIFY_NONE); + } else if (self->crt_bundle_attach != NULL) { mbedtls_ssl_conf_authmode(&o->conf, MBEDTLS_SSL_VERIFY_REQUIRED); self->crt_bundle_attach(&o->conf); } else if (self->cacert_buf && self->cacert_bytes) { From 04a55c99da427f8d7cae52b2c9c255498de69316 Mon Sep 17 00:00:00 2001 From: tyeth Date: Wed, 9 Sep 2026 15:27:45 +0100 Subject: [PATCH 12/15] zephyr-cp: make TLS 1.2 ECDHE and PEM certificates work on the Pico W boards Two Kconfig gaps kept an HTTPS server (or client) from ever completing a handshake on these boards: - Zephyr's mbedTLS 4 default serves secp256r1 through the p256-m PSA driver and leaves the builtin ECP module out. mbedTLS then defines the dummy MBEDTLS_ECP_MAX_BITS 1, and ssl.h sizes the TLS 1.2 premaster buffer (union mbedtls_ssl_premaster_secret._pms_ecdh[MBEDTLS_ECP_MAX_BYTES]) from it: sizeof(handshake->premaster) was 1 in the firmware (DWARF). Every ECDHE key agreement then failed -- "psa_raw_key_agreement() returned -138 (-0x008a)", PSA_ERROR_BUFFER_TOO_SMALL for a 32-byte shared secret -- and the raw PSA status leaked through mbedtls_ssl_read() as OSError 138 on the first read of any TLS connection. Disabling the p256-m driver brings the builtin ECP module back and the buffer is 32 bytes again. This is an upstream sizing bug (ssl.h should size that buffer from PSA when the ECP module is absent); the Kconfig is the workaround until it is fixed. - PEM parsing was not compiled in, so load_cert_chain() with the usual fullchain.pem / key.pem could only fail; the collector's certstore is PEM. Verified on a Pico 2 W: an RSA-2048 PEM certificate served from CIRCUITPY over the softAP; an ESP32-C6 client completed the handshake and received "HTTP/1.0 200 OK" in 2.0 s. Flash +1,868 B (PEM) and +4,716 B (builtin ECP instead of p256-m) on the Pico 2 W; no static RAM change. Co-Authored-By: Claude Opus 5 --- .../raspberrypi/rpi_pico2_w_zephyr/board.conf | 15 +++++++++++++++ .../raspberrypi/rpi_pico_w_zephyr/board.conf | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.conf b/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.conf index 0a81c068770..42c247c8488 100644 --- a/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.conf +++ b/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.conf @@ -25,6 +25,21 @@ CONFIG_MBEDTLS_CIPHERSUITE_TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256=y CONFIG_MBEDTLS_ENTROPY_C=y CONFIG_MBEDTLS_CTR_DRBG_C=y +# The collector's captive portal serves HTTPS from a PEM certificate chain +# (fullchain.pem / key.pem via ssl.SSLContext.load_cert_chain); mbedTLS only +# parses DER unless PEM decoding is compiled in. +CONFIG_MBEDTLS_PEM_PARSE_C=y + +# TLS 1.2 ECDHE. Zephyr's mbedTLS 4 default serves secp256r1 through the p256-m +# PSA driver and leaves the builtin ECP module out. mbedTLS then defines a dummy +# MBEDTLS_ECP_MAX_BITS of 1, and ssl.h sizes the TLS 1.2 premaster buffer +# (union mbedtls_ssl_premaster_secret._pms_ecdh[MBEDTLS_ECP_MAX_BYTES]) from +# it, so every ECDHE key agreement fails with PSA_ERROR_BUFFER_TOO_SMALL -- a +# 1-byte buffer for a 32-byte shared secret -- and surfaces as OSError 138 on +# the first read of any TLS connection, client or server. Keep the builtin +# ECP module (p256-m off) until the header sizes that buffer from PSA. +CONFIG_MBEDTLS_PSA_P256M_DRIVER_ENABLED=n + # Bluetooth over the shared gSPI bus (CONFIG_BT itself comes from the # zephyr,bt-hci chosen node in the overlay). # diff --git a/ports/zephyr-cp/boards/raspberrypi/rpi_pico_w_zephyr/board.conf b/ports/zephyr-cp/boards/raspberrypi/rpi_pico_w_zephyr/board.conf index b0c388aa430..100e830d220 100644 --- a/ports/zephyr-cp/boards/raspberrypi/rpi_pico_w_zephyr/board.conf +++ b/ports/zephyr-cp/boards/raspberrypi/rpi_pico_w_zephyr/board.conf @@ -25,6 +25,21 @@ CONFIG_MBEDTLS_CIPHERSUITE_TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256=y CONFIG_MBEDTLS_ENTROPY_C=y CONFIG_MBEDTLS_CTR_DRBG_C=y +# The collector's captive portal serves HTTPS from a PEM certificate chain +# (fullchain.pem / key.pem via ssl.SSLContext.load_cert_chain); mbedTLS only +# parses DER unless PEM decoding is compiled in. +CONFIG_MBEDTLS_PEM_PARSE_C=y + +# TLS 1.2 ECDHE. Zephyr's mbedTLS 4 default serves secp256r1 through the p256-m +# PSA driver and leaves the builtin ECP module out. mbedTLS then defines a dummy +# MBEDTLS_ECP_MAX_BITS of 1, and ssl.h sizes the TLS 1.2 premaster buffer +# (union mbedtls_ssl_premaster_secret._pms_ecdh[MBEDTLS_ECP_MAX_BYTES]) from +# it, so every ECDHE key agreement fails with PSA_ERROR_BUFFER_TOO_SMALL -- a +# 1-byte buffer for a 32-byte shared secret -- and surfaces as OSError 138 on +# the first read of any TLS connection, client or server. Keep the builtin +# ECP module (p256-m off) until the header sizes that buffer from PSA. +CONFIG_MBEDTLS_PSA_P256M_DRIVER_ENABLED=n + # File descriptors. Zephyr sizes the fd table from the subsystems' declared # needs (ZVFS_OPEN_ADD_SIZE_*), which came to 4 here; the DHCPv4 server takes # one socket plus the socket-service eventfd, leaving a program two, and From 8ea8f3f75c3d04eedf3017fe63d9f97c7877813d Mon Sep 17 00:00:00 2001 From: tyeth Date: Wed, 9 Sep 2026 15:27:45 +0100 Subject: [PATCH 13/15] zephyr-cp: implement softAP with a DHCPv4 server on the Pico W boards wifi.radio.start_ap() returned silently with ap_active False: the whole body of common_hal_wifi_radio_start_ap() was commented-out ESP-IDF code. The AIROC driver already implements ap_enable/ap_disable through wifi_mgmt, so this is the hook-up, plus the pieces around it. start_ap(ssid, password, channel, authmode, max_connections) issues NET_REQUEST_WIFI_AP_ENABLE with the CircuitPython authmode mapped to WIFI_SECURITY_TYPE_NONE / PSK / SAE (the driver brings every PSK variant up as WPA2-AES-PSK; WPA1/TKIP is not offered separately). max_connections gets the ESP32 range check but cannot be applied: the CYW43439's association limit is fixed in the controller. The AP gets 192.168.4.1/24 by default (as on ESP32), settable with set_ipv4_address_ap(), and the DHCPv4 server starts with it (pool of 8 above the AP address, DNS pointing at the AP, RFC 8910 option 114 so phones and laptops find a captive portal automatically); start_dhcp_ap()/stop_dhcp_ap() remain explicit controls. stop_ap() tears it all down. ap_active asks the driver (WIFI_MODE_AP in the interface status) rather than trusting a flag. stations_ap lists the stations the driver reported through NET_EVENT_WIFI_AP_STA_CONNECTED/DISCONNECTED with the IPv4 address from the DHCP lease table (no RSSI: wifi_mgmt's station events carry none). set_ipv4_address() for the station side, start_dhcp() and stop_dhcp() are implemented on the way. The AIROC driver runs the AP on the same net_if as the station and refuses AP_ENABLE with -EBUSY while the station is associated, so there is no simultaneous AP+STA on these boards: disconnect() first. Soft reboot tears the AP down (wifi_reset), and start_ap() disables a running AP the driver still reports before enabling, so the two cannot get out of step -- a soft reboot was seen to leave the driver's flag set when its stop failed. Needs the AIROC driver fix in tyeth/zephyr (branch airoc-softap-fixes): without it the driver's chanspec composition makes the firmware reject every 2.4 GHz channel, and a station leaving took the AP's interface dormant. Verified on a Pico 2 W with an ESP32-C6 as the station: start_ap() completes in 0.15 s; the C6 associates in 7 s, gets 192.168.4.2 from the DHCP server with gateway/DNS 192.168.4.1, pings the AP in 5-8 ms; stations_ap shows it with its lease, empties when it disconnects (the AP stays active), and shows it again on rejoin; the AP survives stop_ap()/start_ap() cycles and a soft reboot. Flash +9,444 B, RAM +3,568 B (DHCPv4 server + socket service). Co-Authored-By: Claude Opus 5 --- .../raspberrypi/rpi_pico2_w_zephyr/board.conf | 12 + .../raspberrypi/rpi_pico_w_zephyr/board.conf | 12 + ports/zephyr-cp/common-hal/wifi/Radio.c | 458 +++++++++++++----- ports/zephyr-cp/common-hal/wifi/Radio.h | 22 + ports/zephyr-cp/common-hal/wifi/__init__.c | 46 +- 5 files changed, 427 insertions(+), 123 deletions(-) diff --git a/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.conf b/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.conf index 42c247c8488..8ee2ca6e9b8 100644 --- a/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.conf +++ b/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.conf @@ -40,6 +40,18 @@ CONFIG_MBEDTLS_PEM_PARSE_C=y # ECP module (p256-m off) until the header sizes that buffer from PSA. CONFIG_MBEDTLS_PSA_P256M_DRIVER_ENABLED=n +# SoftAP. wifi.radio.start_ap() drives the AIROC driver's ap_enable through +# wifi_mgmt (the driver already implements it; this only enables the pieces +# around it). The DHCPv4 server hands joining stations addresses from a pool +# just above the AP's own -- 192.168.4.1 by default, as on ESP32 -- and tells +# them the AP is their DNS server and (RFC 8910 option 114) captive portal, so +# a portal served from CircuitPython is found by phones and laptops without +# the user typing an address. +CONFIG_NET_DHCPV4_SERVER=y +CONFIG_NET_DHCPV4_SERVER_ADDR_COUNT=8 +CONFIG_NET_DHCPV4_SERVER_OPTION_DNS_ADDRESS="192.168.4.1" +CONFIG_NET_DHCPV4_SERVER_OPTION_CAPTIVE_PORTAL=y + # Bluetooth over the shared gSPI bus (CONFIG_BT itself comes from the # zephyr,bt-hci chosen node in the overlay). # diff --git a/ports/zephyr-cp/boards/raspberrypi/rpi_pico_w_zephyr/board.conf b/ports/zephyr-cp/boards/raspberrypi/rpi_pico_w_zephyr/board.conf index 100e830d220..7384c18b57e 100644 --- a/ports/zephyr-cp/boards/raspberrypi/rpi_pico_w_zephyr/board.conf +++ b/ports/zephyr-cp/boards/raspberrypi/rpi_pico_w_zephyr/board.conf @@ -47,6 +47,18 @@ CONFIG_MBEDTLS_PSA_P256M_DRIVER_ENABLED=n # listener. Give sockets a real ceiling (each fd entry is ~12 bytes). CONFIG_ZVFS_OPEN_MAX=16 +# SoftAP. wifi.radio.start_ap() drives the AIROC driver's ap_enable through +# wifi_mgmt (the driver already implements it; this only enables the pieces +# around it). The DHCPv4 server hands joining stations addresses from a pool +# just above the AP's own -- 192.168.4.1 by default, as on ESP32 -- and tells +# them the AP is their DNS server and (RFC 8910 option 114) captive portal, so +# a portal served from CircuitPython is found by phones and laptops without +# the user typing an address. +CONFIG_NET_DHCPV4_SERVER=y +CONFIG_NET_DHCPV4_SERVER_ADDR_COUNT=8 +CONFIG_NET_DHCPV4_SERVER_OPTION_DNS_ADDRESS="192.168.4.1" +CONFIG_NET_DHCPV4_SERVER_OPTION_CAPTIVE_PORTAL=y + CONFIG_TEST_RANDOM_GENERATOR=y # Bluetooth over the shared gSPI bus (CONFIG_BT itself comes from the diff --git a/ports/zephyr-cp/common-hal/wifi/Radio.c b/ports/zephyr-cp/common-hal/wifi/Radio.c index a85f6bbb736..eaabc7b02ac 100644 --- a/ports/zephyr-cp/common-hal/wifi/Radio.c +++ b/ports/zephyr-cp/common-hal/wifi/Radio.c @@ -14,6 +14,7 @@ #include "shared/runtime/interrupt_char.h" #include "py/gc.h" #include "py/obj.h" +#include "py/objnamedtuple.h" #include "py/runtime.h" #include "shared-bindings/ipaddress/IPv4Address.h" #include "shared-bindings/wifi/ScannedNetworks.h" @@ -27,6 +28,9 @@ #include #include #include +#if defined(CONFIG_NET_DHCPV4_SERVER) +#include +#endif // dns_resolve_get_default() for radio.ipv4_dns. #include #include @@ -283,86 +287,255 @@ void common_hal_wifi_radio_stop_station(wifi_radio_obj_t *self) { // set_mode_station(self, false); } +// ------------------------------------------------------------- access point +// +// The AIROC (CYW43439) driver runs the access point on the same net_if as the +// station and refuses NET_REQUEST_WIFI_AP_ENABLE with -EBUSY while the station +// is associated, so unlike ESP32 there is no simultaneous AP+STA here: a +// program that wants the portal must disconnect() first. The AP's IPv4 +// configuration, DHCPv4 server and station list are kept in the radio object +// beside sta_netif. + +#define WIFI_AP_DEFAULT_ADDRESS "192.168.4.1" +#define WIFI_AP_DEFAULT_NETMASK "255.255.255.0" + +static void ipv4address_to_net_in_addr(mp_obj_t obj, struct net_in_addr *out) { + if (!mp_obj_is_type(obj, &ipaddress_ipv4address_type)) { + mp_raise_ValueError(MP_ERROR_TEXT("Only IPv4 addresses supported")); + } + mp_buffer_info_t buf; + mp_get_buffer_raise(common_hal_ipaddress_ipv4address_get_packed(MP_OBJ_TO_PTR(obj)), &buf, MP_BUFFER_READ); + memset(out, 0, sizeof(*out)); + memcpy(out->s4_addr, buf.buf, MIN(buf.len, sizeof(out->s4_addr))); +} + +static void wifi_radio_ap_address_defaults(wifi_radio_obj_t *self) { + if (self->ap_addr_configured) { + return; + } + net_addr_pton(NET_AF_INET, WIFI_AP_DEFAULT_ADDRESS, &self->ap_addr); + net_addr_pton(NET_AF_INET, WIFI_AP_DEFAULT_NETMASK, &self->ap_netmask); + self->ap_gw = self->ap_addr; +} + +// Put self->ap_addr on the interface. It has CONFIG_NET_IF_MAX_IPV4_COUNT (one) +// unicast slot, so anything a station lease left behind has to go first. +static void wifi_radio_ap_apply_address(wifi_radio_obj_t *self) { + struct net_if *iface = self->sta_netif; + + wifi_radio_ap_address_defaults(self); + + struct net_in_addr *old = net_if_ipv4_get_global_addr(iface, NET_ADDR_ANY_STATE); + if (old != NULL && old->s_addr != self->ap_addr.s_addr) { + struct net_in_addr stale = *old; + net_if_ipv4_addr_rm(iface, &stale); + } + if (net_if_ipv4_addr_add(iface, &self->ap_addr, NET_ADDR_MANUAL, 0) == NULL) { + raise_zephyr_error(-ENOMEM); + } + net_if_ipv4_set_netmask_by_addr(iface, &self->ap_addr, &self->ap_netmask); + net_if_ipv4_set_gw(iface, &self->ap_gw); +} + +static void wifi_radio_ap_remove_address(wifi_radio_obj_t *self) { + struct net_in_addr none = { 0 }; + + net_if_ipv4_addr_rm(self->sta_netif, &self->ap_addr); + net_if_ipv4_set_gw(self->sta_netif, &none); +} + void common_hal_wifi_radio_start_ap(wifi_radio_obj_t *self, uint8_t *ssid, size_t ssid_len, uint8_t *password, size_t password_len, uint8_t channel, uint32_t authmode, uint8_t max_connections) { - // set_mode_ap(self, true); + if (!common_hal_wifi_radio_get_enabled(self)) { + mp_raise_RuntimeError(MP_ERROR_TEXT("WiFi is not enabled")); + } - // uint8_t esp_authmode = 0; - // switch (authmode) { - // case AUTHMODE_OPEN: - // esp_authmode = WIFI_AUTH_OPEN; - // break; - // case AUTHMODE_WPA | AUTHMODE_PSK: - // esp_authmode = WIFI_AUTH_WPA_PSK; - // break; - // case AUTHMODE_WPA2 | AUTHMODE_PSK: - // esp_authmode = WIFI_AUTH_WPA2_PSK; - // break; - // case AUTHMODE_WPA | AUTHMODE_WPA2 | AUTHMODE_PSK: - // esp_authmode = WIFI_AUTH_WPA_WPA2_PSK; - // break; - // default: - // mp_arg_error_invalid(MP_QSTR_authmode); - // break; - // } + enum wifi_security_type security = WIFI_SECURITY_TYPE_NONE; + switch (authmode) { + case AUTHMODE_OPEN: + security = WIFI_SECURITY_TYPE_NONE; + break; + // The driver brings every PSK mode up as WPA2-AES-PSK; it does not + // offer WPA1/TKIP on its own, so all three CircuitPython spellings map + // to the one the controller does. + case AUTHMODE_WPA | AUTHMODE_PSK: + case AUTHMODE_WPA2 | AUTHMODE_PSK: + case AUTHMODE_WPA | AUTHMODE_WPA2 | AUTHMODE_PSK: + security = WIFI_SECURITY_TYPE_PSK; + break; + case AUTHMODE_WPA3 | AUTHMODE_PSK: + case AUTHMODE_WPA2 | AUTHMODE_WPA3 | AUTHMODE_PSK: + security = WIFI_SECURITY_TYPE_SAE; + break; + default: + mp_arg_error_invalid(MP_QSTR_authmode); + break; + } + + // wifi_mgmt has no per-AP association limit and the CYW43439's is fixed in + // the controller (WHD only exposes whd_wifi_ap_get_max_assoc), so the + // argument gets the same range check as on ESP32 and is otherwise unused. + mp_arg_validate_int_range(max_connections, 0, 10, MP_QSTR_max_connections); + + // Take a running AP down first -- ours, or one the driver still reports + // after a failed teardown (a soft reboot has been seen to leave the two + // out of step). ap_enable refuses with -EAGAIN while its is_ap_up is set. + struct wifi_iface_status status = { 0 }; + bool driver_ap_up = net_mgmt(NET_REQUEST_WIFI_IFACE_STATUS, self->sta_netif, &status, + sizeof(status)) == 0 && status.iface_mode == WIFI_MODE_AP; + if (self->ap_mode) { + common_hal_wifi_radio_stop_ap(self); + } else if (driver_ap_up) { + int res = net_mgmt(NET_REQUEST_WIFI_AP_DISABLE, self->sta_netif, NULL, 0); + if (res < 0 && res != -EALREADY) { + raise_zephyr_error(res); + } + } + + struct wifi_connect_req_params params = { 0 }; + params.ssid = ssid; + params.ssid_length = ssid_len; + if (security != WIFI_SECURITY_TYPE_NONE) { + params.psk = password; + params.psk_length = password_len; + } + if (security == WIFI_SECURITY_TYPE_SAE) { + params.sae_password = password; + params.sae_password_length = password_len; + } + params.band = WIFI_FREQ_BAND_2_4_GHZ; + params.channel = channel; + params.security = security; + params.mfp = WIFI_MFP_OPTIONAL; + params.bandwidth = WIFI_FREQ_BANDWIDTH_20MHZ; - // wifi_config_t *config = &self->ap_config; - // memcpy(&config->ap.ssid, ssid, ssid_len); - // config->ap.ssid[ssid_len] = 0; - // memcpy(&config->ap.password, password, password_len); - // config->ap.password[password_len] = 0; - // config->ap.channel = channel; - // config->ap.authmode = esp_authmode; + #if defined(CONFIG_NET_DHCPV4) + // A station DHCP client left running would keep renewing into the + // interface's single IPv4 slot underneath the AP's address. + net_dhcpv4_stop(self->sta_netif); + #endif - // mp_arg_validate_int_range(max_connections, 0, 10, MP_QSTR_max_connections); + self->ap_station_count = 0; + CHECK_ZEPHYR_RESULT(net_mgmt(NET_REQUEST_WIFI_AP_ENABLE, self->sta_netif, ¶ms, sizeof(params))); + self->ap_mode = true; - // config->ap.max_connection = max_connections; + wifi_radio_ap_apply_address(self); - // esp_wifi_set_config(WIFI_IF_AP, config); + #if defined(CONFIG_NET_DHCPV4_SERVER) + // As on ESP32, where the AP netif's DHCP server is on by default; a program + // that wants static clients calls stop_dhcp_ap(). + common_hal_wifi_radio_start_dhcp_server(self); + #endif +} + +// Soft-reboot / wifi_reset() path: take the AP down without raising, so the +// next program starts from a station-only interface with no stale address, +// DHCP server or station table. +void wifi_radio_ap_reset(wifi_radio_obj_t *self) { + if (!self->ap_mode || self->sta_netif == NULL) { + return; + } + #if defined(CONFIG_NET_DHCPV4_SERVER) + if (self->dhcp_server_running) { + net_dhcpv4_server_stop(self->sta_netif); + self->dhcp_server_running = false; + } + #endif + int res = net_mgmt(NET_REQUEST_WIFI_AP_DISABLE, self->sta_netif, NULL, 0); + wifi_radio_ap_remove_address(self); + self->ap_station_count = 0; + if (res == 0 || res == -EALREADY) { + self->ap_mode = false; + } else { + // The driver still believes the AP is up (its next ap_enable would + // fail with "Already AP is on"); keep our flag in step so the next + // start_ap() retries the disable instead of trusting it. + LOG_ERR("AP disable at reset failed: %d", res); + } } bool common_hal_wifi_radio_get_ap_active(wifi_radio_obj_t *self) { - // return self->ap_mode && esp_netif_is_netif_up(self->ap_netif); - return false; + if (!self->ap_mode || self->sta_netif == NULL) { + return false; + } + // Ask the driver rather than trusting our own flag: the AIROC driver + // reports WIFI_MODE_AP only while its AP interface is really up. + struct wifi_iface_status status = { 0 }; + if (net_mgmt(NET_REQUEST_WIFI_IFACE_STATUS, self->sta_netif, &status, sizeof(status)) != 0) { + return false; + } + return status.iface_mode == WIFI_MODE_AP && net_if_is_up(self->sta_netif); } void common_hal_wifi_radio_stop_ap(wifi_radio_obj_t *self) { - // set_mode_ap(self, false); + if (!self->ap_mode) { + return; + } + common_hal_wifi_radio_stop_dhcp_server(self); + int res = net_mgmt(NET_REQUEST_WIFI_AP_DISABLE, self->sta_netif, NULL, 0); + wifi_radio_ap_remove_address(self); + self->ap_mode = false; + self->ap_station_count = 0; + if (res < 0 && res != -EALREADY) { + raise_zephyr_error(res); + } } -mp_obj_t common_hal_wifi_radio_get_stations_ap(wifi_radio_obj_t *self) { - // wifi_sta_list_t esp_sta_list; - // esp_err_t result; - - // result = esp_wifi_ap_get_sta_list(&esp_sta_list); - // if (result != ESP_OK) { - // return mp_const_none; - // } - - // esp_netif_pair_mac_ip_t mac_ip_pair[esp_sta_list.num]; - // for (int i = 0; i < esp_sta_list.num; i++) { - // memcpy(mac_ip_pair[i].mac, esp_sta_list.sta[i].mac, MAC_ADDRESS_LENGTH); - // mac_ip_pair[i].ip.addr = 0; - // } - - // result = esp_netif_dhcps_get_clients_by_mac(self->ap_netif, esp_sta_list.num, mac_ip_pair); - // if (result != ESP_OK) { - // return mp_const_none; - // } +#if defined(CONFIG_NET_DHCPV4_SERVER) +typedef struct { + const uint8_t *mac; + uint32_t addr; +} wifi_radio_lease_lookup_t; + +static void wifi_radio_lease_cb(struct net_if *iface, struct dhcpv4_addr_slot *lease, void *user_data) { + wifi_radio_lease_lookup_t *lookup = user_data; + (void)iface; + if (lease->state != DHCPV4_SERVER_ADDR_ALLOCATED) { + return; + } + // The server keeps the client's chaddr beside the client-identifier + // option, but a lease created through its address-probe path only carries + // the option (RFC 2132 type 1 + MAC for every client seen so far), so + // accept either form. + const struct dhcpv4_client_id *id = &lease->client_id; + bool hw_match = id->hw_addr_len == MAC_ADDRESS_LENGTH && + memcmp(id->hw_addr_buf, lookup->mac, MAC_ADDRESS_LENGTH) == 0; + bool opt_match = id->len == MAC_ADDRESS_LENGTH + 1 && id->buf[0] == 1 && + memcmp(&id->buf[1], lookup->mac, MAC_ADDRESS_LENGTH) == 0; + if (hw_match || opt_match) { + lookup->addr = lease->addr.s_addr; + } +} +#endif +mp_obj_t common_hal_wifi_radio_get_stations_ap(wifi_radio_obj_t *self) { mp_obj_t mp_sta_list = mp_obj_new_list(0, NULL); - // for (int i = 0; i < esp_sta_list.num; i++) { - // mp_obj_t elems[3] = { - // mp_obj_new_bytes(esp_sta_list.sta[i].mac, MAC_ADDRESS_LENGTH), - // MP_OBJ_NEW_SMALL_INT(esp_sta_list.sta[i].rssi), - // mp_const_none - // }; - - // if (mac_ip_pair[i].ip.addr) { - // elems[2] = common_hal_ipaddress_new_ipv4address(mac_ip_pair[i].ip.addr); - // } + if (!self->ap_mode) { + return mp_sta_list; + } - // mp_obj_list_append(mp_sta_list, namedtuple_make_new((const mp_obj_type_t *)&wifi_radio_station_type, 3, 0, elems)); - // } + // Snapshot the table; the net_mgmt event thread rewrites it. + uint8_t macs[WIFI_AP_MAX_STATIONS][MAC_ADDRESS_LENGTH]; + unsigned int key = irq_lock(); + size_t count = self->ap_station_count; + memcpy(macs, self->ap_stations, sizeof(macs)); + irq_unlock(key); + + for (size_t i = 0; i < count; i++) { + // wifi_mgmt's AP station events carry no RSSI, so that field is None. + mp_obj_t elems[3] = { + mp_obj_new_bytes(macs[i], MAC_ADDRESS_LENGTH), + mp_const_none, + mp_const_none + }; + #if defined(CONFIG_NET_DHCPV4_SERVER) + wifi_radio_lease_lookup_t lookup = { .mac = macs[i], .addr = 0 }; + net_dhcpv4_server_foreach_lease(self->sta_netif, wifi_radio_lease_cb, &lookup); + if (lookup.addr != 0) { + elems[2] = common_hal_ipaddress_new_ipv4address(lookup.addr); + } + #endif + mp_obj_list_append(mp_sta_list, namedtuple_make_new((const mp_obj_type_t *)&wifi_radio_station_type, 3, 0, elems)); + } return mp_sta_list; } @@ -686,11 +859,10 @@ mp_obj_t common_hal_wifi_radio_get_ipv4_gateway(wifi_radio_obj_t *self) { } mp_obj_t common_hal_wifi_radio_get_ipv4_gateway_ap(wifi_radio_obj_t *self) { - // if (!esp_netif_is_netif_up(self->ap_netif)) { - return mp_const_none; - // } - // esp_netif_get_ip_info(self->ap_netif, &self->ap_ip_info); - // return common_hal_ipaddress_new_ipv4address(self->ap_ip_info.gw.addr); + if (!common_hal_wifi_radio_get_ap_active(self)) { + return mp_const_none; + } + return common_hal_ipaddress_new_ipv4address(self->ap_gw.s_addr); } mp_obj_t common_hal_wifi_radio_get_ipv4_subnet(wifi_radio_obj_t *self) { @@ -715,11 +887,10 @@ mp_obj_t common_hal_wifi_radio_get_ipv4_subnet(wifi_radio_obj_t *self) { } mp_obj_t common_hal_wifi_radio_get_ipv4_subnet_ap(wifi_radio_obj_t *self) { - // if (!esp_netif_is_netif_up(self->ap_netif)) { - return mp_const_none; - // } - // esp_netif_get_ip_info(self->ap_netif, &self->ap_ip_info); - // return common_hal_ipaddress_new_ipv4address(self->ap_ip_info.netmask.addr); + if (!common_hal_wifi_radio_get_ap_active(self)) { + return mp_const_none; + } + return common_hal_ipaddress_new_ipv4address(self->ap_netmask.s_addr); } // static mp_obj_t common_hal_wifi_radio_get_addresses_netif(wifi_radio_obj_t *self, esp_netif_t *netif) { @@ -799,12 +970,10 @@ mp_obj_t common_hal_wifi_radio_get_ipv4_address(wifi_radio_obj_t *self) { } mp_obj_t common_hal_wifi_radio_get_ipv4_address_ap(wifi_radio_obj_t *self) { - // if (!esp_netif_is_netif_up(self->ap_netif)) { - // return mp_const_none; - // } - // esp_netif_get_ip_info(self->ap_netif, &self->ap_ip_info); - // return common_hal_ipaddress_new_ipv4address(self->ap_ip_info.ip.addr); - return mp_const_none; + if (!common_hal_wifi_radio_get_ap_active(self)) { + return mp_const_none; + } + return common_hal_ipaddress_new_ipv4address(self->ap_addr.s_addr); } mp_obj_t common_hal_wifi_radio_get_ipv4_dns(wifi_radio_obj_t *self) { @@ -838,66 +1007,113 @@ void common_hal_wifi_radio_set_ipv4_dns(wifi_radio_obj_t *self, mp_obj_t ipv4_dn } void common_hal_wifi_radio_start_dhcp_client(wifi_radio_obj_t *self, bool ipv4, bool ipv6) { - // if (ipv4) { - // esp_netif_dhcpc_start(self->netif); - // } else { - // esp_netif_dhcpc_stop(self->netif); - // } - // #if LWIP_IPV6_DHCP6 - // if (ipv6) { - // esp_netif_create_ip6_linklocal(self->netif); - // dhcp6_enable_stateless(esp_netif_get_netif_impl(self->netif)); - // } else { - // dhcp6_disable(esp_netif_get_netif_impl(self->netif)); - // } - // #else - // if (ipv6) { - // mp_raise_NotImplementedError_varg(MP_ERROR_TEXT("%q"), MP_QSTR_ipv6); - // } - // #endif + if (ipv6) { + mp_raise_NotImplementedError_varg(MP_ERROR_TEXT("%q"), MP_QSTR_ipv6); + } + #if defined(CONFIG_NET_DHCPV4) + if (self->sta_netif == NULL) { + return; + } + if (ipv4) { + net_dhcpv4_start(self->sta_netif); + } else { + net_dhcpv4_stop(self->sta_netif); + } + #endif } void common_hal_wifi_radio_stop_dhcp_client(wifi_radio_obj_t *self) { - // esp_netif_dhcpc_stop(self->netif); - // #if LWIP_IPV6_DHCP6 - // dhcp6_disable(esp_netif_get_netif_impl(self->netif)); - // #endif + #if defined(CONFIG_NET_DHCPV4) + if (self->sta_netif != NULL) { + net_dhcpv4_stop(self->sta_netif); + } + #endif } void common_hal_wifi_radio_start_dhcp_server(wifi_radio_obj_t *self) { - // esp_netif_dhcps_start(self->ap_netif); + #if defined(CONFIG_NET_DHCPV4_SERVER) + if (self->dhcp_server_running) { + return; + } + if (!self->ap_mode) { + raise_zephyr_error(-ENETDOWN); + } + // The pool starts just above the AP's own address and holds + // CONFIG_NET_DHCPV4_SERVER_ADDR_COUNT consecutive addresses. + struct net_in_addr base = self->ap_addr; + base.s4_addr[3] += 1; + int res = net_dhcpv4_server_start(self->sta_netif, &base); + if (res != 0 && res != -EALREADY) { + raise_zephyr_error(res); + } + self->dhcp_server_running = true; + #else + mp_raise_NotImplementedError(NULL); + #endif } void common_hal_wifi_radio_stop_dhcp_server(wifi_radio_obj_t *self) { - // esp_netif_dhcps_stop(self->ap_netif); + #if defined(CONFIG_NET_DHCPV4_SERVER) + if (!self->dhcp_server_running) { + return; + } + net_dhcpv4_server_stop(self->sta_netif); + self->dhcp_server_running = false; + #endif } void common_hal_wifi_radio_set_ipv4_address(wifi_radio_obj_t *self, mp_obj_t ipv4, mp_obj_t netmask, mp_obj_t gateway, mp_obj_t ipv4_dns) { - // common_hal_wifi_radio_stop_dhcp_client(self); // Must stop station DHCP to set a manual address - - // esp_netif_ip_info_t ip_info; - // ipaddress_ipaddress_to_esp_idf_ip4(ipv4, &ip_info.ip); - // ipaddress_ipaddress_to_esp_idf_ip4(netmask, &ip_info.netmask); - // ipaddress_ipaddress_to_esp_idf_ip4(gateway, &ip_info.gw); - - // esp_netif_set_ip_info(self->netif, &ip_info); + struct net_in_addr addr, mask, gw; + ipv4address_to_net_in_addr(ipv4, &addr); + ipv4address_to_net_in_addr(netmask, &mask); + ipv4address_to_net_in_addr(gateway, &gw); + + // Must stop station DHCP to set a manual address, or the next renewal + // replaces it. + common_hal_wifi_radio_stop_dhcp_client(self); + + struct net_in_addr *old = net_if_ipv4_get_global_addr(self->sta_netif, NET_ADDR_ANY_STATE); + if (old != NULL) { + struct net_in_addr stale = *old; + net_if_ipv4_addr_rm(self->sta_netif, &stale); + } + if (net_if_ipv4_addr_add(self->sta_netif, &addr, NET_ADDR_MANUAL, 0) == NULL) { + raise_zephyr_error(-ENOMEM); + } + net_if_ipv4_set_netmask_by_addr(self->sta_netif, &addr, &mask); + net_if_ipv4_set_gw(self->sta_netif, &gw); - // if (ipv4_dns != MP_OBJ_NULL) { - // common_hal_wifi_radio_set_ipv4_dns(self, ipv4_dns); - // } + if (ipv4_dns != MP_OBJ_NULL && ipv4_dns != mp_const_none) { + common_hal_wifi_radio_set_ipv4_dns(self, ipv4_dns); + } } void common_hal_wifi_radio_set_ipv4_address_ap(wifi_radio_obj_t *self, mp_obj_t ipv4, mp_obj_t netmask, mp_obj_t gateway) { - // common_hal_wifi_radio_stop_dhcp_server(self); // Must stop access point DHCP to set a manual address - - // esp_netif_ip_info_t ip_info; - // ipaddress_ipaddress_to_esp_idf_ip4(ipv4, &ip_info.ip); - // ipaddress_ipaddress_to_esp_idf_ip4(netmask, &ip_info.netmask); - // ipaddress_ipaddress_to_esp_idf_ip4(gateway, &ip_info.gw); + struct net_in_addr addr, mask, gw; + ipv4address_to_net_in_addr(ipv4, &addr); + ipv4address_to_net_in_addr(netmask, &mask); + ipv4address_to_net_in_addr(gateway, &gw); + + bool restart_dhcp = false; + if (self->ap_mode) { + // Must stop access point DHCP to move its address: the pool is derived + // from it. Drop the old address before the new one is recorded. + restart_dhcp = self->dhcp_server_running; + common_hal_wifi_radio_stop_dhcp_server(self); + wifi_radio_ap_remove_address(self); + } - // esp_netif_set_ip_info(self->ap_netif, &ip_info); + self->ap_addr = addr; + self->ap_netmask = mask; + self->ap_gw = gw; + self->ap_addr_configured = true; - // common_hal_wifi_radio_start_dhcp_server(self); // restart access point DHCP + if (self->ap_mode) { + wifi_radio_ap_apply_address(self); + if (restart_dhcp) { + common_hal_wifi_radio_start_dhcp_server(self); + } + } } #if CIRCUITPY_WIFI_PING diff --git a/ports/zephyr-cp/common-hal/wifi/Radio.h b/ports/zephyr-cp/common-hal/wifi/Radio.h index 2500079df09..cdebd0b4d9d 100644 --- a/ports/zephyr-cp/common-hal/wifi/Radio.h +++ b/ports/zephyr-cp/common-hal/wifi/Radio.h @@ -15,6 +15,10 @@ #include #include +// Stations tracked for wifi.radio.stations_ap; more than this and the oldest +// entries are simply not listed. +#define WIFI_AP_MAX_STATIONS 8 + // Event bits for the Radio event group. #define WIFI_SCAN_DONE_BIT BIT0 #define WIFI_CONNECTED_BIT BIT1 @@ -50,6 +54,24 @@ typedef struct { // for the network we are already on can return without touching the link. uint8_t current_ssid[WIFI_SSID_MAX_LEN]; size_t current_ssid_len; + + // Access point state. The AIROC (CYW43439) driver runs the AP on the same + // net_if as the station, so this is bookkeeping beside sta_netif rather + // than a second interface: the AP's IPv4 configuration, whether the DHCPv4 + // server is up, and the stations the driver has reported as associated. + struct net_in_addr ap_addr; + struct net_in_addr ap_netmask; + struct net_in_addr ap_gw; + bool ap_addr_configured; + bool dhcp_server_running; + uint8_t ap_stations[WIFI_AP_MAX_STATIONS][6]; + size_t ap_station_count; } wifi_radio_obj_t; +// Maintained from the net_mgmt AP station events (common-hal/wifi/__init__.c). +void wifi_radio_ap_station_add(wifi_radio_obj_t *self, const uint8_t *mac); +void wifi_radio_ap_station_remove(wifi_radio_obj_t *self, const uint8_t *mac); +// Non-raising AP teardown for the supervisor's wifi_reset(). +void wifi_radio_ap_reset(wifi_radio_obj_t *self); + extern void common_hal_wifi_radio_gc_collect(wifi_radio_obj_t *self); diff --git a/ports/zephyr-cp/common-hal/wifi/__init__.c b/ports/zephyr-cp/common-hal/wifi/__init__.c index 182ae5a3c30..e6811783113 100644 --- a/ports/zephyr-cp/common-hal/wifi/__init__.c +++ b/ports/zephyr-cp/common-hal/wifi/__init__.c @@ -35,6 +35,7 @@ wifi_radio_obj_t common_hal_wifi_radio_obj; #include #include +#include #define MAC_ADDRESS_LENGTH 6 @@ -53,6 +54,36 @@ static void schedule_background_on_cp_core(void *arg) { static struct net_mgmt_event_callback wifi_cb; static struct net_mgmt_event_callback ipv4_cb; +// The station table is written here, on the net_mgmt event thread, and read by +// wifi.radio.stations_ap on the main thread; it is tiny, so an irq lock is the +// simplest way to keep the two consistent. +void wifi_radio_ap_station_add(wifi_radio_obj_t *self, const uint8_t *mac) { + unsigned int key = irq_lock(); + for (size_t i = 0; i < self->ap_station_count; i++) { + if (memcmp(self->ap_stations[i], mac, MAC_ADDRESS_LENGTH) == 0) { + irq_unlock(key); + return; + } + } + if (self->ap_station_count < WIFI_AP_MAX_STATIONS) { + memcpy(self->ap_stations[self->ap_station_count++], mac, MAC_ADDRESS_LENGTH); + } + irq_unlock(key); +} + +void wifi_radio_ap_station_remove(wifi_radio_obj_t *self, const uint8_t *mac) { + unsigned int key = irq_lock(); + for (size_t i = 0; i < self->ap_station_count; i++) { + if (memcmp(self->ap_stations[i], mac, MAC_ADDRESS_LENGTH) == 0) { + self->ap_station_count--; + memmove(self->ap_stations[i], self->ap_stations[i + 1], + (self->ap_station_count - i) * MAC_ADDRESS_LENGTH); + break; + } + } + irq_unlock(key); +} + static void _event_handler(struct net_mgmt_event_callback *cb, uint64_t mgmt_event, struct net_if *iface) { wifi_radio_obj_t *self = &common_hal_wifi_radio_obj; (void)iface; @@ -119,12 +150,22 @@ static void _event_handler(struct net_mgmt_event_callback *cb, uint64_t mgmt_eve case NET_EVENT_WIFI_AP_DISABLE_RESULT: LOG_DBG("NET_EVENT_WIFI_AP_DISABLE_RESULT"); break; - case NET_EVENT_WIFI_AP_STA_CONNECTED: + case NET_EVENT_WIFI_AP_STA_CONNECTED: { + const struct wifi_ap_sta_info *info = cb->info; LOG_DBG("NET_EVENT_WIFI_AP_STA_CONNECTED"); + if (info != NULL && info->mac_length == MAC_ADDRESS_LENGTH) { + wifi_radio_ap_station_add(self, info->mac); + } break; - case NET_EVENT_WIFI_AP_STA_DISCONNECTED: + } + case NET_EVENT_WIFI_AP_STA_DISCONNECTED: { + const struct wifi_ap_sta_info *info = cb->info; LOG_DBG("NET_EVENT_WIFI_AP_STA_DISCONNECTED"); + if (info != NULL && info->mac_length == MAC_ADDRESS_LENGTH) { + wifi_radio_ap_station_remove(self, info->mac); + } break; + } case NET_EVENT_IPV4_ADDR_ADD: // DHCP bound, or a static address was configured. The address is read // live by the ipv4_address getter, so nothing is stored here; the @@ -376,6 +417,7 @@ void wifi_reset(void) { } common_hal_wifi_monitor_deinit(MP_STATE_VM(wifi_monitor_singleton)); wifi_radio_obj_t *radio = &common_hal_wifi_radio_obj; + wifi_radio_ap_reset(radio); common_hal_wifi_radio_set_enabled(radio, false); // #ifndef CONFIG_IDF_TARGET_ESP32 // ESP_ERROR_CHECK(esp_event_handler_instance_unregister(WIFI_EVENT, From fb700f76bcaffd0df0d2fc741d4bbf3e32cc287f Mon Sep 17 00:00:00 2001 From: tyeth Date: Wed, 9 Sep 2026 15:27:45 +0100 Subject: [PATCH 14/15] zephyr-cp: freeze .mpy modules named in circuitpython.toml The port had no frozen-module support: its Python-driven build never runs make, and the freeze pipeline lives in py/circuitpy_mpconfig.mk. Reimplement the pipeline in cptools/build_circuitpython.py behind a per-board opt-in, FROZEN_MPY_DIRS = ["frozen/", ...] in circuitpython.toml (paths relative to the repository root, as $(TOP)/... is in mpconfigboard.mk): - tools/preprocess_frozen_modules.py stages the trees into /frozen_mpy (repo directory dropped, __version__ filled in, examples and tests left out), before the qstr pass; - MICROPY_QSTR_EXTRA_POOL / MICROPY_MODULE_FROZEN_MPY are added to the flags for the qstr pass as well as the compile -- Q(.frozen) and the sys.path entry that uses it are behind MICROPY_MODULE_FROZEN; - after genhdr/qstrdefs.generated.h and root_pointers.h exist, mpy-cross compiles each module (-s with the module path, so mpy-tool derives the frozen name from it) and tools/mpy-tool.py -f -q emits frozen_content.c, fed the same collected qstr list that produced the generated header so the frozen pool numbers from MP_QSTRnumber_of correctly. tools/makemanifest.py is bypassed: it insists on genhdr/qstrdefs.preprocessed.h, which this builder never produces. Only MPY freezing: MICROPY_MODULE_FROZEN_STR would reference the mp_frozen_str_* tables makemanifest emits. - frozen_content.c is compiled with the no-qstr sources (it defines its own MP_QSTR_* enum values and must never go through extraction). pre_zephyr_build_prep.py builds mpy-cross first when a board freezes modules (honouring MICROPY_MPYCROSS), and tools/ci_fetch_deps.py learns the toml key so CI initialises the right frozen/ submodules (it had a TODO for this). The Pico W opts in with adafruit_ble: it has ~42 KB of heap and a BLE node cannot otherwise load the library. Measured on a Pico 2 W (same core, same flags): importing adafruit_ble plus its advertising.standard and services.nordic modules costs 10,384 B of heap frozen vs 21,792 B from .mpy files on CIRCUITPY (gc.mem_alloc() delta after gc.collect(); gc.mem_free() is not usable here, the split heap grows on demand), 0.055 s vs 0.132 s. Freezing the 20 modules adds 28,336 B of flash and no static RAM on the Pico W (1,208,636 -> 1,236,972 B with the rest of this series). The Pico 2 W is not opted in: it has the heap, and a frozen copy would pin the library version for everyone. Co-Authored-By: Claude Opus 5 --- .../rpi_pico_w_zephyr/circuitpython.toml | 5 + .../zephyr-cp/cptools/build_circuitpython.py | 98 +++++++++++++++++++ .../cptools/pre_zephyr_build_prep.py | 9 ++ tools/ci_fetch_deps.py | 10 +- 4 files changed, 120 insertions(+), 2 deletions(-) diff --git a/ports/zephyr-cp/boards/raspberrypi/rpi_pico_w_zephyr/circuitpython.toml b/ports/zephyr-cp/boards/raspberrypi/rpi_pico_w_zephyr/circuitpython.toml index deb99e3effa..5f22387d920 100644 --- a/ports/zephyr-cp/boards/raspberrypi/rpi_pico_w_zephyr/circuitpython.toml +++ b/ports/zephyr-cp/boards/raspberrypi/rpi_pico_w_zephyr/circuitpython.toml @@ -3,3 +3,8 @@ BLOBS=["hal_infineon"] # Non-Zephyr build of the same board; nvm and CIRCUITPY must sit where it puts them. counterpart = "raspberrypi/raspberry_pi_pico_w" + +# Frozen into flash: the Pico W has ~42 KB of heap, and adafruit_ble alone takes +# ~30 KB of it when loaded from CIRCUITPY as .mpy. Freezing keeps the bytecode in +# flash so a BLE node fits. +FROZEN_MPY_DIRS = ["frozen/Adafruit_CircuitPython_BLE"] diff --git a/ports/zephyr-cp/cptools/build_circuitpython.py b/ports/zephyr-cp/cptools/build_circuitpython.py index 8594c245c81..6172e45f35c 100644 --- a/ports/zephyr-cp/cptools/build_circuitpython.py +++ b/ports/zephyr-cp/cptools/build_circuitpython.py @@ -1,7 +1,10 @@ import asyncio import logging import os +import os import pathlib +import shutil +import subprocess import pickle import sys @@ -356,6 +359,77 @@ def determine_enabled_modules(board_info, portdir, srcdir): return enabled_modules, module_reasons +def stage_frozen_modules(frozen_dirs, srcdir, builddir): + """Copy the boards' frozen library trees into builddir/frozen_mpy. + + Mirrors the ``$(BUILD)/frozen_mpy`` step of py/circuitpy_mpconfig.mk: the + repo-name directory is dropped, ``__version__`` is filled in and examples, + docs and tests are left out. Returns the staged .py files, relative to the + staging directory, in a stable order. + """ + staging = builddir / "frozen_mpy" + if staging.exists(): + shutil.rmtree(staging) + staging.mkdir(parents=True) + env = dict(os.environ) + env["PYTHONPATH"] = str(srcdir / "tools" / "python-semver") + subprocess.run( + [ + sys.executable, + srcdir / "tools" / "preprocess_frozen_modules.py", + "-o", + staging, + *[srcdir / d for d in frozen_dirs], + ], + cwd=srcdir, + env=env, + check=True, + ) + return sorted(p.relative_to(staging) for p in staging.rglob("*.py")) + + +def freeze_modules(frozen_sources, srcdir, builddir, mpy_cross, qstr_defs): + """Compile the staged modules with mpy-cross and emit frozen_content.c. + + The make flow drives this through tools/makemanifest.py; that script insists + on ``$(BUILD)/genhdr/qstrdefs.preprocessed.h``, which this builder never + produces (it feeds the collected qstrs straight to makeqstrdata.py), so the + two tools it wraps are invoked directly. ``qstr_defs`` must be the exact + file that produced genhdr/qstrdefs.generated.h: mpy-tool numbers the frozen + modules' extra qstrs from MP_QSTRnumber_of onwards, so the two pools must be + computed from the same set. + """ + staging = builddir / "frozen_mpy" + mpy_files = [] + for rel in frozen_sources: + out = staging / rel.with_suffix(".mpy") + # -s records the module path (not the staging path) as the source name, + # which is what mpy-tool derives the frozen module name from. + subprocess.run( + [mpy_cross, "-s", str(rel), "-o", out, staging / rel], + cwd=staging, + check=True, + ) + mpy_files.append(out) + frozen_content = builddir / "frozen_content.c" + with frozen_content.open("w") as f: + subprocess.run( + [ + sys.executable, + srcdir / "tools" / "mpy-tool.py", + "-f", + "-q", + qstr_defs, + "-mlongint-impl=mpz", + *mpy_files, + ], + cwd=srcdir, + stdout=f, + check=True, + ) + return frozen_content + + async def build_circuitpython(): # noqa: C901 circuitpython_flags = ["-DCIRCUITPY"] port_flags = [] @@ -404,6 +478,22 @@ async def build_circuitpython(): # noqa: C901 if mpconfigboard_fn is not None and mpconfigboard_fn.exists(): with mpconfigboard_fn.open("rb") as f: mpconfigboard.update(tomllib.load(f)) + # Frozen modules (opt-in per board: FROZEN_MPY_DIRS in circuitpython.toml, + # paths relative to the repository root, as $(TOP)/... is in mpconfigboard.mk). + # The flags have to be present for the qstr pass as well as the compile: + # MICROPY_MODULE_FROZEN gates both Q(.frozen) and the sys.path entry that + # uses it, and the extra pool is how frozen qstrs get their numbers. + frozen_dirs = mpconfigboard.get("FROZEN_MPY_DIRS", []) + frozen_sources = [] + if frozen_dirs: + circuitpython_flags.append("-DMICROPY_QSTR_EXTRA_POOL=mp_qstr_frozen_const_pool") + # Only .mpy freezing: MICROPY_MODULE_FROZEN_STR would make frozenmod.c + # reference the mp_frozen_str_* tables that tools/makemanifest.py + # emits, and mpy-tool alone does not. + circuitpython_flags.append("-DMICROPY_MODULE_FROZEN_MPY=1") + frozen_sources = stage_frozen_modules(frozen_dirs, srcdir, builddir) + logger.info(f"Freezing {len(frozen_sources)} modules from {', '.join(frozen_dirs)}") + async with asyncio.TaskGroup() as tg: tg.create_task( cpbuild.run_command( @@ -736,6 +826,14 @@ async def build_circuitpython(): # noqa: C901 # This file is generated by the QSTR/translation process. source_files.append(builddir / f"translations-{translation}.c") + if frozen_dirs: + # Needs genhdr/qstrdefs.generated.h and root_pointers.h from the task + # group above. frozen_content.c defines its own MP_QSTR_* enum values, so + # it must never go through the qstr extraction pass. + mpy_cross = os.environ.get("MICROPY_MPYCROSS", str(srcdir / "mpy-cross" / "build" / "mpy-cross")) + source_files.append( + freeze_modules(frozen_sources, srcdir, builddir, mpy_cross, builddir / "qstrdefs.collected") + ) # These files don't include unique QSTRs. They just need to be compiled. source_files.append(portdir / "supervisor" / "flash.c") source_files.append(portdir / "supervisor" / "port.c") diff --git a/ports/zephyr-cp/cptools/pre_zephyr_build_prep.py b/ports/zephyr-cp/cptools/pre_zephyr_build_prep.py index f42fc1a3a85..4795aaabc66 100644 --- a/ports/zephyr-cp/cptools/pre_zephyr_build_prep.py +++ b/ports/zephyr-cp/cptools/pre_zephyr_build_prep.py @@ -1,4 +1,5 @@ # Called by the Makefile before calling out to `west`. +import os import pathlib import subprocess import sys @@ -20,6 +21,14 @@ args = blob_fetch_args.get(blob, []) subprocess.run(["west", "blobs", "fetch", blob, *args], check=True) +# Frozen modules need the host mpy-cross; build it up front, where make is +# already in use, rather than from inside the CMake-driven CircuitPython step. +if mpconfigboard.get("FROZEN_MPY_DIRS") and "MICROPY_MPYCROSS" not in os.environ: + subprocess.run( + ["make", "-C", str(portdir.parent.parent / "mpy-cross"), "USER_C_MODULES="], + check=True, + ) + if board.endswith("bsim"): subprocess.run( ["make", "everything", "-j", "8"], diff --git a/tools/ci_fetch_deps.py b/tools/ci_fetch_deps.py index 8994e54c338..cfb35af5ca2 100644 --- a/tools/ci_fetch_deps.py +++ b/tools/ci_fetch_deps.py @@ -5,6 +5,7 @@ import pathlib import re import subprocess +import tomllib TOP = pathlib.Path(__file__).parent.parent @@ -254,8 +255,13 @@ def main(target): lib_folder = "/".join(lib_folder[:2]) submodules.append(lib_folder) else: - # TODO: Add a way to specify frozen modules in circuitpython.toml - pass + # ports/zephyr-cp: FROZEN_MPY_DIRS = ["frozen/", ...] in circuitpython.toml + with config.open("rb") as f: + board_config = tomllib.load(f) + for lib_folder in board_config.get("FROZEN_MPY_DIRS", []): + if lib_folder.count("/") > 1: + lib_folder = "/".join(lib_folder.split("/", maxsplit=2)[:2]) + submodules.append(lib_folder) print("Submodules:", " ".join(submodules)) From 7eae93cfad096305a25787d20091f69332e1a68f Mon Sep 17 00:00:00 2001 From: tyeth Date: Wed, 9 Sep 2026 23:44:56 +0100 Subject: [PATCH 15/15] zephyr-cp: fix the make DEBUG=1 build (quote the EXTRA_CONF_FILE list) With DEBUG=1 the Makefile appends debug.conf to the board conf as a ";"-separated list and passed it to west unquoted: -Dzephyr-cp_EXTRA_CONF_FILE=.../board.conf;.../debug.conf The unquoted ";" ends the shell command, so west ran with only board.conf and the shell then tried to execute ".../debug.conf" as a program. Quote the whole -D argument so the ";"-separated list reaches CMake intact. A DEBUG=1 build now completes; verified it links and produces a bootable image (build reaches "Completed 'zephyr-cp'", debug.conf is merged, CONFIG_DEBUG and CONFIG_LOG_MODE_IMMEDIATE are set). Note debug.conf sets CONFIG_LOG_MODE_IMMEDIATE=y, which serialises logging in-thread over UART0 and distorts timing, so a DEBUG build must not be used to measure latency. Co-Authored-By: Claude Opus 5 --- ports/zephyr-cp/Makefile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ports/zephyr-cp/Makefile b/ports/zephyr-cp/Makefile index e21a96e9eb8..aa99eb4d1eb 100644 --- a/ports/zephyr-cp/Makefile +++ b/ports/zephyr-cp/Makefile @@ -40,8 +40,11 @@ else CP_BOARD_CONF := $(DEBUG_CONF_FILE) endif endif +# Quoted: with DEBUG=1 this is a ;-separated list, and an unquoted ; ends the +# shell command, so west built without debug.conf and the shell then tried to +# run debug.conf as a program. ifneq ($(CP_BOARD_CONF),) -WEST_CMAKE_ARGS += -Dzephyr-cp_EXTRA_CONF_FILE=$(CP_BOARD_CONF) +WEST_CMAKE_ARGS += "-Dzephyr-cp_EXTRA_CONF_FILE=$(CP_BOARD_CONF)" endif .PHONY: $(BUILD)/zephyr-cp/zephyr/zephyr.elf flash recover debug debug-jlink debugserver attach run run-sim clean menuconfig all clean-all sim clean-sim test fetch-port-submodules