Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
113 changes: 113 additions & 0 deletions include/trossen_sdk/hw/vr/vr_session_control_component.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* @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 <atomic>
#include <chrono>
#include <cstdint>
#include <memory>
#include <string>
#include <thread>
#include <unordered_map>
#include <vector>

#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 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
: 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).
*
* 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 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<Binding> 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}};

/// Holds this component's reference on the shared VrSession; releases the
/// reference and input claims automatically on teardown.
VrSessionLease session_lease_;

/// Reader-thread state. `stop_requested_` ends the loop; `running_`
/// guards start/stop idempotency.
std::thread reader_thread_;
std::atomic<bool> stop_requested_{false};
std::atomic<bool> running_{false};

/// Rising-edge state per bound input.
std::unordered_map<VrInput, bool> prev_pressed_;
};

} // namespace trossen::hw::vr

#endif // TROSSEN_SDK__HW__VR__VR_SESSION_CONTROL_COMPONENT_HPP_
247 changes: 247 additions & 0 deletions src/hw/vr/vr_session_control_component.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
/**
* @file vr_session_control_component.cpp
* @brief VR button → session-control event bridge.
*/

#include "trossen_sdk/hw/vr/vr_session_control_component.hpp"

#include <cmath>
#include <stdexcept>
#include <string>
#include <vector>

#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<VrSessionControlComponent::Binding> default_bindings() {
return {
{VrInput::kButtonOne, Event::kStart},
{VrInput::kButtonTwo, Event::kRerecord},
};
}

} // namespace

VrSessionControlComponent::~VrSessionControlComponent() {
// Stop the reader thread first so callbacks stop firing into a
// half-destructed object; the session lease releases on destruction.
stop();
}

void VrSessionControlComponent::configure(const nlohmann::json& config) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Each configure() says "I am using the connection," but the cleanup only says "I am done" once. If configure() runs twice, the count never reaches zero and the connection never closes. Can we release before re-taking it, or block a second configure?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed via the RAII lease: acquire() releases the previous reference before taking a new one, so a second configure() can't leak.

if (!config.contains("controller_type")) {
throw std::runtime_error(
"VrSessionControlComponent: 'controller_type' is required "
"(\"left\" or \"right\")");
}
controller_type_ = config.at("controller_type").get<std::string>();
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<std::uint16_t>(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<std::int64_t>(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};

// 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<std::string>())});
}
} 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.
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.
auto& session = VrSession::instance();
for (const auto& b : bindings_) {
session.claim_inputs(controller_type_, get_identifier(), {b.input});
}
Comment thread
abhichothani42 marked this conversation as resolved.

// 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()},

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This spot uses the proper "is it connected?" check. Reusing it in the worker loop above makes the disconnect detection work.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done, reused it in the reader loop.

};
return j;
}

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() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

A couple of small timing notes: callbacks should be set before start() and not swapped while running, and a callback should not call stop() on this same component (it would make the worker wait for itself). Worth saying so in the header.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added to set_callbacks, set before start(), don't swap while running, and a callback must not call this component's own 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();

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. The base guarantees the disconnect fires once.
if (!session.is_vr_connected()) {
signal_disconnect();
std::this_thread::sleep_for(poll_interval_);
continue;
}
arm_disconnect();

const auto frame_opt = session.latest_frame();
if (frame_opt) {
const auto& frame = *frame_opt;

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) {
emit_event(b.event);
}
prev_pressed_[b.input] = pressed;
}
}

std::this_thread::sleep_for(poll_interval_);
}
}

REGISTER_HARDWARE(VrSessionControlComponent, "vr_session_control")

} // namespace trossen::hw::vr
4 changes: 4 additions & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading