Key Concepts

This chapter covers two key concepts essential to understanding and using this library:

Handling Different Robots

Robot arms are capable of performing a wide range of tasks, though not all robots are equally suited for every application. While the functionality and behavior of robots may vary, many fundamental actions – such as reading joint states or controlling joint movements – share common characteristics across different robotic systems.

To streamline development and enhance usability, this library standardizes these shared functionalities while clearly distinguishing where robots differ in capabilities.

Modular Robot Definition

Within this library, a robot is defined in terms of its features in a modular fashion, allowing functionality to be shared across multiple robot platforms. A robot is represented as the pool of actions it can perform. For example:

  • Robot A: MovePtp

  • Robot B: MovePtp, MoveLinear, MoveCircular

Both robots share MovePtp, which operates identically on both. For an application that only requires MovePtp, the choice of robot is interchangeable. An application that needs MoveCircular can only use Robot B, and the compiler tells you so.

This design contributes to a user experience that adheres to the “Principle of Least Astonishment”, especially when interacting with robots from different manufacturers.

Contract-Based Traits

Technically, the modular robot definition is achieved with Rust traits. Each capability is one trait, and a robot struct implements the traits for the capabilities it has. This is the same idea as Protocols in Python, interfaces in Java, or abstract classes in C++.

Robot Types and Their Capabilities

All robot types run on the voraus.core robot control. A robot type is the integration of one robot family with it and implements the capabilities that integration supports, and a read-only view implements the reading capabilities of its robot. A function that needs a capability states it in its trait bounds, and the compiler rejects a robot that lacks it.

The robot types are VorausIndustrialRobotArm with its alias YuRobotArm, KukaRobotArm and FanucRobotArm. Their views are VorausIndustrialRobotArmView with its alias YuRobotArmView, KukaRobotArmView and FanucRobotArmView. A capability is the same on every robot that has it: move_linear() takes the same targets, coordinate systems, blending and motion profile on a FanucRobotArm as on a VorausIndustrialRobotArm.

The state of every robot is the main state of the voraus.core, a RobotState, which GetRobotState reads; there is no separate lifecycle state. Lifecycle moves the robot between these states and tells with is_connected() whether the robot is connected at all. GetRobotState also tells whether the robot stands still and waits for it, which takes the moment of Ready between two motions into account; see Reading the Robot State.

Type Checking and Development Support

The traits a function requires are part of its signature. The compiler checks that the robot handed in provides them, and rust-analyzer offers exactly the methods those traits provide. A missing capability is found at compile time, not on the shop floor.

Example

For a seamless experience with all robots supported by voraus robotik, voraus-robot-arm provides ready-to-use robot structs. Since a robot is just a combination of trait implementations, the simplest approach is to use one of them directly and pass it around by its concrete type:

fn application_specific_robot(robot: &VorausIndustrialRobotArm) -> Result<()> {
    info!("--- Application bound to VorausIndustrialRobotArm ---");
    robot.move_ptp(poses::home()).start()?.join()?;
    robot.move_ptp(poses::vertical()).start()?.join()?;
    info!("  Home and vertical reached on the concrete type.");
    Ok(())
}

The benefit is a low entry barrier: all functionality of the chosen robot is available. The downside is that the application is bound to that robot, even where it only used functionality that every robot provides.

The strongly recommended alternative is to require only the functionality that is actually needed, and to choose the most generic traits that fulfill the requirements. Rust expresses this with trait bounds, impl Trait1 + Trait2:

fn generic_application(robot: &(impl MovePtp + MoveLinear)) -> Result<()> {
    info!("--- Generic application (any robot with MovePtp + MoveLinear) ---");

    robot.move_ptp(poses::home_c()).start()?.join()?;
    info!("  Home reached.");

    // Call the generic utility function.
    move_back(robot)?;

    let up = CartesianPose::new(
        Meters(0.4),
        Meters(0.0),
        Meters(0.8),
        Radians(-PI),
        Radians(0.0),
        Radians(FRAC_PI_2),
    );
    robot.move_ptp(up).start()?.join()?;
    info!("  UP reached.");
    Ok(())
}

Only the functionality that is used is required from the robot, so the application works with any robot that implements those traits, and autocompletion offers only what is really available. Utility functions can narrow the requirement further:

fn move_back<R: MoveLinear>(robot: &R) -> Result<()> {
    robot
        .move_linear_relative(x(Meters(-0.2)), common::SLOW_TRAVEL_SPEED)
        .start()?
        .join()?;
    info!("  Moved 20 cm back (-X) via MoveLinear.");
    Ok(())
}

This utility is reusable wherever a robot with MoveLinear is used.

The capability traits are dyn-compatible, so a robot can also live behind a trait object. That is needed when the concrete type is only known at run time, for example when the robot type comes from a configuration file. An application first names the capabilities it needs in one trait with a blanket implementation, then builds the configured robot and forgets its type. The Send + Sync bounds let the box, or an Arc of it, be shared with a watchdog thread that calls stop_motion():

trait PickAndPlaceRobot: MovePtp + MoveLinear + Stop + Send + Sync {}
impl<R: MovePtp + MoveLinear + Stop + Send + Sync + ?Sized> PickAndPlaceRobot for R {}

/// Build, connect and enable the configured robot, then forget its type.
///
/// The type is decided at run time, so the function returns a trait object;
/// an `impl PickAndPlaceRobot` return type would need one type on every
/// path. The factory connects and enables the robot because the box only
/// exposes the capabilities named in [`PickAndPlaceRobot`]. Dropping the
/// box disconnects the robot.
fn robot_from_config(kind: RobotKind, host: &str, port: u16) -> Result<Box<dyn PickAndPlaceRobot>> {
    match kind {
        RobotKind::Industrial => {
            let mut robot = VorausIndustrialRobotArm::new();
            robot.connect(host, port)?;
            robot.enable()?;
            Ok(Box::new(robot))
        }
        RobotKind::Fanuc => {
            let mut robot = FanucRobotArm::new();
            robot.connect(host, port)?;
            robot.enable()?;
            Ok(Box::new(robot))
        }
    }
}

The factory returns a Box<dyn PickAndPlaceRobot> because the type differs between its branches; an impl PickAndPlaceRobot return type would need one type on every path. The factory connects and enables the robot because the box only exposes the capabilities named in PickAndPlaceRobot. An application that wants to manage the lifecycle itself, and see the errors of disable() and disconnect() instead of relying on the drop, adds Lifecycle to the supertraits and calls it on a mut box. The application itself takes &impl PickAndPlaceRobot, which accepts a concrete robot as well as the box: Box<dyn PickAndPlaceRobot> implements the capabilities by forwarding and is sized, so the builders work on it without further ado. Pass the box, &robot, not &*robot:

fn pick_and_place(
    robot: &impl PickAndPlaceRobot,
    pick: CartesianPose,
    place: CartesianPose,
) -> Result<()> {
    info!("--- Pick and place on a robot chosen at run time ---");
    robot.move_ptp(poses::home_c()).start()?.join()?;
    let motion = robot
        .move_linear(pick, common::SLOW_TRAVEL_SPEED)
        .blending(Millimeters(20.0))
        .move_linear(place, common::SLOW_TRAVEL_SPEED)
        .start()?;
    match motion.join_timeout(Duration::from_secs(30)) {
        Err(err @ RobotArmError::Timeout { timeout, .. }) => {
            info!("  Place pose not reached within {timeout:?}; stopping.");
            robot.stop_motion()?;
            Err(err)
        }
        result => {
            result?;
            info!("  Place pose reached.");
            Ok(())
        }
    }
}

The full example is rust/crates/voraus-robot-arm/examples/trait_generic.rs:

Testing Trait-Generic Code

Write application code generic over the capability traits it needs, as in the example above. Then test it against a voraus.core, or against your own test double that implements those traits. The trait bounds keep that code usable with every robot that has those capabilities.

Asynchronous Instruction Execution

The library provides synchronous operations called commands and asynchronous operations called instructions.

A command is executed immediately. It blocks until the robot control has confirmed it and returns Result<()>. Examples:

  • Set time override

  • Stop and unstop

  • Pause and continue

Most commands also have a send_* form. It returns a CommandHandle as soon as the command is sent, so the program can do other work while the robot control applies it. confirm() on the handle blocks until the robot control has confirmed the command. Its deadline is the command timeout, counted from the send. For a transition of the time override, the deadline is the transition time plus the command timeout. confirm_timeout() takes its own timeout, counted from the call. The handle is #[must_use]: a command that is never confirmed can fail without notice. The blocking form is the send_* form followed by confirm().

Blocking form

send_* form

enable(), disable()

send_enable(), send_disable()

stop_motion()

send_stop_motion()

pause_motion(), continue_motion()

send_pause_motion(), send_continue_motion()

set_time_override()

send_time_override()

set_time_override_with_transition()

send_time_override_with_transition()

set_stop_type(), set_stop_duration()

send_stop_type(), send_stop_duration()

set_payload(), remove_payload()

send_payload(), send_remove_payload()

select_tool(), deselect_tool()

send_select_tool(), send_deselect_tool()

activate_limit_set()

send_activate_limit_set()

deactivate_limit_set()

send_deactivate_limit_set()

set_external_error()

send_external_error()

reset_error() of the error handler

send_reset_error()

write(), set_high(), set_low() of a digital output

send(), send_high(), send_low()

write_map(), write_values(), write_int() of an output group

send_map(), send_values(), send_int()

connect(), disconnect(), unstop_motion(), set_cs_origin() and the heartbeat have no send_* form.

An instruction is queued and then sent to the robot. It may take time to execute if other instructions are ahead in the queue, for example several motions. Instructions are therefore processed asynchronously: the call returns at once with a handle. Examples:

  • Move PTP

  • Move linear

  • Move circular

  • Wait time

Working with Instruction Handles

An instruction is built with a short builder chain and queued with start(). start() does not block; it returns an InstructionHandle and your program continues immediately:

let vertical: InstructionHandle = robot.move_ptp(poses::vertical()).start()?;
info!("Instruction sent (robot is moving...)");

While the robot moves to the vertical pose, the program keeps running: the message is printed before the robot arrives.

At any time, you can wait for an instruction by calling join() on its handle. It blocks until the instruction has a result. A result is either the completion of the instruction, most of the time the target pose being reached, or the error that made it fail. To synchronize your program with the robot motion, append .join()? to every start(). The program only continues with the next instruction once the previous one has finished:

robot.move_ptp(poses::home()).start()?.join()?;
info!("HOME reached.");

robot.move_ptp(poses::vertical()).start()?.join()?;
info!("VERTICAL reached.");

Dropping a handle without joining it is fine. The robot keeps executing the instruction; only its outcome goes unobserved.

Instruction Blocks and Blending

Motions that belong together, for example because their segments shall be blended, are written as one block: a single builder chain in which every step is another move_* call. Nothing is sent until start(), then all steps go out back to back, and one handle, a BlockHandle, covers the whole block. Waiting for the block means waiting for its last step. One handle is enough: the robot passes the blended waypoints without stopping, so there is no moment at which a single blended step is “done” that a program could wait for; the end of the block is the only observable event.

A block is queued atomically. A motion or block that another thread starts in the meantime is queued after the block’s last step, never between two steps, where it would take the blending meant for the next one.

fn instruction_block_with_blending(robot: &impl MovePtp) -> Result<()> {
    info!("--- Instruction block with blending ---");

    let block = robot
        .move_ptp(poses::home_c())
        .move_ptp(poses::blending_1())
        .blending(Percent(50.0))
        .move_ptp(poses::blending_2())
        .blending(Percent(50.0))
        .move_ptp(poses::blending_3())
        .start()?;
    info!("All instructions sent.");

    // One handle for the whole block; it resolves when the last step ends.
    match block.join() {
        Ok(()) => info!("BLENDING_3 reached."),
        Err(e) => info!("Block ended: {e}"),
    }
    Ok(())
}

A note with regards to blending: blending only works if the instruction that follows is known to the robot control in advance. .blending() is therefore the transition into the next step: after it, only another motion can follow, and a block can never end on a blended step. Sending a blended instruction and waiting for it on its own therefore does not compile:

// Does not compile: a block cannot end on a blended step.
robot.move_ptp(poses::blending_1()).blending(Percent(50.0)).start()?;

The instructions chapter covers handles and blocks in detail, including timeouts and how errors surface.