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/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/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/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..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 @@ -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 @@ -14,3 +20,64 @@ CONFIG_NET_HOSTNAME_DYNAMIC=y 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 + +# 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). +# +# 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 + +# 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 + +# 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 + +# 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_pico2_w_zephyr/board.overlay b/ports/zephyr-cp/boards/raspberrypi/rpi_pico2_w_zephyr/board.overlay index aaaa51c2e0e..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,3 +1,57 @@ /* 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 + * 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/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..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 @@ -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,4 +21,88 @@ 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 +# 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 + +# 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 +# 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 + +# 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 + +# 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=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 / 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 +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 + +# 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 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" 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/common-hal/_bleio/Adapter.c b/ports/zephyr-cp/common-hal/_bleio/Adapter.c index 7819e68676e..97b87c380dc 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; } @@ -353,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) { @@ -461,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]; @@ -677,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)) { @@ -729,10 +747,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 +771,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); 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 { diff --git a/ports/zephyr-cp/common-hal/wifi/Radio.c b/ports/zephyr-cp/common-hal/wifi/Radio.c index 1570d57d63a..9ff226c5a81 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 @@ -281,93 +285,304 @@ 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 +// +// 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 stop_station() 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 +906,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 +934,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 +1017,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 +1054,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..e0238fbdf7d 100644 --- a/ports/zephyr-cp/common-hal/wifi/Radio.h +++ b/ports/zephyr-cp/common-hal/wifi/Radio.h @@ -12,9 +12,14 @@ #include "shared-bindings/wifi/Network.h" #include +#include #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 @@ -40,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; @@ -50,6 +57,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..31f1c366449 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; @@ -85,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. @@ -119,12 +157,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 +424,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, diff --git a/ports/zephyr-cp/cptools/build_circuitpython.py b/ports/zephyr-cp/cptools/build_circuitpython.py index 56b09917b8e..b06768786a0 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( @@ -446,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, ) ) @@ -825,6 +916,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/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", 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/pre_zephyr_build_prep.py b/ports/zephyr-cp/cptools/pre_zephyr_build_prep.py index f42fc1a3a85..847f99fc4fc 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"), "BUILD=build", "USER_C_MODULES="], + check=True, + ) + if board.endswith("bsim"): subprocess.run( ["make", "everything", "-j", "8"], 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"] diff --git a/ports/zephyr-cp/debug.conf b/ports/zephyr-cp/debug.conf index ec1453b20fe..89e04129350 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 @@ -104,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/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 diff --git a/ports/zephyr-cp/prj.conf b/ports/zephyr-cp/prj.conf index 39b4206ff88..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 @@ -136,3 +137,17 @@ 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 + +# 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 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 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/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/ports/zephyr-cp/zephyr-config/west.yml b/ports/zephyr-cp/zephyr-config/west.yml index 1d412bd8d5e..7e2bb3d58f9 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-ai-assisted/zephyr + revision: 5bf4230db7fe1e6b27bd866cf1691ff54ac5099d clone-depth: 100 import: # Skip what no CircuitPython board can use, so west update fetches 37 @@ -61,3 +77,13 @@ manifest: - percepio - uoscore-uedhoc - zephyr-xenlib + - name: hal_rpi_pico + url: https://github.com/tyeth-ai-assisted/hal_rpi_pico + revision: de12fc2a0f756d889cc13c129622ddb663025d29 + path: modules/hal/rpi_pico + submodules: + - path: lib/cyw43-driver + - name: hal_infineon + url: https://github.com/tyeth-ai-assisted/hal_infineon + revision: 7f93dc60675126a63358ffa0780648d94fd2d634 + path: modules/hal/infineon 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/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) { 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])) 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() 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() 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< +#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 +#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()) 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))