mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 02:00:06 +02:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d985573c0 | ||
|
|
537fdac419 | ||
|
|
87a63932c0 | ||
|
|
bd06207db9 |
No files matched your search
@@ -0,0 +1,158 @@
|
||||
# Gaze first: plan
|
||||
|
||||
**Abandoned on 2026-09-30.** Steam reads the Frame controllers itself, outside SteamVR's bindings, and every press and release it sees takes SteamVR out of laser mode. Gaze mode stays a mouse feature; `docs/gaze-controllers.md` on `experimental` explains what was tried and why it doesn't work. This branch keeps the plan, the probes, and the code for reference; it isn't merged.
|
||||
|
||||
Gaze first makes the eyes the pointer for everything flat in VR, and the controllers its buttons. With gaze on and no game running, the gaze drives Frametop's 3D pointer (the `ft_pointer` driver) everywhere the mouse can go, SteamVR's dashboard included. Either controller's trigger clicks where you look, and the controllers stop showing lasers. The work happens on branch `gaze-first` (worktree `~/frametop/.worktrees/gaze-first`, from `experimental`) and is merged into `experimental` after it's been tested in the headset.
|
||||
|
||||
The decisions below were made with the user on 2026-09-30. Nothing is built yet: the first step is a headset session with four tests (see "Tests before building").
|
||||
|
||||
## Decisions
|
||||
|
||||
Input, with gaze on and outside games:
|
||||
- The gaze drives `ft_pointer`, so anything the mouse does today works with the gaze, on Frametop's screens, floating windows, the keyboard, and SteamVR's and Steam's panels. The mouse keeps working as it does today in gaze mode.
|
||||
- The gaze wakes the pointer by itself: the headset is worn and the tracker sees your eyes. No mouse movement is needed.
|
||||
- Trigger, on either controller:
|
||||
- A tap clicks where you look.
|
||||
- Moving the controller within `POINTER_GAZE_HOLD` (0.5 s) of the press is precision: the pointer stops where you looked, and your hand's movement steers it, relative, like tap and drag on the Apple Vision Pro (the controller's position seen from the eye, not where it points). The release clicks there, and the correction goes to the gaze service as a lesson.
|
||||
- Holding still for 0.5 s makes a real press; then the hand's movement drags 1:1.
|
||||
- Bumper, on either controller: the same, with the right button.
|
||||
- Right thumbstick: scrolls at the pointer (vertical, and horizontal when pushed sideways).
|
||||
|
||||
Controllers:
|
||||
- They keep their hand roles and their stock SteamVR bindings: the Steam button (tap for the dashboard, double tap for the room view, hold to recenter, the screenshot chord), gamepad mode (both grips), locomotion, room setup, and every per-hand feature.
|
||||
- Only the trigger and the bumper are muted from SteamVR while gaze first is on, so they never click or take the laser.
|
||||
- When something still moves the laser to a controller (a Steam button summoning the dashboard, a grip), the helper moves it straight back to our device.
|
||||
- Later: mute the grips too, and maybe have turning gaze off switch the controllers to gamepad mode.
|
||||
|
||||
Turning gaze on and off:
|
||||
- Gaze is opt-in and off by default. On or off is remembered across restarts (`POINTER_GAZE`), whatever turned it on or off.
|
||||
- Any game gets the controllers: a VR game (a scene application) or a flatscreen Steam game. Gaze first is off during one.
|
||||
- The toggle macro, both thumbstick clicks held 1 s by default, turns gaze on or off. In a game it turns gaze first on for that game only, until the game exits.
|
||||
- You can record your own toggle macro on the Gaze page of Frametop Input Settings: a chord of buttons on one or both controllers, held together for 0.5 to 2 s. The Steam button can't be part of it. It needs 2 or more buttons, or 1 button held at least 1.5 s. Firing it cancels a gaze click in progress. "Reset to default" brings back the thumbsticks. The one-button "Gaze pointer on/off" mapping and key combinations keep working.
|
||||
- Gaze can't turn on without a calibration for the tracker in use, or without a working tracker service. Turning it on then opens the calibrator, or points to "Repair eye tracker".
|
||||
|
||||
Eye tracker:
|
||||
- Our own tracker (`gaze/tracker/`) is the default. Choosing SteamVR's tracker on the Gaze page turns ours off. The same calibration rule applies to SteamVR's.
|
||||
- Its root service, `frametop-eyegrab`, is installed by Frametop's installer (a step that defaults to yes) and stays enabled. Turning our tracker off means no longer asking it for frames: it then holds none of SteamVR's buffers, and nothing needs root.
|
||||
- The tracker waits for a calibration before tracking, and idles whenever gaze input is off (tracking costs roughly 7 to 36 % of a core).
|
||||
- If the service is missing or broken, gaze can't turn on, and the Gaze page offers "Repair eye tracker". There's no silent switch to SteamVR's tracker.
|
||||
- Updates: the gaze service compares the installed `ft-eyegrab` with the build, and when they differ the Gaze page offers "Update eye tracker". Both repair and update ask for the password each time, through polkit (`pkexec`). There's no passwordless sudoers or polkit rule: the build output is writable by the user, so such a rule would let any program running as the user get root.
|
||||
|
||||
Calibration, in one head-locked panel:
|
||||
- Quick check: one centre dot. It's captured by a dwell (about 0.6 s of steady fixation; steadiness, not position, so it works however far off the tracker is), the trigger accepts early, and it closes itself after about 4 s if ignored. It opens when the headset is put on, when our tracker notices the headset slipping (at most once every 2 minutes), and from a mappable action. If the first 3 nudges after it are still more than 2 degrees off, it asks for 5 dots.
|
||||
- Full calibration: the same panel, about 60 degrees across, running the probe's calibration (21 dots in dark, medium, and bright rounds). Frametop's screens hide while it runs. It opens when turning gaze on finds no calibration. Quitting it leaves gaze off; turning gaze on again reopens it. Resetting the calibration while gaze is on turns gaze off and opens it.
|
||||
- The dots move with your head, so there's no "keep your head still", and the calibration doesn't depend on where the screens are.
|
||||
- The gaze probe stays as the lab tool.
|
||||
|
||||
## What exists
|
||||
|
||||
- Gaze mode (`POINTER_GAZE`, `pointer/helper/ft-pointer.cpp`): the gaze aims the pointer; the mouse's held-back press, precision, hold to drag, and nudge lessons are the model for the trigger.
|
||||
- Holds in the helper (`struct Hold`): pinches and grips steer by the hand's movement seen from the eye, in the room, from where the eye was when the gesture began (`PoseHistory`). The trigger's steering is the same with the controller's position instead of the hand's.
|
||||
- `gaze_precision` and `gaze_drag` (relay actions, "precision|gazedrag <source> 1|0" to the helper) steer by the controller's aim. Controller holds switch to position steering.
|
||||
- Controller buttons through the helper's global action sets (`pointer/helper/vrbuttons.h`), one action set per button, active only for mapped buttons and only outside games.
|
||||
- ft-screens' `controllers always|outside_games|dashboard` and its `hide`/`show` switch.
|
||||
- Our tracker's headset-moved detection (`gaze/tracker/eyes_model.py`: a glint slip change over 3 px held 1 s) and its reseat after the frames stop.
|
||||
- `gaze/tracker/install.sh` already installs `ft-eyegrab` as a system service with sudo.
|
||||
|
||||
## What SteamVR does (found 2026-09-30)
|
||||
|
||||
- The Frame controller's compositor bindings (`/opt/steamvr/drivers/frame_controller/resources/input/vrcompositor_bindings_frame_controller.json`) put the laser on a controller in only two ways: a button that clicks or switches the laser (trigger and bumper click; trigger, bumper, and grip move the laser to that hand with `switchlaserhand`), or summoning the dashboard with its Steam button. Everything else there doesn't touch the laser.
|
||||
- The gamepad and laser modes are the `/actions/dualanalog` action set (`ModeSwitch1` and `ModeSwitch2` on the grips), with `dashboard.modalGamepadAndLaser`. Those actions are application-scoped: the mode lives in Steam's UI, and we can't set it from outside.
|
||||
- The laser doesn't need a hand. The headset's own binding (`/opt/steamvr/drivers/frame_hmd/resources/input/vrcompositor_bindings_frame_hmd.json`) runs the laser from `/user/head/pose/raw`. Our device has only tried the right, left, and stylus roles; the stylus attempt got no role at all.
|
||||
- A Frame controller in the hand takes its hand's role back through its touch sensors, so a device that needs a hand role loses it whenever both controllers are held. That's why the laser has to live somewhere else.
|
||||
- vrserver's web socket (`/input/getstate.json` and `request_input_state_updates`, as frame-voice uses) lists each controller's `/input/trigger/click`, `/input/bumper/click`, `/input/grip/click`, `/input/thumbstick/click`, `/input/thumbstick/x` and `y`, `/input/system/click`, and each device's `side`. The headset has `/proximity`, but it flickers off for 0.3 to 0.5 s at a time while worn, so the quick check follows SteamVR's activity level (as the helper already does) rather than the raw sensor. Reading the socket takes nothing from anyone. A controller's path is `/devices/cv/<serial>` instead of `/user/hand/<side>` while our device holds that hand.
|
||||
|
||||
## Test results (2026-09-30)
|
||||
|
||||
Run with the headset on its stand and the controllers on (`pointer/probe/lasertest`, `input/vrws.py`):
|
||||
|
||||
1. **Laser on the treadmill role: works.** Our device held the dashboard laser with no hand role. SteamVR gives a device the `/user/treadmill` path only if it hints treadmill when it's added, so the driver hints a role that's no hand from `Activate`; a hint changed on connecting kept it at `/devices/ft_pointer/ft_pointer_0`. `GetControllerRoleForTrackedDeviceIndex` reports no role for it, so the helper's "no hand role" release skips treadmill.
|
||||
2. **Muting through the helper's action sets: doesn't work.** SteamVR reported those actions inactive (`vrstatus`: `"active": []`) while its laser mouse had input focus, as frame-voice found on 2026-09-26, and a trigger pull moved the laser to its controller until the release. **Muting through the compositor binding works:** `pointer/bindings/vrcompositor_frame_controller_gazefirst.json` is the stock binding without its trigger and bumper laser entries, selected with `POST /input/selectconfig.action` on vrserver's port 27062 (a JSON body `{"app_key": "openvr.component.vrcompositor", "controller_type": "frame_controller", "url": "file:///..."}`; a form-encoded one gets "Parse failed"). `GET /input/getactions.json?app_key=openvr.component.vrcompositor` shows the choice (`current_binding_url`). With it, the triggers showed no laser. Selecting the stock file puts it back.
|
||||
3. **Snap back: works** in 11 to 15 ms after a grip or a Steam button summon. A trigger held the laser until its release (0.4 to 1 s), which the muting removes. Our device also takes the laser from "none".
|
||||
4. **Web socket: works.** Every click, the thumbstick axes, and both thumbsticks clicked together arrive. The headset's `/proximity` flickers off for 0.3 to 0.5 s at a time while worn, so the quick check goes by SteamVR's activity level instead.
|
||||
|
||||
Also found: the bumpers, the thumbsticks' movement, and their clicks switch Steam's dashboard into its controller (gamepad) mode, and the laser owner goes to "none"; both grips go back to laser mode. Steam's own Frame controller binding (`steam_vrgamepad_bindings_frame_controller.json`, app `steam.client`) has only haptics, so that input reaches Steam's UI some other way (most likely Steam Input's virtual gamepad), and a SteamVR binding can't mute it.
|
||||
|
||||
Decided after the tests: gaze replaces the laser pointer's controls, never the controller mode's. Controller mode takes precedence while it's on, and leaving it gives the laser back to the gaze. Right click is one grip held with a trigger (the bumpers belong to controller mode).
|
||||
|
||||
## Tests before building
|
||||
|
||||
One headset session, about 30 minutes, with the user wearing the headset. Installing the test driver needs a SteamVR restart, which closes everything in VR, so it's done at the start of the session and only when the user says so.
|
||||
|
||||
1. **Laser on the treadmill role.** The driver takes a `role treadmill` command, and its compositor bindings repeat the right hand's under `/user/treadmill`. Pass: after its `switchlaserhand` (`/input/a`), `GetPrimaryDashboardDevice()` is our device, and the pointer clicks Frametop's screens and the dashboard while both controllers are held. Fail: the fallback (below).
|
||||
2. **Muting.** The helper activates its trigger and bumper action sets on both sides. Pass: a real trigger or bumper, pointed at a panel, neither clicks nor moves the laser, with the dashboard open and closed, and the helper sees the press. Fail: we need our own compositor binding for the Frame controller, switched when gaze first turns on and off.
|
||||
3. **Snap back.** A Steam button tap on either controller, and a grip. Pass: the helper sees the laser move to a controller and moves it back within about 100 ms, with no stray click.
|
||||
4. **Web socket.** Update rates for the thumbstick axes and clicks while held, and `/proximity` at don and doff.
|
||||
|
||||
Fallback if test 1 fails: gaze input goes straight into ft-screens for Frametop's own panels (the helper already knows which panel it hits and where), with both controllers held. SteamVR's and Steam's panels then take the gaze only while one hand is empty.
|
||||
|
||||
## Design by component
|
||||
|
||||
### Driver (`pointer/driver`)
|
||||
|
||||
- `role treadmill` joins `right`, `left`, and `stylus`. The hint stays OptOut while disconnected, as now.
|
||||
- `ft_pointer_vrcompositor.json` and `ft_pointer_steam.json` get the same bindings under `/user/treadmill`. They're additive, so nothing changes while the device is a hand.
|
||||
|
||||
### Pointer helper (`pointer/helper/ft-pointer.cpp`)
|
||||
|
||||
- Gaze first = gaze mode on, the gaze service ready, no game, or a game with the macro's override.
|
||||
- Waking: in gaze first, fresh gaze with the headset worn wakes the pointer (connect, treadmill role, `switchlaserhand`), with no mouse counts. The "no hand role" release doesn't apply to the treadmill role.
|
||||
- The laser: while awake in gaze first, if the dashboard's primary device becomes a controller, press `/input/a` on ours again, at most every 100 ms. Last used wins stays off in gaze mode, as now.
|
||||
- Muting: the trigger and bumper action sets on both sides are active whenever gaze first is on, whatever the relay's mappings say. Their presses go to the built-in state machine, not to the relay.
|
||||
- Trigger and bumper: a hold with a new source, a controller's position. It starts as a held-back press at the gaze; moving past `POINTER_TRIGGER_DEADZONE` (about 1 degree, seen from the eye; the pull jolts the controller) within `POINTER_GAZE_HOLD` is precision at `POINTER_TRIGGER_GAIN` (0.5); still until `POINTER_GAZE_HOLD` is a real press, and dragging at `POINTER_GAZE_DRAG_GAIN` (1). Release: click, lesson (as with the mouse, up to `POINTER_GAZE_NUDGE_MAX`), or release the press. The existing controller holds (`gaze_precision`, `gaze_drag`) move to position steering too.
|
||||
- Scroll: "scroll <dx> <dy>" from the relay goes to the driver, as the mouse's wheel does.
|
||||
- Calibration: while ft-gazed says its panel is up ("calpanel 1|0"), the pointer hides, and a trigger press goes to ft-gazed as "calaccept" instead of clicking.
|
||||
- The macro's "cancel": drops a held-back press without clicking.
|
||||
- Headset worn or not goes to ft-gazed ("headset 1|0"), for the quick check.
|
||||
|
||||
### Input relay (`input/input-relay.py`)
|
||||
|
||||
- A web socket reader for vrserver, in the standard library (the host's Python has no `websockets` module, and the relay needs nothing but Python). It follows the controllers by `side`, since their paths change with the roles.
|
||||
- The toggle macro: `"gaze_macro": {"buttons": ["left/thumbstick", "right/thumbstick"], "hold": 1.0}` in the rules file. Outside games it writes `POINTER_GAZE` and tells the helper; in a game it toggles the override for that game. It sends the helper "cancel" when it fires.
|
||||
- Recording: Input Settings asks for "macro record" and gets back the chord and how long it was held, after the checks in "Decisions".
|
||||
- Scroll: the right thumbstick's axes, while gaze first is on, as "scroll" lines to the helper.
|
||||
- Games: a Steam game running (a `reaper` process with `SteamLaunch AppId=`, checked every 2 s; to confirm with a flatscreen game) goes to the helper, which already knows scene applications.
|
||||
- `gaze_toggle` writes `POINTER_GAZE` instead of lasting until a restart.
|
||||
|
||||
### Gaze service (`gaze/ft-gazed`)
|
||||
|
||||
- `GAZE_TRACKER` defaults to `own`.
|
||||
- Readiness goes to the helper ("gazeready 1|0"): the tracker's service works (for ours: `frametop-eyegrab` active, and its binary the same as the build) and the tracker in use has a calibration. When gaze is on but not ready, it opens the calibrator, or asks for the repair.
|
||||
- Our tracker runs only while gaze is on and ready, while a calibration runs, or for the probe's lease.
|
||||
- The quick check opens on "headset 1" from the helper, on our tracker's jump (at most once every 2 minutes), and on "quickcal" (the mappable action). For our tracker, the dot is a click on `@ft_eyes`, like the probe's one-dot check; for SteamVR's, it's a lesson.
|
||||
- The full calibration's logic (the dots, rounds, sample rejection, and fits) moves out of the probe into `gazecal.py`, so the probe and the service share it. Quitting writes `POINTER_GAZE=0`.
|
||||
- Hiding Frametop's screens during a full calibration goes through ft-screens' `hide` and `show`, keeping the user's own switch as it was.
|
||||
|
||||
### Calibration panel (`gaze/panel/ft-gazepanel`, new)
|
||||
|
||||
A small C++ OpenVR overlay program in the dev container: a head-locked overlay (placed relative to the headset) of a fixed angular size, drawn on the CPU and uploaded with `SetOverlayRaw`, like ft-screens' keyboard, with labels from stb_truetype. ft-gazed drives it over `@ft_gazepanel` (show quick or full, dot at yaw and pitch with its state, background brightness, a line of text, hide). It takes no input: the trigger comes through the helper, and the dwell is ft-gazed's. It's a separate program because the helper is already 2,000 lines, and the panel has nothing to do with pointing.
|
||||
|
||||
### Frametop Input Settings, Gaze page
|
||||
|
||||
- The gaze switch, blocked with the reason while not ready ("Calibrate first", "Repair eye tracker").
|
||||
- Eye tracker: ours (default) or SteamVR's.
|
||||
- Status: the service, the calibration, and when it was made. Buttons for Calibrate, Quick check, and Repair or Update eye tracker (`pkexec gaze/tracker/install.sh`, which then runs without sudo inside).
|
||||
- The toggle macro: what it is, Record (a 3 s countdown, hold the chord, confirm), and Reset to default.
|
||||
|
||||
### Installer (`install.sh`)
|
||||
|
||||
- A step for our eye tracker ("It needs your password (sudo)"), defaulting to yes, and the gaze service, which needs no root. Gaze itself stays off.
|
||||
|
||||
## Order of work
|
||||
|
||||
1. The tests above, then this plan updated with the results.
|
||||
2. Driver and helper: the treadmill role, muting, snap back, waking by gaze, and the trigger and bumper holds. Gaze still turns on the old way.
|
||||
3. Relay: the web socket reader, scroll, the macro, games and the override, `POINTER_GAZE` remembered.
|
||||
4. Gaze service: readiness, idling, the default tracker, the service check, and the quick check's triggers.
|
||||
5. The calibration panel and calibrator: the quick check, then the full calibration and the move to 5 dots.
|
||||
6. Input Settings, the installer, and the docs (`README.md`, `docs/design.md`, `docs/reference.md`, `gaze/README.md`).
|
||||
|
||||
Each step is tested in the headset before it's merged into `experimental`.
|
||||
|
||||
## Risks
|
||||
|
||||
- SteamVR may refuse the laser on a treadmill device, or a SteamVR update may change the Frame's compositor bindings.
|
||||
- The muting relies on "Enable global input from overlays" (`steamvr/globalActionSetPriority`), which SteamVR calls experimental.
|
||||
- `pkexec` runs a script the user can write. That's acceptable only because every run asks for the password.
|
||||
- Our tracker's CPU cost while gaze is on, with SteamVR and a busy desktop; it idles otherwise.
|
||||
- Head-locked panels can be uncomfortable; the panel stays small and short-lived, except for the full calibration.
|
||||
@@ -0,0 +1,306 @@
|
||||
"""Gaze first, the relay's part (docs/gaze-first.md).
|
||||
|
||||
While the pointer helper says gaze first is on ("gazefirst 1" every 5 s: gaze mode, no game,
|
||||
the headset on; "gazefirst 0" or 12 s of silence ends it), the Frame controllers belong to the
|
||||
gaze:
|
||||
|
||||
- SteamVR: the Frame controller's compositor binding is
|
||||
pointer/bindings/vrcompositor_frame_controller_gazefirst.json (only the Steam button left),
|
||||
chosen through vrserver's /input/selectconfig.action, and the stock one again after.
|
||||
- Steam's UI: input/steam-gamepad-filter.js runs in Steam's SharedJSContext (input/steamui.py)
|
||||
and drops the controllers' gamepad input (the Steam button still passes). The block lasts 15 s
|
||||
unless it's renewed, which happens every 5 s, so it ends by itself if the relay dies; and
|
||||
Steam reloads its UI with SteamVR, which the renewal also covers.
|
||||
- The trigger and bumper, read from vrserver's web socket (input/vrws.py, which takes nothing
|
||||
from anyone), go to the helper as "ctrl <left|right> <trigger|bumper> 1|0"; the right
|
||||
thumbstick scrolls ("scroll <x> <y>", as the mouse's wheel).
|
||||
|
||||
Steam still sees the controllers, and SteamVR leaves laser mode after each press and release:
|
||||
the helper takes the laser back (see "Gaze first" in pointer/helper/ft-pointer.cpp).
|
||||
|
||||
The toggle macro works with gaze first on or off: both thumbstick clicks held 1 s turn gaze mode
|
||||
on or off, and POINTER_GAZE in ~/.config/frametop.conf remembers it. So does `toggle()`, which
|
||||
the relay's gaze_toggle action uses.
|
||||
|
||||
The binding, the filter, and finding new controllers run on a thread of their own: they're HTTP
|
||||
and web socket round trips that mustn't hold up the relay's mouse and keyboard.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
import steamui
|
||||
from vrws import HEADERS, ORIGIN, VrSocket, getstate
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
FILTER_JS = os.path.join(ROOT, "input", "steam-gamepad-filter.js")
|
||||
GAZE_BINDING = "file://" + os.path.join(ROOT, "pointer", "bindings", "vrcompositor_frame_controller_gazefirst.json")
|
||||
STOCK_BINDING = "file:///opt/steamvr/drivers/frame_controller/resources/input/vrcompositor_bindings_frame_controller.json"
|
||||
COMPOSITOR = "openvr.component.vrcompositor"
|
||||
CONF = os.path.expanduser("~/.config/frametop.conf")
|
||||
|
||||
BUTTONS = {"/input/trigger/click": "trigger", "/input/bumper/click": "bumper"}
|
||||
STICK_CLICK = "/input/thumbstick/click"
|
||||
MACRO_HOLD = 1.0 # seconds both thumbstick clicks are held to toggle gaze mode
|
||||
SCROLL_DEADZONE = 0.3
|
||||
ON_FOR = 12.0 # "gazefirst 1" lasts this long without another
|
||||
RENEW = 5.0 # the binding and filter are checked (and the filter's block renewed) this often
|
||||
BLOCK_FOR = 15000 # ms the filter blocks without a renewal
|
||||
|
||||
|
||||
def select_binding(url):
|
||||
body = json.dumps({"app_key": COMPOSITOR, "controller_type": "frame_controller", "url": url}).encode()
|
||||
req = urllib.request.Request(f"{ORIGIN}/input/selectconfig.action", data=body,
|
||||
headers={**HEADERS, "Origin": ORIGIN, "Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=2) as resp:
|
||||
if not json.load(resp).get("success"):
|
||||
raise OSError("vrserver refused the binding")
|
||||
|
||||
|
||||
def current_binding():
|
||||
req = urllib.request.Request(f"{ORIGIN}/input/getactions.json?app_key={COMPOSITOR}", headers=HEADERS)
|
||||
with urllib.request.urlopen(req, timeout=2) as resp:
|
||||
return (json.load(resp).get("current_binding_url") or {}).get("frame_controller", "")
|
||||
|
||||
|
||||
def read_gaze(path=CONF):
|
||||
try:
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
line = line.split("#", 1)[0].strip()
|
||||
if line.split("=", 1)[0].strip() == "POINTER_GAZE" and "=" in line:
|
||||
return line.split("=", 1)[1].strip() not in ("", "0")
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def write_gaze(on, path=CONF):
|
||||
"""POINTER_GAZE=1|0 in the config, keeping every other line as it is."""
|
||||
try:
|
||||
with open(path) as f:
|
||||
lines = f.read().splitlines()
|
||||
except OSError:
|
||||
lines = []
|
||||
value = f"POINTER_GAZE={1 if on else 0}"
|
||||
for i, line in enumerate(lines):
|
||||
if line.split("#", 1)[0].split("=", 1)[0].strip() == "POINTER_GAZE":
|
||||
lines[i] = value
|
||||
break
|
||||
else:
|
||||
lines.append(value)
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
class GazeFirst:
|
||||
def __init__(self, send, log):
|
||||
self.send = send # a command to the pointer helper
|
||||
self.log = log
|
||||
self.on = False
|
||||
self.on_until = 0.0
|
||||
self.ws = None
|
||||
self.next_connect = 0.0
|
||||
self.sides = {} # subscribed device path -> "left" or "right"
|
||||
self.values = {} # (side, component) -> last value
|
||||
self.held = set() # (side, button) the helper has as pressed
|
||||
self.macro_since = None
|
||||
self.macro_fired = False
|
||||
self.scrolling = (0.0, 0.0)
|
||||
# The worker thread's: a wake-up, the devices it found, what it last applied.
|
||||
self.lock = threading.Lock()
|
||||
self.wake = threading.Event()
|
||||
self.found = []
|
||||
self.applied = None
|
||||
self.problems = {}
|
||||
threading.Thread(target=self.work, daemon=True, name="gazefirst").start()
|
||||
|
||||
# The relay's select loop: readable while connected to vrserver.
|
||||
def fileno(self):
|
||||
return self.ws.fileno()
|
||||
|
||||
def message(self, words, now):
|
||||
""""gazefirst 1|0" from the helper."""
|
||||
on = words[1] == "1"
|
||||
self.on_until = now + ON_FOR if on else 0.0
|
||||
if on != self.on:
|
||||
self.set_on(on)
|
||||
|
||||
def set_on(self, on):
|
||||
self.on = on
|
||||
if not on:
|
||||
self.release_all(cancel=True)
|
||||
self.log(f"gaze first {'on' if on else 'off'}")
|
||||
self.wake.set()
|
||||
|
||||
def release_all(self, cancel=False):
|
||||
"""Let go of what the helper holds: as releases (a click), or cancelled (no click)."""
|
||||
if cancel and self.held:
|
||||
self.send("ctrlcancel")
|
||||
else:
|
||||
for side, button in sorted(self.held):
|
||||
self.send(f"ctrl {side} {button} 0")
|
||||
self.held.clear()
|
||||
if self.scrolling != (0.0, 0.0):
|
||||
self.send("scroll 0 0")
|
||||
self.scrolling = (0.0, 0.0)
|
||||
|
||||
def toggle(self):
|
||||
"""Gaze mode on or off, remembered (POINTER_GAZE)."""
|
||||
on = not read_gaze()
|
||||
try:
|
||||
write_gaze(on)
|
||||
except OSError as e:
|
||||
self.log(f"gaze mode: can't write {CONF}: {e}")
|
||||
self.send(f"gaze {'on' if on else 'off'}")
|
||||
self.log(f"gaze mode {'on' if on else 'off'}")
|
||||
|
||||
def timeout(self):
|
||||
return 0.05 if self.macro_since is not None and not self.macro_fired else 0.5
|
||||
|
||||
def tick(self, now):
|
||||
if self.on and now > self.on_until:
|
||||
self.set_on(False) # the helper went quiet
|
||||
if self.ws is None and now >= self.next_connect:
|
||||
self.next_connect = now + 3.0
|
||||
try:
|
||||
self.ws = VrSocket(timeout=1)
|
||||
self.ws.open(f"frametop_relay_{os.getpid()}")
|
||||
self.sides = {}
|
||||
self.wake.set() # find the controllers now
|
||||
except OSError:
|
||||
self.ws = None
|
||||
with self.lock:
|
||||
found, self.found = self.found, []
|
||||
for path, side in found:
|
||||
if self.ws and self.sides.get(path) != side:
|
||||
try:
|
||||
self.ws.subscribe(path)
|
||||
self.sides[path] = side
|
||||
except OSError:
|
||||
self.drop()
|
||||
if self.macro_since is not None and not self.macro_fired and now - self.macro_since >= MACRO_HOLD:
|
||||
self.macro_fired = True
|
||||
self.release_all(cancel=True) # a press in progress doesn't click
|
||||
self.toggle()
|
||||
|
||||
def drop(self):
|
||||
if self.ws:
|
||||
try:
|
||||
self.ws.close()
|
||||
except OSError:
|
||||
pass
|
||||
self.ws = None
|
||||
self.release_all(cancel=True)
|
||||
|
||||
def readable(self, now):
|
||||
try:
|
||||
while True:
|
||||
msg = self.ws.recv(timeout=0)
|
||||
if isinstance(msg, dict) and msg.get("type") == "update_component_states":
|
||||
side = self.sides.get(msg.get("device"))
|
||||
if side:
|
||||
self.update(side, msg.get("components") or {}, now)
|
||||
if not self.ws.pending():
|
||||
return # the rest, if any, wakes select again
|
||||
except OSError:
|
||||
self.drop()
|
||||
|
||||
def update(self, side, components, now):
|
||||
for name, value in components.items():
|
||||
key = (side, name)
|
||||
if self.values.get(key) == value:
|
||||
continue
|
||||
self.values[key] = value
|
||||
button = BUTTONS.get(name)
|
||||
if button:
|
||||
down = bool(value)
|
||||
if down and self.on and (side, button) not in self.held:
|
||||
self.held.add((side, button))
|
||||
self.send(f"ctrl {side} {button} 1")
|
||||
elif not down and (side, button) in self.held:
|
||||
self.held.discard((side, button))
|
||||
self.send(f"ctrl {side} {button} 0")
|
||||
elif name == STICK_CLICK:
|
||||
both = self.values.get(("left", STICK_CLICK)) and self.values.get(("right", STICK_CLICK))
|
||||
if both and self.macro_since is None:
|
||||
self.macro_since, self.macro_fired = now, False
|
||||
elif not both:
|
||||
self.macro_since = None
|
||||
elif side == "right" and name in ("/input/thumbstick/x", "/input/thumbstick/y"):
|
||||
self.scroll()
|
||||
|
||||
def scroll(self):
|
||||
def shape(v):
|
||||
v = float(v or 0)
|
||||
if abs(v) < SCROLL_DEADZONE:
|
||||
return 0.0
|
||||
return round((abs(v) - SCROLL_DEADZONE) / (1 - SCROLL_DEADZONE) * (1 if v > 0 else -1), 1)
|
||||
want = (shape(self.values.get(("right", "/input/thumbstick/x"))),
|
||||
shape(self.values.get(("right", "/input/thumbstick/y")))) if self.on else (0.0, 0.0)
|
||||
if want != self.scrolling:
|
||||
self.scrolling = want
|
||||
self.send(f"scroll {want[0]:.1f} {want[1]:.1f}")
|
||||
|
||||
def shutdown(self):
|
||||
"""The relay is stopping: the controllers go back to SteamVR and Steam."""
|
||||
self.on = False
|
||||
self.release_all(cancel=True)
|
||||
try:
|
||||
select_binding(STOCK_BINDING)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
steamui.evaluate('window.__frametopGaze && (window.__frametopGaze.mode = "off")', timeout=1)
|
||||
except (OSError, RuntimeError):
|
||||
pass
|
||||
|
||||
# The worker thread.
|
||||
def problem(self, what, error=None):
|
||||
"""Log a failure once, and when it's over (error None)."""
|
||||
if error is None:
|
||||
if self.problems.pop(what, None) is not None:
|
||||
self.log(f"gaze first: {what} works again")
|
||||
return
|
||||
if self.problems.get(what) != str(error):
|
||||
self.problems[what] = str(error)
|
||||
self.log(f"gaze first: {what}: {error}")
|
||||
|
||||
def work(self):
|
||||
while True:
|
||||
self.wake.wait(RENEW)
|
||||
self.wake.clear()
|
||||
on = self.on
|
||||
try:
|
||||
found = [(d["root_path"], d.get("side") or d["root_path"].rsplit("/", 1)[-1])
|
||||
for d in getstate(timeout=1) if d.get("controller_type") == "frame_controller"]
|
||||
with self.lock:
|
||||
self.found = [(p, s) for p, s in found if s in ("left", "right")]
|
||||
self.problem("vrserver")
|
||||
except (OSError, ValueError) as e:
|
||||
self.problem("vrserver", e)
|
||||
continue
|
||||
try:
|
||||
want = GAZE_BINDING if on else STOCK_BINDING
|
||||
if current_binding() != want:
|
||||
select_binding(want)
|
||||
self.log(f"gaze first: {'gaze' if on else 'stock'} controller binding")
|
||||
self.problem("binding")
|
||||
except (OSError, ValueError) as e:
|
||||
self.problem("binding", e)
|
||||
try:
|
||||
if on:
|
||||
with open(FILTER_JS) as f:
|
||||
steamui.evaluate(f.read())
|
||||
steamui.evaluate('(() => { const G = window.__frametopGaze; G.mode = "block"; '
|
||||
f'G.until = Date.now() + {BLOCK_FOR}; return G.mode; }})()')
|
||||
elif self.applied is not False:
|
||||
steamui.evaluate('window.__frametopGaze && (window.__frametopGaze.mode = "off")')
|
||||
self.applied = on
|
||||
self.problem("Steam's UI")
|
||||
except (OSError, RuntimeError, ValueError) as e:
|
||||
self.problem("Steam's UI", e)
|
||||
+33
-8
@@ -19,8 +19,9 @@ keyboard node for its extra buttons). Roles, from ~/.config/frametop-input.json
|
||||
ignore not grabbed, only observed for identification in the settings app
|
||||
Buttons and keys of pointer devices go through a per-device map to actions
|
||||
(left, right, middle, back, scroll_up, scroll_down, dashboard, recenter,
|
||||
pointer_toggle, follow_toggle = head follow on or off, gaze_toggle = gaze mode on or off
|
||||
(the pointer goes where you look; see pointer/helper/ft-pointer.cpp), gaze_precision = while
|
||||
pointer_toggle, follow_toggle = head follow on or off, gaze_toggle = gaze mode on or off,
|
||||
remembered as POINTER_GAZE (the pointer goes where you look; see pointer/helper/ft-pointer.cpp
|
||||
and input/gazefirst.py), gaze_precision = while
|
||||
held, the pointer stops where you look and the button's device (the mouse, or that
|
||||
controller's aim) steers it, and the release clicks there, gaze_drag = the same, but pressed
|
||||
at once, so it drags ("precision|gazedrag mouse|left|right|keyboard 1|0" to the helper), sens_up, sens_down,
|
||||
@@ -84,6 +85,7 @@ Control socket (abstract datagram @frametop_relay, JSON replies to the sender):
|
||||
vrcapture <s> take every controller button for s seconds (0: stop), so the settings
|
||||
app can capture one; watchers see them as events with id frame_controller
|
||||
vrbtn, vrhello, gazeawake from the pointer helper (above)
|
||||
gazefirst 1|0 from the pointer helper: gaze first on or off (input/gazefirst.py)
|
||||
textfield 1|0 from the desktop's input method (above)
|
||||
|
||||
Runs on the Frame host as a user service (frametop-input-relay.service). The
|
||||
@@ -110,6 +112,8 @@ import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import gazefirst
|
||||
|
||||
# Linux input constants (include/uapi/linux/input-event-codes.h, input.h, uinput.h).
|
||||
EV_SYN, EV_KEY, EV_REL, EV_MSC = 0x00, 0x01, 0x02, 0x04
|
||||
SYN_REPORT = 0
|
||||
@@ -465,9 +469,6 @@ class Pointer:
|
||||
elif name == "follow_toggle":
|
||||
self.send("follow toggle") # until the next restart; the setting is POINTER_FOLLOW
|
||||
log("head follow toggled")
|
||||
elif name == "gaze_toggle":
|
||||
self.send("gaze toggle") # until the next restart; the setting is POINTER_GAZE
|
||||
log("gaze mode toggled")
|
||||
elif name == "screens_toggle":
|
||||
try:
|
||||
self.sock.sendto(b"toggle", SCREENS)
|
||||
@@ -658,6 +659,8 @@ def main():
|
||||
reply(addr, {"t": "event", "id": VR_DEVICE, "path": "", "name": "Steam Frame controllers",
|
||||
"type": "vr", "code": button, "value": value})
|
||||
action = state["rules"]["controller_buttons"].get(button)
|
||||
if gaze.on and button.split("/")[-1] in ("trigger", "bumper"):
|
||||
return # gaze first has them (input/gazefirst.py)
|
||||
if state["pointer"] and action in ACTIONS and action not in ("key", "none"):
|
||||
do_action(action, value, now, button.split("/")[0])
|
||||
|
||||
@@ -684,7 +687,10 @@ def main():
|
||||
|
||||
def do_action(action, value, now, source="mouse"):
|
||||
"""A mapped mouse or controller button, or key combination (pointer mode only)."""
|
||||
if action == "keyboard_toggle":
|
||||
if action == "gaze_toggle":
|
||||
if value == 1:
|
||||
gaze.toggle() # remembered (POINTER_GAZE), like the toggle macro
|
||||
elif action == "keyboard_toggle":
|
||||
if value == 1 and vr_keyboard_mode() != "never":
|
||||
vr_keyboard("toggle")
|
||||
else:
|
||||
@@ -882,6 +888,9 @@ def main():
|
||||
if cmd == "textfield" and len(words) == 2:
|
||||
text_field(words[1] == "1")
|
||||
continue
|
||||
if cmd == "gazefirst" and len(words) == 2:
|
||||
gaze.message(words, now)
|
||||
continue
|
||||
if cmd == "gazeawake" and len(words) == 2:
|
||||
if state["pointer"]:
|
||||
state["pointer"].gaze_awake_until = now + 12.0 if words[1] == "1" else 0.0
|
||||
@@ -925,6 +934,17 @@ def main():
|
||||
else:
|
||||
reply(addr, msg)
|
||||
|
||||
def to_helper(command):
|
||||
try:
|
||||
screens_sock.sendto(command.encode(), HELPER)
|
||||
except OSError:
|
||||
pass # helper not running
|
||||
|
||||
# Gaze first (input/gazefirst.py): the controllers' trigger, bumper, thumbstick, and the
|
||||
# toggle macro, from vrserver's web socket; SteamVR's and Steam's muting.
|
||||
gaze = gazefirst.GazeFirst(to_helper, log)
|
||||
atexit.register(gaze.shutdown)
|
||||
|
||||
vr_bind(time.monotonic()) # a helper that's already running keeps its buttons in step
|
||||
waiting = False # a keyboard's grab waits for its keys to come up
|
||||
while True:
|
||||
@@ -959,11 +979,12 @@ def main():
|
||||
if added:
|
||||
apply_roles()
|
||||
|
||||
ready, _, _ = select.select(list(nodes) + [control], [], [],
|
||||
volume.timeout(now, pointer.timeout() if pointer else 0.5))
|
||||
ready, _, _ = select.select(list(nodes) + [control] + ([gaze] if gaze.ws else []), [], [],
|
||||
min(gaze.timeout(), volume.timeout(now, pointer.timeout() if pointer else 0.5)))
|
||||
now = time.monotonic()
|
||||
if pointer:
|
||||
pointer.tick(now)
|
||||
gaze.tick(now)
|
||||
volume.tick(now)
|
||||
if state["vr_capture_until"] and now >= state["vr_capture_until"]:
|
||||
vr_bind(now) # capture over: back to the mapped buttons
|
||||
@@ -973,6 +994,10 @@ def main():
|
||||
if fd is control:
|
||||
handle_control(now)
|
||||
continue
|
||||
if fd is gaze:
|
||||
if gaze.ws:
|
||||
gaze.readable(now)
|
||||
continue
|
||||
node = nodes[fd]
|
||||
try:
|
||||
data = os.read(fd, EVENT.size * 64)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// Frametop gaze first: a filter in Steam's UI (SharedJSContext, through Steam's CEF debugger on
|
||||
// 127.0.0.1:8080) that keeps the Frame controllers' gamepad input away from Steam's UI while
|
||||
// gaze mode has the dashboard (docs/gaze-first.md). Steam reads the controllers as its own
|
||||
// virtual gamepad (Steam Input), so no SteamVR binding can do this.
|
||||
//
|
||||
// Steam's gamepad input source (webpack module 17900, class E) turns each controller message
|
||||
// into this.OnButtonDown / OnButtonUp / OnAnalogPad calls, which live on its base class. The
|
||||
// filter defines wrappers on E's prototype, so both live instances go through them. They look
|
||||
// up window.__frametopGaze at every call, so evaluating this file again updates the logic in
|
||||
// place; a wrapper from an older version is replaced. It returns the state as JSON.
|
||||
//
|
||||
// The dashboard's mode (laser or gamepad) is SteamVR's, not the UI's: the UI only mirrors it
|
||||
// (VRFocus, module 84114: J.Instance.ShowGamepadFocusMode, from SteamVR's
|
||||
// system_panel_interaction_mode).
|
||||
//
|
||||
// window.__frametopGaze:
|
||||
// mode "off" (pass everything), "log" (pass, and log), "block" (drop all but `allow`),
|
||||
// "auto" (block while the dashboard is in laser mode, pass in gamepad mode)
|
||||
// until with "block" or "auto": blocking stops at this time (Date.now(), ms) unless it's
|
||||
// moved on. The input relay (input/gazefirst.py) sets it 15 s ahead every 10 s or so,
|
||||
// so if the relay dies, the controllers come back to Steam's UI by themselves.
|
||||
// allow buttons that always pass: the Steam button's guide and quick menu, and Steam's
|
||||
// own dummy input
|
||||
// log the last 256 events: [ms, "down"|"up"|"analog", button, controller, passed]
|
||||
// modes the dashboard's mode changes: [ms, "gamepad"|"laser"] (sampled every 50 ms)
|
||||
// A release passes if its press did, so nothing stays held when blocking starts.
|
||||
// Buttons (Steam's enum): 1 OK 2 CANCEL 3 SECONDARY 4 OPTIONS 5/6 bumpers 7/8 triggers 9-12 dpad
|
||||
// 13 SELECT 14 START 15/16 stick clicks 17/18 stick touch 23-26 rear buttons (24 left grip,
|
||||
// 26 right grip) 27 STEAM_GUIDE 28 STEAM_QUICK_MENU 29 DUMMY_INPUT.
|
||||
(() => {
|
||||
const VERSION = 2;
|
||||
if (!window.__ftreq) {
|
||||
const name = Object.keys(window).find(k => k.startsWith("webpackChunk"));
|
||||
window[name].push([[Symbol("frametop")], {}, r => { window.__ftreq = r; }]);
|
||||
}
|
||||
const E = window.__ftreq(17900).E;
|
||||
const proto = E.prototype;
|
||||
if (!("HandleControllerInputMessages" in proto)) throw new Error("Steam's gamepad source moved (module 17900)");
|
||||
const base = Object.getPrototypeOf(proto);
|
||||
const G = window.__frametopGaze || (window.__frametopGaze = {mode: "log", allow: [27, 28, 29], log: [], held: {}, dropped: 0});
|
||||
G.modes = G.modes || [];
|
||||
G.gamepadMode = () => {
|
||||
try { return !!window.__ftreq(84114).J.Instance?.ShowGamepadFocusMode; } catch (e) { return false; }
|
||||
};
|
||||
G.note = (ev, button, controller, passed) => {
|
||||
G.log.push([Math.round(performance.now()), ev, button, controller, passed]);
|
||||
if (G.log.length > 256) G.log.shift();
|
||||
};
|
||||
G.blocks = button => {
|
||||
if (G.allow.includes(button) || (G.until && Date.now() > G.until)) return false;
|
||||
return G.mode === "block" || (G.mode === "auto" && !G.gamepadMode());
|
||||
};
|
||||
if (proto.OnButtonDown && proto.OnButtonDown.__frametop !== VERSION) {
|
||||
delete proto.OnButtonDown;
|
||||
delete proto.OnButtonUp;
|
||||
delete proto.OnAnalogPad;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(proto, "OnButtonDown")) {
|
||||
proto.OnButtonDown = function (button, controller, ...rest) {
|
||||
let pass = true;
|
||||
try {
|
||||
const g = window.__frametopGaze;
|
||||
g.inst = this;
|
||||
pass = !g.blocks(button);
|
||||
if (g.mode !== "off") g.note("down", button, controller, pass);
|
||||
if (pass) g.held[controller + ":" + button] = true;
|
||||
else g.dropped++;
|
||||
} catch (e) { pass = true; }
|
||||
if (pass) return base.OnButtonDown.call(this, button, controller, ...rest);
|
||||
};
|
||||
proto.OnButtonUp = function (button, controller, ...rest) {
|
||||
let pass = true;
|
||||
try {
|
||||
const g = window.__frametopGaze, key = controller + ":" + button;
|
||||
pass = !!g.held[key] || !g.blocks(button);
|
||||
delete g.held[key];
|
||||
if (g.mode !== "off") g.note("up", button, controller, pass);
|
||||
} catch (e) { pass = true; }
|
||||
if (pass) return base.OnButtonUp.call(this, button, controller, ...rest);
|
||||
};
|
||||
proto.OnAnalogPad = function (button, x, y, controller, ...rest) {
|
||||
let pass = true;
|
||||
try {
|
||||
const g = window.__frametopGaze;
|
||||
pass = !g.blocks(button);
|
||||
if (g.mode !== "off" && (x || y)) g.note("analog", button, controller, pass);
|
||||
} catch (e) { pass = true; }
|
||||
if (pass) return base.OnAnalogPad.call(this, button, x, y, controller, ...rest);
|
||||
};
|
||||
for (const f of [proto.OnButtonDown, proto.OnButtonUp, proto.OnAnalogPad]) f.__frametop = VERSION;
|
||||
G.installed = Date.now();
|
||||
}
|
||||
if (!G.sampler) {
|
||||
let last = null;
|
||||
G.sampler = setInterval(() => {
|
||||
const m = G.gamepadMode() ? "gamepad" : "laser";
|
||||
if (m !== last) {
|
||||
last = m;
|
||||
G.modes.push([Math.round(performance.now()), m]);
|
||||
if (G.modes.length > 128) G.modes.shift();
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
return JSON.stringify({version: VERSION, mode: G.mode, gamepad: G.gamepadMode(), installed: G.installed,
|
||||
dropped: G.dropped, modes: G.modes.slice(-10), log: G.log.slice(-10)});
|
||||
})()
|
||||
@@ -0,0 +1,40 @@
|
||||
"""steamui: run JavaScript in Steam's UI (its SharedJSContext) through Steam's CEF debugger on
|
||||
127.0.0.1:8080, with nothing but the standard library (input/vrws.py's web socket client).
|
||||
|
||||
evaluate(expression) the expression's value (JSON-able), or an OSError when Steam or its
|
||||
UI isn't there, or a RuntimeError when the script threw
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
from vrws import VrSocket
|
||||
|
||||
DEBUGGER = "http://127.0.0.1:8080"
|
||||
|
||||
|
||||
def evaluate(expression, target="SharedJSContext", timeout=2.0):
|
||||
with urllib.request.urlopen(f"{DEBUGGER}/json", timeout=timeout) as resp:
|
||||
pages = json.load(resp)
|
||||
url = next((p.get("webSocketDebuggerUrl") for p in pages if p.get("title") == target), None)
|
||||
if not url:
|
||||
raise OSError(f"Steam's {target} isn't open")
|
||||
ws = VrSocket(timeout=timeout, url=url)
|
||||
try:
|
||||
ws.send(json.dumps({"id": 1, "method": "Runtime.evaluate",
|
||||
"params": {"expression": expression, "returnByValue": True}}))
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
left = deadline - time.monotonic()
|
||||
msg = ws.recv(timeout=max(left, 0)) if left > 0 else None
|
||||
if msg is None:
|
||||
raise OSError("Steam's UI didn't answer")
|
||||
if isinstance(msg, dict) and msg.get("id") == 1:
|
||||
break
|
||||
finally:
|
||||
ws.close()
|
||||
result = msg.get("result") or {}
|
||||
if "exceptionDetails" in result:
|
||||
details = result["exceptionDetails"]
|
||||
raise RuntimeError((details.get("exception") or {}).get("description") or details.get("text", "error"))
|
||||
return (result.get("result") or {}).get("value")
|
||||
Executable
+220
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/python3
|
||||
"""vrws: vrserver's local web socket, with nothing but the standard library.
|
||||
|
||||
vrserver (SteamVR) serves its controller binding page on 127.0.0.1:27062, and that page
|
||||
follows the controllers' raw input through a web socket there. Reading it takes nothing
|
||||
from anyone: whatever the buttons are bound to still happens, and it works whatever app
|
||||
has focus, in games too. frame-voice reads its push-to-talk button the same way. The host's
|
||||
Python has no `websockets` module, so this is a small RFC 6455 client of its own.
|
||||
|
||||
getstate() the devices SteamVR has now, from /input/getstate.json: each with its
|
||||
root path (/user/hand/left, /devices/cv/<serial> while our pointer holds
|
||||
that hand, /user/head), controller type, side, and input components
|
||||
VrSocket() a connection: open(mailbox), subscribe(device), recv() -> dict or None
|
||||
|
||||
Run as a program, it prints component changes as they come, for the gaze-first tests
|
||||
(docs/gaze-first.md, test 4):
|
||||
|
||||
input/vrws.py [--all] [--seconds N] [component ...]
|
||||
|
||||
By default it follows the buttons (…/click), the thumbsticks' axes, the trigger's value,
|
||||
and the headset's proximity; --all shows every component. At the end it prints how often
|
||||
each one updated.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
HOST, PORT = "127.0.0.1", 27062
|
||||
ORIGIN = f"http://{HOST}:{PORT}"
|
||||
HEADERS = {"Referer": f"{ORIGIN}/dashboard/controllerbinding.html"}
|
||||
|
||||
|
||||
def getstate(timeout=3):
|
||||
"""The devices SteamVR has now (only the ones with a root path)."""
|
||||
req = urllib.request.Request(f"{ORIGIN}/input/getstate.json", headers=HEADERS)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return [d for d in json.load(resp).get("devices", []) if d.get("root_path")]
|
||||
|
||||
|
||||
class VrSocket:
|
||||
"""vrserver's web socket, or with `url` (ws://host:port/path) any other local one, such as
|
||||
Steam's CEF debugger (input/steamui.py)."""
|
||||
|
||||
def __init__(self, timeout=3, url=None):
|
||||
if url:
|
||||
hostport, path = url.removeprefix("ws://").split("/", 1)
|
||||
host, port = hostport.rsplit(":", 1)
|
||||
extra = ""
|
||||
else:
|
||||
host, port, path, hostport = HOST, PORT, "", f"{HOST}:{PORT}"
|
||||
extra = f"Origin: {ORIGIN}\r\nReferer: {HEADERS['Referer']}\r\n"
|
||||
self.sock = socket.create_connection((host, int(port)), timeout=timeout)
|
||||
key = base64.b64encode(os.urandom(16)).decode()
|
||||
self.sock.sendall((f"GET /{path} HTTP/1.1\r\nHost: {hostport}\r\nUpgrade: websocket\r\n"
|
||||
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n"
|
||||
f"{extra}\r\n").encode())
|
||||
head = b""
|
||||
while b"\r\n\r\n" not in head:
|
||||
chunk = self.sock.recv(4096)
|
||||
if not chunk:
|
||||
raise OSError("vrserver closed the connection during the handshake")
|
||||
head += chunk
|
||||
head, self.buf = head.split(b"\r\n\r\n", 1)
|
||||
status = head.split(b"\r\n", 1)[0]
|
||||
if b" 101 " not in status + b" ":
|
||||
raise OSError(f"vrserver refused the web socket: {status.decode(errors='replace')}")
|
||||
self.sock.settimeout(None)
|
||||
self.parts = b""
|
||||
|
||||
def fileno(self):
|
||||
return self.sock.fileno()
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self._frame(0x8, b"")
|
||||
except OSError:
|
||||
pass
|
||||
self.sock.close()
|
||||
|
||||
def _frame(self, opcode, payload):
|
||||
n = len(payload)
|
||||
head = bytes([0x80 | opcode])
|
||||
if n < 126:
|
||||
head += bytes([0x80 | n])
|
||||
elif n < 65536:
|
||||
head += bytes([0x80 | 126]) + struct.pack(">H", n)
|
||||
else:
|
||||
head += bytes([0x80 | 127]) + struct.pack(">Q", n)
|
||||
mask = os.urandom(4)
|
||||
self.sock.sendall(head + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(payload)))
|
||||
|
||||
def send(self, text):
|
||||
self._frame(0x1, text.encode())
|
||||
|
||||
def open(self, mailbox):
|
||||
self.send(f"mailbox_open {mailbox}")
|
||||
self.mailbox = mailbox
|
||||
|
||||
def subscribe(self, device, on=True):
|
||||
kind = "request_input_state_updates" if on else "cancel_input_state_updates"
|
||||
self.send("mailbox_send input_server " +
|
||||
json.dumps({"type": kind, "device_path": device, "returnAddress": self.mailbox}))
|
||||
|
||||
def _read(self, n):
|
||||
while len(self.buf) < n:
|
||||
chunk = self.sock.recv(65536)
|
||||
if not chunk:
|
||||
raise OSError("vrserver closed the web socket")
|
||||
self.buf += chunk
|
||||
out, self.buf = self.buf[:n], self.buf[n:]
|
||||
return out
|
||||
|
||||
def pending(self):
|
||||
"""A message (or part of one) is already read and waiting."""
|
||||
return bool(self.buf)
|
||||
|
||||
def recv(self, timeout=None):
|
||||
"""The next text message as parsed JSON (None for one that isn't JSON), or None at
|
||||
the timeout. Raises OSError when the connection ends."""
|
||||
if not self.buf and timeout is not None:
|
||||
if not select.select([self.sock], [], [], timeout)[0]:
|
||||
return None
|
||||
while True:
|
||||
b0, b1 = self._read(2)
|
||||
opcode, n = b0 & 0x0F, b1 & 0x7F
|
||||
if n == 126:
|
||||
n = struct.unpack(">H", self._read(2))[0]
|
||||
elif n == 127:
|
||||
n = struct.unpack(">Q", self._read(8))[0]
|
||||
mask = self._read(4) if b1 & 0x80 else None
|
||||
data = self._read(n)
|
||||
if mask:
|
||||
data = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
|
||||
if opcode == 0x8:
|
||||
raise OSError("vrserver closed the web socket")
|
||||
if opcode == 0x9:
|
||||
self._frame(0xA, data)
|
||||
continue
|
||||
if opcode in (0x1, 0x2, 0x0):
|
||||
self.parts += data
|
||||
if not b0 & 0x80:
|
||||
continue
|
||||
text, self.parts = self.parts, b""
|
||||
try:
|
||||
return json.loads(text)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
args = [a for a in sys.argv[1:]]
|
||||
show_all = "--all" in args
|
||||
seconds = 0.0
|
||||
if "--seconds" in args:
|
||||
i = args.index("--seconds")
|
||||
seconds = float(args[i + 1])
|
||||
del args[i:i + 2]
|
||||
wanted = [a for a in args if not a.startswith("--")]
|
||||
|
||||
def followed(name):
|
||||
if show_all:
|
||||
return True
|
||||
if wanted:
|
||||
return name in wanted
|
||||
return (name.endswith("/click") or name in ("/input/thumbstick/x", "/input/thumbstick/y",
|
||||
"/input/trigger/value", "/proximity"))
|
||||
|
||||
names = {}
|
||||
ws = VrSocket()
|
||||
ws.open(f"frametop_vrws_{os.getpid()}")
|
||||
|
||||
def follow_new():
|
||||
"""Subscribe to devices that appeared (vrserver only streams changes, and a device
|
||||
that connects later, or changes its path with a role, is a new subscription)."""
|
||||
for d in getstate():
|
||||
path = d["root_path"]
|
||||
name = f"{path} ({d.get('controller_type', '?')}{', ' + d['side'] if d.get('side') else ''})"
|
||||
if names.get(path) != name:
|
||||
names[path] = name
|
||||
print(f"{time.monotonic() - start:9.3f} device {name}", flush=True)
|
||||
ws.subscribe(path)
|
||||
|
||||
start = time.monotonic()
|
||||
follow_new()
|
||||
next_poll = start + 2
|
||||
last, counts = {}, {}
|
||||
try:
|
||||
while not seconds or time.monotonic() - start < seconds:
|
||||
if time.monotonic() >= next_poll:
|
||||
next_poll = time.monotonic() + 2
|
||||
follow_new()
|
||||
msg = ws.recv(timeout=0.5)
|
||||
if not isinstance(msg, dict) or msg.get("type") != "update_component_states":
|
||||
continue
|
||||
dev = msg.get("device")
|
||||
for name, value in (msg.get("components") or {}).items():
|
||||
if not followed(name):
|
||||
continue
|
||||
key = (dev, name)
|
||||
counts[key] = counts.get(key, 0) + 1
|
||||
if last.get(key) != value:
|
||||
last[key] = value
|
||||
print(f"{time.monotonic() - start:9.3f} {names.get(dev, dev)} {name} = {value}", flush=True)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
ws.close()
|
||||
took = max(time.monotonic() - start, 1e-6)
|
||||
for (dev, name), n in sorted(counts.items()):
|
||||
print(f"updates: {names.get(dev, dev)} {name}: {n} ({n / took:.1f}/s)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,187 @@
|
||||
{
|
||||
"action_manifest_version": 0,
|
||||
"alias_info": {},
|
||||
"app_key": "openvr.component.vrcompositor",
|
||||
"bindings": {
|
||||
"/actions/system": {
|
||||
"chords": [
|
||||
{
|
||||
"inputs": [
|
||||
[
|
||||
"/user/hand/left/input/system",
|
||||
"held"
|
||||
],
|
||||
[
|
||||
"/user/hand/left/input/trigger",
|
||||
"click"
|
||||
]
|
||||
],
|
||||
"output": "/actions/system/in/TakeScreenshot"
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
[
|
||||
"/user/hand/right/input/system",
|
||||
"held"
|
||||
],
|
||||
[
|
||||
"/user/hand/right/input/trigger",
|
||||
"click"
|
||||
]
|
||||
],
|
||||
"output": "/actions/system/in/TakeScreenshot"
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
[
|
||||
"/user/hand/left/input/system",
|
||||
"held"
|
||||
],
|
||||
[
|
||||
"/user/hand/right/input/trigger",
|
||||
"click"
|
||||
]
|
||||
],
|
||||
"output": "/actions/system/in/TakeScreenshot"
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
[
|
||||
"/user/hand/right/input/system",
|
||||
"held"
|
||||
],
|
||||
[
|
||||
"/user/hand/left/input/trigger",
|
||||
"click"
|
||||
]
|
||||
],
|
||||
"output": "/actions/system/in/TakeScreenshot"
|
||||
}
|
||||
],
|
||||
"sources": [
|
||||
{
|
||||
"inputs": {},
|
||||
"mode": "button",
|
||||
"parameters": {
|
||||
"haptic_amplitude": "0"
|
||||
},
|
||||
"path": "/user/hand/left/input/trigger"
|
||||
},
|
||||
{
|
||||
"inputs": {},
|
||||
"mode": "button",
|
||||
"parameters": {
|
||||
"haptic_amplitude": "0"
|
||||
},
|
||||
"path": "/user/hand/right/input/trigger"
|
||||
},
|
||||
{
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/system/in/ToggleDashboard"
|
||||
},
|
||||
"double": {
|
||||
"output": "/actions/system/in/ToggleRoomView"
|
||||
},
|
||||
"held": {
|
||||
"output": "/actions/system/in/SystemButtonChord"
|
||||
}
|
||||
},
|
||||
"mode": "button",
|
||||
"path": "/user/hand/left/input/system"
|
||||
},
|
||||
{
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/system/in/ToggleDashboard"
|
||||
},
|
||||
"double": {
|
||||
"output": "/actions/system/in/ToggleRoomView"
|
||||
},
|
||||
"held": {
|
||||
"output": "/actions/system/in/SystemButtonChord"
|
||||
}
|
||||
},
|
||||
"mode": "button",
|
||||
"path": "/user/hand/right/input/system"
|
||||
}
|
||||
]
|
||||
},
|
||||
"/actions/quickrecenter": {
|
||||
"sources": [
|
||||
{
|
||||
"inputs": {
|
||||
"long": {
|
||||
"output": "/actions/quickrecenter/in/Recenter"
|
||||
}
|
||||
},
|
||||
"parameters": {
|
||||
"long_press_delay": 1.0,
|
||||
"long_press_expiry": 4.0
|
||||
},
|
||||
"mode": "button",
|
||||
"path": "/user/hand/left/input/system"
|
||||
},
|
||||
{
|
||||
"inputs": {
|
||||
"long": {
|
||||
"output": "/actions/quickrecenter/in/Recenter"
|
||||
}
|
||||
},
|
||||
"parameters": {
|
||||
"long_press_delay": 1.0,
|
||||
"long_press_expiry": 4.0
|
||||
},
|
||||
"mode": "button",
|
||||
"path": "/user/hand/right/input/system"
|
||||
}
|
||||
]
|
||||
},
|
||||
"/actions/roomsetup": {
|
||||
"sources": [
|
||||
{
|
||||
"inputs": {
|
||||
"position": {
|
||||
"output": "/actions/roomsetup/in/FloorFineAdjust"
|
||||
}
|
||||
},
|
||||
"mode": "joystick",
|
||||
"path": "/user/hand/left/input/thumbstick"
|
||||
},
|
||||
{
|
||||
"inputs": {
|
||||
"position": {
|
||||
"output": "/actions/roomsetup/in/FloorFineAdjust"
|
||||
}
|
||||
},
|
||||
"mode": "joystick",
|
||||
"path": "/user/hand/right/input/thumbstick"
|
||||
},
|
||||
{
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/roomsetup/in/Accept"
|
||||
}
|
||||
},
|
||||
"mode": "button",
|
||||
"path": "/user/hand/right/input/a"
|
||||
},
|
||||
{
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/roomsetup/in/Back"
|
||||
}
|
||||
},
|
||||
"mode": "button",
|
||||
"path": "/user/hand/right/input/b"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"category": "steamvr_input",
|
||||
"controller_type": "frame_controller",
|
||||
"description": "Gaze first (Frametop, docs/gaze-first.md): the stock compositor binding with only the Steam button (dashboard, room view, recenter, screenshot chord) and room setup left. The relay reads the rest from vrserver's web socket.",
|
||||
"name": "Frametop gaze first: Frame controller, Steam button only",
|
||||
"options": {},
|
||||
"simulated_actions": []
|
||||
}
|
||||
@@ -17,11 +17,12 @@
|
||||
// btn <name> <0|1> name: trigger, b, x, system, joystick, a (a = claim the laser, no click)
|
||||
// scroll <x> <y> 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/gaze-first.md, test 1)
|
||||
//
|
||||
// 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 <openvr_driver.h>
|
||||
|
||||
#include <atomic>
|
||||
@@ -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/gaze-first.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;
|
||||
@@ -237,7 +240,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_{};
|
||||
VRInputComponentHandle_t buttons_[kButtons] = {};
|
||||
VRInputComponentHandle_t scrollX_ = 0, scrollY_ = 0;
|
||||
@@ -343,7 +350,8 @@ private:
|
||||
state_.role = !std::strcmp(name, "left") ? TrackedControllerRole_LeftHand
|
||||
: !std::strcmp(name, "right") ? TrackedControllerRole_RightHand
|
||||
: !std::strcmp(name, "stylus") ? TrackedControllerRole_Stylus
|
||||
: state_.role;
|
||||
: !std::strcmp(name, "treadmill") ? TrackedControllerRole_Treadmill
|
||||
: state_.role;
|
||||
} else if (std::sscanf(cmd, "btn %31s %d", name, &v) == 2) {
|
||||
for (int i = 0; i < kButtons; ++i)
|
||||
if (std::strcmp(name, kButtonNames[i]) == 0) state_.buttons[i] = v != 0;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+182
-17
@@ -150,8 +150,8 @@
|
||||
// release it when the mouse is idle ("gazeawake 1|0" tells it). A controller that moves
|
||||
// still releases it (the mouse is gaze mode's only pointer device), and in games the mouse
|
||||
// wakes it and idling releases it, as without gaze.
|
||||
// The dot only shows while the mouse moves it (within POINTER_GAZE_SHOW, 1 s), while a
|
||||
// press is held, and briefly for each click (a pulse);
|
||||
// With POINTER_GAZE_DOT=moving, the dot only shows while the mouse moves it (within
|
||||
// POINTER_GAZE_SHOW, 1 s), while a press is held, and briefly for each click (a pulse);
|
||||
// otherwise it's transparent (still there for the laser to land on). The gaze moving it
|
||||
// doesn't show it: you know where you're looking.
|
||||
// When the mouse took the pointer and you then click, the nudge was probably onto what
|
||||
@@ -177,10 +177,30 @@
|
||||
// precision, the default), or clicks at once where the pointer is (direct). And in gaze mode a
|
||||
// moving controller doesn't take the pointer away (last used wins is off): the gaze points,
|
||||
// and the controllers are its tools.
|
||||
// POINTER_ROLE (right, left, or stylus): the hand role our device takes while connected. A
|
||||
// POINTER_ROLE (right, left, stylus, or treadmill): the role our device takes while connected. A
|
||||
// Frame controller in your hand counts as used through its touch sensors and takes its hand's
|
||||
// role back, and then no click lands (see "no hand role" in the main loop): with a controller in
|
||||
// the right hand as the precision tool, the pointer needs the left hand, or the stylus role.
|
||||
// the right hand as the precision tool, the pointer needs the left hand, or a role that's no
|
||||
// hand. Treadmill is being tested for that (docs/gaze-first.md, test 1).
|
||||
//
|
||||
// Gaze first (docs/gaze-first.md): with gaze mode on outside games, the relay mutes the Frame
|
||||
// controllers in SteamVR and in Steam's UI, and sends their trigger and bumper here ("ctrl
|
||||
// <left|right> <trigger|bumper> 1|0", from vrserver's web socket). A press stops the pointer
|
||||
// where you look, like the mouse's held-back press. Moving the controller past
|
||||
// POINTER_TRIGGER_DEADZONE (1 deg: its position seen from the eye, as the press began) within
|
||||
// POINTER_GAZE_HOLD steers the pointer at POINTER_TRIGGER_GAIN (0.5), and the release clicks
|
||||
// there (a lesson, as with the mouse). Held still that long instead, it's a real press, and the
|
||||
// controller drags at POINTER_GAZE_DRAG_GAIN (1). The trigger is the left button, the bumper the
|
||||
// right. "gazefirst 1|0" tells the relay when gaze first is on (gaze mode, no game, headset on).
|
||||
// Steam reads the controllers as a gamepad, and SteamVR leaves laser mode about 40 ms after
|
||||
// each press and each release Steam sees, whatever the bindings say. So while gaze first is on
|
||||
// and the pointer is awake, the helper takes the laser back whenever it isn't ours (on no device,
|
||||
// or on a controller) with the driver's "a" (switchlaserhand), at most every 100 ms
|
||||
// (POINTER_GAZE_KEEP_LASER, on): a blink of 20 to 40 ms. A controller's click or press waits for
|
||||
// the laser: until it has left and come back after the last press or release (100 ms at least),
|
||||
// or 250 ms.
|
||||
// POINTER_GAZE_DOT: always (the default) shows the dot all the time in gaze mode, which also
|
||||
// covers those blinks; moving shows it as described under gaze mode.
|
||||
//
|
||||
// Hands (POINTER_HANDS, off by default; needs hand tracking, hands/): ft-hands publishes
|
||||
// pinches and grips (hands/include/fh_gestures.h), read here every frame.
|
||||
@@ -668,6 +688,10 @@ int main() {
|
||||
// Gaze mode (see the top); gazeConf is POINTER_GAZE as last read, like followConf.
|
||||
bool gazeOn = false, gazeConf = false;
|
||||
double gazeRetake = 5, gazeNudgeMax = 8, gazeHold = 0.5, gazeShow = 1;
|
||||
// Gaze first (see the top): POINTER_GAZE_DOT, POINTER_GAZE_KEEP_LASER, POINTER_TRIGGER_GAIN,
|
||||
// POINTER_TRIGGER_DEADZONE.
|
||||
bool gazeDotAlways = true, keepLaser = true;
|
||||
double triggerGain = 0.5, triggerDeadzone = 1.0;
|
||||
// Hands (see the top): POINTER_HANDS, POINTER_PINCH_GAIN, POINTER_PINCH_DEADZONE, POINTER_GRIP_GAIN.
|
||||
bool handsOn = false;
|
||||
double pinchGain = 0.5, pinchDeadzone = 1.5, gripGain = 1.0, handBelow = 0.35, typingHold = 1.0;
|
||||
@@ -701,6 +725,11 @@ int main() {
|
||||
gazeShow = std::clamp(ConfDouble(conf, "POINTER_GAZE_SHOW", 1), 0.0, 30.0);
|
||||
const bool wantGaze = ConfDouble(conf, "POINTER_GAZE", 0) != 0;
|
||||
if (wantGaze != gazeConf) gazeOn = gazeConf = wantGaze;
|
||||
const auto gd = conf.find("POINTER_GAZE_DOT");
|
||||
gazeDotAlways = gd == conf.end() || gd->second != "moving";
|
||||
keepLaser = ConfDouble(conf, "POINTER_GAZE_KEEP_LASER", 1) != 0;
|
||||
triggerGain = std::clamp(ConfDouble(conf, "POINTER_TRIGGER_GAIN", 0.5), 0.05, 3.0);
|
||||
triggerDeadzone = std::clamp(ConfDouble(conf, "POINTER_TRIGGER_DEADZONE", 1.0), 0.0, 10.0);
|
||||
handsOn = ConfDouble(conf, "POINTER_HANDS", 0) != 0;
|
||||
pinchGain = std::clamp(ConfDouble(conf, "POINTER_PINCH_GAIN", 0.5), 0.05, 3.0);
|
||||
pinchDeadzone = std::clamp(ConfDouble(conf, "POINTER_PINCH_DEADZONE", 1.5), 0.0, 10.0);
|
||||
@@ -713,7 +742,7 @@ int main() {
|
||||
const auto gm = conf.find("POINTER_GAZE_MOUSE");
|
||||
gazeMousePrecision = gm == conf.end() || gm->second != "direct";
|
||||
const auto ro = conf.find("POINTER_ROLE");
|
||||
role = ro != conf.end() && (ro->second == "left" || ro->second == "stylus") ? ro->second : "right";
|
||||
role = ro != conf.end() && (ro->second == "left" || ro->second == "stylus" || ro->second == "treadmill") ? ro->second : "right";
|
||||
pickupScale = std::clamp(ConfDouble(conf, "POINTER_CONTROLLER_PICKUP", 1), 0.5, 5.0);
|
||||
const auto ig = conf.find("POINTER_IGNORE");
|
||||
ignore = ParseIgnore(ig == conf.end() ? "" : ig->second);
|
||||
@@ -856,6 +885,9 @@ int main() {
|
||||
bool grip = false; // pressed at once and dragging (a grip, gaze drag), not a click on release
|
||||
bool engaged = false; // past the dead zone
|
||||
bool pressed = false; // a real press went out (a grip or gaze drag, or a pinch without gaze mode)
|
||||
bool ctrlPos = false; // gaze first's trigger or bumper: steered by the controller's position
|
||||
bool promote = false; // held still for POINTER_GAZE_HOLD, it becomes a real press
|
||||
bool right = false; // the right button (the bumper)
|
||||
Vec3 origin;
|
||||
vr::TrackedDeviceIndex_t ctrl = vr::k_unTrackedDeviceIndexInvalid;
|
||||
double refYaw = 0, refPitch = 0, startYaw = 0, startPitch = 0, lastYaw = 0, lastPitch = 0;
|
||||
@@ -866,6 +898,18 @@ int main() {
|
||||
bool drag, down;
|
||||
};
|
||||
std::vector<DevicePress> devicePresses;
|
||||
// "ctrl <left|right> <trigger|bumper> 1|0" from the relay, done in the frame (see Gaze first).
|
||||
struct CtrlPress {
|
||||
std::string side;
|
||||
bool right, down;
|
||||
};
|
||||
std::vector<CtrlPress> ctrlPresses;
|
||||
bool ctrlCancel = false; // "ctrlcancel": the held trigger or bumper ends without a click
|
||||
// Gaze first's laser keeper, and its controller clicks' wait (see the top): who has the
|
||||
// laser, since when it's ours, whether it left after the last controller press or release.
|
||||
vr::TrackedDeviceIndex_t primary = vr::k_unTrackedDeviceIndexInvalid;
|
||||
Clock::time_point ctrlEventAt{}, laserOursSince{}, keepPressAt{};
|
||||
bool laserLeft = false, keepHeld = false;
|
||||
uint32_t seenBegins[2][2] = {}, seenEnds[2][2] = {}; // [pinch, grip][side], as last read
|
||||
bool handBaseline = false;
|
||||
int handOpens = 0;
|
||||
@@ -873,10 +917,13 @@ int main() {
|
||||
Clock::time_point handUsed{}; // a gesture began then (keeps the pointer, like gaze mode)
|
||||
Clock::time_point lastTyping{}; // the relay's last "typing": a key on a keyboard
|
||||
// Gaze mode outside games, and its dot (see the top): lastMove/lastHeld/pulseAt.
|
||||
bool inGame = false, gazeAwake = false;
|
||||
Clock::time_point inGameAt{}, gazeAwakeAt{};
|
||||
bool inGame = false, gazeAwake = false, gazeFirst = false;
|
||||
Clock::time_point inGameAt{}, gazeAwakeAt{}, gazeFirstAt{};
|
||||
Clock::time_point lastMove{}, lastHeld{}, pulseAt{};
|
||||
// The left button, as sent to the driver.
|
||||
// The left button, as sent to the driver; pressRight: the next press is the right button
|
||||
// instead (gaze first's bumper), heldButton: the one pressed.
|
||||
bool pressRight = false;
|
||||
std::string heldButton = "trigger";
|
||||
auto pressLeft = [&] {
|
||||
// ft-screens sends the keyboard to the panel clicked last; it sees clicks on
|
||||
// its own screens, but only we know when one lands on another panel.
|
||||
@@ -906,14 +953,16 @@ int main() {
|
||||
tiltYaw = tiltPitch = 0; // a new drag starts untilted
|
||||
dropHoldUntil = {};
|
||||
pulseAt = Clock::now();
|
||||
SendTo(out, "ft_pointer", "btn trigger 1");
|
||||
heldButton = pressRight ? "b" : "trigger";
|
||||
pressRight = false;
|
||||
SendTo(out, "ft_pointer", "btn " + heldButton + " 1");
|
||||
};
|
||||
auto releaseLeft = [&] {
|
||||
leftHeld = false;
|
||||
tilting = false;
|
||||
// Hold the drag pose (tilt, frozen distance) while SteamVR finishes the drop.
|
||||
dropHoldUntil = Clock::now() + std::chrono::milliseconds(500);
|
||||
SendTo(out, "ft_pointer", "btn trigger 0");
|
||||
SendTo(out, "ft_pointer", "btn " + heldButton + " 0");
|
||||
// ft-screens releases a button held on its screens in KWin even when SteamVR hands
|
||||
// the release to some other overlay (its catcher usually gets it; this is the backstop).
|
||||
SendTo(out, "ft_screens", "up");
|
||||
@@ -1140,6 +1189,15 @@ int main() {
|
||||
gazeAwakeAt = t;
|
||||
SendTo(out, "frametop_relay", awake ? "gazeawake 1" : "gazeawake 0");
|
||||
}
|
||||
// Gaze first (see the top): the relay mutes the controllers while it's on.
|
||||
const bool first = gazeOn && !inGame && !headsetOff;
|
||||
if (first != gazeFirst || t - gazeFirstAt > std::chrono::seconds(5)) {
|
||||
if (first != gazeFirst) std::printf("gaze first %s\n", first ? "on" : "off");
|
||||
if (first != gazeFirst) std::fflush(stdout);
|
||||
gazeFirst = first;
|
||||
gazeFirstAt = t;
|
||||
SendTo(out, "frametop_relay", first ? "gazefirst 1" : "gazefirst 0");
|
||||
}
|
||||
}
|
||||
|
||||
// Commands from the relay.
|
||||
@@ -1163,6 +1221,23 @@ int main() {
|
||||
gz = {g[0], g[1], g[2], g[3], Clock::now()};
|
||||
continue;
|
||||
}
|
||||
if (std::strcmp(buf, "ctrlcancel") == 0) { // the relay's macro fired, or it lost the controllers
|
||||
ctrlCancel = true;
|
||||
ctrlPresses.clear();
|
||||
continue;
|
||||
}
|
||||
{
|
||||
char side[8], button[16];
|
||||
int v;
|
||||
if (std::sscanf(buf, "ctrl %7s %15s %d", side, button, &v) == 3) {
|
||||
lastMouse = Clock::now();
|
||||
if (!active) wake(Clock::now());
|
||||
ctrlPresses.push_back({side, !std::strcmp(button, "bumper"), v != 0});
|
||||
ctrlEventAt = Clock::now();
|
||||
laserLeft = false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
{
|
||||
char kind[16], source[16];
|
||||
int v;
|
||||
@@ -1198,6 +1273,13 @@ int main() {
|
||||
overlays.Request(sender, senderLen); // answered from the list's thread
|
||||
continue;
|
||||
}
|
||||
if (std::strcmp(buf, "gazefirst ?") == 0) { // why gaze first is on or off
|
||||
char msg[160];
|
||||
std::snprintf(msg, sizeof msg, "ok first %d gaze %d game %d headset %s awake %d laser %d ours %d", gazeFirst,
|
||||
gazeOn, inGame, headsetOff ? "off" : "on", active, int(primary), int(ours));
|
||||
reply(msg);
|
||||
continue;
|
||||
}
|
||||
if (std::strncmp(buf, "gaze", 4) == 0) {
|
||||
const char *arg = buf + 4;
|
||||
while (*arg == ' ') ++arg;
|
||||
@@ -1352,8 +1434,9 @@ int main() {
|
||||
if (debug) std::fflush(stdout);
|
||||
}
|
||||
|
||||
// Didn't get the hand role (a held controller keeps it): release, back off.
|
||||
if (active && tnow - wokeAt > std::chrono::seconds(1) && ours != vr::k_unTrackedDeviceIndexInvalid &&
|
||||
// Didn't get the hand role (a held controller keeps it): release, back off. Treadmill is
|
||||
// no hand, and SteamVR reports no role for it (it's /user/treadmill): nothing to lose.
|
||||
if (active && role != "treadmill" && tnow - wokeAt > std::chrono::seconds(1) && ours != vr::k_unTrackedDeviceIndexInvalid &&
|
||||
sys->GetControllerRoleForTrackedDeviceIndex(ours) == vr::TrackedControllerRole_Invalid) {
|
||||
active = false;
|
||||
claimPending = claimHeld = false;
|
||||
@@ -1461,11 +1544,12 @@ int main() {
|
||||
if (gazeOn) gazeOwns = true, nudging = false; // no click: the pointer goes back to the gaze
|
||||
} else {
|
||||
clickPress = true; // the click is on the release, where the pointer is now
|
||||
pressRight = hold.right;
|
||||
gazeBack = nudgeMoved < 0.2;
|
||||
}
|
||||
}
|
||||
if (debug)
|
||||
std::printf("hold %s %s%s\n", hold.src == Src::Hand ? (hold.grip ? "grip" : "pinch") : hold.grip ? "gaze drag" : "gaze precision",
|
||||
std::printf("hold %s %s%s\n", hold.src == Src::Hand ? (hold.grip ? "grip" : "pinch") : hold.ctrlPos ? (hold.right ? "bumper" : "trigger") : hold.grip ? "gaze drag" : "gaze precision",
|
||||
lost ? "lost" : "released", hold.pressed ? "" : lost ? " (no click)" : " (click)");
|
||||
if (debug) std::fflush(stdout);
|
||||
hold = {};
|
||||
@@ -1551,6 +1635,15 @@ int main() {
|
||||
cp = std::asin(std::clamp(f.y, -1.0, 1.0)) * 180 / M_PI;
|
||||
return true;
|
||||
};
|
||||
// A controller's position seen from `from`: yaw and pitch, degrees (gaze first's holds).
|
||||
auto controllerPosAngles = [&](vr::TrackedDeviceIndex_t i, const Vec3 &from, double &cy, double &cp) {
|
||||
if (i >= vr::k_unMaxTrackedDeviceCount || !all[i].bPoseIsValid) return false;
|
||||
const auto &m = all[i].mDeviceToAbsoluteTracking.m;
|
||||
const Vec3 d = Normalize(Vec3{m[0][3], m[1][3], m[2][3]} - from);
|
||||
cy = std::atan2(-d.x, -d.z) * 180 / M_PI;
|
||||
cp = std::asin(std::clamp(d.y, -1.0, 1.0)) * 180 / M_PI;
|
||||
return true;
|
||||
};
|
||||
// The Frame controller in a hand (not our own device, which may hold that hand's role).
|
||||
auto controllerFor = [&](const std::string &hand) {
|
||||
const int32_t want = hand == "left" ? vr::TrackedControllerRole_LeftHand : vr::TrackedControllerRole_RightHand;
|
||||
@@ -1587,10 +1680,37 @@ int main() {
|
||||
if (debug) std::fflush(stdout);
|
||||
}
|
||||
devicePresses.clear();
|
||||
// Gaze first's trigger and bumper (see the top): a held-back press at the gaze, steered
|
||||
// by the controller's position seen from the eye as it began.
|
||||
if (ctrlCancel) {
|
||||
ctrlCancel = false;
|
||||
if (hold.src != Src::None && hold.ctrlPos) endHold(true);
|
||||
}
|
||||
for (const CtrlPress &p : ctrlPresses) {
|
||||
const int side = p.side == "right";
|
||||
if (!p.down) {
|
||||
if (hold.src != Src::None && hold.ctrlPos && hold.side == side && hold.right == p.right) endHold(false);
|
||||
continue;
|
||||
}
|
||||
if (hold.src != Src::None || leftHeld || aimHeld || clickPress || clickRelease || !hmd.bPoseIsValid) continue;
|
||||
Hold h;
|
||||
h.src = Src::Controller, h.ctrlPos = h.promote = true, h.right = p.right, h.side = side, h.origin = eye;
|
||||
h.ctrl = controllerFor(p.side);
|
||||
if (!controllerPosAngles(h.ctrl, h.origin, h.refYaw, h.refPitch)) h.src = Src::Mouse; // no pose: the mouse steers
|
||||
hold = h;
|
||||
startHold(false);
|
||||
if (debug) std::printf("hold %s %s began\n", p.side.c_str(), p.right ? "bumper" : "trigger");
|
||||
if (debug) std::fflush(stdout);
|
||||
}
|
||||
ctrlPresses.clear();
|
||||
if (hold.src == Src::Controller) {
|
||||
double cy, cp;
|
||||
if (controllerAngles(hold.ctrl, cy, cp))
|
||||
if (hold.ctrlPos) {
|
||||
if (controllerPosAngles(hold.ctrl, hold.origin, cy, cp))
|
||||
steer(cy, cp, hold.pressed ? 0.3 : triggerDeadzone, hold.pressed ? dragGain : triggerGain);
|
||||
} else if (controllerAngles(hold.ctrl, cy, cp)) {
|
||||
steer(cy, cp, precisionDeadzone, hold.grip ? dragGain : precisionGain);
|
||||
}
|
||||
}
|
||||
fh_gestures_t hg;
|
||||
const bool handOk = handsOn && handFile.Read(hg);
|
||||
@@ -1894,7 +2014,7 @@ int main() {
|
||||
// the page, with SteamVR's hit dot hidden on it.
|
||||
const Vec3 near = eye + sight * SETTINGS_DOT, far = eye + sight * SETTINGS_CATCHER;
|
||||
double alpha = 1;
|
||||
if (gazeOn) {
|
||||
if (gazeOn && !gazeDotAlways) {
|
||||
auto secs = [&](Clock::time_point t) { return std::chrono::duration<double>(tnow - t).count(); };
|
||||
alpha = std::clamp(1 - std::min(secs(lastMove) - gazeShow, secs(lastHeld)) / 0.25, 0.0, 1.0);
|
||||
}
|
||||
@@ -1919,7 +2039,7 @@ int main() {
|
||||
double scale = 1, alpha = 1;
|
||||
if (gazeOn) {
|
||||
auto secs = [&](Clock::time_point t) { return std::chrono::duration<double>(tnow - t).count(); };
|
||||
alpha = std::clamp(1 - std::min(secs(lastMove) - gazeShow, secs(lastHeld)) / 0.25, 0.0, 1.0);
|
||||
alpha = gazeDotAlways ? 1.0 : std::clamp(1 - std::min(secs(lastMove) - gazeShow, secs(lastHeld)) / 0.25, 0.0, 1.0);
|
||||
const double pulse = secs(pulseAt);
|
||||
if (pulse < 0.6) {
|
||||
scale = 1 + 1.5 * std::max(0.0, 1 - pulse / 0.3);
|
||||
@@ -1991,7 +2111,52 @@ int main() {
|
||||
gazeBack = true;
|
||||
pressLeft();
|
||||
}
|
||||
if (clickPress) {
|
||||
|
||||
// Gaze first (see the top): take the laser back whenever it isn't ours, and let a
|
||||
// controller's press or click out only once it's back.
|
||||
primary = overlay->GetPrimaryDashboardDevice();
|
||||
if (primary == ours && ours != vr::k_unTrackedDeviceIndexInvalid) {
|
||||
if (laserOursSince == Clock::time_point{}) laserOursSince = tnow;
|
||||
} else {
|
||||
laserOursSince = {};
|
||||
laserLeft = true;
|
||||
}
|
||||
const bool keeping = keepLaser && gazeOn && !inGame && active && !headsetOff &&
|
||||
ours != vr::k_unTrackedDeviceIndexInvalid && sys->IsTrackedDeviceConnected(ours) &&
|
||||
tnow - wokeAt > std::chrono::milliseconds(600);
|
||||
if (keepHeld && tnow - keepPressAt >= std::chrono::milliseconds(40)) {
|
||||
SendTo(out, "ft_pointer", "btn a 0");
|
||||
keepHeld = false;
|
||||
}
|
||||
if (keeping && primary != ours && !keepHeld && !claimPending && !claimHeld &&
|
||||
tnow - keepPressAt >= std::chrono::milliseconds(100)) {
|
||||
SendTo(out, "ft_pointer", "btn a 1");
|
||||
keepHeld = true;
|
||||
keepPressAt = tnow;
|
||||
if (debug) std::printf("laser keeper: laser on %d, taking it back\n", int(primary));
|
||||
if (debug) std::fflush(stdout);
|
||||
}
|
||||
auto laserReady = [&] {
|
||||
if (ctrlEventAt == Clock::time_point{} || !keeping) return true;
|
||||
const auto since = tnow - ctrlEventAt;
|
||||
if (since > std::chrono::seconds(1)) return true; // never came back: don't hold the click forever
|
||||
if (laserOursSince == Clock::time_point{} || tnow - laserOursSince < std::chrono::milliseconds(25)) return false;
|
||||
return since >= std::chrono::milliseconds(250) || (laserLeft && since >= std::chrono::milliseconds(100));
|
||||
};
|
||||
// A trigger or bumper held still for POINTER_GAZE_HOLD: a real press, then the
|
||||
// controller drags (from where it is now).
|
||||
if (aimHeld && aimHand && hold.promote && !hold.engaged &&
|
||||
tnow - aimSince >= std::chrono::duration<double>(gazeHold) && laserReady()) {
|
||||
aimHeld = aimHand = false;
|
||||
hold.pressed = hold.grip = true;
|
||||
if (hold.src == Src::Controller) controllerPosAngles(hold.ctrl, hold.origin, hold.refYaw, hold.refPitch);
|
||||
gazeBack = true;
|
||||
pressRight = hold.right;
|
||||
pressLeft();
|
||||
if (debug) std::printf("hold %s: pressed (held still)\n", hold.right ? "bumper" : "trigger");
|
||||
if (debug) std::fflush(stdout);
|
||||
}
|
||||
if (clickPress && laserReady()) {
|
||||
clickPress = false;
|
||||
pressLeft();
|
||||
clickRelease = true;
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build vrprobe in the dev container on the Frame (pointer/probe/build/vrprobe).
|
||||
# Build vrprobe, lasertest, vrsetting and focustest in the dev container on the Frame (pointer/probe/build/vrprobe).
|
||||
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"'
|
||||
g++ -std=c++17 -O2 -Wall -I/opt/steamvr/tools/hellovr_vulkan_linux/src/openvr/headers \
|
||||
-o build/lasertest lasertest.cpp -L/opt/steamvr/bin/linuxarm64 -lopenvr_api -Wl,-rpath,/opt/steamvr/bin/linuxarm64
|
||||
g++ -std=c++17 -O2 -Wall -I/opt/steamvr/tools/hellovr_vulkan_linux/src/openvr/headers \
|
||||
-o build/vrsetting vrsetting.cpp -L/opt/steamvr/bin/linuxarm64 -lopenvr_api -Wl,-rpath,/opt/steamvr/bin/linuxarm64
|
||||
g++ -std=c++17 -O2 -Wall -I/opt/steamvr/tools/hellovr_vulkan_linux/src/openvr/headers \
|
||||
-o build/focustest focustest.cpp -L/opt/steamvr/bin/linuxarm64 -lopenvr_api -Wl,-rpath,/opt/steamvr/bin/linuxarm64
|
||||
echo "built build/vrprobe build/lasertest build/vrsetting build/focustest"'
|
||||
@@ -0,0 +1,135 @@
|
||||
// Does a dashboard overlay with overlay flag 1 << 4 take SteamVR's input focus for its own process
|
||||
// (docs/gaze-first.md)? SteamVR's dashboard gives an external overlay's page a "VR client" input
|
||||
// focus (its owner's PID) when that flag is set, and a Steam Input one with 1 << 30. While a VR
|
||||
// client has the input focus, as a game does, Steam shouldn't get the controllers.
|
||||
// Shows a dashboard overlay "Frametop focus test" (a plain colour) with that flag, and prints
|
||||
// input-focus events, IsInputAvailable(), and the dashboard's visibility as they change.
|
||||
// Runs as an OpenVR overlay client (in the dev container).
|
||||
// With --manifest it's a SteamVR input client too (focustest_actions/: Y and the triggers), and
|
||||
// prints when those actions change, which shows whether it gets them while Steam doesn't.
|
||||
// Usage: focustest [--seconds N] [--no-flag] [--steam-flag] [--manifest]
|
||||
#include <openvr.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <csignal>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <limits.h>
|
||||
#include <stdlib.h>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <unistd.h>
|
||||
#include <vector>
|
||||
|
||||
using Clock = std::chrono::steady_clock;
|
||||
static volatile std::sig_atomic_t g_stop = 0;
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
double seconds = 0;
|
||||
bool flag = true, steamFlag = false, manifest = 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], "--no-flag")) flag = false;
|
||||
else if (!std::strcmp(argv[i], "--steam-flag")) steamFlag = true;
|
||||
else if (!std::strcmp(argv[i], "--manifest")) manifest = true;
|
||||
else {
|
||||
std::fprintf(stderr, "usage: %s [--seconds N] [--no-flag] [--steam-flag] [--manifest]\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_Overlay);
|
||||
if (err != vr::VRInitError_None) {
|
||||
std::printf("VR_Init failed: %s\n", vr::VR_GetVRInitErrorAsEnglishDescription(err));
|
||||
return 1;
|
||||
}
|
||||
vr::IVROverlay *ov = vr::VROverlay();
|
||||
vr::VRActionSetHandle_t set = vr::k_ulInvalidActionSetHandle;
|
||||
vr::VRActionHandle_t actY = vr::k_ulInvalidActionHandle, actTrigger = vr::k_ulInvalidActionHandle;
|
||||
if (manifest) {
|
||||
char exe[PATH_MAX] = "";
|
||||
const ssize_t n = readlink("/proc/self/exe", exe, sizeof exe - 1);
|
||||
std::string dir = n > 0 ? std::string(exe, n) : std::string(".");
|
||||
dir = dir.substr(0, dir.rfind('/')); // build/
|
||||
dir = dir.substr(0, dir.rfind('/')); // pointer/probe
|
||||
const std::string path = dir + "/focustest_actions/actions.json";
|
||||
const auto ie = vr::VRInput()->SetActionManifestPath(path.c_str());
|
||||
vr::VRInput()->GetActionSetHandle("/actions/focus", &set);
|
||||
vr::VRInput()->GetActionHandle("/actions/focus/in/y", &actY);
|
||||
vr::VRInput()->GetActionHandle("/actions/focus/in/trigger", &actTrigger);
|
||||
std::printf("action manifest %s: error %d\n", path.c_str(), int(ie));
|
||||
}
|
||||
vr::VROverlayHandle_t main = vr::k_ulOverlayHandleInvalid, thumb = vr::k_ulOverlayHandleInvalid;
|
||||
auto e = ov->CreateDashboardOverlay("frametop.focustest", "Frametop focus test", &main, &thumb);
|
||||
if (e != vr::VROverlayError_None) {
|
||||
std::printf("CreateDashboardOverlay: %s\n", ov->GetOverlayErrorNameFromEnum(e));
|
||||
return 1;
|
||||
}
|
||||
// A plain teal page, and a teal thumbnail.
|
||||
const uint32_t w = 256, h = 160;
|
||||
std::vector<uint8_t> px(w * h * 4);
|
||||
for (uint32_t i = 0; i < w * h; ++i) px[i * 4] = 20, px[i * 4 + 1] = 140, px[i * 4 + 2] = 140, px[i * 4 + 3] = 255;
|
||||
ov->SetOverlayRaw(main, px.data(), w, h, 4);
|
||||
ov->SetOverlayRaw(thumb, px.data(), w, h, 4);
|
||||
ov->SetOverlayWidthInMeters(main, 1.5f);
|
||||
ov->SetOverlayInputMethod(main, vr::VROverlayInputMethod_Mouse);
|
||||
if (flag) {
|
||||
e = ov->SetOverlayFlag(main, vr::VROverlayFlags(1 << 4), true);
|
||||
std::printf("SetOverlayFlag(1 << 4): %s\n", ov->GetOverlayErrorNameFromEnum(e));
|
||||
}
|
||||
if (steamFlag) {
|
||||
e = ov->SetOverlayFlag(main, vr::VROverlayFlags(1 << 30), true);
|
||||
std::printf("SetOverlayFlag(1 << 30): %s\n", ov->GetOverlayErrorNameFromEnum(e));
|
||||
}
|
||||
uint32_t flags = 0;
|
||||
ov->GetOverlayFlags(main, &flags);
|
||||
std::printf("overlay flags now 0x%x; pid %d\n", flags, int(getpid()));
|
||||
std::fflush(stdout);
|
||||
|
||||
const auto start = Clock::now();
|
||||
auto ms = [&] { return std::chrono::duration<double, std::milli>(Clock::now() - start).count(); };
|
||||
int lastAvail = -1, lastDash = -1, lastActive = -1, lastY = -1, lastTrigger = -1;
|
||||
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));
|
||||
if (ev.eventType == vr::VREvent_InputFocusChanged || ev.eventType == vr::VREvent_InputFocusCaptured ||
|
||||
ev.eventType == vr::VREvent_InputFocusReleased)
|
||||
std::printf("%9.1f ms %s pid %u (old %u)\n", ms(), name, ev.data.process.pid, ev.data.process.oldPid);
|
||||
else if (ev.eventType == vr::VREvent_ButtonPress || ev.eventType == vr::VREvent_ButtonUnpress ||
|
||||
ev.eventType == vr::VREvent_DashboardActivated || ev.eventType == vr::VREvent_DashboardDeactivated)
|
||||
std::printf("%9.1f ms %s device %u button %u\n", ms(), name, ev.trackedDeviceIndex, ev.data.controller.button);
|
||||
}
|
||||
while (ov->PollNextOverlayEvent(main, &ev, sizeof ev)) {
|
||||
const char *name = sys->GetEventTypeNameFromEnum(vr::EVREventType(ev.eventType));
|
||||
if (ev.eventType != vr::VREvent_MouseMove && std::strncmp(name, "Unknown", 7) != 0 &&
|
||||
std::strcmp(name, "VREvent_OverlayMouseFocusChanged") != 0 && ev.eventType != vr::VREvent_PropertyChanged)
|
||||
std::printf("%9.1f ms overlay: %s\n", ms(), name);
|
||||
}
|
||||
if (manifest) {
|
||||
vr::VRActiveActionSet_t active{};
|
||||
active.ulActionSet = set;
|
||||
vr::VRInput()->UpdateActionState(&active, sizeof active, 1);
|
||||
vr::InputDigitalActionData_t y{}, t{};
|
||||
vr::VRInput()->GetDigitalActionData(actY, &y, sizeof y, vr::k_ulInvalidInputValueHandle);
|
||||
vr::VRInput()->GetDigitalActionData(actTrigger, &t, sizeof t, vr::k_ulInvalidInputValueHandle);
|
||||
const int yv = y.bActive ? y.bState : -2, tv = t.bActive ? t.bState : -2;
|
||||
if (yv != lastY || tv != lastTrigger) {
|
||||
std::printf("%9.1f ms our actions: y %d, trigger %d (-2: inactive)\n", ms(), yv, tv);
|
||||
lastY = yv, lastTrigger = tv;
|
||||
}
|
||||
}
|
||||
const int avail = sys->IsInputAvailable(), dash = ov->IsDashboardVisible(), active = ov->IsActiveDashboardOverlay(main);
|
||||
if (avail != lastAvail || dash != lastDash || active != lastActive) {
|
||||
std::printf("%9.1f ms input available %d, dashboard %d, our page active %d\n", ms(), avail, dash, active);
|
||||
lastAvail = avail, lastDash = dash, lastActive = active;
|
||||
}
|
||||
std::fflush(stdout);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
}
|
||||
vr::VR_Shutdown();
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"default_bindings": [
|
||||
{"controller_type": "frame_controller", "binding_url": "bindings_frame_controller.json"}
|
||||
],
|
||||
"actions": [
|
||||
{"name": "/actions/focus/in/y", "type": "boolean"},
|
||||
{"name": "/actions/focus/in/trigger", "type": "boolean"}
|
||||
],
|
||||
"action_sets": [
|
||||
{"name": "/actions/focus", "usage": "leftright"}
|
||||
],
|
||||
"localization": [
|
||||
{"language_tag": "en_US", "/actions/focus": "Focus test", "/actions/focus/in/y": "Y", "/actions/focus/in/trigger": "Trigger"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"action_manifest_version": 0,
|
||||
"controller_type": "frame_controller",
|
||||
"description": "focustest (pointer/probe/focustest.cpp)",
|
||||
"name": "focustest",
|
||||
"bindings": {
|
||||
"/actions/focus": {
|
||||
"sources": [
|
||||
{"path": "/user/hand/right/input/y", "mode": "button", "inputs": {"click": {"output": "/actions/focus/in/y"}}},
|
||||
{"path": "/user/hand/right/input/trigger", "mode": "button", "inputs": {"click": {"output": "/actions/focus/in/trigger"}}},
|
||||
{"path": "/user/hand/left/input/trigger", "mode": "button", "inputs": {"click": {"output": "/actions/focus/in/trigger"}}}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// Watch who has SteamVR's laser mouse, for the gaze-first tests (docs/gaze-first.md, tests 1-3).
|
||||
// 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 <openvr.h>
|
||||
|
||||
#include <sys/socket.h>
|
||||
#include <sys/un.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <csignal>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
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<sockaddr *>(&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<double, std::milli>(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<double, std::milli>(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;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Read or change one SteamVR setting through vrserver (IVRSettings). Editing steamvr.vrsettings
|
||||
// while SteamVR runs doesn't stick: vrserver writes its own copy back on the next change.
|
||||
// Runs as a background OpenVR client (in the dev container).
|
||||
// Usage: vrsetting get SECTION KEY
|
||||
// vrsetting set-bool SECTION KEY true|false
|
||||
// vrsetting remove SECTION KEY
|
||||
#include <openvr.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc < 4 || (std::strcmp(argv[1], "set-bool") == 0 && argc < 5)) {
|
||||
std::fprintf(stderr, "usage: %s get|set-bool|remove SECTION KEY [true|false]\n", argv[0]);
|
||||
return 2;
|
||||
}
|
||||
const char *cmd = argv[1], *section = argv[2], *key = argv[3];
|
||||
vr::EVRInitError err = vr::VRInitError_None;
|
||||
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;
|
||||
}
|
||||
vr::EVRSettingsError serr = vr::VRSettingsError_None;
|
||||
int rc = 0;
|
||||
if (!std::strcmp(cmd, "get")) {
|
||||
char s[512] = "";
|
||||
vr::VRSettings()->GetString(section, key, s, sizeof s, &serr);
|
||||
if (serr == vr::VRSettingsError_None) std::printf("%s\n", s);
|
||||
else {
|
||||
const bool b = vr::VRSettings()->GetBool(section, key, &serr);
|
||||
std::printf("%s\n", serr == vr::VRSettingsError_None ? (b ? "true" : "false") : "(unset)");
|
||||
}
|
||||
} else if (!std::strcmp(cmd, "set-bool")) {
|
||||
vr::VRSettings()->SetBool(section, key, !std::strcmp(argv[4], "true"), &serr);
|
||||
} else if (!std::strcmp(cmd, "remove")) {
|
||||
vr::VRSettings()->RemoveKeyInSection(section, key, &serr);
|
||||
} else {
|
||||
std::fprintf(stderr, "unknown command %s\n", cmd);
|
||||
rc = 2;
|
||||
}
|
||||
if (serr != vr::VRSettingsError_None) {
|
||||
std::printf("error: %s\n", vr::VRSettings()->GetSettingsErrorNameFromEnum(serr));
|
||||
rc = 1;
|
||||
}
|
||||
vr::VR_Shutdown();
|
||||
return rc;
|
||||
}
|
||||
Reference in new issue
Block a user