From d55b9466f42fde140edc4b37e4ea4e114f056cae Mon Sep 17 00:00:00 2001 From: tyeth Date: Tue, 8 Sep 2026 17:48:33 +0100 Subject: [PATCH 01/36] 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 ec1453b20fe..f66156d2f40 100644 --- a/ports/zephyr-cp/debug.conf +++ b/ports/zephyr-cp/debug.conf @@ -36,6 +36,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 43fef48d398fcb8dace6e0720d4e4ee02abea87f Mon Sep 17 00:00:00 2001 From: tyeth Date: Tue, 8 Sep 2026 14:55:45 +0100 Subject: [PATCH 02/36] 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 | 23 +++++++++++++++++++ ports/zephyr-cp/cptools/compat2driver.py | 1 + 4 files changed, 34 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 769c76bb467..e41282a6346 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 W" [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 cb71fb8a6ad..183fb7717ed 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 @@ -14,3 +14,12 @@ CONFIG_NET_HOSTNAME_DYNAMIC=y CONFIG_NET_HOSTNAME="circuitpython" CONFIG_MBEDTLS=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 aaaa51c2e0e..f0eae560914 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 @@ -1,3 +1,26 @@ /* Partition layout is owned by the mcuboot fork (dts/raspberrypi/rpi_pico2.dtsi) * and applied to this image by sysbuild. This overlay only adds board/app config. */ + +/* + * 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 + * ../../../internal-modules/circuitpython-kconfig/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 62d47e66f6ff02ca8baf655549b07fa8de09ac39 Mon Sep 17 00:00:00 2001 From: tyeth Date: Tue, 8 Sep 2026 17:49:35 +0100 Subject: [PATCH 03/36] 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 | 31 +++++++++++++++++++ ports/zephyr-cp/debug.conf | 6 ++++ ports/zephyr-cp/prj.conf | 8 +++++ 4 files changed, 53 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 183fb7717ed..1db2152a9f1 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 @@ -23,3 +23,11 @@ CONFIG_MBEDTLS=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 f0eae560914..573792beb9d 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 @@ -1,6 +1,37 @@ /* Partition layout is owned by the mcuboot fork (dts/raspberrypi/rpi_pico2.dtsi) * and applied to this image by sysbuild. This overlay only adds board/app config. */ +/* + * Settings partition: one exception to the layout above. sysbuild prepends the + * adaboot layout (dts/raspberrypi/rpi_pico2_w.dtsi) to this overlay, so what + * is set here wins. + * + * 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. + * The layout gives it one (0x17f000, 4K); 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 the filesystem 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. + */ +&code_partition { + reg = <0x0 0x17e000>; +}; + +/delete-node/ &storage_partition; + +&flash0 { + partitions { + storage_partition: partition@17e000 { + compatible = "zephyr,mapped-partition"; + label = "storage"; + reg = <0x17e000 0x2000>; + }; + }; +}; + /* * 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 diff --git a/ports/zephyr-cp/debug.conf b/ports/zephyr-cp/debug.conf index f66156d2f40..89e04129350 100644 --- a/ports/zephyr-cp/debug.conf +++ b/ports/zephyr-cp/debug.conf @@ -105,3 +105,9 @@ CONFIG_NET_SOCKETS_SERVICE_STACK_SIZE=4096 # ... and the TCP workqueue, which processes every TCP segment (with the # net_tcp DBG trace) plus retransmission/timer work. CONFIG_NET_TCP_WORKQ_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 39b4206ff88..9e397417b92 100644 --- a/ports/zephyr-cp/prj.conf +++ b/ports/zephyr-cp/prj.conf @@ -136,3 +136,11 @@ CONFIG_BT_MAX_CONN=5 # evicts the BLE workflow's bond (BT_KEYS_OVERWRITE_OLDEST). 3 matches # espressif's NimBLE default; nordic keeps dozens. CONFIG_BT_MAX_PAIRED=3 + +# 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 74017f86194016c058379e384e3d14a4d2fa8c77 Mon Sep 17 00:00:00 2001 From: tyeth Date: Tue, 8 Sep 2026 19:07:28 +0100 Subject: [PATCH 04/36] 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 | 55 +++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) 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 92c73c4d255..1b3351941b8 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 W" [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 ccd3af6668a..49ee24bbbd8 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 @@ -16,3 +16,12 @@ CONFIG_NET_HOSTNAME="circuitpython" CONFIG_MBEDTLS=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 f8f00f5cb37..2a2ca56d9ec 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 @@ -1,3 +1,58 @@ /* Partition layout is owned by the mcuboot fork (dts/raspberrypi/rpi_pico.dtsi) * and applied to this image by sysbuild. This overlay only adds board/app config. */ + +/* + * Settings partition: one exception to the layout above. sysbuild prepends the + * adaboot layout (dts/raspberrypi/rpi_pico_w.dtsi) to this overlay, so what + * is set here wins. + * + * 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. + * The layout gives it one (0x17f000, 4K); 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 the filesystem 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. The code partition + * still starts at 0x100, after the second stage bootloader. + */ +&code_partition { + reg = <0x100 (0x17e000 - 0x100)>; +}; + +/delete-node/ &storage_partition; + +&flash0 { + partitions { + storage_partition: partition@17e000 { + compatible = "zephyr,mapped-partition"; + label = "storage"; + reg = <0x17e000 0x2000>; + }; + }; +}; + +/* + * 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 + * ../../../internal-modules/circuitpython-kconfig/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 17f165b58cdbcffef64cb36446a89e4178be00eb Mon Sep 17 00:00:00 2001 From: tyeth Date: Tue, 8 Sep 2026 18:41:46 +0100 Subject: [PATCH 05/36] 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 c63d9397e47..33132ace4dc 100644 --- a/.github/actions/deps/ports/zephyr-cp/action.yml +++ b/.github/actions/deps/ports/zephyr-cp/action.yml @@ -43,6 +43,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 2f8c0f68d8c..26f87f43f05 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 1d412bd8d5e..081ff08dcd6 100644 --- a/ports/zephyr-cp/zephyr-config/west.yml +++ b/ports/zephyr-cp/zephyr-config/west.yml @@ -12,9 +12,25 @@ manifest: url: https://github.com/adafruit/adaboot revision: a6f8f35a36f6363b3c24b1b3d89b7f7846983c4e path: bootloader/mcuboot + # 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 @ 3ab36efea64edda80e45ece016753e3686bb763f + # 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: 3ab36efea64edda80e45ece016753e3686bb763f + url: https://github.com/tyeth/zephyr + revision: cyw43-shared-bus-ble clone-depth: 100 import: # Skip what no CircuitPython board can use, so west update fetches 37 @@ -61,3 +77,11 @@ manifest: - percepio - uoscore-uedhoc - zephyr-xenlib + - 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 72b031d30fad56ed05597edeed72a9b2ee3a6e38 Mon Sep 17 00:00:00 2001 From: tyeth Date: Wed, 9 Sep 2026 11:42:25 +0100 Subject: [PATCH 06/36] 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 1db2152a9f1..e390674d9d4 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 @@ -31,3 +31,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 6d509578d461d8126d4818e9bdc344c269e2f084 Mon Sep 17 00:00:00 2001 From: tyeth Date: Wed, 9 Sep 2026 12:30:48 +0100 Subject: [PATCH 07/36] zephyr-cp: fix USB loss under BLE load on the Pico W boards 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. This commit used to also raise BT_MAX_CONN to 4 on the Pico 2 W (Zephyr defaults to 1). Upstream has since set CONFIG_BT_MAX_CONN=5 port-wide in prj.conf, so that part is dropped: keeping it would now lower the limit. Co-Authored-By: Claude Opus 5 Co-Authored-By: Claude Opus 5.5 --- ports/zephyr-cp/prj.conf | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ports/zephyr-cp/prj.conf b/ports/zephyr-cp/prj.conf index 9e397417b92..10165199ab5 100644 --- a/ports/zephyr-cp/prj.conf +++ b/ports/zephyr-cp/prj.conf @@ -144,3 +144,9 @@ CONFIG_BT_MAX_PAIRED=3 # 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 e6c2d1686e56360496f4d16308472b981b7db99e Mon Sep 17 00:00:00 2001 From: tyeth Date: Wed, 9 Sep 2026 15:26:38 +0100 Subject: [PATCH 08/36] 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 | 31 ++++++++++++++++++++ ports/zephyr-cp/common-hal/_bleio/__init__.h | 4 +++ 3 files changed, 42 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 7819e68676e..ec25cb43ae3 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 @@ -286,6 +289,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; } @@ -729,10 +733,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; } @@ -742,6 +757,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; 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 d2ceb92ac36b5038759918e67a59722c8b901a47 Mon Sep 17 00:00:00 2001 From: tyeth Date: Wed, 9 Sep 2026 15:27:45 +0100 Subject: [PATCH 09/36] 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 6d306c2f071..aee871e074b 100644 --- a/shared-module/ssl/SSLSocket.c +++ b/shared-module/ssl/SSLSocket.c @@ -325,7 +325,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 295395f99914368a720b662cfc8756b46c04a3ac Mon Sep 17 00:00:00 2001 From: tyeth Date: Wed, 9 Sep 2026 15:27:45 +0100 Subject: [PATCH 10/36] 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 | 13 +++++++++++ .../raspberrypi/rpi_pico_w_zephyr/board.conf | 13 +++++++++++ .../zephyr-cp/common-hal/socketpool/Socket.c | 23 +++++++++++++++++++ 3 files changed, 49 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 e390674d9d4..7e7924f08f6 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 @@ -41,3 +47,10 @@ 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 + +# 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 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 49ee24bbbd8..ea551a1c244 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 @@ -15,6 +21,13 @@ CONFIG_NET_HOSTNAME="circuitpython" CONFIG_MBEDTLS=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 0a940c81197..628df8fa2ad 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) { @@ -258,6 +266,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) { @@ -280,6 +295,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 395113b44b2319f85155e985d25d85a892b41aa8 Mon Sep 17 00:00:00 2001 From: tyeth Date: Wed, 9 Sep 2026 15:27:45 +0100 Subject: [PATCH 11/36] 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 7e7924f08f6..2b9faec3825 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 @@ -21,6 +21,21 @@ CONFIG_NET_HOSTNAME="circuitpython" CONFIG_MBEDTLS=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 ea551a1c244..0761e6132cf 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 @@ -21,6 +21,21 @@ CONFIG_NET_HOSTNAME="circuitpython" CONFIG_MBEDTLS=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 8d7f46dc0b9432b0ed42309568df05723b8a0069 Mon Sep 17 00:00:00 2001 From: tyeth Date: Wed, 9 Sep 2026 15:27:45 +0100 Subject: [PATCH 12/36] 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 2b9faec3825..456f24e4043 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 @@ -36,6 +36,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 0761e6132cf..b366f86f770 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 @@ -43,6 +43,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 1570d57d63a..1266758ca0c 100644 --- a/ports/zephyr-cp/common-hal/wifi/Radio.c +++ b/ports/zephyr-cp/common-hal/wifi/Radio.c @@ -15,6 +15,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" @@ -28,6 +29,9 @@ #include #include #include +#if defined(CONFIG_NET_DHCPV4_SERVER) +#include +#endif // dns_resolve_get_default() for radio.ipv4_dns. #include #include @@ -288,86 +292,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; } @@ -691,11 +864,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) { @@ -720,11 +892,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) { @@ -804,12 +975,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) { @@ -843,66 +1012,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 70cc61fe2267b01c07c8f858b8773b9087672c85 Mon Sep 17 00:00:00 2001 From: tyeth Date: Wed, 9 Sep 2026 15:27:45 +0100 Subject: [PATCH 13/36] 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 | 97 +++++++++++++++++++ .../cptools/pre_zephyr_build_prep.py | 9 ++ tools/ci_fetch_deps.py | 10 +- 4 files changed, 119 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 73c240baeb5..92537b6de77 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 @@ -4,3 +4,8 @@ NAME="Raspberry Pi Pico W" # 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 56b09917b8e..27b0a25cd6f 100644 --- a/ports/zephyr-cp/cptools/build_circuitpython.py +++ b/ports/zephyr-cp/cptools/build_circuitpython.py @@ -2,6 +2,8 @@ import logging import os import pathlib +import shutil +import subprocess import pickle import sys @@ -383,6 +385,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 = [] @@ -433,6 +506,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( @@ -825,6 +914,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 c50746651b6..4aad3574e5a 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 @@ -264,8 +265,13 @@ def main(target): if "lib/micropython-lib" not in submodules: submodules.append("lib/micropython-lib") 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 0d457f0471e4ad21b6d1966c8db7ba4a2e104447 Mon Sep 17 00:00:00 2001 From: tyeth Date: Thu, 10 Sep 2026 22:53:56 +0100 Subject: [PATCH 14/36] zephyr-cp: add make RTT=1 to put the console and log backend on SEGGER RTT The Zephyr console on these boards is UART0 on GP0/GP1, which needs a wired probe UART (and the Debug Probe bridge only forwards while DTR is asserted). When USB CDC is down after a kernel halt that is the only way to see what the firmware said. RTT gives the same printk/LOG_* stream over the SWD probe that is already attached, read with OpenOCD (rtt setup/start/server) as plain memory polls, so it does not disturb the target or USB. rtt.conf is a Kconfig fragment appended to the board conf list the same way debug.conf is for DEBUG=1; the two compose. Both rp2040 and rp2350 select HAS_SEGGER_RTT when the segger module is present. Co-Authored-By: Claude Fable 5.1 --- ports/zephyr-cp/Makefile | 13 ++++++++++--- ports/zephyr-cp/rtt.conf | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) create mode 100755 ports/zephyr-cp/rtt.conf diff --git a/ports/zephyr-cp/Makefile b/ports/zephyr-cp/Makefile index adb1d658ee6..4910f332833 100644 --- a/ports/zephyr-cp/Makefile +++ b/ports/zephyr-cp/Makefile @@ -74,10 +74,17 @@ ifneq ($(CP_BOARD_CONSOLE_OVERLAY),) CP_BOARD_CONF := $(strip $(CP_BOARD_CONF) $(CURDIR)/console.conf) endif endif +# When RTT=1, move the Zephyr console and log backend from UART0 to SEGGER RTT, so +# printk/LOG_* can be read over the same SWD probe with OpenOCD (`rtt setup`, +# `rtt start`, `rtt server start 0`) even when USB CDC is down and no UART +# is wired. Composable with DEBUG=1. +RTT_CONF_FILE ?= $(CURDIR)/rtt.conf +ifeq ($(RTT),1) +CP_BOARD_CONF := $(strip $(CP_BOARD_CONF) $(RTT_CONF_FILE)) +endif CP_BOARD_CONF := $(subst $(eval) ,;,$(CP_BOARD_CONF)) -# Always pass the conf and console overlay files, even if empty. This undoes DEBUG=1 or CONSOLE=1 -# when it's omitted on a later run. Quoted because the conf value can be a semicolon-separated -# list, and an unquoted semicolon would end the shell command. +# Always pass both values, even if empty, to undo DEBUG/CONSOLE/RTT on a later run. +# Quote the semicolon-separated configuration list for the shell. WEST_CMAKE_ARGS += -Dzephyr-cp_EXTRA_CONF_FILE="$(CP_BOARD_CONF)" WEST_CMAKE_ARGS += -Dzephyr-cp_EXTRA_DTC_OVERLAY_FILE="$(CP_BOARD_CONSOLE_OVERLAY)" diff --git a/ports/zephyr-cp/rtt.conf b/ports/zephyr-cp/rtt.conf new file mode 100755 index 00000000000..1e5e4bb8617 --- /dev/null +++ b/ports/zephyr-cp/rtt.conf @@ -0,0 +1,16 @@ +# Zephyr console + log backend on SEGGER RTT instead of UART0 (make RTT=1). +# +# Read it over SWD with OpenOCD, no UART wiring needed, e.g. on a Pico 2 W: +# openocd -f interface/cmsis-dap.cfg -c "adapter serial " -f target/rp2350.cfg \ +# -c init -c 'rtt setup 0x20000000 0x82000 "SEGGER RTT"' -c 'rtt start' \ +# -c 'rtt server start 9090 0' # then read TCP 9090 +# (RP2040: target/rp2040.cfg and a 0x42000 search size.) +# RTT reads are plain memory polls, so unlike a halt they do not take USB CDC down. +CONFIG_USE_SEGGER_RTT=y +CONFIG_RTT_CONSOLE=y +CONFIG_UART_CONSOLE=n +CONFIG_LOG_BACKEND_RTT=y +CONFIG_LOG_BACKEND_UART=n +# Never block the logger when no host is reading the up-buffer. +CONFIG_LOG_BACKEND_RTT_MODE_DROP=y +CONFIG_SEGGER_RTT_BUFFER_SIZE_UP=8192 From cef2fe5f7a666dcc9f40e4deac631aa771c2d3a5 Mon Sep 17 00:00:00 2001 From: tyeth Date: Thu, 22 Oct 2026 02:26:23 +0100 Subject: [PATCH 15/36] Pin rebased Zephyr and retained HAL fixes for alpha.2 --- ports/zephyr-cp/zephyr-config/west.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ports/zephyr-cp/zephyr-config/west.yml b/ports/zephyr-cp/zephyr-config/west.yml index 081ff08dcd6..783d05b367b 100644 --- a/ports/zephyr-cp/zephyr-config/west.yml +++ b/ports/zephyr-cp/zephyr-config/west.yml @@ -29,8 +29,8 @@ manifest: # 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/tyeth/zephyr - revision: cyw43-shared-bus-ble + url: https://github.com/tyeth-ai-assisted/zephyr + revision: 4b1c753a769b507077c7fd69732d15d7c326d0fe clone-depth: 100 import: # Skip what no CircuitPython board can use, so west update fetches 37 @@ -79,9 +79,9 @@ manifest: - zephyr-xenlib - name: hal_rpi_pico url: https://github.com/tyeth/hal_rpi_pico - revision: integration-pico2w-ble + revision: 725e9e429eab65eda01762a1af9016426dee00ac path: modules/hal/rpi_pico - name: hal_infineon url: https://github.com/tyeth/hal_infineon - revision: cyw43-shared-bus-ble + revision: daae5ca73811dbee8ad01fb1aaeafa98896ef94b path: modules/hal/infineon From ca28494d647d58863dad41714fc4247f879216f8 Mon Sep 17 00:00:00 2001 From: tyeth Date: Thu, 22 Oct 2026 02:49:19 +0100 Subject: [PATCH 16/36] Fit Pico W networking in RAM and isolate host mpy-cross build output --- .../boards/raspberrypi/rpi_pico_w_zephyr/board.conf | 11 +++++++++++ ports/zephyr-cp/cptools/pre_zephyr_build_prep.py | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) 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 b366f86f770..7a34042b4b5 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 @@ -65,3 +65,14 @@ CONFIG_TEST_RANDOM_GENERATOR=y # 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 + +# The RP2040 has 264 KB RAM. Five reserved BLE connections plus WiFi/TLS +# overflow RAM with alpha.2; two cover the workflow and one application peer. +CONFIG_BT_MAX_CONN=2 + +# Keep the WiFi + BLE integration within RP2040 RAM. The portal uses IPv4; +# six contexts cover DHCP/DNS, a listener, an accepted client and spare +# outbound sockets. Pico 2 W keeps the larger dual-stack configuration. +CONFIG_NET_IPV6=n +CONFIG_NET_MAX_CONTEXTS=6 +CONFIG_NET_MAX_CONN=6 diff --git a/ports/zephyr-cp/cptools/pre_zephyr_build_prep.py b/ports/zephyr-cp/cptools/pre_zephyr_build_prep.py index 4795aaabc66..847f99fc4fc 100644 --- a/ports/zephyr-cp/cptools/pre_zephyr_build_prep.py +++ b/ports/zephyr-cp/cptools/pre_zephyr_build_prep.py @@ -25,7 +25,7 @@ # 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="], + ["make", "-C", str(portdir.parent.parent / "mpy-cross"), "BUILD=build", "USER_C_MODULES="], check=True, ) From 9e0262f4bacec81dd4e63fe3afc7c365e5e89eda Mon Sep 17 00:00:00 2001 From: tyeth Date: Thu, 22 Oct 2026 02:57:32 +0100 Subject: [PATCH 17/36] Fetch the shared-bus HCI driver submodule through west --- ports/zephyr-cp/zephyr-config/west.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ports/zephyr-cp/zephyr-config/west.yml b/ports/zephyr-cp/zephyr-config/west.yml index 783d05b367b..05ca4e02670 100644 --- a/ports/zephyr-cp/zephyr-config/west.yml +++ b/ports/zephyr-cp/zephyr-config/west.yml @@ -81,6 +81,8 @@ manifest: url: https://github.com/tyeth/hal_rpi_pico revision: 725e9e429eab65eda01762a1af9016426dee00ac path: modules/hal/rpi_pico + submodules: + - path: lib/cyw43-driver - name: hal_infineon url: https://github.com/tyeth/hal_infineon revision: daae5ca73811dbee8ad01fb1aaeafa98896ef94b From 168149058ee86f93239ce7727a027bce3e63ff2b Mon Sep 17 00:00:00 2001 From: tyeth Date: Thu, 22 Oct 2026 04:15:50 +0100 Subject: [PATCH 18/36] zephyr-cp: reserve Pico W runtime RAM for USB and Python --- .../boards/raspberrypi/rpi_pico_w_zephyr/board.conf | 8 ++++++++ ports/zephyr-cp/zephyr-config/west.yml | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) 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 7a34042b4b5..85e7c623125 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 @@ -76,3 +76,11 @@ CONFIG_BT_MAX_CONN=2 CONFIG_NET_IPV6=n CONFIG_NET_MAX_CONTEXTS=6 CONFIG_NET_MAX_CONN=6 + +# Leave runtime RAM for the Python heap and USB CDC buffers. Merely linking +# the dual-radio image can consume all SRAM and silently disable USB CDC. +# Keep full-sized WHD packets and the six-buffer coexistence reserve; reduce +# burst capacity only on the 264 KB RP2040 (Pico 2 W retains the defaults). +CONFIG_AIROC_WIFI_TX_PACKET_POOL_COUNT=5 +CONFIG_AIROC_WIFI_RX_PACKET_POOL_COUNT=5 +CONFIG_MAIN_STACK_SIZE=16384 diff --git a/ports/zephyr-cp/zephyr-config/west.yml b/ports/zephyr-cp/zephyr-config/west.yml index 05ca4e02670..3cf11fc54c7 100644 --- a/ports/zephyr-cp/zephyr-config/west.yml +++ b/ports/zephyr-cp/zephyr-config/west.yml @@ -30,7 +30,7 @@ manifest: # hal_infineon as imported by Zephyr's own manifest - name: zephyr url: https://github.com/tyeth-ai-assisted/zephyr - revision: 4b1c753a769b507077c7fd69732d15d7c326d0fe + revision: 62aa864927d3ae43ede3b2b557b6f410aae8899a clone-depth: 100 import: # Skip what no CircuitPython board can use, so west update fetches 37 From 5afc122780626409b4377a50e8f55843c42b864b Mon Sep 17 00:00:00 2001 From: tyeth Date: Thu, 22 Oct 2026 04:34:44 +0100 Subject: [PATCH 19/36] zephyr-cp: refresh version metadata on incremental builds --- .../zephyr-cp/cptools/build_circuitpython.py | 2 + ports/zephyr-cp/cptools/cpbuild.py | 7 ++- ports/zephyr-cp/cptools/tests/test_cpbuild.py | 59 +++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 ports/zephyr-cp/cptools/tests/test_cpbuild.py diff --git a/ports/zephyr-cp/cptools/build_circuitpython.py b/ports/zephyr-cp/cptools/build_circuitpython.py index 27b0a25cd6f..b06768786a0 100644 --- a/ports/zephyr-cp/cptools/build_circuitpython.py +++ b/ports/zephyr-cp/cptools/build_circuitpython.py @@ -535,6 +535,8 @@ async def build_circuitpython(): # noqa: C901 ], srcdir, check_hash=[version_header], + # Git commits, tags and dirty state are not file dependencies. + always_run=True, ) ) diff --git a/ports/zephyr-cp/cptools/cpbuild.py b/ports/zephyr-cp/cptools/cpbuild.py index 8121f4b8016..cba56133535 100644 --- a/ports/zephyr-cp/cptools/cpbuild.py +++ b/ports/zephyr-cp/cptools/cpbuild.py @@ -131,6 +131,7 @@ async def run_command( check_hash=[], extradeps=[], responsefile: Optional[pathlib.Path] = None, + always_run=False, ): """ Runs a command asynchronously. The command should ideally be a list of strings @@ -146,6 +147,10 @@ async def run_command( the same, then the old mtimes are reset. This is helpful if a command may produce the same result and you don't want the rest of the build impacted + always_run bypasses the persistent timestamp cache for commands whose inputs + include external state, such as Git metadata. check_hash still preserves the + timestamp of unchanged outputs. Commands remain deduplicated within one build. + responsefile is used to store the command line arguments if they are too long for the OS. The arguments will be replaced with @ and tried again. If None, commands that are too long will fail. @@ -183,7 +188,7 @@ async def run_command( run_reason = None # If the path inputs are all older than the last time we ran them, then we don't have anything to do. - if command_hash in LAST_BUILD_TIMES and all((p.exists() for p in paths)): + if not always_run and command_hash in LAST_BUILD_TIMES and all((p.exists() for p in paths)): last_build_time = LAST_BUILD_TIMES[command_hash] # Check all paths in the command because one must be modified by the command. newest_file = max((0 if p.is_dir() else p.stat().st_mtime_ns for p in paths)) diff --git a/ports/zephyr-cp/cptools/tests/test_cpbuild.py b/ports/zephyr-cp/cptools/tests/test_cpbuild.py new file mode 100644 index 00000000000..072e859c1c4 --- /dev/null +++ b/ports/zephyr-cp/cptools/tests/test_cpbuild.py @@ -0,0 +1,59 @@ +"""Regression checks for commands depending on state outside their path inputs.""" +import asyncio +import atexit +import importlib.util +import pathlib +import sys + +import pytest + + +@pytest.fixture +def build_command(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("MAKEFLAGS", raising=False) + path = pathlib.Path(__file__).resolve().parents[1] / "cpbuild.py" + spec = importlib.util.spec_from_file_location("real_cpbuild_for_test", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + atexit.unregister(module.save_trace) + script = tmp_path / "generate.py" + script.write_text( + "import os, pathlib, sys\n" + "out=pathlib.Path(sys.argv[1])\n" + "out.write_text(os.environ['TEST_VERSION'])\n" + "out.touch()\n" + "with out.with_name('calls.txt').open('a') as f: f.write('called\\n')\n" + ) + output = tmp_path / "version.h" + + def run(always=False): + # Each call represents a fresh build with the persisted timestamps. + module.ALREADY_RUN.clear() + asyncio.run(module.run_command( + [sys.executable, script, output], tmp_path, + check_hash=[output], always_run=always, + )) + return run, output + + +def test_external_state_refresh_bypasses_only_persistent_cache(build_command, monkeypatch): + run, output = build_command + monkeypatch.setenv("TEST_VERSION", "commit-a") + run() + monkeypatch.setenv("TEST_VERSION", "commit-b-dirty") + run() + assert output.read_text() == "commit-a" + run(always=True) + assert output.read_text() == "commit-b-dirty" + assert output.with_name("calls.txt").read_text().splitlines() == ["called", "called"] + + +def test_refresh_preserves_unchanged_output_timestamp(build_command, monkeypatch): + run, output = build_command + monkeypatch.setenv("TEST_VERSION", "same-commit") + run(always=True) + before = output.stat().st_mtime_ns + run(always=True) + assert output.stat().st_mtime_ns == before + assert output.with_name("calls.txt").read_text().splitlines() == ["called", "called"] From a24d38f09fd11e0a20c599a8eeaead2b7e3ba9fa Mon Sep 17 00:00:00 2001 From: tyeth Date: Thu, 22 Oct 2026 05:34:59 +0100 Subject: [PATCH 20/36] zephyr-cp: pin CYW43 combo firmware and HCI framing fixes --- ports/zephyr-cp/zephyr-config/west.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ports/zephyr-cp/zephyr-config/west.yml b/ports/zephyr-cp/zephyr-config/west.yml index 3cf11fc54c7..7317653e4fe 100644 --- a/ports/zephyr-cp/zephyr-config/west.yml +++ b/ports/zephyr-cp/zephyr-config/west.yml @@ -30,7 +30,7 @@ manifest: # hal_infineon as imported by Zephyr's own manifest - name: zephyr url: https://github.com/tyeth-ai-assisted/zephyr - revision: 62aa864927d3ae43ede3b2b557b6f410aae8899a + revision: ea703e85b630897a5df75f4156f22f72edd5a81d clone-depth: 100 import: # Skip what no CircuitPython board can use, so west update fetches 37 From 3e8c1601cf8511ad691170906a98eeca412744ed Mon Sep 17 00:00:00 2001 From: tyeth Date: Thu, 22 Oct 2026 06:23:53 +0100 Subject: [PATCH 21/36] Reserve Pico W runtime RAM for verified TLS with BLE --- .../raspberrypi/rpi_pico_w_zephyr/board.conf | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) 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 85e7c623125..2b4307381be 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 @@ -81,6 +81,22 @@ CONFIG_NET_MAX_CONN=6 # the dual-radio image can consume all SRAM and silently disable USB CDC. # Keep full-sized WHD packets and the six-buffer coexistence reserve; reduce # burst capacity only on the 264 KB RP2040 (Pico 2 W retains the defaults). -CONFIG_AIROC_WIFI_TX_PACKET_POOL_COUNT=5 -CONFIG_AIROC_WIFI_RX_PACKET_POOL_COUNT=5 -CONFIG_MAIN_STACK_SIZE=16384 +CONFIG_AIROC_WIFI_TX_PACKET_POOL_COUNT=4 +CONFIG_AIROC_WIFI_RX_PACKET_POOL_COUNT=4 +# A verified RSA TLS handshake alongside BLE used 3,536 bytes of this stack. +# Keep an 8 KiB reservation with overflow detection and high-water evidence; +# the former 16 KiB reservation left too little heap for that handshake. +CONFIG_MAIN_STACK_SIZE=8192 + +# Keep space for TLS's 16 KB inbound / 4 KB outbound records and handshake. +# IPv4 packets remain full-sized through chained 128-byte buffers; the smaller +# pools limit concurrent bursts rather than the accepted network MTU. +CONFIG_NET_PKT_RX_COUNT=8 +CONFIG_NET_PKT_TX_COUNT=8 +CONFIG_NET_BUF_RX_COUNT=24 +CONFIG_NET_BUF_TX_COUNT=24 + +# Retain stack/allocator measurements for on-device RAM validation. +CONFIG_INIT_STACKS=y +CONFIG_STACK_SENTINEL=y +CONFIG_SYS_HEAP_RUNTIME_STATS=y From 6fc7497ef4f4bef79f794cc26b61a9f04bbf3927 Mon Sep 17 00:00:00 2001 From: tyeth Date: Thu, 22 Oct 2026 07:21:10 +0100 Subject: [PATCH 22/36] Pin atomic CYW43 backplane transactions for scan coexistence --- ports/zephyr-cp/zephyr-config/west.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ports/zephyr-cp/zephyr-config/west.yml b/ports/zephyr-cp/zephyr-config/west.yml index 7317653e4fe..29302cf8ace 100644 --- a/ports/zephyr-cp/zephyr-config/west.yml +++ b/ports/zephyr-cp/zephyr-config/west.yml @@ -84,6 +84,6 @@ manifest: submodules: - path: lib/cyw43-driver - name: hal_infineon - url: https://github.com/tyeth/hal_infineon - revision: daae5ca73811dbee8ad01fb1aaeafa98896ef94b + url: https://github.com/tyeth-ai-assisted/hal_infineon + revision: 12f662c34e21e7c1addd3f7aad0488d35bffd8c2 path: modules/hal/infineon From e4553033a1765caaa7a814db14bcd84567b5838a Mon Sep 17 00:00:00 2001 From: tyeth Date: Thu, 22 Oct 2026 08:21:22 +0100 Subject: [PATCH 23/36] zephyr-cp: allow complete modern CA chains in TLS server records --- ports/zephyr-cp/prj.conf | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ports/zephyr-cp/prj.conf b/ports/zephyr-cp/prj.conf index 10165199ab5..ce8b363de6a 100644 --- a/ports/zephyr-cp/prj.conf +++ b/ports/zephyr-cp/prj.conf @@ -114,10 +114,11 @@ CONFIG_PSA_WANT_ALG_SHA_256=y # MBEDTLS_ERR_SSL_BAD_INPUT_DATA, which in mbedtls 4.x numerically collides # with MBEDTLS_ERR_PK_BAD_INPUT_DATA, so CircuitPython surfaces it as a # misleading "invalid key" ValueError in the middle of the handshake. -# Use mbedtls-style defaults matching ports/espressif/sdkconfig and -# lib/mbedtls_config/mbedtls_config.h: 16384 in, 4096 out. +# Keep a full-size input record. The current three-certificate portal chain +# needs 4159 bytes including TLS framing, so a 4096-byte output record fails +# with MBEDTLS_ERR_SSL_BUFFER_TOO_SMALL before any certificate is sent. CONFIG_MBEDTLS_SSL_IN_CONTENT_LEN=16384 -CONFIG_MBEDTLS_SSL_OUT_CONTENT_LEN=4096 +CONFIG_MBEDTLS_SSL_OUT_CONTENT_LEN=8192 # Settings storage backend for BT bond keys # SECTOR_COUNT is set high — the init code caps it to what fits in the storage From 9f2ff6968d066cf51ec374ce6c41cb96c112d234 Mon Sep 17 00:00:00 2001 From: tyeth Date: Thu, 22 Oct 2026 10:53:40 +0100 Subject: [PATCH 24/36] Pin atomic CYW43 bus wake and power transition fixes --- ports/zephyr-cp/zephyr-config/west.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ports/zephyr-cp/zephyr-config/west.yml b/ports/zephyr-cp/zephyr-config/west.yml index 29302cf8ace..400a0a26938 100644 --- a/ports/zephyr-cp/zephyr-config/west.yml +++ b/ports/zephyr-cp/zephyr-config/west.yml @@ -30,7 +30,7 @@ manifest: # hal_infineon as imported by Zephyr's own manifest - name: zephyr url: https://github.com/tyeth-ai-assisted/zephyr - revision: ea703e85b630897a5df75f4156f22f72edd5a81d + revision: 560a6a2f4a448c6be36c6c9ec69905243725e9ee clone-depth: 100 import: # Skip what no CircuitPython board can use, so west update fetches 37 @@ -85,5 +85,5 @@ manifest: - path: lib/cyw43-driver - name: hal_infineon url: https://github.com/tyeth-ai-assisted/hal_infineon - revision: 12f662c34e21e7c1addd3f7aad0488d35bffd8c2 + revision: 7f93dc60675126a63358ffa0780648d94fd2d634 path: modules/hal/infineon From 1609ec9b0b697b44039a11b1667f344cf99bd725 Mon Sep 17 00:00:00 2001 From: tyeth Date: Thu, 22 Oct 2026 11:28:34 +0100 Subject: [PATCH 25/36] Use one mass-storage buffer to recover Pico W TLS heap --- .../boards/raspberrypi/rpi_pico_w_zephyr/board.conf | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 2b4307381be..da89e1982aa 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 @@ -88,7 +88,7 @@ CONFIG_AIROC_WIFI_RX_PACKET_POOL_COUNT=4 # the former 16 KiB reservation left too little heap for that handshake. CONFIG_MAIN_STACK_SIZE=8192 -# Keep space for TLS's 16 KB inbound / 4 KB outbound records and handshake. +# Keep space for TLS's 16 KB inbound / 8 KB outbound records and handshake. # IPv4 packets remain full-sized through chained 128-byte buffers; the smaller # pools limit concurrent bursts rather than the accepted network MTU. CONFIG_NET_PKT_RX_COUNT=8 @@ -100,3 +100,9 @@ CONFIG_NET_BUF_TX_COUNT=24 CONFIG_INIT_STACKS=y CONFIG_STACK_SENTINEL=y CONFIG_SYS_HEAP_RUNTIME_STATS=y + +# A second 4 KiB USB mass-storage transfer buffer leaves insufficient native +# heap for a verified RSA handshake with the full-chain TLS record sizes. +# Keep mass storage and its full transfer size; trade pipelined throughput +# for RAM on RP2040. Pico 2 W retains double buffering. +CONFIG_USBD_MSC_DOUBLE_BUFFERING=n From 5bb1d640043691aa16a00137e710b19e0fc23fbc Mon Sep 17 00:00:00 2001 From: tyeth Date: Thu, 22 Oct 2026 14:25:30 +0100 Subject: [PATCH 26/36] zephyr-cp: require completed Bluetooth startup before settings and identity --- ports/zephyr-cp/common-hal/_bleio/Adapter.c | 17 +- ports/zephyr-cp/zephyr-config/west.yml | 2 +- tools/check_zephyr_ble_startup.py | 255 ++++++++++++++++++++ 3 files changed, 269 insertions(+), 5 deletions(-) create mode 100644 tools/check_zephyr_ble_startup.py diff --git a/ports/zephyr-cp/common-hal/_bleio/Adapter.c b/ports/zephyr-cp/common-hal/_bleio/Adapter.c index ec25cb43ae3..20d42d5a22b 100644 --- a/ports/zephyr-cp/common-hal/_bleio/Adapter.c +++ b/ports/zephyr-cp/common-hal/_bleio/Adapter.c @@ -357,16 +357,21 @@ static int bleio_adapter_ensure_stack_ready(void) { return 0; } int err = bt_enable(NULL); - if (err != 0 && err != -EALREADY) { - return err; + if (err != 0) { + // -EALREADY also covers an incomplete or failed earlier startup. + // Its settings handlers are unsafe until bt_init() has succeeded. + return err == -EALREADY && bt_is_ready() ? 0 : err; } // bt_init() returns early without setting BT_DEV_READY when // CONFIG_BT_SETTINGS=y and no identity is loaded yet. // Load settings so the BT settings handler fires and calls // bt_finalize_init() which sets BT_DEV_READY. - settings_load(); - return 0; + err = settings_load(); + if (err != 0) { + return err; + } + return bt_is_ready() ? 0 : -EIO; } void common_hal_bleio_adapter_set_enabled(bleio_adapter_obj_t *self, bool enabled) { @@ -465,6 +470,10 @@ void common_hal_bleio_adapter_set_tx_power(bleio_adapter_obj_t *self, mp_int_t t } bleio_address_obj_t *common_hal_bleio_adapter_get_address(bleio_adapter_obj_t *self) { + int err = bleio_adapter_ensure_stack_ready(); + if (err != 0) { + raise_zephyr_error(err); + } // Report the local identity address, which is the same address used to // derive the default adapter name in common_hal_bleio_adapter_set_enabled. bt_addr_le_t id_addrs[CONFIG_BT_ID_MAX]; diff --git a/ports/zephyr-cp/zephyr-config/west.yml b/ports/zephyr-cp/zephyr-config/west.yml index 400a0a26938..713b01c2f5b 100644 --- a/ports/zephyr-cp/zephyr-config/west.yml +++ b/ports/zephyr-cp/zephyr-config/west.yml @@ -30,7 +30,7 @@ manifest: # hal_infineon as imported by Zephyr's own manifest - name: zephyr url: https://github.com/tyeth-ai-assisted/zephyr - revision: 560a6a2f4a448c6be36c6c9ec69905243725e9ee + revision: 0589940bee3386b144961901717b2bce3576a2d6 clone-depth: 100 import: # Skip what no CircuitPython board can use, so west update fetches 37 diff --git a/tools/check_zephyr_ble_startup.py b/tools/check_zephyr_ble_startup.py new file mode 100644 index 00000000000..46d443aff5b --- /dev/null +++ b/tools/check_zephyr_ble_startup.py @@ -0,0 +1,255 @@ +"""Check actual Zephyr/CP BLE startup functions using host-side fault injection. + +Run with Python 3 and a C compiler (cc) supporting UndefinedBehaviorSanitizer: + python tools/check_zephyr_ble_startup.py --circuitpython . --zephyr ../zephyr + +The harness extracts the production functions instead of duplicating them. Its +kernel, controller and settings stubs check startup ordering and cleanup only; +it does not establish physical transport recovery, concurrency or soak stability. +""" + +import argparse +from pathlib import Path +import subprocess +import tempfile + + +def function(path, signature): + source = path.read_text() + start = source.index(signature) + opening = source.index("{", start) + depth = 1 + end = opening + 1 + while depth: + depth += (source[end] == "{") - (source[end] == "}") + end += 1 + return source[start:end] + + +prefix = r""" +#include +#include +#include +#include +#include +#include +#include +#define CONFIG_ZTEST 0 +#define CONFIG_BT_SETTINGS 1 +#define CONFIG_BT_DEVICE_NAME_DYNAMIC 0 +#define CONFIG_BT_WAIT_NOP 0 +#define CONFIG_BT_DEVICE_NAME "test" +#define CONFIG_BT_ID_MAX 1 +#define BT_ID_DEFAULT 0 +#define BT_ADDR_LE_ANY NULL +#define CONFIG_BT_RX_STACK_SIZE 64 +#define CONFIG_BT_RX_PRIO 1 +#define IS_ENABLED(value) (value) +#define K_PRIO_COOP(value) (value) +#define LOG_ERR(...) ((void)0) +#define LOG_WRN(...) ((void)0) +#define BT_MONITOR_TYPE_PRIMARY 0 +#define BT_HCI_BUS 0 +#define BT_ADDR_ANY NULL +#define BT_HCI_NAME "test" +#define BT_MONITOR_OPEN_INDEX 0 +enum {BT_DEV_DISABLE, BT_DEV_ENABLE, BT_DEV_READY}; +struct {unsigned flags[1]; void *hci; int ncmd_sem,cmd_tx_queue,init;} bt_dev; +struct {int thread_id;} bt_workq; +static int rx_thread_stack[64]; +typedef void (*bt_ready_cb_t)(int); +static bt_ready_cb_t ready_cb; +static int open_error, settings_init_error, init_error, load_error; +static bool driver_ready, host_initialized, load_sets_ready, ready_during_enable; +static int opens, inits, loads, unsafe_loads, aborts, starts, live_threads, duplicate_threads; +static int submissions; +static int failures, checks; +typedef struct {struct {unsigned char val[6];} a;} bt_addr_le_t; +typedef struct {struct {void *type;} base;} bleio_address_obj_t; +typedef struct {bleio_address_obj_t address;} bleio_adapter_obj_t; +static int bleio_address_type; +static int identity_reads, identity_creates, raised_error; +static jmp_buf exception; +static void raise_zephyr_error(int err) {raised_error=err;longjmp(exception,1);} +static void bt_id_get(bt_addr_le_t *addresses,size_t *count) { + identity_reads++;memset(addresses,0,sizeof(*addresses));*count=1; +} +static bool bt_addr_le_eq(const bt_addr_le_t *a,const bt_addr_le_t *b) {(void)a;(void)b;return false;} +static int bt_id_create(void *a,void *b) {(void)a;(void)b;identity_creates++;return 0;} +static int bleio_address_type_from_zephyr(const bt_addr_le_t *a) {(void)a;return 0;} +static void common_hal_bleio_address_construct(bleio_address_obj_t *a,const unsigned char *bytes,int type) { + (void)a;(void)bytes;(void)type; +} +static void check(bool value, const char *name) { + checks++; + if (!value) {fprintf(stderr, "FAIL: %s\n",name); failures++;} +} +static void atomic_clear_bit(unsigned *flags, int bit) {*flags &= ~(1u< Date: Thu, 22 Oct 2026 15:17:35 +0100 Subject: [PATCH 27/36] Restore empty CP_VERSION fallback in Zephyr integration --- py/version.py | 6 ++- tools/check_build_version.py | 83 ++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100755 tools/check_build_version.py diff --git a/py/version.py b/py/version.py index 7ef2a587628..76ebe247e1a 100755 --- a/py/version.py +++ b/py/version.py @@ -3,7 +3,11 @@ def get_version_info_from_git(repo_path, extra_args=[]): - if "CP_VERSION" in os.environ: + # CIRCUITPY-CHANGE: treat an empty CP_VERSION as unset. A workflow that routes an + # unpopulated step output through `env:` (build-board-custom.yml -> + # .github/actions/mpy_cross does this for boards with frozen modules) exports + # CP_VERSION="" and must fall back to git describe instead of failing. + if os.environ.get("CP_VERSION"): git_tag = os.environ["CP_VERSION"] else: # Note: git describe doesn't work if no tag is available diff --git a/tools/check_build_version.py b/tools/check_build_version.py new file mode 100755 index 00000000000..2ed98894843 --- /dev/null +++ b/tools/check_build_version.py @@ -0,0 +1,83 @@ +"""Exercise version-header generation with CI's CP_VERSION environment values.""" + +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +class BuildVersionTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.repo = Path(temporary.name) + self.git("init", "--quiet") + self.git( + "-c", + "user.name=Version test", + "-c", + "user.email=test@example.invalid", + "commit", + "--allow-empty", + "--quiet", + "-m", + "version fixture", + ) + self.git("tag", "11.0.0-alpha.2") + + def git(self, *args): + return subprocess.run( + ["git", "-C", str(self.repo), *args], capture_output=True, text=True, check=True + ).stdout.strip() + + def generate(self, override): + env = dict(os.environ, SOURCE_DATE_EPOCH="0") + env.pop("CP_VERSION", None) + if override is not None: + env["CP_VERSION"] = override + destination = self.repo / "version.h" + result = subprocess.run( + [ + sys.executable, + str(ROOT / "py/makeversionhdr.py"), + "--repo-path", + str(self.repo), + str(destination), + ], + env=env, + capture_output=True, + text=True, + ) + return result, destination.read_text() if destination.exists() else None + + def assert_version(self, override, expected): + result, header = self.generate(override) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn('#define MICROPY_GIT_TAG "' + expected + '"', header) + self.assertIn( + '#define MICROPY_GIT_HASH "' + self.git("rev-parse", "--short", "HEAD") + '"', header + ) + + def test_unset_version_uses_git_tag(self): + self.assert_version(None, "11.0.0-alpha.2") + + def test_empty_ci_version_uses_git_tag(self): + self.assert_version("", "11.0.0-alpha.2") + + def test_nonempty_version_overrides_git_tag(self): + self.assert_version("9.9.9-test", "9.9.9-test") + + def test_invalid_nonempty_override_is_not_silently_ignored(self): + result, header = self.generate("invalid") + self.assertNotEqual(result.returncode, 0) + self.assertIn("Cannot determine version", result.stderr) + self.assertIsNone(header) + + +if __name__ == "__main__": + unittest.main() From a0c1485cf53a1f333a28cd3bb9dd307fd2470f36 Mon Sep 17 00:00:00 2001 From: tyeth Date: Thu, 22 Oct 2026 16:03:59 +0100 Subject: [PATCH 28/36] zephyr-cp: reject unsupported extended scan requests --- ports/zephyr-cp/common-hal/_bleio/Adapter.c | 7 +- .../tests/bsim/test_bsim_ble_scan.py | 42 +++++ tools/check_zephyr_ble_scan.py | 143 ++++++++++++++++++ 3 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 tools/check_zephyr_ble_scan.py diff --git a/ports/zephyr-cp/common-hal/_bleio/Adapter.c b/ports/zephyr-cp/common-hal/_bleio/Adapter.c index 20d42d5a22b..97b87c380dc 100644 --- a/ports/zephyr-cp/common-hal/_bleio/Adapter.c +++ b/ports/zephyr-cp/common-hal/_bleio/Adapter.c @@ -690,7 +690,12 @@ bool common_hal_bleio_adapter_get_advertising(bleio_adapter_obj_t *self) { } mp_obj_t common_hal_bleio_adapter_start_scan(bleio_adapter_obj_t *self, uint8_t *prefixes, size_t prefix_length, bool extended, mp_int_t buffer_size, mp_float_t timeout, mp_float_t interval, mp_float_t window, mp_int_t minimum_rssi, bool active) { - (void)extended; + // The Zephyr host chooses the scan mode from its build/controller + // capabilities; this port cannot currently honour an extended request. + // Reject it before replacing a scan or allocating its results. + if (extended) { + mp_raise_NotImplementedError(NULL); + } if (self->scan_results != NULL) { if (!shared_module_bleio_scanresults_get_done(self->scan_results)) { diff --git a/ports/zephyr-cp/tests/bsim/test_bsim_ble_scan.py b/ports/zephyr-cp/tests/bsim/test_bsim_ble_scan.py index 15de1787cdc..9538c13cc41 100644 --- a/ports/zephyr-cp/tests/bsim/test_bsim_ble_scan.py +++ b/ports/zephyr-cp/tests/bsim/test_bsim_ble_scan.py @@ -159,3 +159,45 @@ def test_bsim_scan_passive(bsim_phy, circuitpython, zephyr_sample): assert "scan passive start" in output assert "found beacon passive" in output assert "scan passive done True" in output + + +BSIM_SCAN_UNSUPPORTED_EXTENDED_CODE = """\ +import _bleio + +adapter = _bleio.adapter +scan = adapter.start_scan(timeout=4.0, active=True) +try: + adapter.start_scan(extended=True, timeout=4.0) +except NotImplementedError: + print("extended rejected") +else: + raise AssertionError("extended request was silently accepted") + +found = False +for entry in scan: + if b"zephyrproject" in entry.advertisement_bytes: + found = True + break +adapter.stop_scan() +print("original scan survived", found) + +found = False +for entry in adapter.start_scan(timeout=4.0, active=True): + if b"zephyrproject" in entry.advertisement_bytes: + found = True + break +adapter.stop_scan() +print("subsequent scan works", found) +""" + + +@pytest.mark.zephyr_sample("bluetooth/beacon") +@pytest.mark.circuitpy_drive({"code.py": BSIM_SCAN_UNSUPPORTED_EXTENDED_CODE}) +def test_bsim_scan_rejects_extended_without_stopping_scan(bsim_phy, circuitpython, zephyr_sample): + """An unsupported extended request must preserve an ordinary scan.""" + _ = zephyr_sample + circuitpython.wait_until_done() + output = circuitpython.serial.all_output + assert "extended rejected" in output + assert "original scan survived True" in output + assert "subsequent scan works True" in output diff --git a/tools/check_zephyr_ble_scan.py b/tools/check_zephyr_ble_scan.py new file mode 100644 index 00000000000..279a2a2425f --- /dev/null +++ b/tools/check_zephyr_ble_scan.py @@ -0,0 +1,143 @@ +"""Exercise the production scan entry point with host-side stubs. + +Checks unsupported-request handling and existing scan ownership. This is not +a controller, simulator, or physical radio test. Requires Python 3 and cc. +""" + +import argparse +from pathlib import Path +import subprocess +import tempfile + + +def function(path, signature): + source = path.read_text() + start = source.index(signature) + opening = source.index("{", start) + depth = 1 + end = opening + 1 + while depth: + depth += (source[end] == "{") - (source[end] == "}") + end += 1 + return source[start:end] + + +PREFIX = r""" +#include +#include +#include +#include +#include +typedef void *mp_obj_t; +typedef int mp_int_t; +typedef float mp_float_t; +typedef struct {bool done;} result_t; +typedef struct {result_t *scan_results;} bleio_adapter_obj_t; +struct bt_le_scan_param {int type,options;uint16_t interval,window,timeout,interval_coded,window_coded;}; +enum {BT_LE_SCAN_TYPE_ACTIVE=1,BT_LE_SCAN_TYPE_PASSIVE=0}; +#define MP_OBJ_FROM_PTR(ptr) ((void *)(ptr)) +static result_t old_result,new_result; +static result_t *active_scan_results; +static bool scan_callbacks_registered; +static struct {void (*recv)(void);void (*timeout)(void);} scan_callbacks; +static uint64_t scan_deadline_ms; +static int allocations,stops,starts,registrations,raised,checks,failures; +static struct bt_le_scan_param last_params; +static jmp_buf exception; +static void mp_raise_NotImplementedError(const char *message) {(void)message;raised=1;longjmp(exception,1);} +static void raise_zephyr_error(int err) {raised=err;longjmp(exception,1);} +static bool shared_module_bleio_scanresults_get_done(result_t *r) {return r->done;} +static void common_hal_bleio_adapter_stop_scan(bleio_adapter_obj_t *self) { + stops++;self->scan_results->done=true;self->scan_results=NULL; + active_scan_results=NULL;scan_deadline_ms=0; +} +static result_t *shared_module_bleio_new_scanresults(int size,uint8_t *prefixes,size_t length,int rssi) { + (void)size;(void)prefixes;(void)length;(void)rssi;allocations++;new_result.done=false;return &new_result; +} +static void scan_recv_cb(void) {} +static void scan_timeout_cb(void) {} +static int bt_le_scan_cb_register(void *cb) {(void)cb;registrations++;return 0;} +static uint16_t bleio_validate_and_convert_timeout(float timeout) {return (uint16_t)(timeout*100);} +static int bt_le_scan_start(struct bt_le_scan_param *params,void *cb) { + (void)cb;starts++;last_params=*params;return 0; +} +static uint64_t supervisor_ticks_ms64(void) {return 1000;} +static void check(bool ok,const char *label) {checks++;if(!ok){failures++;fprintf(stderr,"FAIL %s\n",label);}} +static void reset(bleio_adapter_obj_t *adapter,bool scanning) { + old_result.done=false;new_result.done=false; + adapter->scan_results=scanning?&old_result:NULL; + active_scan_results=adapter->scan_results; + scan_deadline_ms=scanning?5000:0; + scan_callbacks_registered=scanning; + allocations=stops=starts=registrations=raised=0; +} +""" + +CASES = r""" +static void request(bleio_adapter_obj_t *adapter,bool extended,bool active,float timeout) { + raised=0; + if(setjmp(exception)==0) { + common_hal_bleio_adapter_start_scan(adapter,NULL,0,extended,512,timeout,0.1f,0.05f,-80,active); + } +} +int main(void) { + bleio_adapter_obj_t adapter; + for(int scanning=0;scanning<2;scanning++) { + reset(&adapter,scanning); + request(&adapter,true,true,2); + check(raised==1,"extended request raises NotImplementedError"); + check(allocations==0 && registrations==0 && starts==0,"unsupported request has no allocation or HCI side effects"); + check(stops==0 && !old_result.done,"unsupported request preserves existing scan"); + check(adapter.scan_results==(scanning?&old_result:NULL),"scan owner unchanged"); + check(active_scan_results==adapter.scan_results,"active scan owner unchanged"); + check(scan_deadline_ms==(scanning?5000:0),"existing deadline unchanged"); + request(&adapter,false,true,2); + check(raised==0 && allocations==1 && starts==1,"ordinary scan still works after rejected request"); + } + for(int active=0;active<2;active++) { + reset(&adapter,false); + request(&adapter,false,active,2); + check(raised==0 && starts==1 && allocations==1,"ordinary scan starts"); + check(last_params.type==active && last_params.options==0,"active/passive and duplicate policy preserved"); + check(last_params.interval==160 && last_params.window==80 && last_params.timeout==200,"scan timing preserved"); + check(scan_deadline_ms==3000,"host timeout preserved"); + request(&adapter,false,active,0); + check(stops==1 && starts==2 && registrations==1,"replacement scan stops old scan without duplicate callback registration"); + check(scan_deadline_ms==0,"indefinite scan has no deadline"); + } + printf("%d/%d scan checks passed\n",checks-failures,checks); + return failures?1:0; +} +""" + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--circuitpython", type=Path, default=Path(__file__).resolve().parents[1]) + args = parser.parse_args() + path = args.circuitpython / "ports/zephyr-cp/common-hal/_bleio/Adapter.c" + production = function(path, "mp_obj_t common_hal_bleio_adapter_start_scan(") + with tempfile.TemporaryDirectory(prefix="ble-scan-") as directory: + root = Path(directory) + source = root / "scan.c" + source.write_text(PREFIX + production + CASES) + binary = root / "scan" + subprocess.run( + [ + "cc", + "-std=c11", + "-Wall", + "-Wextra", + "-Wno-unused-function", + "-fsanitize=undefined", + "-o", + str(binary), + str(source), + ], + check=True, + ) + subprocess.run([str(binary)], check=True) + + +if __name__ == "__main__": + main() From 1ca79521f3261cf90e0e7c6dfa398d5ad9885735 Mon Sep 17 00:00:00 2001 From: tyeth Date: Thu, 22 Oct 2026 17:11:35 +0100 Subject: [PATCH 29/36] Pin Zephyr settings sector validation fix --- ports/zephyr-cp/zephyr-config/west.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ports/zephyr-cp/zephyr-config/west.yml b/ports/zephyr-cp/zephyr-config/west.yml index 713b01c2f5b..c14e2e79429 100644 --- a/ports/zephyr-cp/zephyr-config/west.yml +++ b/ports/zephyr-cp/zephyr-config/west.yml @@ -30,7 +30,7 @@ manifest: # hal_infineon as imported by Zephyr's own manifest - name: zephyr url: https://github.com/tyeth-ai-assisted/zephyr - revision: 0589940bee3386b144961901717b2bce3576a2d6 + revision: 353bcd30f76e1fa140291540dde20334e952e0e6 clone-depth: 100 import: # Skip what no CircuitPython board can use, so west update fetches 37 From 7d4ef1aac72bf322f1bcead966a8d3eac0acb2af Mon Sep 17 00:00:00 2001 From: tyeth Date: Thu, 22 Oct 2026 18:15:29 +0100 Subject: [PATCH 30/36] zephyr-cp: pin CYW43 receive bus contention fix --- ports/zephyr-cp/zephyr-config/west.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ports/zephyr-cp/zephyr-config/west.yml b/ports/zephyr-cp/zephyr-config/west.yml index c14e2e79429..ddebf926d7c 100644 --- a/ports/zephyr-cp/zephyr-config/west.yml +++ b/ports/zephyr-cp/zephyr-config/west.yml @@ -30,7 +30,7 @@ manifest: # hal_infineon as imported by Zephyr's own manifest - name: zephyr url: https://github.com/tyeth-ai-assisted/zephyr - revision: 353bcd30f76e1fa140291540dde20334e952e0e6 + revision: 7379648278ab49e391af00f1a62ec73387d4c519 clone-depth: 100 import: # Skip what no CircuitPython board can use, so west update fetches 37 From 6b117bdce576abf927554a4946b8610149150aa0 Mon Sep 17 00:00:00 2001 From: tyeth Date: Fri, 23 Oct 2026 01:29:32 +0100 Subject: [PATCH 31/36] zephyr: keep socket services runnable alongside the interpreter --- .../internal-modules/circuitpython-kconfig/Kconfig | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ports/zephyr-cp/internal-modules/circuitpython-kconfig/Kconfig b/ports/zephyr-cp/internal-modules/circuitpython-kconfig/Kconfig index d138858f80f..1111dd07e03 100644 --- a/ports/zephyr-cp/internal-modules/circuitpython-kconfig/Kconfig +++ b/ports/zephyr-cp/internal-modules/circuitpython-kconfig/Kconfig @@ -319,6 +319,14 @@ config NET_RX_STACK_SIZE config NET_MGMT_EVENT_STACK_SIZE default 2048 if NETWORKING +# DHCP server and DNS callbacks run on the socket-service dispatcher. Its +# lowest-priority Zephyr default can be starved by the interpreter while it +# waits at the raw REPL or runs Python without sleeping. Share the main +# thread's priority so the normal scheduler timeslice can dispatch network +# callbacks without requiring an application-level time.sleep(). +config NET_SOCKETS_SERVICE_THREAD_PRIO + default MAIN_THREAD_PRIORITY if NETWORKING + # ... and the socket-service thread, whose poll() set includes the DNS # resolver's UDP sockets; its 1200 byte default overflows under DBG logs. config NET_SOCKETS_SERVICE_STACK_SIZE From 9e153ad624bcd1559211dfad8eb54ef8841f15e9 Mon Sep 17 00:00:00 2001 From: tyeth Date: Fri, 23 Oct 2026 02:13:11 +0100 Subject: [PATCH 32/36] zephyr: implement station lifecycle and preserve failed disconnect state --- ports/zephyr-cp/common-hal/wifi/Radio.c | 48 ++++- ports/zephyr-cp/common-hal/wifi/Radio.h | 3 + ports/zephyr-cp/common-hal/wifi/__init__.c | 9 +- tools/check_zephyr_station_lifecycle.py | 195 +++++++++++++++++++++ 4 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 tools/check_zephyr_station_lifecycle.py diff --git a/ports/zephyr-cp/common-hal/wifi/Radio.c b/ports/zephyr-cp/common-hal/wifi/Radio.c index 1266758ca0c..9ff226c5a81 100644 --- a/ports/zephyr-cp/common-hal/wifi/Radio.c +++ b/ports/zephyr-cp/common-hal/wifi/Radio.c @@ -285,11 +285,53 @@ void common_hal_wifi_radio_stop_scanning_networks(wifi_radio_obj_t *self) { } void common_hal_wifi_radio_start_station(wifi_radio_obj_t *self) { - // set_mode_station(self, true); + if (self->sta_netif == NULL) { + raise_zephyr_error(-ENODEV); + } + common_hal_wifi_radio_set_enabled(self, true); + self->sta_mode = true; } void common_hal_wifi_radio_stop_station(wifi_radio_obj_t *self) { - // set_mode_station(self, false); + if (self->sta_netif == NULL) { + raise_zephyr_error(-ENODEV); + } + // AIROC uses the station interface for its mutually exclusive AP mode. + // A station disconnect there would make the live AP interface dormant. + if (self->ap_mode) { + self->sta_mode = false; + return; + } + common_hal_wifi_radio_stop_scanning_networks(self); + atomic_set(&self->last_disconnect_status, -EINPROGRESS); + int res = net_mgmt(NET_REQUEST_WIFI_DISCONNECT, self->sta_netif, NULL, 0); + if (res == 0) { + // The request may return before the event callback. Do not report a + // stopped station when the driver failed or never completed it. + int64_t deadline = k_uptime_get() + 2000; + while (atomic_get(&self->last_disconnect_status) == -EINPROGRESS) { + RUN_BACKGROUND_TASKS; + if (mp_hal_is_interrupted()) { + raise_zephyr_error(-EINTR); + } + if (k_uptime_get() >= deadline) { + raise_zephyr_error(-ETIMEDOUT); + } + k_msleep(50); + } + res = atomic_get(&self->last_disconnect_status); + } + if (res != 0 && res != -EALREADY && res != -ENOTCONN) { + raise_zephyr_error(res); + } + #if defined(CONFIG_NET_DHCPV4) + net_dhcpv4_stop(self->sta_netif); + #endif + self->connected = false; + self->current_ssid_len = 0; + self->sta_mode = false; + // Keep the radio/interface enabled, as on the native Pico port. The next + // connect() can join again without globally re-enabling the radio. } // ------------------------------------------------------------- access point @@ -297,7 +339,7 @@ void common_hal_wifi_radio_stop_station(wifi_radio_obj_t *self) { // 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 +// program that wants the portal must stop_station() first. The AP's IPv4 // configuration, DHCPv4 server and station list are kept in the radio object // beside sta_netif. diff --git a/ports/zephyr-cp/common-hal/wifi/Radio.h b/ports/zephyr-cp/common-hal/wifi/Radio.h index cdebd0b4d9d..e0238fbdf7d 100644 --- a/ports/zephyr-cp/common-hal/wifi/Radio.h +++ b/ports/zephyr-cp/common-hal/wifi/Radio.h @@ -12,6 +12,7 @@ #include "shared-bindings/wifi/Network.h" #include +#include #include #include @@ -44,6 +45,8 @@ typedef struct { uint8_t retries_left; uint8_t starting_retries; uint8_t last_disconnect_reason; + // Result of the latest station disconnect request, set by net_mgmt. + atomic_t last_disconnect_status; // Signalled from the net_mgmt event handler when a connect attempt // finishes, so common_hal_wifi_radio_connect() can wait on the result. struct k_sem connect_sem; diff --git a/ports/zephyr-cp/common-hal/wifi/__init__.c b/ports/zephyr-cp/common-hal/wifi/__init__.c index e6811783113..31f1c366449 100644 --- a/ports/zephyr-cp/common-hal/wifi/__init__.c +++ b/ports/zephyr-cp/common-hal/wifi/__init__.c @@ -116,7 +116,14 @@ static void _event_handler(struct net_mgmt_event_callback *cb, uint64_t mgmt_eve case NET_EVENT_WIFI_DISCONNECT_RESULT: { const struct wifi_status *status = cb->info; self->last_disconnect_reason = status != NULL ? (uint8_t)status->status : 0; - self->connected = false; + int result = status != NULL ? status->status : -EIO; + if (result == 0) { + self->connected = false; + self->current_ssid_len = 0; + } + // Publish completion after updating the connection state. A + // failed disconnect request does not mean the link went down. + atomic_set(&self->last_disconnect_status, result); LOG_DBG("NET_EVENT_WIFI_DISCONNECT_RESULT reason %d", self->last_disconnect_reason); // A disconnect can also be the failure result of a connect attempt, // so release any waiter rather than letting it sit until timeout. diff --git a/tools/check_zephyr_station_lifecycle.py b/tools/check_zephyr_station_lifecycle.py new file mode 100644 index 00000000000..bb88e9f94aa --- /dev/null +++ b/tools/check_zephyr_station_lifecycle.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +"""Exercise actual Zephyr station lifecycle functions with driver fault injection. + +python tools/check_zephyr_station_lifecycle.py --circuitpython . +Use --revision REV to demonstrate the preceding source fails the same checks. +""" +import argparse +import hashlib +import json +from pathlib import Path +import re +import subprocess +import tempfile + + +def block(source, start): + opening=source.index('{',start);end=opening+1;depth=1 + while depth: + depth+=(source[end]=='{')-(source[end]=='}');end+=1 + return source[start:end] + + +def function(source,name): + match=re.search(r'^[\w *]+\b'+re.escape(name)+r'\([^;]*?\)\s*\{',source,re.M) + if match is None:raise ValueError('Missing source function '+name) + return block(source,match.start()) + + +HARNESS=r''' +#include +#include +#include +#include +#include +#include +#include +#include +#include +typedef _Atomic int atomic_t; +#define atomic_get(p) atomic_load(p) +#define atomic_set(p,v) atomic_exchange(p,v) +#define LOG_DBG(...) ((void)0) +#define NET_REQUEST_WIFI_DISCONNECT 1 +struct net_if { bool up; }; +struct wifi_status { int status; }; +struct net_mgmt_event_callback { const void *info; }; +typedef struct { + struct net_if *sta_netif, *ap_netif; + void *current_scan; + bool started, connected, ap_mode, sta_mode; + size_t current_ssid_len; + atomic_t last_disconnect_status; + uint8_t last_disconnect_reason; + int connect_sem; +} wifi_radio_obj_t; +static wifi_radio_obj_t radio; +static struct net_if sta,ap; +static int request_result,event_result,event_at,request_calls,up_calls,down_calls; +static int dhcp_stops,scan_stops,up_result,down_result,background_calls; +static int64_t now; +static bool no_event,null_event,interrupt_now; +static jmp_buf exception; +static int raised; +static void dispatch_disconnect(struct net_mgmt_event_callback *cb); +static void send_event(void) { + struct wifi_status status={event_result}; + struct net_mgmt_event_callback cb={null_event?NULL:&status}; + dispatch_disconnect(&cb); +} +static void raise_zephyr_error(int error) { raised=error;longjmp(exception,1); } +static int net_if_up(struct net_if *iface) { + up_calls++;if(up_result==0 || up_result==-EALREADY)iface->up=true;return up_result; +} +static int net_if_down(struct net_if *iface) { + down_calls++;if(down_result==0 || down_result==-EALREADY)iface->up=false;return down_result; +} +static int net_mgmt(int op,struct net_if *iface,void *data,size_t len) { + assert(op==NET_REQUEST_WIFI_DISCONNECT && iface==&sta && data==NULL && len==0); + request_calls++; + if(!no_event && event_at==0)send_event(); + return request_result; +} +static void common_hal_wifi_radio_stop_scanning_networks(wifi_radio_obj_t *self) { + if(self->current_scan) {scan_stops++;self->current_scan=NULL;} +} +static void net_dhcpv4_stop(struct net_if *iface) { assert(iface==&sta);dhcp_stops++; } +static void k_sem_give(int *sem) { (*sem)++; } +static int64_t k_uptime_get(void) { return now; } +static void k_msleep(int delay) { + now+=delay; + if(!no_event && event_at>0 && now>=event_at) {event_at=-1;send_event();} +} +static bool mp_hal_is_interrupted(void) {return interrupt_now;} +#define RUN_BACKGROUND_TASKS do { background_calls++; } while(0) +''' + +TESTS=r''' +static int failed,total; +static void check(const char *name,bool ok) { + total++;if(!ok)failed++; + printf("%s %s\n",ok?"PASS":"FAIL",name); +} +static void reset(void) { + sta=(struct net_if){true};ap=(struct net_if){true}; + radio=(wifi_radio_obj_t){.sta_netif=&sta,.started=true,.connected=true,.sta_mode=true,.current_ssid_len=8,.current_scan=(void*)1}; + request_result=event_result=request_calls=up_calls=down_calls=0; + dhcp_stops=scan_stops=up_result=down_result=background_calls=0; + event_at=100;now=0;no_event=null_event=interrupt_now=false;raised=0; +} +static void stop(void) { + if(setjmp(exception)==0)common_hal_wifi_radio_stop_station(&radio); +} +static void start(void) { + if(setjmp(exception)==0)common_hal_wifi_radio_start_station(&radio); +} +int main(void) { + reset();event_result=-EIO;send_event(); + check("failed_disconnect_event_does_not_clear_live_link",radio.connected && radio.current_ssid_len==8); + reset();send_event(); + check("successful_disconnect_event_clears_cached_ssid",!radio.connected && radio.current_ssid_len==0); + reset();stop(); + check("async_disconnect_confirms_before_return",raised==0 && !radio.connected && !radio.sta_mode && radio.current_ssid_len==0 && request_calls==1 && now==100); + check("stop_preserves_enabled_interface",radio.started && sta.up && down_calls==0); + check("stop_cleans_scan_and_dhcp",scan_stops==1 && dhcp_stops==EXPECT_DHCP); + reset();event_at=0;stop(); + check("synchronous_event_not_lost",raised==0 && !radio.connected && request_calls==1 && now==0); + reset();request_result=-EIO;event_result=-EIO;event_at=0;stop(); + check("request_error_preserves_connection",raised==-EIO && radio.connected && radio.current_ssid_len==8 && dhcp_stops==0); + reset();event_result=-EIO;stop(); + check("async_error_preserves_connection",raised==-EIO && radio.connected && dhcp_stops==0); + reset();null_event=true;stop(); + check("missing_event_payload_is_error",raised==-EIO && radio.connected && dhcp_stops==0); + reset();no_event=true;stop(); + check("missing_event_times_out_without_false_success",raised==-ETIMEDOUT && radio.connected && now==2000 && dhcp_stops==0); + reset();no_event=true;interrupt_now=true;stop(); + check("interrupt_returns_without_false_success",raised==-EINTR && radio.connected && now==0); + reset();no_event=true;request_result=-EALREADY;stop(); + check("already_disconnected_idempotent",raised==0 && !radio.connected && !radio.sta_mode && now==0); + reset();no_event=true;request_result=-ENOTCONN;stop(); + check("not_connected_idempotent",raised==0 && !radio.connected && now==0); + reset();radio.ap_mode=true;radio.ap_netif=&sta;radio.connected=false;stop(); + check("shared_interface_ap_preserved",raised==0 && radio.ap_mode && radio.started && sta.up && !radio.sta_mode && request_calls==0 && dhcp_stops==0 && scan_stops==0); + reset();radio.ap_mode=true;radio.ap_netif=NULL;radio.connected=false;stop(); + check("implicit_shared_interface_ap_preserved",raised==0 && radio.ap_mode && request_calls==0 && dhcp_stops==0); + reset();radio.ap_mode=true;radio.ap_netif=≈stop(); + check("ap_preserved_even_with_unused_separate_ap_pointer",raised==0 && radio.ap_mode && ap.up && radio.started && request_calls==0 && dhcp_stops==0); + reset();radio.started=false;radio.sta_mode=false;radio.connected=false;sta.up=false;start(); + check("start_brings_interface_up",raised==0 && radio.started && radio.sta_mode && sta.up && up_calls==1); + start();check("repeated_start_idempotent",raised==0 && up_calls==1); + reset();radio.started=false;radio.sta_mode=false;sta.up=false;up_result=-EIO;start(); + check("start_failure_does_not_set_flags",raised==-EIO && !radio.started && !radio.sta_mode && !sta.up); + reset();radio.started=false;radio.sta_mode=false;up_result=-EALREADY;start(); + check("start_accepts_already_up",raised==0 && radio.started && radio.sta_mode); + reset();radio.ap_mode=true;radio.ap_netif=&sta;radio.sta_mode=false;start(); + check("start_does_not_disrupt_shared_ap",raised==0 && radio.ap_mode && sta.up && request_calls==0 && down_calls==0); + reset();radio.sta_netif=NULL;stop();check("missing_interface_stop_errors",raised==-ENODEV && request_calls==0); + reset();radio.sta_netif=NULL;start();check("missing_interface_start_errors",raised==-ENODEV && up_calls==0); + printf("SUMMARY %d checks, %d failures\n",total,failed); + return failed?1:0; +} +''' + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--circuitpython',type=Path,default=Path('.')) + parser.add_argument('--revision') + parser.add_argument('--output',type=Path) + args=parser.parse_args();root=args.circuitpython.resolve() + paths=['ports/zephyr-cp/common-hal/wifi/Radio.c','ports/zephyr-cp/common-hal/wifi/__init__.c'] + if args.revision: + sources={p:subprocess.check_output(['git','-C',str(root),'show',args.revision+':'+p],text=True) for p in paths} + else:sources={p:(root/p).read_text() for p in paths} + radio=sources[paths[0]];init=sources[paths[1]] + marker='case NET_EVENT_WIFI_DISCONNECT_RESULT: ' + event=block(init,init.index('{',init.index(marker))) + code=HARNESS+'\n'+ '\n'.join(function(radio,n) for n in ('common_hal_wifi_radio_set_enabled','common_hal_wifi_radio_start_station','common_hal_wifi_radio_stop_station')) + code+='\nstatic void dispatch_disconnect(struct net_mgmt_event_callback *cb) { wifi_radio_obj_t *self=&radio; switch(0) { case 0: '+event+' }}\n'+TESTS + results=[] + with tempfile.TemporaryDirectory(prefix='cp-station-') as tmp: + source=Path(tmp)/'station.c';source.write_text(code) + for dhcp in (True,False): + exe=Path(tmp)/('station-dhcp' if dhcp else 'station-static') + flags=['-DCONFIG_NET_DHCPV4=1','-DEXPECT_DHCP=1'] if dhcp else ['-DEXPECT_DHCP=0'] + subprocess.run(['cc','-std=c11','-O2','-Wall','-Wextra','-Werror','-Wno-unused-function','-Wno-unused-parameter',*flags,str(source),'-o',str(exe)],check=True) + run=subprocess.run([str(exe)],capture_output=True,text=True) + print(('DHCP' if dhcp else 'STATIC')+'\n'+run.stdout+run.stderr) + results.append(dict(dhcp=dhcp,returncode=run.returncode,checks=run.stdout.splitlines())) + evidence=dict(revision=args.revision or 'working-tree',source_sha256={p:hashlib.sha256(s.encode()).hexdigest() for p,s in sources.items()},results=results,scope='Actual station lifecycle/set_enabled functions and disconnect event case; mocked network/kernel callbacks, not physical hardware.') + if args.output:args.output.write_text(json.dumps(evidence,indent=2)+'\n') + return int(any(r['returncode'] for r in results)) + + +if __name__=='__main__':raise SystemExit(main()) From 6839890534556b06b8b3d7c491e36fd2005b2483 Mon Sep 17 00:00:00 2001 From: tyeth Date: Fri, 23 Oct 2026 16:56:56 +0100 Subject: [PATCH 33/36] Pin USB reconfiguration and CDC cancellation recovery fixes --- ports/zephyr-cp/zephyr-config/west.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ports/zephyr-cp/zephyr-config/west.yml b/ports/zephyr-cp/zephyr-config/west.yml index ddebf926d7c..8add8bd3b12 100644 --- a/ports/zephyr-cp/zephyr-config/west.yml +++ b/ports/zephyr-cp/zephyr-config/west.yml @@ -30,7 +30,7 @@ manifest: # hal_infineon as imported by Zephyr's own manifest - name: zephyr url: https://github.com/tyeth-ai-assisted/zephyr - revision: 7379648278ab49e391af00f1a62ec73387d4c519 + revision: c6200e80ea6973b092da3c30c672901cc1951fc2 clone-depth: 100 import: # Skip what no CircuitPython board can use, so west update fetches 37 From 0d545efdcf74fdefef66190236f8707ac2b545c5 Mon Sep 17 00:00:00 2001 From: tyeth Date: Fri, 23 Oct 2026 21:30:15 +0100 Subject: [PATCH 34/36] zephyr-cp: pin Bluetooth firmware-ready retry guard --- ports/zephyr-cp/zephyr-config/west.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ports/zephyr-cp/zephyr-config/west.yml b/ports/zephyr-cp/zephyr-config/west.yml index 8add8bd3b12..ff07adb551c 100644 --- a/ports/zephyr-cp/zephyr-config/west.yml +++ b/ports/zephyr-cp/zephyr-config/west.yml @@ -78,8 +78,8 @@ manifest: - uoscore-uedhoc - zephyr-xenlib - name: hal_rpi_pico - url: https://github.com/tyeth/hal_rpi_pico - revision: 725e9e429eab65eda01762a1af9016426dee00ac + url: https://github.com/tyeth-ai-assisted/hal_rpi_pico + revision: de12fc2a0f756d889cc13c129622ddb663025d29 path: modules/hal/rpi_pico submodules: - path: lib/cyw43-driver From 5f73172d46cd6c49f9b08570fe793bf1d12b7768 Mon Sep 17 00:00:00 2001 From: tyeth Date: Sat, 24 Oct 2026 02:29:35 +0100 Subject: [PATCH 35/36] zephyr-cp: propagate MSC block errors and validate transfer ranges --- ports/zephyr-cp/supervisor/usb.c | 68 ++++++---- tools/check_zephyr_msc_io.py | 219 +++++++++++++++++++++++++++++++ 2 files changed, 265 insertions(+), 22 deletions(-) create mode 100644 tools/check_zephyr_msc_io.py diff --git a/ports/zephyr-cp/supervisor/usb.c b/ports/zephyr-cp/supervisor/usb.c index 44fc23c0c9a..f650bfa3ead 100644 --- a/ports/zephyr-cp/supervisor/usb.c +++ b/ports/zephyr-cp/supervisor/usb.c @@ -140,48 +140,67 @@ int _zephyr_disk_status(struct disk_info *disk) { return DISK_STATUS_OK; } -int _zephyr_disk_read(struct disk_info *disk, uint8_t *data_buf, uint32_t start_sector, uint32_t num_sector) { - supervisor_vfs_t *root_vfs = filesystem_circuitpy(); - fs_user_mount_t *root = root_vfs == NULL ? NULL : &root_vfs->fat; - +// Validate before touching a buffer or suspending autoreload. Subtraction +// avoids wrapping start_sector + num_sector for a malformed request. +static int zephyr_disk_check_range(fs_user_mount_t *root, uint32_t start_sector, uint32_t num_sector) { + if (root == NULL) { + return -ENODEV; + } uint32_t disk_block_count; - disk_ioctl(root, GET_SECTOR_COUNT, &disk_block_count); - - if (start_sector + num_sector > disk_block_count) { + if (disk_ioctl(root, GET_SECTOR_COUNT, &disk_block_count) != RES_OK || + start_sector > disk_block_count || num_sector > disk_block_count - start_sector) { return -EIO; } - disk_read(root, data_buf, start_sector, num_sector); return 0; } +int _zephyr_disk_read(struct disk_info *disk, uint8_t *data_buf, uint32_t start_sector, uint32_t num_sector) { + supervisor_vfs_t *root_vfs = filesystem_circuitpy(); + fs_user_mount_t *root = root_vfs == NULL ? NULL : &root_vfs->fat; + int ret = zephyr_disk_check_range(root, start_sector, num_sector); + if (ret != 0) { + return ret; + } + return disk_read(root, data_buf, start_sector, num_sector) == RES_OK ? 0 : -EIO; +} + int _zephyr_disk_write(struct disk_info *disk, const uint8_t *data_buf, uint32_t start_sector, uint32_t num_sector) { supervisor_vfs_t *root_vfs = filesystem_circuitpy(); fs_user_mount_t *root = root_vfs == NULL ? NULL : &root_vfs->fat; + int ret = zephyr_disk_check_range(root, start_sector, num_sector); + if (ret != 0) { + return ret; + } int lun = 0; autoreload_suspend(AUTORELOAD_SUSPEND_USB); - disk_write(root, data_buf, start_sector, num_sector); - // Since by getting here we assume the mount is read-only to - // CircuitPython let's update the cached FatFs sector if it's the one - // we just wrote. + DRESULT result = disk_write(root, data_buf, start_sector, num_sector); + if (result != RES_OK) { + // Do not acknowledge a rejected block write or copy rejected data + // into FatFs's window. Balance suspension even for callers that + // do not issue CTRL_SYNC after the error. + autoreload_resume(AUTORELOAD_SUSPEND_USB); + return result == RES_WRPRT ? -EROFS : -EIO; + } + // The mount is read-only to CircuitPython. Refresh the cached FatFs + // sector when it lies anywhere within this successful multi-sector write. if #if FF_MAX_SS != FF_MIN_SS (root->fatfs.ssize == MSC_FLASH_BLOCK_SIZE) #else - // The compiler can optimize this away. (FF_MAX_SS == FILESYSTEM_BLOCK_SIZE) #endif { - if (start_sector == root->fatfs.winsect && start_sector > 0) { + if (root->fatfs.winsect > 0 && root->fatfs.winsect >= start_sector && + root->fatfs.winsect - start_sector < num_sector) { memcpy(root->fatfs.win, data_buf + MSC_FLASH_BLOCK_SIZE * (root->fatfs.winsect - start_sector), MSC_FLASH_BLOCK_SIZE); } } - // A write to an lba below fatbase is in the filesystem metadata (BPB) area or the "Reserved Region", - // and is probably setting or clearing the dirty bit. This should not trigger auto-reload. - // All other writes will trigger auto-reload. - if (start_sector >= root->fatfs.fatbase) { + // A request beginning in the reserved region can still reach file or + // metadata sectors. The checked range above cannot overflow here. + if (num_sector > 0 && start_sector + num_sector > root->fatfs.fatbase) { content_write[lun] = true; } return 0; @@ -192,13 +211,18 @@ int _zephyr_disk_ioctl(struct disk_info *disk, uint8_t cmd, void *buff) { supervisor_vfs_t *root_vfs = filesystem_circuitpy(); fs_user_mount_t *root = root_vfs == NULL ? NULL : &root_vfs->fat; int lun = 0; + if (root == NULL) { + if (cmd == DISK_IOCTL_CTRL_SYNC) { + autoreload_resume(AUTORELOAD_SUSPEND_USB); + content_write[lun] = false; + } + return -ENODEV; + } switch (cmd) { case DISK_IOCTL_GET_SECTOR_COUNT: - disk_ioctl(root, GET_SECTOR_COUNT, buff); - return 0; + return disk_ioctl(root, GET_SECTOR_COUNT, buff) == RES_OK ? 0 : -EIO; case DISK_IOCTL_GET_SECTOR_SIZE: - disk_ioctl(root, GET_SECTOR_SIZE, buff); - return 0; + return disk_ioctl(root, GET_SECTOR_SIZE, buff) == RES_OK ? 0 : -EIO; case DISK_IOCTL_CTRL_SYNC: // Zephyr's MSC class sends this after every WRITE10. Don't flush // here; the background flush writes the cached page. diff --git a/tools/check_zephyr_msc_io.py b/tools/check_zephyr_msc_io.py new file mode 100644 index 00000000000..8080b6a67ff --- /dev/null +++ b/tools/check_zephyr_msc_io.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +"""Run actual MSC/FatFs/block-write C functions against injected disk failures. + +Use --revision to run the same cases against preceding firmware source. +Each case runs in its own process so a missing-media crash cannot hide later cases. +""" +import argparse +import hashlib +import json +from pathlib import Path +import re +import subprocess +import tempfile + + +def function(source, name): + match = re.search(r'^[\w *]+\b' + re.escape(name) + r'\([^;]*?\)\s*\{', source, re.M) + if not match: + raise ValueError('Missing function ' + name) + start = match.start() + end = source.index('{', start) + 1 + depth = 1 + while depth: + depth += (source[end] == '{') - (source[end] == '}') + end += 1 + return source[start:end] + + +HARNESS = r''' +#include +#include +#include +#include +#include +#include +#include +#define MSC_FLASH_BLOCK_SIZE 512 +#define FILESYSTEM_BLOCK_SIZE 512 +#define FF_MIN_SS 512 +#define PART1_START_BLOCK 1 +#define CIRCUITPY_SAVES_PARTITION_SIZE 0 +#define AUTORELOAD_SUSPEND_USB 1 +#define GET_SECTOR_COUNT 1 +#define GET_SECTOR_SIZE 2 +#define DISK_IOCTL_GET_SECTOR_COUNT 3 +#define DISK_IOCTL_GET_SECTOR_SIZE 4 +#define DISK_IOCTL_CTRL_SYNC 5 +#define MP_EROFS EROFS +#define printk(...) ((void)0) +typedef enum { RES_OK, RES_ERROR, RES_WRPRT, RES_NOTRDY, RES_PARERR } DRESULT; +typedef uint8_t BYTE; +typedef uint32_t DWORD; +typedef unsigned int UINT; +typedef unsigned int mp_uint_t; +typedef void *mp_obj_t; +typedef struct { int unused; } blockdev_t; +typedef struct { + blockdev_t blockdev; + struct { uint32_t winsect,fatbase; uint16_t ssize; uint8_t win[512]; } fatfs; +} fs_user_mount_t; +typedef struct { fs_user_mount_t fat; } supervisor_vfs_t; +typedef fs_user_mount_t *bdev_t; +struct disk_info { int unused; }; +static supervisor_vfs_t volume; +static bool present=true,content_write[1],suspended; +static int ioctl_error,read_error,write_error,read_calls,write_calls,dirty_calls,trigger_calls; +static uint32_t count=32; +static uint8_t sectors[32*512],buffer[8*512]; +static supervisor_vfs_t *filesystem_circuitpy(void) { return present?&volume:NULL; } +static fs_user_mount_t *disk_get_device(bdev_t p) { return p; } +static DRESULT disk_ioctl(fs_user_mount_t *root,int cmd,void *dest) { + if(!root || ioctl_error)return RES_ERROR; + if(cmd==GET_SECTOR_COUNT)*(uint32_t*)dest=count; + else if(cmd==GET_SECTOR_SIZE)*(uint16_t*)dest=512; + else return RES_PARERR; + return RES_OK; +} +static void autoreload_suspend(int what) { (void)what;suspended=true; } +static void autoreload_resume(int what) { (void)what;suspended=false; } +static void autoreload_trigger(void) { trigger_calls++; } +static void supervisor_flash_mark_dirty(void) { dirty_calls++; } +static mp_uint_t supervisor_flash_write_blocks(const uint8_t *data,uint32_t start,uint32_t length) { + write_calls++; + if(write_error)return write_error; + if(start>=31 || length>31-start)return EIO; + memcpy(sectors+(start+1)*512,data,length*512); + return 0; +} +static int mp_vfs_blockdev_read(blockdev_t *b,uint32_t start,uint32_t length,uint8_t *data) { + (void)b;read_calls++; + if(read_error)return read_error; + if(start>=32 || length>32-start)return EIO; + memcpy(data,sectors+start*512,length*512);return 0; +} +''' + +BRIDGE = r''' +static int mp_vfs_blockdev_write(blockdev_t *b,uint32_t start,uint32_t length,const uint8_t *data) { + if(write_error==-EROFS)return -EROFS; + return flash_write_blocks(b,data,start,length); +} +''' + +TESTS = r''' +static void reset(void) { + memset(&volume,0,sizeof(volume));memset(sectors,0x33,sizeof(sectors)); + for(unsigned n=0;n Date: Sat, 24 Oct 2026 14:10:53 +0100 Subject: [PATCH 36/36] zephyr-cp: pin advertiser identity selection for first passive scan --- ports/zephyr-cp/zephyr-config/west.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ports/zephyr-cp/zephyr-config/west.yml b/ports/zephyr-cp/zephyr-config/west.yml index ff07adb551c..7e2bb3d58f9 100644 --- a/ports/zephyr-cp/zephyr-config/west.yml +++ b/ports/zephyr-cp/zephyr-config/west.yml @@ -30,7 +30,7 @@ manifest: # hal_infineon as imported by Zephyr's own manifest - name: zephyr url: https://github.com/tyeth-ai-assisted/zephyr - revision: c6200e80ea6973b092da3c30c672901cc1951fc2 + revision: 5bf4230db7fe1e6b27bd866cf1691ff54ac5099d clone-depth: 100 import: # Skip what no CircuitPython board can use, so west update fetches 37