mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 08:00:09 +02:00
docs/gaze-first.md is the plan agreed on 2026-09-30: with gaze on and no game, the gaze drives the 3D pointer everywhere, either trigger clicks where you look (tap, precision by hand movement, hold to drag), and the controllers keep everything but their lasers. For its four tests: - The driver takes "role treadmill", and its compositor bindings repeat the right hand's under /user/treadmill. They're additive, so nothing changes while the device is a hand. POINTER_ROLE accepts treadmill. - pointer/probe/lasertest follows who has the dashboard laser and the controllers' roles, and with --snapback takes the laser back. - input/vrws.py reads vrserver's web socket with the standard library (the host's Python has no websockets module) and prints component changes and update rates. Checked: it connects, and lists the controllers' bumper, thumbstick axes, and Steam button, and the headset's /proximity, which flickers off for 0.3-0.5 s while worn. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
196 lines
7.4 KiB
Python
Executable File
196 lines
7.4 KiB
Python
Executable File
#!/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. Reading it takes nothing
|
|
from anyone: whatever the buttons are bound to still happens, and it works whatever app
|
|
has focus, in games too. frame-voice reads its push-to-talk button the same way. The host's
|
|
Python has no `websockets` module, so this is a small RFC 6455 client of its own.
|
|
|
|
getstate() the devices SteamVR has now, from /input/getstate.json: each with its
|
|
root path (/user/hand/left, /devices/cv/<serial> while our pointer holds
|
|
that hand, /user/head), controller type, side, and input components
|
|
VrSocket() a connection: open(mailbox), subscribe(device), recv() -> dict or None
|
|
|
|
Run as a program, it prints component changes as they come, for the gaze-first tests
|
|
(docs/gaze-first.md, test 4):
|
|
|
|
input/vrws.py [--all] [--seconds N] [component ...]
|
|
|
|
By default it follows the buttons (…/click), the thumbsticks' axes, the trigger's value,
|
|
and the headset's proximity; --all shows every component. At the end it prints how often
|
|
each one updated.
|
|
"""
|
|
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}"
|
|
HEADERS = {"Referer": f"{ORIGIN}/dashboard/controllerbinding.html"}
|
|
|
|
|
|
def getstate(timeout=3):
|
|
"""The devices SteamVR has now (only the ones with a root path)."""
|
|
req = urllib.request.Request(f"{ORIGIN}/input/getstate.json", headers=HEADERS)
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
return [d for d in json.load(resp).get("devices", []) if d.get("root_path")]
|
|
|
|
|
|
class VrSocket:
|
|
def __init__(self, timeout=3):
|
|
self.sock = socket.create_connection((HOST, PORT), timeout=timeout)
|
|
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, self.buf = 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')}")
|
|
self.sock.settimeout(None)
|
|
self.parts = b""
|
|
|
|
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):
|
|
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, device, 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": device, "returnAddress": self.mailbox}))
|
|
|
|
def _read(self, n):
|
|
while len(self.buf) < n:
|
|
chunk = self.sock.recv(65536)
|
|
if not chunk:
|
|
raise OSError("vrserver closed the web socket")
|
|
self.buf += chunk
|
|
out, self.buf = self.buf[:n], self.buf[n:]
|
|
return out
|
|
|
|
def recv(self, timeout=None):
|
|
"""The next text message as parsed JSON (None for one that isn't JSON), or None at
|
|
the timeout. Raises OSError when the connection ends."""
|
|
if not self.buf and timeout is not None:
|
|
if not select.select([self.sock], [], [], timeout)[0]:
|
|
return None
|
|
while True:
|
|
b0, b1 = self._read(2)
|
|
opcode, n = b0 & 0x0F, b1 & 0x7F
|
|
if n == 126:
|
|
n = struct.unpack(">H", self._read(2))[0]
|
|
elif n == 127:
|
|
n = struct.unpack(">Q", self._read(8))[0]
|
|
mask = self._read(4) if b1 & 0x80 else None
|
|
data = self._read(n)
|
|
if mask:
|
|
data = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
|
|
if opcode == 0x8:
|
|
raise OSError("vrserver closed the web socket")
|
|
if opcode == 0x9:
|
|
self._frame(0xA, data)
|
|
continue
|
|
if opcode in (0x1, 0x2, 0x0):
|
|
self.parts += data
|
|
if not b0 & 0x80:
|
|
continue
|
|
text, self.parts = self.parts, b""
|
|
try:
|
|
return json.loads(text)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def main():
|
|
args = [a for a in sys.argv[1:]]
|
|
show_all = "--all" in args
|
|
seconds = 0.0
|
|
if "--seconds" in args:
|
|
i = args.index("--seconds")
|
|
seconds = float(args[i + 1])
|
|
del args[i:i + 2]
|
|
wanted = [a for a in args if not a.startswith("--")]
|
|
|
|
def followed(name):
|
|
if show_all:
|
|
return True
|
|
if wanted:
|
|
return name in wanted
|
|
return (name.endswith("/click") or name in ("/input/thumbstick/x", "/input/thumbstick/y",
|
|
"/input/trigger/value", "/proximity"))
|
|
|
|
devices = getstate()
|
|
names = {}
|
|
for d in devices:
|
|
names[d["root_path"]] = f"{d['root_path']} ({d.get('controller_type', '?')}{', ' + d['side'] if d.get('side') else ''})"
|
|
print("device", names[d["root_path"]])
|
|
ws = VrSocket()
|
|
ws.open(f"frametop_vrws_{os.getpid()}")
|
|
for d in devices:
|
|
ws.subscribe(d["root_path"])
|
|
start = time.monotonic()
|
|
last, counts = {}, {}
|
|
try:
|
|
while not seconds or time.monotonic() - start < seconds:
|
|
msg = ws.recv(timeout=0.5)
|
|
if not isinstance(msg, dict) or msg.get("type") != "update_component_states":
|
|
continue
|
|
dev = msg.get("device")
|
|
for name, value in (msg.get("components") or {}).items():
|
|
if not followed(name):
|
|
continue
|
|
key = (dev, name)
|
|
counts[key] = counts.get(key, 0) + 1
|
|
if last.get(key) != value:
|
|
last[key] = value
|
|
print(f"{time.monotonic() - start:9.3f} {names.get(dev, dev)} {name} = {value}", flush=True)
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
ws.close()
|
|
took = max(time.monotonic() - start, 1e-6)
|
|
for (dev, name), n in sorted(counts.items()):
|
|
print(f"updates: {names.get(dev, dev)} {name}: {n} ({n / took:.1f}/s)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|