Tutorial 5: Hardware & Real-Time (Rust)

Prefer another language? The same tutorial exists for Python and C++.

Connect real sensors and actuators to HORUS with proper real-time scheduling. This tutorial builds a motor controller that reads encoder feedback and drives a motor with SCHED_FIFO priority.

What You'll Learn

  • Opening serial ports from a Node
  • RT scheduling: budget(), deadline(), core(), priority()
  • Watchdog for detecting frozen hardware
  • enter_safe_state() for actuator safety
  • CPU governor and kernel requirements

Prerequisites

The Hardware Pattern

Every hardware driver follows the same pattern:

init():              open device, configure, verify connection
tick():              read sensor OR write actuator (never both blocking)
enter_safe_state():  zero actuators, disable outputs
shutdown():          close device, release resources

The C++ page calls the last one on_shutdown(). In Rust the Node trait method is shutdown(&mut self) -> Result<()> — same slot in the lifecycle, different name.

Complete Code: RT Motor Driver

Create the project with horus new rt_motor, then replace src/main.rs:

📝No serial crate — on purpose

A project created by horus new depends only on the horus crate, and Rust's std has neither the open(2) flag constants nor a termios binding. So this driver opens the port with std::fs::OpenOptions — passing O_NONBLOCK through OpenOptionsExt::custom_flags, the std equivalent of the extra argument the C++ version hands to open(2) — and sets baud rate, parity and stop bits by shelling out to stty in init(), which is not RT-critical.

That is real, working code with no extra dependency. A production driver runs horus add serialport --source crates.io and replaces init() with the crate's own configuration call; the node structure and every RT knob below stay exactly the same. Dropping the File closes the descriptor, so shutdown() needs no close() call and cannot double-close.

use horus::prelude::*;
use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::os::unix::fs::OpenOptionsExt;
use std::process::Command;

// Linux open(2) flags. std does not re-export libc's constants and a HORUS
// project depends only on `horus`, so spell out the two we need.
const O_NOCTTY: i32 = 0o400;
const O_NONBLOCK: i32 = 0o4000;

// 100 ticks at 100 Hz = 1 s without encoder feedback.
const ENCODER_TIMEOUT_TICKS: u32 = 100;

struct MotorDriver {
    port: String,
    baudrate: u32,
    serial: Option<File>,
    cmd: Topic<CmdVel>,
    state: Topic<CmdVel>,
    last_cmd: f32,
    watchdog_counter: u32,
    watchdog_fed: bool,
    watchdog_alarmed: bool,
}

impl MotorDriver {
    fn new(port: &str, baudrate: u32) -> Result<Self> {
        Ok(Self {
            port: port.to_string(),
            baudrate,
            serial: None,
            cmd: Topic::new("motor.cmd")?,
            state: Topic::new("motor.state")?,
            last_cmd: 0.0,
            watchdog_counter: 0,
            watchdog_fed: false,
            watchdog_alarmed: false,
        })
    }

    /// Write one framed command to the motor controller.
    /// Protocol: "M<duty>\n" where duty is -100 to 100.
    fn write_duty(&mut self, duty: f32) {
        // Format into a stack buffer: the hot path of an RT tick should not
        // reach the heap.
        let mut buf = [0u8; 32];
        let used = {
            let mut cursor = &mut buf[..];
            let _ = write!(cursor, "M{:.0}\n", duty);
            32 - cursor.len()
        };
        if let Some(serial) = self.serial.as_mut() {
            let _ = serial.write_all(&buf[..used]);
        }
    }

    fn send_zero(&mut self) {
        self.write_duty(0.0);
        self.state.send(CmdVel::new(0.0, 0.0));
    }
}

impl Node for MotorDriver {
    fn name(&self) -> &str {
        "motor_driver"
    }

    fn init(&mut self) -> Result<()> {
        // Line discipline: 115200 8N1, raw, no modem control.
        let stty = Command::new("stty")
            .arg("-F")
            .arg(&self.port)
            .arg(self.baudrate.to_string())
            .args(["raw", "-echo", "clocal", "cs8", "-parenb", "-cstopb"])
            .status();
        if let Err(e) = stty {
            hlog!(warn, "stty failed on {}: {}", self.port, e);
        }

        match OpenOptions::new()
            .read(true)
            .write(true)
            .custom_flags(O_NOCTTY | O_NONBLOCK)
            .open(&self.port)
        {
            Ok(serial) => {
                self.serial = Some(serial);
                hlog!(info, "Serial port {} opened, motor ready", self.port);
            }
            Err(e) => {
                // Log rather than fail: the scheduler keeps ticking and every
                // tick short-circuits while `serial` is None.
                hlog!(error, "Failed to open serial port {}: {}", self.port, e);
            }
        }
        Ok(())
    }

    fn tick(&mut self) {
        if self.serial.is_none() {
            return;
        }

        // Drain to the newest command so a burst never queues up behind us.
        let mut latest = None;
        while let Some(cmd) = self.cmd.recv() {
            latest = Some(cmd);
        }
        if let Some(cmd) = latest {
            self.last_cmd = cmd.linear;
            self.write_duty(cmd.linear * 100.0);
        }

        // Encoder feedback. The fd is O_NONBLOCK, so an idle UART returns
        // WouldBlock immediately instead of parking the RT thread.
        let mut rbuf = [0u8; 64];
        let n = match self.serial.as_mut() {
            Some(serial) => serial.read(&mut rbuf).unwrap_or(0),
            None => 0,
        };
        if n > 0 {
            if let Ok(text) = std::str::from_utf8(&rbuf[..n]) {
                let rpm = text
                    .trim()
                    .strip_prefix('E')
                    .and_then(|v| v.parse::<f32>().ok());
                if let Some(rpm) = rpm {
                    self.state.send(CmdVel::new(rpm, self.last_cmd));
                    self.watchdog_fed = true;
                }
            }
        }

        // Watchdog: no encoder response for 100 ticks (1 s) means the cable is
        // out, or the motor controller has stopped talking.
        if self.watchdog_fed {
            self.watchdog_counter = 0;
            self.watchdog_fed = false;
            // Clear the latch so a recovered encoder can alarm again later.
            self.watchdog_alarmed = false;
        } else {
            self.watchdog_counter += 1;
            if self.watchdog_counter > ENCODER_TIMEOUT_TICKS && !self.watchdog_alarmed {
                hlog!(error, "Encoder watchdog timeout (no feedback for 1 s)");
                self.watchdog_alarmed = true;
                self.send_zero();
            }
        }
    }

    fn enter_safe_state(&mut self) {
        self.send_zero();
        hlog!(error, "Safe state: motor zeroed");
    }

    fn shutdown(&mut self) -> Result<()> {
        if self.serial.is_some() {
            self.send_zero();
            self.serial = None; // dropping the File closes the fd
        }
        hlog!(info, "Serial port closed");
        Ok(())
    }
}

fn main() -> Result<()> {
    let mut sched = Scheduler::new()
        .tick_rate(100_u64.hz())
        .name("rt_motor")
        .blackbox(8) // 8 MB flight recorder for post-mortem analysis
        .prefer_rt(); // use SCHED_FIFO if available

    sched
        .add(MotorDriver::new("/dev/ttyUSB0", 115_200)?)
        .order(0) // highest priority
        .budget(2_u64.ms()) // must complete in 2 ms
        .deadline(5_u64.ms()) // absolute deadline 5 ms
        .on_miss(Miss::SafeMode) // stop motor if overrun
        .core(2) // pin to CPU core 2
        .priority(90) // SCHED_FIFO priority 90
        .watchdog(1_u64.secs()) // per-node watchdog
        .build()?;

    sched.run()
}

Build and run it:

horus build && horus run
📝Where the blackbox entries go

C++ has a horus::blackbox::record(category, message) free function. It is a thin wrapper that publishes a warning-level log entry, so the Rust equivalent is simply hlog!(warn, "...") — same destination, readable with horus log. The recorder itself is a scheduler feature: .blackbox(8) gives you an 8 MB ring buffer that captures deadline misses, budget violations and emergency stops on its own, with no per-call instrumentation.

RT Configuration Explained

use horus::prelude::*;

fn main() -> Result<()> {
    // Try SCHED_FIFO and mlockall; log a warning and keep going if the kernel
    // or your privileges do not allow them. Never panics.
    let sched = Scheduler::new().tick_rate(1000_u64.hz()).prefer_rt();

    // Production alternative — panics at construction instead of running
    // without RT:
    //     let sched = Scheduler::new().tick_rate(1000_u64.hz()).require_rt();

    // prefer_rt() only records the request. The RT config is applied when the
    // scheduler starts, so `degradations()` is empty and `has_full_rt()` is
    // `true` until then — inspect the capabilities detected at construction
    // instead. (At `run()` the scheduler prints its own
    // "[SCHEDULER] RT degraded: ..." lines for whatever it could not get.)
    if let Some(caps) = sched.capabilities() {
        if !caps.rt_priority_available {
            hlog!(warn, "RT priority unavailable — nodes run at normal priority");
        }
        if !caps.mlockall_permitted {
            hlog!(warn, "mlockall not permitted — expect page-fault jitter");
        }
    }
    Ok(())
}
SettingPurposeTypical Value
.budget(2_u64.ms())Max time per tick50-80% of period
.deadline(5_u64.ms())Absolute tick deadline90-95% of period
.core(2)CPU affinityDedicated core, not core 0
.priority(90)SCHED_FIFO level80-99 for critical, 50-79 for normal
.watchdog(1_u64.secs())Frozen node detection5-10x expected tick period
⚠️It is `.core()`, not `pin_core()`

The C++ builder spells CPU affinity pin_core(2); the Rust builder spells it .core(2). Both take a zero-based core index.

.core() and .priority() only mean anything on an RT node, and what makes a node RT is .rate(), .budget() or .deadline(). Set affinity or priority without one of those and .build() emits a has no effect warning through the log crate facade — but a binary launched by horus run installs no log logger, so the warning never prints and you are ignored silently. The driver above is RT because it sets .budget() and .deadline().

RT Kernel Setup

For full RT guarantees:

# Check current kernel
uname -r  # Look for "-rt" suffix

# Let HORUS inspect and configure the machine for you
horus setup-rt --check
horus setup-rt

# Or set the CPU governor to performance by hand
sudo cpupower frequency-set -g performance

# Grant RT privileges without root (release build, not the debug one)
horus build --release
sudo setcap cap_sys_nice+ep .horus/target/release/rt_motor

# Or run with elevated privileges
sudo nice -n -20 .horus/target/release/rt_motor

Without an RT kernel, HORUS still works — prefer_rt() logs warnings but continues with best-effort scheduling. Measure in release mode: a debug build is typically 10-50x slower, so deadline misses there tell you nothing about production timing.

Key Takeaways

  • init() opens hardware, tick() reads/writes, enter_safe_state() zeros actuators
  • Never block in tick() — use non-blocking I/O (O_NONBLOCK plus unwrap_or(0) on WouldBlock)
  • Format the wire command into a stack buffer — the hot path of tick() never touches the heap
  • Watchdog detects frozen hardware (encoder cable disconnected, motor driver crash)
  • .core() prevents the OS from migrating the thread — critical for latency
  • .budget() + Miss::SafeMode = automatic motor shutdown on timing overrun
  • .core() and .priority() are ignored on non-RT nodes; .rate()/.budget()/.deadline() is what makes a node RT
  • Test without an RT kernel first, add RT for production deployment

Next Steps