Heartbeat
A heartbeat is a periodic signal between two components that indicates normal operation. When heartbeat messages stop arriving, the robot control assumes the originator has failed or shut down and transitions into an error state, stopping all motion. It is the dead man’s switch between your application and the robot.
Warning
The heartbeat functionality is not a replacement for properly configured and certified safety components. In the context of safety certification, the heartbeat is considered unsafe software.
General Concepts
The robot control allows an application to register itself as a heartbeat provider. Once a session is active, the robot control expects a beat within a configurable interval (1 s by default). If the interval is violated, the robot stops with error 1516.
Multiple providers can be registered concurrently, each identified by a session id and monitored independently. Alternatively, a unique session can be requested, which enforces that it is the only active provider. Registering any session while a unique one is active, or a unique session while any other is active, puts the robot control into an error state.
The library sends the beats from its own worker thread, the same thread that processes instructions. There is nothing to call periodically.
Using the Heartbeat in an Application
heartbeat() returns a builder; start() registers the session and returns a HeartbeatSession guard that
stops the session gracefully when it is dropped. The guard borrows the robot, so it always ends before the robot is
disconnected. For the same reason enable() and disable(), which take &mut self, cannot be called while it lives.
fn scoped_heartbeat(robot: &(impl Heartbeat + MovePtp)) -> Result<(), ExampleError> {
info!("--- Scoped heartbeat ---");
check_heartbeat(robot.heartbeat_state(), HeartbeatState::Inactive)?;
{
// Random session id, 1 s interval.
let session = robot.heartbeat().start()?;
info!("Heartbeat state: {:?}", session.state());
check_heartbeat(session.state(), HeartbeatState::Alive)?;
// Move while the heartbeat is active.
robot
.move_ptp(poses::home())
.move_ptp(poses::vertical())
.start()?
.join()?;
}
check_heartbeat(robot.heartbeat_state(), HeartbeatState::Inactive)?;
info!("Heartbeat session complete.");
Ok(())
}
Builder options:
session_id(impl Into<HeartbeatSessionId>): use this id instead of a random one; a plaini32converts. An id can be reused once its previous session stopped or died.interval(Duration): the timeout after which the control assumes the heartbeat is dead. DefaultDEFAULT_HEARTBEAT_INTERVAL(1 s). The control counts whole milliseconds, andstart()rejects an interval below 1 ms withRobotArmError::InvalidArgument.unique(): require this to be the only session on the control.kill_on_panic(PanicScope): which panics kill this session.OwnThread(default) is a panic that unwinds through the session’s guard;Processis a panic on any thread (see below).
When the program needs to know that the control confirmed the stop, call stop() on the guard instead of letting
it drop. It consumes the guard and returns the control’s answer. A session that was already killed (see
Reporting a Fault Yourself) is only cleared locally and stop() returns Ok(())
although the tracker stays armed; check session.state() first when that matters:
let session = robot.heartbeat().start()?;
robot.move_ptp(poses::home()).start()?.join()?;
session.stop()?;
When no scope fits, for example an HMI that toggles the heartbeat or a session that must keep running while the
robot is disabled and enabled again after an error, call detach() on the guard. The session then runs until
stop_heartbeat() is called, without the guard’s stop on drop and kill on panic:
fn detached_unique_heartbeat(robot: &(impl Heartbeat + MovePtp)) -> Result<(), ExampleError> {
info!("--- Detached unique heartbeat ---");
robot
.heartbeat()
.unique()
.session_id(42)
.interval(Duration::from_millis(500))
.start()?
.detach();
robot.move_ptp(poses::home()).start()?.join()?;
robot.stop_heartbeat()?;
check_heartbeat(robot.heartbeat_state(), HeartbeatState::Inactive)?;
Ok(())
}
Only one session of this library can be active per robot; a second start() fails with RobotArmError::NotAllowed.
Heartbeat States
session.state() and robot.heartbeat_state() report one of:
State |
Description |
|---|---|
|
No session is active. |
|
The session is running and beats are being sent. |
|
The session was killed. Beats are no longer sent; the robot control times out into an error. |
While a session is Dead, new instructions are rejected with RobotArmError::NotAllowed (“Heartbeat is dead”). To
recover:
Reset the robot error with
VorausErrorHandler::reset_error()(see Resetting Errors).Drop the dead guard, or call
stop_heartbeat()for a detached session.Start a new session if desired, and
enable()the robot again.
Step 2 clears Dead locally at once, but it does not remove the tracker: the control still faults when the interval
expires. Do not start a new session, in particular one with the same id, before that error was reset in step 1.
What the Heartbeat Detects
The robot control only knows whether beats arrive. The library stops sending them in these situations; anything else has to be reported by the application.
Detected without any application code |
Not detected |
|---|---|
The process ends in any way: killed, crashed, out of memory, |
A thread that ends by returning an |
The network connection to the robot control is lost |
An application thread that hangs while the worker loop is healthy |
The library’s worker loop fails |
A panic on another thread, with |
A panic that unwinds through the session’s guard |
|
A panic on any thread, with |
|
An explicit kill (below) |
Reporting a Fault Yourself
Rust applications usually already supervise their threads: a JoinHandle::join() that returns Err, a
std::thread::scope that unwinds, a tokio JoinError, a watchdog that notices a stalled queue. From that place,
kill the heartbeat:
robot.kill_heartbeat()kills the session of one robot.HeartbeatKiller::kill()kills every active session of the process and needs no robot in scope.
Killing does not remove the tracker from the robot control. The beats simply stop, the control times out, and
the robot enters the error state. The session reads Dead:
fn killed_heartbeat(robot: &(impl Heartbeat + MovePtp)) -> Result<(), ExampleError> {
info!("--- Killed heartbeat ---");
let session = robot.heartbeat().start()?;
// Simulate a fault detected by the application's own supervision.
robot.kill_heartbeat();
check_heartbeat(session.state(), HeartbeatState::Dead)?;
if let Err(e) = robot.move_ptp(poses::home()).start() {
info!("Expected: no instructions while the heartbeat is dead: {e}");
} else {
info!("Instruction accepted (unexpected).");
}
// Dropping the dead session cleans up locally; the robot error itself is
// reset through the error handler (below).
drop(session);
Ok(())
}
/// Recovering from a dead heartbeat.
///
/// The control notices the missing beats only once the interval expires, so
/// wait for the error state first: a reset sent earlier finds nothing to
/// reset. After the reset the robot is enabled again, and a new session can
/// be started.
fn recover_from_dead_heartbeat(
robot: &mut (impl Lifecycle + GetRobotState),
error_handler: &VorausErrorHandler,
) -> Result<(), ExampleError> {
let start = Instant::now();
while robot.robot_state()? != RobotState::Error {
if start.elapsed() >= ERROR_STATE_TIMEOUT {
return Err(ExampleError::ErrorStateNotReached(ERROR_STATE_TIMEOUT));
}
sleep(Duration::from_millis(100));
}
error_handler.reset_error(&*robot)?;
robot.enable()?;
Ok(())
}
The guard behaves the same way when it is dropped while its thread is panicking: it kills the session instead of stopping it gracefully. A dead man’s switch must not be disarmed by the fault it exists to catch.
Opt-in Panic Monitoring
kill_on_panic(PanicScope::Process) installs a process-wide panic hook, once, that kills every session with that
scope before chaining to the previously installed hook. Sessions with the default OwnThread scope keep beating, so
two robots that work independently in one process are not stopped by a panic in each other’s threads; give both the
Process scope when they must stop together. The hook is not installed by default because it only sees panics, and
because a library should not silently own the process’s panic policy. If your application installs its own panic hook
after a session started, it replaces this one; call HeartbeatKiller::kill(), which kills every session, from your
hook.
fn heartbeat_killed_by_panic(robot: &impl Heartbeat) -> Result<(), ExampleError> {
info!("--- Heartbeat killed by a panicking thread ---");
let session = robot
.heartbeat()
.kill_on_panic(PanicScope::Process)
.start()?;
check_heartbeat(session.state(), HeartbeatState::Alive)?;
// A worker thread of the application dies with a panic. The message it
// prints below comes from the default panic reporting, which stays in
// place because the heartbeat hook chains to it.
let worker = spawn(panicking_worker);
if worker.join().is_ok() {
return Err(ExampleError::WorkerDidNotPanic);
}
check_heartbeat(session.state(), HeartbeatState::Dead)?;
info!("Heartbeat state after the panic: {:?}", session.state());
drop(session);
Ok(())
}
/// A worker thread of the application that dies with a panic.
#[expect(
clippy::panic,
reason = "the panic is the fault that the panic monitoring example shows"
)]
fn panicking_worker() {
panic!("simulated fault in an application thread");
}
Full Example
Heartbeat example
//! Heartbeat examples.
//!
//! Demonstrates the heartbeat functionality: starting a heartbeat session,
//! moving while the heartbeat is active, and stopping it.
//!
//! Also shows the two ways an application reports a fault: killing the
//! session explicitly, and the process-wide panic scope that kills a
//! session when any thread panics. Both make the robot control time out into an
//! error state.
mod common;
use std::thread::{sleep, spawn};
use std::time::{Duration, Instant};
use common::poses;
use thiserror::Error;
use tracing::info;
use voraus_robot_arm::prelude::*;
/// How long the robot control may take to time out into the error state.
const ERROR_STATE_TIMEOUT: Duration = Duration::from_secs(10);
/// The errors of this example.
#[derive(Debug, Error)]
enum ExampleError {
/// A call to the robot failed.
#[error(transparent)]
Robot(#[from] RobotArmError),
/// The heartbeat is not in the state that the step expects.
#[error("the heartbeat is {actual:?}, expected {expected:?}")]
UnexpectedHeartbeatState {
/// The state the step expects.
expected: HeartbeatState,
/// The state the heartbeat reports.
actual: HeartbeatState,
},
/// The robot control did not time out into the error state.
#[error("the robot control did not reach the error state within {0:?}")]
ErrorStateNotReached(Duration),
/// The worker thread returned, where the example expects it to panic.
#[error("the worker thread did not panic")]
WorkerDidNotPanic,
}
/// Check that the heartbeat is in the expected state.
///
/// # Errors
///
/// Returns [`ExampleError::UnexpectedHeartbeatState`] for any other state.
fn check_heartbeat(actual: HeartbeatState, expected: HeartbeatState) -> Result<(), ExampleError> {
if actual == expected {
Ok(())
} else {
Err(ExampleError::UnexpectedHeartbeatState { expected, actual })
}
}
/// Scoped heartbeat session: the safe default.
///
/// The session guard borrows the robot and stops the heartbeat gracefully
/// when it is dropped.
fn scoped_heartbeat(robot: &(impl Heartbeat + MovePtp)) -> Result<(), ExampleError> {
info!("--- Scoped heartbeat ---");
check_heartbeat(robot.heartbeat_state(), HeartbeatState::Inactive)?;
{
// Random session id, 1 s interval.
let session = robot.heartbeat().start()?;
info!("Heartbeat state: {:?}", session.state());
check_heartbeat(session.state(), HeartbeatState::Alive)?;
// Move while the heartbeat is active.
robot
.move_ptp(poses::home())
.move_ptp(poses::vertical())
.start()?
.join()?;
}
check_heartbeat(robot.heartbeat_state(), HeartbeatState::Inactive)?;
info!("Heartbeat session complete.");
Ok(())
}
/// Stop the session explicitly and learn whether the control confirmed it.
///
/// Dropping the guard stops too, but can only log a failure.
fn stopped_heartbeat(robot: &(impl Heartbeat + MovePtp)) -> Result<(), ExampleError> {
info!("--- Explicitly stopped heartbeat ---");
let session = robot.heartbeat().start()?;
robot.move_ptp(poses::home()).start()?.join()?;
session.stop()?;
check_heartbeat(robot.heartbeat_state(), HeartbeatState::Inactive)?;
Ok(())
}
/// A unique session with explicit options, kept alive beyond its scope.
///
/// `detach()` consumes the guard and leaves the heartbeat running until
/// `stop_heartbeat()` is called. Use it when no scope fits, for
/// example an HMI that toggles the heartbeat.
fn detached_unique_heartbeat(robot: &(impl Heartbeat + MovePtp)) -> Result<(), ExampleError> {
info!("--- Detached unique heartbeat ---");
robot
.heartbeat()
.unique()
.session_id(42)
.interval(Duration::from_millis(500))
.start()?
.detach();
robot.move_ptp(poses::home()).start()?.join()?;
robot.stop_heartbeat()?;
check_heartbeat(robot.heartbeat_state(), HeartbeatState::Inactive)?;
Ok(())
}
/// Killing the heartbeat from application monitoring.
///
/// The library sees process death, network loss and its own worker-loop
/// failures by itself. Faults inside the application are reported by the
/// application: `kill_heartbeat()` kills this robot's session, and
/// `HeartbeatKiller::kill()` kills every session of the process without a
/// robot in scope. After the interval expires the robot control transitions
/// to an error state. The session reads `Dead` until it is stopped or its
/// guard dropped, which clears it locally but leaves the tracker armed.
fn killed_heartbeat(robot: &(impl Heartbeat + MovePtp)) -> Result<(), ExampleError> {
info!("--- Killed heartbeat ---");
let session = robot.heartbeat().start()?;
// Simulate a fault detected by the application's own supervision.
robot.kill_heartbeat();
check_heartbeat(session.state(), HeartbeatState::Dead)?;
if let Err(e) = robot.move_ptp(poses::home()).start() {
info!("Expected: no instructions while the heartbeat is dead: {e}");
} else {
info!("Instruction accepted (unexpected).");
}
// Dropping the dead session cleans up locally; the robot error itself is
// reset through the error handler (below).
drop(session);
Ok(())
}
/// Recovering from a dead heartbeat.
///
/// The control notices the missing beats only once the interval expires, so
/// wait for the error state first: a reset sent earlier finds nothing to
/// reset. After the reset the robot is enabled again, and a new session can
/// be started.
fn recover_from_dead_heartbeat(
robot: &mut (impl Lifecycle + GetRobotState),
error_handler: &VorausErrorHandler,
) -> Result<(), ExampleError> {
let start = Instant::now();
while robot.robot_state()? != RobotState::Error {
if start.elapsed() >= ERROR_STATE_TIMEOUT {
return Err(ExampleError::ErrorStateNotReached(ERROR_STATE_TIMEOUT));
}
sleep(Duration::from_millis(100));
}
error_handler.reset_error(&*robot)?;
robot.enable()?;
Ok(())
}
/// Opt-in panic monitoring.
///
/// `kill_on_panic(PanicScope::Process)` makes a panic on any thread kill
/// this session; the hook behind it is process-wide and installed once. It only sees panics: a thread that
/// returns an error is not detected. `std::process::exit` needs no hook, the
/// beats stop with the process.
fn heartbeat_killed_by_panic(robot: &impl Heartbeat) -> Result<(), ExampleError> {
info!("--- Heartbeat killed by a panicking thread ---");
let session = robot
.heartbeat()
.kill_on_panic(PanicScope::Process)
.start()?;
check_heartbeat(session.state(), HeartbeatState::Alive)?;
// A worker thread of the application dies with a panic. The message it
// prints below comes from the default panic reporting, which stays in
// place because the heartbeat hook chains to it.
let worker = spawn(panicking_worker);
if worker.join().is_ok() {
return Err(ExampleError::WorkerDidNotPanic);
}
check_heartbeat(session.state(), HeartbeatState::Dead)?;
info!("Heartbeat state after the panic: {:?}", session.state());
drop(session);
Ok(())
}
/// A worker thread of the application that dies with a panic.
#[expect(
clippy::panic,
reason = "the panic is the fault that the panic monitoring example shows"
)]
fn panicking_worker() {
panic!("simulated fault in an application thread");
}
fn main() -> Result<(), ExampleError> {
common::init_logging();
let mut robot = common::create_connected_robot()?;
let mut error_handler = VorausErrorHandler::new();
error_handler.set_command_timeout(Duration::from_secs(10));
error_handler.connect(&common::robot_host(), common::error_handler_port())?;
scoped_heartbeat(&robot)?;
stopped_heartbeat(&robot)?;
detached_unique_heartbeat(&robot)?;
killed_heartbeat(&robot)?;
recover_from_dead_heartbeat(&mut robot, &error_handler)?;
heartbeat_killed_by_panic(&robot)?;
// Leave the robot usable for whatever runs next.
recover_from_dead_heartbeat(&mut robot, &error_handler)?;
info!("Done.");
Ok(())
}
Trait Definition
Heartbeat
pub trait Heartbeat {
/// Start a heartbeat session.
///
/// Only one session of this library can be active per robot. A unique
/// session is rejected by the control while any other session is
/// active, and any new session is rejected while a unique one is
/// active; both put the control into an error state.
///
/// # Arguments
///
/// * `config` - The session configuration (id, interval, uniqueness, panic monitoring).
///
/// # Returns
///
/// The token of the started session. It carries the session id in use
/// and identifies this start to the id-checked stop and kill.
///
/// # Errors
///
/// Returns [`RobotArmError::NotAllowed`](crate::error::RobotArmError::NotAllowed)
/// if a session is already active,
/// [`RobotArmError::InvalidArgument`](crate::error::RobotArmError::InvalidArgument)
/// if the interval is shorter than 1 ms or does not fit the control's
/// millisecond field,
/// [`RobotArmError::CommunicationEnded`](crate::error::RobotArmError::CommunicationEnded)
/// if communication with the robot arm ended, or the error the control
/// rejected the session with.
#[doc(hidden)]
fn start_heartbeat(&self, config: HeartbeatConfig) -> Result<HeartbeatSessionToken>;
/// Stop the active heartbeat session gracefully.
///
/// A dead session is only cleared locally: its tracker stays on the
/// control, which still faults when the interval expires.
///
/// # Returns
///
/// `Ok(())` once the control removed the tracker, or once a dead
/// session was cleared locally.
///
/// # Errors
///
/// Returns [`RobotArmError::NotAllowed`](crate::error::RobotArmError::NotAllowed)
/// if no session is active, or the error the control rejected the
/// removal of the tracker with; the session is cleared locally either
/// way.
fn stop_heartbeat(&self) -> Result<()>;
/// Kill the active heartbeat session because the application detected a
/// fault.
///
/// The session turns `Dead`: the library stops sending beats but leaves
/// the tracker registered, so the robot control times out into an error
/// state and stops the robot. New instructions are rejected until the
/// dead session is stopped or its guard dropped. That clears the session
/// locally without removing the tracker, so the control still faults;
/// reset that error before starting a new session. A no-op when no
/// session is active or it is already dead.
///
/// # Returns
///
/// Nothing; the outcome is observable through
/// [`heartbeat_state`](Self::heartbeat_state).
fn kill_heartbeat(&self);
/// Stop the heartbeat session gracefully if it is the one `token` was
/// issued for.
///
/// The checked form of
/// [`stop_heartbeat`](Self::stop_heartbeat) that
/// [`HeartbeatSession`] uses: the check and the stop are one step, so a
/// session started after the token's session ended is left alone, also
/// one that reuses its id.
///
/// # Arguments
///
/// * `token` - The token of the session to stop.
///
/// # Returns
///
/// As [`stop_heartbeat`](Self::stop_heartbeat).
///
/// # Errors
///
/// Returns [`RobotArmError::NotAllowed`](crate::error::RobotArmError::NotAllowed)
/// if the token's session is not active, or the error the control
/// rejected the stop with.
#[doc(hidden)]
fn stop_heartbeat_if(&self, token: HeartbeatSessionToken) -> Result<()>;
/// Kill the heartbeat session if it is the one `token` was issued for.
///
/// The checked form of
/// [`kill_heartbeat`](Self::kill_heartbeat) that
/// [`HeartbeatSession`] uses; a no-op for any other session.
///
/// # Arguments
///
/// * `token` - The token of the session to kill.
///
/// # Returns
///
/// `Ok(())` once the token's session is dead, also when it was dead
/// before.
///
/// # Errors
///
/// Returns [`RobotArmError::NotAllowed`](crate::error::RobotArmError::NotAllowed)
/// if the token's session is not active.
#[doc(hidden)]
fn kill_heartbeat_if(&self, token: HeartbeatSessionToken) -> Result<()>;
/// The state of the heartbeat session.
///
/// # Returns
///
/// `Alive` while a session is running, `Dead` after a detected fault,
/// and `Inactive` when no session was started.
fn heartbeat_state(&self) -> HeartbeatState;
/// The id of the active heartbeat session.
///
/// # Returns
///
/// The session id while a session is alive or dead, `None` when no
/// session was started or it was stopped.
fn heartbeat_session_id(&self) -> Option<HeartbeatSessionId>;
/// Configure and start a heartbeat session.
///
/// Defaults: a random session id, [`DEFAULT_HEARTBEAT_INTERVAL`], not
/// unique, and [`PanicScope::OwnThread`]. Change them with
/// [`session_id`](HeartbeatBuilder::session_id),
/// [`interval`](HeartbeatBuilder::interval),
/// [`unique`](HeartbeatBuilder::unique) and
/// [`kill_on_panic`](HeartbeatBuilder::kill_on_panic), then call
/// [`start`](HeartbeatBuilder::start). The returned
/// [`HeartbeatSession`] stops the session when it is dropped. Only one
/// session of this library can be active per robot.
///
/// # Example
///
/// ```rust
/// # use std::time::Duration;
/// # use voraus_robot_arm::prelude::*;
/// # fn demo(robot: &(impl Heartbeat + MovePtp), home: JointPose) -> Result<()> {
/// {
/// let _session = robot.heartbeat().start()?;
/// robot.move_ptp(home).start()?.join()?;
/// } // session stopped here
///
/// // Stop explicitly to learn whether the control confirmed it.
/// let session = robot.heartbeat().start()?;
/// robot.move_ptp(home).start()?.join()?;
/// session.stop()?;
///
/// robot.heartbeat().unique().interval(Duration::from_millis(500)).start()?.detach();
///
/// // Kill all sessions of the process when any thread panics.
/// let _session = robot.heartbeat().kill_on_panic(PanicScope::Process).start()?;
/// # Ok(())
/// # }
/// ```
///
/// # Returns
///
/// A builder with the default configuration; call `start()` to begin the session.
fn heartbeat(&self) -> HeartbeatBuilder<'_, Self>
where
Self: Sized,
{
HeartbeatBuilder {
robot: self,
config: HeartbeatConfig::default(),
}
}
}