From e8b7fce854a05c129eb97e0167a23201a179e39f Mon Sep 17 00:00:00 2001 From: abhi chothani Date: Mon, 22 Jun 2026 18:44:04 -0500 Subject: [PATCH 1/4] vr: add VrSessionControlComponent button-to-session bridge Add VrSessionControlComponent, which turns VR controller button presses into SessionControlCapable events. Registered as hardware type "vr_session_control". - A background reader thread polls the shared VR frame stream, detects rising edges on bound buttons, and fires the event callback once per press. Frames stalling past disconnect_timeout_s fire the one-shot disconnect callback. - Configurable bindings map buttons to events: button_a/button_x -> primary, button_b/button_y -> secondary; events start, stop_early, rerecord, stop_session. Default is A=start, B=rerecord. - Claims its bound buttons on the configured controller_type via VrSession, so two controls fighting for the same button fail at configure() time. Implements SessionControlCapable and builds on VrSession. Adds src/hw/vr/vr_session_control_component.cpp to the library; this completes the VR hardware layer. --- CMakeLists.txt | 1 + .../hw/vr/vr_session_control_component.hpp | 121 ++++++++ src/hw/vr/vr_session_control_component.cpp | 279 ++++++++++++++++++ 3 files changed, 401 insertions(+) create mode 100644 include/trossen_sdk/hw/vr/vr_session_control_component.hpp create mode 100644 src/hw/vr/vr_session_control_component.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index c5ca2424..673656ad 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -91,6 +91,7 @@ if(TROSSEN_ENABLE_VR) src/hw/vr/vr_session.cpp src/hw/vr/vr_arm_component.cpp src/hw/vr/vr_base_component.cpp + src/hw/vr/vr_session_control_component.cpp ) target_compile_definitions(trossen_sdk PRIVATE TROSSEN_ENABLE_VR) diff --git a/include/trossen_sdk/hw/vr/vr_session_control_component.hpp b/include/trossen_sdk/hw/vr/vr_session_control_component.hpp new file mode 100644 index 00000000..62aa3fd5 --- /dev/null +++ b/include/trossen_sdk/hw/vr/vr_session_control_component.hpp @@ -0,0 +1,121 @@ +/** + * @file vr_session_control_component.hpp + * @brief VR controller buttons as a session-control source. + */ + +#ifndef TROSSEN_SDK__HW__VR__VR_SESSION_CONTROL_COMPONENT_HPP_ +#define TROSSEN_SDK__HW__VR__VR_SESSION_CONTROL_COMPONENT_HPP_ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "trossen_sdk/hw/hardware_component.hpp" +#include "trossen_sdk/hw/session_control/session_control_capable.hpp" +#include "trossen_sdk/hw/vr/vr_session.hpp" + +namespace trossen::hw::vr { + +/** + * @brief VR button component that drives SessionManager state transitions. + * + * Polls the shared VR frame stream on a background thread, detects rising + * edges on bound buttons, and fires `SessionControlCapable` event callbacks. + * Disconnection is detected when frames stop arriving for longer than the + * configured threshold. Input conflicts (e.g. two components claiming the + * same button on the same controller side) are caught at `configure()` time. + */ +class VrSessionControlComponent + : public HardwareComponent, + public session_control::SessionControlCapable { +public: + explicit VrSessionControlComponent(std::string identifier) + : HardwareComponent(std::move(identifier)) {} + + ~VrSessionControlComponent() override; + + VrSessionControlComponent(const VrSessionControlComponent&) = delete; + VrSessionControlComponent& operator=(const VrSessionControlComponent&) = delete; + VrSessionControlComponent(VrSessionControlComponent&&) = delete; + VrSessionControlComponent& operator=(VrSessionControlComponent&&) = delete; + + /** + * @brief Configure from JSON. + * + * Required: + * - `controller_type`: "left" or "right". + * + * Optional: + * - `bindings`: object mapping VR input name to event name. Defaults: + * `{ "button_a": "start", "button_b": "rerecord" }`. + * Recognized input names: + * "button_a" / "button_x" — primary button (A on right, X on left). + * "button_b" / "button_y" — secondary button (B on right, Y on left). + * Recognized event names: "start", "stop_early", "rerecord", "stop_session". + * - `vr_port`: network port (default 9000). + * - `connection_timeout_s`: How long `start()` blocks for the headset + * app to connect before throwing (default 10 s). + * - `poll_interval_ms`: reader-thread poll cadence (default 50 ms). + * - `disconnect_timeout_s`: consecutive seconds without a new + * frame before disconnect fires (default 2 s). + * + * Claims the inputs referenced by `bindings` on the configured controller + * type so overlapping VR configurations fail loudly at configure() time. + */ + void configure(const nlohmann::json& config) override; + + std::string get_type() const override { return "vr_session_control"; } + nlohmann::json get_info() const override; + + // ── SessionControlCapable ──────────────────────────────────────────── + + void set_callbacks(EventCallback on_event, + DisconnectCallback on_disconnect) override; + void start() override; + void stop() override; + + /// Public so the in-cpp default-bindings helper can name it; still a + /// detail of this component. + struct Binding { + VrInput input; + session_control::SessionControlEvent event; + }; + +private: + /// Reader-thread loop: samples the VR frame stream and emits events. + void reader_loop(); + + std::vector bindings_; + + /// Config primitives. + std::string controller_type_{"right"}; + std::uint16_t vr_port_{9000}; + std::chrono::milliseconds connection_timeout_{std::chrono::seconds{10}}; + std::chrono::milliseconds poll_interval_{std::chrono::milliseconds{50}}; + std::chrono::milliseconds disconnect_timeout_{std::chrono::seconds{2}}; + + /// VR session refcount: acquired in configure(), released in dtor. + bool session_held_{false}; + + /// Callbacks installed by SessionManager via `set_callbacks()`. + EventCallback event_cb_; + DisconnectCallback disconnect_cb_; + + /// Reader-thread state. `stop_requested_` ends the loop; `running_` + /// guards start/stop idempotency. + std::thread reader_thread_; + std::atomic stop_requested_{false}; + std::atomic running_{false}; + + /// Rising-edge state per bound input. + std::unordered_map prev_pressed_; +}; + +} // namespace trossen::hw::vr + +#endif // TROSSEN_SDK__HW__VR__VR_SESSION_CONTROL_COMPONENT_HPP_ diff --git a/src/hw/vr/vr_session_control_component.cpp b/src/hw/vr/vr_session_control_component.cpp new file mode 100644 index 00000000..37f8dfff --- /dev/null +++ b/src/hw/vr/vr_session_control_component.cpp @@ -0,0 +1,279 @@ +/** + * @file vr_session_control_component.cpp + * @brief VR button → session-control event bridge. + */ + +#include "trossen_sdk/hw/vr/vr_session_control_component.hpp" + +#include +#include +#include +#include +#include + +#include "trossen_sdk/hw/hardware_registry.hpp" + +namespace trossen::hw::vr { + +namespace { + +using Event = session_control::SessionControlEvent; + +/// Parse a JSON input name into the VrInput enum. Only button +/// inputs are valid here. +/// +/// Valid inputs: +/// - button_a / button_b : primary/secondary button on right controller (A/B) +/// - button_x / button_y : primary/secondary button on left controller (X/Y) +/// button_x is an alias for button_a; button_y is an alias for button_b. +/// Both map to the same underlying VrInput because VRFrame stores buttons +/// as one/two regardless of which hand — the controller_type determines +/// which physical button is read at runtime. +VrInput vr_input_from_name(const std::string& name) { + if (name == "button_a" || name == "button_x") return VrInput::kButtonOne; + if (name == "button_b" || name == "button_y") return VrInput::kButtonTwo; + throw std::runtime_error( + "VrSessionControlComponent: input '" + name + + "' is not bindable for session control " + "(valid: button_a, button_b, button_x, button_y)"); +} + +Event event_from_name(const std::string& name) { + if (name == "start") return Event::kStart; + if (name == "stop_early") return Event::kStopEarly; + if (name == "rerecord") return Event::kRerecord; + if (name == "stop_session") return Event::kStopSession; + throw std::runtime_error( + "VrSessionControlComponent: unknown event name '" + name + "'"); +} + +std::vector default_bindings() { + return { + {VrInput::kButtonOne, Event::kStart}, + {VrInput::kButtonTwo, Event::kRerecord}, + }; +} + +} // namespace + +VrSessionControlComponent::~VrSessionControlComponent() { + // Stop the reader thread before anything else so callbacks stop + // firing into a half-destructed object. + stop(); + if (session_held_) { + VrSession::instance().release_claims(get_identifier()); + VrSession::instance().release(); + session_held_ = false; + } +} + +void VrSessionControlComponent::configure(const nlohmann::json& config) { + if (!config.contains("controller_type")) { + throw std::runtime_error( + "VrSessionControlComponent: 'controller_type' is required " + "(\"left\" or \"right\")"); + } + controller_type_ = config.at("controller_type").get(); + if (controller_type_ != "left" && controller_type_ != "right") { + throw std::runtime_error( + "VrSessionControlComponent: 'controller_type' must be \"left\" or " + "\"right\", got \"" + controller_type_ + "\""); + } + + vr_port_ = config.value("vr_port", static_cast(9000)); + + const double wait_s = config.value("connection_timeout_s", 10.0); + if (!std::isfinite(wait_s) || wait_s < 0.0) { + throw std::runtime_error( + "VrSessionControlComponent: 'connection_timeout_s' must be a " + "non-negative finite number"); + } + connection_timeout_ = std::chrono::milliseconds( + static_cast(wait_s * 1000.0)); + + const int poll_ms = config.value("poll_interval_ms", 50); + if (poll_ms <= 0) { + throw std::runtime_error( + "VrSessionControlComponent: 'poll_interval_ms' must be positive"); + } + poll_interval_ = std::chrono::milliseconds{poll_ms}; + + const double disconnect_s = config.value("disconnect_timeout_s", 2.0); + if (!std::isfinite(disconnect_s) || disconnect_s <= 0.0) { + throw std::runtime_error( + "VrSessionControlComponent: 'disconnect_timeout_s' must be " + "positive and finite"); + } + disconnect_timeout_ = std::chrono::milliseconds( + static_cast(disconnect_s * 1000.0)); + + // Parse bindings. A user-provided `bindings` block fully replaces + // the defaults — otherwise unbinding a default entry is impossible. + bindings_.clear(); + if (config.contains("bindings")) { + const auto& b = config.at("bindings"); + if (!b.is_object()) { + throw std::runtime_error( + "VrSessionControlComponent: 'bindings' must be an object"); + } + for (const auto& [input_name, event_json] : b.items()) { + if (!event_json.is_string()) { + throw std::runtime_error( + "VrSessionControlComponent: bindings['" + input_name + + "'] must be a string"); + } + const VrInput input = vr_input_from_name(input_name); + // button_a/button_x and button_b/button_y are aliases for the same + // physical button. Reject configs that bind both aliases, which would + // otherwise fire the session-control event twice per press. + for (const auto& existing : bindings_) { + if (existing.input == input) { + throw std::runtime_error( + "VrSessionControlComponent: bindings['" + input_name + + "'] maps to the same button as an earlier binding " + "(button_a/button_x and button_b/button_y are aliases); " + "bind each button at most once"); + } + } + bindings_.push_back(Binding{ + input, + event_from_name(event_json.get())}); + } + } else { + bindings_ = default_bindings(); + } + + if (bindings_.empty()) { + throw std::runtime_error( + "VrSessionControlComponent: at least one binding is required"); + } + + // Acquire the shared VR session and claim the bound inputs on the + // configured controller type. Conflicts with arm/base/other session-control + // components surface here as readable errors. + VrSession::instance().ensure_started(vr_port_); + session_held_ = true; + + // Claim one input at a time so a conflict error names the specific button + // that clashed rather than the whole set. + auto& session = VrSession::instance(); + for (const auto& b : bindings_) { + session.claim_inputs(controller_type_, get_identifier(), {b.input}); + } + + // Reset rising-edge state for any subsequent start(). + prev_pressed_.clear(); + for (const auto& b : bindings_) { + prev_pressed_[b.input] = false; + } +} + +nlohmann::json VrSessionControlComponent::get_info() const { + nlohmann::json j{ + {"type", get_type()}, + {"identifier", get_identifier()}, + {"controller_type", controller_type_}, + {"vr_port", vr_port_}, + {"poll_interval_ms", poll_interval_.count()}, + {"binding_count", bindings_.size()}, + {"connected", VrSession::instance().is_vr_connected()}, + }; + return j; +} + +void VrSessionControlComponent::set_callbacks( + EventCallback on_event, + DisconnectCallback on_disconnect) +{ + event_cb_ = std::move(on_event); + disconnect_cb_ = std::move(on_disconnect); +} + +void VrSessionControlComponent::start() { + if (running_.exchange(true)) return; // Already running. + + // Block until the VR headset connects. Throws if the headset app is + // not running — fails early with a clear message. + if (!VrSession::instance().wait_for_connection(connection_timeout_)) { + running_.store(false); + throw std::runtime_error( + "VrSessionControlComponent: timed out waiting for VR headset to " + "connect on port " + std::to_string(vr_port_) + + " — is the VR app running?"); + } + + stop_requested_.store(false); + reader_thread_ = std::thread(&VrSessionControlComponent::reader_loop, this); +} + +void VrSessionControlComponent::stop() { + if (!running_.exchange(false)) return; // Already stopped / never started. + stop_requested_.store(true); + if (reader_thread_.joinable()) { + reader_thread_.join(); + } +} + +void VrSessionControlComponent::reader_loop() { + auto& session = VrSession::instance(); + + // Connection staleness tracking: monitor when we last saw a valid frame. + auto last_frame_time = std::chrono::steady_clock::now(); + bool has_first_frame = false; + bool disconnect_fired = false; + + while (!stop_requested_.load()) { + const auto frame_opt = session.latest_frame(); + const auto now = std::chrono::steady_clock::now(); + + if (frame_opt) { + const auto& frame = *frame_opt; + + last_frame_time = now; + has_first_frame = true; + disconnect_fired = false; + + const auto& controller = (controller_type_ == "right") + ? frame.right_controller + : frame.left_controller; + + // Rising-edge detection per bound input: fire once per press. + for (const auto& b : bindings_) { + bool pressed = false; + + switch (b.input) { + case VrInput::kButtonOne: + // Primary button: A on the right controller, X on the left. + pressed = (controller.buttons.one != 0); + break; + case VrInput::kButtonTwo: + // Secondary button: B on the right controller, Y on the left. + pressed = (controller.buttons.two != 0); + break; + default: + pressed = false; + break; + } + + const bool was = prev_pressed_[b.input]; + if (pressed && !was && event_cb_) { + event_cb_(b.event); + } + prev_pressed_[b.input] = pressed; + } + } else if (has_first_frame && + (now - last_frame_time) > disconnect_timeout_ && + !disconnect_fired && + disconnect_cb_) { + // Frames stopped arriving for longer than the disconnect threshold. + disconnect_fired = true; + disconnect_cb_(); + } + + std::this_thread::sleep_for(poll_interval_); + } +} + +REGISTER_HARDWARE(VrSessionControlComponent, "vr_session_control") + +} // namespace trossen::hw::vr From 49b18a619e9893b58df423cd04b62550647c3527 Mon Sep 17 00:00:00 2001 From: abhi chothani Date: Sun, 5 Jul 2026 14:50:16 -0500 Subject: [PATCH 2/4] review: fix VR disconnect detection and adopt VrSessionLease --- .../hw/vr/vr_session_control_component.hpp | 17 ++++--- src/hw/vr/vr_session_control_component.cpp | 51 ++++++------------- 2 files changed, 26 insertions(+), 42 deletions(-) diff --git a/include/trossen_sdk/hw/vr/vr_session_control_component.hpp b/include/trossen_sdk/hw/vr/vr_session_control_component.hpp index 62aa3fd5..7741ca32 100644 --- a/include/trossen_sdk/hw/vr/vr_session_control_component.hpp +++ b/include/trossen_sdk/hw/vr/vr_session_control_component.hpp @@ -26,8 +26,8 @@ namespace trossen::hw::vr { * * Polls the shared VR frame stream on a background thread, detects rising * edges on bound buttons, and fires `SessionControlCapable` event callbacks. - * Disconnection is detected when frames stop arriving for longer than the - * configured threshold. Input conflicts (e.g. two components claiming the + * Disconnection is detected from the shared `VrSession` connection state. + * Input conflicts (e.g. two components claiming the * same button on the same controller side) are caught at `configure()` time. */ class VrSessionControlComponent @@ -61,8 +61,6 @@ class VrSessionControlComponent * - `connection_timeout_s`: How long `start()` blocks for the headset * app to connect before throwing (default 10 s). * - `poll_interval_ms`: reader-thread poll cadence (default 50 ms). - * - `disconnect_timeout_s`: consecutive seconds without a new - * frame before disconnect fires (default 2 s). * * Claims the inputs referenced by `bindings` on the configured controller * type so overlapping VR configurations fail loudly at configure() time. @@ -74,6 +72,11 @@ class VrSessionControlComponent // ── SessionControlCapable ──────────────────────────────────────────── + /// Install the event/disconnect callbacks. Call before `start()`; the + /// reader thread reads them without locking, so do not swap them while + /// running. Callbacks run on the reader thread and must not call this + /// component's own `stop()` (that self-joins the reader thread — deadlock); + /// hand the intent to the main loop instead. void set_callbacks(EventCallback on_event, DisconnectCallback on_disconnect) override; void start() override; @@ -97,10 +100,10 @@ class VrSessionControlComponent std::uint16_t vr_port_{9000}; std::chrono::milliseconds connection_timeout_{std::chrono::seconds{10}}; std::chrono::milliseconds poll_interval_{std::chrono::milliseconds{50}}; - std::chrono::milliseconds disconnect_timeout_{std::chrono::seconds{2}}; - /// VR session refcount: acquired in configure(), released in dtor. - bool session_held_{false}; + /// Holds this component's reference on the shared VrSession; releases the + /// reference and input claims automatically on teardown. + VrSessionLease session_lease_; /// Callbacks installed by SessionManager via `set_callbacks()`. EventCallback event_cb_; diff --git a/src/hw/vr/vr_session_control_component.cpp b/src/hw/vr/vr_session_control_component.cpp index 37f8dfff..d81691f0 100644 --- a/src/hw/vr/vr_session_control_component.cpp +++ b/src/hw/vr/vr_session_control_component.cpp @@ -57,14 +57,9 @@ std::vector default_bindings() { } // namespace VrSessionControlComponent::~VrSessionControlComponent() { - // Stop the reader thread before anything else so callbacks stop - // firing into a half-destructed object. + // Stop the reader thread first so callbacks stop firing into a + // half-destructed object; the session lease releases on destruction. stop(); - if (session_held_) { - VrSession::instance().release_claims(get_identifier()); - VrSession::instance().release(); - session_held_ = false; - } } void VrSessionControlComponent::configure(const nlohmann::json& config) { @@ -98,15 +93,6 @@ void VrSessionControlComponent::configure(const nlohmann::json& config) { } poll_interval_ = std::chrono::milliseconds{poll_ms}; - const double disconnect_s = config.value("disconnect_timeout_s", 2.0); - if (!std::isfinite(disconnect_s) || disconnect_s <= 0.0) { - throw std::runtime_error( - "VrSessionControlComponent: 'disconnect_timeout_s' must be " - "positive and finite"); - } - disconnect_timeout_ = std::chrono::milliseconds( - static_cast(disconnect_s * 1000.0)); - // Parse bindings. A user-provided `bindings` block fully replaces // the defaults — otherwise unbinding a default entry is impossible. bindings_.clear(); @@ -151,8 +137,7 @@ void VrSessionControlComponent::configure(const nlohmann::json& config) { // Acquire the shared VR session and claim the bound inputs on the // configured controller type. Conflicts with arm/base/other session-control // components surface here as readable errors. - VrSession::instance().ensure_started(vr_port_); - session_held_ = true; + session_lease_.acquire(vr_port_, get_identifier()); // Claim one input at a time so a conflict error names the specific button // that clashed rather than the whole set. @@ -216,23 +201,26 @@ void VrSessionControlComponent::stop() { void VrSessionControlComponent::reader_loop() { auto& session = VrSession::instance(); - - // Connection staleness tracking: monitor when we last saw a valid frame. - auto last_frame_time = std::chrono::steady_clock::now(); - bool has_first_frame = false; bool disconnect_fired = false; while (!stop_requested_.load()) { - const auto frame_opt = session.latest_frame(); - const auto now = std::chrono::steady_clock::now(); + // Use the session's connection state to detect drops: latest_frame() + // keeps returning the last frame after a disconnect, so it can never + // signal one on its own. + if (!session.is_vr_connected()) { + if (!disconnect_fired && disconnect_cb_) { + disconnect_fired = true; + disconnect_cb_(); + } + std::this_thread::sleep_for(poll_interval_); + continue; + } + disconnect_fired = false; + const auto frame_opt = session.latest_frame(); if (frame_opt) { const auto& frame = *frame_opt; - last_frame_time = now; - has_first_frame = true; - disconnect_fired = false; - const auto& controller = (controller_type_ == "right") ? frame.right_controller : frame.left_controller; @@ -261,13 +249,6 @@ void VrSessionControlComponent::reader_loop() { } prev_pressed_[b.input] = pressed; } - } else if (has_first_frame && - (now - last_frame_time) > disconnect_timeout_ && - !disconnect_fired && - disconnect_cb_) { - // Frames stopped arriving for longer than the disconnect threshold. - disconnect_fired = true; - disconnect_cb_(); } std::this_thread::sleep_for(poll_interval_); From abce767c7cfde92f2e31549f967ac33117ab57a6 Mon Sep 17 00:00:00 2001 From: abhi chothani Date: Sun, 5 Jul 2026 17:36:20 -0500 Subject: [PATCH 3/4] test: add VrSessionControlComponent button/disconnect tests --- tests/CMakeLists.txt | 4 + tests/test_vr_session_control_component.cpp | 186 ++++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 tests/test_vr_session_control_component.cpp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 3dbd4136..69665700 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -44,6 +44,9 @@ if(TROSSEN_ENABLE_VR) add_executable(test_vr_base_component test_vr_base_component.cpp) target_link_libraries(test_vr_base_component PRIVATE trossen_sdk GTest::gtest_main) + + add_executable(test_vr_session_control_component test_vr_session_control_component.cpp) + target_link_libraries(test_vr_session_control_component PRIVATE trossen_sdk GTest::gtest_main) endif() add_executable(test_lerobot_depth_utils test_lerobot_depth_utils.cpp) @@ -127,6 +130,7 @@ if(TROSSEN_ENABLE_VR) gtest_discover_tests(test_vr_session) gtest_discover_tests(test_vr_arm_component) gtest_discover_tests(test_vr_base_component) + gtest_discover_tests(test_vr_session_control_component) endif() gtest_discover_tests(test_lerobot_depth_utils) gtest_discover_tests(test_record_types) diff --git a/tests/test_vr_session_control_component.cpp b/tests/test_vr_session_control_component.cpp new file mode 100644 index 00000000..3fd3e3d3 --- /dev/null +++ b/tests/test_vr_session_control_component.cpp @@ -0,0 +1,186 @@ +/** + * @file test_vr_session_control_component.cpp + * @brief Unit tests for the VR button → session-control bridge. + * + * Drives the real VrSessionControlComponent with synthetic frames via the + * VrSession test seam — no VR hardware required. Covers config/binding + * validation, button rising-edge detection, and the disconnect path + * (which must fire exactly once per drop and re-arm on reconnect). + */ + +#include +#include +#include +#include + +#include "gtest/gtest.h" +#include "nlohmann/json.hpp" + +#include "trossen_vr/vr_types.hpp" + +#include "trossen_sdk/hw/session_control/session_control_capable.hpp" +#include "trossen_sdk/hw/vr/vr_session.hpp" +#include "trossen_sdk/hw/vr/vr_session_control_component.hpp" + +using trossen::hw::session_control::SessionControlEvent; +using trossen::hw::vr::VrSession; +using trossen::hw::vr::VrSessionControlComponent; + +namespace { + +trossen_vr::VRFrame button_frame(const std::string& side, uint8_t one, + uint8_t two) { + trossen_vr::VRFrame f; + auto& c = (side == "right") ? f.right_controller : f.left_controller; + c.is_tracked = 1; + c.buttons.one = one; + c.buttons.two = two; + return f; +} + +nlohmann::json sc_config() { + return nlohmann::json{ + {"controller_type", "right"}, + {"poll_interval_ms", 5}, + }; +} + +// Poll a predicate until true or the timeout elapses (reader runs on its own +// thread, so results arrive asynchronously). +template +bool wait_for(Pred pred, + std::chrono::milliseconds timeout = std::chrono::seconds(2)) { + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::chrono::steady_clock::now() < deadline) { + if (pred()) return true; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + return pred(); +} + +} // namespace + +// ── config / binding validation (no thread) ───────────────────────────────── + +TEST(VrSessionControlConfig, MissingControllerTypeThrows) { + VrSessionControlComponent c("sc_cfg1"); + EXPECT_THROW(c.configure(nlohmann::json::object()), std::runtime_error); +} + +TEST(VrSessionControlConfig, InvalidControllerTypeThrows) { + VrSessionControlComponent c("sc_cfg2"); + EXPECT_THROW(c.configure({{"controller_type", "middle"}}), + std::runtime_error); +} + +TEST(VrSessionControlConfig, BindingsMustBeObject) { + VrSessionControlComponent c("sc_cfg3"); + EXPECT_THROW( + c.configure({{"controller_type", "right"}, {"bindings", "nope"}}), + std::runtime_error); +} + +TEST(VrSessionControlConfig, UnknownInputNameThrows) { + VrSessionControlComponent c("sc_cfg4"); + EXPECT_THROW( + c.configure({{"controller_type", "right"}, + {"bindings", {{"button_z", "start"}}}}), + std::runtime_error); +} + +TEST(VrSessionControlConfig, UnknownEventNameThrows) { + VrSessionControlComponent c("sc_cfg5"); + EXPECT_THROW( + c.configure({{"controller_type", "right"}, + {"bindings", {{"button_a", "launch"}}}}), + std::runtime_error); +} + +TEST(VrSessionControlConfig, AliasDoubleBindThrows) { + // button_a and button_x are aliases for the same physical button; binding + // both would fire the event twice per press. + VrSessionControlComponent c("sc_cfg6"); + EXPECT_THROW( + c.configure({{"controller_type", "right"}, + {"bindings", {{"button_a", "start"}, {"button_x", "rerecord"}}}}), + std::runtime_error); +} + +TEST(VrSessionControlConfig, EmptyBindingsThrows) { + VrSessionControlComponent c("sc_cfg7"); + EXPECT_THROW( + c.configure({{"controller_type", "right"}, {"bindings", nlohmann::json::object()}}), + std::runtime_error); +} + +TEST(VrSessionControlConfig, NonPositivePollIntervalThrows) { + VrSessionControlComponent c("sc_cfg8"); + EXPECT_THROW( + c.configure({{"controller_type", "right"}, {"poll_interval_ms", 0}}), + std::runtime_error); +} + +// ── button detection + disconnect (seam-driven, threaded) ─────────────────── + +class VrSessionControlRun : public ::testing::Test { + protected: + void SetUp() override { + VrSession::instance().set_test_frame(button_frame("right", 0, 0)); + } + void TearDown() override { VrSession::instance().clear_test_frame(); } +}; + +TEST_F(VrSessionControlRun, ButtonPressFiresEventOncePerEdge) { + VrSessionControlComponent c("sc_edge"); + c.configure(sc_config()); + + std::atomic start_count{0}; + c.set_callbacks( + [&](SessionControlEvent e) { + if (e == SessionControlEvent::kStart) ++start_count; + }, + [] {}); + c.start(); + + // Press the primary button (default binding: button one → start). + VrSession::instance().set_test_frame(button_frame("right", 1, 0)); + ASSERT_TRUE(wait_for([&] { return start_count.load() == 1; })); + + // Holding it must not re-fire. + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + EXPECT_EQ(start_count.load(), 1); + + // Release then press again → a second edge. + VrSession::instance().set_test_frame(button_frame("right", 0, 0)); + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + VrSession::instance().set_test_frame(button_frame("right", 1, 0)); + EXPECT_TRUE(wait_for([&] { return start_count.load() == 2; })); + + c.stop(); +} + +TEST_F(VrSessionControlRun, DisconnectFiresOnceAndReArms) { + VrSessionControlComponent c("sc_disc"); + c.configure(sc_config()); + + std::atomic disconnect_count{0}; + c.set_callbacks([](SessionControlEvent) {}, + [&] { ++disconnect_count; }); + c.start(); + + // Drop the link: the disconnect callback must fire exactly once. + VrSession::instance().set_test_connected(false); + ASSERT_TRUE(wait_for([&] { return disconnect_count.load() == 1; })); + + // Still down → must not fire repeatedly. + std::this_thread::sleep_for(std::chrono::milliseconds(40)); + EXPECT_EQ(disconnect_count.load(), 1); + + // Reconnect then drop again → re-armed, fires a second time. + VrSession::instance().set_test_frame(button_frame("right", 0, 0)); + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + VrSession::instance().set_test_connected(false); + EXPECT_TRUE(wait_for([&] { return disconnect_count.load() == 2; })); + + c.stop(); +} From 6404b8670f46643789dca0ea289e87ea283a85b6 Mon Sep 17 00:00:00 2001 From: abhi chothani Date: Wed, 8 Jul 2026 17:09:23 -0500 Subject: [PATCH 4/4] review: use base-class fire-once helpers in VrSessionControlComponent --- .../hw/vr/vr_session_control_component.hpp | 11 --------- src/hw/vr/vr_session_control_component.cpp | 23 ++++--------------- 2 files changed, 5 insertions(+), 29 deletions(-) diff --git a/include/trossen_sdk/hw/vr/vr_session_control_component.hpp b/include/trossen_sdk/hw/vr/vr_session_control_component.hpp index 7741ca32..8039ffe9 100644 --- a/include/trossen_sdk/hw/vr/vr_session_control_component.hpp +++ b/include/trossen_sdk/hw/vr/vr_session_control_component.hpp @@ -72,13 +72,6 @@ class VrSessionControlComponent // ── SessionControlCapable ──────────────────────────────────────────── - /// Install the event/disconnect callbacks. Call before `start()`; the - /// reader thread reads them without locking, so do not swap them while - /// running. Callbacks run on the reader thread and must not call this - /// component's own `stop()` (that self-joins the reader thread — deadlock); - /// hand the intent to the main loop instead. - void set_callbacks(EventCallback on_event, - DisconnectCallback on_disconnect) override; void start() override; void stop() override; @@ -105,10 +98,6 @@ class VrSessionControlComponent /// reference and input claims automatically on teardown. VrSessionLease session_lease_; - /// Callbacks installed by SessionManager via `set_callbacks()`. - EventCallback event_cb_; - DisconnectCallback disconnect_cb_; - /// Reader-thread state. `stop_requested_` ends the loop; `running_` /// guards start/stop idempotency. std::thread reader_thread_; diff --git a/src/hw/vr/vr_session_control_component.cpp b/src/hw/vr/vr_session_control_component.cpp index d81691f0..8ad99012 100644 --- a/src/hw/vr/vr_session_control_component.cpp +++ b/src/hw/vr/vr_session_control_component.cpp @@ -8,7 +8,6 @@ #include #include #include -#include #include #include "trossen_sdk/hw/hardware_registry.hpp" @@ -166,14 +165,6 @@ nlohmann::json VrSessionControlComponent::get_info() const { return j; } -void VrSessionControlComponent::set_callbacks( - EventCallback on_event, - DisconnectCallback on_disconnect) -{ - event_cb_ = std::move(on_event); - disconnect_cb_ = std::move(on_disconnect); -} - void VrSessionControlComponent::start() { if (running_.exchange(true)) return; // Already running. @@ -201,21 +192,17 @@ void VrSessionControlComponent::stop() { void VrSessionControlComponent::reader_loop() { auto& session = VrSession::instance(); - bool disconnect_fired = false; while (!stop_requested_.load()) { // Use the session's connection state to detect drops: latest_frame() // keeps returning the last frame after a disconnect, so it can never - // signal one on its own. + // signal one on its own. The base guarantees the disconnect fires once. if (!session.is_vr_connected()) { - if (!disconnect_fired && disconnect_cb_) { - disconnect_fired = true; - disconnect_cb_(); - } + signal_disconnect(); std::this_thread::sleep_for(poll_interval_); continue; } - disconnect_fired = false; + arm_disconnect(); const auto frame_opt = session.latest_frame(); if (frame_opt) { @@ -244,8 +231,8 @@ void VrSessionControlComponent::reader_loop() { } const bool was = prev_pressed_[b.input]; - if (pressed && !was && event_cb_) { - event_cb_(b.event); + if (pressed && !was) { + emit_event(b.event); } prev_pressed_[b.input] = pressed; }