Add a report script for keys that stick or leak

scripts/keys-report.py records what happens to the modifiers, Tab, and
Esc while someone reproduces a key problem: each press, release, and
autorepeat as the relay reads it from a physical keyboard, and as it
comes out of the relay's virtual keyboard to gamescope and SteamVR. It
adds the relay's view of every device (role, grabbed), which programs
have each input node open, and the relay, pointer helper, and desktop
logs for the same time. Other keys show only as "other key", so nothing
typed ends up in the report, and Bluetooth addresses are masked.

For #2: Shift+Tab in the Frametop desktop opened the SteamVR dashboard
and then stayed held, which doesn't happen here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DeeJanuzandClaude Opus 5.5 committed 2026-09-28 21:35:16 -06:00
1 parent 18aa0fec3a
commit e60cb2f28a
2 files changed
+212

No files matched your search

+1
View File
@@ -18,6 +18,7 @@ ft-screens drops keys while no screen has focus or the SteamVR dashboard is open
- **A release that never arrives leaves the key held in the desktop.** KWin repeats held keys itself, so a stuck letter repeats and a stuck modifier changes every later key (Ctrl+Alt held turns T into Konsole). Pressing and releasing the key again clears it.
- **A keyboard that disconnects mid-press is one way to get there.** The relay forgets the held key without telling ft-screens. The same goes for the relay restarting while a key is down.
- **To see where a key went,** run `scripts/keys-report.py` and reproduce the problem while it records. It logs the modifiers, Tab, and Esc (no other keys) as the relay reads them and as its virtual keyboard sends them on, with the device roles and grabs, which programs have each keyboard open, and the relay's and desktop's logs.
- **Switching where typing goes waits for keys to come up.** The relay changes a keyboard's grab only while none of its keys are down, so a press and its release go to the same side. A key held for a long time delays the switch until it's let go.
## Typing and grabbed keyboards
+211
View File
@@ -0,0 +1,211 @@
#!/usr/bin/env python3
"""Record what happens to a key combination, for a report on stuck or leaking keys.
Run it on the Frame (a terminal, or `ssh frame python3 - < scripts/keys-report.py`), then
reproduce the problem while it records (40 seconds by default):
scripts/keys-report.py [seconds]
It writes ~/frametop-keys-report-<time>.txt with:
- the input devices, how the relay treats each (role, grabbed), and which programs have
each one open
- every press, release, and autorepeat of the modifiers, Tab, and Esc, as the relay reads
them from the physical keyboards and as they come out of the relay's virtual keyboard
(what gamescope and SteamVR see). Every other key shows as "other key" with no code, so
nothing you type is in the report.
- the relay's and pointer helper's log for the same time, and the end of the Frametop
desktop's log (where typing went)
Bluetooth addresses and the headset's serial number are masked.
"""
import glob
import json
import os
import re
import select
import socket
import struct
import subprocess
import sys
import time
RELAY = "\0frametop_relay"
SCREENS = "\0ft_screens"
EVENT = struct.Struct("llHHi")
EV_KEY = 0x01
KEYS = {1: "Esc", 15: "Tab", 29: "LeftCtrl", 42: "LeftShift", 54: "RightShift", 56: "LeftAlt",
97: "RightCtrl", 100: "RightAlt", 125: "LeftMeta", 126: "RightMeta",
0x110: "BTN_LEFT", 0x111: "BTN_RIGHT", 0x112: "BTN_MIDDLE", 0x113: "BTN_SIDE", 0x114: "BTN_EXTRA"}
VALUES = {0: "up", 1: "down", 2: "repeat"}
HOME = os.path.expanduser("~")
ENV = dict(os.environ, XDG_RUNTIME_DIR=f"/run/user/{os.getuid()}",
DBUS_SESSION_BUS_ADDRESS=f"unix:path=/run/user/{os.getuid()}/bus")
def run(*cmd):
try:
return subprocess.run(cmd, capture_output=True, text=True, env=ENV, timeout=20).stdout.strip()
except (OSError, subprocess.TimeoutExpired) as e:
return f"({cmd[0]}: {e})"
def ask(address, text, timeout=3.0):
s = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
s.bind("") # autobind, so the other side can reply
s.settimeout(timeout)
try:
s.sendto(text.encode(), address)
return s.recv(65536).decode(errors="replace")
except OSError as e:
return f"no answer ({e})"
finally:
s.close()
def key_name(code):
return KEYS.get(code, "other key")
def openers():
"""/dev/input/eventN -> programs (of this user) that have it open."""
out = {}
for fd in glob.glob("/proc/[0-9]*/fd/*"):
try:
target = os.readlink(fd)
except OSError:
continue
if target.startswith("/dev/input/event"):
pid = fd.split("/")[2]
try:
with open(f"/proc/{pid}/comm") as f:
comm = f.read().strip()
except OSError:
continue
out.setdefault(target, set()).add(f"{comm}({pid})")
return out
def input_devices():
"""(name, event node) of every input device, from /proc/bus/input/devices."""
try:
with open("/proc/bus/input/devices") as f:
blocks = f.read().split("\n\n")
except OSError:
return []
found = []
for b in blocks:
name = re.search(r'N: Name="(.*)"', b)
ev = re.search(r"H: Handlers=.*\b(event\d+)", b)
if name and ev:
found.append((name.group(1), "/dev/input/" + ev.group(1)))
return found
def main():
seconds = float(sys.argv[1]) if len(sys.argv) > 1 else 40
lines = []
out = lines.append
out(f"Frametop keys report, {time.strftime('%Y-%m-%d %H:%M:%S')}")
# Run from stdin (over SSH) there's no __file__: try the usual checkouts.
repos = [os.path.dirname(os.path.dirname(os.path.abspath(__file__)))] if "__file__" in globals() else []
repos += [os.path.join(HOME, "frametop"), os.path.join(HOME, "dev/frametop")]
repo = next((r for r in repos if os.path.isdir(os.path.join(r, ".git"))), "")
out("Frametop: " + (run("git", "-C", repo, "log", "-1", "--format=%h %cd", "--date=short") if repo else "?"))
out("SteamOS: " + run("sh", "-c", ". /etc/os-release; echo $VERSION_ID build $BUILD_ID"))
try:
with open(os.path.join(HOME, ".config/frametop.conf")) as f:
conf = [ln.split("#")[0].strip() for ln in f]
out("Settings: " + " ".join(c for c in conf if re.match(r"(POINTER|META_DASHBOARD|SHARE_KEYS|BACKEND)=", c)))
except OSError:
out("Settings: no ~/.config/frametop.conf")
out("ft-screens state (visibility, manual, wrist, gesture, lasers, game running, in games): "
+ ask(SCREENS, "state"))
out("\n===== Input devices (programs with the node open)")
opened = openers()
for name, node in input_devices():
out(f"{node} {name!r} <- {', '.join(sorted(opened.get(node, []))) or 'nobody'}")
out("\n===== Relay: devices, roles, grabs")
reply = ask(RELAY, "devices")
try:
for n in json.loads(reply).get("nodes", []):
out(f"{n['path']} {n['name']!r} bus={n['bus']} kinds={','.join(n['kinds'])} "
f"role={n['role']} grabbed={n['grabbed']} id={n['id']}")
except (ValueError, KeyError):
out(reply)
# The relay's virtual keyboard: what gamescope and SteamVR get from the relay.
virtual = []
for name, node in input_devices():
if name.startswith("frametop virtual"):
try:
virtual.append((os.open(node, os.O_RDONLY | os.O_NONBLOCK), name))
except OSError as e:
out(f"can't read {name} ({node}): {e}")
watch = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
watch.bind("")
watch.sendto(f"watch {seconds:g}".encode(), RELAY)
print(f"Recording for {seconds:g} seconds. Now, in the Frametop desktop:")
print(" 1. Click into a text field in Chromium, type a few letters.")
print(" 2. Press Shift+Tab once, and let go.")
print(" 3. Close the SteamVR dashboard if it opened, click back into Chromium, and see if Shift or Tab is still held.")
print(" 4. If it is, press and release Shift, then Tab, on their own, and check again.")
print("Then wait here until it says it's done.", flush=True)
start_wall = time.time()
start = time.monotonic()
events = []
while (left := start + seconds - time.monotonic()) > 0:
ready, _, _ = select.select([watch] + [fd for fd, _ in virtual], [], [], min(left, 1.0))
t = time.monotonic() - start
for r in ready:
if r is watch:
try:
m = json.loads(watch.recv(65536).decode(errors="replace"))
except (OSError, ValueError):
continue
if m.get("t") == "event" and m.get("type") == "key":
events.append(f"{t:7.3f} relay reads {m['name']!r:40} {key_name(m['code']):12} "
f"{VALUES.get(m['value'], m['value'])}")
continue
name = next(n for fd, n in virtual if fd == r)
try:
data = os.read(r, EVENT.size * 64)
except OSError:
continue
for off in range(0, len(data) - EVENT.size + 1, EVENT.size):
_, _, etype, code, value = EVENT.unpack_from(data, off)
if etype == EV_KEY:
events.append(f"{t:7.3f} relay sends {name!r:40} {key_name(code):12} "
f"{VALUES.get(value, value)}")
for fd, _ in virtual:
os.close(fd)
print("Done. Writing the report.", flush=True)
out(f"\n===== Keys during the recording (seconds from the start, {time.strftime('%H:%M:%S', time.localtime(start_wall))})")
out("relay reads: from a physical device. relay sends: out of the relay's virtual devices,"
" to gamescope and SteamVR. Keys typed into the desktop go to ft-screens, not listed here.")
lines.extend(events or ["(no keys)"])
since = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(start_wall - 5))
for unit in ("frametop-input-relay", "frametop-pointer"):
out(f"\n===== {unit} log")
out(run("journalctl", "--user", "-u", unit, "--since", since, "--no-pager", "-o", "short-precise"))
started = run("sh", "-c", "ps -o lstart= -p $(pgrep -x ft-screens | head -1) 2>/dev/null")
out(f"\n===== Frametop desktop log (last 40 lines; times count from its start, {started or 'not running'})")
try:
with open("/tmp/frametop-screens.log", errors="replace") as f:
lines.extend(ln.rstrip() for ln in f.readlines()[-40:])
except OSError:
out("missing")
text = "\n".join(lines) + "\n"
text = re.sub(r"([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}", "xx:xx:xx:xx:xx:xx", text)
text = re.sub(r"cv\.[A-Z0-9]{8,}", "cv.<serial>", text)
path = os.path.join(HOME, f"frametop-keys-report-{time.strftime('%Y%m%d-%H%M%S')}.txt")
with open(path, "w") as f:
f.write(text)
print(f"Wrote {path}")
print("Attach it to https://github.com/DeeJanuz/frametop/issues/2 with what you saw at each step.")
if __name__ == "__main__":
main()