# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
"""Haptic feedback (device output) for IsaacTeleop teleoperation.
This module adds an *output* path to the otherwise input-only teleop stack: a
sim-side signal (contact force on a gripper, per-finger grip force on an object,
...) is rendered on the operator's device (controller rumble, haptic glove, ...).
The design keeps three concerns pluggable and device-agnostic so a single seam
serves multiple devices:
* **Signal source** -- how a per-hand vector is read from the environment. Built
by :meth:`HapticFeedbackCfg.make_signal_fn`; ready-made factories are
:func:`contact_force_magnitude` (one scalar per hand) and
:func:`per_finger_object_grip` (per-finger force against a target object).
* **Output port** -- the narrow :class:`HapticFeedbackReceiver` protocol. Its
payload is a *vector* (length ``num_taxels``): length 1 for a rumble motor, one
channel per finger for a glove. Kept off the input-only ``DeviceBase``.
* **Device backend** -- how the vector is rendered. Selected by the concrete
:class:`HapticFeedbackCfg` subclass via :meth:`~HapticFeedbackCfg.build_sink`,
which wires an ``isaacteleop`` retargeter + ``IHapticDevice`` behind a
``HapticSink``. Isaac Lab supplies the signal; ``isaacteleop`` owns the
mapping curve and the device write.
:class:`HapticFeedbackDriver` ties them together and is used identically by every
teleop script.
"""
from __future__ import annotations
from collections.abc import Callable, Sequence
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
import numpy as np
import torch
from isaaclab.utils.configclass import configclass
if TYPE_CHECKING:
from isaaclab.envs import ManagerBasedRLEnv
# Well-known endpoint identifiers for the two hands. These match ``isaacteleop``'s
# ``Endpoint`` strings for hand-mounted haptic devices (controllers and gloves).
ENDPOINT_LEFT: str = "left"
ENDPOINT_RIGHT: str = "right"
# A signal function reads the environment and returns the per-hand output vector
# (length ``num_taxels``) for each endpoint.
SignalFn = Callable[["ManagerBasedRLEnv"], "dict[str, np.ndarray]"]
[docs]
@runtime_checkable
class HapticFeedbackReceiver(Protocol):
"""Protocol for a teleop device that can render haptic feedback.
Deliberately a single-method protocol rather than an addition to
``DeviceBase``: input-only devices (keyboard, space-mouse, gamepad) cannot
honor a feedback call, so forcing the method onto every device would break
Liskov substitutability. Scripts guard with ``isinstance(device,
HapticFeedbackReceiver)`` and only drive feedback when the active device
actually supports it.
"""
[docs]
def send_haptic(self, endpoint: str, values: Sequence[float]) -> None:
"""Render one frame of haptic output on a device endpoint.
Args:
endpoint: Which hand to actuate -- :data:`ENDPOINT_LEFT` or
:data:`ENDPOINT_RIGHT`.
values: The per-hand output vector (length ``num_taxels``): a single
scalar for a rumble motor, one value per finger for a glove. An
all-zero vector stops any active feedback. The signal-to-device
mapping (gain, deadband, saturation) is applied downstream.
"""
...
# ----------------------------------------------------------------------------
# Signal-source factories (environment -> per-hand vector)
# ----------------------------------------------------------------------------
def contact_force_magnitude(left_sensor_name: str, right_sensor_name: str) -> SignalFn:
"""Signal source: total contact-force magnitude per hand (a length-1 vector).
Sums the per-body contact-force magnitudes of each hand's
:class:`~isaaclab.sensors.ContactSensor` for the first environment. Suited to
a single-actuator device such as a controller rumble motor.
Args:
left_sensor_name: Scene entity name of the left-hand contact sensor.
right_sensor_name: Scene entity name of the right-hand contact sensor.
Returns:
A signal function mapping the environment to ``{endpoint: [force]}`` [N].
"""
names = {ENDPOINT_LEFT: left_sensor_name, ENDPOINT_RIGHT: right_sensor_name}
def signal_fn(env: ManagerBasedRLEnv) -> dict[str, np.ndarray]:
# Reduce each hand on-device via the explicit ``.torch`` view (avoids the
# ProxyArray deprecation path); a hand with no sensor data fails safe to 0.0.
# There are at most two hands, so bringing each scalar to the host with
# ``.item()`` is negligible and keeps the key/value pairing explicit.
magnitudes = {
endpoint: torch.linalg.vector_norm(forces.torch[0], dim=-1).sum().item()
for endpoint, name in names.items()
if (forces := env.scene[name].data.net_forces_w) is not None
}
return {endpoint: np.array([magnitudes.get(endpoint, 0.0)], dtype=np.float32) for endpoint in names}
return signal_fn
def per_finger_object_grip(
left_sensor_name: str,
right_sensor_name: str,
finger_order: Sequence[str],
) -> SignalFn:
"""Signal source: per-finger grip force against the filtered object.
Reads each hand's contact sensor ``force_matrix_w`` -- the contact force
*filtered against the grasped object* (see
:attr:`ContactSensorCfg.filter_prim_paths_expr`) -- and accumulates it into one
channel per finger. Only force against the object contributes, so incidental
table or self contact does not trigger feedback. Suited to a haptic glove.
Each sensor body is assigned to a channel by substring: a body maps to the
first ``finger_order`` entry its (lower-cased) name contains, and every link of
a finger (proximal, intermediate, distal) sums into that finger's channel. The
per-body assignment is resolved once per sensor and cached.
Args:
left_sensor_name: Scene entity name of the left-hand contact sensor
(must be configured with the object as a contact filter).
right_sensor_name: Scene entity name of the right-hand contact sensor.
finger_order: Per-channel finger substrings in device channel order
(e.g. ``["thumb", "index", "middle", "ring", "pinky"]``).
Returns:
A signal function mapping the environment to ``{endpoint: [f0, ..., fN]}`` [N].
"""
names = {ENDPOINT_LEFT: left_sensor_name, ENDPOINT_RIGHT: right_sensor_name}
fingers = [f.lower() for f in finger_order]
num_channels = len(fingers)
body_channels: dict[str, list[int | None]] = {}
def _resolve(sensor: Any) -> list[int | None]:
# Map each sensor body to its finger channel by substring (unmatched -> None).
return [
next((c for c, finger in enumerate(fingers) if finger in body_name.lower()), None)
for body_name in (sensor.body_names or [])
]
def signal_fn(env: ManagerBasedRLEnv) -> dict[str, np.ndarray]:
out: dict[str, np.ndarray] = {}
for endpoint, name in names.items():
sensor = env.scene[name]
vec = np.zeros(num_channels, dtype=np.float32)
# force_matrix_w: [num_envs, num_bodies, num_filters, 3] (None before first contact).
force_matrix = sensor.data.force_matrix_w
if force_matrix is not None:
channels = body_channels.get(name)
if channels is None:
channels = body_channels[name] = _resolve(sensor)
# Per body: contact-force magnitude summed over the object's filter
# shapes, via the explicit ``.torch`` view (avoids the ProxyArray
# deprecation path) and moved to the host once to skip a per-body sync.
per_body = torch.linalg.vector_norm(force_matrix.torch[0], dim=-1).sum(dim=-1).tolist()
for body_index, channel in enumerate(channels):
if channel is not None:
vec[channel] += per_body[body_index]
out[endpoint] = vec
return out
return signal_fn
# ----------------------------------------------------------------------------
# Configuration: device-agnostic base + backend-specific subclasses
# ----------------------------------------------------------------------------
[docs]
@configclass
class HapticFeedbackCfg:
"""Base configuration for teleop haptic feedback.
Device-agnostic. Attach a concrete subclass
(:class:`ControllerHapticFeedbackCfg` or :class:`GloveHapticFeedbackCfg`) to an
environment config as a sibling of ``isaac_teleop`` (e.g.
``self.haptic_feedback = ...`` in ``__post_init__``). The teleop scripts
discover it via ``hasattr(env_cfg, "haptic_feedback")``.
A subclass supplies the two pluggable pieces: :meth:`make_signal_fn` (how the
per-hand vector is read from the environment) and :meth:`build_sink` (how that
vector is rendered by an ``isaacteleop`` device).
"""
left_sensor_name: str = "left_hand_contact"
"""Scene entity name of the left-hand :class:`~isaaclab.sensors.ContactSensor`."""
right_sensor_name: str = "right_hand_contact"
"""Scene entity name of the right-hand :class:`~isaaclab.sensors.ContactSensor`."""
num_taxels: int = 1
"""Length of the per-hand output vector: 1 for a single rumble motor, one
channel per finger for a glove."""
gain: float = 1.0
"""Signal-to-output gain applied after the deadband (see the subclass docs for units)."""
deadband: float = 0.0
"""Signal magnitude below which no output is produced (rejects sensor noise)."""
saturation: float = 1.0
"""Upper clamp on the normalized output amplitude in ``[0, 1]``."""
[docs]
def make_signal_fn(self) -> SignalFn:
"""Return the signal source (environment -> per-hand vector) for this config."""
raise NotImplementedError
[docs]
def build_sink(self, force_inputs: dict[str, Any], tracker_provider: Callable[[], Any]) -> tuple[Any, Any]:
"""Build the ``isaacteleop`` ``HapticSink`` that renders the cached vectors.
Args:
force_inputs: Per-endpoint graph output nodes, each carrying a
``TactileVector(num_taxels)`` fed from the driver every step.
tracker_provider: Callable returning a DeviceIO tracker to reuse for
devices that need one (e.g. a controller). Cross-process devices
(e.g. a glove) may ignore it.
Returns:
A ``(connected_sink, tracker)`` tuple. ``connected_sink`` is the
``HapticSink.connect(...)`` result for ``TeleopSessionConfig(sinks=[...])``
(a subgraph, *not* the sink node). ``tracker`` is the device's DeviceIO
tracker (or ``None``); the session uses it to request the device's OpenXR
extensions (e.g. a glove's ``XR_NVX1_push_tensor``). It is returned
separately because the connected subgraph does not expose the device.
"""
raise NotImplementedError
[docs]
@configclass
class ControllerHapticFeedbackCfg(HapticFeedbackCfg):
"""Haptic feedback rendered as XR motion-controller vibration.
Renders a single scalar per hand (total gripper contact force) as controller
rumble via ``ControllerHapticDevice`` + ``TactileVectorToControllerPulse``.
"""
num_taxels: int = 1
gain: float = 0.05
"""Force-to-amplitude gain [1/N] applied after the deadband. Default maps ~20 N to full scale."""
deadband: float = 0.5
"""Contact force [N] below which no vibration is produced."""
frequency_hz: float = 0.0
"""Vibration frequency [Hz]. ``0`` selects the XR runtime default."""
duration_s: float = 0.0
"""Pulse duration [s]. ``0`` selects the shortest supported pulse; refreshed each frame."""
[docs]
def make_signal_fn(self) -> SignalFn:
return contact_force_magnitude(self.left_sensor_name, self.right_sensor_name)
[docs]
def build_sink(self, force_inputs: dict[str, Any], tracker_provider: Callable[[], Any]) -> tuple[Any, Any]:
from isaacteleop.haptic_devices.controller import ControllerHapticDevice
from isaacteleop.retargeters.tactile_retargeters import TactileVectorToControllerPulse
from isaacteleop.retargeting_engine.deviceio_source_nodes import HapticSink
sink_inputs = {}
for endpoint, force_output in force_inputs.items():
pulse = TactileVectorToControllerPulse(
f"_haptic_pulse_{endpoint}",
num_taxels=self.num_taxels,
gain=self.gain,
deadband=self.deadband,
saturation=self.saturation,
frequency_hz=self.frequency_hz,
duration_s=self.duration_s,
).connect({TactileVectorToControllerPulse.INPUT_TACTILE: force_output})
sink_inputs[endpoint] = pulse.output(TactileVectorToControllerPulse.OUTPUT_PULSE)
device = ControllerHapticDevice(tracker_provider())
return HapticSink("_haptic_sink", device).connect(sink_inputs), device.get_tracker()
[docs]
@configclass
class GloveHapticFeedbackCfg(HapticFeedbackCfg):
"""Haptic feedback rendered as per-finger power on a haptic glove.
Renders per-finger grip force against the grasped object as finger vibration
via a cross-process ``haptic_glove_device`` + ``TactileVectorToFingerPower``.
"""
gain: float = 0.1
"""Force-to-power gain [1/N] applied after the deadband. Default maps ~10 N to full power."""
deadband: float = 0.5
"""Contact force [N] below which no vibration is produced."""
smoothing: float = 0.5
"""EMA new-sample weight in ``[0, 1]`` (1.0 = no smoothing) applied to each finger power."""
collection_id: str = "manus_glove_haptic"
"""Push-tensor collection id pairing Isaac Teleop with the glove plugin process
(the Manus plugin's default). Change it to target a different glove vendor."""
finger_order: list[str] = ["thumb", "index", "middle", "ring", "pinky"]
"""Per-channel finger substrings, in glove channel order, matched against the
contact sensor's body names to group each finger's links into one channel.
This is the sole source of the finger-channel count (``num_taxels``)."""
def __post_init__(self) -> None:
# finger_order is the single source of truth for the channel count.
self.num_taxels = len(self.finger_order)
[docs]
def make_signal_fn(self) -> SignalFn:
return per_finger_object_grip(self.left_sensor_name, self.right_sensor_name, self.finger_order)
[docs]
def build_sink(self, force_inputs: dict[str, Any], tracker_provider: Callable[[], Any]) -> tuple[Any, Any]:
from isaacteleop.haptic_devices.glove import haptic_glove_device
from isaacteleop.retargeters.tactile_retargeters import TactileVectorToFingerPower
from isaacteleop.retargeting_engine.deviceio_source_nodes import HapticSink
sink_inputs = {}
for endpoint, force_output in force_inputs.items():
powers = TactileVectorToFingerPower(
f"_haptic_powers_{endpoint}",
num_taxels=self.num_taxels,
num_fingers=self.num_taxels,
gain=self.gain,
deadband=self.deadband,
saturation=self.saturation,
smoothing=self.smoothing,
).connect({TactileVectorToFingerPower.INPUT_TACTILE: force_output})
sink_inputs[endpoint] = powers.output(TactileVectorToFingerPower.OUTPUT_POWERS)
# Cross-process device; tracker_provider is unused. Its push-tensor tracker
# carries the required XR_NVX1_push_tensor / XR_NVX1_tensor_data extensions.
device = haptic_glove_device(self.collection_id, num_fingers=self.num_taxels)
return HapticSink("_haptic_sink", device).connect(sink_inputs), device.get_tracker()
# ----------------------------------------------------------------------------
# Driver + discovery
# ----------------------------------------------------------------------------
[docs]
class HapticFeedbackDriver:
"""Reads the per-hand signal from the scene and pushes it to the device.
One driver is shared by every teleop script. Construct it with
:func:`create_haptic_feedback_driver` (which returns ``None`` when the env has
no haptic config or the device cannot render it), then call :meth:`update`
once per step *after* ``env.step`` so the sensors hold fresh post-physics data.
"""
[docs]
def __init__(self, env: ManagerBasedRLEnv, device: HapticFeedbackReceiver, cfg: HapticFeedbackCfg):
"""Initialize the driver.
Args:
env: The (unwrapped) environment whose scene owns the sensors.
device: The teleop device implementing :class:`HapticFeedbackReceiver`.
cfg: Haptic feedback configuration providing the signal source.
"""
self._env = env
self._device = device
self._signal_fn = cfg.make_signal_fn()
self._zero = np.zeros(cfg.num_taxels, dtype=np.float32)
[docs]
def update(self) -> None:
"""Read the per-hand signal and forward each endpoint's vector to the device."""
for endpoint, values in self._signal_fn(self._env).items():
self._device.send_haptic(endpoint, values)
[docs]
def stop(self) -> None:
"""Zero every endpoint so any active feedback stops.
Call this on every frame the robot is not being stepped (teleop paused or
session not started). Because the device re-emits the cached vector each
frame, forgetting to zero it would leave the device rendering a stale grip
force until the next reset.
"""
for endpoint in (ENDPOINT_LEFT, ENDPOINT_RIGHT):
self._device.send_haptic(endpoint, self._zero)
[docs]
def create_haptic_feedback_driver(
env: ManagerBasedRLEnv, device: object, env_cfg: object
) -> HapticFeedbackDriver | None:
"""Build a :class:`HapticFeedbackDriver` when the env and device support haptics.
Follows the same capability-discovery idiom the teleop scripts use for
``isaac_teleop``: a ``haptic_feedback`` attribute on the env config plus a
device that satisfies :class:`HapticFeedbackReceiver`.
Args:
env: The (unwrapped) environment.
device: The active teleop device (any type).
env_cfg: The environment configuration; checked for a ``haptic_feedback``
:class:`HapticFeedbackCfg` attribute.
Returns:
A ready driver, or ``None`` if haptics are not configured or the device
cannot render them.
"""
cfg = getattr(env_cfg, "haptic_feedback", None)
if cfg is None:
return None
if not isinstance(device, HapticFeedbackReceiver):
return None
return HapticFeedbackDriver(env, device, cfg)