From 597551cf23edff05401a12e0baa4c59c8f718349 Mon Sep 17 00:00:00 2001 From: DeeJanuz <45082401+DeeJanuz@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:21:09 -0600 Subject: [PATCH] Pause gesture: look the controllers up every 30 s, not every 3 s The gesture reader fetched vrserver's /input/getstate.json over HTTP every 3 seconds, the whole time the relay runs, to notice a controller's root path changing when the 3D mouse takes or gives back its hand role. It now looks them up when it connects, when a message comes from a device path it doesn't know (at most every 3 s; the device is read from the message with two string searches, not a JSON parse of all 160 a second), 1.5 s after the relay's 3D mouse connects or lets go (the relay tells it through GamePause.controllers_changed), and otherwise every 30 s. The keys test's pause stub gets the new method. Co-Authored-By: Claude Opus 5.5 --- docs/design.md | 2 +- input/game_pause.py | 36 ++++++++++++++++++++++++++++++++++-- input/input-relay.py | 9 ++++++++- input/test/keys-test.py | 2 +- 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/docs/design.md b/docs/design.md index 8ecb1c4..8da91b6 100644 --- a/docs/design.md +++ b/docs/design.md @@ -197,7 +197,7 @@ Hiding the screens during a game kept them out of view, but Frametop kept using - A hidden screen still cost as much as a visible one. ft-screens sent every committed screen its frame callback at 90 Hz whether its overlay showed or not, so KWin kept drawing, and its apps with it. Paused, ft-screens sends the callbacks once a second. A Wayland client draws again only after its last frame's callback, so KWin's output stalls, KWin's own clients stop getting theirs, and the whole desktop idles, without anything losing its connection. A second's pace, rather than none, keeps any client that waits on a callback from waiting forever. Stopping KWin or the apps with SIGSTOP would free the same, but a Wayland peer that stops reading overflows the other side's 4 KB socket buffer, which ends the connection: that's how the live desktop died once when its KWin stalled (`Data too big for buffer`). They also sit in different cgroups (KWin under steam.service when the VR launcher starts it, ft-screens in the dev container's), so no single freeze stops them together. - The relay does the pausing because it's the one part that always runs, and the pointer helper keeps running because stopping it leaves its virtual controller connected with its last pose (the driver has no staleness timeout), maybe holding a hand role, with the 3D mouse dead. Releasing it does the job. The helper already checks for a scene app twice a second, so it's what tells the relay a game started. -- The gesture has to work during a game, but SteamVR input reaches only the app with input focus, and an overlay with global input (`steamvr/globalActionSetPriority`) takes the buttons it binds from the game. vrserver's web socket on 127.0.0.1:27062, which its controller binding page uses for the live view, reports every controller component whatever has focus, and reading it takes nothing. The game sees the clicks too, so the default is a gesture games hardly use: both thumbsticks, together, twice. "Together" means within 0.3 seconds of each other, so a stick held down to sprint while the other clicks doesn't count. The stream is about 160 messages a second, nearly all capacitive sensing, so the reader parses only the few that mention a gesture's button. A controller's root path changes while the 3D mouse holds its hand role (`/devices/cv/` instead of `/user/hand/right`), so the reader looks the controllers up again every 3 seconds. +- The gesture has to work during a game, but SteamVR input reaches only the app with input focus, and an overlay with global input (`steamvr/globalActionSetPriority`) takes the buttons it binds from the game. vrserver's web socket on 127.0.0.1:27062, which its controller binding page uses for the live view, reports every controller component whatever has focus, and reading it takes nothing. The game sees the clicks too, so the default is a gesture games hardly use: both thumbsticks, together, twice. "Together" means within 0.3 seconds of each other, so a stick held down to sprint while the other clicks doesn't count. The stream is about 160 messages a second, nearly all capacitive sensing, so the reader parses only the few that mention a gesture's button. A controller's root path changes while the 3D mouse holds its hand role (`/devices/cv/` instead of `/user/hand/right`), so the reader looks the controllers up again (an HTTP request to vrserver): when the relay's 3D mouse connects or lets go, when a message comes from a device it doesn't know, and every 30 seconds. It used to be every 3 seconds. - Resuming starts remote desktop through `systemd-run --scope`: started straight from the relay, it would join the relay's cgroup and end with the next relay restart. ## SteamOS updates diff --git a/input/game_pause.py b/input/game_pause.py index a31ca8a..49f1427 100644 --- a/input/game_pause.py +++ b/input/game_pause.py @@ -65,6 +65,18 @@ RESUME_DELAY = 5.0 # after a VR game ends: loading the next one can end one sce 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 +LOOKUP_EVERY = 30.0 # the controllers are looked up again this often (and sooner when they may have changed) +LOOKUP_GAP = 3.0 # but not more often than this for a message from a device it doesn't know + + +def device_of(text): + """A web socket message's "device", without parsing all of it (there are about 160 a second).""" + at = text.find('"device"') + if at < 0: + return None + start = text.find('"', text.find(":", at) + 1) + end = text.find('"', start + 1) + return text[start + 1:end] if 0 <= start < end else None def gesture_of(rules, buttons): @@ -137,6 +149,12 @@ class ControllerWatch(threading.Thread): self.changed = threading.Event() self.connected = False self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) + self.lookup_at = None # look the controllers up again then (see recheck) + + def recheck(self, delay=1.5): + """The controllers' root paths may change soon: the 3D mouse's virtual controller is taking + or giving back its hand role. SteamVR takes a moment to hand the role over.""" + self.lookup_at = time.monotonic() + delay def set_gesture(self, spec): """(buttons, presses), or None for no gesture.""" @@ -176,15 +194,22 @@ class ControllerWatch(threading.Thread): ws.open(f"frametop_pause_{os.getpid()}") sides = {} # root path -> side next_poll = 0.0 + last_poll = 0.0 while True: g = self.current() if g is None: return now = time.monotonic() + lookup_at = self.lookup_at + if lookup_at is not None and now >= lookup_at: + self.lookup_at = None + next_poll = now 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 + # virtual controller takes or gives back its hand role. Each lookup is an HTTP + # request to vrserver, so it's at connect, every LOOKUP_EVERY, when a message + # comes from a device not on the list, and when the relay says (recheck). + next_poll, last_poll = now + LOOKUP_EVERY, now found = vrws.controllers() if found != sides: for path in sides.keys() - found.keys(): @@ -199,6 +224,9 @@ class ControllerWatch(threading.Thread): 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): + device = device_of(text) + if device and device not in sides: + next_poll = min(next_poll, last_poll + LOOKUP_GAP) # its root path changed, maybe # 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()): @@ -470,6 +498,10 @@ class GamePause: return max(0.05, self.resume_at - now) return 1.0 if self.game else 3600.0 + def controllers_changed(self): + """The 3D mouse took or gave back its hand role, which changes a controller's root path.""" + self.watch.recheck() + def helper_started(self): """The pointer helper (re)started, so SteamVR did too, maybe with the gaze service.""" if self.paused: diff --git a/input/input-relay.py b/input/input-relay.py index f2289cd..64c2e62 100755 --- a/input/input-relay.py +++ b/input/input-relay.py @@ -785,7 +785,8 @@ def main(): # 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, "vr_bind_retry": False} + "desktop_until": 0.0, "typing_applied": None, "vr_capture_until": 0.0, "vr_bind_retry": False, + "pointer_holding": False} def pause_changed(paused): """Frametop paused or resumed (game_pause.py): the relay's own part.""" @@ -1292,6 +1293,12 @@ def main(): if added: apply_roles() + # The 3D mouse connecting or letting go moves a hand role, so the pause gesture's reader + # looks the controllers up again (game_pause.py). + holding = bool(state["pointer_conf"] and state["pointer_conf"].active) + if holding != state["pointer_holding"]: + state["pointer_holding"] = holding + pause.controllers_changed() ready, _, _ = select.select(list(nodes) + [control], [], [], min(volume.timeout(now, pointer.timeout() if pointer else 0.5), pause.timeout(now))) now = time.monotonic() diff --git a/input/test/keys-test.py b/input/test/keys-test.py index 005af84..9fac4d1 100755 --- a/input/test/keys-test.py +++ b/input/test/keys-test.py @@ -56,7 +56,7 @@ class StubPause: def configure(self, *args): pass - game_state = tick = helper_started = configure + game_state = tick = helper_started = controllers_changed = configure stub = {}