Files
DeeJanuz--frametop/input/input-relay.py
T
DeeJanuzandClaude Opus 5.5 d439bc3f25 Frametop: a multi-screen desktop and universal 3D mouse for the Steam Frame
Several KDE Plasma screens floating in SteamVR, each a real monitor of any
resolution and shape, shown by our own compositor (ft-screens), with a
layout, wrist pinning, and visibility modes; a Bluetooth mouse that drives
all of SteamVR as a room-anchored 3D pointer (input relay, ft-pointer
helper, ft_pointer SteamVR driver); two settings apps; and Bluetooth LE
fixes. Installs on the headset with ./install.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 16:14:13 -06:00

642 lines
26 KiB
Python
Executable File

#!/usr/bin/env python3
"""Input relay for the Steam Frame: stable virtual devices, the 3D pointer, device rules.
SteamVR opens /dev/input/event* only when it starts and never hotplugs, so a
Bluetooth mouse that sleeps and reconnects (new event nodes) stops working
until SteamVR restarts. This relay creates a virtual mouse and a virtual
keyboard through /dev/uinput once, before SteamVR starts, and feeds them from
the physical devices as they come and go.
Every USB or Bluetooth mouse and keyboard is a candidate. A physical device is
identified by its Bluetooth address (EVIOCGUNIQ) or USB bus:vendor:product:name,
so all its event nodes share one role (the Swiftpoint Z3 has a mouse node and a
keyboard node for its extra buttons). Roles, from ~/.config/frametop-input.json
(written by the Frametop Input Settings app):
pointer grabbed; drives the universal 3D mouse (default for devices with a mouse node)
passthrough not grabbed, only observed, e.g. for the Meta dashboard shortcut (default for keyboards)
ignore not grabbed, only observed for identification in the settings app
Buttons and keys of pointer devices go through a per-device map to actions
(left, right, middle, back, scroll_up, scroll_down, dashboard, recenter,
pointer_toggle, sens_up, sens_down, layout_reset = put the desktop screens back in
their saved layout, screens_toggle = hide or show the desktop screens, key = pass
through as a key, none).
Keys also go to ft-screens (@ft_screens, the Frametop desktop's compositor), which
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.
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
keyboard instead.
Control socket (abstract datagram @frametop_relay, JSON replies to the sender):
devices list event nodes with id, name, kinds, role, grabbed
watch <seconds> stream input events from every candidate node (identification)
reload re-read both config files, re-apply roles, tell the helper
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
restart gets the same devices back and SteamVR never loses them. The service is
Type=notify: READY=1 goes out only after the devices exist, so SteamVR (ordered
after it) always finds them. Dependency-free: Python standard library plus the
kernel's evdev and uinput interfaces.
input-relay.py the service
input-relay.py --no-grab never grab, for testing next to a running SteamVR
"""
import array
import errno
import fcntl
import json
import os
import select
import socket
import struct
import subprocess
import sys
import time
# 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
BTN_MISC, KEY_MAX = 0x100, 0x2FF
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
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.
EVENT = struct.Struct("llHHi")
def _ioc(direction, nr, size, kind):
return (direction << 30) | (size << 16) | (ord(kind) << 8) | nr
def _iow(kind, nr, size):
return _ioc(1, nr, size, kind)
def _ior(kind, nr, size):
return _ioc(2, nr, size, kind)
UI_DEV_CREATE = _ioc(0, 1, 0, "U")
UI_DEV_DESTROY = _ioc(0, 2, 0, "U")
UI_DEV_SETUP = _iow("U", 3, 92) # struct uinput_setup: input_id (4 x u16), name[80], u32
UI_SET_EVBIT = _iow("U", 100, 4)
UI_SET_KEYBIT = _iow("U", 101, 4)
UI_SET_RELBIT = _iow("U", 102, 4)
EVIOCGRAB = _iow("E", 0x90, 4)
EVIOCGID = _ior("E", 0x02, 8)
EV_NAMES = {EV_KEY: "key", EV_REL: "rel"}
def eviocgbit(ev, length):
return _ior("E", 0x20 + ev, length)
def eviocgname(length):
return _ior("E", 0x06, length)
def eviocguniq(length):
return _ior("E", 0x08, length)
VIRTUAL_PREFIX = "frametop virtual"
RULES_PATH = os.path.expanduser("~/.config/frametop-input.json")
ACTIONS = ("left", "right", "middle", "back", "scroll_up", "scroll_down", "dashboard", "recenter",
"pointer_toggle", "sens_up", "sens_down", "layout_reset", "screens_toggle", "key", "none")
SCREENS = "\0ft_screens"
FT_LAYOUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "layout", "ft-layout")
DEFAULT_BUTTONS = {BTN_LEFT: "left", BTN_RIGHT: "right", BTN_MIDDLE: "middle",
BTN_SIDE: "back", BTN_EXTRA: "back"}
def log(*args):
print(*args, flush=True)
def notify(state, fds=()):
"""sd_notify, with optional file descriptors for the fd store. No-op outside systemd."""
addr = os.environ.get("NOTIFY_SOCKET")
if not addr:
return
if addr.startswith("@"):
addr = "\0" + addr[1:]
# socket.send_fds() ignores its address argument (Python 3.12), so use sendmsg.
ancillary = [(socket.SOL_SOCKET, socket.SCM_RIGHTS, array.array("i", fds))] if fds else []
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as sock:
sock.sendmsg([state.encode()], ancillary, 0, addr)
except OSError as e:
log(f"sd_notify failed ({state.splitlines()[0]}): {e}")
def stored_fds():
"""File descriptors handed back by systemd's fd store, by name."""
if os.environ.get("LISTEN_PID") != str(os.getpid()):
return {}
names = os.environ.get("LISTEN_FDNAMES", "").split(":")
count = int(os.environ.get("LISTEN_FDS", "0"))
return {names[i]: 3 + i for i in range(count) if i < len(names)}
class Virtual:
"""One uinput device, reused from systemd's fd store when possible."""
def __init__(self, name, product, keys, rels, stored):
self.dirty = False
store_name = f"vdev{product}"
if store_name in stored:
self.fd = stored[store_name]
os.set_blocking(self.fd, False)
log(f"reusing {name} from the fd store")
return
self.fd = os.open("/dev/uinput", os.O_WRONLY | os.O_NONBLOCK)
fcntl.ioctl(self.fd, UI_SET_EVBIT, EV_KEY)
for code in keys:
fcntl.ioctl(self.fd, UI_SET_KEYBIT, code)
if rels:
fcntl.ioctl(self.fd, UI_SET_EVBIT, EV_REL)
for code in rels:
fcntl.ioctl(self.fd, UI_SET_RELBIT, code)
setup = struct.pack("HHHH80sI", BUS_VIRTUAL, 0x4D44, product, 1, name.encode(), 0)
fcntl.ioctl(self.fd, UI_DEV_SETUP, setup)
fcntl.ioctl(self.fd, UI_DEV_CREATE)
notify(f"FDSTORE=1\nFDNAME={store_name}", [self.fd])
log(f"created {name}")
def emit(self, etype, code, value):
os.write(self.fd, EVENT.pack(0, 0, etype, code, value))
self.dirty = True
def sync(self):
if self.dirty:
os.write(self.fd, EVENT.pack(0, 0, EV_SYN, SYN_REPORT, 0))
self.dirty = False
def bits(fd, ev, count):
buf = bytearray((count + 7) // 8)
try:
fcntl.ioctl(fd, eviocgbit(ev, len(buf)), buf)
except OSError:
return set()
return {i for i in range(count) if buf[i // 8] >> (i % 8) & 1}
def read_config(path=os.path.expanduser("~/.config/frametop.conf")):
"""KEY=VALUE lines, # comments allowed. Missing file means defaults."""
conf = {}
try:
with open(path) as f:
for line in f:
line = line.split("#", 1)[0].strip()
if "=" in line:
key, value = line.split("=", 1)
conf[key.strip()] = value.strip()
except OSError:
pass
return conf
def read_rules(path=RULES_PATH):
"""{"devices": {id: {"role", "name"}}, "buttons": {id: {"<code>": action}}}."""
try:
with open(path) as f:
rules = json.load(f)
except (OSError, ValueError):
rules = {}
rules.setdefault("devices", {})
rules.setdefault("buttons", {})
return rules
class Pointer:
"""Drives the ft_pointer SteamVR driver from a mouse (pointer mode).
The virtual controller connects when the mouse is used (taking the right
hand role and recentering on the gaze) and disconnects after `idle` seconds
without mouse activity, so the real controllers get their role back: the
last used device wins.
"""
DRIVER_BUTTONS = {"left": "trigger", "right": "b", "middle": "x", "back": "joystick"}
SCROLL_PULSE = 0.08 # seconds of joystick deflection per wheel notch
CLAIM_PULSE = 0.06 # seconds the claim button (switchlaserhand, no click) is held
RESUME_PAUSE = 1.5 # mouse idle this long, then moving again, re-claims the laser
WAKE_WINDOW = 1.0 # seconds in which WAKE_COUNTS of motion must add up
def __init__(self, sensitivity, idle, wake_counts=40):
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
self.sensitivity = sensitivity # degrees per mouse count
self.idle = idle
self.active = False
self.last_used = 0.0
self.dx = self.dy = 0
self.scroll_until = None
self.claim_at = None # when to press the claim button
self.claim_release = None
self.system_at = None # dashboard toggle: when to press the virtual system button
self.system_release = None
# Waking (or re-claiming after a pause) needs deliberate movement, so sensor
# jitter from a mouse lying on a desk can't steal the laser from a controller.
self.wake_counts = wake_counts
self.pending = 0
self.pending_since = 0.0
def send(self, command):
try:
self.sock.sendto(command.encode(), "\0ft_pointer_helper")
except OSError:
pass # helper not running (SteamVR not running)
def wake(self, now):
if not self.active:
self.send("show")
self.send("recenter")
self.active = True
self.claim_at = now + 0.3 # let SteamVR bind the freshly connected device first
log("pointer on")
elif now - self.last_used > self.RESUME_PAUSE and self.claim_at is None:
self.claim_at = now # another device may have taken the laser meanwhile
self.last_used = now
def motion(self, code, value, now):
dormant = not self.active or now - self.last_used > self.RESUME_PAUSE
if dormant and code in (REL_X, REL_Y):
if now - self.pending_since > self.WAKE_WINDOW:
self.pending, self.pending_since = 0, now
self.pending += abs(value)
if self.pending < self.wake_counts:
return # not yet deliberate movement
self.pending = 0
self.wake(now)
if code == REL_X:
self.dx += value
elif code == REL_Y:
self.dy += value
elif code == REL_WHEEL and value:
self.send(f"scroll 0 {1 if value > 0 else -1}")
self.scroll_until = now + self.SCROLL_PULSE
def action(self, name, value, now):
"""A mapped button: value 1 press, 0 release, 2 autorepeat (ignored)."""
if value == 2:
return
driver = self.DRIVER_BUTTONS.get(name)
if driver:
self.wake(now)
self.flush()
self.send(f"btn {driver} {value}")
elif value != 1:
return # the rest act on press
elif name in ("scroll_up", "scroll_down"):
self.wake(now)
self.send(f"scroll 0 {1 if name == 'scroll_up' else -1}")
self.scroll_until = now + self.SCROLL_PULSE
elif name == "dashboard":
self.dashboard(now)
elif name == "recenter":
self.wake(now)
self.send("recenter")
elif name == "pointer_toggle":
if self.active:
self.send("hide")
self.active = False
log("pointer off (toggle)")
else:
self.wake(now)
elif name == "screens_toggle":
try:
self.sock.sendto(b"toggle", SCREENS)
except OSError:
pass # ft-screens not running
elif name == "layout_reset":
# Runs a few seconds and borrows the pointer; ft-layout refuses a second copy.
subprocess.Popen([FT_LAYOUT, "apply"], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True)
log("layout reset")
elif name in ("sens_up", "sens_down"):
self.sensitivity *= 1.25 if name == "sens_up" else 0.8
log(f"sensitivity {self.sensitivity:.4f} deg/count")
def flush(self):
if self.dx or self.dy:
# Mouse right turns the ray right (negative yaw); mouse down tilts it down.
self.send(f"move {-self.dx * self.sensitivity:.4f} {-self.dy * self.sensitivity:.4f}")
self.dx = self.dy = 0
def dashboard(self, now=None):
"""Toggle the SteamVR dashboard with the virtual controller's system button.
SteamVR needs the button held for a frame or two (press and release in the
same instant is ignored), and the virtual controller must be connected and
bound, so wake it first when needed.
"""
now = time.monotonic() if now is None else now
woke = not self.active
self.wake(now)
self.system_at = now + (0.4 if woke else 0.0)
def tick(self, now):
if self.system_at is not None and now >= self.system_at:
self.send("btn system 1")
self.system_at = None
self.system_release = now + 0.12
elif self.system_release is not None and now >= self.system_release:
self.send("btn system 0")
self.system_release = None
if self.claim_at is not None and now >= self.claim_at:
self.send("btn a 1")
self.claim_at = None
self.claim_release = now + self.CLAIM_PULSE
elif self.claim_release is not None and now >= self.claim_release:
self.send("btn a 0")
self.claim_release = None
if self.scroll_until is not None and now >= self.scroll_until:
self.send("scroll 0 0")
self.scroll_until = None
if self.active and now - self.last_used > self.idle:
self.send("hide")
self.active = False
log("pointer off (idle)")
def timeout(self):
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
class Node:
"""One input event node of a candidate device (mouse or keyboard, USB or Bluetooth)."""
def __init__(self, path, fd, name, bus, vendor, product, uniq, is_mouse, is_keyboard):
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
# 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.grabbed = False
self.held = set() # keys and buttons currently down, released if the device vanishes
self.last_watch = 0.0
def describe(self):
kinds = [k for k, on in (("mouse", self.is_mouse), ("keyboard", self.is_keyboard)) if on]
return {"path": self.path, "name": self.name, "id": self.id, "uniq": self.uniq,
"bus": {BUS_USB: "usb", BUS_BLUETOOTH: "bluetooth"}.get(self.bus, str(self.bus)),
"kinds": kinds, "role": self.role, "grabbed": self.grabbed}
def probe(path):
"""Open a node if it is a USB or Bluetooth mouse or keyboard, else return None."""
try:
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
except OSError:
return None
try:
buf = bytearray(256)
fcntl.ioctl(fd, eviocgname(len(buf)), buf)
name = buf.split(b"\0", 1)[0].decode(errors="replace")
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):
raise ValueError
uniq_buf = bytearray(64)
try:
fcntl.ioctl(fd, eviocguniq(len(uniq_buf)), uniq_buf)
uniq = uniq_buf.split(b"\0", 1)[0].decode(errors="replace")
except OSError:
uniq = ""
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):
raise ValueError
return Node(path, fd, name, bus, vendor, product, uniq, is_mouse, is_keyboard)
except (OSError, ValueError):
os.close(fd)
return None
def main():
can_grab = "--no-grab" not in sys.argv
stored = stored_fds()
mouse = Virtual(f"{VIRTUAL_PREFIX} mouse", 1,
keys=range(BTN_MISC, 0x118), rels=range(REL_MAX + 1), stored=stored)
keyboard = Virtual(f"{VIRTUAL_PREFIX} keyboard", 2,
keys=range(1, BTN_MISC), rels=(), stored=stored)
notify("READY=1")
control = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
control.bind("\0frametop_relay")
control.setblocking(False)
watchers = {} # address -> watch end time
state = {"pointer": None, "rules": {}}
def load_config():
conf = read_config()
state["rules"] = read_rules()
if conf.get("POINTER", "0") == "1":
p = state["pointer"] 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
log(f"pointer mode: {p.sensitivity} deg/count, idle {p.idle} s, wake {p.wake_counts} counts")
else:
state["pointer"] = None
log("pointer mode off: pointer devices feed the virtual mouse and keyboard")
load_config()
meta_down = False # Meta pressed with no other key yet: a tap toggles the dashboard
screens_sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM | socket.SOCK_NONBLOCK)
def to_screens(code, value):
"""A key for the desktop screens (ft-screens decides whether it types)."""
if value in (0, 1) and code < BTN_MISC:
try:
screens_sock.sendto(f"key {code} {value}".encode(), SCREENS)
except OSError:
pass # ft-screens not running
nodes = {} # fd -> Node
seen = set() # paths already probed (rejected or open)
next_scan = 0.0
def role_of(node):
rule = state["rules"]["devices"].get(node.id, {})
if rule.get("role") in ("pointer", "passthrough", "ignore"):
return rule["role"]
has_mouse = any(n.is_mouse for n in nodes.values() if n.id == node.id) or node.is_mouse
return "pointer" if has_mouse else "passthrough"
def release_held(node):
for code in node.held:
(mouse if code >= BTN_MISC else keyboard).emit(EV_KEY, code, 0)
node.held.clear()
mouse.sync()
keyboard.sync()
def apply_roles():
for node in nodes.values():
role = role_of(node)
want_grab = can_grab and role == "pointer"
if want_grab != node.grabbed:
try:
fcntl.ioctl(node.fd, EVIOCGRAB, 1 if want_grab else 0)
node.grabbed = want_grab
except OSError as e:
log(f"{'grab' if want_grab else 'ungrab'} failed for {node.name}: {e}")
if not node.grabbed:
release_held(node)
if role != node.role:
log(f"{node.name} ({node.path}, {node.id}): {role}{', grabbed' if node.grabbed else ''}")
node.role = role
def drop(node, reason):
release_held(node)
os.close(node.fd)
del nodes[node.fd]
seen.discard(node.path)
log(f"released {node.name} ({node.path}): {reason}")
def reply(addr, obj):
try:
control.sendto(json.dumps(obj).encode(), addr)
except OSError:
watchers.pop(addr, None)
def handle_control(now):
while True:
try:
data, addr = control.recvfrom(4096)
except BlockingIOError:
return
if not addr:
continue # unbound sender, nowhere to reply
words = data.decode(errors="replace").split()
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()]})
elif cmd == "watch":
seconds = float(words[1]) if len(words) > 1 else 30
watchers[addr] = now + min(seconds, 600)
reply(addr, {"t": "watching", "seconds": seconds})
elif cmd == "reload":
load_config()
apply_roles()
if state["pointer"]:
state["pointer"].send("reload")
reply(addr, {"t": "reloaded"})
else:
reply(addr, {"t": "error", "error": f"unknown command {cmd!r}"})
def broadcast(node, etype, code, value, now):
if not watchers:
return
if etype == EV_REL and now - node.last_watch < 0.05:
return # motion: enough for an activity light
node.last_watch = now
msg = {"t": "event", "id": node.id, "path": node.path, "name": node.name,
"type": EV_NAMES.get(etype, str(etype)), "code": code, "value": value}
for addr, until in list(watchers.items()):
if now > until:
del watchers[addr]
else:
reply(addr, msg)
while True:
now = time.monotonic()
pointer = state["pointer"]
if now >= next_scan:
next_scan = now + 1.0
paths = {f"/dev/input/{n}" for n in os.listdir("/dev/input") if n.startswith("event")}
seen &= paths | {n.path for n in nodes.values()}
added = False
for path in sorted(paths - seen):
seen.add(path)
node = probe(path)
if node:
nodes[node.fd] = node
added = True
if added:
apply_roles()
ready, _, _ = select.select(list(nodes) + [control], [], [],
pointer.timeout() if pointer else 0.5)
now = time.monotonic()
if pointer:
pointer.tick(now)
for fd in ready:
if fd is control:
handle_control(now)
continue
node = nodes[fd]
try:
data = os.read(fd, EVENT.size * 64)
except OSError as e:
if e.errno == errno.EAGAIN:
continue
drop(node, os.strerror(e.errno))
continue
if not data:
drop(node, "closed")
continue
buttons = state["rules"]["buttons"].get(node.id, {})
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)
if node.role != "pointer":
# Observed only. A Meta tap on any keyboard toggles the dashboard.
if node.role == "passthrough" and etype == EV_KEY:
to_screens(code, value)
if pointer and node.role == "passthrough" and etype == EV_KEY:
if code in (KEY_LEFTMETA, KEY_RIGHTMETA):
if value == 1:
meta_down = True
elif value == 0 and meta_down:
meta_down = False
pointer.dashboard()
elif value == 1:
meta_down = False # Meta used as a modifier, not a tap
continue
if etype == EV_KEY:
action = buttons.get(str(code), DEFAULT_BUTTONS.get(code, "key"))
if pointer and action not in ("key", "none"):
pointer.action(action, value, now)
continue
if action == "none":
continue
target = mouse if code >= BTN_MISC else keyboard
target.emit(etype, code, value)
to_screens(code, value)
if value:
node.held.add(code)
else:
node.held.discard(code)
elif etype == EV_REL:
if pointer:
pointer.motion(code, value, now)
else:
mouse.emit(etype, code, value)
elif etype == EV_SYN and code == SYN_REPORT:
if pointer:
pointer.flush()
mouse.sync()
keyboard.sync()
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
pass