mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 01:00:06 +02:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e7c12e0d4 | ||
|
|
7066b591ff | ||
|
|
a000725021 | ||
|
|
3460e610d1 | ||
|
|
92a72b8797 | ||
|
|
ff36356992 | ||
|
|
4d50739393 | ||
|
|
f60da63702 | ||
|
|
82d2107e1d | ||
|
|
12f2e844d5 | ||
|
|
ee2ce1a8d2 | ||
|
|
540d8c425c | ||
|
|
ba6ccdfbd1 | ||
|
|
77f28f0922 | ||
|
|
85532a54f3 | ||
|
|
3864741f53 | ||
|
|
12ad93d24c | ||
|
|
a7f9a8dd80 | ||
|
|
ac142f6e4a | ||
|
|
b417534425 | ||
|
|
c377133859 | ||
|
|
675ef3f0d5 | ||
|
|
fadc0f467c | ||
|
|
2487351556 | ||
|
|
4479fbfcc1 |
No files matched your search
@@ -13,6 +13,8 @@ Two settings apps come with it: Frametop Display Settings for the screens, profi
|
||||
|
||||
Frametop is an independent project, not made by or affiliated with Valve.
|
||||
|
||||
Join the [Frametop Discord](https://discord.gg/W3X9f7z3Bc) for questions, ideas, and help with your setup.
|
||||
|
||||
## Install on the headset
|
||||
|
||||
You need a Steam Frame with an internet connection, a keyboard (Bluetooth, or the on-screen one), and about 3 GB of free space.
|
||||
@@ -27,7 +29,7 @@ You need a Steam Frame with an internet connection, a keyboard (Bluetooth, or th
|
||||
|
||||
It asks which version you want: stable (the `main` branch, tested releases) or experimental (the `experimental` branch, the newest features, less tested). Then it clones the repo into `~/frametop` and runs `install.sh`. To choose without the question, add `-s -- --stable` or `-s -- --experimental` after `bash`. By hand, the same is `git clone https://github.com/DeeJanuz/frametop.git ~/frametop`, then `cd ~/frametop` and `./install.sh` (add `--branch experimental` to the clone for experimental).
|
||||
|
||||
The installer sets up distrobox in your home folder (the system files aren't touched), a Fedora build container, and everything else. The first run downloads 1–2 GB. It asks you three things along the way: whether to install gaze mode (experimental, yes by default) and the Bluetooth fixes, then whether to restart SteamVR. The Bluetooth fixes need your `sudo` password; if you've never set one, run `passwd` first, or skip them for now. SteamVR has to restart once at the end, which closes everything open in VR, including the terminal. Rebooting the headset works too.
|
||||
The installer sets up distrobox in your home folder (the system files aren't touched), a Fedora build container, and everything else. The first run downloads 1–2 GB. It asks you four things along the way: whether to install gaze mode (experimental, yes by default), our own eye tracker for it (yes by default), and the Bluetooth fixes, then whether to restart SteamVR. The eye tracker and the Bluetooth fixes need your `sudo` password; if you've never set one, run `passwd` first, or skip them for now. SteamVR has to restart once at the end, which closes everything open in VR, including the terminal. Rebooting the headset works too.
|
||||
|
||||
After the restart, Launch a program → Desktop opens the multi-screen desktop, with its screens arranged around where you're facing. Frametop Display Settings and Frametop Input Settings are in the desktop's application menu, under Settings.
|
||||
|
||||
@@ -54,6 +56,7 @@ If you work in the desktop for long stretches, or leave the headset on a stand,
|
||||
| Set a screen to On your head (Frametop Display Settings, Visibility & pins) | Pins it to your head where it is, like a HUD. Grab its bar to move it; it stays on your head |
|
||||
| Meta+Shift+R in the desktop | Puts the screens back in their layout (also in the menu as Reset Screen Layout, and mappable to a mouse button) |
|
||||
| Meta+Shift+H in the desktop | Hides or shows all screens (also in the menu as Hide/Show Screens, and mappable). The Visibility & pins tab of Frametop Display Settings can instead show them only with the dashboard open, or while you look at your wrist |
|
||||
| Tap Meta, on any keyboard | Opens the Steam menu in the SteamVR dashboard, or closes the dashboard, wherever you are. The desktop's launcher is still on the taskbar and Alt+F1. Change it in Frametop Input Settings (Keyboard page), where any key combination or modifier tap can do a Frametop or Steam action, open a profile, or run a command of your own |
|
||||
| Switch a screen to Hidden (Frametop Display Settings, Visibility & pins → Screens shown) | Hides just that screen until you switch it back, whatever the other visibility settings say; new windows that would open on it float instead |
|
||||
| Play a VR game | The screens hide and your controllers stay in the game. Open the SteamVR dashboard, or press Meta+Shift+H, to see and use them. To keep them visible over games, change During VR games on the Visibility & pins tab; the controllers still stay in the game, and you use the screens with the mouse or the dashboard |
|
||||
|
||||
@@ -92,7 +95,7 @@ A profile is a named setup: where the screens are, with their sizes and pins, wh
|
||||
|
||||
### Gaze mode (experimental)
|
||||
|
||||
In gaze mode the pointer goes where you look, and the mouse or the keyboard does the last bit. The installer offers it (or run `gaze/run.sh install` later). Turn it on and calibrate it on the Gaze page of Frametop Input Settings.
|
||||
In gaze mode the pointer goes where you look, and the mouse or the keyboard does the last bit. The installer offers it (or run `gaze/run.sh install` later), and then our own eye tracker for it, which is more accurate than SteamVR's (or run `gaze/tracker/install.sh` later; it needs `sudo`). Gaze mode uses ours once it's installed, and SteamVR's until then. Turn it on and calibrate it on the Gaze page of Frametop Input Settings.
|
||||
|
||||
| Do this | To get this |
|
||||
| --- | --- |
|
||||
@@ -138,7 +141,7 @@ In a terminal on the headset, run:
|
||||
cd ~/frametop && scripts/report.sh
|
||||
```
|
||||
|
||||
This writes `frametop-report-<date>.txt` with version numbers, service states, settings, and recent logs. Bluetooth addresses and the headset's serial number are masked. Then [open an issue](https://github.com/DeeJanuz/frametop/issues), describe what you did, what you expected, and what happened, and attach the file.
|
||||
This writes `frametop-report-<date>.txt` with version numbers, service states, settings, and recent logs. Bluetooth addresses and the headset's serial number are masked. Then [open an issue](https://github.com/DeeJanuz/frametop/issues), describe what you did, what you expected, and what happened, and attach the file. Quick questions can go to [Discord](https://discord.gg/W3X9f7z3Bc) instead.
|
||||
|
||||
## Update
|
||||
|
||||
|
||||
@@ -4,117 +4,26 @@ Steam, not systemd, puts the Frame to sleep: after "When Plugged In and Idle ->
|
||||
(an hour by default) without input, even while it charges. That's a Steam client setting,
|
||||
`system_idle_suspend_ac_sec` (0 = never), with no file or command line to change it. Steam
|
||||
on the Frame runs with -cef-enable-debugging, so its UI's JavaScript context
|
||||
(SharedJSContext) is reachable over the Chrome DevTools Protocol on 127.0.0.1:8080. There
|
||||
`settingsStore.clientSettings` has the current values, and `SteamClient.Settings.SetSetting`
|
||||
takes a change as a serialized CMsgClientSettings protobuf, which is what Steam's own
|
||||
Settings -> Power page sends. Standard library only (a minimal WebSocket client).
|
||||
(SharedJSContext) is reachable over the Chrome DevTools Protocol on 127.0.0.1:8080
|
||||
(steam/steamui.py). There `settingsStore.clientSettings` has the current values, and
|
||||
`SteamClient.Settings.SetSetting` takes a change as a serialized CMsgClientSettings protobuf,
|
||||
which is what Steam's own Settings -> Power page sends.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import urllib.request
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "steam"))
|
||||
from steamui import SteamUnreachable, evaluate # noqa: E402,F401 (callers catch SteamUnreachable here)
|
||||
|
||||
CDP_PORT = 8080
|
||||
# CMsgClientSettings field numbers (Steam's UI bundle maps the names to these).
|
||||
FIELDS = {"system_idle_suspend_ac_sec": 24004, "system_idle_suspend_battery_sec": 24003}
|
||||
|
||||
|
||||
class SteamUnreachable(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class _WebSocket:
|
||||
def __init__(self, url, timeout=5):
|
||||
host_port, path = url[len("ws://"):].split("/", 1)
|
||||
host, port = host_port.rsplit(":", 1)
|
||||
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: {host_port}\r\nUpgrade: websocket\r\n"
|
||||
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n").encode())
|
||||
head = b""
|
||||
while b"\r\n\r\n" not in head:
|
||||
chunk = self.sock.recv(4096)
|
||||
if not chunk:
|
||||
raise SteamUnreachable("Steam closed the connection")
|
||||
head += chunk
|
||||
if b" 101 " not in head.split(b"\r\n", 1)[0]:
|
||||
raise SteamUnreachable(head.split(b"\r\n", 1)[0].decode(errors="replace"))
|
||||
self.buf = head.split(b"\r\n\r\n", 1)[1]
|
||||
|
||||
def send(self, text):
|
||||
data, mask = text.encode(), os.urandom(4)
|
||||
n = len(data)
|
||||
if n < 126:
|
||||
head = struct.pack(">BB", 0x81, 0x80 | n)
|
||||
elif n < 65536:
|
||||
head = struct.pack(">BBH", 0x81, 0x80 | 126, n)
|
||||
else:
|
||||
head = struct.pack(">BBQ", 0x81, 0x80 | 127, n)
|
||||
self.sock.sendall(head + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(data)))
|
||||
|
||||
def _take(self, n):
|
||||
while len(self.buf) < n:
|
||||
chunk = self.sock.recv(65536)
|
||||
if not chunk:
|
||||
raise SteamUnreachable("Steam closed the connection")
|
||||
self.buf += chunk
|
||||
out, self.buf = self.buf[:n], self.buf[n:]
|
||||
return out
|
||||
|
||||
def recv(self):
|
||||
message = b""
|
||||
while True:
|
||||
b0, b1 = self._take(2)
|
||||
n = b1 & 0x7F
|
||||
if n == 126:
|
||||
n = struct.unpack(">H", self._take(2))[0]
|
||||
elif n == 127:
|
||||
n = struct.unpack(">Q", self._take(8))[0]
|
||||
message += self._take(n)
|
||||
if b0 & 0x80:
|
||||
return message.decode(errors="replace")
|
||||
|
||||
def close(self):
|
||||
self.sock.close()
|
||||
|
||||
|
||||
def _evaluate(expression):
|
||||
"""Runs JavaScript in Steam's SharedJSContext and returns its (awaited) value."""
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{CDP_PORT}/json", timeout=3) as r:
|
||||
targets = json.load(r)
|
||||
except OSError as e:
|
||||
raise SteamUnreachable(f"Steam isn't reachable on port {CDP_PORT} ({e})") from e
|
||||
url = next((t["webSocketDebuggerUrl"] for t in targets if t.get("title") == "SharedJSContext"), None)
|
||||
if not url:
|
||||
raise SteamUnreachable("Steam's UI isn't running")
|
||||
try:
|
||||
ws = _WebSocket(url)
|
||||
try:
|
||||
ws.send(json.dumps({"id": 1, "method": "Runtime.evaluate",
|
||||
"params": {"expression": expression, "awaitPromise": True, "returnByValue": True}}))
|
||||
while True:
|
||||
reply = json.loads(ws.recv())
|
||||
if reply.get("id") == 1:
|
||||
break
|
||||
finally:
|
||||
ws.close()
|
||||
except OSError as e:
|
||||
raise SteamUnreachable(str(e)) from e
|
||||
result = reply.get("result", {})
|
||||
if "exceptionDetails" in result:
|
||||
details = result["exceptionDetails"]
|
||||
raise SteamUnreachable(details.get("exception", {}).get("description") or details.get("text", "error"))
|
||||
return result.get("result", {}).get("value")
|
||||
|
||||
|
||||
def sleep_settings():
|
||||
"""{"ac": seconds, "battery": seconds}: when Steam puts the Frame to sleep without input,
|
||||
plugged in and on battery (0 = never)."""
|
||||
value = _evaluate("(() => { const c = settingsStore.clientSettings; "
|
||||
"return {ac: c.system_idle_suspend_ac_sec, battery: c.system_idle_suspend_battery_sec}; })()")
|
||||
value = evaluate("(() => { const c = settingsStore.clientSettings; "
|
||||
"return {ac: c.system_idle_suspend_ac_sec, battery: c.system_idle_suspend_battery_sec}; })()")
|
||||
if not isinstance(value, dict) or not all(isinstance(value.get(k), int) for k in ("ac", "battery")):
|
||||
raise SteamUnreachable("Steam's settings don't have the sleep timeouts")
|
||||
return value
|
||||
@@ -125,7 +34,7 @@ def set_sleep_setting(name, seconds):
|
||||
field, seconds = FIELDS[name], int(seconds)
|
||||
if seconds < 0:
|
||||
raise ValueError("seconds must be 0 (never) or more")
|
||||
ok = _evaluate(f"""(async () => {{
|
||||
ok = evaluate(f"""(async () => {{
|
||||
const bytes = [];
|
||||
const varint = n => {{ while (n > 127) {{ bytes.push((n & 127) | 128); n = Math.floor(n / 128); }} bytes.push(n); }};
|
||||
varint({field} * 8); varint({seconds});
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Controller desktop click stability
|
||||
|
||||
Trigger presses reach KDE immediately, but controller motion within 8 logical
|
||||
pixels of the press stays at that position until release. Releasing without a motion outside this
|
||||
zone delivers the click at the original position, even if the hand moved during
|
||||
release. Moving outside the zone begins a normal drag immediately; returning to
|
||||
the zone does not turn it back into a click. There is no hold-duration timer.
|
||||
|
||||
This filters overlay pointer content events on desktop monitors only, and only
|
||||
presses from hand controllers start it. The 3D mouse (whose laser comes from the
|
||||
`ft_pointer` virtual controller), SteamVR UI, separate screen grab bars and
|
||||
floating-app title-bar carrying are unaffected. Multi-button gestures keep their
|
||||
existing behavior. A motion onto another desktop monitor starts a drag;
|
||||
cross-monitor motion is not stabilized.
|
||||
|
||||
CLI (runtime preferences, reset to 8 on desktop restart):
|
||||
|
||||
```sh
|
||||
input/ft-clickctl status
|
||||
input/ft-clickctl threshold 8
|
||||
input/ft-clickctl threshold 0 # disable without a restart
|
||||
```
|
||||
|
||||
Thresholds are 0–64 logical pixels, normalized to each panel's KDE scale.
|
||||
Status reports held state, suppressed motions, stabilized clicks and drags.
|
||||
Changing the threshold while a controller button is held is refused.
|
||||
|
||||
This is a separate contribution from desktop mouse/controller ownership. Its
|
||||
hardware validation must check small controls, intentional text selection,
|
||||
long presses, cross-monitor dragging and simultaneous mouse use. The existing
|
||||
renderer laser remains tracked; this change stabilizes desktop input rather
|
||||
than smoothing the visual laser. Default threshold is a starting point to test.
|
||||
|
||||
Run `scripts/test-controller-click.sh` for the isolated gesture-state tests.
|
||||
+1
-1
@@ -175,7 +175,7 @@ Flatpak apps need `XDG_DATA_DIRS` to include Flatpak's exports, or Plasma opens
|
||||
|
||||
The private runtime directory also moves the session's document portal to `$XDG_RUNTIME_DIR/frametop/doc`, and that broke saving and uploading in Flatpak apps. The file picker (xdg-desktop-portal 1.18.4 on SteamOS) gives a sandboxed app the host path of the file it picked, `/run/user/1000/frametop/doc/ID/NAME`. Inside the sandbox the portal is at `/run/flatpak/doc`, and `/run/user/1000` is a private per-app folder (`.flatpak/APP/xdg-run` in the runtime directory). So Brave created the missing folder there, "finished" the download into it, and the file vanished when the session cleaned up. The session script now links that path to `/run/flatpak/doc` in each installed app's folder before Plasma starts. Upstream xdg-desktop-portal fixed this after 1.22.1 (commit `69ba5e1`) by handing Flatpak apps `/run/flatpak/doc` paths, after which the links go unused.
|
||||
|
||||
A podman container's monitor process (conmon) stays in the cgroup of whatever started the container, and `distrobox enter` starts it on demand. When a Frametop service happened to start the `dev` container, stopping that service stopped the container and everything in it, including the desktop's compositor. `scripts/container-up.sh` starts the container in a systemd scope of its own before anything enters it.
|
||||
A podman container's monitor process (conmon) stays in the cgroup of whatever started the container, and `distrobox enter` starts it on demand. When a Frametop service happened to start the `dev` container, stopping that service stopped the container and everything in it, including the desktop's compositor. `scripts/container-up.sh` starts the container in a systemd scope of its own before anything enters it. It then waits for distrobox-init to log `container_setup_done`, as `distrobox enter` does only for containers it starts itself. A new container's first start takes a minute or more (it installs distrobox's dependencies and sets up passwordless sudo), and an install that entered right away met a sudo password prompt with no terminal to answer it ([#9](https://github.com/DeeJanuz/frametop/issues/9)).
|
||||
|
||||
Program names stay within 15 characters, because Linux truncates process names there and the scripts find programs with `pgrep -x` and `pkill -x`. That's why the prefix is `ft-`.
|
||||
|
||||
|
||||
+7
-5
@@ -85,6 +85,8 @@ The relay also owns the volume keys, on every device that has them, the headset'
|
||||
desktops.sh relay install # enable it (starts with the next reboot or SteamVR start)
|
||||
desktops.sh relay status | log | uninstall
|
||||
input/input-relay.py --no-grab # try it without taking devices from SteamVR
|
||||
input/test/keys-test.py # key combinations and modifier taps, against fake devices (safe next to the live relay)
|
||||
steam/ft-steam menu # what Open Steam menu does; ft-steam check: Steam's UI still has the calls
|
||||
```
|
||||
|
||||
The first time, the relay has to start before SteamVR, so reboot or restart SteamVR after installing it. After that it's safe to restart on its own: systemd keeps the virtual devices open in its file descriptor store (`FileDescriptorStorePreserve=yes`), so SteamVR keeps the same devices.
|
||||
@@ -99,7 +101,7 @@ A mouse drives SteamVR the way a controller's laser does, but shows up as a smal
|
||||
|
||||
Whichever device you used last wins. Picking up a controller hands the laser back at once, and moving the mouse takes it again. When the headset comes off, the pointer lets go, so the displays can sleep, and it stays off until you're wearing the headset again.
|
||||
|
||||
To move a floating panel, left-drag its grab bar. The scroll wheel pushes and pulls it while you drag. Hold the right button while dragging and move the mouse to tilt the panel around the grab point; the right press isn't sent as a click. The tilt stays for the rest of the drag, and releasing the left button drops the panel as it is. A mapped Toggle dashboard button (or a Meta tap, with `META_DASHBOARD=1`) wakes the pointer if needed and holds the virtual system button for 0.12 s, because SteamVR ignores a press and release in the same instant.
|
||||
To move a floating panel, left-drag its grab bar. The scroll wheel pushes and pulls it while you drag. Hold the right button while dragging and move the mouse to tilt the panel around the grab point; the right press isn't sent as a click. The tilt stays for the rest of the drag, and releasing the left button drops the panel as it is. A mapped Toggle dashboard button wakes the pointer if needed and holds the virtual system button for 0.12 s, because SteamVR ignores a press and release in the same instant. Open Steam menu / close dashboard (`steam_menu`) needs no pointer: `steam/ft-steam menu` asks Steam's UI, over its debugging port (`steam/steamui.py`), to show its dashboard overlay and focus the Steam frame's menu, or to hide the dashboard if it's up.
|
||||
|
||||
```
|
||||
pointer/driver/build.sh && pointer/driver/install.sh install # then restart SteamVR
|
||||
@@ -108,16 +110,16 @@ pointer/helper/run.sh status | log | restart
|
||||
pointer/driver/install.sh probe # devices, hand roles, who owns the dashboard pointer
|
||||
```
|
||||
|
||||
The pointer settings are in `~/.config/frametop.conf`: `POINTER_SENSITIVITY`, `POINTER_IDLE`, `POINTER_WAKE_COUNTS`, `POINTER_CONTROLLER_PICKUP`, `POINTER_DISTANCE`, `POINTER_CURSOR_DEG`, `POINTER_ORIGIN_FRACTION`, `POINTER_ORIGIN_MARGIN`, `POINTER_SCENE_RADIUS`, `POINTER_EDGE_REACH`, `POINTER_LASER_WIDTH`, `POINTER_IGNORE`, the head follow settings `POINTER_FOLLOW`, `POINTER_LEASH_DEG`, `POINTER_LEASH_DELAY`, `POINTER_LEASH_RETURN`, and `POINTER_FOLLOW_REACH`, and the gaze mode settings `POINTER_GAZE`, `POINTER_GAZE_RETAKE`, `POINTER_GAZE_NUDGE_MAX`, `POINTER_GAZE_HOLD`, `POINTER_GAZE_DOT`, `POINTER_GAZE_SHOW`, `POINTER_GAZE_MOUSE`, `POINTER_GAZE_MOUSE_MOVE`, and the keyboard clicks' `POINTER_HEAD_DEADZONE` and `POINTER_KEY_TAP`, and the gaze service's `GAZE_TRACKER` (SteamVR's eye tracker or our own) and `GAZE_EYE` (the eye bias). The example config explains each. Frametop Input Settings changes them live; after editing the file by hand, restart the relay or the helper (the gaze service reads its two again when the file changes).
|
||||
The pointer settings are in `~/.config/frametop.conf`: `POINTER_SENSITIVITY`, `POINTER_IDLE`, `POINTER_WAKE_COUNTS`, `POINTER_CONTROLLER_PICKUP`, `POINTER_DISTANCE`, `POINTER_CURSOR_DEG`, `POINTER_ORIGIN_FRACTION`, `POINTER_ORIGIN_MARGIN`, `POINTER_SCENE_RADIUS`, `POINTER_EDGE_REACH`, `POINTER_LASER_WIDTH`, `POINTER_IGNORE`, the head follow settings `POINTER_FOLLOW`, `POINTER_LEASH_DEG`, `POINTER_LEASH_DELAY`, `POINTER_LEASH_RETURN`, and `POINTER_FOLLOW_REACH`, and the gaze mode settings `POINTER_GAZE`, `POINTER_GAZE_RETAKE`, `POINTER_GAZE_NUDGE_MAX`, `POINTER_GAZE_HOLD`, `POINTER_GAZE_DOT`, `POINTER_GAZE_SHOW`, `POINTER_GAZE_MOUSE`, `POINTER_GAZE_MOUSE_MOVE`, and the keyboard clicks' `POINTER_HEAD_DEADZONE` and `POINTER_KEY_TAP`, and the gaze service's `GAZE_TRACKER` (`auto`, the default: our own eye tracker when it's installed, else SteamVR's; or `own` or `steam`) and `GAZE_EYE` (the eye bias). The example config explains each. Frametop Input Settings changes them live; after editing the file by hand, restart the relay or the helper (the gaze service reads its two again when the file changes).
|
||||
|
||||
## Frametop Input Settings
|
||||
|
||||
A Kirigami app with a Python backend, in the Plasma menu under Settings. It runs in the `dev` container and talks to the relay over its control socket, `@frametop_relay`. It has eight pages:
|
||||
|
||||
- Devices lists every USB and Bluetooth mouse and keyboard, with a light that flashes when the device is used. Each device gets a role: 3D pointer (grabbed, drives the pointer; the default for anything with a mouse), Pass through (grabbed only while typing goes to the desktop; the default for keyboards, where a Meta tap toggles the dashboard if `META_DASHBOARD=1` is in `~/.config/frametop.conf`), or Ignore. A device is identified by its Bluetooth address, or its USB ids and name, so all of its input nodes share one role. Forget drops everything saved for a device.
|
||||
- Devices lists every USB and Bluetooth mouse and keyboard, with a light that flashes when the device is used. Each device gets a role: 3D pointer (grabbed, drives the pointer; the default for anything with a mouse), Pass through (grabbed only while typing goes to the desktop; the default for keyboards, whose key combinations work everywhere), or Ignore. A device is identified by its Bluetooth address, or its USB ids and name, so all of its input nodes share one role. Forget drops everything saved for a device.
|
||||
- Buttons maps a pointer device's buttons. Choose Capture a button, press the button or key, then pick an action: a click, back, scroll, toggle dashboard, recenter, pointer on or off, head follow on or off, gaze pointer on or off, gaze precision, gaze drag, gaze quick check, faster or slower, reset the screen layout, hide or show the screens, open or close the keyboard, float a window in VR or put it back, put all floating windows back, Open profile NAME (one per profile, [profiles.md](profiles.md)), pass the key through, or nothing. Devices with saved mappings are listed even while they're asleep.
|
||||
- Controllers maps the Frame controllers' buttons (every button but the system button) to the same actions, except passing a key through and the gaze actions: gaze mode is a mouse and keyboard feature ([gaze-controllers.md](gaze-controllers.md)). Capture a button and press it on a controller, or pick it from the list. The controllers aren't input devices on the host; only SteamVR sees them. So the pointer helper reads them with SteamVR input (`pointer/helper/vrbuttons.h`, `pointer/helper/actions/`) and sends presses to the relay (`vrbtn right/a 1`), which does the mapped action. The helper only takes the buttons that are mapped (the relay tells it with `vrbind`), at an overlay-global priority, and only while no game (scene application) runs, so games keep every button; with In games on (`controller_in_games`), a mapped button is taken from games too. That needs SteamVR's "Enable global input from overlays (Experimental)" setting (`steamvr/globalActionSetPriority`), which the page's Global input switch turns on and off. Mappings are saved as `controller_buttons` in `~/.config/frametop-input.json`.
|
||||
- Keyboard sets when Frametop's keyboard opens: whenever a text field is selected; only while no pass-through keyboard is connected (the default; keyboards other programs make through uinput, like frame-voice's, don't count); only with a mouse or controller button mapped to Open/close keyboard; or never, which turns the button off too. Keep it open (on by default, `vr_keyboard_persist`) leaves it open after the text field loses focus. The mode is saved as `vr_keyboard` in `~/.config/frametop-input.json`, and the page lists the keyboards that count as connected. Its Key combinations section maps modifiers plus a key, on any keyboard, to any action but passing a key through or nothing. The gaze clicks (Gaze left click and Gaze right click) only go on key combinations. The defaults are Meta+J (gaze left click), Meta+K (gaze right click), and Meta+Shift+F (float window in VR or put it back); remove them or add others there. The combination's last key isn't typed, and the modifiers still reach the app. They're saved as `key_bindings` in the same file.
|
||||
- Keyboard sets when Frametop's keyboard opens: whenever a text field is selected; only while no pass-through keyboard is connected (the default; keyboards other programs make through uinput, like frame-voice's, don't count); only with a mouse or controller button mapped to Open/close keyboard; or never, which turns the button off too. Keep it open (on by default, `vr_keyboard_persist`) leaves it open after the text field loses focus. The mode is saved as `vr_keyboard` in `~/.config/frametop-input.json`, and the page lists the keyboards that count as connected. Its Key combinations section maps modifiers plus a key, or one modifier tapped on its own, on any keyboard, to any action but passing a key through or nothing, or to Run a command…: a command line the input relay runs with `sh -c` when you press the keys (`command:CMD`). The command runs as the relay's user service, outside the desktop's session, with `layout/`, `float/` and `steam/` on its `PATH` (so `ft-layout use Work` or `ft-float launch org.kde.dolphin` work as they are), and its output goes to the relay's journal. The gaze clicks (Gaze left click and Gaze right click) only go on key combinations. The defaults are a Meta tap (open the Steam menu, or close the dashboard), Meta+J (gaze left click), Meta+K (gaze right click), and Meta+Shift+F (float window in VR or put it back); remove them or add others there. A tap is a press and release with no other key, mouse button, or scroll in between; a bound one sends the desktop F24 before the release, so Plasma's launcher doesn't open on it. The combination's last key isn't typed, and the modifiers still reach the app; while typing goes to Steam rather than the desktop, keyboards aren't grabbed, so Steam or the game sees the keys too. They're saved as `key_bindings` in the same file; a file with its own list, even an empty one, gets no defaults.
|
||||
- Pointer has a Head follow switch and sliders for the pointer settings, which apply immediately, and a Recenter button.
|
||||
- Ignored panels lists the SteamVR overlays that are showing, grouped by app (the first two parts of the overlay key, such as `sasaken.frame-perf-overlay`), from the pointer helper (`overlays`). Tick a panel, or Ignore the whole app, and the pointer passes through it to what's behind. It's for panels you only look at, like a performance overlay that follows your view. The list is saved as `POINTER_IGNORE` in `~/.config/frametop.conf`: comma-separated overlay keys, where a shell pattern like `vendor.app*` covers a whole app, including panels it opens later. The helper reloads at once. Frametop's own screens aren't listed, and entries for apps that aren't open are listed below, to remove.
|
||||
- Gaze has the gaze pointer switch (on now and from now on; a mapped button toggles it until the helper restarts), what the mouse's left button and movement do, the gaze dot, the eye tracker and eye bias, the gaze mode sliders, the gaze service's state (headset, samples per second, how often the tracker is losing each eye, the calibration, the nudges learned), and Quick check, Calibrate, and Check headset fit (each in a panel in the headset), Reload calibration, and Forget nudges. The gaze probe, a development tool, is in the page's overflow menu.
|
||||
@@ -204,7 +206,7 @@ In gaze mode the 3D mouse's pointer goes where you look, and the mouse or the ke
|
||||
- Meta+J left-clicks and Meta+K right-clicks where you look. A quick tap clicks where the dot was at the press. Hold instead, and the dot stays put in your view: turn your head until it's on what you meant, and let go to click there. Held still for `POINTER_GAZE_HOLD` (0.5 s), the press becomes a real one, and your head drags. Meta+K with Meta+J held presses where the dot is now, to drag from there, and a second Meta+K during that drag (a double Meta+K) pans and tilts what you're dragging while it's held.
|
||||
- The mouse's buttons work the same way, with the mouse steering instead of your head (`POINTER_GAZE_MOUSE=precision`, the default): the right button with the left held starts a drag, and a double right click pans and tilts what you're dragging. With `POINTER_GAZE_MOUSE_MOVE=held`, the default, the mouse only corrects: while the gaze has the pointer, moving it does nothing unless a button is held. `free` lets the mouse take the pointer any time. With the gaze stale for a second, in a game, or with the headset off, the mouse works as usual.
|
||||
- A correction before a click teaches the gaze service the tracker's error there. A correction bigger than `POINTER_GAZE_NUDGE_MAX` (55 degrees) isn't learned; it opens a quick check instead.
|
||||
- Calibration and checks run in a panel fixed to the headset (`gaze/panel/ft-gazepanel`, which the gaze service runs), from the Gaze page: Quick check is one dot, and also opens when you put the headset on. Calibrate is three rounds of dots, dark to bright; look at each dot and left click or press Meta+J to take it. Check headset fit shows, live, how well the tracker sees each eye. A right click or Meta+K closes the panel. Gaze mode coming on without a calibration opens Calibrate by itself.
|
||||
- Calibration and checks run in a panel fixed to the headset (`gaze/panel/ft-gazepanel`, which the gaze service runs), from the Gaze page: Quick check is one dot, and also opens when you put the headset on. Calibrate is three rounds of dots, dark to bright; look at each dot and left click or press Meta+J to take it. Check headset fit shows, live, how well the tracker sees each eye. A right click or Meta+K closes the panel. Gaze mode on without a calibration opens Calibrate by itself, as soon as your eyes are seen. If gaze mode is on but can't follow your eyes yet (no calibration, the calibration can't open, the gaze service not running), the Gaze page says why under the Gaze pointer switch, and `gaze/ft-gazectl on` notes it.
|
||||
|
||||
[gaze/README.md](../gaze/README.md) has the details, our own eye tracker, and the gaze probe, a development tool.
|
||||
|
||||
|
||||
+3
-3
@@ -7,7 +7,7 @@ The Steam Frame's eye tracking as pointer input: a gaze mode for the 3D mouse (t
|
||||
- `tracker/` is our own eye tracker, an alternative to SteamVR's: `ft-eyes` finds the pupils and glints in the eye-camera frames that `ft-eyegrab` (a small root service) copies out of SteamVR's tracker. See "Our own eye tracker" below.
|
||||
- `probe/ft-gazeprobe` (GTK 4, host Python) is a fullscreen playground, for developing the gaze tracking: day to day, the calibration and the checks run in the headset panel (Quick check, Calibrate, and Check headset fit on the Gaze page). It runs ft-gaze, draws where you're looking, measures accuracy, and tries out hold-to-adjust clicking with a calibration that learns from your adjustments.
|
||||
|
||||
Day to day, install the gaze service, then turn gaze mode on and calibrate on the Gaze page of Frametop Input Settings (Calibrate). The installer offers the gaze service (`gaze/run.sh install`); the probe and our own tracker are installed by hand.
|
||||
Day to day, install the gaze service, then turn gaze mode on and calibrate on the Gaze page of Frametop Input Settings (Calibrate). The installer offers the gaze service (`gaze/run.sh install`) and then our own tracker (`gaze/tracker/install.sh`), which gaze mode uses once it's installed; the probe is installed by hand.
|
||||
|
||||
```
|
||||
gaze/run.sh install # the gaze service: builds ft-gaze and the panel, starts with SteamVR
|
||||
@@ -23,7 +23,7 @@ gaze/probe/ft-gazeprobe --screen 1
|
||||
Gaze as an input method for the whole desktop, without replacing anything of SteamVR's:
|
||||
|
||||
- `ft-gazed` (host Python, a user service: `gaze/run.sh install`) runs ft-gaze and corrects its gaze. Two settings on the Gaze page of Frametop Input Settings (`GAZE_TRACKER` and `GAZE_EYE` in `~/.config/frametop.conf`, read again when the file changes) pick whose eye tracking it uses and how it weights the eyes:
|
||||
- **Eye tracker:** SteamVR's (the default), or our own (Own tracker: see "Our own eye tracker" below). The gaze service runs ours while this is on. It keeps its own calibration: with Own tracker chosen, Calibrate on the Gaze page calibrates it. The gaze pointer's settings (hand back, nudges, hold to drag, the dot) are the pointer helper's, so they're the same with either.
|
||||
- **Eye tracker:** our own (Own tracker: see "Our own eye tracker" below) or SteamVR's. The default, `GAZE_TRACKER=auto`, is ours when it's installed (its frame grabber, and ft-eyes' Python in the gaze service's checkout), else SteamVR's, and it switches when ours is installed or removed; picking one on the Gaze page sets it for good. The gaze service runs ours while it's the one in use. It keeps its own calibration: with Own tracker chosen, Calibrate on the Gaze page calibrates it. The gaze pointer's settings (hand back, nudges, hold to drag, the dot) are the pointer helper's, so they're the same with either.
|
||||
- **Eye bias:** Auto, Left, or Right. The gaze combines both eyes, each calibrated on its own, because their errors partly cancel: on 306 clicks with our tracker, the eyes' sideways errors were correlated -0.37, and both together were 0.65 degrees off (median) against 0.96 for the left eye alone and 1.11 for the right. So Left or Right leans instead of choosing: that eye counts twice as much as the other (0.03 degrees worse there toward the better eye, 0.13 toward the worse). Auto weights each eye by the inverse square of how far off it was at your last 20 nudges, once each eye has 5, and evenly before that. Each eye's miss is measured before that nudge teaches anything, so each is a fresh test. The calibration's own fit isn't used for this: on SteamVR's test of 2026-09-29, the calibration dots said the left eye was the better one, and new spots said the right. Either eye carries the gaze alone while the other is closed or lost.
|
||||
|
||||
With SteamVR, each eye is its own reading (set 2), corrected by its calibration from the probe (the Left eye and Right eye sources) plus what the pointer has taught that eye since. On that test, the two eyes each calibrated and averaged were 1.70 degrees off (median; mean 1.62) against 1.72 (mean 1.84) for SteamVR's combined gaze with its calibration. A calibration from before the probe had the eyes as sources, or `--source`, uses the older path. That path runs on SteamVR's combined gaze (mmap set 1), corrected as a whole. When the tracker loses one eye (its variance for that eye jumps from about 0.001 to 0.02), the gaze comes from the other eye instead: that eye's own reading (set 2) plus what it usually reads against the combined gaze, learned while both eyes are seen, in 10 degree cells of where it looks. Set 1 keeps going on one eye too, but it holds the lost eye's yaw where it was, so the gaze moves half as far sideways as your eyes do. On a recording, one eye alone came out a median 0.8 degrees from both eyes' gaze over a steady look, a little more jittery.
|
||||
@@ -33,7 +33,7 @@ Gaze as an input method for the whole desktop, without replacing anything of Ste
|
||||
- **The mouse only corrects** (the default; the Gaze page's Mouse movement switch, `POINTER_GAZE_MOUSE_MOVE=held`): while the gaze has the pointer, moving the mouse does nothing. The buttons work like Meta+J and Meta+K: press and hold one and the pointer stops where you look; move the mouse onto what you meant and let go to click there (a left or a right click). Held still for half a second, a press is a real one (to drag). Once you've moved, the left button alone only clicks: press the right one while still holding the left to start a drag there; it lasts while either button is held. Press the right one again (a double right click, the left still held) to pan and tilt what you're dragging, as a right press does during any drag. A bumped or drifting mouse can't pull the pointer away, and every mouse move is a correction, so the tracker only learns from real ones. With the gaze stale for a second (the tracker stopped, eyes lost), in a game, or with the headset off, the mouse moves the pointer as usual. `free` (the switch off) lets the mouse take the pointer any time.
|
||||
- **Keyboard clicks** (Meta+J left, Meta+K right; other key combinations on the Keyboard page of Input Settings): tap to click where you look. A quick tap (let go within 0.25 s, `POINTER_KEY_TAP`) clicks where the dot was when you pressed, whatever your head did, and tells the gaze service it was right there. Hold instead, and the dot stays put in your view: turn your head until it sits on what you meant, and let go to click there (the correction is a lesson, as with the mouse, under the same limit: past `POINTER_GAZE_NUDGE_MAX` it opens the quick check instead). Hold still for half a second to press for real, then turn your head to drag. With Meta+J held, Meta+K presses where the dot is now, so you can correct first and then drag; the drag lasts while either key is held. Meta+K during a Meta+J drag (again, after starting it with Meta+K: a double Meta+K) pans and tilts what you're dragging while it's held: turn your head to turn it.
|
||||
- **Learning from nudges:** if the mouse took the pointer from the gaze and moved it (0.2 degrees or more, and the correction within `POINTER_GAZE_NUDGE_MAX`: 55 degrees by default, half of the 109 the headset shows across, and 1 to 110; the same limit for mouse, keyboard, and pinch clicks) before you clicked, or you dragged a held press that far, you were nudging it onto what you looked at. The helper sends that as a lesson, from the raw gaze when the mouse took over to where you clicked, and ft-gazed learns it. So using it is what calibrates it. The raw gaze is one ft-gazed sent, so it also finds when that look was, and what each eye read then. With SteamVR, each eye learns its own error. With our tracker, the look goes to it as a click, like the probe's, and it relearns how the headset sits on your face. After the headset was off, your first nudge and click there resets that (the quick check's dot does the same). A correction past `POINTER_GAZE_NUDGE_MAX` isn't learned: the helper asks ft-gazed for the quick check instead ("recheck", after its 2-minute cooldown). Tested on our tracker's 409 clicks since its Sep 29 calibration: a one-dot check set from any one of them put the next 2 minutes' clicks within 15 degrees (99% within 4.2) and the next 10 minutes' within 25 (the far ones after the headset moved), so the check gets back well under it. The limit used to be 8 degrees, and live on 2026-10-01 our tracker was 12 off after the headset went on, so every correction was dropped. One lesson moves the whole correction by only a third of what it measured (more near where it was taken), since in the first live test one 6 degree lesson moved everything and put the next target 7 degrees off. `ft-gazectl status` shows the lessons, and `ft-gazectl forget` drops them.
|
||||
- **Checks and calibration in the headset** (`gaze/gazecheck.py`, shown by `gaze/panel/ft-gazepanel`, a panel fixed to the headset that ft-gazed runs): a one-dot quick check opens when you put the headset on (SteamVR's tracker sees your eyes for 3 s after none for 3 s; its "HMD on" log line can't say, since it repeats every minute or so and can stay on for hours with nobody in the headset), when our tracker asks for a click (its "reseat", when the headset may sit differently), at most once every 2 minutes, and from Quick check on the Gaze page. Look at the dot: it takes your gaze once it has held still for 0.6 s (the steadiness counts, not where the tracker puts it, so it works however far off it is), or at once with a left click or Meta+J; a right click or Meta+K closes it, and ignoring it changes nothing. It also runs when a click's correction was past `POINTER_GAZE_NUDGE_MAX`. The dot is still and the ring fills in quarters, so the panel is drawn again only a few times per dot. If the first 3 lessons after it are still over 2 degrees off, five dots follow. The full calibration (Calibrate on the Gaze page, or by itself when gaze mode comes on without one) is the probe's: three rounds, dark, medium and bright, of the middle and a ring around it, in a panel 64 degrees wide, with Frametop's screens hidden. Its dots (and the five-dot check's) wait for a click: look at the dot and left click or press Meta+J, and the gaze held still up to then is taken. Capturing whenever the gaze held still sometimes took a look that wasn't on the dot. The panel draws into three shared buffers SteamVR imported once, as Frametop's keyboard does: uploading each picture anew (SetOverlayRaw) flickered, and in one live test left the headset showing an old picture. Quitting it while there's still no calibration turns gaze mode off; turning it on again reopens it. For our tracker a check is a click and the calibration is its own (calib-point per dot); for SteamVR's, a check is a lesson for each eye and the calibration replaces calibration.json, and the lessons start over. Checks go to `checks.jsonl`.
|
||||
- **Checks and calibration in the headset** (`gaze/gazecheck.py`, shown by `gaze/panel/ft-gazepanel`, a panel fixed to the headset that ft-gazed runs): a one-dot quick check opens when you put the headset on (SteamVR's tracker sees your eyes for 3 s after none for 3 s; its "HMD on" log line can't say, since it repeats every minute or so and can stay on for hours with nobody in the headset), when our tracker asks for a click (its "reseat", when the headset may sit differently), at most once every 2 minutes, and from Quick check on the Gaze page. Look at the dot: it takes your gaze once it has held still for 0.6 s (the steadiness counts, not where the tracker puts it, so it works however far off it is), or at once with a left click or Meta+J; a right click or Meta+K closes it, and ignoring it changes nothing. It also runs when a click's correction was past `POINTER_GAZE_NUDGE_MAX`. The dot is still and the ring fills in quarters, so the panel is drawn again only a few times per dot. If the first 3 lessons after it are still over 2 degrees off, five dots follow. The full calibration (Calibrate on the Gaze page, or by itself whenever gaze mode is on without one and your eyes are seen) is the probe's: three rounds, dark, medium and bright, of the middle and a ring around it, in a panel 64 degrees wide, with Frametop's screens hidden. Its dots (and the five-dot check's) wait for a click: look at the dot and left click or press Meta+J, and the gaze held still up to then is taken. A dot that isn't taken says why, on an orange line over the instructions: with SteamVR's tracker, what dropped most of that look's samples (an eye lost, a blink, the two eyes disagreeing); with ours, its reply (an eye seen in too few frames, or moving). A dot gets two tries, then it's skipped. A calibration left with under two thirds of its dots fails and names the most common reason, as the Gaze page does after it. A click that has taken nothing after 1.5 s says what it waits for: the gaze to hold still, or an eye tracker that isn't sending. Capturing whenever the gaze held still sometimes took a look that wasn't on the dot. The panel draws into three shared buffers SteamVR imported once, as Frametop's keyboard does: uploading each picture anew (SetOverlayRaw) flickered, and in one live test left the headset showing an old picture. Quitting it while there's still no calibration turns gaze mode off; turning it on again reopens it. One that closes otherwise unfinished (ignored for 2 minutes, too few dots) opens again after the headset comes off and on. Why gaze mode, on, can't work yet goes in the service's status as `checks.problem`, which the Gaze page shows under the Gaze pointer switch. For our tracker a check is a click and the calibration is its own (calib-point per dot); for SteamVR's, a check is a lesson for each eye and the calibration replaces calibration.json, and the lessons start over. Checks go to `checks.jsonl`.
|
||||
- Nothing writes to SteamVR, its eye tracker, or its files: ft-gaze maps the eye tracker's shared memory read-only. With no fresh gaze (a blink, the service stopped, the headset off), the pointer stays where it is, and the mouse works as always.
|
||||
|
||||
Lessons are logged to `pointer-lessons.jsonl`: the raw gaze, the true direction, the correction at the time, and how far off it was.
|
||||
|
||||
+35
-18
@@ -479,9 +479,24 @@ int main(int argc, char **argv) {
|
||||
double lastEmit = 0;
|
||||
int actionErrors = 0;
|
||||
vr::EVRInputError lastActionError = vr::VRInputError_None;
|
||||
// During a VR game, SteamVR's gaze action is left alone. With ft-gaze reading the eyes, SteamVR
|
||||
// restarted its eye tracker every 10 s or so in a game, as if the headset came off, and each
|
||||
// restart took input focus from the game: Beat Saber paused (PR #13). Of what ft-gaze reads,
|
||||
// only the action reaches SteamVR (the mmap and our tracker are files), so gaze still works
|
||||
// over the dashboard. Games are told apart the way ft-screens does it, by the scene app.
|
||||
bool inGame = false;
|
||||
double nextGameCheck = 0;
|
||||
|
||||
while (true) {
|
||||
const double now = NowRaw();
|
||||
if (now >= nextGameCheck) {
|
||||
nextGameCheck = now + 0.5;
|
||||
const bool game = vr::VRApplications()->GetCurrentSceneProcessId() != 0;
|
||||
if (game != inGame)
|
||||
std::fprintf(stderr, "ft-gaze: %s\n",
|
||||
game ? "a VR game is running: SteamVR's gaze action left alone" : "the VR game ended");
|
||||
inGame = game;
|
||||
}
|
||||
vr::TrackedDevicePose_t hp;
|
||||
sys->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding, 0, &hp, 1);
|
||||
if (hp.bPoseIsValid) history.Add(now, hp.mDeviceToAbsoluteTracking);
|
||||
@@ -502,24 +517,26 @@ int main(int argc, char **argv) {
|
||||
// SteamVR's action: a room-space origin and fixation point, turned into the head
|
||||
// frame so every source reports the same kind of angles.
|
||||
std::string action = "{\"ok\":0}";
|
||||
vr::VRActiveActionSet_t active{};
|
||||
active.ulActionSet = set;
|
||||
active.nPriority = vr::k_nActionSetOverlayGlobalPriorityMin;
|
||||
input->UpdateActionState(&active, sizeof active, 1);
|
||||
vr::VREyeTrackingData_t e{};
|
||||
const vr::EVRInputError ae =
|
||||
input->GetEyeTrackingDataRelativeToNow(gaze, vr::TrackingUniverseStanding, 0, &e, sizeof e);
|
||||
if (ae == vr::VRInputError_None && e.bActive && e.bValid) {
|
||||
const Vec3 o{e.vGazeOrigin.v[0], e.vGazeOrigin.v[1], e.vGazeOrigin.v[2]};
|
||||
const Vec3 t{e.vGazeTarget.v[0], e.vGazeTarget.v[1], e.vGazeTarget.v[2]};
|
||||
const Vec3 dHead = RotateInverse(headNow, Normalize(t - o));
|
||||
char extra[96];
|
||||
std::snprintf(extra, sizeof extra, "\"tracked\":%d,\"dist\":%.3f,", int(e.bTracked), Length(t - o));
|
||||
action = SrcJson(list, headNow, dHead, extra);
|
||||
} else if (ae != lastActionError || (verbose && ++actionErrors % 90 == 1)) {
|
||||
std::fprintf(stderr, "ft-gaze: action: error %d active %d valid %d\n", int(ae), int(e.bActive),
|
||||
int(e.bValid));
|
||||
lastActionError = ae;
|
||||
if (!inGame) {
|
||||
vr::VRActiveActionSet_t active{};
|
||||
active.ulActionSet = set;
|
||||
active.nPriority = vr::k_nActionSetOverlayGlobalPriorityMin;
|
||||
input->UpdateActionState(&active, sizeof active, 1);
|
||||
vr::VREyeTrackingData_t e{};
|
||||
const vr::EVRInputError ae =
|
||||
input->GetEyeTrackingDataRelativeToNow(gaze, vr::TrackingUniverseStanding, 0, &e, sizeof e);
|
||||
if (ae == vr::VRInputError_None && e.bActive && e.bValid) {
|
||||
const Vec3 o{e.vGazeOrigin.v[0], e.vGazeOrigin.v[1], e.vGazeOrigin.v[2]};
|
||||
const Vec3 t{e.vGazeTarget.v[0], e.vGazeTarget.v[1], e.vGazeTarget.v[2]};
|
||||
const Vec3 dHead = RotateInverse(headNow, Normalize(t - o));
|
||||
char extra[96];
|
||||
std::snprintf(extra, sizeof extra, "\"tracked\":%d,\"dist\":%.3f,", int(e.bTracked), Length(t - o));
|
||||
action = SrcJson(list, headNow, dHead, extra);
|
||||
} else if (ae != lastActionError || (verbose && ++actionErrors % 90 == 1)) {
|
||||
std::fprintf(stderr, "ft-gaze: action: error %d active %d valid %d\n", int(ae), int(e.bActive),
|
||||
int(e.bValid));
|
||||
lastActionError = ae;
|
||||
}
|
||||
}
|
||||
|
||||
std::string m1 = "{\"ok\":0}", m2 = m1, left = m1, right = m1, eye = "null";
|
||||
|
||||
+10
-1
@@ -36,8 +36,17 @@ def main():
|
||||
if r is None:
|
||||
sys.exit("the pointer helper isn't running")
|
||||
print(f"gaze mode {r.removeprefix('ok ')}" if r else "no answer (an older pointer helper without gaze mode?)")
|
||||
if ask("ft_gazed", "status", 0.3) is None:
|
||||
st = ask("ft_gazed", "status", 0.3)
|
||||
if st is None:
|
||||
print("note: the gaze service (ft-gazed) isn't running, so the pointer has no gaze to follow")
|
||||
elif r == "ok on":
|
||||
try:
|
||||
calibrated = (json.loads(st).get("checks") or {}).get("calibrated")
|
||||
except ValueError:
|
||||
calibrated = None
|
||||
if calibrated is False:
|
||||
print("note: no calibration for this eye tracker yet; it opens in the headset "
|
||||
"(if it can't, ft-gazectl status says why: checks.problem)")
|
||||
elif cmd in ("status", "forget", "reload"):
|
||||
r = ask("ft_gazed", cmd)
|
||||
if r is None:
|
||||
|
||||
+29
-10
@@ -3,8 +3,12 @@
|
||||
|
||||
Two settings in ~/.config/frametop.conf (the Gaze page of Frametop Input Settings), read
|
||||
again when the file changes:
|
||||
GAZE_TRACKER=steam|own SteamVR's eye tracker (default), or our own (gaze/tracker/ft-eyes,
|
||||
ft-gaze's source "own"; this service runs it, see below)
|
||||
GAZE_TRACKER=auto|own|steam
|
||||
our own eye tracker (gaze/tracker/ft-eyes, ft-gaze's source "own";
|
||||
this service runs it, see below) or SteamVR's. auto (the default)
|
||||
is ours when it's installed (gaze/tracker/install.sh: the frame
|
||||
grabber, and ft-eyes' Python in this checkout), else SteamVR's; it
|
||||
switches when ours is installed or removed.
|
||||
GAZE_EYE=auto|left|right the eye bias (gazecal.EyeWeights): auto weights each eye by how far
|
||||
off it was at your recent nudges; left or right counts that eye twice
|
||||
as much as the other. Either eye alone carries the gaze when the
|
||||
@@ -119,6 +123,7 @@ EYES_PROG = REPO / "gaze" / "tracker" / "ft-eyes" # our own tracker
|
||||
EYES_PYTHON = REPO / "gaze" / "tracker" / "build" / "venv" / "bin" / "python" # numpy, OpenCV (build.sh)
|
||||
EYES_SOCKET = "\0ft_eyes" # its control socket
|
||||
EYES_CAMS = Path("/dev/shm/frametop-eyes-cams") # the frames it reads (frametop-eyegrab.service)
|
||||
EYEGRAB = (Path("/etc/frametop/ft-eyegrab"), Path("/etc/systemd/system/frametop-eyegrab.service")) # tracker/install.sh
|
||||
CONF = Path.home() / ".config" / "frametop.conf"
|
||||
CALIBRATION = STATE / "calibration.json"
|
||||
LESSONS = STATE / "pointer-lessons.json"
|
||||
@@ -126,6 +131,7 @@ LESSON_LOG = STATE / "pointer-lessons.jsonl"
|
||||
SOURCES = ("action", "mmap1", "mmap2", "left", "right") # the ones with a calibration here
|
||||
SIDES = ("left", "right") # ft-gaze's order, and the sources for each eye alone
|
||||
TRACKERS = ("steam", "own")
|
||||
TRACKER_SETTINGS = ("auto",) + TRACKERS
|
||||
BIASES = ("auto", "left", "right")
|
||||
NUDGE_MAX = 55.0 # degrees: POINTER_GAZE_NUDGE_MAX's default, the largest lesson taken
|
||||
HISTORY = 12.0 # seconds of the gaze sent, to find a lesson's look (the helper sends it up to 10 s later)
|
||||
@@ -153,8 +159,14 @@ def log(msg):
|
||||
print(f"ft-gazed: {msg}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def own_installed():
|
||||
"""Is our own tracker installed: its frame grabber, and ft-eyes' Python here?"""
|
||||
return all(p.exists() for p in EYEGRAB) and EYES_PYTHON.exists()
|
||||
|
||||
|
||||
def read_settings():
|
||||
"""(tracker, eye bias, nudge max) from frametop.conf, defaults for anything missing or unknown."""
|
||||
"""(tracker, GAZE_TRACKER, eye bias, nudge max) from frametop.conf, defaults for anything
|
||||
missing or unknown. The tracker is "steam" or "own": auto picks ours when it's installed."""
|
||||
conf = {}
|
||||
try:
|
||||
for line in CONF.read_text().splitlines():
|
||||
@@ -164,13 +176,15 @@ def read_settings():
|
||||
conf[k.strip()] = v.strip().lower()
|
||||
except OSError:
|
||||
pass
|
||||
tracker = conf.get("GAZE_TRACKER", "steam")
|
||||
setting = conf.get("GAZE_TRACKER", "auto")
|
||||
setting = setting if setting in TRACKER_SETTINGS else "auto"
|
||||
tracker = setting if setting != "auto" else "own" if own_installed() else "steam"
|
||||
bias = conf.get("GAZE_EYE", "auto")
|
||||
try:
|
||||
nudge = min(max(float(conf.get("POINTER_GAZE_NUDGE_MAX", NUDGE_MAX)), 1.0), 110.0) # the helper's range
|
||||
except ValueError:
|
||||
nudge = NUDGE_MAX
|
||||
return tracker if tracker in TRACKERS else "steam", bias if bias in BIASES else "auto", nudge
|
||||
return tracker, setting, bias if bias in BIASES else "auto", nudge
|
||||
|
||||
|
||||
def mtime(path):
|
||||
@@ -185,7 +199,7 @@ class Service:
|
||||
self.override, self.verbose, self.to = source, verbose, to
|
||||
self.source = source or "mmap1" # the older path's source
|
||||
STATE.mkdir(parents=True, exist_ok=True)
|
||||
self.tracker, self.bias, self.nudge_max = read_settings()
|
||||
self.tracker, self.tracker_setting, self.bias, self.nudge_max = read_settings()
|
||||
self.conf_mtime = mtime(CONF)
|
||||
self.models = {name: Correction() for name in SOURCES}
|
||||
self.mode = DEFAULT_MODEL
|
||||
@@ -246,9 +260,12 @@ class Service:
|
||||
|
||||
def load_settings(self):
|
||||
self.conf_mtime = mtime(CONF)
|
||||
tracker, bias, self.nudge_max = read_settings()
|
||||
tracker, self.tracker_setting, bias, self.nudge_max = read_settings()
|
||||
if (tracker, bias) != (self.tracker, self.bias):
|
||||
log(f"tracker {tracker}, eye bias {bias}" + (f" (--source {self.override} wins)" if self.override else ""))
|
||||
auto = ""
|
||||
if self.tracker_setting == "auto":
|
||||
auto = " (auto: ours is installed)" if tracker == "own" else " (auto: ours isn't installed)"
|
||||
log(f"tracker {tracker}{auto}, eye bias {bias}" + (f" (--source {self.override} wins)" if self.override else ""))
|
||||
self.tracker, self.bias = tracker, bias
|
||||
for w in self.weights.values():
|
||||
w.bias = bias
|
||||
@@ -733,7 +750,8 @@ class Service:
|
||||
st.update(calibration_samples=cal.get("dots", 0), calibration_made=cal.get("made"),
|
||||
lessons=max(len(m) for m in w.misses), own_running=bool(own),
|
||||
own_reseat=any(e.get("reseat") for e in own.get("eyes", {}).values()))
|
||||
st.update(eyes_process=self.eyes_proc is not None, eyegrab=EYES_CAMS.exists())
|
||||
st.update(eyes_process=self.eyes_proc is not None, eyegrab=EYES_CAMS.exists(),
|
||||
tracker_setting=self.tracker_setting, own_installed=own_installed())
|
||||
st.update({"ft_gaze": self.proc is not None, "sample_age_s": round(time.monotonic() - self.last_sample, 2)
|
||||
if self.last_sample else None, "headset_on": self.steam.wearing(),
|
||||
"headset_on_since": self.steam.worn(), "last": [round(v, 2) for v in self.last] if self.last else None,
|
||||
@@ -749,7 +767,8 @@ class Service:
|
||||
if mtime(CALIBRATION) != self.cal_mtime:
|
||||
self.load_calibration()
|
||||
self.refit()
|
||||
if mtime(CONF) != self.conf_mtime:
|
||||
if mtime(CONF) != self.conf_mtime or (self.tracker_setting == "auto"
|
||||
and own_installed() != (self.tracker == "own")):
|
||||
self.load_settings()
|
||||
want = self.eyes_wanted()
|
||||
if want and not self.eyes_proc and time.monotonic() >= self.eyes_restart_at:
|
||||
|
||||
+23
-10
@@ -640,31 +640,44 @@ def cross_validate(points, mode):
|
||||
return errs
|
||||
|
||||
|
||||
def steady_samples(samples, vergence_jump=1.5):
|
||||
def steady_samples(samples, vergence_jump=1.5, why=None):
|
||||
"""The samples of one look at one spot where the tracker had both eyes: none in a blink
|
||||
(openness under half its median over the samples), none where it had lost an eye (its
|
||||
variance over EYE_LOST), and none where the angle between the eyes' directions (`lr`, the
|
||||
vergence) is more than `vergence_jump` degrees from its median over the samples. The
|
||||
vergence itself depends on distance (about 2.8 degrees for a screen 1.3 m away, a
|
||||
fraction of one far off), so only a jump away from what it was during this look means
|
||||
the tracker lost an eye. Without the mmap there's nothing to judge by: all are kept."""
|
||||
the tracker lost an eye. Without the mmap there's nothing to judge by: all are kept.
|
||||
`why`, a dict, gets how many were dropped for each reason: "lost_left", "lost_right",
|
||||
"lost_both", "blink" and "vergence" (each sample once, for the first that applies)."""
|
||||
if why is None:
|
||||
why = {}
|
||||
# Openness: a blink is a sharp drop from what it was during this look. Not a fixed
|
||||
# level: looking down, the upper lids come down with the eyes, and in bright light you
|
||||
# squint, so the reading can stay under 0.5 for the whole look while the tracker follows
|
||||
# the eyes fine (a calibration dot at the bottom of the bright round failed that way).
|
||||
opens = [min(o) for o in ((smp["src"].get("mmap1") or {}).get("open") for smp in samples) if o]
|
||||
floor = max(0.12, 0.5 * statistics.median(opens)) if len(opens) >= 5 else 0.12
|
||||
opened = []
|
||||
seen = []
|
||||
for smp in samples:
|
||||
o = (smp["src"].get("mmap1") or {}).get("open")
|
||||
if not o or min(o) >= floor:
|
||||
opened.append(smp)
|
||||
m1 = smp["src"].get("mmap1") or {}
|
||||
o = m1.get("open")
|
||||
lost = [u > EYE_LOST for u in m1.get("unc") or [0, 0]]
|
||||
# A lost eye's openness reads 0 too, so a lost eye is named before a blink.
|
||||
key = ("lost_both" if all(lost) else "lost_left" if lost[0] else "lost_right") if any(lost) else \
|
||||
"blink" if o and min(o) < floor else None
|
||||
if key:
|
||||
why[key] = why.get(key, 0) + 1
|
||||
else:
|
||||
seen.append(smp)
|
||||
|
||||
def vergence(smp):
|
||||
return (smp["src"].get("mmap1") or {}).get("lr", (smp["src"].get("mmap2") or {}).get("lr"))
|
||||
opened = [smp for smp in opened if max((smp["src"].get("mmap1") or {}).get("unc") or [0]) <= EYE_LOST]
|
||||
have = [v for v in map(vergence, opened) if v is not None]
|
||||
have = [v for v in map(vergence, seen) if v is not None]
|
||||
if len(have) < 5:
|
||||
return opened
|
||||
return seen
|
||||
med = statistics.median(have)
|
||||
return [smp for smp in opened if vergence(smp) is None or abs(vergence(smp) - med) <= vergence_jump]
|
||||
kept = [smp for smp in seen if vergence(smp) is None or abs(vergence(smp) - med) <= vergence_jump]
|
||||
if len(kept) < len(seen):
|
||||
why["vergence"] = why.get("vergence", 0) + len(seen) - len(kept)
|
||||
return kept
|
||||
+142
-23
@@ -17,9 +17,12 @@ directions: look at each one.
|
||||
size, and the tracker's error with it, changes with brightness), each the middle and
|
||||
a ring of six (SteamVR's tracker) or eight (ours, whose fit goes wrong past its dots)
|
||||
RING degrees out, half that in the middle round, turned 20 degrees a round. It opens
|
||||
when gaze mode comes on without a calibration for the tracker in use, and on
|
||||
"calibrate". Frametop's screens hide while it runs. Quitting it while there's still
|
||||
no calibration turns gaze mode off (POINTER_GAZE=0); turning it on again reopens it.
|
||||
whenever gaze mode is on without a calibration for the tracker in use and someone's
|
||||
in the headset, and on "calibrate". One that closes unfinished (ignored, too few
|
||||
dots) opens again only once the headset comes off and on, or gaze mode off and on.
|
||||
Frametop's screens hide while it runs. Quitting it while there's still no
|
||||
calibration turns gaze mode off (POINTER_GAZE=0); turning it on again reopens it.
|
||||
Why gaze mode can't work yet goes in the status ("problem"), for Input Settings.
|
||||
|
||||
fit the headset fit check (on "fitcheck", Check headset fit on the Gaze page): live, a
|
||||
card per eye (tracked or lost, the tracker's signal, how much of the last 10 s it was
|
||||
@@ -34,7 +37,10 @@ works however far off the tracker is; a left click or Meta+J (the pointer helper
|
||||
"calaccept") takes it now. The full calibration's and five's dots wait for that click: you
|
||||
click when you're looking at the dot (the user asked for that: a steady gaze isn't always on
|
||||
the dot), and the gaze held still up to then is taken (ACCEPT_SPREAD). They wait as long as
|
||||
it takes, up to CLICK_IDLE. A right click or Meta+K ("calquit") closes the panel. The pointer hides meanwhile ("calpanel 1",
|
||||
it takes, up to CLICK_IDLE. A dot not taken says why in the panel's note line (reject_reason:
|
||||
gazecal.steady_samples' drop counts for SteamVR's tracker, ft-eyes' reply for ours), as does a
|
||||
click with nothing taken after ACCEPT_WAIT, and a failed calibration names its most common
|
||||
reason there and in the status. A right click or Meta+K ("calquit") closes the panel. The pointer hides meanwhile ("calpanel 1",
|
||||
renewed every second; the helper shows it again by itself when that stops).
|
||||
|
||||
What a capture teaches:
|
||||
@@ -90,6 +96,7 @@ ROUND_BG = (0.03, 0.33, 0.8)
|
||||
ROUND_NAMES = ("dark", "medium", "bright")
|
||||
RING_SCALE = (1.0, 0.5, 1.0)
|
||||
PANEL_RETRY = 10.0
|
||||
FULL_RETRY = 10.0 # seconds before an automatic calibration that failed to start tries again
|
||||
FIT_TIMEOUT = 300.0 # seconds the fit check stays up
|
||||
FIT_EVERY = 0.5 # seconds between its cards' updates (each is a new picture for the panel)
|
||||
FIT_HINT_WIDTH = 95 # characters a hint line holds in the panel
|
||||
@@ -117,6 +124,42 @@ def check_dots(kind, own):
|
||||
return out
|
||||
|
||||
|
||||
# Why SteamVR's samples for a look were dropped (gazecal.steady_samples' counts) -> (short, long):
|
||||
# short for the small panel, long for the calibration's. FIT_HINT goes after the ones the
|
||||
# headset's fit causes, in the calibration's panel.
|
||||
STEAM_REASONS = {"lost_left": ("left eye lost", "SteamVR lost your left eye"),
|
||||
"lost_right": ("right eye lost", "SteamVR lost your right eye"),
|
||||
"lost_both": ("both eyes lost", "SteamVR lost both eyes"),
|
||||
"blink": ("blinked", "you blinked"),
|
||||
"vergence": ("eyes disagreed", "SteamVR's two eyes disagreed")}
|
||||
FIT_HINT = "check the headset fit"
|
||||
ACCEPT_WAIT = 1.5 # seconds after a click with no capture before the panel says what it waits for
|
||||
|
||||
|
||||
def reject_reason(reply, why):
|
||||
"""Why a dot wasn't taken -> (short, long, fit): our tracker's reply (`reply`), or SteamVR's
|
||||
drop counts (`why`, when `reply` is None). `fit`: the headset's fit is the likely cause."""
|
||||
if reply is None:
|
||||
if not why:
|
||||
return "no reading", "SteamVR sent no reading for that look", False
|
||||
key = max(why, key=why.get)
|
||||
return *STEAM_REASONS[key], key.startswith("lost")
|
||||
words = reply.removeprefix("fail ").split()
|
||||
# ft-eyes: "fail the left eye was seen in only 3 frames", "fail the left eye moved (4.2 px)"
|
||||
if len(words) >= 3 and words[0] == "the" and words[2] == "eye":
|
||||
eye = words[1]
|
||||
if "seen" in words:
|
||||
return f"{eye} eye not seen", f"our tracker saw your {eye} eye in only {words[-2]} frames", True
|
||||
if "moved" in words:
|
||||
return f"{eye} eye moved", f"your {eye} eye moved while you looked", False
|
||||
if not reply:
|
||||
return "no answer", "our tracker didn't answer", False
|
||||
if reply.startswith("our tracker isn't running"):
|
||||
return "tracker not running", "our tracker isn't running", False
|
||||
text = reply.removeprefix("fail ")
|
||||
return text[:24], f"our tracker said: {text}", False
|
||||
|
||||
|
||||
def spread(points):
|
||||
"""The median point and the spread around it (1.4826 x the median distance: a standard
|
||||
deviation that one stray sample can't move far)."""
|
||||
@@ -167,7 +210,10 @@ class Checks:
|
||||
self.check = None
|
||||
self.gaze_on = None
|
||||
self.gaze_heard = 0.0
|
||||
self.want_full_until = 0.0 # gaze mode came on before the tracker said whether it's calibrated
|
||||
self.full_armed = True # gaze mode on without a calibration opens the full one (need_full)
|
||||
self.full_blocked = None # why it can't open now
|
||||
self.full_retry_at = 0.0
|
||||
self.full_failed = None # why the last calibration failed (too few dots), for problem()
|
||||
self.last_quick = 0.0
|
||||
self.sample_at = 0.0 # the tracker last sent anything
|
||||
self.seen_at = 0.0 # eyes last seen (SteamVR's tracker's variance for them, "unc")
|
||||
@@ -282,11 +328,49 @@ class Checks:
|
||||
return self.eyes_seen() and time.monotonic() - self.svc.last_sample < 2
|
||||
|
||||
def on_gaze_on(self):
|
||||
self.full_armed = True
|
||||
self.need_full("gaze mode came on without a calibration")
|
||||
|
||||
def need_full(self, reason):
|
||||
"""Gaze mode is on without a calibration: open the full one, once per arming (see the top),
|
||||
or note why it can't open."""
|
||||
cal = self.calibrated()
|
||||
if cal is False:
|
||||
self.start("full", "gaze mode came on without a calibration")
|
||||
elif cal is None:
|
||||
self.want_full_until = time.monotonic() + 20
|
||||
if cal:
|
||||
self.full_armed = True # missing one later (the other tracker picked) is news again
|
||||
if not self.gaze_on or self.check or not self.full_armed or cal is not False:
|
||||
self.full_blocked = None
|
||||
return
|
||||
now = time.monotonic()
|
||||
if not self.can_run():
|
||||
why = ("the eye tracker isn't sending" if now - self.svc.last_sample >= 2
|
||||
else "no eyes seen (is the headset on?)")
|
||||
elif now < self.full_retry_at:
|
||||
return
|
||||
else:
|
||||
reply = self.start("full", reason)
|
||||
why = None if reply == "ok" else reply.removeprefix("error ")
|
||||
if why:
|
||||
self.full_retry_at = now + FULL_RETRY
|
||||
if why and why != self.full_blocked:
|
||||
log(f"the calibration can't open: {why}")
|
||||
self.full_blocked = why
|
||||
if not why:
|
||||
self.full_armed = False
|
||||
|
||||
def problem(self):
|
||||
"""Why gaze mode, on, can't follow your eyes yet, or None. Our tracker not having said
|
||||
yet is None: Input Settings has its own line for our tracker."""
|
||||
if not self.gaze_on or self.calibrated() is not False:
|
||||
return None
|
||||
if self.check and self.check["kind"] == "full":
|
||||
return "Not calibrated yet: the calibration is open in the headset"
|
||||
if self.full_blocked:
|
||||
return f"Not calibrated, and the calibration can't open: {self.full_blocked}"
|
||||
if not self.full_armed:
|
||||
if self.full_failed:
|
||||
return f"Not calibrated: the calibration failed (most dots: {self.full_failed}). Use Calibrate"
|
||||
return "Not calibrated: the calibration closed unfinished. Use Calibrate"
|
||||
return "Not calibrated: the calibration opens in the headset"
|
||||
|
||||
def auto_quick(self, reason):
|
||||
now = time.monotonic()
|
||||
@@ -318,7 +402,7 @@ class Checks:
|
||||
now = time.monotonic()
|
||||
self.check = {"kind": kind, "reason": reason, "own": own, "dots": check_dots(kind, own), "i": 0,
|
||||
"started": now, "shown": now, "run": [], "accept": False, "done_at": None, "tries": 0,
|
||||
"skipped": 0, "captured": 0, "points": {}}
|
||||
"skipped": 0, "captured": 0, "points": {}, "reasons": {}, "fit_reasons": set(), "note": ""}
|
||||
log(f"{kind} check: {reason}")
|
||||
if kind == "full":
|
||||
st = ask(SCREENS, "state", 0.5).split()
|
||||
@@ -402,7 +486,7 @@ class Checks:
|
||||
self.to_panel(f"dot {yaw:.3f} {pitch:.3f} look")
|
||||
self.last_progress = None
|
||||
c["shown"] = time.monotonic()
|
||||
c["run"], c["accept"], c["done_at"] = [], False, None
|
||||
c["run"], c["accept"], c["done_at"], c["accept_at"] = [], False, None, None
|
||||
|
||||
def on_sample(self, s):
|
||||
self.sample_at = time.monotonic()
|
||||
@@ -428,6 +512,7 @@ class Checks:
|
||||
if "hy" not in src:
|
||||
return
|
||||
now = time.monotonic()
|
||||
c["gaze_at"] = now
|
||||
if now < c["shown"] + CHECK_SETTLE:
|
||||
return
|
||||
g = (src["hy"], src["hp"])
|
||||
@@ -488,7 +573,9 @@ class Checks:
|
||||
if ok:
|
||||
svc.weights["own"].add(miss)
|
||||
else:
|
||||
steady = steady_samples(samples)
|
||||
why = {}
|
||||
steady = steady_samples(samples, why=why)
|
||||
rec["dropped"] = why
|
||||
reads = {}
|
||||
for name in ("action", "mmap1", "mmap2", "left", "right"):
|
||||
pts = [(smp["src"][name]["hy"], smp["src"][name]["hp"]) for smp in steady
|
||||
@@ -522,23 +609,41 @@ class Checks:
|
||||
if svc.kind == "eyes":
|
||||
svc.weights["steam"].add(miss)
|
||||
svc.dirty = True
|
||||
if not ok:
|
||||
short, long, fit = reject_reason(rec.get("reply", "") if c["own"] else None, rec.get("dropped"))
|
||||
rec["reason"] = long
|
||||
c["reasons"][long] = c["reasons"].get(long, 0) + 1
|
||||
if fit:
|
||||
c["fit_reasons"].add(long)
|
||||
rec["accepted"] = ok
|
||||
self.log_check(rec)
|
||||
if not ok:
|
||||
c["tries"] += 1
|
||||
log(f"{c['kind']} check dot {c['i'] + 1}: not taken ({rec.get('reply', '')})")
|
||||
if c["tries"] >= 2 or c["kind"] != "full":
|
||||
log(f"{c['kind']} check dot {c['i'] + 1}: not taken: {rec['reason']} ({rec.get('reply', '')})")
|
||||
full = c["kind"] == "full"
|
||||
if c["tries"] >= 2 or not full:
|
||||
self.note(f"Dot skipped: {long}" + (f" ({FIT_HINT})" if fit else "") if full else f"Skipped: {short}")
|
||||
self.skip()
|
||||
else:
|
||||
self.note(f"Not taken: {long}. Look at the dot and click again")
|
||||
self.to_panel(f"dot {yaw:.3f} {pitch:.3f} fail")
|
||||
c["run"], c["accept"] = [], False
|
||||
c["run"], c["accept"], c["accept_at"] = [], False, None
|
||||
c["shown"] = time.monotonic() # settle again, then retry
|
||||
return
|
||||
c["captured"] += 1
|
||||
c["tries"] = 0
|
||||
self.note("")
|
||||
self.to_panel(f"dot {yaw:.3f} {pitch:.3f} done")
|
||||
c["done_at"] = time.monotonic() + DONE_PAUSE
|
||||
|
||||
def note(self, text):
|
||||
"""The panel's warning line, over the instructions (empty: none). It stays until the
|
||||
next dot is taken, so a skipped dot's reason is still there at the one after it."""
|
||||
c = self.check
|
||||
if c.get("note") != text:
|
||||
c["note"] = text
|
||||
self.to_panel(f"note {text}".rstrip())
|
||||
|
||||
def skip(self):
|
||||
c = self.check
|
||||
yaw, pitch, _ = c["dots"][c["i"]]
|
||||
@@ -567,12 +672,20 @@ class Checks:
|
||||
return
|
||||
n = len(c["dots"])
|
||||
if c["captured"] < n * 2 / 3:
|
||||
log(f"calibration failed: only {c['captured']} of {n} dots; the old one stays")
|
||||
reasons = c["reasons"]
|
||||
main = max(reasons, key=reasons.get) if reasons else None
|
||||
self.full_failed = main
|
||||
log(f"calibration failed: only {c['captured']} of {n} dots; the old one stays"
|
||||
+ (f". Not taken: {', '.join(f'{r} ({k})' for r, k in reasons.items())}" if reasons else ""))
|
||||
self.to_panel(f"text Calibration failed: only {c['captured']} of {n} dots. Try again from Input Settings")
|
||||
if main:
|
||||
fit = main in c["fit_reasons"]
|
||||
self.note(f"Most dots: {main}" + (". Check headset fit on the Gaze page" if fit else ""))
|
||||
self.to_panel("dot 0 0 off")
|
||||
c["done_at"] = time.monotonic() + 3.0
|
||||
c["done_at"] = time.monotonic() + (8.0 if main else 3.0) # time to read why
|
||||
c["closing"] = True
|
||||
return
|
||||
self.full_failed = None
|
||||
if c["own"]:
|
||||
reply = ask(EYES, "calib-fit", 10.0)
|
||||
log(f"calibration ({c['captured']} of {n} dots): our tracker says {reply or 'nothing'}")
|
||||
@@ -642,6 +755,8 @@ class Checks:
|
||||
if self.check and self.check["kind"] == "fit":
|
||||
self.check["fit"].toggle_guide(time.monotonic())
|
||||
elif self.check:
|
||||
if not self.check["accept"]:
|
||||
self.check["accept_at"] = time.monotonic()
|
||||
self.check["accept"] = True
|
||||
return "ok"
|
||||
if cmd == "calquit":
|
||||
@@ -688,6 +803,13 @@ class Checks:
|
||||
return
|
||||
if c["done_at"]:
|
||||
return
|
||||
if c["accept"] and c["accept_at"] and now - c["accept_at"] > ACCEPT_WAIT:
|
||||
# Clicked, but no capture yet (see on_sample): say what it's waiting for.
|
||||
full = c["kind"] == "full"
|
||||
if now - c.get("gaze_at", 0.0) > 0.5:
|
||||
self.note("Waiting: the eye tracker isn't sending a gaze" if full else "Waiting: no gaze")
|
||||
else:
|
||||
self.note("Waiting for your gaze to hold still on the dot" if full else "Hold your look still")
|
||||
if c["kind"] == "quick" and now - c["started"] > QUICK_TIMEOUT:
|
||||
self.close("ignored")
|
||||
elif c["kind"] != "quick" and now - c["shown"] > CLICK_IDLE:
|
||||
@@ -703,18 +825,13 @@ class Checks:
|
||||
self.gaze_on = None # the helper isn't answering
|
||||
if self.check:
|
||||
self.to_helper("calpanel 1")
|
||||
if self.want_full_until:
|
||||
cal = self.calibrated()
|
||||
if cal is not None or now > self.want_full_until:
|
||||
self.want_full_until = 0.0
|
||||
if cal is False and self.gaze_on:
|
||||
self.start("full", "gaze mode came on without a calibration")
|
||||
if not self.eyes_seen(AWAY_MIN):
|
||||
self.away, self.back_since = True, None
|
||||
elif self.back_since is not None and not self.eyes_seen():
|
||||
self.back_since = None # gone again before DON_DELAY
|
||||
elif self.back_since is not None and now - self.back_since >= DON_DELAY:
|
||||
self.away, self.back_since = False, None
|
||||
self.full_armed = True # a calibration that closed unfinished opens again
|
||||
self.auto_quick("the headset went on")
|
||||
if svc.kind == "own" and now - svc.own_at < 5:
|
||||
reseat = any(e.get("reseat") for e in (svc.own.get("eyes") or {}).values())
|
||||
@@ -723,10 +840,12 @@ class Checks:
|
||||
elif not self.reseat_seen and self.can_run():
|
||||
self.reseat_seen = True
|
||||
self.auto_quick("our tracker asked for a click")
|
||||
self.need_full("gaze mode is on without a calibration")
|
||||
|
||||
def status(self):
|
||||
c = self.check
|
||||
st = {"check": None, "gaze_mode": self.gaze_on, "calibrated": self.calibrated(), "eyes": self.eyes_seen(),
|
||||
"problem": self.problem(),
|
||||
"panel": self.panel_proc is not None,
|
||||
"last_quick_s": round(time.monotonic() - self.last_quick) if self.last_quick else None}
|
||||
if c:
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
// look, capture (a ring filling to progress), done,
|
||||
// fail, off
|
||||
// title <text> / text <text> a line at the top / at the bottom (empty to clear)
|
||||
// note <text> a warning line just above the bottom one, in orange (red on
|
||||
// the bright round): why a dot wasn't taken (empty to clear)
|
||||
// eye <0|1> <r> <g> <b> <signal 0..1|-1> <seen 0..1|-1> <word>
|
||||
// fit: an eye's card (0 left): its state in that colour, the
|
||||
// tracker's signal, the share of the last 10 s it was seen
|
||||
@@ -143,7 +145,7 @@ struct Panel {
|
||||
double wDeg = kQuickDeg; // across
|
||||
std::vector<uint8_t> px;
|
||||
double bg = 0.05;
|
||||
std::string title, text;
|
||||
std::string title, text, note;
|
||||
bool dotOn = false;
|
||||
double dotYaw = 0, dotPitch = 0, progress = 0;
|
||||
std::string state = "off";
|
||||
@@ -268,6 +270,9 @@ void Draw(Panel &p) {
|
||||
const int titleSize = int(pxPerDeg * (p.full ? 1.5 : 1.1)), textSize = int(pxPerDeg * (p.full ? 1.2 : 0.9));
|
||||
Text(p, p.title, titleSize, p.w / 2, int(titleSize * 1.2), faint);
|
||||
Text(p, p.text, textSize, p.w / 2, p.h - int(textSize * 1.3), faint);
|
||||
// Under the full calibration's lowest dots (RING degrees down) and over the text.
|
||||
if (light) Text(p, p.note, textSize, p.w / 2, p.h - int(textSize * 2.8), 0.7, 0.12, 0.05);
|
||||
else Text(p, p.note, textSize, p.w / 2, p.h - int(textSize * 2.8), 1.0, 0.62, 0.3);
|
||||
if (p.fit) DrawFit(p, pxPerDeg, textSize, faint);
|
||||
if (!p.dotOn || p.state == "off") return;
|
||||
double x, y;
|
||||
@@ -468,7 +473,7 @@ int main(int argc, char **argv) {
|
||||
p.w = p.full ? kFullW : p.fit ? kFitW : kQuickPx;
|
||||
p.h = p.full ? kFullH : p.fit ? kFitH : kQuickPx;
|
||||
p.wDeg = p.full ? kFullDeg : p.fit ? kFitDeg : kQuickDeg;
|
||||
p.title.clear(), p.text.clear(), p.dotOn = false, p.state = "off";
|
||||
p.title.clear(), p.text.clear(), p.note.clear(), p.dotOn = false, p.state = "off";
|
||||
p.eyes[0] = p.eyes[1] = EyeCard{}, p.hints.clear();
|
||||
place();
|
||||
visible = dirty = true; // shown with its first picture
|
||||
@@ -496,6 +501,8 @@ int main(int argc, char **argv) {
|
||||
p.title = buf[5] == ' ' ? buf + 6 : "", dirty = true;
|
||||
} else if (!std::strncmp(buf, "text", 4)) {
|
||||
p.text = buf[4] == ' ' ? buf + 5 : "", dirty = true;
|
||||
} else if (!std::strncmp(buf, "note", 4)) {
|
||||
p.note = buf[4] == ' ' ? buf + 5 : "", dirty = true;
|
||||
} else if (std::sscanf(buf, "%15s", word) == 1 && !std::strcmp(word, "ping")) {
|
||||
reply = visible ? "ok shown" : "ok hidden";
|
||||
} else {
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
# Install (or remove) the frame grabber our own eye tracker needs: ft-eyegrab, as the system
|
||||
# service frametop-eyegrab.service. It copies the eye-camera frames, read-only, out of
|
||||
# SteamVR's eyetracking process into /dev/shm/frametop-eyes-cams for ft-eyes, and only while
|
||||
# ft-eyes wants them. The gaze service (gaze/ft-gazed) runs ft-eyes itself, when Eye tracker
|
||||
# is Own tracker or the gaze probe uses it.
|
||||
# ft-eyes wants them. The gaze service (gaze/ft-gazed) runs ft-eyes itself, when ours is the
|
||||
# tracker in use (GAZE_TRACKER=auto, the default, picks it once this is installed) or the gaze
|
||||
# probe uses it. install.sh offers this after gaze mode.
|
||||
# Needs host sudo, for the binary (/etc/frametop/ft-eyegrab, root's) and the unit: it asks for
|
||||
# the password in the terminal, on the Frame or from a PC, or runs SUDO_ASKPASS when that's set
|
||||
# (frame_sudo in scripts/_env.sh, which also takes it from the repo's .env).
|
||||
|
||||
+7
-2
@@ -14,6 +14,8 @@ Two programs, each a user service that stops when SteamVR does:
|
||||
|
||||
They don't start with SteamVR. `hands/run.sh install` builds them, gives ft-camd its capabilities, installs both services disabled, and links `hands/ft-handsctl` into `~/.local/bin`. Then `ft-handsctl on` starts hand tracking and `ft-handsctl off` stops it. `install.sh` doesn't install it.
|
||||
|
||||
For the cutouts alone, `hands/ft-cutouts on` starts the same two programs with ft-hands' `--no-gestures`: your hands show through the screens, and no pinch or grip is detected, so nothing clicks or drags. It runs this checkout's build as transient user units, so it needs `hands/build.sh` and ft-camd's capabilities (`hands/run.sh caps`) but not `hands/run.sh install`. It and `ft-handsctl on` stop each other's services, and it stops with SteamVR too.
|
||||
|
||||
```
|
||||
ft-handsctl on | off # on the Frame: start or stop hand tracking (SteamVR must be running)
|
||||
ft-handsctl status # the services, and ft-hands' last status lines
|
||||
@@ -21,6 +23,8 @@ ft-handsctl log [lines]
|
||||
ft-handsctl cutouts on|off|state # ft-screens' hand cutouts, without stopping tracking
|
||||
ft-handsctl gestures # pinches and grips, live (tools/watch_gestures.py --distance)
|
||||
|
||||
hands/ft-cutouts on | off | status # the cutouts only: no pinches or grips
|
||||
|
||||
hands/run.sh install # build, give ft-camd its capabilities (sudo, once per build), install disabled
|
||||
hands/run.sh start|stop # start or stop the services
|
||||
hands/run.sh restart # after changing a setting
|
||||
@@ -45,7 +49,7 @@ Files, all in `/run/user/UID/frametop-hands/` (private to the user; not `/run/us
|
||||
| --- | --- | --- | --- |
|
||||
| `cam-ring` | ft-camd | `camd/fhring.h` | ft-hands, `tools/ring.py` |
|
||||
| `hands` | ft-hands | `include/fh_hands.h` | ft-screens (`screens/handcut.cpp`) |
|
||||
| `gestures` | ft-hands | `include/fh_gestures.h` | the pointer helper (`pointer/helper/ft-pointer.cpp`), `tools/watch_gestures.py` |
|
||||
| `gestures` | ft-hands | `include/fh_gestures.h` | the pointer helper (`pointer/helper/pointer_holds.cpp`), `tools/watch_gestures.py` |
|
||||
|
||||
The source keeps the `fh_` names and magic strings of frame-hands, the project it started as, so recordings made with it still work.
|
||||
|
||||
@@ -104,6 +108,7 @@ Options:
|
||||
- `--models DIR`: where the models are.
|
||||
- `--nice N`: niceness. Default 5, so the VR stack wins contested CPUs.
|
||||
- `--no-publish`: don't write the hands and gestures files.
|
||||
- `--no-gestures`: hands for the cutouts only. No pinch or grip detection, so nothing reaches the pointer and a closing hand doesn't raise the rate to 30 Hz. The gestures file is removed at start. `ft-cutouts` runs it this way.
|
||||
- `--record DIR`, `--record-for S`: save every frame set for S seconds (default 120) to `DIR/sets.bin`. That's about 80 MB/s. Sending the tracker SIGUSR1 (`pkill -USR1 -x ft-hands`) starts a recording in `~/.local/share/frametop/hands/rec-<time>` without a restart. Recordings are images of your hands and room: they stay on the headset unless you move them.
|
||||
- `--record-only`: record without tracking or publishing, so it can run beside the live tracker. Give it `--record DIR`, since SIGUSR1 would reach both trackers. With `ft-camd --with-dark`, recordings also hold each camera's newest dark frame as `<name>_dk`, which doubles the rate. With `--with-color`, each colour camera's newest frame is saved with every set, as `color_video<N>`, which adds about 70 MB/s. Run the recorder at normal I/O priority: idle I/O priority stalled a 165 MB/s recording.
|
||||
- `--keep-presence P`: the landmark presence a tracked view needs to stay tracked. New views always need 0.5. Default 0.5. Lowering it to 0.2 barely helped in the bright recording, because lost hands drop to near-zero presence.
|
||||
@@ -151,7 +156,7 @@ ft-hands detects a pinch per hand (`track/pinch.h`) and publishes it to the gest
|
||||
|
||||
## Pinches and grips in the pointer
|
||||
|
||||
With `POINTER_HANDS=1`, the pointer helper (`pointer/helper/ft-pointer.cpp`) reads the gestures file every frame. It's off by default.
|
||||
With `POINTER_HANDS=1`, the pointer helper (`pointer/helper/pointer_holds.cpp`) reads the gestures file every frame. It's off by default.
|
||||
|
||||
- **Pinch to click.** In gaze mode a pinch works like the mouse's press: the pointer stops where the gaze put it, and the click comes when the pinch opens, where the pointer is then. A quick tap clicks where you looked. Held, the pinching hand moves the pointer to correct the gaze, and the correction is a lesson for the gaze tracker, as with the mouse.
|
||||
- **Without gaze mode,** a pinch is a real press, like the mouse's button: pressed when it closes, released when it opens, and while it's held the hand drags the pointer. A tap is still a click where the pointer is.
|
||||
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# ft-cutouts: the hand cutouts without pinches and grips. Your hands show through Frametop's
|
||||
# screens, but ft-hands runs with --no-gestures, so nothing clicks or drags. ft-handsctl on is
|
||||
# the full version; each stops the other. Like it, this doesn't start with SteamVR, and it
|
||||
# stops when SteamVR does. On the Frame.
|
||||
#
|
||||
# ft-cutouts on | off | status
|
||||
#
|
||||
# Runs this checkout's hands/build as transient user units, so it doesn't need hands/run.sh
|
||||
# install: only a build (hands/build.sh) and ft-camd's capabilities (hands/run.sh caps).
|
||||
set -euo pipefail
|
||||
here=$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)
|
||||
camd=frametop-cutouts-camd.service hands=frametop-cutouts-hands.service
|
||||
|
||||
die() { echo "$*" >&2; exit 1; }
|
||||
|
||||
run_unit() { # unit, the ft-handsctl unit it replaces, description, then the command
|
||||
local unit=$1 conflicts=$2 what=$3
|
||||
shift 3
|
||||
systemctl --user -q is-active "$unit" && return
|
||||
systemd-run --user --quiet --collect --unit="$unit" --description="Frametop hand cutouts: $what" \
|
||||
-p PartOf=steamvr.service -p After=steamvr.service -p Conflicts="$conflicts" \
|
||||
-p Restart=always -p RestartSec=5 -p TimeoutStopSec=5 "$@"
|
||||
}
|
||||
|
||||
case ${1:-status} in
|
||||
on)
|
||||
systemctl --user -q is-active steamvr.service || die "SteamVR isn't running"
|
||||
[ -x "$here/build/ft-camd" ] && [ -x "$here/build/ft-hands" ] || die "not built: hands/build.sh"
|
||||
grep -qa -- --no-gestures "$here/build/ft-hands" || die "ft-hands predates --no-gestures: hands/build.sh"
|
||||
getcap "$here/build/ft-camd" | grep -q cap_sys_ptrace ||
|
||||
die "ft-camd needs its capabilities: hands/run.sh caps (asks for sudo)"
|
||||
if systemctl --user -q is-active frametop-camd.service frametop-hands.service; then
|
||||
echo "stopping ft-handsctl's hand tracking (it has pinches and grips)"
|
||||
fi
|
||||
run_unit $camd frametop-camd.service "the camera broker" "$here/build/ft-camd" --status 60
|
||||
if ! systemctl --user -q is-active $hands; then
|
||||
"$here/../scripts/container-up.sh" # so stopping the unit can't take the container down
|
||||
pkill -x ft-hands || true
|
||||
fi
|
||||
run_unit $hands frametop-hands.service "hand tracking, no gestures" "$HOME/.local/bin/distrobox" enter dev -- \
|
||||
"$here/build/ft-hands" --status 60 --no-gestures
|
||||
"$here/ft-handsctl" cutouts on >/dev/null || true
|
||||
sleep 4
|
||||
"$0" status ;;
|
||||
off)
|
||||
systemctl --user stop $hands $camd 2>/dev/null || true
|
||||
systemctl --user -q is-active frametop-hands.service || pkill -x ft-hands || true
|
||||
echo "hand cutouts off" ;;
|
||||
status)
|
||||
for u in $camd $hands; do echo "$u: $(systemctl --user is-active $u || true)"; done
|
||||
echo "ft-screens cutouts: $("$here/ft-handsctl" cutouts state 2>&1)"
|
||||
inv=$(systemctl --user show -p InvocationID --value $hands) # this run's lines only
|
||||
[ -n "$inv" ] && journalctl --user _SYSTEMD_INVOCATION_ID="$inv" --no-pager -o cat |
|
||||
grep -E '^ *[0-9.]+s |sets with a hand|cameras:' | tail -2 | cut -c1-160 || true ;;
|
||||
*) sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;;
|
||||
esac
|
||||
+32
-20
@@ -3,9 +3,12 @@
|
||||
// Python prototype: the same scheduling, with the models on a few threads.
|
||||
//
|
||||
// ft-hands [--seconds N] [--threads N] [--int8] [--status S] [--models DIR] [--nice N]
|
||||
// [--no-publish] [--record DIR] [--swap-sides] [--cams auto|mono|color|all] ...
|
||||
// [--no-publish] [--no-gestures] [--record DIR] [--swap-sides] [--cams auto|mono|color|all] ...
|
||||
// (--help lists them all)
|
||||
//
|
||||
// --no-gestures: hands for the cutouts only. No pinch or grip detection, so nothing reaches
|
||||
// the pointer and a closing hand doesn't raise the rate; the gestures file is removed.
|
||||
//
|
||||
// Which cameras (--cams, HANDS_CAMERAS): the four mono IR cameras light the hands with their
|
||||
// own IR and track well in dim rooms, but in bright light (a sunny room, a window behind the
|
||||
// hands) they expose for the room and the hands come out dark. The two Arcturus colour
|
||||
@@ -150,7 +153,7 @@ struct Lighting {
|
||||
int main(int argc, char **argv) {
|
||||
double seconds = 0, status = 5;
|
||||
int threads = 3, niceness = 5;
|
||||
bool int8 = false, publish = true, track = true, swap_sides = false;
|
||||
bool int8 = false, publish = true, track = true, swap_sides = false, gestures_on = true;
|
||||
std::string models = std::string(argv[0]).substr(0, std::string(argv[0]).rfind('/') + 1) + "../models/ncnn";
|
||||
std::string record, ring_path = "/run/user/" + std::to_string(getuid()) + "/" FH_RING_NAME;
|
||||
// SteamOS starts user processes on CPUs 0-4 and keeps 5-7 (two A720s and the X4) for
|
||||
@@ -189,6 +192,7 @@ int main(int argc, char **argv) {
|
||||
else if (a == "--nice" && more) niceness = std::atoi(argv[++i]);
|
||||
else if (a == "--int8") int8 = true;
|
||||
else if (a == "--no-publish") publish = false;
|
||||
else if (a == "--no-gestures") gestures_on = false;
|
||||
else if (a == "--pinch-begin" && more) pinch_params.begin_m = std::atof(argv[++i]);
|
||||
else if (a == "--pinch-end" && more) pinch_params.end_m = std::atof(argv[++i]);
|
||||
else if (a == "--pinch-triangulated") pinch_params.triangulated = true;
|
||||
@@ -217,6 +221,7 @@ int main(int argc, char **argv) {
|
||||
}
|
||||
else {
|
||||
std::printf("usage: %s [--seconds N] [--threads N] [--int8] [--status S] [--models DIR] [--nice N] [--no-publish]\n"
|
||||
" [--no-gestures] (hands for the cutouts only: no pinches or grips)\n"
|
||||
" [--record DIR] [--record-for S] [--record-only] [--cpus 5,6,7] [--swap-sides]\n"
|
||||
" [--keep-presence P] (0.5) [--ring PATH] (ft-camd's, or ft-ringplay's)\n"
|
||||
" [--cams auto|mono|color|all] (auto) [--bright all|color] (all) [--bright-on L] (40) [--bright-off L] (25)\n"
|
||||
@@ -265,12 +270,14 @@ int main(int argc, char **argv) {
|
||||
return true;
|
||||
};
|
||||
if (!load_calibration(calib, err) || !ring.open(ring_path.c_str(), err) || !nets.load(models, int8, err) ||
|
||||
(publish && (!pub.open(err) || !gestures.open(pinch, grip, err))) ||
|
||||
(publish && (!pub.open(err) || (gestures_on && !gestures.open(pinch, grip, err)))) ||
|
||||
(!record.empty() && !start_recording(record, err))) {
|
||||
std::fprintf(stderr, "%s\n", err.c_str());
|
||||
return 1;
|
||||
}
|
||||
if (!ring.alive()) return std::fprintf(stderr, "ft-camd isn't running (no heartbeat)\n"), 1;
|
||||
// so a reader can't take an earlier run's file for this one's
|
||||
if (publish && !gestures_on) unlink((run_dir() + "/gestures").c_str());
|
||||
|
||||
std::map<std::string, int> index; // mono calibration name -> ring camera
|
||||
std::map<std::string, int> color; // colour calibration name (color_video<N>) -> ring camera
|
||||
@@ -541,9 +548,11 @@ int main(int argc, char **argv) {
|
||||
const auto hands = tracker.step(images, int64_t(tmin));
|
||||
const uint64_t capture = uint64_t(int64_t(tmin) - raw_off); // CLOCK_MONOTONIC
|
||||
const std::vector<Seen> views = tracker.views_now();
|
||||
grip.update(hands, views, int64_t(capture));
|
||||
pinch.update(hands, views, int64_t(capture), grip.gripping());
|
||||
if (gesture_log && capture - t_glog >= 100'000'000) {
|
||||
if (gestures_on) {
|
||||
grip.update(hands, views, int64_t(capture));
|
||||
pinch.update(hands, views, int64_t(capture), grip.gripping());
|
||||
}
|
||||
if (gestures_on && gesture_log && capture - t_glog >= 100'000'000) {
|
||||
t_glog = capture;
|
||||
for (int k = 0; k < 2; ++k)
|
||||
if (pinch.world_d[k] >= 0)
|
||||
@@ -554,7 +563,8 @@ int main(int argc, char **argv) {
|
||||
// a gesture down or closing gets the full rate, even while the palm holds still
|
||||
next_ns = tmin + uint64_t((std::min(tracker.interval(), pinch.engaged() || grip.engaged() ? 1 / 30.0 : 1.0) -
|
||||
0.005) * 1e9);
|
||||
if (publish) pub.write(hands, capture), gestures.write(pinch, grip, capture);
|
||||
if (publish) pub.write(hands, capture);
|
||||
if (publish && gestures_on) gestures.write(pinch, grip, capture);
|
||||
for (const Pinch::Event &e : grip.events)
|
||||
std::printf("grip %s %-5s curl %.2f at %+.3f %+.3f %+.3f\n", e.side ? "right" : "left ", e.what, e.distance,
|
||||
e.point[0], e.point[1], e.point[2]);
|
||||
@@ -593,17 +603,19 @@ int main(int argc, char **argv) {
|
||||
s.handoff_miss, s.dups, s.splits, s.created, s.merged, s.forgotten,
|
||||
!rec ? "" : (" recorded " + std::to_string(rec->written()) + " dropped " +
|
||||
std::to_string(rec->dropped())).c_str());
|
||||
std::printf(" pinches: left %u right %u (held back, palm down: %d %d) grips: left %u right %u",
|
||||
pinch.side(0).begins, pinch.side(1).begins, pinch.held_back[0], pinch.held_back[1],
|
||||
grip.side(0).begins, grip.side(1).begins);
|
||||
for (int k = 0; k < 2; ++k)
|
||||
if (pinch.side(k).flags & FH_PINCH_TRACKED)
|
||||
std::printf(" %s %s d %.3f curl %.2f", k ? "right" : "left",
|
||||
grip.side(k).flags & FH_PINCH_DOWN ? "GRIP"
|
||||
: pinch.side(k).flags & FH_PINCH_DOWN ? "PINCH"
|
||||
: "open",
|
||||
pinch.side(k).distance, grip.curl[k]);
|
||||
std::printf("\n");
|
||||
if (gestures_on) {
|
||||
std::printf(" pinches: left %u right %u (held back, palm down: %d %d) grips: left %u right %u",
|
||||
pinch.side(0).begins, pinch.side(1).begins, pinch.held_back[0], pinch.held_back[1],
|
||||
grip.side(0).begins, grip.side(1).begins);
|
||||
for (int k = 0; k < 2; ++k)
|
||||
if (pinch.side(k).flags & FH_PINCH_TRACKED)
|
||||
std::printf(" %s %s d %.3f curl %.2f", k ? "right" : "left",
|
||||
grip.side(k).flags & FH_PINCH_DOWN ? "GRIP"
|
||||
: pinch.side(k).flags & FH_PINCH_DOWN ? "PINCH"
|
||||
: "open",
|
||||
pinch.side(k).distance, grip.curl[k]);
|
||||
std::printf("\n");
|
||||
}
|
||||
for (const Hand *h : hands)
|
||||
std::printf(" hand %d %-5s views %d wrist %+.3f %+.3f %+.3f m scale %.2f speed %.2f m/s\n", h->id,
|
||||
h->right() ? "right" : "left", h->nviews, h->pts[0][0], h->pts[0][1], h->pts[0][2], h->scale,
|
||||
@@ -616,8 +628,8 @@ int main(int argc, char **argv) {
|
||||
}
|
||||
}
|
||||
if (!color.empty()) unlink(want_file.c_str());
|
||||
if (publish) {
|
||||
pub.write({}, mono_ns());
|
||||
if (publish) pub.write({}, mono_ns());
|
||||
if (publish && gestures_on) {
|
||||
pinch.release(int64_t(mono_ns())); // a drag in progress ends, as lost
|
||||
grip.release(int64_t(mono_ns()));
|
||||
gestures.write(pinch, grip, mono_ns());
|
||||
|
||||
@@ -9,8 +9,9 @@ to the input relay over its control socket (@frametop_relay):
|
||||
- Controllers: the same for the Frame controllers' buttons, minus the gaze actions (gaze
|
||||
mode is a mouse feature). They're read by the pointer helper through SteamVR input
|
||||
(@ft_pointer_helper: vrstatus, vrglobal), and a mapped button is taken from games.
|
||||
- Keyboard: when Frametop's keyboard opens, and key combinations for any action (Meta+Shift+F
|
||||
floats a window unless the rules have their own list).
|
||||
- Keyboard: when Frametop's keyboard opens, and key combinations for any action, a command
|
||||
of your own too ("command:CMD"), or a modifier tapped alone (a Meta tap opens Steam's menu
|
||||
and Meta+Shift+F floats a window, unless the rules have their own list).
|
||||
- Pointer: speed, dot size, distance and the rest, applied live.
|
||||
- Ignored panels: SteamVR overlays the pointer passes through (POINTER_IGNORE), by app or
|
||||
one by one. The helper lists them (@ft_pointer_helper "overlays").
|
||||
@@ -60,6 +61,7 @@ DEFAULT_BUTTONS = {0x110: "left", 0x111: "right", 0x112: "middle", 0x113: "back"
|
||||
ACTION_LABELS = {
|
||||
"left": "Left click", "right": "Right click", "middle": "Middle click", "back": "Back",
|
||||
"scroll_up": "Scroll up", "scroll_down": "Scroll down", "dashboard": "Toggle SteamVR dashboard",
|
||||
"steam_menu": "Open Steam menu / close dashboard",
|
||||
"recenter": "Recenter pointer", "pointer_toggle": "Pointer on/off",
|
||||
"follow_toggle": "Head follow on/off (experimental)", "gaze_toggle": "Gaze pointer on/off (experimental)",
|
||||
"gaze_precision": "Gaze precision: hold to steer, release to click",
|
||||
@@ -102,6 +104,8 @@ KEYBOARD_ONLY = ("gaze_left", "gaze_right")
|
||||
# Profiles (docs/profiles.md): "profile:NAME" opens one (the relay runs ft-layout use NAME).
|
||||
LAYOUT_PATH = os.path.expanduser("~/.config/frametop-layout.json")
|
||||
PROFILE = "profile:"
|
||||
# "command:CMD": the relay runs CMD with sh -c (key combinations only, here).
|
||||
COMMAND = "command:"
|
||||
|
||||
|
||||
def profile_actions():
|
||||
@@ -113,6 +117,8 @@ def profile_actions():
|
||||
def action_label(a):
|
||||
if a.startswith(PROFILE):
|
||||
return f"Open profile {a[len(PROFILE):]}"
|
||||
if a.startswith(COMMAND):
|
||||
return f"Run: {a[len(COMMAND):]}"
|
||||
return ACTION_LABELS.get(a, a)
|
||||
|
||||
|
||||
@@ -120,14 +126,18 @@ def is_profile(a):
|
||||
return a.startswith(PROFILE) and len(a) > len(PROFILE)
|
||||
|
||||
|
||||
def is_command(a):
|
||||
return a.startswith(COMMAND) and bool(a[len(COMMAND):].strip())
|
||||
|
||||
|
||||
def mappable(a):
|
||||
"""An action a controller button can have."""
|
||||
return a in CONTROLLER_ACTIONS or is_profile(a)
|
||||
|
||||
|
||||
def shortcut_mappable(a):
|
||||
"""An action a key combination can have: the gaze ones too."""
|
||||
return a in SHORTCUT_ACTIONS or is_profile(a)
|
||||
"""An action a key combination can have: the gaze ones and commands too."""
|
||||
return a in SHORTCUT_ACTIONS or is_profile(a) or is_command(a)
|
||||
# Gaze mode settings (pointer helper), like POINTER_SETTINGS.
|
||||
GAZE_SETTINGS = [
|
||||
("POINTER_GAZE_RETAKE", "Look away to hand back", 5, 1, 45, 0.5, "°"),
|
||||
@@ -141,9 +151,10 @@ GAZE_MOUSE = {"precision": "Gaze precision: hold to steer with the mouse, releas
|
||||
# Key combinations ("key_bindings" in the rules): modifiers, either side folded into the left code.
|
||||
MODIFIER_CODES = {29: 29, 97: 29, 42: 42, 54: 42, 56: 56, 100: 56, 125: 125, 126: 125}
|
||||
MODIFIER_NAMES = {29: "Ctrl", 42: "Shift", 56: "Alt", 125: "Meta"}
|
||||
# What a rules file without "key_bindings" gets (the relay's DEFAULT_KEY_BINDINGS): Meta+J and
|
||||
# Meta+K click at the gaze, Meta+Shift+F floats a window.
|
||||
DEFAULT_KEY_BINDINGS = {"125+36": "gaze_left", "125+37": "gaze_right", "42+125+33": "float_toggle"}
|
||||
# What a rules file without "key_bindings" gets (the relay's DEFAULT_KEY_BINDINGS): a Meta tap
|
||||
# opens Steam's menu, Meta+J and Meta+K click at the gaze, Meta+Shift+F floats a window.
|
||||
DEFAULT_KEY_BINDINGS = {"125": "steam_menu", "125+36": "gaze_left", "125+37": "gaze_right",
|
||||
"42+125+33": "float_toggle"}
|
||||
|
||||
|
||||
def key_bindings(rules):
|
||||
@@ -151,8 +162,12 @@ def key_bindings(rules):
|
||||
counts as its own)."""
|
||||
bound = rules.get("key_bindings")
|
||||
return dict(bound) if isinstance(bound, dict) else dict(DEFAULT_KEY_BINDINGS)
|
||||
# The gaze service's settings (gaze/ft-gazed): whose eye tracking, and the eye bias.
|
||||
# The gaze service's settings (gaze/ft-gazed): whose eye tracking, and the eye bias. GAZE_TRACKER
|
||||
# can also be auto (the default): ours when it's installed, else SteamVR's.
|
||||
GAZE_TRACKERS = {"steam": "SteamVR's eye tracker", "own": "our own eye tracker"}
|
||||
# Our tracker's frame grabber (gaze/tracker/install.sh), on the host: this runs in the dev
|
||||
# container, which has the host's /etc under /run/host.
|
||||
EYEGRAB = ("/etc/frametop/ft-eyegrab", "/run/host/etc/frametop/ft-eyegrab")
|
||||
GAZE_EYES = {"auto": "auto", "left": "left eye", "right": "right eye"}
|
||||
# Pointer settings: key, label, default, min, max, step, unit.
|
||||
POINTER_SETTINGS = [
|
||||
@@ -295,6 +310,7 @@ class Backend(QObject):
|
||||
self._capture_vr = False
|
||||
self._capture_combo = "" # the action a key combination is being captured for
|
||||
self._combo_mods = set()
|
||||
self._combo_tap = None # a modifier pressed alone, nothing since: released, it's a tap
|
||||
self._vr = {} # the helper's vrstatus, {} when it doesn't answer
|
||||
self._vr_at = 0.0
|
||||
self._gaze = {} # ft-gazed's status, {} when it isn't running
|
||||
@@ -408,13 +424,16 @@ class Backend(QObject):
|
||||
self.activity.emit(msg["id"])
|
||||
code, value = int(msg["code"]), msg["value"]
|
||||
if code in MODIFIER_CODES:
|
||||
(self._combo_mods.add if value else self._combo_mods.discard)(MODIFIER_CODES[code])
|
||||
mod = MODIFIER_CODES[code]
|
||||
if value == 1:
|
||||
self._combo_tap = None if self._combo_mods else mod
|
||||
(self._combo_mods.add if value else self._combo_mods.discard)(mod)
|
||||
if value == 0 and self._combo_tap == mod:
|
||||
self._end_shortcut_capture(str(mod)) # the relay's modifier tap
|
||||
elif value == 1 and code < BTN_MISC:
|
||||
self._save_shortcut("+".join(str(c) for c in sorted(self._combo_mods) + [code]),
|
||||
self._capture_combo)
|
||||
self._capture_combo = ""
|
||||
self._combo_mods = set()
|
||||
self.shortcutCaptureChanged.emit()
|
||||
self._end_shortcut_capture("+".join(str(c) for c in sorted(self._combo_mods) + [code]))
|
||||
elif value == 1:
|
||||
self._combo_tap = None # a mouse button: no tap
|
||||
elif t == "event":
|
||||
self.activity.emit(msg["id"])
|
||||
if (self._capture_id and msg["id"] == self._capture_id and msg["type"] == "key"
|
||||
@@ -890,6 +909,8 @@ class Backend(QObject):
|
||||
# --- key combinations ("key_bindings") ---
|
||||
def comboName(self, combo):
|
||||
parts = [int(c) for c in combo.split("+") if c.isdigit()]
|
||||
if len(parts) == 1 and parts[0] in MODIFIER_NAMES:
|
||||
return MODIFIER_NAMES[parts[0]] + " tap"
|
||||
return "+".join(MODIFIER_NAMES.get(c) or self.codeName(c).removeprefix("KEY_").title() for c in parts)
|
||||
|
||||
@Property("QVariantList", notify=mappingsChanged)
|
||||
@@ -900,7 +921,8 @@ class Backend(QObject):
|
||||
|
||||
@Property("QVariantList", constant=True)
|
||||
def shortcutActions(self):
|
||||
return [{"value": a, "text": action_label(a)} for a in SHORTCUT_ACTIONS + profile_actions()]
|
||||
return [{"value": a, "text": action_label(a)} for a in SHORTCUT_ACTIONS + profile_actions()] + \
|
||||
[{"value": COMMAND, "text": "Run a command…"}]
|
||||
|
||||
@Property(bool, notify=shortcutCaptureChanged)
|
||||
def capturingShortcut(self):
|
||||
@@ -911,6 +933,7 @@ class Backend(QObject):
|
||||
if shortcut_mappable(action):
|
||||
self._capture_combo = action
|
||||
self._combo_mods = set()
|
||||
self._combo_tap = None
|
||||
self._send("watch 60")
|
||||
self.shortcutCaptureChanged.emit()
|
||||
|
||||
@@ -919,6 +942,13 @@ class Backend(QObject):
|
||||
self._capture_combo = ""
|
||||
self.shortcutCaptureChanged.emit()
|
||||
|
||||
def _end_shortcut_capture(self, combo):
|
||||
self._save_shortcut(combo, self._capture_combo)
|
||||
self._capture_combo = ""
|
||||
self._combo_mods = set()
|
||||
self._combo_tap = None
|
||||
self.shortcutCaptureChanged.emit()
|
||||
|
||||
def _save_shortcut(self, combo, action):
|
||||
rules = read_json(RULES_PATH)
|
||||
rules["key_bindings"] = dict(key_bindings(rules), **{combo: action})
|
||||
@@ -935,9 +965,25 @@ class Backend(QObject):
|
||||
|
||||
@Property(str, notify=gazeChanged)
|
||||
def gazeTracker(self):
|
||||
"""Whose eye tracking the gaze service uses: "steam" or "own" (GAZE_TRACKER)."""
|
||||
v = read_conf().get("GAZE_TRACKER", "steam")
|
||||
return v if v in GAZE_TRACKERS else "steam"
|
||||
"""Whose eye tracking the gaze service uses: "steam" or "own" (GAZE_TRACKER; auto is
|
||||
ours when it's installed)."""
|
||||
v = read_conf().get("GAZE_TRACKER", "auto")
|
||||
if v in GAZE_TRACKERS:
|
||||
return v
|
||||
return "own" if self.gazeOwnInstalled else "steam"
|
||||
|
||||
@Property(bool, notify=gazeChanged)
|
||||
def gazeTrackerAuto(self):
|
||||
"""GAZE_TRACKER is auto (or missing): ours once it's installed, without a choice here."""
|
||||
return read_conf().get("GAZE_TRACKER", "auto") not in GAZE_TRACKERS
|
||||
|
||||
@Property(bool, notify=gazeChanged)
|
||||
def gazeOwnInstalled(self):
|
||||
"""Is our own tracker installed? The gaze service says (it also checks ft-eyes' Python in
|
||||
its checkout); without it, whether the frame grabber is."""
|
||||
if "own_installed" in self._gaze:
|
||||
return bool(self._gaze["own_installed"])
|
||||
return any(os.path.exists(p) for p in EYEGRAB)
|
||||
|
||||
@Property(str, notify=gazeChanged)
|
||||
def gazeEye(self):
|
||||
|
||||
+61
-11
@@ -192,7 +192,7 @@ Kirigami.ApplicationWindow {
|
||||
wrapMode: Text.Wrap
|
||||
opacity: 0.7
|
||||
text: "Move or press a device to see which row it is. 3D pointer: grabbed, drives the SteamVR pointer. "
|
||||
+ "Pass through: left alone (a Meta tap toggles the dashboard if META_DASHBOARD=1). Ignore: left alone."
|
||||
+ "Pass through: left alone, but its key combinations (Keyboard page) work. Ignore: left alone."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -545,7 +545,11 @@ Kirigami.ApplicationWindow {
|
||||
delegate: RowLayout {
|
||||
required property var modelData
|
||||
Kirigami.FormData.label: modelData.label + ":"
|
||||
Controls.Label { text: modelData.actionLabel }
|
||||
Controls.Label {
|
||||
text: modelData.actionLabel
|
||||
elide: Text.ElideRight
|
||||
Layout.maximumWidth: Kirigami.Units.gridUnit * 24
|
||||
}
|
||||
Controls.ToolButton {
|
||||
icon.name: "edit-delete"
|
||||
display: Controls.AbstractButton.IconOnly
|
||||
@@ -568,16 +572,33 @@ Kirigami.ApplicationWindow {
|
||||
}
|
||||
Controls.Button {
|
||||
text: backend.capturingShortcut ? "Press the keys… (Cancel)" : "Set keys…"
|
||||
enabled: backend.capturingShortcut || shortcutAction.currentValue !== "command:"
|
||||
|| shortcutCommand.text.trim() !== ""
|
||||
onClicked: backend.capturingShortcut ? backend.cancelShortcutCapture()
|
||||
: backend.startShortcutCapture(shortcutAction.currentValue)
|
||||
: backend.startShortcutCapture(
|
||||
shortcutAction.currentValue === "command:"
|
||||
? "command:" + shortcutCommand.text.trim()
|
||||
: shortcutAction.currentValue)
|
||||
}
|
||||
}
|
||||
Controls.TextField {
|
||||
id: shortcutCommand
|
||||
Kirigami.FormData.label: "Command:"
|
||||
visible: shortcutAction.currentValue === "command:"
|
||||
placeholderText: "e.g. ft-layout use Work"
|
||||
Layout.preferredWidth: Kirigami.Units.gridUnit * 24
|
||||
}
|
||||
Controls.Label {
|
||||
Layout.maximumWidth: Kirigami.Units.gridUnit * 30
|
||||
wrapMode: Text.WordWrap
|
||||
text: "Hold the modifiers (Ctrl, Alt, Shift, Meta), then press the key, on any keyboard. The "
|
||||
+ "combination's last key isn't typed; the modifiers still reach the app. Meta+Shift+F floats "
|
||||
+ "the desktop window under the pointer in VR, or puts it back, until you remove or change it."
|
||||
text: "Hold the modifiers (Ctrl, Alt, Shift, Meta), then press the key, on any keyboard. Or tap "
|
||||
+ "one modifier on its own. The combination's last key isn't typed; the modifiers still reach "
|
||||
+ "the app. While you're in Steam or a game, they see the keys too. Until you remove or change "
|
||||
+ "them, a Meta tap opens the Steam menu (or closes the dashboard) instead of the desktop's "
|
||||
+ "launcher, and Meta+Shift+F floats the desktop window under the pointer in VR, or puts it "
|
||||
+ "back. A command runs with sh as the input relay's service, outside the desktop's session, "
|
||||
+ "with Frametop's ft-layout, ft-float and ft-steam on its path; its output goes to the "
|
||||
+ "relay's log (journalctl --user -u frametop-input-relay)."
|
||||
opacity: 0.7
|
||||
font: Kirigami.Theme.smallFont
|
||||
}
|
||||
@@ -881,6 +902,23 @@ Kirigami.ApplicationWindow {
|
||||
opacity: 0.7
|
||||
font: Kirigami.Theme.smallFont
|
||||
}
|
||||
Controls.Label {
|
||||
// Why the gaze pointer, on, can't follow your eyes yet, so it isn't left looking
|
||||
// like a plain mouse. The calibration part is the gaze service's (gaze/gazecheck.py).
|
||||
readonly property var checks: gpage.status.checks || {}
|
||||
readonly property string why: !backend.gazeServiceInstalled
|
||||
? "The gaze service isn't installed: run gaze/run.sh install in the Frametop folder, in a terminal"
|
||||
: !backend.gazeServiceRunning ? "The gaze service isn't running (it starts with SteamVR)"
|
||||
: backend.gazeTracker === "own" && !gpage.status.eyegrab
|
||||
? "Our eye tracker needs its frame grabber: run gaze/tracker/install.sh (asks for sudo)"
|
||||
: checks.problem || ""
|
||||
visible: backend.gazeMode > 0 && why !== ""
|
||||
text: why
|
||||
Layout.maximumWidth: Kirigami.Units.gridUnit * 30
|
||||
wrapMode: Text.WordWrap
|
||||
color: checks.check ? Kirigami.Theme.neutralTextColor : Kirigami.Theme.negativeTextColor
|
||||
font: Kirigami.Theme.smallFont
|
||||
}
|
||||
ColumnLayout {
|
||||
Kirigami.FormData.label: "Mouse left button:"
|
||||
Repeater {
|
||||
@@ -928,16 +966,28 @@ Kirigami.ApplicationWindow {
|
||||
}
|
||||
RowLayout {
|
||||
Kirigami.FormData.label: "Eye tracker:"
|
||||
Controls.RadioButton {
|
||||
text: "Own tracker (recommended)"
|
||||
checked: backend.gazeTracker === "own"
|
||||
onToggled: if (checked) backend.setGazeTracker("own")
|
||||
}
|
||||
Controls.RadioButton {
|
||||
text: "SteamVR"
|
||||
checked: backend.gazeTracker === "steam"
|
||||
onToggled: if (checked) backend.setGazeTracker("steam")
|
||||
}
|
||||
Controls.RadioButton {
|
||||
text: "Own tracker"
|
||||
checked: backend.gazeTracker === "own"
|
||||
onToggled: if (checked) backend.setGazeTracker("own")
|
||||
}
|
||||
}
|
||||
Controls.Label {
|
||||
// Ours is the default once it's installed (GAZE_TRACKER=auto, gaze/ft-gazed).
|
||||
visible: backend.gazeTracker === "steam" && !backend.gazeOwnInstalled
|
||||
text: "Our own tracker is more accurate. Install it with gaze/tracker/install.sh in the "
|
||||
+ "Frametop folder, in a terminal (asks for sudo)"
|
||||
+ (backend.gazeTrackerAuto ? "; gaze then uses it, and you calibrate it once."
|
||||
: ", then pick Own tracker here and calibrate it once.")
|
||||
Layout.maximumWidth: Kirigami.Units.gridUnit * 30
|
||||
wrapMode: Text.WordWrap
|
||||
opacity: 0.7
|
||||
font: Kirigami.Theme.smallFont
|
||||
}
|
||||
Controls.Label {
|
||||
// The gaze service runs ours (gaze/tracker/ft-eyes); it needs the frame grabber
|
||||
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Controller desktop click stability over local IPC; no SteamVR client."""
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import socket
|
||||
|
||||
def request(command):
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as client:
|
||||
client.bind(''); client.settimeout(2)
|
||||
client.sendto(command.encode(), '\0ft_screens')
|
||||
response=client.recv(8192).decode()
|
||||
if response.startswith('error'): raise RuntimeError(response)
|
||||
return response
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
sub=parser.add_subparsers(dest='command', required=True)
|
||||
sub.add_parser('status')
|
||||
sub.add_parser('threshold').add_argument('pixels', type=float)
|
||||
args=parser.parse_args()
|
||||
try:
|
||||
if args.command == 'threshold':
|
||||
if not math.isfinite(args.pixels) or not 0 <= args.pixels <= 64:
|
||||
raise ValueError('Threshold must be 0–64 logical pixels; 0 disables stabilization')
|
||||
if request(f'controller-click {args.pixels:g}') != 'ok': raise RuntimeError('Threshold rejected')
|
||||
print(json.dumps(json.loads(request('controller-click?')), indent=2))
|
||||
except (OSError, ValueError, RuntimeError) as error: parser.exit(1, str(error)+'\n')
|
||||
if __name__ == '__main__': main()
|
||||
+94
-47
@@ -14,8 +14,7 @@ keyboard node for its extra buttons). Roles, from ~/.config/frametop-input.json
|
||||
(written by the Frametop Input Settings app):
|
||||
pointer grabbed; drives the universal 3D mouse (default for devices with a mouse node)
|
||||
passthrough keys go to the desktop; grabbed only while typing goes there, otherwise only observed,
|
||||
e.g. for the Meta dashboard shortcut (default for keyboards; the shortcut is off
|
||||
unless META_DASHBOARD=1 is in ~/.config/frametop.conf)
|
||||
for the key combinations below (default for keyboards)
|
||||
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,
|
||||
@@ -32,19 +31,26 @@ layout_reset = put the desktop screens back in their saved layout, screens_toggl
|
||||
keyboard_toggle = open or close Frametop's keyboard, float_toggle = float the desktop window under the
|
||||
pointer (else the active one) in VR, or put it back if it floats, dock_all = put every floating
|
||||
window back (both to ft-floatd, @frametop_float), profile:NAME = switch to that profile (ft-layout
|
||||
use NAME: its screens and apps; docs/profiles.md), key = pass through as a key, none).
|
||||
use NAME: its screens and apps; docs/profiles.md), steam_menu = open the SteamVR dashboard on
|
||||
Steam's menu, or close the dashboard (steam/ft-steam menu, through Steam's UI), command:CMD = run
|
||||
CMD with sh -c (on the host, as this service: its environment, output to its log, and
|
||||
COMMAND_PATH, so ft-layout, ft-float and ft-steam need no path), key = pass through as a key, none).
|
||||
|
||||
Frame controller buttons can be mapped too ("controller_buttons": {"right/a": action} in the
|
||||
rules file; any action but key and the gaze ones, GAZE_ACTIONS: gaze mode is a mouse feature,
|
||||
docs/gaze-controllers.md). So can key combinations on any keyboard ("key_bindings":
|
||||
{"29+56+34": action}, evdev codes joined by "+", modifiers first and left-hand codes for
|
||||
either side, here Ctrl+Alt+G): the combination does the action, and its last key isn't typed.
|
||||
A rules file without "key_bindings" gets DEFAULT_KEY_BINDINGS (Meta+J: gaze_left, Meta+K:
|
||||
gaze_right, Meta+Shift+F: float_toggle); one with its own, even an empty one, doesn't. The float
|
||||
actions work without pointer mode too. A combination with Meta also sends the desktop an F24 press
|
||||
and Meta's release right away: so letting go of Meta doesn't open Plasma's launcher, and a gaze
|
||||
click isn't Meta+click (KWin's window move and resize). Another key while Meta is still held gives
|
||||
the desktop Meta back. The controllers aren't input devices here, only SteamVR sees
|
||||
A modifier on its own ("125": Meta) is a tap: pressed and released with no other key, mouse
|
||||
button, or scroll in between; the desktop gets an F24 press before its release, so Plasma's
|
||||
launcher doesn't open on a Meta tap that's bound. A rules file without "key_bindings" gets
|
||||
DEFAULT_KEY_BINDINGS (Meta tap: steam_menu, Meta+J: gaze_left, Meta+K: gaze_right, Meta+Shift+F:
|
||||
float_toggle); one with its own, even an empty one, doesn't. The float, profile, Steam menu and
|
||||
command actions work without pointer mode too. A combination with Meta also sends the desktop an
|
||||
F24 press and Meta's release right away: so letting go of Meta doesn't open Plasma's launcher, and
|
||||
a gaze click isn't Meta+click (KWin's window move and resize). Another key while Meta is still
|
||||
held gives the desktop Meta back. While typing goes to Steam, keyboards aren't grabbed, so Steam
|
||||
or the game sees a combination's keys too. The controllers aren't input devices here, only SteamVR sees
|
||||
them, so the pointer helper reads them with SteamVR input and sends "vrbtn <button> 1|0".
|
||||
It only takes the buttons the relay tells it to ("vrbind <button>..." to @ft_pointer_helper,
|
||||
sent on start, reload, and when the helper says "vrhello"), and only while no game runs,
|
||||
@@ -130,6 +136,7 @@ SYN_REPORT = 0
|
||||
BTN_MISC, KEY_MAX = 0x100, 0x2FF
|
||||
KEY_A = 30
|
||||
REL_X, REL_Y, REL_WHEEL, REL_MAX = 0x00, 0x01, 0x08, 0x0F
|
||||
SCROLLS = {0x06, REL_WHEEL, 0x0B, 0x0C} # REL_HWHEEL, REL_WHEEL and their _HI_RES
|
||||
BTN_LEFT, BTN_RIGHT, BTN_MIDDLE, BTN_SIDE, BTN_EXTRA = 0x110, 0x111, 0x112, 0x113, 0x114
|
||||
KEY_LEFTMETA, KEY_RIGHTMETA = 125, 126
|
||||
KEY_VOLUMEDOWN, KEY_VOLUMEUP = 114, 115
|
||||
@@ -189,12 +196,14 @@ RULES_PATH = os.path.expanduser("~/.config/frametop-input.json")
|
||||
ACTIONS = ("left", "right", "middle", "back", "scroll_up", "scroll_down", "dashboard", "recenter",
|
||||
"pointer_toggle", "follow_toggle", "gaze_toggle", "gaze_precision", "gaze_drag", "gaze_left", "gaze_right",
|
||||
"gaze_quickcal", "sens_up", "sens_down", "layout_reset", "screens_toggle", "keyboard_toggle", "float_toggle",
|
||||
"dock_all", "key", "none")
|
||||
"dock_all", "steam_menu", "key", "none")
|
||||
# Gaze mode is a mouse feature: these never come from a controller button (docs/gaze-controllers.md).
|
||||
GAZE_ACTIONS = ("gaze_toggle", "gaze_precision", "gaze_drag", "gaze_left", "gaze_right", "gaze_quickcal")
|
||||
# Key combinations a rules file without "key_bindings" gets: Meta+J and Meta+K click at the gaze
|
||||
# (free on the Frametop desktop, and apps don't use Meta), Meta+Shift+F floats a window.
|
||||
DEFAULT_KEY_BINDINGS = {"125+36": "gaze_left", "125+37": "gaze_right", "42+125+33": "float_toggle"}
|
||||
# Key combinations a rules file without "key_bindings" gets: a Meta tap opens Steam's menu, Meta+J
|
||||
# and Meta+K click at the gaze (free on the Frametop desktop, and apps don't use Meta),
|
||||
# Meta+Shift+F floats a window.
|
||||
DEFAULT_KEY_BINDINGS = {"125": "steam_menu", "125+36": "gaze_left", "125+37": "gaze_right",
|
||||
"42+125+33": "float_toggle"}
|
||||
KEY_F24 = 194 # sent to the desktop with a Meta combination (see the top)
|
||||
# Key combinations ("key_bindings"): modifiers, each side's code folded into the left one's.
|
||||
MODIFIERS = {29: 29, 97: 29, 42: 42, 54: 42, 56: 56, 100: 56, 125: 125, 126: 125}
|
||||
@@ -211,16 +220,23 @@ FLOAT = "\0frametop_float" # ft-floatd, floating windows in the Frametop deskto
|
||||
# Actions for ft-floatd ("float_toggle", "dock_all"): they don't need pointer mode.
|
||||
FLOAT_ACTIONS = {"float_toggle": b"float pointer", "dock_all": b"dock all"}
|
||||
PROFILE = "profile:" # "profile:NAME": switch to that profile (doesn't need pointer mode either)
|
||||
COMMAND = "command:" # "command:CMD": run CMD (nor does this)
|
||||
|
||||
|
||||
def known_action(a):
|
||||
return a in ACTIONS or (isinstance(a, str) and a.startswith(PROFILE) and len(a) > len(PROFILE))
|
||||
return a in ACTIONS or (isinstance(a, str) and any(a.startswith(p) and a[len(p):].strip()
|
||||
for p in (PROFILE, COMMAND)))
|
||||
|
||||
|
||||
def needs_pointer(a):
|
||||
return a not in FLOAT_ACTIONS and not a.startswith(PROFILE)
|
||||
return a not in FLOAT_ACTIONS and a != "steam_menu" and not a.startswith((PROFILE, COMMAND))
|
||||
KEYS = "\0frametop_keys" # keys of keyboards grabbed for the desktop, for other readers
|
||||
FT_LAYOUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "layout", "ft-layout")
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
FT_LAYOUT = os.path.join(REPO, "layout", "ft-layout")
|
||||
FT_STEAM = os.path.join(REPO, "steam", "ft-steam")
|
||||
# Commands ("command:CMD") find Frametop's own tools (ft-layout, ft-float, ft-steam) on their PATH.
|
||||
COMMAND_PATH = ":".join([os.path.join(REPO, d) for d in ("layout", "float", "steam")]
|
||||
+ [os.environ.get("PATH", "/usr/local/bin:/usr/bin")])
|
||||
DEFAULT_BUTTONS = {BTN_LEFT: "left", BTN_RIGHT: "right", BTN_MIDDLE: "middle",
|
||||
BTN_SIDE: "back", BTN_EXTRA: "back"}
|
||||
|
||||
@@ -672,13 +688,12 @@ def main():
|
||||
# desktop_until: typing goes to the Frametop desktop until then (ft-screens says so
|
||||
# every second); typing_applied: the grabs match that as of the last apply_roles().
|
||||
# vr_capture_until: every controller button is taken until then (the settings app capturing one).
|
||||
state = {"pointer": None, "rules": {}, "meta_dashboard": False, "share_keys": False,
|
||||
state = {"pointer": None, "rules": {}, "share_keys": False,
|
||||
"desktop_until": 0.0, "typing_applied": None, "vr_capture_until": 0.0}
|
||||
|
||||
def load_config():
|
||||
conf = read_config()
|
||||
state["rules"] = read_rules()
|
||||
state["meta_dashboard"] = conf.get("META_DASHBOARD", "0") == "1"
|
||||
state["share_keys"] = conf.get("SHARE_KEYS", "0") == "1"
|
||||
if conf.get("POINTER", "0") == "1":
|
||||
p = state["pointer"] or Pointer(0.03, 30)
|
||||
@@ -692,7 +707,7 @@ def main():
|
||||
log("pointer mode off: pointer devices feed the virtual mouse and keyboard")
|
||||
|
||||
load_config()
|
||||
meta_down = False # Meta pressed with no other key yet: a tap toggles the dashboard
|
||||
tap = None # the modifier (folded, MODIFIERS) pressed alone, with nothing since: its release is a tap
|
||||
screens_sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM | socket.SOCK_NONBLOCK)
|
||||
last_typing = 0.0 # the helper was last told of a key then (see "typing" at the top)
|
||||
|
||||
@@ -748,7 +763,7 @@ def main():
|
||||
|
||||
def do_action(action, value, now, source="mouse"):
|
||||
"""A mapped mouse or controller button, or key combination (pointer mode only, but
|
||||
for FLOAT_ACTIONS and profiles)."""
|
||||
for the actions needs_pointer() says don't)."""
|
||||
if action == "keyboard_toggle":
|
||||
if value == 1 and vr_keyboard_mode() != "never":
|
||||
vr_keyboard("toggle")
|
||||
@@ -758,6 +773,17 @@ def main():
|
||||
subprocess.Popen([FT_LAYOUT, "use", action[len(PROFILE):]], stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
|
||||
log(action)
|
||||
elif action == "steam_menu":
|
||||
if value == 1:
|
||||
# Talks to Steam's UI for a moment; its errors go to this service's log.
|
||||
subprocess.Popen([FT_STEAM, "menu"], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
start_new_session=True)
|
||||
log(action)
|
||||
elif action.startswith(COMMAND):
|
||||
if value == 1:
|
||||
subprocess.Popen(["sh", "-c", action[len(COMMAND):]], stdin=subprocess.DEVNULL,
|
||||
env=dict(os.environ, PATH=COMMAND_PATH), start_new_session=True)
|
||||
log(action)
|
||||
elif action in FLOAT_ACTIONS:
|
||||
if value == 1:
|
||||
try:
|
||||
@@ -776,14 +802,20 @@ def main():
|
||||
def key_binding(code, value, now):
|
||||
"""A key from a keyboard: does it complete a key combination ("key_bindings")? True if
|
||||
it was taken for one (then it isn't typed)."""
|
||||
nonlocal meta_down
|
||||
nonlocal tap
|
||||
if code in MODIFIERS:
|
||||
(held_modifiers.add if value else held_modifiers.discard)(MODIFIERS[code])
|
||||
mod = MODIFIERS[code]
|
||||
if value == 1:
|
||||
tap = mod if not held_modifiers and not combos_down else None
|
||||
(held_modifiers.add if value else held_modifiers.discard)(mod)
|
||||
if code in (KEY_LEFTMETA, KEY_RIGHTMETA):
|
||||
(held_meta.add if value else held_meta.discard)(code)
|
||||
if value == 0 and code in meta_hidden:
|
||||
meta_hidden.discard(code)
|
||||
return True # the desktop already had it come up (below)
|
||||
if value == 0 and tap == mod:
|
||||
tap = None
|
||||
modifier_tap(mod, now)
|
||||
return False
|
||||
if value == 1 and code not in combos_down and meta_hidden:
|
||||
# Another key while Meta is still held after a combination: the desktop gets Meta
|
||||
@@ -805,10 +837,9 @@ def main():
|
||||
combos_down[code] = action
|
||||
if held_meta - meta_hidden:
|
||||
# The desktop saw Meta go down. Another key in between keeps its release from
|
||||
# opening Plasma's launcher (and Meta from toggling the dashboard here), and Meta
|
||||
# comes up there now: KWin takes Meta with a mouse button for moving or resizing
|
||||
# windows, which would swallow a gaze click. Its real release is dropped (above).
|
||||
meta_down = False
|
||||
# opening Plasma's launcher, and Meta comes up there now: KWin takes Meta with a
|
||||
# mouse button for moving or resizing windows, which would swallow a gaze click.
|
||||
# Its real release is dropped (above).
|
||||
to_screens(KEY_F24, 1)
|
||||
to_screens(KEY_F24, 0)
|
||||
for c in sorted(held_meta - meta_hidden):
|
||||
@@ -819,6 +850,20 @@ def main():
|
||||
log(f"key combination {combo}: {action}")
|
||||
return True
|
||||
|
||||
def modifier_tap(mod, now):
|
||||
"""A modifier pressed and released alone: its binding, if it has one ("125": Meta tap)."""
|
||||
action = state["rules"]["key_bindings"].get(str(mod))
|
||||
if not known_action(action) or action in ("key", "none"):
|
||||
return
|
||||
# The desktop gets a key in between before the release goes there, so the tap isn't one
|
||||
# there too: a Meta tap would open Plasma's launcher.
|
||||
to_screens(KEY_F24, 1)
|
||||
to_screens(KEY_F24, 0)
|
||||
if state["pointer"] or not needs_pointer(action):
|
||||
do_action(action, 1, now, "keyboard")
|
||||
do_action(action, 0, now, "keyboard")
|
||||
log(f"key combination {mod}: {action}")
|
||||
|
||||
|
||||
screens_down = set() # keys the desktop was told went down and not yet up (see reconcile_desktop_keys)
|
||||
|
||||
@@ -1094,6 +1139,11 @@ def main():
|
||||
# New here: a new device, or one that came back in the same place.
|
||||
for old in [n for n in nodes.values() if n.path == path]:
|
||||
drop(old, "replaced by a new device node")
|
||||
# A new node is root's alone until udev gives it to the input group, a moment
|
||||
# after it appears. Opened in that gap, it would fail and never be tried
|
||||
# again: leave it for the next scan instead.
|
||||
if not os.access(path, os.R_OK):
|
||||
continue
|
||||
seen[path] = ino
|
||||
node = probe(path)
|
||||
if node and node.volume_keys and not take_volume(node):
|
||||
@@ -1136,40 +1186,37 @@ def main():
|
||||
if etype in (EV_KEY, EV_REL):
|
||||
broadcast(node, etype, VOLUME_ORIGINAL.get(code, code) if node.remapped else code,
|
||||
value, now)
|
||||
if (etype == EV_KEY and value == 1 and code not in MODIFIERS) or (etype == EV_REL and code in SCROLLS):
|
||||
tap = None # a key, button or scroll in between: a modifier's release isn't a tap
|
||||
if etype == EV_KEY and ((node.remapped and code in VOLUME_ORIGINAL)
|
||||
or (node.grabbed and code in VOLUME_STANDIN)):
|
||||
volume.key(fd, code, value, now)
|
||||
if value == 1:
|
||||
meta_down = False # Meta used as a modifier, not a tap
|
||||
continue
|
||||
if node.role == "volume":
|
||||
continue
|
||||
if node.role != "pointer":
|
||||
# Observed only, unless typing goes to the desktop. With META_DASHBOARD=1,
|
||||
# a Meta tap on any keyboard toggles the dashboard.
|
||||
# Observed only, unless typing goes to the desktop. Key combinations work on
|
||||
# any pass-through keyboard.
|
||||
if (node.role == "passthrough" and etype == EV_KEY and node.grabbed
|
||||
and code < BTN_MISC and value in (0, 1)):
|
||||
# Shared as pressed, key combinations included: the Meta release a
|
||||
# combination keeps from the desktop must still reach frame-voice, or
|
||||
# it waits for that release before typing anything.
|
||||
share_key(node, code, value)
|
||||
if value:
|
||||
node.held.add(code)
|
||||
else:
|
||||
node.held.discard(code)
|
||||
if node.role == "passthrough" and etype == EV_KEY and code < BTN_MISC and key_binding(code, value, now):
|
||||
continue
|
||||
if node.role == "passthrough" and etype == EV_KEY:
|
||||
if value == 1 and code < BTN_MISC and now - last_typing >= 0.25:
|
||||
last_typing = now
|
||||
screens_sock.sendto(b"typing", HELPER)
|
||||
try:
|
||||
screens_sock.sendto(b"typing", HELPER)
|
||||
except OSError:
|
||||
pass # helper not running (SteamVR not running)
|
||||
to_screens(code, value)
|
||||
if node.grabbed and code < BTN_MISC and value in (0, 1):
|
||||
share_key(node, code, value)
|
||||
if value:
|
||||
node.held.add(code)
|
||||
else:
|
||||
node.held.discard(code)
|
||||
if (pointer and state["meta_dashboard"] and node.role == "passthrough"
|
||||
and etype == EV_KEY):
|
||||
if code in (KEY_LEFTMETA, KEY_RIGHTMETA):
|
||||
if value == 1:
|
||||
meta_down = True
|
||||
elif value == 0 and meta_down:
|
||||
meta_down = False
|
||||
pointer.dashboard()
|
||||
elif value == 1:
|
||||
meta_down = False # Meta used as a modifier, not a tap
|
||||
continue
|
||||
if etype == EV_KEY:
|
||||
action = buttons.get(str(code), DEFAULT_BUTTONS.get(code, "key"))
|
||||
|
||||
Executable
+219
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline test of the input relay's key combinations and modifier taps.
|
||||
|
||||
Runs the relay's main() against a fake pass-through keyboard and a fake mouse (pipes), with
|
||||
every socket it sends to renamed, no uinput devices, no grabs, and ft-steam swapped for a
|
||||
logger. Nothing reaches the live desktop, SteamVR, or the running relay, so it's safe next
|
||||
to them. Rules come from the test, not ~/.config/frametop-input.json.
|
||||
|
||||
input/test/keys-test.py [RELAY] (default: input/input-relay.py next to this folder)
|
||||
"""
|
||||
import fcntl
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
relay_path = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HERE, "..", "input-relay.py")
|
||||
tag = f"ft_keys_test_{os.getpid()}"
|
||||
with open(relay_path) as f:
|
||||
src = f.read().replace('"\\0frametop_relay"', f'"\\0{tag}_relay"')
|
||||
relay = importlib.util.module_from_spec(importlib.util.spec_from_loader("relay", loader=None))
|
||||
relay.__file__ = os.path.abspath(relay_path)
|
||||
exec(compile(src, relay_path, "exec"), relay.__dict__)
|
||||
|
||||
for name in ("SCREENS", "HELPER", "FLOAT", "GAZED", "KEYS"):
|
||||
setattr(relay, name, f"\0{tag}_{name.lower()}")
|
||||
OUT = tempfile.mkdtemp(prefix="ft-keys-test-")
|
||||
steam_log, cmd_log = f"{OUT}/steam.log", f"{OUT}/cmd.log"
|
||||
relay.FT_STEAM = f"{OUT}/ft-steam"
|
||||
with open(relay.FT_STEAM, "w") as f:
|
||||
f.write(f'#!/bin/sh\necho "$@" >> {steam_log}\n')
|
||||
os.chmod(relay.FT_STEAM, 0o755)
|
||||
|
||||
|
||||
class NoDevice:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def emit(self, *args):
|
||||
pass
|
||||
|
||||
def sync(self):
|
||||
pass
|
||||
|
||||
|
||||
relay.Virtual = NoDevice
|
||||
relay.log = lambda *args: None # the relay's own log
|
||||
bindings = {"now": None} # the rules' key_bindings; None: the relay's defaults
|
||||
|
||||
|
||||
def read_rules(path=None):
|
||||
rules = {"devices": {}, "buttons": {}, "controller_buttons": {}}
|
||||
rules["key_bindings"] = dict(relay.DEFAULT_KEY_BINDINGS if bindings["now"] is None else bindings["now"])
|
||||
return rules
|
||||
|
||||
|
||||
relay.read_rules = read_rules
|
||||
relay.read_config = lambda path=None: {"POINTER": "0"}
|
||||
|
||||
# The fake devices: /dev/input/event900 (keyboard) and event901 (mouse), each a pipe.
|
||||
kb_r, kb_w = os.pipe()
|
||||
ms_r, ms_w = os.pipe()
|
||||
for fd in (kb_r, ms_r):
|
||||
os.set_blocking(fd, False)
|
||||
FAKE = {"/dev/input/event900": kb_r, "/dev/input/event901": ms_r}
|
||||
HELD = {kb_r: set(), ms_r: set()} # what EVIOCGKEY says each holds
|
||||
|
||||
|
||||
class Inode:
|
||||
st_ino = 1
|
||||
|
||||
|
||||
fake_os = type(os)("os")
|
||||
fake_os.__dict__.update(os.__dict__)
|
||||
fake_os.listdir = lambda path: ["event900", "event901"] if path == "/dev/input" else os.listdir(path)
|
||||
fake_os.stat = lambda path, *a, **k: Inode() if path in FAKE else os.stat(path, *a, **k)
|
||||
relay.os = fake_os
|
||||
|
||||
|
||||
def ioctl(fd, request, arg=0, *rest):
|
||||
if fd in HELD and request == relay.EVIOCGKEY:
|
||||
for c in HELD[fd]:
|
||||
arg[c // 8] |= 1 << (c % 8)
|
||||
return 0
|
||||
return fcntl.ioctl(fd, request, arg, *rest)
|
||||
|
||||
|
||||
fake_fcntl = type(fcntl)("fcntl")
|
||||
fake_fcntl.__dict__.update(fcntl.__dict__)
|
||||
fake_fcntl.ioctl = ioctl
|
||||
relay.fcntl = fake_fcntl
|
||||
|
||||
|
||||
def probe(path):
|
||||
if FAKE[path] == kb_r:
|
||||
return relay.Node(path, kb_r, "test keyboard", relay.BUS_USB, 1, 2, "", False, True)
|
||||
return relay.Node(path, ms_r, "test mouse", relay.BUS_USB, 3, 4, "", True, False)
|
||||
|
||||
|
||||
relay.probe = probe
|
||||
|
||||
screens = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
||||
screens.bind(relay.SCREENS)
|
||||
screens.settimeout(0.05)
|
||||
EVENT = struct.Struct("llHHi")
|
||||
|
||||
|
||||
def send(fd, etype, code, value):
|
||||
os.write(fd, EVENT.pack(0, 0, etype, code, value) + EVENT.pack(0, 0, 0, 0, 0))
|
||||
time.sleep(0.03)
|
||||
|
||||
|
||||
def key(code, value, fd=kb_w):
|
||||
held = HELD[ms_r if fd == ms_w else kb_r]
|
||||
(held.add if value else held.discard)(code)
|
||||
send(fd, relay.EV_KEY, code, value)
|
||||
|
||||
|
||||
def typed():
|
||||
"""What the desktop was sent since the last call."""
|
||||
got = []
|
||||
while True:
|
||||
try:
|
||||
got.append(screens.recv(256).decode())
|
||||
except socket.timeout:
|
||||
return got
|
||||
|
||||
|
||||
def use(key_bindings):
|
||||
bindings["now"] = key_bindings
|
||||
c = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
||||
c.bind("")
|
||||
c.settimeout(2)
|
||||
c.sendto(b"reload", f"\0{tag}_relay")
|
||||
c.recv(4096)
|
||||
time.sleep(0.05)
|
||||
typed()
|
||||
|
||||
|
||||
def lines(path, wait=0.3):
|
||||
time.sleep(wait)
|
||||
try:
|
||||
with open(path) as f:
|
||||
return f.read().split()
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
failures = []
|
||||
|
||||
|
||||
def check(label, got, want):
|
||||
ok = got == want
|
||||
print(("ok " if ok else "FAIL ") + label + ("" if ok else f": got {got!r}, want {want!r}"), flush=True)
|
||||
if not ok:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
META, RMETA, SHIFT, CTRL, RCTRL, A, F, J = 125, 126, 42, 29, 97, 30, 33, 36
|
||||
F24 = ["key 194 1", "key 194 0"]
|
||||
|
||||
|
||||
def tests():
|
||||
time.sleep(1.5) # the relay's first device scan
|
||||
typed()
|
||||
key(META, 1); key(META, 0)
|
||||
check("Meta tap (default): Steam menu", lines(steam_log), ["menu"])
|
||||
check("Meta tap: the desktop gets F24 before Meta's release", typed(), ["key 125 1"] + F24 + ["key 125 0"])
|
||||
|
||||
key(RMETA, 1); key(RMETA, 2); key(RMETA, 2); key(RMETA, 0)
|
||||
check("right Meta held (autorepeat), then released: a tap", lines(steam_log), ["menu", "menu"])
|
||||
typed()
|
||||
|
||||
key(META, 1); key(A, 1); key(A, 0); key(META, 0)
|
||||
check("Meta+A: no tap", lines(steam_log), ["menu", "menu"])
|
||||
check("Meta+A (unbound) is typed as is", typed(), ["key 125 1", "key 30 1", "key 30 0", "key 125 0"])
|
||||
|
||||
key(META, 1); key(relay.BTN_LEFT, 1, ms_w); key(relay.BTN_LEFT, 0, ms_w); key(META, 0)
|
||||
check("Meta+click: no tap", lines(steam_log), ["menu", "menu"])
|
||||
key(META, 1); send(ms_w, relay.EV_REL, relay.REL_WHEEL, 1); key(META, 0)
|
||||
check("Meta+scroll: no tap", lines(steam_log), ["menu", "menu"])
|
||||
key(META, 1); key(SHIFT, 1); key(SHIFT, 0); key(META, 0)
|
||||
check("Meta+Shift: no tap", lines(steam_log), ["menu", "menu"])
|
||||
typed()
|
||||
|
||||
key(META, 1); key(J, 1); key(J, 0); key(META, 0)
|
||||
check("Meta+J (default gaze click): no tap", lines(steam_log), ["menu", "menu"])
|
||||
check("Meta+J: F24, Meta up early, its real release dropped", typed(), ["key 125 1"] + F24 + ["key 125 0"])
|
||||
|
||||
use({"29+42+33": f"command:echo combo >> {cmd_log}", "125": "none"})
|
||||
key(CTRL, 1); key(SHIFT, 1); key(F, 1); key(F, 0); key(SHIFT, 0); key(CTRL, 0)
|
||||
check("Ctrl+Shift+F runs its command", lines(cmd_log), ["combo"])
|
||||
check("the combination's last key isn't typed", [k for k in typed() if k.startswith("key 33 ")], [])
|
||||
key(META, 1); key(META, 0)
|
||||
check("Meta tap bound to none: nothing", lines(steam_log), ["menu", "menu"])
|
||||
check("Meta tap bound to none: no F24", typed(), ["key 125 1", "key 125 0"])
|
||||
|
||||
use({"29": f"command:command -v ft-steam ft-layout ft-float | wc -l >> {cmd_log}"})
|
||||
key(RCTRL, 1); key(RCTRL, 0)
|
||||
check("right Ctrl tap runs its command, which finds Frametop's tools", lines(cmd_log), ["combo", "3"])
|
||||
check("Ctrl tap: F24 before Ctrl's release", typed(), ["key 97 1"] + F24 + ["key 97 0"])
|
||||
|
||||
check("an empty command isn't an action", relay.known_action("command: "), False)
|
||||
check("steam_menu and commands work without pointer mode",
|
||||
(relay.needs_pointer("steam_menu"), relay.needs_pointer("command:ls")), (False, False))
|
||||
print("FAILED: " + ", ".join(failures) if failures else "all passed", flush=True)
|
||||
shutil.rmtree(OUT, ignore_errors=True)
|
||||
os._exit(1 if failures else 0)
|
||||
|
||||
|
||||
threading.Thread(target=tests, daemon=True).start()
|
||||
sys.argv = [relay_path, "--no-grab"]
|
||||
relay.main()
|
||||
+51
-14
@@ -1,13 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install everything on the Steam Frame: the build container, Frametop (multi-screen
|
||||
# desktop, input relay, universal 3D mouse, settings app), and optionally gaze mode and the
|
||||
# Bluetooth fixes. Run it on the headset in a terminal, from this repo. It's safe to re-run,
|
||||
# desktop, input relay, universal 3D mouse, settings app), and optionally gaze mode, our own
|
||||
# eye tracker for it, and the Bluetooth fixes. Run it on the headset in a terminal, from this repo. It's safe to re-run,
|
||||
# for example after `git pull`. (Hand tracking, hands/, is deferred: it isn't offered here.)
|
||||
# (It also works from a PC over SSH; see "Developing from a PC" in the README.)
|
||||
#
|
||||
# Usage: ./install.sh [--yes] [--no-bluetooth]
|
||||
# --yes don't ask; installs gaze mode, skips the Bluetooth fixes and the SteamVR
|
||||
# restart
|
||||
# --yes don't ask; installs gaze mode, and our eye tracker if sudo can run without
|
||||
# a password prompt; skips the Bluetooth fixes and the SteamVR restart
|
||||
# --no-bluetooth don't offer the Bluetooth fixes
|
||||
set -euo pipefail
|
||||
|
||||
@@ -19,7 +19,7 @@ for arg in "$@"; do
|
||||
case $arg in
|
||||
--yes) assume_yes=1 ;;
|
||||
--no-bluetooth) bluetooth=0 ;;
|
||||
-h|--help) sed -n '2,11p' "$0"; exit 0 ;;
|
||||
-h|--help) sed -n '2,12p' "$0"; exit 0 ;;
|
||||
*) echo "unknown option: $arg" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
@@ -38,6 +38,13 @@ ask() { # ask "question" default(y|n)
|
||||
answer=${answer:-$2}
|
||||
[[ $answer =~ ^[Yy] ]]
|
||||
}
|
||||
# Can sudo run here without a password prompt (SUDO_ASKPASS, the repo's .env, a recent sudo)?
|
||||
# See frame_sudo in scripts/_env.sh.
|
||||
sudo_quiet() {
|
||||
{ [ "$FRAME_LOCAL" = 1 ] && [ -n "${SUDO_ASKPASS:-}" ]; } && return 0
|
||||
grep -q '^steamos_root_pwd=.' "$REPO_ROOT/.env" 2>/dev/null && return 0
|
||||
on_frame 'sudo -n true' 2>/dev/null
|
||||
}
|
||||
|
||||
if [ "$FRAME_LOCAL" = 1 ]; then
|
||||
echo "Installing on this Steam Frame from $FRAME_REPO"
|
||||
@@ -50,7 +57,7 @@ if [ "$FRAME_LOCAL" = 0 ] || [ -n "${SSH_CONNECTION:-}" ]; then
|
||||
echo "that need SteamVR start with it if it isn't running now."
|
||||
fi
|
||||
|
||||
step "1/9 distrobox (container tool, installed in your home folder)"
|
||||
step "1/10 distrobox (container tool, installed in your home folder)"
|
||||
if on_frame 'test -x ~/.local/bin/distrobox'; then
|
||||
echo "already installed: $(on_frame '~/.local/bin/distrobox version | head -1')"
|
||||
else
|
||||
@@ -60,25 +67,25 @@ else
|
||||
cd ~/dev/src/distrobox && ./install --prefix ~/.local'
|
||||
fi
|
||||
|
||||
step "2/9 build container (Fedora 44 'dev', about 1-2 GB the first time)"
|
||||
step "2/10 build container (Fedora 44 'dev', about 1-2 GB the first time)"
|
||||
"$root/setup/dev-container.sh"
|
||||
|
||||
step "3/9 input relay (keeps Bluetooth mice working in SteamVR, device roles, button maps)"
|
||||
step "3/10 input relay (keeps Bluetooth mice working in SteamVR, device roles, button maps)"
|
||||
"$root/desktops.sh" relay install
|
||||
|
||||
step "4/9 3D mouse: SteamVR driver"
|
||||
step "4/10 3D mouse: SteamVR driver"
|
||||
"$root/pointer/driver/build.sh"
|
||||
"$root/pointer/driver/install.sh" install 2>&1 | grep -v xdg-open
|
||||
|
||||
step "5/9 3D mouse: pointer helper service"
|
||||
step "5/10 3D mouse: pointer helper service"
|
||||
"$root/pointer/helper/build.sh"
|
||||
"$root/pointer/helper/run.sh" install
|
||||
|
||||
step "6/9 power service (turns the displays off while the headset isn't used, even on a stand)"
|
||||
step "6/10 power service (turns the displays off while the headset isn't used, even on a stand)"
|
||||
"$root/power/build.sh"
|
||||
"$root/power/run.sh" install
|
||||
|
||||
step "7/9 multi-screen desktop (ft-screens), Frametop Input Settings, and Frametop Display Settings"
|
||||
step "7/10 multi-screen desktop (ft-screens), Frametop Input Settings, and Frametop Display Settings"
|
||||
"$root/screens/build.sh"
|
||||
"$root/desktops.sh" install >/dev/null
|
||||
"$root/input-settings/install.sh"
|
||||
@@ -87,14 +94,44 @@ step "7/9 multi-screen desktop (ft-screens), Frametop Input Settings, and Framet
|
||||
on_frame "sed -i 's/^POINTER=0/POINTER=1/' ~/.config/frametop.conf; grep -q '^POINTER=' ~/.config/frametop.conf || echo 'POINTER=1' >> ~/.config/frametop.conf"
|
||||
echo "the launcher's Desktop entry now opens the multi-screen desktop; 3D mouse on (POINTER=1 in ~/.config/frametop.conf)"
|
||||
|
||||
step "8/9 gaze mode (optional, experimental: the pointer goes where you look)"
|
||||
step "8/10 gaze mode (optional, experimental: the pointer goes where you look)"
|
||||
gaze=0
|
||||
if ask "Install gaze mode? You turn it on and calibrate it in Frametop Input Settings, on the Gaze page." y; then
|
||||
"$root/gaze/run.sh" install
|
||||
gaze=1
|
||||
else
|
||||
echo "skipped. Install later with: gaze/run.sh install"
|
||||
fi
|
||||
|
||||
step "9/9 Bluetooth fixes (optional; they let LE mice and keyboards like the Swiftpoint Z3 reconnect)"
|
||||
# Ours is gaze mode's default once it's installed (GAZE_TRACKER=auto). Without it, gaze mode
|
||||
# uses SteamVR's eye tracker.
|
||||
step "9/10 our own eye tracker for gaze mode (recommended: more accurate than SteamVR's)"
|
||||
if [ "$gaze" = 0 ]; then
|
||||
echo "skipped: gaze mode isn't installed. Install it later with: gaze/tracker/install.sh"
|
||||
elif [ "$assume_yes" = 1 ] && ! sudo_quiet; then
|
||||
echo "skipped: it needs your password (sudo), and --yes doesn't ask. Install it later with: gaze/tracker/install.sh"
|
||||
elif ask "Install our own eye tracker? Gaze mode then uses it instead of SteamVR's. Its frame grabber is a small system service, so it needs your password (sudo), and it downloads about 165 MB (numpy, OpenCV)." y; then
|
||||
if "$root/gaze/tracker/install.sh" install; then
|
||||
# Configs made before GAZE_TRACKER=auto say steam, which keeps SteamVR's.
|
||||
tracker=$(on_frame "sed -n 's/^GAZE_TRACKER=\([a-z]*\).*/\1/p' ~/.config/frametop.conf | tail -1")
|
||||
if [ "$tracker" = steam ] &&
|
||||
ask "Your settings pick SteamVR's eye tracker (GAZE_TRACKER=steam, the old default). Use ours instead?" y; then
|
||||
on_frame "sed -i 's~^GAZE_TRACKER=steam\b.*~GAZE_TRACKER=auto # gaze service: auto = our own eye tracker when installed, else SteamVR | own | steam~' ~/.config/frametop.conf"
|
||||
tracker=auto
|
||||
fi
|
||||
if [ "$tracker" = steam ]; then
|
||||
echo "installed; your settings keep SteamVR's eye tracker (pick Own tracker on the Gaze page to use ours)"
|
||||
else
|
||||
echo "gaze mode uses our eye tracker: calibrate it once, with Calibrate on the Gaze page"
|
||||
fi
|
||||
else
|
||||
echo "our eye tracker didn't install, so gaze mode uses SteamVR's. Try again with: gaze/tracker/install.sh"
|
||||
fi
|
||||
else
|
||||
echo "skipped: gaze mode uses SteamVR's eye tracker. Install ours later with: gaze/tracker/install.sh"
|
||||
fi
|
||||
|
||||
step "10/10 Bluetooth fixes (optional; they let LE mice and keyboards like the Swiftpoint Z3 reconnect)"
|
||||
if [ "$bluetooth" = 1 ] && ask "Install the Bluetooth fixes? They need your password (sudo)." n; then
|
||||
"$root/setup/bluetooth/install.sh" install
|
||||
else
|
||||
|
||||
@@ -5,5 +5,5 @@ root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
"$root/scripts/sync.sh" >/dev/null
|
||||
exec "$root/scripts/frame.sh" -C pointer/helper 'set -e; mkdir -p build
|
||||
g++ -std=c++17 -O2 -Wall -Wno-unused-parameter -I/opt/steamvr/tools/hellovr_vulkan_linux/src/openvr/headers -I../common \
|
||||
-o build/ft-pointer ft-pointer.cpp -L/opt/steamvr/bin/linuxarm64 -lopenvr_api -Wl,-rpath,/opt/steamvr/bin/linuxarm64 -lpthread
|
||||
-o build/ft-pointer ft-pointer.cpp pointer_*.cpp -L/opt/steamvr/bin/linuxarm64 -lopenvr_api -Wl,-rpath,/opt/steamvr/bin/linuxarm64 -lpthread
|
||||
echo "built build/ft-pointer"'
|
||||
+174
-1854
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,478 @@
|
||||
// ft-pointer's shared code: helpers, the Frame, and the Pointer struct, whose member functions are
|
||||
// in ft-pointer.cpp and pointer_*.cpp.
|
||||
// "The top" in comments here is the header comment of ft-pointer.cpp.
|
||||
#pragma once
|
||||
|
||||
#include <openvr.h>
|
||||
|
||||
#include "pointer_config.h"
|
||||
#include "vrbuttons.h"
|
||||
#include "vrmath.h"
|
||||
|
||||
extern "C" {
|
||||
#include "../../hands/include/fh_gestures.h"
|
||||
}
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
#include <fstream>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <tuple>
|
||||
#include <vector>
|
||||
|
||||
#include <climits>
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <fnmatch.h>
|
||||
|
||||
#include <sys/mman.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/un.h>
|
||||
#include <unistd.h>
|
||||
|
||||
using namespace md;
|
||||
|
||||
// Hand gestures from ft-hands (see "Hands" at the top): /run/user/UID/frametop-hands/gestures,
|
||||
// mapped read-only. Version 1 files have pinches only; their grips read as zero.
|
||||
class HandGestures {
|
||||
public:
|
||||
~HandGestures() { Close(); }
|
||||
// A consistent copy of the file (its sequence lock), if it's there. Opens it, and
|
||||
// checks it's still the same file, at most once a second.
|
||||
bool Read(fh_gestures_t &out) {
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (now - checked_ > std::chrono::seconds(1)) {
|
||||
checked_ = now;
|
||||
const std::string path = "/run/user/" + std::to_string(getuid()) + "/frametop-hands/gestures";
|
||||
struct stat st;
|
||||
if (stat(path.c_str(), &st) != 0 || size_t(st.st_size) != len_ || st.st_ino != ino_) {
|
||||
Close();
|
||||
const int fd = open(path.c_str(), O_RDONLY | O_CLOEXEC | O_NOFOLLOW);
|
||||
if (fd >= 0 && fstat(fd, &st) == 0 && size_t(st.st_size) >= offsetof(fh_gestures_t, grip)) {
|
||||
void *m = mmap(nullptr, size_t(st.st_size), PROT_READ, MAP_SHARED, fd, 0);
|
||||
if (m != MAP_FAILED) map_ = m, len_ = size_t(st.st_size), ino_ = st.st_ino, ++opens;
|
||||
}
|
||||
if (fd >= 0) close(fd);
|
||||
}
|
||||
}
|
||||
if (!map_) return false;
|
||||
const auto *g = static_cast<const fh_gestures_t *>(map_);
|
||||
if (std::memcmp(g->magic, FH_GESTURES_MAGIC, 8) != 0) return false;
|
||||
const size_t n = std::min(len_, sizeof out);
|
||||
for (int tries = 0; tries < 3; ++tries) {
|
||||
const uint64_t seq = __atomic_load_n(&g->seq, __ATOMIC_ACQUIRE);
|
||||
if (seq & 1) continue;
|
||||
std::memset(&out, 0, sizeof out);
|
||||
std::memcpy(&out, map_, n);
|
||||
__atomic_thread_fence(__ATOMIC_ACQUIRE);
|
||||
if (__atomic_load_n(&g->seq, __ATOMIC_RELAXED) != seq) continue;
|
||||
if (out.version < 2) std::memset(out.grip, 0, sizeof out.grip);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
int opens = 0; // a new file: the counters start over
|
||||
|
||||
private:
|
||||
void Close() {
|
||||
if (map_) munmap(map_, len_);
|
||||
map_ = nullptr, len_ = 0, ino_ = 0;
|
||||
}
|
||||
void *map_ = nullptr;
|
||||
size_t len_ = 0;
|
||||
ino_t ino_ = 0;
|
||||
std::chrono::steady_clock::time_point checked_{};
|
||||
};
|
||||
|
||||
// The HMD's recent poses (standing universe), to turn the gestures' head-frame points into
|
||||
// the room as the head was when the cameras took them.
|
||||
class PoseHistory {
|
||||
public:
|
||||
void Add(std::chrono::steady_clock::time_point t, const vr::HmdMatrix34_t &m) {
|
||||
poses_.push_back({t, m});
|
||||
while (poses_.size() > 128) poses_.pop_front(); // about a second
|
||||
}
|
||||
// The pose at CLOCK_MONOTONIC time t_ns (steady_clock's), or the nearest kept.
|
||||
bool At(uint64_t t_ns, vr::HmdMatrix34_t &out) const {
|
||||
if (poses_.empty()) return false;
|
||||
const std::chrono::steady_clock::time_point t{std::chrono::nanoseconds(t_ns)};
|
||||
const auto *best = &poses_.front();
|
||||
for (const auto &p : poses_)
|
||||
if (std::chrono::abs(p.first - t) < std::chrono::abs(best->first - t)) best = &p;
|
||||
out = best->second;
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
std::deque<std::pair<std::chrono::steady_clock::time_point, vr::HmdMatrix34_t>> poses_;
|
||||
};
|
||||
|
||||
// One of ft-screens' panels showing a desktop: a screen (frametop.screen.N), a floating
|
||||
// window (frametop.float.N), or a floating window's popup (frametop.float.N.sub.K), not a
|
||||
// control of theirs.
|
||||
inline bool FramePanel(const std::string &key) {
|
||||
for (const char *prefix : {"frametop.screen.", "frametop.float."}) {
|
||||
if (key.rfind(prefix, 0) != 0) continue;
|
||||
const std::string rest = key.substr(std::strlen(prefix));
|
||||
const size_t dot = rest.find('.');
|
||||
return dot == std::string::npos || rest.compare(dot, 5, ".sub.") == 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
inline void SendTo(int fd, const char *name, const std::string &msg) {
|
||||
sockaddr_un addr{};
|
||||
addr.sun_family = AF_UNIX;
|
||||
std::memcpy(addr.sun_path + 1, name, std::strlen(name));
|
||||
const socklen_t len = offsetof(sockaddr_un, sun_path) + 1 + std::strlen(name);
|
||||
sendto(fd, msg.data(), msg.size(), 0, reinterpret_cast<sockaddr *>(&addr), len);
|
||||
}
|
||||
|
||||
// JSON string literal (names come from other apps).
|
||||
inline std::string JsonQuote(const std::string &s) {
|
||||
std::string out = "\"";
|
||||
for (const unsigned char c : s) {
|
||||
if (c == '"' || c == '\\') out += '\\', out += char(c);
|
||||
else if (c < 0x20) {
|
||||
char esc[8];
|
||||
std::snprintf(esc, sizeof esc, "\\u%04x", c);
|
||||
out += esc;
|
||||
} else out += char(c);
|
||||
}
|
||||
return out + "\"";
|
||||
}
|
||||
|
||||
// Overlay keys, refreshed in the background from `vrcmd --overlays` (OpenVR has no
|
||||
// public call to enumerate other apps' overlays). Hidden ones are listed too: the
|
||||
// window controls under a floating panel only appear while something hovers the
|
||||
// panel, and the cursor has to find them the moment they do, not a second later.
|
||||
// Paused while the pointer is off: each vrcmd run connects to SteamVR as a new app, and a new
|
||||
// app every second kept SteamVR (and the headset's displays) from going to standby.
|
||||
// "overlays" requests (Frametop Input Settings' Ignored panels page) refresh the list even
|
||||
// while paused, and are answered from this thread once it's fresh.
|
||||
class OverlayList {
|
||||
public:
|
||||
void Start() {
|
||||
out_ = socket(AF_UNIX, SOCK_DGRAM | SOCK_CLOEXEC, 0);
|
||||
thread_ = std::thread([this] {
|
||||
while (running_) {
|
||||
if (!paused_ || requested_) Refresh();
|
||||
// Wait a second, or less when the pointer wakes (refresh right away then).
|
||||
for (int i = 0; i < 10 && running_; ++i) {
|
||||
const bool wasPaused = paused_;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
if ((wasPaused && !paused_) || requested_) break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
void SetPaused(bool paused) { paused_ = paused; }
|
||||
void Stop() {
|
||||
running_ = false;
|
||||
if (thread_.joinable()) thread_.join();
|
||||
}
|
||||
std::vector<std::string> Keys() {
|
||||
std::lock_guard<std::mutex> guard(lock_);
|
||||
std::vector<std::string> keys;
|
||||
for (const auto &e : entries_) keys.push_back(e.key);
|
||||
return keys;
|
||||
}
|
||||
// Answer `to` with {"t":"overlays","list":[{"key","name","visible"}...]} after the next refresh.
|
||||
void Request(const sockaddr_un &to, socklen_t len) {
|
||||
if (len <= offsetof(sockaddr_un, sun_path)) return;
|
||||
std::lock_guard<std::mutex> guard(lock_);
|
||||
if (waiting_.size() < 8) waiting_.push_back({to, len});
|
||||
requested_ = true;
|
||||
}
|
||||
|
||||
private:
|
||||
struct Entry {
|
||||
std::string key, name;
|
||||
bool visible;
|
||||
};
|
||||
void Refresh() {
|
||||
requested_ = false;
|
||||
FILE *p = popen("LD_LIBRARY_PATH=/opt/steamvr/bin/linuxarm64 /opt/steamvr/bin/linuxarm64/vrcmd --overlays 2>/dev/null", "r");
|
||||
if (!p) return;
|
||||
std::vector<Entry> entries;
|
||||
char line[1024];
|
||||
while (std::fgets(line, sizeof line, p)) {
|
||||
// 'key' -- 'name', WxH visible VROverlayType_...
|
||||
if (line[0] != '\'') continue;
|
||||
const char *end = std::strchr(line + 1, '\'');
|
||||
if (!end) continue;
|
||||
const std::string key(line + 1, size_t(end - (line + 1)));
|
||||
const std::string rest(end);
|
||||
if (rest.find("Thumbnail") != std::string::npos || rest.find("Subview") != std::string::npos) continue;
|
||||
if (key.rfind("system.pointer", 0) == 0 || key.rfind("system.cursor", 0) == 0 ||
|
||||
key.rfind("frametop.pointer", 0) == 0 || key.rfind("frametop.guide", 0) == 0 ||
|
||||
key == "frametop.gazepanel" || // the gaze calibration panel, fixed to the headset
|
||||
key == "frametop.catcher" || // ft-screens' release catcher: only on a laser mid-drag
|
||||
key == "system.HeadsetView" || key == "system.toast")
|
||||
continue;
|
||||
// The name can hold quotes; it ends at the last "', " (the size and state follow).
|
||||
const auto nameAt = rest.find("-- '"), nameEnd = rest.rfind("', ");
|
||||
const std::string name =
|
||||
nameAt != std::string::npos && nameEnd > nameAt + 3 ? rest.substr(nameAt + 4, nameEnd - nameAt - 4) : key;
|
||||
entries.push_back({key, name, rest.find(" not_visible ") == std::string::npos});
|
||||
}
|
||||
pclose(p);
|
||||
std::vector<std::pair<sockaddr_un, socklen_t>> waiting;
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(lock_);
|
||||
entries_ = std::move(entries);
|
||||
waiting.swap(waiting_);
|
||||
}
|
||||
if (waiting.empty()) return;
|
||||
std::string msg = "{\"t\":\"overlays\",\"list\":[";
|
||||
for (size_t i = 0; i < entries_.size(); ++i)
|
||||
msg += std::string(i ? "," : "") + "{\"key\":" + JsonQuote(entries_[i].key) + ",\"name\":" +
|
||||
JsonQuote(entries_[i].name) + ",\"visible\":" + (entries_[i].visible ? "true" : "false") + "}";
|
||||
msg += "]}";
|
||||
for (const auto &[to, len] : waiting)
|
||||
sendto(out_, msg.data(), msg.size(), MSG_DONTWAIT, reinterpret_cast<const sockaddr *>(&to), len);
|
||||
}
|
||||
|
||||
std::thread thread_;
|
||||
int out_ = -1;
|
||||
std::atomic<bool> paused_{false};
|
||||
std::atomic<bool> running_{true};
|
||||
std::atomic<bool> requested_{false};
|
||||
std::mutex lock_;
|
||||
std::vector<Entry> entries_; // written only by the thread; the lock guards readers
|
||||
std::vector<std::pair<sockaddr_un, socklen_t>> waiting_;
|
||||
};
|
||||
|
||||
using Clock = std::chrono::steady_clock;
|
||||
|
||||
// One frame's poses and time: ReadFrame reads them after the relay's commands, and the sections
|
||||
// after it share them. handOk: PinchesAndGrips read the hand gestures (for HandsStopped).
|
||||
struct Frame {
|
||||
vr::TrackedDevicePose_t all[vr::k_unMaxTrackedDeviceCount];
|
||||
vr::TrackedDevicePose_t hmdRaw;
|
||||
Clock::time_point tnow;
|
||||
Vec3 eye;
|
||||
bool handOk = false;
|
||||
const vr::TrackedDevicePose_t &Hmd() const { return all[0]; }
|
||||
};
|
||||
|
||||
// Everything the pointer keeps: its settings, what Init sets up, and the state the main loop
|
||||
// carries from one frame to the next.
|
||||
struct Pointer {
|
||||
PointerConfig cfg;
|
||||
// Head follow (see the top). followConf is POINTER_FOLLOW as last read: a reload only
|
||||
// overrides a "follow" command when the setting itself changed.
|
||||
bool follow = false, followConf = false, followReset = true;
|
||||
// Gaze mode (see the top); gazeConf is POINTER_GAZE as last read, like followConf.
|
||||
bool gazeOn = false, gazeConf = false;
|
||||
// Set in Init, after VR_Init.
|
||||
vr::IVRSystem *sys = nullptr;
|
||||
vr::IVROverlay *overlay = nullptr;
|
||||
// The dot, the marker, and laser mode's overlay, created in Init.
|
||||
vr::VROverlayHandle_t cursor = vr::k_ulOverlayHandleInvalid;
|
||||
vr::VROverlayHandle_t marker = vr::k_ulOverlayHandleInvalid;
|
||||
vr::VROverlayHandle_t laserMode = vr::k_ulOverlayHandleInvalid;
|
||||
bool laserModeShown = false;
|
||||
int in = -1, out = -1; // @ft_pointer_helper, and the socket we send from (opened in Init)
|
||||
ControllerButtons controllerButtons; // Frame controller buttons (vrbuttons.h)
|
||||
OverlayList overlays;
|
||||
std::map<std::string, vr::VROverlayHandle_t> handles;
|
||||
std::map<std::string, bool> sceneGraph; // no texture: plane test instead of ComputeOverlayIntersection
|
||||
std::map<std::string, bool> visible; // refreshed every 50 ms
|
||||
Clock::time_point lastVisible{}; // set in Init
|
||||
// The plane of the last panel the cursor was on, and the last point on it (panel edges).
|
||||
Vec3 edgePoint, edgeNormal, edgeLast;
|
||||
std::string edgeKey;
|
||||
|
||||
bool active = false, recenter = false, anchored = false;
|
||||
Clock::time_point lastMouse{}, claimAt{}, claimRelease{}, wokeAt{}, noWakeUntil{};
|
||||
bool claimPending = false, claimHeld = false;
|
||||
// Last used wins: since when each controller has been moving (zero: it isn't).
|
||||
Clock::time_point movingSince[vr::k_unMaxTrackedDeviceCount] = {};
|
||||
// Tilt mode (see top of file).
|
||||
bool leftHeld = false, tilting = false, tiltStart = false, swallowedRight = false;
|
||||
// The mouse's buttons in gaze mode (see the top): leftDown, rightDown as the relay last said;
|
||||
// aimRight, the held-back press is the right button's; chordDrag, a drag the right button
|
||||
// began during the left's held-back press (the first release drops it, the other's is nothing).
|
||||
bool leftDown = false, rightDown = false, aimRight = false, chordDrag = false;
|
||||
bool ignoreLeftUp = false;
|
||||
// The keyboard clicks' keys as the relay last said, and a tilt gaze_right holds during a
|
||||
// keyboard drag (see the top): the head turns the panel, from where it was (keyTiltYaw, keyTiltPitch).
|
||||
bool keyLeftDown = false, keyRightDown = false, keyTilting = false;
|
||||
double keyTiltYaw = 0, keyTiltPitch = 0;
|
||||
double tiltYaw = 0, tiltPitch = 0;
|
||||
double dragDistance = 0, lastDistance = 1.5; // drag lock: distance from the anchor at the press
|
||||
bool onVrSettings = false; // the cursor is on the SteamVR Settings page (kept while dragging)
|
||||
bool catcherHidesHit = false; // the laser-catching dot hides SteamVR's hit dot (Settings page)
|
||||
Clock::time_point dropHoldUntil{}; // after a left release: keep the drag pose this long
|
||||
bool debug = false;
|
||||
std::string lastHit;
|
||||
// The ft-screens panel the left button was pressed on, and where it was then (see the top).
|
||||
std::string pressKey;
|
||||
vr::HmdMatrix34_t pressPose{};
|
||||
Clock::time_point lastDebug{}; // set in Init
|
||||
vr::VROverlayHandle_t systemPointer = vr::k_ulOverlayHandleInvalid; // found in Init
|
||||
Vec3 pivot, tiltOrigin, lastPoint, lastOrigin, lastAim{0, 0, -1};
|
||||
Basis tiltBasis{};
|
||||
bool headsetOff = false; // nobody is wearing the headset (see the main loop)
|
||||
Vec3 anchor;
|
||||
double yaw = 0, pitch = 0;
|
||||
Vec3 followRef{0, 0, -1}; // head follow's reference direction (see the top)
|
||||
Clock::time_point followAt{}; // its last update, for the easing (set in Init)
|
||||
bool following = false; // past the leash: easing toward the head
|
||||
double followLag = 0; // radians the reference trails the head
|
||||
std::chrono::steady_clock::time_point leashOutSince{}; // head past the leash since (delay)
|
||||
// Gaze mode (see the top).
|
||||
struct Gaze {
|
||||
double hy = 0, hp = 0, rhy = 0, rhp = 0; // corrected, and raw
|
||||
Clock::time_point at{};
|
||||
} gz;
|
||||
bool gazeOwns = true; // the pointer follows the gaze; false: the mouse has it
|
||||
bool nudging = false; // the mouse took it from the gaze: the next click may be a lesson
|
||||
double nudgeRawHy = 0, nudgeRawHp = 0, nudgeMoved = 0;
|
||||
vr::HmdMatrix34_t nudgeHead{}, lastHead{};
|
||||
bool haveHead = false, havePoint = false;
|
||||
Clock::time_point nudgeAt{}, retakeSince{};
|
||||
// A held-back press (see the top): aimHeld while the button is down; then the click
|
||||
// (clickPress at the end of the next frame, clickRelease 40 ms later). gazeBack: give
|
||||
// the gaze the pointer again when the press or click is over.
|
||||
vr::TrackedDeviceIndex_t ours = vr::k_unTrackedDeviceIndexInvalid;
|
||||
bool aimHeld = false, clickPress = false, clickRelease = false, gazeBack = false;
|
||||
bool aimHand = false; // the held-back press is a hold's (below): it never turns into a real press
|
||||
Clock::time_point aimSince{}, clickReleaseAt{};
|
||||
// Holds (see "Gaze precision" and "Hands" at the top): a pinch or grip, or a gaze
|
||||
// precision or gaze drag button, held now. What steers the pointer meanwhile (a hand, or
|
||||
// the mouse through its own moves), from where it pointed when the hold began (for a
|
||||
// hand: seen from the eye then, in the room), and the pointer then.
|
||||
HandGestures handFile;
|
||||
PoseHistory poses;
|
||||
enum class Src { None, Hand, Mouse, Head };
|
||||
struct Hold {
|
||||
Src src = Src::None;
|
||||
int side = -1; // a hand's side (0 left, 1 right)
|
||||
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 promote = false; // held still for POINTER_GAZE_HOLD, it becomes a real press (keyboard clicks)
|
||||
bool right = false; // the right button (gaze_right)
|
||||
bool keyDrag = false; // gaze_right pressed while gaze_left aimed: a left press, either key ends it
|
||||
double pressYaw = 0, pressPitch = 0; // the pointer at the press (a quick tap clicks there)
|
||||
Vec3 origin;
|
||||
double refYaw = 0, refPitch = 0, startYaw = 0, startPitch = 0, lastYaw = 0, lastPitch = 0;
|
||||
} hold;
|
||||
// "precision|gazedrag <source> 1|0" from the relay, done in the frame (see Holds).
|
||||
struct DevicePress {
|
||||
std::string source;
|
||||
bool drag, down;
|
||||
};
|
||||
std::vector<DevicePress> devicePresses;
|
||||
// "gazekey left|right 1|0" from the relay (keyboard clicks), done in the frame.
|
||||
struct KeyPress {
|
||||
bool right, down;
|
||||
};
|
||||
std::vector<KeyPress> keyPresses;
|
||||
uint32_t seenBegins[2][2] = {}, seenEnds[2][2] = {}; // [pinch, grip][side], as last read
|
||||
bool handBaseline = false;
|
||||
int handOpens = 0;
|
||||
uint64_t handSeq = 0, handPublished = 0;
|
||||
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{};
|
||||
Clock::time_point lastMove{}, lastHeld{}, pulseAt{};
|
||||
// The left button, as sent to the driver; pressRight: the next press is the right button
|
||||
// instead (gaze_right), heldButton: the one pressed.
|
||||
bool pressRight = false;
|
||||
std::string heldButton = "trigger";
|
||||
bool confirmLesson = false; // a keyboard click's quick tap: a lesson with no correction (see the top)
|
||||
// The gaze calibration panel is up until then ("calpanel 1"; see the top); calOpened: it just
|
||||
// came up, so a press in progress ends without a click.
|
||||
Clock::time_point calPanelUntil{};
|
||||
bool calOpened = false;
|
||||
Clock::time_point lastSlow{}; // set in Init
|
||||
|
||||
void ApplyConfig();
|
||||
void Init();
|
||||
void Wake(Clock::time_point t);
|
||||
void PressLeft();
|
||||
void ReleaseLeft();
|
||||
bool CanAim();
|
||||
void AimStart(bool right);
|
||||
void ChordDrop();
|
||||
void LeftButton(bool down);
|
||||
bool RightButton(bool down);
|
||||
bool MouseMoveHeld();
|
||||
void SendPose(Vec3 originStanding, const Basis &b);
|
||||
std::pair<bool, Vec3> HeadPos();
|
||||
void Borrow();
|
||||
void GiveBack();
|
||||
std::string FindPanel(const char *key, Panel &p, Vec3 &eye);
|
||||
void GrabProbe(const char *key);
|
||||
std::string Place(const char *key, Vec3 target, const Basis &bt, double grabBelow);
|
||||
// Nearest's answer: the nearest overlay along a ray.
|
||||
struct Hit {
|
||||
double along = 1e9;
|
||||
std::string key;
|
||||
bool scene = false;
|
||||
Vec3 point, normal;
|
||||
};
|
||||
// Where the cursor is this frame (FindCursor), for DrawDot and SendRay.
|
||||
struct Spot {
|
||||
bool dragging = false;
|
||||
Vec3 dir, point;
|
||||
double best = 1e9, distance = 0;
|
||||
bool onEdge = false, occluded = false, onScene = false, onPanel = false;
|
||||
};
|
||||
|
||||
// The main loop's sections, in the order main calls them, and what they share.
|
||||
void HeadsetOff();
|
||||
void GazeAwake();
|
||||
void RelayCommands();
|
||||
void Command(const char *buf, const sockaddr_un &sender, socklen_t senderLen);
|
||||
void ReadFrame(Frame &frame);
|
||||
void ClaimPulse(const Frame &frame);
|
||||
void PauseOverlayList();
|
||||
void LaserMode();
|
||||
void HandRole(const Frame &frame);
|
||||
void LastUsedWins(const Frame &frame);
|
||||
void Recenter(const Frame &frame);
|
||||
void GazeMode(const Frame &frame);
|
||||
void Hands(const Frame &frame);
|
||||
bool HandAngles(const float p[3], uint64_t t_ns, const Vec3 &from, double &hy, double &hp);
|
||||
void EndHold(bool lost);
|
||||
void StartHold(bool press, Clock::time_point tnow);
|
||||
void Steer(double hy, double hp, double deadzone, double gain, Clock::time_point tnow);
|
||||
void BeginHold(int side, bool grip, const fh_pinch_t &g, Clock::time_point tnow);
|
||||
void GazePrecisionButtons(const Frame &frame);
|
||||
bool HeadAngles(const vr::TrackedDevicePose_t &hmd, double &hy, double &hp);
|
||||
void KeyboardClicks(const Frame &frame);
|
||||
void CalPanelOpened();
|
||||
void HeadSteer(const Frame &frame);
|
||||
void PinchesAndGrips(Frame &frame);
|
||||
void HandsStopped(const Frame &frame);
|
||||
void MarkHeld(const Frame &frame);
|
||||
void HeadFollow(const Frame &frame);
|
||||
void SlowWork();
|
||||
void Cursor(const Frame &frame);
|
||||
void Tilt(const Frame &frame);
|
||||
Hit Nearest(Vec3 from, Vec3 d);
|
||||
Spot FindCursor(const Frame &frame);
|
||||
void DrawDot(const Frame &frame, const Spot &spot);
|
||||
void SendRay(const Frame &frame, const Spot &spot);
|
||||
void HeldBackPress(const Frame &frame);
|
||||
void KeyboardClickHold(const Frame &frame);
|
||||
void Click(const Frame &frame);
|
||||
void PollControllerButtons();
|
||||
bool SteamVRQuit();
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
// ft-pointer's settings, from ~/.config/frametop.conf. "The top" in comments here is the
|
||||
// header comment of ft-pointer.cpp, which describes every setting.
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
inline std::map<std::string, std::string> ReadConfig() {
|
||||
std::map<std::string, std::string> conf;
|
||||
const char *home = std::getenv("HOME");
|
||||
std::ifstream in(std::string(home ? home : "") + "/.config/frametop.conf");
|
||||
std::string line;
|
||||
while (std::getline(in, line)) {
|
||||
line = line.substr(0, line.find('#'));
|
||||
const auto eq = line.find('=');
|
||||
if (eq == std::string::npos) continue;
|
||||
auto trim = [](std::string s) {
|
||||
s.erase(0, s.find_first_not_of(" \t"));
|
||||
s.erase(s.find_last_not_of(" \t") + 1);
|
||||
return s;
|
||||
};
|
||||
conf[trim(line.substr(0, eq))] = trim(line.substr(eq + 1));
|
||||
}
|
||||
return conf;
|
||||
}
|
||||
|
||||
inline double ConfDouble(const std::map<std::string, std::string> &c, const char *key, double fallback) {
|
||||
auto it = c.find(key);
|
||||
return it == c.end() ? fallback : std::atof(it->second.c_str());
|
||||
}
|
||||
|
||||
// POINTER_IGNORE: overlay keys the pointer passes through, as if they weren't there
|
||||
// (display-only panels such as a performance overlay). Comma-separated shell patterns
|
||||
// (fnmatch, no escapes), so "vendor.app*" covers an app's overlays.
|
||||
inline std::vector<std::string> ParseIgnore(const std::string &list) {
|
||||
std::vector<std::string> out;
|
||||
size_t at = 0;
|
||||
while (at <= list.size()) {
|
||||
const size_t comma = std::min(list.find(',', at), list.size());
|
||||
std::string item = list.substr(at, comma - at);
|
||||
item.erase(0, item.find_first_not_of(" \t"));
|
||||
item.erase(item.find_last_not_of(" \t") + 1);
|
||||
if (!item.empty()) out.push_back(item);
|
||||
at = comma + 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
struct PointerConfig {
|
||||
double freeDistance = 1.5, cursorDeg = 0.4, originFraction = 0.95, originMargin = 0.15, sceneRadius = 0.5,
|
||||
edgeReach = 0.3, grabOffset = 0.075,
|
||||
slideSpeed = 0.5, leashDeg = 10, leashReturn = 0.2, leashDelay = 0.2, followReach = 70;
|
||||
// POINTER_FOLLOW and POINTER_GAZE as read. They turn head follow and gaze mode on or off
|
||||
// only when they change, so a "follow" or "gaze" command wins until then (ApplyConfig in
|
||||
// ft-pointer.cpp).
|
||||
bool follow = false, gaze = false;
|
||||
double gazeRetake = 5, gazeNudgeMax = 55, gazeHold = 0.5, gazeShow = 1;
|
||||
bool gazeDotAlways = true; // POINTER_GAZE_DOT (see the top)
|
||||
double headDeadzone = 0.5, keyTap = 0.25; // POINTER_HEAD_DEADZONE, POINTER_KEY_TAP (keyboard clicks, see the top)
|
||||
// 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;
|
||||
// Gaze precision (see the top): POINTER_GAZE_MOUSE (precision: the mouse's left button
|
||||
// holds back its press in gaze mode; direct: it clicks at once), POINTER_ROLE.
|
||||
bool gazeMousePrecision = true;
|
||||
bool gazeMouseHeld = true; // POINTER_GAZE_MOUSE_MOVE (see the top)
|
||||
std::string role = "right";
|
||||
double pickupScale = 1; // POINTER_CONTROLLER_PICKUP: scales the controller-moved limits
|
||||
std::vector<std::string> ignore; // POINTER_IGNORE (see ParseIgnore)
|
||||
|
||||
void Load(const std::map<std::string, std::string> &conf) {
|
||||
freeDistance = std::clamp(ConfDouble(conf, "POINTER_DISTANCE", 1.5), 0.3, 10.0);
|
||||
cursorDeg = std::clamp(ConfDouble(conf, "POINTER_CURSOR_DEG", 0.4), 0.05, 5.0);
|
||||
originFraction = std::clamp(ConfDouble(conf, "POINTER_ORIGIN_FRACTION", 0.95), 0.0, 0.98);
|
||||
originMargin = std::clamp(ConfDouble(conf, "POINTER_ORIGIN_MARGIN", 0.15), 0.0, 1.0);
|
||||
sceneRadius = std::clamp(ConfDouble(conf, "POINTER_SCENE_RADIUS", 0.5), 0.05, 2.0);
|
||||
edgeReach = std::clamp(ConfDouble(conf, "POINTER_EDGE_REACH", 0.3), 0.0, 2.0);
|
||||
grabOffset = std::clamp(ConfDouble(conf, "LAYOUT_GRAB_OFFSET", 0.075), 0.0, 1.0);
|
||||
slideSpeed = std::clamp(ConfDouble(conf, "LAYOUT_SLIDE_SPEED", 0.5), 0.02, 2.0);
|
||||
leashDeg = std::clamp(ConfDouble(conf, "POINTER_LEASH_DEG", 10), 0.0, 90.0);
|
||||
leashReturn = std::clamp(ConfDouble(conf, "POINTER_LEASH_RETURN", 0.2), 0.0, 5.0);
|
||||
leashDelay = std::clamp(ConfDouble(conf, "POINTER_LEASH_DELAY", 0.2), 0.0, 5.0);
|
||||
followReach = std::clamp(ConfDouble(conf, "POINTER_FOLLOW_REACH", 70), 10.0, 89.0);
|
||||
follow = ConfDouble(conf, "POINTER_FOLLOW", 0) != 0;
|
||||
gazeRetake = std::clamp(ConfDouble(conf, "POINTER_GAZE_RETAKE", 5), 1.0, 45.0);
|
||||
gazeNudgeMax = std::clamp(ConfDouble(conf, "POINTER_GAZE_NUDGE_MAX", 55), 1.0, 110.0);
|
||||
gazeHold = std::clamp(ConfDouble(conf, "POINTER_GAZE_HOLD", 0.5), 0.1, 5.0);
|
||||
gazeShow = std::clamp(ConfDouble(conf, "POINTER_GAZE_SHOW", 1), 0.0, 30.0);
|
||||
headDeadzone = std::clamp(ConfDouble(conf, "POINTER_HEAD_DEADZONE", 0.5), 0.0, 5.0);
|
||||
keyTap = std::clamp(ConfDouble(conf, "POINTER_KEY_TAP", 0.25), 0.0, 1.0);
|
||||
const auto gd = conf.find("POINTER_GAZE_DOT");
|
||||
gazeDotAlways = gd == conf.end() || gd->second != "moving";
|
||||
gaze = ConfDouble(conf, "POINTER_GAZE", 0) != 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);
|
||||
gripGain = std::clamp(ConfDouble(conf, "POINTER_GRIP_GAIN", 1.0), 0.05, 3.0);
|
||||
handBelow = std::clamp(ConfDouble(conf, "POINTER_GRIP_BELOW", 0.35), 0.05, 1.0);
|
||||
typingHold = std::clamp(ConfDouble(conf, "POINTER_PINCH_TYPING", 1.0), 0.0, 5.0);
|
||||
const auto gm = conf.find("POINTER_GAZE_MOUSE");
|
||||
gazeMousePrecision = gm == conf.end() || gm->second != "direct";
|
||||
const auto mm = conf.find("POINTER_GAZE_MOUSE_MOVE");
|
||||
gazeMouseHeld = mm == conf.end() || mm->second != "free";
|
||||
const auto ro = conf.find("POINTER_ROLE");
|
||||
role = ro != conf.end() && (ro->second == "left" || ro->second == "stylus") ? 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);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,474 @@
|
||||
// ft-pointer: recenter, head follow, the overlay handles, and the cursor (see "Cursor model" at
|
||||
// the top).
|
||||
// "The top" in comments here is the header comment of ft-pointer.cpp.
|
||||
#include "pointer.h"
|
||||
|
||||
namespace {
|
||||
|
||||
bool Ignored(const std::vector<std::string> &patterns, const std::string &key) {
|
||||
for (const auto &p : patterns)
|
||||
if (fnmatch(p.c_str(), key.c_str(), FNM_NOESCAPE) == 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
vr::HmdMatrix34_t Billboard(Vec3 at, Vec3 eye) {
|
||||
// Overlay faces +Z; point +Z at the eye, keep +Y roughly up.
|
||||
const Vec3 z = Normalize(eye - at);
|
||||
const Vec3 x = Normalize(Cross({0, 1, 0}, z));
|
||||
const Vec3 y = Cross(z, x);
|
||||
vr::HmdMatrix34_t m{};
|
||||
const Vec3 cols[3] = {x, y, z};
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
m.m[0][c] = float(cols[c].x);
|
||||
m.m[1][c] = float(cols[c].y);
|
||||
m.m[2][c] = float(cols[c].z);
|
||||
}
|
||||
m.m[0][3] = float(at.x);
|
||||
m.m[1][3] = float(at.y);
|
||||
m.m[2][3] = float(at.z);
|
||||
return m;
|
||||
}
|
||||
|
||||
// Unit vector v, turned toward unit vector `center` until it's at most maxRad from it.
|
||||
Vec3 PullWithin(Vec3 v, Vec3 center, double maxRad) {
|
||||
if (std::acos(std::clamp(Dot(v, center), -1.0, 1.0)) <= maxRad) return v;
|
||||
Vec3 axis = Cross(center, v);
|
||||
if (Length(axis) < 1e-9) axis = Cross(center, {0, 1, 0}); // opposite: any perpendicular
|
||||
return RotateAbout(center, Normalize(axis), maxRad);
|
||||
}
|
||||
|
||||
// Unit direction with its pitch limited to +-maxDeg (AimBasis needs it off vertical).
|
||||
Vec3 LimitPitch(Vec3 d, double maxDeg) {
|
||||
const double pitch = std::clamp(std::asin(std::clamp(d.y, -1.0, 1.0)) * 180 / M_PI, -maxDeg, maxDeg);
|
||||
return Direction(std::atan2(-d.x, -d.z) * 180 / M_PI, pitch);
|
||||
}
|
||||
|
||||
// SteamVR Settings page (see the top): the laser starts this far from the eye, the dot is
|
||||
// drawn this far out, and the laser-catching dot sits this far out (metres).
|
||||
constexpr double SETTINGS_ORIGIN = 0.25, SETTINGS_DOT = 0.6, SETTINGS_CATCHER = 8.0;
|
||||
|
||||
// Whether the line of sight from `eye` along `d` crosses the SteamVR Settings page, drawn by
|
||||
// the dashboard's scene-graph panel (valve.steam.gamepadui.frame.menu.N, transform t); see
|
||||
// "SteamVR Settings" at the top. The page has no size in OpenVR, so this is its area as
|
||||
// measured on the Frame, generously: in metres from the panel's origin, which is near the
|
||||
// page's left edge, it ran from about -0.35 (the sidebar) to 1.15 across and +-0.4 up and
|
||||
// down, with the transform scaled 0.369. Kept in the transform's units so it scales with it.
|
||||
bool OnSettingsPage(const vr::HmdMatrix34_t &t, Vec3 eye, Vec3 d) {
|
||||
const Vec3 c = Position(t), x{t.m[0][0], t.m[1][0], t.m[2][0]}, y{t.m[0][1], t.m[1][1], t.m[2][1]},
|
||||
z{t.m[0][2], t.m[1][2], t.m[2][2]};
|
||||
const double sx = Dot(x, x), sy = Dot(y, y), denom = Dot(d, z);
|
||||
if (sx < 1e-9 || sy < 1e-9 || std::fabs(denom) < 1e-6) return false;
|
||||
const double along = Dot(c - eye, z) / denom;
|
||||
if (along <= 0) return false;
|
||||
const Vec3 off = eye + d * along - c;
|
||||
const double u = Dot(off, x) / sx, v = Dot(off, y) / sy; // in the transform's units
|
||||
return u >= -1.35 && u <= 3.4 && std::fabs(v) <= 1.25;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// A pending recenter (the command, a wake, a first move): the anchor goes to the eye, aimed where the
|
||||
// head faces.
|
||||
void Pointer::Recenter(const Frame &frame) {
|
||||
const vr::TrackedDevicePose_t &hmd = frame.Hmd();
|
||||
const auto &hm = hmd.mDeviceToAbsoluteTracking.m;
|
||||
const Vec3 &eye = frame.eye;
|
||||
if (recenter && hmd.bPoseIsValid) {
|
||||
anchor = eye;
|
||||
const Vec3 f{-hm[0][2], -hm[1][2], -hm[2][2]};
|
||||
yaw = std::atan2(-f.x, -f.z) * 180 / M_PI;
|
||||
pitch = std::asin(std::clamp(f.y, -1.0, 1.0)) * 180 / M_PI;
|
||||
anchored = true;
|
||||
recenter = false;
|
||||
followReset = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Head follow (see the top): past the leash (for the delay), ease the reference to the
|
||||
// head's facing, and turn the cursor with it. Not in gaze mode: the gaze places it.
|
||||
void Pointer::HeadFollow(const Frame &frame) {
|
||||
const vr::TrackedDevicePose_t &hmd = frame.Hmd();
|
||||
const auto &hm = hmd.mDeviceToAbsoluteTracking.m;
|
||||
const auto tnow = frame.tnow;
|
||||
const Vec3 &eye = frame.eye;
|
||||
if (follow && !gazeOn && active && anchored && hmd.bPoseIsValid) {
|
||||
const Vec3 head = LimitPitch(Vec3{-hm[0][2], -hm[1][2], -hm[2][2]}, 85);
|
||||
if (followReset) {
|
||||
followRef = head, followLag = 0, leashOutSince = {};
|
||||
followReset = following = false;
|
||||
}
|
||||
const double dt = std::min(0.1, std::chrono::duration<double>(tnow - followAt).count());
|
||||
const double leash = cfg.leashDeg * M_PI / 180;
|
||||
const double lag = std::acos(std::clamp(Dot(followRef, head), -1.0, 1.0));
|
||||
double keep = lag; // how far the reference stays behind the head after this frame
|
||||
if (leash <= 0) {
|
||||
keep = 0; // head-locked
|
||||
} else if (following) {
|
||||
keep = lag * (cfg.leashReturn > 0 ? std::exp(-dt / cfg.leashReturn) : 0.0);
|
||||
// A fast turn drags it at the leash's end; past it already (after the delay), it
|
||||
// can't fall further behind, and it closes in from there without a jump.
|
||||
keep = std::min(keep, std::max(leash, followLag));
|
||||
if (keep < 0.05 * M_PI / 180) keep = 0, following = false; // landed on the facing
|
||||
} else if (lag > leash) {
|
||||
if (leashOutSince == decltype(leashOutSince){}) leashOutSince = tnow;
|
||||
if (std::chrono::duration<double>(tnow - leashOutSince).count() >= cfg.leashDelay)
|
||||
following = true, leashOutSince = {};
|
||||
} else {
|
||||
leashOutSince = {}; // back inside before the delay: a glance
|
||||
}
|
||||
followLag = keep;
|
||||
const Vec3 ref = LimitPitch(PullWithin(followRef, head, keep), 85);
|
||||
if (!leftHeld && tnow >= dropHoldUntil) {
|
||||
// The cursor keeps its offset from the reference (frames without roll).
|
||||
Vec3 d = FromBasis(AimBasis(ref), ToBasis(AimBasis(followRef), Direction(yaw, pitch)));
|
||||
d = PullWithin(Normalize(d), ref, cfg.followReach * M_PI / 180);
|
||||
yaw = std::atan2(-d.x, -d.z) * 180 / M_PI;
|
||||
pitch = std::clamp(std::asin(std::clamp(d.y, -1.0, 1.0)) * 180 / M_PI, -85.0, 85.0);
|
||||
// The ray origin closes in on the eye as the reference does on the facing.
|
||||
anchor = eye + (anchor - eye) * (leash <= 0 ? 0.0 : lag > 1e-6 ? keep / lag : following ? 0.0 : 1.0);
|
||||
}
|
||||
followRef = ref;
|
||||
}
|
||||
followAt = tnow;
|
||||
}
|
||||
|
||||
// Slow work, once a second: overlay handles, our device index, laser width.
|
||||
void Pointer::SlowWork() {
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (now - lastSlow > std::chrono::seconds(1)) {
|
||||
lastSlow = now;
|
||||
handles.clear();
|
||||
for (const auto &key : overlays.Keys()) {
|
||||
if (Ignored(cfg.ignore, key)) continue;
|
||||
vr::VROverlayHandle_t h;
|
||||
if (overlay->FindOverlay(key.c_str(), &h) != vr::VROverlayError_None) continue;
|
||||
handles[key] = h;
|
||||
// Scene-graph overlays are placed absolutely. Other overlays can report no
|
||||
// texture too (gamescope's app panels, placed as dashboard tabs, share theirs
|
||||
// from another process), and ComputeOverlayIntersection handles those.
|
||||
uint32_t tw = 0, th = 0;
|
||||
overlay->GetOverlayTextureSize(h, &tw, &th);
|
||||
vr::VROverlayTransformType tt = vr::VROverlayTransform_Invalid;
|
||||
overlay->GetOverlayTransformType(h, &tt);
|
||||
// ft-screens' panels (frametop.screen.N, floating windows with their popups,
|
||||
// frametop.float.N..., the keyboard) are 0x0 and absolute too (a shared
|
||||
// texture), but they're real panels of any size.
|
||||
sceneGraph[key] = (tw == 0 || th == 0) && tt == vr::VROverlayTransform_Absolute &&
|
||||
key.rfind("frametop.", 0) != 0;
|
||||
}
|
||||
ours = vr::k_unTrackedDeviceIndexInvalid;
|
||||
for (vr::TrackedDeviceIndex_t i = 0; i < vr::k_unMaxTrackedDeviceCount; ++i) {
|
||||
char type[64] = "";
|
||||
sys->GetStringTrackedDeviceProperty(i, vr::Prop_ControllerType_String, type, sizeof type);
|
||||
if (std::strcmp(type, "ft_pointer") == 0) ours = i;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (now - lastVisible > std::chrono::milliseconds(50) || visible.size() != handles.size()) {
|
||||
lastVisible = now;
|
||||
visible.clear();
|
||||
for (const auto &[key, h] : handles) visible[key] = overlay->IsOverlayVisible(h);
|
||||
}
|
||||
}
|
||||
|
||||
// The cursor: tilting a grabbed panel, or pointing (find the spot, draw the dot, send the ray).
|
||||
void Pointer::Cursor(const Frame &frame) {
|
||||
const vr::TrackedDevicePose_t &hmd = frame.Hmd();
|
||||
if (active && anchored && hmd.bPoseIsValid && tilting) {
|
||||
Tilt(frame);
|
||||
} else if (active && anchored && hmd.bPoseIsValid) {
|
||||
const Spot spot = FindCursor(frame);
|
||||
DrawDot(frame, spot);
|
||||
SendRay(frame, spot);
|
||||
}
|
||||
}
|
||||
|
||||
void Pointer::Tilt(const Frame &frame) {
|
||||
const vr::TrackedDevicePose_t &hmd = frame.Hmd();
|
||||
const Vec3 &eye = frame.eye;
|
||||
const vr::TrackedDevicePose_t &hmdRaw = frame.hmdRaw;
|
||||
// Rotate the device around the grab point; the grabbed panel turns with it.
|
||||
if (tiltStart) {
|
||||
pivot = lastPoint;
|
||||
tiltOrigin = lastOrigin;
|
||||
tiltBasis = AimBasis(lastAim);
|
||||
tiltStart = false; // angles carry on from any earlier tilt in this drag
|
||||
overlay->HideOverlay(cursor);
|
||||
overlay->HideOverlay(marker);
|
||||
}
|
||||
const double yr = tiltYaw * M_PI / 180, pr = tiltPitch * M_PI / 180;
|
||||
const Vec3 up{0, 1, 0}, side = tiltBasis.x;
|
||||
auto turn = [&](Vec3 v) { return RotateAbout(RotateAbout(v, side, pr), up, yr); };
|
||||
const Vec3 originStanding = pivot + turn(tiltOrigin - pivot);
|
||||
const Basis b{turn(tiltBasis.x), turn(tiltBasis.y), turn(tiltBasis.z)};
|
||||
const auto &S = hmd.mDeviceToAbsoluteTracking, &R = hmdRaw.mDeviceToAbsoluteTracking;
|
||||
auto toRaw = [&](Vec3 v) { return Rotate(R, RotateInverse(S, v)); }; // directions: raw <- standing
|
||||
const Vec3 originRaw = Position(R) + toRaw(originStanding - eye);
|
||||
double q[4];
|
||||
BasisQuat({toRaw(b.x), toRaw(b.y), toRaw(b.z)}, q);
|
||||
char msg[200];
|
||||
std::snprintf(msg, sizeof msg, "posq %.5f %.5f %.5f %.6f %.6f %.6f %.6f", originRaw.x, originRaw.y,
|
||||
originRaw.z, q[0], q[1], q[2], q[3]);
|
||||
SendTo(out, "ft_pointer", msg);
|
||||
}
|
||||
|
||||
// Nearest visible overlay along a ray (frozen while dragging).
|
||||
Pointer::Hit Pointer::Nearest(Vec3 from, Vec3 d) {
|
||||
Hit h;
|
||||
for (const auto &[key, handle] : handles) {
|
||||
if (!visible[key]) continue;
|
||||
if (sceneGraph[key]) {
|
||||
// Plane test: overlay origin and its +Z normal, within sceneRadius of the origin.
|
||||
vr::ETrackingUniverseOrigin uo;
|
||||
vr::HmdMatrix34_t t{};
|
||||
if (overlay->GetOverlayTransformAbsolute(handle, &uo, &t) != vr::VROverlayError_None) continue;
|
||||
const Vec3 center = Position(t), normal{t.m[0][2], t.m[1][2], t.m[2][2]};
|
||||
const double denom = Dot(d, normal);
|
||||
if (std::fabs(denom) < 1e-4) continue;
|
||||
const double along = Dot(center - from, normal) / denom;
|
||||
const Vec3 at = from + d * along;
|
||||
if (along > 0.05 && along < h.along && std::sqrt(Dot(at - center, at - center)) <= cfg.sceneRadius)
|
||||
h.along = along, h.key = key, h.scene = true, h.point = at, h.normal = normal;
|
||||
continue;
|
||||
}
|
||||
vr::VROverlayIntersectionParams_t params{};
|
||||
params.vSource = {float(from.x), float(from.y), float(from.z)};
|
||||
params.vDirection = {float(d.x), float(d.y), float(d.z)};
|
||||
params.eOrigin = vr::TrackingUniverseStanding;
|
||||
vr::VROverlayIntersectionResults_t r{};
|
||||
if (overlay->ComputeOverlayIntersection(handle, ¶ms, &r) && r.fDistance > 0.05f &&
|
||||
r.fDistance < h.along) {
|
||||
h.along = r.fDistance, h.key = key, h.scene = false;
|
||||
h.point = {r.vPoint.v[0], r.vPoint.v[1], r.vPoint.v[2]};
|
||||
h.normal = {r.vNormal.v[0], r.vNormal.v[1], r.vNormal.v[2]};
|
||||
}
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
// Where the cursor is this frame: on a panel, a scene-graph plane, or a panel's edge, or out in free
|
||||
// space.
|
||||
Pointer::Spot Pointer::FindCursor(const Frame &frame) {
|
||||
const auto tnow = frame.tnow;
|
||||
const Vec3 &eye = frame.eye;
|
||||
const bool dragging = leftHeld || tnow < dropHoldUntil;
|
||||
if (!dragging) tiltYaw = tiltPitch = 0; // drop finished: back to plain pointing
|
||||
const Vec3 dir = Direction(yaw, pitch);
|
||||
Hit first;
|
||||
if (!dragging) first = Nearest(anchor, dir);
|
||||
double best = first.along;
|
||||
std::string bestKey = first.key;
|
||||
bool bestScene = first.scene;
|
||||
Vec3 bestPoint = first.point, bestNormal = first.normal;
|
||||
bool onEdge = false;
|
||||
if (!dragging && best < 1e8 && !bestScene) {
|
||||
edgeKey = bestKey, edgePoint = bestPoint, edgeNormal = Normalize(bestNormal), edgeLast = bestPoint;
|
||||
} else if (!dragging && best >= 1e8 && !edgeKey.empty() && visible[edgeKey]) {
|
||||
// Just off a panel: stay on its plane (see "Panel edges" at the top).
|
||||
const double denom = Dot(dir, edgeNormal);
|
||||
if (std::fabs(denom) > 1e-4) {
|
||||
const double along = Dot(edgePoint - anchor, edgeNormal) / denom;
|
||||
const Vec3 at = anchor + dir * along;
|
||||
if (along > 0.05 && std::sqrt(Dot(at - edgeLast, at - edgeLast)) <= cfg.edgeReach) {
|
||||
best = along;
|
||||
bestKey = edgeKey;
|
||||
onEdge = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Held on one of ft-screens' panels that stays where it is: onto whichever of them
|
||||
// the ray meets (see the top).
|
||||
if (leftHeld && !pressKey.empty()) {
|
||||
vr::ETrackingUniverseOrigin uo;
|
||||
vr::HmdMatrix34_t now{};
|
||||
auto it = handles.find(pressKey);
|
||||
bool still = it != handles.end() &&
|
||||
overlay->GetOverlayTransformAbsolute(it->second, &uo, &now) == vr::VROverlayError_None;
|
||||
for (int i = 0; still && i < 3; ++i)
|
||||
for (int j = 0; j < 4; ++j)
|
||||
if (std::fabs(now.m[i][j] - pressPose.m[i][j]) > 0.001f) still = false;
|
||||
if (still) {
|
||||
Hit h;
|
||||
for (const auto &[key, handle] : handles) {
|
||||
if (!visible[key] || !FramePanel(key)) continue;
|
||||
vr::VROverlayIntersectionParams_t params{};
|
||||
params.vSource = {float(anchor.x), float(anchor.y), float(anchor.z)};
|
||||
params.vDirection = {float(dir.x), float(dir.y), float(dir.z)};
|
||||
params.eOrigin = vr::TrackingUniverseStanding;
|
||||
vr::VROverlayIntersectionResults_t r{};
|
||||
if (overlay->ComputeOverlayIntersection(handle, ¶ms, &r) && r.fDistance > 0.05f &&
|
||||
r.fDistance < h.along)
|
||||
h.along = r.fDistance, h.key = key;
|
||||
}
|
||||
if (h.along < 1e8) dragDistance = h.along, lastHit = h.key;
|
||||
} else {
|
||||
pressKey.clear(); // carried: the lock holds for the rest of this press
|
||||
}
|
||||
}
|
||||
// While dragging: keep the press-time distance and show the non-interactive marker.
|
||||
// On a scene-graph plane or a panel's edge: the laser-catching dot goes 5 cm behind it.
|
||||
double distance = dragging ? dragDistance : (best < 1e8 ? best : cfg.freeDistance);
|
||||
Vec3 point = anchor + dir * distance;
|
||||
bool occluded = false;
|
||||
if (!dragging) {
|
||||
// The cursor lands on what you see under it: the ray above starts at the anchor,
|
||||
// not the eye, so after leaning it can pick a panel that something nearer
|
||||
// covers from where you are now (panels close together in view, at different
|
||||
// depths). Anything in front of the point on the eye's line of sight wins.
|
||||
const double toPoint = std::sqrt(Dot(point - eye, point - eye));
|
||||
const Hit front = Nearest(eye, Normalize(point - eye));
|
||||
if (front.along < toPoint - 0.02) {
|
||||
occluded = true;
|
||||
bestKey = front.key, bestScene = front.scene;
|
||||
point = front.point;
|
||||
if (!front.scene) edgeKey = front.key, edgePoint = front.point, edgeNormal = Normalize(front.normal),
|
||||
edgeLast = front.point;
|
||||
distance = std::sqrt(Dot(point - anchor, point - anchor));
|
||||
best = distance;
|
||||
onEdge = false;
|
||||
}
|
||||
lastDistance = distance, lastHit = bestKey;
|
||||
}
|
||||
const bool onScene = !dragging && ((bestScene && best < 1e8) || onEdge);
|
||||
const bool onPanel = dragging || (best < 1e8 && !onScene);
|
||||
Spot s;
|
||||
s.dragging = dragging, s.dir = dir, s.point = point, s.best = best, s.distance = distance;
|
||||
s.onEdge = onEdge, s.occluded = occluded, s.onScene = onScene, s.onPanel = onPanel;
|
||||
return s;
|
||||
}
|
||||
|
||||
// Our dot where the cursor is (see "Looks" and "SteamVR Settings" at the top).
|
||||
void Pointer::DrawDot(const Frame &frame, const Spot &spot) {
|
||||
const auto tnow = frame.tnow;
|
||||
const Vec3 &eye = frame.eye;
|
||||
const bool dragging = spot.dragging, onScene = spot.onScene, onPanel = spot.onPanel;
|
||||
const Vec3 dir = spot.dir, point = spot.point;
|
||||
const Vec3 sight = Normalize(point - eye);
|
||||
if (!dragging) {
|
||||
// SteamVR Settings (see the top): the dashboard's main panel is hidden and its
|
||||
// scene-graph panel shows the page.
|
||||
onVrSettings = false;
|
||||
const auto mainIt = visible.find("valve.steam.gamepadui.main");
|
||||
if (overlay->IsDashboardVisible() && !(mainIt != visible.end() && mainIt->second)) {
|
||||
for (const auto &[key, handle] : handles) {
|
||||
if (!visible[key] || key.rfind("valve.steam.gamepadui.frame.menu.", 0) != 0) continue;
|
||||
vr::ETrackingUniverseOrigin uo;
|
||||
vr::HmdMatrix34_t t{};
|
||||
if (overlay->GetOverlayTransformAbsolute(handle, &uo, &t) == vr::VROverlayError_None &&
|
||||
OnSettingsPage(t, eye, sight))
|
||||
onVrSettings = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (onVrSettings != catcherHidesHit) {
|
||||
overlay->SetOverlayFlag(cursor, vr::VROverlayFlags_HideLaserIntersection, onVrSettings);
|
||||
catcherHidesHit = onVrSettings;
|
||||
}
|
||||
|
||||
if (onVrSettings) {
|
||||
// The dot close in front of the page, which is nearer than any guess of ours;
|
||||
// the laser-catching dot far behind everything, invisible, so it never covers
|
||||
// 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 && !cfg.gazeDotAlways) {
|
||||
auto secs = [&](Clock::time_point t) { return std::chrono::duration<double>(tnow - t).count(); };
|
||||
alpha = std::clamp(1 - std::min(secs(lastMove) - cfg.gazeShow, secs(lastHeld)) / 0.25, 0.0, 1.0);
|
||||
}
|
||||
if (tnow < calPanelUntil) alpha = 0;
|
||||
overlay->SetOverlayAlpha(marker, float(alpha));
|
||||
overlay->SetOverlayWidthInMeters(marker, float(2 * SETTINGS_DOT * std::tan(cfg.cursorDeg * M_PI / 360)));
|
||||
auto mm = Billboard(near, eye);
|
||||
overlay->SetOverlayTransformAbsolute(marker, vr::TrackingUniverseStanding, &mm);
|
||||
overlay->SetOverlayAlpha(cursor, 0);
|
||||
overlay->SetOverlayWidthInMeters(cursor, float(2 * SETTINGS_CATCHER * std::tan(cfg.cursorDeg * M_PI / 360)));
|
||||
auto mc = Billboard(far, eye);
|
||||
overlay->SetOverlayTransformAbsolute(cursor, vr::TrackingUniverseStanding, &mc);
|
||||
overlay->ShowOverlay(marker);
|
||||
overlay->ShowOverlay(cursor);
|
||||
} else {
|
||||
// On a panel: the non-interactive marker, pulled 5 mm toward the eye so it
|
||||
// draws on top. In free space: the interactive dot the laser lands on.
|
||||
const vr::VROverlayHandle_t show = onPanel ? marker : cursor, hide = onPanel ? cursor : marker;
|
||||
const Vec3 at = onPanel ? point + Normalize(eye - point) * 0.005 : onScene ? point + dir * 0.05 : point;
|
||||
const double dist = std::sqrt(Dot(at - eye, at - eye));
|
||||
// Gaze mode: a pulse for each click, and with POINTER_GAZE_DOT=moving, shown only
|
||||
// while something moves it or a press holds it (see the top); transparent
|
||||
// otherwise, the laser still lands on it.
|
||||
double scale = 1, alpha = 1;
|
||||
if (gazeOn) {
|
||||
auto secs = [&](Clock::time_point t) { return std::chrono::duration<double>(tnow - t).count(); };
|
||||
alpha = cfg.gazeDotAlways ? 1.0 : std::clamp(1 - std::min(secs(lastMove) - cfg.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);
|
||||
alpha = std::max(alpha, std::clamp((0.6 - pulse) / 0.3, 0.0, 1.0));
|
||||
}
|
||||
}
|
||||
if (tnow < calPanelUntil) alpha = 0; // the calibration panel is up (see the top)
|
||||
overlay->SetOverlayAlpha(show, float(alpha));
|
||||
overlay->SetOverlayWidthInMeters(show, float(2 * dist * std::tan(scale * cfg.cursorDeg * M_PI / 360)));
|
||||
auto m = Billboard(at, eye);
|
||||
overlay->SetOverlayTransformAbsolute(show, vr::TrackingUniverseStanding, &m);
|
||||
overlay->ShowOverlay(show);
|
||||
overlay->HideOverlay(hide);
|
||||
}
|
||||
}
|
||||
|
||||
void Pointer::SendRay(const Frame &frame, const Spot &spot) {
|
||||
const vr::TrackedDevicePose_t &hmd = frame.Hmd();
|
||||
const auto tnow = frame.tnow;
|
||||
const Vec3 &eye = frame.eye;
|
||||
const vr::TrackedDevicePose_t &hmdRaw = frame.hmdRaw;
|
||||
const bool dragging = spot.dragging, occluded = spot.occluded, onEdge = spot.onEdge, onScene = spot.onScene;
|
||||
const double best = spot.best, distance = spot.distance;
|
||||
const Vec3 point = spot.point;
|
||||
// Controller ray: from the eye, aimed at the cursor point, converted from the
|
||||
// standing universe to raw tracking space via the HMD's pose in both.
|
||||
const Vec3 aimStanding = Normalize(point - eye);
|
||||
const auto &S = hmd.mDeviceToAbsoluteTracking, &R = hmdRaw.mDeviceToAbsoluteTracking;
|
||||
const Vec3 aim = Rotate(R, RotateInverse(S, aimStanding)); // raw <- head <- standing
|
||||
// Origin partway along the line of sight to the cursor (smaller hit dot).
|
||||
const double toPoint = std::sqrt(Dot(point - eye, point - eye));
|
||||
double originDist = std::max(0.0, std::min(toPoint * cfg.originFraction, toPoint - cfg.originMargin));
|
||||
if (onVrSettings) originDist = std::min(originDist, SETTINGS_ORIGIN); // SteamVR finds the page
|
||||
const Vec3 originStanding = eye + Normalize(point - eye) * originDist;
|
||||
const Vec3 eyeRaw = Position(R) + Rotate(R, RotateInverse(S, originStanding - eye));
|
||||
lastPoint = point, lastOrigin = originStanding, lastAim = aimStanding; // tilt starts from here
|
||||
havePoint = true;
|
||||
if (debug && tnow - lastDebug > std::chrono::milliseconds(500)) {
|
||||
lastDebug = tnow;
|
||||
if (systemPointer == vr::k_ulOverlayHandleInvalid) overlay->FindOverlay("system.pointer", &systemPointer);
|
||||
std::printf("dbg %s hit=%s dist=%.2f eye->point=%.2f origin=%.2f vrsettings=%d yaw=%.1f pitch=%.1f gaze=%s steamvr_dot=%d primary=%u\n",
|
||||
dragging ? "DRAG" : occluded ? "INFRONT" : onEdge ? "EDGE" : onScene ? "SCENE" : (best < 1e8 ? "PANEL" : "FREE"), lastHit.empty() ? "-" : lastHit.c_str(),
|
||||
distance, toPoint, originDist, onVrSettings, yaw, pitch,
|
||||
!gazeOn ? "off" : tnow - gz.at > std::chrono::milliseconds(150) ? "stale" : gazeOwns ? "owns" : "mouse",
|
||||
systemPointer != vr::k_ulOverlayHandleInvalid && overlay->IsOverlayVisible(systemPointer),
|
||||
overlay->GetPrimaryDashboardDevice());
|
||||
std::fflush(stdout);
|
||||
}
|
||||
const double ayaw = std::atan2(-aim.x, -aim.z) * 180 / M_PI;
|
||||
const double apitch = std::asin(std::clamp(aim.y, -1.0, 1.0)) * 180 / M_PI;
|
||||
if (dragging && (tiltYaw != 0 || tiltPitch != 0)) {
|
||||
// Keep this drag's tilt applied, about the current cursor point.
|
||||
const double yr = tiltYaw * M_PI / 180, pr = tiltPitch * M_PI / 180;
|
||||
const Basis base = AimBasis(aimStanding);
|
||||
const Vec3 up{0, 1, 0}, side = base.x;
|
||||
auto turn = [&](Vec3 v) { return RotateAbout(RotateAbout(v, side, pr), up, yr); };
|
||||
const Vec3 o = point + turn(originStanding - point);
|
||||
const Basis b{turn(base.x), turn(base.y), turn(base.z)};
|
||||
auto toRaw = [&](Vec3 v) { return Rotate(R, RotateInverse(S, v)); };
|
||||
const Vec3 oRaw = Position(R) + toRaw(o - eye);
|
||||
double q[4];
|
||||
BasisQuat({toRaw(b.x), toRaw(b.y), toRaw(b.z)}, q);
|
||||
char msg[200];
|
||||
std::snprintf(msg, sizeof msg, "posq %.5f %.5f %.5f %.6f %.6f %.6f %.6f", oRaw.x, oRaw.y, oRaw.z, q[0],
|
||||
q[1], q[2], q[3]);
|
||||
SendTo(out, "ft_pointer", msg);
|
||||
} else {
|
||||
char msg[160];
|
||||
std::snprintf(msg, sizeof msg, "pose %.5f %.5f %.5f %.4f %.4f", eyeRaw.x, eyeRaw.y, eyeRaw.z, ayaw, apitch);
|
||||
SendTo(out, "ft_pointer", msg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// ft-pointer: gaze mode and its held-back press (see "Gaze mode" at the top).
|
||||
// "The top" in comments here is the header comment of ft-pointer.cpp.
|
||||
#include "pointer.h"
|
||||
|
||||
// Outside games, gaze mode keeps the pointer (see the top); the relay needs to know.
|
||||
void Pointer::GazeAwake() {
|
||||
const auto t = Clock::now();
|
||||
if (t - inGameAt > std::chrono::milliseconds(500)) {
|
||||
inGameAt = t;
|
||||
inGame = vr::VRApplications()->GetCurrentSceneProcessId() != 0;
|
||||
}
|
||||
// Hand gestures in the last two minutes keep it the same way.
|
||||
const bool handsRecent = cfg.handsOn && handUsed != Clock::time_point{} && t - handUsed < std::chrono::minutes(2);
|
||||
const bool awake = (gazeOn || handsRecent) && !inGame && !headsetOff;
|
||||
if (awake != gazeAwake || t - gazeAwakeAt > std::chrono::seconds(5)) {
|
||||
if (awake != gazeAwake)
|
||||
std::printf("%s keeps the pointer: %s\n", gazeOn ? "gaze" : "hand use",
|
||||
awake ? "yes" : "no (off, in a game, or headset off)");
|
||||
if (awake != gazeAwake) std::fflush(stdout);
|
||||
gazeAwake = awake;
|
||||
gazeAwakeAt = t;
|
||||
SendTo(out, "frametop_relay", awake ? "gazeawake 1" : "gazeawake 0");
|
||||
}
|
||||
}
|
||||
|
||||
// Gaze mode (see the top): the gaze has the pointer, or takes it back when you look
|
||||
// well away from it. Not while a press holds the pointer, and only on fresh gaze.
|
||||
void Pointer::GazeMode(const Frame &frame) {
|
||||
const vr::TrackedDevicePose_t &hmd = frame.Hmd();
|
||||
const auto tnow = frame.tnow;
|
||||
const Vec3 &eye = frame.eye;
|
||||
if (hmd.bPoseIsValid) lastHead = hmd.mDeviceToAbsoluteTracking, haveHead = true;
|
||||
if (gazeOn && active && hmd.bPoseIsValid && !tilting && !leftHeld && !aimHeld && !clickPress && !clickRelease &&
|
||||
tnow >= dropHoldUntil &&
|
||||
tnow - gz.at < std::chrono::milliseconds(150)) {
|
||||
const Vec3 g = Rotate(hmd.mDeviceToAbsoluteTracking, Direction(gz.hy, gz.hp));
|
||||
if (!gazeOwns && havePoint) {
|
||||
const double off = std::acos(std::clamp(Dot(g, Normalize(lastPoint - eye)), -1.0, 1.0)) * 180 / M_PI;
|
||||
if (off > cfg.gazeRetake && tnow - lastMouse > std::chrono::milliseconds(300)) {
|
||||
if (retakeSince == Clock::time_point{}) retakeSince = tnow;
|
||||
if (tnow - retakeSince >= std::chrono::milliseconds(120)) gazeOwns = true, nudging = false;
|
||||
} else {
|
||||
retakeSince = {};
|
||||
}
|
||||
}
|
||||
if (gazeOwns) {
|
||||
retakeSince = {};
|
||||
anchor = eye;
|
||||
anchored = true;
|
||||
yaw = std::atan2(-g.x, -g.z) * 180 / M_PI;
|
||||
pitch = std::clamp(std::asin(std::clamp(g.y, -1.0, 1.0)) * 180 / M_PI, -85.0, 85.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Pointer::CalPanelOpened() {
|
||||
if (calOpened) { // the calibration panel came up: a press in progress ends, no click
|
||||
calOpened = false;
|
||||
if (hold.src != Src::None) EndHold(true);
|
||||
if (leftHeld) ReleaseLeft();
|
||||
aimHeld = aimHand = clickPress = false;
|
||||
}
|
||||
}
|
||||
|
||||
// A held-back press (see the top): held still long enough, it's a real press (a drag);
|
||||
// released, it's a click where the pointer is now (this frame's pose has gone out).
|
||||
void Pointer::HeldBackPress(const Frame &frame) {
|
||||
const auto tnow = frame.tnow;
|
||||
if (!active) aimHeld = aimRight = clickPress = aimHand = confirmLesson = false; // released meanwhile: nothing to click
|
||||
if (aimHeld && !aimHand && nudgeMoved < 0.2 && tnow - aimSince >= std::chrono::duration<double>(cfg.gazeHold)) {
|
||||
aimHeld = false;
|
||||
gazeBack = true;
|
||||
pressRight = aimRight; // the right button's: a real right press (see the top)
|
||||
aimRight = false;
|
||||
PressLeft();
|
||||
}
|
||||
}
|
||||
|
||||
// The click a held-back press ends in: the press now, the release 40 ms later.
|
||||
void Pointer::Click(const Frame &frame) {
|
||||
const auto tnow = frame.tnow;
|
||||
if (clickPress) {
|
||||
clickPress = false;
|
||||
PressLeft();
|
||||
clickRelease = true;
|
||||
clickReleaseAt = tnow + std::chrono::milliseconds(40);
|
||||
} else if (clickRelease && tnow >= clickReleaseAt) {
|
||||
clickRelease = false;
|
||||
ReleaseLeft();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// ft-pointer: holds, hands, and the gaze precision buttons (see "Gaze precision" and "Hands" at
|
||||
// the top).
|
||||
// "The top" in comments here is the header comment of ft-pointer.cpp.
|
||||
#include "pointer.h"
|
||||
|
||||
// Hands (see the top): pinches and grips from ft-hands.
|
||||
void Pointer::Hands(const Frame &frame) {
|
||||
const auto tnow = frame.tnow;
|
||||
vr::TrackedDevicePose_t h0;
|
||||
sys->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding, 0, &h0, 1);
|
||||
if (h0.bPoseIsValid) poses.Add(tnow, h0.mDeviceToAbsoluteTracking);
|
||||
}
|
||||
|
||||
// Where a gesture's point is, seen from the hold's origin: yaw and pitch, degrees.
|
||||
bool Pointer::HandAngles(const float p[3], uint64_t t_ns, const Vec3 &from, double &hy, double &hp) {
|
||||
vr::HmdMatrix34_t head;
|
||||
if (!poses.At(t_ns, head)) return false;
|
||||
const Vec3 d = Normalize(Position(head) + Rotate(head, Vec3{p[0], p[1], p[2]}) - from);
|
||||
hy = std::atan2(-d.x, -d.z) * 180 / M_PI;
|
||||
hp = std::asin(std::clamp(d.y, -1.0, 1.0)) * 180 / M_PI;
|
||||
return true;
|
||||
}
|
||||
|
||||
void Pointer::EndHold(bool lost) {
|
||||
if (hold.pressed) {
|
||||
if (leftHeld) ReleaseLeft();
|
||||
} else if (aimHeld) {
|
||||
aimHeld = aimHand = false;
|
||||
if (lost) {
|
||||
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.src == Src::Head ? (hold.right ? "key right" : "key left")
|
||||
: hold.grip ? "gaze drag" : "gaze precision",
|
||||
lost ? "lost" : "released", hold.pressed ? "" : lost ? " (no click)" : " (click)");
|
||||
if (debug) std::fflush(stdout);
|
||||
hold = {};
|
||||
}
|
||||
|
||||
// The press, once a hold's source is set: a real press (dragging until the release), or
|
||||
// held back like gaze mode's mouse press (see the top): the click comes on the release.
|
||||
void Pointer::StartHold(bool press, Clock::time_point tnow) {
|
||||
hold.startYaw = hold.lastYaw = yaw, hold.startPitch = hold.lastPitch = pitch;
|
||||
hold.pressed = press;
|
||||
if (press) {
|
||||
gazeBack = gazeOn && gazeOwns;
|
||||
PressLeft();
|
||||
} else {
|
||||
gazeOwns = false;
|
||||
nudging = gazeOn && haveHead && tnow - gz.at < std::chrono::milliseconds(200);
|
||||
nudgeRawHy = gz.rhy, nudgeRawHp = gz.rhp, nudgeHead = lastHead;
|
||||
nudgeAt = aimSince = tnow, nudgeMoved = 0;
|
||||
aimHeld = aimHand = true;
|
||||
pulseAt = tnow;
|
||||
}
|
||||
}
|
||||
|
||||
// The hold's source moved to (hy, hp): past the dead zone, the pointer follows at the gain,
|
||||
// from where it was when it got past.
|
||||
void Pointer::Steer(double hy, double hp, double deadzone, double gain, Clock::time_point tnow) {
|
||||
const double dy = std::remainder(hy - hold.refYaw, 360.0), dp = hp - hold.refPitch;
|
||||
if (!hold.engaged) {
|
||||
if (std::hypot(dy, dp) <= deadzone) return;
|
||||
hold.engaged = true;
|
||||
hold.refYaw = hy, hold.refPitch = hp;
|
||||
hold.startYaw = hold.lastYaw = yaw, hold.startPitch = hold.lastPitch = pitch;
|
||||
return;
|
||||
}
|
||||
yaw = std::remainder(hold.startYaw + gain * dy, 360.0);
|
||||
pitch = std::clamp(hold.startPitch + gain * dp, -85.0, 85.0);
|
||||
// How far the nudge went: for a keyboard click, from where the dot was at the press
|
||||
// (a head wobbles on the way); otherwise the path, as with the mouse.
|
||||
if (!hold.pressed && hold.src == Src::Head)
|
||||
nudgeMoved = std::hypot(std::remainder(yaw - hold.pressYaw, 360.0), pitch - hold.pressPitch);
|
||||
else if (!hold.pressed)
|
||||
nudgeMoved += std::hypot(std::remainder(yaw - hold.lastYaw, 360.0), pitch - hold.lastPitch);
|
||||
hold.lastYaw = yaw, hold.lastPitch = pitch;
|
||||
lastMove = tnow; // the dot shows while it moves (gaze mode)
|
||||
}
|
||||
|
||||
void Pointer::BeginHold(int side, bool grip, const fh_pinch_t &g, Clock::time_point tnow) {
|
||||
handUsed = tnow;
|
||||
if (hold.src != Src::None) {
|
||||
// A grip takes over a pinch on the way to it (closing the hand passes through
|
||||
// one); otherwise one hold at a time.
|
||||
if (hold.src != Src::Hand || !grip || hold.grip) return;
|
||||
aimHeld = aimHand = false;
|
||||
hold = {};
|
||||
}
|
||||
if (leftHeld || aimHeld || clickPress || clickRelease) return; // the mouse's button is busy
|
||||
if (!active) {
|
||||
Wake(tnow); // the first gesture only wakes the pointer, like the first mouse move
|
||||
return;
|
||||
}
|
||||
vr::HmdMatrix34_t head;
|
||||
if (!poses.At(g.begin_ns, head)) return;
|
||||
// No pinch just after a key (typing touches thumb to index), and a grip only with
|
||||
// the hand held up (hands on a desk curl like a loose fist; looking down at them
|
||||
// puts them straight ahead in the head's frame, where ft-hands can't tell).
|
||||
const Vec3 at = Position(head) + Rotate(head, Vec3{g.begin_point[0], g.begin_point[1], g.begin_point[2]});
|
||||
if (!grip && tnow - lastTyping < std::chrono::duration<double>(cfg.typingHold)) {
|
||||
if (debug) std::printf("hand pinch ignored: typing\n");
|
||||
return;
|
||||
}
|
||||
if (grip && Position(head).y - at.y > cfg.handBelow) {
|
||||
if (debug) std::printf("hand grip ignored: %.2f m below the eyes\n", Position(head).y - at.y);
|
||||
return;
|
||||
}
|
||||
Hold h;
|
||||
h.src = Src::Hand, h.side = side, h.grip = grip, h.origin = Position(head);
|
||||
if (!HandAngles(g.begin_point, g.begin_ns, h.origin, h.refYaw, h.refPitch)) return;
|
||||
hold = h;
|
||||
// A grip, or a pinch without gaze mode: a real press where the pointer is (where
|
||||
// you look, in gaze mode), dragging with the hand until it opens.
|
||||
StartHold(grip || !gazeOn, tnow);
|
||||
if (debug) std::printf("hand %s %s began\n", side ? "right" : "left", grip ? "grip" : "pinch");
|
||||
if (debug) std::fflush(stdout);
|
||||
}
|
||||
|
||||
// Gaze precision and gaze drag buttons (see the top): the mouse steers.
|
||||
void Pointer::GazePrecisionButtons(const Frame &frame) {
|
||||
const auto tnow = frame.tnow;
|
||||
for (const DevicePress &p : devicePresses) {
|
||||
if (p.source == "left" || p.source == "right") continue; // no controllers in gaze mode
|
||||
if (!p.down) {
|
||||
if (hold.src == Src::Mouse) EndHold(false);
|
||||
continue;
|
||||
}
|
||||
if (hold.src != Src::None || leftHeld || aimHeld || clickPress || clickRelease) continue;
|
||||
Hold h;
|
||||
h.src = Src::Mouse, h.grip = p.drag;
|
||||
hold = h;
|
||||
StartHold(p.drag, tnow);
|
||||
if (debug) std::printf("hold gaze %s began (%s)\n", p.drag ? "drag" : "precision", p.source.c_str());
|
||||
if (debug) std::fflush(stdout);
|
||||
}
|
||||
devicePresses.clear();
|
||||
}
|
||||
|
||||
// Pinches and grips from ft-hands (see "Hands" at the top), read every frame.
|
||||
void Pointer::PinchesAndGrips(Frame &frame) {
|
||||
const auto tnow = frame.tnow;
|
||||
fh_gestures_t hg;
|
||||
const bool handOk = cfg.handsOn && handFile.Read(hg);
|
||||
frame.handOk = handOk;
|
||||
if (handOk && (hg.seq != handSeq || handFile.opens != handOpens)) {
|
||||
handSeq = hg.seq;
|
||||
handPublished = hg.publish_ns;
|
||||
const fh_pinch_t *slots[2] = {hg.pinch, hg.grip};
|
||||
// A new file, or a tracker whose counters went back: start counting from here.
|
||||
bool rebase = !handBaseline || handFile.opens != handOpens;
|
||||
for (int k = 0; k < 2; ++k)
|
||||
for (int s = 0; s < 2; ++s)
|
||||
rebase = rebase || slots[k][s].begins < seenBegins[k][s] || slots[k][s].ends < seenEnds[k][s];
|
||||
if (rebase) {
|
||||
if (hold.src == Src::Hand) EndHold(true);
|
||||
for (int k = 0; k < 2; ++k)
|
||||
for (int s = 0; s < 2; ++s) seenBegins[k][s] = slots[k][s].begins, seenEnds[k][s] = slots[k][s].ends;
|
||||
handBaseline = true, handOpens = handFile.opens;
|
||||
}
|
||||
// Not over a VR game (unless the dashboard is up), and not with the headset off.
|
||||
const bool allowed = !headsetOff && (!inGame || overlay->IsDashboardVisible());
|
||||
for (int k = 1; k >= 0; --k) // grips first: one takes over a pinch
|
||||
for (int s = 0; s < 2; ++s) {
|
||||
const fh_pinch_t &g = slots[k][s];
|
||||
const bool began = g.begins != seenBegins[k][s], ended = g.ends != seenEnds[k][s];
|
||||
const bool lost = g.flags & FH_PINCH_LOST;
|
||||
seenBegins[k][s] = g.begins, seenEnds[k][s] = g.ends;
|
||||
auto holding = [&] { return hold.src == Src::Hand && hold.side == s && hold.grip == (k == 1); };
|
||||
if (holding() && ended) EndHold(lost); // the one held ended (another may have begun)
|
||||
if (began && allowed) {
|
||||
BeginHold(s, k == 1, g, tnow);
|
||||
// begun and ended since the last read (a quick tap): its release too
|
||||
if (!(g.flags & FH_PINCH_DOWN) && holding()) EndHold(lost);
|
||||
}
|
||||
}
|
||||
// The held gesture's hand moves the pointer: past the dead zone (a tap's jitter,
|
||||
// the pinch point shifting as the fingers close), then at the gain, from there.
|
||||
if (hold.src == Src::Hand) {
|
||||
const fh_pinch_t &g = hold.grip ? hg.grip[hold.side] : hg.pinch[hold.side];
|
||||
double hy, hp;
|
||||
if ((g.flags & FH_PINCH_TRACKED) && HandAngles(g.point, hg.capture_ns, hold.origin, hy, hp))
|
||||
Steer(hy, hp, hold.grip ? 0.5 : cfg.pinchDeadzone, hold.grip ? cfg.gripGain : cfg.pinchGain, tnow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ft-hands stopped (or hung) with a gesture held: it's over, as lost.
|
||||
void Pointer::HandsStopped(const Frame &frame) {
|
||||
const auto tnow = frame.tnow;
|
||||
const bool handOk = frame.handOk;
|
||||
const uint64_t nowNs =
|
||||
uint64_t(std::chrono::duration_cast<std::chrono::nanoseconds>(tnow.time_since_epoch()).count());
|
||||
if (hold.src == Src::Hand && (!handOk || !active || nowNs - handPublished > 1'500'000'000ull)) EndHold(true);
|
||||
if (hold.src != Src::None && hold.src != Src::Hand && !active) EndHold(true);
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
// ft-pointer: waking the pointer, its buttons, and the commands from the relay.
|
||||
// "The top" in comments here is the header comment of ft-pointer.cpp.
|
||||
#include "pointer.h"
|
||||
|
||||
void Pointer::Wake(Clock::time_point t) {
|
||||
if (t < noWakeUntil || headsetOff) return;
|
||||
wokeAt = t;
|
||||
active = true;
|
||||
recenter = true;
|
||||
SendTo(out, "ft_pointer", "role " + cfg.role); // POINTER_ROLE, before it takes it
|
||||
SendTo(out, "ft_pointer", "show");
|
||||
claimPending = true; // take the laser without clicking, once SteamVR has bound the device
|
||||
claimAt = t + std::chrono::milliseconds(300);
|
||||
}
|
||||
|
||||
void Pointer::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.
|
||||
SendTo(out, "ft_screens", "click " + (lastHit.empty() ? std::string("-") : lastHit));
|
||||
// A click after nudging the gaze-placed pointer: the nudge is a lesson, or past
|
||||
// POINTER_GAZE_NUDGE_MAX, a quick check (see the top).
|
||||
if (gazeOn && nudging && !gazeOwns && havePoint && Clock::now() - nudgeAt < std::chrono::seconds(10) &&
|
||||
(confirmLesson || nudgeMoved >= 0.2)) {
|
||||
const Vec3 d = RotateInverse(nudgeHead, Normalize(lastPoint - Position(nudgeHead)));
|
||||
const double ty = std::atan2(-d.x, -d.z) * 180 / M_PI, tp = std::asin(std::clamp(d.y, -1.0, 1.0)) * 180 / M_PI;
|
||||
const double off = std::hypot(std::remainder(ty - nudgeRawHy, 360.0), tp - nudgeRawHp);
|
||||
char msg[160];
|
||||
if (off <= cfg.gazeNudgeMax)
|
||||
std::snprintf(msg, sizeof msg, "lesson %.3f %.3f %.3f %.3f", nudgeRawHy, nudgeRawHp, ty, tp);
|
||||
else
|
||||
std::snprintf(msg, sizeof msg, "recheck %.0f", off);
|
||||
SendTo(out, "ft_gazed", msg);
|
||||
if (debug) std::printf("gaze %s (nudged %.2f deg, off %.2f)\n", msg, nudgeMoved, off);
|
||||
if (debug) std::fflush(stdout);
|
||||
}
|
||||
nudging = confirmLesson = false;
|
||||
leftHeld = true;
|
||||
dragDistance = lastDistance;
|
||||
pressKey.clear();
|
||||
if (FramePanel(lastHit)) {
|
||||
vr::ETrackingUniverseOrigin uo;
|
||||
auto it = handles.find(lastHit);
|
||||
if (it != handles.end() &&
|
||||
overlay->GetOverlayTransformAbsolute(it->second, &uo, &pressPose) == vr::VROverlayError_None)
|
||||
pressKey = lastHit;
|
||||
}
|
||||
tiltYaw = tiltPitch = 0; // a new drag starts untilted
|
||||
dropHoldUntil = {};
|
||||
pulseAt = Clock::now();
|
||||
heldButton = pressRight ? "b" : "trigger";
|
||||
pressRight = false;
|
||||
SendTo(out, "ft_pointer", "btn " + heldButton + " 1");
|
||||
}
|
||||
|
||||
void Pointer::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 " + 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");
|
||||
if (gazeBack) gazeOwns = true, gazeBack = false;
|
||||
}
|
||||
|
||||
// The left button, from the relay's "btn trigger", with gaze mode's held-back press (see
|
||||
// the top).
|
||||
bool Pointer::CanAim() {
|
||||
return gazeOn && cfg.gazeMousePrecision && gazeOwns && !aimHeld && !clickPress && !clickRelease &&
|
||||
hold.src == Src::None;
|
||||
}
|
||||
|
||||
// Hold the press back: the pointer stops where the gaze put it.
|
||||
void Pointer::AimStart(bool right) {
|
||||
gazeOwns = false;
|
||||
nudging = haveHead && Clock::now() - gz.at < std::chrono::milliseconds(200);
|
||||
nudgeRawHy = gz.rhy, nudgeRawHp = gz.rhp, nudgeHead = lastHead;
|
||||
nudgeAt = aimSince = Clock::now(), nudgeMoved = 0;
|
||||
aimHeld = true, aimRight = right;
|
||||
}
|
||||
|
||||
// A drag the right button began (chordDrag): it lasts while either button is held.
|
||||
void Pointer::ChordDrop() {
|
||||
chordDrag = false;
|
||||
if (leftHeld) ReleaseLeft();
|
||||
if (debug) std::printf("mouse drag dropped\n");
|
||||
if (debug) std::fflush(stdout);
|
||||
}
|
||||
|
||||
void Pointer::LeftButton(bool down) {
|
||||
leftDown = down;
|
||||
if (down) {
|
||||
if (aimHeld && aimRight && !aimHand) {
|
||||
ignoreLeftUp = true; // the right's press is held back: the left does nothing
|
||||
return;
|
||||
}
|
||||
if (CanAim()) {
|
||||
AimStart(false);
|
||||
return;
|
||||
}
|
||||
PressLeft();
|
||||
return;
|
||||
}
|
||||
if (ignoreLeftUp) {
|
||||
ignoreLeftUp = false;
|
||||
return;
|
||||
}
|
||||
if (chordDrag) {
|
||||
if (!rightDown) ChordDrop(); // otherwise the right holds it (tilting, maybe)
|
||||
return;
|
||||
}
|
||||
if (aimHeld && !aimHand && !aimRight) {
|
||||
aimHeld = false;
|
||||
clickPress = true; // after this frame's pose, so it lands where the pointer was moved to
|
||||
gazeBack = nudgeMoved < 0.2;
|
||||
return;
|
||||
}
|
||||
if (leftHeld) ReleaseLeft();
|
||||
}
|
||||
|
||||
// The right button (see the top); false: not taken here (a tilt, or passed on as it is).
|
||||
bool Pointer::RightButton(bool down) {
|
||||
rightDown = down;
|
||||
if (down) {
|
||||
if (aimHeld && !aimHand && !aimRight) {
|
||||
// During the left's held-back press: press the left where the pointer is now (the
|
||||
// correction is a lesson), and the mouse drags.
|
||||
aimHeld = false;
|
||||
chordDrag = gazeBack = true;
|
||||
PressLeft();
|
||||
if (debug) std::printf("mouse drag began (right during left)\n");
|
||||
if (debug) std::fflush(stdout);
|
||||
return true;
|
||||
}
|
||||
if (CanAim() && !leftHeld) {
|
||||
AimStart(true);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (chordDrag) {
|
||||
if (leftDown) return !swallowedRight; // the left holds it; a tilt's release ends the tilt
|
||||
tilting = swallowedRight = false;
|
||||
ChordDrop();
|
||||
return true;
|
||||
}
|
||||
if (aimHeld && !aimHand && aimRight) {
|
||||
aimHeld = aimRight = false;
|
||||
pressRight = clickPress = true; // the right click, where the pointer was moved to
|
||||
gazeBack = nudgeMoved < 0.2;
|
||||
return true;
|
||||
}
|
||||
if (leftHeld && heldButton == "b") { // its press, held still into a real one
|
||||
ReleaseLeft();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// POINTER_GAZE_MOUSE_MOVE=held (see the top): the gaze is fresh and nothing is pressed, so a
|
||||
// mouse move doesn't move the pointer.
|
||||
bool Pointer::MouseMoveHeld() {
|
||||
return gazeOn && cfg.gazeMouseHeld && !inGame && !headsetOff && Clock::now() - gz.at < std::chrono::seconds(1) &&
|
||||
!aimHeld && !leftHeld && !tilting && hold.src == Src::None && !clickPress && !clickRelease;
|
||||
}
|
||||
|
||||
// Commands from the relay.
|
||||
void Pointer::RelayCommands() {
|
||||
char buf[256];
|
||||
ssize_t n;
|
||||
sockaddr_un from{};
|
||||
socklen_t fromLen = sizeof from;
|
||||
while ((n = recvfrom(in, buf, sizeof buf - 1, 0, reinterpret_cast<sockaddr *>(&from), &fromLen)) > 0) {
|
||||
buf[n] = 0;
|
||||
// Reply to the sender (the layout tool binds an abstract address to get answers).
|
||||
const sockaddr_un sender = from;
|
||||
const socklen_t senderLen = fromLen;
|
||||
fromLen = sizeof from;
|
||||
Command(buf, sender, senderLen);
|
||||
}
|
||||
}
|
||||
|
||||
// One command from the relay (or another sender: reply answers it).
|
||||
void Pointer::Command(const char *buf, const sockaddr_un &sender, socklen_t senderLen) {
|
||||
auto reply = [&](const std::string &msg) {
|
||||
if (senderLen > offsetof(sockaddr_un, sun_path))
|
||||
sendto(out, msg.data(), msg.size(), 0, reinterpret_cast<const sockaddr *>(&sender), senderLen);
|
||||
};
|
||||
// The gaze, from ft-gazed: not mouse input, it never wakes the pointer.
|
||||
double g[4];
|
||||
if (std::sscanf(buf, "gz %lf %lf %lf %lf", &g[0], &g[1], &g[2], &g[3]) == 4) {
|
||||
gz = {g[0], g[1], g[2], g[3], Clock::now()};
|
||||
return;
|
||||
}
|
||||
// The gaze calibration panel (see the top): presses answer it instead of clicking.
|
||||
{
|
||||
int on;
|
||||
if (std::sscanf(buf, "calpanel %d", &on) == 1) {
|
||||
const bool was = Clock::now() < calPanelUntil;
|
||||
calPanelUntil = on ? Clock::now() + std::chrono::seconds(3) : Clock::time_point{};
|
||||
if (on && !was) calOpened = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (Clock::now() < calPanelUntil) {
|
||||
const bool accept = !std::strncmp(buf, "btn trigger 1", 13) || !std::strncmp(buf, "gazekey left 1", 14);
|
||||
const bool quit = !std::strncmp(buf, "btn b 1", 7) || !std::strncmp(buf, "gazekey right 1", 15);
|
||||
if (accept || quit) {
|
||||
SendTo(out, "ft_gazed", accept ? "calaccept" : "calquit");
|
||||
return;
|
||||
}
|
||||
if (!std::strncmp(buf, "btn ", 4) || !std::strncmp(buf, "gazekey ", 8) ||
|
||||
!std::strncmp(buf, "precision ", 10) || !std::strncmp(buf, "gazedrag ", 9))
|
||||
return; // their releases, and the other buttons: nothing to click now
|
||||
}
|
||||
{
|
||||
char kind[16], source[16];
|
||||
int v;
|
||||
if (std::sscanf(buf, "%15s %15s %d", kind, source, &v) == 3 &&
|
||||
(!std::strcmp(kind, "precision") || !std::strcmp(kind, "gazedrag"))) {
|
||||
lastMouse = Clock::now();
|
||||
if (!active) Wake(Clock::now());
|
||||
devicePresses.push_back({source, !std::strcmp(kind, "gazedrag"), v != 0});
|
||||
return;
|
||||
}
|
||||
if (std::sscanf(buf, "gazekey %15s %d", source, &v) == 2 &&
|
||||
(!std::strcmp(source, "left") || !std::strcmp(source, "right"))) {
|
||||
lastMouse = Clock::now();
|
||||
if (!active) Wake(Clock::now());
|
||||
keyPresses.push_back({!std::strcmp(source, "right"), v != 0});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (std::strcmp(buf, "typing") == 0) { // not mouse input: it never wakes the pointer
|
||||
lastTyping = Clock::now();
|
||||
return;
|
||||
}
|
||||
if (std::strncmp(buf, "vrbind", 6) == 0) {
|
||||
std::printf("controller buttons: %s\n", controllerButtons.Bind(buf + 6).c_str());
|
||||
std::fflush(stdout);
|
||||
return;
|
||||
}
|
||||
if (std::strncmp(buf, "vrglobal", 8) == 0) {
|
||||
const char *arg = buf + 8;
|
||||
while (*arg == ' ') ++arg;
|
||||
ControllerButtons::SetGlobal(std::strncmp(arg, "off", 3) != 0);
|
||||
reply(controllerButtons.Status());
|
||||
return;
|
||||
}
|
||||
if (std::strncmp(buf, "vrstatus", 8) == 0) {
|
||||
reply(controllerButtons.Status());
|
||||
return;
|
||||
}
|
||||
if (std::strncmp(buf, "overlays", 8) == 0) {
|
||||
overlays.Request(sender, senderLen); // answered from the list's thread
|
||||
return;
|
||||
}
|
||||
if (std::strncmp(buf, "gaze", 4) == 0) {
|
||||
const char *arg = buf + 4;
|
||||
while (*arg == ' ') ++arg;
|
||||
if (*arg != '?') { // "gaze ?" only asks
|
||||
gazeOn = std::strncmp(arg, "on", 2) == 0 ? true
|
||||
: std::strncmp(arg, "off", 3) == 0 ? false
|
||||
: !gazeOn;
|
||||
gazeOwns = true, nudging = false;
|
||||
std::printf("gaze mode %s\n", gazeOn ? "on" : "off");
|
||||
std::fflush(stdout);
|
||||
}
|
||||
reply(gazeOn ? "ok on" : "ok off");
|
||||
return;
|
||||
}
|
||||
const bool mouseInput = std::strncmp(buf, "move", 4) == 0 || std::strncmp(buf, "btn", 3) == 0 ||
|
||||
std::strncmp(buf, "scroll", 6) == 0;
|
||||
// A move held back (POINTER_GAZE_MOUSE_MOVE=held) only wakes the pointer: it isn't using
|
||||
// the mouse, so a drifting mouse doesn't keep the gaze from taking the pointer back.
|
||||
const bool moveHeldBack = std::strncmp(buf, "move", 4) == 0 && MouseMoveHeld();
|
||||
if (mouseInput && !moveHeldBack) lastMouse = Clock::now();
|
||||
// Any mouse input wakes the pointer (after a controller took over, or a helper restart).
|
||||
if (!active && mouseInput) Wake(Clock::now());
|
||||
if (moveHeldBack) return;
|
||||
double a, b;
|
||||
char key[128];
|
||||
double px, py, pz, pyaw, ppitch, proll = 0, pgrab = -1;
|
||||
if (std::sscanf(buf, "grabprobe %127s", key) == 1) {
|
||||
GrabProbe(key);
|
||||
return;
|
||||
}
|
||||
if (std::sscanf(buf, "place %127s %lf %lf %lf %lf %lf %lf %lf", key, &px, &py, &pz, &pyaw, &ppitch, &proll,
|
||||
&pgrab) >= 6) {
|
||||
reply(Place(key, {px, py, pz}, PanelBasis(pyaw, ppitch, proll), pgrab >= 0 ? pgrab : cfg.grabOffset));
|
||||
return;
|
||||
}
|
||||
if (std::sscanf(buf, "measure %127s", key) == 1) {
|
||||
Panel p;
|
||||
Vec3 eye;
|
||||
const std::string err = FindPanel(key, p, eye);
|
||||
char msg[400] = "";
|
||||
if (err.empty())
|
||||
std::snprintf(msg, sizeof msg, "ok %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f",
|
||||
p.center.x, p.center.y, p.center.z, p.width, p.height, p.basis.x.x, p.basis.x.y,
|
||||
p.basis.x.z, p.basis.y.x, p.basis.y.y, p.basis.y.z, p.basis.z.x, p.basis.z.y,
|
||||
p.basis.z.z);
|
||||
reply(err.empty() ? msg : "error " + err);
|
||||
return;
|
||||
}
|
||||
if (std::strncmp(buf, "head", 4) == 0) {
|
||||
vr::TrackedDevicePose_t h;
|
||||
sys->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding, 0, &h, 1);
|
||||
const Vec3 e = Position(h.mDeviceToAbsoluteTracking);
|
||||
const Vec3 f = Rotate(h.mDeviceToAbsoluteTracking, {0, 0, -1});
|
||||
char msg[200];
|
||||
std::snprintf(msg, sizeof msg, "ok %.4f %.4f %.4f %.2f %.2f", e.x, e.y, e.z,
|
||||
std::atan2(-f.x, -f.z) * 180 / M_PI, std::asin(std::clamp(f.y, -1.0, 1.0)) * 180 / M_PI);
|
||||
reply(h.bPoseIsValid ? msg : "error no head pose (headset off?)");
|
||||
return;
|
||||
}
|
||||
if (std::strncmp(buf, "debug", 5) == 0) {
|
||||
debug = !debug;
|
||||
std::printf("debug %s\n", debug ? "on" : "off");
|
||||
std::fflush(stdout);
|
||||
return;
|
||||
}
|
||||
if (std::strncmp(buf, "btn trigger 1", 13) == 0) {
|
||||
LeftButton(true);
|
||||
return;
|
||||
} else if (std::strncmp(buf, "btn trigger 0", 13) == 0) {
|
||||
LeftButton(false);
|
||||
return;
|
||||
} else if (std::strncmp(buf, "btn b 1", 7) == 0 && RightButton(true)) {
|
||||
return;
|
||||
} else if (std::strncmp(buf, "btn b 0", 7) == 0 && RightButton(false)) {
|
||||
return;
|
||||
} else if (std::strncmp(buf, "btn b 1", 7) == 0 && leftHeld) {
|
||||
tilting = tiltStart = swallowedRight = true; // right press while dragging: tilt, no right-click
|
||||
return;
|
||||
} else if (std::strncmp(buf, "btn b 0", 7) == 0 && swallowedRight) {
|
||||
tilting = swallowedRight = false;
|
||||
return;
|
||||
}
|
||||
if (tilting && std::sscanf(buf, "move %lf %lf", &a, &b) == 2) {
|
||||
tiltYaw += a;
|
||||
tiltPitch = std::clamp(tiltPitch + b, -80.0, 80.0);
|
||||
return;
|
||||
}
|
||||
if (std::sscanf(buf, "move %lf %lf", &a, &b) == 2) {
|
||||
lastMove = Clock::now(); // the dot shows while the mouse moves it (gaze mode)
|
||||
if (gazeOn && gazeOwns) {
|
||||
// The mouse takes the pointer from the gaze, from where the gaze left it.
|
||||
gazeOwns = false;
|
||||
nudging = haveHead && Clock::now() - gz.at < std::chrono::milliseconds(200);
|
||||
nudgeRawHy = gz.rhy, nudgeRawHp = gz.rhp, nudgeHead = lastHead;
|
||||
nudgeAt = Clock::now(), nudgeMoved = 0;
|
||||
}
|
||||
if (nudging || aimHeld) nudgeMoved += std::hypot(a, b);
|
||||
if (!anchored) recenter = true;
|
||||
yaw += a;
|
||||
while (yaw > 180) yaw -= 360;
|
||||
while (yaw < -180) yaw += 360;
|
||||
pitch = std::clamp(pitch + b, -85.0, 85.0);
|
||||
} else if (std::strncmp(buf, "recenter", 8) == 0) {
|
||||
recenter = true;
|
||||
} else if (std::strncmp(buf, "reload", 6) == 0) {
|
||||
cfg.Load(ReadConfig());
|
||||
ApplyConfig();
|
||||
lastSlow = Clock::now() - std::chrono::seconds(10); // apply POINTER_IGNORE now
|
||||
std::printf("reloaded: free distance %.2f m, dot %.2f deg, origin %.2f, head follow %s, leash %.0f deg, "
|
||||
"controller pickup %.1fx, %zu ignored\n",
|
||||
cfg.freeDistance, cfg.cursorDeg, cfg.originFraction, follow ? "on" : "off", cfg.leashDeg, cfg.pickupScale,
|
||||
cfg.ignore.size());
|
||||
std::fflush(stdout);
|
||||
} else if (std::strncmp(buf, "follow", 6) == 0) {
|
||||
const char *arg = buf + 6;
|
||||
while (*arg == ' ') ++arg;
|
||||
const bool was = follow;
|
||||
follow = std::strncmp(arg, "on", 2) == 0 ? true : std::strncmp(arg, "off", 3) == 0 ? false : !follow;
|
||||
if (follow && !was) followReset = true;
|
||||
std::printf("head follow %s (leash %.0f deg)\n", follow ? "on" : "off", cfg.leashDeg);
|
||||
std::fflush(stdout);
|
||||
} else if (std::strncmp(buf, "show", 4) == 0) {
|
||||
if (!active) Wake(Clock::now());
|
||||
} else if (std::strncmp(buf, "hide", 4) == 0) {
|
||||
active = false;
|
||||
overlay->HideOverlay(cursor);
|
||||
overlay->HideOverlay(marker);
|
||||
SendTo(out, "ft_pointer", "hide");
|
||||
} else {
|
||||
SendTo(out, "ft_pointer", buf); // btn, scroll
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// ft-pointer: keyboard clicks (see "Keyboard clicks" under "Gaze precision" at the top).
|
||||
// "The top" in comments here is the header comment of ft-pointer.cpp.
|
||||
#include "pointer.h"
|
||||
|
||||
// Where the head faces: yaw and pitch, degrees.
|
||||
bool Pointer::HeadAngles(const vr::TrackedDevicePose_t &hmd, double &hy, double &hp) {
|
||||
const auto &hm = hmd.mDeviceToAbsoluteTracking.m;
|
||||
if (!hmd.bPoseIsValid) return false;
|
||||
const Vec3 f = Normalize({-hm[0][2], -hm[1][2], -hm[2][2]});
|
||||
hy = std::atan2(-f.x, -f.z) * 180 / M_PI;
|
||||
hp = std::asin(std::clamp(f.y, -1.0, 1.0)) * 180 / M_PI;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Keyboard clicks (see the top): held back at the gaze, steered by the head.
|
||||
void Pointer::KeyboardClicks(const Frame &frame) {
|
||||
const vr::TrackedDevicePose_t &hmd = frame.Hmd();
|
||||
const auto tnow = frame.tnow;
|
||||
for (const KeyPress &k : keyPresses) {
|
||||
(k.right ? keyRightDown : keyLeftDown) = k.down;
|
||||
if (!k.down) {
|
||||
if (keyTilting && k.right) { // the tilt ends; the head drags again from here
|
||||
keyTilting = tilting = false;
|
||||
hold.engaged = false;
|
||||
if (debug) std::printf("hold key left: tilt ended\n");
|
||||
if (debug) std::fflush(stdout);
|
||||
}
|
||||
// The keys holding it: a gaze_left then gaze_right drag, either; otherwise its own.
|
||||
const bool held = hold.keyDrag ? keyLeftDown || keyRightDown : hold.right ? keyRightDown : keyLeftDown;
|
||||
if (hold.src == Src::Head && !held) {
|
||||
if (!hold.pressed && aimHeld && tnow - aimSince < std::chrono::duration<double>(cfg.keyTap)) {
|
||||
// A quick tap: a click where the dot was at the press, and the gaze was right.
|
||||
yaw = hold.pressYaw, pitch = hold.pressPitch;
|
||||
nudgeMoved = 0;
|
||||
confirmLesson = true;
|
||||
}
|
||||
EndHold(false);
|
||||
keyTilting = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (k.right && hold.src == Src::Head && !hold.right && hold.pressed && leftHeld) {
|
||||
// gaze_right during a gaze_left drag: tilt while it's held (see the top).
|
||||
tilting = tiltStart = keyTilting = true;
|
||||
HeadAngles(hmd, keyTiltYaw, keyTiltPitch);
|
||||
if (debug) std::printf("hold key left: tilt began\n");
|
||||
if (debug) std::fflush(stdout);
|
||||
continue;
|
||||
}
|
||||
if (k.right && hold.src == Src::Head && !hold.right && !hold.pressed && aimHeld) {
|
||||
// gaze_right while gaze_left aims: press the left button where the dot is now
|
||||
// (the correction is a lesson), then the head drags.
|
||||
aimHeld = aimHand = false;
|
||||
hold.pressed = hold.grip = hold.keyDrag = true, hold.promote = false, hold.engaged = false;
|
||||
HeadAngles(hmd, hold.refYaw, hold.refPitch);
|
||||
gazeBack = gazeOn;
|
||||
pressRight = false;
|
||||
PressLeft();
|
||||
if (debug) std::printf("hold key left: pressed (right key)\n");
|
||||
if (debug) std::fflush(stdout);
|
||||
continue;
|
||||
}
|
||||
if (hold.src != Src::None || leftHeld || aimHeld || clickPress || clickRelease) continue;
|
||||
Hold h;
|
||||
h.src = Src::Head, h.promote = true, h.right = k.right, h.pressYaw = yaw, h.pressPitch = pitch;
|
||||
if (!HeadAngles(hmd, h.refYaw, h.refPitch)) continue;
|
||||
hold = h;
|
||||
StartHold(false, tnow);
|
||||
if (debug) std::printf("hold key %s began\n", k.right ? "right" : "left");
|
||||
if (debug) std::fflush(stdout);
|
||||
}
|
||||
keyPresses.clear();
|
||||
}
|
||||
|
||||
// A keyboard click's hold: the head steers the pointer, or turns the panel in a tilt.
|
||||
void Pointer::HeadSteer(const Frame &frame) {
|
||||
const vr::TrackedDevicePose_t &hmd = frame.Hmd();
|
||||
const auto tnow = frame.tnow;
|
||||
if (hold.src == Src::Head) {
|
||||
double hy, hp;
|
||||
if (HeadAngles(hmd, hy, hp) && keyTilting) {
|
||||
// The head turns the panel, as the mouse does in a tilt; the pointer stays put.
|
||||
tiltYaw += std::remainder(hy - keyTiltYaw, 360.0);
|
||||
tiltPitch = std::clamp(tiltPitch + hp - keyTiltPitch, -80.0, 80.0);
|
||||
keyTiltYaw = hy, keyTiltPitch = hp;
|
||||
} else if (HeadAngles(hmd, hy, hp)) {
|
||||
Steer(hy, hp, hold.pressed ? 0.3 : cfg.headDeadzone, 1.0, tnow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A keyboard click held still for POINTER_GAZE_HOLD: a real press, then the head drags
|
||||
// (from where it is now).
|
||||
void Pointer::KeyboardClickHold(const Frame &frame) {
|
||||
const vr::TrackedDevicePose_t &hmd = frame.Hmd();
|
||||
const auto &hm = hmd.mDeviceToAbsoluteTracking.m;
|
||||
const auto tnow = frame.tnow;
|
||||
if (aimHeld && aimHand && hold.promote && !hold.engaged &&
|
||||
tnow - aimSince >= std::chrono::duration<double>(cfg.gazeHold)) {
|
||||
aimHeld = aimHand = false;
|
||||
hold.pressed = hold.grip = true;
|
||||
if (hmd.bPoseIsValid) {
|
||||
const Vec3 f = Normalize({-hm[0][2], -hm[1][2], -hm[2][2]});
|
||||
hold.refYaw = std::atan2(-f.x, -f.z) * 180 / M_PI;
|
||||
hold.refPitch = std::asin(std::clamp(f.y, -1.0, 1.0)) * 180 / M_PI;
|
||||
}
|
||||
gazeBack = gazeOn;
|
||||
pressRight = hold.right;
|
||||
PressLeft();
|
||||
if (debug) std::printf("hold key %s: pressed (held still)\n", hold.right ? "right" : "left");
|
||||
if (debug) std::fflush(stdout);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// ft-pointer: panel placement for layouts (see "Placement" at the top).
|
||||
// "The top" in comments here is the header comment of ft-pointer.cpp.
|
||||
#include "pointer.h"
|
||||
|
||||
namespace {
|
||||
|
||||
void SleepMs(int ms) { std::this_thread::sleep_for(std::chrono::milliseconds(ms)); }
|
||||
|
||||
} // namespace
|
||||
|
||||
// Placement speeds (see "Placement" at the top).
|
||||
constexpr double kPlaceDegPerSec = 60; // tested: 40 deg/s is applied exactly
|
||||
|
||||
// --- Panel placement (see "Placement" at the top of the file) ---
|
||||
// Device pose, given in the standing universe, sent to the driver in raw space.
|
||||
void Pointer::SendPose(Vec3 originStanding, const Basis &b) {
|
||||
vr::TrackedDevicePose_t s, r;
|
||||
sys->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding, 0, &s, 1);
|
||||
sys->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseRawAndUncalibrated, 0, &r, 1);
|
||||
const auto &S = s.mDeviceToAbsoluteTracking, &R = r.mDeviceToAbsoluteTracking;
|
||||
auto toRaw = [&](Vec3 v) { return Rotate(R, RotateInverse(S, v)); };
|
||||
const Vec3 o = Position(R) + toRaw(originStanding - Position(S));
|
||||
double q[4];
|
||||
BasisQuat({toRaw(b.x), toRaw(b.y), toRaw(b.z)}, q);
|
||||
char msg[200];
|
||||
std::snprintf(msg, sizeof msg, "posq %.5f %.5f %.5f %.6f %.6f %.6f %.6f", o.x, o.y, o.z, q[0], q[1], q[2], q[3]);
|
||||
SendTo(out, "ft_pointer", msg);
|
||||
}
|
||||
|
||||
std::pair<bool, Vec3> Pointer::HeadPos() {
|
||||
vr::TrackedDevicePose_t s;
|
||||
sys->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding, 0, &s, 1);
|
||||
return std::make_pair(s.bPoseIsValid, Position(s.mDeviceToAbsoluteTracking));
|
||||
}
|
||||
|
||||
// Borrow the device and the laser for a placement: connect, claim, laser mode on.
|
||||
void Pointer::Borrow() {
|
||||
SendTo(out, "ft_pointer", "show");
|
||||
overlay->ShowOverlay(laserMode);
|
||||
overlay->HideOverlay(cursor);
|
||||
overlay->HideOverlay(marker);
|
||||
SleepMs(active ? 50 : 400); // a fresh connect needs SteamVR to bind the device
|
||||
SendTo(out, "ft_pointer", "btn a 1");
|
||||
SleepMs(60);
|
||||
SendTo(out, "ft_pointer", "btn a 0");
|
||||
}
|
||||
|
||||
void Pointer::GiveBack() {
|
||||
if (!active) {
|
||||
SendTo(out, "ft_pointer", "hide");
|
||||
overlay->HideOverlay(laserMode);
|
||||
laserModeShown = false;
|
||||
}
|
||||
}
|
||||
|
||||
std::string Pointer::FindPanel(const char *key, Panel &p, Vec3 &eye) {
|
||||
vr::VROverlayHandle_t h;
|
||||
if (overlay->FindOverlay(key, &h) != vr::VROverlayError_None) return std::string("no overlay ") + key;
|
||||
bool valid;
|
||||
std::tie(valid, eye) = HeadPos();
|
||||
if (!valid) return "no head pose (headset off?)";
|
||||
p = ScanPanel(h, eye, 0.5);
|
||||
if (!p.found) return std::string("panel not visible: ") + key;
|
||||
return "";
|
||||
}
|
||||
|
||||
// grabprobe (maintenance): aim down from the panel's bottom edge, 1 cm a step, and log where
|
||||
// SteamVR's laser hits something (the hit dot shows) and whether the window controls are up.
|
||||
void Pointer::GrabProbe(const char *key) {
|
||||
Panel p;
|
||||
Vec3 eye;
|
||||
const std::string err = FindPanel(key, p, eye);
|
||||
if (!err.empty()) {
|
||||
std::printf("grabprobe: %s\n", err.c_str());
|
||||
std::fflush(stdout);
|
||||
return;
|
||||
}
|
||||
Borrow();
|
||||
vr::VROverlayHandle_t footer = vr::k_ulOverlayHandleInvalid;
|
||||
overlay->FindOverlay("valve.steam.gamepadui.floatingfooter", &footer);
|
||||
const Vec3 bottom = p.center - p.basis.y * (p.height / 2);
|
||||
std::printf("grabprobe %s: center (%.3f %.3f %.3f) %.3f x %.3f m\n", key, p.center.x, p.center.y, p.center.z,
|
||||
p.width, p.height);
|
||||
for (int cm = -5; cm <= 45; ++cm) {
|
||||
const Vec3 target = bottom - p.basis.y * (cm / 100.0);
|
||||
SendPose(eye, AimBasis(target - eye));
|
||||
SleepMs(90);
|
||||
const bool dot = systemPointer != vr::k_ulOverlayHandleInvalid && overlay->IsOverlayVisible(systemPointer);
|
||||
const bool foot = footer != vr::k_ulOverlayHandleInvalid && overlay->IsOverlayVisible(footer);
|
||||
std::printf(" %+3d cm below the bottom edge: steamvr_dot=%d footer=%d", cm, dot, foot);
|
||||
if (foot) {
|
||||
vr::ETrackingUniverseOrigin uo;
|
||||
vr::HmdMatrix34_t t{};
|
||||
if (overlay->GetOverlayTransformAbsolute(footer, &uo, &t) == vr::VROverlayError_None) {
|
||||
const Vec3 f = Position(t) - p.center;
|
||||
std::printf(" footer at panel (%.3f %.3f %.3f)", Dot(f, p.basis.x), Dot(f, p.basis.y),
|
||||
Dot(f, p.basis.z));
|
||||
}
|
||||
}
|
||||
std::printf("\n");
|
||||
}
|
||||
std::fflush(stdout);
|
||||
GiveBack();
|
||||
}
|
||||
|
||||
// Carry a floating panel so its centre lands on `target` with frame `bt` (see
|
||||
// "Placement" at the top). Returns "ok ..." or "error ...".
|
||||
std::string Pointer::Place(const char *key, Vec3 target, const Basis &bt, double grabBelow) {
|
||||
Panel p;
|
||||
Vec3 eye;
|
||||
std::string err = FindPanel(key, p, eye);
|
||||
if (!err.empty()) return "error " + err;
|
||||
auto offBy = [&](const Panel &q, double &cm, double °) {
|
||||
cm = Length(q.center - target) * 100;
|
||||
const double c = (Dot(q.basis.x, bt.x) + Dot(q.basis.y, bt.y) + Dot(q.basis.z, bt.z) - 1) / 2;
|
||||
deg = std::acos(std::clamp(c, -1.0, 1.0)) * 180 / M_PI;
|
||||
};
|
||||
double cm, deg;
|
||||
int moves = 0;
|
||||
Borrow();
|
||||
for (int attempt = 0; attempt < 3; ++attempt) {
|
||||
offBy(p, cm, deg);
|
||||
if (cm < 1.5 && deg < 1.0) break;
|
||||
// The rigid motion that takes the panel to the target: rotate by R, then move.
|
||||
auto turn = [&](Vec3 v) { return FromBasis(bt, ToBasis(p.basis, v)); };
|
||||
double q[4];
|
||||
BasisQuat({turn({1, 0, 0}), turn({0, 1, 0}), turn({0, 0, 1})}, q);
|
||||
const double angle = 2 * std::acos(std::clamp(q[0], -1.0, 1.0));
|
||||
const Vec3 axis = std::sin(angle / 2) > 1e-6 ? Normalize({q[1], q[2], q[3]}) : Vec3{0, 1, 0};
|
||||
const Vec3 grab = p.center - p.basis.y * (p.height / 2 + grabBelow);
|
||||
const Basis d0 = AimBasis(grab - eye);
|
||||
const Vec3 o1 = target + turn(eye - p.center); // device origin at the end
|
||||
++moves;
|
||||
SendPose(eye, d0);
|
||||
SleepMs(150); // hover: the window controls come up
|
||||
SendTo(out, "ft_pointer", "btn trigger 1");
|
||||
SleepMs(150);
|
||||
// 1. Rotate about the device origin (the eye): the dashboard applies it exactly.
|
||||
const int rsteps = std::max(4, int(angle * 180 / M_PI / kPlaceDegPerSec * 60));
|
||||
for (int i = 1; i <= rsteps; ++i) {
|
||||
const double a = angle * i / rsteps;
|
||||
auto r = [&](Vec3 v) { return RotateAbout(v, axis, a); };
|
||||
SendPose(eye, {r(d0.x), r(d0.y), r(d0.z)});
|
||||
SleepMs(16);
|
||||
}
|
||||
// 2. Slide the device slowly: fast moves are accelerated by the dashboard.
|
||||
const Basis d1{turn(d0.x), turn(d0.y), turn(d0.z)};
|
||||
const int tsteps = std::max(4, int(Length(o1 - eye) / cfg.slideSpeed * 60));
|
||||
for (int i = 1; i <= tsteps; ++i) {
|
||||
SendPose(eye + (o1 - eye) * (double(i) / tsteps), d1);
|
||||
SleepMs(16);
|
||||
}
|
||||
SleepMs(100);
|
||||
SendTo(out, "ft_pointer", "btn trigger 0");
|
||||
SleepMs(600); // the dashboard re-reads the pose up to 150 ms after the release
|
||||
err = FindPanel(key, p, eye);
|
||||
if (!err.empty()) break;
|
||||
}
|
||||
GiveBack();
|
||||
if (!err.empty()) return "error after the move: " + err;
|
||||
offBy(p, cm, deg);
|
||||
char msg[160];
|
||||
std::snprintf(msg, sizeof msg, "ok %s off by %.1f cm, %.1f deg after %d move%s", key, cm, deg, moves,
|
||||
moves == 1 ? "" : "s");
|
||||
std::printf("place: %s\n", msg);
|
||||
std::fflush(stdout);
|
||||
return msg;
|
||||
}
|
||||
+20
-1
@@ -58,6 +58,7 @@
|
||||
#include <wlr/util/log.h>
|
||||
|
||||
#include "vr.h"
|
||||
#include "controller-click.h"
|
||||
|
||||
#define MAX_SCREENS 24 // screens and spare outputs
|
||||
|
||||
@@ -104,6 +105,7 @@ struct server {
|
||||
struct wl_list buffers; // tracked_buffer
|
||||
struct wl_event_source *tick;
|
||||
struct screen *pointer_focus;
|
||||
struct ft_controller_click controller_click;
|
||||
pid_t child;
|
||||
// Where typing goes: the screens after a click on one, Steam after a click on another
|
||||
// panel. The input relay grabs the keyboards while it's the screens (see keys_update).
|
||||
@@ -292,6 +294,11 @@ static void handle_vr_event(const struct ft_event *e, void *data) {
|
||||
return;
|
||||
}
|
||||
if (e->screen < 0 || e->screen >= MAX_SCREENS || !s->screens[e->screen]) return;
|
||||
struct ft_event filtered = *e;
|
||||
if (e->screen < s->n_config &&
|
||||
!ft_controller_click_filter(&s->controller_click, &filtered, s->scale[e->screen])) return;
|
||||
e = &filtered;
|
||||
if (e->screen < 0 || e->screen >= MAX_SCREENS || !s->screens[e->screen]) return;
|
||||
struct screen *sc = s->screens[e->screen];
|
||||
struct wlr_surface *surface = sc->toplevel->base->surface;
|
||||
const uint32_t t = now_ms();
|
||||
@@ -514,7 +521,18 @@ static int control_readable(int fd, uint32_t mask, void *data) {
|
||||
unsigned code;
|
||||
int value, index, w, h;
|
||||
double scale;
|
||||
if (sscanf(buf, "size %d %d %d", &index, &w, &h) == 3) {
|
||||
char tail;
|
||||
if (strcmp(buf, "controller-click?") == 0) {
|
||||
snprintf(reply, sizeof reply,
|
||||
"{\"supported\":true,\"threshold\":%.3f,\"held\":%s,\"dragging\":%s,\"suppressedMotions\":%lu,\"clicks\":%lu,\"drags\":%lu}",
|
||||
s->controller_click.threshold, s->controller_click.held ? "true" : "false",
|
||||
s->controller_click.dragging ? "true" : "false", s->controller_click.suppressed,
|
||||
s->controller_click.clicks, s->controller_click.drags);
|
||||
} else if (sscanf(buf, "controller-click %lf %c", &scale, &tail) == 1) {
|
||||
if (!isfinite(scale) || scale < 0 || scale > 64 || s->controller_click.buttons)
|
||||
snprintf(reply, sizeof reply, "error threshold or held controller button");
|
||||
else { s->controller_click.threshold = scale; snprintf(reply, sizeof reply, "ok"); }
|
||||
} else if (sscanf(buf, "size %d %d %d", &index, &w, &h) == 3) {
|
||||
// A new resolution for a screen, live: KWin resizes the screen to match. (KWin makes
|
||||
// it this size times its scale; ft-floatd sends spares' sizes divided by theirs.)
|
||||
const int min_w = index - 1 < s->n_config ? 320 : 64, min_h = index - 1 < s->n_config ? 200 : 64;
|
||||
@@ -641,6 +659,7 @@ static bool setup_dmabuf(struct server *s) {
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
struct server s = {0};
|
||||
s.controller_click.threshold = 8;
|
||||
for (int i = 0; i < MAX_SCREENS; ++i) s.scale[i] = 1;
|
||||
s.kb_screen = -1;
|
||||
const char *socket_name = "ft-screens-0", *control_name = "ft_screens";
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
#ifndef FT_CONTROLLER_CLICK_H
|
||||
#define FT_CONTROLLER_CLICK_H
|
||||
#include <math.h>
|
||||
#include <linux/input-event-codes.h>
|
||||
#include "vr.h"
|
||||
struct ft_controller_click {
|
||||
bool held, dragging;
|
||||
int screen;
|
||||
uint32_t buttons;
|
||||
double x, y, threshold, radius;
|
||||
unsigned long suppressed, clicks, drags;
|
||||
};
|
||||
// Hold the desktop position at press time until movement exceeds a logical-pixel
|
||||
// radius. No timer or delayed button-down. Only a hand controller's press starts
|
||||
// it: the 3D mouse drives a laser too, and its short drags must stay drags.
|
||||
static inline bool ft_controller_click_filter(struct ft_controller_click *c,
|
||||
struct ft_event *e, double scale) {
|
||||
if (e->type == FT_BUTTON && e->button >= BTN_LEFT && e->button < BTN_LEFT+8) {
|
||||
uint32_t bit = 1u << (e->button-BTN_LEFT);
|
||||
if (e->pressed) c->buttons |= bit; else c->buttons &= ~bit;
|
||||
}
|
||||
if (e->type == FT_BUTTON && e->pressed) {
|
||||
if (e->button == BTN_LEFT && e->controller && !c->held && c->threshold > 0) {
|
||||
c->held = true; c->dragging = false; c->screen = e->screen;
|
||||
c->x = e->x; c->y = e->y; c->radius = c->threshold * scale;
|
||||
} else if (e->button != BTN_LEFT) {
|
||||
c->held = false; // Multi-button gestures retain their usual semantics.
|
||||
}
|
||||
} else if (e->type == FT_MOTION && c->held && !c->dragging) {
|
||||
if (e->screen == c->screen && hypot(e->x-c->x, e->y-c->y) <= c->radius) {
|
||||
++c->suppressed;
|
||||
return false;
|
||||
}
|
||||
c->dragging = true; ++c->drags;
|
||||
} else if (e->type == FT_BUTTON && e->button == BTN_LEFT && !e->pressed && c->held) {
|
||||
if (!c->dragging) {
|
||||
e->screen = c->screen; e->x = c->x; e->y = c->y;
|
||||
++c->clicks;
|
||||
}
|
||||
c->held = false; c->dragging = false;
|
||||
} else if (e->type == FT_LEAVE && c->held) {
|
||||
return false; // Preserve the implicit grab through tiny edge excursions.
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,42 @@
|
||||
#include <assert.h>
|
||||
#include "../controller-click.h"
|
||||
static struct ft_event event(enum ft_event_type t, bool down, double x, double y) {
|
||||
return (struct ft_event){.type=t,.screen=0,.button=BTN_LEFT,.pressed=down,.controller=true,.x=x,.y=y};
|
||||
}
|
||||
int main(void) {
|
||||
struct ft_controller_click c={.threshold=8};
|
||||
struct ft_event e=event(FT_BUTTON,true,100,100);
|
||||
assert(ft_controller_click_filter(&c,&e,2));
|
||||
e=event(FT_MOTION,false,112,108);
|
||||
assert(!ft_controller_click_filter(&c,&e,2));
|
||||
assert(c.held && !c.dragging && c.suppressed==1);
|
||||
e=event(FT_BUTTON,false,116,111);
|
||||
assert(ft_controller_click_filter(&c,&e,2));
|
||||
assert(e.x==100 && e.y==100 && c.clicks==1 && !c.held);
|
||||
e=event(FT_BUTTON,true,100,100);ft_controller_click_filter(&c,&e,1);
|
||||
e=event(FT_MOTION,false,109,100);assert(ft_controller_click_filter(&c,&e,1));
|
||||
assert(c.dragging && c.drags==1);
|
||||
e=event(FT_MOTION,false,101,100);assert(ft_controller_click_filter(&c,&e,1));
|
||||
e=event(FT_BUTTON,false,103,100);assert(ft_controller_click_filter(&c,&e,1));
|
||||
assert(e.x==103 && c.clicks==1 && !c.held && !c.dragging);
|
||||
e=event(FT_BUTTON,true,100,100);ft_controller_click_filter(&c,&e,1);
|
||||
e=event(FT_LEAVE,false,0,0);assert(!ft_controller_click_filter(&c,&e,1));
|
||||
e=event(FT_BUTTON,false,800,900);e.screen=1;ft_controller_click_filter(&c,&e,1);
|
||||
assert(e.screen==0 && e.x==100 && c.clicks==2);
|
||||
e=event(FT_BUTTON,true,100,100);ft_controller_click_filter(&c,&e,1);
|
||||
e=event(FT_MOTION,false,101,100);e.screen=1;
|
||||
assert(ft_controller_click_filter(&c,&e,1) && c.dragging);
|
||||
e=event(FT_BUTTON,false,101,100);ft_controller_click_filter(&c,&e,1);
|
||||
c.threshold=0;
|
||||
e=event(FT_BUTTON,true,100,100);ft_controller_click_filter(&c,&e,1);
|
||||
e=event(FT_MOTION,false,101,100);assert(ft_controller_click_filter(&c,&e,1) && !c.held);
|
||||
c.threshold=8;
|
||||
e=event(FT_BUTTON,true,100,100);ft_controller_click_filter(&c,&e,1);
|
||||
e=event(FT_BUTTON,true,100,100);e.button=BTN_RIGHT;ft_controller_click_filter(&c,&e,1);
|
||||
assert(!c.held);
|
||||
// The 3D mouse's laser: a short drag stays a drag, and the release stays where it was.
|
||||
e=event(FT_BUTTON,true,100,100);e.controller=false;assert(ft_controller_click_filter(&c,&e,1) && !c.held);
|
||||
e=event(FT_MOTION,false,104,100);assert(ft_controller_click_filter(&c,&e,1));
|
||||
e=event(FT_BUTTON,false,106,100);e.controller=false;
|
||||
assert(ft_controller_click_filter(&c,&e,1) && e.x==106 && c.clicks==2 && c.suppressed==1);
|
||||
}
|
||||
+3
-1
@@ -1330,7 +1330,8 @@ void UpdateCatcher() {
|
||||
for (int k = 0; k < 3; ++k) params.vSource.v[k] = l.m[k][3], params.vDirection.v[k] = -l.m[k][2];
|
||||
for (auto &[i, s] : g_screens) {
|
||||
if (!s.visible) continue;
|
||||
std::vector<vr::VROverlayHandle_t> parts(s.All().begin(), s.All().end());
|
||||
const auto all = s.All(); // one copy: two calls give two temporaries, not one range
|
||||
std::vector<vr::VROverlayHandle_t> parts(all.begin(), all.end());
|
||||
for (const auto &[k, sub] : s.subs) parts.push_back(sub.overlay);
|
||||
for (auto o : parts) {
|
||||
vr::VROverlayIntersectionResults_t hit;
|
||||
@@ -1741,6 +1742,7 @@ void ft_vr_poll(void (*handle)(const struct ft_event *, void *), void *data) {
|
||||
e.type = FT_BUTTON;
|
||||
e.button = LinuxButton(ev.data.mouse.button);
|
||||
e.pressed = ev.eventType == vr::VREvent_MouseButtonDown;
|
||||
e.controller = IsHandController(ev.trackedDeviceIndex);
|
||||
at();
|
||||
if (!e.pressed && s.titleCarry) e.x = s.carryX, e.y = s.carryY, s.titleCarry = false;
|
||||
if (e.pressed) {
|
||||
|
||||
@@ -25,6 +25,7 @@ struct ft_event {
|
||||
double x, y; // FT_MOTION: buffer pixels from the top left
|
||||
uint32_t button; // FT_BUTTON: linux BTN_*
|
||||
bool pressed;
|
||||
bool controller; // FT_BUTTON: from a hand controller's laser (not the 3D mouse's)
|
||||
double dx, dy; // FT_SCROLL: notches (positive dy: scroll down)
|
||||
uint32_t key; // FT_KEY: linux KEY_* from our keyboard (pressed: down or up)
|
||||
};
|
||||
|
||||
+18
-3
@@ -6,7 +6,22 @@
|
||||
# `distrobox enter`.
|
||||
box=${FRAME_BOX:-dev}
|
||||
export XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/run/user/$(id -u)}
|
||||
[ "$(podman container inspect -f '{{.State.Running}}' "$box" 2>/dev/null)" = true ] && exit 0
|
||||
running() { [ "$(podman container inspect -f '{{.State.Running}}' "$box" 2>/dev/null)" = true ]; }
|
||||
running && exit 0
|
||||
podman container exists "$box" 2>/dev/null || exit 0 # not created yet: setup/dev-container.sh does that
|
||||
exec systemd-run --user --scope --quiet --collect --description="$box container (started for Frametop)" \
|
||||
podman start "$box" >/dev/null
|
||||
since=$(date -u +%FT%T)
|
||||
systemd-run --user --scope --quiet --collect --description="$box container (started for Frametop)" \
|
||||
podman start "$box" >/dev/null || exit
|
||||
|
||||
# Every start runs distrobox-init in the container, and `distrobox enter` only waits for it
|
||||
# when it starts the container itself. The first start installs what distrobox needs and sets
|
||||
# up passwordless sudo, which takes a minute or more; until then sudo in the container asks
|
||||
# for a password (issue #9). Later starts take a few seconds.
|
||||
for i in $(seq 600); do
|
||||
podman logs --since "$since" "$box" 2>&1 | grep -q '^container_setup_done' && exit 0
|
||||
running || break
|
||||
[ "$i" = 10 ] && echo "setting up the $box container (the first start takes a few minutes)" >&2
|
||||
sleep 1
|
||||
done
|
||||
echo "the $box container didn't finish starting; see: podman logs $box" >&2
|
||||
exit 1
|
||||
@@ -112,7 +112,7 @@ def main():
|
||||
try:
|
||||
with open(os.path.join(HOME, ".config/frametop.conf")) as f:
|
||||
conf = [ln.split("#")[0].strip() for ln in f]
|
||||
out("Settings: " + " ".join(c for c in conf if re.match(r"(POINTER|META_DASHBOARD|SHARE_KEYS|BACKEND)=", c)))
|
||||
out("Settings: " + " ".join(c for c in conf if re.match(r"(POINTER|SHARE_KEYS|BACKEND)=", c)))
|
||||
except OSError:
|
||||
out("Settings: no ~/.config/frametop.conf")
|
||||
out("ft-screens state (visibility, manual, wrist, gesture, lasers, game running, in games): "
|
||||
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
"$root/scripts/frame.sh" -C screens 'mkdir -p build; gcc -std=c11 -Wall -Wextra -Werror tests/controller-click-test.c -lm -o build/controller-click-test && build/controller-click-test'
|
||||
+27
-2
@@ -285,11 +285,11 @@ def check_openvr():
|
||||
def check_overlays(desktop_up):
|
||||
code, out = run(f"{STEAMVR_BIN}/vrcmd", "--overlays",
|
||||
env=dict(os.environ, LD_LIBRARY_PATH=STEAMVR_BIN))
|
||||
# The format ft-pointer.cpp and ft_layout.py parse: 'key' -- 'name', WxH visible VROverlayType_...
|
||||
# The format the pointer helper (pointer.h) and ft_layout.py parse: 'key' -- 'name', WxH visible VROverlayType_...
|
||||
keys = re.findall(r"^'([^']+)' -- '.*VROverlayType_", out, re.M)
|
||||
if not keys:
|
||||
report("FAIL", "vrcmd --overlays", "lists no overlays in the expected format, so the 3D mouse can't "
|
||||
"find panels (pointer/helper/ft-pointer.cpp and layout/ft_layout.py parse it)")
|
||||
"find panels (pointer/helper/pointer.h and layout/ft_layout.py parse it)")
|
||||
elif desktop_up and not any(re.fullmatch(r"frametop\.screen\.\d+", k) for k in keys):
|
||||
report("FAIL", "vrcmd --overlays", f"lists {len(keys)} overlays, but none of the desktop's screens")
|
||||
else:
|
||||
@@ -321,6 +321,30 @@ def check_services(sockets, desktop_up):
|
||||
report("FAIL", "XRService", "not running, so hand tracking has no cameras")
|
||||
|
||||
|
||||
def check_steam_ui():
|
||||
"""Steam's UI calls the Steam menu shortcut uses (steam/ft-steam). They're Steam client
|
||||
internals, so a Steam update can change them too, not just a SteamOS one."""
|
||||
# Run from stdin (over SSH) there's no __file__: try the usual checkouts.
|
||||
repos = [os.path.dirname(os.path.dirname(os.path.abspath(__file__)))] if "__file__" in globals() else []
|
||||
repos += [os.path.join(HOME, "frametop"), os.path.join(HOME, "dev/frametop")]
|
||||
tool = next((t for t in (os.path.join(r, "steam/ft-steam") for r in repos) if os.path.exists(t)), None)
|
||||
if not tool:
|
||||
report("skip", "Steam menu shortcut", "steam/ft-steam isn't in a checkout here")
|
||||
return
|
||||
try:
|
||||
p = subprocess.run([tool, "check"], capture_output=True, text=True, timeout=15)
|
||||
except (OSError, subprocess.TimeoutExpired) as e:
|
||||
report("warn", "Steam menu shortcut", f"ft-steam check didn't run ({e})")
|
||||
return
|
||||
detail = (p.stdout + p.stderr).strip()
|
||||
if p.returncode == 0:
|
||||
report("ok", "Steam menu shortcut", "Steam's UI still has the calls steam/ft-steam makes")
|
||||
elif detail.startswith("ft-steam:"):
|
||||
report("skip", "Steam menu shortcut", detail[len("ft-steam:"):].strip())
|
||||
else:
|
||||
report("warn", "Steam menu shortcut", f"{detail}; a Meta tap won't open the Steam menu (steam/ft-steam)")
|
||||
|
||||
|
||||
def check_eye_tracker():
|
||||
gaze = systemctl("is-enabled", "frametop-gaze") == "enabled"
|
||||
try:
|
||||
@@ -369,6 +393,7 @@ def main():
|
||||
check_openvr()
|
||||
check_overlays(desktop_up)
|
||||
check_services(sockets, desktop_up)
|
||||
check_steam_ui()
|
||||
check_eye_tracker()
|
||||
|
||||
if args == ["--mark-good"]:
|
||||
|
||||
@@ -38,7 +38,7 @@ POINTER_GAZE_SHOW=1 # gaze mode, with POINTER_GAZE_DOT=moving: the dot sh
|
||||
POINTER_GAZE_MOUSE=precision # gaze mode: the left button holds back its press, the mouse steers, the release clicks (precision) | clicks right away (direct)
|
||||
POINTER_HEAD_DEADZONE=0.5 # keyboard clicks at the gaze (Meta+J, Meta+K): degrees the head turns before the held dot moves with it
|
||||
POINTER_KEY_TAP=0.25 # keyboard clicks: let go within this (s) and it clicks where the dot was at the press, and tells the gaze tracker it was right
|
||||
GAZE_TRACKER=steam # gaze service: steam = SteamVR's eye tracker | own = our own (gaze/tracker: its frame grabber needs gaze/tracker/install.sh; calibrated with Calibrate on Frametop Input Settings' Gaze page)
|
||||
GAZE_TRACKER=auto # gaze service: auto = our own eye tracker when it's installed (gaze/tracker/install.sh, which install.sh offers), else SteamVR's | own = ours | steam = SteamVR's. Each keeps its own calibration (Calibrate on Frametop Input Settings' Gaze page)
|
||||
GAZE_EYE=auto # gaze service: eye bias. auto = each eye weighted by how far off it was at your recent nudges | left | right = that eye counts twice
|
||||
HANDS_SWAP_SIDES=0 # hand tracking (hands/run.sh install): 1 = the side cameras' names are swapped, which some SteamVR restarts cause (hands/tools/check_sides.py --ring tells)
|
||||
HANDS_CPUS=5,6,7 # hand tracking: the CPUs its model threads run on
|
||||
@@ -54,7 +54,6 @@ POINTER_PINCH_DEADZONE=1.5 # hands: degrees the pinching hand moves before the p
|
||||
POINTER_GRIP_GAIN=1 # hands: the pointer moves this times the gripping hand's angle
|
||||
POINTER_GRIP_BELOW=0.35 # hands: grips beginning more than this (m) below the eyes are ignored (hands on a desk)
|
||||
POINTER_PINCH_TYPING=1 # hands: no pinch within this many seconds of a key (typing touches thumb to index)
|
||||
META_DASHBOARD=0 # 1 = a Meta tap on a pass-through keyboard toggles the SteamVR dashboard (pointer mode only)
|
||||
SHARE_KEYS=0 # 1 = keys of keyboards grabbed for the desktop also go to @frametop_keys, for hotkey tools (any local process can listen)
|
||||
DISPLAY_OFF_MIN=0 # minutes without use before frametop-power turns the displays off, even if the headset seems worn (0 = never)
|
||||
DISPLAY_MOVE_MM=5 # displays off: the headset or a controller moving this far (mm) within 10 s counts as use
|
||||
|
||||
@@ -45,9 +45,9 @@ fi
|
||||
"$distrobox" enter dev -- bash -c '
|
||||
set -euo pipefail
|
||||
echo "installing ${#@} packages (already-installed ones are skipped)"
|
||||
sudo dnf install -y -q "$@" 2>&1 | { grep -vE "is already installed|^Nothing to do|^$" || true; }
|
||||
sudo -n dnf install -y -q "$@" 2>&1 | { grep -vE "is already installed|^Nothing to do|^$" || true; }
|
||||
# OpenVR programs built here (the pointer helper and probe) look for the runtime at /opt/steamvr.
|
||||
[ -e /opt/steamvr ] || sudo ln -s /run/host/opt/steamvr /opt/steamvr
|
||||
[ -e /opt/steamvr ] || sudo -n ln -s /run/host/opt/steamvr /opt/steamvr
|
||||
echo "dev container ready: $(. /etc/os-release; echo $PRETTY_NAME), glibc $(ldd --version | head -1 | grep -oE "[0-9.]+$")"
|
||||
' dev "$@"
|
||||
EOF
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ft-steam: Steam's menu and checks, through Steam's own UI (steamui.py).
|
||||
|
||||
ft-steam menu open the SteamVR dashboard on Steam's menu, or close the dashboard if it's
|
||||
open (the Steam button's job, as a shortcut: the input relay's steam_menu)
|
||||
ft-steam check do Steam's UI calls that the above needs still exist (update-check.py)
|
||||
|
||||
No pointer mode needed: it asks Steam to show its dashboard overlay (window.ShowVROverlay, what
|
||||
Steam itself calls) and to focus the Steam frame's left menu (MenuStore.OpenMainMenu, which in
|
||||
VR invokes the frame's focus_left_frame_menu_action). SteamClient.OpenVR.VROverlay says
|
||||
whether the dashboard is up and hides it. These are Steam's UI internals, not an API, so a
|
||||
Steam client update can rename them: `ft-steam check` says so.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
|
||||
from steamui import SteamUnreachable, evaluate # noqa: E402
|
||||
|
||||
WINDOW = "SteamUIStore.WindowStore.VRGamepadUIMainWindowInstance"
|
||||
|
||||
MENU = f"""(async () => {{
|
||||
const vr = SteamClient.OpenVR.VROverlay;
|
||||
if (await vr.IsDashboardVisible()) {{ vr.HideDashboard(); return "closed"; }}
|
||||
const w = {WINDOW};
|
||||
if (!w) throw new Error("Steam's VR dashboard window isn't open");
|
||||
ShowVROverlay(w.GetVROverlayKey());
|
||||
// The frame's menu takes focus only once the dashboard is up.
|
||||
for (let i = 0; i < 40 && !(await vr.IsDashboardVisible()); i++) await new Promise(r => setTimeout(r, 25));
|
||||
w.MenuStore.OpenMainMenu();
|
||||
return "opened";
|
||||
}})()"""
|
||||
|
||||
CHECK = f"""(() => {{
|
||||
const missing = [];
|
||||
const vr = SteamClient.OpenVR && SteamClient.OpenVR.VROverlay;
|
||||
for (const f of ["IsDashboardVisible", "HideDashboard"]) if (!vr || typeof vr[f] !== "function") missing.push("SteamClient.OpenVR.VROverlay." + f);
|
||||
if (typeof window.ShowVROverlay !== "function") missing.push("ShowVROverlay");
|
||||
const w = {WINDOW};
|
||||
if (!w) missing.push("{WINDOW}");
|
||||
else {{
|
||||
if (typeof w.GetVROverlayKey !== "function") missing.push("GetVROverlayKey");
|
||||
if (!w.MenuStore || typeof w.MenuStore.OpenMainMenu !== "function") missing.push("MenuStore.OpenMainMenu");
|
||||
}}
|
||||
return missing;
|
||||
}})()"""
|
||||
|
||||
|
||||
def main():
|
||||
cmd = sys.argv[1] if len(sys.argv) == 2 else ""
|
||||
if cmd not in ("menu", "check"):
|
||||
sys.exit(__doc__)
|
||||
try:
|
||||
result = evaluate(MENU if cmd == "menu" else CHECK)
|
||||
except SteamUnreachable as e:
|
||||
sys.exit(f"ft-steam: {e}")
|
||||
if cmd == "check":
|
||||
if result:
|
||||
sys.exit("Steam's UI lacks " + ", ".join(result))
|
||||
print("ok")
|
||||
else:
|
||||
print(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Run JavaScript in Steam's UI, standard library only.
|
||||
|
||||
Steam on the Frame runs with -cef-enable-debugging, so its UI's JavaScript context
|
||||
(SharedJSContext) is reachable over the Chrome DevTools Protocol on 127.0.0.1:8080. Steam's
|
||||
own calls live there: SteamClient.* (settings, SteamVR's dashboard) and the UI's stores
|
||||
(SteamUIStore). Used by Frametop Display Settings (display-settings/steam_settings.py) and
|
||||
ft-steam (the Steam menu shortcut).
|
||||
|
||||
evaluate(expression) the expression's value (awaited, JSON-able); SteamUnreachable when
|
||||
Steam or its UI isn't there, or the script threw
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import urllib.request
|
||||
|
||||
CDP_PORT = 8080
|
||||
|
||||
|
||||
class SteamUnreachable(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class _WebSocket:
|
||||
def __init__(self, url, timeout=5):
|
||||
host_port, path = url[len("ws://"):].split("/", 1)
|
||||
host, port = host_port.rsplit(":", 1)
|
||||
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: {host_port}\r\nUpgrade: websocket\r\n"
|
||||
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n").encode())
|
||||
head = b""
|
||||
while b"\r\n\r\n" not in head:
|
||||
chunk = self.sock.recv(4096)
|
||||
if not chunk:
|
||||
raise SteamUnreachable("Steam closed the connection")
|
||||
head += chunk
|
||||
if b" 101 " not in head.split(b"\r\n", 1)[0]:
|
||||
raise SteamUnreachable(head.split(b"\r\n", 1)[0].decode(errors="replace"))
|
||||
self.buf = head.split(b"\r\n\r\n", 1)[1]
|
||||
|
||||
def send(self, text):
|
||||
data, mask = text.encode(), os.urandom(4)
|
||||
n = len(data)
|
||||
if n < 126:
|
||||
head = struct.pack(">BB", 0x81, 0x80 | n)
|
||||
elif n < 65536:
|
||||
head = struct.pack(">BBH", 0x81, 0x80 | 126, n)
|
||||
else:
|
||||
head = struct.pack(">BBQ", 0x81, 0x80 | 127, n)
|
||||
self.sock.sendall(head + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(data)))
|
||||
|
||||
def _take(self, n):
|
||||
while len(self.buf) < n:
|
||||
chunk = self.sock.recv(65536)
|
||||
if not chunk:
|
||||
raise SteamUnreachable("Steam closed the connection")
|
||||
self.buf += chunk
|
||||
out, self.buf = self.buf[:n], self.buf[n:]
|
||||
return out
|
||||
|
||||
def recv(self):
|
||||
message = b""
|
||||
while True:
|
||||
b0, b1 = self._take(2)
|
||||
n = b1 & 0x7F
|
||||
if n == 126:
|
||||
n = struct.unpack(">H", self._take(2))[0]
|
||||
elif n == 127:
|
||||
n = struct.unpack(">Q", self._take(8))[0]
|
||||
message += self._take(n)
|
||||
if b0 & 0x80:
|
||||
return message.decode(errors="replace")
|
||||
|
||||
def close(self):
|
||||
self.sock.close()
|
||||
|
||||
|
||||
def evaluate(expression):
|
||||
"""Runs JavaScript in Steam's SharedJSContext and returns its (awaited) value."""
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{CDP_PORT}/json", timeout=3) as r:
|
||||
targets = json.load(r)
|
||||
except OSError as e:
|
||||
raise SteamUnreachable(f"Steam isn't reachable on port {CDP_PORT} ({e})") from e
|
||||
url = next((t["webSocketDebuggerUrl"] for t in targets if t.get("title") == "SharedJSContext"), None)
|
||||
if not url:
|
||||
raise SteamUnreachable("Steam's UI isn't running")
|
||||
try:
|
||||
ws = _WebSocket(url)
|
||||
try:
|
||||
ws.send(json.dumps({"id": 1, "method": "Runtime.evaluate",
|
||||
"params": {"expression": expression, "awaitPromise": True, "returnByValue": True}}))
|
||||
while True:
|
||||
reply = json.loads(ws.recv())
|
||||
if reply.get("id") == 1:
|
||||
break
|
||||
finally:
|
||||
ws.close()
|
||||
except OSError as e:
|
||||
raise SteamUnreachable(str(e)) from e
|
||||
result = reply.get("result", {})
|
||||
if "exceptionDetails" in result:
|
||||
details = result["exceptionDetails"]
|
||||
raise SteamUnreachable(details.get("exception", {}).get("description") or details.get("text", "error"))
|
||||
return result.get("result", {}).get("value")
|
||||
Reference in new issue
Block a user