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