feat: add Haply Robotics hand tracking plugin#267
feat: add Haply Robotics hand tracking plugin#267vickybot911 wants to merge 3 commits intoNVIDIA:mainfrom
Conversation
Add a new plugin for Haply Robotics Inverse3 and VerseGrip devices, bridging the Haply SDK WebSocket interface to the Isaac Teleop framework via OpenXR hand tracking injection. Components: - haply_plugin_core: shared library (libIsaacTeleopPluginsHaply.so) implementing WebSocket client, JSON parsing, OpenXR HandInjector integration, and DeviceIO controller tracking - haply_hand_plugin: plugin executable running at 90Hz - haply_hand_tracker_printer: standalone CLI tool for verifying Haply device data without OpenXR dependencies Architecture: - Background I/O thread communicates with Haply SDK via WebSocket (ws://localhost:10001, configurable via HAPLY_WEBSOCKET_HOST/PORT) - Maps Inverse3 cursor position and VerseGrip orientation to OpenXR XrHandJointLocationEXT format (26 joints) - WRIST and PALM joints receive real tracked data; finger joints are placed at wrist position with VALID but not TRACKED flags - Controller aim pose provides root positioning (same as Manus plugin) - Exponential backoff reconnection on WebSocket errors No build-time SDK dependency — Haply SDK is a runtime service. Vendored: nlohmann/json (MIT, single header) for JSON parsing. Signed-off-by: Vicky <vickybot911@gmail.com>
📝 WalkthroughWalkthroughAdds a new Haply hand‑tracking plugin: CMake build integration, core shared library with a WebSocket client and OpenXR injection, a runtime executable (90 Hz update loop), a CLI printer tool, plugin manifest, and documentation. Changes
Sequence Diagram(s)sequenceDiagram
participant App as haply_hand_plugin
participant Tracker as HaplyTracker
participant WS as HaplyWebSocket
participant SDK as Haply SDK
participant Injector as HandInjector
participant OXR as OpenXR
App->>Tracker: instance() / update()
activate Tracker
Tracker->>WS: connect(host,port,path)
WS->>SDK: WebSocket HTTP upgrade (101)
SDK-->>WS: 101 Switching Protocols
loop ~11.11ms (90 Hz)
App->>Tracker: update()
Tracker->>WS: recv_text(JSON)
Tracker->>Tracker: parse JSON -> update internal state
Tracker->>Injector: inject_hand_data()
Injector->>OXR: submit hand joint poses
OXR-->>Injector: ack
end
App->>Tracker: shutdown()
Tracker->>WS: close()
deactivate Tracker
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
📝 Coding Plan
Comment |
There was a problem hiding this comment.
Actionable comments posted: 8
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/plugins/haply/app/main.cpp`:
- Around line 24-33: The infinite loop using while (true) that repeatedly calls
tracker.update() and sleeps_until(frame_start + target_frame_duration) makes the
trailing return 0 unreachable; either remove the unreachable return statement or
convert the loop to use a mutable running flag checked in the condition (e.g.,
bool running = true; while (running) { ... }) and install a signal handler to
set running = false for graceful shutdown so that tracker.update() can exit
cleanly and the function can return normally.
In `@src/plugins/haply/core/haply_hand_tracking_plugin.cpp`:
- Around line 409-412: The port parsing using std::atoi is unsafe because it
returns 0 on failure; in haply_hand_tracking_plugin.cpp replace the
std::atoi-based parsing for port_env/ws_port with std::stoul-based parsing:
first check port_env is non-null and non-empty, call std::stoul inside a
try/catch, validate the resulting unsigned long is <= 65535 and > 0, then
static_cast to uint16_t; on exception or out-of-range value fall back to the
default 10001 and optionally log the invalid value so ws_port always gets a
validated port number.
- Line 320: Validate and bound payload_len before allocating
std::vector<uint8_t> data(payload_len): define a sensible MAX_PAYLOAD_SIZE
(e.g., a few MB), check if payload_len == 0 or payload_len > MAX_PAYLOAD_SIZE,
and reject the frame by logging an error and returning/closing the WebSocket
instead of allocating; update the allocation site that uses payload_len and data
to use the validated length and ensure any error path cleans up/avoids
processing the malformed frame.
- Around line 710-715: The field m_grip_interpolant is computed from
snapshot.buttons (button_0..3) with grip_speed but never used to influence
finger poses, so either (A) add a clear TODO comment above this block explaining
the intended use (e.g., "TODO: apply m_grip_interpolant to finger joint
positions / blend wrist->fist pose") so future work knows this smoothing is
intentional, or (B) remove the computation here and delete the
m_grip_interpolant declaration from the class header to avoid dead state;
reference m_grip_interpolant, snapshot.buttons, and grip_speed when making the
change so the correct code is updated.
In `@src/plugins/haply/README.md`:
- Line 74: Replace the British spelling "Synthesised" with the American spelling
"Synthesized" in the README entry for "**Finger joints**" so the line reads
"**Finger joints**: Synthesized at the wrist position (valid but not
individually tracked)"; locate the phrase "**Finger joints**" in
src/plugins/haply/README.md and update that word only to preserve the rest of
the sentence.
In `@src/plugins/haply/tools/haply_hand_tracker_printer.cpp`:
- Around line 31-33: The lightweight WebSocket client duplicated in
haply_hand_tracker_printer.cpp should be factored into a reusable header-only
helper to avoid future duplication; create a new header (e.g.,
websocket_helper.h) that exposes clear functions like connectWebSocket,
sendWebSocketMessage, receiveWebSocketMessage, and closeWebSocket, move the
WebSocket implementation out of haply_hand_tracker_printer.cpp into that header,
update haply_hand_tracker_printer.cpp to call these helper functions, and ensure
the helper is self-contained with only standard or project-wide dependencies so
other tools can include it without pulling plugin internals.
- Around line 307-310: The port parsing using std::atoi for port_env is unsafe;
update the logic (around host_env/port_env, host, and port variables in
haply_hand_tracker_printer.cpp) to parse the environment string with std::stoul
(or equivalent), catch std::invalid_argument/std::out_of_range, validate the
resulting value is within 1–65535, and only then static_cast to uint16_t; on any
parse/validation failure fall back to the default port 10001 and/or log the
invalid value so connections don't silently use 0.
- Around line 184-188: The code allocates std::vector<uint8_t> data(plen) using
an unvalidated plen parsed from the wire; add a sanity check before allocating:
ensure plen is non-negative and <= a defined upper limit (e.g., kMaxPayloadSize
or MAX_PAYLOAD_BYTES) and return false (or handle error) if it exceeds the
limit, then proceed to allocate and call recv_raw; reference and update the
check at the place where plen is used and where recv_raw(...) is called so
malformed frames cannot cause OOM.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: cc3305fd-bb37-40af-a6f8-fe9a1b93e7cc
📒 Files selected for processing (15)
CMakeLists.txtsrc/plugins/haply/.gitignoresrc/plugins/haply/CMakeLists.txtsrc/plugins/haply/CMakePresets.jsonsrc/plugins/haply/CMakePresets.json.licensesrc/plugins/haply/README.mdsrc/plugins/haply/app/CMakeLists.txtsrc/plugins/haply/app/main.cppsrc/plugins/haply/core/CMakeLists.txtsrc/plugins/haply/core/haply_hand_tracking_plugin.cppsrc/plugins/haply/inc/core/haply_hand_tracking_plugin.hppsrc/plugins/haply/plugin.yamlsrc/plugins/haply/third_party/nlohmann/json.hppsrc/plugins/haply/tools/CMakeLists.txtsrc/plugins/haply/tools/haply_hand_tracker_printer.cpp
| // ============================================================================ | ||
| // Lightweight WebSocket client (duplicated from the plugin to stay standalone) | ||
| // ============================================================================ |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Code duplication is acknowledged; consider future consolidation.
The comment notes this WebSocket implementation is duplicated to keep the tool standalone. This is acceptable for a CLI diagnostic utility, but if more tools emerge, extracting a shared header-only WebSocket helper could reduce maintenance burden.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/plugins/haply/tools/haply_hand_tracker_printer.cpp` around lines 31 - 33,
The lightweight WebSocket client duplicated in haply_hand_tracker_printer.cpp
should be factored into a reusable header-only helper to avoid future
duplication; create a new header (e.g., websocket_helper.h) that exposes clear
functions like connectWebSocket, sendWebSocketMessage, receiveWebSocketMessage,
and closeWebSocket, move the WebSocket implementation out of
haply_hand_tracker_printer.cpp into that header, update
haply_hand_tracker_printer.cpp to call these helper functions, and ensure the
helper is self-contained with only standard or project-wide dependencies so
other tools can include it without pulling plugin internals.
- Add WebSocket payload size limit (16 MB) to prevent OOM on malformed frames - Add SO_RCVTIMEO (5s) connect timeout to prevent indefinite blocking - Replace std::atoi with std::stoul + range validation for port parsing - Add signal handler for graceful shutdown in plugin executable - Add TODO comment for unused m_grip_interpolant (future finger synthesis) - Fix British spelling: Synthesised → Synthesized - Apply same payload limit and port validation fixes to printer tool Signed-off-by: Vicky <vickybot911@gmail.com>
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/plugins/haply/core/haply_hand_tracking_plugin.cpp`:
- Around line 658-666: The lambda process_hand currently calls injector.reset()
unconditionally when the hand doesn't match; change it to only reset when
injector is non-null to avoid redundant resets—i.e., check the unique_ptr
(injector) for truthiness before calling reset (affects calls like
process_hand(false, m_right_injector) / process_hand(true, m_left_injector) and
the injector parameter inside the lambda).
- Around line 420-423: The code reads environment variables using getenv with
keys "HAPLY_WS_HOST" and "HAPLY_WS_PORT"; update these getenv calls to use the
documented names "HAPLY_WEBSOCKET_HOST" and "HAPLY_WEBSOCKET_PORT" so they match
README and the printer tool, keeping the existing fallbacks (ws_host default
"127.0.0.1" and ws_port default 10001) and the same variables (host_env,
port_env, ws_host, ws_port) to minimize other changes.
In `@src/plugins/haply/tools/haply_hand_tracker_printer.cpp`:
- Around line 79-82: The MiniWebSocket code in haply_hand_tracker_printer.cpp
currently sets TCP_NODELAY on fd_ but lacks the receive timeout used by
HaplyWebSocket::connect(); add a SO_RCVTIMEO setsockopt call after the existing
setsockopt(fd_, IPPROTO_TCP, TCP_NODELAY, ...) to set a 5-second timeout (create
a struct timeval tv with tv_sec=5, tv_usec=0 and pass sizeof(tv) with
SOL_SOCKET, SO_RCVTIMEO). Ensure you reference fd_ and reuse the same error
handling pattern around setsockopt as other socket options.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: de57bafd-2e55-4c91-a694-f926c275f8dc
📒 Files selected for processing (4)
src/plugins/haply/README.mdsrc/plugins/haply/app/main.cppsrc/plugins/haply/core/haply_hand_tracking_plugin.cppsrc/plugins/haply/tools/haply_hand_tracker_printer.cpp
| auto process_hand = [&](bool target_is_left, std::unique_ptr<plugin_utils::HandInjector>& injector) | ||
| { | ||
| // Only process the hand that matches the Haply device handedness | ||
| if (target_is_left != is_left) | ||
| { | ||
| // Other hand has no data — reset injector so runtime sees isActive=false | ||
| injector.reset(); | ||
| return; | ||
| } |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Consider caching handedness to avoid redundant injector resets.
Currently, if the device is configured for the left hand, m_right_injector.reset() is called every frame in process_hand(false, m_right_injector). While harmless (resetting a null unique_ptr is a no-op after the first call), the logic could be slightly cleaner by checking if the injector is already null before resetting.
♻️ Optional: Skip redundant reset
// Only process the hand that matches the Haply device handedness
if (target_is_left != is_left)
{
// Other hand has no data — reset injector so runtime sees isActive=false
- injector.reset();
+ if (injector)
+ {
+ injector.reset();
+ }
return;
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/plugins/haply/core/haply_hand_tracking_plugin.cpp` around lines 658 -
666, The lambda process_hand currently calls injector.reset() unconditionally
when the hand doesn't match; change it to only reset when injector is non-null
to avoid redundant resets—i.e., check the unique_ptr (injector) for truthiness
before calling reset (affects calls like process_hand(false, m_right_injector) /
process_hand(true, m_left_injector) and the injector parameter inside the
lambda).
- Rename HAPLY_WS_HOST/HAPLY_WS_PORT to HAPLY_WEBSOCKET_HOST/HAPLY_WEBSOCKET_PORT in core plugin to match README and printer tool - Add SO_RCVTIMEO (5s) to printer tool's MiniWebSocket for consistency Signed-off-by: Vicky <vickybot911@gmail.com>
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (1)
src/plugins/haply/core/haply_hand_tracking_plugin.cpp (1)
660-668: 🧹 Nitpick | 🔵 TrivialConsider guarding redundant
injector.reset()calls.When the hand doesn't match the device handedness,
injector.reset()is called every frame. After the first call, resetting a nullunique_ptris a no-op. While harmless, a guard would clarify intent.♻️ Optional: Skip redundant reset
// Only process the hand that matches the Haply device handedness if (target_is_left != is_left) { // Other hand has no data — reset injector so runtime sees isActive=false - injector.reset(); + if (injector) + { + injector.reset(); + } return; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/plugins/haply/core/haply_hand_tracking_plugin.cpp` around lines 660 - 668, The lambda process_hand currently calls injector.reset() unconditionally when the hand doesn't match is_left; change this to only call reset if the unique_ptr is non-null to avoid redundant no-op resets and clarify intent: replace the unconditional injector.reset() with a guarded call (e.g., if (injector) injector.reset()) inside the process_hand lambda that takes std::unique_ptr<plugin_utils::HandInjector>& injector and checks target_is_left against is_left.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/plugins/haply/core/haply_hand_tracking_plugin.cpp`:
- Around line 379-388: HaplyTracker::update currently dereferences
m_deviceio_session without a null check; add a guard at the start of
HaplyTracker::update to return early if m_deviceio_session is null (e.g., if
(!m_deviceio_session) return;), so callers who catch initialize() exceptions
won't hit a null pointer when calling update(), then proceed to call
m_deviceio_session->update() and inject_hand_data() only when the session
exists.
- Around line 544-634: The code holds m_state_mutex while calling ws.send_text
which can block; to fix, capture the needed value (m_state.inverse3_device_id)
into a local string while holding the lock (lock_guard on m_state_mutex), then
release the lock and construct/send the zero-force command with ws.send_text
using that local variable; update the block around m_state.inverse3_device_id
and ws.send_text (referencing m_state_mutex, m_state.inverse3_device_id, and
ws.send_text) so no mutex is held during the send.
---
Duplicate comments:
In `@src/plugins/haply/core/haply_hand_tracking_plugin.cpp`:
- Around line 660-668: The lambda process_hand currently calls injector.reset()
unconditionally when the hand doesn't match is_left; change this to only call
reset if the unique_ptr is non-null to avoid redundant no-op resets and clarify
intent: replace the unconditional injector.reset() with a guarded call (e.g., if
(injector) injector.reset()) inside the process_hand lambda that takes
std::unique_ptr<plugin_utils::HandInjector>& injector and checks target_is_left
against is_left.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 0a512ea8-c614-41ee-aa01-f9c6fb84d9ab
📒 Files selected for processing (2)
src/plugins/haply/core/haply_hand_tracking_plugin.cppsrc/plugins/haply/tools/haply_hand_tracker_printer.cpp
| void HaplyTracker::update() | ||
| { | ||
| // Update DeviceIOSession which handles time conversion and tracker updates internally | ||
| if (!m_deviceio_session->update()) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| inject_hand_data(); | ||
| } |
There was a problem hiding this comment.
Missing null check on m_deviceio_session before use.
If initialize() throws an exception (lines 489-497), the HaplyTracker object is constructed but m_deviceio_session remains null. Callers that catch the exception and still call update() would dereference a null pointer.
🛡️ Proposed fix
void HaplyTracker::update()
{
+ if (!m_deviceio_session)
+ {
+ return;
+ }
+
// Update DeviceIOSession which handles time conversion and tracker updates internally
if (!m_deviceio_session->update())
{
return;
}
inject_hand_data();
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/plugins/haply/core/haply_hand_tracking_plugin.cpp` around lines 379 -
388, HaplyTracker::update currently dereferences m_deviceio_session without a
null check; add a guard at the start of HaplyTracker::update to return early if
m_deviceio_session is null (e.g., if (!m_deviceio_session) return;), so callers
who catch initialize() exceptions won't hit a null pointer when calling
update(), then proceed to call m_deviceio_session->update() and
inject_hand_data() only when the session exists.
| try | ||
| { | ||
| json j = json::parse(msg); | ||
|
|
||
| std::lock_guard<std::mutex> lock(m_state_mutex); | ||
|
|
||
| // Parse inverse3 array | ||
| if (j.contains("inverse3") && j["inverse3"].is_array() && !j["inverse3"].empty()) | ||
| { | ||
| const auto& inv3 = j["inverse3"][0]; | ||
| if (inv3.contains("device_id")) | ||
| { | ||
| m_state.inverse3_device_id = inv3["device_id"].get<std::string>(); | ||
| } | ||
| // Handedness from config (only in first message) | ||
| if (inv3.contains("config") && inv3["config"].contains("handedness")) | ||
| { | ||
| m_state.handedness = inv3["config"]["handedness"].get<std::string>(); | ||
| } | ||
| if (inv3.contains("state")) | ||
| { | ||
| const auto& st = inv3["state"]; | ||
| if (st.contains("cursor_position")) | ||
| { | ||
| const auto& pos = st["cursor_position"]; | ||
| m_state.cursor_position.x = pos.value("x", 0.0f); | ||
| m_state.cursor_position.y = pos.value("y", 0.0f); | ||
| m_state.cursor_position.z = pos.value("z", 0.0f); | ||
| } | ||
| if (st.contains("cursor_velocity")) | ||
| { | ||
| const auto& vel = st["cursor_velocity"]; | ||
| m_state.cursor_velocity.x = vel.value("x", 0.0f); | ||
| m_state.cursor_velocity.y = vel.value("y", 0.0f); | ||
| m_state.cursor_velocity.z = vel.value("z", 0.0f); | ||
| } | ||
| } | ||
| m_state.has_data = true; | ||
| } | ||
|
|
||
| // Parse wireless_verse_grip array | ||
| if (j.contains("wireless_verse_grip") && j["wireless_verse_grip"].is_array() && | ||
| !j["wireless_verse_grip"].empty()) | ||
| { | ||
| const auto& vg = j["wireless_verse_grip"][0]; | ||
| if (vg.contains("device_id")) | ||
| { | ||
| m_state.versegrip_device_id = vg["device_id"].get<std::string>(); | ||
| } | ||
| if (vg.contains("state")) | ||
| { | ||
| const auto& st = vg["state"]; | ||
| if (st.contains("orientation")) | ||
| { | ||
| const auto& ori = st["orientation"]; | ||
| m_state.orientation.w = ori.value("w", 1.0f); | ||
| m_state.orientation.x = ori.value("x", 0.0f); | ||
| m_state.orientation.y = ori.value("y", 0.0f); | ||
| m_state.orientation.z = ori.value("z", 0.0f); | ||
| } | ||
| if (st.contains("buttons")) | ||
| { | ||
| const auto& btn = st["buttons"]; | ||
| m_state.buttons.button_0 = btn.value("button_0", false); | ||
| m_state.buttons.button_1 = btn.value("button_1", false); | ||
| m_state.buttons.button_2 = btn.value("button_2", false); | ||
| m_state.buttons.button_3 = btn.value("button_3", false); | ||
| } | ||
| } | ||
| m_state.has_data = true; | ||
| } | ||
|
|
||
| // Send a command to keep receiving updates (set zero force) | ||
| if (!m_state.inverse3_device_id.empty()) | ||
| { | ||
| json cmd; | ||
| cmd["inverse3"] = json::array(); | ||
| json dev; | ||
| dev["device_id"] = m_state.inverse3_device_id; | ||
| dev["commands"]["set_cursor_force"]["values"]["x"] = 0; | ||
| dev["commands"]["set_cursor_force"]["values"]["y"] = 0; | ||
| dev["commands"]["set_cursor_force"]["values"]["z"] = 0; | ||
| cmd["inverse3"].push_back(dev); | ||
| ws.send_text(cmd.dump()); | ||
| } | ||
| } | ||
| catch (const json::exception& e) | ||
| { | ||
| std::cerr << "[HaplyTracker] JSON parse error: " << e.what() << std::endl; | ||
| } | ||
| } |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Lock held during WebSocket send may cause contention.
The m_state_mutex is held (line 548) while sending the zero-force command via ws.send_text() (line 627). If the send blocks or is slow, this could delay get_raw_state() and inject_hand_data() calls on other threads.
♻️ Proposed fix: release lock before sending
try
{
json j = json::parse(msg);
- std::lock_guard<std::mutex> lock(m_state_mutex);
+ std::string device_id_copy;
+ {
+ std::lock_guard<std::mutex> lock(m_state_mutex);
// Parse inverse3 array
if (j.contains("inverse3") && j["inverse3"].is_array() && !j["inverse3"].empty())
{
// ... existing parsing code ...
}
// Parse wireless_verse_grip array
if (j.contains("wireless_verse_grip") && j["wireless_verse_grip"].is_array() &&
!j["wireless_verse_grip"].empty())
{
// ... existing parsing code ...
}
+ device_id_copy = m_state.inverse3_device_id;
+ } // lock released
// Send a command to keep receiving updates (set zero force)
- if (!m_state.inverse3_device_id.empty())
+ if (!device_id_copy.empty())
{
json cmd;
// ... build command ...
- dev["device_id"] = m_state.inverse3_device_id;
+ dev["device_id"] = device_id_copy;
// ...
ws.send_text(cmd.dump());
}
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/plugins/haply/core/haply_hand_tracking_plugin.cpp` around lines 544 -
634, The code holds m_state_mutex while calling ws.send_text which can block; to
fix, capture the needed value (m_state.inverse3_device_id) into a local string
while holding the lock (lock_guard on m_state_mutex), then release the lock and
construct/send the zero-force command with ws.send_text using that local
variable; update the block around m_state.inverse3_device_id and ws.send_text
(referencing m_state_mutex, m_state.inverse3_device_id, and ws.send_text) so no
mutex is held during the send.
Summary
Add a new plugin for Haply Robotics Inverse3 and VerseGrip devices at
src/plugins/haply/, following the same architecture as the existing Manus plugin.Components
haply_plugin_core(libIsaacTeleopPluginsHaply.so) — Core library with WebSocket client, JSON parsing, OpenXR HandInjector integration, and DeviceIO controller trackinghaply_hand_plugin— Plugin executable running at 90Hzhaply_hand_tracker_printer— Standalone CLI tool for verifying Haply device data (no OpenXR dependency)Architecture
ws://localhost:10001)XrHandJointLocationEXT[26]Mapping
Configuration
HAPLY_WEBSOCKET_HOST127.0.0.1HAPLY_WEBSOCKET_PORT10001Dependencies
Checklist
Tests in separate PR.
Summary by CodeRabbit
New Features
Documentation
Configuration