hilt 0.2.0

Renode-based hardware-in-the-loop test fixtures for embedded Rust projects
Documentation
# hilt

[![crates.io](https://img.shields.io/crates/v/hilt.svg)](https://crates.io/crates/hilt)
[![docs.rs](https://docs.rs/hilt/badge.svg)](https://docs.rs/hilt)
[![license](https://img.shields.io/badge/license-MIT%2FApache--2.0-blue.svg)](#license)

Test your embedded Rust firmware with plain `cargo test` — no hardware on the
desk, no flashing, no probe.

`hilt` boots your compiled firmware inside a [Renode](https://renode.io)
simulation running in a Podman/Docker container, captures everything it does,
and hands the result back to your test to assert on. It works natively on
both x86-64 and arm64 hosts, so the same test suite runs on the laptop where
you develop — Apple Silicon included — and in CI, with no code changes. The
whole point is making embedded development and testing a local, everyday
`cargo test` loop instead of a board-farm ritual.

```rust,ignore
use hilt::{HilConfig, Platform, RenodeRunner, assert_hil_ok};

#[test]
fn firmware_self_check_passes() {
    let output = RenodeRunner::new(HilConfig::single(
        Platform::rp2040(),
        "target/thumbv6m-none-eabi/release/examples/hil_keyboard",
    ))
    .run();
    assert_hil_ok!(output);
}
```

Zero dependencies. The only thing it shells out to is a container runtime;
firmware ELFs are read in pure Rust.

## What you need

- **Podman or Docker** — auto-detected, or pick one with
  `HILT_CONTAINER_RUNTIME=docker`. This is where Renode runs; you never
  install Renode itself.
- A firmware ELF cross-compiled for your target (your normal embedded build).

On Apple Silicon the first run also builds a small native Renode image
(one-time, needs network); see [Image selection](#renode-image-selection)
below.

## Your first test

The simplest style is **self-reporting firmware**: the firmware checks itself
and signals success, and the host test just watches for that signal.

**1. In your firmware**, add an empty marker function and call it when your
checks pass:

```rust,ignore
#[no_mangle]
#[inline(never)]
pub extern "C" fn hil_marker() {}

// ... in your test firmware's main, after asserting whatever you care about:
hil_marker();
```

That's the whole firmware-side contract. `hilt` finds the `hil_marker` symbol
in the ELF, sets a CPU hook on its address, and logs `HIL OK` the moment
execution reaches it. No UART driver, no semihosting, no test framework in
the firmware.

Failure is signalled the same way: reaching a `hil_fail` symbol (rename via
`HilConfig::fail_marker`) or the firmware's panic handler logs `HIL FAIL`,
`output.failed()` turns true, and `assert_hil_ok!` fails with a distinct
"reported FAILURE" message.

Failure ends the simulation immediately. So does pass for a single machine
with no UART/CAN bridge, so a passing self-reporting test takes seconds, not
the full timeout. Bridged or multi-machine runs keep going after pass, since
the host or another machine may still be mid-test.

If you define both markers, give them different bodies — the linker folds
identical empty functions onto one address.

**2. In your host test**, point `hilt` at the ELF (build it yourself, or let
`GuestBuild` run the cross-compile for you):

```rust,ignore
use hilt::{GuestBuild, HilConfig, Platform, RenodeRunner, assert_hil_ok};

#[test]
fn firmware_self_checks_pass() {
    let elf = GuestBuild::example("my-firmware", "hil_smoke")
        .target("thumbv6m-none-eabi")
        .build();
    let output = RenodeRunner::new(HilConfig::single(Platform::rp2040(), elf)).run();
    assert_hil_ok!(output);
}
```

**3. Run `cargo test`.** Behind the scenes, `hilt` generates a Renode script,
stages it with your ELF in a work directory, runs Renode in a container, and
returns a `HilOutput`. If the test fails, the full simulation log is printed
so you can see what the firmware actually did. `output.contains("...")`
asserts on any log line, not just the marker.

Whatever the firmware prints on its console UART (`Platform::uart`, e.g.
`sysbus.uart0` on `rp2040`) is captured too: `output.uart()` returns the text,
and `output.contains(..)` searches it.

There are two timeouts. `HilConfig::timeout` is *simulated* time — how long
the firmware gets to run. `HilConfig::wall_timeout` is the *real* time budget
for the whole run, defaulting to `2 × timeout + 60 s` because boards can
simulate slower than realtime. If the wall-clock budget kills the run,
`output.timed_out()` is `true`.

## Talking to the firmware while it runs

Self-reporting covers a lot, but sometimes the host needs to be *in* the
conversation — sending bytes or bus frames and asserting on the replies.

**UART ↔ TCP** (works on Linux, macOS, and Windows): expose any machine's
UART as a TCP socket on localhost and speak raw bytes to it:

```rust,ignore
use std::io::{Read, Write};
use hilt::{HilConfig, MachineSpec, Platform, RenodeRunner};

let machine = MachineSpec::new("dev", "firmware.elf", Platform::rp2040())
    .with_uart_bridge(0, "sysbus.uart0"); // 0 = pick a free port
let run = RenodeRunner::new(HilConfig::multi(vec![machine])).spawn();
let mut uart = run.connect_uart("dev"); // retries until Renode accepts
uart.write_all(b"ping")?;
let mut reply = [0; 4];
uart.read_exact(&mut reply)?;
let output = run.kill(); // or run.wait() to let the simulation finish
```

`spawn()` returns a `RunningHil` handle: `wait()` for the run to end, `kill()`
to stop it early, or just drop it — every path removes the container. Port
`0` gives each run its own port (`run.uart_port("dev")`), so parallel tests
never collide.

**CAN ↔ SocketCAN** (Linux only): wire multiple machines into a shared CAN
bus and bridge it to a host `vcan` interface, so host code exchanges frames
with the simulated network:

```rust,ignore
let machines = vec![
    MachineSpec::new("controller", "controller.elf", Platform::stm32h7())
        .with_socketcan_bridge(),
    MachineSpec::new("sensor", "sensor.elf", Platform::stm32h7()),
];
// hilt::setup_vcan("vcan0");  // once, needs privileges
let run = RenodeRunner::new(HilConfig::multi(machines)).spawn();
// open vcan0 with your favorite SocketCAN library and talk to the firmware
let output = run.wait();
```

All three styles mix freely in one run.

## Supported boards

| Constructor            | Chip                     | Console UART    | Notes                       |
| ---------------------- | ------------------------ | --------------- | --------------------------- |
| `Platform::rp2040()`   | RP2040 (Cortex-M0+)      | `sysbus.uart0`  | e.g. Raspberry Pi Pico      |
| `Platform::nrf52840()` | nRF52840 (Cortex-M4F)    | —               |                             |
| `Platform::stm32f3()`  | STM32F303xB (Cortex-M4F) | —               | e.g. ZSA Voyager            |
| `Platform::stm32f4()`  | STM32F411 (Cortex-M4F)   | —               |                             |
| `Platform::stm32h7()`  | STM32H753ZI Nucleo       | `sysbus.usart3` | full board model, CAN-ready |

Not listed? Every `Platform` field is public, so describe any Renode platform
(`.repl`) with a struct literal — embed your own string or reference one
bundled in the Renode image:

```rust,ignore
const MY_BOARD: Platform = Platform {
    name: "myboard",
    repl: ReplSource::Embedded(include_str!("myboard.repl")),
    ..Platform::rp2040()
};
```

Adding a board is usually a dozen lines; the built-in `.repl`s are good
templates.

## Beyond the basics

- **Simulated key matrices** (feature `matrix`) — `MatrixTestPlan` generates
  a Python Renode peripheral that "presses" keys on a GPIO row/column matrix, plus
  parsers (`parse_key_log`, `parse_tapdance_log`) for asserting the firmware
  saw the right keys. Built for keyboard firmware, reusable for any scanned
  matrix.
- **Hand-rolled runs** — the runner's building blocks (`extract_elf_info`,
  `find_symbol_address`, `run_renode_container`,
  `run_with_timeout_and_cleanup`) are public if you need a custom flow.
- **Environment probes** — `container_runtime_available()`,
  `renode_available()`, `interface_available()` let tests skip gracefully on
  machines without the needed setup; `detect_container_runtime()` names the
  runtime that will be used.

Run `cargo doc --open` for the full API reference.

## Renode image selection

The container image is picked by host architecture, so the same tests run on
an Apple-Silicon laptop and amd64 CI unchanged:

- **x86-64** hosts use `docker.io/antmicro/renode:latest`, Antmicro's
  official (amd64-only) image, pulled on first use.
- **aarch64** hosts get `localhost/hilt-renode:arm64`, a native image `hilt`
  builds on demand the first time it's needed — the official amd64 image
  never finishes booting under qemu emulation. First run downloads the
  portable Renode release and builds; later runs reuse the cached image.

Either way, any missing image is pulled or built once, before the run's
wall-clock timer starts, so a slow first pull/build doesn't eat into the
timeout budget.

Override with `HILT_RENODE_IMAGE=<ref>` or per-config via
`HilConfig::image`.

## License

Licensed under either of [Apache License, Version 2.0](LICENSE-APACHE) or
[MIT license](LICENSE-MIT) at your option.

Unless you explicitly state otherwise, any contribution intentionally
submitted for inclusion in the work by you, as defined in the Apache-2.0
license, shall be dual licensed as above, without any additional terms or
conditions.