From 1e2e1eccb4f04e311d4473a7b7147b73c5fac26e Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Mon, 12 Oct 2026 18:39:16 -0400 Subject: [PATCH 01/18] zephyr-cp: make the Feather UART console a build option The Feather nRF52840 and Feather Bluefruit Sense put the Zephyr console on UART0, which fixed TX/RX and kept them from busio.UART. UART0 is now a dynamic iobroker instance like UART1, and the console comes back only in CONSOLE=1 builds through the board's console.overlay and console.conf. DEBUG=1 implies CONSOLE=1. Co-Authored-By: Claude Opus 5.5 --- ports/zephyr-cp/Makefile | 20 +++++---- ports/zephyr-cp/README.md | 5 +++ .../feather_nrf52840_sense_zephyr/board.conf | 5 +++ .../board.overlay | 42 ++++++++++++++----- .../console.conf | 4 ++ .../console.overlay | 18 ++++++++ .../feather_nrf52840_zephyr/board.conf | 5 +++ .../feather_nrf52840_zephyr/board.overlay | 37 ++++++++++++---- .../feather_nrf52840_zephyr/console.conf | 4 ++ .../feather_nrf52840_zephyr/console.overlay | 18 ++++++++ 10 files changed, 132 insertions(+), 26 deletions(-) create mode 100644 ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/console.conf create mode 100644 ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/console.overlay create mode 100644 ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/console.conf create mode 100644 ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/console.overlay diff --git a/ports/zephyr-cp/Makefile b/ports/zephyr-cp/Makefile index 8e134ca66fa..f889f5eea90 100644 --- a/ports/zephyr-cp/Makefile +++ b/ports/zephyr-cp/Makefile @@ -61,16 +61,22 @@ endif # When DEBUG=1, apply additional Kconfig fragments for debug-friendly settings. DEBUG_CONF_FILE ?= $(CURDIR)/debug.conf ifeq ($(DEBUG),1) -ifneq ($(CP_BOARD_CONF),) -CP_BOARD_CONF := $(CP_BOARD_CONF);$(DEBUG_CONF_FILE) -else -CP_BOARD_CONF := $(DEBUG_CONF_FILE) +CP_BOARD_CONF := $(strip $(CP_BOARD_CONF) $(DEBUG_CONF_FILE)) endif +# When CONSOLE=1 (implied by DEBUG=1), apply the board's console.conf and console.overlay if it +# has them. Boards whose console pins are also user pins enable the Zephyr UART console only then. +CONSOLE ?= $(DEBUG) +CP_BOARD_CONSOLE_OVERLAY := +ifeq ($(CONSOLE),1) +CP_BOARD_CONF := $(strip $(CP_BOARD_CONF) $(wildcard $(CP_BOARD_DIR)/console.conf)) +CP_BOARD_CONSOLE_OVERLAY := $(wildcard $(CP_BOARD_DIR)/console.overlay) endif -# Always pass the conf file, even if empty. This undoes DEBUG=1 when it's omitted on a later run. -# Quoted because with DEBUG=1 the value is a semicolon-separated list, and an unquoted semicolon -# would end the shell command. +CP_BOARD_CONF := $(subst $(eval) ,;,$(CP_BOARD_CONF)) +# Always pass the conf and console overlay files, even if empty. This undoes DEBUG=1 or CONSOLE=1 +# when it's omitted on a later run. Quoted because the conf value can be a semicolon-separated +# list, and an unquoted semicolon would end the shell command. WEST_CMAKE_ARGS += -Dzephyr-cp_EXTRA_CONF_FILE="$(CP_BOARD_CONF)" +WEST_CMAKE_ARGS += -Dzephyr-cp_EXTRA_DTC_OVERLAY_FILE="$(CP_BOARD_CONSOLE_OVERLAY)" # Boards can opt into building the MCUboot bootloader (and thus BLE OTA # updates) by shipping a sysbuild.conf fragment. It sets sysbuild-level diff --git a/ports/zephyr-cp/README.md b/ports/zephyr-cp/README.md index 4e895936787..f3ba5e63adb 100644 --- a/ports/zephyr-cp/README.md +++ b/ports/zephyr-cp/README.md @@ -28,6 +28,11 @@ make BOARD=nordic_nrf7002dk This uses Zephyr's cmake to generate Makefiles that then delegate to `tools/cpbuild/build_circuitpython.py` to build the CircuitPython bits in parallel. +`DEBUG=1` adds the debug Kconfig settings in `debug.conf`. `CONSOLE=1` (implied +by `DEBUG=1`) applies a board's `console.conf` and `console.overlay`, if it has +them. Boards whose console UART pins are also user pins, such as the Feather +nRF52840, have the Zephyr UART console only in those builds. + ## Native simulator build container Building the native sim requires `libsdl2-dev:i386` and other 32bit dependencies that diff --git a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/board.conf b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/board.conf index eae90680937..414256fc88f 100644 --- a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/board.conf +++ b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/board.conf @@ -1,5 +1,10 @@ CONFIG_BOARD_SERIAL_BACKEND_CDC_ACM=n +# No Zephyr UART console by default (see board.overlay); a CONSOLE=1 build turns +# it back on in console.conf. +CONFIG_UART_CONSOLE=n +CONFIG_LOG_BACKEND_UART=n + # Pin numbers are module pins (MDBT50Q-1MV2 carries the aQFN73 inside). # The feathers use the MDBT50Q-1MV2 module, so pin numbers are module pins. CONFIG_IOBROKER_PACKAGE_MDBT50Q_1MV2=y diff --git a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/board.overlay b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/board.overlay index 148011e6fab..dd5cbaecef1 100644 --- a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/board.overlay +++ b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/board.overlay @@ -1,13 +1,15 @@ /* Partition layout is owned by the mcuboot fork * (dts/adafruit/adafruit_feather_nrf52840.dtsi) and applied to this image by * sysbuild. This overlay only adds board/app config. */ +/* No Zephyr console by default, so TX/RX are free for busio.UART. A CONSOLE=1 + * build puts the console back on UART0 (console.overlay). */ / { chosen { - zephyr,console = &uart0; - zephyr,shell-uart = &uart0; - zephyr,uart-mcumgr = &uart0; - zephyr,bt-mon-uart = &uart0; - zephyr,bt-c2h-uart = &uart0; + /delete-property/ zephyr,console; + /delete-property/ zephyr,shell-uart; + /delete-property/ zephyr,uart-mcumgr; + /delete-property/ zephyr,bt-mon-uart; + /delete-property/ zephyr,bt-c2h-uart; }; }; @@ -18,13 +20,26 @@ // Bus instances enabled with all pins disconnected and marked // zephyr,deferred-init so nothing is claimed at boot: the iobroker // initializes and routes a device to arbitrary pins when a busio object is -// constructed, and de-initializes it on release: I2C1, SPI2, SPI3 and UART1. -// (The fixed I2C0 is the Stemma QT bus with the SHT31 sensor, and UART0 the -// console. The formerly fixed SPI1 is dropped: it had no device attached and -// its instance is the same peripheral as I2C1, so only one mode can be -// enabled.) +// constructed, and de-initializes it on release: I2C1, SPI2, SPI3, UART0 +// and UART1. (The fixed I2C0 is the Stemma QT bus with the SHT31 sensor. The +// formerly fixed SPI1 is dropped: it had no device attached and its instance +// is the same peripheral as I2C1, so only one mode can be enabled.) &pinctrl { + uart0_dyn_default: uart0_dyn_default { + group1 { + psels = , + ; + }; + }; + + uart0_dyn_sleep: uart0_dyn_sleep { + group1 { + psels = , + ; + }; + }; + i2c1_dyn_default: i2c1_dyn_default { group1 { psels = , @@ -197,7 +212,12 @@ }; &uart0 { - status = "okay"; + status = "okay"; + zephyr,deferred-init; + current-speed = <115200>; + pinctrl-0 = <&uart0_dyn_default>; + pinctrl-1 = <&uart0_dyn_sleep>; + pinctrl-names = "default", "sleep"; }; // PWM instances, same scheme: all four outputs disconnected and deferred diff --git a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/console.conf b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/console.conf new file mode 100644 index 00000000000..83db0acb170 --- /dev/null +++ b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/console.conf @@ -0,0 +1,4 @@ +# CONSOLE=1 and DEBUG=1 builds: the Zephyr UART console on TX/RX +# (console.overlay). +CONFIG_UART_CONSOLE=y +CONFIG_LOG_BACKEND_UART=y diff --git a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/console.overlay b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/console.overlay new file mode 100644 index 00000000000..095c4aa1d2d --- /dev/null +++ b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/console.overlay @@ -0,0 +1,18 @@ +/* Applied after board.overlay in CONSOLE=1 and DEBUG=1 builds: the Zephyr + * console on UART0, fixed to the TX/RX pins. TX/RX are then not available to + * busio.UART. */ +/ { + chosen { + zephyr,console = &uart0; + zephyr,shell-uart = &uart0; + zephyr,uart-mcumgr = &uart0; + zephyr,bt-mon-uart = &uart0; + zephyr,bt-c2h-uart = &uart0; + }; +}; + +&uart0 { + /delete-property/ zephyr,deferred-init; + pinctrl-0 = <&uart0_default>; + pinctrl-1 = <&uart0_sleep>; +}; diff --git a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/board.conf b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/board.conf index eae90680937..414256fc88f 100644 --- a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/board.conf +++ b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/board.conf @@ -1,5 +1,10 @@ CONFIG_BOARD_SERIAL_BACKEND_CDC_ACM=n +# No Zephyr UART console by default (see board.overlay); a CONSOLE=1 build turns +# it back on in console.conf. +CONFIG_UART_CONSOLE=n +CONFIG_LOG_BACKEND_UART=n + # Pin numbers are module pins (MDBT50Q-1MV2 carries the aQFN73 inside). # The feathers use the MDBT50Q-1MV2 module, so pin numbers are module pins. CONFIG_IOBROKER_PACKAGE_MDBT50Q_1MV2=y diff --git a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/board.overlay b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/board.overlay index 935a5ade10d..4004ce0522a 100644 --- a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/board.overlay +++ b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/board.overlay @@ -1,13 +1,15 @@ /* Partition layout is owned by the mcuboot fork * (dts/adafruit/adafruit_feather_nrf52840.dtsi) and applied to this image by * sysbuild. This overlay only adds board/app config. */ +/* No Zephyr console by default, so TX/RX are free for busio.UART. A CONSOLE=1 + * build puts the console back on UART0 (console.overlay). */ / { chosen { - zephyr,console = &uart0; - zephyr,shell-uart = &uart0; - zephyr,uart-mcumgr = &uart0; - zephyr,bt-mon-uart = &uart0; - zephyr,bt-c2h-uart = &uart0; + /delete-property/ zephyr,console; + /delete-property/ zephyr,shell-uart; + /delete-property/ zephyr,uart-mcumgr; + /delete-property/ zephyr,bt-mon-uart; + /delete-property/ zephyr,bt-c2h-uart; }; }; @@ -18,12 +20,26 @@ // Bus instances enabled with all pins disconnected and marked // zephyr,deferred-init so nothing is claimed at boot: the iobroker // initializes and routes a device to arbitrary pins when a busio object is -// constructed, and de-initializes it on release: I2C1, SPI2, SPI3 and UART1. -// (The fixed I2C0 is the Stemma QT bus and UART0 the console. The formerly +// constructed, and de-initializes it on release: I2C1, SPI2, SPI3, UART0 +// and UART1. (The fixed I2C0 is the Stemma QT bus. The formerly // fixed SPI1 is dropped: it had no device attached and its instance is the // same peripheral as I2C1, so only one mode can be enabled.) &pinctrl { + uart0_dyn_default: uart0_dyn_default { + group1 { + psels = , + ; + }; + }; + + uart0_dyn_sleep: uart0_dyn_sleep { + group1 { + psels = , + ; + }; + }; + i2c1_dyn_default: i2c1_dyn_default { group1 { psels = , @@ -232,7 +248,12 @@ }; &uart0 { - status = "okay"; + status = "okay"; + zephyr,deferred-init; + current-speed = <115200>; + pinctrl-0 = <&uart0_dyn_default>; + pinctrl-1 = <&uart0_dyn_sleep>; + pinctrl-names = "default", "sleep"; }; #include "../../../app.overlay" diff --git a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/console.conf b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/console.conf new file mode 100644 index 00000000000..83db0acb170 --- /dev/null +++ b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/console.conf @@ -0,0 +1,4 @@ +# CONSOLE=1 and DEBUG=1 builds: the Zephyr UART console on TX/RX +# (console.overlay). +CONFIG_UART_CONSOLE=y +CONFIG_LOG_BACKEND_UART=y diff --git a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/console.overlay b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/console.overlay new file mode 100644 index 00000000000..095c4aa1d2d --- /dev/null +++ b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/console.overlay @@ -0,0 +1,18 @@ +/* Applied after board.overlay in CONSOLE=1 and DEBUG=1 builds: the Zephyr + * console on UART0, fixed to the TX/RX pins. TX/RX are then not available to + * busio.UART. */ +/ { + chosen { + zephyr,console = &uart0; + zephyr,shell-uart = &uart0; + zephyr,uart-mcumgr = &uart0; + zephyr,bt-mon-uart = &uart0; + zephyr,bt-c2h-uart = &uart0; + }; +}; + +&uart0 { + /delete-property/ zephyr,deferred-init; + pinctrl-0 = <&uart0_default>; + pinctrl-1 = <&uart0_sleep>; +}; From 3fe840296532b15822d13f8bf5ff0469e7573480 Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Mon, 12 Oct 2026 20:44:40 -0400 Subject: [PATCH 02/18] zephyr-cp: make the Feather nRF52840 I2C0 dynamic The upstream Feather nRF52840 and Feather Bluefruit Sense boards fix I2C0 to SCL/SDA, so busio.I2C() on those pins failed with "SCL in use" and the pins could not be used as GPIO. I2C0 is now a dynamic TWIM instance like I2C1. The Sense drops its SHT3XD node, which sat on the fixed bus. The pinctrl entries and device nodes in both overlays are now in alphabetical order; the compiled devicetree is otherwise unchanged. Co-Authored-By: Claude Opus 5.5 --- .../board.overlay | 205 ++++++++++-------- .../feather_nrf52840_zephyr/board.overlay | 185 +++++++++------- 2 files changed, 220 insertions(+), 170 deletions(-) diff --git a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/board.overlay b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/board.overlay index dd5cbaecef1..ed906edb858 100644 --- a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/board.overlay +++ b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/board.overlay @@ -20,23 +20,21 @@ // Bus instances enabled with all pins disconnected and marked // zephyr,deferred-init so nothing is claimed at boot: the iobroker // initializes and routes a device to arbitrary pins when a busio object is -// constructed, and de-initializes it on release: I2C1, SPI2, SPI3, UART0 -// and UART1. (The fixed I2C0 is the Stemma QT bus with the SHT31 sensor. The -// formerly fixed SPI1 is dropped: it had no device attached and its instance -// is the same peripheral as I2C1, so only one mode can be enabled.) +// constructed, and de-initializes it on release: I2C0, I2C1, SPI2, SPI3, +// UART0 and UART1. &pinctrl { - uart0_dyn_default: uart0_dyn_default { + i2c0_dyn_default: i2c0_dyn_default { group1 { - psels = , - ; + psels = , + ; }; }; - uart0_dyn_sleep: uart0_dyn_sleep { + i2c0_dyn_sleep: i2c0_dyn_sleep { group1 { - psels = , - ; + psels = , + ; }; }; @@ -54,52 +52,6 @@ }; }; - spi2_dyn_default: spi2_dyn_default { - group1 { - psels = , - , - ; - }; - }; - - spi2_dyn_sleep: spi2_dyn_sleep { - group1 { - psels = , - , - ; - }; - }; - - spi3_dyn_default: spi3_dyn_default { - group1 { - psels = , - , - ; - }; - }; - - spi3_dyn_sleep: spi3_dyn_sleep { - group1 { - psels = , - , - ; - }; - }; - - uart1_dyn_default: uart1_dyn_default { - group1 { - psels = , - ; - }; - }; - - uart1_dyn_sleep: uart1_dyn_sleep { - group1 { - psels = , - ; - }; - }; - pwm0_dyn_default: pwm0_dyn_default { group1 { psels = , @@ -171,52 +123,88 @@ ; }; }; -}; -&i2c1 { - status = "okay"; - zephyr,deferred-init; - pinctrl-0 = <&i2c1_dyn_default>; - pinctrl-1 = <&i2c1_dyn_sleep>; - pinctrl-names = "default", "sleep"; -}; + spi2_dyn_default: spi2_dyn_default { + group1 { + psels = , + , + ; + }; + }; -// SPI1 is the same peripheral instance as I2C1, so leave it disabled. -&spi1 { - status = "disabled"; -}; + spi2_dyn_sleep: spi2_dyn_sleep { + group1 { + psels = , + , + ; + }; + }; -&spi2 { - status = "okay"; - zephyr,deferred-init; - pinctrl-0 = <&spi2_dyn_default>; - pinctrl-1 = <&spi2_dyn_sleep>; - pinctrl-names = "default", "sleep"; -}; + spi3_dyn_default: spi3_dyn_default { + group1 { + psels = , + , + ; + }; + }; -&spi3 { - status = "okay"; - zephyr,deferred-init; - pinctrl-0 = <&spi3_dyn_default>; - pinctrl-1 = <&spi3_dyn_sleep>; - pinctrl-names = "default", "sleep"; + spi3_dyn_sleep: spi3_dyn_sleep { + group1 { + psels = , + , + ; + }; + }; + + uart0_dyn_default: uart0_dyn_default { + group1 { + psels = , + ; + }; + }; + + uart0_dyn_sleep: uart0_dyn_sleep { + group1 { + psels = , + ; + }; + }; + + uart1_dyn_default: uart1_dyn_default { + group1 { + psels = , + ; + }; + }; + + uart1_dyn_sleep: uart1_dyn_sleep { + group1 { + psels = , + ; + }; + }; }; -&uart1 { +// The upstream board fixes I2C0 to SCL/SDA with the legacy TWI driver. Use +// TWIM like I2C1 so iobroker can route it. +&i2c0 { + compatible = "nordic,nrf-twim"; status = "okay"; zephyr,deferred-init; - current-speed = <115200>; - pinctrl-0 = <&uart1_dyn_default>; - pinctrl-1 = <&uart1_dyn_sleep>; + pinctrl-0 = <&i2c0_dyn_default>; + pinctrl-1 = <&i2c0_dyn_sleep>; pinctrl-names = "default", "sleep"; }; -&uart0 { +// CircuitPython drives the SHT31 from Python, so drop the Zephyr sensor node +// on the now dynamic I2C0. +/delete-node/ &SHT3XD; + +&i2c1 { status = "okay"; zephyr,deferred-init; - current-speed = <115200>; - pinctrl-0 = <&uart0_dyn_default>; - pinctrl-1 = <&uart0_dyn_sleep>; + pinctrl-0 = <&i2c1_dyn_default>; + pinctrl-1 = <&i2c1_dyn_sleep>; pinctrl-names = "default", "sleep"; }; @@ -256,6 +244,45 @@ pinctrl-names = "default", "sleep"; }; +// SPI1 is the same peripheral instance as I2C1, so leave it disabled. +&spi1 { + status = "disabled"; +}; + +&spi2 { + status = "okay"; + zephyr,deferred-init; + pinctrl-0 = <&spi2_dyn_default>; + pinctrl-1 = <&spi2_dyn_sleep>; + pinctrl-names = "default", "sleep"; +}; + +&spi3 { + status = "okay"; + zephyr,deferred-init; + pinctrl-0 = <&spi3_dyn_default>; + pinctrl-1 = <&spi3_dyn_sleep>; + pinctrl-names = "default", "sleep"; +}; + +&uart0 { + status = "okay"; + zephyr,deferred-init; + current-speed = <115200>; + pinctrl-0 = <&uart0_dyn_default>; + pinctrl-1 = <&uart0_dyn_sleep>; + pinctrl-names = "default", "sleep"; +}; + +&uart1 { + status = "okay"; + zephyr,deferred-init; + current-speed = <115200>; + pinctrl-0 = <&uart1_dyn_default>; + pinctrl-1 = <&uart1_dyn_sleep>; + pinctrl-names = "default", "sleep"; +}; + #include "../../../app.overlay" /* Enable the SAADC for analogio (AIN0..AIN7 are on P0.02..P0.05 and P0.28..P0.31). */ diff --git a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/board.overlay b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/board.overlay index 4004ce0522a..dfdf0a4f184 100644 --- a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/board.overlay +++ b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/board.overlay @@ -20,23 +20,21 @@ // Bus instances enabled with all pins disconnected and marked // zephyr,deferred-init so nothing is claimed at boot: the iobroker // initializes and routes a device to arbitrary pins when a busio object is -// constructed, and de-initializes it on release: I2C1, SPI2, SPI3, UART0 -// and UART1. (The fixed I2C0 is the Stemma QT bus. The formerly -// fixed SPI1 is dropped: it had no device attached and its instance is the -// same peripheral as I2C1, so only one mode can be enabled.) +// constructed, and de-initializes it on release: I2C0, I2C1, SPI2, SPI3, +// UART0 and UART1. &pinctrl { - uart0_dyn_default: uart0_dyn_default { + i2c0_dyn_default: i2c0_dyn_default { group1 { - psels = , - ; + psels = , + ; }; }; - uart0_dyn_sleep: uart0_dyn_sleep { + i2c0_dyn_sleep: i2c0_dyn_sleep { group1 { - psels = , - ; + psels = , + ; }; }; @@ -54,52 +52,6 @@ }; }; - spi2_dyn_default: spi2_dyn_default { - group1 { - psels = , - , - ; - }; - }; - - spi2_dyn_sleep: spi2_dyn_sleep { - group1 { - psels = , - , - ; - }; - }; - - spi3_dyn_default: spi3_dyn_default { - group1 { - psels = , - , - ; - }; - }; - - spi3_dyn_sleep: spi3_dyn_sleep { - group1 { - psels = , - , - ; - }; - }; - - uart1_dyn_default: uart1_dyn_default { - group1 { - psels = , - ; - }; - }; - - uart1_dyn_sleep: uart1_dyn_sleep { - group1 { - psels = , - ; - }; - }; - pwm0_dyn_default: pwm0_dyn_default { group1 { psels = , @@ -171,43 +123,84 @@ ; }; }; -}; -&i2c1 { - status = "okay"; - zephyr,deferred-init; - pinctrl-0 = <&i2c1_dyn_default>; - pinctrl-1 = <&i2c1_dyn_sleep>; - pinctrl-names = "default", "sleep"; -}; + spi2_dyn_default: spi2_dyn_default { + group1 { + psels = , + , + ; + }; + }; -// SPI1 is the same peripheral instance as I2C1, so leave it disabled. -&spi1 { - status = "disabled"; -}; + spi2_dyn_sleep: spi2_dyn_sleep { + group1 { + psels = , + , + ; + }; + }; -&spi2 { - status = "okay"; - zephyr,deferred-init; - pinctrl-0 = <&spi2_dyn_default>; - pinctrl-1 = <&spi2_dyn_sleep>; - pinctrl-names = "default", "sleep"; + spi3_dyn_default: spi3_dyn_default { + group1 { + psels = , + , + ; + }; + }; + + spi3_dyn_sleep: spi3_dyn_sleep { + group1 { + psels = , + , + ; + }; + }; + + uart0_dyn_default: uart0_dyn_default { + group1 { + psels = , + ; + }; + }; + + uart0_dyn_sleep: uart0_dyn_sleep { + group1 { + psels = , + ; + }; + }; + + uart1_dyn_default: uart1_dyn_default { + group1 { + psels = , + ; + }; + }; + + uart1_dyn_sleep: uart1_dyn_sleep { + group1 { + psels = , + ; + }; + }; }; -&spi3 { +// The upstream board fixes I2C0 to SCL/SDA with the legacy TWI driver. Use +// TWIM like I2C1 so iobroker can route it. +&i2c0 { + compatible = "nordic,nrf-twim"; status = "okay"; zephyr,deferred-init; - pinctrl-0 = <&spi3_dyn_default>; - pinctrl-1 = <&spi3_dyn_sleep>; + pinctrl-0 = <&i2c0_dyn_default>; + pinctrl-1 = <&i2c0_dyn_sleep>; pinctrl-names = "default", "sleep"; }; -&uart1 { +&i2c1 { status = "okay"; zephyr,deferred-init; - current-speed = <115200>; - pinctrl-0 = <&uart1_dyn_default>; - pinctrl-1 = <&uart1_dyn_sleep>; + pinctrl-0 = <&i2c1_dyn_default>; + pinctrl-1 = <&i2c1_dyn_sleep>; pinctrl-names = "default", "sleep"; }; @@ -247,6 +240,27 @@ pinctrl-names = "default", "sleep"; }; +// SPI1 is the same peripheral instance as I2C1, so leave it disabled. +&spi1 { + status = "disabled"; +}; + +&spi2 { + status = "okay"; + zephyr,deferred-init; + pinctrl-0 = <&spi2_dyn_default>; + pinctrl-1 = <&spi2_dyn_sleep>; + pinctrl-names = "default", "sleep"; +}; + +&spi3 { + status = "okay"; + zephyr,deferred-init; + pinctrl-0 = <&spi3_dyn_default>; + pinctrl-1 = <&spi3_dyn_sleep>; + pinctrl-names = "default", "sleep"; +}; + &uart0 { status = "okay"; zephyr,deferred-init; @@ -256,6 +270,15 @@ pinctrl-names = "default", "sleep"; }; +&uart1 { + status = "okay"; + zephyr,deferred-init; + current-speed = <115200>; + pinctrl-0 = <&uart1_dyn_default>; + pinctrl-1 = <&uart1_dyn_sleep>; + pinctrl-names = "default", "sleep"; +}; + #include "../../../app.overlay" /* Enable the SAADC for analogio (AIN0..AIN7 are on P0.02..P0.05 and P0.28..P0.31). */ From 7454a0698a8d25f7a29d870039d0c4e333a1bc79 Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Mon, 12 Oct 2026 20:44:58 -0400 Subject: [PATCH 03/18] zephyr-cp: define board.I2C(), SPI() and UART() in circuitpython.toml zephyr2cp.py named a board's main bus after the single enabled devicetree instance of each kind, which doesn't work once the instances are dynamic iobroker pools. A board now lists its buses as [[board_i2c]], [[board_spi]] and [[board_uart]] tables in circuitpython.toml, each with its board names and pins, and zephyr2cp.py generates the CIRCUITPY_BOARD_* pin tables for shared-module/board. As in the other ports, each bus is constructed on its pins on first use. The Feather nRF52840 and Feather Bluefruit Sense define I2C, SPI and UART this way. Co-Authored-By: Claude Opus 5.5 --- .../circuitpython.toml | 18 ++++ .../circuitpython.toml | 18 ++++ .../zephyr-cp/cptools/tests/test_zephyr2cp.py | 69 +++++++++++++ ports/zephyr-cp/cptools/zephyr2cp.py | 99 ++++++++++++++++++- 4 files changed, 202 insertions(+), 2 deletions(-) diff --git a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/circuitpython.toml b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/circuitpython.toml index 985f53ca662..ecea8e87174 100644 --- a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/circuitpython.toml +++ b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/circuitpython.toml @@ -54,3 +54,21 @@ MICROPHONE_CLOCK = 18 MICROPHONE_DATA = 17 PROXIMITY_LIGHT_INTERRUPT = 47 ACCELEROMETER_GYRO_INTERRUPT = 4 + +# Board buses, as on the ports/ counterpart: board.I2C(), board.SPI() and +# board.UART(), constructed on these pins when first used. +[[board_i2c]] +names = ["I2C"] +scl = "SCL" +sda = "SDA" + +[[board_spi]] +names = ["SPI"] +clock = "SCK" +mosi = "MOSI" +miso = "MISO" + +[[board_uart]] +names = ["UART"] +tx = "TX" +rx = "RX" diff --git a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/circuitpython.toml b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/circuitpython.toml index ef4dc115a83..db6d04ed545 100644 --- a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/circuitpython.toml +++ b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/circuitpython.toml @@ -52,3 +52,21 @@ LED = 8 RED_LED = 8 D3 = 8 BLUE_LED = 3 + +# Board buses, as on the ports/ counterpart: board.I2C(), board.SPI() and +# board.UART(), constructed on these pins when first used. +[[board_i2c]] +names = ["I2C"] +scl = "SCL" +sda = "SDA" + +[[board_spi]] +names = ["SPI"] +clock = "SCK" +mosi = "MOSI" +miso = "MISO" + +[[board_uart]] +names = ["UART"] +tx = "TX" +rx = "RX" diff --git a/ports/zephyr-cp/cptools/tests/test_zephyr2cp.py b/ports/zephyr-cp/cptools/tests/test_zephyr2cp.py index 556aec0cccb..d69abc4f2a9 100644 --- a/ports/zephyr-cp/cptools/tests/test_zephyr2cp.py +++ b/ports/zephyr-cp/cptools/tests/test_zephyr2cp.py @@ -23,6 +23,7 @@ BLOCKED_FLASH_COMPAT, MINIMUM_RAM_SIZE, add_toml_pin_names, + _toml_board_buses, _toml_status_pin, ) @@ -796,3 +797,71 @@ def test_absent_key_is_none(self): def test_unknown_name_raises(self): with pytest.raises(RuntimeError, match="STATUS_NEOPIXEL = 'NOPE' is not a board pin name"): _toml_status_pin({"STATUS_NEOPIXEL": "NOPE"}, self.BOARD_NAMES, "STATUS_NEOPIXEL") + + +class TestTomlBoardBuses: + """Resolution of circuitpython.toml [[board_i2c]]/[[board_spi]]/[[board_uart]].""" + + BOARD_NAMES = { + ("gpio0", 11): ["SCL"], + ("gpio0", 12): ["SDA"], + ("gpio0", 14): ["SCK"], + ("gpio0", 13): ["MOSI"], + ("gpio0", 25): ["TX"], + ("gpio0", 24): ["RX"], + ("gpio1", 1): ["SCL1"], + ("gpio1", 2): ["SDA1"], + } + + def test_resolves_buses_in_order(self): + buses = _toml_board_buses( + { + "board_i2c": [ + {"names": ["I2C", "stemma_i2c"], "scl": "SCL", "sda": "SDA"}, + {"names": ["I2C1"], "scl": "SCL1", "sda": "SDA1"}, + ], + "board_uart": [{"names": ["UART"], "tx": "TX", "rx": "RX"}], + }, + self.BOARD_NAMES, + ) + assert buses == { + "i2c": [ + (["I2C", "STEMMA_I2C"], {"scl": ("gpio0", 11), "sda": ("gpio0", 12)}), + (["I2C1"], {"scl": ("gpio1", 1), "sda": ("gpio1", 2)}), + ], + "uart": [(["UART"], {"tx": ("gpio0", 25), "rx": ("gpio0", 24)})], + } + + def test_optional_pins_may_be_absent(self): + buses = _toml_board_buses( + {"board_spi": [{"names": ["SPI"], "clock": "SCK", "mosi": "MOSI"}]}, + self.BOARD_NAMES, + ) + assert buses == {"spi": [(["SPI"], {"clock": ("gpio0", 14), "mosi": ("gpio0", 13)})]} + + def test_absent_tables_are_empty(self): + assert _toml_board_buses({}, self.BOARD_NAMES) == {} + assert _toml_board_buses(None, self.BOARD_NAMES) == {} + + def test_missing_names_raises(self): + with pytest.raises(RuntimeError, match=r"\[\[board_i2c\]\] needs names"): + _toml_board_buses({"board_i2c": [{"scl": "SCL", "sda": "SDA"}]}, self.BOARD_NAMES) + + def test_missing_required_pin_raises(self): + with pytest.raises(RuntimeError, match=r"\[\[board_i2c\]\] needs sda"): + _toml_board_buses({"board_i2c": [{"names": ["I2C"], "scl": "SCL"}]}, self.BOARD_NAMES) + + def test_no_pins_raises(self): + with pytest.raises(RuntimeError, match="needs at least one pin"): + _toml_board_buses({"board_uart": [{"names": ["UART"]}]}, self.BOARD_NAMES) + + def test_unknown_key_raises(self): + with pytest.raises(RuntimeError, match=r"unknown keys \['sck'\]"): + _toml_board_buses({"board_spi": [{"names": ["SPI"], "sck": "SCK"}]}, self.BOARD_NAMES) + + def test_unknown_pin_raises(self): + with pytest.raises(RuntimeError, match="'NOPE' is not a board pin name"): + _toml_board_buses( + {"board_i2c": [{"names": ["I2C"], "scl": "NOPE", "sda": "SDA"}]}, + self.BOARD_NAMES, + ) diff --git a/ports/zephyr-cp/cptools/zephyr2cp.py b/ports/zephyr-cp/cptools/zephyr2cp.py index 660b263262f..f55b0582959 100644 --- a/ports/zephyr-cp/cptools/zephyr2cp.py +++ b/ports/zephyr-cp/cptools/zephyr2cp.py @@ -745,6 +745,52 @@ def _toml_status_pin(mpconfigboard, board_names, key): raise RuntimeError(f"circuitpython.toml {key} = {name!r} is not a board pin name") +# Pin fields of the circuitpython.toml [[board_i2c]], [[board_spi]] and +# [[board_uart]] tables, as (field, required). The field names match the +# CIRCUITPY_BOARD_*_PIN structs in shared-module/board. +BOARD_BUS_PINS = { + "i2c": (("scl", True), ("sda", True)), + "spi": (("clock", True), ("mosi", False), ("miso", False)), + "uart": (("tx", False), ("rx", False)), +} +# The board bus class each busio-capable devicetree driver provides. +BOARD_BUS_OF_DRIVER = {"i2c": "i2c", "spi": "spi", "serial": "uart"} + + +def _toml_board_buses(mpconfigboard, board_names): + """Resolve the circuitpython.toml board bus tables to pins. + + Each ``[[board_i2c]]``, ``[[board_spi]]`` or ``[[board_uart]]`` table is + one board bus, in instance order: ``names`` lists its board names and the + other keys name its pins. Returns ``{bus: [(names, {field: pin}), ...]}`` + for the bus classes the toml defines. + """ + buses = {} + for bus, fields in BOARD_BUS_PINS.items(): + key = f"board_{bus}" + resolved = [] + for table in (mpconfigboard or {}).get(key, []): + field_names = {field for field, _ in fields} + unknown = sorted(set(table) - field_names - {"names"}) + if unknown: + raise RuntimeError(f"circuitpython.toml [[{key}]] has unknown keys {unknown}") + names = table.get("names") + if not names: + raise RuntimeError(f"circuitpython.toml [[{key}]] needs names") + pins = {} + for field, required in fields: + if field in table: + pins[field] = _toml_status_pin(table, board_names, field) + elif required: + raise RuntimeError(f"circuitpython.toml [[{key}]] needs {field}") + if not pins: + raise RuntimeError(f"circuitpython.toml [[{key}]] needs at least one pin") + resolved.append(([str(name).upper() for name in names], pins)) + if resolved: + buses[bus] = resolved + return buses + + @cpbuild.run_in_thread def zephyr_dts_to_cp_board(board_id, portdir, builddir, zephyrbuilddir, mpconfigboard=None): # noqa: C901 board_dir = builddir / "board" @@ -1190,6 +1236,13 @@ def zephyr_dts_to_cp_board(board_id, portdir, builddir, zephyrbuilddir, mpconfig # the [pins] table, that the status LED code drives with neopixel_write. status_neopixel = _toml_status_pin(mpconfigboard, board_names, "STATUS_NEOPIXEL") status_neopixel_power = _toml_status_pin(mpconfigboard, board_names, "STATUS_NEOPIXEL_POWER") + # board.I2C(), board.SPI(), board.UART() and other named board buses from + # circuitpython.toml, constructed on demand on their pins. + board_buses = _toml_board_buses(mpconfigboard, board_names) + board_bus_names = { + name for tables in board_buses.values() for names, _ in tables for name in names + } + pin_object_of = {} # GPIO pad number -> pad: every (label, num) name and alias the # devicetree walk produced keys into one pin object per pad. pad_of_gpio = {} @@ -1249,6 +1302,7 @@ def zephyr_dts_to_cp_board(board_id, portdir, builddir, zephyrbuilddir, mpconfig status_neopixel = pin_object_name if status_neopixel_power and (ioport, num) == status_neopixel_power: status_neopixel_power = pin_object_name + pin_object_of[(ioport, num)] = pin_object_name pin_defs.append( f"const mcu_pin_obj_t pin_{pin_object_name} = {{ .base.type = &mcu_pin_type, .package_pin = {package_pin_init}}};" ) @@ -1324,8 +1378,11 @@ def zephyr_dts_to_cp_board(board_id, portdir, builddir, zephyrbuilddir, mpconfig zephyr_binding_headers.append(f'#include "shared-bindings/{module}/{driverclass}.h"') - # Designate a main device such as board.I2C or board.I2S. - if len(instances) == 1: + # Designate a main device such as board.I2C or board.I2S, unless + # circuitpython.toml defines the board buses of this class. + if BOARD_BUS_OF_DRIVER.get(driver) in board_buses: + pass + elif len(instances) == 1: instances[0].append(driverclass) else: # Check to see if a main device has already been designated @@ -1381,6 +1438,9 @@ def zephyr_dts_to_cp_board(board_id, portdir, builddir, zephyrbuilddir, mpconfig static MP_DEFINE_CONST_FUN_OBJ_0({function_object}, {c_function_name});""".lstrip() ) for label in labels: + if label.upper() in board_bus_names: + # A circuitpython.toml board bus has this name. + continue zephyr_binding_labels.append( f"{{ MP_ROM_QSTR(MP_QSTR_{label.upper()}), MP_ROM_PTR(&{function_object}) }}," ) @@ -1388,6 +1448,38 @@ def zephyr_dts_to_cp_board(board_id, portdir, builddir, zephyrbuilddir, mpconfig zephyr_binding_objects = "\n".join(zephyr_binding_objects) zephyr_binding_labels = "\n".join(zephyr_binding_labels) + # Board buses from circuitpython.toml: the CIRCUITPY_BOARD_* pin tables for + # shared-module/board, and board names for each bus. Bus 0 of a class is + # board__obj; later ones get their own singleton. + board_bus_defines = [] + board_bus_objects = [] + board_bus_labels = [] + for bus, tables in board_buses.items(): + entries = [] + for index, (names, pins) in enumerate(tables): + fields = ", ".join( + f".{field} = &pin_{pin_object_of[pin]}" for field, pin in pins.items() + ) + entries.append(f"{{{fields}}}") + if index == 0: + function_object = f"board_{bus}_obj" + else: + board_bus_objects.append( + f"CIRCUITPY_BOARD_BUS_SINGLETON({names[0].lower()}, {bus}, {index})" + ) + function_object = f"board_{names[0].lower()}_obj" + for name in names: + board_bus_labels.append( + f"{{ MP_ROM_QSTR(MP_QSTR_{name}), MP_ROM_PTR(&{function_object}) }}," + ) + board_bus_defines.append(f"#define CIRCUITPY_BOARD_{bus.upper()} ({len(tables)})") + board_bus_defines.append( + f"#define CIRCUITPY_BOARD_{bus.upper()}_PIN {{{', '.join(entries)}}}" + ) + board_bus_defines = "\n".join(board_bus_defines) + board_bus_objects = "\n".join(board_bus_objects) + board_bus_labels = "\n".join(board_bus_labels) + # Generate tables of allocatable bus instances for the iobroker # Zephyr module (dynamic pin routing; nRF SoCs). Instances enabled with # all-disconnected pinctrl can be routed to arbitrary pins at runtime; @@ -1682,6 +1774,7 @@ def zephyr_dts_to_cp_board(board_id, portdir, builddir, zephyrbuilddir, mpconfig {boot_button_active_high} {status_neopixel} {status_neopixel_power} +{board_bus_defines} """ if not header.exists() or header.read_text() != new_header_content: header.write_text(new_header_content) @@ -1731,6 +1824,7 @@ def zephyr_dts_to_cp_board(board_id, portdir, builddir, zephyrbuilddir, mpconfig {pin_defs} {zephyr_binding_objects} +{board_bus_objects} {iobroker_tables} {zephyr_display_object} {i2sout_reset_func} @@ -1748,6 +1842,7 @@ def zephyr_dts_to_cp_board(board_id, portdir, builddir, zephyrbuilddir, mpconfig {board_pin_mapping} {zephyr_binding_labels} +{board_bus_labels} }}; From 11d8f1d4cb4cf4736b4c87857f4e98c2185e2ec4 Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Mon, 12 Oct 2026 20:45:02 -0400 Subject: [PATCH 04/18] zephyr-cp: allocate the fastest bus instance first iobroker hands out the first free instance in a pool, and the pools were in devicetree order, so busio.SPI() on the nRF52840 got the 8 MHz SPIM2 before the 32 MHz SPIM3. zephyr2cp.py now orders each pool by the instances' max-frequency, highest first, keeping devicetree order among equals. Co-Authored-By: Claude Opus 5.5 --- .../zephyr-cp/cptools/tests/test_zephyr2cp.py | 33 +++++++++++++++++++ ports/zephyr-cp/cptools/zephyr2cp.py | 24 +++++++++++--- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/ports/zephyr-cp/cptools/tests/test_zephyr2cp.py b/ports/zephyr-cp/cptools/tests/test_zephyr2cp.py index d69abc4f2a9..538f6923764 100644 --- a/ports/zephyr-cp/cptools/tests/test_zephyr2cp.py +++ b/ports/zephyr-cp/cptools/tests/test_zephyr2cp.py @@ -1,6 +1,7 @@ import sys import pathlib import tempfile +from types import SimpleNamespace # Add devicetree library to path portdir = pathlib.Path(__file__).parent.parent.parent @@ -23,6 +24,7 @@ BLOCKED_FLASH_COMPAT, MINIMUM_RAM_SIZE, add_toml_pin_names, + _fastest_first, _toml_board_buses, _toml_status_pin, ) @@ -865,3 +867,34 @@ def test_unknown_pin_raises(self): {"board_i2c": [{"names": ["I2C"], "scl": "NOPE", "sda": "SDA"}]}, self.BOARD_NAMES, ) + + +class TestFastestFirst: + """Ordering of bus instance pools by devicetree max-frequency.""" + + @staticmethod + def _node(name, max_frequency=None): + props = {} + if max_frequency is not None: + props["max-frequency"] = SimpleNamespace(to_num=lambda: max_frequency) + return SimpleNamespace(name=name, props=props) + + def test_highest_first(self): + nodes = [self._node("spi2", 8_000_000), self._node("spi3", 32_000_000)] + assert [n.name for n in _fastest_first(nodes)] == ["spi3", "spi2"] + + def test_stable_for_equal_and_missing(self): + nodes = [ + self._node("i2c1"), + self._node("spi22", 8_000_000), + self._node("i2c0"), + self._node("spi21", 8_000_000), + self._node("spi00", 32_000_000), + ] + assert [n.name for n in _fastest_first(nodes)] == [ + "spi00", + "spi22", + "spi21", + "i2c1", + "i2c0", + ] diff --git a/ports/zephyr-cp/cptools/zephyr2cp.py b/ports/zephyr-cp/cptools/zephyr2cp.py index f55b0582959..b9d481b7b88 100644 --- a/ports/zephyr-cp/cptools/zephyr2cp.py +++ b/ports/zephyr-cp/cptools/zephyr2cp.py @@ -525,6 +525,21 @@ def _reg_addr_expr(device_tree, label): return f"DT_REG_ADDR(DT_NODELABEL({label}))" +def _fastest_first(nodes): + """Order bus instance nodes by their ``max-frequency``, highest first. + + iobroker hands out the first free instance in a pool, so this gives + busio the fastest one first, such as the nRF52840's 32 MHz SPIM3. The + sort is stable: nodes with equal or no ``max-frequency`` keep their order. + """ + + def max_frequency(node): + prop = node.props.get("max-frequency") + return prop.to_num() if prop is not None else 0 + + return sorted(nodes, key=max_frequency, reverse=True) + + def _pinctrl_default_psels(node): """Return the raw nRF psel entries of a node's "default" pinctrl state. @@ -1552,8 +1567,9 @@ def zephyr_dts_to_cp_board(board_id, portdir, builddir, zephyrbuilddir, mpconfig instances = ( active_zephyr_devices.get(driver, []) if driver != "pwm" or pwm_nrfx else [] ) - for labels in instances: - node = device_tree.label2node[labels[0]] + nodes = _fastest_first(device_tree.label2node[labels[0]] for labels in instances) + for node in nodes: + label = node.labels[0] if node in path2chosen: # Console and other system devices are not allocatable. continue @@ -1561,9 +1577,9 @@ def zephyr_dts_to_cp_board(board_id, portdir, builddir, zephyrbuilddir, mpconfig if psels is None or len(psels) > 4: continue if all((value & NRF_PIN_FIELD_MASK) == NRF_PIN_FIELD_MASK for value in psels): - dynamic_entries.append((labels[0], None, 0)) + dynamic_entries.append((label, None, 0)) else: - fixed_entries.append((labels[0], psels, len(psels))) + fixed_entries.append((label, psels, len(psels))) entries = dynamic_entries + fixed_entries From fcb193973e582c5ea9dc91328f396ea24520313d Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Mon, 12 Oct 2026 20:45:07 -0400 Subject: [PATCH 05/18] zephyr-cp: document how nRF serial instances are shared and allocated Add an "nRF serial instances" section to the iobroker README, the counterpart of the comments in ports/nordic's nrfx_config.h: I2C, SPI and UART modes share an instance, the board overlay picks each instance's mode, the Feather split matches ports/nordic, and pools are allocated fastest first. Each nRF board overlay points to it. Co-Authored-By: Claude Opus 5.5 --- .../clue_nrf52840_zephyr/board.overlay | 3 ++ .../board.overlay | 3 ++ .../feather_nrf52840_zephyr/board.overlay | 3 ++ .../boards/nordic/nrf5340dk/board.overlay | 3 ++ .../boards/nordic/nrf54h20dk/board.overlay | 3 ++ .../boards/nordic/nrf54l15dk/board.overlay | 3 ++ .../boards/nordic/nrf54l15tag/board.overlay | 3 ++ .../boards/nordic/nrf54lm20dk/board.overlay | 3 ++ .../boards/nordic/nrf7002dk/board.overlay | 3 ++ .../internal-modules/iobroker/README.md | 31 +++++++++++++++++++ 10 files changed, 58 insertions(+) diff --git a/ports/zephyr-cp/boards/adafruit/clue_nrf52840_zephyr/board.overlay b/ports/zephyr-cp/boards/adafruit/clue_nrf52840_zephyr/board.overlay index d7a16eacb50..08e919bfcce 100644 --- a/ports/zephyr-cp/boards/adafruit/clue_nrf52840_zephyr/board.overlay +++ b/ports/zephyr-cp/boards/adafruit/clue_nrf52840_zephyr/board.overlay @@ -21,6 +21,9 @@ // constructed, and de-initializes it on release. (I2C0 is the fixed sensor // bus, SPI2 the fixed display bus, and QSPI the fixed external flash.) // +// See "nRF serial instances" in internal-modules/iobroker/README.md for how +// the instances are shared and allocated. +// // I2C1 and SPI1 are the same peripheral instance (0x40004000), so only one // mode can be enabled: I2C is the more useful dynamic bus on the CLUE. // SPI1 is left disabled; UART1 is enabled by neither overlay because UART0 diff --git a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/board.overlay b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/board.overlay index ed906edb858..ac3edc35c66 100644 --- a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/board.overlay +++ b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/board.overlay @@ -22,6 +22,9 @@ // initializes and routes a device to arbitrary pins when a busio object is // constructed, and de-initializes it on release: I2C0, I2C1, SPI2, SPI3, // UART0 and UART1. +// +// See "nRF serial instances" in internal-modules/iobroker/README.md for how +// the instances are shared and allocated. &pinctrl { i2c0_dyn_default: i2c0_dyn_default { diff --git a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/board.overlay b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/board.overlay index dfdf0a4f184..b2bcdd5b827 100644 --- a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/board.overlay +++ b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/board.overlay @@ -22,6 +22,9 @@ // initializes and routes a device to arbitrary pins when a busio object is // constructed, and de-initializes it on release: I2C0, I2C1, SPI2, SPI3, // UART0 and UART1. +// +// See "nRF serial instances" in internal-modules/iobroker/README.md for how +// the instances are shared and allocated. &pinctrl { i2c0_dyn_default: i2c0_dyn_default { diff --git a/ports/zephyr-cp/boards/nordic/nrf5340dk/board.overlay b/ports/zephyr-cp/boards/nordic/nrf5340dk/board.overlay index 996a9e5aa42..50b2583f827 100644 --- a/ports/zephyr-cp/boards/nordic/nrf5340dk/board.overlay +++ b/ports/zephyr-cp/boards/nordic/nrf5340dk/board.overlay @@ -29,6 +29,9 @@ i2s_rxtx: &i2s0 { // claimed at boot; iobroker initializes and routes a device when a busio // object is constructed and de-initializes it on release. // +// See "nRF serial instances" in internal-modules/iobroker/README.md for how +// the instances are shared and allocated. +// // Instance 0 is left alone: uart0 is the console and i2c0/spi0 share that // same peripheral, so they must not be enabled. Instance 1 stays with the // fixed arduino header i2c1, and spi4 stays the fixed arduino header SPI. diff --git a/ports/zephyr-cp/boards/nordic/nrf54h20dk/board.overlay b/ports/zephyr-cp/boards/nordic/nrf54h20dk/board.overlay index 35d0af63aeb..f4c8dbcdacf 100644 --- a/ports/zephyr-cp/boards/nordic/nrf54h20dk/board.overlay +++ b/ports/zephyr-cp/boards/nordic/nrf54h20dk/board.overlay @@ -44,6 +44,9 @@ // nothing is claimed at boot; iobroker initializes and routes a device when a // busio object is constructed and de-initializes it on release. // +// See "nRF serial instances" in internal-modules/iobroker/README.md for how +// the instances are shared and allocated. +// // Instance 130 stays with the fixed arduino header i2c130. Instances 120, // 135 and 136 are left alone: uart120 and uart135 are the console UARTs of // the (not built here) FLPR and PPR cores, and uart136 is the app console. diff --git a/ports/zephyr-cp/boards/nordic/nrf54l15dk/board.overlay b/ports/zephyr-cp/boards/nordic/nrf54l15dk/board.overlay index cf0ff6756cc..03d399f05b3 100644 --- a/ports/zephyr-cp/boards/nordic/nrf54l15dk/board.overlay +++ b/ports/zephyr-cp/boards/nordic/nrf54l15dk/board.overlay @@ -8,6 +8,9 @@ // claimed at boot; iobroker initializes and routes a device when a busio // object is constructed and de-initializes it on release. // +// See "nRF serial instances" in internal-modules/iobroker/README.md for how +// the instances are shared and allocated. +// // Instance 20 is left alone: uart20 is the console (main UART) and i2c20/spi20 // share that same peripheral, so they must not be enabled. Instance 00 is // owned by spi00 and the MX25R64 SPI flash (no TWI00 on this SoC). diff --git a/ports/zephyr-cp/boards/nordic/nrf54l15tag/board.overlay b/ports/zephyr-cp/boards/nordic/nrf54l15tag/board.overlay index 6dd08bfce59..627a28177ce 100644 --- a/ports/zephyr-cp/boards/nordic/nrf54l15tag/board.overlay +++ b/ports/zephyr-cp/boards/nordic/nrf54l15tag/board.overlay @@ -46,6 +46,9 @@ // claimed at boot; iobroker initializes and routes a device when a busio // object is constructed and de-initializes it on release. // +// See "nRF serial instances" in internal-modules/iobroker/README.md for how +// the instances are shared and allocated. +// // Instance 21 stays with the fixed i2c21 (bme688 and adxl367 hang off it) and // instance 22 with the fixed spi22 (bmi270). Instance 30 is left alone: // uart30 is the console above and i2c30/spi30 share that same peripheral, so diff --git a/ports/zephyr-cp/boards/nordic/nrf54lm20dk/board.overlay b/ports/zephyr-cp/boards/nordic/nrf54lm20dk/board.overlay index e8ffce9a459..e07eac751f5 100644 --- a/ports/zephyr-cp/boards/nordic/nrf54lm20dk/board.overlay +++ b/ports/zephyr-cp/boards/nordic/nrf54lm20dk/board.overlay @@ -14,6 +14,9 @@ // claimed at boot; iobroker initializes and routes a device when a busio // object is constructed and de-initializes it on release. // +// See "nRF serial instances" in internal-modules/iobroker/README.md for how +// the instances are shared and allocated. +// // Instance 20 is left alone: uart20 is the console (main UART) and i2c20/spi20 // share that same peripheral, so they must not be enabled. Instance 00 is // owned by spi00 and the MX25R64 SPI flash, so uart00 must not be enabled. diff --git a/ports/zephyr-cp/boards/nordic/nrf7002dk/board.overlay b/ports/zephyr-cp/boards/nordic/nrf7002dk/board.overlay index 0103b74e9e6..eeb4e248ecf 100644 --- a/ports/zephyr-cp/boards/nordic/nrf7002dk/board.overlay +++ b/ports/zephyr-cp/boards/nordic/nrf7002dk/board.overlay @@ -6,6 +6,9 @@ // claimed at boot; iobroker initializes and routes a device when a busio // object is constructed and de-initializes it on release. // +// See "nRF serial instances" in internal-modules/iobroker/README.md for how +// the instances are shared and allocated. +// // Instance 0 is left alone: uart0 is the console and i2c0/spi0 share that // same peripheral, so they must not be enabled. Instance 1 stays with the // fixed arduino header i2c1, and spi4 stays fixed because it owns the diff --git a/ports/zephyr-cp/internal-modules/iobroker/README.md b/ports/zephyr-cp/internal-modules/iobroker/README.md index 6ddacaaee7e..6435dc43373 100644 --- a/ports/zephyr-cp/internal-modules/iobroker/README.md +++ b/ports/zephyr-cp/internal-modules/iobroker/README.md @@ -136,6 +136,37 @@ within the port) shared with the pin objects, and let the module resolve a global pin number back to the controller device and pin number that Zephyr's GPIO API takes. +## nRF serial instances + +On nRF SoCs, the I2C and SPI nodes with the same instance number are one +peripheral, and on nRF53, nRF54L and nRF91 so is the UART node: on the +nRF52840, `i2c1` and `spi1` are both the peripheral at 0x40004000. Only one +mode of an instance can be enabled, which Zephyr checks at build time +(`zephyr/soc/nordic/validate_enabled_instances.c`). + +So the board overlay decides how many instances of each kind there are. It +enables each free instance in one mode, with all signals disconnected and +`zephyr,deferred-init`, and leaves alone the instances the board uses for a +fixed purpose, such as the console or external flash. The overlay's +comment says what it chose and why. + +The Feather nRF52840 boards use the same split as `ports/nordic`, whose +`nrfx_config.h` defaults to `CIRCUITPY_NRF_NUM_I2C` = 2: + +| Kind | Instances | +|---|---| +| I2C | `i2c0`, `i2c1` (TWIM) | +| SPI | `spi2`, `spi3` (SPIM) | +| UART | `uart0`, `uart1` (UARTE, separate peripherals on nRF52) | + +`spi0` and `spi1` stay disabled because I2C has those instances. + +`zephyr2cp.py` lists each pool's instances by devicetree `max-frequency`, +highest first, and iobroker allocates the first free instance that can +reach the requested pins. So busio gets the nRF52840's 32 MHz SPIM3 before +the 8 MHz SPIM2, and on the nRF54L the 32 MHz `spi00` before the 8 MHz +instances, when a board leaves `spi00` dynamic. + ## Public API See `include/iobroker/iobroker.h`. Signals are requested with From 13cd72c881f2a5401d48c717b888ef296fa4a67d Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Tue, 13 Oct 2026 19:20:52 -0400 Subject: [PATCH 06/18] zephyr-cp: share the Feather nRF52840 boards' overlays and console files The Feather nRF52840 and Feather Bluefruit Sense had identical console.conf and console.overlay files, and board.overlay files that differed only in deleting the Sense's SHT3XD node. console.conf is now one port-level file, which the Makefile applies in CONSOLE=1 builds of any board that has a console.overlay. The Sense's console.overlay and board.overlay include the Feather's; its board.overlay then only deletes SHT3XD. The compiled devicetree is unchanged. Co-Authored-By: Claude Opus 5.5 --- ports/zephyr-cp/Makefile | 9 +- ports/zephyr-cp/README.md | 7 +- .../board.overlay | 293 +----------------- .../console.conf | 4 - .../console.overlay | 20 +- .../feather_nrf52840_zephyr/console.conf | 4 - ports/zephyr-cp/console.conf | 5 + 7 files changed, 20 insertions(+), 322 deletions(-) delete mode 100644 ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/console.conf delete mode 100644 ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/console.conf create mode 100644 ports/zephyr-cp/console.conf diff --git a/ports/zephyr-cp/Makefile b/ports/zephyr-cp/Makefile index f889f5eea90..adb1d658ee6 100644 --- a/ports/zephyr-cp/Makefile +++ b/ports/zephyr-cp/Makefile @@ -63,13 +63,16 @@ DEBUG_CONF_FILE ?= $(CURDIR)/debug.conf ifeq ($(DEBUG),1) CP_BOARD_CONF := $(strip $(CP_BOARD_CONF) $(DEBUG_CONF_FILE)) endif -# When CONSOLE=1 (implied by DEBUG=1), apply the board's console.conf and console.overlay if it -# has them. Boards whose console pins are also user pins enable the Zephyr UART console only then. +# When CONSOLE=1 (implied by DEBUG=1), apply the board's console.overlay, if it has one, and the +# port's console.conf. Boards whose console pins are also user pins enable the Zephyr UART console +# only then. CONSOLE ?= $(DEBUG) CP_BOARD_CONSOLE_OVERLAY := ifeq ($(CONSOLE),1) -CP_BOARD_CONF := $(strip $(CP_BOARD_CONF) $(wildcard $(CP_BOARD_DIR)/console.conf)) CP_BOARD_CONSOLE_OVERLAY := $(wildcard $(CP_BOARD_DIR)/console.overlay) +ifneq ($(CP_BOARD_CONSOLE_OVERLAY),) +CP_BOARD_CONF := $(strip $(CP_BOARD_CONF) $(CURDIR)/console.conf) +endif endif CP_BOARD_CONF := $(subst $(eval) ,;,$(CP_BOARD_CONF)) # Always pass the conf and console overlay files, even if empty. This undoes DEBUG=1 or CONSOLE=1 diff --git a/ports/zephyr-cp/README.md b/ports/zephyr-cp/README.md index f3ba5e63adb..4285bf3c98c 100644 --- a/ports/zephyr-cp/README.md +++ b/ports/zephyr-cp/README.md @@ -29,9 +29,10 @@ This uses Zephyr's cmake to generate Makefiles that then delegate to `tools/cpbuild/build_circuitpython.py` to build the CircuitPython bits in parallel. `DEBUG=1` adds the debug Kconfig settings in `debug.conf`. `CONSOLE=1` (implied -by `DEBUG=1`) applies a board's `console.conf` and `console.overlay`, if it has -them. Boards whose console UART pins are also user pins, such as the Feather -nRF52840, have the Zephyr UART console only in those builds. +by `DEBUG=1`) applies a board's `console.overlay`, if it has one, together with +the port's `console.conf`, which turns the Zephyr UART console back on. Boards +whose console UART pins are also user pins, such as the Feather nRF52840, have +the Zephyr UART console only in those builds. ## Native simulator build container diff --git a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/board.overlay b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/board.overlay index ac3edc35c66..72163bd5a2c 100644 --- a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/board.overlay +++ b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/board.overlay @@ -1,294 +1,7 @@ -/* Partition layout is owned by the mcuboot fork - * (dts/adafruit/adafruit_feather_nrf52840.dtsi) and applied to this image by - * sysbuild. This overlay only adds board/app config. */ -/* No Zephyr console by default, so TX/RX are free for busio.UART. A CONSOLE=1 - * build puts the console back on UART0 (console.overlay). */ -/ { - chosen { - /delete-property/ zephyr,console; - /delete-property/ zephyr,shell-uart; - /delete-property/ zephyr,uart-mcumgr; - /delete-property/ zephyr,bt-mon-uart; - /delete-property/ zephyr,bt-c2h-uart; - }; -}; - -&zephyr_udc0 { - /delete-node/ board_cdc_acm_uart; -}; - -// Bus instances enabled with all pins disconnected and marked -// zephyr,deferred-init so nothing is claimed at boot: the iobroker -// initializes and routes a device to arbitrary pins when a busio object is -// constructed, and de-initializes it on release: I2C0, I2C1, SPI2, SPI3, -// UART0 and UART1. -// -// See "nRF serial instances" in internal-modules/iobroker/README.md for how -// the instances are shared and allocated. - -&pinctrl { - i2c0_dyn_default: i2c0_dyn_default { - group1 { - psels = , - ; - }; - }; - - i2c0_dyn_sleep: i2c0_dyn_sleep { - group1 { - psels = , - ; - }; - }; - - i2c1_dyn_default: i2c1_dyn_default { - group1 { - psels = , - ; - }; - }; - - i2c1_dyn_sleep: i2c1_dyn_sleep { - group1 { - psels = , - ; - }; - }; - - pwm0_dyn_default: pwm0_dyn_default { - group1 { - psels = , - , - , - ; - }; - }; - - pwm0_dyn_sleep: pwm0_dyn_sleep { - group1 { - psels = , - , - , - ; - }; - }; - - pwm1_dyn_default: pwm1_dyn_default { - group1 { - psels = , - , - , - ; - }; - }; - - pwm1_dyn_sleep: pwm1_dyn_sleep { - group1 { - psels = , - , - , - ; - }; - }; - - pwm2_dyn_default: pwm2_dyn_default { - group1 { - psels = , - , - , - ; - }; - }; - - pwm2_dyn_sleep: pwm2_dyn_sleep { - group1 { - psels = , - , - , - ; - }; - }; - - pwm3_dyn_default: pwm3_dyn_default { - group1 { - psels = , - , - , - ; - }; - }; - - pwm3_dyn_sleep: pwm3_dyn_sleep { - group1 { - psels = , - , - , - ; - }; - }; - - spi2_dyn_default: spi2_dyn_default { - group1 { - psels = , - , - ; - }; - }; - - spi2_dyn_sleep: spi2_dyn_sleep { - group1 { - psels = , - , - ; - }; - }; - - spi3_dyn_default: spi3_dyn_default { - group1 { - psels = , - , - ; - }; - }; - - spi3_dyn_sleep: spi3_dyn_sleep { - group1 { - psels = , - , - ; - }; - }; - - uart0_dyn_default: uart0_dyn_default { - group1 { - psels = , - ; - }; - }; - - uart0_dyn_sleep: uart0_dyn_sleep { - group1 { - psels = , - ; - }; - }; - - uart1_dyn_default: uart1_dyn_default { - group1 { - psels = , - ; - }; - }; - - uart1_dyn_sleep: uart1_dyn_sleep { - group1 { - psels = , - ; - }; - }; -}; - -// The upstream board fixes I2C0 to SCL/SDA with the legacy TWI driver. Use -// TWIM like I2C1 so iobroker can route it. -&i2c0 { - compatible = "nordic,nrf-twim"; - status = "okay"; - zephyr,deferred-init; - pinctrl-0 = <&i2c0_dyn_default>; - pinctrl-1 = <&i2c0_dyn_sleep>; - pinctrl-names = "default", "sleep"; -}; +/* Same as the Feather nRF52840 Express, whose upstream board is the same + * apart from the LED pin and the SHT3XD node below. */ +#include "../feather_nrf52840_zephyr/board.overlay" // CircuitPython drives the SHT31 from Python, so drop the Zephyr sensor node // on the now dynamic I2C0. /delete-node/ &SHT3XD; - -&i2c1 { - status = "okay"; - zephyr,deferred-init; - pinctrl-0 = <&i2c1_dyn_default>; - pinctrl-1 = <&i2c1_dyn_sleep>; - pinctrl-names = "default", "sleep"; -}; - -// PWM instances, same scheme: all four outputs disconnected and deferred -// init, so iobroker can hand an instance to neopixel_write and pwmio and -// route its outputs at runtime. - -&pwm0 { - status = "okay"; - zephyr,deferred-init; - pinctrl-0 = <&pwm0_dyn_default>; - pinctrl-1 = <&pwm0_dyn_sleep>; - pinctrl-names = "default", "sleep"; -}; - -&pwm1 { - status = "okay"; - zephyr,deferred-init; - pinctrl-0 = <&pwm1_dyn_default>; - pinctrl-1 = <&pwm1_dyn_sleep>; - pinctrl-names = "default", "sleep"; -}; - -&pwm2 { - status = "okay"; - zephyr,deferred-init; - pinctrl-0 = <&pwm2_dyn_default>; - pinctrl-1 = <&pwm2_dyn_sleep>; - pinctrl-names = "default", "sleep"; -}; - -&pwm3 { - status = "okay"; - zephyr,deferred-init; - pinctrl-0 = <&pwm3_dyn_default>; - pinctrl-1 = <&pwm3_dyn_sleep>; - pinctrl-names = "default", "sleep"; -}; - -// SPI1 is the same peripheral instance as I2C1, so leave it disabled. -&spi1 { - status = "disabled"; -}; - -&spi2 { - status = "okay"; - zephyr,deferred-init; - pinctrl-0 = <&spi2_dyn_default>; - pinctrl-1 = <&spi2_dyn_sleep>; - pinctrl-names = "default", "sleep"; -}; - -&spi3 { - status = "okay"; - zephyr,deferred-init; - pinctrl-0 = <&spi3_dyn_default>; - pinctrl-1 = <&spi3_dyn_sleep>; - pinctrl-names = "default", "sleep"; -}; - -&uart0 { - status = "okay"; - zephyr,deferred-init; - current-speed = <115200>; - pinctrl-0 = <&uart0_dyn_default>; - pinctrl-1 = <&uart0_dyn_sleep>; - pinctrl-names = "default", "sleep"; -}; - -&uart1 { - status = "okay"; - zephyr,deferred-init; - current-speed = <115200>; - pinctrl-0 = <&uart1_dyn_default>; - pinctrl-1 = <&uart1_dyn_sleep>; - pinctrl-names = "default", "sleep"; -}; - -#include "../../../app.overlay" - -/* Enable the SAADC for analogio (AIN0..AIN7 are on P0.02..P0.05 and P0.28..P0.31). */ -&adc { - status = "okay"; -}; diff --git a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/console.conf b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/console.conf deleted file mode 100644 index 83db0acb170..00000000000 --- a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/console.conf +++ /dev/null @@ -1,4 +0,0 @@ -# CONSOLE=1 and DEBUG=1 builds: the Zephyr UART console on TX/RX -# (console.overlay). -CONFIG_UART_CONSOLE=y -CONFIG_LOG_BACKEND_UART=y diff --git a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/console.overlay b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/console.overlay index 095c4aa1d2d..03f4f3d1c3b 100644 --- a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/console.overlay +++ b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/console.overlay @@ -1,18 +1,2 @@ -/* Applied after board.overlay in CONSOLE=1 and DEBUG=1 builds: the Zephyr - * console on UART0, fixed to the TX/RX pins. TX/RX are then not available to - * busio.UART. */ -/ { - chosen { - zephyr,console = &uart0; - zephyr,shell-uart = &uart0; - zephyr,uart-mcumgr = &uart0; - zephyr,bt-mon-uart = &uart0; - zephyr,bt-c2h-uart = &uart0; - }; -}; - -&uart0 { - /delete-property/ zephyr,deferred-init; - pinctrl-0 = <&uart0_default>; - pinctrl-1 = <&uart0_sleep>; -}; +/* Same console setup as the Feather nRF52840 Express. */ +#include "../feather_nrf52840_zephyr/console.overlay" diff --git a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/console.conf b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/console.conf deleted file mode 100644 index 83db0acb170..00000000000 --- a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/console.conf +++ /dev/null @@ -1,4 +0,0 @@ -# CONSOLE=1 and DEBUG=1 builds: the Zephyr UART console on TX/RX -# (console.overlay). -CONFIG_UART_CONSOLE=y -CONFIG_LOG_BACKEND_UART=y diff --git a/ports/zephyr-cp/console.conf b/ports/zephyr-cp/console.conf new file mode 100644 index 00000000000..c2f90e7e25b --- /dev/null +++ b/ports/zephyr-cp/console.conf @@ -0,0 +1,5 @@ +# CONSOLE=1 and DEBUG=1 builds of boards that have a console.overlay: turn the +# Zephyr UART console back on, on the UART that the board's console.overlay +# sets up. +CONFIG_UART_CONSOLE=y +CONFIG_LOG_BACKEND_UART=y From 18a2b4817440188556d7598507495b356988fa13 Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Wed, 14 Oct 2026 14:36:55 -0400 Subject: [PATCH 07/18] zephyr-cp: find board buses from devicetree labels Replace the [[board_i2c]], [[board_spi]] and [[board_uart]] tables in circuitpython.toml with the devicetree as the source of truth. A board whose devicetree has a feather_i2c, feather_spi or feather_serial label gets board.I2C(), board.SPI() or board.UART() on the header pins named SCL/SDA, SCK/MOSI/MISO or TX/RX. The labeled node only marks that the board has the bus; iobroker picks the instance when the bus is constructed. The Feather nRF52840 boards' circuitpython.toml files are back to their main versions, and the generated board buses are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../circuitpython.toml | 18 --- .../circuitpython.toml | 18 --- .../zephyr-cp/cptools/tests/test_zephyr2cp.py | 78 ++++------- ports/zephyr-cp/cptools/zephyr2cp.py | 123 ++++++++---------- 4 files changed, 80 insertions(+), 157 deletions(-) diff --git a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/circuitpython.toml b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/circuitpython.toml index ecea8e87174..985f53ca662 100644 --- a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/circuitpython.toml +++ b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/circuitpython.toml @@ -54,21 +54,3 @@ MICROPHONE_CLOCK = 18 MICROPHONE_DATA = 17 PROXIMITY_LIGHT_INTERRUPT = 47 ACCELEROMETER_GYRO_INTERRUPT = 4 - -# Board buses, as on the ports/ counterpart: board.I2C(), board.SPI() and -# board.UART(), constructed on these pins when first used. -[[board_i2c]] -names = ["I2C"] -scl = "SCL" -sda = "SDA" - -[[board_spi]] -names = ["SPI"] -clock = "SCK" -mosi = "MOSI" -miso = "MISO" - -[[board_uart]] -names = ["UART"] -tx = "TX" -rx = "RX" diff --git a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/circuitpython.toml b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/circuitpython.toml index db6d04ed545..ef4dc115a83 100644 --- a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/circuitpython.toml +++ b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/circuitpython.toml @@ -52,21 +52,3 @@ LED = 8 RED_LED = 8 D3 = 8 BLUE_LED = 3 - -# Board buses, as on the ports/ counterpart: board.I2C(), board.SPI() and -# board.UART(), constructed on these pins when first used. -[[board_i2c]] -names = ["I2C"] -scl = "SCL" -sda = "SDA" - -[[board_spi]] -names = ["SPI"] -clock = "SCK" -mosi = "MOSI" -miso = "MISO" - -[[board_uart]] -names = ["UART"] -tx = "TX" -rx = "RX" diff --git a/ports/zephyr-cp/cptools/tests/test_zephyr2cp.py b/ports/zephyr-cp/cptools/tests/test_zephyr2cp.py index 538f6923764..76dacee0a3f 100644 --- a/ports/zephyr-cp/cptools/tests/test_zephyr2cp.py +++ b/ports/zephyr-cp/cptools/tests/test_zephyr2cp.py @@ -25,7 +25,7 @@ MINIMUM_RAM_SIZE, add_toml_pin_names, _fastest_first, - _toml_board_buses, + _dt_board_buses, _toml_status_pin, ) @@ -801,72 +801,50 @@ def test_unknown_name_raises(self): _toml_status_pin({"STATUS_NEOPIXEL": "NOPE"}, self.BOARD_NAMES, "STATUS_NEOPIXEL") -class TestTomlBoardBuses: - """Resolution of circuitpython.toml [[board_i2c]]/[[board_spi]]/[[board_uart]].""" +class TestDtBoardBuses: + """board.I2C(), board.SPI() and board.UART() from devicetree bus labels.""" BOARD_NAMES = { ("gpio0", 11): ["SCL"], ("gpio0", 12): ["SDA"], ("gpio0", 14): ["SCK"], ("gpio0", 13): ["MOSI"], + ("gpio0", 15): ["MISO"], ("gpio0", 25): ["TX"], ("gpio0", 24): ["RX"], - ("gpio1", 1): ["SCL1"], - ("gpio1", 2): ["SDA1"], } - def test_resolves_buses_in_order(self): - buses = _toml_board_buses( - { - "board_i2c": [ - {"names": ["I2C", "stemma_i2c"], "scl": "SCL", "sda": "SDA"}, - {"names": ["I2C1"], "scl": "SCL1", "sda": "SDA1"}, - ], - "board_uart": [{"names": ["UART"], "tx": "TX", "rx": "RX"}], - }, - self.BOARD_NAMES, + @staticmethod + def _device_tree(*labels): + return SimpleNamespace(label2node={label: object() for label in labels}) + + def test_feather_labels(self): + buses = _dt_board_buses( + self._device_tree("feather_i2c", "feather_spi", "feather_serial"), self.BOARD_NAMES ) assert buses == { - "i2c": [ - (["I2C", "STEMMA_I2C"], {"scl": ("gpio0", 11), "sda": ("gpio0", 12)}), - (["I2C1"], {"scl": ("gpio1", 1), "sda": ("gpio1", 2)}), - ], - "uart": [(["UART"], {"tx": ("gpio0", 25), "rx": ("gpio0", 24)})], + "i2c": {"scl": ("gpio0", 11), "sda": ("gpio0", 12)}, + "spi": {"clock": ("gpio0", 14), "mosi": ("gpio0", 13), "miso": ("gpio0", 15)}, + "uart": {"tx": ("gpio0", 25), "rx": ("gpio0", 24)}, } - def test_optional_pins_may_be_absent(self): - buses = _toml_board_buses( - {"board_spi": [{"names": ["SPI"], "clock": "SCK", "mosi": "MOSI"}]}, - self.BOARD_NAMES, - ) - assert buses == {"spi": [(["SPI"], {"clock": ("gpio0", 14), "mosi": ("gpio0", 13)})]} + def test_only_labeled_buses(self): + buses = _dt_board_buses(self._device_tree("feather_i2c", "i2c0"), self.BOARD_NAMES) + assert list(buses) == ["i2c"] - def test_absent_tables_are_empty(self): - assert _toml_board_buses({}, self.BOARD_NAMES) == {} - assert _toml_board_buses(None, self.BOARD_NAMES) == {} + def test_no_labels(self): + assert _dt_board_buses(self._device_tree("i2c0", "spi1"), self.BOARD_NAMES) == {} - def test_missing_names_raises(self): - with pytest.raises(RuntimeError, match=r"\[\[board_i2c\]\] needs names"): - _toml_board_buses({"board_i2c": [{"scl": "SCL", "sda": "SDA"}]}, self.BOARD_NAMES) + def test_pin_names_ignore_case(self): + names = dict(self.BOARD_NAMES) + names[("gpio0", 11)] = ["scl"] + buses = _dt_board_buses(self._device_tree("feather_i2c"), names) + assert buses["i2c"]["scl"] == ("gpio0", 11) - def test_missing_required_pin_raises(self): - with pytest.raises(RuntimeError, match=r"\[\[board_i2c\]\] needs sda"): - _toml_board_buses({"board_i2c": [{"names": ["I2C"], "scl": "SCL"}]}, self.BOARD_NAMES) - - def test_no_pins_raises(self): - with pytest.raises(RuntimeError, match="needs at least one pin"): - _toml_board_buses({"board_uart": [{"names": ["UART"]}]}, self.BOARD_NAMES) - - def test_unknown_key_raises(self): - with pytest.raises(RuntimeError, match=r"unknown keys \['sck'\]"): - _toml_board_buses({"board_spi": [{"names": ["SPI"], "sck": "SCK"}]}, self.BOARD_NAMES) - - def test_unknown_pin_raises(self): - with pytest.raises(RuntimeError, match="'NOPE' is not a board pin name"): - _toml_board_buses( - {"board_i2c": [{"names": ["I2C"], "scl": "NOPE", "sda": "SDA"}]}, - self.BOARD_NAMES, - ) + def test_missing_pin_name_raises(self): + names = {key: value for key, value in self.BOARD_NAMES.items() if value != ["MISO"]} + with pytest.raises(RuntimeError, match="feather_spi needs a board pin named MISO"): + _dt_board_buses(self._device_tree("feather_spi"), names) class TestFastestFirst: diff --git a/ports/zephyr-cp/cptools/zephyr2cp.py b/ports/zephyr-cp/cptools/zephyr2cp.py index ef9bafeeb2c..630293053f2 100644 --- a/ports/zephyr-cp/cptools/zephyr2cp.py +++ b/ports/zephyr-cp/cptools/zephyr2cp.py @@ -760,49 +760,51 @@ def _toml_status_pin(mpconfigboard, board_names, key): raise RuntimeError(f"circuitpython.toml {key} = {name!r} is not a board pin name") -# Pin fields of the circuitpython.toml [[board_i2c]], [[board_spi]] and -# [[board_uart]] tables, as (field, required). The field names match the -# CIRCUITPY_BOARD_*_PIN structs in shared-module/board. -BOARD_BUS_PINS = { - "i2c": (("scl", True), ("sda", True)), - "spi": (("clock", True), ("mosi", False), ("miso", False)), - "uart": (("tx", False), ("rx", False)), +# Devicetree labels that mark a board's standard header buses, mapped to the +# bus class and the board pin names its signals use, as (field, pin name). +# The field names match the CIRCUITPY_BOARD_*_PIN structs in +# shared-module/board. The pin names come from the board's connector (see +# CONNECTORS) or its [pins] table. +BOARD_BUS_LABELS = { + "feather_i2c": ("i2c", (("scl", "SCL"), ("sda", "SDA"))), + "feather_spi": ("spi", (("clock", "SCK"), ("mosi", "MOSI"), ("miso", "MISO"))), + "feather_serial": ("uart", (("tx", "TX"), ("rx", "RX"))), } # The board bus class each busio-capable devicetree driver provides. BOARD_BUS_OF_DRIVER = {"i2c": "i2c", "spi": "spi", "serial": "uart"} -def _toml_board_buses(mpconfigboard, board_names): - """Resolve the circuitpython.toml board bus tables to pins. +def _board_pin_named(board_names, name): + """Return the ``(ioport, num)`` of the board pin called ``name``, or None.""" + for pin_key, names in board_names.items(): + if any(existing.upper() == name for existing in names): + return pin_key + return None + - Each ``[[board_i2c]]``, ``[[board_spi]]`` or ``[[board_uart]]`` table is - one board bus, in instance order: ``names`` lists its board names and the - other keys name its pins. Returns ``{bus: [(names, {field: pin}), ...]}`` - for the bus classes the toml defines. +def _dt_board_buses(device_tree, board_names): + """Find the board buses that devicetree labels mark. + + Each BOARD_BUS_LABELS label in the devicetree gives one board bus, named + after its class (``board.I2C``, ``board.SPI`` or ``board.UART``), on the + board pins with the label's pin names. The labeled node only says that + the board has the bus. It may be dynamic or disabled, because iobroker + picks the instance when the bus is constructed. Returns + ``{bus: {field: pin}}``. """ buses = {} - for bus, fields in BOARD_BUS_PINS.items(): - key = f"board_{bus}" - resolved = [] - for table in (mpconfigboard or {}).get(key, []): - field_names = {field for field, _ in fields} - unknown = sorted(set(table) - field_names - {"names"}) - if unknown: - raise RuntimeError(f"circuitpython.toml [[{key}]] has unknown keys {unknown}") - names = table.get("names") - if not names: - raise RuntimeError(f"circuitpython.toml [[{key}]] needs names") - pins = {} - for field, required in fields: - if field in table: - pins[field] = _toml_status_pin(table, board_names, field) - elif required: - raise RuntimeError(f"circuitpython.toml [[{key}]] needs {field}") - if not pins: - raise RuntimeError(f"circuitpython.toml [[{key}]] needs at least one pin") - resolved.append(([str(name).upper() for name in names], pins)) - if resolved: - buses[bus] = resolved + for label, (bus, fields) in BOARD_BUS_LABELS.items(): + if label not in device_tree.label2node: + continue + if bus in buses: + raise RuntimeError(f"devicetree label {label} is a second {bus} board bus") + pins = {} + for field, name in fields: + pin = _board_pin_named(board_names, name) + if pin is None: + raise RuntimeError(f"devicetree label {label} needs a board pin named {name}") + pins[field] = pin + buses[bus] = pins return buses @@ -1258,12 +1260,10 @@ def zephyr_dts_to_cp_board(board_id, portdir, builddir, zephyrbuilddir, mpconfig # the [pins] table, that the status LED code drives with neopixel_write. status_neopixel = _toml_status_pin(mpconfigboard, board_names, "STATUS_NEOPIXEL") status_neopixel_power = _toml_status_pin(mpconfigboard, board_names, "STATUS_NEOPIXEL_POWER") - # board.I2C(), board.SPI(), board.UART() and other named board buses from - # circuitpython.toml, constructed on demand on their pins. - board_buses = _toml_board_buses(mpconfigboard, board_names) - board_bus_names = { - name for tables in board_buses.values() for names, _ in tables for name in names - } + # board.I2C(), board.SPI() and board.UART() for the buses that devicetree + # labels mark, constructed on demand on their pins. + board_buses = _dt_board_buses(device_tree, board_names) + board_bus_names = {bus.upper() for bus in board_buses} pin_object_of = {} # GPIO pad number -> pad: every (label, num) name and alias the # devicetree walk produced keys into one pin object per pad. @@ -1400,8 +1400,8 @@ def zephyr_dts_to_cp_board(board_id, portdir, builddir, zephyrbuilddir, mpconfig zephyr_binding_headers.append(f'#include "shared-bindings/{module}/{driverclass}.h"') - # Designate a main device such as board.I2C or board.I2S, unless - # circuitpython.toml defines the board buses of this class. + # Designate a main device such as board.I2C or board.I2S, unless a + # devicetree label marks a board bus of this class. if BOARD_BUS_OF_DRIVER.get(driver) in board_buses: pass elif len(instances) == 1: @@ -1461,7 +1461,7 @@ def zephyr_dts_to_cp_board(board_id, portdir, builddir, zephyrbuilddir, mpconfig ) for label in labels: if label.upper() in board_bus_names: - # A circuitpython.toml board bus has this name. + # A labeled board bus has this name. continue zephyr_binding_labels.append( f"{{ MP_ROM_QSTR(MP_QSTR_{label.upper()}), MP_ROM_PTR(&{function_object}) }}," @@ -1470,36 +1470,18 @@ def zephyr_dts_to_cp_board(board_id, portdir, builddir, zephyrbuilddir, mpconfig zephyr_binding_objects = "\n".join(zephyr_binding_objects) zephyr_binding_labels = "\n".join(zephyr_binding_labels) - # Board buses from circuitpython.toml: the CIRCUITPY_BOARD_* pin tables for - # shared-module/board, and board names for each bus. Bus 0 of a class is - # board__obj; later ones get their own singleton. + # Board buses from devicetree labels: the CIRCUITPY_BOARD_* pin tables for + # shared-module/board, and the board name for each bus. board_bus_defines = [] - board_bus_objects = [] board_bus_labels = [] - for bus, tables in board_buses.items(): - entries = [] - for index, (names, pins) in enumerate(tables): - fields = ", ".join( - f".{field} = &pin_{pin_object_of[pin]}" for field, pin in pins.items() - ) - entries.append(f"{{{fields}}}") - if index == 0: - function_object = f"board_{bus}_obj" - else: - board_bus_objects.append( - f"CIRCUITPY_BOARD_BUS_SINGLETON({names[0].lower()}, {bus}, {index})" - ) - function_object = f"board_{names[0].lower()}_obj" - for name in names: - board_bus_labels.append( - f"{{ MP_ROM_QSTR(MP_QSTR_{name}), MP_ROM_PTR(&{function_object}) }}," - ) - board_bus_defines.append(f"#define CIRCUITPY_BOARD_{bus.upper()} ({len(tables)})") - board_bus_defines.append( - f"#define CIRCUITPY_BOARD_{bus.upper()}_PIN {{{', '.join(entries)}}}" + for bus, pins in board_buses.items(): + fields = ", ".join(f".{field} = &pin_{pin_object_of[pin]}" for field, pin in pins.items()) + board_bus_defines.append(f"#define CIRCUITPY_BOARD_{bus.upper()} (1)") + board_bus_defines.append(f"#define CIRCUITPY_BOARD_{bus.upper()}_PIN {{{{{fields}}}}}") + board_bus_labels.append( + f"{{ MP_ROM_QSTR(MP_QSTR_{bus.upper()}), MP_ROM_PTR(&board_{bus}_obj) }}," ) board_bus_defines = "\n".join(board_bus_defines) - board_bus_objects = "\n".join(board_bus_objects) board_bus_labels = "\n".join(board_bus_labels) # Generate tables of allocatable bus instances for the iobroker @@ -1870,7 +1852,6 @@ def zephyr_dts_to_cp_board(board_id, portdir, builddir, zephyrbuilddir, mpconfig {pin_defs} {zephyr_binding_objects} -{board_bus_objects} {iobroker_tables} void board_init(void) {{ From 09f6a81f85799c714e5264b0f8758e39b012d0e4 Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Wed, 14 Oct 2026 14:58:21 -0400 Subject: [PATCH 08/18] zephyr-cp: use bus labels for board buses only with nRF routing Constructing a board bus on its pins needs iobroker's runtime routing, which only nRF SoCs have. On other SoCs, such as the Feather RP2040, the labeled buses would raise NotImplementedError. Find board buses from devicetree labels only when CONFIG_PINCTRL_NRF is on, and leave other boards as before. The .config scan moves earlier so the board bus code can use it. Co-Authored-By: Claude Opus 5.5 --- ports/zephyr-cp/cptools/zephyr2cp.py | 47 ++++++++++++++-------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/ports/zephyr-cp/cptools/zephyr2cp.py b/ports/zephyr-cp/cptools/zephyr2cp.py index 630293053f2..981bfd5ffa1 100644 --- a/ports/zephyr-cp/cptools/zephyr2cp.py +++ b/ports/zephyr-cp/cptools/zephyr2cp.py @@ -1260,9 +1260,31 @@ def zephyr_dts_to_cp_board(board_id, portdir, builddir, zephyrbuilddir, mpconfig # the [pins] table, that the status LED code drives with neopixel_write. status_neopixel = _toml_status_pin(mpconfigboard, board_names, "STATUS_NEOPIXEL") status_neopixel_power = _toml_status_pin(mpconfigboard, board_names, "STATUS_NEOPIXEL_POWER") + pinctrl_nrf = False + pwm_nrfx = False + pwm_emul = False + if config_present: + for line in config.read_text().splitlines(): + if line.startswith("CONFIG_PINCTRL_NRF="): + pinctrl_nrf = line.strip().endswith("=y") + elif line.startswith("CONFIG_PWM_NRFX="): + # Zephyr's nRF PWM driver defines the PWM instance devices. + # Without it, PWM nodes have no device to reference, and + # neopixel_write (the neopixel module's nRF implementation, + # which allocates an instance through iobroker) cannot transmit, so + # both the pwm pool and the module follow this symbol. pwmio + # allocates its channels through iobroker's nRF implementation + # too. + pwm_nrfx = line.strip().endswith("=y") + elif line.startswith("CONFIG_PWM_ADAFRUIT_EMUL="): + # The emulated PWM controllers on native_sim (pwm_emul + # module), which iobroker allocates pwmio channels on too. + pwm_emul = line.strip().endswith("=y") + board_info["neopixel_write"] = pwm_nrfx # board.I2C(), board.SPI() and board.UART() for the buses that devicetree - # labels mark, constructed on demand on their pins. - board_buses = _dt_board_buses(device_tree, board_names) + # labels mark, constructed on demand on their pins. That needs iobroker + # routing (nRF); other SoCs keep their fixed devicetree buses. + board_buses = _dt_board_buses(device_tree, board_names) if pinctrl_nrf else {} board_bus_names = {bus.upper() for bus in board_buses} pin_object_of = {} # GPIO pad number -> pad: every (label, num) name and alias the @@ -1489,27 +1511,6 @@ def zephyr_dts_to_cp_board(board_id, portdir, builddir, zephyrbuilddir, mpconfig # all-disconnected pinctrl can be routed to arbitrary pins at runtime; # instances with fixed devicetree pins are only usable when the requested # pins match their state. - pinctrl_nrf = False - pwm_nrfx = False - pwm_emul = False - if config_present: - for line in config.read_text().splitlines(): - if line.startswith("CONFIG_PINCTRL_NRF="): - pinctrl_nrf = line.strip().endswith("=y") - elif line.startswith("CONFIG_PWM_NRFX="): - # Zephyr's nRF PWM driver defines the PWM instance devices. - # Without it, PWM nodes have no device to reference, and - # neopixel_write (the neopixel module's nRF implementation, - # which allocates an instance through iobroker) cannot transmit, so - # both the pwm pool and the module follow this symbol. pwmio - # allocates its channels through iobroker's nRF implementation - # too. - pwm_nrfx = line.strip().endswith("=y") - elif line.startswith("CONFIG_PWM_ADAFRUIT_EMUL="): - # The emulated PWM controllers on native_sim (pwm_emul - # module), which iobroker allocates pwmio channels on too. - pwm_emul = line.strip().endswith("=y") - board_info["neopixel_write"] = pwm_nrfx board_info["pwmio"] = pwm_nrfx or pwm_emul iobroker_includes = """ From 6e572934dbc58d57138a21265c562d028cf88595 Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Wed, 14 Oct 2026 18:03:46 -0400 Subject: [PATCH 09/18] zephyr-cp: mark board buses with generic board_* labels BOARD_BUS_LABELS now knows only board_i2c, board_spi and board_serial. Boards add them in their board.overlay: the Feather nRF52840 overlay aliases the upstream feather_* connector labels, and boards without upstream labels (ItsyBitsy, CLUE) can label the bus nodes directly. Co-Authored-By: Claude Opus 5.5 --- .../adafruit/feather_nrf52840_zephyr/board.overlay | 6 ++++++ ports/zephyr-cp/cptools/tests/test_zephyr2cp.py | 14 ++++++++------ ports/zephyr-cp/cptools/zephyr2cp.py | 10 +++++++--- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/board.overlay b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/board.overlay index b2bcdd5b827..517074cdb7b 100644 --- a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/board.overlay +++ b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/board.overlay @@ -17,6 +17,12 @@ /delete-node/ board_cdc_acm_uart; }; +// board.I2C(), board.SPI() and board.UART() are on the Feather header buses. +// See BOARD_BUS_LABELS in cptools/zephyr2cp.py. +board_i2c: &feather_i2c {}; +board_spi: &feather_spi {}; +board_serial: &feather_serial {}; + // Bus instances enabled with all pins disconnected and marked // zephyr,deferred-init so nothing is claimed at boot: the iobroker // initializes and routes a device to arbitrary pins when a busio object is diff --git a/ports/zephyr-cp/cptools/tests/test_zephyr2cp.py b/ports/zephyr-cp/cptools/tests/test_zephyr2cp.py index 76dacee0a3f..136a1b75443 100644 --- a/ports/zephyr-cp/cptools/tests/test_zephyr2cp.py +++ b/ports/zephyr-cp/cptools/tests/test_zephyr2cp.py @@ -818,9 +818,9 @@ class TestDtBoardBuses: def _device_tree(*labels): return SimpleNamespace(label2node={label: object() for label in labels}) - def test_feather_labels(self): + def test_board_labels(self): buses = _dt_board_buses( - self._device_tree("feather_i2c", "feather_spi", "feather_serial"), self.BOARD_NAMES + self._device_tree("board_i2c", "board_spi", "board_serial"), self.BOARD_NAMES ) assert buses == { "i2c": {"scl": ("gpio0", 11), "sda": ("gpio0", 12)}, @@ -829,7 +829,9 @@ def test_feather_labels(self): } def test_only_labeled_buses(self): - buses = _dt_board_buses(self._device_tree("feather_i2c", "i2c0"), self.BOARD_NAMES) + buses = _dt_board_buses( + self._device_tree("board_i2c", "feather_spi", "i2c0"), self.BOARD_NAMES + ) assert list(buses) == ["i2c"] def test_no_labels(self): @@ -838,13 +840,13 @@ def test_no_labels(self): def test_pin_names_ignore_case(self): names = dict(self.BOARD_NAMES) names[("gpio0", 11)] = ["scl"] - buses = _dt_board_buses(self._device_tree("feather_i2c"), names) + buses = _dt_board_buses(self._device_tree("board_i2c"), names) assert buses["i2c"]["scl"] == ("gpio0", 11) def test_missing_pin_name_raises(self): names = {key: value for key, value in self.BOARD_NAMES.items() if value != ["MISO"]} - with pytest.raises(RuntimeError, match="feather_spi needs a board pin named MISO"): - _dt_board_buses(self._device_tree("feather_spi"), names) + with pytest.raises(RuntimeError, match="board_spi needs a board pin named MISO"): + _dt_board_buses(self._device_tree("board_spi"), names) class TestFastestFirst: diff --git a/ports/zephyr-cp/cptools/zephyr2cp.py b/ports/zephyr-cp/cptools/zephyr2cp.py index 981bfd5ffa1..c9fa84c4307 100644 --- a/ports/zephyr-cp/cptools/zephyr2cp.py +++ b/ports/zephyr-cp/cptools/zephyr2cp.py @@ -765,10 +765,14 @@ def _toml_status_pin(mpconfigboard, board_names, key): # The field names match the CIRCUITPY_BOARD_*_PIN structs in # shared-module/board. The pin names come from the board's connector (see # CONNECTORS) or its [pins] table. +# +# These labels are ours, not upstream's. Each board adds them in its +# board.overlay, as an alias of an upstream connector label when there is +# one (board_i2c: &feather_i2c {};) or on the bus node otherwise. BOARD_BUS_LABELS = { - "feather_i2c": ("i2c", (("scl", "SCL"), ("sda", "SDA"))), - "feather_spi": ("spi", (("clock", "SCK"), ("mosi", "MOSI"), ("miso", "MISO"))), - "feather_serial": ("uart", (("tx", "TX"), ("rx", "RX"))), + "board_i2c": ("i2c", (("scl", "SCL"), ("sda", "SDA"))), + "board_spi": ("spi", (("clock", "SCK"), ("mosi", "MOSI"), ("miso", "MISO"))), + "board_serial": ("uart", (("tx", "TX"), ("rx", "RX"))), } # The board bus class each busio-capable devicetree driver provides. BOARD_BUS_OF_DRIVER = {"i2c": "i2c", "spi": "spi", "serial": "uart"} From 70caedf6f112df1b2a91cb67432b57b5eb639715 Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Mon, 19 Oct 2026 12:21:10 -0400 Subject: [PATCH 10/18] zephyr-cp: always generate i2sout_reset() reset_port() calls i2sout_reset() on every board with audiobusio.I2SOut, but zephyr2cp only generated it when the board had an I2SOut object, so a board with I2S but no board object failed to link. Generate it with an empty body in that case. Co-Authored-By: Claude Opus 5.5 --- ports/zephyr-cp/cptools/zephyr2cp.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/ports/zephyr-cp/cptools/zephyr2cp.py b/ports/zephyr-cp/cptools/zephyr2cp.py index c9fa84c4307..cde0531762c 100644 --- a/ports/zephyr-cp/cptools/zephyr2cp.py +++ b/ports/zephyr-cp/cptools/zephyr2cp.py @@ -1673,17 +1673,16 @@ def zephyr_dts_to_cp_board(board_id, portdir, builddir, zephyrbuilddir, mpconfig if table_parts: iobroker_tables = iobroker_tables + "\n\n" + "\n\n".join(table_parts) - # Generate i2sout_reset() that stops all board I2SOut instances - if i2sout_instance_names: - stop_calls = "\n ".join( - f"common_hal_audiobusio_i2sout_stop(&{name}_obj);" for name in i2sout_instance_names - ) - i2sout_reset_func = f""" + # Generate i2sout_reset() that stops the board's I2SOut objects. It is + # generated even when there are none, because reset_port() calls it on + # every board with audiobusio.I2SOut. + stop_calls = "\n ".join( + f"common_hal_audiobusio_i2sout_stop(&{name}_obj);" for name in i2sout_instance_names + ) + i2sout_reset_func = f""" void i2sout_reset(void) {{ {stop_calls} }}""" - else: - i2sout_reset_func = "" zephyr_display_header = "" zephyr_display_init = "" From 1050db36c1af0f82467dd48069fb3cb213991711 Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Mon, 19 Oct 2026 12:21:23 -0400 Subject: [PATCH 11/18] zephyr-cp: choose board buses from connector pin names Drop the board_* devicetree labels. board.I2C(), board.SPI() and board.UART() now use the board's standard pins: the pins named SCL/SDA, SCK/MOSI/MISO and TX/RX by a connector (see CONNECTORS) or the circuitpython.toml [pins] table. - On nRF, a fixed instance on the standard pins is the board object. Otherwise, if the board has a routable instance, the board object is a bus on the standard pins, and iobroker picks the instance at runtime. - Other SoCs keep the old choice of main instance. - Only that object is exported, under its standard name (I2C, SPI, UART, I2SOUT). Instance and label names, and other fixed instances, are no longer board objects. - The zephyr,console and zephyr,shell-uart UARTs are never board objects. - The arduino-header-r3 connector names D0/D1 RX/TX, D11-D13 MOSI/MISO/SCK and D14/D15 SDA/SCL, as on Arduino Uno R3 boards. Co-Authored-By: Claude Opus 5.5 --- .../feather_nrf52840_zephyr/board.overlay | 6 - .../zephyr-cp/cptools/tests/test_zephyr2cp.py | 114 ++++++-- ports/zephyr-cp/cptools/zephyr2cp.py | 250 +++++++++++------- ports/zephyr-cp/tests/test_audiobusio.py | 8 +- 4 files changed, 244 insertions(+), 134 deletions(-) diff --git a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/board.overlay b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/board.overlay index 517074cdb7b..b2bcdd5b827 100644 --- a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/board.overlay +++ b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/board.overlay @@ -17,12 +17,6 @@ /delete-node/ board_cdc_acm_uart; }; -// board.I2C(), board.SPI() and board.UART() are on the Feather header buses. -// See BOARD_BUS_LABELS in cptools/zephyr2cp.py. -board_i2c: &feather_i2c {}; -board_spi: &feather_spi {}; -board_serial: &feather_serial {}; - // Bus instances enabled with all pins disconnected and marked // zephyr,deferred-init so nothing is claimed at boot: the iobroker // initializes and routes a device to arbitrary pins when a busio object is diff --git a/ports/zephyr-cp/cptools/tests/test_zephyr2cp.py b/ports/zephyr-cp/cptools/tests/test_zephyr2cp.py index 136a1b75443..770b630da05 100644 --- a/ports/zephyr-cp/cptools/tests/test_zephyr2cp.py +++ b/ports/zephyr-cp/cptools/tests/test_zephyr2cp.py @@ -25,7 +25,8 @@ MINIMUM_RAM_SIZE, add_toml_pin_names, _fastest_first, - _dt_board_buses, + _fixed_instance_on_pins, + _standard_bus_pins, _toml_status_pin, ) @@ -801,8 +802,8 @@ def test_unknown_name_raises(self): _toml_status_pin({"STATUS_NEOPIXEL": "NOPE"}, self.BOARD_NAMES, "STATUS_NEOPIXEL") -class TestDtBoardBuses: - """board.I2C(), board.SPI() and board.UART() from devicetree bus labels.""" +class TestStandardBusPins: + """The standard pins of board.I2C(), board.SPI() and board.UART().""" BOARD_NAMES = { ("gpio0", 11): ["SCL"], @@ -814,39 +815,102 @@ class TestDtBoardBuses: ("gpio0", 24): ["RX"], } - @staticmethod - def _device_tree(*labels): - return SimpleNamespace(label2node={label: object() for label in labels}) - - def test_board_labels(self): - buses = _dt_board_buses( - self._device_tree("board_i2c", "board_spi", "board_serial"), self.BOARD_NAMES - ) - assert buses == { + def test_all_standard_names(self): + assert _standard_bus_pins(self.BOARD_NAMES) == { "i2c": {"scl": ("gpio0", 11), "sda": ("gpio0", 12)}, "spi": {"clock": ("gpio0", 14), "mosi": ("gpio0", 13), "miso": ("gpio0", 15)}, "uart": {"tx": ("gpio0", 25), "rx": ("gpio0", 24)}, } - def test_only_labeled_buses(self): - buses = _dt_board_buses( - self._device_tree("board_i2c", "feather_spi", "i2c0"), self.BOARD_NAMES - ) - assert list(buses) == ["i2c"] + def test_missing_name_leaves_out_bus(self): + names = {key: value for key, value in self.BOARD_NAMES.items() if value != ["MISO"]} + assert list(_standard_bus_pins(names)) == ["i2c", "uart"] - def test_no_labels(self): - assert _dt_board_buses(self._device_tree("i2c0", "spi1"), self.BOARD_NAMES) == {} + def test_no_standard_names(self): + assert _standard_bus_pins({("gpio0", 2): ["D2"]}) == {} def test_pin_names_ignore_case(self): names = dict(self.BOARD_NAMES) names[("gpio0", 11)] = ["scl"] - buses = _dt_board_buses(self._device_tree("board_i2c"), names) - assert buses["i2c"]["scl"] == ("gpio0", 11) + assert _standard_bus_pins(names)["i2c"]["scl"] == ("gpio0", 11) + + def test_connector_aliases(self): + # Arduino header pins carry both their header name and the standard name. + names = { + ("gpio1", 0): ["D0", "RX"], + ("gpio1", 1): ["D1", "TX"], + ("gpio1", 2): ["D14", "SDA"], + ("gpio1", 3): ["D15", "SCL"], + } + assert _standard_bus_pins(names) == { + "i2c": {"scl": ("gpio1", 3), "sda": ("gpio1", 2)}, + "uart": {"tx": ("gpio1", 1), "rx": ("gpio1", 0)}, + } - def test_missing_pin_name_raises(self): - names = {key: value for key, value in self.BOARD_NAMES.items() if value != ["MISO"]} - with pytest.raises(RuntimeError, match="board_spi needs a board pin named MISO"): - _dt_board_buses(self._device_tree("board_spi"), names) + +class TestFixedInstanceOnPins: + """The fixed nRF bus instance on a board's standard pins.""" + + # psels entries carry the signal function in their upper bits and the + # absolute pin (port * 32 + pin) in the low 9 bits; 0x1ff is disconnected. + DTS = """ +/dts-v1/; +/ { + pinctrl { + i2c1_default: i2c1_default { + group1 { + psels = <0x0c000022>, <0x0b000023>; + }; + }; + i2c2_dynamic: i2c2_dynamic { + group1 { + psels = <0x0c0001ff>, <0x0b0001ff>; + }; + }; + uart1_default: uart1_default { + group1 { + psels = <0x00000021>, <0x00020020>, <0x0001000b>; + }; + }; + }; + i2c1: i2c@1 { + pinctrl-0 = <&i2c1_default>; + }; + i2c2: i2c@2 { + pinctrl-0 = <&i2c2_dynamic>; + }; + uart1: uart@1 { + pinctrl-0 = <&uart1_default>; + }; +}; +""" + PORT_INDEXES = {"gpio0": 0, "gpio1": 1} + I2C_PINS = [("gpio1", 3), ("gpio1", 2)] + + def _find(self, instances, pins, routable=()): + return _fixed_instance_on_pins( + parse_dts_string(self.DTS), instances, set(routable), self.PORT_INDEXES, pins + ) + + def test_finds_instance_on_pins(self): + instances = [["i2c2"], ["i2c1", "arduino_i2c"]] + assert self._find(instances, self.I2C_PINS) == ["i2c1", "arduino_i2c"] + + def test_extra_signals_still_match(self): + # TX and RX are routed, along with RTS on another pin. + assert self._find([["uart1"]], [("gpio1", 1), ("gpio1", 0)]) == ["uart1"] + + def test_skips_routable_instances(self): + assert self._find([["i2c1"]], self.I2C_PINS, routable=["i2c1"]) is None + + def test_no_instance_on_pins(self): + assert self._find([["i2c1"], ["i2c2"]], [("gpio0", 3), ("gpio0", 4)]) is None + + def test_unknown_port_is_none(self): + assert self._find([["i2c1"]], [("expander", 3), ("gpio1", 2)]) is None + + def test_no_pins_is_none(self): + assert self._find([["i2c1"]], []) is None class TestFastestFirst: diff --git a/ports/zephyr-cp/cptools/zephyr2cp.py b/ports/zephyr-cp/cptools/zephyr2cp.py index cde0531762c..28e944f3b1d 100644 --- a/ports/zephyr-cp/cptools/zephyr2cp.py +++ b/ports/zephyr-cp/cptools/zephyr2cp.py @@ -73,6 +73,9 @@ "SCL", "SDA", ], + # The standard bus pin names are those on Arduino Uno R3 boards: TX/RX on + # D1/D0, SPI on D11-D13 (and the 2x3 SPI header), and SDA/SCL on the two + # pins above AREF, which this binding calls D14 and D15. "arduino-header-r3": [ "A0", "A1", @@ -80,8 +83,8 @@ "A3", "A4", "A5", - "D0", - "D1", + ["D0", "RX"], + ["D1", "TX"], "D2", "D3", "D4", @@ -91,11 +94,11 @@ "D8", "D9", "D10", - "D11", - "D12", - "D13", - "D14", - "D15", + ["D11", "MOSI"], + ["D12", "MISO"], + ["D13", "SCK"], + ["D14", "SDA"], + ["D15", "SCL"], ], "adafruit-feather-header": [ "A0", @@ -760,21 +763,31 @@ def _toml_status_pin(mpconfigboard, board_names, key): raise RuntimeError(f"circuitpython.toml {key} = {name!r} is not a board pin name") -# Devicetree labels that mark a board's standard header buses, mapped to the -# bus class and the board pin names its signals use, as (field, pin name). -# The field names match the CIRCUITPY_BOARD_*_PIN structs in -# shared-module/board. The pin names come from the board's connector (see -# CONNECTORS) or its [pins] table. +# The standard pins of each board bus. For example: +# +# "i2c [bus kind]": {"scl [pin field]": "SCL [pin name]", "sda [pin field]": "SDA [pin name]"}, # -# These labels are ours, not upstream's. Each board adds them in its -# board.overlay, as an alias of an upstream connector label when there is -# one (board_i2c: &feather_i2c {};) or on the bus node otherwise. -BOARD_BUS_LABELS = { - "board_i2c": ("i2c", (("scl", "SCL"), ("sda", "SDA"))), - "board_spi": ("spi", (("clock", "SCK"), ("mosi", "MOSI"), ("miso", "MISO"))), - "board_serial": ("uart", (("tx", "TX"), ("rx", "RX"))), +# - bus kind: board.I2C(), board.SPI() or board.UART(). The generated pin table +# for shared-module/board is named after it (CIRCUITPY_BOARD_I2C_PIN). +# - pin field: The corresponding name of the field in the struct holding the +# pins for the bus in shared-module/board/__init__.c. For instance, the `scl` +# field in the `board_i2c_pin_t` struct. +# - pin name: the standard board pin name whose pin goes in that field. A +# board's connector (see CONNECTORS) or its circuitpython.toml [pins] table +# gives its header pins these names. +BOARD_BUS_PINS = { + "i2c": {"scl": "SCL", "sda": "SDA"}, + "spi": {"clock": "SCK", "mosi": "MOSI", "miso": "MISO"}, + "uart": {"tx": "TX", "rx": "RX"}, } -# The board bus class each busio-capable devicetree driver provides. +# The board bus kind for each busio driver kind. For example: +# +# "serial [driver kind]": "uart [bus kind]", +# +# - driver kind: a BUSIO_CLASSES key, the kind of devicetree device that a +# busio class uses ("serial" for busio.UART, after Zephyr's serial drivers). +# - bus kind: a BOARD_BUS_PINS key, the board bus that such devices provide +# ("uart" for board.UART()). BOARD_BUS_OF_DRIVER = {"i2c": "i2c", "spi": "spi", "serial": "uart"} @@ -786,30 +799,39 @@ def _board_pin_named(board_names, name): return None -def _dt_board_buses(device_tree, board_names): - """Find the board buses that devicetree labels mark. +def _standard_bus_pins(board_names): + """Find the standard pins of each board bus class. - Each BOARD_BUS_LABELS label in the devicetree gives one board bus, named - after its class (``board.I2C``, ``board.SPI`` or ``board.UART``), on the - board pins with the label's pin names. The labeled node only says that - the board has the bus. It may be dynamic or disabled, because iobroker - picks the instance when the bus is constructed. Returns - ``{bus: {field: pin}}``. + A class has standard pins when the board names every pin it needs with + the standard names in BOARD_BUS_PINS. Returns ``{bus: {field: pin}}``, + with ``pin`` an ``(ioport, num)`` key of ``board_names``. """ - buses = {} - for label, (bus, fields) in BOARD_BUS_LABELS.items(): - if label not in device_tree.label2node: + standard_pins = {} + for bus, fields in BOARD_BUS_PINS.items(): + pins = {field: _board_pin_named(board_names, name) for field, name in fields.items()} + if None not in pins.values(): + standard_pins[bus] = pins + return standard_pins + + +def _fixed_instance_on_pins(device_tree, instances, routable_labels, port_indexes, pins): + """Return the labels of the fixed nRF bus instance that uses ``pins``. + + ``pins`` are ``(ioport, num)`` board pins. An instance uses them when its + default pinctrl state routes signals to all of them. Routable instances + have no pins of their own and are skipped. Returns None when no instance + uses the pins. + """ + if not pins or not all(ioport in port_indexes for ioport, _ in pins): + return None + wanted = {port_indexes[ioport] * 32 + num for ioport, num in pins} + for labels in instances: + if labels[0] in routable_labels: continue - if bus in buses: - raise RuntimeError(f"devicetree label {label} is a second {bus} board bus") - pins = {} - for field, name in fields: - pin = _board_pin_named(board_names, name) - if pin is None: - raise RuntimeError(f"devicetree label {label} needs a board pin named {name}") - pins[field] = pin - buses[bus] = pins - return buses + psels = _pinctrl_default_psels(device_tree.label2node[labels[0]]) or [] + if wanted <= {value & NRF_PIN_FIELD_MASK for value in psels}: + return labels + return None @cpbuild.run_in_thread @@ -1285,11 +1307,7 @@ def zephyr_dts_to_cp_board(board_id, portdir, builddir, zephyrbuilddir, mpconfig # module), which iobroker allocates pwmio channels on too. pwm_emul = line.strip().endswith("=y") board_info["neopixel_write"] = pwm_nrfx - # board.I2C(), board.SPI() and board.UART() for the buses that devicetree - # labels mark, constructed on demand on their pins. That needs iobroker - # routing (nRF); other SoCs keep their fixed devicetree buses. - board_buses = _dt_board_buses(device_tree, board_names) if pinctrl_nrf else {} - board_bus_names = {bus.upper() for bus in board_buses} + standard_bus_pins = _standard_bus_pins(board_names) pin_object_of = {} # GPIO pad number -> pad: every (label, num) name and alias the # devicetree walk produced keys into one pin object per pad. @@ -1409,6 +1427,41 @@ def zephyr_dts_to_cp_board(board_id, portdir, builddir, zephyrbuilddir, mpconfig ): iobroker_labels.add(labels[0]) + # The zephyr,console and zephyr,shell-uart UARTs belong to Zephyr, so they + # are never board objects. + chosen_props = device_tree.root.nodes["chosen"].props + console_nodes = [ + chosen_props[name].to_path() + for name in ("zephyr,console", "zephyr,shell-uart") + if name in chosen_props + ] + + # On nRF, board.I2C(), board.SPI() and board.UART() use the board's + # standard pins: + # - A fixed instance on the standard pins is the board object, unless it + # is a console. + # - Otherwise, if the board has a routable instance of the class, the + # board object is a bus on the standard pins, and iobroker picks the + # instance when the bus is constructed. + fixed_on_standard_pins = {} + board_buses = {} + if pinctrl_nrf: + for driver, bus in BOARD_BUS_OF_DRIVER.items(): + if bus not in standard_bus_pins: + continue + instances = active_zephyr_devices.get(driver, []) + fixed = _fixed_instance_on_pins( + device_tree, + instances, + iobroker_labels, + port_indexes, + standard_bus_pins[bus].values(), + ) + if fixed is not None: + fixed_on_standard_pins[driver] = fixed + elif any(labels[0] in iobroker_labels for labels in instances): + board_buses[bus] = standard_bus_pins[bus] + zephyr_binding_headers = [] zephyr_binding_objects = [] zephyr_binding_labels = [] @@ -1426,54 +1479,57 @@ def zephyr_dts_to_cp_board(board_id, portdir, builddir, zephyrbuilddir, mpconfig zephyr_binding_headers.append(f'#include "shared-bindings/{module}/{driverclass}.h"') - # Designate a main device such as board.I2C or board.I2S, unless a - # devicetree label marks a board bus of this class. - if BOARD_BUS_OF_DRIVER.get(driver) in board_buses: + # A board has at most one object per class, under the class's + # standard name (board.I2C, board.I2SOUT, ...). Other fixed instances + # are not board objects. + bus = BOARD_BUS_OF_DRIVER.get(driver) + candidates = [ + labels + for labels in instances + if labels[0] not in iobroker_labels + and device_tree.label2node[labels[0]] not in console_nodes + ] + main = None + if bus in board_buses: + # The board bus has the standard name. pass - elif len(instances) == 1: - instances[0].append(driverclass) + elif pinctrl_nrf and bus is not None: + # The fixed instance on the standard pins, unless it is a console. + fixed = fixed_on_standard_pins.get(driver) + if fixed in candidates: + main = fixed + elif len(candidates) == 1: + main = candidates[0] else: - # Check to see if a main device has already been designated - found_main = False - for labels in instances: - for label in labels: - if label == driverclass: - found_main = True - if not found_main: - for priority_label in (f"zephyr_{driver}", f"arduino_{driver}"): - for labels in instances: - if priority_label in labels: - labels.append(driverclass) - found_main = True - break - if found_main: - break - for labels in instances: - if labels[0] in iobroker_labels: - # Dynamically routable instances are not exposed as board - # singletons; construct a busio object with pins instead. - continue - instance_name = f"{driver.replace('/', '_')}_{labels[0]}" - c_function_name = f"_{instance_name}" - singleton_ptr = f"{c_function_name}_singleton" - function_object = f"{c_function_name}_obj" - obj_type = f"{module}_{driverclass.lower()}" - - # Handle special cases for different drivers - if driver == "serial": - # UART needs a receiver buffer - buffer_decl = f"static byte {instance_name}_buffer[128];" - construct_call = f"common_hal_busio_uart_construct_from_device(&{instance_name}_obj, DEVICE_DT_GET(DT_NODELABEL({labels[0]})), 128, {instance_name}_buffer)" - else: - # Default case (I2C, SPI, I2S) - buffer_decl = "" - construct_call = f"common_hal_{module}_{driverclass.lower()}_construct_from_device(&{instance_name}_obj, DEVICE_DT_GET(DT_NODELABEL({labels[0]})))" + # A label picks the main instance. + for priority_label in (driverclass, f"zephyr_{driver}", f"arduino_{driver}"): + main = next((labels for labels in candidates if priority_label in labels), None) + if main is not None: + break + if main is None: + continue - if driver == "i2s": - i2sout_instance_names.append(instance_name) + instance_name = f"{driver.replace('/', '_')}_{main[0]}" + c_function_name = f"_{instance_name}" + singleton_ptr = f"{c_function_name}_singleton" + function_object = f"{c_function_name}_obj" + obj_type = f"{module}_{driverclass.lower()}" + + # Handle special cases for different drivers + if driver == "serial": + # UART needs a receiver buffer + buffer_decl = f"static byte {instance_name}_buffer[128];" + construct_call = f"common_hal_busio_uart_construct_from_device(&{instance_name}_obj, DEVICE_DT_GET(DT_NODELABEL({main[0]})), 128, {instance_name}_buffer)" + else: + # Default case (I2C, SPI, I2S) + buffer_decl = "" + construct_call = f"common_hal_{module}_{driverclass.lower()}_construct_from_device(&{instance_name}_obj, DEVICE_DT_GET(DT_NODELABEL({main[0]})))" + + if driver == "i2s": + i2sout_instance_names.append(instance_name) - zephyr_binding_objects.append( - f"""{buffer_decl} + zephyr_binding_objects.append( + f"""{buffer_decl} static {obj_type}_obj_t {instance_name}_obj; static mp_obj_t {singleton_ptr} = mp_const_none; static mp_obj_t {c_function_name}(void) {{ @@ -1484,19 +1540,15 @@ def zephyr_dts_to_cp_board(board_id, portdir, builddir, zephyrbuilddir, mpconfig return {singleton_ptr}; }} static MP_DEFINE_CONST_FUN_OBJ_0({function_object}, {c_function_name});""".lstrip() - ) - for label in labels: - if label.upper() in board_bus_names: - # A labeled board bus has this name. - continue - zephyr_binding_labels.append( - f"{{ MP_ROM_QSTR(MP_QSTR_{label.upper()}), MP_ROM_PTR(&{function_object}) }}," - ) + ) + zephyr_binding_labels.append( + f"{{ MP_ROM_QSTR(MP_QSTR_{driverclass.upper()}), MP_ROM_PTR(&{function_object}) }}," + ) zephyr_binding_headers = "\n".join(zephyr_binding_headers) zephyr_binding_objects = "\n".join(zephyr_binding_objects) zephyr_binding_labels = "\n".join(zephyr_binding_labels) - # Board buses from devicetree labels: the CIRCUITPY_BOARD_* pin tables for + # Board buses on the standard pins: the CIRCUITPY_BOARD_* pin tables for # shared-module/board, and the board name for each bus. board_bus_defines = [] board_bus_labels = [] diff --git a/ports/zephyr-cp/tests/test_audiobusio.py b/ports/zephyr-cp/tests/test_audiobusio.py index 9d03e976bc9..67bfd61f69d 100644 --- a/ports/zephyr-cp/tests/test_audiobusio.py +++ b/ports/zephyr-cp/tests/test_audiobusio.py @@ -31,7 +31,7 @@ channel_count=2, ) -dac = board.I2S0() +dac = board.I2SOUT() print("playing") dac.play(sample, loop=True) time.sleep(0.5) @@ -134,7 +134,7 @@ def test_i2s_play_and_stop(circuitpython): channel_count=2, ) -dac = board.I2S0() +dac = board.I2SOUT() dac.play(sample, loop=True) print("playing") time.sleep(0.2) @@ -183,7 +183,7 @@ def test_i2s_stops_on_code_exit(circuitpython): channel_count=2, ) -dac = board.I2S0() +dac = board.I2SOUT() dac.play(sample, loop=True) print("playing") time.sleep(0.2) @@ -243,7 +243,7 @@ def test_i2s_pause_resume(circuitpython): channel_count=2, ) -dac = board.I2S0() +dac = board.I2SOUT() print("playing") dac.play(sample, loop=False) time.sleep(0.5) From b6e30f41df1d9dc7d18c323d38fe54820af47b4a Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Mon, 19 Oct 2026 12:58:59 -0400 Subject: [PATCH 12/18] zephyr-cp: use UARTE for UART0 on the Feather nRF52840 The upstream board uses the legacy UART driver for UART0. It has no de-init, so once a busio.UART on UART0 was released, iobroker could not route it again, and the next busio.UART failed with "All UART peripherals are in use" when UART1 was busy. Use UARTE, like UART1. Co-Authored-By: Claude Opus 5.5 --- .../boards/adafruit/feather_nrf52840_zephyr/board.overlay | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/board.overlay b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/board.overlay index b2bcdd5b827..a26e02917e2 100644 --- a/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/board.overlay +++ b/ports/zephyr-cp/boards/adafruit/feather_nrf52840_zephyr/board.overlay @@ -264,7 +264,11 @@ pinctrl-names = "default", "sleep"; }; +// The upstream board uses the legacy UART driver for UART0, which can't be +// de-initialized, so iobroker could not route it again once it was released. +// Instead, use the UARTE driver, which can be deinited, like UART1. &uart0 { + compatible = "nordic,nrf-uarte"; status = "okay"; zephyr,deferred-init; current-speed = <115200>; From db682b7c22cd56c5c6f44edb6764f2209ef80b91 Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Mon, 19 Oct 2026 15:45:37 -0400 Subject: [PATCH 13/18] zephyr-cp: give native_sim a non-console UART for board.UART() The console UART is no longer a board object, so native_sim had no board UART, and test_busio_uart.py's board.UART0() failed. Enable the second PTY UART (uart1) on native_sim, which becomes board.UART(), and use it in the test. uart1 also announces its pseudotty on stdout, so after a reboot the test harness reattached to it instead of the console. Reconnect only to the console UART's pseudotty. Co-Authored-By: Claude Opus 5.5 --- .../zephyr-cp/boards/native/native_sim/board.overlay | 6 ++++++ .../boards/native/native_sim_lfs/board.overlay | 6 ++++++ ports/zephyr-cp/tests/__init__.py | 12 +++++++++++- ports/zephyr-cp/tests/test_busio_uart.py | 4 ++-- 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/ports/zephyr-cp/boards/native/native_sim/board.overlay b/ports/zephyr-cp/boards/native/native_sim/board.overlay index 2985a004670..3d4776350dd 100644 --- a/ports/zephyr-cp/boards/native/native_sim/board.overlay +++ b/ports/zephyr-cp/boards/native/native_sim/board.overlay @@ -36,6 +36,12 @@ }; }; +/* A second PTY UART, so there is a UART that isn't the console (uart0): + * board.UART() and the busio.UART tests use it. */ +&uart1 { + status = "okay"; +}; + /* Expand ADC channels for analogio tests. */ &adc0 { nchannels = <4>; diff --git a/ports/zephyr-cp/boards/native/native_sim_lfs/board.overlay b/ports/zephyr-cp/boards/native/native_sim_lfs/board.overlay index 66b9de603ff..a40d65d84f3 100644 --- a/ports/zephyr-cp/boards/native/native_sim_lfs/board.overlay +++ b/ports/zephyr-cp/boards/native/native_sim_lfs/board.overlay @@ -67,6 +67,12 @@ }; }; +/* A second PTY UART, so there is a UART that isn't the console (uart0): + * board.UART() and the busio.UART tests use it. */ +&uart1 { + status = "okay"; +}; + /* Expand ADC channels for analogio tests. */ &adc0 { nchannels = <4>; diff --git a/ports/zephyr-cp/tests/__init__.py b/ports/zephyr-cp/tests/__init__.py index 78b9a17a676..5357f20467d 100644 --- a/ports/zephyr-cp/tests/__init__.py +++ b/ports/zephyr-cp/tests/__init__.py @@ -148,6 +148,12 @@ def __init__(self, cmd, timeout=5, trace_file=None, env=None, flash_file=None): if "connected to pseudotty:" not in uart_pty_line: raise RuntimeError("Failed to connect to UART") pty_path = uart_pty_line.strip().rsplit(":", maxsplit=1)[1].strip() + # The console UART's device name ("uart"). Other PTY UARTs announce + # their pseudotty in the same way, so reconnect_serial() looks for this + # name's line only. + self._console_pty_marker = ( + uart_pty_line.split("connected to pseudotty:")[0].strip() + " connected to pseudotty:" + ) self.serial = SerialSaver( serial.Serial(pty_path, baudrate=115200, timeout=0.05, write_timeout=0), name="uart0", @@ -169,13 +175,17 @@ def reconnect_serial(self, timeout=30.0): """ import time - marker = "connected to pseudotty:" + marker = self._console_pty_marker deadline = time.monotonic() + timeout while time.monotonic() < deadline: if self._proc.poll() is not None: return False out = self.debug_serial.all_output + # Only a line that starts with the console's name: the marker must + # not match the end of another UART's line. idx = out.find(marker, self._pty_search_offset) + while idx > 0 and out[idx - 1] not in "\r\n": + idx = out.find(marker, idx + 1) newline = out.find("\n", idx) if idx >= 0 else -1 if idx >= 0 and newline >= 0: line = out[idx:newline] diff --git a/ports/zephyr-cp/tests/test_busio_uart.py b/ports/zephyr-cp/tests/test_busio_uart.py index 9d5a044e3ba..b8a9cc32756 100644 --- a/ports/zephyr-cp/tests/test_busio_uart.py +++ b/ports/zephyr-cp/tests/test_busio_uart.py @@ -1,14 +1,14 @@ # SPDX-FileCopyrightText: 2026 Mikey Sklar for Adafruit Industries # SPDX-License-Identifier: MIT -"""busio.UART on native_sim, through the console UART.""" +"""busio.UART on native_sim, through board.UART() on the second PTY UART.""" import pytest TIMEOUT_CODE = """\ import board -uart = board.UART0() +uart = board.UART() for t in (0.5, 2, 0): uart.timeout = t print("timeout", uart.timeout) From 33b39d60446edea9a455994c80ad0ef172938b5d Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Mon, 19 Oct 2026 15:53:13 -0400 Subject: [PATCH 14/18] CI: pin Python 3.14 until minify_html has a 3.15 wheel setup-python's "3.x" now picks Python 3.15.0. minify_html, which requirements-dev.txt installs, has no 3.15 wheel, and building it fails because PyO3 supports Python only up to 3.14. So pre-commit, the Build CI scheduler and the non-Zephyr board builds fail before building anything. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build-board-custom.yml | 2 +- .github/workflows/build-boards.yml | 2 +- .github/workflows/build-mpy-cross.yml | 2 +- .github/workflows/build.yml | 6 +++--- .github/workflows/create-website-pr.yml | 2 +- .github/workflows/pre-commit.yml | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build-board-custom.yml b/.github/workflows/build-board-custom.yml index caeb16cfddf..2f8c0f68d8c 100644 --- a/.github/workflows/build-board-custom.yml +++ b/.github/workflows/build-board-custom.yml @@ -72,7 +72,7 @@ jobs: - name: Set up python uses: actions/setup-python@v6 with: - python-version: 3.x + python-version: "3.14" # minify_html has no Python 3.15 wheel yet - name: Board to port id: board-to-port run: | diff --git a/.github/workflows/build-boards.yml b/.github/workflows/build-boards.yml index 016a2f2f32e..0610103faa4 100644 --- a/.github/workflows/build-boards.yml +++ b/.github/workflows/build-boards.yml @@ -39,7 +39,7 @@ jobs: - name: Set up python uses: actions/setup-python@v6 with: - python-version: ${{ inputs.port == 'zephyr-cp' && '3.13' || '3.x' }} + python-version: ${{ inputs.port == 'zephyr-cp' && '3.13' || '3.14' }} # minify_html has no Python 3.15 wheel yet - name: Set up port id: set-up-port diff --git a/.github/workflows/build-mpy-cross.yml b/.github/workflows/build-mpy-cross.yml index dedf9154b8d..8a09d5b0897 100644 --- a/.github/workflows/build-mpy-cross.yml +++ b/.github/workflows/build-mpy-cross.yml @@ -37,7 +37,7 @@ jobs: - name: Set up python uses: actions/setup-python@v6 with: - python-version: 3.x + python-version: "3.14" # minify_html has no Python 3.15 wheel yet - name: Set up submodules uses: ./.github/actions/deps/submodules with: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f0f57a74243..4f53ed308b4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,7 +42,7 @@ jobs: - name: Set up python uses: actions/setup-python@v6 with: - python-version: 3.x + python-version: "3.14" # minify_html has no Python 3.15 wheel yet - name: Duplicate USB VID/PID check run: python3 -u -m tools.ci_check_duplicate_usb_vid_pid - name: Set up submodules @@ -135,7 +135,7 @@ jobs: - name: Set up python uses: actions/setup-python@v6 with: - python-version: 3.x + python-version: "3.14" # minify_html has no Python 3.15 wheel yet - name: Set up submodules uses: ./.github/actions/deps/submodules - name: Versions @@ -177,7 +177,7 @@ jobs: - name: Set up python uses: actions/setup-python@v6 with: - python-version: 3.x + python-version: "3.14" # minify_html has no Python 3.15 wheel yet - name: Set up submodules uses: ./.github/actions/deps/submodules with: diff --git a/.github/workflows/create-website-pr.yml b/.github/workflows/create-website-pr.yml index 559a41e67e7..25e4943d549 100644 --- a/.github/workflows/create-website-pr.yml +++ b/.github/workflows/create-website-pr.yml @@ -26,7 +26,7 @@ jobs: - name: Set up python uses: actions/setup-python@v6 with: - python-version: 3.x + python-version: "3.14" # minify_html has no Python 3.15 wheel yet - name: Set up submodules uses: ./.github/actions/deps/submodules with: diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 46961aca41f..b0dd8798774 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -30,7 +30,7 @@ jobs: - name: Set up python uses: actions/setup-python@v6 with: - python-version: 3.x + python-version: "3.14" # minify_html has no Python 3.15 wheel yet - name: Set up submodules uses: ./.github/actions/deps/submodules - name: Set up external From dc51f717ce85cef7bca6a4af084f27d7d7ec31c0 Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Mon, 19 Oct 2026 17:57:45 -0400 Subject: [PATCH 15/18] zephyr-cp: dynamic Arduino buses on the nRF5340 DK Make i2c1 and spi4 dynamic iobroker instances, so board.I2C(), board.SPI() and board.UART() are routed at runtime on the Arduino header pins, like the other buses. Remove the fixed i2s0 for the Audio Codec Shield, which used the Arduino SPI pins (D10-D13), until audiobusio.I2SOut can be routed dynamically. Turn off the upstream board's GPIO forwarder, which handed the Arduino D0/D1 pins (and P0.10/P0.11) to the network core for its UART console, so the application core can use them for board.UART(). Co-Authored-By: Claude Opus 5.5 --- .../boards/nordic/nrf5340dk/board.overlay | 85 +++++++++++++------ 1 file changed, 61 insertions(+), 24 deletions(-) diff --git a/ports/zephyr-cp/boards/nordic/nrf5340dk/board.overlay b/ports/zephyr-cp/boards/nordic/nrf5340dk/board.overlay index 50b2583f827..775763d7523 100644 --- a/ports/zephyr-cp/boards/nordic/nrf5340dk/board.overlay +++ b/ports/zephyr-cp/boards/nordic/nrf5340dk/board.overlay @@ -1,25 +1,6 @@ -&pinctrl { - i2s0_default_alt: i2s0_default_alt { - group1 { - psels = , - , - , - , - ; - }; - }; -}; - -&clock { - hfclkaudio-frequency = <11289600>; -}; - -i2s_rxtx: &i2s0 { - status = "okay"; - pinctrl-0 = <&i2s0_default_alt>; - pinctrl-names = "default"; - clock-source = "ACLK"; -}; +// TODO: restore the I2S for the Open Music Labs Audio Codec Shield. The fixed +// i2s0 that was here used the Arduino SPI pins (D10-D13), so board.SPI() could +// not use them, and audiobusio.I2SOut can't be routed dynamically yet. // Enable free serial peripheral instances so CircuitPython can mux them to // arbitrary pins at runtime. Each instance is enabled in one mode because the @@ -33,10 +14,42 @@ i2s_rxtx: &i2s0 { // the instances are shared and allocated. // // Instance 0 is left alone: uart0 is the console and i2c0/spi0 share that -// same peripheral, so they must not be enabled. Instance 1 stays with the -// fixed arduino header i2c1, and spi4 stays the fixed arduino header SPI. +// same peripheral, so they must not be enabled. Instance 1 is i2c1, the +// Arduino header I2C. spi4 is the upstream Arduino header SPI and the only +// SPI instance. uart1 is the upstream Arduino serial, but it is the same +// instance as i2c1, so busio.UART uses uart3. &pinctrl { + i2c1_dyn_default: i2c1_dyn_default { + group1 { + psels = , + ; + }; + }; + + i2c1_dyn_sleep: i2c1_dyn_sleep { + group1 { + psels = , + ; + }; + }; + + spi4_dyn_default: spi4_dyn_default { + group1 { + psels = , + , + ; + }; + }; + + spi4_dyn_sleep: spi4_dyn_sleep { + group1 { + psels = , + , + ; + }; + }; + i2c2_dyn_default: i2c2_dyn_default { group1 { psels = , @@ -66,6 +79,14 @@ i2s_rxtx: &i2s0 { }; }; +&i2c1 { + status = "okay"; + zephyr,deferred-init; + pinctrl-0 = <&i2c1_dyn_default>; + pinctrl-1 = <&i2c1_dyn_sleep>; + pinctrl-names = "default", "sleep"; +}; + &i2c2 { status = "okay"; zephyr,deferred-init; @@ -74,6 +95,14 @@ i2s_rxtx: &i2s0 { pinctrl-names = "default", "sleep"; }; +&spi4 { + status = "okay"; + zephyr,deferred-init; + pinctrl-0 = <&spi4_dyn_default>; + pinctrl-1 = <&spi4_dyn_sleep>; + pinctrl-names = "default", "sleep"; +}; + &uart3 { status = "okay"; zephyr,deferred-init; @@ -83,6 +112,14 @@ i2s_rxtx: &i2s0 { pinctrl-names = "default", "sleep"; }; +// The upstream board forwards the Arduino D0/D1 pins (P1.00/P1.01) and +// P0.10/P0.11 to the network core for its UART console, so the application +// core could not use them. Keep them on the application core, for +// board.UART() on TX/RX (D1/D0). +&gpio_fwd { + status = "disabled"; +}; + // PWM instances 1-3, same scheme: all four outputs disconnected and deferred // init, so iobroker can hand an instance to neopixel_write and pwmio and // route its outputs at runtime. Instance 0 is left as the board defines it: From ffe8830a66c560e32f48d5c34981400900ee7198 Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Mon, 19 Oct 2026 17:57:45 -0400 Subject: [PATCH 16/18] zephyr-cp: dynamic I2C on the nRF54H20 DK Make i2c130 a dynamic iobroker instance instead of fixed to P2.10/P2.11, and name those pins SCL and SDA in circuitpython.toml, so board.I2C() is routed there at runtime. Enable gpio2, which the SoC dtsi leaves disabled, so the P2 pins have pin objects. Co-Authored-By: Claude Opus 5.5 --- .../boards/nordic/nrf54h20dk/board.overlay | 61 ++++++++++--------- .../nordic/nrf54h20dk/circuitpython.toml | 7 +++ 2 files changed, 39 insertions(+), 29 deletions(-) diff --git a/ports/zephyr-cp/boards/nordic/nrf54h20dk/board.overlay b/ports/zephyr-cp/boards/nordic/nrf54h20dk/board.overlay index f4c8dbcdacf..6a2395bfc7b 100644 --- a/ports/zephyr-cp/boards/nordic/nrf54h20dk/board.overlay +++ b/ports/zephyr-cp/boards/nordic/nrf54h20dk/board.overlay @@ -1,32 +1,10 @@ -/* - * SDA = P2.11 - * SCL = P2.10 - */ - -&pinctrl { - i2c130_default: i2c130_default { - group1 { - psels = , - ; - }; - }; - - i2c130_sleep: i2c130_sleep { - group1 { - psels = , - ; - low-power-enable; - }; - }; -}; +// board.I2C() is on SCL/SDA (P2.10/P2.11), the pins that circuitpython.toml +// names SCL and SDA. -&i2c130 { - clock-frequency = ; - pinctrl-0 = <&i2c130_default>; - pinctrl-1 = <&i2c130_sleep>; - pinctrl-names = "default", "sleep"; - zephyr,concat-buf-size = <256>; - memory-regions = <&cpuapp_dma_region>; +// Enable P2's GPIO controller so its pads get pin objects. The SoC dtsi +// leaves every port disabled and the board enables only the LED and button +// ports. +&gpio2 { status = "okay"; }; @@ -47,13 +25,28 @@ // See "nRF serial instances" in internal-modules/iobroker/README.md for how // the instances are shared and allocated. // -// Instance 130 stays with the fixed arduino header i2c130. Instances 120, +// Instance 130 is i2c130, which the upstream board fixes to P2.10/P2.11. +// Instances 120, // 135 and 136 are left alone: uart120 and uart135 are the console UARTs of // the (not built here) FLPR and PPR cores, and uart136 is the app console. // The DMA region is required on this SoC: the serial drivers only access // memory the peripheral's EasyDMA is allowed to reach. &pinctrl { + i2c130_dyn_default: i2c130_dyn_default { + group1 { + psels = , + ; + }; + }; + + i2c130_dyn_sleep: i2c130_dyn_sleep { + group1 { + psels = , + ; + }; + }; + i2c131_dyn_default: i2c131_dyn_default { group1 { psels = , @@ -99,6 +92,16 @@ }; }; +&i2c130 { + status = "okay"; + zephyr,deferred-init; + pinctrl-0 = <&i2c130_dyn_default>; + pinctrl-1 = <&i2c130_dyn_sleep>; + pinctrl-names = "default", "sleep"; + zephyr,concat-buf-size = <256>; + memory-regions = <&cpuapp_dma_region>; +}; + &i2c131 { status = "okay"; zephyr,deferred-init; diff --git a/ports/zephyr-cp/boards/nordic/nrf54h20dk/circuitpython.toml b/ports/zephyr-cp/boards/nordic/nrf54h20dk/circuitpython.toml index 15d36f2576f..7b8d0483c9c 100644 --- a/ports/zephyr-cp/boards/nordic/nrf54h20dk/circuitpython.toml +++ b/ports/zephyr-cp/boards/nordic/nrf54h20dk/circuitpython.toml @@ -1,2 +1,9 @@ CIRCUITPY_BUILD_EXTENSIONS = ["hex", "uf2"] DISABLED_MODULES=["jpegio", "gifio", "tilepalettemapper"] + +# No package map for this SoC yet, so pins are global GPIO numbers +# (port * 32 + pin). +[pins] +# board.I2C() pins: P2.10/P2.11. +SCL = 74 +SDA = 75 From b75f40f11178f3a5c76b580c96c33296f8df48da Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Mon, 19 Oct 2026 17:57:46 -0400 Subject: [PATCH 17/18] zephyr-cp: no fixed board buses on boards that use iobroker A board either routes its I2C, SPI and UART buses with iobroker or has only fixed buses from the devicetree. Drop the nRF pinctrl matching that chose a fixed instance on the standard pins as the board object. On a board with routable instances, board.I2C(), board.SPI() and board.UART() are always buses on the standard pins, and fixed bus instances are never board objects. The build fails if one is not used by Zephyr itself: a /chosen node, a node with child devices, or a node that another enabled node refers to by phandle. Boards with only fixed buses keep the existing choice of main instance. Co-Authored-By: Claude Opus 5.5 --- .../zephyr-cp/cptools/tests/test_zephyr2cp.py | 81 ++++++------- ports/zephyr-cp/cptools/zephyr2cp.py | 107 +++++++++++------- 2 files changed, 98 insertions(+), 90 deletions(-) diff --git a/ports/zephyr-cp/cptools/tests/test_zephyr2cp.py b/ports/zephyr-cp/cptools/tests/test_zephyr2cp.py index 770b630da05..eeb0d3f998e 100644 --- a/ports/zephyr-cp/cptools/tests/test_zephyr2cp.py +++ b/ports/zephyr-cp/cptools/tests/test_zephyr2cp.py @@ -25,9 +25,9 @@ MINIMUM_RAM_SIZE, add_toml_pin_names, _fastest_first, - _fixed_instance_on_pins, _standard_bus_pins, _toml_status_pin, + _used_by_zephyr, ) @@ -848,69 +848,56 @@ def test_connector_aliases(self): } -class TestFixedInstanceOnPins: - """The fixed nRF bus instance on a board's standard pins.""" +class TestUsedByZephyr: + """Fixed bus instances that Zephyr itself uses.""" - # psels entries carry the signal function in their upper bits and the - # absolute pin (port * 32 + pin) in the low 9 bits; 0x1ff is disconnected. DTS = """ /dts-v1/; / { - pinctrl { - i2c1_default: i2c1_default { - group1 { - psels = <0x0c000022>, <0x0b000023>; - }; - }; - i2c2_dynamic: i2c2_dynamic { - group1 { - psels = <0x0c0001ff>, <0x0b0001ff>; - }; - }; - uart1_default: uart1_default { - group1 { - psels = <0x00000021>, <0x00020020>, <0x0001000b>; - }; + chosen { + zephyr,console = &uart0; + }; + uart0: uart@0 { + }; + uart1: uart@1 { + }; + spi0: spi@0 { + flash@0 { + reg = <0>; }; }; - i2c1: i2c@1 { - pinctrl-0 = <&i2c1_default>; + spi1: spi@1 { }; - i2c2: i2c@2 { - pinctrl-0 = <&i2c2_dynamic>; + spi2: spi@2 { }; - uart1: uart@1 { - pinctrl-0 = <&uart1_default>; + display { + spi-dev = <&spi1>; + }; + disabled_display { + status = "disabled"; + spi-dev = <&spi2>; }; }; """ - PORT_INDEXES = {"gpio0": 0, "gpio1": 1} - I2C_PINS = [("gpio1", 3), ("gpio1", 2)] - - def _find(self, instances, pins, routable=()): - return _fixed_instance_on_pins( - parse_dts_string(self.DTS), instances, set(routable), self.PORT_INDEXES, pins - ) - def test_finds_instance_on_pins(self): - instances = [["i2c2"], ["i2c1", "arduino_i2c"]] - assert self._find(instances, self.I2C_PINS) == ["i2c1", "arduino_i2c"] + def _used(self, label): + dt = parse_dts_string(self.DTS) + return _used_by_zephyr(dt, dt.label2node[label]) - def test_extra_signals_still_match(self): - # TX and RX are routed, along with RTS on another pin. - assert self._find([["uart1"]], [("gpio1", 1), ("gpio1", 0)]) == ["uart1"] + def test_chosen_console(self): + assert self._used("uart0") - def test_skips_routable_instances(self): - assert self._find([["i2c1"]], self.I2C_PINS, routable=["i2c1"]) is None + def test_child_device(self): + assert self._used("spi0") - def test_no_instance_on_pins(self): - assert self._find([["i2c1"], ["i2c2"]], [("gpio0", 3), ("gpio0", 4)]) is None + def test_referenced_by_phandle(self): + assert self._used("spi1") - def test_unknown_port_is_none(self): - assert self._find([["i2c1"]], [("expander", 3), ("gpio1", 2)]) is None + def test_referenced_only_by_disabled_node(self): + assert not self._used("spi2") - def test_no_pins_is_none(self): - assert self._find([["i2c1"]], []) is None + def test_unused(self): + assert not self._used("uart1") class TestFastestFirst: diff --git a/ports/zephyr-cp/cptools/zephyr2cp.py b/ports/zephyr-cp/cptools/zephyr2cp.py index 69795206d80..a9289342986 100644 --- a/ports/zephyr-cp/cptools/zephyr2cp.py +++ b/ports/zephyr-cp/cptools/zephyr2cp.py @@ -814,24 +814,41 @@ def _standard_bus_pins(board_names): return standard_pins -def _fixed_instance_on_pins(device_tree, instances, routable_labels, port_indexes, pins): - """Return the labels of the fixed nRF bus instance that uses ``pins``. +def _node_enabled(node): + """Return True if a devicetree node's status is "okay" (or unset).""" + status = node.props.get("status") + return status is None or status.to_string() == "okay" - ``pins`` are ``(ioport, num)`` board pins. An instance uses them when its - default pinctrl state routes signals to all of them. Routable instances - have no pins of their own and are skipped. Returns None when no instance - uses the pins. + +def _used_by_zephyr(device_tree, node): + """Return True if Zephyr itself uses a bus node. + + A bus is Zephyr's when a /chosen property points at it (such as the + zephyr,console UART), it has child device nodes (such as a flash chip), + or another enabled node refers to it by phandle (such as a display's + mipi-dbi node). """ - if not pins or not all(ioport in port_indexes for ioport, _ in pins): - return None - wanted = {port_indexes[ioport] * 32 + num for ioport, num in pins} - for labels in instances: - if labels[0] in routable_labels: + for prop in device_tree.root.nodes["chosen"].props.values(): + try: + if prop.to_path() is node: + return True + except dtlib.DTError: + pass + if node.nodes: + return True + for other in device_tree.node_iter(): + if other is node or not _node_enabled(other): continue - psels = _pinctrl_default_psels(device_tree.label2node[labels[0]]) or [] - if wanted <= {value & NRF_PIN_FIELD_MASK for value in psels}: - return labels - return None + for prop in other.props.values(): + if prop.type == dtlib.Type.PHANDLE: + referenced = [prop.to_node()] + elif prop.type == dtlib.Type.PHANDLES: + referenced = prop.to_nodes() + else: + continue + if node in referenced: + return True + return False @cpbuild.run_in_thread @@ -1436,30 +1453,39 @@ def zephyr_dts_to_cp_board(board_id, portdir, builddir, zephyrbuilddir, mpconfig if name in chosen_props ] - # On nRF, board.I2C(), board.SPI() and board.UART() use the board's - # standard pins: - # - A fixed instance on the standard pins is the board object, unless it - # is a console. - # - Otherwise, if the board has a routable instance of the class, the - # board object is a bus on the standard pins, and iobroker picks the - # instance when the bus is constructed. - fixed_on_standard_pins = {} + # A board either routes its I2C, SPI and UART buses with iobroker (nRF + # boards with routable instances) or has only fixed buses from the + # devicetree. + # + # - With iobroker, board.I2C(), board.SPI() and board.UART() are buses on + # the board's standard pins, and iobroker picks the instance when the + # bus is constructed. Fixed bus instances are never board objects, and + # only Zephyr may use them (see _used_by_zephyr()). + # - With only fixed buses, the board objects are fixed instances (below). + uses_iobroker = pinctrl_nrf and any( + labels[0] in iobroker_labels + for driver in BOARD_BUS_OF_DRIVER + for labels in active_zephyr_devices.get(driver, []) + ) board_buses = {} - if pinctrl_nrf: + if uses_iobroker: for driver, bus in BOARD_BUS_OF_DRIVER.items(): - if bus not in standard_bus_pins: - continue instances = active_zephyr_devices.get(driver, []) - fixed = _fixed_instance_on_pins( - device_tree, - instances, - iobroker_labels, - port_indexes, - standard_bus_pins[bus].values(), - ) - if fixed is not None: - fixed_on_standard_pins[driver] = fixed - elif any(labels[0] in iobroker_labels for labels in instances): + for labels in instances: + node = device_tree.label2node[labels[0]] + if labels[0] not in iobroker_labels and not _used_by_zephyr(device_tree, node): + message = ( + f"devicetree node {labels[0]} is a fixed {driver} instance, but the " + "board routes its buses with iobroker: make it dynamic (all pins " + "disconnected, zephyr,deferred-init) or disable it" + ) + # The build's TaskGroup reports only that a task failed, + # so log the reason too. + logger.error(message) + raise RuntimeError(message) + if bus in standard_bus_pins and any( + labels[0] in iobroker_labels for labels in instances + ): board_buses[bus] = standard_bus_pins[bus] zephyr_binding_headers = [] @@ -1490,14 +1516,9 @@ def zephyr_dts_to_cp_board(board_id, portdir, builddir, zephyrbuilddir, mpconfig and device_tree.label2node[labels[0]] not in console_nodes ] main = None - if bus in board_buses: - # The board bus has the standard name. + if uses_iobroker and bus is not None: + # The board bus, if any, has the standard name. pass - elif pinctrl_nrf and bus is not None: - # The fixed instance on the standard pins, unless it is a console. - fixed = fixed_on_standard_pins.get(driver) - if fixed in candidates: - main = fixed elif len(candidates) == 1: main = candidates[0] else: From 4d20eae51903eec75dfa9cbeeed5b824743c5197 Mon Sep 17 00:00:00 2001 From: Dan Halbert Date: Mon, 19 Oct 2026 18:33:23 -0400 Subject: [PATCH 18/18] zephyr-cp: dynamic I2C0 and UARTE UART0 on the CLUE The upstream board fixes I2C0 to SCL/SDA with the legacy TWI driver, which the board-bus rule no longer allows on a board that uses iobroker. Make it a dynamic TWIM instance; the onboard sensors on SCL/SDA have no devicetree nodes, so it doesn't need to be fixed. The upstream board also uses the legacy UART driver for UART0, the CLUE's only UART instance. It can't be de-initialized, so after the first busio.UART was released, no UART could be created again. Use UARTE. Add a console.overlay, so a CONSOLE=1 build puts the Zephyr console on TX/RX, as on the Feathers. Co-Authored-By: Claude Opus 5.5 --- .../clue_nrf52840_zephyr/board.overlay | 40 +++++++++++++++++-- .../clue_nrf52840_zephyr/console.overlay | 18 +++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 ports/zephyr-cp/boards/adafruit/clue_nrf52840_zephyr/console.overlay diff --git a/ports/zephyr-cp/boards/adafruit/clue_nrf52840_zephyr/board.overlay b/ports/zephyr-cp/boards/adafruit/clue_nrf52840_zephyr/board.overlay index 08e919bfcce..e8e0189ce2b 100644 --- a/ports/zephyr-cp/boards/adafruit/clue_nrf52840_zephyr/board.overlay +++ b/ports/zephyr-cp/boards/adafruit/clue_nrf52840_zephyr/board.overlay @@ -1,6 +1,8 @@ /* Partition layout is owned by the mcuboot fork * (dts/adafruit/adafruit_clue_nrf52840.dtsi) and applied to this image by * sysbuild. This overlay only adds board/app config. */ +/* No Zephyr console by default, so TX/RX are free for busio.UART. A CONSOLE=1 + * build puts the console back on UART0 (console.overlay). */ / { chosen { /delete-property/ zephyr,console; @@ -15,11 +17,12 @@ /delete-node/ board_cdc_acm_uart; }; -// UART0, I2C1 and SPI3 are enabled with all pins disconnected and marked -// zephyr,deferred-init so nothing is claimed at boot: the iobroker +// UART0, I2C0, I2C1 and SPI3 are enabled with all pins disconnected and +// marked zephyr,deferred-init so nothing is claimed at boot: the iobroker // initializes and routes a device to arbitrary pins when a busio object is -// constructed, and de-initializes it on release. (I2C0 is the fixed sensor -// bus, SPI2 the fixed display bus, and QSPI the fixed external flash.) +// constructed, and de-initializes it on release. (SPI2 is the fixed display +// bus and QSPI the fixed external flash. The onboard sensors are on SCL/SDA +// but have no devicetree nodes, so I2C0 does not need to be fixed.) // // See "nRF serial instances" in internal-modules/iobroker/README.md for how // the instances are shared and allocated. @@ -44,6 +47,20 @@ }; }; + i2c0_dyn_default: i2c0_dyn_default { + group1 { + psels = , + ; + }; + }; + + i2c0_dyn_sleep: i2c0_dyn_sleep { + group1 { + psels = , + ; + }; + }; + i2c1_dyn_default: i2c1_dyn_default { group1 { psels = , @@ -147,7 +164,11 @@ }; }; +// The upstream board uses the legacy UART driver for UART0, which can't be +// de-initialized, so iobroker could not route it again once it was released. +// Instead, use the UARTE driver, which can be deinited. &uart0 { + compatible = "nordic,nrf-uarte"; status = "okay"; zephyr,deferred-init; current-speed = <115200>; @@ -156,6 +177,17 @@ pinctrl-names = "default", "sleep"; }; +// The upstream board fixes I2C0 to SCL/SDA with the legacy TWI driver. Use +// TWIM like I2C1 so iobroker can route it. +&i2c0 { + compatible = "nordic,nrf-twim"; + status = "okay"; + zephyr,deferred-init; + pinctrl-0 = <&i2c0_dyn_default>; + pinctrl-1 = <&i2c0_dyn_sleep>; + pinctrl-names = "default", "sleep"; +}; + &i2c1 { status = "okay"; zephyr,deferred-init; diff --git a/ports/zephyr-cp/boards/adafruit/clue_nrf52840_zephyr/console.overlay b/ports/zephyr-cp/boards/adafruit/clue_nrf52840_zephyr/console.overlay new file mode 100644 index 00000000000..095c4aa1d2d --- /dev/null +++ b/ports/zephyr-cp/boards/adafruit/clue_nrf52840_zephyr/console.overlay @@ -0,0 +1,18 @@ +/* Applied after board.overlay in CONSOLE=1 and DEBUG=1 builds: the Zephyr + * console on UART0, fixed to the TX/RX pins. TX/RX are then not available to + * busio.UART. */ +/ { + chosen { + zephyr,console = &uart0; + zephyr,shell-uart = &uart0; + zephyr,uart-mcumgr = &uart0; + zephyr,bt-mon-uart = &uart0; + zephyr,bt-c2h-uart = &uart0; + }; +}; + +&uart0 { + /delete-property/ zephyr,deferred-init; + pinctrl-0 = <&uart0_default>; + pinctrl-1 = <&uart0_sleep>; +};