Files
DeeJanuz--frametop/hands/rec/tests/test_nohands.py
DeeJanuzandClaude Opus 5.5 3ebae6a88f Hands: check the tracking cameras before recording, and watch for losing them
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>
2026-10-02 20:12:16 -06:00

229 lines
9.9 KiB
Python

#!/usr/bin/env python3
"""Tests for the session's camera check: the preflight that keeps a session from starting
with the upper cameras off, and the early no-hands stop in the hand-size section's first step
(dry runs with a made-up hands file; no processes, no real cameras).
python3 hands/rec/tests/test_nohands.py
"""
import json
import os
import sys
import threading
import unittest
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(HERE))
sys.path.insert(0, HERE)
import session # noqa: E402
import camcheck # noqa: E402 (session.py put hands/ on the path)
from test_session import SessionBase # noqa: E402
SCRIPT = {
"version": 1, "intro_s": 1, "between_s": 1,
"welcome": {"title": "Test", "seconds": 1, "text": "A test."},
"done": {"title": "Done", "seconds": 0, "text": "Done."},
"stopped": {"title": "Stopped", "seconds": 0, "text": "Stopped."},
"sections": [
{"id": "hand-size", "title": "Hand size", "intro": "Hand size.", "go": "Hold",
"defaults": {"hands": "both"},
"prompts": [{"text": "Both hands flat.", "seconds": 6, "pose": "flat"},
{"text": "Backs.", "seconds": 2, "pose": "flat-back"}]}]}
HAND = {"palm": [0.0, 0.0, -0.4], "palm_m": 0.4, "tip": [0.0, 0.05, -0.45]}
DEGRADED = {"status": "degraded", "reason": camcheck.VCINT_REASON, "summary": camcheck.DEGRADED_VCINT,
"evidence": ["17:02:47 Failed to load VCINT FPGA image when passthrough cameras are connected"]}
OK = {"status": "ok", "reason": "4 tracking cameras running", "summary": "ok", "evidence": []}
class FakeHands:
"""Stands in for the hands file: what read() gives can change mid-session."""
def __init__(self, value):
self.value = value
self.lock = threading.Lock()
def set(self, value):
with self.lock:
self.value = value
def read(self):
with self.lock:
return None if self.value is None else dict(self.value)
class NoHandsTest(SessionBase):
def setUp(self):
super().setUp()
with open(self.script, "w") as f:
json.dump(SCRIPT, f)
def events(self, s):
with open(os.path.join(s.session_dir, "takes", "01-hand-size", "prompts.jsonl")) as f:
# feedback lines (twice a second, as the tracker publishes) aren't what's tested here
return [e for e in map(json.loads, f) if e["event"] != "feedback"]
def run_to_first_hold(self, hands, camera=DEGRADED, **kw):
s = self.session(hands_reader=hands, speed=3, **kw)
s.camera_check_fn = lambda: camera
s.start()
self.waiting(s, "starting")
s.next_step()
self.waiting(s, "intro")
s.next_step()
self.waiting(s, "ready", "Both hands flat.")
s.next_step()
return s
def session_json(self, s):
with open(os.path.join(s.session_dir, "session.json")) as f:
return json.load(f)
def log(self, s):
with open(os.path.join(s.session_dir, "session.log")) as f:
return f.read()
def test_no_hands_then_redo(self):
hands = FakeHands({"left": None, "right": None}) # the tracker publishes, sees nothing
s = self.run_to_first_hold(hands)
st = self.wait_for(s, lambda st: st["state"] == "nohands" and st["waiting"], "the no-hands stop")
self.assertTrue(st["nohands"])
self.assertTrue(st["can_redo"])
self.assertEqual(st["camera"]["status"], "degraded")
self.assertIn(camcheck.USER_TEXT, st["prompt"])
self.assertIn("panel: title " + session.NO_HANDS_TITLE, self.panel)
hands.set({"left": HAND, "right": HAND})
s.next_step() # try again: straight to the countdown, no second ready wait
self.wait_for(s, lambda st: st["state"] == "countdown", "the countdown again")
self.waiting(s, "ready", "Backs.")
s.next_step()
s.join(20)
self.assertEqual(s.state, "done")
ev = self.events(s)
names = [e["event"] for e in ev]
self.assertEqual(names, ["take", "ready", "prompt", "wait", "nohands", "redo",
"ready", "prompt", "wait", "ready", "prompt", "wait", "end"])
nohands, redo = ev[4], ev[5]
self.assertGreaterEqual(nohands["reads"], session.HANDS_CHECK_MIN_READS)
self.assertEqual(redo["from"], ev[1]["t"]) # the failed try gets no labels
self.assertTrue(ev[2]["t"] < redo["to"])
# the retry wasn't stopped: a hand was seen
self.assertRegex(self.log(s), r"hands check hand-size/flat/both: a hand in [1-9][0-9]* of")
self.assertNotIn("stop_reason", self.session_json(s))
def test_no_hands_then_stop(self):
s = self.run_to_first_hold(FakeHands({"left": None, "right": None}))
self.wait_for(s, lambda st: st["state"] == "nohands" and st["waiting"], "the no-hands stop")
s.stop(wait=10)
self.assertEqual(s.state, "stopped")
sj = self.session_json(s)
self.assertIn("no hands were seen", sj["stop_reason"])
self.assertIn(camcheck.DEGRADED_VCINT, sj["stop_reason"])
self.assertIn(camcheck.USER_TEXT, s._status["prompt"])
self.assertIn("camera check: " + camcheck.DEGRADED_VCINT, self.log(s))
def test_redo_key_and_skip(self):
hands = FakeHands({"left": None, "right": None})
s = self.run_to_first_hold(hands, camera=OK)
st = self.wait_for(s, lambda st: st["state"] == "nohands" and st["waiting"], "the no-hands stop")
self.assertIn("The camera check found nothing wrong", st["prompt"])
s.redo() # R is the same as Next there
self.wait_for(s, lambda st: st["state"] == "countdown", "the countdown again")
self.wait_for(s, lambda st: st["state"] == "nohands" and st["waiting"], "no hands again")
s.skip() # S skips the section; the no-hands note doesn't stick
s.join(20)
self.assertEqual(s.state, "done")
self.assertNotIn("stop_reason", self.session_json(s))
self.assertEqual([e["event"] for e in self.events(s)].count("nohands"), 2)
def test_hand_seen_sometimes(self):
hands = FakeHands({"left": None, "right": None})
s = self.run_to_first_hold(hands)
self.wait_for(s, lambda st: st["state"] == "running", "the hold")
hands.set({"left": HAND, "right": None}) # one hand, for part of the hold: fine
self.waiting(s, "ready", "Backs.")
s.next_step()
s.join(20)
self.assertEqual(s.state, "done")
self.assertNotIn("nohands", [e["event"] for e in self.events(s)])
def test_no_tracker_cant_tell(self):
s = self.run_to_first_hold(FakeHands(None)) # nothing published: can't tell, go on
self.waiting(s, "ready", "Backs.")
s.next_step()
s.join(20)
self.assertEqual(s.state, "done")
self.assertNotIn("nohands", [e["event"] for e in self.events(s)])
self.assertIn("can't tell", self.log(s))
def test_only_the_first_step(self):
hands = FakeHands({"left": HAND, "right": HAND})
s = self.run_to_first_hold(hands)
self.wait_for(s, lambda st: st["state"] == "running", "the hold")
self.waiting(s, "ready", "Backs.")
hands.set({"left": None, "right": None}) # gone in step 2: notes, but no stop
s.next_step()
s.join(20)
self.assertEqual(s.state, "done")
self.assertNotIn("nohands", [e["event"] for e in self.events(s)])
def test_auto_mode(self):
hands = FakeHands({"left": None, "right": None})
s = self.session(hands_reader=hands, speed=3, auto=True)
s.camera_check_fn = lambda: DEGRADED
s.start()
self.wait_for(s, lambda st: st["state"] == "nohands" and st["waiting"], "the no-hands stop")
hands.set({"left": HAND, "right": HAND})
s.next_step()
s.join(20)
self.assertEqual(s.state, "done")
names = [e["event"] for e in self.events(s)]
self.assertEqual(names, ["take", "prompt", "prompt", "pause", "nohands", "redo", "resume", "prompt",
"prompt", "end"])
class PreflightTest(SessionBase):
def test_degraded_stops_before_anything(self):
s = self.session()
s.dry_run = False # as a real session; the check is the only step run
s.camera_check_fn = lambda: DEGRADED
self.assertTrue(s._preflight())
self.assertEqual(s.state, "error")
self.assertEqual(s._status["error"], camcheck.USER_TEXT)
self.assertEqual(s.session_dir, "") # no session folder made
self.assertFalse(os.path.exists(os.path.join(self.tmp, "base", "sessions")))
def test_other_degraded_text(self):
r = dict(DEGRADED, reason="only 3 of 4 tracking cameras running")
self.assertIn("only 3 of 4", session.camera_text(r))
self.assertEqual(session.camera_text(OK), "")
self.assertEqual(session.camera_text({"status": "unknown"}), "")
self.assertEqual(session.camera_text(None), "")
def test_ok_unknown_ignored(self):
for result in (OK, {"status": "unknown", "summary": "unknown: x", "evidence": []}):
s = self.session()
s.dry_run = False
s.camera_check_fn = lambda: result
self.assertFalse(s._preflight())
s = self.session(check_cameras=False)
s.dry_run = False
s.camera_check_fn = lambda: DEGRADED
self.assertFalse(s._preflight()) # --ignore-cameras
s = self.session(ring="/tmp/some-ring")
s.dry_run = False
s.camera_check_fn = lambda: DEGRADED
self.assertFalse(s._preflight()) # ft-ringplay's frames: no headset cameras involved
def test_camera_check_never_raises(self):
orig = camcheck.check
try:
camcheck.check = lambda: 1 / 0
r = session.camera_check()
finally:
camcheck.check = orig
self.assertEqual(r["status"], "unknown")
if __name__ == "__main__":
unittest.main()