hilt 0.1.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_boots_and_scans_the_matrix() {
    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 things it shells out to are a container runtime
and cargo's own binutils.

## 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.
- **cargo binutils** — `cargo install cargo-binutils && rustup component add llvm-tools`. Used to read the vector table out of your firmware ELF.
- 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.

**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 with
a hard timeout (killed and cleaned up if the firmware hangs), 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.

## 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 hilt::{HilConfig, MachineSpec, Platform, RenodeRunner};

let machine = MachineSpec::new("dev", "firmware.elf", Platform::stm32f3())
    .with_uart_bridge(3456, "sysbus.usart1");
let runner = RenodeRunner::new(HilConfig::multi(vec![machine]));
std::thread::spawn(move || runner.run());
// connect to 127.0.0.1:3456 and exchange bytes with the firmware's serial line
```

**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 runner = RenodeRunner::new(HilConfig::multi(machines));
std::thread::spawn(move || runner.run());
// open vcan0 with your favorite SocketCAN library and talk to the firmware
```

All three styles mix freely in one run.

## Supported boards

| Constructor            | Chip                     | Notes                       |
| ---------------------- | ------------------------ | --------------------------- |
| `Platform::rp2040()`   | RP2040 (Cortex-M0+)      | 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       | full board model, CAN-ready |

Not listed? `Platform::custom(..)` takes any Renode platform description
(`.repl`) — embed your own string or reference one bundled in the Renode
image. Adding a board is usually a dozen lines; the built-ins are good
templates.

## Beyond the basics

- **Simulated key matrices** — `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** — everything the runner does is exposed as building
  blocks (`extract_elf_info`, `run_renode_container`,
  `run_with_timeout_and_cleanup`, `generate_matrix_test_resc`) 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.
- **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.

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.