-
Notifications
You must be signed in to change notification settings - Fork 6
vr: add VrSessionControlComponent button-to-session bridge #263
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: vr-06-vr-base-component
Are you sure you want to change the base?
Changes from all commits
e8b7fce
49b18a6
abce767
6404b86
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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_ |
| 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) { | ||
| 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}); | ||
| } | ||
|
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()}, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A couple of small timing notes: callbacks should be set before
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment.
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. Ifconfigure()runs twice, the count never reaches zero and the connection never closes. Can we release before re-taking it, or block a second configure?There was a problem hiding this comment.
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.