Files
DeeJanuz--frametop/hands/tests/test_camcheck.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

275 lines
14 KiB
Python

#!/usr/bin/env python3
"""Tests for hands/camcheck.py: the real XRService log of 2026-10-02 (a good start at 13:39,
the VCINT failure after the 17:02 wake) cut at several points, synthetic logs for the other
cases, and a made-up ft-camd ring. Nothing here touches SteamVR or the cameras.
python3 hands/tests/test_camcheck.py
"""
import io
import json
import os
import shutil
import struct
import sys
import tempfile
import time
import unittest
from contextlib import redirect_stdout
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(HERE))
import camcheck # noqa: E402
REAL_LOG = os.path.expanduser("~/.local/share/Steam/logs/XRService-2026.10.02/XRService-13-39-32.log")
def L(t, text, level="INFO"):
"""A log line as XRService writes it (with its colour codes on INFO lines)."""
if level == "INFO":
return "Fri Oct 02 2026 %s.000000 INFO: \x1b[0;36m%s\x1b[0m" % (t, text)
return "Fri Oct 02 2026 %s.000000 %s: %s" % (t, level, text)
START = [L("10:00:00", "XRService logging to /tmp/x.log. Use --showLogToConsole to see it here as well")]
GOOD_OPEN = [
L("10:00:01", "FPGA state check: PASSTHRU (register value: 0x00011212)"),
L("10:00:01", "Passthrough connected but FPGA is PASSTHRU - loading VCINT"),
L("10:00:01", "Loading FPGA image: VCINT"),
L("10:00:02", "FPGA image VCINT loaded and verified successfully"),
L("10:00:02", "Upper cameras FPGA interleaving support: 1 (Driver features available = 1 | VCINT loaded = 1)"),
L("10:00:02", "[buildMediaCtlSetupTasks] Created 6 tasks (4 tracking, 2 passthrough)"),
] + [L("10:00:03", "TrackingCameraInit: index: %d. video device: /dev/video%d. v4l subdevice: /dev/v4l-subdev3%d"
% (i, n, i)) for i, n in enumerate((9, 13, 6, 7))]
CLOSE = [L("11:00:00", "[SystemdInhibitor] Received systemd suspend notification"),
L("11:00:00", "[DeckardCaptureSource] Closing tracking camera interfaces camerasToUse: 1111"),
L("11:00:01", "[DeckardCaptureSource] Streaming paused")]
RESUME = [L("11:30:00", "[SystemdInhibitor] Received systemd resume notification"),
L("11:30:00", "Failed to read FPGA register 0x12 (exit code 256)", "ERROR"),
L("11:30:00", "Passthrough connected but FPGA is ERROR/UNKNOWN - loading VCINT"),
L("11:30:00", "Loading FPGA image: VCINT")]
FAIL = [L("11:30:01", "FPGA load failed (exit code 256): Loading VC Interleaving FPGA image", "ERROR"),
"Loading FPGA image from bitstream file /usr/lib/deckard-fpga/images/csi_agg_deckard_ev1_1_vcint.binx",
" FAILED: FPGA config_done signal did not assert",
L("11:30:01", "Failed to load VCINT FPGA image when passthrough cameras are connected", "ERROR"),
L("11:30:01", "Upper cameras FPGA interleaving support: 0 (Driver features available = 1 | VCINT loaded = 0)"),
L("11:30:01", "[buildMediaCtlSetupTasks] Created 4 tasks (2 tracking, 2 passthrough)"),
L("11:30:01", "TrackingCameraInit: index: 0. video device: /dev/video9. v4l subdevice: /dev/v4l-subdev31"),
L("11:30:01", "TrackingCameraInit: index: 1. video device: /dev/video13. v4l subdevice: /dev/v4l-subdev30"),
L("11:30:02", "[DeckardCaptureSource] Streaming resumed (FPGA: PASSTHRU, VC interleaving: disabled)")]
# A wake that works prints no "Created N tasks" (2026-10-01 16:39), so an older one must not count.
RESUME_OK = [L("12:30:00", "[SystemdInhibitor] Received systemd resume notification"),
L("12:30:00", "Passthrough connected but FPGA is ERROR/UNKNOWN - loading VCINT"),
L("12:30:01", "FPGA image VCINT loaded and verified successfully"),
L("12:30:01", "FPGA state check: VCINT (register value: 0x00021211)"),
L("12:30:01", "Upper cameras FPGA interleaving support: 1 (Driver features available = 1 | VCINT loaded = 1)")] + \
[L("12:30:01", "TrackingCameraInit: index: %d. video device: /dev/video%d. v4l subdevice: x" % (i, n))
for i, n in enumerate((9, 13, 6, 7))] + \
[L("12:30:02", "[DeckardCaptureSource] Streaming resumed (FPGA: VCINT, VC interleaving: enabled)")]
EXIT = [L("13:00:00", "XRService - main thread exiting"), L("13:00:00", "Exiting XRService")]
def state(lines):
return camcheck.LogState("test").feed_text("\n".join(lines))
class SyntheticLogs(unittest.TestCase):
def test_good_start(self):
st = state(START + GOOD_OPEN)
self.assertEqual(st.verdict()[:2], ("ok", "4 tracking cameras running"))
self.assertEqual(st.episode["vcint"], "ok")
self.assertEqual(st.upper_nodes(), (6, 7))
def test_good_start_tasks_only(self):
# "Created 6 tasks (4 tracking ...)" and no camera init yet: 4 tracking cameras planned
self.assertEqual(state(START + GOOD_OPEN[:6]).verdict()[0], "ok")
def test_starting(self):
self.assertEqual(state(START + GOOD_OPEN[:3]).verdict()[:2], ("unknown", "the cameras are starting"))
def test_vcint_already_loaded(self):
# A SteamVR restart in the same boot (2026-10-01 15:27): the FPGA still has VCINT.
lines = START + [L("10:00:01", "FPGA state check: VCINT (register value: 0x00021211)"),
L("10:00:01", "Upper cameras FPGA interleaving support: 1 (Driver features available = 1 | VCINT loaded = 1)"),
L("10:00:01", "[buildMediaCtlSetupTasks] Created 6 tasks (4 tracking, 2 passthrough)")]
st = state(lines)
self.assertEqual(st.verdict()[0], "ok")
self.assertEqual(st.episode["vcint"], "loaded")
def test_asleep(self):
status, reason, ev = state(START + GOOD_OPEN + CLOSE).verdict()
self.assertEqual(status, "unknown")
self.assertIn("closed", reason)
self.assertTrue(any("Closing tracking camera interfaces" in e for e in ev))
def test_vcint_failure(self):
st = state(START + GOOD_OPEN + CLOSE + RESUME + FAIL)
status, reason, ev = st.verdict()
self.assertEqual((status, reason), ("degraded", camcheck.VCINT_REASON))
self.assertTrue(any("Created 4 tasks (2 tracking, 2 passthrough)" in e for e in ev))
self.assertTrue(any("Closing tracking camera interfaces" in e for e in ev))
self.assertFalse(any("\x1b" in e for e in ev))
self.assertEqual(st.snapshot()["failure"], "test@11:30:01")
self.assertEqual(len(st.failures), 1)
def test_failure_seen_before_the_cameras_start(self):
# the failure line alone (camera init not logged yet): degraded already
st = state(START + GOOD_OPEN + CLOSE + RESUME + FAIL[:4])
self.assertEqual(st.verdict()[0], "degraded")
def test_wake_after_failure_works(self):
st = state(START + GOOD_OPEN + CLOSE + RESUME + FAIL + CLOSE + RESUME_OK)
self.assertEqual(st.verdict()[0], "ok")
self.assertEqual(st.snapshot()["failure"], "")
self.assertEqual(len(st.failures), 1) # still remembered for the log's history
def test_fewer_cameras_without_vcint(self):
lines = START + [L("10:00:01", "[buildMediaCtlSetupTasks] Created 4 tasks (2 tracking, 2 passthrough)")]
status, reason, _ = state(lines).verdict()
self.assertEqual(status, "degraded")
self.assertEqual(reason, "only 2 of 4 tracking cameras running")
def test_exited_and_empty(self):
self.assertEqual(state(START + GOOD_OPEN + EXIT).verdict()[0], "unknown")
self.assertEqual(state([]).verdict()[0], "unknown")
self.assertEqual(state(START).verdict()[:2], ("unknown", "the cameras haven't started yet in this log"))
def test_new_instance_resets(self):
st = state(START + GOOD_OPEN + CLOSE + RESUME + FAIL + START + GOOD_OPEN)
self.assertEqual(st.verdict()[0], "ok")
self.assertEqual(st.failures, [])
def test_incremental_equals_whole(self):
text = "\n".join(START + GOOD_OPEN + CLOSE + RESUME + FAIL)
st = camcheck.LogState("test")
for line in text.split("\n"):
st.feed(line + "\n")
self.assertEqual(st.snapshot(), state(START + GOOD_OPEN + CLOSE + RESUME + FAIL).snapshot())
@unittest.skipUnless(os.path.exists(REAL_LOG), "the 2026-10-02 XRService log isn't on this machine")
class RealLog(unittest.TestCase):
"""The log with both the good 13:39 start and the 17:02 failure, cut in copies in /tmp."""
@classmethod
def setUpClass(cls):
with open(REAL_LOG, errors="replace") as f:
cls.lines = f.read().split("\n")
cls.tmp = tempfile.mkdtemp(prefix="camcheck-test-")
@classmethod
def tearDownClass(cls):
shutil.rmtree(cls.tmp, ignore_errors=True)
def cut_after(self, needle, nth=1):
"""The log up to and including the nth line containing needle, as a file in /tmp."""
seen = 0
for i, line in enumerate(self.lines):
if needle in line:
seen += 1
if seen == nth:
path = os.path.join(self.tmp, "cut-%d.log" % i)
with open(path, "w") as f:
f.write("\n".join(self.lines[:i + 1]) + "\n")
return path
self.fail("no line %d with %r" % (nth, needle))
def check(self, path):
return camcheck.check(log=path, proc=False, ring=False)
def test_cut_points(self):
cases = [
("Loading FPGA image: VCINT", 1, "unknown", "the cameras are starting"),
("FPGA image VCINT loaded and verified successfully", 1, "unknown", "the cameras are starting"),
("Created 6 tasks (4 tracking, 2 passthrough)", 1, "ok", None),
("TrackingCameraInit: index: 3.", 1, "ok", None),
("Upper cameras are not in sync", 1, "ok", None), # 15:45: a resync, not a failure
("Closing tracking camera interfaces", 1, "unknown", None), # 16:41: asleep
("Passthrough connected but FPGA is ERROR/UNKNOWN", 1, "unknown", "the cameras are starting"),
("Failed to load VCINT FPGA image", 1, "degraded", camcheck.VCINT_REASON),
("Created 4 tasks (2 tracking, 2 passthrough)", 1, "degraded", camcheck.VCINT_REASON),
("Streaming resumed (FPGA: PASSTHRU", 1, "degraded", camcheck.VCINT_REASON),
]
for needle, nth, status, reason in cases:
with self.subTest(cut=needle):
r = self.check(self.cut_after(needle, nth))
self.assertEqual(r["status"], status, r["summary"])
if reason:
self.assertEqual(r["reason"], reason)
r = self.check(self.cut_after("Closing tracking camera interfaces"))
self.assertIn("closed", r["reason"])
def test_whole_log(self):
r = self.check(REAL_LOG)
self.assertEqual(r["summary"], camcheck.DEGRADED_VCINT)
ev = "\n".join(r["evidence"])
for want in ("17:02:47 Failed to load VCINT FPGA image", "interleaving support: 0",
"Created 4 tasks (2 tracking, 2 passthrough)", "/dev/video9", "/dev/video13",
"Closing tracking camera interfaces"):
self.assertIn(want, ev)
self.assertNotIn("/dev/video6", ev) # the 13:39 start isn't this episode's evidence
self.assertTrue(r["failure"].endswith("@17:02:47"))
self.assertTrue(camcheck.is_vcint_failure(r))
def test_cli(self):
out = io.StringIO()
with redirect_stdout(out):
code = camcheck.main(["--log", REAL_LOG, "--no-proc", "--no-ring", "--json"])
self.assertEqual(code, 1)
self.assertEqual(json.loads(out.getvalue())["status"], "degraded")
out = io.StringIO()
with redirect_stdout(out):
code = camcheck.main(["--log", self.cut_after("TrackingCameraInit: index: 3."), "--no-proc", "--no-ring"])
self.assertEqual(code, 0)
self.assertEqual(out.getvalue().split("\n")[0], "ok")
def make_ring(path, mono_names, alive=True):
"""A ring header as ft-camd writes it (camd/fhring.h), no frames."""
cams = [(b"og01a1b", n.encode(), 9 + i) for i, n in enumerate(mono_names)]
hb = time.clock_gettime_ns(time.CLOCK_MONOTONIC) if alive else 1
data = bytearray(camcheck.RING_HDR.pack(b"FHRING01", 1, 0, len(cams), 0, 0, 4242, 0))
struct.pack_into("<Q", data, 40, hb)
for sensor, name, node in cams:
data += camcheck.RING_CAM.pack(sensor, name, node, 0, 1056, 1024, 1056, 16, 0, 0, 0, 0, 0, 0, 0.0)
with open(path, "wb") as f:
f.write(bytes(data))
class Ring(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="camcheck-ring-")
self.ring = os.path.join(self.tmp, "cam-ring")
self.log = os.path.join(self.tmp, "x.log")
with open(self.log, "w") as f:
f.write("\n".join(START + GOOD_OPEN) + "\n")
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def test_two_cameras_in_ring(self):
make_ring(self.ring, ["slam_video9", "slam_video13"])
r = camcheck.check(log=self.log, proc=False, ring_path=self.ring)
self.assertEqual(r["status"], "degraded")
self.assertIn("ft-camd publishes only 2 of 4", r["reason"])
def test_four_cameras_in_ring(self):
make_ring(self.ring, ["a_video9", "b_video13", "c_video6", "d_video7", "a_video9_dk"])
r = camcheck.check(log=self.log, proc=False, ring_path=self.ring)
self.assertEqual(r["status"], "ok")
self.assertEqual(len(r["ring"]["mono"]), 4) # the dark twin doesn't count
def test_stale_ring_ignored(self):
make_ring(self.ring, ["slam_video9"], alive=False)
r = camcheck.check(log=self.log, proc=False, ring_path=self.ring)
self.assertEqual(r["status"], "ok")
self.assertIn("stale ring", "\n".join(r["evidence"]))
def test_no_ring(self):
r = camcheck.check(log=self.log, proc=False, ring_path=os.path.join(self.tmp, "none"))
self.assertEqual(r["status"], "ok")
self.assertIsNone(r["ring"])
if __name__ == "__main__":
unittest.main()