mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-24 11:01:01 +02:00
methods_of reads gaze clicks, the hand ray and direct touch from frametop.conf as they work today; method_writes toggles one and pins the others' keys so they don't move with POINTER_HANDS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
359 lines
16 KiB
Python
Executable File
359 lines
16 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""handsmode: when hand tracking runs, by HANDS_MODE in ~/.config/frametop.conf.
|
|
|
|
frametop ft-camd, ft-hands and the hand view run while the Frametop desktop does: the session
|
|
(session/frametop-session.sh) says "desktop up" once ft-screens is up and "desktop
|
|
down" as it ends.
|
|
always they're wanted by steamvr.service (systemctl add-wants), so they run whenever SteamVR
|
|
does, in games and the SteamVR dashboard too, with or without the desktop. The input
|
|
relay then leaves them running and unparked when Frametop pauses for a game.
|
|
off (the default, and anything else) nothing starts them. ft-handsctl on still starts hand
|
|
tracking by hand, until SteamVR stops; choosing off stops it, and ft-cutouts' units too.
|
|
|
|
The units are the ones hands/run.sh install puts in ~/.config/systemd/user, and the hand view's
|
|
frametop-handview.service when it's installed; a unit that isn't installed is left out.
|
|
ft-camd needs its file capabilities (hands/run.sh caps, with sudo): without them this starts
|
|
nothing and says so ("problem"), and never asks for a password.
|
|
|
|
python3 hands/handsmode.py status [--json] # the mode, what runs, what's wrong
|
|
python3 hands/handsmode.py apply # start what HANDS_MODE wants now, fix the SteamVR wants
|
|
python3 hands/handsmode.py set MODE # write HANDS_MODE, then apply it, stopping what it
|
|
# doesn't want (Frametop Input Settings, ft-handsctl mode)
|
|
python3 hands/handsmode.py desktop up|down # the session's hooks; only HANDS_MODE=frametop acts
|
|
|
|
It also reads and writes the three hand input switches Frametop Input Settings shows (METHODS:
|
|
gaze clicks, the hand ray, direct touch), which are pointer and ft-screens settings, not services.
|
|
|
|
On the host, system Python, standard library only (the settings app runs it through
|
|
distrobox-host-exec, and imports it for the settings' defaults).
|
|
"""
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
|
|
CONF_PATH = os.path.expanduser("~/.config/frametop.conf")
|
|
MODES = ("frametop", "always", "off")
|
|
DEFAULT_MODE = "off"
|
|
# The cutouts' margin (HANDS_MATTE_MARGIN, degrees seen from the eyes; ft-screens' default) and the
|
|
# range Frametop Input Settings offers. ft-screens takes 0-10.
|
|
MARGIN_DEFAULT = 1.5
|
|
MARGIN_MIN, MARGIN_MAX, MARGIN_STEP = 0.0, 3.0, 0.25
|
|
STEAMVR = "steamvr.service"
|
|
TRACKING = ("frametop-camd.service", "frametop-hands.service")
|
|
VIEW = "frametop-handview.service" # draws the hands where Room View is off (hands-view)
|
|
CUTOUTS = ("frametop-cutouts-camd.service", "frametop-cutouts-hands.service") # ft-cutouts' transient units
|
|
UNITS = TRACKING + (VIEW,)
|
|
CAMD = "frametop-camd.service"
|
|
WANTS_DIR = os.path.expanduser("~/.config/systemd/user/" + STEAMVR + ".wants")
|
|
|
|
|
|
def read_conf(path=CONF_PATH):
|
|
"""KEY=value lines, as the shell that sources the file would mostly see them."""
|
|
conf = {}
|
|
try:
|
|
with open(path) as f:
|
|
for line in f:
|
|
line = line.split("#", 1)[0].strip()
|
|
if "=" in line:
|
|
k, v = line.split("=", 1)
|
|
v = v.strip()
|
|
if len(v) >= 2 and v[0] == v[-1] and v[0] in "'\"":
|
|
v = v[1:-1]
|
|
conf[k.strip()] = v
|
|
except OSError:
|
|
pass
|
|
return conf
|
|
|
|
|
|
def write_value(key, value, path=CONF_PATH):
|
|
"""Set KEY=value, keeping comments and the rest of the file (as Frametop Input Settings does)."""
|
|
try:
|
|
with open(path) as f:
|
|
lines = f.read().splitlines()
|
|
except OSError:
|
|
lines = []
|
|
for i, line in enumerate(lines):
|
|
if line.split("#", 1)[0].strip().startswith(f"{key}="):
|
|
comment = line[line.index("#"):] if "#" in line else ""
|
|
lines[i] = f"{key}={value}" + (f" {comment}" if comment else "")
|
|
break
|
|
else:
|
|
lines.append(f"{key}={value}")
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
with open(path, "w") as f:
|
|
f.write("\n".join(lines) + "\n")
|
|
|
|
|
|
def mode_of(conf):
|
|
v = conf.get("HANDS_MODE", "")
|
|
return v if v in MODES else DEFAULT_MODE
|
|
|
|
|
|
def margin_of(conf):
|
|
"""HANDS_MATTE_MARGIN as ft-screens reads it: a number 0-10, else its default."""
|
|
try:
|
|
v = float(conf.get("HANDS_MATTE_MARGIN", ""))
|
|
except ValueError:
|
|
return MARGIN_DEFAULT
|
|
return v if 0 <= v <= 10 else MARGIN_DEFAULT
|
|
|
|
|
|
# How the hands work the desktop, as three switches that don't depend on each other (Frametop Input
|
|
# Settings' Hands page; user, 2026-10-10). In frametop.conf:
|
|
# gaze_clicks gaze mode's hand clicks: a pinch clicks where you look, a middle pinch right-clicks, a
|
|
# pinch by each hand holds both buttons. POINTER_HANDS=1 and POINTER_GAZE_PINCH (1).
|
|
# ray with gaze mode off, a raised pointing hand's ray drives the pointer (and a low hand's
|
|
# pinch presses where it is). POINTER_HANDS=1 and POINTER_HAND_RAY (1).
|
|
# touch ft-screens' direct touch, poke and grab its panels, gaze or not. HANDS_NEAR: 1 (the
|
|
# default) with POINTER_HANDS=1, on without it, force always, 0 off.
|
|
# POINTER_HANDS turns the pointer's hand input on for either of the first two, so the others' keys
|
|
# are pinned when it changes, and an old config reads as it always worked (HANDS_NEAR=1 and
|
|
# POINTER_HANDS=1: all three on; POINTER_HANDS=0, the default: all three off).
|
|
METHODS = ("gaze_clicks", "ray", "touch")
|
|
|
|
|
|
def _flag(conf, key, default):
|
|
"""A 0/1 setting as the pointer helper reads it (a number, not 0), else the default."""
|
|
v = conf.get(key)
|
|
if v is None or v == "":
|
|
return default
|
|
try:
|
|
return float(v) != 0
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def near_of(conf):
|
|
"""Whether ft-screens' direct touch is on: HANDS_NEAR as ft-screens and the helper read it."""
|
|
v = conf.get("HANDS_NEAR", "1")
|
|
if v == "0":
|
|
return False
|
|
if v in ("on", "force"):
|
|
return True
|
|
return _flag(conf, "POINTER_HANDS", False)
|
|
|
|
|
|
def methods_of(conf):
|
|
"""{method: on} for METHODS."""
|
|
hands = _flag(conf, "POINTER_HANDS", False)
|
|
return {"gaze_clicks": hands and _flag(conf, "POINTER_GAZE_PINCH", True),
|
|
"ray": hands and _flag(conf, "POINTER_HAND_RAY", True),
|
|
"touch": near_of(conf)}
|
|
|
|
|
|
def method_writes(conf, method, on):
|
|
"""The KEY: value writes that turn one method on or off and leave the other two as they are."""
|
|
want = methods_of(conf)
|
|
want[method] = bool(on)
|
|
writes = {}
|
|
hands = want["gaze_clicks"] or want["ray"]
|
|
if _flag(conf, "POINTER_HANDS", False) != hands:
|
|
writes["POINTER_HANDS"] = "1" if hands else "0"
|
|
if hands:
|
|
for key, m in (("POINTER_GAZE_PINCH", "gaze_clicks"), ("POINTER_HAND_RAY", "ray")):
|
|
if _flag(conf, key, True) != want[m]:
|
|
writes[key] = "1" if want[m] else "0"
|
|
if near_of(dict(conf, **writes)) != want["touch"]:
|
|
writes["HANDS_NEAR"] = "on" if want["touch"] else "0"
|
|
return writes
|
|
|
|
|
|
class Host:
|
|
"""systemctl --user, pgrep and getcap. Tests replace it."""
|
|
|
|
def __init__(self):
|
|
uid = os.getuid()
|
|
# Inside the Frametop session XDG_RUNTIME_DIR is the session's own folder, and the bus is
|
|
# its private one: systemctl needs the user manager's.
|
|
self.env = dict(os.environ, XDG_RUNTIME_DIR=f"/run/user/{uid}",
|
|
DBUS_SESSION_BUS_ADDRESS=f"unix:path=/run/user/{uid}/bus")
|
|
|
|
def run(self, cmd, timeout=20):
|
|
try:
|
|
return subprocess.run(cmd, env=self.env, stdin=subprocess.DEVNULL, capture_output=True,
|
|
text=True, timeout=timeout)
|
|
except (OSError, subprocess.TimeoutExpired) as e:
|
|
return subprocess.CompletedProcess(cmd, 1, "", str(e))
|
|
|
|
def systemctl(self, *args):
|
|
return self.run(["systemctl", "--user", *args])
|
|
|
|
def units(self, names):
|
|
"""{unit: {"load": LoadState, "active": ActiveState}} for each."""
|
|
out = self.systemctl("show", "-p", "Id,LoadState,ActiveState", *names).stdout
|
|
found = {}
|
|
for block in out.strip().split("\n\n"):
|
|
props = dict(line.split("=", 1) for line in block.splitlines() if "=" in line)
|
|
if props.get("Id"):
|
|
found[props["Id"]] = {"load": props.get("LoadState", ""), "active": props.get("ActiveState", "")}
|
|
return {u: found.get(u, {"load": "not-found", "active": "inactive"}) for u in names}
|
|
|
|
def exec_path(self, unit):
|
|
m = re.search(r"path=(\S+)", self.systemctl("show", "-p", "ExecStart", "--value", unit).stdout)
|
|
return m.group(1) if m else ""
|
|
|
|
def wanted(self, unit):
|
|
return os.path.lexists(os.path.join(WANTS_DIR, unit))
|
|
|
|
def exists(self, path):
|
|
return os.path.exists(path)
|
|
|
|
def caps(self, path):
|
|
"""Does ft-camd have its capabilities? None: can't tell (no getcap)."""
|
|
if not shutil.which("getcap"):
|
|
return None
|
|
return "cap_sys_ptrace" in self.run(["getcap", path]).stdout
|
|
|
|
def desktop_up(self):
|
|
return self.run(["pgrep", "-x", "ft-screens"]).returncode == 0
|
|
|
|
def cameras(self):
|
|
"""camcheck's summary ("ok", "degraded: ...", "unknown: ...")."""
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
try:
|
|
import camcheck
|
|
return camcheck.check()["summary"]
|
|
except Exception as e: # a status line, not a crash
|
|
return f"unknown: camcheck failed: {e}"
|
|
|
|
|
|
def status(host, conf=None, cameras=True):
|
|
"""What the settings app shows: the mode, what's installed and running, and "problem", a
|
|
sentence saying why hand tracking can't run (empty when nothing's wrong)."""
|
|
conf = read_conf() if conf is None else conf
|
|
states = host.units((STEAMVR,) + UNITS + CUTOUTS)
|
|
|
|
def loaded(u):
|
|
return states[u]["load"] == "loaded"
|
|
|
|
def running(u):
|
|
return states[u]["active"] in ("active", "activating", "reloading")
|
|
|
|
st = {"mode": mode_of(conf), "steamvr": running(STEAMVR), "desktop": host.desktop_up(),
|
|
"installed": all(loaded(u) for u in TRACKING), "view": loaded(VIEW),
|
|
"tracking": all(running(u) for u in TRACKING), "view_running": running(VIEW),
|
|
"cutouts": any(running(u) for u in CUTOUTS), "wanted": [u for u in UNITS if host.wanted(u)],
|
|
"active": [u for u in UNITS + CUTOUTS if running(u)],
|
|
"camd": "", "built": False, "caps": None, "cameras": "", "problem": ""}
|
|
if st["installed"]:
|
|
st["camd"] = host.exec_path(CAMD)
|
|
st["built"] = bool(st["camd"]) and host.exists(st["camd"])
|
|
if st["built"]:
|
|
st["caps"] = host.caps(st["camd"])
|
|
repo = os.path.dirname(os.path.dirname(os.path.dirname(st["camd"]))) if st["camd"] else "the Frametop folder"
|
|
if not st["installed"]:
|
|
st["problem"] = ("Hand tracking isn't installed. To install it, run hands/run.sh install in the "
|
|
"Frametop folder, in a terminal (it asks for your password once).")
|
|
elif not st["built"]:
|
|
st["problem"] = f"ft-camd isn't built ({st['camd'] or 'no path'}). Run hands/run.sh install in {repo}."
|
|
elif st["caps"] is False:
|
|
st["problem"] = (f"ft-camd is missing the permissions it needs to read the cameras (a rebuild clears "
|
|
f"them). In a terminal, run {repo}/hands/run.sh caps (it asks for your password).")
|
|
elif cameras and st["steamvr"]:
|
|
st["cameras"] = host.cameras()
|
|
if st["cameras"].startswith("degraded"):
|
|
st["problem"] = "The headset's cameras aren't all running: " + st["cameras"].split(": ", 1)[-1]
|
|
st["summary"] = summary(st)
|
|
return st
|
|
|
|
|
|
def summary(st):
|
|
"""One plain line: what runs, or when it will."""
|
|
if st["tracking"]:
|
|
how = {"off": " (started by hand: ft-handsctl)"}.get(st["mode"], "")
|
|
return "Hand tracking is running" + how
|
|
if st["cutouts"]:
|
|
return "The hand cutouts are running (started by hand: ft-cutouts)"
|
|
if st["mode"] == "off":
|
|
return "Hand tracking is off"
|
|
when = "with the Frametop desktop" if st["mode"] == "frametop" else "with SteamVR"
|
|
if st["problem"]:
|
|
return "Hand tracking can't start " + when
|
|
if not st["steamvr"]:
|
|
return "Hand tracking starts " + when
|
|
return "Hand tracking is starting" if st["mode"] == "always" or st["desktop"] else "Hand tracking starts " + when
|
|
|
|
|
|
def plan(mode, st, desktop=None, strict=False):
|
|
"""The systemctl calls that bring the units to MODE: a list of argument lists. Starting and the
|
|
SteamVR wants always follow the mode; stopping only when the desktop just ended (desktop=False,
|
|
the session's hook) or with strict (the mode was just chosen), so an install or a caps run
|
|
doesn't stop what ft-handsctl or ft-cutouts started by hand.
|
|
desktop: True/False from the session's hooks, None to look (st["desktop"])."""
|
|
units = [u for u in TRACKING if st["installed"]] + ([VIEW] if st["view"] else [])
|
|
up = st["desktop"] if desktop is None else desktop
|
|
can_run = st["installed"] and st["built"] and st["caps"] is not False
|
|
calls = []
|
|
if mode == "always":
|
|
missing = [u for u in units if u not in st["wanted"]]
|
|
if missing:
|
|
calls.append(["add-wants", STEAMVR, *missing])
|
|
else:
|
|
unwanted = [u for u in units if u in st["wanted"]]
|
|
if unwanted:
|
|
calls.append(["disable", *unwanted])
|
|
run = st["steamvr"] and can_run and (mode == "always" or (mode == "frametop" and up))
|
|
if run and units:
|
|
calls.append(["start", "--no-block", *units])
|
|
elif not (strict or desktop is False):
|
|
pass
|
|
elif mode == "off" and st["active"]:
|
|
calls.append(["stop", "--no-block", *st["active"]]) # ft-handsctl's or ft-cutouts' too
|
|
elif mode == "frametop" and not up:
|
|
active = [u for u in units if u in st["active"]]
|
|
if active:
|
|
calls.append(["stop", "--no-block", *active])
|
|
return calls
|
|
|
|
|
|
def apply(host, conf=None, desktop=None, strict=False):
|
|
"""Bring the units to HANDS_MODE (plan); returns the systemctl calls made, and the status they
|
|
started from."""
|
|
conf = read_conf() if conf is None else conf
|
|
st = status(host, conf, cameras=False)
|
|
calls = plan(st["mode"], st, desktop, strict)
|
|
for args in calls:
|
|
host.systemctl(*args)
|
|
return calls, st
|
|
|
|
|
|
def main(argv):
|
|
host = Host()
|
|
cmd = argv[0] if argv else "status"
|
|
if cmd == "status":
|
|
st = status(host)
|
|
if "--json" in argv:
|
|
print(json.dumps(st))
|
|
else:
|
|
print(f"HANDS_MODE={st['mode']}: {st['summary']}")
|
|
if st["problem"]:
|
|
print(st["problem"])
|
|
return 0
|
|
if (cmd == "set" and len(argv) == 2 and argv[1] in MODES) or argv == ["apply"]:
|
|
if cmd == "set":
|
|
write_value("HANDS_MODE", argv[1])
|
|
calls, st = apply(host, strict=cmd == "set")
|
|
for args in calls:
|
|
print("systemctl --user " + " ".join(args))
|
|
if st["problem"]:
|
|
print(st["problem"], file=sys.stderr)
|
|
return 0
|
|
if cmd == "desktop" and len(argv) == 2 and argv[1] in ("up", "down"):
|
|
conf = read_conf()
|
|
if mode_of(conf) == "frametop":
|
|
calls, st = apply(host, conf, desktop=argv[1] == "up")
|
|
for args in calls: # into the session's log
|
|
print("frametop: hand tracking (HANDS_MODE=frametop): systemctl --user " + " ".join(args))
|
|
if st["problem"] and argv[1] == "up":
|
|
print("frametop: hand tracking: " + st["problem"])
|
|
return 0
|
|
print("usage: handsmode.py status [--json] | apply | set frametop|always|off | desktop up|down", file=sys.stderr)
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|