From 44c99f2980bfe2b353d3e45dd0f608cdd591ff88 Mon Sep 17 00:00:00 2001 From: DeeJanuz <45082401+DeeJanuz@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:24:43 -0600 Subject: [PATCH] Pointer: treadmill role in the driver, and probes for hand interaction The driver takes "role treadmill" and hints its role from Activate, so a treadmill gets /user/treadmill (from gaze-first 87a6393); the compositor binding has the /user/treadmill entries. The default role stays the right hand until the helper side is ready. docs/design.md says why (Treadmill role). Probes for frame-hands/notes/hand-interaction.md section 7: - pointer/probe/lasertest (from gaze-first): who has the laser, claim time - pointer/probe/ctlnoise: controller pose noise, activity level, charging - vrprobe prints each device's activity level and charging - input/vrws.py --touch prints touch and value components Co-Authored-By: Claude Opus 5.5 --- docs/design.md | 4 + input/vrws.py | 18 +- pointer/driver/driver_ft_pointer.cpp | 30 +-- .../input/ft_pointer_vrcompositor.json | 85 +++++++++ pointer/probe/build.sh | 13 +- pointer/probe/ctlnoise.cpp | 171 ++++++++++++++++++ pointer/probe/lasertest.cpp | 148 +++++++++++++++ pointer/probe/vrprobe.cpp | 14 +- 8 files changed, 461 insertions(+), 22 deletions(-) create mode 100644 pointer/probe/ctlnoise.cpp create mode 100644 pointer/probe/lasertest.cpp diff --git a/docs/design.md b/docs/design.md index d417fcf..1ad0677 100644 --- a/docs/design.md +++ b/docs/design.md @@ -119,6 +119,10 @@ Frametop's SteamVR clients (ft-pointer, ft-screens, ft-gaze) connect as a backgr The driver starts disconnected, because holding the right-hand role while SteamVR starts leaves the Steam UI stuck on its loading icon. It connects when the mouse is used and claims the right hand. SteamVR keeps a hand role reserved for a disconnected device that still asks for it, so the driver switches its role hint between right hand (connected) and opt-out (not connected). +#### Treadmill role + +With a hand role, a Frame controller held in that hand takes the role back (its touch sensors count as use), and the virtual controller is left with no role and no clicks. So mixing a controller in one hand with the 3D mouse or a bare hand's ray in the other fails whenever that controller is merely held. The treadmill role (4) is no hand: the real controllers keep both hand roles, and the virtual controller never fights them. It held the dashboard laser in the gaze-first tests (2026-09-30, branch `gaze-first`), with two conditions. SteamVR gives a device the `/user/treadmill` path only when the device is added, so the driver must hint the role from `Activate`, not on connect: the `role` in its `default.vrsettings` (or `driver_ft_pointer/role` in SteamVR's settings), and switching it needs a SteamVR restart. And the compositor binding needs `/user/treadmill` entries next to the hand ones (`ft_pointer_vrcompositor.json`). A treadmill reports no controller role (`GetControllerRoleForTrackedDeviceIndex` is invalid), so the helper's "no hand role" release skips it. The laser claim (`/input/a`, bound to `switchlaserhand`) works the same; SteamVR took the laser back to our device 11-15 ms after a controller took it. + ### The cursor Mouse motion turns into yaw and pitch around an anchor, the head position at the last recenter. A ray from the anchor is tested against every visible overlay with `ComputeOverlayIntersection`. On a hit, the cursor sits on that surface; otherwise it floats at `POINTER_DISTANCE`. Since the anchor isn't your current eye position, a second test runs along your line of sight to the cursor point, and anything nearer wins, so the cursor always lands on what you see under it. Overlays in `POINTER_IGNORE` are left out of both tests. A display-only panel, like a performance overlay locked to your view, has no input method, so SteamVR's laser passes through it, but `ComputeOverlayIntersection` still hits it, and the cursor stuck to it. The laser starts just before the cursor point, so an ignored panel nearer to you doesn't catch it either. Both tests ask SteamVR about every visible overlay, so a frame where nothing moved (the mouse, the anchor, the eye by more than 5 mm, which overlays show) reuses the last result, for up to 100 ms, since overlays can also move on their own. The dots' overlay settings go to SteamVR only when they change, and the pose goes to the driver, which keeps the last one, only when the laser would land 0.1 mm or more elsewhere, and at least every 100 ms. diff --git a/input/vrws.py b/input/vrws.py index ae41784..5ad85a7 100755 --- a/input/vrws.py +++ b/input/vrws.py @@ -18,9 +18,11 @@ The host's Python has no `websockets` module, so this is a small RFC 6455 client "components": {"/input/thumbstick/click": true, ...}} with the components that changed (mostly capacitive sensing, about 160 a second) -Run as a program, it prints the controllers' button changes for a while (default 10 s): +Run as a program, it prints the controllers' button changes for a while (default 10 s), or with +--touch, every touch and value component too (probe P3 of the hand interaction design: which +components say a controller is held, and how soon they let go): - input/vrws.py [seconds] + input/vrws.py [--touch] [seconds] """ import base64 import json @@ -166,7 +168,10 @@ class VrSocket: def main(): - seconds = float(sys.argv[1]) if len(sys.argv) > 1 else 10.0 + args = sys.argv[1:] + touch = "--touch" in args + args = [a for a in args if a != "--touch"] + seconds = float(args[0]) if args else 10.0 ws = VrSocket() ws.open(f"frametop_vrws_{os.getpid()}") sides = controllers() @@ -181,7 +186,7 @@ def main(): while time.monotonic() - start < seconds: for text in ws.messages(timeout=0.5): count += 1 - if "/click" not in text: + if "/click" not in text and not (touch and ("/touch" in text or "/value" in text)): continue msg = json.loads(text) side = sides.get(msg.get("device"), msg.get("device")) @@ -189,6 +194,11 @@ def main(): if name.endswith("/click"): print(f"{time.monotonic() - start:7.3f} {side} {name[len('/input/'):-len('/click')]} " f"{'down' if value else 'up'}", flush=True) + elif touch and name.endswith("/touch"): + print(f"{time.monotonic() - start:7.3f} {side} {name[len('/input/'):-len('/touch')]} " + f"{'touched' if value else 'let go'}", flush=True) + elif touch and name.endswith("/value"): + print(f"{time.monotonic() - start:7.3f} {side} {name} {value}", flush=True) finally: ws.close() print(f"{count} messages in {seconds:.0f} s") diff --git a/pointer/driver/driver_ft_pointer.cpp b/pointer/driver/driver_ft_pointer.cpp index 63f6949..7d8ef5a 100644 --- a/pointer/driver/driver_ft_pointer.cpp +++ b/pointer/driver/driver_ft_pointer.cpp @@ -17,11 +17,12 @@ // btn <0|1> name: trigger, b, x, system, joystick, a (a = claim the laser, no click) // scroll joystick deflection -1..1 // show | hide connect (take the hand role) or disconnect (give it back) -// role right|left|stylus which role to hint while connected, from now on (the helper -// sends POINTER_ROLE). A Frame controller in your hand takes -// its hand's role back (it counts as used while held), so a +// role right|left|stylus|treadmill which role to hint while connected, from now on (the +// helper sends POINTER_ROLE). A Frame controller in your hand +// takes its hand's role back (it counts as used while held), so a // controller used beside the pointer needs the pointer on the -// other hand, or on no hand at all +// other hand, or on no hand at all. Treadmill is no hand: its +// bindings are under /user/treadmill (docs/design.md, "Treadmill role") // // The device starts disconnected, so it never holds a hand role at boot (holding // the right hand while SteamVR started left the Steam UI stuck loading). It @@ -30,7 +31,8 @@ // SteamVR keeps a hand role reserved for a disconnected device that still hints // that hand, so the real controller would never get it back otherwise. // -// Settings (steamvr.vrsettings section "driver_ft_pointer"): role (int, 2 = right hand, 5 = stylus). +// Settings (steamvr.vrsettings section "driver_ft_pointer"): role (int, 2 = right hand, 4 = treadmill, +// 5 = stylus). #include #include @@ -115,7 +117,9 @@ public: props->SetStringProperty(c, Prop_ControllerType_String, "ft_pointer"); props->SetStringProperty(c, Prop_InputProfilePath_String, "{ft_pointer}/input/ft_pointer_profile.json"); props->SetStringProperty(c, Prop_RenderModelName_String, "{ft_pointer}/rendermodels/ft_pointer_invisible"); - props->SetInt32Property(c, Prop_ControllerRoleHint_Int32, TrackedControllerRole_OptOut); // until "show" + // A hand only while shown (see the top); a role that's no hand from the start, since + // SteamVR gives a treadmill its path only when the device is added (docs/design.md). + props->SetInt32Property(c, Prop_ControllerRoleHint_Int32, HintFor(false)); props->SetInt32Property(c, Prop_DeviceClass_Int32, TrackedDeviceClass_Controller); props->SetBoolProperty(c, Prop_NeverTracked_Bool, false); @@ -211,12 +215,11 @@ public: } if (snapshot.role > 0 && snapshot.role != role_) { role_ = snapshot.role; - if (hinted_) VRProperties()->SetInt32Property(container_, Prop_ControllerRoleHint_Int32, role_); + VRProperties()->SetInt32Property(container_, Prop_ControllerRoleHint_Int32, HintFor(hinted_)); } if (snapshot.visible != hinted_) { // Claim the hand before connecting; give it up when disconnecting. - VRProperties()->SetInt32Property(container_, Prop_ControllerRoleHint_Int32, - snapshot.visible ? role_ : int32_t(TrackedControllerRole_OptOut)); + VRProperties()->SetInt32Property(container_, Prop_ControllerRoleHint_Int32, HintFor(snapshot.visible)); hinted_ = snapshot.visible; } const bool ok = hmd.bPoseIsValid && snapshot.visible; @@ -248,7 +251,11 @@ private: uint32_t objectId_ = k_unTrackedDeviceIndexInvalid; PropertyContainerHandle_t container_ = k_ulInvalidPropertyContainer; int32_t role_ = TrackedControllerRole_RightHand; - bool hinted_ = false; // whether the role hint currently claims role_ + bool hinted_ = false; // whether we're shown (a hand role is hinted only then) + int32_t HintFor(bool shown) const { + const bool hand = role_ == TrackedControllerRole_LeftHand || role_ == TrackedControllerRole_RightHand; + return shown || !hand ? role_ : int32_t(TrackedControllerRole_OptOut); + } DriverPose_t pose_{}; bool reportedOff_ = false; // the disconnected pose has gone out (it isn't repeated) // What the input components were last set to. @@ -335,7 +342,8 @@ private: role = !std::strcmp(name, "left") ? TrackedControllerRole_LeftHand : !std::strcmp(name, "right") ? TrackedControllerRole_RightHand : !std::strcmp(name, "stylus") ? TrackedControllerRole_Stylus - : 0; + : !std::strcmp(name, "treadmill") ? TrackedControllerRole_Treadmill + : 0; if (role) kind = kRole; } else if (std::sscanf(cmd, "btn %31s %d", name, &v) == 2) { for (int i = 0; i < kButtons; ++i) diff --git a/pointer/driver/ft_pointer/resources/input/ft_pointer_vrcompositor.json b/pointer/driver/ft_pointer/resources/input/ft_pointer_vrcompositor.json index 7d2531f..37ec32b 100644 --- a/pointer/driver/ft_pointer/resources/input/ft_pointer_vrcompositor.json +++ b/pointer/driver/ft_pointer/resources/input/ft_pointer_vrcompositor.json @@ -82,6 +82,42 @@ "output": "/actions/lasermouse/in/back" } } + }, + { + "path": "/user/treadmill/input/trigger", + "mode": "button", + "inputs": { + "click": { + "output": "/actions/lasermouse/in/leftclick" + } + } + }, + { + "path": "/user/treadmill/input/b", + "mode": "button", + "inputs": { + "click": { + "output": "/actions/lasermouse/in/rightclick" + } + } + }, + { + "path": "/user/treadmill/input/x", + "mode": "button", + "inputs": { + "click": { + "output": "/actions/lasermouse/in/middleclick" + } + } + }, + { + "path": "/user/treadmill/input/joystick", + "mode": "button", + "inputs": { + "click": { + "output": "/actions/lasermouse/in/back" + } + } } ], "poses": [ @@ -92,6 +128,10 @@ { "output": "/actions/lasermouse/in/Pointer", "path": "/user/hand/right/pose/raw" + }, + { + "output": "/actions/lasermouse/in/Pointer", + "path": "/user/treadmill/pose/raw" } ] }, @@ -114,6 +154,15 @@ "output": "/actions/scroll_discrete/in/scroll" } } + }, + { + "path": "/user/treadmill/input/joystick", + "mode": "scroll", + "inputs": { + "scroll": { + "output": "/actions/scroll_discrete/in/scroll" + } + } } ] }, @@ -136,6 +185,15 @@ "output": "/actions/scroll_smooth/in/scroll" } } + }, + { + "path": "/user/treadmill/input/joystick", + "mode": "scroll", + "inputs": { + "scroll": { + "output": "/actions/scroll_smooth/in/scroll" + } + } } ] }, @@ -158,6 +216,15 @@ "output": "/actions/system/in/ToggleDashboard" } } + }, + { + "path": "/user/treadmill/input/system", + "mode": "button", + "inputs": { + "click": { + "output": "/actions/system/in/ToggleDashboard" + } + } } ] }, @@ -198,6 +265,24 @@ "output": "/actions/lasermouse_secondary/in/switchlaserhand" } } + }, + { + "path": "/user/treadmill/input/trigger", + "mode": "button", + "inputs": { + "click": { + "output": "/actions/lasermouse_secondary/in/switchlaserhand" + } + } + }, + { + "path": "/user/treadmill/input/a", + "mode": "button", + "inputs": { + "click": { + "output": "/actions/lasermouse_secondary/in/switchlaserhand" + } + } } ] } diff --git a/pointer/probe/build.sh b/pointer/probe/build.sh index 830fd04..9b76f74 100755 --- a/pointer/probe/build.sh +++ b/pointer/probe/build.sh @@ -1,9 +1,14 @@ #!/usr/bin/env bash -# Build vrprobe in the dev container on the Frame (pointer/probe/build/vrprobe). +# Build the probes in the dev container on the Frame (pointer/probe/build/): +# vrprobe devices, roles, activity levels, charging, the dashboard's pointer device +# lasertest who has SteamVR's laser mouse, and how fast our device takes it back +# ctlnoise controller pose noise, resting and held still (the handoff arbiter's thresholds) set -euo pipefail root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) "$root/scripts/sync.sh" >/dev/null exec "$root/scripts/frame.sh" -C pointer/probe 'set -e; mkdir -p build -g++ -std=c++17 -O2 -Wall -I/opt/steamvr/tools/hellovr_vulkan_linux/src/openvr/headers -I../common \ - -o build/vrprobe vrprobe.cpp -L/opt/steamvr/bin/linuxarm64 -lopenvr_api -Wl,-rpath,/opt/steamvr/bin/linuxarm64 -echo "built build/vrprobe"' +. ../../scripts/openvr.sh +for p in vrprobe lasertest ctlnoise; do + g++ -std=c++17 -O2 -Wall $OPENVR_CFLAGS -I../common -o build/$p $p.cpp $OPENVR_LIBS +done +echo "built build/vrprobe build/lasertest build/ctlnoise"' diff --git a/pointer/probe/ctlnoise.cpp b/pointer/probe/ctlnoise.cpp new file mode 100644 index 0000000..33a29a9 --- /dev/null +++ b/pointer/probe/ctlnoise.cpp @@ -0,0 +1,171 @@ +// Controller noise logger: probes P4 and P5 of the hand interaction design +// (frame-hands/notes/hand-interaction.md, section 7). For the handoff arbiter's thresholds +// (pointer/helper/handoff.h): how much a controller resting on a desk moves, against one held as +// still as possible, and what SteamVR says about it meanwhile. +// +// Every 11 ms it reads every controller's pose (standing universe). Each second it prints, per +// controller: tracking result, activity level, charging, position spread over that second (the +// largest axis' standard deviation, mm), the mean and largest speed (m/s) and spin (rad/s), and +// how many 100 ms runs would count as "moving" for the arbiter (speed > 0.15 m/s or spin > +// 1 rad/s for 100 ms in a row). At the end, per controller: the 1 s spreads' p50 and p99, and +// the speed and spin p99 over every sample. +// Runs as a background OpenVR client (in the dev container); it takes nothing from anyone. +// Usage: ctlnoise [--seconds N] [--quiet] (--quiet: only the summary) +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using Clock = std::chrono::steady_clock; + +static volatile std::sig_atomic_t g_stop = 0; + +namespace { + +const char *Activity(vr::EDeviceActivityLevel l) { + switch (l) { + case vr::k_EDeviceActivityLevel_Unknown: return "unknown"; + case vr::k_EDeviceActivityLevel_Idle: return "idle"; + case vr::k_EDeviceActivityLevel_UserInteraction: return "interaction"; + case vr::k_EDeviceActivityLevel_UserInteraction_Timeout: return "interaction-timeout"; + case vr::k_EDeviceActivityLevel_Standby: return "standby"; + case vr::k_EDeviceActivityLevel_Idle_Timeout: return "idle-timeout"; + } + return "?"; +} + +const char *Result(vr::ETrackingResult r) { + switch (r) { + case vr::TrackingResult_Uninitialized: return "uninit"; + case vr::TrackingResult_Calibrating_InProgress: return "calibrating"; + case vr::TrackingResult_Calibrating_OutOfRange: return "cal-out"; + case vr::TrackingResult_Running_OK: return "ok"; + case vr::TrackingResult_Running_OutOfRange: return "out-of-range"; + case vr::TrackingResult_Fallback_RotationOnly: return "rotation-only"; + } + return "?"; +} + +double Pct(std::vector v, double p) { + if (v.empty()) return 0; + std::sort(v.begin(), v.end()); + return v[std::min(v.size() - 1, size_t(p * double(v.size() - 1) + 0.5))]; +} + +struct Window { + double sum[3] = {}, sq[3] = {}; + int n = 0; + double speedSum = 0, speedMax = 0, spinSum = 0, spinMax = 0; + int movingRuns = 0; +}; + +struct Track { + std::string name; + Window w; + std::vector spreads, speeds, spins; + Clock::time_point movingSince{}; + bool counted = false; // this moving run has been counted +}; + +} // namespace + +int main(int argc, char **argv) { + double seconds = 60; + bool quiet = false; + for (int i = 1; i < argc; ++i) { + if (!std::strcmp(argv[i], "--seconds") && i + 1 < argc) seconds = std::atof(argv[++i]); + else if (!std::strcmp(argv[i], "--quiet")) quiet = true; + else { + std::fprintf(stderr, "usage: %s [--seconds N] [--quiet]\n", argv[0]); + return 2; + } + } + std::signal(SIGINT, [](int) { g_stop = 1; }); + std::signal(SIGTERM, [](int) { g_stop = 1; }); + vr::EVRInitError err = vr::VRInitError_None; + vr::IVRSystem *sys = vr::VR_Init(&err, vr::VRApplication_Background); + if (err != vr::VRInitError_None) { + std::printf("VR_Init failed: %s\n", vr::VR_GetVRInitErrorAsEnglishDescription(err)); + return 1; + } + static const char *roles[] = {"none", "left", "right", "optout", "treadmill", "stylus"}; + std::map tracks; + const auto start = Clock::now(); + auto lastPrint = start; + while (!g_stop && std::chrono::duration(Clock::now() - start).count() < seconds) { + vr::TrackedDevicePose_t all[vr::k_unMaxTrackedDeviceCount]; + sys->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding, 0, all, vr::k_unMaxTrackedDeviceCount); + const auto now = Clock::now(); + for (vr::TrackedDeviceIndex_t i = 1; i < vr::k_unMaxTrackedDeviceCount; ++i) { + if (sys->GetTrackedDeviceClass(i) != vr::TrackedDeviceClass_Controller) continue; + char type[64] = ""; + sys->GetStringTrackedDeviceProperty(i, vr::Prop_ControllerType_String, type, sizeof type); + if (!std::strcmp(type, "ft_pointer")) continue; // ours: not a real controller + Track &t = tracks[i]; + const auto role = sys->GetControllerRoleForTrackedDeviceIndex(i); + t.name = std::to_string(i) + " " + type + " " + (role < 6 ? roles[role] : "?"); + const auto &p = all[i]; + if (!p.bPoseIsValid || p.eTrackingResult != vr::TrackingResult_Running_OK) { + t.movingSince = {}; + continue; + } + const auto &m = p.mDeviceToAbsoluteTracking.m; + for (int k = 0; k < 3; ++k) t.w.sum[k] += m[k][3], t.w.sq[k] += double(m[k][3]) * m[k][3]; + ++t.w.n; + const auto &v = p.vVelocity.v, &a = p.vAngularVelocity.v; + const double speed = std::sqrt(double(v[0]) * v[0] + double(v[1]) * v[1] + double(v[2]) * v[2]); + const double spin = std::sqrt(double(a[0]) * a[0] + double(a[1]) * a[1] + double(a[2]) * a[2]); + t.w.speedSum += speed, t.w.speedMax = std::max(t.w.speedMax, speed); + t.w.spinSum += spin, t.w.spinMax = std::max(t.w.spinMax, spin); + t.speeds.push_back(speed), t.spins.push_back(spin); + if (speed > 0.15 || spin > 1.0) { + if (t.movingSince == Clock::time_point{}) t.movingSince = now, t.counted = false; + if (!t.counted && now - t.movingSince >= std::chrono::milliseconds(100)) ++t.w.movingRuns, t.counted = true; + } else { + t.movingSince = {}; + } + } + if (now - lastPrint >= std::chrono::seconds(1)) { + lastPrint = now; + const double at = std::chrono::duration(now - start).count(); + for (auto &[i, t] : tracks) { + vr::ETrackedPropertyError pe; + const bool charging = sys->GetBoolTrackedDeviceProperty(i, vr::Prop_DeviceIsCharging_Bool, &pe); + const auto level = sys->GetTrackedDeviceActivityLevel(i); + double spread = 0; + if (t.w.n > 1) + for (int k = 0; k < 3; ++k) { + const double mean = t.w.sum[k] / t.w.n; + spread = std::max(spread, std::sqrt(std::max(0.0, t.w.sq[k] / t.w.n - mean * mean))); + } + if (t.w.n > 1) t.spreads.push_back(spread * 1000); + if (!quiet) + std::printf("%7.1f s [%s] %s activity=%s charging=%d%s spread %.2f mm speed %.3f/%.3f m/s " + "spin %.2f/%.2f rad/s moving %d\n", + at, t.name.c_str(), Result(all[i].eTrackingResult), Activity(level), charging, + pe == vr::TrackedProp_Success ? "" : "(?)", spread * 1000, + t.w.n ? t.w.speedSum / t.w.n : 0, t.w.speedMax, t.w.n ? t.w.spinSum / t.w.n : 0, + t.w.spinMax, t.w.movingRuns); + t.w = Window{}; + } + std::fflush(stdout); + } + std::this_thread::sleep_for(std::chrono::milliseconds(11)); + } + std::printf("summary over %.0f s:\n", std::chrono::duration(Clock::now() - start).count()); + for (auto &[i, t] : tracks) + std::printf(" [%s] 1 s spread p50 %.2f mm p99 %.2f mm (%zu s) speed p99 %.3f m/s spin p99 %.2f rad/s\n", + t.name.c_str(), Pct(t.spreads, 0.5), Pct(t.spreads, 0.99), t.spreads.size(), Pct(t.speeds, 0.99), + Pct(t.spins, 0.99)); + vr::VR_Shutdown(); + return 0; +} diff --git a/pointer/probe/lasertest.cpp b/pointer/probe/lasertest.cpp new file mode 100644 index 0000000..bb4b72f --- /dev/null +++ b/pointer/probe/lasertest.cpp @@ -0,0 +1,148 @@ +// Watch who has SteamVR's laser mouse: probes P1 and P2 of the hand interaction design +// (frame-hands/notes/hand-interaction.md, section 7; first written for the gaze-first tests). +// Prints the dashboard's primary device and every controller's role whenever either changes, +// and the dashboard and role events. With --snapback, when the laser goes to any device but +// ours (the ft_pointer driver's), it presses our /input/a (switchlaserhand) to take it back, +// at most every 100 ms, and prints how long that took. With --reclaim MS it also takes the +// laser back when it has been on no device (gamepad mode) for MS milliseconds. +// Runs as a background OpenVR client (in the dev container). +// Usage: lasertest [--snapback] [--reclaim MS] [--seconds N] +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +using Clock = std::chrono::steady_clock; + +static volatile std::sig_atomic_t g_stop = 0; + +static void Send(const char *msg) { + static int fd = socket(AF_UNIX, SOCK_DGRAM | SOCK_CLOEXEC, 0); + sockaddr_un a{}; + a.sun_family = AF_UNIX; + const char name[] = "ft_pointer"; // the driver's abstract socket + std::memcpy(a.sun_path + 1, name, sizeof name - 1); + sendto(fd, msg, std::strlen(msg), 0, reinterpret_cast(&a), + socklen_t(offsetof(sockaddr_un, sun_path) + 1 + sizeof name - 1)); +} + +static std::string Describe(vr::IVRSystem *sys, vr::TrackedDeviceIndex_t i) { + if (i == vr::k_unTrackedDeviceIndexInvalid) return "none"; + static const char *roles[] = {"none", "left", "right", "optout", "treadmill", "stylus"}; + char type[64] = "", serial[64] = ""; + sys->GetStringTrackedDeviceProperty(i, vr::Prop_ControllerType_String, type, sizeof type); + sys->GetStringTrackedDeviceProperty(i, vr::Prop_SerialNumber_String, serial, sizeof serial); + const auto role = sys->GetControllerRoleForTrackedDeviceIndex(i); + const int hint = sys->GetInt32TrackedDeviceProperty(i, vr::Prop_ControllerRoleHint_Int32); + char out[200]; + std::snprintf(out, sizeof out, "%u %s %s role=%s hint=%d", i, type, serial, role < 6 ? roles[role] : "?", hint); + return out; +} + +int main(int argc, char **argv) { + bool snapback = false; + double seconds = 0, reclaimMs = 0; + for (int i = 1; i < argc; ++i) { + if (!std::strcmp(argv[i], "--snapback")) snapback = true; + else if (!std::strcmp(argv[i], "--seconds") && i + 1 < argc) seconds = std::atof(argv[++i]); + else if (!std::strcmp(argv[i], "--reclaim") && i + 1 < argc) reclaimMs = std::atof(argv[++i]); + else { + std::fprintf(stderr, "usage: %s [--snapback] [--reclaim MS] [--seconds N]\n", argv[0]); + return 2; + } + } + std::signal(SIGINT, [](int) { g_stop = 1; }); + std::signal(SIGTERM, [](int) { g_stop = 1; }); + vr::EVRInitError err = vr::VRInitError_None; + vr::IVRSystem *sys = vr::VR_Init(&err, vr::VRApplication_Background); + if (err != vr::VRInitError_None) { + std::printf("VR_Init failed: %s\n", vr::VR_GetVRInitErrorAsEnglishDescription(err)); + return 1; + } + const auto start = Clock::now(); + auto ms = [&] { return std::chrono::duration(Clock::now() - start).count(); }; + std::string lastPrimary, lastRoles; + Clock::time_point lostAt{}, pressedAt{}, noneSince{}; + bool pressed = false; + while (!g_stop && (seconds <= 0 || ms() < seconds * 1000)) { + vr::VREvent_t ev; + while (sys->PollNextEvent(&ev, sizeof ev)) { + const char *name = sys->GetEventTypeNameFromEnum(vr::EVREventType(ev.eventType)); + switch (ev.eventType) { + case vr::VREvent_DashboardActivated: + case vr::VREvent_DashboardDeactivated: + case vr::VREvent_TrackedDeviceRoleChanged: + case vr::VREvent_TrackedDeviceActivated: + case vr::VREvent_TrackedDeviceDeactivated: + case vr::VREvent_ButtonPress: + case vr::VREvent_ButtonUnpress: + std::printf("%9.1f ms event %s device %u button %u\n", ms(), name, ev.trackedDeviceIndex, + ev.data.controller.button); + break; + default: + break; + } + } + vr::TrackedDeviceIndex_t ours = vr::k_unTrackedDeviceIndexInvalid; + std::string roles; + for (vr::TrackedDeviceIndex_t i = 1; i < vr::k_unMaxTrackedDeviceCount; ++i) { + if (sys->GetTrackedDeviceClass(i) != vr::TrackedDeviceClass_Controller) continue; + char type[64] = ""; + sys->GetStringTrackedDeviceProperty(i, vr::Prop_ControllerType_String, type, sizeof type); + if (!std::strcmp(type, "ft_pointer")) ours = i; + roles += " [" + Describe(sys, i) + " connected=" + std::to_string(sys->IsTrackedDeviceConnected(i)) + "]"; + } + if (roles != lastRoles) { + std::printf("%9.1f ms controllers:%s\n", ms(), roles.c_str()); + lastRoles = roles; + } + const auto primary = vr::VROverlay()->GetPrimaryDashboardDevice(); + const std::string p = Describe(sys, primary); + if (p != lastPrimary) { + std::printf("%9.1f ms laser: %s%s\n", ms(), p.c_str(), primary == ours ? " (ours)" : ""); + if (primary == ours && lostAt != Clock::time_point{}) { + std::printf("%9.1f ms back on ours after %.0f ms\n", ms(), + std::chrono::duration(Clock::now() - lostAt).count()); + lostAt = {}; + } + if (primary != ours && ours != vr::k_unTrackedDeviceIndexInvalid && primary != vr::k_unTrackedDeviceIndexInvalid) + lostAt = Clock::now(); + noneSince = primary == vr::k_unTrackedDeviceIndexInvalid ? Clock::now() : Clock::time_point{}; + lastPrimary = p; + } + if (snapback || reclaimMs > 0) { + const auto now = Clock::now(); + if (pressed && now - pressedAt > std::chrono::milliseconds(40)) { + Send("btn a 0"); + pressed = false; + } + if (snapback && !pressed && lostAt != Clock::time_point{} && now - pressedAt > std::chrono::milliseconds(100)) { + Send("btn a 1"); + pressed = true; + pressedAt = now; + std::printf("%9.1f ms snapback: pressed our a\n", ms()); + } + if (!pressed && reclaimMs > 0 && noneSince != Clock::time_point{} && ours != vr::k_unTrackedDeviceIndexInvalid && + sys->IsTrackedDeviceConnected(ours) && + now - noneSince > std::chrono::milliseconds(int(reclaimMs)) && now - pressedAt > std::chrono::milliseconds(100)) { + Send("btn a 1"); + pressed = true; + pressedAt = now; + std::printf("%9.1f ms reclaim: pressed our a\n", ms()); + } + } + std::fflush(stdout); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + if (pressed) Send("btn a 0"); + vr::VR_Shutdown(); + return 0; +} diff --git a/pointer/probe/vrprobe.cpp b/pointer/probe/vrprobe.cpp index c27490a..ee3a262 100644 --- a/pointer/probe/vrprobe.cpp +++ b/pointer/probe/vrprobe.cpp @@ -1,5 +1,5 @@ // Print SteamVR's view of tracked devices: class, hand role, controller type, -// connection, pose validity, and the dashboard's primary pointer device. +// connection, pose validity, activity level, charging, and the dashboard's primary pointer device. // Runs as a background OpenVR client (in the dev container). #include @@ -59,9 +59,17 @@ int main(int argc, char **argv) { sys->GetStringTrackedDeviceProperty(i, vr::Prop_SerialNumber_String, serial, sizeof serial); const auto role = sys->GetControllerRoleForTrackedDeviceIndex(i); const int hint = sys->GetInt32TrackedDeviceProperty(i, vr::Prop_ControllerRoleHint_Int32); - std::printf("%u %-10s role=%-6s hint=%d type=%-16s connected=%d pose=%d %s\n", i, + // Activity level and charging: whether a controller can be told in a hand from on a desk + // or a charger (the handoff arbiter, pointer/helper/handoff.h; probe P4). + static const char *levels[] = {"unknown", "idle", "interaction", "interaction-timeout", "standby", "idle-timeout"}; + const int level = int(sys->GetTrackedDeviceActivityLevel(i)) + 1; + vr::ETrackedPropertyError pe; + const bool charging = sys->GetBoolTrackedDeviceProperty(i, vr::Prop_DeviceIsCharging_Bool, &pe); + std::printf("%u %-10s role=%-6s hint=%d type=%-16s connected=%d pose=%d activity=%s charging=%s %s\n", i, cls < 6 ? classes[cls] : "?", role < 6 ? roles[role] : "?", hint, type, - sys->IsTrackedDeviceConnected(i), poses[i].bPoseIsValid, serial); + sys->IsTrackedDeviceConnected(i), poses[i].bPoseIsValid, + level >= 0 && level < 6 ? levels[level] : "?", + pe == vr::TrackedProp_Success ? (charging ? "yes" : "no") : "n/a", serial); } std::printf("left hand = %u, right hand = %u\n", sys->GetTrackedDeviceIndexForControllerRole(vr::TrackedControllerRole_LeftHand),