Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Lifecycle Nodes

Lifecycle nodes give you explicit control over when a node starts communicating, making bringup and shutdown of complex systems safe and predictable.

Tip

Use lifecycle nodes whenever a node needs external resources (hardware drivers, network connections, configuration files) that must be ready before it starts publishing. They are the standard ROS 2 pattern for production system orchestration.

State Machine

A lifecycle node moves through a defined set of states. Only in the Active state does a node send or receive messages.

stateDiagram-v2
    direction LR

    [*] --> Unconfigured

    state "Unconfigured" as Unconfigured
    state "Inactive" as Inactive
    state "Active<br>(publishing)" as Active
    state "Finalized" as Finalized

    Unconfigured --> Inactive    : configure
    Inactive     --> Active      : activate
    Active       --> Inactive    : deactivate
    Inactive     --> Unconfigured: cleanup

    Unconfigured --> Finalized   : shutdown
    Inactive     --> Finalized   : shutdown
    Active       --> Finalized   : shutdown
    Finalized    --> [*]

Primary States

StateIDDescription
Unconfigured1Initial state. Node exists but has not loaded configuration.
Inactive2Configured but not communicating. Publishers drop messages.
Active3Fully operational. Publishers deliver messages.
Finalized4Terminal state. Node is destroyed.

Transitions

MethodFromTo (success)
configure()UnconfiguredInactive
activate()InactiveActive
deactivate()ActiveInactive
cleanup()InactiveUnconfigured
shutdown()Any primaryFinalized

Creating a Lifecycle Node

use ros_z::lifecycle::{ZLifecycleNode, CallbackReturn, LifecycleState};
use ros_z::prelude::*;

fn main() -> zenoh::Result<()> {
let ctx = ZContextBuilder::default().build()?;
let mut node = ctx.create_lifecycle_node("my_node").build()?;
Ok(())
}

With a Namespace

use ros_z::prelude::*;

fn main() -> zenoh::Result<()> {
let ctx = ZContextBuilder::default().build()?;
let mut node = ctx
    .create_lifecycle_node("my_node")
    .with_namespace("/robot")
    .build()?;
Ok(())
}

Lifecycle Callbacks

Set callbacks on the node before triggering any transitions. Each callback receives the previous state and returns a CallbackReturn.

use ros_z::lifecycle::{ZLifecycleNode, CallbackReturn};
use ros_z::prelude::*;

fn main() -> zenoh::Result<()> {
let ctx = ZContextBuilder::default().build()?;
let mut node = ctx.create_lifecycle_node("talker").build()?;

node.on_configure = Box::new(|_prev| {
    // Load parameters, open files, connect to hardware
    println!("configuring");
    CallbackReturn::Success
});

node.on_activate = Box::new(|_prev| {
    // Start timers, enable hardware
    println!("activating");
    CallbackReturn::Success
});

node.on_deactivate = Box::new(|_prev| {
    // Pause timers, disable hardware outputs
    println!("deactivating");
    CallbackReturn::Success
});

node.on_cleanup = Box::new(|_prev| {
    // Release resources acquired in on_configure
    println!("cleaning up");
    CallbackReturn::Success
});

node.on_shutdown = Box::new(|_prev| {
    CallbackReturn::Success
});
Ok(())
}

Callback Return Values

ValueEffect
CallbackReturn::SuccessTransition completes normally
CallbackReturn::FailureTransition rolls back; state unchanged
CallbackReturn::Erroron_error callback is called

The default on_error returns Failure, which drives the node to Finalized. Override it to recover to Unconfigured instead:

use ros_z::lifecycle::{CallbackReturn};
use ros_z::prelude::*;
fn main() -> zenoh::Result<()> {
let ctx = ZContextBuilder::default().build()?;
let mut node = ctx.create_lifecycle_node("n").build()?;
// Recover to Unconfigured instead of crashing to Finalized
node.on_error = Box::new(|_prev| CallbackReturn::Success);
Ok(())
}

Lifecycle Publishers

A lifecycle publisher wraps a regular publisher and silently drops publish calls while the node is not Active. This makes publishing code clean — no manual state checks needed.

use ros_z::lifecycle::ZLifecycleNode;
use ros_z::prelude::*;
use ros_z_msgs::ros::std_msgs::String as RosString;

fn main() -> zenoh::Result<()> {
let ctx = ZContextBuilder::default().build()?;
let mut node = ctx.create_lifecycle_node("talker").build()?;
// Create the publisher — it starts deactivated
let pub_ = node.create_publisher::<RosString>("chatter")?;

node.configure()?;
node.activate()?;
// Publisher is now active — messages are delivered
pub_.publish(&RosString { data: "hello".to_string() })?;

node.deactivate()?;
// Publisher is deactivated — publish() returns Ok(()) but drops the message
pub_.publish(&RosString { data: "dropped".to_string() })?;
Ok(())
}

All publishers created with create_publisher are registered as managed entities: they activate and deactivate automatically when the node transitions.

Note

Publishers created after activate() are immediately activated. You can safely create publishers at any point in the node's lifetime.

Triggering Transitions

Transitions can be triggered programmatically or via the lifecycle management services.

Programmatic

use ros_z::prelude::*;
fn main() -> zenoh::Result<()> {
let ctx = ZContextBuilder::default().build()?;
let mut node = ctx.create_lifecycle_node("n").build()?;
let state = node.configure()?;   // → Inactive
let state = node.activate()?;    // → Active
let state = node.deactivate()?;  // → Inactive
let state = node.cleanup()?;     // → Unconfigured
let state = node.shutdown()?;    // → Finalized
Ok(())
}

Via ROS 2 CLI

Every lifecycle node automatically exposes five management services under its node name:

# Inspect current state
ros2 lifecycle get /my_node

# List available transitions
ros2 lifecycle list /my_node

# Trigger a transition
ros2 lifecycle set /my_node configure
ros2 lifecycle set /my_node activate

Services Exposed

ServiceDescription
~/change_stateTrigger a transition by ID or label
~/get_stateQuery the current state
~/get_available_statesList all states in the machine
~/get_available_transitionsList transitions valid from the current state
~/get_transition_graphList all transitions in the full graph

The ~/transition_event topic publishes a TransitionEvent message on every state change, enabling external monitoring.

Lifecycle Client

ZLifecycleClient lets you drive a remote lifecycle node's state machine from Rust code — the building block for bringup managers and system orchestrators.

use std::time::Duration;
use ros_z::prelude::*;
use ros_z::lifecycle::ZLifecycleClient;

async fn run() -> zenoh::Result<()> {
let ctx = ZContextBuilder::default().build()?;
let mgr = ctx.create_node("bringup_manager").build()?;

// Connect to the lifecycle node's management services
let client = ZLifecycleClient::new(&mgr, "camera_driver")?;
let timeout = Duration::from_secs(5);

// Drive the node through its lifecycle
client.configure(timeout).await?;
client.activate(timeout).await?;

// Query the current state at any time
let state = client.get_state(timeout).await?;
println!("camera_driver is {:?}", state);

// Graceful shutdown (auto-detects the current state)
client.shutdown(timeout).await?;
Ok(())
}

Tip

shutdown() queries the node's current state and sends the correct shutdown transition automatically. You don't need to deactivate or cleanup first.

Full Example: Managed Talker

use ros_z::{Builder, Result, context::ZContextBuilder, lifecycle::CallbackReturn};
use ros_z_msgs::std_msgs::String as RosString;

fn main() -> Result<()> {
    // Build a Zenoh context and create a lifecycle node.
    // The node starts in the Unconfigured state.
    let ctx = ZContextBuilder::default().build()?;
    let mut node = ctx.create_lifecycle_node("lifecycle_talker").build()?;

    // Register callbacks for each lifecycle transition.
    // Each callback receives the previous state and must return
    // CallbackReturn::Success, ::Failure, or ::Error.
    node.on_configure = Box::new(|_prev| {
        // Load parameters, open files, connect to hardware here.
        println!("[configure] loading parameters");
        CallbackReturn::Success
    });

    node.on_activate = Box::new(|_prev| {
        // Start timers, enable hardware outputs here.
        println!("[activate] publisher enabled");
        CallbackReturn::Success
    });

    node.on_deactivate = Box::new(|_prev| {
        // Pause timers, disable hardware outputs here.
        println!("[deactivate] publisher paused");
        CallbackReturn::Success
    });

    node.on_cleanup = Box::new(|_prev| {
        // Release resources acquired in on_configure here.
        println!("[cleanup] releasing resources");
        CallbackReturn::Success
    });

    // Create a lifecycle-gated publisher.
    // It is registered as a managed entity: activate()/deactivate() on the
    // node will automatically gate this publisher. While deactivated,
    // publish() returns Ok(()) but silently drops the message.
    let pub_ = node.create_publisher::<RosString>("chatter")?;

    // configure(): Unconfigured → Inactive
    // on_configure callback fires; publisher remains deactivated.
    node.configure()?;

    // This publish is silently dropped — the node is Inactive.
    pub_.publish(&RosString {
        data: "dropped (inactive)".to_string(),
    })?;

    // activate(): Inactive → Active
    // on_activate callback fires; publisher is now live.
    node.activate()?;

    // Messages are delivered while the node is Active.
    for i in 0..5 {
        let msg = RosString {
            data: format!("hello {i}"),
        };
        pub_.publish(&msg)?;
        println!("published: {}", msg.data);
    }

    // deactivate(): Active → Inactive — publisher is gated again.
    node.deactivate()?;
    // cleanup(): Inactive → Unconfigured — release resources.
    node.cleanup()?;
    // shutdown(): Unconfigured → Finalized — terminal state.
    node.shutdown()?;

    println!("node finalized");
    Ok(())
}

Full Example: Bringup Orchestrator

use std::time::Duration;

use ros_z::{
    Builder, Result,
    context::ZContextBuilder,
    lifecycle::{CallbackReturn, ZLifecycleClient},
};

fn main() -> Result<()> {
    // Shared Zenoh context — both the lifecycle node and the client connect
    // through the same context (in production they'd typically be separate
    // processes connected via a Zenoh router).
    let ctx = ZContextBuilder::default().build()?;

    // --- Lifecycle node (simulates a remote node) ---
    let mut node = ctx.create_lifecycle_node("camera_driver").build()?;

    node.on_configure = Box::new(|_| {
        println!("[camera_driver] on_configure: opening device");
        CallbackReturn::Success
    });
    node.on_activate = Box::new(|_| {
        println!("[camera_driver] on_activate: streaming");
        CallbackReturn::Success
    });
    node.on_deactivate = Box::new(|_| {
        println!("[camera_driver] on_deactivate: paused");
        CallbackReturn::Success
    });
    node.on_shutdown = Box::new(|_| {
        println!("[camera_driver] on_shutdown: releasing device");
        CallbackReturn::Success
    });

    // Give services a moment to register with Zenoh
    std::thread::sleep(Duration::from_millis(200));

    // --- Bringup manager ---
    let mgr = ctx.create_node("bringup_manager").build()?;
    let client = ZLifecycleClient::new(&mgr, "camera_driver")?;

    // Allow time for service discovery
    std::thread::sleep(Duration::from_millis(300));

    let timeout = Duration::from_secs(5);
    let rt = tokio::runtime::Runtime::new().unwrap();

    rt.block_on(async {
        // Query the initial state
        let state = client.get_state(timeout).await?;
        println!("[bringup] camera_driver is {:?}", state);

        // Drive the node through its lifecycle
        println!("[bringup] configuring...");
        assert!(client.configure(timeout).await?);

        println!("[bringup] activating...");
        assert!(client.activate(timeout).await?);

        let state = client.get_state(timeout).await?;
        println!("[bringup] camera_driver is now {:?}", state);

        // Simulate some work
        println!("[bringup] node is running... (would do work here)");

        // Graceful shutdown
        println!("[bringup] deactivating...");
        assert!(client.deactivate(timeout).await?);

        println!("[bringup] shutting down...");
        assert!(client.shutdown(timeout).await?);

        let state = client.get_state(timeout).await?;
        println!("[bringup] camera_driver is now {:?}", state);

        Ok(())
    })
}