Key Concepts
This chapter covers two key concepts essential to understanding and using this library:
Handling Different Robots – how
voraus-robot-armhandles various robotsAsynchronous Instruction Execution – synchronous and asynchronous operations
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:
MovePtpRobot 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:
Trait-generic programming example
//! Trait-based generic programming.
//!
//! Demonstrates how to write robot-agnostic functions using trait bounds and
//! trait objects, so that an application works with every robot that has the
//! capabilities it needs.
mod common;
use common::poses;
use std::env;
use std::error::Error;
use std::f64::consts::{FRAC_PI_2, PI};
use std::fmt;
use std::str::FromStr;
use std::time::Duration;
use tracing::info;
use voraus_robot_arm::prelude::*;
use voraus_robot_arm::types::coordinates::{x, z};
/// An application bound to one concrete robot type.
///
/// Everything the robot offers is available, but the function cannot be
/// reused with another robot even where that robot has every capability it
/// uses.
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(())
}
/// A generic utility function that only requires `MoveLinear`.
///
/// This function can be used with *any* robot that supports linear motion,
/// regardless of its concrete type.
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(())
}
/// A generic application that uses both `MovePtp` and `MoveLinear`.
///
/// The requirement is expressed with `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(())
}
/// A function that works with a single trait bound.
fn utility_single_trait(robot: &impl MoveLinear) -> Result<()> {
info!("--- Utility with single trait bound ---");
robot
.move_linear_relative(z(Meters(-0.05)), common::SLOW_TRAVEL_SPEED)
.start()?
.join()?;
info!(" Moved 5 cm down via MoveLinear.");
Ok(())
}
/// A `ROBOT_TYPE` value that names no known robot family.
#[derive(Debug)]
struct UnknownRobotType(String);
impl fmt::Display for UnknownRobotType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"unknown robot type '{}'; set ROBOT_TYPE to 'industrial' or 'fanuc'",
self.0
)
}
}
impl Error for UnknownRobotType {}
/// The robot family named in the configuration.
///
/// Read from `ROBOT_TYPE` and parsed with `FromStr`; the default is the
/// voraus industrial robot.
#[derive(Clone, Copy)]
enum RobotKind {
/// A robot on the voraus.core control.
Industrial,
/// A Fanuc robot, which lacks payload configuration and digital I/O but
/// has every capability this application needs.
Fanuc,
}
impl FromStr for RobotKind {
type Err = UnknownRobotType;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"industrial" => Ok(Self::Industrial),
"fanuc" => Ok(Self::Fanuc),
_ => Err(UnknownRobotType(value.to_owned())),
}
}
}
/// Everything this application needs from a robot.
///
/// Both robot families implement these capabilities, so either can stand
/// behind the trait object. The blanket implementation makes every such
/// robot a `PickAndPlaceRobot`. `Send + Sync` 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 application. It never learns which robot it drives.
///
/// A capability is the same on every robot that has it, so the bound on
/// `MoveLinear` alone is enough to blend the two linear motions by a radius.
///
/// The bound accepts a concrete robot as well as the box from
/// [`robot_from_config`]: `Box<dyn PickAndPlaceRobot>` implements the
/// capabilities by forwarding, and it is sized, so the builders work on it.
/// If the motion overruns its time budget, the robot is stopped rather than
/// left moving; it then stays stopped until `unstop_motion()`.
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(())
}
}
}
fn main() -> Result<(), Box<dyn Error>> {
common::init_logging();
let mut robot = common::create_connected_robot()?;
// Bound to the concrete type.
application_specific_robot(&robot)?;
// Static dispatch with our concrete VorausIndustrialRobotArm.
generic_application(&robot)?;
utility_single_trait(&robot)?;
robot.disconnect()?;
// A robot chosen from configuration, driven through a trait object.
let robot_type = env::var("ROBOT_TYPE").unwrap_or_else(|_| "industrial".to_owned());
let kind: RobotKind = robot_type.parse()?;
let robot = robot_from_config(kind, &common::robot_host(), common::robot_port())?;
pick_and_place(
&robot,
poses::home_c() + z(Meters(-0.05)),
poses::home_c() + z(Meters(-0.05)) + x(Meters(0.1)),
)?;
info!("Done.");
Ok(())
}
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 |
|
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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.