Receiving Control
A robot becomes useful when it does something with the input it receives. There are two sources of control:
- operate.adamohq.com — gamepad and VR input from the hosted operator, published as CDR-encoded payloads on
control/joy,head_pose, and the controller poses. - Your own custom operator — a teleop program you wrote yourself, publishing whatever topics and payload format you like (typically JSON). See Building Your Own Operator.
For both, the SDK primitive is the same: subscribe to a key expression and react to incoming samples.
Gamepad from operate.adamohq.com
Section titled “Gamepad from operate.adamohq.com”The web app publishes a Joy message to {robot}/control/joy whenever the gamepad changes. The wire format is CDR.
import adamofrom pycdr2 import CdrReader # pip install pycdr2
robot = adamo.Robot(api_key="ak_...", name="my-arm")
@robot.on("my-arm", "control/joy", decode=None)def on_joy(payload: bytes): rdr = CdrReader(payload) rdr.read_header() # Joy message header axes = list(rdr.read_float32_array()) buttons = list(rdr.read_int32_array()) # axes[0..1] = left stick X/Y, axes[2..3] = right stick X/Y, etc. drive(axes[0], axes[1])
robot.run()use adamo::Session;
fn main() -> adamo::Result<()> { let session = Session::open_default("ak_...")?; let _sub = session.subscribe_with("my-arm/control/joy", |sample| { // sample.payload is a CDR-encoded Joy message. // Decode with the `cdr-encoding` crate (or your own decoder). if let Ok(joy) = cdr_encoding::deserialize::<JoyMsg>(&sample.payload) { drive(joy.axes[0], joy.axes[1]); } })?; std::thread::park(); Ok(())}adamo_session_t *sess = adamo_open_default("ak_...");
void on_joy(const adamo_sample_t *s, void *user) { /* decode CDR Joy message from s->payload, s->payload_len */}
adamo_cb_sub_t *sub = adamo_subscribe_cb( sess, "my-arm/control/joy", on_joy, /* user */ NULL);For C/C++ robots, eProsima Fast CDR is a small library that handles the wire format.
The standard W3C / Xbox button and axis mapping is documented in the TypeScript SDK reference.
VR Headset from operate.adamohq.com
Section titled “VR Headset from operate.adamohq.com”When a viewer enters immersive VR mode on a stereo track, the headset publishes pose data continuously:
| Topic | Payload | Description |
|---|---|---|
{robot}/head_pose | CDR-encoded PoseStamped | Headset pose |
{robot}/controller/left | CDR-encoded PoseStamped | Left controller |
{robot}/controller/right | CDR-encoded PoseStamped | Right controller |
Subscribe the same way as gamepad.
@robot.on("my-arm", "head_pose", decode=None)def on_head(payload: bytes): pose = decode_pose_stamped(payload) update_head_tracking(pose)let _head = session.subscribe_with("my-arm/head_pose", |sample| { let pose: PoseStamped = cdr_encoding::deserialize(&sample.payload).unwrap(); update_head_tracking(pose);})?;void on_head(const adamo_sample_t *s, void *user) { /* decode CDR PoseStamped */}adamo_subscribe_cb(sess, "my-arm/head_pose", on_head, NULL);Custom Control Topics
Section titled “Custom Control Topics”When you write your own operator (XR app, mobile, GELLO leader, …), you choose the topic name and the payload format. JSON is the default in the Python SDK and works across all three languages.
The example below is the robot-side receiver that pairs with the bimanual XR operator on the Building Your Own Operator page.
The @robot.on(...) decorator subscribes and decodes JSON in one step. Brace-wrapped path segments capture the matched value as a keyword argument.
import adamo
robot = adamo.Robot(api_key="ak_...", name="my-arm")
# Cameras streamed back to the operatorrobot.attach_video("wrist_left", device="/dev/video0")robot.attach_video("wrist_right", device="/dev/video1")robot.attach_video("head", shm="head_cam")
# Bimanual hand controllers — {side} captures "left" or "right"@robot.on("xr-operator", "control/xr/hand/{side}", priority=250)def hand(msg, side): move_arm(side, msg["pos"], msg["quat"]) set_gripper(side, msg["trigger"])
# Head pose — separate handler@robot.on("xr-operator", "control/xr/head", priority=250)def on_head(msg): update_head_tracking(msg["pos"], msg["quat"])
robot.run()The first argument to @robot.on(...) is the broadcaster name — the operator publishing those topics. The decorator subscribes to {broadcaster}/{track} under the hood.
use adamo::Session;use serde::Deserialize;
#[derive(Deserialize)]struct HandMsg { pos: [f32; 3], quat: [f32; 4], trigger: f32 }
fn main() -> adamo::Result<()> { let session = Session::open_default("ak_...")?;
let _left = session.subscribe_with("xr-operator/control/xr/hand/left", |s| { let msg: HandMsg = serde_json::from_slice(&s.payload).unwrap(); move_arm("left", msg.pos, msg.quat); set_gripper("left", msg.trigger); })?;
let _right = session.subscribe_with("xr-operator/control/xr/hand/right", |s| { let msg: HandMsg = serde_json::from_slice(&s.payload).unwrap(); move_arm("right", msg.pos, msg.quat); set_gripper("right", msg.trigger); })?;
std::thread::park(); Ok(())}The Rust SDK doesn’t have brace-capture pattern matching like Python — declare a subscriber per side, or subscribe to xr-operator/control/xr/hand/* and pull the side off the key string yourself.
Use one callback subscriber per topic, or subscribe to a wildcard and switch on the key:
void on_hand(const adamo_sample_t *s, void *user) { const char *side = strrchr(s->key, '/'); if (side) side++; /* "left" or "right" */ /* decode JSON from s->payload, s->payload_len */ /* move_arm(side, ...); */}
adamo_subscribe_cb(sess, "xr-operator/control/xr/hand/*", on_hand, NULL);Bilateral Force Feedback
Section titled “Bilateral Force Feedback”A bilateral teleop setup sends measured follower joint efforts back to the leader on a real-time control topic. The leader keeps only the newest sample, scales it negatively, then applies it in the leader arm’s external-effort mode.
The example below isolates the feedback path. Replace the two hardware-driver functions with calls to your robot API:
- follower side: read measured external efforts from the follower arm
- leader side: apply external efforts to the leader arm
The payload is eight big-endian f64 values: [timestamp_seconds, effort_0, ..., effort_6].
import osimport structimport sysimport time
import adamo
N_JOINTS = 7RATE_HZ = 100.0GAIN = 0.1ROBOT = os.getenv("ADAMO_ROBOT_NAME", "my-arm")EFFORT_TOPIC = f"{ROBOT}/control/force_feedback/follower_effort"
def now_seconds(session): return session.fabric_now_us() / 1_000_000.0
def pack_efforts(timestamp, efforts): if len(efforts) != N_JOINTS: raise ValueError(f"expected {N_JOINTS} joint efforts") return struct.pack("!" + "d" * (1 + N_JOINTS), timestamp, *efforts)
def unpack_efforts(payload): if len(payload) != 8 * (1 + N_JOINTS): raise ValueError(f"bad effort payload size: {len(payload)}") values = struct.unpack("!" + "d" * (1 + N_JOINTS), payload) return values[0], list(values[1:])
def read_follower_external_efforts(): raise NotImplementedError("read joint efforts from your follower driver")
def apply_leader_external_efforts(efforts): raise NotImplementedError("send joint efforts to your leader driver")
def run_follower(session): with session.publisher( EFFORT_TOPIC, priority=250, express=True, reliable=False, ) as pub: while True: efforts = read_follower_external_efforts() pub.put(pack_efforts(now_seconds(session), efforts)) time.sleep(1.0 / RATE_HZ)
def run_leader(session): teleop_started_at = now_seconds(session)
with session.subscribe(EFFORT_TOPIC) as sub: while True: latest = None while True: sample = sub.try_recv() if sample is None: break latest = sample
if latest is not None: timestamp, measured = unpack_efforts(latest.payload) if timestamp >= teleop_started_at: applied = [-GAIN * effort for effort in measured] apply_leader_external_efforts(applied)
time.sleep(1.0 / RATE_HZ)
session = adamo.connect( api_key=os.environ["ADAMO_API_KEY"],)
mode = sys.argv[1] if len(sys.argv) > 1 else "leader"if mode == "follower": run_follower(session)else: run_leader(session)use adamo::{PublisherOptions, Session};use std::{env, thread, time::Duration};
const N_JOINTS: usize = 7;const EFFORT_BYTES: usize = (1 + N_JOINTS) * 8;const RATE_HZ: f64 = 100.0;const GAIN: f64 = 0.1;
fn effort_topic() -> String { let robot = env::var("ADAMO_ROBOT_NAME").unwrap_or_else(|_| "my-arm".to_string()); format!("{robot}/control/force_feedback/follower_effort")}
fn now_seconds() -> f64 { adamo::fabric_now_us() as f64 / 1_000_000.0}
fn pack_efforts(timestamp: f64, efforts: [f64; N_JOINTS]) -> [u8; EFFORT_BYTES] { let mut out = [0_u8; EFFORT_BYTES]; for (i, value) in std::iter::once(timestamp) .chain(efforts.iter().copied()) .enumerate() { let start = i * 8; out[start..start + 8].copy_from_slice(&value.to_be_bytes()); } out}
fn unpack_efforts(payload: &[u8]) -> Option<(f64, [f64; N_JOINTS])> { if payload.len() != EFFORT_BYTES { return None; }
let mut timestamp_bytes = [0_u8; 8]; timestamp_bytes.copy_from_slice(&payload[0..8]);
let mut efforts = [0.0_f64; N_JOINTS]; for (i, effort) in efforts.iter_mut().enumerate() { let start = (i + 1) * 8; let mut bytes = [0_u8; 8]; bytes.copy_from_slice(&payload[start..start + 8]); *effort = f64::from_be_bytes(bytes); }
Some((f64::from_be_bytes(timestamp_bytes), efforts))}
fn realtime_options() -> PublisherOptions { PublisherOptions { priority: 250, express: true, reliable: false, }}
fn read_follower_external_efforts() -> [f64; N_JOINTS] { todo!("read joint efforts from your follower driver")}
fn apply_leader_external_efforts(_efforts: [f64; N_JOINTS]) { todo!("send joint efforts to your leader driver")}
fn run_follower(session: &Session) -> adamo::Result<()> { let topic = effort_topic(); let publisher = session.publisher(&topic, realtime_options())?;
loop { let efforts = read_follower_external_efforts(); let payload = pack_efforts(now_seconds(), efforts); publisher.put(&payload)?; thread::sleep(Duration::from_secs_f64(1.0 / RATE_HZ)); }}
fn run_leader(session: &Session) -> adamo::Result<()> { let topic = effort_topic(); let subscriber = session.subscribe(&topic)?; let teleop_started_at = now_seconds();
loop { let mut latest = None; while let Some(sample) = subscriber.try_recv()? { latest = Some(sample); }
if let Some(sample) = latest { if let Some((timestamp, measured)) = unpack_efforts(&sample.payload) { if timestamp >= teleop_started_at { let mut applied = [0.0_f64; N_JOINTS]; for (out, effort) in applied.iter_mut().zip(measured.iter()) { *out = -GAIN * *effort; } apply_leader_external_efforts(applied); } } }
thread::sleep(Duration::from_secs_f64(1.0 / RATE_HZ)); }}
fn main() -> adamo::Result<()> { let api_key = env::var("ADAMO_API_KEY").expect("set ADAMO_API_KEY"); let session = Session::open_default(&api_key)?; let mode = env::args().nth(1).unwrap_or_else(|| "leader".to_string());
if mode == "follower" { run_follower(&session) } else { run_leader(&session) }}#define _POSIX_C_SOURCE 199309L
#include <adamo/adamo.h>
#include <stdint.h>#include <stdio.h>#include <stdlib.h>#include <string.h>#include <time.h>
#define N_JOINTS 7#define EFFORT_BYTES ((1 + N_JOINTS) * 8)#define RATE_HZ 100.0#define GAIN 0.1
static int host_is_little_endian(void) { const uint16_t probe = 1; return *(const uint8_t *)&probe == 1;}
static void pack_be_double(double value, uint8_t *out) { uint8_t bytes[8]; memcpy(bytes, &value, 8); if (host_is_little_endian()) { for (int i = 0; i < 8; i++) out[i] = bytes[7 - i]; } else { memcpy(out, bytes, 8); }}
static double unpack_be_double(const uint8_t *in) { uint8_t bytes[8]; if (host_is_little_endian()) { for (int i = 0; i < 8; i++) bytes[i] = in[7 - i]; } else { memcpy(bytes, in, 8); } double value = 0.0; memcpy(&value, bytes, 8); return value;}
static void pack_efforts(double timestamp, const double efforts[N_JOINTS], uint8_t out[EFFORT_BYTES]) { pack_be_double(timestamp, out); for (int i = 0; i < N_JOINTS; i++) { pack_be_double(efforts[i], out + (i + 1) * 8); }}
static int unpack_efforts(const uint8_t *payload, size_t len, double *timestamp, double efforts[N_JOINTS]) { if (len != EFFORT_BYTES) return -1; *timestamp = unpack_be_double(payload); for (int i = 0; i < N_JOINTS; i++) { efforts[i] = unpack_be_double(payload + (i + 1) * 8); } return 0;}
static double now_seconds(void) { return (double)adamo_fabric_now_us() / 1000000.0;}
static void sleep_tick(void) { struct timespec ts; ts.tv_sec = 0; ts.tv_nsec = (long)(1000000000.0 / RATE_HZ); nanosleep(&ts, NULL);}
static void read_follower_external_efforts(double efforts[N_JOINTS]) { /* Fill from your follower driver, e.g. get_all_external_efforts(). */ for (int i = 0; i < N_JOINTS; i++) efforts[i] = 0.0;}
static void apply_leader_external_efforts(const double efforts[N_JOINTS]) { /* Send to your leader driver, e.g. set_all_external_efforts(...). */ (void)efforts;}
static int run_follower(adamo_session_t *session, const char *topic) { adamo_publisher_t *pub = adamo_publisher( session, topic, /* priority */ 250, /* express */ 1, /* reliable */ 0); if (!pub) return -1;
for (;;) { double efforts[N_JOINTS]; uint8_t payload[EFFORT_BYTES]; read_follower_external_efforts(efforts); pack_efforts(now_seconds(), efforts, payload); if (adamo_publisher_put(pub, payload, sizeof(payload)) != 0) { adamo_publisher_free(pub); return -1; } sleep_tick(); }}
static int run_leader(adamo_session_t *session, const char *topic) { adamo_subscriber_t *sub = adamo_subscribe(session, topic); if (!sub) return -1;
const double teleop_started_at = now_seconds(); for (;;) { adamo_sample_t *latest = NULL; for (;;) { adamo_sample_t *sample = adamo_sub_try_recv(sub); if (!sample) { const char *err = adamo_last_error(); if (err && *err) { if (latest) adamo_sample_free(latest); adamo_sub_free(sub); return -1; } break; } if (latest) adamo_sample_free(latest); latest = sample; }
if (latest) { double timestamp = 0.0; double measured[N_JOINTS]; if (unpack_efforts(latest->payload, latest->payload_len, ×tamp, measured) == 0 && timestamp >= teleop_started_at) { double applied[N_JOINTS]; for (int i = 0; i < N_JOINTS; i++) { applied[i] = -GAIN * measured[i]; } apply_leader_external_efforts(applied); } adamo_sample_free(latest); }
sleep_tick(); }}
int main(int argc, char **argv) { const char *api_key = getenv("ADAMO_API_KEY"); const char *robot = getenv("ADAMO_ROBOT_NAME"); if (!api_key) { fprintf(stderr, "set ADAMO_API_KEY\n"); return 1; } if (!robot) robot = "my-arm";
char topic[256]; snprintf(topic, sizeof(topic), "%s/control/force_feedback/follower_effort", robot);
adamo_session_t *session = adamo_open_default(api_key); if (!session) { fprintf(stderr, "adamo_open: %s\n", adamo_last_error()); return 1; }
const char *mode = argc > 1 ? argv[1] : "leader"; int rc = strcmp(mode, "follower") == 0 ? run_follower(session, topic) : run_leader(session, topic);
fprintf(stderr, "adamo error: %s\n", adamo_last_error()); adamo_session_free(session); return rc == 0 ? 0 : 1;}Real-Time Priority
Section titled “Real-Time Priority”Control topics should be published with REAL_TIME priority and dropped on congestion — a command that arrives 200 ms late is worse than no command at all. The robot side doesn’t choose priority on subscribe (the router uses the publisher’s choice), but it’s worth knowing the convention so your own operator programs follow it.
robot.publish("control/joy", priority=250, express=True)# 0–255 mapped to 8 priority classes; ≥240 is REAL_TIME.session.publisher("control/joy", PublisherOptions { priority: 250, express: true, reliable: false,})?;adamo_publisher(sess, "control/joy", /* priority */ 250, /* express */ 1, /* reliable */ 0);Frame Safety Stats
Section titled “Frame Safety Stats”Robots publish video latency and frame-age stats on {robot}/stats/latency. Use them as a safety gate before applying teleop commands.
def on_safety(stats): if stats.is_stale: stop_robot()
session.watch_frame_safety("my-arm", on_safety, stale_after_ms=250)import { watchFrameSafety } from "@adamo/fleet";
await watchFrameSafety(session, org, "my-arm", ({ isStale }) => { if (isStale) stopRobot();}, { staleAfterMs: 250 });For lower-level dashboards, subscribe to video latency stats directly and read frameAgeMs, maxFrameAgeMs, droppedFrames, and channelLag.
See Building Your Own Operator for the publisher side end-to-end.
Hosted UI Payload Reference
Section titled “Hosted UI Payload Reference”The current adamo-ts/examples/web frontend publishes operator input as real-time, best-effort messages. The exact key and payload depend on the input mode.
CDR ROS envelope
Section titled “CDR ROS envelope”When the UI is using CDR mode, the bytes on the Adamo key are a small ROS envelope followed by the CDR-encoded ROS message:
u32 topic_length_beutf8 topicu32 type_length_beutf8 typebytes cdr_payloadThe topic and type fields describe the inner ROS message. The cdr_payload is the serialized message named by type.
Browser gamepad / Xbox controller
Section titled “Browser gamepad / Xbox controller”Gamepad input is published on:
adamo/{org}/{robot}/control/joyBy default the payload is a ROS envelope with:
| Inner field | Value |
|---|---|
topic | /joy |
type | sensor_msgs/msg/Joy |
payload | CDR sensor_msgs/msg/Joy |
The Joy message uses header.frame_id = "joy" and contains six axes plus 21 button slots. Standard W3C / Xbox controllers populate buttons 0..16; buttons 17..20 are reserved and normally remain 0.
| Axis | Meaning | Range |
|---|---|---|
axes[0] | Left stick X | -1 left, +1 right |
axes[1] | Left stick Y | -1 up, +1 down |
axes[2] | Right stick X | -1 left, +1 right |
axes[3] | Right stick Y | -1 up, +1 down |
axes[4] | Left trigger analog | 0 released, 1 pressed |
axes[5] | Right trigger analog | 0 released, 1 pressed |
| Button | Xbox / W3C control |
|---|---|
buttons[0] | A |
buttons[1] | B |
buttons[2] | X |
buttons[3] | Y |
buttons[4] | LB |
buttons[5] | RB |
buttons[6] | LT pressed |
buttons[7] | RT pressed |
buttons[8] | Back / Select |
buttons[9] | Start |
buttons[10] | Left stick click |
buttons[11] | Right stick click |
buttons[12] | D-pad up |
buttons[13] | D-pad down |
buttons[14] | D-pad left |
buttons[15] | D-pad right |
buttons[16] | Guide / Xbox |
If joystick serialization is switched to JSON in the UI, the same key carries:
{ "type": "JoystickCommand", "sequence_id": 42, "stamp": 1710000000.123, "axes": [0, 0, 0, 0, 0, 0], "buttons": [0, 0, 0]}Logitech Extreme 3D Pro
Section titled “Logitech Extreme 3D Pro”The Logitech Extreme 3D Pro uses the same control/joy key and payload format, but the axes are mapped as a joystick:
| Axis | Meaning |
|---|---|
axes[0] | Stick X |
axes[1] | Stick Y |
axes[2] | Twist / rudder |
axes[3] | Throttle, with forward usually negative |
axes[4] | Unused, normally 0 |
axes[5] | Unused, normally 0 |
Buttons 0..11 are copied from the physical buttons. Buttons 12..15 are the hat switch when the browser reports it as buttons.
XR head, controllers, and hands
Section titled “XR head, controllers, and hands”XR tracking is published on one Adamo key:
adamo/{org}/{robot}/control/cdr/xr_trackingEach publish contains one ROS envelope. Consumers should decode the envelope and dispatch by the inner topic:
| Inner topic | Type | Contents |
|---|---|---|
/head_pose | geometry_msgs/msg/PoseStamped | Head pose |
/controller/{handedness} | geometry_msgs/msg/PoseStamped | Physical controller grip pose, or hand wrist pose when hand tracking is active without a physical controller |
/controller/{handedness}/joy | sensor_msgs/msg/Joy | XR controller axes and buttons |
/hand/{handedness} | geometry_msgs/msg/PoseArray | 25 hand joint poses when hand tracking is enabled |
{handedness} is normally left or right. Pose headers use frame_id = "xr_origin". Positions are in meters in the WebXR local-floor reference space. Orientations are published as ROS quaternions {x, y, z, w}; the frontend converts from WebXR’s internal [w, x, y, z] order before encoding the ROS message.
XR controller Joy messages use:
const rawAxisCount = xrGamepad.axes.length;axes = [ ...xrGamepad.axes, ...xrGamepad.buttons.map((button) => button.value),];buttons = xrGamepad.buttons.map((button) => button.pressed ? 1 : 0);The raw axis and button order is the order reported by the WebXR runtime for that controller. The UI does not remap XR controller buttons to Xbox-style button indices.
For controllers using the WebXR xr-standard mapping, the Joy values are:
| Joy value | WebXR value | Meaning |
|---|---|---|
axes[0] | xrGamepad.axes[0] | Primary touchpad X, or placeholder 0 |
axes[1] | xrGamepad.axes[1] | Primary touchpad Y, or placeholder 0 |
axes[2] | xrGamepad.axes[2] | Primary thumbstick X |
axes[3] | xrGamepad.axes[3] | Primary thumbstick Y |
buttons[0] | xrGamepad.buttons[0].pressed | Primary trigger pressed |
axes[rawAxisCount + 0] | xrGamepad.buttons[0].value | Primary trigger analog value |
buttons[1] | xrGamepad.buttons[1].pressed | Grip / squeeze pressed |
axes[rawAxisCount + 1] | xrGamepad.buttons[1].value | Grip / squeeze analog value |
buttons[2] | xrGamepad.buttons[2].pressed | Primary touchpad pressed, if present |
axes[rawAxisCount + 2] | xrGamepad.buttons[2].value | Primary touchpad button value |
buttons[3] | xrGamepad.buttons[3].pressed | Primary thumbstick pressed, if present |
axes[rawAxisCount + 3] | xrGamepad.buttons[3].value | Primary thumbstick button value |
buttons[4] | xrGamepad.buttons[4].pressed | First extra button. On current Quest/Pico-style controllers this is usually X on left hand and A on right hand. |
axes[rawAxisCount + 4] | xrGamepad.buttons[4].value | First extra button value |
buttons[5] | xrGamepad.buttons[5].pressed | Second extra button. On current Quest/Pico-style controllers this is usually Y on left hand and B on right hand. |
axes[rawAxisCount + 5] | xrGamepad.buttons[5].value | Second extra button value |
rawAxisCount is commonly 4 for thumbstick controllers, so the trigger analog value is commonly axes[4], grip is axes[5], thumbstick button value is axes[7], and the first extra face button value is axes[8]. Check axes.length - buttons.length if you want to derive the offset at runtime.
Hand tracking publishes PoseArray joints in this fixed order:
wrist,thumb-metacarpal, thumb-phalanx-proximal, thumb-phalanx-distal, thumb-tip,index-finger-metacarpal, index-finger-phalanx-proximal, index-finger-phalanx-intermediate, index-finger-phalanx-distal, index-finger-tip,middle-finger-metacarpal, middle-finger-phalanx-proximal, middle-finger-phalanx-intermediate, middle-finger-phalanx-distal, middle-finger-tip,ring-finger-metacarpal, ring-finger-phalanx-proximal, ring-finger-phalanx-intermediate, ring-finger-phalanx-distal, ring-finger-tip,pinky-finger-metacarpal, pinky-finger-phalanx-proximal, pinky-finger-phalanx-intermediate, pinky-finger-phalanx-distal, pinky-finger-tip