Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

#include "tracker.hpp"

#include <string>

namespace core
{

Expand Down Expand Up @@ -34,6 +36,18 @@ class IControllerTrackerImpl : public ITrackerImpl
/// tear down the tracker.
virtual void apply_left_haptic_feedback(float amplitude, float frequency_hz, float duration_s) const = 0;
virtual void apply_right_haptic_feedback(float amplitude, float frequency_hz, float duration_s) const = 0;

/// The interaction profile the runtime has bound for the controllers, as an
/// OpenXR path (e.g. "/interaction_profiles/bytedance/pico4_controller"), or
/// empty when none is bound yet or the implementation cannot report one.
///
/// Identifies the physical hardware behind a remote-streaming runtime, which
/// otherwise presents every headset identically. Empty is a normal state, not
/// an error: nothing is bound until actions have synced at least once.
virtual std::string get_interaction_profile() const
{
return {};
}
};

} // namespace core
5 changes: 5 additions & 0 deletions src/core/deviceio_trackers/cpp/controller_tracker.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,9 @@ void ControllerTracker::apply_right_haptic_feedback(const ITrackerSession& sessi
.apply_right_haptic_feedback(amplitude, frequency_hz, duration_s);
}

std::string ControllerTracker::get_interaction_profile(const ITrackerSession& session) const
{
return static_cast<const IControllerTrackerImpl&>(session.get_tracker_impl(*this)).get_interaction_profile();
}

} // namespace core
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
#include <deviceio_base/controller_tracker_base.hpp>
#include <schema/controller_generated.h>

#include <string>

namespace core
{

Expand Down Expand Up @@ -50,6 +52,10 @@ class ControllerTracker : public ITracker
float frequency_hz,
float duration_s) const;

/// The interaction profile bound for the controllers; see
/// :cpp:func:`IControllerTrackerImpl::get_interaction_profile`.
std::string get_interaction_profile(const ITrackerSession& session) const;

private:
static constexpr const char* TRACKER_NAME = "ControllerTracker";
};
Expand Down
12 changes: 11 additions & 1 deletion src/core/deviceio_trackers/python/tracker_bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,17 @@ PYBIND11_MODULE(_deviceio_trackers, m)
float frequency_hz, float duration_s)
{ self.apply_right_haptic_feedback(session, amplitude, frequency_hz, duration_s); },
py::arg("session"), py::arg("amplitude"), py::arg("frequency_hz") = 0.0f, py::arg("duration_s") = 0.0f,
"Apply one frame of haptic vibration to the right controller. See apply_left_haptic_feedback.");
"Apply one frame of haptic vibration to the right controller. See apply_left_haptic_feedback.")
.def(
"get_interaction_profile",
[](const core::ControllerTracker& self, const core::ITrackerSession& session)
{ return self.get_interaction_profile(session); },
py::arg("session"),
"Get the interaction profile the runtime bound for the controllers, as an OpenXR\n"
"path (e.g. '/interaction_profiles/bytedance/pico4_controller').\n\n"
"Identifies the physical headset behind a streaming runtime, which otherwise\n"
"presents every device identically. Returns an empty string until actions have\n"
"synced at least once, so treat empty as 'not known yet' rather than an error.");

py::enum_<core::MessageChannelStatus>(m, "MessageChannelStatus")
.value("CONNECTING", core::MessageChannelStatus::CONNECTING)
Expand Down
68 changes: 68 additions & 0 deletions src/core/live_trackers/cpp/live_controller_tracker_impl.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ LiveControllerTrackerImpl::LiveControllerTrackerImpl(const OpenXRSessionHandles&
std::unique_ptr<ControllerMcapChannels> mcap_channels)
: core_funcs_(OpenXRCoreFunctions::load(handles.instance, handles.xrGetInstanceProcAddr)),
time_converter_(handles),
instance_(handles.instance),
session_(handles.session),
base_space_(handles.space),
left_hand_path_(xr_path_from_string(core_funcs_, handles.instance, "/user/hand/left")),
Expand Down Expand Up @@ -314,6 +315,27 @@ LiveControllerTrackerImpl::LiveControllerTrackerImpl(const OpenXRSessionHandles&
throw std::runtime_error("Failed to suggest interaction profile bindings: " + std::to_string(result));
}

// Also offer the PICO profile. The runtime binds whichever matches the
// connected hardware, which is what makes the headset identifiable through
// a streaming runtime that presents every device identically.
//
// Suggested separately and tolerantly on purpose: the call rejects a whole
// profile if any binding path is unsupported, and controller input must keep
// working even when a profile is refused. Measured accepted on both a PICO 4
// Ultra and a Quest 3.
XrInteractionProfileSuggestedBinding pico_bindings{ XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING };
pico_bindings.next = &binding_ctx_info;
pico_bindings.interactionProfile =
xr_path_from_string(core_funcs_, handles.instance, "/interaction_profiles/bytedance/pico4_controller");
pico_bindings.countSuggestedBindings = static_cast<uint32_t>(bindings.size());
pico_bindings.suggestedBindings = bindings.data();
if (XR_FAILED(core_funcs_.xrSuggestInteractionProfileBindings(handles.instance, &pico_bindings)))
{
std::cout << "[ControllerTracker] PICO interaction profile not accepted; headset identification "
"will report no profile"
<< std::endl;
}

// Create session action context (makes the instance context immutable)
session_action_context_ =
createSessionActionContext(action_ctx_funcs_, handles.session, instance_action_context_.get());
Expand All @@ -337,6 +359,44 @@ LiveControllerTrackerImpl::LiveControllerTrackerImpl(const OpenXRSessionHandles&
std::cout << "ControllerTracker initialized (left + right) with action context" << std::endl;
}

std::string LiveControllerTrackerImpl::get_interaction_profile() const
{
return interaction_profile_;
}

std::string LiveControllerTrackerImpl::query_interaction_profile() const
{
if (action_ctx_funcs_.get_current_interaction_profile == nullptr)
{
return {};
}

// Both hands normally report the same profile; take the left and fall back to
// the right so one inactive controller does not hide the headset's identity.
for (const XrPath hand_path : { left_hand_path_, right_hand_path_ })
{
XrInteractionProfileGetInfo2NV get_info{ XR_TYPE_INTERACTION_PROFILE_GET_INFO_2_NV };
get_info.topLevelUserPath = hand_path;
get_info.sessionActionContext = session_action_context_.get();

XrInteractionProfileState profile_state{ XR_TYPE_INTERACTION_PROFILE_STATE };
if (XR_FAILED(action_ctx_funcs_.get_current_interaction_profile(session_, &get_info, &profile_state)) ||
profile_state.interactionProfile == XR_NULL_PATH)
{
continue;
}

char buf[XR_MAX_PATH_LENGTH]{};
uint32_t written = 0;
if (XR_SUCCEEDED(
core_funcs_.xrPathToString(instance_, profile_state.interactionProfile, sizeof(buf), &written, buf)))
{
return std::string(buf);
}
}
return {};
}

void LiveControllerTrackerImpl::update(int64_t monotonic_time_ns)
{
last_update_time_ = monotonic_time_ns;
Expand All @@ -362,6 +422,14 @@ void LiveControllerTrackerImpl::update(int64_t monotonic_time_ns)
throw std::runtime_error("[ControllerTracker] xrSyncActions2NV failed: " + std::to_string(result));
}

// Nothing is bound until actions have synced, so this cannot be resolved at
// construction. Re-read only when the runtime reports a change (a headset
// swap), which keeps this off the per-frame IPC path.
if (interaction_profile_.empty() || sync_state.interactionProfileChanged)
{
interaction_profile_ = query_interaction_profile();
}

auto update_controller = [&](XrPath hand_path, const XrSpacePtr& grip_space, const XrSpacePtr& aim_space,
ControllerSnapshotTrackedT& tracked)
{
Expand Down
9 changes: 9 additions & 0 deletions src/core/live_trackers/cpp/live_controller_tracker_impl.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,12 @@ class LiveControllerTrackerImpl : public IControllerTrackerImpl
const ControllerSnapshotTrackedT& get_right_controller() const override;
void apply_left_haptic_feedback(float amplitude, float frequency_hz, float duration_s) const override;
void apply_right_haptic_feedback(float amplitude, float frequency_hz, float duration_s) const override;
std::string get_interaction_profile() const override;

private:
std::string query_interaction_profile() const;

public:
private:
// Internal side selector for the shared haptic implementation. The public
// surface stays split (apply_left/right) to match get_left/right_controller.
Expand All @@ -60,7 +65,11 @@ class LiveControllerTrackerImpl : public IControllerTrackerImpl
const OpenXRCoreFunctions core_funcs_;
XrTimeConverter time_converter_;

XrInstance instance_;
XrSession session_;

// Cached on sync: querying per call would cost an IPC round trip per frame.
std::string interaction_profile_;
XrSpace base_space_;

XrPath left_hand_path_;
Expand Down
14 changes: 14 additions & 0 deletions src/core/oxr_utils/cpp/inc/oxr_utils/oxr_funcs.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ struct OpenXRCoreFunctions

// Action system functions (for controller tracking)
PFN_xrStringToPath xrStringToPath;
PFN_xrPathToString xrPathToString;
PFN_xrCreateActionSet xrCreateActionSet;
PFN_xrDestroyActionSet xrDestroyActionSet;
PFN_xrCreateAction xrCreateAction;
Expand Down Expand Up @@ -89,6 +90,7 @@ struct OpenXRCoreFunctions
// Action system functions (optional, for controller tracking)
// Note: These don't fail the load if not available, as they're only needed by controller tracker
getProcAddr(instance, "xrStringToPath", reinterpret_cast<PFN_xrVoidFunction*>(&results.xrStringToPath));
getProcAddr(instance, "xrPathToString", reinterpret_cast<PFN_xrVoidFunction*>(&results.xrPathToString));
getProcAddr(instance, "xrCreateActionSet", reinterpret_cast<PFN_xrVoidFunction*>(&results.xrCreateActionSet));
getProcAddr(instance, "xrDestroyActionSet", reinterpret_cast<PFN_xrVoidFunction*>(&results.xrDestroyActionSet));
getProcAddr(instance, "xrCreateAction", reinterpret_cast<PFN_xrVoidFunction*>(&results.xrCreateAction));
Expand Down Expand Up @@ -149,6 +151,9 @@ struct ActionContextFunctions
PFN_xrCreateSessionActionContextNV create_session_ctx;
PFN_xrDestroySessionActionContextNV destroy_session_ctx;
PFN_xrSyncActions2NV sync_actions_2;
// Optional: null on runtimes predating the query, so null-check before use.
// Loaded without loadExtensionFunction on purpose -- see load() below.
PFN_xrGetCurrentInteractionProfile2NV get_current_interaction_profile;

static ActionContextFunctions load(XrInstance instance, PFN_xrGetInstanceProcAddr getProcAddr)
{
Expand All @@ -163,6 +168,15 @@ struct ActionContextFunctions
reinterpret_cast<PFN_xrVoidFunction*>(&f.destroy_session_ctx));
loadExtensionFunction(
instance, getProcAddr, "xrSyncActions2NV", reinterpret_cast<PFN_xrVoidFunction*>(&f.sync_actions_2));
// Looked up directly rather than through loadExtensionFunction, which
// throws when a symbol is missing. This one is genuinely optional: a
// runtime without it must still construct a controller tracker, so leave
// the pointer null and let the caller report "no profile".
if (XR_FAILED(getProcAddr(instance, "xrGetCurrentInteractionProfile2NV",
reinterpret_cast<PFN_xrVoidFunction*>(&f.get_current_interaction_profile))))
{
f.get_current_interaction_profile = nullptr;
}

if (!f.create_instance_ctx || !f.destroy_instance_ctx || !f.create_session_ctx || !f.destroy_session_ctx ||
!f.sync_actions_2)
Expand Down
85 changes: 85 additions & 0 deletions src/core/retargeting_engine_tests/python/test_headset_identity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""
Tests for identifying a headset from its OpenXR interaction profile.

The two profiles asserted against real hardware are marked below. Everything
else must resolve to None rather than a guess: a wrong answer here feeds a
robot the wrong skeleton correction, and permuted joint axes are not a
recoverable error.
"""

import pytest

from isaacteleop.cloudxr.headset import (
HEADSET_BY_INTERACTION_PROFILE,
identify_headset,
)

# Measured: PICO 4 Ultra and Quest 3 over CloudXR, both hands, both sessions.
MEASURED = {
"/interaction_profiles/bytedance/pico4_controller": "pico",
"/interaction_profiles/oculus/touch_controller": "quest",
}


class TestMeasuredHardware:
@pytest.mark.parametrize("profile,expected", sorted(MEASURED.items()))
def test_matches_observed_hardware(self, profile, expected):
assert identify_headset(profile) == expected

def test_the_two_headsets_are_distinguished(self):
"""The whole point: these must not collapse to the same answer."""
answers = {identify_headset(p) for p in MEASURED}
assert answers == {"pico", "quest"}
assert None not in answers


class TestUnknownInput:
@pytest.mark.parametrize(
"value",
[
None,
"",
"/interaction_profiles/khr/simple_controller",
"/interaction_profiles/htc/vive_controller",
"/interaction_profiles/valve/index_controller",
"quest3",
"pico",
"bytedance/pico4_controller",
"/INTERACTION_PROFILES/BYTEDANCE/PICO4_CONTROLLER",
" /interaction_profiles/bytedance/pico4_controller ",
],
)
def test_unknown_returns_none(self, value):
"""Includes 'quest3', the CloudXR device-profile string that names the
wrong vendor on a PICO session, and near-misses that must not fuzzy-match."""
assert identify_headset(value) is None

def test_empty_is_not_an_error(self):
"""Nothing is bound until actions sync, so empty is an expected state."""
assert identify_headset("") is None


class TestTable:
def test_every_value_is_a_known_headset(self):
assert set(HEADSET_BY_INTERACTION_PROFILE.values()) <= {"pico", "quest"}

def test_keys_are_full_openxr_paths(self):
for profile in HEADSET_BY_INTERACTION_PROFILE:
assert profile.startswith("/interaction_profiles/")

def test_vendor_prefix_agrees_with_headset(self):
"""A bytedance path must never map to quest, or vice versa."""
for profile, headset in HEADSET_BY_INTERACTION_PROFILE.items():
if "/bytedance/" in profile:
assert headset == "pico", profile
else:
assert headset == "quest", profile

def test_no_duplicate_or_empty_keys(self):
assert all(HEADSET_BY_INTERACTION_PROFILE)
assert len(HEADSET_BY_INTERACTION_PROFILE) == len(
set(HEADSET_BY_INTERACTION_PROFILE)
)
7 changes: 6 additions & 1 deletion src/python/isaacteleop/cloudxr/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@

"""CloudXR integration for isaacteleop."""

from .headset import HEADSET_BY_INTERACTION_PROFILE, identify_headset
from .launcher import CloudXRLauncher

__all__ = ["CloudXRLauncher"]
__all__ = [
"CloudXRLauncher",
"identify_headset",
"HEADSET_BY_INTERACTION_PROFILE",
]
53 changes: 53 additions & 0 deletions src/python/isaacteleop/cloudxr/headset.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""
Identify the headset behind a streaming runtime from its interaction profile.

A remote-streaming runtime presents every headset through the same OpenXR
surface, so system and device-profile fields do not distinguish them: CloudXR
reports ``NV_DEVICE_PROFILE=Quest3`` with a PICO connected. The interaction
profile the runtime binds for the controllers does track the real hardware,
measured on a PICO 4 Ultra and a Quest 3.

Callers that drive hardware from this should treat ``None`` as "ask the
operator", never as a default: applying a skeleton correction meant for the
other headset feeds a robot permuted joint axes.
"""

from typing import Optional

# OpenXR interaction profile -> headset key.
#
# bytedance/pico4_controller is a positive match: only PICO hardware binds it.
# oculus/touch_controller is identification by elimination -- it is the generic
# Meta-compatible fallback, and a Quest 3 binds it when no Meta-specific profile
# is suggested. Anything else stays unknown rather than guessing.
HEADSET_BY_INTERACTION_PROFILE = {
"/interaction_profiles/bytedance/pico4_controller": "pico",
"/interaction_profiles/bytedance/pico_neo3_controller": "pico",
"/interaction_profiles/bytedance/pico_g3_controller": "pico",
"/interaction_profiles/meta/touch_controller_plus": "quest",
"/interaction_profiles/meta/touch_controller_quest_2": "quest",
"/interaction_profiles/facebook/touch_controller_pro": "quest",
"/interaction_profiles/oculus/touch_controller": "quest",
}


def identify_headset(interaction_profile: Optional[str]) -> Optional[str]:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I better place for this will be python/isaacteleop/cloudxr

context: the deviceio module is meant to be C++ only and the python bits are its bindings.

"""
Map an OpenXR interaction profile to a headset key.

Args:
interaction_profile: Profile path from
:meth:`ControllerTracker.get_interaction_profile`. Empty or ``None``
means the runtime has not bound one yet.

Returns:
``"pico"``, ``"quest"``, or ``None`` when the headset cannot be
determined. ``None`` is a normal early-session state: nothing is bound
until actions have synced at least once.
"""
if not interaction_profile:
return None
return HEADSET_BY_INTERACTION_PROFILE.get(interaction_profile)
Loading