Tutorial 2: Build a Motor Controller

Looking for the Python version? See Tutorial 2: Motor Controller (Python).

In this tutorial, you'll build a motor controller — a node that receives velocity commands and tracks joint position. This is the actuator side of a robot, complementing the sensor from Tutorial 1.

Prerequisites: Tutorial 1: IMU Sensor Node completed.

What you'll learn:

  • Subscribing to command topics
  • Publishing state feedback
  • Managing state between ticks (integration)
  • Multiple topics per node

Time: 15 minutes


What We're Building

A motor controller node that:

  1. Subscribes to velocity commands on "motor.command"
  2. Integrates velocity into position (simple physics)
  3. Publishes current position on "motor.state"
  4. A commander node that sends test commands

Step 1: Create the Project

horus new motor-demo -r
cd motor-demo

Step 2: Define the Data Types

use horus::prelude::*;

/// Velocity command sent to the motor.
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, LogSummary)]
#[repr(C)]
struct MotorCommand {
    velocity: f32,    // Desired velocity (rad/s)
    max_torque: f32,  // Torque limit (N*m)
}

/// Motor state feedback.
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, LogSummary)]
#[repr(C)]
struct MotorState {
    position: f32,    // Current position (radians)
    velocity: f32,    // Current velocity (rad/s)
    torque: f32,      // Applied torque (N*m)
    timestamp: f64,
}

Step 3: Build the Motor Controller

This is the core of the tutorial — a node that subscribes AND publishes:

struct MotorController {
    commands: Topic<MotorCommand>,     // Subscribe to commands
    state_pub: Topic<MotorState>,      // Publish state
    position: f32,                      // Accumulated position
    velocity: f32,                      // Current velocity
    dt: f32,                            // Time step (set from rate)
}

impl MotorController {
    fn new(rate_hz: f32) -> Result<Self> {
        Ok(Self {
            commands: Topic::new("motor.command")?,
            state_pub: Topic::new("motor.state")?,
            position: 0.0,
            velocity: 0.0,
            dt: 1.0 / rate_hz,
        })
    }
}

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

    fn tick(&mut self) {
        // IMPORTANT: call recv() every tick to consume the latest command.
        // Even if no command arrives, the motor continues integrating position.
        if let Some(cmd) = self.commands.recv() {
            // Simple velocity tracking with torque limit
            let error = cmd.velocity - self.velocity;
            let torque = error.clamp(-cmd.max_torque, cmd.max_torque);
            self.velocity += torque * self.dt;
        }

        // Integrate velocity → position
        self.position += self.velocity * self.dt;

        // Publish current state
        self.state_pub.send(MotorState {
            position: self.position,
            velocity: self.velocity,
            torque: 0.0,
            timestamp: 0.0,  // Simplified for tutorial
        });
    }

    // SAFETY: shutdown() is CRITICAL for actuator nodes. Without it, the motor
    // could continue running at its last commanded velocity after the scheduler stops.
    // Always zero velocity and publish the stopped state before returning.
    fn shutdown(&mut self) -> Result<()> {
        // CRITICAL: zero velocity before publishing — order matters.
        self.velocity = 0.0;
        self.state_pub.send(MotorState {
            position: self.position,
            velocity: 0.0,
            torque: 0.0,
            timestamp: 0.0,
        });
        eprintln!("Motor stopped safely at position {:.2} rad", self.position);
        Ok(())
    }
}

Key points:

  • Two topics: one for receiving commands, one for publishing state
  • State between ticks: position and velocity persist across tick() calls
  • Simple physics: velocity integrates into position (position += velocity * dt)
  • Safe shutdown: motor velocity set to zero in shutdown()

Step 4: Build the Commander

A test node that sends velocity commands:

struct Commander {
    publisher: Topic<MotorCommand>,
    tick_count: u64,
}

impl Commander {
    fn new() -> Result<Self> {
        Ok(Self {
            publisher: Topic::new("motor.command")?,
            tick_count: 0,
        })
    }
}

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

    fn tick(&mut self) {
        let t = self.tick_count as f64 * 0.01; // At 100Hz

        // Send a sine wave velocity command
        let velocity = 1.0 * (t * 0.5).sin() as f32;

        self.publisher.send(MotorCommand {
            velocity,
            max_torque: 10.0,
        });

        self.tick_count += 1;
    }

    // SAFETY: shutdown() sends a zero-velocity command so the motor stops
    // even if the motor controller's own shutdown() hasn't run yet.
    fn shutdown(&mut self) -> Result<()> {
        self.publisher.send(MotorCommand { velocity: 0.0, max_torque: 0.0 });
        eprintln!("Commander shutting down, sent zero-velocity command.");
        Ok(())
    }
}

Step 5: Add a State Display

struct StateDisplay {
    subscriber: Topic<MotorState>,
    sample_count: u64,
}

impl StateDisplay {
    fn new() -> Result<Self> {
        Ok(Self {
            subscriber: Topic::new("motor.state")?,
            sample_count: 0,
        })
    }
}

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

    fn tick(&mut self) {
        // IMPORTANT: call recv() every tick to drain the topic buffer.
        if let Some(state) = self.subscriber.recv() {
            self.sample_count += 1;
            if self.sample_count % 50 == 0 {
                println!(
                    "pos={:.2} rad  vel={:.2} rad/s",
                    state.position, state.velocity,
                );
            }
        }
    }

    // SAFETY: shutdown() logs final display state for diagnostics.
    fn shutdown(&mut self) -> Result<()> {
        eprintln!("StateDisplay shutting down after {} samples", self.sample_count);
        Ok(())
    }
}

Step 6: Wire Everything Together

fn main() -> Result<()> {
    eprintln!("Motor controller demo starting...\n");

    let rate = 100.0; // Hz
    let mut scheduler = Scheduler::new();

    // Execution order: Commander (0) → MotorController (1) → StateDisplay (2).
    // Commander must publish before motor reads; motor must publish before display reads.

    scheduler.add(Commander::new()?)
        .order(0)
        // NOTE: .rate(100.hz()) triggers auto-RT detection at finalize().
        .rate(100.hz())
        .build()?;

    scheduler.add(MotorController::new(rate)?)
        .order(1)
        // NOTE: .rate(100.hz()) triggers auto-RT detection at finalize().
        .rate(100.hz())
        .build()?;

    scheduler.add(StateDisplay::new()?)
        .order(2)
        .build()?;

    scheduler.run()
}

Notice the data flow: Commander (0) → MotorController (1) → StateDisplay (2). Order matters — the commander must send before the controller reads.

Step 7: Run It

horus run

Expected output:

Motor controller demo starting...

pos=0.02 rad  vel=0.05 rad/s
pos=0.26 rad  vel=0.44 rad/s
pos=0.86 rad  vel=0.76 rad/s
pos=1.58 rad  vel=0.84 rad/s

Press Ctrl+C to stop — you'll see the shutdown message:

Motor stopped safely at position 3.14 rad

What You Learned

  • Subscribing to commands with commands.recv()
  • Publishing state feedback with state_pub.send()
  • State management between ticks (velocity integration)
  • Safe shutdown — always stop motors in shutdown()
  • Data flow ordering — commander before controller before display

Next: Tutorial 3: Full Robot Integration

In the next tutorial, we'll combine the IMU sensor and motor controller into a complete robot system with coordinate frame tracking and monitoring.


See Also