diff --git a/.gitignore b/.gitignore index 1355488147..6d80a581da 100644 --- a/.gitignore +++ b/.gitignore @@ -77,6 +77,9 @@ MUJOCO_LOG.TXT # Agent session logs (never committed) tmp/ +# Local unpacked toolchain/dependency trees are not repository inputs. +.local-deps/ + # SO-101 leader-gripper and follower-arm assets, fetched by # scripts/fetch-so-arm.sh into the package's assets directory, so only the # authored wrapper XML is tracked. diff --git a/AGENTS.md b/AGENTS.md index 0113244048..96b6c31c09 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -269,6 +269,8 @@ pre-commit install --hook-type commit-msg - Format touched Python source with `ruff format` before staging. If the pre-commit hook rewrites a file, restage it and rerun the hooks. - **REUSE:** files covered by the REUSE hook need **`SPDX-FileCopyrightText`** and **`SPDX-License-Identifier`** in the form the repo already uses (for example the HTML comment block at the top of `README.md` also applies to **`AGENTS.md`** and similar docs). +- Keep local dependency or toolchain trees outside the repository or ignored: + the REUSE `--fix` hook enumerates non-ignored files and can rewrite them. - **C++ formatting is enforced by CI, not pre-commit.** The hook set runs `ruff` for Python but does **not** run `clang-format`; CI (`build-ubuntu.yml`) installs **`clang-format-14`** and rejects unformatted C++ as `-Wclang-format-violations`. Before pushing, format touched C++ with the system `clang-format` (match CI's version 14) and verify: ```bash diff --git a/examples/sharpa_avatar/README.md b/examples/sharpa_avatar/README.md index 1806a04b04..b04b11afb1 100644 --- a/examples/sharpa_avatar/README.md +++ b/examples/sharpa_avatar/README.md @@ -26,7 +26,7 @@ cmake -B build \ cmake --build build --parallel cmake --install build uv pip install -e ./examples/sharpa_avatar -python -m isaaccapture_examples.sharpa_avatar +python -m isaaccapture_examples.sharpa_avatar --print-health ``` Open the URL it prints (default ). The left hand is cyan @@ -43,6 +43,7 @@ above first. `uv pip install -e` pulls `viser`; CloudXR comes from the ```text --no-haptic disable pinch feedback --no-viz terminal + haptic only (no browser view) +--print-health print Sharpa device health initially and on changes --host / --port viser bind (default 127.0.0.1:8080) --no-launch-plugin connect to an already running plugin --no-launch-cloudxr-runtime connect to an already running CloudXR runtime diff --git a/examples/sharpa_avatar/python/isaaccapture_examples/sharpa_avatar/__main__.py b/examples/sharpa_avatar/python/isaaccapture_examples/sharpa_avatar/__main__.py index 375c7b4155..b8dc23189b 100644 --- a/examples/sharpa_avatar/python/isaaccapture_examples/sharpa_avatar/__main__.py +++ b/examples/sharpa_avatar/python/isaaccapture_examples/sharpa_avatar/__main__.py @@ -6,4 +6,7 @@ from .live_view import main -sys.exit(main()) +try: + sys.exit(main()) +except KeyboardInterrupt: + pass diff --git a/examples/sharpa_avatar/python/isaaccapture_examples/sharpa_avatar/live_view.py b/examples/sharpa_avatar/python/isaaccapture_examples/sharpa_avatar/live_view.py index ff154b364e..401ca8cb1e 100644 --- a/examples/sharpa_avatar/python/isaaccapture_examples/sharpa_avatar/live_view.py +++ b/examples/sharpa_avatar/python/isaaccapture_examples/sharpa_avatar/live_view.py @@ -388,6 +388,21 @@ def _fmt_optional(group, *, kind: str, hand_input_index) -> str: return f"error:{type(exc).__name__}" +def _print_health_changes(snapshot, previous: dict[str, tuple]) -> None: + for device in snapshot.devices: + if not device.id.startswith(f"{PLUGIN_ROOT_ID}/"): + continue + current = (device.status, device.reason, device.error) + if previous.get(device.id) == current: + continue + previous[device.id] = current + detail = f": {device.error}" if device.error else "" + print( + f"[HEALTH] {device.id}: {device.status.value} " + f"({device.reason.value}){detail}" + ) + + def _build_haptic_pipeline(hands, mapping, sinks, hands_source_cls) -> None: from isaaccapture.haptic_devices.glove import haptic_glove_device from isaaccapture.retargeters.tactile_retargeters import ( @@ -489,6 +504,11 @@ def main() -> int: default=DEFAULT_PRINT_HZ, help="Terminal status rate. 0 silences prints.", ) + parser.add_argument( + "--print-health", + action="store_true", + help="Print Sharpa device health initially and whenever it changes.", + ) parser.add_argument( "--haptic", action=argparse.BooleanOptionalAction, @@ -621,6 +641,7 @@ def main() -> int: print_period = (1.0 / args.print_hz) if args.print_hz > 0 else None next_print = time.monotonic() last_offline_hint = 0.0 + previous_health: dict[str, tuple] = {} upright = not args.world_frame print("=" * 72) @@ -644,13 +665,21 @@ def main() -> int: print(f" viser: http://{args.host}:{args.port} (Ctrl+C to exit)") else: print(" viser: off (--no-viz); terminal + haptic only. Ctrl+C to exit.") + print(f" health: {'changes' if args.print_health else 'off'}") print("=" * 72) with CloudXRLauncher.launch_context(args), TeleopSession(config) as session: if args.viz: _server, viz_left, viz_right = _start_viser(args) while True: - result = session.step() + try: + result = session.step() + except Exception: + if args.print_health: + _print_health_changes(session.get_status(), previous_health) + raise + if args.print_health: + _print_health_changes(session.get_status(), previous_health) n_l = n_r = 0 if viz_left is not None and viz_right is not None: pts, mask, n_l = _hand_points( diff --git a/src/plugins/sharpa_avatar/AGENTS.md b/src/plugins/sharpa_avatar/AGENTS.md index 560e94301d..3fcd161984 100644 --- a/src/plugins/sharpa_avatar/AGENTS.md +++ b/src/plugins/sharpa_avatar/AGENTS.md @@ -48,6 +48,12 @@ SPDX-License-Identifier: Apache-2.0 - OpenXR setup is required for publication; initialization errors must abort construction instead of leaving an Avatar-only idle plugin. - Keep initialization non-blocking; `update()` owns retries for absent gloves. +- In pinned SDK 1.7.3 wireless mode, `DeviceInfo.online` means recent joint + activity, not an independent physical/RF connection signal. Report it as an + SDK stream signal and keep sample freshness and USB dongle presence separate. +- Track health freshness independently for HUMAN, RAW, and ROBOT outputs; one + advancing category must not keep another category healthy. Replace cached SDK + inventory only after a complete refresh succeeds. - Keep the static HUMAN-to-OpenXR index table synchronized with the pinned production SDK's landmark order. Do not read joint names from `sdk_config.json` at runtime or fill unsupported OpenXR slots with neighbouring diff --git a/src/plugins/sharpa_avatar/README.md b/src/plugins/sharpa_avatar/README.md index 1b26bb4dbd..55280b0afd 100644 --- a/src/plugins/sharpa_avatar/README.md +++ b/src/plugins/sharpa_avatar/README.md @@ -97,6 +97,23 @@ The gloves do not provide a world-space wrist pose. Downstream consumers that need a display frame (the example) place both skeletons locally; a headset wrist source is used when one is available. +## Health reporting + +When launched by `TeleopSession`, the plugin reports the USB dongle and every +manifest data path through the standard plugin health interface. Left and right +states are independent. A path is connected only while Avatar SDK +`DeviceInfo.online` is true and fresh samples are arriving; missing current data +is reported as degraded rather than diagnosed as a physical glove disconnect. +HUMAN, RAW, and ROBOT freshness are tracked independently from their sample +timestamps. A path becomes stale after three seconds without a newer timestamp; +the elapsed timeout uses `std::chrono::steady_clock` and is unaffected by wall +clock adjustments. + +In the pinned Avatar SDK 1.7.3 wireless transport, `DeviceInfo.online` follows +recent joint-frame activity. It does not independently distinguish an RF-linked +glove that stopped streaming from a disconnected glove. Dongle insertion is +reported separately from Linux USB enumeration (`1915:520f`). + ## Troubleshooting | Symptom | Resolution | diff --git a/src/plugins/sharpa_avatar/app/main.cpp b/src/plugins/sharpa_avatar/app/main.cpp index cfd50cb117..6c813ee4c8 100644 --- a/src/plugins/sharpa_avatar/app/main.cpp +++ b/src/plugins/sharpa_avatar/app/main.cpp @@ -57,7 +57,7 @@ AvatarPluginConfig parse_args(int argc, char** argv) } else if (starts_with(arg, "--plugin-root-id=")) { - // Injected by the PluginManager; unused by this plugin. + config.monitoring_plugin_root_id = arg.substr(std::string("--plugin-root-id=").size()); } else if (!starts_with(arg, "--")) { diff --git a/src/plugins/sharpa_avatar/core/CMakeLists.txt b/src/plugins/sharpa_avatar/core/CMakeLists.txt index 8a47428c7e..8dbe5e0137 100644 --- a/src/plugins/sharpa_avatar/core/CMakeLists.txt +++ b/src/plugins/sharpa_avatar/core/CMakeLists.txt @@ -3,7 +3,9 @@ add_library(avatar_plugin_core STATIC avatar_glove_collection.hpp + avatar_health.cpp avatar_hand_tracking_plugin.cpp + inc/avatar/avatar_health.hpp inc/avatar/avatar_hand_tracking_plugin.hpp ) diff --git a/src/plugins/sharpa_avatar/core/avatar_hand_tracking_plugin.cpp b/src/plugins/sharpa_avatar/core/avatar_hand_tracking_plugin.cpp index 9a7df1454d..317c09ac24 100644 --- a/src/plugins/sharpa_avatar/core/avatar_hand_tracking_plugin.cpp +++ b/src/plugins/sharpa_avatar/core/avatar_hand_tracking_plugin.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -36,9 +37,17 @@ namespace { constexpr size_t kJointFlatbufferSize = 4096; -constexpr auto kAvatarDataTimeout = std::chrono::seconds(3); constexpr auto kGloveRetryInterval = std::chrono::seconds(2); constexpr auto kHapticCommandTimeout = std::chrono::milliseconds(200); +constexpr auto kSdkHealthRefreshInterval = std::chrono::milliseconds(100); +constexpr auto kDonglePresenceInterval = std::chrono::seconds(1); +constexpr uint16_t kAvatarDongleVendorId = 0x1915; +constexpr uint16_t kAvatarDongleProductId = 0x520F; +constexpr std::string_view kUsbDevicesRoot = "/sys/bus/usb/devices"; + +constexpr std::array kHandDevicePaths{ "/hand/left", "/hand/right" }; +constexpr std::array kRawDevicePaths{ "/glove/avatar_raw_left", "/glove/avatar_raw_right" }; +constexpr std::array kRobotDevicePaths{ "/glove/avatar_robot_left", "/glove/avatar_robot_right" }; const std::shared_ptr& logger() { @@ -84,6 +93,16 @@ size_t joint_category_index(::avatar::DeviceDataCategory category) throw std::invalid_argument("HUMAN data is not a joint stream"); } +size_t data_category_index(::avatar::DeviceDataCategory category) +{ + const auto it = std::find(kDataCategories.begin(), kDataCategories.end(), category); + if (it == kDataCategories.end()) + { + throw std::invalid_argument("Unsupported Avatar data category"); + } + return static_cast(std::distance(kDataCategories.begin(), it)); +} + const char* collection_id(::avatar::DeviceSide side, ::avatar::DeviceDataCategory category) { static constexpr std::array, 2> kCollectionIds = { { @@ -262,7 +281,7 @@ void GloveState::reset() noexcept robot_frame = {}; device.reset(); last_successful_fetch.reset(); - last_sample_stamp.reset(); + last_sample_stamps = {}; streaming = false; } @@ -277,6 +296,11 @@ AvatarTracker::AvatarTracker(AvatarPluginConfig config) : m_config(std::move(con void AvatarTracker::initialize_openxr() { + if (m_config.monitoring_plugin_root_id && m_config.monitoring_plugin_root_id->empty()) + { + throw std::invalid_argument("AvatarTracker monitoring_plugin_root_id must not be empty"); + } + plugin_utils::WristSourceConfig wrist_config; wrist_config.mode = plugin_utils::WristSourceMode::Auto; wrist_config.left_aim_to_wrist = kLeftHandOffset; @@ -310,6 +334,13 @@ void AvatarTracker::initialize_openxr() { add_extension(XR_NVX1_DEVICE_INTERFACE_BASE_EXTENSION_NAME); } + if (m_config.monitoring_plugin_root_id) + { + for (const auto& extension : plugin_utils::PluginDeviceStatusPublisher::get_required_extensions()) + { + add_extension(extension); + } + } for (const char* extension : wrist_requirements.extensions) { add_extension(extension); @@ -319,6 +350,12 @@ void AvatarTracker::initialize_openxr() m_session = std::make_shared(m_config.app_name, extensions, wait_for_openxr_system); m_handles = m_session->get_handles(); + if (m_config.monitoring_plugin_root_id) + { + m_status_publisher = + std::make_unique(m_handles, *m_config.monitoring_plugin_root_id); + } + m_time_converter.emplace(m_handles); if (m_config.human) @@ -419,6 +456,39 @@ void AvatarTracker::try_connect_missing_gloves() m_glove_wait_logged = waiting; } +void AvatarTracker::refresh_sdk_health() +{ + if (!m_status_publisher) + { + return; + } + + const auto now = std::chrono::steady_clock::now(); + if (!m_last_sdk_health_refresh || now - *m_last_sdk_health_refresh >= kSdkHealthRefreshInterval) + { + std::array next_inventory_present{}; + std::array next_sdk_online{}; + for (const auto& info : m_sdk.get().list_device_info(::avatar::DeviceType::GLOVE)) + { + const size_t index = side_index(info.device_side); + next_inventory_present[index] = true; + next_sdk_online[index] = info.online; + } + m_inventory_present = next_inventory_present; + m_sdk_online = next_sdk_online; + for (size_t index = 0; index < m_ever_observed.size(); ++index) + { + m_ever_observed[index] = m_ever_observed[index] || m_inventory_present[index]; + } + m_last_sdk_health_refresh = now; + } + if (!m_last_dongle_check || now - *m_last_dongle_check >= kDonglePresenceInterval) + { + m_dongle_presence = detect_usb_device_presence(kUsbDevicesRoot, kAvatarDongleVendorId, kAvatarDongleProductId); + m_last_dongle_check = now; + } +} + void AvatarTracker::refresh_data() { for (const ::avatar::DeviceSide side : kDeviceSides) @@ -435,18 +505,24 @@ void AvatarTracker::refresh_data() // fetch_data() keeps returning a silent glove's last sample, so only a newer // sample stamp counts as data for the timeout below. + const size_t side_idx = side_index(side); + const auto now = std::chrono::steady_clock::now(); bool fetched_any = false; - const auto note_stamp = [&state, &fetched_any](const ::avatar::Stamp& stamp) + const auto note_stamp = [this, &state, &fetched_any, side_idx, now]( + ::avatar::DeviceDataCategory category, const ::avatar::Stamp& stamp) { - if (!state.last_sample_stamp) + const size_t category_idx = data_category_index(category); + auto& last_stamp = state.last_sample_stamps[category_idx]; + if (!last_stamp) { - state.last_sample_stamp = stamp; + last_stamp = stamp; } - else if (std::tie(stamp.sec, stamp.nanosec) > - std::tie(state.last_sample_stamp->sec, state.last_sample_stamp->nanosec)) + else if (std::tie(stamp.sec, stamp.nanosec) > std::tie(last_stamp->sec, last_stamp->nanosec)) { - state.last_sample_stamp = stamp; + last_stamp = stamp; fetched_any = true; + m_last_fresh_data[side_idx][category_idx] = now; + m_ever_received_fresh_data[side_idx][category_idx] = true; } }; if (m_config.human) @@ -456,7 +532,7 @@ void AvatarTracker::refresh_data() state.device->fetch_data(frame, ::avatar::DeviceDataCategory::HUMAN) == ::avatar::ErrorCode::SUCCESS; if (fetched) { - note_stamp(frame.skeleton.header.stamp); + note_stamp(::avatar::DeviceDataCategory::HUMAN, frame.skeleton.header.stamp); state.landmarks = std::move(frame.skeleton.landmark); } else @@ -475,7 +551,7 @@ void AvatarTracker::refresh_data() ::avatar::AvatarDataFrame& cached = cached_joint_frame(state, category); if (fetched) { - note_stamp(hand_payload(frame, category).header.stamp); + note_stamp(category, hand_payload(frame, category).header.stamp); cached = std::move(frame); } else @@ -483,7 +559,6 @@ void AvatarTracker::refresh_data() cached = {}; } } - const auto now = std::chrono::steady_clock::now(); if (fetched_any) { if (!state.streaming) @@ -502,6 +577,46 @@ void AvatarTracker::refresh_data() } } +void AvatarTracker::publish_device_status() +{ + if (!m_status_publisher) + { + return; + } + + const auto now = std::chrono::steady_clock::now(); + std::vector entries; + entries.reserve(7); + entries.push_back(make_dongle_status(m_dongle_presence)); + + for (const ::avatar::DeviceSide side : kDeviceSides) + { + const size_t index = side_index(side); + const auto make_input = [this, index, now](::avatar::DeviceDataCategory category, bool enabled) + { + const size_t category_idx = data_category_index(category); + const auto& last_fresh_data = m_last_fresh_data[index][category_idx]; + return GloveHealthInput{ + .enabled = enabled, + .inventory_present = m_inventory_present[index], + .ever_observed = m_ever_observed[index], + .sdk_online = m_sdk_online[index], + .fresh_data = last_fresh_data && now - *last_fresh_data < kAvatarDataTimeout, + .ever_received_fresh_data = m_ever_received_fresh_data[index][category_idx], + }; + }; + + entries.push_back(make_glove_status( + kHandDevicePaths[index], make_input(::avatar::DeviceDataCategory::HUMAN, m_config.human))); + entries.push_back( + make_glove_status(kRawDevicePaths[index], make_input(::avatar::DeviceDataCategory::RAW, m_config.raw))); + entries.push_back(make_glove_status( + kRobotDevicePaths[index], make_input(::avatar::DeviceDataCategory::ROBOT, m_config.robot))); + } + + m_status_publisher->publish_if_changed(entries, core::os_monotonic_now_ns()); +} + const GloveState& AvatarTracker::glove(::avatar::DeviceSide side) const { return m_gloves[side_index(side)]; @@ -514,9 +629,51 @@ GloveState& AvatarTracker::glove(::avatar::DeviceSide side) void AvatarTracker::update() { + try + { + refresh_sdk_health(); + m_status_refresh_error_logged = false; + } + catch (const std::exception& error) + { + if (!m_status_refresh_error_logged) + { + logger()->warn("Device status refresh failed; tracking will continue: {}", error.what()); + m_status_refresh_error_logged = true; + } + } + catch (...) + { + if (!m_status_refresh_error_logged) + { + logger()->warn("Device status refresh failed; tracking will continue."); + m_status_refresh_error_logged = true; + } + } try_connect_missing_gloves(); m_deviceio_session->update(); refresh_data(); + try + { + publish_device_status(); + m_status_publish_error_logged = false; + } + catch (const std::exception& error) + { + if (!m_status_publish_error_logged) + { + logger()->warn("Device status publishing failed; tracking will continue: {}", error.what()); + m_status_publish_error_logged = true; + } + } + catch (...) + { + if (!m_status_publish_error_logged) + { + logger()->warn("Device status publishing failed; tracking will continue."); + m_status_publish_error_logged = true; + } + } if (m_haptic_reader) { const auto now = std::chrono::steady_clock::now(); diff --git a/src/plugins/sharpa_avatar/core/avatar_health.cpp b/src/plugins/sharpa_avatar/core/avatar_health.cpp new file mode 100644 index 0000000000..ab7acdd71f --- /dev/null +++ b/src/plugins/sharpa_avatar/core/avatar_health.cpp @@ -0,0 +1,150 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Avatar SDK contributors. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#include "inc/avatar/avatar_health.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace plugins +{ +namespace avatar +{ + +namespace +{ + +std::optional read_trimmed(const std::filesystem::path& path) +{ + std::ifstream stream(path); + std::string value; + if (!stream || !std::getline(stream, value)) + { + return std::nullopt; + } + value.erase( + std::remove_if(value.begin(), value.end(), [](unsigned char c) { return std::isspace(c) != 0; }), value.end()); + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) { return std::tolower(c); }); + return value; +} + +std::string hex_id(uint16_t value) +{ + std::ostringstream stream; + stream << std::hex << std::setfill('0') << std::setw(4) << value; + return stream.str(); +} + +} // anonymous namespace + +plugin_utils::PluginDeviceStatusEntry make_glove_status(std::string path, const GloveHealthInput& input) +{ + plugin_utils::PluginDeviceStatusEntry entry{ .path = std::move(path) }; + if (!input.enabled) + { + entry.state = core::PluginDeviceState_DISABLED; + entry.reason = core::PluginDeviceReason_DISABLED_BY_CONFIGURATION; + } + else if (!input.inventory_present) + { + entry.state = input.ever_observed ? core::PluginDeviceState_DISCONNECTED : core::PluginDeviceState_UNKNOWN; + entry.reason = core::PluginDeviceReason_NO_HARDWARE_SIGNAL; + entry.error = input.ever_observed ? "glove no longer appears in the Avatar SDK inventory" : + "waiting for the Avatar SDK to discover the glove"; + } + else if (!input.sdk_online) + { + entry.state = input.ever_received_fresh_data ? core::PluginDeviceState_DEGRADED : core::PluginDeviceState_UNKNOWN; + entry.reason = core::PluginDeviceReason_NO_CURRENT_DATA; + entry.error = "Avatar SDK reports online=false; the pinned SDK derives online from recent joint data"; + } + else if (!input.fresh_data) + { + entry.reason = core::PluginDeviceReason_NO_CURRENT_DATA; + if (input.ever_received_fresh_data) + { + entry.state = core::PluginDeviceState_DEGRADED; + entry.error = "glove has no newer samples within the " + std::to_string(kAvatarDataTimeout.count()) + + "-second freshness window"; + } + else + { + entry.state = core::PluginDeviceState_UNKNOWN; + entry.error = "waiting for the first fresh glove sample"; + } + } + else + { + entry.state = core::PluginDeviceState_CONNECTED; + entry.reason = core::PluginDeviceReason_NONE; + } + return entry; +} + +plugin_utils::PluginDeviceStatusEntry make_dongle_status(UsbDevicePresence presence) +{ + plugin_utils::PluginDeviceStatusEntry entry{ .path = "/transport/dongle" }; + switch (presence) + { + case UsbDevicePresence::PRESENT: + entry.state = core::PluginDeviceState_CONNECTED; + entry.reason = core::PluginDeviceReason_NONE; + break; + case UsbDevicePresence::ABSENT: + entry.state = core::PluginDeviceState_DISCONNECTED; + entry.reason = core::PluginDeviceReason_NO_HARDWARE_SIGNAL; + entry.error = "Avatar USB dongle 1915:520f is not enumerated"; + break; + case UsbDevicePresence::UNKNOWN: + entry.state = core::PluginDeviceState_UNKNOWN; + entry.reason = core::PluginDeviceReason_NO_HARDWARE_SIGNAL; + entry.error = "USB device enumeration is unavailable"; + break; + } + return entry; +} + +UsbDevicePresence detect_usb_device_presence(const std::filesystem::path& devices_root, + uint16_t vendor_id, + uint16_t product_id) +{ + std::error_code error; + std::filesystem::directory_iterator devices(devices_root, error); + if (error) + { + return UsbDevicePresence::UNKNOWN; + } + + const std::string expected_vendor = hex_id(vendor_id); + const std::string expected_product = hex_id(product_id); + bool incomplete_scan = false; + for (const auto& device : devices) + { + // USB interface rows such as 1-2:1.0 do not own VID/PID files. + if (device.path().filename().string().find(':') != std::string::npos) + { + continue; + } + const auto vendor = read_trimmed(device.path() / "idVendor"); + const auto product = read_trimmed(device.path() / "idProduct"); + if (!vendor || !product) + { + incomplete_scan = true; + continue; + } + if (*vendor == expected_vendor && *product == expected_product) + { + return UsbDevicePresence::PRESENT; + } + } + return incomplete_scan ? UsbDevicePresence::UNKNOWN : UsbDevicePresence::ABSENT; +} + +} // namespace avatar +} // namespace plugins diff --git a/src/plugins/sharpa_avatar/core/inc/avatar/avatar_hand_tracking_plugin.hpp b/src/plugins/sharpa_avatar/core/inc/avatar/avatar_hand_tracking_plugin.hpp index 0fa977ab95..696db0b71a 100644 --- a/src/plugins/sharpa_avatar/core/inc/avatar/avatar_hand_tracking_plugin.hpp +++ b/src/plugins/sharpa_avatar/core/inc/avatar/avatar_hand_tracking_plugin.hpp @@ -3,6 +3,8 @@ #pragma once +#include "avatar_health.hpp" + #include #include #include @@ -10,6 +12,7 @@ #include #include #include +#include #include #include @@ -31,6 +34,8 @@ using DeviceSide = ::avatar::DeviceSide; using DeviceDataCategory = ::avatar::DeviceDataCategory; inline constexpr std::array kDeviceSides{ DeviceSide::LEFT, DeviceSide::RIGHT }; +inline constexpr std::array kDataCategories{ DeviceDataCategory::HUMAN, DeviceDataCategory::RAW, + DeviceDataCategory::ROBOT }; inline constexpr std::array kJointDataCategories{ DeviceDataCategory::RAW, DeviceDataCategory::ROBOT }; @@ -65,6 +70,7 @@ inline constexpr uint32_t kAvatarHumanLandmarkCount = 25; struct AvatarPluginConfig { std::string app_name = "AvatarHandPlugin"; + std::optional monitoring_plugin_root_id; std::string sdk_config_path; bool human = true; bool raw = true; @@ -100,8 +106,8 @@ struct GloveState ::avatar::AvatarDataFrame robot_frame; // Empty until start() or a successful fetch; drives the data-timeout window. std::optional last_successful_fetch; - // The first stamp is a baseline; only a later stamp proves the glove is streaming. - std::optional<::avatar::Stamp> last_sample_stamp; + // The first stamp is a baseline; only a later stamp proves that stream is advancing. + std::array, kDataCategories.size()> last_sample_stamps; bool streaming = false; }; @@ -118,7 +124,9 @@ class __attribute__((visibility("default"))) AvatarTracker void initialize_openxr(); void try_connect_missing_gloves(); void start_glove_if_present(GloveState& glove, ::avatar::DeviceSide side); + void refresh_sdk_health(); void refresh_data(); + void publish_device_status(); void inject_hand_data(); void push_joint_frames(); void push_joint_frame(const GloveState& glove, @@ -141,6 +149,7 @@ class __attribute__((visibility("default"))) AvatarTracker std::shared_ptr m_session; core::OpenXRSessionHandles m_handles; std::array, 2> m_injectors; + std::unique_ptr m_status_publisher; std::shared_ptr m_haptic_reader; std::unique_ptr m_deviceio_session; std::optional m_time_converter; @@ -152,7 +161,17 @@ class __attribute__((visibility("default"))) AvatarTracker std::array m_haptic_stopped{ { true, true } }; std::array m_haptic_error_logged{ { false, false } }; std::optional m_last_glove_retry; + std::array m_inventory_present{}; + std::array m_ever_observed{}; + std::array m_sdk_online{}; + std::array, 2> m_ever_received_fresh_data{}; + std::array, kDataCategories.size()>, 2> m_last_fresh_data; + std::optional m_last_sdk_health_refresh; + std::optional m_last_dongle_check; + UsbDevicePresence m_dongle_presence = UsbDevicePresence::UNKNOWN; bool m_glove_wait_logged = false; + bool m_status_refresh_error_logged = false; + bool m_status_publish_error_logged = false; }; } // namespace avatar diff --git a/src/plugins/sharpa_avatar/core/inc/avatar/avatar_health.hpp b/src/plugins/sharpa_avatar/core/inc/avatar/avatar_health.hpp new file mode 100644 index 0000000000..7690c57634 --- /dev/null +++ b/src/plugins/sharpa_avatar/core/inc/avatar/avatar_health.hpp @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Avatar SDK contributors. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include + +#include +#include +#include +#include + +namespace plugins +{ +namespace avatar +{ + +inline constexpr auto kAvatarDataTimeout = std::chrono::seconds(3); + +enum class UsbDevicePresence +{ + UNKNOWN, + ABSENT, + PRESENT, +}; + +struct GloveHealthInput +{ + bool enabled = true; + bool inventory_present = false; + bool ever_observed = false; + bool sdk_online = false; + bool fresh_data = false; + bool ever_received_fresh_data = false; +}; + +plugin_utils::PluginDeviceStatusEntry make_glove_status(std::string path, const GloveHealthInput& input); +plugin_utils::PluginDeviceStatusEntry make_dongle_status(UsbDevicePresence presence); + +UsbDevicePresence detect_usb_device_presence(const std::filesystem::path& devices_root, + uint16_t vendor_id, + uint16_t product_id); + +} // namespace avatar +} // namespace plugins diff --git a/src/plugins/sharpa_avatar/plugin.yaml.in b/src/plugins/sharpa_avatar/plugin.yaml.in index 42d7f40dd7..2a8f3da7bc 100644 --- a/src/plugins/sharpa_avatar/plugin.yaml.in +++ b/src/plugins/sharpa_avatar/plugin.yaml.in @@ -11,6 +11,9 @@ description: > command: "./avatar_hand_plugin" version: "1.1.0" devices: + - path: "/transport/dongle" + type: "transport" + description: "Sharpa AvatarExo wireless USB dongle" - path: "/hand/left" type: "hand_tracker" description: "Left Sharpa AE Glove — 26 OpenXR joints (HUMAN FK → OpenXR)" diff --git a/src/python/isaaccapture/teleop_session_manager/teleop_session.py b/src/python/isaaccapture/teleop_session_manager/teleop_session.py index f9c019ac7d..c511d2df18 100644 --- a/src/python/isaaccapture/teleop_session_manager/teleop_session.py +++ b/src/python/isaaccapture/teleop_session_manager/teleop_session.py @@ -62,7 +62,9 @@ logger = logging.getLogger(__name__) # Plugins that publish device status (see PluginDeviceStatusPublisher); others launch unmonitored. -_MONITORED_PLUGIN_NAMES = frozenset({"manus_hand_plugin", "spacemouse", "gamepad"}) +_MONITORED_PLUGIN_NAMES = frozenset( + {"manus_hand_plugin", "avatar_hand_plugin", "spacemouse", "gamepad"} +) def _resolve_sink(entry: GraphExecutable) -> tuple[GraphExecutable, IDeviceIOSink]: diff --git a/tests/cpp/plugins/CMakeLists.txt b/tests/cpp/plugins/CMakeLists.txt index 1fd14aa203..46847194f9 100644 --- a/tests/cpp/plugins/CMakeLists.txt +++ b/tests/cpp/plugins/CMakeLists.txt @@ -3,6 +3,10 @@ add_subdirectory(plugin_utils) +if(TARGET avatar_plugin_core) + add_subdirectory(sharpa_avatar) +endif() + # The SpaceMouse and gamepad plugins (and their core libraries) are Linux only. if(CMAKE_SYSTEM_NAME STREQUAL "Linux") add_subdirectory(spacemouse) diff --git a/tests/cpp/plugins/sharpa_avatar/CMakeLists.txt b/tests/cpp/plugins/sharpa_avatar/CMakeLists.txt new file mode 100644 index 0000000000..cdef58dbc5 --- /dev/null +++ b/tests/cpp/plugins/sharpa_avatar/CMakeLists.txt @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +add_executable(sharpa_avatar_plugin_tests + test_avatar_health.cpp +) + +target_link_libraries(sharpa_avatar_plugin_tests + PRIVATE + Catch2::Catch2WithMain + isaac::avatar_plugin +) + +catch_discover_tests(sharpa_avatar_plugin_tests) diff --git a/tests/cpp/plugins/sharpa_avatar/test_avatar_health.cpp b/tests/cpp/plugins/sharpa_avatar/test_avatar_health.cpp new file mode 100644 index 0000000000..057bd91efb --- /dev/null +++ b/tests/cpp/plugins/sharpa_avatar/test_avatar_health.cpp @@ -0,0 +1,184 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#include +#include + +#include +#include +#include +#include + +using plugins::avatar::GloveHealthInput; +using plugins::avatar::UsbDevicePresence; + +namespace +{ + +class TemporaryDirectory +{ +public: + TemporaryDirectory() + { + const auto nonce = std::chrono::steady_clock::now().time_since_epoch().count(); + path = std::filesystem::temp_directory_path() / ("sharpa-avatar-health-" + std::to_string(nonce)); + std::filesystem::create_directories(path); + } + + ~TemporaryDirectory() + { + std::error_code error; + std::filesystem::remove_all(path, error); + } + + std::filesystem::path path; +}; + +void write_text(const std::filesystem::path& path, const std::string& value) +{ + std::ofstream stream(path); + REQUIRE(stream); + stream << value; + REQUIRE(stream.good()); +} + +} // anonymous namespace + +TEST_CASE("Glove health preserves startup, stream, and configuration states", "[sharpa_avatar][health]") +{ + GloveHealthInput input; + + SECTION("disabled dataset") + { + input.enabled = false; + const auto status = plugins::avatar::make_glove_status("/hand/left", input); + CHECK(status.state == core::PluginDeviceState_DISABLED); + CHECK(status.reason == core::PluginDeviceReason_DISABLED_BY_CONFIGURATION); + } + + SECTION("startup before discovery") + { + const auto status = plugins::avatar::make_glove_status("/hand/left", input); + CHECK(status.state == core::PluginDeviceState_UNKNOWN); + CHECK(status.reason == core::PluginDeviceReason_NO_HARDWARE_SIGNAL); + } + + SECTION("previously observed glove disappears") + { + input.ever_observed = true; + const auto status = plugins::avatar::make_glove_status("/hand/left", input); + CHECK(status.state == core::PluginDeviceState_DISCONNECTED); + CHECK(status.reason == core::PluginDeviceReason_NO_HARDWARE_SIGNAL); + } + + SECTION("SDK online false is a data condition") + { + input.inventory_present = true; + input.ever_observed = true; + input.ever_received_fresh_data = true; + const auto status = plugins::avatar::make_glove_status("/hand/left", input); + CHECK(status.state == core::PluginDeviceState_DEGRADED); + CHECK(status.reason == core::PluginDeviceReason_NO_CURRENT_DATA); + CHECK(status.error.find("online=false") != std::string::npos); + } + + SECTION("SDK offline before first stream remains startup waiting") + { + input.inventory_present = true; + input.ever_observed = true; + const auto status = plugins::avatar::make_glove_status("/hand/left", input); + CHECK(status.state == core::PluginDeviceState_UNKNOWN); + CHECK(status.reason == core::PluginDeviceReason_NO_CURRENT_DATA); + } + + SECTION("SDK online waits for the first fresh sample") + { + input.inventory_present = true; + input.ever_observed = true; + input.sdk_online = true; + const auto status = plugins::avatar::make_glove_status("/hand/left", input); + CHECK(status.state == core::PluginDeviceState_UNKNOWN); + CHECK(status.reason == core::PluginDeviceReason_NO_CURRENT_DATA); + } + + SECTION("fresh data and SDK online are healthy") + { + input.inventory_present = true; + input.ever_observed = true; + input.sdk_online = true; + input.fresh_data = true; + input.ever_received_fresh_data = true; + const auto status = plugins::avatar::make_glove_status("/hand/left", input); + CHECK(status.state == core::PluginDeviceState_CONNECTED); + CHECK(status.reason == core::PluginDeviceReason_NONE); + CHECK(status.error.empty()); + } + + SECTION("stale data after streaming is degraded") + { + input.inventory_present = true; + input.ever_observed = true; + input.sdk_online = true; + input.ever_received_fresh_data = true; + const auto status = plugins::avatar::make_glove_status("/hand/left", input); + CHECK(status.state == core::PluginDeviceState_DEGRADED); + CHECK(status.reason == core::PluginDeviceReason_NO_CURRENT_DATA); + CHECK(status.error.find("3-second freshness window") != std::string::npos); + } + + SECTION("published paths can report independent freshness") + { + input.inventory_present = true; + input.ever_observed = true; + input.sdk_online = true; + input.ever_received_fresh_data = true; + + input.fresh_data = true; + const auto human = plugins::avatar::make_glove_status("/hand/left", input); + input.fresh_data = false; + const auto raw = plugins::avatar::make_glove_status("/glove/avatar_raw_left", input); + + CHECK(human.state == core::PluginDeviceState_CONNECTED); + CHECK(raw.state == core::PluginDeviceState_DEGRADED); + CHECK(raw.reason == core::PluginDeviceReason_NO_CURRENT_DATA); + } +} + +TEST_CASE("Dongle status reports Linux USB enumeration", "[sharpa_avatar][health]") +{ + TemporaryDirectory sysfs; + + CHECK(plugins::avatar::detect_usb_device_presence(sysfs.path, 0x1915, 0x520F) == UsbDevicePresence::ABSENT); + + std::filesystem::create_directories(sysfs.path / "1-2:1.0"); + CHECK(plugins::avatar::detect_usb_device_presence(sysfs.path, 0x1915, 0x520F) == UsbDevicePresence::ABSENT); + + const auto incomplete_device = sysfs.path / "1-3"; + std::filesystem::create_directories(incomplete_device); + write_text(incomplete_device / "idVendor", "1915\n"); + CHECK(plugins::avatar::detect_usb_device_presence(sysfs.path, 0x1915, 0x520F) == UsbDevicePresence::UNKNOWN); + + const auto dongle = sysfs.path / "1-2"; + std::filesystem::create_directories(dongle); + write_text(dongle / "idVendor", "1915\n"); + write_text(dongle / "idProduct", "520F\n"); + CHECK(plugins::avatar::detect_usb_device_presence(sysfs.path, 0x1915, 0x520F) == UsbDevicePresence::PRESENT); + + CHECK(plugins::avatar::detect_usb_device_presence(sysfs.path / "missing", 0x1915, 0x520F) == + UsbDevicePresence::UNKNOWN); +} + +TEST_CASE("Dongle health distinguishes present, absent, and unavailable", "[sharpa_avatar][health]") +{ + const auto present = plugins::avatar::make_dongle_status(UsbDevicePresence::PRESENT); + CHECK(present.state == core::PluginDeviceState_CONNECTED); + CHECK(present.reason == core::PluginDeviceReason_NONE); + + const auto absent = plugins::avatar::make_dongle_status(UsbDevicePresence::ABSENT); + CHECK(absent.state == core::PluginDeviceState_DISCONNECTED); + CHECK(absent.reason == core::PluginDeviceReason_NO_HARDWARE_SIGNAL); + + const auto unknown = plugins::avatar::make_dongle_status(UsbDevicePresence::UNKNOWN); + CHECK(unknown.state == core::PluginDeviceState_UNKNOWN); + CHECK(unknown.reason == core::PluginDeviceReason_NO_HARDWARE_SIGNAL); +} diff --git a/tests/python/core/teleop_session_manager/test_teleop_session.py b/tests/python/core/teleop_session_manager/test_teleop_session.py index 7c37700560..125a862b3c 100644 --- a/tests/python/core/teleop_session_manager/test_teleop_session.py +++ b/tests/python/core/teleop_session_manager/test_teleop_session.py @@ -1595,7 +1595,7 @@ def __new__(cls, value, *, aim_valid=True, grip_valid=True): == teleop_session_manager.DeviceState.CONNECTED ) - @pytest.mark.parametrize("plugin", ["spacemouse", "gamepad"]) + @pytest.mark.parametrize("plugin", ["spacemouse", "gamepad", "avatar_hand_plugin"]) def test_device_plugin_is_monitored(self, tmp_path, plugin): manager = MockPluginManager(plugin_names=[plugin]) manager.get_plugin_info = MagicMock(