Skip to content

Receiving Control

A robot becomes useful when it does something with the input it receives. There are two sources of control:

  1. operate.adamohq.com — gamepad and VR input from the hosted operator, published as ROS envelopes containing CDR-encoded payloads on control/joy and control/cdr/xr_tracking.
  2. 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.

The hosted web operator can require a deliberate ownership step before teleoperation. An organization owner or admin enables Settings → Operator Control → Require explicit Take Control. Operators then enter robot pages in view-only mode and use Take Control and Release Control in the teleoperation header.

The policy is stored on the organization, so it follows users across browsers and applies to every operator in that organization.

While an operator holds explicit control, the web app publishes JSON on:

{robot}/control/json/operator_control

The SDK automatically adds the organization prefix. On the wire, the full key is adamo/{org}/{robot}/control/json/operator_control.

An acquisition looks like:

{
"version": 1,
"active": true,
"event": "acquired",
"operator": {
"userId": "2b1d…",
"sessionId": "b9f1…",
"displayName": "Sam Considine"
},
"timestampMs": 1785270488243
}

The event field is one of:

EventMeaning
acquiredThis operator session has taken control
heartbeatThe same session still holds control; sent once per second
releasedThe session deliberately released control or left the robot page

Track ownership by operator.sessionId, not display name. A released event should only clear the matching session: during a takeover, the old holder’s release can race with the new holder’s acquisition.

Treat the stream as a lease. Stop accepting motion when either:

  • the matching released event arrives; or
  • no matching heartbeat arrives for your chosen timeout (three seconds is a reasonable starting point for the current one-second heartbeat).

The timeout is required because a crashed browser or broken network cannot reliably send released.

The adamo-network repository includes adamo-py/examples/operator_control_listener.py. It listens to the ownership lease and browser keyboard topic together, printing acquired, heartbeats, keyboard edges, and released. Keyboard samples received without an active lease are labelled UNEXPECTED_WHILE_RELEASED.

Run it with:

Terminal window
ADAMO_API_KEY=ak_... \
python adamo-py/examples/operator_control_listener.py --robot my-arm

Use this listener while testing Take Control → input → Release Control. It is a diagnostic tool; the production receiver still needs to enforce the lease before applying commands.

The hosted web operator publishes a presence signal while a browser tab is viewing a robot. This is separate from explicit control ownership: a viewer can be present without holding control, and multiple viewers can be present at once.

Presence is a Zenoh liveliness token at:

{robot}/operator/presence/{userId}/{sessionId}/{displayNameBase64Url}

The SDK adds the organization prefix. On the wire, the full key is:

adamo/{org}/{robot}/operator/presence/{userId}/{sessionId}/{displayNameBase64Url}

Each browser tab gets its own sessionId. The last segment is the operator’s UTF-8 display name encoded as unpadded base64url. Use userId and sessionId as identity; display names are labels and are not unique.

Subscribe with history enabled so the callback first receives everyone already present and then receives changes:

import base64
import adamo
session = adamo.connect(api_key="ak_...")
def decode_display_name(segment: str) -> str:
padding = "=" * (-len(segment) % 4)
return base64.urlsafe_b64decode(segment + padding).decode("utf-8")
def on_operator_presence(key: str, is_present: bool) -> None:
# SDK callbacks receive the org-prefix-stripped key:
# robot/operator/presence/user/session/display
robot, marker, presence, user_id, session_id, display = key.split("/", 5)
if marker != "operator" or presence != "presence":
return
state = "joined" if is_present else "left"
print(
f"{decode_display_name(display)} {state} {robot} "
f"(user={user_id}, session={session_id})"
)
presence_sub = session.on_liveliness(
"*/operator/presence/**",
callback=on_operator_presence,
history=True,
)

Keep presence_sub alive for as long as you want to receive events. The same primitive is available as Rust Session::on_liveliness, C adamo_liveliness_subscribe, and the TypeScript session’s liveliness().declareSubscriber. See Robot Discovery for cross-language examples.

A token appearing means that tab joined the robot view. It disappears when the tab leaves normally or when Zenoh detects that its session disconnected, so an unexpected browser or network failure does not depend on a final application message. Detection after an unclean disconnect is asynchronous and should not be used as a hard real-time actuator stop; use the short ownership lease above for control gating.

The web operator also publishes best-effort JSON every two seconds on:

{robot}/operator/viewing/{userId}/{sessionId}
{
"displayName": "Sam Considine",
"visible": true,
"sentAt": 1785270488243
}

A clean exit publishes visible: false. If you consume this data topic instead of liveliness, expire a viewer after six seconds without a heartbeat because a crashed tab cannot reliably publish its final message. Prefer the liveliness token when you only need join/leave events.

There is currently no typed OperatorConnected / OperatorDisconnected SDK helper. These signals are readable through the public pub/sub and liveliness primitives, but applications must parse the key or JSON payload themselves.

The web app publishes a Joy message to {robot}/control/joy whenever the gamepad changes. The payload is a ROS envelope whose inner payload is a CDR-encoded sensor_msgs/msg/Joy.

Requires adamo>=0.4.54.

import time
import adamo
from adamo.xr import XRJoy, subscribe_xr_control
session = adamo.connect(api_key="ak_...")
def on_joy(sample):
if sample.topic != "/joy" or not isinstance(sample.message, XRJoy):
return
axes = sample.message.axes
buttons = sample.message.buttons
# axes[0..1] = left stick X/Y, axes[2..3] = right stick X/Y, etc.
drive(axes[0], axes[1])
# The ROS Header timestamp is preserved. Missing timestamps and samples that
# were already buffered for more than one second are dropped before on_joy.
sub = subscribe_xr_control(
session,
"my-arm",
on_joy,
channel="joy",
max_age_seconds=1.0,
)
try:
while True:
time.sleep(1)
finally:
sub.close()
session.close()

The standard W3C / Xbox button and axis mapping is documented in the TypeScript SDK reference.

When a viewer enters immersive VR mode on a stereo track, the headset publishes pose data continuously:

Inner topicPayloadDescription
/head_posegeometry_msgs/msg/PoseStampedHeadset pose
/controller/leftgeometry_msgs/msg/PoseStampedLeft controller grip (wrist/hold pose)
/controller/rightgeometry_msgs/msg/PoseStampedRight controller grip (wrist/hold pose)
/controller/{handedness}/tipgeometry_msgs/msg/PoseStampedController tip — the aim pose (WebXR targetRaySpace)
/controller/{handedness}/joysensor_msgs/msg/JoyXR controller axes and buttons

The hosted UI sends XR data on {robot}/control/cdr/xr_tracking. Each payload is a ROS envelope whose inner topic is /head_pose, /controller/{handedness}, /controller/{handedness}/tip, or /controller/{handedness}/joy.

Save this as adamo_xr_to_ros.py:

#!/usr/bin/env python3
import argparse
from functools import partial
import adamo
import rclpy
from adamo.xr import XRDecodeError, decode_ros_envelope
from geometry_msgs.msg import PoseStamped
from rclpy.serialization import deserialize_message
from sensor_msgs.msg import Joy
ROS_TYPES = {
"geometry_msgs/msg/PoseStamped": PoseStamped,
"sensor_msgs/msg/Joy": Joy,
}
def publish_xr_sample(sample, *, node, publishers):
try:
envelope = decode_ros_envelope(bytes(sample.payload))
except XRDecodeError as error:
node.get_logger().warn(f"Ignoring malformed XR payload: {error}")
return
msg_type = ROS_TYPES.get(envelope.type_name)
if msg_type is None:
node.get_logger().warn(f"Ignoring unsupported XR type: {envelope.type_name}")
return
ros_topic = f"/adamo/xr{envelope.topic}"
if ros_topic not in publishers:
publishers[ros_topic] = node.create_publisher(msg_type, ros_topic, 10)
node.get_logger().info(f"Publishing XR {envelope.topic} -> {ros_topic}")
publishers[ros_topic].publish(deserialize_message(envelope.cdr, msg_type))
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--api-key", required=True)
parser.add_argument("--robot", required=True)
args = parser.parse_args()
rclpy.init()
node = rclpy.create_node("adamo_xr_bridge")
publishers = {}
session = adamo.connect(api_key=args.api_key)
sub = session.subscribe(
f"{args.robot}/control/cdr/xr_tracking",
callback=partial(publish_xr_sample, node=node, publishers=publishers),
)
node.get_logger().info(f"Bridging {args.robot}/control/cdr/xr_tracking")
try:
rclpy.spin(node)
finally:
sub.close()
session.close()
node.destroy_node()
rclpy.shutdown()
if __name__ == "__main__":
main()

Run it from a ROS-sourced shell:

Terminal window
python3 adamo_xr_to_ros.py --api-key ak_... --robot my-arm

It republishes to ROS topics such as /adamo/xr/head_pose, /adamo/xr/controller/left, and /adamo/xr/controller/left/joy. If you do not run ROS, use subscribe_xr_control from adamo.xr. It returns a typed PoseStamped or XRJoy, preserves the source Header timestamp and frame, and can reject stale source input before your callback runs. Quaternion order is always ROS/WebXR scalar-last: x, y, z, w.

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 operator
robot.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.

For labelling data collection, the hosted UI can emit task start/stop signals — the operator picks a named task and marks when it begins and ends. This is opt-in (Settings → Tasks → “Show task list” in the web app) and is a standalone signal: it does not touch the recording pipeline. Use it to segment and tag what your robot is doing.

The signal is published on:

{robot}/tasks/signal

reliably (DATA priority), as UTF-8 JSON:

{
"action": "start",
"task_id": "d69c5a80-9f03-4d8a-b32a-3819cd00a7c9",
"task_name": "Solder my own hand",
"ts": 1780686715804
}

action is "start" or "stop". task_id is the task’s UUID (matches the task in the web app); task_name is the human-readable label (may be absent); ts is Date.now() in milliseconds. Subscribe to */tasks/signal to catch every robot in the org — the robot is the first key segment.

import adamo, json
session = adamo.connect(api_key="ak_...")
def on_task(sample):
sig = json.loads(sample.payload)
robot = sample.key.split("/")[0]
print(robot, sig["action"], sig["task_id"], sig.get("task_name"))
sub = session.subscribe("*/tasks/signal", callback=on_task)

Tasks and sets are normally created in the web app, but you can also manage them over a small REST API — handy for pre-creating a set before a collection run so the task_id is known up front. Authenticate with your org API key (the same ak_… key the SDKs use): exchange it for a short-lived bearer token, then call the task endpoints. The token response also gives you your org_id.

Terminal window
RESP=$(curl -s -X POST https://api.adamohq.com/api/keys/token -H "X-API-Key: ak_...")
TOKEN=$(jq -r .token <<<"$RESP")
ORG=$(jq -r .org_id <<<"$RESP")

All endpoints are under https://api.adamohq.com/api/orgs/{org_id} and take/return JSON:

MethodPathBodyDescription
GET/task-setsList sets, each with its tasks (ordered by position)
POST/task-sets{name, position?}Create a set
GET/task-sets/{set_id}Get one set with its tasks
DELETE/task-sets/{set_id}Delete a set (and its tasks)
POST/task-sets/{set_id}/tasks{name, position?}Add a task
PATCH/task-sets/{set_id}/tasks/{task_id}{name?, position?}Rename / reorder a task
DELETE/task-sets/{set_id}/tasks/{task_id}Remove a task
Terminal window
# Create a set, then add a task to it
SET=$(curl -s -X POST https://api.adamohq.com/api/orgs/$ORG/task-sets \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"Pick and place"}' | jq -r .id)
curl -X POST https://api.adamohq.com/api/orgs/$ORG/task-sets/$SET/tasks \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"Grasp the cube"}'

The task_id returned here is the same UUID that appears in the task signal above, so a script can lay out a set and the operator just selects and records.

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 os
import struct
import sys
import time
import adamo
N_JOINTS = 7
RATE_HZ = 100.0
GAIN = 0.1
ROBOT = 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)

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.

Robots publish a 1 Hz heartbeat on {robot}/heartbeat carrying latency stats — the network regime classified by the robot’s congestion forecaster, jitter, loss rate, and queuing delay. Use it as a safety gate before applying teleop commands: stop the robot when heartbeats go stale or the regime degrades.

import time
from adamo.stats import Regime
last_beat = time.monotonic()
def on_stats(stats):
global last_beat
last_beat = time.monotonic()
if stats.regime is not Regime.STABLE:
slow_down()
sub = session.watch_latency("my-arm", on_stats)
# In the control loop: two missed heartbeats = stale link
if time.monotonic() - last_beat > 2.5:
stop_robot()
import { watchLatest } from "adamo/fleet";
let lastBeat = performance.now();
const hb = await watchLatest(session, `adamo/${org}/my-arm/heartbeat`);
hb.onMessage(() => { lastBeat = performance.now(); });
// In the control loop: two missed heartbeats = stale link
if (performance.now() - lastBeat > 2500) stopRobot();

For dashboards, the parsed stats expose regime, jitter_ms, garch_sigma_ms, target_bitrate_kbps, loss_rate, and queuing_delay_ms — see the per-SDK LatencyStats reference.

See Building Your Own Operator for the publisher side end-to-end.

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.

Raw keyboard state is published on:

{robot}/control/json/keyboard

The payload is UTF-8 JSON:

{
"key": "w",
"code": "KeyW",
"action": "down",
"stamp": 1770210000000
}

key is the browser KeyboardEvent.key value, so it reflects the active keyboard layout and modifiers. code is the browser KeyboardEvent.code value, so it identifies the physical key position. action is "down" or "up", and stamp is Date.now() in milliseconds.

The hosted UI suppresses browser auto-repeat events. While a physical key remains held, it republishes a "down" heartbeat about every 100 ms. Receivers should treat the heartbeat as a dead-man signal and stop the associated motion if it expires, rather than relying only on a single "up" edge.

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_be
utf8 topic
u32 type_length_be
utf8 type
bytes cdr_payload

The topic and type fields describe the inner ROS message. The cdr_payload is the serialized message named by type.

Python applications should normally use decode_ros_envelope, decode_xr_control, or subscribe_xr_control from adamo.xr instead of unpacking these fields manually. The SDK validates name lengths, UTF-8, CDR alignment and endianness, string bounds, sequence bounds, and ROS timestamps. The wire layout remains documented here for non-Python SDKs and protocol debugging.

Gamepad input is published on:

adamo/{org}/{robot}/control/joy

By default the payload is a ROS envelope with:

Inner fieldValue
topic/joy
typesensor_msgs/msg/Joy
payloadCDR 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.

AxisMeaningRange
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 analog0 released, 1 pressed
axes[5]Right trigger analog0 released, 1 pressed
ButtonXbox / 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]
}

The Logitech Extreme 3D Pro uses the same control/joy key and payload format, but the axes are mapped as a joystick:

AxisMeaning
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 tracking is published on one Adamo key:

adamo/{org}/{robot}/control/cdr/xr_tracking

Each publish contains one ROS envelope. Consumers should decode the envelope and dispatch by the inner topic:

Inner topicTypeContents
/head_posegeometry_msgs/msg/PoseStampedHead pose
/controller/{handedness}geometry_msgs/msg/PoseStampedPhysical controller grip pose (the wrist/hold point), or hand wrist pose when hand tracking is active without a physical controller
/controller/{handedness}/tipgeometry_msgs/msg/PoseStampedPhysical controller tip — the aim pose from WebXR targetRaySpace. Published alongside the grip pose above: grip is where the hand holds the controller, tip is where it points. Omitted for a frame if the runtime reports no target-ray pose.
/controller/{handedness}/joysensor_msgs/msg/JoyXR controller axes and buttons
/hand/{handedness}geometry_msgs/msg/PoseArray25 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.

In Python, adamo.xr decodes PoseStamped and Joy into immutable typed objects and retains the complete ROS Header. Compare XRControlSample.source_timestamp with fabric time via is_stale(...), or pass max_age_seconds to subscribe_xr_control, before using controller input to issue commands. PoseArray hand-joint payloads are not currently part of the typed helper and still require ROS deserialization or a general CDR decoder.

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 valueWebXR valueMeaning
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].pressedPrimary trigger pressed
axes[rawAxisCount + 0]xrGamepad.buttons[0].valuePrimary trigger analog value
buttons[1]xrGamepad.buttons[1].pressedGrip / squeeze pressed
axes[rawAxisCount + 1]xrGamepad.buttons[1].valueGrip / squeeze analog value
buttons[2]xrGamepad.buttons[2].pressedPrimary touchpad pressed, if present
axes[rawAxisCount + 2]xrGamepad.buttons[2].valuePrimary touchpad button value
buttons[3]xrGamepad.buttons[3].pressedPrimary thumbstick pressed, if present
axes[rawAxisCount + 3]xrGamepad.buttons[3].valuePrimary thumbstick button value
buttons[4]xrGamepad.buttons[4].pressedFirst 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].valueFirst extra button value
buttons[5]xrGamepad.buttons[5].pressedSecond 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].valueSecond 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