Services and Actions API
HORUS provides two communication patterns beyond pub/sub topics: services for synchronous request/response RPC, and actions for long-running tasks with progress feedback and cancellation. Both use JSON as the wire format over shared memory topics, so they work same-process and cross-process with zero configuration.
Rust: See Services and Actions for the Rust API. Python: Services and actions are available via the Python bindings.
// simplified
#include <horus/service.hpp>
#include <horus/action.hpp>
Quick Reference -- ServiceClient
| Method | Returns | Description |
|---|---|---|
ServiceClient(name) | -- | Construct a client for the named service |
.call(json, timeout) | optional<string> | Send request JSON, wait for response |
operator bool() | bool | Check if the client handle is valid |
Quick Reference -- ServiceServer
| Method | Returns | Description |
|---|---|---|
ServiceServer(name) | -- | Construct a server for the named service |
.set_handler(fn) | void | Set the request handler callback |
operator bool() | bool | Check if the server handle is valid |
Quick Reference -- ActionClient
| Method | Returns | Description |
|---|---|---|
ActionClient(name) | -- | Construct a client for the named action |
.send_goal(json) | GoalHandle | Send a goal and get a handle to track it |
operator bool() | bool | Check if the client handle is valid |
Quick Reference -- ActionServer
| Method | Returns | Description |
|---|---|---|
ActionServer(name) | -- | Construct a server for the named action |
.set_accept_handler(fn) | void | Set the goal acceptance callback |
.set_execute_handler(fn) | void | Set the goal execution callback |
.is_ready() | bool | Check if both handlers are set |
operator bool() | bool | Check if the server handle is valid |
Quick Reference -- GoalHandle
| Method | Returns | Description |
|---|---|---|
.status() | GoalStatus | Current status of the goal |
.id() | uint64_t | Unique goal identifier |
.is_active() | bool | True if Pending or Active |
.cancel() | void | Request cancellation of the goal |
operator bool() | bool | Check if the handle is valid |
Quick Reference -- GoalStatus Enum
| Value | Description |
|---|---|
GoalStatus::Pending | Goal accepted, waiting to start |
GoalStatus::Active | Goal is executing |
GoalStatus::Succeeded | Goal completed successfully |
GoalStatus::Aborted | Goal failed during execution |
GoalStatus::Canceled | Goal was canceled by client |
GoalStatus::Rejected | Goal was rejected by server |
Services -- Request/Response RPC
Services implement a synchronous request/response pattern. A client sends a JSON request and blocks until the server responds or a timeout expires.
ServiceClient
Create a client by name. Call .call() with a JSON string and a timeout:
#include <horus/service.hpp>
#include <chrono>
using namespace std::chrono_literals;
horus::ServiceClient client("add_two_ints");
// Check that the handle was created successfully
if (!client) {
fprintf(stderr, "Failed to create service client\n");
return;
}
// Send request, wait up to 1 second for response
auto response = client.call(R"({"a": 3, "b": 4})", 1000ms);
if (response) {
printf("Response: %s\n", response->c_str());
// Output: {"sum": 7}
} else {
printf("Service call timed out or failed\n");
}
The call() method accepts both const char* and const std::string&:
// String literal
auto r1 = client.call(R"({"x": 1.0})", 500ms);
// std::string
std::string request = R"({"x": 1.0, "y": 2.0})";
auto r2 = client.call(request, 500ms);
ServiceServer
Create a server and set a handler function. The handler receives raw bytes (the JSON request) and writes raw bytes (the JSON response):
#include <horus/service.hpp>
#include <cstring>
#include <cstdio>
horus::ServiceServer server("add_two_ints");
server.set_handler([](const uint8_t* req, size_t req_len,
uint8_t* res, size_t* res_len) -> bool {
// Parse request (in production, use a JSON library)
// For this example, assume req is: {"a": 3, "b": 4}
int a = 3, b = 4; // parsed from req
// Write response
int written = snprintf(reinterpret_cast<char*>(res), 4096,
R"({"sum": %d})", a + b);
*res_len = static_cast<size_t>(written);
return true; // true = success, false = error
});
The handler signature is:
using Handler = bool(*)(const uint8_t* req, size_t req_len,
uint8_t* res, size_t* res_len);
req/req_len: Request payload (JSON bytes)res/res_len: Response buffer (4096 bytes max) -- write your response here- Return
truefor success,falsefor error
Actions -- Long-Running Tasks
Actions handle operations that take time to complete, like navigation or trajectory execution. The client sends a goal and gets a GoalHandle to track progress and request cancellation. The server accepts or rejects goals and executes them asynchronously.
ActionClient
Create a client, send a goal as JSON, and track it via GoalHandle:
#include <horus/action.hpp>
horus::ActionClient client("navigate_to_pose");
if (!client) return;
auto goal = client.send_goal(R"({"target_x": 5.0, "target_y": 3.0})");
if (!goal) return;
printf("Goal %lu submitted\n", goal.id());
// Poll until complete
while (goal.is_active()) { /* do other work or sleep */ }
if (goal.status() == horus::GoalStatus::Succeeded) {
printf("Navigation complete!\n");
}
Canceling a Goal
Call cancel() on the GoalHandle to request cancellation:
auto goal = client.send_goal(R"({"x": 10.0, "y": 0.0})");
// Cancel after 5 seconds if not done
std::this_thread::sleep_for(std::chrono::seconds(5));
if (goal.is_active()) goal.cancel();
GoalHandle Lifecycle
send_goal() --> Pending --> Active --> Succeeded
| |
| +--> Aborted (server error)
| +--> Canceled (client cancel)
|
+--> Rejected (server rejects)
The is_active() method returns true for Pending and Active states, false for all terminal states.
ActionServer
Create a server with two handlers: one to accept/reject goals, one to execute them:
#include <horus/action.hpp>
horus::ActionServer server("navigate_to_pose");
// Accept handler: receives goal data, returns 0 to accept, 1 to reject
server.set_accept_handler([](const uint8_t* goal_data, size_t len) -> uint8_t {
// Parse goal JSON, validate parameters
// Return 0 = accept, 1 = reject
return 0; // accept all goals
});
// Execute handler: receives goal_id and goal data, runs the task
server.set_execute_handler([](uint64_t goal_id,
const uint8_t* goal_data, size_t len) {
printf("Executing goal %lu\n", goal_id);
// Parse goal, execute navigation...
// When done, the goal transitions to Succeeded/Aborted automatically
});
// Verify both handlers are set
if (server.is_ready()) {
printf("Action server ready\n");
}
The handler signatures are:
using AcceptHandler = uint8_t(*)(const uint8_t* goal, size_t len);
using ExecuteHandler = void(*)(uint64_t goal_id, const uint8_t* goal, size_t len);
JSON Wire Transport
Services and actions use JSON as the wire format, serialized into JsonWireMessage Pod structs (4 KB each) transported over SHM topics. This design means:
- No code generation: No
.srvor.actionIDL files. Just send/receive JSON strings - Cross-language: A Rust service server can handle requests from a C++ client (and vice versa)
- Cross-process: Uses the same SHM transport as topics -- works across processes automatically
- Debugging: JSON payloads are human-readable in
horus monitorand BlackBox recordings
Topic Layout
Each service creates two internal topics:
{name}.request -- client sends request here
{name}.response.{client_pid} -- server sends response here (per-client)
Each action creates three internal topics:
{name}.goal -- client sends goals here
{name}.feedback -- server publishes progress here
{name}.result -- server publishes final result here
These are standard HORUS topics -- you can monitor them with horus topic list and horus monitor.
Ownership and Move Semantics
All service and action types are move-only. Copy is deleted:
horus::ServiceClient a("my_svc");
// horus::ServiceClient b = a; // COMPILE ERROR
horus::ServiceClient b = std::move(a); // OK
horus::GoalHandle g = client.send_goal("{}");
// horus::GoalHandle g2 = g; // COMPILE ERROR
horus::GoalHandle g2 = std::move(g); // OK
Resources are released in destructors via the C FFI (horus_*_destroy functions). Use RAII -- do not call destroy manually.
Common Patterns
Service with JSON Library
In production, use a JSON library (nlohmann/json, rapidjson, simdjson) for parsing:
#include <horus/service.hpp>
#include <nlohmann/json.hpp>
using json = nlohmann::json;
horus::ServiceServer server("compute_ik");
server.set_handler([](const uint8_t* req, size_t req_len,
uint8_t* res, size_t* res_len) -> bool {
auto request = json::parse(req, req + req_len);
double x = request["target_x"];
double y = request["target_y"];
double z = request["target_z"];
// Compute inverse kinematics...
json response = {
{"joint_angles", {0.1, 0.5, -0.3, 0.0, 1.2, 0.0}},
{"success", true}
};
std::string resp_str = response.dump();
std::memcpy(res, resp_str.data(), resp_str.size());
*res_len = resp_str.size();
return true;
});
Cross-Process Service Call
Services work across processes with no extra configuration. Start the server in one process and the client in another -- they communicate via SHM automatically:
// Process A: server
horus::ServiceServer server("robot.status");
server.set_handler([](const uint8_t*, size_t,
uint8_t* res, size_t* res_len) -> bool {
const char* s = R"({"battery": 85, "state": "idle"})";
std::memcpy(res, s, strlen(s));
*res_len = strlen(s);
return true;
});
// Process B: client
horus::ServiceClient client("robot.status");
auto resp = client.call("{}", std::chrono::milliseconds(500));
if (resp) printf("Robot status: %s\n", resp->c_str());
Action with Scheduler Integration
Run an action server inside a scheduled node for navigation:
horus::ActionServer nav_server("navigate");
bool nav_active = false;
nav_server.set_accept_handler([&](const uint8_t*, size_t) -> uint8_t {
return nav_active ? 1 : 0; // reject if already navigating
});
nav_server.set_execute_handler([&](uint64_t id, const uint8_t* data, size_t len) {
nav_active = true;
// parse target from data...
});
auto odom_sub = sched.subscribe<horus::msg::Odometry>("odom");
auto cmd_pub = sched.advertise<horus::msg::CmdVel>("motor.cmd");
sched.add("nav_executor").rate(50_hz)
.tick([&] {
if (!nav_active) return;
auto odom = odom_sub.recv();
if (!odom) return;
double dist = std::sqrt(/* dx^2 + dy^2 */);
if (dist < 0.1) {
nav_active = false;
cmd_pub.send(horus::msg::CmdVel{0, 0.0f, 0.0f});
} else {
cmd_pub.send(horus::msg::CmdVel{0, 0.3f, 0.0f});
}
}).build();
See Also
- Scheduler API -- Where services and actions are registered alongside nodes
- Node API -- Node lifecycle for hosting service/action servers
- Publisher and Subscriber API -- The underlying SHM transport
- C++ API Overview -- All classes at a glance
- Rust Services API -- Equivalent Rust service API
- Rust Actions API -- Equivalent Rust action API