diff --git a/ports/zephyr-cp/background.c b/ports/zephyr-cp/background.c index 56e9e98f1f2..61526313645 100644 --- a/ports/zephyr-cp/background.c +++ b/ports/zephyr-cp/background.c @@ -11,6 +11,10 @@ #include +#if CIRCUITPY_BLEIO +#include "common-hal/_bleio/Adapter.h" +#endif + void port_start_background_tick(void) { } @@ -22,6 +26,13 @@ void port_background_tick(void) { } void port_background_task(void) { + #if CIRCUITPY_BLEIO + // Resume advertising after a disconnect. Deferred to here because the + // Zephyr disconnect callback runs in the BT thread, where bt_le_adv_start() + // must not be called. + bleio_background(); + #endif + // Make sure time advances in the simulator. #if defined(CONFIG_ARCH_POSIX) k_busy_wait(100); diff --git a/ports/zephyr-cp/boards/adafruit_feather_nrf52840_nrf52840_sense_uf2.conf b/ports/zephyr-cp/boards/adafruit_feather_nrf52840_nrf52840_sense_uf2.conf index 20176b34be0..9efc2da2410 100644 --- a/ports/zephyr-cp/boards/adafruit_feather_nrf52840_nrf52840_sense_uf2.conf +++ b/ports/zephyr-cp/boards/adafruit_feather_nrf52840_nrf52840_sense_uf2.conf @@ -2,6 +2,13 @@ CONFIG_BT=y CONFIG_BT_PERIPHERAL=y CONFIG_BT_CENTRAL=y CONFIG_BT_BROADCASTER=y + +# common-hal/_bleio/Service.c registers GATT services built at runtime from +# Python, so it calls bt_gatt_service_register()/_unregister(). Those are only +# compiled into the host when BT_GATT_DYNAMIC_DB is set; without it the link +# fails with "undefined reference to bt_gatt_service_register". Every board +# that enables BT needs this as long as the port ships a GATT server. +CONFIG_BT_GATT_DYNAMIC_DB=y CONFIG_BT_OBSERVER=y CONFIG_BT_EXT_ADV=y diff --git a/ports/zephyr-cp/boards/adafruit_feather_nrf52840_uf2.conf b/ports/zephyr-cp/boards/adafruit_feather_nrf52840_uf2.conf index 20176b34be0..9efc2da2410 100644 --- a/ports/zephyr-cp/boards/adafruit_feather_nrf52840_uf2.conf +++ b/ports/zephyr-cp/boards/adafruit_feather_nrf52840_uf2.conf @@ -2,6 +2,13 @@ CONFIG_BT=y CONFIG_BT_PERIPHERAL=y CONFIG_BT_CENTRAL=y CONFIG_BT_BROADCASTER=y + +# common-hal/_bleio/Service.c registers GATT services built at runtime from +# Python, so it calls bt_gatt_service_register()/_unregister(). Those are only +# compiled into the host when BT_GATT_DYNAMIC_DB is set; without it the link +# fails with "undefined reference to bt_gatt_service_register". Every board +# that enables BT needs this as long as the port ships a GATT server. +CONFIG_BT_GATT_DYNAMIC_DB=y CONFIG_BT_OBSERVER=y CONFIG_BT_EXT_ADV=y diff --git a/ports/zephyr-cp/boards/board_aliases.cmake b/ports/zephyr-cp/boards/board_aliases.cmake index ad0c1b5a57a..8caf8245d42 100644 --- a/ports/zephyr-cp/boards/board_aliases.cmake +++ b/ports/zephyr-cp/boards/board_aliases.cmake @@ -51,3 +51,4 @@ cp_board_alias(raspberrypi_rpi_pico_w_zephyr rpi_pico/rp2040/w) cp_board_alias(raspberrypi_rpi_pico2_zephyr rpi_pico2/rp2350a/m33) cp_board_alias(raspberrypi_rpi_pico2_w_zephyr rpi_pico2/rp2350a/m33/w) cp_board_alias(st_nucleo_n657x0_q nucleo_n657x0_q/stm32n657xx) +cp_board_alias(silabs_siwx917_dk2605a siwx917_dk2605a) diff --git a/ports/zephyr-cp/boards/da14695_dk_usb.conf b/ports/zephyr-cp/boards/da14695_dk_usb.conf index 145a9393407..16540445ecb 100644 --- a/ports/zephyr-cp/boards/da14695_dk_usb.conf +++ b/ports/zephyr-cp/boards/da14695_dk_usb.conf @@ -2,6 +2,13 @@ CONFIG_BT=y CONFIG_BT_PERIPHERAL=y CONFIG_BT_CENTRAL=y CONFIG_BT_BROADCASTER=y + +# common-hal/_bleio/Service.c registers GATT services built at runtime from +# Python, so it calls bt_gatt_service_register()/_unregister(). Those are only +# compiled into the host when BT_GATT_DYNAMIC_DB is set; without it the link +# fails with "undefined reference to bt_gatt_service_register". Every board +# that enables BT needs this as long as the port ships a GATT server. +CONFIG_BT_GATT_DYNAMIC_DB=y CONFIG_BT_OBSERVER=y CONFIG_BT_EXT_ADV=y diff --git a/ports/zephyr-cp/boards/frdm_rw612.conf b/ports/zephyr-cp/boards/frdm_rw612.conf index c06f78ae830..65681cf3a76 100644 --- a/ports/zephyr-cp/boards/frdm_rw612.conf +++ b/ports/zephyr-cp/boards/frdm_rw612.conf @@ -22,6 +22,13 @@ CONFIG_BT=y CONFIG_BT_PERIPHERAL=y CONFIG_BT_CENTRAL=y CONFIG_BT_BROADCASTER=y + +# common-hal/_bleio/Service.c registers GATT services built at runtime from +# Python, so it calls bt_gatt_service_register()/_unregister(). Those are only +# compiled into the host when BT_GATT_DYNAMIC_DB is set; without it the link +# fails with "undefined reference to bt_gatt_service_register". Every board +# that enables BT needs this as long as the port ships a GATT server. +CONFIG_BT_GATT_DYNAMIC_DB=y CONFIG_BT_OBSERVER=y CONFIG_BT_EXT_ADV=y diff --git a/ports/zephyr-cp/boards/nordic/nrf5340dk/autogen_board_info.toml b/ports/zephyr-cp/boards/nordic/nrf5340dk/autogen_board_info.toml index ee20efa6fee..e7864b79ddc 100644 --- a/ports/zephyr-cp/boards/nordic/nrf5340dk/autogen_board_info.toml +++ b/ports/zephyr-cp/boards/nordic/nrf5340dk/autogen_board_info.toml @@ -18,8 +18,10 @@ atexit = false audiobusio = true # Zephyr board has audiobusio audiocore = true # Zephyr board has audiobusio audiodelays = true # Zephyr board has audiobusio +audiofilewriter = false audiofilters = true # Zephyr board has audiobusio audiofreeverb = true # Zephyr board has audiobusio +audioi2sin = false audioio = false audiomixer = true # Zephyr board has audiobusio audiomp3 = true # Zephyr board has audiobusio @@ -105,6 +107,7 @@ touchio = false traceback = true uheap = false usb = false +usb_audio = false usb_cdc = true usb_hid = false usb_host = false diff --git a/ports/zephyr-cp/boards/nordic/nrf7002dk/autogen_board_info.toml b/ports/zephyr-cp/boards/nordic/nrf7002dk/autogen_board_info.toml index e0b759e01ce..d7b4b28b4d6 100644 --- a/ports/zephyr-cp/boards/nordic/nrf7002dk/autogen_board_info.toml +++ b/ports/zephyr-cp/boards/nordic/nrf7002dk/autogen_board_info.toml @@ -18,8 +18,10 @@ atexit = false audiobusio = false audiocore = false audiodelays = false +audiofilewriter = false audiofilters = false audiofreeverb = false +audioi2sin = false audioio = false audiomixer = false audiomp3 = false @@ -105,6 +107,7 @@ touchio = false traceback = true uheap = false usb = false +usb_audio = false usb_cdc = true usb_hid = false usb_host = false diff --git a/ports/zephyr-cp/boards/nrf5340bsim_nrf5340_cpuapp.conf b/ports/zephyr-cp/boards/nrf5340bsim_nrf5340_cpuapp.conf index 57628a61e20..9e6b33d4198 100644 --- a/ports/zephyr-cp/boards/nrf5340bsim_nrf5340_cpuapp.conf +++ b/ports/zephyr-cp/boards/nrf5340bsim_nrf5340_cpuapp.conf @@ -11,6 +11,13 @@ CONFIG_BT_CENTRAL=y CONFIG_BT_OBSERVER=y CONFIG_BT_BROADCASTER=y +# common-hal/_bleio/Service.c registers GATT services built at runtime from +# Python, so it calls bt_gatt_service_register()/_unregister(). Those are only +# compiled into the host when BT_GATT_DYNAMIC_DB is set; without it the link +# fails with "undefined reference to bt_gatt_service_register". Every board +# that enables BT needs this as long as the port ships a GATT server. +CONFIG_BT_GATT_DYNAMIC_DB=y + CONFIG_BT_L2CAP_TX_MTU=253 CONFIG_BT_BUF_CMD_TX_COUNT=2 CONFIG_BT_BUF_CMD_TX_SIZE=255 diff --git a/ports/zephyr-cp/boards/nrf5340dk_nrf5340_cpuapp.conf b/ports/zephyr-cp/boards/nrf5340dk_nrf5340_cpuapp.conf index 145a9393407..16540445ecb 100644 --- a/ports/zephyr-cp/boards/nrf5340dk_nrf5340_cpuapp.conf +++ b/ports/zephyr-cp/boards/nrf5340dk_nrf5340_cpuapp.conf @@ -2,6 +2,13 @@ CONFIG_BT=y CONFIG_BT_PERIPHERAL=y CONFIG_BT_CENTRAL=y CONFIG_BT_BROADCASTER=y + +# common-hal/_bleio/Service.c registers GATT services built at runtime from +# Python, so it calls bt_gatt_service_register()/_unregister(). Those are only +# compiled into the host when BT_GATT_DYNAMIC_DB is set; without it the link +# fails with "undefined reference to bt_gatt_service_register". Every board +# that enables BT needs this as long as the port ships a GATT server. +CONFIG_BT_GATT_DYNAMIC_DB=y CONFIG_BT_OBSERVER=y CONFIG_BT_EXT_ADV=y diff --git a/ports/zephyr-cp/boards/nrf7002dk_nrf5340_cpuapp.conf b/ports/zephyr-cp/boards/nrf7002dk_nrf5340_cpuapp.conf index 91c956fa676..932da2915c6 100644 --- a/ports/zephyr-cp/boards/nrf7002dk_nrf5340_cpuapp.conf +++ b/ports/zephyr-cp/boards/nrf7002dk_nrf5340_cpuapp.conf @@ -11,6 +11,13 @@ CONFIG_BT=y CONFIG_BT_PERIPHERAL=y CONFIG_BT_CENTRAL=y CONFIG_BT_BROADCASTER=y + +# common-hal/_bleio/Service.c registers GATT services built at runtime from +# Python, so it calls bt_gatt_service_register()/_unregister(). Those are only +# compiled into the host when BT_GATT_DYNAMIC_DB is set; without it the link +# fails with "undefined reference to bt_gatt_service_register". Every board +# that enables BT needs this as long as the port ships a GATT server. +CONFIG_BT_GATT_DYNAMIC_DB=y CONFIG_BT_OBSERVER=y CONFIG_BT_EXT_ADV=y diff --git a/ports/zephyr-cp/boards/renesas_da14695_dk_usb.conf b/ports/zephyr-cp/boards/renesas_da14695_dk_usb.conf index 145a9393407..16540445ecb 100644 --- a/ports/zephyr-cp/boards/renesas_da14695_dk_usb.conf +++ b/ports/zephyr-cp/boards/renesas_da14695_dk_usb.conf @@ -2,6 +2,13 @@ CONFIG_BT=y CONFIG_BT_PERIPHERAL=y CONFIG_BT_CENTRAL=y CONFIG_BT_BROADCASTER=y + +# common-hal/_bleio/Service.c registers GATT services built at runtime from +# Python, so it calls bt_gatt_service_register()/_unregister(). Those are only +# compiled into the host when BT_GATT_DYNAMIC_DB is set; without it the link +# fails with "undefined reference to bt_gatt_service_register". Every board +# that enables BT needs this as long as the port ships a GATT server. +CONFIG_BT_GATT_DYNAMIC_DB=y CONFIG_BT_OBSERVER=y CONFIG_BT_EXT_ADV=y diff --git a/ports/zephyr-cp/boards/silabs/siwx917_dk2605a/autogen_board_info.toml b/ports/zephyr-cp/boards/silabs/siwx917_dk2605a/autogen_board_info.toml new file mode 100644 index 00000000000..83fae0991b7 --- /dev/null +++ b/ports/zephyr-cp/boards/silabs/siwx917_dk2605a/autogen_board_info.toml @@ -0,0 +1,123 @@ +# This file is autogenerated when a board is built. Do not edit. Do commit it to git. Other scripts use its info. +name = "Silicon Labs SiWx917 Wi-Fi 6 and Bluetooth LE SoC Dev Kit (BRD2605A)" + +[modules] +__future__ = true +_bleio = true # Zephyr board has _bleio +_eve = false +_pew = false +_pixelmap = false +_stage = false +adafruit_bus_device = true +adafruit_pixelbuf = false +aesio = true +alarm = false +analogbufio = false +analogio = false +atexit = false +audiobusio = false +audiocore = false +audiodelays = false +audiofilewriter = false +audiofilters = false +audiofreeverb = false +audioi2sin = false +audioio = false +audiomixer = false +audiomp3 = false +audiopwmio = false +audiospeed = false +aurora_epaper = false +bitbangio = false +bitmapfilter = true # Zephyr board has busio +bitmaptools = true # Zephyr board has busio +bitops = false +board = false +busdisplay = true # Zephyr board has busio +busio = true # Zephyr board has busio +camera = false +canio = false +codeop = false +countio = false +digitalio = true +displayio = true # Zephyr board has busio +dotclockframebuffer = false +dualbank = false +epaperdisplay = true # Zephyr board has busio +floppyio = false +fontio = true # Zephyr board has busio +fourwire = true # Zephyr board has busio +framebufferio = true # Zephyr board has busio +frequencyio = false +getpass = true +gifio = true # Zephyr board has busio +gnss = false +hashlib = true # Zephyr networking enabled +hostnetwork = false +i2cdisplaybus = true # Zephyr board has busio +i2cioexpander = false +i2ctarget = false +imagecapture = false +ipaddress = true # Zephyr networking enabled +is31fl3741 = false +jpegio = true # Zephyr board has busio +keypad = false +keypad_demux = false +locale = false +lvfontio = true # Zephyr board has busio +math = true +max3421e = false +mcp4822 = false +mdns = true # Zephyr board has mdns +memorymap = false +memorymonitor = false +microcontroller = true +mipidsi = false +msgpack = true +neopixel_write = false +nvm = false +onewireio = false +os = true +paralleldisplaybus = false +ps2io = false +pulseio = false +pwmio = false +qrio = false +qspibus = false +rainbowio = true +random = true +rclcpy = false +rgbmatrix = false +rotaryio = true # Zephyr board has rotaryio +rtc = false +sdcardio = true # Zephyr board has busio +sdioio = false +sharpdisplay = true # Zephyr board has busio +socketpool = true # Zephyr networking enabled +spitarget = false +ssl = true # Zephyr networking enabled +storage = true +struct = true +supervisor = true +synthio = false +terminalio = true # Zephyr board has busio +tilepalettemapper = true # Zephyr board has busio +time = true +touchio = false +traceback = true +uheap = false +usb = false +usb_audio = false +usb_cdc = false +usb_hid = false +usb_host = false +usb_midi = false +usb_video = false +ustack = false +vectorio = true # Zephyr board has busio +warnings = true +watchdog = false +wifi = true # Zephyr board has wifi +zephyr_display = false +zephyr_kernel = false +zlib = true diff --git a/ports/zephyr-cp/boards/silabs/siwx917_dk2605a/circuitpython.toml b/ports/zephyr-cp/boards/silabs/siwx917_dk2605a/circuitpython.toml new file mode 100644 index 00000000000..f576bf56051 --- /dev/null +++ b/ports/zephyr-cp/boards/silabs/siwx917_dk2605a/circuitpython.toml @@ -0,0 +1,2 @@ +CIRCUITPY_BUILD_EXTENSIONS = ["elf", "hex"] +CIRCUITPY_ULAB = true diff --git a/ports/zephyr-cp/boards/siwx917_dk2605a.conf b/ports/zephyr-cp/boards/siwx917_dk2605a.conf new file mode 100644 index 00000000000..a95543221e9 --- /dev/null +++ b/ports/zephyr-cp/boards/siwx917_dk2605a.conf @@ -0,0 +1,187 @@ +# The upstream board defconfig does not enable GPIO, so no gpio device structs +# are instantiated and CircuitPython's generated board.c fails to link with +# "undefined reference to __device_dts_ord_NN" for every pin. +CONFIG_GPIO=y + +# The siwx91x SoC family Kconfig (zephyr/soc/silabs/silabs_siwx91x/Kconfig) +# never selects BUILD_OUTPUT_HEX, unlike Silicon Labs' other SoC families +# (silabs_s0/s1/s2/sim3, which all select it). The port's Makefile +# unconditionally copies zephyr.hex to firmware.hex for release artifacts, so +# without this every release build fails with "cannot stat +# .../zephyr.hex: No such file or directory" even though the .elf/.rps +# linked and flashed fine. +CONFIG_BUILD_OUTPUT_HEX=y + +# getaddrinfo() calls k_calloc(), which is compiled out when the kernel heap is +# zero-sized. prj.conf also enables CONFIG_DYNAMIC_THREAD_ALLOC, which draws +# thread stacks from this same heap. +CONFIG_HEAP_MEM_POOL_SIZE=16384 + +CONFIG_NETWORKING=y +CONFIG_NET_IPV4=y +CONFIG_NET_DHCPV4=y +CONFIG_NET_SOCKETS=y + +# TCP is not enabled by any of the options above, and the siwx91x driver +# defaults to the native network stack. Wi-Fi association and DHCP still work +# because DHCP is UDP, so the board looks healthy while every SOCK_STREAM +# socket fails: the web workflow listener never opens and socketpool raises +# "Out of sockets" from Python. +CONFIG_NET_TCP=y + +# Every Zephyr socket also takes a zvfs file descriptor, which defaults to 0. +CONFIG_ZVFS_OPEN_MAX=8 + +# Net context budget. NET_MAX_CONN is the constraining pool for incoming +# connections, and at 8 the web workflow refused every connection after a +# burst of ~6 zero-spacing requests, recovering ~1.14s later (issue #35). +# +# Measured A/B/A on this board, burst-until-refused, 3 trials each: +# NET_MAX_CONN=8 refused after 6, recovery 1.11-1.14s +# NET_MAX_CONN=12 200 consecutive requests, zero refusals (x2) +# NET_MAX_CONN=8 refused after 6 again - symptom returns +# +# It is a race, not a hard ceiling: at 8 slots a zero-spacing burst consumes +# connections faster than teardown recycles them, which is why spacing +# requests >=100ms also avoids it. 12 gives enough headroom that recycling +# always keeps up. +# +# Ruled out by measurement, each changed in isolation with no effect on the +# boundary: NET_SOCKET_MAX_SEND_WAIT (10000->2000), ZVFS_OPEN_MAX (8->16), +# NET_MAX_CONTEXTS (10->14). +# +# Why NET_MAX_CONN and not NET_MAX_CONTEXTS (found by Hermes in Zephyr's +# source, after the measurement said the slab was not it): a TCP connection +# consumes TWO separate resources, and only one of them is the slab. +# +# subsys/net/ip/tcp.c tcp_conns_slab, sized by NET_MAX_CONTEXTS +# subsys/net/ip/connection.c:60 +# static struct net_conn conns[CONFIG_NET_MAX_CONN] +# subsys/net/ip/tcp.c:2566 every TCP connection also registers a packet +# handler into that conns[] array +# +# conns[] is the binding constraint here, which is why raising the slab +# changed nothing and raising NET_MAX_CONN eliminated the symptom. +CONFIG_NET_MAX_CONTEXTS=10 +CONFIG_NET_MAX_CONN=12 + +# Zephyr's console (printk, boot banner, driver logs) goes to SEGGER RTT over +# the existing SWD connection instead of sharing the UART with the REPL. +# CircuitPython's REPL is unaffected: supervisor/serial.c takes the device from +# DT_CHOSEN(zephyr_console) and drives it with busio directly, so it keeps +# ulpuart and the J-Link VCOM regardless of this Kconfig. +# +# Without this, every net event printk interleaves with the REPL and corrupts +# the raw REPL handshake that test tooling depends on. +CONFIG_USE_SEGGER_RTT=y +CONFIG_RTT_CONSOLE=y +CONFIG_UART_CONSOLE=n + +CONFIG_WIFI=y +CONFIG_NET_L2_WIFI_MGMT=y +CONFIG_NET_MGMT_EVENT=y +CONFIG_NET_MGMT_EVENT_INFO=y + +CONFIG_NET_HOSTNAME_ENABLE=y +CONFIG_NET_HOSTNAME_DYNAMIC=y +CONFIG_NET_HOSTNAME="circuitpython" + +# mDNS responder + DNS-SD (issue #37): answers .local and +# advertises services registered via mdns.Server.advertise_service(), which +# is how /cp/version.json's board_name/hostname fields get populated. +# Zephyr's responder is push-only (it never sends its own PTR/SRV queries), +# so mdns.Server.find() has nothing to search with yet -- see the comment in +# common-hal/mdns/Server.c. +CONFIG_MDNS_RESPONDER=y +CONFIG_DNS_SD=y +CONFIG_MDNS_RESPONDER_DNS_SD=y + +CONFIG_MBEDTLS=y +CONFIG_MBEDTLS_SSL_PROTO_TLS1_2=y +CONFIG_MBEDTLS_CIPHERSUITE_TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256=y +CONFIG_MBEDTLS_ENTROPY_C=y +CONFIG_MBEDTLS_CTR_DRBG_C=y + +CONFIG_BT=y +CONFIG_BT_PERIPHERAL=y +CONFIG_BT_CENTRAL=y +CONFIG_BT_BROADCASTER=y +CONFIG_BT_OBSERVER=y +CONFIG_BT_EXT_ADV=y + +CONFIG_BT_DEVICE_APPEARANCE_DYNAMIC=y +CONFIG_BT_DEVICE_NAME_DYNAMIC=y +CONFIG_BT_DEVICE_NAME_MAX=28 +CONFIG_BT_L2CAP_TX_MTU=253 + +# Required for bt_gatt_service_register()/_unregister(): the GATT server +# built into common-hal/_bleio/Service.c registers services built up at +# runtime from Python, which needs the dynamic attribute table. Without this, +# linking bt_gatt_service_register fails. +CONFIG_BT_GATT_DYNAMIC_DB=y +# Long writes (queued prepare-write ATT ops) need at least one prepare slot; +# 0 silently rejects any write that doesn't fit in a single ATT_WRITE_REQ. +CONFIG_BT_ATT_PREPARE_COUNT=2 + +# --- Link supervision timeout. THIS IS THE FLAP KNOB (per Hermes/rex, +# ports/zephyr-cp/common-hal/_bleio, 2026-08-03). +# +# Zephyr's peripheral preferred-connection-parameter defaults are +# MIN_INT=24 (30ms), MAX_INT=40 (50ms), LATENCY=0, TIMEOUT=42 -- the last one +# is in 10ms units, so the default supervision timeout is 420ms. With +# CONFIG_BT_GAP_AUTO_UPDATE_CONN_PARAMS=y (also default) the host sends that +# preference to the central 5s into every connection, actively downgrading a +# link the central set up more conservatively. +# +# Measured on Hermes's board: central opened at timeout 72 (720ms), board +# talked it down to 42 (420ms) 5s later. At 420ms with a 30ms interval, 14 +# consecutive missed connection events kill the link -- thin on a +# single-antenna coexisting part like this one, where the NWP is servicing +# Wi-Fi on the same radio. +# +# 400 = 4.0s, Apple's accessory guideline value. +CONFIG_BT_PERIPHERAL_PREF_MIN_INT=24 +CONFIG_BT_PERIPHERAL_PREF_MAX_INT=40 +CONFIG_BT_PERIPHERAL_PREF_LATENCY=0 +CONFIG_BT_PERIPHERAL_PREF_TIMEOUT=400 + +# BT Buffers +CONFIG_BT_BUF_CMD_TX_SIZE=255 +CONFIG_BT_BUF_EVT_RX_COUNT=16 +CONFIG_BT_BUF_EVT_RX_SIZE=255 +# The controller reports 15 ACL TX buffers but the host was configured for 8: +# bt_hci_core: Num of Controller's ACL packets != ACL bt_conn_tx +# contexts (15 != 8) +# Match the host to the controller so the host isn't the bottleneck on +# notification-heavy traffic. +CONFIG_BT_BUF_ACL_TX_COUNT=15 +CONFIG_BT_BUF_ACL_TX_SIZE=251 +CONFIG_BT_BUF_ACL_RX_COUNT_EXTRA=1 +CONFIG_BT_BUF_ACL_RX_SIZE=255 + +# DNS resolution: DHCP hands us a resolver, but the resolver subsystem has to +# be present for socketpool.getaddrinfo() and radio.ipv4_dns to work. +CONFIG_DNS_RESOLVER=y + +# --- SPI (GSPI) — issue #3 --- +# The GSPI controller driver plus DMA. +# +# DMA is no longer a boolean. Upstream replaced SPI_SILABS_SIWX91X_GSPI_DMA +# with the integer SPI_SILABS_SIWX91X_GSPI_DMA_DESCR_COUNT +# ("default 3 if DMA", 0 disables), so CONFIG_DMA=y below is what turns DMA +# on now; assigning the old symbol is a hard Kconfig error against the +# current tree. GSPI itself is "default y" once the devicetree node is +# enabled, so the explicit enable is redundant but kept for legibility. +CONFIG_SPI=y +CONFIG_SPI_SILABS_SIWX91X_GSPI=y +CONFIG_SPI_ASYNC=y +CONFIG_DMA=y + +# --- ICM-40627 IMU — issue #4 --- +# NOTE: the in-tree driver is I2C-ONLY. drivers/sensor/tdk/icm40627/ ships +# icm40627_i2c.c and no SPI transport; ICM40627_BUS_SPI is defined in +# icm40627.h:16 and never referenced anywhere, and there is no +# invensense,icm40627-spi.yaml binding. So enabling this does NOT get the +# on-board IMU working over GSPI — a SPI transport has to be written first. +# Left commented rather than enabled so nothing silently half-configures. +# CONFIG_ICM40627=y diff --git a/ports/zephyr-cp/boards/siwx917_dk2605a.overlay b/ports/zephyr-cp/boards/siwx917_dk2605a.overlay new file mode 100644 index 00000000000..3273ee5c5f2 --- /dev/null +++ b/ports/zephyr-cp/boards/siwx917_dk2605a.overlay @@ -0,0 +1,146 @@ +// Replaces the port's app.overlay, which cannot be used here: it does +// `&zephyr_udc0 { ... }` to add the CDC-ACM console, and the SiWG917 has no USB +// device controller. The CircuitPython console falls back to the Zephyr console +// on &ulpuart, reaching the host over the on-board J-Link VCOM. + +// CIRCUITPY filesystem. +// +// supervisor/flash.c looks for a partition node labeled `circuitpy_partition` +// (#define CIRCUITPY_PARTITION circuitpy_partition). Without it the port falls +// back to scanning for a flash device not covered by any partition, finds none +// on this board, prints "no flash found for filesystem" and boots into safe +// mode with "CIRCUITPY drive could not be found or created". +// +// The whole 8 MB is already partitioned by siwg917m111mgtba.dtsi, so there is +// no unallocated space to claim: +// 0x000000..0x011000 mbr_nwp (do not touch) +// 0x011000..0x1f0000 code_nwp - NWP firmware (do not touch) +// 0x1f0000..0x202000 mbr + hdr (do not touch) +// 0x202000..0x3f8000 code_partition - our M4 app +// 0x3f8000..0x400000 storage - only 32 KB, unusable as CIRCUITPY +// 0x400000..0x800000 ota_swap_partition +// 0x7cf000..0x800000 storage_nwp, storage_shared, backup_bootloader +// +// Placing CIRCUITPY in the upper region (0x400000..0x7cf000, inside ota_swap) +// opens and reads fine but every write fails: +// +// flash write failed: -5 (-EIO) +// address 0 length 512 +// +// In common-flash mode the M4 cannot write flash itself; the driver calls +// sl_si91x_command_to_write_common_flash() to have the NWP do it, and the NWP +// refuses writes in the OTA-swap/NWP-owned upper area. +// +// So carve CIRCUITPY out of the M4's own code_partition instead, which is +// definitely writable - it is where our firmware is flashed. code_partition is +// shrunk from 2008K to 1M (the image currently uses ~703K, so there is room; +// if it ever overflows the build fails loudly at link time rather than +// silently corrupting the filesystem). +// +// 0x202000 + 1M code_partition -> ends 0x302000 +// 0x302000 + 984K circuitpy -> ends 0x3f8000 +// 0x3f8000 + 32K storage (unchanged) +// PSRAM is kept OUT of the CircuitPython heap until it is understood. +// +// The 8 MB PSRAM at 0xa000000 works as memory: sparse writes across a 64 KB +// buffer verify correctly and a 64 KB allocation succeeds. But using it as the +// Python heap corrupts objects under sustained allocation, and the failure is +// not about large buffers - a plain `for i in range(200000): s = s + i` with no +// buffer at all hangs the board. Symptoms are corrupted objects: an int that +// became a function, a bytearray with a wrong length, and an MPU fault calling +// a garbage pointer read from PSRAM: +// +// ***** MPU FAULT ***** Instruction Access Violation +// r0/a1: 0x0a0012c0 r12/ip: 0x0a0031ac <- PSRAM pointers +// PC: 0x410e29c8 <- called through r3, garbage +// +// Not a timing problem: identical at fast-freq 144 MHz and 33 MHz. Not the +// NWP-reserved/DMA pools either; excluding those (supervisor/port.c minimum +// region size) did not help. +// +// ROOT CAUSE FOUND 2026-08-01, and the node is enabled again. +// +// The SoC has a 16 KB data cache dedicated to PSRAM (family RM rev 1.2 section +// 5.4.5) at 0x44040000, on the QSPI2 path. The bootloader leaves it enabled and +// half-configured, and nothing in this build maintains it - see the long +// comment in supervisor/port.c, which disables it in port_heap_init() before +// any TLSF pool exists. +// +// Note what did NOT work, so it is not retried: setting ATTR_MPU_RAM_NOCACHE on +// this node changes nothing. That is an MPU attribute governing the Cortex-M +// architectural cache, and CPU_HAS_DCACHE is never selected, so there is +// nothing there for it to govern. The cache that matters is a separate +// peripheral and an MPU attribute does not reach it. +&psram { + status = "okay"; +}; + +&flash0 { + partitions { + code_partition: partition@202000 { + compatible = "zephyr,mapped-partition"; + reg = <0x00202000 0x00100000>; + label = "code_partition"; + }; + + circuitpy_partition: partition@302000 { + compatible = "zephyr,mapped-partition"; + reg = <0x00302000 0x000f6000>; + label = "circuitpy"; + }; + }; +}; + +// --- SPI (GSPI) -------------------------------------------------------------- +// Issue #3. The board .dts leaves spi0 disabled and carries a bare +// `/* TODO: Add ICMCM-40627 with SSI */` comment; nothing wires the bus. +// +// Pin assignment is NOT guessed. Upstream Zephyr already ships a verified +// mapping for this exact board in +// zephyr/tests/drivers/spi/spi_loopback/boards/siwx917_dk2605a.overlay +// (Silicon Laboratories Inc., 2025), which routes GSPI to the breakout pads: +// +// CLK -> GSPI_CLK_HP25 (breakout PAD 3) +// MOSI -> GSPI_MOSI_HP27 (breakout PAD 5) +// MISO -> GSPI_MISO_HP26 (breakout PAD 7) +// CS -> gpiob 12, active low +// +// Note the SoC dtsi has exactly ONE SPI controller node, spi0, and it is +// `compatible = "silabs,gspi"`. There is no `ssi` node in +// dts/arm/silabs/siwg917.dtsi at all, so the board comment's "with SSI" is not +// reachable from devicetree today: SSI and GSPI are separate peripherals in the +// WiSeConnect HAL (peripherals-ssi vs peripherals-gspi) and Zephyr ships only a +// GSPI controller driver (drivers/spi/spi_silabs_siwx91x_gspi.c). SSI pinmux +// macros exist in siwx91x-pinctrl.h but have no controller behind them. +// +// Per UG581 §3.4.3 Fig 3.6 the on-board ICM-40627 IMU is on the ULP SPI via +// ULP GPIO pins (ULP_GPIO_8/1/2/10), a third bus distinct from both GSPI and +// SSI — see issue #4. This GSPI bus serves the breakout pads only. +// +// cptools/compat2driver.py already maps "silabs_gspi" -> "spi", so enabling the +// node is sufficient for zephyr2cp.py to emit board.SPI / busio.SPI. +&pinctrl0 { + spi0_default: spi0_default { + out { + pinmux = , ; + }; + + in { + pinmux = ; + }; + }; +}; + +&spi0 { + status = "okay"; + cs-gpios = <&gpiob 12 GPIO_ACTIVE_LOW>; + pinctrl-0 = <&spi0_default>; + pinctrl-names = "default"; + + dmas = <&gpdma 0 11>, <&gpdma 1 10>; + dma-names = "tx", "rx"; +}; + +&gpdma { + status = "okay"; +}; diff --git a/ports/zephyr-cp/common-hal/_bleio/Adapter.c b/ports/zephyr-cp/common-hal/_bleio/Adapter.c index d1410f02e1b..97c1395f313 100644 --- a/ports/zephyr-cp/common-hal/_bleio/Adapter.c +++ b/ports/zephyr-cp/common-hal/_bleio/Adapter.c @@ -9,6 +9,8 @@ #include #include +#include + #include #include #include @@ -21,6 +23,7 @@ #include "shared-bindings/_bleio/__init__.h" #include "shared-bindings/_bleio/Adapter.h" #include "shared-bindings/_bleio/Address.h" +#include "shared-bindings/_bleio/Service.h" #include "shared-module/_bleio/Address.h" #include "shared-module/_bleio/ScanResults.h" #include "supervisor/shared/tick.h" @@ -40,6 +43,64 @@ static struct bt_data scan_resp_data[BLEIO_ADV_MAX_FIELDS]; static uint8_t adv_data_storage[BLEIO_ADV_MAX_DATA_LEN]; static uint8_t scan_resp_storage[BLEIO_ADV_MAX_DATA_LEN]; +// Advertising state retained so that connectable advertising can be resumed +// after a disconnect. The controller stops the advertiser when a connection is +// established and Zephyr has no restart path of its own, so without this the +// peripheral accepts exactly one connection per boot and is then invisible +// until reset. The payload arrays above are already file-scope; only the +// parameters and the field counts were locals of start_advertising(). +static struct bt_le_adv_param retained_adv_params; +static size_t retained_adv_count; +static size_t retained_scan_resp_count; + +// Whether advertising *should* be running. Distinct from ble_advertising, +// which tracks whether it *is*. They differ for exactly as long as a +// connection is up. +static bool ble_advertising_intent = false; + +// Set from the disconnected callback, consumed by the background task. The +// callback runs in Zephyr's BT thread, where bt_le_adv_start() must not be +// called: it blocks waiting for an HCI command completion that the same thread +// is responsible for delivering. Zephyr's own peripheral samples set a flag in +// the callback and restart from their main loop for this reason. +static atomic_t ble_advertising_resume_pending = ATOMIC_INIT(0); + +// Services queued by Service.c's constructor, registered with Zephyr when +// advertising starts. Held as raw C pointers, not an mp_obj_list_t, so a +// Service object could otherwise be GC-collected while these still point into +// its embedded attr table -- bleio_adapter_gc_collect() below roots this array +// the same way it roots bleio_connections. +#define BLEIO_ADAPTER_MAX_PENDING_SERVICES 8 +static bleio_service_obj_t *pending_services[BLEIO_ADAPTER_MAX_PENDING_SERVICES]; +static size_t pending_service_count; + +void bleio_adapter_add_pending_service(bleio_service_obj_t *self) { + for (size_t i = 0; i < pending_service_count; i++) { + if (pending_services[i] == self) { + return; + } + } + if (pending_service_count >= BLEIO_ADAPTER_MAX_PENDING_SERVICES) { + raise_zephyr_error(-ENOSPC); + } + pending_services[pending_service_count++] = self; +} + +static void bleio_adapter_register_pending_services(void) { + for (size_t i = 0; i < pending_service_count; i++) { + bleio_service_register_if_needed(pending_services[i]); + } +} + +bool bleio_adapter_any_connected(void) { + for (size_t i = 0; i < BLEIO_TOTAL_CONNECTION_COUNT; i++) { + if (bleio_connections[i].conn != NULL) { + return true; + } + } + return false; +} + static uint8_t bleio_address_type_from_zephyr(const bt_addr_le_t *addr) { if (addr == NULL) { return BLEIO_ADDRESS_TYPE_PUBLIC; @@ -141,14 +202,34 @@ static void bleio_connection_release(bleio_connection_internal_t *connection, ui static void bleio_connected_cb(struct bt_conn *conn, uint8_t err) { if (err != 0) { + printk("bleio: connection setup failed, HCI err 0x%02x\n", err); return; } + printk("bleio: connected %p\n", conn); if (bleio_connection_track(conn) == NULL) { + printk("bleio: no free connection slot, rejecting %p\n", conn); bt_conn_disconnect(conn, BT_HCI_ERR_CONN_LIMIT_EXCEEDED); return; } + // Log the NEGOTIATED link parameters. The disconnect reason alone tells + // you a link died; these tell you whether it was ever survivable. With + // this port's Zephyr defaults (BT_PERIPHERAL_PREF_TIMEOUT=42 => 420ms + // supervision timeout) a single bad stretch on a 2.4 GHz band shared with + // Wi-Fi can kill the link -- see BT_PERIPHERAL_PREF_TIMEOUT in + // boards/siwx917_dk2605a.conf. Units are raw HCI: interval 1.25ms, + // timeout 10ms. + struct bt_conn_info cinfo; + if (bt_conn_get_info(conn, &cinfo) == 0 && cinfo.type == BT_CONN_TYPE_LE) { + printk("bleio: connected %p interval %u (%u.%02u ms) latency %u timeout %u (%u ms)\n", + conn, + cinfo.le.interval, + (cinfo.le.interval * 125u) / 100u, ((cinfo.le.interval * 125u) % 100u), + cinfo.le.latency, + cinfo.le.timeout, cinfo.le.timeout * 10u); + } + // When connectable advertising results in a connection, the controller // auto-stops advertising. Clear our flag to match (we cannot call // stop_advertising() here because this callback runs in Zephyr's BT @@ -158,14 +239,58 @@ static void bleio_connected_cb(struct bt_conn *conn, uint8_t err) { common_hal_bleio_adapter_obj.connection_objs = NULL; } +// Fires when link parameters change AFTER connection. Zephyr's +// BT_GAP_AUTO_UPDATE_CONN_PARAMS sends our peripheral preference ~5s in +// (BT_CONN_PARAM_UPDATE_TIMEOUT), so the parameters at connect time are +// frequently NOT the ones in force when a drop happens -- logging only at +// connect reports a link state that may no longer exist. +static void bleio_le_param_updated_cb(struct bt_conn *conn, uint16_t interval, + uint16_t latency, uint16_t timeout) { + printk("bleio: param-updated %p interval %u (%u.%02u ms) latency %u timeout %u (%u ms)\n", + conn, interval, + (interval * 125u) / 100u, ((interval * 125u) % 100u), + latency, timeout, timeout * 10u); +} + static void bleio_disconnected_cb(struct bt_conn *conn, uint8_t reason) { - printk("disconnected %p\n", conn); + printk("disconnected %p reason 0x%02x\n", conn, reason); bleio_connection_release(bleio_connection_find_by_conn(conn), reason); + + // Only flag the work; see ble_advertising_resume_pending above for why the + // restart cannot happen here. + if (ble_advertising_intent && !ble_advertising) { + atomic_set(&ble_advertising_resume_pending, 1); + } +} + +// Called from port_background_task(), i.e. the CircuitPython thread. +void bleio_background(void) { + if (!atomic_cas(&ble_advertising_resume_pending, 1, 0)) { + return; + } + + if (!ble_advertising_intent || ble_advertising || !ble_adapter_enabled) { + return; + } + + int err = bt_le_adv_start(&retained_adv_params, + adv_data, + retained_adv_count, + retained_scan_resp_count > 0 ? scan_resp_data : NULL, + retained_scan_resp_count); + if (err) { + // Nothing to raise into: no Python frame is on the stack here. + printk("_bleio: failed to resume advertising after disconnect (%d)\n", err); + return; + } + + ble_advertising = true; } BT_CONN_CB_DEFINE(bleio_connection_callbacks) = { .connected = bleio_connected_cb, .disconnected = bleio_disconnected_cb, + .le_param_updated = bleio_le_param_updated_cb, }; static void scan_recv_cb(const struct bt_le_scan_recv_info *info, struct net_buf_simple *buf) { @@ -302,13 +427,17 @@ mp_int_t common_hal_bleio_adapter_get_tx_power(bleio_adapter_obj_t *self) { return power; } -void common_hal_bleio_adapter_set_tx_power(bleio_adapter_obj_t *self, mp_int_t tx_power) { +// Returns a Zephyr error code rather than raising, so callers can treat setting +// Tx power as best-effort. BT_HCI_OP_VS_WRITE_TX_POWER_LEVEL is a Zephyr +// vendor-specific command that not every controller implements; the SiWx91x +// answers "Unknown HCI Command" (status 0x01) and the call comes back -EIO. +static int _try_set_tx_power(mp_int_t tx_power) { struct bt_hci_cp_vs_write_tx_power_level *cp; struct net_buf *buf, *rsp = NULL; buf = bt_hci_cmd_alloc(K_MSEC(3000)); if (!buf) { - mp_raise_msg(&mp_type_MemoryError, NULL); + return -ENOMEM; } cp = net_buf_add(buf, sizeof(*cp)); cp->handle_type = BT_HCI_VS_LL_HANDLE_TYPE_ADV; @@ -317,10 +446,21 @@ void common_hal_bleio_adapter_set_tx_power(bleio_adapter_obj_t *self, mp_int_t t int err = bt_hci_cmd_send_sync(BT_HCI_OP_VS_WRITE_TX_POWER_LEVEL, buf, &rsp); if (err) { - raise_zephyr_error(err); + return err; } net_buf_unref(rsp); + return 0; +} + +void common_hal_bleio_adapter_set_tx_power(bleio_adapter_obj_t *self, mp_int_t tx_power) { + int err = _try_set_tx_power(tx_power); + if (err) { + if (err == -ENOMEM) { + mp_raise_msg(&mp_type_MemoryError, NULL); + } + raise_zephyr_error(err); + } } bleio_address_obj_t *common_hal_bleio_adapter_get_address(bleio_adapter_obj_t *self) { @@ -375,6 +515,11 @@ void common_hal_bleio_adapter_start_advertising(bleio_adapter_obj_t *self, raise_zephyr_error(-EALREADY); } + // Register any GATT services built up since the last (re)start. Deferred + // to here, rather than done eagerly in Service.c, so a service with + // several add_characteristic() calls registers once, fully formed. + bleio_adapter_register_pending_services(); + bt_addr_le_t id_addrs[CONFIG_BT_ID_MAX]; size_t id_count = CONFIG_BT_ID_MAX; bt_id_get(id_addrs, &id_count); @@ -428,7 +573,15 @@ void common_hal_bleio_adapter_start_advertising(bleio_adapter_obj_t *self, NULL); } - common_hal_bleio_adapter_set_tx_power(self, tx_power); + // Best-effort: a controller that does not implement Zephyr's vendor-specific + // Tx power command must still be able to advertise. Previously a failure + // here aborted start_advertising() entirely, which made advertising + // impossible on such controllers (e.g. the SiWx91x). + int tx_power_err = _try_set_tx_power(tx_power); + if (tx_power_err) { + printk("_bleio: controller rejected Tx power %d (%d); advertising anyway\n", + (int)tx_power, tx_power_err); + } raise_zephyr_error(bt_le_adv_start(&adv_params, adv_data, @@ -437,10 +590,27 @@ void common_hal_bleio_adapter_start_advertising(bleio_adapter_obj_t *self, scan_resp_count)); ble_advertising = true; + + // Retain everything the resume path needs. Only connectable advertising is + // resumed: the controller does not stop a non-connectable advertiser on + // connect, so there is nothing to restart. + retained_adv_params = adv_params; + retained_adv_count = adv_count; + retained_scan_resp_count = scan_resp_count; + ble_advertising_intent = connectable; } void common_hal_bleio_adapter_stop_advertising(bleio_adapter_obj_t *self) { (void)self; + + // Clear the intent *before* the early return. A stop issued while a + // connection is up finds ble_advertising already false (the controller + // stopped the advertiser on connect), so returning early here would leave + // the intent set and the next disconnect would silently re-advertise + // something the caller had explicitly stopped. + ble_advertising_intent = false; + atomic_set(&ble_advertising_resume_pending, 0); + if (!ble_advertising) { return; } @@ -633,6 +803,11 @@ bool common_hal_bleio_adapter_is_bonded_to_central(bleio_adapter_obj_t *self) { void bleio_adapter_gc_collect(bleio_adapter_obj_t *adapter) { gc_collect_root((void **)adapter, sizeof(bleio_adapter_obj_t) / sizeof(size_t)); gc_collect_root((void **)bleio_connections, sizeof(bleio_connections) / sizeof(size_t)); + // pending_services holds raw pointers into Service objects that Zephyr's + // attribute table also points into once registered; it must be rooted + // like bleio_connections above or a Service with no other Python + // reference could be collected while still registered. + gc_collect_root((void **)pending_services, sizeof(pending_services) / sizeof(size_t)); } void bleio_adapter_reset(bleio_adapter_obj_t *adapter) { @@ -641,6 +816,9 @@ void bleio_adapter_reset(bleio_adapter_obj_t *adapter) { } common_hal_bleio_adapter_stop_scan(adapter); + // Also clears ble_advertising_intent and any pending resume, so a VM reset + // cannot leave advertising restarting itself on behalf of code that is no + // longer running. common_hal_bleio_adapter_stop_advertising(adapter); for (size_t i = 0; i < BLEIO_TOTAL_CONNECTION_COUNT; i++) { @@ -657,6 +835,16 @@ void bleio_adapter_reset(bleio_adapter_obj_t *adapter) { bleio_connection_clear(connection); } + // A VM reset collects every Python object, including any Service whose + // attrs[] Zephyr's GATT DB still points into. Unregister first so the + // stack drops those pointers before the objects become garbage; simply + // forgetting the queue here would leave the DB referencing freed memory. + for (size_t i = 0; i < pending_service_count; i++) { + common_hal_bleio_service_deinit(pending_services[i]); + } + pending_service_count = 0; + memset(pending_services, 0, sizeof(pending_services)); + adapter->scan_results = NULL; adapter->connection_objs = NULL; active_scan_results = NULL; diff --git a/ports/zephyr-cp/common-hal/_bleio/Adapter.h b/ports/zephyr-cp/common-hal/_bleio/Adapter.h index c15c698e2a5..52ba929db8d 100644 --- a/ports/zephyr-cp/common-hal/_bleio/Adapter.h +++ b/ports/zephyr-cp/common-hal/_bleio/Adapter.h @@ -27,3 +27,17 @@ typedef struct { void bleio_adapter_gc_collect(bleio_adapter_obj_t *adapter); void bleio_adapter_reset(bleio_adapter_obj_t *adapter); + +// Deferred BLE work that must not run in Zephyr's BT thread. Called from +// port_background_task(). +void bleio_background(void); + +typedef struct bleio_service_obj bleio_service_obj_t; + +// Queues a Service for bt_gatt_service_register(), which is deferred until +// advertising starts (see common_hal_bleio_adapter_start_advertising()) so +// that a service built up with several add_characteristic() calls registers +// once, fully formed, rather than being re-registered after every call. +void bleio_adapter_add_pending_service(bleio_service_obj_t *self); + +bool bleio_adapter_any_connected(void); diff --git a/ports/zephyr-cp/common-hal/_bleio/Characteristic.c b/ports/zephyr-cp/common-hal/_bleio/Characteristic.c index 386be6004d2..3998fd2ddae 100644 --- a/ports/zephyr-cp/common-hal/_bleio/Characteristic.c +++ b/ports/zephyr-cp/common-hal/_bleio/Characteristic.c @@ -5,10 +5,18 @@ // // SPDX-License-Identifier: MIT +#include +#include + #include "py/runtime.h" #include "shared-bindings/_bleio/Characteristic.h" #include "shared-bindings/_bleio/Descriptor.h" #include "shared-bindings/_bleio/Service.h" +#include "shared-bindings/_bleio/CharacteristicBuffer.h" +#include "shared-bindings/_bleio/PacketBuffer.h" +#include "bindings/zephyr_kernel/__init__.h" + +#include bleio_characteristic_properties_t common_hal_bleio_characteristic_get_properties(bleio_characteristic_obj_t *self) { return self->props; @@ -31,15 +39,68 @@ size_t common_hal_bleio_characteristic_get_max_length(bleio_characteristic_obj_t } size_t common_hal_bleio_characteristic_get_value(bleio_characteristic_obj_t *self, uint8_t *buf, size_t len) { - mp_raise_NotImplementedError(NULL); + // Local GATT server value. Reading a REMOTE characteristic would need + // central-role discovery, which this port does not implement. + size_t n = self->current_value_len; + if (n > len) { + n = len; + } + if (self->current_value != NULL && n > 0) { + memcpy(buf, self->current_value, n); + } + return n; } void common_hal_bleio_characteristic_add_descriptor(bleio_characteristic_obj_t *self, bleio_descriptor_obj_t *descriptor) { + // Extra descriptors beyond the auto-generated CCCD are not supported: the + // attribute table is sized per characteristic in Service.h. mp_raise_NotImplementedError(NULL); } void common_hal_bleio_characteristic_construct(bleio_characteristic_obj_t *self, bleio_service_obj_t *service, uint16_t handle, bleio_uuid_obj_t *uuid, bleio_characteristic_properties_t props, bleio_attribute_security_mode_t read_perm, bleio_attribute_security_mode_t write_perm, mp_int_t max_length, bool fixed_length, mp_buffer_info_t *initial_value_bufinfo, const char *user_description) { - mp_raise_NotImplementedError(NULL); + if (max_length < 0) { + raise_zephyr_error(-EINVAL); + } + self->service = service; + self->uuid = uuid; + self->handle = handle; + self->props = props; + self->read_perm = read_perm; + self->write_perm = write_perm; + self->max_length = (uint16_t)max_length; + self->fixed_length = fixed_length; + self->observer = mp_const_none; + self->descriptor_list = mp_obj_new_list(0, NULL); + self->current_value = NULL; + self->current_value_len = 0; + self->current_value_alloc = 0; + self->value_attr = NULL; + self->cccd_attr = NULL; + memset(&self->chrc, 0, sizeof(self->chrc)); + + if (initial_value_bufinfo != NULL && initial_value_bufinfo->len > 0) { + if (initial_value_bufinfo->len > self->max_length) { + mp_raise_ValueError(MP_ERROR_TEXT("Value length != required fixed length")); + } + self->current_value = m_malloc(self->max_length); + self->current_value_alloc = self->max_length; + memcpy(self->current_value, initial_value_bufinfo->buf, initial_value_bufinfo->len); + self->current_value_len = initial_value_bufinfo->len; + } + + // Attach to the service's attribute table. EVERY other port does this from + // here (nordic Characteristic.c:108, espressif:110, silabs:145) -- omitting + // it is a SILENT WRONG SUCCESS: the characteristic object works standalone + // (set_value and readback both pass) but never appears in + // service.characteristics and gets no GATT attributes, so a connected + // central cannot see it. The symptom is `len(svc.characteristics) == 0` + // while every individual operation reports OK. + if (service->is_remote) { + self->handle = handle; + } else { + common_hal_bleio_service_add_characteristic(self->service, self, + initial_value_bufinfo, user_description); + } } bool common_hal_bleio_characteristic_deinited(bleio_characteristic_obj_t *self) { @@ -51,11 +112,40 @@ void common_hal_bleio_characteristic_deinit(bleio_characteristic_obj_t *self) { } void common_hal_bleio_characteristic_set_cccd(bleio_characteristic_obj_t *self, bool notify, bool indicate) { + // Writing a CCCD is a CENTRAL-role operation (subscribing to a remote + // peripheral). As a peripheral the CCCD is written by the connected + // central and handled by bt_gatt_attr_write_ccc. mp_raise_NotImplementedError(NULL); } void common_hal_bleio_characteristic_set_value(bleio_characteristic_obj_t *self, mp_buffer_info_t *bufinfo) { - mp_raise_NotImplementedError(NULL); + if (self->fixed_length && bufinfo->len != self->max_length) { + mp_raise_ValueError(MP_ERROR_TEXT("Value length != required fixed length")); + } + if (bufinfo->len > self->max_length) { + mp_raise_ValueError(MP_ERROR_TEXT("Value length > max_length")); + } + if (self->current_value == NULL) { + self->current_value = m_malloc(self->max_length); + self->current_value_alloc = self->max_length; + } + memcpy(self->current_value, bufinfo->buf, bufinfo->len); + self->current_value_len = bufinfo->len; + + // Push to any subscribed central. With no connection or no subscriber the + // stack legitimately refuses: -ENOTCONN (no link), -EINVAL (attr not yet + // registered) and -ENOENT (registered, but nobody has written the CCCD to + // subscribe) are all NORMAL states for a peripheral sitting idle, not + // errors to raise. Before the characteristic was attached to the service + // this path returned -EINVAL; attaching it made -ENOENT the common case. + if (self->value_attr != NULL && + (self->props & (CHAR_PROP_NOTIFY | CHAR_PROP_INDICATE))) { + int err = bt_gatt_notify(NULL, self->value_attr, + self->current_value, self->current_value_len); + if (err != 0 && err != -ENOTCONN && err != -EINVAL && err != -ENOENT) { + raise_zephyr_error(err); + } + } } void bleio_characteristic_set_observer(bleio_characteristic_obj_t *self, mp_obj_t observer) { @@ -65,3 +155,60 @@ void bleio_characteristic_set_observer(bleio_characteristic_obj_t *self, mp_obj_ void bleio_characteristic_clear_observer(bleio_characteristic_obj_t *self) { self->observer = mp_const_none; } + +// --- Zephyr ATT callbacks --------------------------------------------------- +// These run in the Bluetooth RX thread, NOT on the CircuitPython VM thread, so +// they must not allocate on the GC heap or raise MicroPython exceptions. + +ssize_t bleio_characteristic_attr_read(struct bt_conn *conn, + const struct bt_gatt_attr *attr, void *buf, uint16_t len, uint16_t offset) { + bleio_characteristic_obj_t *self = attr->user_data; + printk("bleio: ATT read handle %u offset %u len %u\n", attr->handle, offset, len); + if (self->current_value == NULL) { + return bt_gatt_attr_read(conn, attr, buf, len, offset, NULL, 0); + } + return bt_gatt_attr_read(conn, attr, buf, len, offset, + self->current_value, self->current_value_len); +} + +ssize_t bleio_characteristic_attr_write(struct bt_conn *conn, + const struct bt_gatt_attr *attr, const void *buf, uint16_t len, + uint16_t offset, uint8_t flags) { + (void)conn; + (void)flags; + bleio_characteristic_obj_t *self = attr->user_data; + printk("bleio: ATT write handle %u offset %u len %u\n", attr->handle, offset, len); + + if (offset + len > self->max_length) { + return BT_GATT_ERR(BT_ATT_ERR_INVALID_ATTRIBUTE_LEN); + } + if (self->current_value == NULL) { + return BT_GATT_ERR(BT_ATT_ERR_WRITE_NOT_PERMITTED); + } + memcpy(self->current_value + offset, buf, len); + if (offset + len > self->current_value_len) { + self->current_value_len = offset + len; + } + + // Feed an attached CharacteristicBuffer/PacketBuffer observer so Python + // code can see incoming writes. The observer is a plain C-side struct + // pointer; no allocation happens here. + if (self->observer != mp_const_none && self->observer != MP_OBJ_NULL) { + const mp_obj_type_t *t = mp_obj_get_type(self->observer); + if (t == &bleio_characteristic_buffer_type) { + bleio_characteristic_buffer_extend( + MP_OBJ_TO_PTR(self->observer), buf, len); + } else if (t == &bleio_packet_buffer_type) { + bleio_packet_buffer_extend( + MP_OBJ_TO_PTR(self->observer), conn, buf, len); + } + } + return len; +} + +void bleio_characteristic_ccc_changed(const struct bt_gatt_attr *attr, uint16_t value) { + (void)attr; + (void)value; + // Subscription state is tracked by Zephyr; bt_gatt_notify(NULL, ...) is a + // no-op when nobody is subscribed, so nothing is needed here. +} diff --git a/ports/zephyr-cp/common-hal/_bleio/Characteristic.h b/ports/zephyr-cp/common-hal/_bleio/Characteristic.h index b710a9f2662..e0a18f33f8c 100644 --- a/ports/zephyr-cp/common-hal/_bleio/Characteristic.h +++ b/ports/zephyr-cp/common-hal/_bleio/Characteristic.h @@ -15,6 +15,8 @@ #include "common-hal/_bleio/Service.h" #include "common-hal/_bleio/UUID.h" +#include + typedef struct _bleio_characteristic_obj { mp_obj_base_t base; bleio_service_obj_t *service; @@ -34,7 +36,21 @@ typedef struct _bleio_characteristic_obj { uint16_t cccd_handle; uint16_t sccd_handle; bool fixed_length; + + // Zephyr GATT state. bt_gatt_attr entries hold POINTERS to chrc and to + // this object, so both must outlive registration -- hence stored inline. + struct bt_gatt_chrc chrc; + struct bt_gatt_attr *value_attr; + struct bt_gatt_attr *cccd_attr; } bleio_characteristic_obj_t; void bleio_characteristic_set_observer(bleio_characteristic_obj_t *self, mp_obj_t observer); void bleio_characteristic_clear_observer(bleio_characteristic_obj_t *self); + +// Zephyr ATT callbacks, installed into the attribute table by Service.c. +ssize_t bleio_characteristic_attr_read(struct bt_conn *conn, + const struct bt_gatt_attr *attr, void *buf, uint16_t len, uint16_t offset); +ssize_t bleio_characteristic_attr_write(struct bt_conn *conn, + const struct bt_gatt_attr *attr, const void *buf, uint16_t len, + uint16_t offset, uint8_t flags); +void bleio_characteristic_ccc_changed(const struct bt_gatt_attr *attr, uint16_t value); diff --git a/ports/zephyr-cp/common-hal/_bleio/CharacteristicBuffer.c b/ports/zephyr-cp/common-hal/_bleio/CharacteristicBuffer.c index 17e000e905e..6fb6e8d6ab1 100644 --- a/ports/zephyr-cp/common-hal/_bleio/CharacteristicBuffer.c +++ b/ports/zephyr-cp/common-hal/_bleio/CharacteristicBuffer.c @@ -5,9 +5,28 @@ // // SPDX-License-Identifier: MIT +#include + #include "py/mperrno.h" #include "py/runtime.h" +#include "py/stream.h" #include "shared-bindings/_bleio/CharacteristicBuffer.h" +#include "shared/runtime/interrupt_char.h" +#include "supervisor/shared/tick.h" + +#include + +// The ringbuf is filled from the Bluetooth RX thread and drained from the VM +// thread, so every access is wrapped in an irq lock. Zephyr's k_sched_lock is +// not enough: the BT RX thread can be on another priority and the ringbuf +// head/tail updates are not atomic. +static inline unsigned int buf_lock(void) { + return irq_lock(); +} + +static inline void buf_unlock(unsigned int key) { + irq_unlock(key); +} void _common_hal_bleio_characteristic_buffer_construct(bleio_characteristic_buffer_obj_t *self, bleio_characteristic_obj_t *characteristic, @@ -15,45 +34,112 @@ void _common_hal_bleio_characteristic_buffer_construct(bleio_characteristic_buff uint8_t *buffer, size_t buffer_size, void *static_handler_entry, bool watch_for_interrupt_char) { - (void)self; - (void)characteristic; - (void)timeout; - (void)buffer; - (void)buffer_size; (void)static_handler_entry; - (void)watch_for_interrupt_char; - mp_raise_NotImplementedError(NULL); + + self->characteristic = characteristic; + self->timeout = timeout; + self->watch_for_interrupt_char = watch_for_interrupt_char; + self->deinited = false; + ringbuf_init(&self->ringbuf, buffer, buffer_size); + + // Route incoming ATT writes for this characteristic to this buffer. + bleio_characteristic_set_observer(characteristic, MP_OBJ_FROM_PTR(self)); } void common_hal_bleio_characteristic_buffer_construct(bleio_characteristic_buffer_obj_t *self, bleio_characteristic_obj_t *characteristic, mp_float_t timeout, size_t buffer_size) { - (void)self; - (void)characteristic; - (void)timeout; - (void)buffer_size; - mp_raise_NotImplementedError(NULL); + uint8_t *buffer = m_malloc(buffer_size); + _common_hal_bleio_characteristic_buffer_construct(self, characteristic, + timeout, buffer, buffer_size, NULL, false); +} + +void bleio_characteristic_buffer_extend(bleio_characteristic_buffer_obj_t *self, + const void *buf, uint16_t len) { + if (self == NULL || self->deinited) { + return; + } + const uint8_t *bytes = buf; + unsigned int key = buf_lock(); + for (uint16_t i = 0; i < len; i++) { + if (self->watch_for_interrupt_char && bytes[i] == mp_interrupt_char) { + // Deliver the KeyboardInterrupt via the normal supervisor path + // rather than buffering the character. + buf_unlock(key); + mp_sched_keyboard_interrupt(); + key = buf_lock(); + continue; + } + // ringbuf_put returns -1 when full: oldest-wins would corrupt framing, + // so drop the newest byte instead, matching other ports. + if (ringbuf_put(&self->ringbuf, bytes[i]) < 0) { + break; + } + } + buf_unlock(key); } uint32_t common_hal_bleio_characteristic_buffer_read(bleio_characteristic_buffer_obj_t *self, uint8_t *data, size_t len, int *errcode) { - (void)self; - (void)data; - (void)len; - if (errcode != NULL) { - *errcode = MP_EAGAIN; + if (self->deinited) { + if (errcode != NULL) { + *errcode = MP_EINVAL; + } + return 0; + } + + uint64_t start_ticks = supervisor_ticks_ms64(); + uint32_t timeout_ms = (uint32_t)(self->timeout * 1000.0f); + + // Block until at least one byte is available or the timeout expires. A + // zero timeout means non-blocking. + while (true) { + unsigned int key = buf_lock(); + int avail = ringbuf_num_filled(&self->ringbuf); + buf_unlock(key); + if (avail > 0) { + break; + } + if (timeout_ms == 0 || + (supervisor_ticks_ms64() - start_ticks) >= timeout_ms) { + return 0; + } + RUN_BACKGROUND_TASKS; + if (mp_hal_is_interrupted()) { + return 0; + } } - mp_raise_NotImplementedError(NULL); + + uint32_t n = 0; + unsigned int key = buf_lock(); + while (n < len) { + int b = ringbuf_get(&self->ringbuf); + if (b < 0) { + break; + } + data[n++] = (uint8_t)b; + } + buf_unlock(key); + return n; } uint32_t common_hal_bleio_characteristic_buffer_rx_characters_available(bleio_characteristic_buffer_obj_t *self) { - (void)self; - mp_raise_NotImplementedError(NULL); + if (self->deinited) { + return 0; + } + unsigned int key = buf_lock(); + int n = ringbuf_num_filled(&self->ringbuf); + buf_unlock(key); + return n < 0 ? 0 : (uint32_t)n; } void common_hal_bleio_characteristic_buffer_clear_rx_buffer(bleio_characteristic_buffer_obj_t *self) { - (void)self; - mp_raise_NotImplementedError(NULL); + if (self->deinited) { + return; + } + unsigned int key = buf_lock(); + ringbuf_clear(&self->ringbuf); + buf_unlock(key); } bool common_hal_bleio_characteristic_buffer_deinited(bleio_characteristic_buffer_obj_t *self) { @@ -61,13 +147,18 @@ bool common_hal_bleio_characteristic_buffer_deinited(bleio_characteristic_buffer } void common_hal_bleio_characteristic_buffer_deinit(bleio_characteristic_buffer_obj_t *self) { - if (self == NULL) { + if (self == NULL || self->deinited) { return; } + if (self->characteristic != NULL) { + bleio_characteristic_clear_observer(self->characteristic); + } self->deinited = true; } bool common_hal_bleio_characteristic_buffer_connected(bleio_characteristic_buffer_obj_t *self) { - (void)self; - return false; + // A GATT server characteristic is writable only while a central is + // connected, so reuse the adapter's connection state. + extern bool bleio_adapter_any_connected(void); + return !self->deinited && bleio_adapter_any_connected(); } diff --git a/ports/zephyr-cp/common-hal/_bleio/CharacteristicBuffer.h b/ports/zephyr-cp/common-hal/_bleio/CharacteristicBuffer.h index 91ea262945a..513605dcb03 100644 --- a/ports/zephyr-cp/common-hal/_bleio/CharacteristicBuffer.h +++ b/ports/zephyr-cp/common-hal/_bleio/CharacteristicBuffer.h @@ -10,10 +10,19 @@ #include #include "py/obj.h" +#include "py/ringbuf.h" #include "shared-bindings/_bleio/Characteristic.h" typedef struct { mp_obj_base_t base; bleio_characteristic_obj_t *characteristic; + mp_float_t timeout; + // Written from the Bluetooth RX thread, drained from the VM thread. + ringbuf_t ringbuf; + bool watch_for_interrupt_char; bool deinited; } bleio_characteristic_buffer_obj_t; + +// Called from the ATT write callback (Bluetooth RX thread context). +void bleio_characteristic_buffer_extend(bleio_characteristic_buffer_obj_t *self, + const void *buf, uint16_t len); diff --git a/ports/zephyr-cp/common-hal/_bleio/PacketBuffer.c b/ports/zephyr-cp/common-hal/_bleio/PacketBuffer.c index 82fe8a3d176..cd327e41997 100644 --- a/ports/zephyr-cp/common-hal/_bleio/PacketBuffer.c +++ b/ports/zephyr-cp/common-hal/_bleio/PacketBuffer.c @@ -5,48 +5,165 @@ // // SPDX-License-Identifier: MIT +#include + +#include "py/mperrno.h" #include "py/runtime.h" #include "shared-bindings/_bleio/PacketBuffer.h" +#include "shared-bindings/_bleio/Characteristic.h" + +#include +#include + +// Packets are stored length-prefixed (2-byte little-endian length followed by +// the payload) so that packet boundaries survive the ring buffer. Reading a +// PacketBuffer must return exactly one packet per call. + +static inline unsigned int buf_lock(void) { + return irq_lock(); +} + +static inline void buf_unlock(unsigned int key) { + irq_unlock(key); +} + +void _common_hal_bleio_packet_buffer_construct( + bleio_packet_buffer_obj_t *self, bleio_characteristic_obj_t *characteristic, + uint32_t *incoming_buffer, size_t incoming_buffer_size, + uint32_t *outgoing_buffer1, uint32_t *outgoing_buffer2, size_t max_packet_size, + ble_event_handler_t *static_handler_entry) { + (void)outgoing_buffer1; + (void)outgoing_buffer2; + (void)static_handler_entry; + + self->characteristic = characteristic; + self->max_packet_size = (uint16_t)max_packet_size; + self->deinited = false; + ringbuf_init(&self->incoming, (uint8_t *)incoming_buffer, incoming_buffer_size); + + bleio_characteristic_set_observer(characteristic, MP_OBJ_FROM_PTR(self)); +} void common_hal_bleio_packet_buffer_construct( bleio_packet_buffer_obj_t *self, bleio_characteristic_obj_t *characteristic, size_t buffer_size, size_t max_packet_size) { - (void)self; - (void)characteristic; - (void)buffer_size; - (void)max_packet_size; - mp_raise_NotImplementedError(NULL); + if (max_packet_size == 0) { + max_packet_size = characteristic->max_length; + } + // Room for buffer_size packets of max_packet_size plus their 2-byte length + // prefixes. + size_t bytes = (max_packet_size + 2) * (buffer_size ? buffer_size : 1); + uint8_t *storage = m_malloc(bytes); + _common_hal_bleio_packet_buffer_construct(self, characteristic, + (uint32_t *)storage, bytes, NULL, NULL, max_packet_size, NULL); +} + +void bleio_packet_buffer_extend(bleio_packet_buffer_obj_t *self, + struct bt_conn *conn, const void *buf, uint16_t len) { + (void)conn; + if (self == NULL || self->deinited || len == 0) { + return; + } + const uint8_t *bytes = buf; + unsigned int key = buf_lock(); + // Drop the whole packet if it does not fit: a partial packet would + // desynchronise every subsequent read. + if (ringbuf_num_empty(&self->incoming) >= (int)len + 2) { + ringbuf_put(&self->incoming, len & 0xff); + ringbuf_put(&self->incoming, (len >> 8) & 0xff); + for (uint16_t i = 0; i < len; i++) { + ringbuf_put(&self->incoming, bytes[i]); + } + } + buf_unlock(key); } mp_int_t common_hal_bleio_packet_buffer_write(bleio_packet_buffer_obj_t *self, const uint8_t *data, size_t len, uint8_t *header, size_t header_len) { - (void)self; - (void)data; - (void)len; - (void)header; - (void)header_len; - mp_raise_NotImplementedError(NULL); + if (self->deinited || self->characteristic == NULL) { + return -MP_EINVAL; + } + if (len + header_len > self->max_packet_size) { + return -MP_EINVAL; + } + + uint8_t packet[BLEIO_PACKET_BUFFER_MAX_PACKET_SIZE]; + size_t total = 0; + if (header_len > 0 && header != NULL) { + memcpy(packet, header, header_len); + total += header_len; + } + memcpy(packet + total, data, len); + total += len; + + bleio_characteristic_obj_t *chr = self->characteristic; + if (chr->value_attr == NULL) { + return -MP_ENOTCONN; + } + // Notify rather than storing: PacketBuffer is a stream abstraction, so each + // write is an outgoing packet to the subscribed central. + // + // NOTE (adapted from the reference, not independently verified): this + // treats -ENOENT as a hard error (falls through to -MP_EIO below) while + // Characteristic.c's set_value treats -ENOENT as a normal idle state + // alongside -ENOTCONN/-EINVAL. Neither path has been exercised by a real + // subscriber, so which behavior is actually correct is unresolved -- see + // PR discussion. Left as-is pending a real notify test. + int err = bt_gatt_notify(NULL, chr->value_attr, packet, total); + if (err == -ENOTCONN || err == -EINVAL) { + return -MP_ENOTCONN; + } + if (err != 0) { + return -MP_EIO; + } + return (mp_int_t)total; } mp_int_t common_hal_bleio_packet_buffer_readinto(bleio_packet_buffer_obj_t *self, uint8_t *data, size_t len) { - (void)self; - (void)data; - (void)len; - mp_raise_NotImplementedError(NULL); + if (self->deinited) { + return -MP_EINVAL; + } + unsigned int key = buf_lock(); + if (ringbuf_num_filled(&self->incoming) < 2) { + buf_unlock(key); + return 0; + } + int lo = ringbuf_get(&self->incoming); + int hi = ringbuf_get(&self->incoming); + uint16_t plen = (uint16_t)((hi << 8) | lo); + if (plen > len) { + // Caller's buffer is too small. Consume the packet anyway so the + // stream stays framed, and report the required size. + for (uint16_t i = 0; i < plen; i++) { + ringbuf_get(&self->incoming); + } + buf_unlock(key); + return -MP_EINVAL; + } + for (uint16_t i = 0; i < plen; i++) { + int b = ringbuf_get(&self->incoming); + data[i] = (uint8_t)(b < 0 ? 0 : b); + } + buf_unlock(key); + return (mp_int_t)plen; } mp_int_t common_hal_bleio_packet_buffer_get_incoming_packet_length(bleio_packet_buffer_obj_t *self) { - (void)self; - mp_raise_NotImplementedError(NULL); + if (self->deinited) { + return -1; + } + return self->max_packet_size; } mp_int_t common_hal_bleio_packet_buffer_get_outgoing_packet_length(bleio_packet_buffer_obj_t *self) { - (void)self; - mp_raise_NotImplementedError(NULL); + if (self->deinited) { + return -1; + } + return self->max_packet_size; } void common_hal_bleio_packet_buffer_flush(bleio_packet_buffer_obj_t *self) { - (void)self; - mp_raise_NotImplementedError(NULL); + // Writes go out synchronously via bt_gatt_notify, so there is nothing + // queued to flush. } bool common_hal_bleio_packet_buffer_deinited(bleio_packet_buffer_obj_t *self) { @@ -54,13 +171,16 @@ bool common_hal_bleio_packet_buffer_deinited(bleio_packet_buffer_obj_t *self) { } void common_hal_bleio_packet_buffer_deinit(bleio_packet_buffer_obj_t *self) { - if (self == NULL) { + if (self == NULL || self->deinited) { return; } + if (self->characteristic != NULL) { + bleio_characteristic_clear_observer(self->characteristic); + } self->deinited = true; } bool common_hal_bleio_packet_buffer_connected(bleio_packet_buffer_obj_t *self) { - (void)self; - return false; + extern bool bleio_adapter_any_connected(void); + return !self->deinited && bleio_adapter_any_connected(); } diff --git a/ports/zephyr-cp/common-hal/_bleio/PacketBuffer.h b/ports/zephyr-cp/common-hal/_bleio/PacketBuffer.h index c8cd763fd61..c513f32e856 100644 --- a/ports/zephyr-cp/common-hal/_bleio/PacketBuffer.h +++ b/ports/zephyr-cp/common-hal/_bleio/PacketBuffer.h @@ -10,6 +10,7 @@ #include #include "py/obj.h" +#include "py/ringbuf.h" typedef struct _bleio_characteristic_obj bleio_characteristic_obj_t; @@ -17,5 +18,17 @@ typedef void *ble_event_handler_t; typedef struct { mp_obj_base_t base; + bleio_characteristic_obj_t *characteristic; + // Incoming packets are length-prefixed in this ringbuf so packet + // boundaries survive: a plain byte stream would lose framing, which is the + // whole point of PacketBuffer over CharacteristicBuffer. + ringbuf_t incoming; + uint16_t max_packet_size; bool deinited; } bleio_packet_buffer_obj_t; + +struct bt_conn; + +// Called from the ATT write callback (Bluetooth RX thread context). +void bleio_packet_buffer_extend(bleio_packet_buffer_obj_t *self, + struct bt_conn *conn, const void *buf, uint16_t len); diff --git a/ports/zephyr-cp/common-hal/_bleio/Service.c b/ports/zephyr-cp/common-hal/_bleio/Service.c index cefc85b6df6..001db5c6dd1 100644 --- a/ports/zephyr-cp/common-hal/_bleio/Service.c +++ b/ports/zephyr-cp/common-hal/_bleio/Service.c @@ -5,23 +5,112 @@ // // SPDX-License-Identifier: MIT +#include +#include + #include "py/runtime.h" #include "shared-bindings/_bleio/Service.h" #include "shared-bindings/_bleio/Characteristic.h" +#include "bindings/zephyr_kernel/__init__.h" +#include "common-hal/_bleio/Adapter.h" + +#include +#include + +// PERSISTENT declaration UUIDs. +// +// BT_UUID_GATT_PRIMARY / _SECONDARY / _CHRC / _CCC expand to +// BT_UUID_DECLARE_16, which is a COMPOUND LITERAL: +// +// #define BT_UUID_DECLARE_16(value) \ +// ((const struct bt_uuid *) ((const struct bt_uuid_16[]) {BT_UUID_INIT_16(value)})) +// +// At FILE scope a compound literal has static storage duration; inside a +// function it has AUTOMATIC storage and dies with the stack frame. Zephyr's +// own BT_GATT_SERVICE_DEFINE only uses these macros at file scope, so the +// hazard never shows up upstream. Storing one into a heap-allocated +// bt_gatt_attr from inside a function leaves the attribute table pointing at +// dead stack: bt_gatt_service_register() still returns 0 because it validates +// the pointers without dereferencing them, then every later ATT request reads +// garbage. Observed bt_uuid->type values of 1, 158, 158 where only 0/1/2 are +// legal -- and one entry intact by luck, which makes it look intermittent. +// +// No compiler diagnostic catches this: -Wdangling-pointer=2, +// -Wreturn-local-addr, -Wuse-after-free=3 and -fanalyzer are all silent, +// because the pointer is stored THROUGH a pointer into a struct field rather +// than returned. +// +// Upstream's convention for a UUID that must outlive its block is the INIT +// form with static storage (subsys/bluetooth/host/gatt.c:5363, +// audio/csip_set_coordinator.c:92, audio/mcc.c:60). Match it. +static const struct bt_uuid_16 bleio_uuid_primary = BT_UUID_INIT_16(BT_UUID_GATT_PRIMARY_VAL); +static const struct bt_uuid_16 bleio_uuid_secondary = BT_UUID_INIT_16(BT_UUID_GATT_SECONDARY_VAL); +static const struct bt_uuid_16 bleio_uuid_chrc = BT_UUID_INIT_16(BT_UUID_GATT_CHRC_VAL); +static const struct bt_uuid_16 bleio_uuid_ccc = BT_UUID_INIT_16(BT_UUID_GATT_CCC_VAL); + +// Zephyr's GATT server is table driven: a service is an array of bt_gatt_attr +// registered with bt_gatt_service_register(). Nothing in this port previously +// referenced bt_gatt_service_register or BT_GATT_SERVICE_DEFINE at all -- the +// GATT server simply did not exist -- so this file builds the table at runtime +// as characteristics are added from Python. +// +// BT_GATT_SERVICE_DEFINE is deliberately NOT used: it is a static-initialiser +// macro that requires the whole service to be known at compile time, and +// CircuitPython builds services dynamically. uint32_t _common_hal_bleio_service_construct(bleio_service_obj_t *self, bleio_uuid_obj_t *uuid, bool is_secondary, mp_obj_list_t *characteristic_list) { - mp_raise_NotImplementedError(NULL); + self->uuid = uuid; + // The INTERNAL constructor takes the list as a parameter (the public one + // allocates it and delegates here). The BLE workflow uses this path, and a + // NULL list here is a latent HARD FAULT rather than an error: both + // get_characteristics() and add_characteristic() dereference + // characteristic_list->len unconditionally. Allocate a fallback so the + // workflow path cannot crash the board. + if (characteristic_list == NULL) { + characteristic_list = mp_obj_new_list(0, NULL); + } + self->characteristic_list = characteristic_list; + self->is_remote = false; + self->is_secondary = is_secondary; + self->connection = MP_OBJ_NULL; + self->start_handle = 0; + self->end_handle = 0; + self->attr_count = 0; + self->registered = false; + memset(self->attrs, 0, sizeof(self->attrs)); + memset(self->cccs, 0, sizeof(self->cccs)); + + // Slot 0 is the primary/secondary service declaration. Its user_data is + // the service UUID, which must live in the UUID object (not the stack). + const struct bt_uuid *svc_uuid = bleio_uuid_as_bt_uuid(uuid); + self->attrs[0].uuid = is_secondary ? &bleio_uuid_secondary.uuid : &bleio_uuid_primary.uuid; + self->attrs[0].perm = BT_GATT_PERM_READ; + self->attrs[0].read = bt_gatt_attr_read_service; + self->attrs[0].user_data = (void *)svc_uuid; + self->attr_count = 1; + + // Queue for registration when advertising starts. + bleio_adapter_add_pending_service(self); + + return 0; } void common_hal_bleio_service_construct(bleio_service_obj_t *self, bleio_uuid_obj_t *uuid, bool is_secondary) { - mp_raise_NotImplementedError(NULL); + _common_hal_bleio_service_construct(self, uuid, is_secondary, + mp_obj_new_list(0, NULL)); } void common_hal_bleio_service_deinit(bleio_service_obj_t *self) { - // Nothing to do + if (self->registered) { + bt_gatt_service_unregister(&self->registration); + self->registered = false; + } } void common_hal_bleio_service_from_remote_service(bleio_service_obj_t *self, bleio_connection_obj_t *connection, bleio_uuid_obj_t *uuid, bool is_secondary) { + // Central-role service discovery is not implemented; only the peripheral + // GATT server is. Raising here is accurate rather than silently returning + // an empty service. mp_raise_NotImplementedError(NULL); } @@ -41,6 +130,108 @@ bool common_hal_bleio_service_get_is_secondary(bleio_service_obj_t *self) { return self->is_secondary; } +void bleio_service_register_if_needed(bleio_service_obj_t *self) { + if (self->registered || self->attr_count == 0) { + return; + } + self->registration.attrs = self->attrs; + self->registration.attr_count = self->attr_count; + int err = bt_gatt_service_register(&self->registration); + if (err != 0) { + raise_zephyr_error(err); + } + self->registered = true; + // Handles are assigned by the stack during registration. + self->start_handle = self->attrs[0].handle; + self->end_handle = self->attrs[self->attr_count - 1].handle; + printk("bleio: service registered, %u attrs, handles %u-%u\n", + (unsigned)self->attr_count, self->start_handle, self->end_handle); +} + void common_hal_bleio_service_add_characteristic(bleio_service_obj_t *self, bleio_characteristic_obj_t *characteristic, mp_buffer_info_t *initial_value_bufinfo, const char *user_description) { - mp_raise_NotImplementedError(NULL); + (void)user_description; + + if (self->registered) { + // Zephyr assigns handles at registration; the table cannot grow after. + raise_zephyr_error(-EALREADY); + } + + size_t chr_index = self->characteristic_list->len; + if (chr_index >= BLEIO_SERVICE_MAX_CHARACTERISTICS || + self->attr_count + 3 > BLEIO_SERVICE_MAX_ATTRS) { + raise_zephyr_error(-ENOSPC); + } + + const struct bt_uuid *chr_uuid = bleio_uuid_as_bt_uuid(characteristic->uuid); + + // Translate CircuitPython properties to Zephyr's characteristic props and + // attribute permissions. + uint8_t props = 0; + uint8_t perm = 0; + bleio_characteristic_properties_t p = characteristic->props; + if (p & CHAR_PROP_READ) { + props |= BT_GATT_CHRC_READ; + perm |= BT_GATT_PERM_READ; + } + if (p & CHAR_PROP_WRITE) { + props |= BT_GATT_CHRC_WRITE; + perm |= BT_GATT_PERM_WRITE; + } + if (p & CHAR_PROP_WRITE_NO_RESPONSE) { + props |= BT_GATT_CHRC_WRITE_WITHOUT_RESP; + perm |= BT_GATT_PERM_WRITE; + } + if (p & CHAR_PROP_NOTIFY) { + props |= BT_GATT_CHRC_NOTIFY; + } + if (p & CHAR_PROP_INDICATE) { + props |= BT_GATT_CHRC_INDICATE; + } + + // The value buffer is already allocated and seeded by + // common_hal_bleio_characteristic_construct, which is the caller. Doing it + // again here would leak the first allocation and reset current_value_len. + if (characteristic->current_value == NULL) { + characteristic->current_value = m_malloc(characteristic->max_length); + characteristic->current_value_alloc = characteristic->max_length; + characteristic->current_value_len = 0; + } + + // The bt_gatt_chrc user_data for the declaration attribute must persist, + // so it is stored inside the characteristic object. + characteristic->chrc.uuid = chr_uuid; + characteristic->chrc.properties = props; + characteristic->chrc.value_handle = 0; + + // Attribute 1: characteristic declaration. + struct bt_gatt_attr *decl = &self->attrs[self->attr_count++]; + decl->uuid = &bleio_uuid_chrc.uuid; + decl->perm = BT_GATT_PERM_READ; + decl->read = bt_gatt_attr_read_chrc; + decl->user_data = &characteristic->chrc; + + // Attribute 2: the value itself, served by our own read/write callbacks. + struct bt_gatt_attr *value = &self->attrs[self->attr_count++]; + value->uuid = chr_uuid; + value->perm = perm; + value->read = bleio_characteristic_attr_read; + value->write = bleio_characteristic_attr_write; + value->user_data = characteristic; + + // Attribute 3: CCCD, only when the characteristic can notify or indicate. + if (props & (BT_GATT_CHRC_NOTIFY | BT_GATT_CHRC_INDICATE)) { + struct _bt_gatt_ccc *ccc = &self->cccs[chr_index]; + ccc->cfg_changed = bleio_characteristic_ccc_changed; + struct bt_gatt_attr *cccd = &self->attrs[self->attr_count++]; + cccd->uuid = &bleio_uuid_ccc.uuid; + cccd->perm = BT_GATT_PERM_READ | BT_GATT_PERM_WRITE; + cccd->read = bt_gatt_attr_read_ccc; + cccd->write = bt_gatt_attr_write_ccc; + cccd->user_data = ccc; + characteristic->cccd_attr = cccd; + } + + characteristic->service = self; + characteristic->value_attr = value; + mp_obj_list_append(self->characteristic_list, MP_OBJ_FROM_PTR(characteristic)); } diff --git a/ports/zephyr-cp/common-hal/_bleio/Service.h b/ports/zephyr-cp/common-hal/_bleio/Service.h index 86727d3b0f7..747b76578f2 100644 --- a/ports/zephyr-cp/common-hal/_bleio/Service.h +++ b/ports/zephyr-cp/common-hal/_bleio/Service.h @@ -11,6 +11,14 @@ #include "py/objlist.h" #include "common-hal/_bleio/UUID.h" +#include + +// Attribute-table budget for ONE service. Each characteristic costs 3 slots in +// the worst case (declaration + value + CCCD), plus 1 for the primary service +// declaration itself. +#define BLEIO_SERVICE_MAX_CHARACTERISTICS 8 +#define BLEIO_SERVICE_MAX_ATTRS (1 + (BLEIO_SERVICE_MAX_CHARACTERISTICS * 3)) + typedef struct bleio_service_obj { mp_obj_base_t base; bleio_uuid_obj_t *uuid; @@ -20,4 +28,18 @@ typedef struct bleio_service_obj { uint16_t end_handle; bool is_remote; bool is_secondary; + + // Zephyr GATT server state. The attr array and the _ccc storage must + // remain valid for as long as the service is registered -- Zephyr keeps + // pointers into both -- so they are embedded here rather than allocated + // per registration. + struct bt_gatt_attr attrs[BLEIO_SERVICE_MAX_ATTRS]; + struct _bt_gatt_ccc cccs[BLEIO_SERVICE_MAX_CHARACTERISTICS]; + struct bt_gatt_service registration; + uint16_t attr_count; + bool registered; } bleio_service_obj_t; + +// Register (or re-register) the service's attribute table with Zephyr. +// Called when advertising starts, once all characteristics have been added. +void bleio_service_register_if_needed(bleio_service_obj_t *self); diff --git a/ports/zephyr-cp/common-hal/_bleio/UUID.c b/ports/zephyr-cp/common-hal/_bleio/UUID.c index 916eedb2c47..0cc7946ede2 100644 --- a/ports/zephyr-cp/common-hal/_bleio/UUID.c +++ b/ports/zephyr-cp/common-hal/_bleio/UUID.c @@ -11,19 +11,40 @@ #include "shared-bindings/_bleio/UUID.h" void common_hal_bleio_uuid_construct(bleio_uuid_obj_t *self, mp_int_t uuid16, const uint8_t uuid128[16]) { - if (uuid16 != 0) { - // 16-bit UUID + // DISCRIMINATE ON uuid128 == NULL, NOT ON uuid16 != 0. + // + // shared-bindings/_bleio/UUID.c:89 ALWAYS extracts + // uuid16 = (uuid128[13] << 8) | uuid128[12] before calling here, then + // zeroes those two bytes and passes the full 128-bit array as well. So for + // a 128-bit UUID like 0000fa00-1212-efde-1523-785fef13d123, uuid16 is a + // NONZERO 0xfa00 -- branching on it silently flattens every 128-bit UUID + // into a 16-bit Bluetooth-SIG one. + // + // That registers without error (bt_gatt_service_register returns 0: the + // entries were ACCEPTED, not verified to describe what was meant), and the + // resulting attribute table advertises a SIG-assigned short UUID the + // central never asked for. A strict central -- macOS is strict -- finds no + // matching service, gives up, and terminates the link with + // 0x13 REMOTE_USER_TERM. + // + // nordic gets this right at ports/nordic/common-hal/_bleio/UUID.c:26, + // which keys off uuid128 == NULL. Match that. + if (uuid128 == NULL) { + // 16-bit UUID: expand against the Bluetooth Base UUID + // 00000000-0000-1000-8000-00805F9B34FB. self->size = 16; - // Convert 16-bit UUID to 128-bit - // Bluetooth Base UUID: 00000000-0000-1000-8000-00805F9B34FB const uint8_t base_uuid[16] = {0xfb, 0x34, 0x9b, 0x5f, 0x80, 0x00, 0x00, 0x80, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; memcpy(self->uuid128, base_uuid, 16); self->uuid128[12] = (uuid16 & 0xff); self->uuid128[13] = (uuid16 >> 8) & 0xff; } else { - // 128-bit UUID + // 128-bit UUID. Bytes 12 and 13 arrive zeroed, so restore them from + // the uuid16 the binding layer split out -- otherwise the stored UUID + // is missing its two most distinctive bytes. self->size = 128; memcpy(self->uuid128, uuid128, 16); + self->uuid128[12] = (uuid16 & 0xff); + self->uuid128[13] = (uuid16 >> 8) & 0xff; } } @@ -50,3 +71,18 @@ void common_hal_bleio_uuid_pack_into(bleio_uuid_obj_t *self, uint8_t *buf) { memcpy(buf, self->uuid128, 16); } } + +const struct bt_uuid *bleio_uuid_as_bt_uuid(bleio_uuid_obj_t *self) { + // Populate the PERSISTENT .bt member. A bt_gatt_attr stores only a pointer + // to its uuid and Zephyr dereferences it on every ATT request for as long + // as the service is registered, so building this on the stack at + // registration time would leave dangling pointers in the attribute table. + if (self->size == 16) { + self->bt.u16.uuid.type = BT_UUID_TYPE_16; + self->bt.u16.val = (uint16_t)((self->uuid128[13] << 8) | self->uuid128[12]); + } else { + self->bt.u128.uuid.type = BT_UUID_TYPE_128; + memcpy(self->bt.u128.val, self->uuid128, 16); + } + return &self->bt.uuid; +} diff --git a/ports/zephyr-cp/common-hal/_bleio/UUID.h b/ports/zephyr-cp/common-hal/_bleio/UUID.h index 386f5a7b8b9..3624ea54100 100644 --- a/ports/zephyr-cp/common-hal/_bleio/UUID.h +++ b/ports/zephyr-cp/common-hal/_bleio/UUID.h @@ -9,8 +9,21 @@ #include "py/obj.h" +#include + typedef struct { mp_obj_base_t base; uint8_t uuid128[16]; uint8_t size; + // Persistent Zephyr UUID. bt_gatt_attr keeps a POINTER to the uuid for the + // whole lifetime of a registered service, so it cannot be a stack + // temporary built at registration time -- it has to live in the object. + union { + struct bt_uuid uuid; + struct bt_uuid_16 u16; + struct bt_uuid_128 u128; + } bt; } bleio_uuid_obj_t; + +// Fill in the persistent .bt member from uuid128/size and return it. +const struct bt_uuid *bleio_uuid_as_bt_uuid(bleio_uuid_obj_t *self); diff --git a/ports/zephyr-cp/common-hal/busio/UART.c b/ports/zephyr-cp/common-hal/busio/UART.c index 9940853da50..ebc947be194 100644 --- a/ports/zephyr-cp/common-hal/busio/UART.c +++ b/ports/zephyr-cp/common-hal/busio/UART.c @@ -30,11 +30,12 @@ static void serial_cb(const struct device *dev, void *user_data) { uint8_t c; - if (!uart_irq_update(dev)) { - return; - } + // uart_irq_update() returns void: it caches/acks the interrupt status and + // has no success value to test. Readiness is the separate query below -- + // the same order Zephyr's own echo_bot sample uses. + uart_irq_update(dev); - if (!uart_irq_rx_ready(dev)) { + if (uart_irq_rx_ready(dev) <= 0) { return; } diff --git a/ports/zephyr-cp/common-hal/mdns/RemoteService.c b/ports/zephyr-cp/common-hal/mdns/RemoteService.c new file mode 100644 index 00000000000..ad38072f0c9 --- /dev/null +++ b/ports/zephyr-cp/common-hal/mdns/RemoteService.c @@ -0,0 +1,44 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Adafruit Industries LLC +// +// SPDX-License-Identifier: MIT + +#include "shared-bindings/mdns/RemoteService.h" + +#include "shared-bindings/ipaddress/IPv4Address.h" + +const char *common_hal_mdns_remoteservice_get_service_type(mdns_remoteservice_obj_t *self) { + return self->service_name; +} + +const char *common_hal_mdns_remoteservice_get_protocol(mdns_remoteservice_obj_t *self) { + return self->protocol; +} + +const char *common_hal_mdns_remoteservice_get_instance_name(mdns_remoteservice_obj_t *self) { + return self->instance_name; +} + +const char *common_hal_mdns_remoteservice_get_hostname(mdns_remoteservice_obj_t *self) { + return self->hostname; +} + +mp_int_t common_hal_mdns_remoteservice_get_port(mdns_remoteservice_obj_t *self) { + return self->port; +} + +uint32_t mdns_remoteservice_get_ipv4_address(mdns_remoteservice_obj_t *self) { + return self->ipv4_address; +} + +mp_obj_t common_hal_mdns_remoteservice_get_ipv4_address(mdns_remoteservice_obj_t *self) { + uint32_t addr = mdns_remoteservice_get_ipv4_address(self); + if (addr == 0) { + return mp_const_none; + } + return common_hal_ipaddress_new_ipv4address(addr); +} + +void common_hal_mdns_remoteservice_deinit(mdns_remoteservice_obj_t *self) { +} diff --git a/ports/zephyr-cp/common-hal/mdns/RemoteService.h b/ports/zephyr-cp/common-hal/mdns/RemoteService.h new file mode 100644 index 00000000000..412ca346841 --- /dev/null +++ b/ports/zephyr-cp/common-hal/mdns/RemoteService.h @@ -0,0 +1,22 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Adafruit Industries LLC +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include "py/obj.h" + +// Populated by Server.find(), which Zephyr's mDNS stack can't implement yet +// (see common-hal/mdns/Server.c) -- kept the same shape as the other ports' +// so this only needs to grow, not change shape, once that lands upstream. +typedef struct { + mp_obj_base_t base; + uint32_t ipv4_address; + uint16_t port; + char protocol[5]; // RFC 6763 Section 7.2 - 4 bytes + 1 for NUL + char service_name[17]; // RFC 6763 Section 7.2 - 16 bytes + 1 for NUL + char instance_name[64]; // RFC 6763 Section 7.2 - 63 bytes + 1 for NUL + char hostname[64]; // RFC 6762 Appendix A - 63 bytes for label + 1 for NUL +} mdns_remoteservice_obj_t; diff --git a/ports/zephyr-cp/common-hal/mdns/Server.c b/ports/zephyr-cp/common-hal/mdns/Server.c new file mode 100644 index 00000000000..6c8c78c2c62 --- /dev/null +++ b/ports/zephyr-cp/common-hal/mdns/Server.c @@ -0,0 +1,161 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Adafruit Industries LLC +// +// SPDX-License-Identifier: MIT + +#include +#include + +#include "shared-bindings/mdns/Server.h" + +#include "py/runtime.h" +#include "shared-bindings/wifi/__init__.h" +#include "shared-bindings/wifi/Radio.h" +#include "shared-bindings/mdns/RemoteService.h" + +#include +#include +#include + +// Track if we are globally inited. Mirrors every other port: one live +// mdns.Server at a time. A second construct call marks its object deinited +// rather than erroring, so web_workflow.c's own instance and a user's +// `mdns.Server(wifi.radio)` don't fight over the same responder. +static bool object_inited = false; + +void mdns_server_construct(mdns_server_obj_t *self, bool workflow) { + if (object_inited) { + self->inited = false; + return; + } + self->inited = true; + object_inited = true; + self->num_services = 0; + self->instance_name = ""; + + uint8_t mac[6]; + wifi_radio_get_mac_address(&common_hal_wifi_radio_obj, mac); + char default_hostname[sizeof("cpy-XXXXXX")]; + snprintf(default_hostname, sizeof(default_hostname), "cpy-%02x%02x%02x", mac[3], mac[4], mac[5]); + net_hostname_set(default_hostname, strlen(default_hostname)); + + // Other ports also answer "circuitpython.local" as a secondary alias + // when `workflow` is true. Zephyr's mDNS responder only ever answers + // for the single net_hostname() value -- there's no secondary-hostname + // registration to hook up here. + (void)workflow; +} + +void common_hal_mdns_server_construct(mdns_server_obj_t *self, mp_obj_t network_interface) { + if (network_interface != MP_OBJ_FROM_PTR(&common_hal_wifi_radio_obj)) { + mp_raise_ValueError(MP_ERROR_TEXT("mDNS only works with built-in WiFi")); + return; + } + if (object_inited) { + mp_raise_RuntimeError(MP_ERROR_TEXT("mDNS already initialized")); + } + mdns_server_construct(self, false); +} + +void common_hal_mdns_server_deinit(mdns_server_obj_t *self) { + if (common_hal_mdns_server_deinited(self)) { + return; + } + self->inited = false; + object_inited = false; + // Zephyr's mdns_responder_set_ext_records() has no way to clear the + // active record set -- passing NULL/0 is rejected outright (-EINVAL) -- + // so whatever was last advertised keeps answering queries after deinit. + // Nothing in this port currently deinits and re-constructs mDNS at + // runtime, so this is a latent gap rather than an observed one. +} + +bool common_hal_mdns_server_deinited(mdns_server_obj_t *self) { + return !self->inited; +} + +const char *common_hal_mdns_server_get_hostname(mdns_server_obj_t *self) { + return net_hostname_get(); +} + +void common_hal_mdns_server_set_hostname(mdns_server_obj_t *self, const char *hostname) { + if (net_hostname_set(hostname, strlen(hostname)) != 0) { + mp_raise_RuntimeError(MP_ERROR_TEXT("Failed to set hostname")); + } +} + +const char *common_hal_mdns_server_get_instance_name(mdns_server_obj_t *self) { + return self->instance_name; +} + +void common_hal_mdns_server_set_instance_name(mdns_server_obj_t *self, const char *instance_name) { + self->instance_name = instance_name; +} + +// Zephyr's net/lib/dns ships an mDNS *responder* (subsys/net/lib/dns/mdns_responder.c) +// but no client capable of sending PTR/SRV queries and parsing replies -- +// dns_resolve_name(..., DNS_QUERY_TYPE_PTR, ...) resolves one already-known +// name, it doesn't browse a service type. There's nothing to discover here +// yet, so report no results rather than pretending to search. +size_t mdns_server_find(mdns_server_obj_t *self, const char *service_type, const char *protocol, + mp_float_t timeout, mdns_remoteservice_obj_t *out, size_t out_len) { + return 0; +} + +mp_obj_t common_hal_mdns_server_find(mdns_server_obj_t *self, const char *service_type, const char *protocol, mp_float_t timeout) { + return mp_const_empty_tuple; +} + +static size_t encode_txt_records(char *buf, size_t buf_len, const char *txt_records[], size_t num_txt_records) { + size_t off = 0; + for (size_t i = 0; i < num_txt_records; i++) { + size_t len = strlen(txt_records[i]); + if (len > 255 || off + 1 + len > buf_len) { + mp_raise_RuntimeError(MP_ERROR_TEXT("Failed to add service TXT record")); + } + buf[off++] = (char)len; + memcpy(buf + off, txt_records[i], len); + off += len; + } + return off; +} + +void common_hal_mdns_server_advertise_service(mdns_server_obj_t *self, const char *service_type, const char *protocol, mp_int_t port, const char *txt_records[], size_t num_txt_records) { + // Replace the existing record if this service_type was already advertised. + size_t slot = self->num_services; + for (size_t i = 0; i < self->num_services; i++) { + if (service_type == self->service_types[i] || strcmp(service_type, self->service_types[i]) == 0) { + slot = i; + break; + } + } + if (slot == self->num_services) { + if (self->num_services >= MDNS_MAX_SERVICES) { + mp_raise_RuntimeError(MP_ERROR_TEXT("Out of MDNS service slots")); + return; + } + self->num_services++; + } + + self->service_types[slot] = service_type; + self->ports[slot] = sys_cpu_to_be16((uint16_t)port); + + size_t text_size = encode_txt_records(self->text[slot], MDNS_MAX_TXT_BUF, txt_records, num_txt_records); + + struct dns_sd_rec *rec = &self->records[slot]; + rec->instance = self->instance_name; + rec->service = service_type; + rec->proto = protocol; + rec->domain = "local"; + rec->port = &self->ports[slot]; + if (text_size == 0) { + rec->text = dns_sd_empty_txt; + rec->text_size = sizeof(dns_sd_empty_txt); + } else { + rec->text = self->text[slot]; + rec->text_size = text_size; + } + + mdns_responder_set_ext_records(self->records, self->num_services); +} diff --git a/ports/zephyr-cp/common-hal/mdns/Server.h b/ports/zephyr-cp/common-hal/mdns/Server.h new file mode 100644 index 00000000000..09314335a4a --- /dev/null +++ b/ports/zephyr-cp/common-hal/mdns/Server.h @@ -0,0 +1,36 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Adafruit Industries LLC +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include "py/obj.h" + +#include + +// RFC 6763 Section 7.2. +#define MDNS_MAX_SERVICES 4 +// Encoded as length-prefixed key=value pairs back to back; 256 bytes is +// generous for the handful of records a board actually advertises. +#define MDNS_MAX_TXT_BUF 256 + +typedef struct { + mp_obj_base_t base; + const char *instance_name; + + // Parallel to each other by index. records[] is what gets handed to + // Zephyr's mDNS responder (mdns_responder_set_ext_records() keeps the + // pointer, not a copy, so these must stay put for the object's life); + // service_types[]/ports[]/text[] are the backing storage each record + // points into. + struct dns_sd_rec records[MDNS_MAX_SERVICES]; + const char *service_types[MDNS_MAX_SERVICES]; + uint16_t ports[MDNS_MAX_SERVICES]; + char text[MDNS_MAX_SERVICES][MDNS_MAX_TXT_BUF]; + size_t num_services; + + // Track if this object owns the underlying mDNS responder. + bool inited; +} mdns_server_obj_t; diff --git a/ports/zephyr-cp/common-hal/mdns/__init__.c b/ports/zephyr-cp/common-hal/mdns/__init__.c new file mode 100644 index 00000000000..9ea5e1a844d --- /dev/null +++ b/ports/zephyr-cp/common-hal/mdns/__init__.c @@ -0,0 +1,7 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Adafruit Industries LLC +// +// SPDX-License-Identifier: MIT + +// No mdns module functions. diff --git a/ports/zephyr-cp/common-hal/microcontroller/Processor.c b/ports/zephyr-cp/common-hal/microcontroller/Processor.c index 593d73bcc61..b0f80097619 100644 --- a/ports/zephyr-cp/common-hal/microcontroller/Processor.c +++ b/ports/zephyr-cp/common-hal/microcontroller/Processor.c @@ -14,6 +14,10 @@ #include #include +#if defined(CONFIG_SOC_FAMILY_SILABS_SIWX91X) +#include +#endif + float common_hal_mcu_processor_get_temperature(void) { return 0.0; @@ -35,11 +39,38 @@ float common_hal_mcu_processor_get_voltage(void) { void common_hal_mcu_processor_get_uid(uint8_t raw_id[]) { ssize_t len = hwinfo_get_device_id(raw_id, COMMON_HAL_MCU_PROCESSOR_UID_LENGTH); if (len < 0) { - printk("UID retrieval failed: %d\n", len); len = 0; } + #if defined(CONFIG_SOC_FAMILY_SILABS_SIWX91X) + // SiWx91x has no hwinfo driver, and none is worth writing: the efuse + // identity region (0x020..0x02F of the array at TA_EFUSE_IO_BASE_ADDR) is + // unprogrammed on this silicon -- verified on two dies, 2026-08-04. The + // MACs live only in the flash-resident config space ("efusecopy", + // 0x04000560), and Simplicity Commander's "Unique ID" is defined as the + // WiFi MAC zero-extended to 8 bytes. Match that: the wifi net_if link + // address is set from the same store at driver init, before the radio is + // enabled, so it is available whenever this can be called. + // + // CAVEAT: unlike a burned-in hardware serial, this value is whatever the + // WiFi MAC is currently set to, and Silicon Labs' own manufacturing tool + // (mfg917) can reprogram that MAC. Nothing on this silicon offers an + // immutable per-device ID: the RM documents no die/unique ID register, + // the vendor SDK has no such concept, and the one real per-device secret + // (a PUF-derived key, RM ch. 46/48) is deliberately non-readable as + // plaintext. Do not rely on cpu.uid being tamper-proof on this port -- + // e.g. for license binding or provisioning that assumes a fixed serial. + if (len == 0) { + struct net_if *iface = net_if_get_first_wifi(); + struct net_linkaddr *addr = (iface != NULL) ? net_if_get_link_addr(iface) : NULL; + if (addr != NULL && addr->len == 6 && COMMON_HAL_MCU_PROCESSOR_UID_LENGTH >= 8) { + raw_id[0] = 0; + raw_id[1] = 0; + memcpy(&raw_id[2], addr->addr, 6); + len = 8; + } + } + #endif if (len < COMMON_HAL_MCU_PROCESSOR_UID_LENGTH) { - printk("UID shorter %d than defined length %d\n", len, COMMON_HAL_MCU_PROCESSOR_UID_LENGTH); memset(raw_id + len, 0, COMMON_HAL_MCU_PROCESSOR_UID_LENGTH - len); } } diff --git a/ports/zephyr-cp/common-hal/wifi/Network.c b/ports/zephyr-cp/common-hal/wifi/Network.c index 44c049f88c1..3c8074d19f2 100644 --- a/ports/zephyr-cp/common-hal/wifi/Network.c +++ b/ports/zephyr-cp/common-hal/wifi/Network.c @@ -34,35 +34,47 @@ mp_obj_t common_hal_wifi_network_get_country(wifi_network_obj_t *self) { } mp_obj_t common_hal_wifi_network_get_authmode(wifi_network_obj_t *self) { + // The scan result carries Zephyr's wifi_security_type, filled in by the + // driver's security_convert[] table. The old body was a commented-out + // ESP-IDF switch, so authmode ALWAYS returned an empty list -- which reads + // as "open network" to any caller that checks for AUTHMODE_OPEN, and gives + // connect() nothing to pick a handshake with. uint32_t authmode_mask = 0; - // switch (self->record.authmode) { - // case WIFI_AUTH_OPEN: - // authmode_mask = AUTHMODE_OPEN; - // break; - // case WIFI_AUTH_WEP: - // authmode_mask = AUTHMODE_WEP; - // break; - // case WIFI_AUTH_WPA_PSK: - // authmode_mask = AUTHMODE_WPA | AUTHMODE_PSK; - // break; - // case WIFI_AUTH_WPA2_PSK: - // authmode_mask = AUTHMODE_WPA2 | AUTHMODE_PSK; - // break; - // case WIFI_AUTH_WPA_WPA2_PSK: - // authmode_mask = AUTHMODE_WPA | AUTHMODE_WPA2 | AUTHMODE_PSK; - // break; - // case WIFI_AUTH_WPA2_ENTERPRISE: - // authmode_mask = AUTHMODE_WPA2 | AUTHMODE_ENTERPRISE; - // break; - // case WIFI_AUTH_WPA3_PSK: - // authmode_mask = AUTHMODE_WPA3 | AUTHMODE_PSK; - // break; - // case WIFI_AUTH_WPA2_WPA3_PSK: - // authmode_mask = AUTHMODE_WPA2 | AUTHMODE_WPA3 | AUTHMODE_PSK; - // break; - // default: - // break; - // } + switch (self->scan_result.security) { + case WIFI_SECURITY_TYPE_NONE: + authmode_mask = AUTHMODE_OPEN; + break; + case WIFI_SECURITY_TYPE_WEP: + authmode_mask = AUTHMODE_WEP; + break; + case WIFI_SECURITY_TYPE_WPA_PSK: + authmode_mask = AUTHMODE_WPA | AUTHMODE_PSK; + break; + case WIFI_SECURITY_TYPE_PSK: + case WIFI_SECURITY_TYPE_PSK_SHA256: + authmode_mask = AUTHMODE_WPA2 | AUTHMODE_PSK; + break; + case WIFI_SECURITY_TYPE_SAE: // == WIFI_SECURITY_TYPE_SAE_HNP (alias) + case WIFI_SECURITY_TYPE_SAE_H2E: + case WIFI_SECURITY_TYPE_SAE_AUTO: + case WIFI_SECURITY_TYPE_SAE_EXT_KEY: + case WIFI_SECURITY_TYPE_FT_SAE: + authmode_mask = AUTHMODE_WPA3 | AUTHMODE_PSK; + break; + case WIFI_SECURITY_TYPE_WPA_AUTO_PERSONAL: + authmode_mask = AUTHMODE_WPA | AUTHMODE_WPA2 | AUTHMODE_WPA3 | AUTHMODE_PSK; + break; + case WIFI_SECURITY_TYPE_EAP: // == WIFI_SECURITY_TYPE_EAP_TLS (alias) + case WIFI_SECURITY_TYPE_EAP_PEAP_MSCHAPV2: + case WIFI_SECURITY_TYPE_EAP_PEAP_GTC: + case WIFI_SECURITY_TYPE_EAP_TTLS_MSCHAPV2: + case WIFI_SECURITY_TYPE_EAP_PEAP_TLS: + case WIFI_SECURITY_TYPE_FT_EAP: + authmode_mask = AUTHMODE_WPA2 | AUTHMODE_ENTERPRISE; + break; + default: + break; + } mp_obj_t authmode_list = mp_obj_new_list(0, NULL); if (authmode_mask != 0) { for (uint8_t i = 0; i < 32; i++) { diff --git a/ports/zephyr-cp/common-hal/wifi/Radio.c b/ports/zephyr-cp/common-hal/wifi/Radio.c index 35a0b76a362..ab58123950f 100644 --- a/ports/zephyr-cp/common-hal/wifi/Radio.c +++ b/ports/zephyr-cp/common-hal/wifi/Radio.c @@ -28,6 +28,12 @@ #include #include #include +#include +// dns_resolve_get_default() for radio.ipv4_dns. +#include +#include + +LOG_MODULE_DECLARE(cp_wifi); #if CIRCUITPY_MDNS #include "common-hal/mdns/Server.h" @@ -85,7 +91,7 @@ void common_hal_wifi_radio_set_enabled(wifi_radio_obj_t *self, bool enabled) { // #if CIRCUITPY_MDNS // mdns_server_deinit_singleton(); // #endif - printk("net_if_down\n"); + LOG_DBG("net_if_down"); int res = net_if_down(self->sta_netif); if (res < 0 && res != -EALREADY) { raise_zephyr_error(res); @@ -94,7 +100,7 @@ void common_hal_wifi_radio_set_enabled(wifi_radio_obj_t *self, bool enabled) { return; } if (!self->started && enabled) { - printk("net_if_up\n"); + LOG_DBG("net_if_up"); int res = net_if_up(self->sta_netif); if (res < 0 && res != -EALREADY) { raise_zephyr_error(res); @@ -117,9 +123,19 @@ void common_hal_wifi_radio_set_hostname(wifi_radio_obj_t *self, const char *host } } +void wifi_radio_get_mac_address(wifi_radio_obj_t *self, uint8_t *mac) { + memset(mac, 0, MAC_ADDRESS_LENGTH); + if (self->sta_netif != NULL) { + struct net_linkaddr *addr = net_if_get_link_addr(self->sta_netif); + if (addr != NULL && addr->len >= MAC_ADDRESS_LENGTH) { + memcpy(mac, addr->addr, MAC_ADDRESS_LENGTH); + } + } +} + mp_obj_t common_hal_wifi_radio_get_mac_address(wifi_radio_obj_t *self) { uint8_t mac[MAC_ADDRESS_LENGTH]; - // esp_wifi_get_mac(ESP_IF_WIFI_STA, mac); + wifi_radio_get_mac_address(self, mac); return mp_obj_new_bytes(mac, MAC_ADDRESS_LENGTH); } @@ -214,13 +230,11 @@ void common_hal_wifi_radio_set_mac_address_ap(wifi_radio_obj_t *self, const uint } mp_obj_t common_hal_wifi_radio_start_scanning_networks(wifi_radio_obj_t *self, uint8_t start_channel, uint8_t stop_channel) { - printk("common_hal_wifi_radio_start_scanning_networks\n"); + LOG_DBG("common_hal_wifi_radio_start_scanning_networks"); if (self->current_scan != NULL) { - printk("Already scanning for wifi networks\n"); mp_raise_RuntimeError(MP_ERROR_TEXT("Already scanning for wifi networks")); } if (!common_hal_wifi_radio_get_enabled(self)) { - printk("WiFi is not enabled\n"); mp_raise_RuntimeError(MP_ERROR_TEXT("WiFi is not enabled")); } @@ -246,12 +260,12 @@ mp_obj_t common_hal_wifi_radio_start_scanning_networks(wifi_radio_obj_t *self, u K_POLL_MODE_NOTIFY_ONLY, &scan->msgq); wifi_scannednetworks_scan_next_channel(scan); - printk("common_hal_wifi_radio_start_scanning_networks done %p\n", scan); + LOG_DBG("common_hal_wifi_radio_start_scanning_networks done %p", scan); return scan; } void common_hal_wifi_radio_stop_scanning_networks(wifi_radio_obj_t *self) { - printk("common_hal_wifi_radio_stop_scanning_networks\n"); + LOG_DBG("common_hal_wifi_radio_stop_scanning_networks"); // Return early if self->current_scan is NULL to avoid hang if (self->current_scan == NULL) { return; @@ -456,17 +470,206 @@ wifi_radio_error_t common_hal_wifi_radio_connect(wifi_radio_obj_t *self, uint8_t // // We're connected, allow us to retry if we get disconnected. // self->retries_left = self->starting_retries; // } + + struct wifi_connect_req_params params = { 0 }; + + params.ssid = ssid; + params.ssid_length = ssid_len; + params.band = WIFI_FREQ_BAND_2_4_GHZ; + params.channel = channel == 0 ? WIFI_CHANNEL_ANY : channel; + params.mfp = WIFI_MFP_OPTIONAL; + params.timeout = SYS_FOREVER_MS; + + if (password_len > 0) { + params.psk = password; + params.psk_length = password_len; + // WPA2-PSK. Drivers that support a WPA2/WPA3 transition AP will + // negotiate up from here; a WPA3-only network needs + // WIFI_SECURITY_TYPE_SAE, which we cannot infer without a prior scan. + // + // Security type must match what the AP actually advertises; there is + // no single value that works everywhere on this driver: + // + // WIFI_SECURITY_TYPE_PSK -> SL_WIFI_WPA2 + // WIFI_SECURITY_TYPE_WPA_AUTO_PERSONAL -> SL_WIFI_WPA3_TRANSITION + // + // Measured on a SiWx917-DK2605A (Hermes, b74171b3): a WPA2-PSK AP that + // associates fine with SL_WIFI_WPA2 is REJECTED when asked for WPA3 + // transition mode, and a WPA3-SAE AP is rejected when asked for WPA2. + // Both failures surface identically as "Authentication failure", which + // makes them easy to misread as a wrong passphrase. + // + // So look the network up in the most recent scan and use its authmode. + // Fall back to plain WPA2-PSK, which is still the common case, when + // the SSID was not seen (hidden network, or connect() called without a + // prior scan). Known limit: that fallback is silently wrong for a + // WPA3-only hidden AP. + params.security = WIFI_SECURITY_TYPE_PSK; + struct wifi_scan_result *cached = wifi_cached_scan_lookup(ssid, ssid_len); + if (cached != NULL) { + switch (cached->security) { + case WIFI_SECURITY_TYPE_SAE: + case WIFI_SECURITY_TYPE_SAE_H2E: + case WIFI_SECURITY_TYPE_SAE_AUTO: + params.security = WIFI_SECURITY_TYPE_WPA_AUTO_PERSONAL; + break; + case WIFI_SECURITY_TYPE_WPA_PSK: + params.security = WIFI_SECURITY_TYPE_WPA_PSK; + break; + default: + params.security = WIFI_SECURITY_TYPE_PSK; + break; + } + } + } else { + params.security = WIFI_SECURITY_TYPE_NONE; + } + + if (bssid_len == WIFI_MAC_ADDR_LEN) { + memcpy(params.bssid, bssid, WIFI_MAC_ADDR_LEN); + } + + // Already associated to the network being asked for: leave the link alone. + // supervisor_start_web_workflow() calls connect() on every invocation, + // before it checks CIRCUITPY_WEB_API_PASSWORD, so tearing the association + // down here churns the link continuously and the board never holds a DHCP + // lease. + if (self->connected && + ssid_len == self->current_ssid_len && + memcmp(ssid, self->current_ssid, ssid_len) == 0) { + return WIFI_RADIO_ERROR_NONE; + } + + // Switching networks. Connecting while already associated is rejected by + // the driver with -EALREADY ("Device already in active state"), and the + // resulting failure path takes the interface back down, so a reconnect + // attempt would drop a working link. Drop the existing association first + // and let the normal connect path run. + if (self->connected) { + // A failure here is tolerated on purpose: if the interface really is + // unusable, the connect below returns a proper error to the caller. + (void)net_mgmt(NET_REQUEST_WIFI_DISCONNECT, self->sta_netif, NULL, 0); + // Give the controller a moment to tear the association down. + for (int i = 0; i < 40 && self->connected; i++) { + k_msleep(50); + } + self->connected = false; + } + + self->connected = false; + self->last_connect_status = -1; + self->last_disconnect_reason = 0; + k_sem_reset(&self->connect_sem); + + int res = net_mgmt(NET_REQUEST_WIFI_CONNECT, self->sta_netif, ¶ms, sizeof(params)); + if (res == -EALREADY) { + // Already associated to this network. Nothing to do. + self->connected = true; + return WIFI_RADIO_ERROR_NONE; + } + if (res < 0) { + return WIFI_RADIO_ERROR_UNSPECIFIED; + } + + // Wait for NET_EVENT_WIFI_CONNECT_RESULT (or a DISCONNECT_RESULT standing + // in for a failed attempt), staying responsive to ctrl-C. + mp_float_t timeout_s = timeout <= 0 ? (mp_float_t)10 : timeout; + int64_t deadline = k_uptime_get() + (int64_t)(timeout_s * 1000); + bool signalled = false; + while (k_uptime_get() < deadline) { + if (k_sem_take(&self->connect_sem, K_MSEC(50)) == 0) { + signalled = true; + break; + } + if (mp_hal_is_interrupted()) { + return WIFI_RADIO_ERROR_UNSPECIFIED; + } + } + + if (!signalled) { + return WIFI_RADIO_ERROR_HANDSHAKE_TIMEOUT; + } + if (!self->connected) { + switch (self->last_connect_status) { + case WIFI_STATUS_CONN_WRONG_PASSWORD: + return WIFI_RADIO_ERROR_AUTH_FAIL; + case WIFI_STATUS_CONN_AP_NOT_FOUND: + return WIFI_RADIO_ERROR_NO_AP_FOUND; + case WIFI_STATUS_CONN_TIMEOUT: + return WIFI_RADIO_ERROR_HANDSHAKE_TIMEOUT; + default: + return WIFI_RADIO_ERROR_CONNECTION_FAIL; + } + } + + // Remember which network this association is for, so a later connect() for + // the same SSID can return without disturbing it. + self->current_ssid_len = MIN(ssid_len, sizeof(self->current_ssid)); + memcpy(self->current_ssid, ssid, self->current_ssid_len); + + // Associated. Ask for an address; the AP side of DHCP can take a moment. + #if defined(CONFIG_NET_DHCPV4) + net_dhcpv4_start(self->sta_netif); + int64_t ip_deadline = k_uptime_get() + 15000; + while (k_uptime_get() < ip_deadline) { + if (net_if_ipv4_get_global_addr(self->sta_netif, NET_ADDR_PREFERRED) != NULL) { + break; + } + if (mp_hal_is_interrupted()) { + break; + } + k_msleep(50); + } + #endif + return WIFI_RADIO_ERROR_NONE; } bool common_hal_wifi_radio_get_connected(wifi_radio_obj_t *self) { - // return self->sta_mode && esp_netif_is_netif_up(self->netif); - return false; + return self->connected && self->sta_netif != NULL && + net_if_is_up(self->sta_netif); } mp_obj_t common_hal_wifi_radio_get_ap_info(wifi_radio_obj_t *self) { - // if (!esp_netif_is_netif_up(self->netif)) { - return mp_const_none; + if (self->sta_netif == NULL || !self->connected) { + return mp_const_none; + } + + // Zephyr reports the live association through NET_REQUEST_WIFI_IFACE_STATUS, + // which carries everything a wifi.Network needs. Without this, ap_info + // returns None and the only way to learn the current AP's RSSI is a full + // scan matched against the connected SSID -- which costs a scan and + // briefly takes the radio away from the association it is asking about. + struct wifi_iface_status status = { 0 }; + if (net_mgmt(NET_REQUEST_WIFI_IFACE_STATUS, self->sta_netif, + &status, sizeof(status)) != 0) { + return mp_const_none; + } + + // Associated is the weakest state that has a meaningful BSSID and RSSI. + if (status.state < WIFI_STATE_ASSOCIATED) { + return mp_const_none; + } + + // wifi.Network wraps a scan result, so translate the status into one. + wifi_network_obj_t *ap_info = mp_obj_malloc(wifi_network_obj_t, &wifi_network_type); + size_t ssid_len = MIN(status.ssid_len, sizeof(ap_info->scan_result.ssid) - 1); + memcpy(ap_info->scan_result.ssid, status.ssid, ssid_len); + ap_info->scan_result.ssid[ssid_len] = '\0'; + ap_info->scan_result.ssid_length = ssid_len; + memcpy(ap_info->scan_result.mac, status.bssid, WIFI_MAC_ADDR_LEN); + ap_info->scan_result.mac_length = WIFI_MAC_ADDR_LEN; + ap_info->scan_result.band = status.band; + ap_info->scan_result.channel = status.channel; + ap_info->scan_result.security = status.security; + ap_info->scan_result.wpa3_ent_type = status.wpa3_ent_type; + ap_info->scan_result.mfp = status.mfp; + // status.rssi is int, scan_result.rssi is int8_t dBm. Clamp rather than + // truncate: a wrapped value would read as a positive dBm, which is the + // same class of bug as the driver's unsigned-magnitude RSSI. + ap_info->scan_result.rssi = (int8_t)MIN(MAX(status.rssi, INT8_MIN), INT8_MAX); + return MP_OBJ_FROM_PTR(ap_info); + // } // // Make sure the interface is in STA mode @@ -499,11 +702,25 @@ mp_obj_t common_hal_wifi_radio_get_ap_info(wifi_radio_obj_t *self) { } mp_obj_t common_hal_wifi_radio_get_ipv4_gateway(wifi_radio_obj_t *self) { - // if (!esp_netif_is_netif_up(self->netif)) { + if (self->sta_netif == NULL || !net_if_is_up(self->sta_netif)) { + return mp_const_none; + } + // struct net_if_ip's `ipv4` member only exists when CONFIG_NET_IPV4 is + // set (net/net_if.h) -- siwx917 always has it, but this file is also + // compiled for every other Wi-Fi board in the port's CI matrix, and not + // all of them turn IPv4 on. A board without it just has no IPv4 gateway. + #if defined(CONFIG_NET_IPV4) + const struct net_if_config *cfg = net_if_get_config(self->sta_netif); + if (cfg == NULL || cfg->ip.ipv4 == NULL) { + return mp_const_none; + } + if (cfg->ip.ipv4->gw.s_addr == 0) { + return mp_const_none; + } + return common_hal_ipaddress_new_ipv4address(cfg->ip.ipv4->gw.s_addr); + #else return mp_const_none; - // } - // esp_netif_get_ip_info(self->netif, &self->ip_info); - // return common_hal_ipaddress_new_ipv4address(self->ip_info.gw.addr); + #endif } mp_obj_t common_hal_wifi_radio_get_ipv4_gateway_ap(wifi_radio_obj_t *self) { @@ -515,11 +732,27 @@ mp_obj_t common_hal_wifi_radio_get_ipv4_gateway_ap(wifi_radio_obj_t *self) { } mp_obj_t common_hal_wifi_radio_get_ipv4_subnet(wifi_radio_obj_t *self) { - // if (!esp_netif_is_netif_up(self->netif)) { + // Was a hardcoded `return mp_const_none`. + if (self->sta_netif == NULL || !net_if_is_up(self->sta_netif)) { + return mp_const_none; + } + // See the comment in common_hal_wifi_radio_get_ipv4_gateway: `ipv4` only + // exists on struct net_if_ip when CONFIG_NET_IPV4 is set, and this file + // is compiled for every Wi-Fi board in the port, not just ones with it on. + #if defined(CONFIG_NET_IPV4) + struct net_if_ipv4 *ipv4 = self->sta_netif->config.ip.ipv4; + if (ipv4 == NULL) { + return mp_const_none; + } + for (int i = 0; i < NET_IF_MAX_IPV4_ADDR; i++) { + if (ipv4->unicast[i].ipv4.is_used && + ipv4->unicast[i].ipv4.addr_state == NET_ADDR_PREFERRED) { + return common_hal_ipaddress_new_ipv4address( + ipv4->unicast[i].netmask.s_addr); + } + } + #endif return mp_const_none; - // } - // esp_netif_get_ip_info(self->netif, &self->ip_info); - // return common_hal_ipaddress_new_ipv4address(self->ip_info.netmask.addr); } mp_obj_t common_hal_wifi_radio_get_ipv4_subnet_ap(wifi_radio_obj_t *self) { @@ -562,31 +795,61 @@ mp_obj_t common_hal_wifi_radio_get_ipv4_subnet_ap(wifi_radio_obj_t *self) { // } mp_obj_t common_hal_wifi_radio_get_addresses(wifi_radio_obj_t *self) { - // return common_hal_wifi_radio_get_addresses_netif(self, self->netif); - return mp_const_none; + // Was `return mp_const_none`, but the shared-bindings contract + // (shared-bindings/wifi/Radio.c: "addresses: Sequence[str] ... Empty + // sequence when not connected") requires a sequence, never None -- so + // this returned the wrong type in the connected case and the wrong + // *value* (None instead of ()) when disconnected. Same underlying + // address as wifi_radio_get_ipv4_address() above and + // common_hal_wifi_radio_get_ipv4_address() (both already fixed); + // formatted as a string here rather than an IPv4Address object, per the + // Sequence[str] contract other ports also follow. + uint32_t ipv4_address = wifi_radio_get_ipv4_address(self); + if (ipv4_address == 0) { + return mp_const_empty_tuple; + } + uint8_t *octets = (uint8_t *)&ipv4_address; + char buf[16]; + snprintf(buf, sizeof(buf), "%d.%d.%d.%d", octets[0], octets[1], octets[2], octets[3]); + mp_obj_t args[] = { mp_obj_new_str(buf, strlen(buf)) }; + return mp_obj_new_tuple(MP_ARRAY_SIZE(args), args); } mp_obj_t common_hal_wifi_radio_get_addresses_ap(wifi_radio_obj_t *self) { - // return common_hal_wifi_radio_get_addresses_netif(self, self->ap_netif); - return mp_const_none; + // AP mode is not implemented on this port at all (see + // common_hal_wifi_radio_start_ap, also stubbed), so unlike get_addresses + // above there is no real address to report yet. mp_const_none is still + // the wrong type for the Sequence[str] contract; correct that much + // without pretending AP mode works. + return mp_const_empty_tuple; } uint32_t wifi_radio_get_ipv4_address(wifi_radio_obj_t *self) { - // if (!esp_netif_is_netif_up(self->netif)) { - // return 0; - // } - // esp_netif_get_ip_info(self->netif, &self->ip_info); - // return self->ip_info.ip.addr; - return 0; + // Was a hardcoded `return 0`, so the status bar and /cp/version.json's + // "ip" field stayed blank even with a real DHCP lease: this is the raw + // uint32_t sibling of common_hal_wifi_radio_get_ipv4_address() below + // (that one returns an mp_obj_t and was already fixed), used internally + // by supervisor/shared/web_workflow/web_workflow.c rather than from + // Python. + if (self->sta_netif == NULL || !net_if_is_up(self->sta_netif)) { + return 0; + } + struct in_addr *addr = net_if_ipv4_get_global_addr(self->sta_netif, NET_ADDR_PREFERRED); + if (addr == NULL) { + return 0; + } + return addr->s_addr; } mp_obj_t common_hal_wifi_radio_get_ipv4_address(wifi_radio_obj_t *self) { - // if (!esp_netif_is_netif_up(self->netif)) { - // return mp_const_none; - // } - // esp_netif_get_ip_info(self->netif, &self->ip_info); - // return common_hal_ipaddress_new_ipv4address(self->ip_info.ip.addr); - return mp_const_none; + if (self->sta_netif == NULL || !net_if_is_up(self->sta_netif)) { + return mp_const_none; + } + struct in_addr *addr = net_if_ipv4_get_global_addr(self->sta_netif, NET_ADDR_PREFERRED); + if (addr == NULL) { + return mp_const_none; + } + return common_hal_ipaddress_new_ipv4address(addr->s_addr); } mp_obj_t common_hal_wifi_radio_get_ipv4_address_ap(wifi_radio_obj_t *self) { @@ -599,20 +862,26 @@ mp_obj_t common_hal_wifi_radio_get_ipv4_address_ap(wifi_radio_obj_t *self) { } mp_obj_t common_hal_wifi_radio_get_ipv4_dns(wifi_radio_obj_t *self) { - // if (!esp_netif_is_netif_up(self->netif)) { - // return mp_const_none; - // } - - // esp_netif_get_dns_info(self->netif, ESP_NETIF_DNS_MAIN, &self->dns_info); - - // if (self->dns_info.ip.type != ESP_IPADDR_TYPE_V4) { - // return mp_const_none; - // } - // // dns_info is of type esp_netif_dns_info_t, which is just ever so slightly - // // different than esp_netif_ip_info_t used for - // // common_hal_wifi_radio_get_ipv4_address (includes both ipv4 and 6), - // // so some extra jumping is required to get to the actual address - // return common_hal_ipaddress_new_ipv4address(self->dns_info.ip.u_addr.ip4.addr); + // Was a hardcoded `return mp_const_none`. Zephyr keeps resolver state in + // the DNS resolve context rather than on the interface, so read it there. + #if defined(CONFIG_DNS_RESOLVER) + if (self->sta_netif == NULL || !net_if_is_up(self->sta_netif)) { + return mp_const_none; + } + struct dns_resolve_context *ctx = dns_resolve_get_default(); + if (ctx == NULL) { + return mp_const_none; + } + for (int i = 0; i < CONFIG_DNS_RESOLVER_MAX_SERVERS; i++) { + if (ctx->servers[i].dns_server.sa_family == AF_INET) { + struct sockaddr_in *addr = + (struct sockaddr_in *)&ctx->servers[i].dns_server; + if (addr->sin_addr.s_addr != 0) { + return common_hal_ipaddress_new_ipv4address(addr->sin_addr.s_addr); + } + } + } + #endif return mp_const_none; } diff --git a/ports/zephyr-cp/common-hal/wifi/Radio.h b/ports/zephyr-cp/common-hal/wifi/Radio.h index f177f493685..2500079df09 100644 --- a/ports/zephyr-cp/common-hal/wifi/Radio.h +++ b/ports/zephyr-cp/common-hal/wifi/Radio.h @@ -11,7 +11,9 @@ #include "shared-bindings/wifi/ScannedNetworks.h" #include "shared-bindings/wifi/Network.h" +#include #include +#include // Event bits for the Radio event group. #define WIFI_SCAN_DONE_BIT BIT0 @@ -38,6 +40,16 @@ typedef struct { uint8_t retries_left; uint8_t starting_retries; uint8_t last_disconnect_reason; + // 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; + // Latest wifi_conn_status from NET_EVENT_WIFI_CONNECT_RESULT. + int last_connect_status; + bool connected; + // SSID of the association that `connected` refers to, so that a connect() + // 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; } wifi_radio_obj_t; extern void common_hal_wifi_radio_gc_collect(wifi_radio_obj_t *self); diff --git a/ports/zephyr-cp/common-hal/wifi/ScannedNetworks.c b/ports/zephyr-cp/common-hal/wifi/ScannedNetworks.c index 725bf1fa7cb..f86053dc889 100644 --- a/ports/zephyr-cp/common-hal/wifi/ScannedNetworks.c +++ b/ports/zephyr-cp/common-hal/wifi/ScannedNetworks.c @@ -20,11 +20,14 @@ #include #include +#include + +LOG_MODULE_DECLARE(cp_wifi); void wifi_scannednetworks_scan_result(wifi_scannednetworks_obj_t *self, struct wifi_scan_result *result) { if (k_msgq_put(&self->msgq, result, K_NO_WAIT) != 0) { - printk("Dropping scan result!\n"); + LOG_WRN("Dropping scan result"); } } @@ -104,7 +107,7 @@ void wifi_scannednetworks_scan_next_channel(wifi_scannednetworks_obj_t *self) { } else { int res = net_mgmt(NET_REQUEST_WIFI_SCAN, self->netif, ¶ms, sizeof(params)); if (res != 0) { - printk("Failed to start wifi scan %d\n", res); + LOG_ERR("Failed to start wifi scan %d", res); raise_zephyr_error(res); wifi_scannednetworks_done(self); } else { diff --git a/ports/zephyr-cp/common-hal/wifi/__init__.c b/ports/zephyr-cp/common-hal/wifi/__init__.c index 4b967bc2780..f6de1cfc2fc 100644 --- a/ports/zephyr-cp/common-hal/wifi/__init__.c +++ b/ports/zephyr-cp/common-hal/wifi/__init__.c @@ -4,6 +4,8 @@ // // SPDX-License-Identifier: MIT +#include + #include "common-hal/wifi/__init__.h" #include "shared-bindings/wifi/__init__.h" @@ -35,6 +37,13 @@ wifi_radio_obj_t common_hal_wifi_radio_obj; #include +#include +// Registered at DBG so the only gate is the global CONFIG_LOG_MAX_LEVEL +// (2/WRN in prj.conf). The old printk tracing corrupted the raw-REPL +// handshake on the console UART; routing through the log subsystem keeps +// the REPL byte-clean and the traces one config flip away. +LOG_MODULE_REGISTER(cp_wifi, LOG_LEVEL_DBG); + #define MAC_ADDRESS_LENGTH 6 static void schedule_background_on_cp_core(void *arg) { @@ -50,68 +59,128 @@ 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; +// Small cache of the most recent scan, used by common_hal_wifi_radio_connect() +// to pick the right security type per AP. The SiWx91x driver needs WPA2 and +// WPA3 requested explicitly and rejects the wrong one with a generic auth +// failure, so guessing is not viable. +#define WIFI_SCAN_CACHE_LEN 24 +static struct wifi_scan_result scan_cache[WIFI_SCAN_CACHE_LEN]; +static size_t scan_cache_count; + +struct wifi_scan_result *wifi_cached_scan_lookup(const uint8_t *ssid, size_t ssid_len) { + for (size_t i = 0; i < scan_cache_count; i++) { + if (scan_cache[i].ssid_length == ssid_len && + memcmp(scan_cache[i].ssid, ssid, ssid_len) == 0) { + return &scan_cache[i]; + } + } + return NULL; +} + +static void wifi_scan_cache_add(const struct wifi_scan_result *result) { + // Replace an existing entry for the same SSID so the cache tracks the + // latest reading rather than filling up with duplicate BSSIDs. + struct wifi_scan_result *existing = + wifi_cached_scan_lookup(result->ssid, result->ssid_length); + if (existing != NULL) { + *existing = *result; + return; + } + if (scan_cache_count < WIFI_SCAN_CACHE_LEN) { + scan_cache[scan_cache_count++] = *result; + } +} + 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; switch (mgmt_event) { case NET_EVENT_WIFI_SCAN_RESULT: { - printk("NET_EVENT_WIFI_SCAN_RESULT\n"); + LOG_DBG("NET_EVENT_WIFI_SCAN_RESULT"); const struct wifi_scan_result *result = cb->info; - if (result != NULL && self->current_scan != NULL) { - wifi_scannednetworks_scan_result(self->current_scan, result); + if (result != NULL) { + // Remember the authmode so connect() can request the matching + // security type later. + wifi_scan_cache_add(result); + if (self->current_scan != NULL) { + wifi_scannednetworks_scan_result(self->current_scan, result); + } } break; } case NET_EVENT_WIFI_SCAN_DONE: - printk("NET_EVENT_WIFI_SCAN_DONE (thread: %s prio=%d)\n", + LOG_DBG("NET_EVENT_WIFI_SCAN_DONE (thread: %s prio=%d)", k_thread_name_get(k_current_get()), k_thread_priority_get(k_current_get())); if (self->current_scan != NULL) { k_poll_signal_raise(&self->current_scan->channel_done, 0); } break; - case NET_EVENT_WIFI_CONNECT_RESULT: - printk("NET_EVENT_WIFI_CONNECT_RESULT\n"); + case NET_EVENT_WIFI_CONNECT_RESULT: { + const struct wifi_status *status = cb->info; + self->last_connect_status = status != NULL ? status->status : -1; + self->connected = self->last_connect_status == WIFI_STATUS_CONN_SUCCESS; + LOG_DBG("NET_EVENT_WIFI_CONNECT_RESULT status %d", self->last_connect_status); + k_sem_give(&self->connect_sem); break; - case NET_EVENT_WIFI_DISCONNECT_RESULT: - printk("NET_EVENT_WIFI_DISCONNECT_RESULT\n"); + } + 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; + 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. + k_sem_give(&self->connect_sem); break; + } case NET_EVENT_WIFI_IFACE_STATUS: - printk("NET_EVENT_WIFI_IFACE_STATUS\n"); + LOG_DBG("NET_EVENT_WIFI_IFACE_STATUS"); break; case NET_EVENT_WIFI_TWT: - printk("NET_EVENT_WIFI_TWT\n"); + LOG_DBG("NET_EVENT_WIFI_TWT"); break; case NET_EVENT_WIFI_TWT_SLEEP_STATE: - printk("NET_EVENT_WIFI_TWT_SLEEP_STATE\n"); + LOG_DBG("NET_EVENT_WIFI_TWT_SLEEP_STATE"); break; case NET_EVENT_WIFI_RAW_SCAN_RESULT: - printk("NET_EVENT_WIFI_RAW_SCAN_RESULT\n"); + LOG_DBG("NET_EVENT_WIFI_RAW_SCAN_RESULT"); break; case NET_EVENT_WIFI_DISCONNECT_COMPLETE: - printk("NET_EVENT_WIFI_DISCONNECT_COMPLETE\n"); + LOG_DBG("NET_EVENT_WIFI_DISCONNECT_COMPLETE"); break; case NET_EVENT_WIFI_SIGNAL_CHANGE: - printk("NET_EVENT_WIFI_SIGNAL_CHANGE\n"); + LOG_DBG("NET_EVENT_WIFI_SIGNAL_CHANGE"); break; case NET_EVENT_WIFI_NEIGHBOR_REP_COMP: - printk("NET_EVENT_WIFI_NEIGHBOR_REP_COMP\n"); + LOG_DBG("NET_EVENT_WIFI_NEIGHBOR_REP_COMP"); break; case NET_EVENT_WIFI_AP_ENABLE_RESULT: - printk("NET_EVENT_WIFI_AP_ENABLE_RESULT\n"); + LOG_DBG("NET_EVENT_WIFI_AP_ENABLE_RESULT"); break; case NET_EVENT_WIFI_AP_DISABLE_RESULT: - printk("NET_EVENT_WIFI_AP_DISABLE_RESULT\n"); + LOG_DBG("NET_EVENT_WIFI_AP_DISABLE_RESULT"); break; case NET_EVENT_WIFI_AP_STA_CONNECTED: - printk("NET_EVENT_WIFI_AP_STA_CONNECTED\n"); + LOG_DBG("NET_EVENT_WIFI_AP_STA_CONNECTED"); break; case NET_EVENT_WIFI_AP_STA_DISCONNECTED: - printk("NET_EVENT_WIFI_AP_STA_DISCONNECTED\n"); + LOG_DBG("NET_EVENT_WIFI_AP_STA_DISCONNECTED"); + break; + case NET_EVENT_IPV4_ADDR_ADD: + // DHCP bound or a static address was configured. The address is + // read live from Zephyr by the ipv4_address getter, so nothing is + // stored here; the status bar just needs a refresh or it keeps + // showing "No IP" until something else happens to redraw it. + LOG_DBG("NET_EVENT_IPV4_ADDR_ADD"); + schedule_background_on_cp_core(NULL); break; default: - printk("unhandled net event %x\n", mgmt_event); + // Print all 64 bits: the layer lives in the high bits and the command + // in the low 52, so a 32-bit print collapses every unhandled event in + // a layer to the same aliased value (d0d00000 for any Wi-Fi event). + LOG_DBG("unhandled net event %llx", (unsigned long long)mgmt_event); break; } } @@ -196,7 +265,7 @@ static bool wifi_user_initiated; void common_hal_wifi_init(bool user_initiated) { wifi_radio_obj_t *self = &common_hal_wifi_radio_obj; - printk("common_hal_wifi_init\n"); + LOG_DBG("common_hal_wifi_init"); if (wifi_inited) { if (user_initiated && !wifi_user_initiated) { @@ -207,6 +276,9 @@ void common_hal_wifi_init(bool user_initiated) { wifi_inited = true; wifi_user_initiated = user_initiated; self->base.type = &wifi_radio_type; + k_sem_init(&self->connect_sem, 0, 1); + self->connected = false; + self->last_connect_status = -1; // struct net_if *default_iface = net_if_get_default(); // printk("default interface %p\n", default_iface); @@ -223,8 +295,8 @@ void common_hal_wifi_init(bool user_initiated) { // } self->sta_netif = net_if_get_wifi_sta(); self->ap_netif = net_if_get_wifi_sap(); - printk("sta_netif %p\n", self->sta_netif); - printk("ap_netif %p\n", self->ap_netif); + LOG_DBG("sta_netif %p", self->sta_netif); + LOG_DBG("ap_netif %p", self->ap_netif); struct wifi_iface_status status = { 0 }; @@ -232,39 +304,39 @@ void common_hal_wifi_init(bool user_initiated) { CHECK_ZEPHYR_RESULT(net_mgmt(NET_REQUEST_WIFI_IFACE_STATUS, self->sta_netif, &status, sizeof(struct wifi_iface_status))); if (net_if_is_up(self->sta_netif)) { - printk("STA is up\n"); + LOG_DBG("STA is up"); } else { - printk("STA is down\n"); + LOG_DBG("STA is down"); } if (net_if_is_carrier_ok(self->sta_netif)) { - printk("STA carrier is ok\n"); + LOG_DBG("STA carrier is ok"); } else { - printk("STA carrier is not ok\n"); + LOG_DBG("STA carrier is not ok"); } if (net_if_is_dormant(self->sta_netif)) { - printk("STA is dormant\n"); + LOG_DBG("STA is dormant"); } else { - printk("STA is not dormant\n"); + LOG_DBG("STA is not dormant"); } } if (self->ap_netif != NULL) { int res = net_mgmt(NET_REQUEST_WIFI_IFACE_STATUS, self->ap_netif, &status, sizeof(struct wifi_iface_status)); - printk("AP status request response %d\n", res); + LOG_DBG("AP status request response %d", res); if (net_if_is_up(self->ap_netif)) { - printk("AP is up\n"); + LOG_DBG("AP is up"); } else { - printk("AP is down\n"); + LOG_DBG("AP is down"); } if (net_if_is_carrier_ok(self->ap_netif)) { - printk("AP carrier is ok\n"); + LOG_DBG("AP carrier is ok"); } else { - printk("AP carrier is not ok\n"); + LOG_DBG("AP carrier is not ok"); } if (net_if_is_dormant(self->ap_netif)) { - printk("AP is dormant\n"); + LOG_DBG("AP is dormant"); } else { - printk("AP is not dormant\n"); + LOG_DBG("AP is not dormant"); } } @@ -278,6 +350,13 @@ void common_hal_wifi_init(bool user_initiated) { // self->ap_mode = 0; net_mgmt_init_event_callback(&wifi_cb, _event_handler, + // SCAN_RESULT delivers the parsed per-AP entries. Without it the + // handler's NET_EVENT_WIFI_SCAN_RESULT case never runs and scans + // always return zero networks. RAW_SCAN_RESULT is not a substitute: + // it carries raw beacon frames and only fires when + // CONFIG_WIFI_MGMT_RAW_SCAN_RESULTS is enabled, which it is not by + // default. + NET_EVENT_WIFI_SCAN_RESULT | NET_EVENT_WIFI_SCAN_DONE | NET_EVENT_WIFI_CONNECT_RESULT | NET_EVENT_WIFI_DISCONNECT_RESULT | @@ -317,21 +396,21 @@ void common_hal_wifi_init(bool user_initiated) { char cpy_default_hostname[board_len + (MAC_ADDRESS_LENGTH * 2) + 6]; struct net_linkaddr *mac = net_if_get_link_addr(self->sta_netif); if (mac->len < MAC_ADDRESS_LENGTH) { - printk("MAC address too short"); + LOG_ERR("MAC address too short"); } snprintf(cpy_default_hostname, sizeof(cpy_default_hostname), "cpy-%s-%02x%02x%02x%02x%02x%02x", CIRCUITPY_BOARD_ID + board_trim, mac->addr[0], mac->addr[1], mac->addr[2], mac->addr[3], mac->addr[4], mac->addr[5]); CHECK_ZEPHYR_RESULT(net_hostname_set(cpy_default_hostname, strlen(cpy_default_hostname))); } #else - printk("Hostname support disabled in Zephyr config\n"); + LOG_WRN("Hostname support disabled in Zephyr config"); #endif // set station mode to avoid the default SoftAP common_hal_wifi_radio_start_station(self); // start wifi common_hal_wifi_radio_set_enabled(self, true); - printk("common_hal_wifi_init done\n"); + LOG_DBG("common_hal_wifi_init done"); } void wifi_user_reset(void) { @@ -342,7 +421,7 @@ void wifi_user_reset(void) { } void wifi_reset(void) { - printk("wifi_reset\n"); + LOG_DBG("wifi_reset"); if (!wifi_inited) { return; } diff --git a/ports/zephyr-cp/common-hal/wifi/__init__.h b/ports/zephyr-cp/common-hal/wifi/__init__.h index dab519b1a5f..dee6533ce37 100644 --- a/ports/zephyr-cp/common-hal/wifi/__init__.h +++ b/ports/zephyr-cp/common-hal/wifi/__init__.h @@ -8,10 +8,19 @@ #include "py/obj.h" +#include + struct sockaddr_storage; void wifi_reset(void); +// Look up an SSID in the cache of the most recent scan. Returns NULL if the +// network was not seen. Used by common_hal_wifi_radio_connect() to request the +// security type the AP actually advertises: the SiWx91x driver rejects a WPA2 +// handshake on a WPA3-SAE AP and vice versa, and both failures look like a +// generic authentication error. +struct wifi_scan_result *wifi_cached_scan_lookup(const uint8_t *ssid, size_t ssid_len); + // void ipaddress_ipaddress_to_esp_idf(mp_obj_t ip_address, ip_addr_t *esp_ip_address); // void ipaddress_ipaddress_to_esp_idf_ip4(mp_obj_t ip_address, esp_ip4_addr_t *esp_ip_address); diff --git a/ports/zephyr-cp/cptools/build_circuitpython.py b/ports/zephyr-cp/cptools/build_circuitpython.py index b7334402b74..160b91ec87e 100644 --- a/ports/zephyr-cp/cptools/build_circuitpython.py +++ b/ports/zephyr-cp/cptools/build_circuitpython.py @@ -281,6 +281,33 @@ async def generate_web_workflow_static(output_path, static_files): ) +def configure_ulab_module(mpconfigboard, circuitpython_flags, source_files, top): + """Configure ulab module flags and sources if enabled in board config. + + ulab is opt-in per board via CIRCUITPY_ULAB in circuitpython.toml. It adds + roughly 100 KB, so it is not enabled by default. The flags mirror + py/py.mk; check there if they need updating. + + Args: + mpconfigboard: Board configuration dictionary + circuitpython_flags: List of compiler flags to modify in-place + source_files: List of source files to modify in-place + top: Path to the CircuitPython source root + """ + ulab_enabled = bool(mpconfigboard.get("CIRCUITPY_ULAB", False)) + circuitpython_flags.append(f"-DCIRCUITPY_ULAB={1 if ulab_enabled else 0}") + if ulab_enabled: + circuitpython_flags.extend( + ( + "-DMODULE_ULAB_ENABLED=1", + "-DULAB_HAS_USER_MODULE=0", + "-iquote", + str(top / "extmod" / "ulab" / "code"), + ) + ) + source_files.extend(sorted((top / "extmod" / "ulab" / "code").rglob("*.c"))) + + def determine_enabled_modules(board_info, portdir, srcdir): """Determine which CircuitPython modules should be enabled based on board capabilities. @@ -600,6 +627,7 @@ async def build_circuitpython(): circuitpython_flags.append(f"-DCIRCUITPY_{mpflag.upper()}={1 if enabled else 0}") source_files = supervisor_source + hal_source + ["extmod/vfs.c"] + configure_ulab_module(mpconfigboard, circuitpython_flags, source_files, top) assembly_files = [] for file in top.glob("py/*.c"): source_files.append(file) diff --git a/ports/zephyr-cp/cptools/zephyr2cp.py b/ports/zephyr-cp/cptools/zephyr2cp.py index a9b5ecf6e1d..a7aa92b8c75 100644 --- a/ports/zephyr-cp/cptools/zephyr2cp.py +++ b/ports/zephyr-cp/cptools/zephyr2cp.py @@ -466,6 +466,7 @@ def zephyr_dts_to_cp_board(board_id, portdir, builddir, zephyrbuilddir, mpconfig config_bt_enabled = False config_bt_found = False + config_mdns_enabled = False config_present = True config = zephyrbuilddir / ".config" if not config.exists(): @@ -475,10 +476,12 @@ def zephyr_dts_to_cp_board(board_id, portdir, builddir, zephyrbuilddir, mpconfig if line.startswith("CONFIG_BT="): config_bt_enabled = line.strip().endswith("=y") config_bt_found = True - break - if line.startswith("# CONFIG_BT is not set"): + elif line.startswith("# CONFIG_BT is not set"): config_bt_enabled = False config_bt_found = True + elif line.startswith("CONFIG_MDNS_RESPONDER=y"): + config_mdns_enabled = True + if config_bt_found and config_mdns_enabled: break runners = zephyrbuilddir / "runners.yaml" @@ -494,7 +497,16 @@ def zephyr_dts_to_cp_board(board_id, portdir, builddir, zephyrbuilddir, mpconfig else: board_yaml = board_yaml["board"] board_info["vendor_id"] = board_yaml["vendor"] - vendor_index = zephyr_board_dir.parent / "index.rst" + # The vendor directory is the direct child of boards/. Some vendors nest + # boards a level deeper in category directories (boards/silabs/dev_kits/ + # ), where the intermediate index.rst heading is a product-line + # grouping ("Dev Kits and Thunderboards"), not the vendor - and that + # heading would end up prefixed onto the board's display name. Walk up + # to the real vendor directory before reading its index.rst. + vendor_dir = zephyr_board_dir.parent + while vendor_dir.parent.name != "boards" and vendor_dir.parent != vendor_dir: + vendor_dir = vendor_dir.parent + vendor_index = vendor_dir / "index.rst" if vendor_index.exists(): vendor_index = vendor_index.read_text() vendor_index = vendor_index.split("\n") @@ -677,7 +689,10 @@ def zephyr_dts_to_cp_board(board_id, portdir, builddir, zephyrbuilddir, mpconfig if (ioport, num) not in board_names: board_names[(ioport, num)] = [] - board_names[(ioport, num)].append(props["label"].to_string()) + # `label` is optional and deprecated on gpio-keys; modern boards + # identify keys with `zephyr,code` instead. + if "label" in props: + board_names[(ioport, num)].append(props["label"].to_string()) if key in node2alias: if "sw0" in node2alias[key]: board_names[(ioport, num)].append("BUTTON") @@ -980,6 +995,7 @@ def zephyr_dts_to_cp_board(board_id, portdir, builddir, zephyrbuilddir, mpconfig ) board_info["_bleio"] = ble_hardware_present and config_bt_enabled + board_info["mdns"] = config_mdns_enabled board_info["source_files"] = [board_c] board_info["cflags"] = ("-I", board_dir) board_info["flash_count"] = len(flashes) diff --git a/ports/zephyr-cp/hci_ipc_netcore.conf b/ports/zephyr-cp/hci_ipc_netcore.conf new file mode 100644 index 00000000000..07c0a40da4b --- /dev/null +++ b/ports/zephyr-cp/hci_ipc_netcore.conf @@ -0,0 +1,83 @@ +# Net-core (nRF5340 cpunet) configuration for the hci_ipc image — single +# source, replacing upstream's extra-iso-bt_ll_sw_split.conf entirely. +# +# History: upstream split the old combined nrf5340_cpunet_iso-bt_ll_sw_split +# conf into prj.conf + extra-*.conf overlays. We first layered our overrides +# next to upstream's extra-iso overlay in the same EXTRA_CONF_FILE list, which +# collides: the overlay enables ISO and sets its buffer ints, our file +# disabled ISO, and the orphaned ints then fail Kconfig ("unsatisfied deps"). +# Single-source design by Hermes (#the-forge, 2026-08-05): copy the overlay's +# controller baseline here, drop the ISO and extended-advertising content, +# pass ONLY this file. One source, no merge, nothing to contradict. +# (Merge-order footnote, also Hermes: cmake's zephyr_get(... MERGE REVERSE) +# reverses the sysbuild/local/cache/env *scopes*, not files within one value — +# so in-list order was never the problem; the collision was real.) +# +# The port intentionally carries socs/nrf5340_cpunet.conf with the same ISO +# disables, but that file has never applied: Zephyr resolves socs/ fragments +# from APPLICATION_CONFIG_DIR, which for the hci_ipc image is upstream's +# sample directory, not our port dir. This file is the seam that actually +# reaches the image (see sysbuild.cmake). +# +# Rationale for what's cut: CircuitPython's _bleio uses legacy advertising +# (bt_le_adv_start) and scanning only — zero bt_iso_*/bt_le_ext_adv_*/ +# bt_le_per_adv_* call sites anywhere in the port (verified by grep, both +# trees). The ISO TX pool (12 × MTU 310) and the extended-advertising +# allocations (SCAN_DATA_LEN_MAX=1650, ADV_DATA_LEN_MAX=191, three _SET=3 +# tables) are the RAM the net core cannot afford: it overflows by 820 bytes +# with them in (issue #41). + +# --- Stack sizes: pre-rebase values (old combined conf), not the overlay's. +# The overlay raises ISR_STACK_SIZE to 1536 "across supported SoCs"; the old +# combined file used 1024 on cpunet and that is the measured-working value. +CONFIG_ISR_STACK_SIZE=1024 +CONFIG_IDLE_STACK_SIZE=256 +CONFIG_MAIN_STACK_SIZE=512 +CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE=512 +CONFIG_HEAP_MEM_POOL_SIZE=4096 + +# picolib: printf without 64-bit integer support (size) +CONFIG_CBPRINTF_REDUCED_INTEGRAL=y + +# Link-time optimization (size) +CONFIG_ISR_TABLES_LOCAL_DECLARATION=y +CONFIG_LTO=y + +# --- BT buffers (identical to both the old combined conf and the overlay) +CONFIG_BT_BUF_CMD_TX_SIZE=255 +CONFIG_BT_BUF_EVT_RX_COUNT=16 +CONFIG_BT_BUF_EVT_RX_SIZE=255 +CONFIG_BT_BUF_ACL_TX_COUNT=3 +CONFIG_BT_BUF_ACL_TX_SIZE=251 +CONFIG_BT_BUF_ACL_RX_COUNT_EXTRA=1 +CONFIG_BT_BUF_ACL_RX_SIZE=255 + +# --- Host and Controller common: legacy roles only. +# BT_EXT_ADV / BT_PER_ADV / BT_PER_ADV_SYNC deliberately absent. +CONFIG_BT_BROADCASTER=y +CONFIG_BT_OBSERVER=y +CONFIG_BT_CENTRAL=y +CONFIG_BT_PERIPHERAL=y +CONFIG_BT_MAX_CONN=3 + +# --- Controller: split LL, no ISO, no extended advertising. +CONFIG_BT_LL_SW_SPLIT=y +CONFIG_BT_CTLR_DTM_HCI=y +CONFIG_BT_CTLR_ASSERT_HANDLER=y +CONFIG_BT_HCI_VS_FATAL_ERROR=y + +# Rx ACL and Adv Reports +CONFIG_BT_CTLR_RX_BUFFERS=9 +CONFIG_BT_CTLR_DATA_LENGTH_MAX=251 + +# Coded PHY support +CONFIG_BT_CTLR_PHY_CODED=y + +# Control Procedure +CONFIG_BT_CTLR_LLCP_LOCAL_PROC_CTX_BUF_NUM=6 + +# --- Mbed TLS RAM tuning (from the overlay, unchanged): CSPRNG as PSA random +# source, ROM AES tables, 3 key slots (PSA is only AES-CMAC in hci_ipc). +CONFIG_MBEDTLS_PSA_CRYPTO_EXTERNAL_RNG=y +CONFIG_MBEDTLS_AES_ROM_TABLES=y +CONFIG_MBEDTLS_PSA_KEY_SLOT_COUNT=3 diff --git a/ports/zephyr-cp/prj.conf b/ports/zephyr-cp/prj.conf index 765132742bb..6d8805b73be 100644 --- a/ports/zephyr-cp/prj.conf +++ b/ports/zephyr-cp/prj.conf @@ -56,3 +56,16 @@ CONFIG_MBEDTLS_BUILTIN=y CONFIG_MBEDTLS_PSA_CRYPTO_C=y CONFIG_PSA_WANT_ALG_SHA_1=y CONFIG_PSA_WANT_ALG_SHA_256=y + +# The lines above alone are not enough on this zephyr pin: upstream added a +# PSA_CRYPTO bool + PSA_CRYPTO_PROVIDER choice gate in front of the mbedtls +# provider (zephyr/modules/mbedtls/Kconfig.psa.logic). Nothing here selects +# it directly, so it silently resolves to n unless something else (observed: +# boards with Bluetooth enabled) transitively selects it. hashlib needs PSA +# hash unconditionally, so it must not depend on that side channel -- set +# explicitly rather than relying on BT. Diagnosed via native_native_sim +# (no BT) failing to link psa_hash_setup/update/clone/finish after the +# 2026-08 zephyr rebase; its resolved .config showed "CONFIG_PSA_CRYPTO is +# not set" despite CONFIG_MBEDTLS_PSA_CRYPTO_C=y above. +CONFIG_PSA_CRYPTO=y +CONFIG_PSA_CRYPTO_PROVIDER_MBEDTLS=y diff --git a/ports/zephyr-cp/supervisor/port.c b/ports/zephyr-cp/supervisor/port.c index 1ecde0b0141..2e62a051a62 100644 --- a/ports/zephyr-cp/supervisor/port.c +++ b/ports/zephyr-cp/supervisor/port.c @@ -17,6 +17,7 @@ #include #include +#include #include #include @@ -43,6 +44,10 @@ static tlsf_t heap; static size_t tlsf_heap_used = 0; +// Smallest memory region worth adding to the Python heap. Also keeps tiny +// special-purpose regions (NWP-reserved memory, DMA buffers) out of it. +#define MIN_HEAP_REGION_SIZE (8 * 1024) + // Auto generated in pins.c extern const struct device *const rams[]; extern const uint32_t *const ram_bounds[]; @@ -257,6 +262,44 @@ void port_idle_until_interrupt(void) { // Zephyr doesn't maintain one multi-heap. So, make our own using TLSF. void port_heap_init(void) { + #if defined(CONFIG_SOC_FAMILY_SILABS_SIWX91X) + // The SiWx917 has a 16 KB data cache dedicated to PSRAM (family RM rev 1.2 + // section 5.4.5) sitting on the QSPI2 path, at 0x44040000. The bootloader + // leaves it enabled and half-configured, and nothing in this build can + // maintain it: + // + // - WiseConnect's own PSRAM init clears the HPROT allocate signal right + // after enabling the cache. That step never runs here, because + // SL_SI91X_D_CACHE_ENABLE is never defined and the macros it needs + // (DCACHE_CTRL_AND_STATUS, HPORT_ALLOCATE_SIGNAL) do not exist in the + // vendored HAL at all. + // - Zephyr cannot maintain it either: cache_siwx91x.c asserts the SoC has + // no data cache, CPU_HAS_DCACHE is never selected, and sys_cache_data_* + // return -ENOTSUP. + // + // The result is an unmaintained write-allocate cache in front of memory a + // second bus master (the NWP) also writes, which corrupts Python objects + // under allocation churn: ints that become functions, bytearrays with wrong + // lengths, garbled qstrs, and MPU faults through pointers read from PSRAM. + // Disable it here, before any TLSF pool exists. + // + // Only bit 0 of CTRL clears; bit 1 is sticky, and MAINT_STATUS settles at + // 0x100 rather than 0. Do not spin waiting for zero -- the vendor's own + // disable path does exactly that and would hang on this silicon. + // + // This is a workaround, not a fix. The fix is either a real cache driver for + // the peripheral or clearing HPROT allocate the way the vendor intended. + volatile uint32_t *dcache_ctrl = (volatile uint32_t *)(0x44040000 + 0x010); + volatile uint32_t *dcache_maint = (volatile uint32_t *)(0x44040000 + 0x028); + uint32_t dcache_ctrl_before = *dcache_ctrl; + uint32_t dcache_maint_before = *dcache_maint; + *dcache_ctrl &= ~1u; + barrier_dsync_fence_full(); + barrier_isync_fence_full(); + printk("PSRAM dcache disabled: CTRL %08x -> %08x, MAINT %08x -> %08x\n", + dcache_ctrl_before, *dcache_ctrl, dcache_maint_before, *dcache_maint); + #endif + // Do a test malloc to determine if Zephyr has an outer heap that may // overlap with a memory region we've identified in ram_bounds. We'll // corrupt each other if we both use it. @@ -266,7 +309,35 @@ void port_heap_init(void) { zephyr_malloc_active = test_malloc != NULL; #endif + // TLSF's control structure lives in the first pool, so that pool must be + // large enough to hold it. Pick the largest region for it rather than + // whichever happens to come first: a board whose first region is tiny (the + // SiWx917 has two 1 KB regions ahead of 8 MB of PSRAM) otherwise builds its + // heap in a space too small to use, and the first real allocation aborts. + // + // On the SiWx917 this reorder puts the control block in PSRAM, which is + // only safe because the dcache disable above already ran with no + // allocation in between (flagged by Hermes, #the-forge, 2026-08-04). This + // block must keep running after that one -- moving it earlier reintroduces + // the write-allocate corruption the disable exists to prevent. + size_t largest_index = 0; + size_t largest_size = 0; for (size_t i = 0; i < CIRCUITPY_RAM_DEVICE_COUNT; i++) { + size_t size = (ram_bounds[2 * i + 1] - ram_bounds[2 * i]) * sizeof(uint32_t); + if (size > largest_size) { + largest_size = size; + largest_index = i; + } + } + + for (size_t n = 0; n < CIRCUITPY_RAM_DEVICE_COUNT; n++) { + // Visit the largest region first, then the rest in their original order. + size_t i; + if (n == 0) { + i = largest_index; + } else { + i = (n - 1 < largest_index) ? n - 1 : n; + } uint32_t *heap_bottom = ram_bounds[2 * i]; uint32_t *heap_top = ram_bounds[2 * i + 1]; size_t size = (heap_top - heap_bottom) * sizeof(uint32_t); @@ -274,8 +345,21 @@ void port_heap_init(void) { // build time. (The ram_bounds values are sometimes determined by the // linker.) So, we need to guard against regions that aren't actually // free. - if (size < 1024) { - printk("Skipping region because the linker filled it up.\n"); + // Regions this small are never usefully part of the Python heap, and on + // some SoCs they are actively dangerous to allocate from. The siwx91x + // exposes two 1 KB regions that must not be used: memory@0 is reserved + // for the network processor ("The first 1KB of SRAM is reserved for the + // NWP"), and memory-dma@24061c00 is a DMA buffer. tlsf_create_with_pool() + // does not refuse a region this small -- it writes its control block + // past the end of it regardless (measured: a 1 KB region here takes a + // 3.7 KB out-of-bounds write and still returns success), corrupting + // whatever memory follows before the first malloc() ever runs. That + // corruption is what shows up later as an int that has become a + // function, a bytearray whose length is wrong, or a jump through a + // corrupted pointer -- not a clean allocation failure. Note the old + // bound was `< 1024`, which let a region of exactly 1024 bytes through. + if (size < MIN_HEAP_REGION_SIZE) { + printk("Skipping region at %p: too small (%d bytes)\n", heap_bottom, size); continue; } #ifdef CONFIG_COMMON_LIBC_MALLOC diff --git a/ports/zephyr-cp/sysbuild.cmake b/ports/zephyr-cp/sysbuild.cmake index 3c3acf0a803..938e305a5a5 100644 --- a/ports/zephyr-cp/sysbuild.cmake +++ b/ports/zephyr-cp/sysbuild.cmake @@ -13,8 +13,19 @@ if(SB_CONFIG_NET_CORE_IMAGE_HCI_IPC) BOARD ${SB_CONFIG_NET_CORE_BOARD} ) - set(${NET_APP}_CONF_FILE - ${NET_APP_SRC_DIR}/nrf5340_cpunet_iso-bt_ll_sw_split.conf + # Upstream split hci_ipc's single nrf5340_cpunet_iso-bt_ll_sw_split.conf + # into a base prj.conf plus extra-*.conf overlays. Use EXTRA_CONF_FILE, not + # CONF_FILE: the latter replaces prj.conf, which would drop the base + # settings (IPC_SERVICE, MBOX, BT_HCI_RAW) prj.conf now carries. + # + # Deliberately NOT layering upstream's extra-iso overlay here: it enables + # ISO and extended advertising, which _bleio never uses and cpunet's RAM + # cannot afford (issue #41), and mixing "overlay enables, our file + # disables" in one merge list produces orphaned-int Kconfig failures. + # hci_ipc_netcore.conf is the single source: the overlay's controller + # baseline minus ISO/ext-adv, plus the pre-rebase net-core sizing. + set(${NET_APP}_EXTRA_CONF_FILE + ${CMAKE_CURRENT_LIST_DIR}/hci_ipc_netcore.conf CACHE INTERNAL "" ) diff --git a/ports/zephyr-cp/tests/conftest.py b/ports/zephyr-cp/tests/conftest.py index 03451048324..0ac30b033e4 100644 --- a/ports/zephyr-cp/tests/conftest.py +++ b/ports/zephyr-cp/tests/conftest.py @@ -33,6 +33,9 @@ def pytest_configure(config): config.addinivalue_line( "markers", "circuitpy_drive(files): run CircuitPython with files in the flash image" ) + config.addinivalue_line( + "markers", "hw: tests that require a real board (see test_web_workflow_hw.py)" + ) config.addinivalue_line( "markers", "disable_i2c_devices(*names): disable native_sim I2C emulator devices" ) diff --git a/ports/zephyr-cp/tests/test_web_workflow_hw.py b/ports/zephyr-cp/tests/test_web_workflow_hw.py new file mode 100644 index 00000000000..7919b66d144 --- /dev/null +++ b/ports/zephyr-cp/tests/test_web_workflow_hw.py @@ -0,0 +1,376 @@ +# SPDX-FileCopyrightText: 2026 Mikey Sklar +# SPDX-License-Identifier: MIT + +"""Web workflow regression tests against a real board. + +The native_sim tests in test_web_workflow.py exercise the workflow over +hostnetwork only; nothing there touches a real radio, which is how the +listener-error bug fixed in siwx917/fix-web-workflow-listener-errors +survived upstream since 2023 (PR #7836). This module runs the +verified-working baseline from issue #10 against actual hardware. + +These tests are skipped unless a board is provided via environment: + + CP_HW_BOARD_IP board's IP address (required) + CP_HW_WEB_PASSWORD CIRCUITPY_WEB_API_PASSWORD value (required for /fs) + CP_HW_SERIAL_PORT serial port for the BLE-concurrency test (optional) + +Run: + + CP_HW_BOARD_IP=... CP_HW_WEB_PASSWORD=... pytest -m hw test_web_workflow_hw.py +""" + +from __future__ import annotations + +import hashlib +import os +import time + +import pytest +import requests + +BOARD_IP = os.environ.get("CP_HW_BOARD_IP") +WEB_PASSWORD = os.environ.get("CP_HW_WEB_PASSWORD") +SERIAL_PORT = os.environ.get("CP_HW_SERIAL_PORT") + +pytestmark = [ + pytest.mark.hw, + pytest.mark.skipif(BOARD_IP is None, reason="CP_HW_BOARD_IP not set"), +] + +TIMEOUT = 10.0 + +# A Session with keep-alive disabled. The shared-connection version of this +# was a workaround for the connection-pool exhaustion in issue #35; with +# NET_MAX_CONN=12 that is fixed and 200 consecutive fresh connections run +# clean. Reusing a pooled connection is now the liability instead: the board +# closes idle keep-alive, so the next request on a stale one dies with +# ECONNRESET (errno 54) rather than anything meaningful about the workflow. +# Deliberately NO shared Session. The pooled-connection version was a +# workaround for the pool exhaustion in issue #35; with NET_MAX_CONN=12 that +# is fixed and 200 consecutive fresh connections measure clean. Reusing a +# pooled connection is now the liability: the board closes idle keep-alive, +# so a request on a stale one dies with ECONNRESET (errno 54), which says +# nothing about the workflow under test. + + +def url(path): + return f"http://{BOARD_IP}{path}" + + +def auth(): + return ("", WEB_PASSWORD) + + +def wait_until_stable(deadline_s=30.0, consecutive=3): + """Block until the workflow answers `consecutive` times in a row. + + A filesystem write over /fs triggers CircuitPython's auto-reload, which + restarts the VM and with it the web workflow. Any test that runs after one + of those needs to let the board come back, or it measures the restart + instead of whatever it was written to measure. + """ + deadline = time.monotonic() + deadline_s + streak = 0 + while time.monotonic() < deadline: + try: + if requests.get(url("/cp/version.json"), timeout=TIMEOUT).status_code == 200: + streak += 1 + if streak >= consecutive: + return + else: + streak = 0 + except requests.RequestException: + streak = 0 + time.sleep(0.5) + raise AssertionError(f"workflow did not stabilise within {deadline_s}s") + + +def test_version_json_ok(): + """/cp/version.json responds 200 with sane identity fields, no auth.""" + response = requests.get(url("/cp/version.json"), timeout=TIMEOUT) + assert response.status_code == 200 + payload = response.json() + assert payload["board_id"] == "silabs_siwx917_dk2605a" + assert payload["web_api_version"] >= 4 + assert payload["mcu_name"] == "siwg917m111mgtba" + + +def test_version_json_ip_populated(): + """ip should reflect the board's real DHCP-assigned address. + + Was empty on real hardware (mikeysklar/circuitpython#34): the internal + wifi_radio_get_ipv4_address() used by web_workflow.c for this field was + a leftover ESP-IDF stub always returning 0, separate from + common_hal_wifi_radio_get_ipv4_address() (the Python-facing getter, + already fixed) which reads the same address correctly. + """ + payload = requests.get(url("/cp/version.json"), timeout=TIMEOUT).json() + assert payload["ip"] == BOARD_IP + + +def test_version_json_mdns_fields_populated(): + """board_name and hostname come from the mDNS responder (mikeysklar/circuitpython#37). + + Fixed by common-hal/mdns/Server.c, built on Zephyr's CONFIG_MDNS_RESPONDER + + DNS-SD. Only checks what web_workflow.c reads directly (hostname, + instance_name) -- actual on-air mDNS resolution from another host is not + covered here, see test_mdns_hostname_resolves_over_network below. + """ + payload = requests.get(url("/cp/version.json"), timeout=TIMEOUT).json() + assert payload["board_name"] != "" + assert payload["hostname"] != "" + + +def _mdns_query_a(hostname, timeout_s=3.0): + """Send one mDNS A query for `hostname` and return the responder's IP, or None. + + Talks raw multicast DNS (RFC 6762) instead of shelling out to a + platform tool (`dns-sd`/`avahi-browse`/`dig`), so this runs the same way + in CI and locally. + """ + import socket + import struct + + labels = hostname.split(".") + ["local"] + qname = b"".join(bytes([len(label)]) + label.encode() for label in labels) + b"\x00" + # Header: id=0, flags=0, 1 question, 0/0/0 answer/authority/additional. + query = struct.pack(">HHHHHH", 0, 0, 1, 0, 0, 0) + query += qname + struct.pack(">HH", 1, 1) # QTYPE=A, QCLASS=IN + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(timeout_s) + try: + sock.sendto(query, ("224.0.0.251", 5353)) + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + try: + data, (from_ip, _port) = sock.recvfrom(2048) + except OSError: + break + if from_ip == BOARD_IP and len(data) > 12: + return from_ip + return None + finally: + sock.close() + + +@pytest.mark.xfail( + reason="On-air mDNS resolution wasn't reproducible during development: this " + "network's AP appears to block multicast between wireless clients (a " + "control browse for _services._dns-sd._udp turned up nothing from any " + "other device either, only the querying host's own entries). The " + "responder itself is confirmed correctly configured -- see " + "test_version_json_mdns_fields_populated and the Kconfig comment in " + "boards/siwx917_dk2605a.conf. Flips to XPASS on a network that allows it.", + strict=False, +) +def test_mdns_hostname_resolves_over_network(): + """.local should resolve to the board's IP via real mDNS.""" + payload = requests.get(url("/cp/version.json"), timeout=TIMEOUT).json() + hostname = payload["hostname"] + assert hostname + resolved = _mdns_query_a(hostname) + assert resolved == BOARD_IP + + +def test_fs_requires_auth(): + """/fs/ without credentials is 401, never an open listing.""" + response = requests.get(url("/fs/"), timeout=TIMEOUT) + assert response.status_code == 401 + + +@pytest.mark.skipif(WEB_PASSWORD is None, reason="CP_HW_WEB_PASSWORD not set") +def test_fs_authenticated_listing(): + response = requests.get(url("/fs/"), auth=auth(), timeout=TIMEOUT) + assert response.status_code == 200 + + +@pytest.mark.skipif(WEB_PASSWORD is None, reason="CP_HW_WEB_PASSWORD not set") +def test_fs_put_get_delete_cycle(): + """PUT a probe file, read it back byte-exact, delete it, confirm gone.""" + body = (f"# web workflow hw probe {time.time()}\n" + "x" * 512).encode() + digest = hashlib.sha256(body).hexdigest() + + response = requests.put(url("/fs/probe_hw_test.py"), auth=auth(), data=body, timeout=TIMEOUT) + assert response.status_code in (201, 204) + + # A filesystem write triggers auto-reload; the workflow restarts with the + # VM. Give follow-up requests a short retry window across the bounce. + def get_with_retry(path, deadline_s=15.0): + deadline = time.monotonic() + deadline_s + while True: + try: + return requests.get(url(path), auth=auth(), timeout=TIMEOUT) + except requests.ConnectionError: + if time.monotonic() > deadline: + raise + time.sleep(1.0) + + response = get_with_retry("/fs/probe_hw_test.py") + assert response.status_code == 200 + assert hashlib.sha256(response.content).hexdigest() == digest + + response = requests.delete(url("/fs/probe_hw_test.py"), auth=auth(), timeout=TIMEOUT) + assert response.status_code == 204 + + response = get_with_retry("/fs/probe_hw_test.py") + assert response.status_code == 404 + + +def test_http_latency_sane(): + """20 sequential version.json fetches all succeed and none stalls. + + A generous per-request bound: the point is catching a wedged listener or + a starved socket pool, not benchmarking. + """ + # Preceding tests write to /fs, which auto-reloads the board; measuring + # through that restart would time the reboot, not the listener. + wait_until_stable() + + worst = 0.0 + for _ in range(20): + start = time.monotonic() + response = requests.get(url("/cp/version.json"), timeout=TIMEOUT) + elapsed = time.monotonic() - start + assert response.status_code == 200 + worst = max(worst, elapsed) + assert worst < 5.0, f"worst latency {worst:.2f}s" + + +@pytest.mark.skipif(SERIAL_PORT is None, reason="CP_HW_SERIAL_PORT not set") +def test_http_survives_concurrent_ble_gatt(): + """Sustained GATT reads concurrent with HTTP traffic; the #13 leftover. + + Starts a BLE GATT service + advertising on the board over raw REPL, + connects from this host with bleak, then hammers GATT reads while running + HTTP requests. Both sides must complete with zero failures. Covers the + gap noted when #13 closed: coexistence was measured with BLE sampled, not + stressed. + """ + bleak = pytest.importorskip("bleak") + serial = pytest.importorskip("serial") + import asyncio + + setup = ( + b"import _bleio\n" + b"svc = _bleio.Service(_bleio.UUID(0x1234))\n" + b"chrc = _bleio.Characteristic.add_to_service(\n" + b" svc, _bleio.UUID(0x5678), max_length=20, fixed_length=False,\n" + b" properties=_bleio.Characteristic.READ | _bleio.Characteristic.WRITE,\n" + b" read_perm=_bleio.Attribute.OPEN, write_perm=_bleio.Attribute.OPEN,\n" + b" initial_value=b'hw-test')\n" + b"_bleio.adapter.name = 'SiWx917-HWTEST'\n" + b"adv = bytes([15, 0x09]) + b'SiWx917-HWTEST'\n" + b"_bleio.adapter.start_advertising(adv, connectable=True)\n" + b"print('ADV', _bleio.adapter.advertising)\n" + ) + + s = serial.Serial(SERIAL_PORT, 115200, timeout=2) + try: + time.sleep(0.3) + s.write(b"\x03") + time.sleep(1.5) + s.read(s.in_waiting or 1) + # Consume the "press any key" state before the raw-REPL handshake. + s.write(b"\r\n") + time.sleep(1.0) + s.reset_input_buffer() + s.write(b"\x01") + time.sleep(0.4) + s.read(s.in_waiting or 1) + s.write(setup + b"\x04") + time.sleep(3.0) + out = s.read(s.in_waiting or 1) + assert b"ADV True" in out, f"BLE setup failed: {out!r}" + + char_uuid = "00005678-0000-1000-8000-00805f9b34fb" + results = {"gatt_reads": 0, "gatt_fails": 0, "http_ok": 0, "http_fails": 0} + + async def run(): + device = await bleak.BleakScanner.find_device_by_name("SiWx917-HWTEST", timeout=20.0) + assert device is not None, "board not found over BLE" + async with bleak.BleakClient(device) as client: + deadline = time.monotonic() + 30.0 + + async def gatt_hammer(): + while time.monotonic() < deadline: + try: + await client.read_gatt_char(char_uuid) + results["gatt_reads"] += 1 + except Exception: + results["gatt_fails"] += 1 + + async def http_hammer(): + while time.monotonic() < deadline: + try: + response = await asyncio.to_thread( + requests.get, url("/cp/version.json"), timeout=TIMEOUT + ) + if response.status_code == 200: + results["http_ok"] += 1 + else: + results["http_fails"] += 1 + except Exception: + results["http_fails"] += 1 + await asyncio.sleep(0.2) + + await asyncio.gather(gatt_hammer(), http_hammer()) + + asyncio.run(run()) + + assert results["gatt_fails"] == 0, results + assert results["http_fails"] == 0, results + # Sanity that the hammer actually hammered. + assert results["gatt_reads"] > 50, results + assert results["http_ok"] > 20, results + finally: + # Soft reboot so the board returns to its own code.py. + s.write(b"\x02") + time.sleep(0.2) + s.write(b"\x04") + s.close() + + +@pytest.mark.skipif(SERIAL_PORT is None, reason="CP_HW_SERIAL_PORT not set") +def test_wifi_radio_addresses(): + """wifi.radio.addresses is a Sequence[str], never None. + + common_hal_wifi_radio_get_addresses() returned mp_const_none, wrong type + for the shared-bindings contract ("addresses: Sequence[str] ... Empty + sequence when not connected"). Same address as + wifi_radio_get_ipv4_address(), formatted as a string tuple instead of + reusing that raw getter directly. + """ + serial = pytest.importorskip("serial") + s = serial.Serial(SERIAL_PORT, 115200, timeout=2) + try: + time.sleep(0.3) + s.write(b"\x03") + time.sleep(1.5) + s.read(s.in_waiting or 1) + s.write(b"\r\n") + time.sleep(1.0) + s.reset_input_buffer() + s.write(b"\x01") + time.sleep(0.4) + s.read(s.in_waiting or 1) + s.write( + b"import wifi\n" + b"a = wifi.radio.addresses\n" + b"print('TYPE', type(a).__name__)\n" + b"print('LEN', len(a))\n" + b"print('VAL', a[0] if a else None)\n" + b"\x04" + ) + time.sleep(2.0) + out = s.read(s.in_waiting or 1).decode(errors="replace") + finally: + s.write(b"\x02") + time.sleep(0.2) + s.write(b"\x04") + s.close() + + assert "TYPE tuple" in out, out + assert "LEN 1" in out, out + assert f"VAL {BOARD_IP}" in out, out diff --git a/ports/zephyr-cp/zephyr-config/west.yml b/ports/zephyr-cp/zephyr-config/west.yml index 4401481f966..cdd49ce2ae9 100644 --- a/ports/zephyr-cp/zephyr-config/west.yml +++ b/ports/zephyr-cp/zephyr-config/west.yml @@ -7,7 +7,7 @@ manifest: revision: c2927847bdf2ee0af9c5459f4155c67f39f6837a path: modules/bsim_hw_models/nrf_hw_models - name: zephyr - url: https://github.com/adafruit/zephyr - revision: e1dc85052bc8928572fdb972997c65eeb96f555b + url: https://github.com/mikeysklar/zephyr + revision: 1f5ee1ce1034e76fbe636888efddde19ba07be87 clone-depth: 100 import: true diff --git a/supervisor/shared/web_workflow/web_workflow.c b/supervisor/shared/web_workflow/web_workflow.c index 278823676ef..98621af71a7 100644 --- a/supervisor/shared/web_workflow/web_workflow.c +++ b/supervisor/shared/web_workflow/web_workflow.c @@ -398,14 +398,28 @@ bool supervisor_start_web_workflow(void) { if (common_hal_socketpool_socket_get_closed(&listening)) { #if CIRCUITPY_SOCKETPOOL_IPV6 - socketpool_socket(&pool, SOCKETPOOL_AF_INET6, SOCKETPOOL_SOCK_STREAM, 0, &listening); + bool opened = socketpool_socket(&pool, SOCKETPOOL_AF_INET6, SOCKETPOOL_SOCK_STREAM, 0, &listening); #else - socketpool_socket(&pool, SOCKETPOOL_AF_INET, SOCKETPOOL_SOCK_STREAM, 0, &listening); + bool opened = socketpool_socket(&pool, SOCKETPOOL_AF_INET, SOCKETPOOL_SOCK_STREAM, 0, &listening); #endif + // Ports with an offloaded network stack can refuse to create a + // socket until the interface has an address (this board returns + // ENOTCONN before DHCP completes). Binding and listening on the + // unset descriptor then fails with EBADF, and reporting success + // leaves the supervisor polling a socket that was never opened. + // Give up for now; the caller retries on the next invocation. + if (!opened) { + return false; + } common_hal_socketpool_socket_settimeout(&listening, 0); - // Bind to any ip. (Not checking for failures) - common_hal_socketpool_socket_bind(&listening, "", 0, web_api_port); - common_hal_socketpool_socket_listen(&listening, 1); + if (common_hal_socketpool_socket_bind(&listening, "", 0, web_api_port) != 0) { + common_hal_socketpool_socket_close(&listening); + return false; + } + if (!common_hal_socketpool_socket_listen(&listening, 1)) { + common_hal_socketpool_socket_close(&listening); + return false; + } } // Wake polling thread (maybe) socketpool_socket_poll_resume(); diff --git a/tools/ci_changes_per_commit.py b/tools/ci_changes_per_commit.py index 6cbd475fa95..df1b03dc82f 100644 --- a/tools/ci_changes_per_commit.py +++ b/tools/ci_changes_per_commit.py @@ -155,6 +155,13 @@ def get_commit_depth_and_check_suite(query_commits): check_suites = commit["checkSuites"] if check_suites["totalCount"] > 0: for check_suite in check_suites["nodes"]: + # workflowRun is null for check suites that aren't + # attached to a workflow run (e.g. one that was + # deleted or belongs to an app integration), which + # crashed this loop with a TypeError on this fork's + # PR history. + if check_suite["workflowRun"] is None: + continue if check_suite["workflowRun"]["workflow"]["name"] == "Build CI": return [ {"sha": commit_sha, "depth": commit_depth},