mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 07:00:14 +02:00
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:
1 parent
7fbcd177d3
commit
8aaf7db05a
18 files changed
+1534
-41
No files matched your search
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/python3
|
||||
"""ft-pause: pause Frametop for a VR game, so it leaves the CPU and GPU to the game, or resume it.
|
||||
|
||||
ft-pause on|off|toggle pause or resume (input/game_pause.py has what pausing stops)
|
||||
ft-pause status the pause state, as JSON (the default)
|
||||
|
||||
The input relay does it (frametop-input-relay.service). So do the pause_toggle action, the
|
||||
controller gesture (by default both thumbsticks clicked together twice), and, with auto pause on,
|
||||
a VR game starting; Frametop Input Settings has the settings, on its Games page.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
|
||||
|
||||
def ask(msg, timeout=2.0):
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM | socket.SOCK_CLOEXEC)
|
||||
s.bind(f"\0ft_pause.{os.getpid()}")
|
||||
s.settimeout(timeout)
|
||||
try:
|
||||
s.sendto(msg.encode(), "\0frametop_relay")
|
||||
return json.loads(s.recv(65536))
|
||||
except ConnectionRefusedError:
|
||||
sys.exit("the input relay isn't running (frametop-input-relay.service)")
|
||||
except (socket.timeout, ValueError):
|
||||
sys.exit("no answer from the input relay (an older one, without pausing?)")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def main():
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else "status"
|
||||
if cmd in ("on", "off", "toggle"):
|
||||
r = ask(f"pause {cmd} command")
|
||||
print("Frametop paused" if r.get("paused") else "Frametop running")
|
||||
elif cmd == "status":
|
||||
print(json.dumps(ask("pause ?"), indent=1))
|
||||
else:
|
||||
sys.exit(__doc__)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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"]}
|
||||
+96
-16
@@ -32,7 +32,8 @@ keyboard_toggle = open or close Frametop's keyboard, float_toggle = float the de
|
||||
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), 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
|
||||
Steam's menu, or close the dashboard (steam/ft-steam menu, through Steam's UI), pause_toggle =
|
||||
pause Frametop for a VR game, or resume it (game_pause.py), 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).
|
||||
|
||||
@@ -45,8 +46,8 @@ A modifier on its own ("125": Meta) is a tap: pressed and released with no other
|
||||
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
|
||||
float_toggle); one with its own, even an empty one, doesn't. The float, profile, Steam menu,
|
||||
pause 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
|
||||
@@ -92,6 +93,12 @@ keyboards) get their volume entries remapped to unused stand-in codes, so their
|
||||
other keys keep working for SteamVR; a device without a keymap that has only volume
|
||||
keys (the headset's pmic_resin) is grabbed. The keymaps go back when the relay exits.
|
||||
|
||||
Frametop can pause for VR games (game_pause.py: by hand, with a controller gesture, or by itself
|
||||
while a game runs). Paused, it stops the services that cost the game CPU and GPU, and the relay
|
||||
plays a plain one: the pointer devices feed the virtual mouse and keyboard as without pointer
|
||||
mode, typing goes to Steam, and mapped buttons and key combinations do only pause_toggle,
|
||||
steam_menu and command:CMD (game_pause.PAUSED_ACTIONS).
|
||||
|
||||
Pointer mode (POINTER=1 in ~/.config/frametop.conf) sends pointer devices to
|
||||
the ft-pointer helper (pointer/helper), which drives the ft_pointer
|
||||
SteamVR driver. With POINTER=0, pointer devices go to the virtual mouse and
|
||||
@@ -104,7 +111,10 @@ Control socket (abstract datagram @frametop_relay, JSON replies to the sender):
|
||||
vrcapture <s> take every controller button for s seconds (0: stop), so the settings
|
||||
app can capture one; watchers see them as events with id frame_controller
|
||||
vrbtn, vrhello, gazeawake from the pointer helper (above)
|
||||
vrgame 1|0 from the pointer helper: a VR game runs (on a change and every 5 s), for pausing
|
||||
textfield 1|0 from the desktop's input method (above)
|
||||
pause on|off|toggle [reason] pause Frametop or resume it (input/ft-pause; the gesture reader)
|
||||
pause ? the pause state, as {"t": "pause", ...}
|
||||
|
||||
Runs on the Frame host as a user service (frametop-input-relay.service). The
|
||||
virtual devices are parked in systemd's file descriptor store, so a relay
|
||||
@@ -130,6 +140,8 @@ import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import game_pause
|
||||
|
||||
# Linux input constants (include/uapi/linux/input-event-codes.h, input.h, uinput.h).
|
||||
EV_SYN, EV_KEY, EV_REL, EV_MSC = 0x00, 0x01, 0x02, 0x04
|
||||
SYN_REPORT = 0
|
||||
@@ -196,7 +208,7 @@ 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", "steam_menu", "key", "none")
|
||||
"dock_all", "steam_menu", "pause_toggle", "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: a Meta tap opens Steam's menu, Meta+J
|
||||
@@ -229,7 +241,17 @@ def known_action(a):
|
||||
|
||||
|
||||
def needs_pointer(a):
|
||||
return a not in FLOAT_ACTIONS and a != "steam_menu" and not a.startswith((PROFILE, COMMAND))
|
||||
return a not in FLOAT_ACTIONS and a not in ("steam_menu", "pause_toggle") and not a.startswith((PROFILE, COMMAND))
|
||||
|
||||
|
||||
def works_paused(a):
|
||||
"""An action that still does something while Frametop is paused (game_pause.py)."""
|
||||
return a in game_pause.PAUSED_ACTIONS or a.startswith(COMMAND)
|
||||
|
||||
|
||||
# What pressed pause_toggle, for the log (do_action's source).
|
||||
PAUSE_SOURCES = {"mouse": "mouse button", "keyboard": "key combination", "left": "controller button",
|
||||
"right": "controller button"}
|
||||
KEYS = "\0frametop_keys" # keys of keyboards grabbed for the desktop, for other readers
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
FT_LAYOUT = os.path.join(REPO, "layout", "ft-layout")
|
||||
@@ -605,6 +627,22 @@ class Pointer:
|
||||
pending = (self.scroll_until, self.claim_at, self.claim_release, self.system_at, self.system_release)
|
||||
return 0.02 if any(t is not None for t in pending) else 0.5
|
||||
|
||||
def stand_down(self):
|
||||
"""Frametop is pausing: a pulse under way ends now, and the pointer lets go."""
|
||||
if self.system_release is not None:
|
||||
self.send("btn system 0")
|
||||
if self.claim_release is not None:
|
||||
self.send("btn a 0")
|
||||
if self.scroll_until is not None:
|
||||
self.send("scroll 0 0")
|
||||
self.system_at = self.system_release = self.claim_at = self.claim_release = self.scroll_until = None
|
||||
self.dx = self.dy = self.pending = 0
|
||||
self.gaze_awake_until = 0.0
|
||||
if self.active:
|
||||
self.send("hide")
|
||||
self.active = False
|
||||
log("pointer off (paused)")
|
||||
|
||||
|
||||
class Node:
|
||||
"""One input event node: a candidate device (mouse or keyboard, USB or Bluetooth),
|
||||
@@ -688,23 +726,42 @@ 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": {}, "share_keys": False,
|
||||
# pointer: the 3D mouse while it's in use, pointer_conf: the one the config asks for (they
|
||||
# differ while Frametop is paused).
|
||||
state = {"pointer": None, "pointer_conf": None, "rules": {}, "share_keys": False,
|
||||
"desktop_until": 0.0, "typing_applied": None, "vr_capture_until": 0.0}
|
||||
|
||||
def pause_changed(paused):
|
||||
"""Frametop paused or resumed (game_pause.py): the relay's own part."""
|
||||
p = state["pointer_conf"]
|
||||
if paused:
|
||||
if p:
|
||||
p.stand_down()
|
||||
state["pointer"] = None
|
||||
state["desktop_until"] = 0.0 # typing goes to Steam
|
||||
else:
|
||||
state["pointer"] = p
|
||||
apply_roles()
|
||||
vr_bind(time.monotonic())
|
||||
|
||||
pause = game_pause.GamePause(log, pause_changed, VR_BUTTONS)
|
||||
|
||||
def load_config():
|
||||
conf = read_config()
|
||||
state["rules"] = read_rules()
|
||||
state["share_keys"] = conf.get("SHARE_KEYS", "0") == "1"
|
||||
if conf.get("POINTER", "0") == "1":
|
||||
p = state["pointer"] or Pointer(0.03, 30)
|
||||
p = state["pointer_conf"] or Pointer(0.03, 30)
|
||||
p.sensitivity = float(conf.get("POINTER_SENSITIVITY", "0.03"))
|
||||
p.idle = float(conf.get("POINTER_IDLE", "30"))
|
||||
p.wake_counts = int(conf.get("POINTER_WAKE_COUNTS", "40"))
|
||||
state["pointer"] = p
|
||||
state["pointer_conf"] = p
|
||||
log(f"pointer mode: {p.sensitivity} deg/count, idle {p.idle} s, wake {p.wake_counts} counts")
|
||||
else:
|
||||
state["pointer"] = None
|
||||
state["pointer_conf"] = None
|
||||
log("pointer mode off: pointer devices feed the virtual mouse and keyboard")
|
||||
state["pointer"] = None if pause.paused else state["pointer_conf"]
|
||||
pause.configure(state["rules"])
|
||||
|
||||
load_config()
|
||||
tap = None # the modifier (folded, MODIFIERS) pressed alone, with nothing since: its release is a tap
|
||||
@@ -719,7 +776,7 @@ def main():
|
||||
state["vr_capture_until"] = 0.0
|
||||
buttons = " ".join(b for b, a in state["rules"]["controller_buttons"].items()
|
||||
if b in VR_BUTTONS and known_action(a) and a not in ("key", "none")
|
||||
and a not in GAZE_ACTIONS) or "-"
|
||||
and a not in GAZE_ACTIONS and (not pause.paused or works_paused(a))) or "-"
|
||||
if state["rules"].get("controller_in_games"):
|
||||
buttons = "+games " + buttons
|
||||
try:
|
||||
@@ -764,6 +821,12 @@ def main():
|
||||
def do_action(action, value, now, source="mouse"):
|
||||
"""A mapped mouse or controller button, or key combination (pointer mode only, but
|
||||
for the actions needs_pointer() says don't)."""
|
||||
if action == "pause_toggle":
|
||||
if value == 1:
|
||||
pause.toggle(PAUSE_SOURCES.get(source, source), now)
|
||||
return
|
||||
if pause.paused and not works_paused(action):
|
||||
return
|
||||
if action == "keyboard_toggle":
|
||||
if value == 1 and vr_keyboard_mode() != "never":
|
||||
vr_keyboard("toggle")
|
||||
@@ -832,7 +895,7 @@ def main():
|
||||
return value == 2 and code in combos_down
|
||||
combo = "+".join(str(c) for c in sorted(held_modifiers) + [code])
|
||||
action = state["rules"]["key_bindings"].get(combo)
|
||||
if not known_action(action) or action in ("key", "none"):
|
||||
if not known_action(action) or action in ("key", "none") or (pause.paused and not works_paused(action)):
|
||||
return False
|
||||
combos_down[code] = action
|
||||
if held_meta - meta_hidden:
|
||||
@@ -853,7 +916,7 @@ def main():
|
||||
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"):
|
||||
if not known_action(action) or action in ("key", "none") or (pause.paused and not works_paused(action)):
|
||||
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.
|
||||
@@ -1054,7 +1117,7 @@ def main():
|
||||
try:
|
||||
if cmd == "keyboard":
|
||||
# From ft-screens (unbound, no reply): where typing goes, repeated every second.
|
||||
desktop = len(words) > 1 and words[1] == "desktop"
|
||||
desktop = len(words) > 1 and words[1] == "desktop" and not pause.paused
|
||||
state["desktop_until"] = now + 3.0 if desktop else 0.0
|
||||
continue
|
||||
if cmd == "vrbtn" and len(words) == 3 and words[1] in VR_BUTTONS and words[2] in ("0", "1"):
|
||||
@@ -1062,6 +1125,20 @@ def main():
|
||||
continue
|
||||
if cmd == "vrhello":
|
||||
vr_bind(now)
|
||||
pause.helper_started()
|
||||
continue
|
||||
if cmd == "vrgame" and len(words) == 2:
|
||||
pause.game_state(words[1] == "1", now)
|
||||
continue
|
||||
if cmd == "pause" and len(words) >= 2 and words[1] in ("on", "off", "toggle", "?"):
|
||||
# From input/ft-pause, the settings app, or the gesture reader (unbound, no reply).
|
||||
reason = words[2] if len(words) > 2 else "command"
|
||||
if words[1] == "toggle":
|
||||
pause.toggle(reason, now)
|
||||
elif words[1] != "?":
|
||||
pause.set(words[1] == "on", reason, now)
|
||||
if addr:
|
||||
reply(addr, pause.status())
|
||||
continue
|
||||
if cmd == "textfield" and len(words) == 2:
|
||||
text_field(words[1] == "1")
|
||||
@@ -1073,7 +1150,8 @@ def main():
|
||||
if not addr:
|
||||
continue # unbound sender, nowhere to reply
|
||||
if cmd == "devices":
|
||||
reply(addr, {"t": "devices", "pointer_mode": state["pointer"] is not None,
|
||||
reply(addr, {"t": "devices", "pointer_mode": state["pointer_conf"] is not None,
|
||||
"paused": pause.paused,
|
||||
"actions": ACTIONS,
|
||||
"nodes": [n.describe() for n in nodes.values() if n.candidate]})
|
||||
elif cmd == "watch":
|
||||
@@ -1156,16 +1234,18 @@ def main():
|
||||
apply_roles()
|
||||
|
||||
ready, _, _ = select.select(list(nodes) + [control], [], [],
|
||||
volume.timeout(now, pointer.timeout() if pointer else 0.5))
|
||||
min(volume.timeout(now, pointer.timeout() if pointer else 0.5), pause.timeout(now)))
|
||||
now = time.monotonic()
|
||||
if pointer:
|
||||
pointer.tick(now)
|
||||
volume.tick(now)
|
||||
pause.tick(now)
|
||||
if state["vr_capture_until"] and now >= state["vr_capture_until"]:
|
||||
vr_bind(now) # capture over: back to the mapped buttons
|
||||
if (now < state["desktop_until"]) != state["typing_applied"] or waiting:
|
||||
waiting = apply_roles()
|
||||
for fd in ready:
|
||||
pointer = state["pointer"] # (a pause or resume in this batch changes it)
|
||||
if fd is control:
|
||||
handle_control(now)
|
||||
continue
|
||||
@@ -1220,7 +1300,7 @@ def main():
|
||||
continue
|
||||
if etype == EV_KEY:
|
||||
action = buttons.get(str(code), DEFAULT_BUTTONS.get(code, "key"))
|
||||
if pointer and action not in ("key", "none"):
|
||||
if (pointer or action == "pause_toggle") and action not in ("key", "none"):
|
||||
do_action(action, value, now)
|
||||
continue
|
||||
if action == "none":
|
||||
|
||||
+64
-5
@@ -3,8 +3,9 @@
|
||||
|
||||
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.
|
||||
logger. Pausing (game_pause.py) is a stub: no threads, state file, or services. 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)
|
||||
"""
|
||||
@@ -26,8 +27,41 @@ 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)
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(relay_path))) # its game_pause and vrws
|
||||
exec(compile(src, relay_path, "exec"), relay.__dict__)
|
||||
|
||||
|
||||
class StubPause:
|
||||
"""game_pause.GamePause without its threads, state file, or services."""
|
||||
paused = False
|
||||
|
||||
def __init__(self, log, on_change, buttons):
|
||||
self.on_change = on_change
|
||||
stub["pause"] = self
|
||||
|
||||
def toggle(self, reason, now):
|
||||
self.paused = not self.paused
|
||||
self.on_change(self.paused)
|
||||
|
||||
def set(self, on, reason, now):
|
||||
if on != self.paused:
|
||||
self.toggle(reason, now)
|
||||
|
||||
def status(self):
|
||||
return {"t": "pause", "paused": self.paused}
|
||||
|
||||
def timeout(self, now):
|
||||
return 3600.0
|
||||
|
||||
def configure(self, *args):
|
||||
pass
|
||||
|
||||
game_state = tick = helper_started = configure
|
||||
|
||||
|
||||
stub = {}
|
||||
relay.game_pause.GamePause = StubPause
|
||||
|
||||
for name in ("SCREENS", "HELPER", "FLOAT", "GAZED", "KEYS"):
|
||||
setattr(relay, name, f"\0{tag}_{name.lower()}")
|
||||
OUT = tempfile.mkdtemp(prefix="ft-keys-test-")
|
||||
@@ -144,6 +178,15 @@ def use(key_bindings):
|
||||
typed()
|
||||
|
||||
|
||||
def pause(cmd):
|
||||
c = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
||||
c.bind("")
|
||||
c.settimeout(2)
|
||||
c.sendto(f"pause {cmd} test".encode(), f"\0{tag}_relay")
|
||||
c.recv(4096)
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
def lines(path, wait=0.3):
|
||||
time.sleep(wait)
|
||||
try:
|
||||
@@ -163,7 +206,7 @@ def check(label, got, want):
|
||||
failures.append(label)
|
||||
|
||||
|
||||
META, RMETA, SHIFT, CTRL, RCTRL, A, F, J = 125, 126, 42, 29, 97, 30, 33, 36
|
||||
META, RMETA, SHIFT, CTRL, RCTRL, A, F, J, P = 125, 126, 42, 29, 97, 30, 33, 36, 25
|
||||
F24 = ["key 194 1", "key 194 0"]
|
||||
|
||||
|
||||
@@ -207,9 +250,25 @@ def tests():
|
||||
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"])
|
||||
|
||||
use({"125+36": "gaze_left", "29": f"command:echo paused >> {cmd_log}", "42+125+25": "pause_toggle"})
|
||||
pause("on")
|
||||
check("pause on: paused", stub["pause"].paused, True)
|
||||
key(META, 1); key(J, 1); key(J, 0); key(META, 0)
|
||||
check("paused: Meta+J (gaze click) does nothing and is typed as is", typed(),
|
||||
["key 125 1", "key 36 1", "key 36 0", "key 125 0"])
|
||||
key(RCTRL, 1); key(RCTRL, 0)
|
||||
check("paused: a command still runs", lines(cmd_log), ["combo", "3", "paused"])
|
||||
typed()
|
||||
key(SHIFT, 1); key(META, 1); key(P, 1); key(P, 0); key(META, 0); key(SHIFT, 0)
|
||||
check("paused: Meta+Shift+P (pause_toggle) resumes", stub["pause"].paused, False)
|
||||
check("Meta+Shift+P isn't typed", [k for k in typed() if k.startswith("key 25 ")], [])
|
||||
key(META, 1); key(J, 1); key(J, 0); key(META, 0)
|
||||
check("resumed: Meta+J is a combination again", typed(), ["key 125 1"] + F24 + ["key 125 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))
|
||||
check("steam_menu, pause_toggle and commands work without pointer mode",
|
||||
(relay.needs_pointer("steam_menu"), relay.needs_pointer("pause_toggle"), relay.needs_pointer("command:ls")),
|
||||
(False, 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)
|
||||
|
||||
Executable
+214
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline test of pausing for VR games (input/game_pause.py): the controller gesture, and when
|
||||
Frametop pauses and resumes by itself. The worker (services, remote desktop, the desktop) and
|
||||
the controller reader are stubs, the state file is a temporary one, and ft-screens' socket is
|
||||
renamed, so nothing reaches the live Frametop, SteamVR, or the running relay.
|
||||
|
||||
input/test/pause-test.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
|
||||
import game_pause as gp # noqa: E402
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the gesture
|
||||
L, R = "left/thumbstick", "right/thumbstick"
|
||||
|
||||
|
||||
def presses(g, events):
|
||||
"""events: (time, button, down); how many times the gesture went off."""
|
||||
return sum(g.feed(b, d, t) for t, b, d in events)
|
||||
|
||||
|
||||
def both(t, hold=0.1, gap=0.02):
|
||||
"""Both sticks clicked at t (the right one gap later), held for hold."""
|
||||
return [(t, L, True), (t + gap, R, True), (t + hold, L, False), (t + hold + gap, R, False)]
|
||||
|
||||
|
||||
g = gp.Gesture((L, R), 2)
|
||||
check("both sticks twice: goes off", presses(g, both(0) + both(0.3)), 1)
|
||||
g = gp.Gesture((L, R), 2)
|
||||
check("both sticks once: nothing", presses(g, both(0)), 0)
|
||||
g = gp.Gesture((L, R), 2)
|
||||
check("both sticks twice, 0.9 s apart: nothing", presses(g, both(0) + both(0.9)), 0)
|
||||
g = gp.Gesture((L, R), 2)
|
||||
check("left held (sprint), right clicked twice: nothing",
|
||||
presses(g, [(0, L, True), (1.0, R, True), (1.1, R, False), (1.3, R, True), (1.4, R, False)]), 0)
|
||||
g = gp.Gesture((L, R), 2)
|
||||
check("sticks 0.5 s apart, twice: nothing", presses(g, both(0, gap=0.5, hold=0.6) + both(1.2, gap=0.5, hold=0.6)), 0)
|
||||
g = gp.Gesture((L, R), 2)
|
||||
check("three double presses in a row: once", presses(g, both(0) + both(0.3) + both(0.6)), 1)
|
||||
g = gp.Gesture((L, R), 2)
|
||||
check("again after a second: twice in all", presses(g, both(0) + both(0.3) + both(2.0) + both(2.3)), 2)
|
||||
g = gp.Gesture((L, R), 2)
|
||||
check("repeated down states count once",
|
||||
presses(g, [(0, L, True), (0.01, L, True), (0.02, R, True), (0.03, R, True), (0.1, L, False), (0.1, R, False)]
|
||||
+ both(0.3)), 1)
|
||||
g = gp.Gesture((L, R), 1)
|
||||
check("both sticks once (presses 1): goes off", presses(g, both(0)), 1)
|
||||
g = gp.Gesture(("right/b",), 2)
|
||||
check("one button double-clicked: goes off",
|
||||
presses(g, [(0, "right/b", True), (0.1, "right/b", False), (0.3, "right/b", True)]), 1)
|
||||
g = gp.Gesture(("right/b",), 2)
|
||||
check("other buttons don't count", presses(g, [(0, "right/a", True), (0.2, "right/a", True)]), 0)
|
||||
|
||||
VR = ("left/thumbstick", "right/thumbstick", "right/b", "right/a")
|
||||
check("no pause_gesture: both sticks twice", gp.gesture_of({}, VR), ((L, R), 2))
|
||||
check("pause_gesture null: none", gp.gesture_of({"pause_gesture": None}, VR), None)
|
||||
check("one button, presses 1: still twice", gp.gesture_of({"pause_gesture": {"buttons": ["right/b"], "presses": 1}}, VR),
|
||||
(("right/b",), 2))
|
||||
check("unknown buttons dropped", gp.gesture_of({"pause_gesture": {"buttons": ["right/zz", "right/a"]}}, VR),
|
||||
(("right/a",), 2))
|
||||
check("settings: defaults", gp.settings_of({}, VR)["auto"], True)
|
||||
check("settings: unknown desktop mode is hide", gp.settings_of({"pause_desktop": "x"}, VR)["desktop"], "hide")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- pausing by itself
|
||||
class StubWorker:
|
||||
def __init__(self, log, save):
|
||||
self.jobs = []
|
||||
self.busy = False
|
||||
|
||||
def start(self):
|
||||
pass
|
||||
|
||||
def submit(self, job, *args):
|
||||
self.jobs.append(job.__name__)
|
||||
|
||||
def snapshot(self):
|
||||
return {"units": [], "remote": False, "desktop": None}
|
||||
|
||||
def restore(self, stopped):
|
||||
self.restored = stopped
|
||||
|
||||
def pause(self, desktop):
|
||||
pass
|
||||
|
||||
def resume(self):
|
||||
pass
|
||||
|
||||
def enforce(self):
|
||||
pass
|
||||
|
||||
|
||||
class StubWatch:
|
||||
connected = False
|
||||
|
||||
def __init__(self, log):
|
||||
pass
|
||||
|
||||
def start(self):
|
||||
pass
|
||||
|
||||
def set_gesture(self, spec):
|
||||
self.spec = spec
|
||||
|
||||
|
||||
tmp = tempfile.mkdtemp(prefix="ft-pause-test-")
|
||||
gp.STATE_PATH = os.path.join(tmp, "pause.json")
|
||||
gp.SCREENS = f"\0ft_pause_test_{os.getpid()}_screens"
|
||||
gp.Worker, gp.ControllerWatch = StubWorker, StubWatch
|
||||
changes = []
|
||||
|
||||
|
||||
def new(rules=None):
|
||||
"""A relay starting afresh: not paused before."""
|
||||
if os.path.exists(gp.STATE_PATH):
|
||||
os.remove(gp.STATE_PATH)
|
||||
changes.clear()
|
||||
p = gp.GamePause(lambda *a: None, changes.append, VR)
|
||||
p.configure(dict({"pause_sound": False}, **(rules or {})))
|
||||
return p
|
||||
|
||||
|
||||
p = new()
|
||||
p.game_state(True, 0)
|
||||
check("a game starts: paused", (p.paused, p.reason, p.ends_with_game), (True, "game", True))
|
||||
check("the relay heard", changes, [True])
|
||||
p.game_state(True, 5)
|
||||
p.tick(5)
|
||||
check("the helper repeats it: still paused, nothing new", (p.paused, changes), (True, [True]))
|
||||
p.game_state(False, 10)
|
||||
p.tick(12)
|
||||
check("the game ends: not yet resumed", p.paused, True)
|
||||
p.tick(15.1)
|
||||
check("5 s later: resumed", (p.paused, changes), (False, [True, False]))
|
||||
check("the worker paused and resumed", p.worker.jobs, ["pause", "resume"])
|
||||
|
||||
p = new()
|
||||
p.game_state(True, 0)
|
||||
p.game_state(False, 10)
|
||||
p.game_state(True, 12)
|
||||
p.tick(16)
|
||||
check("the next game starts within 5 s: stays paused", p.paused, True)
|
||||
p.game_state(False, 20)
|
||||
p.tick(25.1)
|
||||
check("and resumes 5 s after that one ends", p.paused, False)
|
||||
|
||||
p = new()
|
||||
p.game_state(True, 0)
|
||||
p.toggle("gesture", 1)
|
||||
check("resumed during a game: running", p.paused, False)
|
||||
p.game_state(True, 6)
|
||||
p.tick(30)
|
||||
check("and stays running while that game runs", p.paused, False)
|
||||
p.game_state(False, 40)
|
||||
p.game_state(True, 50)
|
||||
check("the next game pauses again", p.paused, True)
|
||||
|
||||
p = new()
|
||||
p.toggle("command", 0)
|
||||
check("paused outside a game: not tied to one", (p.paused, p.ends_with_game), (True, False))
|
||||
p.game_state(True, 1)
|
||||
p.game_state(False, 10)
|
||||
p.tick(20)
|
||||
check("a game comes and goes: still paused", p.paused, True)
|
||||
p.toggle("command", 21)
|
||||
check("toggled: running", p.paused, False)
|
||||
|
||||
p = new({"pause_auto": False})
|
||||
p.game_state(True, 0)
|
||||
check("auto off: a game doesn't pause", p.paused, False)
|
||||
p.toggle("gesture", 1)
|
||||
p.game_state(False, 10)
|
||||
p.tick(20)
|
||||
check("auto off: a pause during a game outlasts it", p.paused, True)
|
||||
|
||||
p = new()
|
||||
p.game_state(True, 0)
|
||||
p.tick(0 + gp.GAME_STALE + 0.1)
|
||||
check("the helper goes quiet: the game counts as ended", p.game, False)
|
||||
p.tick(0 + gp.GAME_STALE + gp.RESUME_DELAY + 0.2)
|
||||
check("and the pause ends", p.paused, False)
|
||||
|
||||
p = new()
|
||||
p.toggle("gesture", 0)
|
||||
q = gp.GamePause(lambda *a: None, changes.append, VR)
|
||||
check("a relay restarted while paused stays paused", (q.paused, q.reason), (True, "gesture"))
|
||||
check("and stops the services again", q.worker.jobs, ["enforce"])
|
||||
q.toggle("gesture", 1)
|
||||
r = gp.GamePause(lambda *a: None, changes.append, VR)
|
||||
check("resumed: the next relay starts running", r.paused, False)
|
||||
|
||||
p = new({"pause_gesture": None})
|
||||
check("pause_gesture null: the reader gets none", p.watch.spec, None)
|
||||
check("status", sorted(p.status()), sorted(["t", "paused", "reason", "since", "ends_with_game", "game", "busy",
|
||||
"stopped", "gesture_reader", "auto", "desktop"]))
|
||||
|
||||
print("FAILED: " + ", ".join(failures) if failures else "all passed", flush=True)
|
||||
for f in os.listdir(tmp):
|
||||
os.remove(os.path.join(tmp, f))
|
||||
os.rmdir(tmp)
|
||||
sys.exit(1 if failures else 0)
|
||||
Executable
+198
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/python3
|
||||
"""vrws: vrserver's local web socket, with nothing but the standard library.
|
||||
|
||||
vrserver (SteamVR) serves its controller binding page on 127.0.0.1:27062, and that page
|
||||
follows the controllers' raw input through a web socket there (mailbox input_server,
|
||||
request_input_state_updates). Reading it takes nothing from anyone: whatever the buttons are
|
||||
bound to still happens, and it works whatever app has input focus, VR games included. SteamVR
|
||||
input only reaches the focused app, or an overlay with global input, which then takes the button
|
||||
from the game (pointer/helper/vrbuttons.h). frame-voice reads its push-to-talk button the same
|
||||
way. It's undocumented, so a SteamVR update could change it (scripts/update-check.py checks it).
|
||||
The host's Python has no `websockets` module, so this is a small RFC 6455 client of its own.
|
||||
|
||||
controllers() the Frame controllers SteamVR has now: {root path: "left" | "right"}. A
|
||||
controller's root path is /user/hand/<side>, or /devices/cv/<serial> while the
|
||||
3D mouse's virtual controller holds that hand role.
|
||||
VrSocket() a connection: open(mailbox), subscribe(path), messages(timeout) -> raw texts,
|
||||
each a JSON object like {"type": "update_component_states", "device": path,
|
||||
"components": {"/input/thumbstick/click": true, ...}} with the components
|
||||
that changed (mostly capacitive sensing, about 160 a second)
|
||||
|
||||
Run as a program, it prints the controllers' button changes for a while (default 10 s):
|
||||
|
||||
input/vrws.py [seconds]
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
HOST, PORT = "127.0.0.1", 27062
|
||||
ORIGIN = f"http://{HOST}:{PORT}"
|
||||
# vrserver answers the binding page's own requests; these headers make ours look like them.
|
||||
HEADERS = {"Referer": f"{ORIGIN}/dashboard/controllerbinding.html"}
|
||||
|
||||
|
||||
def getstate(timeout=3):
|
||||
"""vrserver's /input/getstate.json: the devices and their input components."""
|
||||
req = urllib.request.Request(f"{ORIGIN}/input/getstate.json", headers=HEADERS)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.load(resp)
|
||||
|
||||
|
||||
def controllers(timeout=3):
|
||||
"""{root path: side} for the Frame controllers SteamVR has now."""
|
||||
return {d["root_path"]: d["side"] for d in getstate(timeout).get("devices", [])
|
||||
if d.get("controller_type") == "frame_controller" and d.get("root_path")
|
||||
and d.get("side") in ("left", "right")}
|
||||
|
||||
|
||||
class VrSocket:
|
||||
def __init__(self, timeout=3):
|
||||
self.sock = socket.create_connection((HOST, PORT), timeout=timeout)
|
||||
try:
|
||||
key = base64.b64encode(os.urandom(16)).decode()
|
||||
self.sock.sendall((f"GET / 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"
|
||||
f"Origin: {ORIGIN}\r\nReferer: {HEADERS['Referer']}\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 OSError("vrserver closed the connection during the handshake")
|
||||
head += chunk
|
||||
head, rest = head.split(b"\r\n\r\n", 1)
|
||||
status = head.split(b"\r\n", 1)[0]
|
||||
if b" 101 " not in status + b" ":
|
||||
raise OSError(f"vrserver refused the web socket: {status.decode(errors='replace')}")
|
||||
except BaseException:
|
||||
self.sock.close()
|
||||
raise
|
||||
self.sock.settimeout(None)
|
||||
self.buf = bytearray(rest)
|
||||
self.parts = bytearray() # a fragmented message so far
|
||||
self.mailbox = ""
|
||||
|
||||
def fileno(self):
|
||||
return self.sock.fileno()
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self._frame(0x8, b"")
|
||||
except OSError:
|
||||
pass
|
||||
self.sock.close()
|
||||
|
||||
def _frame(self, opcode, payload):
|
||||
"""One frame to vrserver: final, masked (as a client's must be)."""
|
||||
n = len(payload)
|
||||
head = bytes([0x80 | opcode])
|
||||
if n < 126:
|
||||
head += bytes([0x80 | n])
|
||||
elif n < 65536:
|
||||
head += bytes([0x80 | 126]) + struct.pack(">H", n)
|
||||
else:
|
||||
head += bytes([0x80 | 127]) + struct.pack(">Q", n)
|
||||
mask = os.urandom(4)
|
||||
self.sock.sendall(head + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(payload)))
|
||||
|
||||
def send(self, text):
|
||||
self._frame(0x1, text.encode())
|
||||
|
||||
def open(self, mailbox):
|
||||
self.send(f"mailbox_open {mailbox}")
|
||||
self.mailbox = mailbox
|
||||
|
||||
def subscribe(self, path, on=True):
|
||||
kind = "request_input_state_updates" if on else "cancel_input_state_updates"
|
||||
self.send("mailbox_send input_server " +
|
||||
json.dumps({"type": kind, "device_path": path, "returnAddress": self.mailbox}))
|
||||
|
||||
def messages(self, timeout=None):
|
||||
"""The text messages that came in, as strings: waits up to `timeout` seconds (None:
|
||||
forever) for some, [] at the timeout. Raises OSError when the connection ends."""
|
||||
out = self._parse()
|
||||
if out:
|
||||
return out
|
||||
if not select.select([self.sock], [], [], timeout)[0]:
|
||||
return []
|
||||
chunk = self.sock.recv(65536)
|
||||
if not chunk:
|
||||
raise OSError("vrserver closed the web socket")
|
||||
self.buf += chunk
|
||||
return self._parse()
|
||||
|
||||
def _parse(self):
|
||||
"""Every whole frame in the buffer; a partial one waits for the rest."""
|
||||
out, buf, pos = [], self.buf, 0
|
||||
while len(buf) - pos >= 2:
|
||||
b0, b1 = buf[pos], buf[pos + 1]
|
||||
n, head = b1 & 0x7F, 2
|
||||
if n == 126:
|
||||
if len(buf) - pos < 4:
|
||||
break
|
||||
n, head = struct.unpack_from(">H", buf, pos + 2)[0], 4
|
||||
elif n == 127:
|
||||
if len(buf) - pos < 10:
|
||||
break
|
||||
n, head = struct.unpack_from(">Q", buf, pos + 2)[0], 10
|
||||
masked = b1 & 0x80
|
||||
if masked:
|
||||
head += 4
|
||||
if len(buf) - pos < head + n:
|
||||
break
|
||||
data = bytes(buf[pos + head:pos + head + n])
|
||||
if masked: # servers don't mask, but nothing says they can't
|
||||
mask = buf[pos + head - 4:pos + head]
|
||||
data = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
|
||||
pos += head + n
|
||||
opcode = b0 & 0x0F
|
||||
if opcode == 0x8:
|
||||
raise OSError("vrserver closed the web socket")
|
||||
if opcode == 0x9:
|
||||
self._frame(0xA, data) # ping: pong
|
||||
elif opcode in (0x0, 0x1, 0x2):
|
||||
self.parts += data
|
||||
if b0 & 0x80:
|
||||
out.append(self.parts.decode(errors="replace"))
|
||||
self.parts = bytearray()
|
||||
del buf[:pos]
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
seconds = float(sys.argv[1]) if len(sys.argv) > 1 else 10.0
|
||||
ws = VrSocket()
|
||||
ws.open(f"frametop_vrws_{os.getpid()}")
|
||||
sides = controllers()
|
||||
for path, side in sides.items():
|
||||
print(f"{side} controller: {path}", flush=True)
|
||||
ws.subscribe(path)
|
||||
if not sides:
|
||||
print("no Frame controllers", flush=True)
|
||||
start = time.monotonic()
|
||||
count = 0
|
||||
try:
|
||||
while time.monotonic() - start < seconds:
|
||||
for text in ws.messages(timeout=0.5):
|
||||
count += 1
|
||||
if "/click" not in text:
|
||||
continue
|
||||
msg = json.loads(text)
|
||||
side = sides.get(msg.get("device"), msg.get("device"))
|
||||
for name, value in sorted(msg.get("components", {}).items()):
|
||||
if name.endswith("/click"):
|
||||
print(f"{time.monotonic() - start:7.3f} {side} {name[len('/input/'):-len('/click')]} "
|
||||
f"{'down' if value else 'up'}", flush=True)
|
||||
finally:
|
||||
ws.close()
|
||||
print(f"{count} messages in {seconds:.0f} s")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user