Tutorial 5: Hardware Drivers
Every robot needs to talk to hardware — servos, LiDARs, cameras, IMUs. HORUS separates what hardware you have (config) from how you use it (code). This tutorial walks through the complete workflow.
Prerequisites: Tutorial 1 completed.
What you'll learn:
- Configure drivers in
horus.toml - Load hardware with
drivers::load() - Use
DriverHandleandDriverParamsin nodes - Register custom drivers with
register_driver! - Test without hardware using
drivers::load_from()
Time: 20 minutes
Step 1: Configure Drivers in horus.toml
Add a [drivers] section to your horus.toml. Each driver has a name and a source (terra, package, or node):
[package]
name = "my-robot"
version = "0.1.0"
language = "rust"
[drivers.arm]
terra = "dynamixel"
port = "/dev/ttyUSB0"
baudrate = 1000000
servo_ids = [1, 2, 3, 4, 5]
[drivers.lidar]
terra = "rplidar"
port = "/dev/ttyUSB1"
baudrate = 256000
[drivers.imu]
terra = "i2c"
bus = 1
address = 104
[drivers.force_sensor]
package = "horus-driver-ati-netft"
address = "192.168.1.100"
[drivers.conveyor]
node = "ConveyorDriver"
port = "/dev/ttyACM0"
speed = 0.5
Three driver sources:
| Key | Source | Example |
|---|---|---|
terra = "..." | Terra HAL driver (Dynamixel, RPLidar, I2C, etc.) | terra = "dynamixel" |
package = "..." | Registry package (installed via horus add) | package = "horus-driver-ati-netft" |
node = "..." | Local code (registered with register_driver!) | node = "ConveyorDriver" |
All other keys in the table become typed parameters accessible via DriverParams.
Step 2: Load Drivers
In your code, call drivers::load() to parse the [drivers] section and get a HardwareSet:
use horus::prelude::*;
use horus::drivers;
fn main() -> Result<()> {
// Loads from horus.toml (searches current dir and parents)
let mut hw = drivers::load()?;
// See what's configured
println!("Drivers: {:?}", hw.list());
// → ["arm", "conveyor", "force_sensor", "imu", "lidar"]
Ok(())
}
Step 3: Get Driver Handles
Use typed accessors to get handles for Terra drivers:
use horus::prelude::*;
use horus::drivers;
fn main() -> Result<()> {
let mut hw = drivers::load()?;
// Typed Terra accessors — validates the driver source matches
let arm = hw.dynamixel("arm")?;
let lidar = hw.rplidar("lidar")?;
let imu = hw.i2c("imu")?;
// Access config params from the handle
let port: String = arm.params().get("port")?;
let baud: u32 = arm.params().get("baudrate")?;
let ids: Vec<u8> = arm.params().get("servo_ids")?;
println!("Arm on {} @ {} baud, servos: {:?}", port, baud, ids);
Ok(())
}
Available typed accessors:
| Accessor | Hardware |
|---|---|
hw.dynamixel(name) | Dynamixel servo bus |
hw.rplidar(name) | RPLidar scanner |
hw.realsense(name) | Intel RealSense camera |
hw.i2c(name) | I2C device |
hw.serial(name) | Serial/UART port |
hw.can(name) | CAN bus |
hw.gpio(name) | GPIO pin |
hw.pwm(name) | PWM output |
hw.webcam(name) | V4L2 camera |
hw.input(name) | Gamepad/input device |
hw.bluetooth(name) | Bluetooth LE device |
hw.net(name) | Network device (TCP/UDP) |
hw.ethercat(name) | EtherCAT bus |
hw.raw(name) | Any driver (escape hatch) |
Step 4: Use Handles in Nodes
Pass the DriverHandle to your node and read params in the constructor:
use horus::prelude::*;
use horus::drivers::{self, DriverHandle};
struct ArmController {
servo_ids: Vec<u8>,
port: String,
cmd_topic: Topic<JointCommand>,
tick_count: u32,
}
impl ArmController {
fn new(handle: DriverHandle) -> Result<Self> {
let servo_ids: Vec<u8> = handle.params().get("servo_ids")?;
let port: String = handle.params().get("port")?;
println!("ArmController: {} servos on {}", servo_ids.len(), port);
Ok(Self {
servo_ids,
port,
cmd_topic: Topic::new("arm.command")?,
tick_count: 0,
})
}
}
impl Node for ArmController {
fn name(&self) -> &str { "ArmController" }
fn tick(&mut self) {
self.tick_count += 1;
// In a real driver, you'd read/write hardware here
// using the port and servo_ids from config
if let Some(cmd) = self.cmd_topic.recv() {
println!("[Arm] Moving {} servos to target positions", self.servo_ids.len());
}
}
}
fn main() -> Result<()> {
let mut hw = drivers::load()?;
let arm_handle = hw.dynamixel("arm")?;
let mut scheduler = Scheduler::new().tick_rate(100_u64.hz());
scheduler.add(ArmController::new(arm_handle)?)
.order(0)
.rate(500_u64.hz())
.on_miss(Miss::SafeMode)
.build()?;
scheduler.run()?;
Ok(())
}
Step 5: Custom Drivers with register_driver!
For local code drivers (the node = "..." source), register your type so HardwareSet::local() can instantiate it from config:
use horus::prelude::*;
use horus::drivers::DriverParams;
use horus::register_driver;
struct ConveyorDriver {
port: String,
speed: f64,
publisher: Topic<CmdVel>,
}
impl ConveyorDriver {
fn from_params(params: &DriverParams) -> Result<Self> {
let port: String = params.get("port")?;
let speed: f64 = params.get_or("speed", 1.0);
Ok(Self {
port,
speed,
publisher: Topic::new("conveyor.velocity")?,
})
}
}
impl Node for ConveyorDriver {
fn name(&self) -> &str { "ConveyorDriver" }
fn tick(&mut self) {
// Drive the conveyor at configured speed
self.publisher.send(CmdVel::new(self.speed as f32, 0.0));
}
}
// Register so [drivers.conveyor] node = "ConveyorDriver" works
register_driver!(ConveyorDriver, ConveyorDriver::from_params);
Then in main.rs:
fn main() -> Result<()> {
let mut hw = drivers::load()?;
// local() looks up "ConveyorDriver" in the registry
// and calls from_params() with the config params
let conveyor_node = hw.local("conveyor")?;
let mut scheduler = Scheduler::new().tick_rate(50_u64.hz());
// local() returns Box<dyn Node> — no extra wrapping needed
scheduler.add(conveyor_node).order(0).build()?;
scheduler.run()?;
Ok(())
}
The corresponding config:
[drivers.conveyor]
node = "ConveyorDriver"
port = "/dev/ttyACM0"
speed = 0.5
Step 6: Testing Without Hardware
Use drivers::load_from() to load from a test config file instead of the project's horus.toml:
#[cfg(test)]
mod tests {
use super::*;
use horus::drivers;
#[test]
fn arm_controller_from_config() {
// Create a test config
std::fs::write("test_drivers.toml", r#"
[drivers.arm]
terra = "dynamixel"
port = "/dev/null"
baudrate = 1000000
servo_ids = [1, 2, 3]
"#).unwrap();
let mut hw = drivers::load_from("test_drivers.toml").unwrap();
let handle = hw.dynamixel("arm").unwrap();
let controller = ArmController::new(handle).unwrap();
assert_eq!(controller.servo_ids, vec![1, 2, 3]);
std::fs::remove_file("test_drivers.toml").ok();
}
}
DriverParams API Quick Reference
| Method | Returns | Use |
|---|---|---|
params.get::<T>(key) | Result<T> | Required param (errors if missing) |
params.get_or(key, default) | T | Optional param with fallback |
params.has(key) | bool | Check if key exists |
params.keys() | Iterator<&str> | List all param names |
params.raw(key) | Option<&toml::Value> | Raw TOML value |
Supported types for get::<T>: String, bool, i32, i64, u8, u32, u64, f32, f64, Vec<T>.
Key Takeaways
horus.toml [drivers]separates hardware config from code -- swap hardware without recompiling- Three driver sources:
terra(built-in HAL),package(registry),node(local code) DriverHandlegives typed access to config params viaparams().get::<T>(key)register_driver!connects your custom struct to the[drivers]config systemdrivers::load_from()enables hardware-free testing with mock config files
Next Steps
- Driver API Reference — full API documentation
- Real-Time Control — scheduling drivers with RT guarantees
- Deployment — deploying driver configs to a robot
See Also
- Hardware & RT (Python) — Python version
- Driver API — Driver loading reference