mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 06:00:09 +02:00
After the headset wakes, XRService sometimes fails to load the colour module's VCINT FPGA image; then only the two side cameras run, without the IR light, and the tracker finds no hands. hands/camcheck.py reads XRService's log, the video nodes it holds and ft-camd's ring, and says ok, degraded or unknown. The recorder won't start while degraded (--ignore-cameras overrides it), offers a confirmed SteamVR restart, and stops the first hand-size step when the tracker sees no hand at all. ft-camwatch (unit file only, not enabled) follows the log, notifies, and with CAMWATCH_AUTO_RESTART=1 restarts SteamVR when the headset isn't worn and nothing else uses VR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
379 lines
15 KiB
Python
Executable File
379 lines
15 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""ft-camwatch: watches SteamVR's XRService log for the camera failure that turns off the
|
|
headset's upper cameras and IR light (a VCINT FPGA load that fails, usually after a wake;
|
|
hands/camcheck.py), tells the Frametop desktop, and can restart SteamVR by itself.
|
|
|
|
Off by default: hands/frametop-camwatch.service is a template the installer doesn't enable.
|
|
Settings in ~/.config/frametop.conf:
|
|
CAMWATCH_AUTO_RESTART=1 restart SteamVR on a failure (default 0: only a notification). Only
|
|
while the headset isn't worn (frame-job's check: vrcompositor runs and
|
|
a backlight is on), after it has been off for CAMWATCH_IDLE_S, with no
|
|
Steam-launched app running and nobody on the remote desktop (VNC).
|
|
At most once per failure, and not again within CAMWATCH_COOLDOWN_MIN.
|
|
CAMWATCH_IDLE_S=60 how long the headset must be off first
|
|
CAMWATCH_COOLDOWN_MIN=30 the least time between two automatic restarts
|
|
CAMWATCH_IGNORE_APPIDS= Steam app ids that don't count as a running VR app (comma-separated)
|
|
CAMWATCH_NOTIFY=1 post a notification in the Frametop desktop (0: log only)
|
|
|
|
A SteamVR restart also closes the Frametop desktop and every window in it (ft-screens quits
|
|
with SteamVR, and the desktop's unit doesn't restart: see hands/README.md, "Camera check").
|
|
|
|
It follows the log with a stat every 2 s (no inotify, nothing else while all is well), and
|
|
logs every decision to stdout (the journal). --dry-run never notifies or restarts; --once
|
|
prints the state and what it would do now, and exits.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, HERE)
|
|
import camcheck # noqa: E402
|
|
|
|
CONF = os.path.expanduser("~/.config/frametop.conf")
|
|
STATE = os.path.expanduser("~/.local/state/frametop/camwatch.json")
|
|
BACKLIGHTS = "/sys/class/backlight"
|
|
POLL_S = 2.0
|
|
MAX_READ = 16 << 20 # a poll reads at most this much of a log that grew
|
|
NOTIFY_TITLE = "Hand tracking: cameras off"
|
|
NOTIFY_TEXT = ("The headset's upper cameras and IR light are off: SteamVR couldn't start the colour camera "
|
|
"module (it happens sometimes after the headset sleeps). Restart SteamVR, or the headset if "
|
|
"that doesn't fix it. Restarting SteamVR closes this desktop and its windows.")
|
|
DEFAULTS = {"CAMWATCH_AUTO_RESTART": "0", "CAMWATCH_IDLE_S": "60", "CAMWATCH_COOLDOWN_MIN": "30",
|
|
"CAMWATCH_IGNORE_APPIDS": "", "CAMWATCH_NOTIFY": "1", "VNC_PORT": "5900"}
|
|
|
|
|
|
def log(text):
|
|
print("%s %s" % (time.strftime("%H:%M:%S"), text), flush=True)
|
|
|
|
|
|
def read_conf(path=CONF):
|
|
"""KEY=VALUE lines of a shell-style file (comments and quotes stripped), over DEFAULTS."""
|
|
conf = dict(DEFAULTS)
|
|
try:
|
|
with open(path) as f:
|
|
for line in f:
|
|
line = line.split("#", 1)[0].strip()
|
|
if "=" not in line:
|
|
continue
|
|
k, v = line.split("=", 1)
|
|
k, v = k.strip(), v.strip().strip("'\"")
|
|
if k.replace("_", "").isalnum():
|
|
conf[k] = v
|
|
except OSError:
|
|
pass
|
|
return conf
|
|
|
|
|
|
def conf_int(conf, key):
|
|
try:
|
|
return int(float(conf.get(key, DEFAULTS.get(key, "0"))))
|
|
except ValueError:
|
|
return int(DEFAULTS.get(key, "0") or 0)
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------
|
|
# What's going on around (all read-only, from /proc and /sys)
|
|
|
|
def process_running(name):
|
|
for pid in os.listdir("/proc"):
|
|
if pid.isdigit():
|
|
try:
|
|
with open("/proc/%s/comm" % pid) as f:
|
|
if f.read().strip() == name:
|
|
return True
|
|
except OSError:
|
|
pass
|
|
return False
|
|
|
|
|
|
def headset_worn(backlights=BACKLIGHTS):
|
|
"""frame-job's check: vrcompositor runs and any panel's backlight is on (SteamVR turns the
|
|
panels off 5 s after the headset comes off)."""
|
|
lit = False
|
|
try:
|
|
for name in os.listdir(backlights):
|
|
try:
|
|
with open(os.path.join(backlights, name, "brightness")) as f:
|
|
lit = lit or int(f.read().strip()) > 0
|
|
except (OSError, ValueError):
|
|
pass
|
|
except OSError:
|
|
return False
|
|
return lit and process_running("vrcompositor")
|
|
|
|
|
|
def vr_apps(ignore=()):
|
|
"""Apps Steam launched ("SteamLaunch AppId=N" in a process's arguments, as Steam's reaper
|
|
runs them): ["AppId=N ..."]. Steam's own processes, SteamVR's and Frametop's services aren't
|
|
launched this way. A guess: no VR game has been run on the Frame to confirm the form."""
|
|
out = set()
|
|
for pid in os.listdir("/proc"):
|
|
if not pid.isdigit():
|
|
continue
|
|
argv = camcheck.proc_argv(pid)
|
|
if "SteamLaunch" not in argv:
|
|
continue
|
|
ids = [a.split("=", 1)[1] for a in argv if a.startswith("AppId=")]
|
|
if ids and ids[0] not in ignore:
|
|
out.add("AppId=" + ids[0])
|
|
return sorted(out)
|
|
|
|
|
|
def remote_viewers(port=5900, tables=("/proc/net/tcp", "/proc/net/tcp6")):
|
|
"""Established connections to the remote desktop's VNC port, from /proc/net/tcp(6)."""
|
|
out = []
|
|
for path in tables:
|
|
try:
|
|
with open(path) as f:
|
|
next(f)
|
|
for line in f:
|
|
p = line.split()
|
|
if len(p) > 3 and p[3] == "01" and int(p[1].rsplit(":", 1)[1], 16) == port:
|
|
out.append(p[2])
|
|
except (OSError, StopIteration, ValueError):
|
|
pass
|
|
return out
|
|
|
|
|
|
def frametop_bus():
|
|
"""The Frametop desktop's D-Bus (from its plasmashell, as decoration/apply.sh finds it), or None."""
|
|
for pid in os.listdir("/proc"):
|
|
if not pid.isdigit():
|
|
continue
|
|
try:
|
|
with open("/proc/%s/comm" % pid) as f:
|
|
if f.read().strip() != "plasmashell":
|
|
continue
|
|
with open("/proc/%s/environ" % pid, "rb") as f:
|
|
env = dict(kv.split(b"=", 1) for kv in f.read().split(b"\0") if b"=" in kv)
|
|
except OSError:
|
|
continue
|
|
if env.get(b"XDG_RUNTIME_DIR", b"").endswith(b"/frametop") and b"DBUS_SESSION_BUS_ADDRESS" in env:
|
|
return env[b"DBUS_SESSION_BUS_ADDRESS"].decode()
|
|
return None
|
|
|
|
|
|
def notify(title, text):
|
|
bus = frametop_bus()
|
|
if not bus:
|
|
log("no Frametop desktop running: no notification")
|
|
return False
|
|
r = subprocess.run(["notify-send", "-a", "Frametop", "-u", "critical", "-i", "dialog-warning", title, text],
|
|
env=dict(os.environ, DBUS_SESSION_BUS_ADDRESS=bus), capture_output=True, text=True,
|
|
timeout=10)
|
|
if r.returncode:
|
|
log("notify-send failed (%d): %s" % (r.returncode, r.stderr.strip()))
|
|
return r.returncode == 0
|
|
|
|
|
|
def restart_steamvr():
|
|
# --no-block: the job runs in systemd; the Frametop desktop closing doesn't cut it short.
|
|
r = subprocess.run(["systemctl", "--user", "restart", "--no-block", "steamvr.service"],
|
|
capture_output=True, text=True, timeout=30)
|
|
log("systemctl --user restart steamvr.service: exit %d %s" % (r.returncode, (r.stderr or "").strip()))
|
|
return r.returncode == 0
|
|
|
|
|
|
def environment(conf):
|
|
ignore = tuple(a.strip() for a in conf.get("CAMWATCH_IGNORE_APPIDS", "").replace(",", " ").split() if a.strip())
|
|
return {"steamvr": camcheck.xrservice_pid() is not None, "worn": headset_worn(),
|
|
"vr_apps": vr_apps(ignore), "remote": remote_viewers(conf_int(conf, "VNC_PORT"))}
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------
|
|
# The decision (pure: tests feed it made-up states)
|
|
|
|
def new_memory():
|
|
return {"pending": "", "notified": [], "restarted": {}, "last_restart": 0.0, "idle_since": None, "why": ""}
|
|
|
|
|
|
def decide(now, failure, env, mem, conf):
|
|
"""What to do now. failure: the current VCINT failure's id ("" if none: the cameras run,
|
|
or they're closed); env: {"steamvr", "worn", "vr_apps", "remote"} (only looked at while a
|
|
failure is current); mem: new_memory(), updated in place; now: wall-clock seconds.
|
|
Returns [("log", text) | ("notify", failure) | ("restart", failure)]."""
|
|
acts = []
|
|
if not failure:
|
|
if mem["pending"]:
|
|
acts.append(("log", "failure %s is no longer current" % mem["pending"]))
|
|
mem.update(pending="", idle_since=None, why="")
|
|
return acts
|
|
if mem["pending"] != failure:
|
|
mem.update(pending=failure, why="")
|
|
acts.append(("log", "VCINT failure: %s (upper cameras and IR light off)" % failure))
|
|
if failure not in mem["notified"]:
|
|
mem["notified"] = (mem["notified"] + [failure])[-20:]
|
|
if conf.get("CAMWATCH_NOTIFY", "1") != "0":
|
|
acts.append(("notify", failure))
|
|
if env.get("worn"):
|
|
mem["idle_since"] = None
|
|
elif mem["idle_since"] is None:
|
|
mem["idle_since"] = now
|
|
idle_s = conf_int(conf, "CAMWATCH_IDLE_S")
|
|
cooldown = conf_int(conf, "CAMWATCH_COOLDOWN_MIN") * 60
|
|
if conf.get("CAMWATCH_AUTO_RESTART", "0") != "1":
|
|
why = "no automatic restart (CAMWATCH_AUTO_RESTART=1 in ~/.config/frametop.conf turns it on)"
|
|
elif failure in mem["restarted"]:
|
|
why = "SteamVR was restarted once for this failure already: restart the headset"
|
|
elif mem["last_restart"] and now - mem["last_restart"] < cooldown:
|
|
why = "no restart: the last automatic one was %d min ago (cooldown %d min)" % (
|
|
(now - mem["last_restart"]) // 60, cooldown // 60)
|
|
elif not env.get("steamvr"):
|
|
why = "no restart: SteamVR isn't running"
|
|
elif env.get("worn"):
|
|
why = "no restart while the headset is worn"
|
|
elif env.get("vr_apps"):
|
|
why = "no restart: a VR app is running (%s)" % ", ".join(env["vr_apps"])
|
|
elif env.get("remote"):
|
|
why = "no restart: someone is on the remote desktop (%s)" % ", ".join(env["remote"])
|
|
elif now - mem["idle_since"] < idle_s:
|
|
why = "waiting for the headset to stay off for %d s" % idle_s
|
|
else:
|
|
mem["restarted"][failure] = now
|
|
mem["last_restart"] = now
|
|
why = "restarting SteamVR (the headset is off, nothing else in VR)"
|
|
acts.append(("log", why))
|
|
acts.append(("restart", failure))
|
|
mem["why"] = why
|
|
return acts
|
|
if why != mem["why"]:
|
|
mem["why"] = why
|
|
acts.append(("log", why))
|
|
return acts
|
|
|
|
|
|
def load_memory(path=STATE):
|
|
mem = new_memory()
|
|
try:
|
|
with open(path) as f:
|
|
saved = json.load(f)
|
|
mem["notified"] = list(saved.get("notified", []))[-20:]
|
|
mem["restarted"] = dict(saved.get("restarted", {}))
|
|
mem["last_restart"] = float(saved.get("last_restart", 0.0))
|
|
except (OSError, ValueError, TypeError, AttributeError):
|
|
pass
|
|
return mem
|
|
|
|
|
|
def save_memory(mem, path=STATE):
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
tmp = path + ".tmp"
|
|
with open(tmp, "w") as f:
|
|
json.dump({"notified": mem["notified"], "restarted": mem["restarted"],
|
|
"last_restart": mem["last_restart"]}, f)
|
|
os.replace(tmp, path)
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------
|
|
# Following the log
|
|
|
|
class Follower:
|
|
"""The running XRService's log, read as it grows. A new log (SteamVR restarted) starts afresh."""
|
|
|
|
def __init__(self, path=None):
|
|
self.fixed = path
|
|
self.path, self.ino, self.offset, self.rest = "", None, 0, ""
|
|
self.state = None
|
|
|
|
def poll(self):
|
|
path = self.fixed or camcheck.newest_log()
|
|
if not path:
|
|
return None
|
|
try:
|
|
st = os.stat(path)
|
|
except OSError:
|
|
return self.state
|
|
if path != self.path or st.st_ino != self.ino or st.st_size < self.offset:
|
|
self.path, self.ino, self.offset, self.rest = path, st.st_ino, 0, ""
|
|
self.state = camcheck.LogState(path)
|
|
log("following %s" % path)
|
|
if st.st_size > self.offset:
|
|
with open(path, "rb") as f:
|
|
f.seek(self.offset)
|
|
data = f.read(MAX_READ)
|
|
self.offset += len(data)
|
|
text = self.rest + data.decode(errors="replace")
|
|
lines = text.split("\n")
|
|
self.rest = lines.pop()
|
|
for line in lines:
|
|
self.state.feed(line)
|
|
return self.state
|
|
|
|
|
|
def current_failure(state, steamvr_running=True):
|
|
"""The failure id if the log's current camera start is a VCINT failure, else ""."""
|
|
if state is None or not steamvr_running:
|
|
return ""
|
|
snap = state.snapshot()
|
|
return snap["failure"] if snap["status"] == "degraded" and snap["reason"] == camcheck.VCINT_REASON else ""
|
|
|
|
|
|
def act(acts, dry):
|
|
changed = False
|
|
for kind, arg in acts:
|
|
if kind == "log":
|
|
log(arg)
|
|
elif kind == "notify":
|
|
changed = True
|
|
if dry:
|
|
log("(dry run) would notify: %s" % NOTIFY_TITLE)
|
|
else:
|
|
notify(NOTIFY_TITLE, NOTIFY_TEXT)
|
|
elif kind == "restart":
|
|
changed = True
|
|
if dry:
|
|
log("(dry run) would restart SteamVR")
|
|
else:
|
|
restart_steamvr()
|
|
return changed
|
|
|
|
|
|
def main(argv=None):
|
|
ap = argparse.ArgumentParser(description="Watch for the camera failure that turns off the upper cameras.")
|
|
ap.add_argument("--dry-run", action="store_true", help="log what it would do; never notify or restart")
|
|
ap.add_argument("--once", action="store_true", help="look once, print the decision (a dry run), and exit")
|
|
ap.add_argument("--log", help="follow this file instead of the running XRService's log (tests)")
|
|
ap.add_argument("--state", default=STATE, help="where it remembers past failures (default %(default)s)")
|
|
a = ap.parse_args(argv)
|
|
dry = a.dry_run or a.once
|
|
mem = load_memory(a.state)
|
|
follower = Follower(a.log)
|
|
conf = read_conf()
|
|
conf_mtime = 0.0
|
|
log("watching; automatic restart %s" % ("on" if conf.get("CAMWATCH_AUTO_RESTART") == "1" else "off"))
|
|
while True:
|
|
try:
|
|
m = os.stat(CONF).st_mtime
|
|
except OSError:
|
|
m = 0.0
|
|
if m != conf_mtime:
|
|
conf, conf_mtime = read_conf(), m
|
|
state = follower.poll()
|
|
failure = current_failure(state)
|
|
env = {}
|
|
if failure or mem["pending"]:
|
|
env = environment(conf)
|
|
if not env["steamvr"] and not a.log:
|
|
failure = "" # the log's last word, but XRService is gone
|
|
acts = decide(time.time(), failure, env, mem, conf)
|
|
if a.once:
|
|
snap = state.snapshot() if state else {"status": "unknown", "reason": "no log"}
|
|
print("log: %s\nstate: %s %s\nenvironment: %s" % (follower.path, snap["status"], snap["reason"],
|
|
json.dumps(env)))
|
|
act(acts, True)
|
|
return 0
|
|
if act(acts, dry) and not dry:
|
|
save_memory(mem, a.state)
|
|
time.sleep(POLL_S)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
sys.exit(main())
|
|
except KeyboardInterrupt:
|
|
sys.exit(0)
|