Skip to content

zephyr-cp: dynamic buses on the Feather nRF52840 boards - #11576

Merged
tannewt merged 20 commits into
adafruit:mainfrom
dhalbert:zephyr-cp-optional-console
Oct 19, 2026
Merged

tannewt merged 20 commits into
adafruit:mainfrom
dhalbert:zephyr-cp-optional-console

Conversation

@dhalbert

@dhalbert dhalbert commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Code and tests by Claude. EDIT: This note was rewritten completely by @dhalbert on Oct 19.

Zephyr UART console took over TX/RX pins

  • zephyr-cp's overlays for the Feather nRF52840 and Feather Bluefruit Sense always put the Zephyr console on UART0, fixed to the pins TX/RX. So busio.UART() could not use those pins. Fixed: stop assigning UART0 in a fixed way to the console. The console is now a build option: set CONSOLE=1 (implied by DEBUG=1) to put the console on TX/RX. The console shows Zephyr's boot banner, printk() and warning/error logs. The CircuitPython REPL stays on USB.

Note that with CONSOLE=1, board.UART() raises "TX in use", because the TX/RX pins are already taken for the console.

Don't allocate I2C0 in a fixed way

  • The upstream boards fixed I2C0 to SCL/SDA. So busio.I2C(board.SCL, board.SDA) failed with "SCL in use". Now, make I2C0 a dynamic TWIM instance, allocated through iobroker.

UART0 reuse

  • The upstream boards used the legacy UART driver for UART0, which can't be de-initialized. Use UARTE instead.

Make board.I2C(), board.SPI(), board.UART() exist again

Problem: In 10.3.x, zephyr-cp boards had board.I2C(), board.SPI() and board.UART() only when a board's devicetree had exactly one device of that kind. This was true on the nRF Feathers, so the names existed. Since #11420 (the iobroker PR, in 11.0.0-alpha.1), these boards also enabled dynamic I2C, SPI and UART instances, which caused board to only have the nonstandard names I2C0, FEATHER_I2C, UART0 and FEATHER_SERIAL.

Fix: Have each board bus use the board's standard pins: SCL/SDA, TX/RX, etc. These names come from the CONNECTORS table in zephyr2cp.py or from the [pins] table in circuitpython.toml. A board either routes its buses with iobroker, or has only fixed buses from the devicetree:

  • For boards with dynamic instances (nRF only right now), the board object is a bus on the standard pins. iobroker picks the instance when it is constructed. The build will fail if a fixed bus instance isn't used by Zephyr itself: like a UART console, SPI flash chip, or a node that another node refers to, such as a display.
  • With only fixed buses, the board objects are fixed instances, chosen as before.

Don't include extra non-standard names defined by the device tree names.

Other SoCs keep their existing choice of main device, and will be fixed in PRs stacked on this one. This restores the three names on the Feather and Sense.

Change the arduino-header-r3 connector. It now also names D0/D1 RX/TX, D11-D13 MOSI/MISO/SCK and D14/D15 SDA/SCL, as on Arduino Uno R3 boards, so boards with an Arduino header have standard pins too.

nRF5340 DK and nRF54H20 DK

  • For the nRF5340 DK, all the buses are dynamic now. The fixed I2S and the GPIO forwarder are turned off for now.
  • nRF54H20 DK: i2c130 is now dynamic, and board.I2C() is on P2.10/P2.11.

I2S reset

  • Bug fix: zephyr2cp.py now always generates i2sout_reset(), which reset_port() calls. Without a board I2SOut object, it was missing and the build failed to link.

Python 3.14 in CI

Allocate 32 MHz SPIM3 on nRF first

zephyr2cp.py orders each pool by max-frequency, highest first, so SPIM3 is allocated first, as in ports/nordic.

Documentation

The iobroker README has a new "nRF serial instances" section: I2C, SPI and UART share an instance, the board overlay chooses each instance's mode, and pools are allocated fastest first. The nRF board overlays point to it.

Testing

  • Feather nRF52840: board.I2C() with an MSA301, busio.I2C() on SCL/SDA and A2/A3 at once, SCL as GPIO after deinit(), board.SPI() loopback at 1 to 16 MHz, board.UART() loopback on TX/RX and to an FTDI adapter both ways, CONSOLE=1 output on TX.
  • Feather nRF52840: busio.UART released and reused on both UART instances while the other is busy.
  • Feather nRF52840 with CONSOLE=1: board.UART() raises "TX in use".
  • Feather Bluefruit Sense: board.I2C() and busio.I2C() find all five onboard sensors.
  • Builds of the other nRF boards (nRF5340 DK, nRF54H20 DK, nRF54L15 DK, nRF54LM20 DK, nRF54L15 Tag and the two bsim boards) and of the other Arduino-header boards (FRDM-RW612, FRDM-MCXN947, MIMXRT1170-EVK, NUCLEO-N657X0-Q, NUCLEO-U575ZI-Q, EK-RA6M5).
  • cptools/tests and the native_sim tests pass. (test_spi_loopback fails on my machine, on main too, but passes in CI.)

dhalbert and others added 5 commits October 12, 2026 18:39
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>

@tannewt tannewt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working on this. A couple Qs.

Comment thread ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/board.overlay Outdated
Comment thread ports/zephyr-cp/boards/adafruit/feather_nrf52840_sense_zephyr/circuitpython.toml Outdated
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 <noreply@anthropic.com>
@dhalbert
dhalbert force-pushed the zephyr-cp-optional-console branch from 262d0e1 to 13cd72c Compare October 13, 2026 23:20
…console

# Conflicts:
#	ports/zephyr-cp/boards/nordic/nrf7002dk/board.overlay

@tannewt tannewt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we still need to settle on where the buses are defined.

CONSOLE=1 is fine with me. The intent is to not change the log level though right? I know changing the log level can require stack size increases too.

@dhalbert

Copy link
Copy Markdown
Collaborator Author

Right, no log level changes for CONSOLE=1.

dhalbert and others added 2 commits October 14, 2026 14:36
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@dhalbert
dhalbert requested a review from tannewt October 14, 2026 20:41
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 <noreply@anthropic.com>

@tannewt tannewt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really want to minimize the DTS that we maintain in CP versus in our zephyr upstream because it's harder to reuse then.

Comment thread ports/zephyr-cp/cptools/zephyr2cp.py Outdated
Comment thread ports/zephyr-cp/cptools/zephyr2cp.py
@mikeysklar

Copy link
Copy Markdown
Collaborator

Tested on Feather nRF52840.

Works: board.I2C/SPI/UART, two I2C buses at once, SPI up to 32 MHz.

Doesn't work: after board.UART().deinit() the UART isn't freed, so the next busio.UART fails with "All UART peripherals are in use". I2C and SPI are fine.

Suggestion: with CONSOLE=1, board.UART0 and FEATHER_SERIAL hand out the console UART. Probably should be hidden.

dhalbert and others added 5 commits October 19, 2026 12:21
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…console

# Conflicts:
#	ports/zephyr-cp/cptools/zephyr2cp.py
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 <noreply@anthropic.com>
@dhalbert

dhalbert commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

@tannewt Good for another look now. In 1050db3, I took out the board_* labels, as we discussed. I didn't need the feather_* or arduino_* labels either.

board.I2C(), .SPI() and .UART() now assign buses to the standard pin names from the CONNECTORS table or a board's [pins] table. On nRF, if a fixed peripheral instance is already assigned to those pins, use that for the board object. Otherwise iobroker picks an instance at runtime.

I added TX/RX, SCK/MOSI/MISO and SDA/SCL pin name aliases to the arduino-header-r3 CONNECTORS entry, so we have those standard names to use for the board buses.

I didn't need to do anything with board overlays for this. The Feather's pin names come from the upstream Feather connector.

There will be further fixes for other boards in the stacked PR's if this scheme is OK.

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 <noreply@anthropic.com>
@dhalbert
dhalbert force-pushed the zephyr-cp-optional-console branch from b7d3463 to 33b39d6 Compare October 19, 2026 20:07

@tannewt tannewt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One comment about the nrf specific fixed peripheral stuff. I think we can simplify it by erroring if a static peripheral exists where IOBroker support does. It should either be all static and we don't know the pins or all dynamic via IOBroker.

Comment thread ports/zephyr-cp/cptools/zephyr2cp.py Outdated
dhalbert and others added 3 commits October 19, 2026 17:57
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@dhalbert

Copy link
Copy Markdown
Collaborator Author

From @mikeysklar:

Doesn't work: after board.UART().deinit() the UART isn't freed, so the next busio.UART fails with "All UART peripherals are in use". I2C and SPI are fine.

Suggestion: with CONSOLE=1, board.UART0 and FEATHER_SERIAL hand out the console UART. Probably should be hidden.

I used the UARTE driver instead of UART; this fixes the deinit problem. For CONSOLE=1, now board.UART() exists but if you call it, you will get an "in use" error. board.UART0 and FEATHER_SERIAL no longer show up.

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 <noreply@anthropic.com>

@tannewt tannewt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working on this! I think we may want to do the Kconfig checking somehow differently but this is fine for now.

@tannewt
tannewt merged commit 246a7ab into adafruit:main Oct 19, 2026
18 checks passed
@dhalbert
dhalbert deleted the zephyr-cp-optional-console branch October 19, 2026 23:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants