Control Messages

Control messages are how Python users make robots move — commanding motors, servos, wheels, and arm joints. The factory methods and presets handle the domain-specific math (kinematics, PID tuning) so you don't have to.

When you need these: If your robot has actuators — motors, servos, wheels, arm joints — you need control messages. CmdVel for simple differential drive, MotorCommand for direct motor control, JointCommand for multi-DOF arms.

from horus import (
    CmdVel, MotorCommand, ServoCommand,
    DifferentialDriveCommand, PidConfig,
    TrajectoryPoint, JointCommand,
)

Note: CmdVel is documented in Geometry Messages since it's a velocity type, but it's listed here too because it's the most common control message.


MotorCommand

Individual motor control with mode-specific factory methods. The factories set the right mode, enable flag, and limits — use them instead of setting raw integers.

Constructor

cmd = MotorCommand(motor_id=0, mode=0, target=1.5,
                   max_velocity=10.0, max_acceleration=5.0, enable=True)

.velocity(motor_id, velocity) — Speed Control

cmd = MotorCommand.velocity(motor_id=0, velocity=1.5)  # 1.5 rad/s

Creates a velocity-mode command. The motor controller maintains the target speed using its internal PID loop. Use this for wheels, conveyors, or any continuous rotation.

The velocity parameter is in rad/s for rotary motors or m/s for linear actuators — check your motor controller's documentation.

.position(motor_id, position, max_velocity) — Position Control

cmd = MotorCommand.position(motor_id=0, position=3.14, max_velocity=2.0)

Creates a position-mode command. The motor moves to the target position at up to max_velocity. Use this for precise positioning — homing, tool changes, pan/tilt control.

The motor controller handles the trajectory — it accelerates, cruises, and decelerates to reach the target smoothly.

.stop(motor_id) — Emergency Stop

cmd = MotorCommand.stop(motor_id=0)

Immediately stops the motor (zero target, disable). Use this for safety — collision detected, e-stop pressed, fault condition.

Common mistake: Sending velocity(id, 0.0) instead of stop(). A zero velocity command keeps the motor enabled and holding position. stop() actually disables the motor, allowing it to coast to a stop and drawing less power.

.is_valid() — Validation

if not cmd.is_valid():
    print("Invalid motor command — check parameters")

ServoCommand

Angle-based servo control with speed limiting and degree-to-radian conversion.

Constructor

cmd = ServoCommand(servo_id=0, position=1.57)  # Radians

.from_degrees(servo_id, degrees) — From Angle in Degrees

cmd = ServoCommand.from_degrees(servo_id=0, degrees=90.0)
# Internally converts to 1.5708 radians

Most servo specifications list angle ranges in degrees (0-180°, 0-270°, etc.), but horus uses radians internally. This factory handles the conversion so you can think in degrees.

Common mistake: Using ServoCommand(servo_id=0, position=90.0) — that's 90 radians (≈ 14 full rotations), which will slam the servo to its limit. Use from_degrees() for degree values.

.with_speed(servo_id, position, speed) — Speed-Limited Movement

cmd = ServoCommand.with_speed(servo_id=0, position=1.57, speed=0.5)

Creates a command with a speed limit in rad/s. Without a speed limit, the servo moves as fast as its hardware allows — which can be dangerous for large servos or when carrying a load.

.disable(servo_id) — Power Off

cmd = ServoCommand.disable(servo_id=0)

Powers off the servo motor. The servo becomes free-moving (no holding torque). Important for:

  • Reducing power consumption when the servo doesn't need to hold position
  • Allowing manual positioning of robot arms
  • Safety — a disabled servo can't exert force

.is_valid() — Validation

print(cmd.is_valid())

DifferentialDriveCommand

Wheel-level control for differential drive robots (two-wheeled robots like TurtleBot, iRobot Create, most ground robots). The from_twist() factory is the key method — it converts a velocity command to left/right wheel speeds using inverse kinematics.

Constructor

cmd = DifferentialDriveCommand(left_velocity=1.0, right_velocity=-1.0)
# Equal and opposite = spin in place

.from_twist(linear, angular, wheel_base, wheel_radius) — Inverse Kinematics

cmd = DifferentialDriveCommand.from_twist(
    linear=0.5,        # m/s forward
    angular=0.3,       # rad/s turning (positive = left)
    wheel_base=0.3,    # meters between wheel centers
    wheel_radius=0.05, # wheel radius in meters
)

Converts a desired robot velocity (linear + angular) to individual wheel speeds. The math:

v_left  = (linear - angular × wheel_base / 2) / wheel_radius
v_right = (linear + angular × wheel_base / 2) / wheel_radius

Parameters you need to measure on your robot:

  • wheel_base: distance between the centers of the two drive wheels (measure with a ruler)
  • wheel_radius: radius of each drive wheel (measure the diameter and divide by 2)

When angular > 0, the robot turns left (counterclockwise from above). The left wheel slows down and the right wheel speeds up.

Common mistake: Swapping wheel_base and wheel_radius. If your robot drives 10x too fast or too slow, you probably swapped them. wheel_base is always larger (typically 0.1-0.5m) and wheel_radius is smaller (typically 0.02-0.1m).

.stop() — Stop Both Wheels

cmd = DifferentialDriveCommand.stop()

Zero velocity on both wheels with enable=true. The motors actively hold position (zero speed).

.is_valid() — Validation

print(cmd.is_valid())

Example — Teleop with Kinematics:

from horus import Node, run, CmdVel, DifferentialDriveCommand, Topic

cmd_vel_topic = Topic(CmdVel)
wheel_topic = Topic(DifferentialDriveCommand)

WHEEL_BASE = 0.287    # TurtleBot3 Burger
WHEEL_RADIUS = 0.033  # TurtleBot3 Burger

def convert_to_wheels(node):
    cmd = cmd_vel_topic.recv(node)
    if cmd is None:
        return
    wheel_cmd = DifferentialDriveCommand.from_twist(
        linear=cmd.linear,
        angular=cmd.angular,
        wheel_base=WHEEL_BASE,
        wheel_radius=WHEEL_RADIUS,
    )
    wheel_topic.send(wheel_cmd, node)

run(Node(tick=convert_to_wheels, rate=50,
         pubs=["wheel_cmd"], subs=["cmd_vel"]))

PidConfig

PID controller gains with preset configurations. The presets are the easiest way to get started — pick the simplest controller that works, then add complexity only if needed.

Constructor

pid = PidConfig(kp=2.0, ki=0.1, kd=0.05)

.proportional(kp) — P Controller

pid = PidConfig.proportional(kp=1.0)

Proportional-only. The simplest controller — output is proportional to error. Fast response, but always has steady-state error (the system never quite reaches the target).

Use when: Speed matters more than precision. Coarse positioning, rough speed control.

.pi(kp, ki) — PI Controller

pid = PidConfig.pi(kp=1.0, ki=0.1)

Proportional + Integral. The integral term eliminates steady-state error — given enough time, the system reaches the exact target. But high ki causes overshoot and oscillation.

Use when: You need zero steady-state error. Temperature control, steady-state speed tracking.

.pd(kp, kd) — PD Controller

pid = PidConfig.pd(kp=1.0, kd=0.05)

Proportional + Derivative. The derivative term damps oscillations and improves transient response. But there's no integral term, so steady-state error persists.

Use when: You need fast, smooth response without overshoot. Joint angle control, quick positioning.

.with_limits(integral_limit, output_limit) — Anti-Windup

pid = PidConfig(kp=2.0, ki=0.5, kd=0.1).with_limits(
    integral_limit=10.0,   # Clamp integral accumulator
    output_limit=100.0,    # Clamp output value
)

Sets bounds on the integral accumulator and the total output. Without limits, the integral term can "wind up" during sustained error (e.g., when the motor is stalled) and cause massive overshoot when the error finally clears.

Returns a new PidConfig with the limits applied.

Common mistake: Setting ki without with_limits(). The integral will accumulate unboundedly, eventually producing huge output spikes. Always set limits when using integral control.

.is_valid() — Validation

if not pid.is_valid():
    print("Invalid PID gains — kp must be >= 0, all values finite")

TrajectoryPoint

A point along a trajectory with position, velocity, and time. Used for smooth motion planning where you specify the desired state at each time step.

.new_2d(x, y, vx, vy, time) — 2D Trajectory Point

tp = TrajectoryPoint.new_2d(x=1.0, y=2.0, vx=0.5, vy=0.0, time=1.0)

Creates a 2D trajectory point with position (x, y), velocity (vx, vy), and time-from-start in seconds. Used for 2D path planning where you want the robot at specific positions at specific times.

.stationary(x, y, z) — Hold Position

hold = TrajectoryPoint.stationary(x=5.0, y=3.0, z=0.0)

Creates a point with zero velocity — the robot should be stationary at this position. Use this for the final point in a trajectory (the "stop here" point).


JointCommand

Multi-joint command for robot arms and legged robots. Add position or velocity commands for individual joints by name.

Constructor

cmd = JointCommand()

.add_position(name, position) — Position a Joint

cmd = JointCommand()
cmd.add_position("shoulder", 1.57)     # Move shoulder to 90°
cmd.add_position("elbow", 0.785)       # Move elbow to 45°
cmd.add_position("wrist_rotate", 0.0)  # Center wrist

Adds a position command for the named joint. Position is in radians for revolute joints, meters for prismatic joints. Raises ValueError if the joint limit (16 joints) is exceeded.

Common mistake: Joint names must match exactly what the arm driver publishes in JointState. If the driver uses "joint_1" but you send "shoulder", the command is ignored. Check JointState.names to see what names your hardware uses.

.add_velocity(name, velocity) — Velocity-Control a Joint

cmd = JointCommand()
cmd.add_velocity("wheel_left", 1.0)   # 1 rad/s
cmd.add_velocity("wheel_right", 1.0)  # 1 rad/s

Same pattern but for velocity control. Typically used for wheels or continuous-rotation joints.

.is_valid() — Validation

if not cmd.is_valid():
    print("Invalid joint command")

Example — Robot Arm Homing:

from horus import JointCommand, Topic

arm_cmd = Topic(JointCommand)

def home_arm(node):
    cmd = JointCommand()
    cmd.add_position("shoulder", 0.0)
    cmd.add_position("elbow", 0.0)
    cmd.add_position("wrist_pitch", 0.0)
    cmd.add_position("wrist_rotate", 0.0)
    cmd.add_position("gripper", 0.0)
    arm_cmd.send(cmd, node)

Design Decisions

Why factory methods instead of mode integers? MotorCommand.velocity(id, 1.5) is self-documenting; MotorCommand(motor_id=0, mode=1, target=1.5) requires you to memorize that mode 1 is velocity. Factory methods encode domain knowledge (correct mode flag, default limits, enable state) so you cannot construct an invalid command by accident.

Why does DifferentialDriveCommand.from_twist() require wheel geometry? Different robots have different wheel sizes and spacing. Hardcoding these values would work only for one robot. By requiring wheel_base and wheel_radius as parameters, the same code works for any differential drive robot. Measure your robot once, define them as constants, and the kinematics are correct forever.

Why PID presets (proportional(), pi(), pd()) instead of just the constructor? Most PID tuning starts with a P controller and adds complexity only if needed. The presets encode this workflow: start with proportional(), add integral with pi() if you have steady-state error, add derivative with pd() if you have oscillation. The constructor exists for when you need all three gains, but the presets catch the 80% case.

Why JointCommand uses name-based addressing instead of index arrays? Index-based commands are fragile — if someone adds a joint to the URDF, all indices shift and every command node breaks. Name-based addressing is robust to hardware changes. The cost is a string comparison per joint, but with a 16-joint limit, this is negligible.


See Also