Pause Frametop for VR games

Frametop kept using the headset during games: with gaze mode off, our eye tracker still took
about 60% of a core, remote desktop about 2 cores while on, and KWin kept drawing hidden
screens because ft-screens sent their frame callbacks at 90 Hz. Pausing gives that back, and
resuming brings back only what pausing stopped. It's also a way to keep the gaze service and
our eye tracker off during games, which PR #13 asked for.

Paused (input/game_pause.py, run by the input relay):
- frametop-gaze stops (ft-eyegrab then idles by itself), and hand tracking and remote desktop
  stop if they run
- the desktop hides and slows down: ft-screens "pause on" hides every panel and sends KWin a
  frame callback once a second; or, with pause_desktop "close", the desktop closes and starts
  again on resume
- the relay lets go of the 3D mouse, typing goes to Steam, and mapped buttons and key
  combinations do only pause_toggle, steam_menu and commands

Toggled by both thumbsticks clicked together twice (configurable), read passively from
vrserver's web socket (input/vrws.py) so it works in games and takes nothing from them; by the
new pause_toggle action; by input/ft-pause; and, with pause_auto (default on), by a VR game
starting and ending, which the pointer helper now reports ("vrgame 1|0"). Frametop Input
Settings has a Games page for it. update-check.py checks the web socket, and doesn't count a
paused gaze service as failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DeeJanuzandClaude Opus 5.5 committed 2026-10-02 21:58:09 -06:00
1 parent 7fbcd177d3
commit 8aaf7db05a
18 files changed
+1534 -41

No files matched your search

+483
View File
@@ -0,0 +1,483 @@
"""Pause Frametop for VR games, so it leaves the headset's CPU and GPU to the game. The input
relay (input-relay.py) runs this: it's the one part of Frametop that always runs.
Paused:
- The gaze service stops (frametop-gaze: ft-gazed, ft-gaze, our own eye tracker ft-eyes, the
gaze panel), and with it every read of SteamVR's eye tracking. Our frame grabber, the root
service ft-eyegrab, goes idle by itself 3 seconds after ft-eyes stops asking for frames.
- Hand tracking stops if it runs (frametop-camd, frametop-hands).
- The desktop, by "pause_desktop": "hide" (the default) hides every screen and floating window
and slows the desktop down ("pause on" to ft-screens: KWin gets one frame callback a second
instead of 90, so it and the apps on it hardly draw), and remote desktop stops if it runs
(session/remote-ctl.sh: krdp, Xvnc, FreeRDP). Windows stay open. "close" closes the desktop
(desktops.sh stop): its windows close, and resuming starts it again (about 12 seconds), in
its start profile if it has one.
- In the relay: the 3D mouse lets go and the mouse is a plain mouse for SteamVR, typing goes to
Steam, and mapped buttons and key combinations do nothing but PAUSED_ACTIONS and commands.
Resuming starts again only what pausing stopped. The pointer helper and ft-powerd keep running:
they cost little, and the helper is what says a VR game started.
Toggled by:
- The pause_toggle action: a mouse button, a key combination, or a controller button outside
games (the pointer helper only reads those outside games).
- A controller gesture, by default both thumbsticks clicked together twice ("pause_gesture").
It's read from vrserver's web socket (vrws.py), which works in games and takes nothing from
them, so the game sees the clicks too.
- input/ft-pause on|off|toggle|status, which uses the relay's control socket
("pause on|off|toggle|?").
- VR games, with "pause_auto" (on by default): the pointer helper says "vrgame 1|0" as a SteamVR
scene app starts and ends (and every 5 seconds). A game starting pauses; a pause that starts
while a game runs ends RESUME_DELAY after the game does, unless another game starts first.
Toggled back on during a game, Frametop stays on until that game ends. A pause that starts
outside a game lasts until it's toggled off.
Settings in ~/.config/frametop-input.json (Frametop Input Settings, Games page): "pause_auto"
(bool), "pause_gesture" ({"buttons": one or two controller buttons, "presses": 1 or 2}, or null
for none; one button always takes two presses), "pause_desktop" ("hide" or "close"), and
"pause_sound" (a sound on pause and resume; bool). The state outlives a relay restart in
STATE_PATH.
"""
import json
import os
import queue
import socket
import subprocess
import threading
import time
import vrws
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DESKTOPS = os.path.join(REPO, "desktops.sh")
REMOTE_CTL = os.path.join(REPO, "session", "remote-ctl.sh")
RELAY = "\0frametop_relay"
SCREENS = "\0ft_screens"
STATE_PATH = f"/run/user/{os.getuid()}/frametop-pause.json"
# User services that pausing stops, if they run.
UNITS = ("frametop-gaze.service", "frametop-hands.service", "frametop-camd.service")
# What still works while paused, besides commands ("command:CMD"): the toggle itself, and Steam's menu.
PAUSED_ACTIONS = ("pause_toggle", "steam_menu")
DEFAULT_GESTURE = {"buttons": ["left/thumbstick", "right/thumbstick"], "presses": 2}
DESKTOP_MODES = ("hide", "close")
SOUNDS = {True: "/usr/share/sounds/ocean/stereo/device-removed.oga",
False: "/usr/share/sounds/ocean/stereo/device-added.oga"}
RESUME_DELAY = 5.0 # after a VR game ends: loading the next one can end one scene app and start another
GAME_STALE = 12.0 # the helper repeats "vrgame" every 5 s; silence this long means no game (or no SteamVR)
CHORD_WINDOW = 0.3 # a two-button gesture's buttons go down within this of each other
DOUBLE_WINDOW = 0.7 # a double press's second press comes within this of the first
def gesture_of(rules, buttons):
"""The rules' pause gesture as (buttons, presses), or None for none. `buttons` are the
controller buttons there are (the relay's VR_BUTTONS)."""
spec = rules.get("pause_gesture", DEFAULT_GESTURE)
if not isinstance(spec, dict) or not isinstance(spec.get("buttons"), list):
return None
chosen = tuple(dict.fromkeys(b for b in spec["buttons"] if b in buttons))[:2]
if not chosen:
return None
return chosen, 1 if len(chosen) == 2 and spec.get("presses") == 1 else 2
def settings_of(rules, buttons):
return {"auto": rules.get("pause_auto", True) is not False,
"gesture": gesture_of(rules, buttons),
"desktop": rules.get("pause_desktop") if rules.get("pause_desktop") in DESKTOP_MODES else "hide",
"sound": rules.get("pause_sound", True) is not False}
class Gesture:
"""The pause gesture, from button presses and releases. A press of a two-button gesture is
both buttons going down within CHORD_WINDOW of each other, so a button held for a while (to
sprint, say) and the other clicked doesn't count. Two presses within DOUBLE_WINDOW make a
double press."""
def __init__(self, buttons, presses):
self.buttons, self.presses = tuple(buttons), presses
self.down = {} # button -> when it went down
self.first = None # when the first press of a double press was
self.fired = None
def reset(self):
self.down.clear()
self.first = None
def feed(self, button, pressed, now):
"""A button's state; True when it completes the gesture."""
if button not in self.buttons:
return False
if not pressed:
self.down.pop(button, None)
return False
if button in self.down:
return False # still down
self.down[button] = now
if len(self.down) < len(self.buttons) or now - min(self.down.values()) > CHORD_WINDOW:
return False
if self.presses == 2 and (self.first is None or now - self.first > DOUBLE_WINDOW):
self.first = now
return False
self.first = None
if self.fired is not None and now - self.fired < 1.0:
return False # a triple press is still one
self.fired = now
return True
class ControllerWatch(threading.Thread):
"""Follows the Frame controllers on vrserver's web socket for the pause gesture, and sends
the relay "pause toggle gesture" when it's made. Reconnects every 5 seconds while SteamVR
is away. With no gesture set, it doesn't connect."""
def __init__(self, log):
super().__init__(name="pause-gesture", daemon=True)
self.log = log
self.lock = threading.Lock()
self.gesture = None
self.changed = threading.Event()
self.connected = False
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
def set_gesture(self, spec):
"""(buttons, presses), or None for no gesture."""
with self.lock:
g = self.gesture
if spec is None:
self.gesture = None
elif g is None or (g.buttons, g.presses) != spec:
self.gesture = Gesture(*spec)
self.changed.set()
def current(self):
with self.lock:
return self.gesture
def run(self):
while True:
if self.current() is None:
if self.connected:
self.log("pause gesture: none set, stopped reading the controllers")
self.connected = False
self.changed.wait()
self.changed.clear()
continue
try:
self.session()
except (OSError, ValueError) as e: # urllib's errors are OSErrors
if self.connected:
self.log(f"pause gesture: SteamVR's web socket went away ({e}); retrying")
self.connected = False
self.changed.wait(5)
self.changed.clear()
def session(self):
ws = vrws.VrSocket()
try:
ws.open(f"frametop_pause_{os.getpid()}")
sides = {} # root path -> side
next_poll = 0.0
while True:
g = self.current()
if g is None:
return
now = time.monotonic()
if now >= next_poll:
# A controller connects later, or changes its root path when the 3D mouse's
# virtual controller takes or gives back its hand role.
next_poll = now + 3.0
found = vrws.controllers()
if found != sides:
for path in sides.keys() - found.keys():
ws.subscribe(path, on=False)
for path in found.keys() - sides.keys():
ws.subscribe(path)
sides = found
g.reset()
if not self.connected:
self.connected = True
self.log(f"pause gesture: following {' and '.join(sorted(sides.values())) or 'no'} "
f"controller{'s' if len(sides) != 1 else ''} on SteamVR's web socket")
wanted = {b: (b.split("/", 1)[0], f"/input/{b.split('/', 1)[1]}/click") for b in g.buttons}
for text in ws.messages(timeout=1.0):
# About 160 messages a second, nearly all capacitive sensing: parse only the
# few that carry one of the gesture's buttons.
if not any(c in text for _, c in wanted.values()):
continue
try:
msg = json.loads(text)
except ValueError:
continue
side = sides.get(msg.get("device"))
components = msg.get("components") or {}
for button, (s, component) in wanted.items():
if s == side and component in components:
if g.feed(button, bool(components[component]), time.monotonic()):
self.log("pause gesture")
try:
self.sock.sendto(b"pause toggle gesture", RELAY)
except OSError:
pass
finally:
ws.close()
def run(cmd, timeout=30):
"""A command's exit status (None if it couldn't run), its output going nowhere."""
try:
return subprocess.run(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, timeout=timeout).returncode
except (OSError, subprocess.TimeoutExpired):
return None
def output(cmd, timeout=10):
try:
return subprocess.run(cmd, stdin=subprocess.DEVNULL, capture_output=True, text=True,
timeout=timeout).stdout
except (OSError, subprocess.TimeoutExpired):
return ""
def active(unit):
return run(["systemctl", "--user", "-q", "is-active", unit], timeout=10) == 0
def desktop_running():
return run(["pgrep", "-x", "ft-screens"], timeout=5) == 0
class Worker(threading.Thread):
"""Does the slow part of pausing and resuming, in order, one job at a time: stopping and
starting services, remote desktop, and closing or starting the desktop. `stopped` is what
the last pause stopped, for the resume to start again."""
def __init__(self, log, save):
super().__init__(name="pause-worker", daemon=True)
self.log, self.save = log, save
self.jobs = queue.Queue()
self.lock = threading.Lock()
self.stopped = {"units": [], "remote": False, "desktop": None}
self.busy = False
def submit(self, job, *args):
self.busy = True
self.jobs.put((job, args))
def run(self):
while True:
job, args = self.jobs.get()
try:
job(*args)
except Exception as e: # one failure mustn't stop the next pause or resume
self.log(f"pause: {job.__name__} failed: {e!r}")
self.busy = not self.jobs.empty()
self.save()
def snapshot(self):
with self.lock:
return {"units": list(self.stopped["units"]), "remote": self.stopped["remote"],
"desktop": self.stopped["desktop"]}
def restore(self, stopped):
with self.lock:
self.stopped = {"units": [u for u in stopped.get("units", []) if u in UNITS],
"remote": bool(stopped.get("remote")),
"desktop": stopped.get("desktop") if stopped.get("desktop") in ("hidden", "closed") else None}
def stop_units(self):
"""Stop the UNITS that run, adding them to what the resume starts again."""
units = [u for u in UNITS if active(u)]
if units:
run(["systemctl", "--user", "stop", *units], timeout=30)
with self.lock:
self.stopped["units"] += [u for u in units if u not in self.stopped["units"]]
self.log(f"pause: stopped {', '.join(units)}")
def pause(self, desktop):
if desktop == "close":
if desktop_running():
run([DESKTOPS, "stop"], timeout=40) # remote desktop goes with it
with self.lock:
self.stopped["desktop"] = "closed"
self.log("pause: desktop closed")
else:
if "running=1" in output([REMOTE_CTL, "status"]):
run([REMOTE_CTL, "stop"])
with self.lock:
self.stopped["remote"] = True
self.log("pause: remote desktop stopped")
with self.lock:
self.stopped["desktop"] = "hidden" # ft-screens was told already (or isn't running)
self.stop_units()
def enforce(self):
"""While paused: SteamVR restarted, and started the gaze service with it."""
self.stop_units()
def resume(self):
stopped = self.snapshot()
units = stopped["units"]
if units:
# They need SteamVR (Requisite=steamvr.service): without it they start with it next time.
if run(["systemctl", "--user", "start", *units], timeout=30) == 0:
self.log(f"resume: started {', '.join(units)}")
else:
self.log(f"resume: couldn't start {', '.join(units)} (SteamVR not running?)")
if stopped["remote"]:
# In a scope of its own, not this service's, so a relay restart doesn't end it.
run(["systemd-run", "--user", "--scope", "--collect", "--quiet", REMOTE_CTL, "start"])
self.log("resume: remote desktop started")
if stopped["desktop"] == "closed":
if run([DESKTOPS, "start"], timeout=60) == 0:
self.log("resume: desktop started")
else:
self.log("resume: the desktop didn't start (see /tmp/frametop-session.log)")
with self.lock:
self.stopped = {"units": [], "remote": False, "desktop": None}
class GamePause:
"""The pause state, its automatic side, and the relay's way in. `on_change(paused)` is
called (from the relay's thread) when the state flips, for the relay's own part."""
def __init__(self, log, on_change, buttons):
self.log, self.on_change, self.buttons = log, on_change, buttons
self.lock = threading.Lock() # the state file, written from both threads
self.paused = False
self.reason = "" # what paused it: gesture, button, key, controller, command, game
self.since = 0.0 # wall clock
self.ends_with_game = False # resume when the VR game ends
self.game = False
self.game_seen = 0.0 # last "vrgame" from the helper
self.resume_at = None
self.settings = settings_of({}, buttons)
self.screens = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM | socket.SOCK_NONBLOCK)
self.worker = Worker(log, self.save)
self.watch = ControllerWatch(log)
self.worker.start()
self.watch.start()
self.load()
# --- state file ---
def save(self):
with self.lock:
state = {"paused": self.paused, "reason": self.reason, "since": self.since,
"ends_with_game": self.ends_with_game, "stopped": self.worker.snapshot()}
try:
tmp = STATE_PATH + ".tmp"
with open(tmp, "w") as f:
json.dump(state, f)
os.replace(tmp, STATE_PATH)
except OSError as e:
self.log(f"pause: can't save {STATE_PATH}: {e}")
def load(self):
"""A relay restarted while paused stays paused (the relay's part is applied by its first
configure)."""
try:
with open(STATE_PATH) as f:
state = json.load(f)
except (OSError, ValueError):
return
if not isinstance(state, dict) or not state.get("paused"):
return
self.paused = True
self.reason = str(state.get("reason", ""))
self.since = float(state.get("since", 0) or 0)
self.ends_with_game = bool(state.get("ends_with_game"))
self.worker.restore(state.get("stopped") or {})
self.log(f"paused since before this relay started ({self.reason})")
self.worker.submit(self.worker.enforce)
# --- settings ---
def configure(self, rules):
self.settings = settings_of(rules, self.buttons)
self.watch.set_gesture(self.settings["gesture"])
if not self.settings["auto"]:
self.ends_with_game, self.resume_at = False, None
# --- pausing and resuming ---
def sound(self, paused):
if self.settings["sound"] and os.path.exists(SOUNDS[paused]):
try:
subprocess.Popen(["pw-play", SOUNDS[paused]], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True)
except OSError:
pass
def tell_screens(self, paused):
try:
self.screens.sendto(b"pause on" if paused else b"pause off", SCREENS)
except OSError:
pass # the desktop isn't running
def pause(self, reason, now):
if self.paused:
return
self.paused, self.reason, self.since = True, reason, time.time()
self.ends_with_game = self.game and self.settings["auto"]
self.resume_at = None
self.log(f"Frametop paused ({reason}{', until the game ends' if self.ends_with_game else ''})")
self.sound(True)
self.tell_screens(True) # at once, even when the desktop is closing: that takes a while
self.on_change(True)
self.worker.submit(self.worker.pause, self.settings["desktop"])
self.save()
def resume(self, reason, now):
if not self.paused:
return
self.paused, self.resume_at, self.ends_with_game = False, None, False
self.log(f"Frametop resumed ({reason})")
self.sound(False)
self.tell_screens(False)
self.on_change(False)
self.worker.submit(self.worker.resume)
self.save()
def toggle(self, reason, now):
"""Pausing only happens by itself as a game starts, so a game resumed during stays on."""
(self.resume if self.paused else self.pause)(reason, now)
def set(self, on, reason, now):
if on != self.paused:
self.toggle(reason, now)
# --- VR games ---
def game_state(self, running, now):
"""From the pointer helper: a VR game runs or not (on a change and every 5 s)."""
self.game_seen = now
if running == self.game:
return
self.game = running
self.log(f"a VR game {'started' if running else 'ended'}")
if running:
self.resume_at = None # the next game came before the resume
if self.settings["auto"]:
self.pause("game", now)
elif self.paused and self.ends_with_game:
self.resume_at = now + RESUME_DELAY
def tick(self, now):
if self.game and now - self.game_seen > GAME_STALE:
self.game_state(False, now) # the helper went quiet: SteamVR (or the helper) stopped
if self.resume_at is not None and now >= self.resume_at:
self.resume("the game ended", now)
def timeout(self, now):
"""How long the relay may wait before tick() has something to do."""
if self.resume_at is not None:
return max(0.05, self.resume_at - now)
return 1.0 if self.game else 3600.0
def helper_started(self):
"""The pointer helper (re)started, so SteamVR did too, maybe with the gaze service."""
if self.paused:
self.worker.submit(self.worker.enforce)
def status(self):
return {"t": "pause", "paused": self.paused, "reason": self.reason, "since": self.since,
"ends_with_game": self.ends_with_game, "game": self.game,
"busy": self.worker.busy, "stopped": self.worker.snapshot(),
"gesture_reader": self.watch.connected, "auto": self.settings["auto"],
"desktop": self.settings["desktop"]}