hilt 0.2.0

Renode-based hardware-in-the-loop test fixtures for embedded Rust projects
Documentation
//! HIL run configuration: machines, networking, and timing.

use std::path::PathBuf;

use crate::platform::Platform;

/// Bridges an emulated UART to a host TCP socket.
///
/// Renode exposes the named UART as a server socket terminal on `host_port`;
/// host code connects (the container port is published) and exchanges raw
/// bytes with the firmware's serial line. This is the UART analogue of the
/// SocketCAN bridge — a generic byte pipe to a simulated board's UART.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UartBridge {
    /// Host TCP port on `127.0.0.1`. `0` picks a free port per run, so
    /// parallel tests don't collide; read it back with
    /// [`RunningHil::uart_port`](crate::RunningHil::uart_port).
    pub host_port: u16,
    /// Renode UART peripheral/connector to attach, e.g. `"sysbus.uart"`.
    pub uart: String,
}

/// One emulated machine in a HIL run.
#[derive(Debug, Clone)]
pub struct MachineSpec {
    /// Machine name, e.g. `"controller"`. Must be unique within a run.
    pub name: String,
    /// Path to the firmware ELF to load.
    pub firmware_elf: PathBuf,
    /// Target platform for this machine.
    pub platform: Platform,
    /// If `true`, bridge this machine's CAN hub to the host SocketCAN
    /// interface so host code can exchange frames with the firmware.
    pub socketcan_bridge: bool,
    /// If set, expose this machine's UART to the host over TCP.
    pub uart_bridge: Option<UartBridge>,
}

impl MachineSpec {
    /// Creates a machine spec for `platform` loading `firmware_elf`.
    #[must_use]
    pub fn new(
        name: impl Into<String>,
        firmware_elf: impl Into<PathBuf>,
        platform: Platform,
    ) -> Self {
        Self {
            name: name.into(),
            firmware_elf: firmware_elf.into(),
            platform,
            socketcan_bridge: false,
            uart_bridge: None,
        }
    }

    /// Bridges this machine to the host SocketCAN interface (see
    /// [`HilConfig::socketcan_iface`]).
    #[must_use]
    pub fn with_socketcan_bridge(mut self) -> Self {
        self.socketcan_bridge = true;
        self
    }

    /// Exposes this machine's `uart` peripheral (e.g. `"sysbus.uart"`) to the
    /// host as a TCP server on `host_port` (`0` = pick a free one, see
    /// [`UartBridge::host_port`]). Host code connects to
    /// `127.0.0.1:host_port` -- or simply calls
    /// [`RunningHil::connect_uart`](crate::RunningHil::connect_uart) -- and
    /// exchanges raw bytes with the firmware.
    #[must_use]
    pub fn with_uart_bridge(mut self, host_port: u16, uart: impl Into<String>) -> Self {
        self.uart_bridge = Some(UartBridge {
            host_port,
            uart: uart.into(),
        });
        self
    }
}

/// Configuration for a HIL run: one or more machines plus run-wide settings.
#[derive(Debug, Clone)]
pub struct HilConfig {
    /// Machines to emulate. Multiple machines are joined by a CAN hub.
    pub machines: Vec<MachineSpec>,
    /// Simulation timeout in seconds (`emulation RunFor`). This is simulated
    /// time, not wall-clock time -- see [`HilConfig::wall_timeout_secs`].
    pub timeout_secs: u32,
    /// Wall-clock kill budget in seconds for the whole run (container
    /// startup, simulation, teardown). `None` (the default) is
    /// `2 * timeout_secs + 60`: a board slower than realtime needs more
    /// wall-clock time than simulated time.
    pub wall_timeout_secs: Option<u32>,
    /// Host SocketCAN interface name used by bridged machines.
    pub socketcan_iface: String,
    /// Optional extra `.repl` overlaid on every machine (peripheral stubs).
    pub stubs_repl: Option<PathBuf>,
    /// Container image used to run Renode.
    pub image: String,
    /// Symbol name hooked to emit `HIL OK` for [`CpuInit::VectorTable`] boards.
    ///
    /// [`CpuInit::VectorTable`]: crate::CpuInit::VectorTable
    pub marker: String,
    /// Symbol name hooked to emit `HIL FAIL` (firmware self-check failed).
    /// The firmware's panic handler is hooked the same way, when present.
    pub fail_marker: String,
}

/// Default Renode container image on x86-64 hosts (Antmicro's official,
/// amd64-only image).
pub const DEFAULT_RENODE_IMAGE: &str = "docker.io/antmicro/renode:latest";
/// Locally-built native Renode image used on `aarch64` hosts, where the
/// amd64-only [`DEFAULT_RENODE_IMAGE`] would run under `qemu-user` and never
/// finish `LoadPlatformDescription`. Built on demand from the upstream
/// `linux-arm64` portable release (see [`crate::RENODE_ARM64_VERSION`]).
pub const RENODE_ARM64_IMAGE: &str = "localhost/hilt-renode:arm64";
/// Env var overriding the auto-selected Renode image.
pub const RENODE_IMAGE_ENV: &str = "HILT_RENODE_IMAGE";
/// Default host SocketCAN interface name.
pub const DEFAULT_SOCKETCAN_IFACE: &str = "vcan0";
/// Default marker symbol hooked to emit `HIL OK`.
pub const DEFAULT_MARKER: &str = "hil_marker";
/// Default marker symbol hooked to emit `HIL FAIL`.
pub const DEFAULT_FAIL_MARKER: &str = "hil_fail";

/// The Renode container image to use by default on this host.
///
/// Resolution order: the `HILT_RENODE_IMAGE` env var, then a host-architecture
/// choice — [`RENODE_ARM64_IMAGE`] on `aarch64` (a native image, built on
/// demand), else the amd64 [`DEFAULT_RENODE_IMAGE`]. This is what lets the same
/// tests run natively on Apple-Silicon and on amd64 CI without code changes.
#[must_use]
pub fn default_renode_image() -> String {
    if let Ok(image) = std::env::var(RENODE_IMAGE_ENV) {
        return image;
    }
    if cfg!(target_arch = "aarch64") {
        RENODE_ARM64_IMAGE.to_string()
    } else {
        DEFAULT_RENODE_IMAGE.to_string()
    }
}

/// Default wall-clock kill budget for a simulated-time `timeout_secs`.
pub(crate) fn default_wall_timeout(timeout_secs: u32) -> u32 {
    timeout_secs.saturating_mul(2).saturating_add(60)
}

impl HilConfig {
    /// Single-machine config named `"hil"`, with the platform's default
    /// timeout. Used by self-contained firmware tests.
    #[must_use]
    pub fn single(platform: Platform, firmware_elf: impl Into<PathBuf>) -> Self {
        Self::multi(vec![MachineSpec::new("hil", firmware_elf, platform)])
            .timeout(platform.default_timeout_secs)
    }

    /// Multi-machine config. Machines are joined by a CAN hub; bridged
    /// machines additionally connect to the host SocketCAN interface.
    #[must_use]
    pub fn multi(machines: Vec<MachineSpec>) -> Self {
        Self {
            machines,
            timeout_secs: 15,
            wall_timeout_secs: None,
            socketcan_iface: DEFAULT_SOCKETCAN_IFACE.to_string(),
            stubs_repl: None,
            image: default_renode_image(),
            marker: DEFAULT_MARKER.to_string(),
            fail_marker: DEFAULT_FAIL_MARKER.to_string(),
        }
    }

    /// Sets the simulation timeout.
    #[must_use]
    pub fn timeout(mut self, secs: u32) -> Self {
        self.timeout_secs = secs;
        self
    }

    /// Sets an explicit wall-clock kill budget, overriding the default
    /// derived from `timeout_secs`.
    #[must_use]
    pub fn wall_timeout(mut self, secs: u32) -> Self {
        self.wall_timeout_secs = Some(secs);
        self
    }

    /// The wall-clock kill budget to actually use.
    pub(crate) fn resolved_wall_timeout_secs(&self) -> u32 {
        self.wall_timeout_secs
            .unwrap_or_else(|| default_wall_timeout(self.timeout_secs))
    }

    /// Overrides the host SocketCAN interface name.
    #[must_use]
    pub fn socketcan_iface(mut self, iface: impl Into<String>) -> Self {
        self.socketcan_iface = iface.into();
        self
    }

    /// Overlays an extra `.repl` (peripheral stubs) on every machine.
    #[must_use]
    pub fn stubs_repl(mut self, path: impl Into<PathBuf>) -> Self {
        self.stubs_repl = Some(path.into());
        self
    }

    /// Overrides the Renode container image.
    #[must_use]
    pub fn image(mut self, image: impl Into<String>) -> Self {
        self.image = image.into();
        self
    }

    /// Overrides the marker symbol hooked to emit `HIL OK`.
    #[must_use]
    pub fn marker(mut self, marker: impl Into<String>) -> Self {
        self.marker = marker.into();
        self
    }

    /// Overrides the marker symbol hooked to emit `HIL FAIL`.
    #[must_use]
    pub fn fail_marker(mut self, marker: impl Into<String>) -> Self {
        self.fail_marker = marker.into();
        self
    }

    /// `true` if any machine bridges to the host (needs `--network=host`).
    #[must_use]
    pub fn needs_host_network(&self) -> bool {
        self.machines.iter().any(|m| m.socketcan_bridge)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn single_defaults() {
        let cfg = HilConfig::single(Platform::rp2040(), "/tmp/fw.elf");
        assert_eq!(cfg.machines.len(), 1);
        assert_eq!(cfg.machines[0].name, "hil");
        assert_eq!(cfg.timeout_secs, 10);
        assert_eq!(cfg.socketcan_iface, "vcan0");
        assert!(cfg.stubs_repl.is_none());
        assert!(!cfg.needs_host_network());
    }

    #[test]
    fn single_uses_platform_timeout() {
        assert_eq!(
            HilConfig::single(Platform::stm32h7(), "/tmp/fw.elf").timeout_secs,
            15
        );
    }

    #[test]
    fn multi_and_bridge_flags() {
        let machines = vec![
            MachineSpec::new("a", "/tmp/a.elf", Platform::stm32h7()).with_socketcan_bridge(),
            MachineSpec::new("b", "/tmp/b.elf", Platform::stm32h7()),
        ];
        let cfg = HilConfig::multi(machines);
        assert_eq!(cfg.machines.len(), 2);
        assert_eq!(cfg.timeout_secs, 15);
        assert!(cfg.needs_host_network());
    }

    #[test]
    fn uart_bridge_builder() {
        let machines = vec![
            MachineSpec::new("a", "/tmp/a.elf", Platform::rp2040())
                .with_uart_bridge(3456, "sysbus.uart"),
            MachineSpec::new("b", "/tmp/b.elf", Platform::rp2040()),
        ];
        let cfg = HilConfig::multi(machines);
        assert!(!cfg.needs_host_network());
        assert_eq!(
            cfg.machines[0].uart_bridge,
            Some(UartBridge {
                host_port: 3456,
                uart: "sysbus.uart".to_string(),
            })
        );
        assert!(cfg.machines[1].uart_bridge.is_none());
    }

    #[test]
    fn default_image_arch_selected_and_env_override() {
        let _guard = crate::test_env_lock();
        let saved = std::env::var(RENODE_IMAGE_ENV).ok();

        // Arch branch: no override → native arm64 image on aarch64, else amd64.
        std::env::remove_var(RENODE_IMAGE_ENV);
        let img = default_renode_image();
        if cfg!(target_arch = "aarch64") {
            assert_eq!(img, RENODE_ARM64_IMAGE);
        } else {
            assert_eq!(img, DEFAULT_RENODE_IMAGE);
        }
        // A fresh single config picks up the same host-appropriate default.
        assert_eq!(
            HilConfig::single(Platform::rp2040(), "/tmp/fw.elf").image,
            img
        );

        // The env override wins over the arch choice.
        std::env::set_var(RENODE_IMAGE_ENV, "localhost/custom:tag");
        assert_eq!(default_renode_image(), "localhost/custom:tag");
        assert_eq!(
            HilConfig::single(Platform::rp2040(), "/tmp/fw.elf").image,
            "localhost/custom:tag"
        );

        match saved {
            Some(v) => std::env::set_var(RENODE_IMAGE_ENV, v),
            None => std::env::remove_var(RENODE_IMAGE_ENV),
        }
    }

    #[test]
    fn builder_overrides() {
        let cfg = HilConfig::single(Platform::stm32h7(), "/tmp/fw.elf")
            .timeout(30)
            .socketcan_iface("vcan1")
            .stubs_repl("/tmp/stubs.repl")
            .image("localhost/renode:dev")
            .marker("done_marker")
            .fail_marker("oops");
        assert_eq!(cfg.timeout_secs, 30);
        assert_eq!(cfg.socketcan_iface, "vcan1");
        assert_eq!(cfg.stubs_repl, Some(PathBuf::from("/tmp/stubs.repl")));
        assert_eq!(cfg.image, "localhost/renode:dev");
        assert_eq!(cfg.marker, "done_marker");
        assert_eq!(cfg.fail_marker, "oops");
    }

    #[test]
    fn wall_timeout_defaults_from_sim_timeout_and_is_overridable() {
        let cfg = HilConfig::single(Platform::stm32h7(), "/tmp/fw.elf").timeout(15);
        assert_eq!(cfg.resolved_wall_timeout_secs(), default_wall_timeout(15));
        assert_eq!(cfg.resolved_wall_timeout_secs(), 90);

        let overridden = cfg.wall_timeout(300);
        assert_eq!(overridden.resolved_wall_timeout_secs(), 300);
    }
}