Actions API

HORUS actions model long-running tasks with real-time feedback and cancellation. Define an action with the action! macro, build a server with ActionServerBuilder, and send goals with ActionClientNode or SyncActionClient.

Python: Actions are Rust-only. Python bindings are not yet available.

When to use Actions

  • Long-running tasks that take seconds to minutes (navigation, arm motion, calibration)
  • Tasks that need progress feedback (distance remaining, percentage complete)
  • Tasks that need cancellation or preemption by higher-priority goals

When NOT to use Actions

  • Quick lookups that complete in milliseconds — use Services instead
  • Continuous data streams — use Topics instead
  • Fire-and-forget commands — use Topics instead

Defining an Action

use horus::prelude::*;

action! {
    /// Navigate to a target pose.
    NavigateToPose {
        goal {
            x: f64,
            y: f64,
            theta: f64,
        }
        feedback {
            distance_remaining: f64,
            estimated_time_sec: f64,
        }
        result {
            success: bool,
            final_x: f64,
            final_y: f64,
        }
    }
}

This generates four types:

  • NavigateToPoseGoal — goal struct
  • NavigateToPoseFeedback — feedback struct
  • NavigateToPoseResult — result struct
  • NavigateToPose — zero-sized marker implementing the Action trait

Action Trait

MethodReturnsDescription
name()&'static strAction name (used as topic prefix)
goal_topic()String"{name}.goal"
cancel_topic()String"{name}.cancel"
result_topic()String"{name}.result"
feedback_topic()String"{name}.feedback"
status_topic()String"{name}.status"

GoalStatus

Lifecycle of a goal:

VariantTerminal?Description
PendingNoReceived but not yet processed
ActiveNoCurrently executing
SucceededYesCompleted successfully
AbortedYesFailed due to server error
CanceledYesCanceled by client request
PreemptedYesPreempted by higher-priority goal
RejectedYesValidation failed at acceptance
MethodReturnsDescription
is_active()boolPending or Active
is_terminal()boolReached a final state
is_success()boolSucceeded
is_failure()boolAborted, Canceled, Preempted, or Rejected

GoalPriority

ConstantValueDescription
HIGHEST0Critical goals
HIGH64Above normal
NORMAL128Default priority
LOW192Below normal
LOWEST255Background tasks

Lower value = higher priority. is_higher_than(other) compares priorities.


PreemptionPolicy

Controls what happens when a new goal arrives while one is active:

VariantDescription
RejectNewNew goals rejected while one is active
PreemptOldNew goals cancel active goals (default)
PriorityHigher priority preempts lower priority
Queue { max_size }Queue goals up to max_size

GoalResponse / CancelResponse

Server returns these from goal acceptance and cancel callbacks:

TypeVariantsMethods
GoalResponseAccept, Reject(String)is_accepted(), is_rejected(), rejection_reason()
CancelResponseAccept, Reject(String)is_accepted(), is_rejected(), rejection_reason()

ActionServerConfig

FieldTypeDefaultDescription
max_concurrent_goalsOption<usize>Some(1)Max simultaneous goals (None = unlimited)
feedback_rate_hzf6410.0Rate limit for feedback publishing
goal_timeoutOption<Duration>NoneAuto-abort after timeout
preemption_policyPreemptionPolicyPreemptOldHow to handle competing goals
result_history_sizeusize100How many results to keep in history

Builder methods: new(), unlimited_goals(), max_goals(n), feedback_rate(hz), timeout(dur), preemption(policy), history_size(n).


ActionError

VariantDescription
GoalRejected(String)Server rejected the goal
GoalCanceledGoal was canceled
GoalPreemptedGoal was preempted by higher priority
GoalTimeoutGoal timed out
ServerUnavailableNo action server running
CommunicationError(String)Topic I/O failure
ExecutionError(String)Server execution error
InvalidGoal(String)Goal validation failed
GoalNotFound(GoalId)Goal ID not recognized

ActionServerBuilder

MethodReturnsDescription
ActionServerBuilder::<A>::new()SelfCreate a new builder
on_goal(callback)SelfGoal acceptance callback (Fn(&Goal) -> GoalResponse)
on_cancel(callback)SelfCancel request callback (Fn(GoalId) -> CancelResponse)
on_execute(callback)SelfExecution callback (Fn(ServerGoalHandle) -> GoalOutcome)
with_config(config)SelfApply full ActionServerConfig
max_concurrent_goals(max)SelfShorthand for max concurrent goals
feedback_rate(rate_hz)SelfShorthand for feedback rate
goal_timeout(timeout)SelfShorthand for goal timeout
preemption_policy(policy)SelfShorthand for preemption policy
build()ActionServerNode<A>Build the server node

ActionServerNode

Implements Node — add it to a Scheduler to process goals.

MethodReturnsDescription
builder()ActionServerBuilder<A>Create a new builder
metrics()ActionServerMetricsGet server metrics snapshot

ActionServerMetrics

FieldTypeDescription
goals_receivedu64Total goals received
goals_acceptedu64Goals that passed acceptance
goals_rejectedu64Goals rejected by on_goal
goals_succeededu64Successfully completed
goals_abortedu64Aborted by server
goals_canceledu64Canceled by client
goals_preemptedu64Preempted by higher priority
active_goalsusizeCurrently executing
queued_goalsusizeWaiting in queue

ServerGoalHandle

Handle passed to the on_execute callback. Use it to publish feedback and finalize the goal.

Query Methods

MethodReturnsDescription
goal_id()GoalIdUnique goal identifier
goal()&A::GoalThe goal data
priority()GoalPriorityGoal priority level
status()GoalStatusCurrent status
elapsed()DurationTime since execution started
is_cancel_requested()boolClient requested cancellation
is_preempt_requested()boolHigher-priority goal wants to preempt
should_abort()booltrue if canceled or preempted — check this in loops

Action Methods

MethodReturnsDescription
publish_feedback(feedback)()Send feedback to client (rate-limited)
succeed(result)GoalOutcome<A>Complete successfully
abort(result)GoalOutcome<A>Abort with error
canceled(result)GoalOutcome<A>Acknowledge cancellation
preempted(result)GoalOutcome<A>Acknowledge preemption

publish_feedback

pub fn publish_feedback(&self, feedback: A::Feedback)

Send progress feedback to the client. Rate-limited by ActionServerConfig::feedback_rate_hz (default: 10Hz). Extra calls within the rate window are silently dropped.

Example:

handle.publish_feedback(NavFeedback {
    distance_remaining: 3.2,
    progress: 0.65,
});

succeed

pub fn succeed(self, result: A::Result) -> GoalOutcome<A>

Complete the goal successfully. Consumes the handle — no further operations possible.

Example:

return handle.succeed(MoveArmResult { final_position: current_pos });

should_abort

pub fn should_abort(&self) -> bool

Returns true if the client has requested cancellation or a higher-priority goal wants to preempt. Check this in your execute loop:

Example:

loop {
    if handle.should_abort() {
        return handle.canceled(partial_result);
    }
    // ... do work ...
    handle.publish_feedback(progress);
}

GoalOutcome

VariantDescription
Succeeded(A::Result)Goal completed successfully
Aborted(A::Result)Server aborted execution
Canceled(A::Result)Client canceled
Preempted(A::Result)Preempted by higher priority

Methods: status() returns GoalStatus, into_result() extracts the result.


Server Example

use horus::prelude::*;

action! {
    MoveArm {
        goal { target_x: f64, target_y: f64, target_z: f64 }
        feedback { progress: f64, current_x: f64, current_y: f64, current_z: f64 }
        result { success: bool, final_x: f64, final_y: f64, final_z: f64 }
    }
}

let server = ActionServerNode::<MoveArm>::builder()
    .on_goal(|goal| {
        // Validate the goal
        if goal.target_z < 0.0 {
            GoalResponse::Reject("Z must be non-negative".into())
        } else {
            GoalResponse::Accept
        }
    })
    .on_cancel(|_goal_id| CancelResponse::Accept)
    .on_execute(|handle| {
        let goal = handle.goal();
        let mut progress = 0.0;

        while progress < 1.0 {
            // Check for cancellation
            if handle.should_abort() {
                return handle.canceled(MoveArmResult {
                    success: false,
                    final_x: 0.0, final_y: 0.0, final_z: 0.0,
                });
            }

            progress += 0.01;
            handle.publish_feedback(MoveArmFeedback {
                progress,
                current_x: goal.target_x * progress,
                current_y: goal.target_y * progress,
                current_z: goal.target_z * progress,
            });

            std::thread::sleep(10_u64.ms());
        }

        handle.succeed(MoveArmResult {
            success: true,
            final_x: goal.target_x,
            final_y: goal.target_y,
            final_z: goal.target_z,
        })
    })
    .preemption_policy(PreemptionPolicy::Priority)
    .goal_timeout(30_u64.secs())
    .build();

let mut scheduler = Scheduler::new();
scheduler.add(server).order(0).build();

ActionClientBuilder

MethodReturnsDescription
ActionClientBuilder::<A>::new()SelfCreate a new builder
on_feedback(callback)SelfFeedback callback (Fn(GoalId, &Feedback))
on_result(callback)SelfResult callback (Fn(GoalId, GoalStatus, &Result))
on_status(callback)SelfStatus change callback (Fn(GoalId, GoalStatus))
build()ActionClientNode<A>Build the client node

ActionClientNode

Implements Node — add it to a Scheduler alongside the server.

MethodReturnsDescription
builder()ActionClientBuilder<A>Create a new builder
send_goal(goal)Result<ClientGoalHandle, ActionError>Send with NORMAL priority
send_goal_with_priority(goal, priority)Result<ClientGoalHandle, ActionError>Send with specific priority
cancel_goal(goal_id)()Request cancellation

send_goal

Sends a goal to the action server with default (NORMAL) priority.

Signature

pub fn send_goal(&self, goal: A::Goal) -> Result<ClientGoalHandle<A>, ActionError>

Parameters

NameTypeRequiredDescription
goalA::GoalyesThe goal data. Type defined by action! macro's goal {} block.

Returns

Result<ClientGoalHandle<A>, ActionError> — a handle to monitor progress, get results, or cancel.

Errors

ErrorCondition
ActionError::ServerUnavailableNo action server registered
ActionError::GoalRejectedServer's on_goal callback rejected the goal

Example

use horus::prelude::*;

let handle = client.send_goal(NavigateToPoseGoal { x: 1.0, y: 2.0, theta: 0.0 })?;
// Monitor with handle.status(), handle.result(), handle.last_feedback()

send_goal_with_priority

Sends a goal with explicit priority. Higher priority goals can preempt lower ones.

Signature

pub fn send_goal_with_priority(&self, goal: A::Goal, priority: GoalPriority) -> Result<ClientGoalHandle<A>, ActionError>

Parameters

NameTypeRequiredDescription
goalA::GoalyesThe goal data.
priorityGoalPriorityyesGoalPriority::LOW, NORMAL, HIGH, or HIGHEST.

Behavior

If the server's PreemptionPolicy allows it, a higher-priority goal will preempt the current executing goal. The preempted goal's is_preempt_requested() returns true. | goal_status(goal_id) | Option<GoalStatus> | Query goal status | | active_goals() | Vec<GoalId> | All active goal IDs | | active_goal_count() | usize | Number of active goals | | metrics() | ActionClientMetrics | Get client metrics |

ActionClientMetrics

FieldTypeDescription
goals_sentu64Total goals sent
goals_succeededu64Successfully completed
goals_failedu64Aborted, canceled, preempted, or rejected
cancels_sentu64Cancel requests sent
active_goalsusizeCurrently active

ClientGoalHandle

Handle returned by send_goal(). Use it to monitor progress and get results.

Query Methods

MethodReturnsDescription
goal_id()GoalIdUnique goal identifier
priority()GoalPriorityGoal priority level
status()GoalStatusCurrent status
is_active()boolStill executing
is_done()boolReached terminal state
is_success()boolCompleted successfully
elapsed()DurationTime since goal was sent
time_since_update()DurationTime since last status change
result()Option<A::Result>Get result if completed
last_feedback()Option<A::Feedback>Most recent feedback

Blocking Methods

MethodReturnsDescription
await_result(timeout)Option<A::Result>Block until result or timeout
await_result_with_feedback(timeout, callback)Result<A::Result, ActionError>Block with feedback callback
cancel()()Send cancel request to server

SyncActionClient (Blocking)

Standalone blocking client — does not need a Scheduler.

MethodReturnsDescription
SyncActionClient::<A>::new()Result<Self>Create and initialize
send_goal_and_wait(goal, timeout)Result<A::Result, ActionError>Send goal, block until result
send_goal_and_wait_with_feedback(goal, timeout, callback)Result<A::Result, ActionError>Block with feedback
cancel_goal(goal_id)()Request cancellation

Type alias: ActionClient<A> = SyncActionClient<A>

send_goal_and_wait

pub fn send_goal_and_wait(&self, goal: A::Goal, timeout: Duration) -> Result<A::Result, ActionError>

Send a goal and block until the server completes it or timeout elapses. The simplest way to execute an action.

Parameters:

  • goal: A::Goal — The goal to send
  • timeout: Duration — Maximum wait time

Returns: Ok(result) on success, Err(ActionError::GoalTimeout) on timeout, Err(ActionError::GoalRejected) if server rejects.

Example:

let result = client.send_goal_and_wait(
    MoveArmGoal { target: [0.5, 0.3, 0.1], speed: 0.5 },
    10_u64.secs(),
)?;
println!("Arm reached: {:?}", result.final_position);

send_goal_and_wait_with_feedback

pub fn send_goal_and_wait_with_feedback(
    &self, goal: A::Goal, timeout: Duration,
    feedback_cb: impl Fn(GoalId, &A::Feedback),
) -> Result<A::Result, ActionError>

Like send_goal_and_wait, but calls feedback_cb whenever the server publishes feedback.

Example:

let result = client.send_goal_and_wait_with_feedback(
    nav_goal,
    30_u64.secs(),
    |_id, feedback| {
        println!("Progress: {:.0}%, distance: {:.1}m",
            feedback.progress * 100.0, feedback.distance_remaining);
    },
)?;

Example

use horus::prelude::*;

// Simple blocking usage (no scheduler needed)
let client = SyncActionClient::<MoveArm>::new()?;

let result = client.send_goal_and_wait_with_feedback(
    MoveArmGoal { target_x: 1.0, target_y: 2.0, target_z: 0.5 },
    30_u64.secs(),
    |feedback| {
        println!("Progress: {:.0}%", feedback.progress * 100.0);
    },
)?;

if result.success {
    println!("Arm reached ({:.1}, {:.1}, {:.1})",
        result.final_x, result.final_y, result.final_z);
}

GoalId

Unique identifier for each goal (Uuid-backed).

MethodReturnsDescription
GoalId::new()SelfGenerate a new unique ID
GoalId::from_uuid(uuid)SelfCreate from existing UUID
as_uuid()&UuidGet underlying UUID

Wire Types

Advanced: These types carry action data through topics internally. Most users interact with ServerGoalHandle and ClientGoalHandle instead.

TypeFieldsDescription
GoalRequest<G>goal_id, goal, priority, timestampSent from client to server when a goal is submitted
CancelRequestgoal_id, timestampSent from client to server to cancel a goal
ActionResult<R>goal_id, status, result, timestampSent from server to client with the final result
ActionFeedback<F>goal_id, feedback, timestampSent from server to client during execution
GoalStatusUpdategoal_id, status, timestampStatus change notification

ActionResult<R> (the struct) has convenience constructors:

ActionResult::succeeded(goal_id, result)
ActionResult::aborted(goal_id, result)
ActionResult::canceled(goal_id, result)
ActionResult::preempted(goal_id, result)

Callback Types

Type aliases for the callback signatures used by builders:

// Server callbacks (passed to ActionServerBuilder)
type GoalCallback<A>    = Box<dyn Fn(&<A as Action>::Goal) -> GoalResponse + Send + Sync>;
type CancelCallback     = Box<dyn Fn(GoalId) -> CancelResponse + Send + Sync>;
type ExecuteCallback<A> = Box<dyn Fn(ServerGoalHandle<A>) -> GoalOutcome<A> + Send + Sync>;

// Client callbacks (passed to ActionClientBuilder)
type FeedbackCallback<A> = Box<dyn Fn(GoalId, &<A as Action>::Feedback) + Send + Sync>;
type ResultCallback<A>   = Box<dyn Fn(GoalId, GoalStatus, &<A as Action>::Result) + Send + Sync>;
type StatusCallback      = Box<dyn Fn(GoalId, GoalStatus) + Send + Sync>;

You never construct these directly — they're inferred from the closures you pass to .on_goal(), .on_execute(), .on_feedback(), etc.


See Also