Files
DeeJanuz--frametop/gaze/test/idle-test.py
T
DeeJanuzandClaude Opus 5.5 5b08e43a5d Gaze: idle while the gaze isn't used
The gaze service ran ft-gaze and our own eye tracker all the time: with gaze mode off, ft-eyes
still took about 60% of a core, and ft-eyegrab, ft-gaze and ft-gazed 3 to 4% each. Now ft-gaze
and our tracker run only while gaze mode is on and someone wears the headset, while a check or
the calibration is open or asked for, or under a "wake" lease, which the Gaze page of Frametop
Input Settings renews while it's open. 30 s after the last use they stop, and the frame grabber
idles with our tracker.

- The pointer helper answers "gaze ? headset" with worn|away (SteamVR's activity level for the
  headset); an older helper answers it as before, and the service then goes by gaze mode alone.
- A quick check, calibration, or fit check asked for while idle wakes the tracker and opens once
  it sends; the automatic calibration waits quietly while it starts.
- Status has "awake" and "idle" (why), and the Gaze page shows it.
- gaze/test/idle-test.py runs the service with a fake helper and ft-gaze, offline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 08:32:49 -06:00

159 lines
5.8 KiB
Python
Executable File

#!/usr/bin/env python3
"""Offline test of the gaze service idling (gaze/ft-gazed, gaze/gazecheck.py): ft-gaze runs only
while the gaze is in use, and a check asked for while it idles waits for the tracker.
Runs ft-gazed's Service with its sockets renamed, a fake pointer helper (answers "gaze ?
headset" as the test says), and a fake ft-gaze (prints samples, quits when its stdin closes).
SteamVR's tracker is the one in use, so our own isn't started; the panel isn't either. Nothing
reaches the live gaze service, the pointer helper, or SteamVR, so it's safe next to them.
gaze/test/idle-test.py
"""
import importlib.machinery
import importlib.util
import os
import socket
import subprocess
import sys
import tempfile
import threading
import time
HERE = os.path.dirname(os.path.abspath(__file__))
GAZE = os.path.join(HERE, "..")
sys.path.insert(0, GAZE)
loader = importlib.machinery.SourceFileLoader("ftgazed", os.path.join(GAZE, "ft-gazed"))
gazed = importlib.util.module_from_spec(importlib.util.spec_from_loader("ftgazed", loader))
loader.exec_module(gazed)
import gazecheck # noqa: E402 (the module ft-gazed imported)
tag = f"ft_gaze_idle_test_{os.getpid()}"
gazed.ME = f"\0{tag}_gazed"
gazed.POINTER = gazecheck.POINTER = f"\0{tag}_helper"
gazecheck.SCREENS = f"\0{tag}_screens"
gazecheck.PANEL_PROG = gazed.REPO / "nonexistent-panel" # "isn't built": no panel
gazed.IDLE_AFTER, gazed.WAKE_SETTLE = 1.0, 3.0
gazed.read_settings = lambda: ("steam", "steam", "auto", 55.0)
logs = []
gazed.log = gazecheck.log = lambda msg: logs.append(msg)
# The fake ft-gaze: 90 samples a second, both eyes seen, until its stdin closes.
tmp = tempfile.mkdtemp(prefix="ft-gaze-idle-test-")
FAKE = os.path.join(tmp, "ft-gaze")
with open(FAKE, "w") as f:
f.write('''import json, select, sys, time
while True:
if select.select([sys.stdin], [], [], 1 / 90)[0] and not sys.stdin.read(1):
break
eye = {"hy": 1.0, "hp": 2.0}
print(json.dumps({"t": time.monotonic(), "src": {"mmap1": {"hy": 1.0, "hp": 2.0, "unc": [0.001, 0.001],
"open": [0.8, 0.8]}, "left": eye, "right": eye}}), flush=True)
''')
started = []
def start_helper(self):
"""ft-gaze, straight from here instead of the dev container."""
import selectors
self.proc = subprocess.Popen([sys.executable, FAKE], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
os.set_blocking(self.proc.stdout.fileno(), False)
os.set_blocking(self.proc.stderr.fileno(), False)
self.sel.register(self.proc.stdout, selectors.EVENT_READ, "stdout")
self.sel.register(self.proc.stderr, selectors.EVENT_READ, "stderr")
self.buf = b""
started.append(time.monotonic())
gazed.Service.start_helper = start_helper
# The fake pointer helper.
helper_state = {"reply": "ok off worn"}
helper = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
helper.bind(gazed.POINTER)
helper.settimeout(0.2)
def answer():
while True:
try:
data, addr = helper.recvfrom(512)
except socket.timeout:
continue
except OSError:
return
if data.startswith(b"gaze ?") and addr:
helper.sendto(helper_state["reply"].encode(), addr)
threading.Thread(target=answer, daemon=True).start()
svc = gazed.Service(None, False, gazed.POINTER)
threading.Thread(target=svc.run, daemon=True).start()
ctl = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
ctl.bind("")
ctl.settimeout(2)
def ask(cmd):
ctl.sendto(cmd.encode(), gazed.ME)
return ctl.recv(4096).decode()
failures = []
def check(label, got, want):
ok = got == want
print(("ok " if ok else "FAIL ") + label + ("" if ok else f": got {got!r}, want {want!r}"), flush=True)
if not ok:
failures.append(label)
def wait(cond, seconds):
end = time.monotonic() + seconds
while time.monotonic() < end:
if cond():
return True
time.sleep(0.05)
return cond()
time.sleep(2.5)
check("gaze mode off: idle, no ft-gaze", (svc.awake, svc.proc is None), (False, True))
check("status says why", ask("status").count('"idle": "gaze mode is off"'), 1)
helper_state["reply"] = "ok on worn"
check("gaze mode on, headset worn: awake within 2 s", wait(lambda: svc.awake and svc.proc is not None, 2.5), True)
check("samples come in", wait(lambda: svc.counts["samples"] > 20, 2), True)
helper_state["reply"] = "ok on away"
check("headset off: still awake for IDLE_AFTER", wait(lambda: not svc.awake, 0.5), False)
check("then idle, ft-gaze stopped", wait(lambda: not svc.awake and svc.proc is None, 3.5), True)
check("why: nobody wears it", ask("status").count('"idle": "nobody is wearing the headset"'), 1)
helper_state["reply"] = "ok on"
check("an older helper (no headset word): awake with gaze mode on", wait(lambda: svc.awake, 2.5), True)
helper_state["reply"] = "ok off worn"
check("gaze mode off again: idle", wait(lambda: not svc.awake and svc.proc is None, 4.5), True)
check("wake lease", ask("wake 2"), "ok")
check("wake: awake at once", (svc.awake, wait(lambda: svc.proc is not None, 1)), (True, True))
check("lease over (2 s + IDLE_AFTER): idle", wait(lambda: not svc.awake, 4.5), True)
time.sleep(0.5)
logs.clear()
count = len(started)
check("quick check while idle: queued, waking", ask("quickcal"), "ok waking the eye tracker first")
check("it woke", wait(lambda: svc.awake and len(started) > count, 1.5), True)
check("once the tracker sends, it ran (and says why it couldn't open)",
wait(lambda: any("quickcal, asked for while idle: the panel isn't running" in m for m in logs), 3), True)
check("nothing left pending", svc.checks.pending, None)
check("idle again after", wait(lambda: not svc.awake, 3), True)
print("FAILED: " + ", ".join(failures) if failures else "all passed", flush=True)
svc.running = False
time.sleep(0.7)
os.remove(FAKE)
os.rmdir(tmp)
os._exit(1 if failures else 0)