Tutorial 6: Write a Reusable Driver
Tutorial 5 showed how to use hardware. This tutorial shows how to create a driver — a reusable hardware driver package that anyone can install with horus install and configure in their horus.toml.
What you'll build: A driver for a serial motor controller (generic protocol) that publishes JointState and subscribes to JointCommand topics. By the end, it's installable from the registry.
Prerequisites: Tutorial 5: Hardware Drivers completed.
Time: 30 minutes
Architecture of a Driver Package
A driver package is a HORUS project that:
- Reads config from
NodeParams(port, baudrate, etc.) - Communicates with hardware (serial, I2C, CAN, etc.)
- Publishes sensor data to topics
- Subscribes to command topics
- Handles errors and safe states
Step 1: Create the Project
horus new horus-driver-serial-motor
cd horus-driver-serial-motor
Edit horus.toml:
[package]
name = "horus-driver-serial-motor"
version = "0.1.0"
description = "Generic serial motor controller driver for HORUS"
type = "driver"
license = "Apache-2.0"
keywords = ["motor", "serial", "driver", "actuator"]
[dependencies]
serialport = { version = "4.5", source = "crates.io" }
The type = "driver" metadata tells the registry this is a driver package, which affects how it shows up in search results.
Step 2: Define the Driver Struct
// simplified
// src/main.rs
use horus::prelude::*;
use horus::hardware::{self, NodeParams};
use horus::register_driver;
use std::io::{Read, Write};
use std::time::Duration;
/// Serial motor controller driver.
///
/// Communicates with a motor controller over UART using a simple
/// binary protocol: [0xFF, motor_id, position_hi, position_lo, checksum].
struct SerialMotorDriver {
port: Box<dyn serialport::SerialPort>,
motor_ids: Vec<u8>,
state_pub: Topic<JointState>,
cmd_sub: Topic<JointCommand>,
}
impl SerialMotorDriver {
fn from_params(params: &NodeParams) -> Result<Self> {
let port_name: String = params.get("port")?;
let baudrate: u32 = params.get_or("baudrate", 115200);
let motor_ids: Vec<u8> = params.get_or("motor_ids", vec![1]);
let topic_prefix: String = params.get_or("topic_prefix", "motor".to_string());
let port = serialport::new(&port_name, baudrate)
.timeout(Duration::from_millis(10))
.open()
.map_err(|e| Error::from(ConfigError::Other(
format!("Failed to open {}: {}", port_name, e)
)))?;
Ok(Self {
port,
motor_ids,
state_pub: Topic::new(&format!("{}.joint_state", topic_prefix))?,
cmd_sub: Topic::new(&format!("{}.joint_cmd", topic_prefix))?,
})
}
/// Read current positions from the controller.
fn read_positions(&mut self) -> Vec<f64> {
let mut positions = Vec::with_capacity(self.motor_ids.len());
for &id in &self.motor_ids {
// Send query: [0xFF, id, 0x00, 0x00, checksum]
let checksum = 0xFF ^ id;
let cmd = [0xFF, id, 0x00, 0x00, checksum];
if self.port.write_all(&cmd).is_err() {
positions.push(0.0);
continue;
}
// Read response: [0xFF, id, pos_hi, pos_lo, checksum]
let mut buf = [0u8; 5];
if self.port.read_exact(&mut buf).is_ok() && buf[0] == 0xFF && buf[1] == id {
let raw = ((buf[2] as u16) << 8) | buf[3] as u16;
// Convert raw position (0-4095) to radians (0-2pi)
positions.push(raw as f64 / 4095.0 * std::f64::consts::TAU);
} else {
positions.push(0.0);
}
}
positions
}
/// Send position commands to the controller.
fn write_positions(&mut self, positions: &[f64]) {
for (i, &id) in self.motor_ids.iter().enumerate() {
if let Some(&pos) = positions.get(i) {
// Convert radians to raw (0-4095)
let raw = ((pos / std::f64::consts::TAU) * 4095.0).clamp(0.0, 4095.0) as u16;
let hi = (raw >> 8) as u8;
let lo = (raw & 0xFF) as u8;
let checksum = 0xFF ^ id ^ hi ^ lo;
let cmd = [0xFF, id, hi, lo, checksum];
let _ = self.port.write_all(&cmd);
}
}
}
}
Step 3: Implement the Node Trait
// simplified
impl Node for SerialMotorDriver {
fn name(&self) -> &str { "SerialMotorDriver" }
fn init(&mut self) -> Result<()> {
// Verify communication by reading positions once
let positions = self.read_positions();
log::info!(
"SerialMotorDriver initialized: {} motors, positions: {:?}",
self.motor_ids.len(),
positions
);
Ok(())
}
fn tick(&mut self) {
// Read current state and publish
let positions = self.read_positions();
self.state_pub.send(JointState {
name: self.motor_ids.iter().map(|id| format!("motor_{}", id)).collect(),
position: positions,
..Default::default()
});
// Apply commands if any
if let Some(cmd) = self.cmd_sub.recv() {
self.write_positions(&cmd.position);
}
}
fn shutdown(&mut self) -> Result<()> {
log::info!("SerialMotorDriver shutting down — stopping all motors");
// Send zero velocity to all motors
let zeros = vec![0.0; self.motor_ids.len()];
self.write_positions(&zeros);
Ok(())
}
fn enter_safe_state(&mut self) {
// Emergency: stop all motors immediately
let zeros = vec![0.0; self.motor_ids.len()];
self.write_positions(&zeros);
}
fn is_safe_state(&self) -> bool {
true // Motors stopped
}
}
// Register so hardware::load() can instantiate this driver from horus.toml
register_driver!(SerialMotorDriver, SerialMotorDriver::from_params);
Step 4: Wire Up main()
hardware::load() reads your horus.toml [hardware] section and returns ready-to-use nodes — one (name, Box<dyn Node>) pair per device entry. For a driver package, you typically construct the node directly from params and add it to the scheduler:
// simplified
fn main() -> Result<()> {
env_logger::init();
// hardware::load() returns Vec<(String, Box<dyn Node>)>
// Each entry is a ready-to-use node from [hardware] config
let nodes = hardware::load()?;
let mut sched = Scheduler::new()
.tick_rate(100_u64.hz());
for (name, node) in nodes {
sched = sched
.add(node)
.rate(100_u64.hz())
.on_miss(Miss::SafeMode)
.build()?;
}
sched.build()?.run()
}
Step 5: Test Without Hardware
Create a test config and use hardware::load_from():
// simplified
#[cfg(test)]
mod tests {
use super::*;
fn test_config() -> &'static str {
r#"
[hardware.motor]
use = "serial"
port = "/dev/null"
baudrate = 115200
motor_ids = [1, 2, 3]
topic_prefix = "test_motor"
"#
}
#[test]
fn driver_parses_config() {
std::fs::write("/tmp/test_motor_driver.toml", test_config()).unwrap();
let nodes = hardware::load_from("/tmp/test_motor_driver.toml").unwrap();
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].0, "motor");
std::fs::remove_file("/tmp/test_motor_driver.toml").ok();
}
#[test]
fn driver_has_safe_state() {
// Verify the driver implements enter_safe_state
// In a real test, you'd use a mock serial port
}
}
For more thorough testing with simulated serial ports, use the serialport crate's TTYPort::pair() for virtual serial ports on Linux, or test with horus run --sim where the simulator provides virtual motor feedback.
Step 6: Publish to the Registry
Once your driver works, publish it so others can install it:
# Login (first time only)
horus auth login
# Verify package metadata
horus check
# Publish
horus publish
Users can then install and use your driver:
horus install horus-driver-serial-motor
# Their horus.toml
[hardware.arm]
use = "horus-driver-serial-motor"
port = "/dev/ttyUSB0"
baudrate = 115200
motor_ids = [1, 2, 3, 4, 5]
topic_prefix = "arm"
Driver Design Checklist
Before publishing, verify your driver follows these practices:
Config
- All hardware-specific values come from
NodeParams, not hardcoded - Reasonable defaults for optional params (
get_or()) -
topic_prefixparam so users can namespace topics per robot - Error messages include the port/device name for debugging
Safety
-
enter_safe_state()stops all actuators immediately -
is_safe_state()returnstruewhen motors are stopped -
shutdown()cleanly closes the hardware connection -
on_miss(Miss::SafeMode)configured for actuator drivers
Topics
- Follow the topic naming convention:
{prefix}.{data_type} - Use standard message types (
JointState,JointCommand,Imu,LaserScan, etc.) - Publish at a consistent rate matching the hardware capability
Testing
- Config parsing tested with
hardware::load_from() - Works with
horus run --sim(simulator provides mock data) - Graceful behavior when hardware is disconnected (no panics)
Package metadata
-
type = "driver"inhorus.toml [package] -
descriptionexplains what hardware it supports -
keywordsinclude the hardware name and interface type -
licensespecified
Example: Complete horus.toml for a Multi-Hardware Robot
[package]
name = "warehouse-robot"
version = "0.1.0"
[robot]
name = "agv"
description = "agv.urdf"
[hardware.wheels]
use = "horus-driver-serial-motor"
port = "/dev/ttyUSB0"
motor_ids = [1, 2]
topic_prefix = "agv"
[hardware.lidar]
use = "rplidar"
port = "/dev/ttyUSB1"
[hardware.arm]
use = "horus-driver-serial-motor"
port = "/dev/ttyUSB2"
motor_ids = [1, 2, 3, 4, 5, 6]
topic_prefix = "arm"
[hardware.imu]
use = "bno055"
bus = "/dev/i2c-1"
[hardware.sim_wheels]
use = "horus-driver-serial-motor"
sim = true
[hardware.sim_lidar]
use = "rplidar"
sim = true
noise = 0.01
[hardware.sim_arm]
use = "horus-driver-serial-motor"
sim = true
[hardware.sim_imu]
use = "bno055"
sim = true
Notice that horus-driver-serial-motor is used multiple times — for the wheels, the arm, and their simulated counterparts — each with different config values. The sim = true flag marks devices that should use simulated backends instead of real hardware. This is the power of config-driven drivers with the unified use field.
Next Steps
- Publishing & Registry — detailed publishing workflow
- Creating CLI Plugins — extend the
horusCLI - Real Hardware — complete I2C and serial examples with real libraries