diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3676eb1379..f5dc050c325 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,12 +18,14 @@ on: paths-ignore: - "**/CHANGELOG.md" - "**/README.md" + - "**/hil-test/**" push: branches-ignore: - "gh-readonly-queue/**" paths-ignore: - "**/CHANGELOG.md" - "**/README.md" + - "**/hil-test/**" merge_group: workflow_dispatch: diff --git a/.github/workflows/hil.yml b/.github/workflows/hil.yml new file mode 100644 index 00000000000..79bbe6f4471 --- /dev/null +++ b/.github/workflows/hil.yml @@ -0,0 +1,59 @@ +name: HIL + +on: + merge_group: + workflow_dispatch: + inputs: + repository: + description: "Owner and repository to test" + required: true + default: 'esp-rs/esp-hal' + branch: + description: "Branch, tag or SHA to checkout." + required: true + default: "main" + +env: + CARGO_TERM_COLOR: always + +jobs: + # Test RISC-V targets: + riscv-hil: + name: HIL Test | ${{ matrix.target.soc }} + runs-on: + labels: [self-hosted, "${{ matrix.target.runner }}"] + strategy: + fail-fast: false + matrix: + target: + - soc: esp32c3 + runner: rustboard + rust-target: riscv32imc-unknown-none-elf + - soc: esp32c6 + runner: esp32c6-usb + rust-target: riscv32imac-unknown-none-elf + - soc: esp32h2 + runner: esp32h2-usb + rust-target: riscv32imac-unknown-none-elf + steps: + - uses: actions/checkout@v4 + if: github.event_name != 'workflow_dispatch' + + - uses: actions/checkout@v4 + if: github.event_name == 'workflow_dispatch' + with: + repository: ${{ github.event.inputs.repository }} + ref: ${{ github.event.inputs.branch }} + + - uses: dtolnay/rust-toolchain@v1 + with: + target: ${{ matrix.target.rust-target }} + toolchain: nightly + components: rust-src + + - name: Run tests + working-directory: hil-test + run: cargo ${{ matrix.target.soc }} + + # Test Xtensa targets: + # TODO: Add jobs for Xtensa once supported by `probe-rs` diff --git a/Cargo.toml b/Cargo.toml index 6215f581191..b86194ea725 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,4 +9,5 @@ exclude = [ "esp-metadata", "esp-riscv-rt", "examples", + "hil-test", ] diff --git a/hil-test/.cargo/config.toml b/hil-test/.cargo/config.toml new file mode 100644 index 00000000000..49e46b3500d --- /dev/null +++ b/hil-test/.cargo/config.toml @@ -0,0 +1,29 @@ +[alias] +# esp32 = "test --release --features=esp32 --target=xtensa-esp32-none-elf -- --chip esp32-3.3v" +# esp32c2 = "test --release --features=esp32c2 --target=riscv32imc-unknown-none-elf -- --chip esp32c2" +esp32c3 = "test --release --features=esp32c3 --target=riscv32imc-unknown-none-elf -- --chip esp32c3" +esp32c6 = "test --release --features=esp32c6 --target=riscv32imac-unknown-none-elf -- --chip esp32c6" +esp32h2 = "test --release --features=esp32h2 --target=riscv32imac-unknown-none-elf -- --chip esp32h2" +# esp32p4 = "test --release --features=esp32p4 --target=riscv32imafc-unknown-none-elf -- --chip esp32p4" +# esp32s2 = "test --release --features=esp32s2 --target=xtensa-esp32s2-none-elf -- --chip esp32s2" +esp32s3 = "test --release --features=esp32s3 --target=xtensa-esp32s3-none-elf -- --chip esp32s3" + +[target.'cfg(target_arch = "riscv32")'] +runner = "probe-rs run" +rustflags = [ + "-C", "link-arg=-Tlinkall.x", + "-C", "link-arg=-Tembedded-test.x", + "-C", "link-arg=-Tdefmt.x", +] + +[target.'cfg(target_arch = "xtensa")'] +runner = "probe-rs run" +rustflags = [ + "-C", "link-arg=-nostartfiles", + "-C", "link-arg=-Wl,-Tlinkall.x", + "-C", "link-arg=-Tdefmt.x", + "-C", "link-arg=-Tembedded-test.x", +] + +[unstable] +build-std = ["core"] diff --git a/hil-test/Cargo.toml b/hil-test/Cargo.toml new file mode 100644 index 00000000000..dd1f5a5dde2 --- /dev/null +++ b/hil-test/Cargo.toml @@ -0,0 +1,93 @@ +[package] +name = "hil-test" +version = "0.0.0" +edition = "2021" +publish = false + +[[test]] +name = "aes" +harness = false + +[[test]] +name = "gpio" +harness = false + +[[test]] +name = "spi_full_duplex" +harness = false + +[[test]] +name = "uart" +harness = false + +[dependencies] +defmt = { version = "0.3.5" } +defmt-rtt = { version = "0.4.0" } +esp-hal = { path = "../esp-hal", features = ["embedded-hal", "embedded-hal-02", "defmt"], optional = true } +embedded-hal-02 = { version = "0.2.7", package = "embedded-hal", features = ["unproven"] } +embedded-hal-async = { version = "1.0.0", optional = true } +embedded-hal = { version = "1.0.0" } +embedded-hal-nb = { version = "1.0.0", optional = true } +embassy-executor = { default-features = false, version = "0.5.0", features = ["executor-thread", "arch-riscv32"], optional = true } +semihosting = "0.1.6" + +[dev-dependencies] +# Add the `embedded-test/defmt` feature for more verbose testing +embedded-test = {git = "https://github.com/probe-rs/embedded-test", rev = "8e3f925"} + +[features] +# Device support (required!): +esp32 = ["esp-hal/esp32"] +esp32c2 = ["esp-hal/esp32c2"] +esp32c3 = ["esp-hal/esp32c3"] +esp32c6 = ["esp-hal/esp32c6"] +esp32h2 = ["esp-hal/esp32h2"] +esp32s2 = ["esp-hal/esp32s2"] +esp32s3 = ["esp-hal/esp32s3"] +# Async & Embassy: +async = ["dep:embedded-hal-async", "esp-hal?/async"] +embassy = ["esp-hal?/embassy", "embedded-test/embassy", "dep:embassy-executor"] +embassy-executor-interrupt = ["esp-hal?/embassy-executor-interrupt"] +embassy-executor-thread = ["esp-hal?/embassy-executor-thread"] +embassy-time-systick-16mhz = ["esp-hal?/embassy-time-systick-16mhz"] +embassy-time-systick-80mhz = ["esp-hal?/embassy-time-systick-80mhz"] +embassy-time-timg0 = ["esp-hal?/embassy-time-timg0"] + +# cargo build/run +[profile.dev] +codegen-units = 1 +debug = 2 +debug-assertions = true # <- +incremental = false +opt-level = 'z' # <- +overflow-checks = true # <- + +# cargo test +[profile.test] +codegen-units = 1 +debug = 2 +debug-assertions = true # <- +incremental = false +opt-level = 3 # <- +overflow-checks = true # <- + +# cargo build/run --release +[profile.release] +codegen-units = 1 +debug = 2 +debug-assertions = false # <- +incremental = false +opt-level = 3 # <- +overflow-checks = false # <- + +# cargo test --release +[profile.bench] +codegen-units = 1 +debug = 2 +debug-assertions = false # <- +incremental = false +opt-level = 3 # <- +overflow-checks = false # <- + +[patch.crates-io] +semihosting = { git = "https://github.com/taiki-e/semihosting", rev = "c829c19" } diff --git a/hil-test/README.md b/hil-test/README.md new file mode 100644 index 00000000000..6a04bc0bb49 --- /dev/null +++ b/hil-test/README.md @@ -0,0 +1,95 @@ +# hil-test + +Hardware-in-loop testing for `esp-hal`. + +For assistance with this package please [open an issue] or [start a discussion]. + +[open an issue]: https://github.com/esp-rs/esp-hal/issues/new +[start a discussion]: https://github.com/esp-rs/esp-hal/discussions/new/choose + +## Quickstart + +We use [embedded-test] as our testing framework, which relies on [defmt] internally. 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. + +[embedded-test]: https://github.com/probe-rs/embedded-test +[defmt]: https://github.com/knurling-rs/defmt + +### 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, and with the correct features enabled: + +```text +cargo install probe-rs \ + --git=https://github.com/probe-rs/probe-rs \ + --rev=b431b24 \ + --features=cli \ + --bin=probe-rs +``` + +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 test for a given device using: + +```shell +cargo +nightly esp32c6 +# or +cargo +esp esp32s3 +``` + +For running a single test on a target: + +```shell +# 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. +- 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). + +### 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 Virtual Machines have the following setup: +- ESP32-C3 (`rustboard`): + - Devkit: `ESP32-C3-DevKit-RUST-1` connected via USB-Serial-JTAG. + - `GPIO2` and `GPIO4` are connected. + - VM: Configured with the following [setup](#vm-setup) +- ESP32-C6 (`esp32c6-usb`): + - Devkit: `ESP32-C6-DevKitC-1 V1.2` connected via USB-Serial-JTAG (`USB` port). + - `GPIO2` and `GPIO4` are connected. + - VM: Configured with the following [setup](#vm-setup) +- ESP32-H2 (`esp32h2-usb`): + - Devkit: `ESP32-H2-DevKitM-1` connected via USB-Serial-JTAG (`USB` port). + - `GPIO2` and `GPIO4` are connected. + - VM: Configured with the following [setup](#vm-setup) + +[`hil.yml`]: https://github.com/esp-rs/esp-hal/blob/main/.github/workflows/hil.yml + +#### VM Setup +```bash +# Install Rust: +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- --default-toolchain stable -y --profile minimal +# Source the current shell: +source "$HOME/.cargo/env" +# Install dependencies +sudo apt install -y pkg-config libudev-dev +# Install probe-rs +cargo install probe-rs --git=https://github.com/probe-rs/probe-rs --rev=b431b24 --features=cli --bin=probe-rs --locked --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 +# Reboot the VM +``` + +## 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. Write some documentation at the top of the `tests/$PERIPHERAL.rs` file with the pins being used and the required connections, if applicable. diff --git a/hil-test/tests/aes.rs b/hil-test/tests/aes.rs new file mode 100644 index 00000000000..950453a574a --- /dev/null +++ b/hil-test/tests/aes.rs @@ -0,0 +1,99 @@ +//! AES Test + +#![no_std] +#![no_main] + +use defmt_rtt as _; +use esp_hal::{ + aes::{Aes, Mode}, + peripherals::Peripherals, +}; + +struct Context<'a> { + aes: Aes<'a>, +} + +impl Context<'_> { + pub fn init() -> Self { + let peripherals = Peripherals::take(); + let aes = Aes::new(peripherals.AES); + + Context { aes } + } +} + +#[cfg(not(any( + feature = "esp32c3", + feature = "esp32c6", + feature = "esp32h2", + feature = "esp32s3" +)))] +mod not_test { + #[esp_hal::entry] + fn main() -> ! { + semihosting::process::exit(0) + } + #[panic_handler] + fn panic(_info: &core::panic::PanicInfo) -> ! { + loop {} + } +} + +#[cfg(test)] +#[cfg(any( + feature = "esp32c3", + feature = "esp32c6", + feature = "esp32h2", + feature = "esp32s3" +))] +#[embedded_test::tests] +mod tests { + use defmt::assert_eq; + + use super::*; + + #[init] + fn init() -> Context<'static> { + Context::init() + } + + #[test] + fn test_aes_encryption(mut ctx: Context<'static>) { + let keytext = "SUp4SeCp@sSw0rd".as_bytes(); + let plaintext = "message".as_bytes(); + let encrypted_message = [ + 0xb3, 0xc8, 0xd2, 0x3b, 0xa7, 0x36, 0x5f, 0x18, 0x61, 0x70, 0x0, 0x3e, 0xd9, 0x3a, + 0x31, 0x96, + ]; + + // create an array with aes128 key size + let mut keybuf = [0_u8; 16]; + keybuf[..keytext.len()].copy_from_slice(keytext); + + // create an array with aes block size + let mut block_buf = [0_u8; 16]; + block_buf[..plaintext.len()].copy_from_slice(plaintext); + + let mut block = block_buf.clone(); + ctx.aes.process(&mut block, Mode::Encryption128, &keybuf); + assert_eq!(block, encrypted_message); + } + + #[test] + fn test_aes_decryption(mut ctx: Context<'static>) { + let keytext = "SUp4SeCp@sSw0rd".as_bytes(); + let plaintext = "message".as_bytes(); + let mut encrypted_message = [ + 0xb3, 0xc8, 0xd2, 0x3b, 0xa7, 0x36, 0x5f, 0x18, 0x61, 0x70, 0x0, 0x3e, 0xd9, 0x3a, + 0x31, 0x96, + ]; + + // create an array with aes128 key size + let mut keybuf = [0_u8; 16]; + keybuf[..keytext.len()].copy_from_slice(keytext); + + ctx.aes + .process(&mut encrypted_message, Mode::Decryption128, &keybuf); + assert_eq!(&encrypted_message[..plaintext.len()], plaintext); + } +} diff --git a/hil-test/tests/gpio.rs b/hil-test/tests/gpio.rs new file mode 100644 index 00000000000..3e53a829af4 --- /dev/null +++ b/hil-test/tests/gpio.rs @@ -0,0 +1,72 @@ +//! GPIO Test +//! +//! Folowing pins are used: +//! GPIO2 +//! GPIO4 + +#![no_std] +#![no_main] + +use defmt_rtt as _; +use embedded_hal::digital::{InputPin as _, OutputPin as _, StatefulOutputPin as _}; +use esp_hal::{ + gpio::{GpioPin, Input, Output, PullDown, PushPull, IO}, + peripherals::Peripherals, +}; + +struct Context { + io2: GpioPin, 2>, + io4: GpioPin, 4>, +} + +impl Context { + pub fn init() -> Self { + let peripherals = Peripherals::take(); + let io = IO::new(peripherals.GPIO, peripherals.IO_MUX); + + Context { + io2: io.pins.gpio2.into_pull_down_input(), + io4: io.pins.gpio4.into_push_pull_output(), + } + } +} + +#[cfg(test)] +#[embedded_test::tests] +mod tests { + use defmt::assert_eq; + + use super::*; + + #[init] + fn init() -> Context { + Context::init() + } + + #[test] + fn test_gpio_input(mut ctx: Context) { + // `InputPin`: + assert_eq!(ctx.io2.is_low(), Ok(true)); + assert_eq!(ctx.io2.is_high(), Ok(false)); + } + + #[test] + fn test_gpio_output(mut ctx: Context) { + // `StatefulOutputPin`: + assert_eq!(ctx.io4.is_set_low(), Ok(true)); + assert_eq!(ctx.io4.is_set_high(), Ok(false)); + assert!(ctx.io4.set_high().is_ok()); + assert_eq!(ctx.io4.is_set_low(), Ok(false)); + assert_eq!(ctx.io4.is_set_high(), Ok(true)); + + // `ToggleableOutputPin`: + assert!(ctx.io4.toggle().is_ok()); + assert_eq!(ctx.io4.is_set_low(), Ok(true)); + assert_eq!(ctx.io4.is_set_high(), Ok(false)); + assert!(ctx.io4.toggle().is_ok()); + assert_eq!(ctx.io4.is_set_low(), Ok(false)); + assert_eq!(ctx.io4.is_set_high(), Ok(true)); + // Leave in initial state for next test + assert!(ctx.io4.toggle().is_ok()); + } +} diff --git a/hil-test/tests/spi_full_duplex.rs b/hil-test/tests/spi_full_duplex.rs new file mode 100644 index 00000000000..657183d971b --- /dev/null +++ b/hil-test/tests/spi_full_duplex.rs @@ -0,0 +1,115 @@ +//! SPI Full Duplex Test +//! +//! Folowing pins are used: +//! SCLK GPIO0 +//! MISO GPIO2 +//! MOSI GPIO4 +//! CS GPIO5 +//! +//! Connect MISO (GPIO2) and MOSI (GPIO4) pins. + +#![no_std] +#![no_main] + +use defmt_rtt as _; +use embedded_hal::spi::SpiBus; +use esp_hal::{ + clock::ClockControl, + gpio::IO, + peripherals::Peripherals, + prelude::*, + spi::{master::Spi, FullDuplexMode, SpiMode}, +}; + +struct Context { + spi: Spi<'static, esp_hal::peripherals::SPI2, FullDuplexMode>, +} + +impl Context { + pub fn init() -> Self { + let peripherals = Peripherals::take(); + let system = peripherals.SYSTEM.split(); + let clocks = ClockControl::boot_defaults(system.clock_control).freeze(); + + let io = IO::new(peripherals.GPIO, peripherals.IO_MUX); + let sclk = io.pins.gpio0; + let miso = io.pins.gpio2; + let mosi = io.pins.gpio4; + let cs = io.pins.gpio5; + + let spi = Spi::new(peripherals.SPI2, 1000u32.kHz(), SpiMode::Mode0, &clocks).with_pins( + Some(sclk), + Some(mosi), + Some(miso), + Some(cs), + ); + + Context { spi } + } +} + +#[cfg(test)] +#[embedded_test::tests] +mod tests { + use defmt::assert_eq; + + use super::*; + + #[init] + fn init() -> Context { + Context::init() + } + + #[test] + fn test_symestric_transfer(mut ctx: Context) { + let write = [0xde, 0xad, 0xbe, 0xef]; + let mut read: [u8; 4] = [0x00u8; 4]; + + ctx.spi + .transfer(&mut read[..], &write[..]) + .expect("Symmetric transfer failed"); + assert_eq!(write, read); + } + + #[test] + fn test_asymestric_transfer(mut ctx: Context) { + let write = [0xde, 0xad, 0xbe, 0xef]; + let mut read: [u8; 4] = [0x00; 4]; + + ctx.spi + .transfer(&mut read[0..2], &write[..]) + .expect("Asymmetric transfer failed"); + assert_eq!(write[0], read[0]); + assert_eq!(read[2], 0x00u8); + } + + #[test] + fn test_symestric_transfer_huge_buffer(mut ctx: Context) { + let mut write = [0x55u8; 4096]; + for byte in 0..write.len() { + write[byte] = byte as u8; + } + let mut read = [0x00u8; 4096]; + + ctx.spi + .transfer(&mut read[..], &write[..]) + .expect("Huge transfer failed"); + assert_eq!(write, read); + } + + #[test] + #[timeout(3)] + fn test_symestric_transfer_huge_buffer_no_alloc(mut ctx: Context) { + let mut write = [0x55u8; 4096]; + for byte in 0..write.len() { + write[byte] = byte as u8; + } + + ctx.spi + .transfer_in_place(&mut write[..]) + .expect("Huge transfer failed"); + for byte in 0..write.len() { + assert_eq!(write[byte], byte as u8); + } + } +} diff --git a/hil-test/tests/uart.rs b/hil-test/tests/uart.rs new file mode 100644 index 00000000000..a06c11948ab --- /dev/null +++ b/hil-test/tests/uart.rs @@ -0,0 +1,73 @@ +//! UART Test +//! +//! Folowing pins are used: +//! TX GPIP2 +//! RX GPIO4 +//! +//! Connect TX (GPIO2) and RX (GPIO4) pins. + +#![no_std] +#![no_main] + +use defmt_rtt as _; +use embedded_hal_02::serial::{Read, Write}; +use esp_hal::{ + clock::ClockControl, + gpio::IO, + peripherals::{Peripherals, UART0}, + prelude::*, + uart::{ + config::{Config, DataBits, Parity, StopBits}, + TxRxPins, + Uart, + }, +}; +use nb::block; + +struct Context { + uart: Uart<'static, UART0>, +} + +impl Context { + pub fn init() -> Self { + let peripherals = Peripherals::take(); + let system = peripherals.SYSTEM.split(); + let clocks = ClockControl::boot_defaults(system.clock_control).freeze(); + let io = IO::new(peripherals.GPIO, peripherals.IO_MUX); + let pins = TxRxPins::new_tx_rx( + io.pins.gpio2.into_push_pull_output(), + io.pins.gpio4.into_floating_input(), + ); + let config = Config { + baudrate: 115200, + data_bits: DataBits::DataBits8, + parity: Parity::ParityNone, + stop_bits: StopBits::STOP1, + }; + + let uart = Uart::new_with_config(peripherals.UART0, config, Some(pins), &clocks); + + Context { uart } + } +} + +#[cfg(test)] +#[embedded_test::tests] +mod tests { + use defmt::assert_eq; + + use super::*; + + #[init] + fn init() -> Context { + Context::init() + } + + #[test] + #[timeout(3)] + fn test_send_receive(mut ctx: Context) { + ctx.uart.write(0x42).ok(); + let read = block!(ctx.uart.read()); + assert_eq!(read, Ok(0x42)); + } +}