From 705b434cf38b2ec3e7c38cf0d755ecf44fbd5456 Mon Sep 17 00:00:00 2001 From: DeeJanuz <45082401+DeeJanuz@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:10:28 -0600 Subject: [PATCH] Take over the volume keys so they can't crash gamescope gamescope sends volume up and down to Steam by moving keyboard focus to Steam for the key and back. When nothing had focus, it moves focus back to null and wlroots aborts, which ends the whole VR session. One press of the headset's volume button did that while working in the Frametop desktop. The input relay now handles volume keys from every device that has them, the headset's buttons included, and steps the default output with wpctl (5%, repeating while held). SteamVR, which passes keys on to gamescope, never sees a volume key: devices with a keymap (gpio-keys, USB and Bluetooth keyboards) get only their volume entries remapped to unused codes, so the headset's click button keeps working, and pmic_resin, which has only volume down, is grabbed. The keymaps go back when the relay stops, and --no-grab leaves the volume keys alone. Co-Authored-By: Claude Opus 5.5 --- docs/design.md | 2 + docs/reference.md | 2 + input/input-relay.py | 178 ++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 170 insertions(+), 12 deletions(-) diff --git a/docs/design.md b/docs/design.md index 2d4eb30..5e26ac1 100644 --- a/docs/design.md +++ b/docs/design.md @@ -100,6 +100,8 @@ SteamVR opens every input device only when it starts. When a Bluetooth mouse sle Keyboards aren't grabbed by default, because a grabbed keyboard's keys went into a virtual keyboard nothing typed from; the relay forwards them to ft-screens instead. +Volume keys must never reach gamescope. With the openvr backend, gamescope sends volume up and down to Steam by moving keyboard focus to Steam for the key and then back to the previously focused surface. When nothing had focus, the one it moves back to is null, and wlroots aborts on a null focus surface (`wlr_seat_keyboard_notify_enter: Assertion 'surface' failed`), which ends the whole VR session. Keyboard focus is often empty while you work in VR, so one press of the headset's volume button could take everything down. SteamVR reads the headset's buttons itself and passes keys on to gamescope, so the relay has to stop volume keys before SteamVR sees them. Grabbing `gpio-keys` would also take the headset's click button, so the relay remaps the volume entries in each device's keymap (`EVIOCSKEYCODE`) and handles the stand-in codes itself. That fix covers every device at once, including keyboards that aren't grabbed. + ## The desktop session The session is modeled on SteamOS's `steamos-nested-desktop` and runs beside it. It has its own runtime directory, config (`~/.config/frametop`), and state, so it never disturbs the stock desktop's layout or panels. It runs on a private D-Bus from `dbus-run-session`, which has two consequences. KDE only launches apps in systemd scopes when systemd is on the session bus, so everything started in the desktop lands in its systemd unit, and stopping the unit would kill all of it; `session/keep-apps.sh` moves those programs out first. And tools that need the real user bus, like podman and `distrobox-host-exec`, have to be pointed at it explicitly. diff --git a/docs/reference.md b/docs/reference.md index b5f2ce0..a176187 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -69,6 +69,8 @@ SteamVR opens input devices only when it starts. A Bluetooth mouse that sleeps a It runs as the user service `frametop-input-relay.service`, ordered before `steamvr.service`. +The relay also owns the volume keys, on every device that has them, the headset's buttons included. It changes the volume itself (`wpctl`, 5% a step, repeating while held), and SteamVR never sees a volume key: on devices with a keymap (the headset's `gpio-keys`, USB and Bluetooth keyboards) it remaps just the volume entries to unused codes (`KEY_MACRO29`, `KEY_MACRO30`), so the headset's click button and the other keys still reach SteamVR, and it grabs `pmic_resin`, which has only volume down. The keymaps go back when the relay stops. With `--no-grab` it leaves the volume keys alone. + ``` desktops.sh relay install # enable it (starts with the next reboot or SteamVR start) desktops.sh relay status | log | uninstall diff --git a/input/input-relay.py b/input/input-relay.py index 4e61963..cd42e99 100755 --- a/input/input-relay.py +++ b/input/input-relay.py @@ -26,6 +26,14 @@ Keys also go to ft-screens (@ft_screens, the Frametop desktop's compositor), whi types them into the desktop screen that has focus (not while the SteamVR dashboard is open): from keyboards that aren't grabbed, and keys a pointer device passes through. +Volume keys, from every device that has them (the headset's own buttons included), +are handled here: wpctl steps the default output. Nothing else may see a volume key, +because gamescope aborts on one when no window has keyboard focus, which ends the +whole VR session. Devices with a keymap (the headset's gpio-keys, USB and Bluetooth +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. + 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 @@ -47,11 +55,13 @@ kernel's evdev and uinput interfaces. input-relay.py --no-grab never grab, for testing next to a running SteamVR """ import array +import atexit import errno import fcntl import json import os import select +import signal import socket import struct import subprocess @@ -66,6 +76,12 @@ KEY_A = 30 REL_X, REL_Y, REL_WHEEL, REL_MAX = 0x00, 0x01, 0x08, 0x0F 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 +# Volume keys are remapped to KEY_MACRO29 and KEY_MACRO30: above 255, so X11 can't +# carry them, and bound to nothing in the default keymap. +VOLUME_STANDIN = {KEY_VOLUMEUP: 0x2AC, KEY_VOLUMEDOWN: 0x2AD} +VOLUME_ORIGINAL = {v: k for k, v in VOLUME_STANDIN.items()} +VOLUME_CODES = set(VOLUME_STANDIN) | set(VOLUME_ORIGINAL) BUS_USB, BUS_BLUETOOTH, BUS_VIRTUAL = 0x03, 0x05, 0x06 # struct input_event on 64-bit: struct timeval (2 x long), u16 type, u16 code, s32 value. @@ -92,6 +108,10 @@ UI_SET_KEYBIT = _iow("U", 101, 4) UI_SET_RELBIT = _iow("U", 102, 4) EVIOCGRAB = _iow("E", 0x90, 4) EVIOCGID = _ior("E", 0x02, 8) +KEYMAP_ENTRY = struct.Struct("BBHI32s") # struct input_keymap_entry: flags, len, index, keycode, scancode +INPUT_KEYMAP_BY_INDEX = 1 +EVIOCGKEYCODE_V2 = _ior("E", 0x04, KEYMAP_ENTRY.size) +EVIOCSKEYCODE_V2 = _iow("E", 0x04, KEYMAP_ENTRY.size) EV_NAMES = {EV_KEY: "key", EV_REL: "rel"} @@ -217,6 +237,69 @@ def read_rules(path=RULES_PATH): return rules +def remap_volume(fd, restore=False): + """Point a device's volume keys at their stand-ins in its keymap, or back with restore. + + Returns how many keymap entries are volume keys or stand-ins, or None when the + device has no keymap to change (uinput devices, some platform buttons). + """ + swap = VOLUME_ORIGINAL if restore else VOLUME_STANDIN + found = 0 + for index in range(8192): + entry = bytearray(KEYMAP_ENTRY.pack(INPUT_KEYMAP_BY_INDEX, 0, index, 0, b"")) + try: + fcntl.ioctl(fd, EVIOCGKEYCODE_V2, entry) + except OSError: + return found if index else None # past the last entry + _, length, _, code, scancode = KEYMAP_ENTRY.unpack(entry) + if code in VOLUME_CODES: + found += 1 + if code in swap: + fcntl.ioctl(fd, EVIOCSKEYCODE_V2, + KEYMAP_ENTRY.pack(INPUT_KEYMAP_BY_INDEX, length, index, swap[code], scancode)) + return found + + +class Volume: + """Volume keys: wpctl steps the default output, repeating while a key is held. + + The repeat is our own, since the headset's buttons have none; kernel autorepeat + from keyboards is ignored so every device repeats the same way. + """ + + STEP = 5 # percent + DELAY, RATE = 0.4, 0.1 # seconds before repeating, and between repeats + + def __init__(self): + self.held = None # (fd, code) of the key being held + self.next_at = None + + def key(self, fd, code, value, now): + if value == 1: + self.held = (fd, code) + self.step(code) + self.next_at = now + self.DELAY + elif value == 0 and self.held == (fd, code): + self.release() + + def release(self): + self.held = self.next_at = None + + def step(self, code): + sign = "+" if VOLUME_ORIGINAL.get(code, code) == KEY_VOLUMEUP else "-" + subprocess.Popen(["wpctl", "set-volume", "--limit", "1.0", "@DEFAULT_AUDIO_SINK@", + f"{self.STEP}%{sign}"], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + def tick(self, now): + if self.next_at is not None and now >= self.next_at: + self.step(self.held[1]) + self.next_at = now + self.RATE + + def timeout(self, now, default): + return default if self.next_at is None else max(0.0, min(default, self.next_at - now)) + + class Pointer: """Drives the ft_pointer SteamVR driver from a mouse (pointer mode). @@ -373,17 +456,21 @@ class Pointer: class Node: - """One input event node of a candidate device (mouse or keyboard, USB or Bluetooth).""" + """One input event node: a candidate device (mouse or keyboard, USB or Bluetooth), + or any other device with volume keys (candidate False, role "volume").""" - def __init__(self, path, fd, name, bus, vendor, product, uniq, is_mouse, is_keyboard): + def __init__(self, path, fd, name, bus, vendor, product, uniq, is_mouse, is_keyboard, + candidate=True, volume_keys=False, only_volume=False): self.path, self.fd, self.name = path, fd, name self.bus, self.vendor, self.product, self.uniq = bus, vendor, product, uniq self.is_mouse, self.is_keyboard = is_mouse, is_keyboard + self.candidate, self.volume_keys, self.only_volume = candidate, volume_keys, only_volume # One physical device, whatever its node: Bluetooth address, else USB ids plus name. base = self.name.split(" Mouse")[0].split(" Keyboard")[0] self.id = uniq.lower() if uniq else f"usb:{vendor:04x}:{product:04x}:{base}" - self.role = None + self.role = None if candidate else "volume" self.grabbed = False + self.remapped = False # volume keys remapped to their stand-ins self.held = set() # keys and buttons currently down, released if the device vanishes self.last_watch = 0.0 @@ -395,7 +482,8 @@ class Node: def probe(path): - """Open a node if it is a USB or Bluetooth mouse or keyboard, else return None.""" + """Open a node if it is a USB or Bluetooth mouse or keyboard, or has volume keys, + else return None.""" try: fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK) except OSError: @@ -407,7 +495,7 @@ def probe(path): ident = bytearray(8) fcntl.ioctl(fd, EVIOCGID, ident) bus, vendor, product, _ = struct.unpack("HHHH", ident) - if name.startswith(VIRTUAL_PREFIX) or bus not in (BUS_USB, BUS_BLUETOOTH): + if name.startswith(VIRTUAL_PREFIX): raise ValueError uniq_buf = bytearray(64) try: @@ -415,11 +503,15 @@ def probe(path): uniq = uniq_buf.split(b"\0", 1)[0].decode(errors="replace") except OSError: uniq = "" + keys = bits(fd, EV_KEY, KEY_MAX + 1) is_mouse = REL_X in bits(fd, EV_REL, REL_MAX + 1) - is_keyboard = KEY_A in bits(fd, EV_KEY, KEY_MAX + 1) - if not (is_mouse or is_keyboard): + is_keyboard = KEY_A in keys + candidate = bus in (BUS_USB, BUS_BLUETOOTH) and (is_mouse or is_keyboard) + volume_keys = bool(keys & VOLUME_CODES) # stand-ins too: kept from before a relay restart + if not (candidate or volume_keys): raise ValueError - return Node(path, fd, name, bus, vendor, product, uniq, is_mouse, is_keyboard) + return Node(path, fd, name, bus, vendor, product, uniq, is_mouse, is_keyboard, + candidate, volume_keys, keys <= VOLUME_CODES) except (OSError, ValueError): os.close(fd) return None @@ -474,6 +566,50 @@ def main(): seen = {} next_scan = 0.0 + volume = Volume() + + def restore_keymaps(): + """Give remapped devices their volume keys back, so they work without the relay.""" + for node in nodes.values(): + if node.remapped: + try: + remap_volume(node.fd, restore=True) + except OSError: + pass # device already gone + + atexit.register(restore_keymaps) + signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) # so atexit runs on systemctl stop + + def take_volume(node): + """Keep the node's volume keys from SteamVR (and so from gamescope). + + Returns False for a non-candidate node there's nothing to do with. + """ + if not can_grab: + return node.candidate + try: + found = remap_volume(node.fd) + except OSError as e: + log(f"remapping volume keys failed for {node.name}: {e}") + # Some entries may have changed already: handle their stand-ins and restore them. + node.remapped = True + found = None + if found: + node.remapped = True + log(f"{node.name} ({node.path}): volume keys taken over (remapped)") + return True + if found is None and node.only_volume: + try: + fcntl.ioctl(node.fd, EVIOCGRAB, 1) + node.grabbed = True + log(f"{node.name} ({node.path}): volume keys taken over (grabbed)") + return True + except OSError as e: + log(f"grab failed for {node.name}: {e}") + # Grabbed pointer devices still have their volume keys handled here. + log(f"{node.name} ({node.path}): can't take over its volume keys") + return node.candidate + def role_of(node): rule = state["rules"]["devices"].get(node.id, {}) if rule.get("role") in ("pointer", "passthrough", "ignore"): @@ -490,6 +626,8 @@ def main(): def apply_roles(): for node in nodes.values(): + if not node.candidate: + continue # volume keys only, taken over when found role = role_of(node) want_grab = can_grab and role == "pointer" if want_grab != node.grabbed: @@ -506,6 +644,8 @@ def main(): def drop(node, reason): release_held(node) + if volume.held and volume.held[0] == node.fd: + volume.release() os.close(node.fd) del nodes[node.fd] seen.pop(node.path, None) @@ -529,7 +669,8 @@ def main(): cmd = words[0] if words else "" if cmd == "devices": reply(addr, {"t": "devices", "pointer_mode": state["pointer"] is not None, - "actions": ACTIONS, "nodes": [n.describe() for n in nodes.values()]}) + "actions": ACTIONS, + "nodes": [n.describe() for n in nodes.values() if n.candidate]}) elif cmd == "watch": seconds = float(words[1]) if len(words) > 1 else 30 watchers[addr] = now + min(seconds, 600) @@ -544,7 +685,7 @@ def main(): reply(addr, {"t": "error", "error": f"unknown command {cmd!r}"}) def broadcast(node, etype, code, value, now): - if not watchers: + if not watchers or not node.candidate: return if etype == EV_REL and now - node.last_watch < 0.05: return # motion: enough for an activity light @@ -580,6 +721,9 @@ def main(): drop(old, "replaced by a new device node") seen[path] = ino node = probe(path) + if node and node.volume_keys and not take_volume(node): + os.close(node.fd) + node = None if node: nodes[node.fd] = node added = True @@ -587,10 +731,11 @@ def main(): apply_roles() ready, _, _ = select.select(list(nodes) + [control], [], [], - pointer.timeout() if pointer else 0.5) + volume.timeout(now, pointer.timeout() if pointer else 0.5)) now = time.monotonic() if pointer: pointer.tick(now) + volume.tick(now) for fd in ready: if fd is control: handle_control(now) @@ -610,7 +755,16 @@ def main(): for off in range(0, len(data) - EVENT.size + 1, EVENT.size): _, _, etype, code, value = EVENT.unpack_from(data, off) if etype in (EV_KEY, EV_REL): - broadcast(node, etype, code, value, now) + broadcast(node, etype, VOLUME_ORIGINAL.get(code, code) if node.remapped else code, + value, now) + 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. With META_DASHBOARD=1, a Meta tap on any keyboard # toggles the dashboard.