Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 106 additions & 2 deletions docs/source/features/isaac_teleop.rst
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,10 @@ and Isaac Lab. It composes three collaborators:
Isaac Sim's XR bridge, creates the ``TeleopSession``, and steps it each frame to produce an
action tensor.

* **CommandHandler** -- registers and dispatches START / STOP / RESET callbacks triggered by XR UI
buttons or the message bus.
* **CommandHandler** -- lightweight callback registry for START / STOP / RESET commands. Scripts
can register callbacks via :meth:`~isaaclab_teleop.IsaacTeleopDevice.add_callback`, but the
primary control path uses :func:`~isaaclab_teleop.poll_control_events` (see
:ref:`isaac-teleop-control-states`).

.. dropdown:: Session lifecycle details

Expand All @@ -127,6 +129,104 @@ and Isaac Lab. It composes three collaborators:
the session is not yet ready or has been torn down.


.. _isaac-teleop-control-states:

Teleop Control States (Start / Stop / Reset)
---------------------------------------------

Isaac Lab supports remote teleop control commands -- **start**, **stop**, and **reset** -- sent
from the XR headset to the simulation. These are used to begin and end demonstration recording,
pause the robot, or reset the environment without touching the simulation host.

How it works
~~~~~~~~~~~~

By default, every :class:`~isaaclab_teleop.IsaacTeleopCfg` enables a control message channel
using the well-known UUID ``uuid5(NAMESPACE_DNS, "teleop_command")``. The channel is created as
a ``teleop_control_pipeline`` inside TeleopCore's :class:`TeleopSession`, which means:

1. A :class:`~isaacteleop.retargeting_engine.deviceio_source_nodes.MessageChannelSource` opens an
OpenXR opaque data channel (``XR_NV_opaque_data_channel``) with the agreed-upon UUID.
2. The CloudXR JS client (or any other client) discovers the channel by UUID and sends UTF-8
JSON commands::

{"type": "teleop_command", "message": {"command": "start teleop"}}
{"type": "teleop_command", "message": {"command": "stop teleop"}}
{"type": "teleop_command", "message": {"command": "reset teleop"}}

3. A :class:`~isaaclab_teleop.teleop_message_processor.TeleopMessageProcessor` parses these
payloads and produces boolean pulse signals (``run_toggle``, ``kill``, ``reset``).
4. :class:`~isaacteleop.teleop_session_manager.DefaultTeleopStateManager` consumes the
boolean signals, runs its state machine (edge detection, fail-safe), and produces
``teleop_state`` (one-hot) and ``reset_event`` (bool pulse) outputs.
5. TeleopCore decodes these outputs into ``ExecutionEvents`` and injects them into every
retargeter's ``ComputeContext``, so stateful retargeters can react to state changes
(e.g. reinitializing cross-step state on reset).

Polling control events in your script
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Use :func:`~isaaclab_teleop.poll_control_events` to read the latest control state each frame:

.. code-block:: python

from isaaclab_teleop import poll_control_events

with IsaacTeleopDevice(cfg) as device:
running = False
while sim_app.is_running():
action = device.advance()

ctrl = poll_control_events(device)
if ctrl.is_active is not None:
running = ctrl.is_active # True after "start", False after "stop"
if ctrl.should_reset:
env.reset() # "reset" command received this frame

if action is not None and running:
env.step(action.repeat(num_envs, 1))
else:
env.sim.render()

:class:`~isaaclab_teleop.ControlEvents` has two fields:

* ``is_active`` -- ``True`` after a "start" command, ``False`` after "stop", ``None`` when no
command has been received yet (callers should leave their own flag unchanged).
* ``should_reset`` -- ``True`` for exactly one frame after a "reset" command.

Disabling the control channel
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

If you do not need headset-driven start/stop/reset (e.g. keyboard-only workflows), set
``control_channel_uuid=None`` in your config:

.. code-block:: python

IsaacTeleopCfg(
pipeline_builder=_build_my_pipeline,
control_channel_uuid=None, # no opaque data channel created
)

Using a custom channel UUID
~~~~~~~~~~~~~~~~~~~~~~~~~~~

To use a different channel UUID (e.g. for a separate control protocol), pass any 16-byte
``bytes`` value:

.. code-block:: python

import uuid

MY_UUID = uuid.uuid5(uuid.NAMESPACE_DNS, "my_custom_control").bytes

IsaacTeleopCfg(
pipeline_builder=_build_my_pipeline,
control_channel_uuid=MY_UUID,
)

The CloudXR JS client must be updated to discover this UUID when sending commands.


.. _isaac-teleop-retargeting:

Retargeting Framework
Expand Down Expand Up @@ -908,6 +1008,10 @@ See the :ref:`isaaclab_teleop-api` for full class and function documentation:
* :class:`~isaaclab_teleop.IsaacTeleopCfg`
* :class:`~isaaclab_teleop.IsaacTeleopDevice`
* :func:`~isaaclab_teleop.create_isaac_teleop_device`
* :class:`~isaaclab_teleop.ControlEvents`
* :class:`~isaaclab_teleop.SupportsControlEvents`
* :func:`~isaaclab_teleop.poll_control_events`
* :data:`~isaaclab_teleop.TELEOP_CONTROL_CHANNEL_UUID`
* :class:`~isaaclab_teleop.XrCfg`
* :class:`~isaaclab_teleop.XrAnchorRotationMode`

Expand Down
9 changes: 8 additions & 1 deletion scripts/environments/teleoperation/teleop_se3_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ def stop_teleoperation() -> None:

try:
if use_isaac_teleop:
from isaaclab_teleop import create_isaac_teleop_device
from isaaclab_teleop import create_isaac_teleop_device, poll_control_events

teleop_interface = create_isaac_teleop_device(
env_cfg.isaac_teleop,
Expand Down Expand Up @@ -297,6 +297,13 @@ def run_loop():
# get device command
action = teleop_interface.advance()

if use_isaac_teleop:
ctrl = poll_control_events(teleop_interface)
if ctrl.is_active is not None:
teleoperation_active = ctrl.is_active
if ctrl.should_reset:
should_reset_recording_instance = True

# action is None when IsaacTeleop session hasn't started yet
# (e.g. waiting for user to click "Start AR")
if action is None:
Expand Down
44 changes: 33 additions & 11 deletions scripts/tools/record_demos.py
Original file line number Diff line number Diff line change
Expand Up @@ -406,26 +406,34 @@ def process_success_condition(env: gym.Env, success_term: object | None, success


def handle_reset(
env: gym.Env, success_step_count: int, instruction_display: InstructionDisplay, label_text: str
env: gym.Env,
success_step_count: int,
instruction_display: InstructionDisplay,
label_text: str,
teleop_interface: object | None = None,
) -> int:
"""Handle resetting the environment.

Resets the environment, recorder manager, and related state variables.
Updates the instruction display with current status.
Resets the environment, recorder manager, teleop device, and related
state variables. Updates the instruction display with current status.

Args:
env: The environment instance to reset
success_step_count: Current count of consecutive successful steps
instruction_display: The display object to update
label_text: Text to display showing current recording status
env: The environment instance to reset.
success_step_count: Current count of consecutive successful steps.
instruction_display: The display object to update.
label_text: Text to display showing current recording status.
teleop_interface: Optional teleop device to reset (resets XR anchor
and retargeter cross-step state).

Returns:
int: Reset success step count (0)
Reset success step count (0).
"""
print("Resetting environment...")
env.sim.reset()
env.recorder_manager.reset()
env.reset()
if teleop_interface is not None and hasattr(teleop_interface, "reset"):
teleop_interface.reset()
success_step_count = 0
instruction_display.show_demo(label_text)
return success_step_count
Expand Down Expand Up @@ -476,7 +484,9 @@ def stop_recording_instance():
running_recording_instance = False
print("Recording paused")

# Set up teleoperation callbacks
# Set up teleoperation callbacks. For IsaacTeleop the primary control
# path is poll_control_events(); these callbacks are bridged automatically
# and also serve native (keyboard / spacemouse) devices.
teleoperation_callbacks = {
"R": reset_recording_instance,
"START": start_recording_instance,
Expand All @@ -485,7 +495,6 @@ def stop_recording_instance():
}

teleop_interface = setup_teleop_device(teleoperation_callbacks, use_isaac_teleop)
teleop_interface.add_callback("R", reset_recording_instance)

label_text = f"Recorded {current_recorded_demo_count} successful demonstrations."
instruction_display = setup_ui(label_text, env)
Expand All @@ -504,10 +513,21 @@ def inner_loop():
stack_name = "IsaacTeleop" if use_isaac_teleop else "native"
print(f"{stack_name} recording started.")

if use_isaac_teleop:
from isaaclab_teleop import poll_control_events

with contextlib.suppress(KeyboardInterrupt), torch.inference_mode():
while simulation_app.is_running():
# Get teleop command (may be None while waiting for session start)
action = teleop_interface.advance()

if use_isaac_teleop:
ctrl = poll_control_events(teleop_interface)
if ctrl.is_active is not None:
running_recording_instance = ctrl.is_active
if ctrl.should_reset:
should_reset_recording_instance = True

if action is None:
env.sim.render()
continue
Expand Down Expand Up @@ -558,7 +578,9 @@ def inner_loop():

# Handle reset if requested
if should_reset_recording_instance:
success_step_count = handle_reset(env, success_step_count, instruction_display, label_text)
success_step_count = handle_reset(
env, success_step_count, instruction_display, label_text, teleop_interface
)
should_reset_recording_instance = False

# Check if simulation is stopped
Expand Down
2 changes: 1 addition & 1 deletion source/isaaclab_teleop/config/extension.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
# Semantic Versioning is used: https://semver.org/
version = "0.3.5"
version = "0.3.6"

# Description
title = "Isaac Lab Teleop"
Expand Down
45 changes: 45 additions & 0 deletions source/isaaclab_teleop/docs/CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,51 @@
Changelog
---------

0.3.6 (2026-04-21)
~~~~~~~~~~~~~~~~~~~

Added
^^^^^

* Added :attr:`~isaaclab_teleop.IsaacTeleopCfg.control_channel_uuid` for
receiving teleop control commands (start/stop/reset) from the headset via
an OpenXR message channel. The channel is managed by TeleopCore's native
``teleop_control_pipeline`` mechanism.

* Added :class:`~isaaclab_teleop.teleop_message_processor.TeleopMessageProcessor`
retargeter that converts raw message-channel payloads into boolean control
signals for :class:`~isaacteleop.teleop_session_manager.DefaultTeleopStateManager`.

* Added :func:`~isaaclab_teleop.poll_control_events` helper,
:class:`~isaaclab_teleop.ControlEvents` dataclass, and
:class:`~isaaclab_teleop.SupportsControlEvents` protocol for polling
start/stop/reset signals from any teleop device in a single call.

* Added :attr:`~isaaclab_teleop.IsaacTeleopDevice.last_control_events`
property exposing the most recent control events from the message channel.
Control events are automatically bridged to legacy
:meth:`~isaaclab_teleop.IsaacTeleopDevice.add_callback` callbacks.

Changed
^^^^^^^

* :meth:`~isaaclab_teleop.IsaacTeleopDevice.reset` now injects a
``reset`` :class:`ExecutionEvents` into TeleopCore's ``ComputeContext``
on the next pipeline step, resetting retargeter cross-step state.
Previously only the XR anchor was reset.

Fixed
^^^^^

* Fixed ``record_demos.py`` not resetting the teleop device when a
success condition triggers an environment reset. Retargeters now
reinitialize their state on success-triggered resets.

* Fixed shutdown hang caused by Kit's pre-shutdown callback calling
``stop()`` while the simulation loop was still running. The callback
now uses the same graceful teardown path as the XR-disabled handler.


0.3.5 (2026-04-06)
~~~~~~~~~~~~~~~~~~~

Expand Down
9 changes: 7 additions & 2 deletions source/isaaclab_teleop/isaaclab_teleop/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,20 @@
__all__ = [
"CLOUDXR_AVP_ENV",
"CLOUDXR_JS_ENV",
"ControlEvents",
"IsaacTeleopCfg",
"IsaacTeleopDevice",
"create_isaac_teleop_device",
"XrAnchorSynchronizer",
"SupportsControlEvents",
"TELEOP_CONTROL_CHANNEL_UUID",
"XrAnchorRotationMode",
"XrAnchorSynchronizer",
"XrCfg",
"create_isaac_teleop_device",
"poll_control_events",
"remove_camera_configs",
]

from .control_events import TELEOP_CONTROL_CHANNEL_UUID, ControlEvents, SupportsControlEvents, poll_control_events
from .isaac_teleop_cfg import CLOUDXR_AVP_ENV, CLOUDXR_JS_ENV, IsaacTeleopCfg
from .isaac_teleop_device import IsaacTeleopDevice, create_isaac_teleop_device
from .xr_anchor_utils import XrAnchorSynchronizer
Expand Down
Loading
Loading