Benedikt c71cbcd2c3
RMT: Refactor driver internals (less macros, type-erased channels) (#3505)
* RMT: Move some methods from (Tx|Rx)ChannelInternal to ChannelInternal

Adds a new `ChannelInternal` trait implemented on `Channel`s, which
bundles some methods that conceptually make sense for both rx and tx
channels (whether the implementation is exactly the same is
chip-specific).
This avoids a small amount of code duplication.

* RMT: Define input/output signals via a const array

Allowing a default implementation of the getter functions in the ChannelInternal
trait, and more importantly, paving the way for type-erased channels
(where it will become necessary to map Channel number to signal at runtime).

The array is indexed by ch_index, i.e. the index of the channel among
channels of the same type (for devices with separate rx/tx channels).

* RMT: move some `Sized` bounds from methods to trait

To avoid repetition. There's no downside, since the trait is only
implemented for a single type anyway, which is Sized.

These traits are user-visible, but since they can't be implemented by users,
and this only makes the bounds stricter, it should require not changes
to user code.

* RMT: Use composition with ConstChannelAccess, reduces macro usage

instead of an extension trait implemened via the impl_*_channel macros.
This reduces macro usage, making the code easier to reason about, and it also
paves the way for type-erased channels by adding a second implementation
of RawChannelAccess.

This touches many lines, but is a fairly mechanical change that should
be easier to review by ignoring whitespace changes.

Previously, channel architecture was as follows:

- `Channel` is parameterized by a const generic `CHANNEL: u8` number
- low-level hardware operations are implemented via the
  *ChannelInternal traits directly on `Channel`. This is done via the
  `impl_*x_channel` macros to account for the different channel
  capabilities (rx/tx only or rx+tx)

This PR changes this to:

- `Channel` contains an `Raw: RawChannelAccess<Dir=Rx|Tx>` where `Rx`
  and `Tx` are ZSTs used as markers for a channel configured for a given
  direction.
- low-level operations are implemented on the `Raw` type, depending on
  a bound on RawChannelAccess::Dir
- the `Raw` types can only be constructed safely from the
  `ChannelCreator`, which ensures that only valid combinations of channel
  number and `Dir=Rx|Tx` can exist.
- currently, the only implementation of `RawChannelAccess` is
  `ConstChannelAccess`, which has a `CHANNEL: u8` const generic
  parameter, just as `Channel` did before. Thus, the compiler should be
  able to inline and const-propagate code just as before.

These new types are user-visible. Thus, if code directly names `Channel`
types, it needs to be adapted. If it just uses a method chain such as
`rmt.channelX.configure(...).transmit(...)`, no changes should be
required.

* RMT: rm (Rx|Tx)ChannelCreatorAsync, use mode generic on (Rx|Tx)ChannelCreator

this de-duplicates some code,
and may be useful to implement user code (e.g. setup functions) that is
independent of DriverMode

* RMT: Rewrite pending_interrupt_for_channel using indexed PAC accessors

This deduplicates some code. I've also changed the return type
(usize -> u8) for better consistency, since channel indices are generally
typed as u8.

* RMT: add DynChannelAccess as basis for type-erased channels

Channels can now be `degrade`d to their type-erased variants.

* RMT: Move around some code

The channel definition used to be somewhere in the middle of channel
implementation. There's no change to the code other than its location.

* RMT: slightly more readable subsclicing

* RMT: Move some chip-specific code to a cfg_if! switch

There's no reason for these to reside in separate modules, and this
restructuring meshes well with moving the Rmt definition to a macro as
well, which will be done next.

* RMT: Declare Rmt struct via macro to avoid repetition

Reduces boilerplate at the cost of a somewhat complex macro.

* RMT: Move RmtState

Which was previously in the middle of channel implementation, but
conceptually is more global to the module, thus more natural define
earlier.

* RMT: explicity mark a few private functions in submodule as pub(super)

* RMT: use DynChannelAccess::conjure to simplify async_interrupt_handler

If the compiler decides to unroll the loops, the resulting code should
be essentially the same. Otherwise, it should be more compact. In any
case, from a developer point of view, this is much more concise and
removes one chip-specific case.

* RMT: Use type-erased channels for some HIL tests

* RMT: don't reset clock divider in start_tx

This seems to fix flaky loopback tests where tx/rx pulse code length
differs by 1.

This matches IDF, which also doesn't reset channels on each transmit
operation, but only once on channel creation, and when a sync_manager is
used (which the Rust driver doesn't support, and which would also need
to be handled differently anyway).

* RMT: implement degrade() even if the channel is already type-erased

This makes wrapping channels in custom structs slightly more convenient
since it allows taking any channel and type-erasing it in that structs
constructor.

* RMT: rename (Rx|Tx)ChannelCreator methods to avoid trait disambiguation problems

For devices with channels that support both Rx and Tx, Rust cannot
disambiguate the trait at the call site (because it doesn't look at the
argument types to do so).

Renaming the methods avoids that. The alternative is to use
fully-qualified names to call the trait methods (i.e. left-side
turbofish), or to import the traits only in a limited scope. Both are
much more verbose than the _rx/_tx suffixes to method names.

* RMT: add basic async HIL test

* RMT: remove overcomplicated WithMode trait from tests

according to a suggestion by @bugadani

---------

Co-authored-by: Scott Mabin <scott@mabez.dev>
2025-06-24 12:11:05 +00:00
..

hil-test

Hardware-in-loop testing for esp-hal.

For assistance with this package please open an issue or start a discussion.

Quickstart

We use embedded-test as our testing framework. This allows us to write unit and integration tests much in the same way you would for a normal Rust project, when the standard library is available, and to execute them using Cargo's built-in test runner.

Running Tests Locally

We use probe-rs for flashing and running the tests on a target device, however, this MUST be installed from the correct revision:

cargo install probe-rs-tools \
  --git https://github.com/probe-rs/probe-rs \
  --rev 9bde591 --force --locked

Target device MUST connected via its USB-Serial-JTAG port, or if unavailable (eg. ESP32, ESP32-C2, ESP32-S2) then you must connect a compatible debug probe such as an ESP-Prog.

You can run all tests for a given device by running the following command from the workspace root:

cargo xtask run-tests $CHIP

To run a single test on a target, run the following command from the workspace root:

# Run GPIO tests for ESP32-C6
cargo xtask run-tests esp32c6 --test gpio

If you want to run a test multiple times:

# Run GPIO tests for ESP32-C6
cargo xtask run-tests esp32c6 --test gpio --repeat 10

Another alternative way of running a single test is, from the hil-tests folder:

# Run GPIO tests for ESP32-C6
CARGO_BUILD_TARGET=riscv32imac-unknown-none-elf \
PROBE_RS_CHIP=esp32c6 \
  cargo +nightly test --features=esp32c6 --test=gpio
  • If the --test argument is omitted, then all tests will be run, independently if the tests are supported for that target, for this reason, we encourage using the xtask approach.
  • The build target MUST be specified via the CARGO_BUILD_TARGET environment variable or as an argument (--target).
  • The chip MUST be specified via the PROBE_RS_CHIP environment variable or as an argument of probe-rs (--chip).

Some tests will require physical connections, please see the current configuration in our runners.

Running Tests Remotes (ie. on Self-Hosted Runners)

The hil.yml workflow builds the test suite for all our available targets and executes them.

Our self-hosted runners have the following setup:

  • ESP32-C2 (esp32c2-jtag):
    • Devkit: ESP8684-DevKitM-1 connected via UART.
      • GPIO18 and GPIO9 are I2C pins.
      • GPIO2 and GPIO3 are connected.
    • Probe: ESP-Prog connected with the following connections
    • RPi: Raspbian 12 configured with the following setup
  • ESP32-C3 (rustboard):
    • Devkit: ESP32-C3-DevKit-RUST-1 connected via USB-Serial-JTAG.
      • GPIO4 and GPIO5 are I2C pins.
      • GPIO2 and GPIO3 are connected.
    • RPi: Raspbian 12 configured with the following setup
  • ESP32-C6 (esp32c6-usb):
    • Devkit: ESP32-C6-DevKitC-1 V1.2 connected via USB-Serial-JTAG (USB port).
      • GPIO6 and GPIO7 are I2C pins.
      • GPIO2 and GPIO3 are connected.
    • RPi: Raspbian 12 configured with the following setup
  • ESP32-H2 (esp32h2-usb):
    • Devkit: ESP32-H2-DevKitM-1 connected via USB-Serial-JTAG (USB port).
      • GPIO12 and GPIO22 are I2C pins.
      • GPIO2 and GPIO3 are connected.
    • RPi: Raspbian 12 configured with the following setup
  • ESP32-S2 (esp32s2-jtag):
    • Devkit: ESP32-S2-Saola-1 connected via UART.
      • GPIO2 and GPIO3 are I2C pins.
      • GPIO9 and GPIO10 are connected.
    • Probe: ESP-Prog connected with the following connections
    • RPi: Raspbian 12 configured with the following setup
  • ESP32-S3 (esp32s3-usb):
    • Devkit: ESP32-S3-DevKitC-1 connected via USB-Serial-JTAG.
      • GPIO2 and GPIO3 are I2C pins.
      • GPIO9 and GPIO10 are connected.
    • RPi: Raspbian 12 configured with the following setup
  • ESP32 (esp32-jtag):
    • Devkit: ESP32-DevKitC-V4 connected via UART.
      • GPIO32 and GPIO33 are I2C pins.
      • GPIO2 and GPIO4 are connected.
    • Probe: ESP-Prog connected with the following connections
    • RPi: Raspbian 12 configured with the following setup

RPi Setup

# Install Rust:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- --default-toolchain stable -y --profile minimal
# Source the current shell:
. "$HOME/.cargo/env"
# Install dependencies
sudo apt install -y pkg-config libudev-dev uhubctl
# Install probe-rs
cargo install probe-rs-tools --git https://github.com/probe-rs/probe-rs --rev 9bde591 --force
# Add the udev rules
wget -O - https://probe.rs/files/69-probe-rs.rules | sudo tee /etc/udev/rules.d/69-probe-rs.rules > /dev/null
# Add the user to plugdev group
sudo usermod -a -G plugdev $USER
# Install espflash
ARCH=$($HOME/.cargo/bin/rustup show | grep "Default host" | sed -e 's/.* //')
curl -L "https://github.com/esp-rs/espflash/releases/latest/download/espflash-${ARCH}.zip" -o "${HOME}/.cargo/bin/espflash.zip"
unzip "${HOME}/.cargo/bin/espflash.zip" -d "${HOME}/.cargo/bin/"
rm "${HOME}/.cargo/bin/espflash.zip"
chmod u+x "${HOME}/.cargo/bin/espflash"
# Reboot the VM
sudo reboot

Adding New Tests

  1. Create a new integration test file (tests/$PERIPHERAL.rs)
  2. Add a corresponding [[test]] entry to Cargol.toml (MUST set harness = false)
  3. Write the tests
  4. Document any necessary physical connections on boards connected to self-hosted runners
  5. Add a header in the test stating which targets support the given tests. Eg:
//! AES Test

//% CHIPS: esp32 esp32c3 esp32c6 esp32h2 esp32s2 esp32s3

If the test is supported by all the targets, you can omit the header.

  1. Write some documentation at the top of the tests/$PERIPHERAL.rs file with the pins being used and the required connections, if applicable.

Logging in tests

The tests can use defmt to print logs. To enable log output, add the defmt feature to the test you want to run. Eg:

//! AES Test

//% CHIPS: esp32 esp32c3 esp32c6 esp32h2 esp32s2 esp32s3
//% FEATURES: defmt

Make sure to remove this addition before you commit any modifications.

NOTE: log output is disabled by default. Enabling it can introduce some timing issues, which makes some tests fail randomly. This issue affects all Xtensa devices, as well as ESP32-C2 and ESP32-C3 currently.