Files
Frametop--frametop/scripts/report-watch.py
T
DeeJanuzandClaude Opus 5.5 3e6dba8276 Report: an area per flag, and --watch records each area live
scripts/report.sh takes --system, --desktop, --gaze (--eyes), --hands and --voice; with
none it reports on all of them, as before. Every report keeps the versions and the
settings. New in it: CPU, memory, heat and the busiest processes (--system), and Frametop
Voice (--voice): its services, state, settings (counts, not phrases or commands), speech
models, and the microphone: proxmicmute's gain, PipeWire's capture chain with its errors,
who holds the capture device, and its level over one second (no sound is kept).

--watch SECONDS now runs scripts/report-watch.py for the chosen areas, one thread each:
ft-screens' debug line (desktop), the gaze service's status (gaze), ft-hands' published
frames read under their sequence lock each second (rate, age, each hand's share and
confidence, closest thumb-index and thumb-middle tips) and the side cameras' decision
(hands), Frametop Voice's events with what was said replaced by word counts and the mic's
level each second with its gain when it goes silent (voice), and load, clocks, heat,
memory and the busiest processes every 5 s (system). Then each area's logs from that time.
README's Reporting problems says how.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:16:16 -06:00

329 lines
13 KiB
Python
Executable File

#!/usr/bin/env python3
"""report-watch: what scripts/report.sh --watch records while the problem happens. On the Frame:
python3 scripts/report-watch.py SECONDS AREA[,AREA...]
Each line: the time, the area, and what it saw. Areas (any of them, each in a thread of its own):
system every 5 s: the load, the CPU clocks, the hottest thermal zone, free memory, and the
processes that used the most CPU in those 5 s
desktop ft-screens' debug line (the Steam menu, our keyboard, which laser drags what, where
typing goes) whenever it changes
gaze the gaze service's status (which tracker, awake, samples coming in, the headset worn)
whenever it changes
hands each second ft-hands publishes or stops: its frames per second, their latency, how often
each hand was in them and how sure, the closest thumb-index and thumb-middle tips; and
sides.json whenever the side cameras' decision changes
voice Frametop Voice's events, with what was said left out (its word count stays), and the
microphone's level each second (peak and RMS, dBFS; no sound is kept)
Read-only: it asks the services over their sockets and reads their published files.
"""
import json
import math
import mmap
import os
import socket
import struct
import subprocess
import sys
import threading
import time
from array import array
from pathlib import Path
RUN = Path(f"/run/user/{os.getuid()}")
lock = threading.Lock()
def say(area, text):
now = time.time()
with lock:
print(time.strftime("%H:%M:%S", time.localtime(now)) + f".{int(now * 100) % 100:02d}", f"[{area}]", text, flush=True)
def ask(name, msg, timeout=1.0):
"""A request to one of Frametop's abstract datagram sockets; the reply, or None."""
s = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
s.bind("")
s.settimeout(timeout)
try:
s.sendto(msg.encode(), "\0" + name)
return s.recv(65536).decode()
except OSError:
return None
finally:
s.close()
def read(path):
try:
return Path(path).read_text().strip()
except OSError:
return ""
# ---- system
def cpu_times():
"""pid -> (name, utime + stime in ticks)."""
out = {}
for d in os.listdir("/proc"):
if not d.isdigit():
continue
try:
s = open(f"/proc/{d}/stat").read()
except OSError:
continue
name, rest = s[s.index("(") + 1:s.rindex(")")], s[s.rindex(")") + 2:].split()
out[int(d)] = (name, int(rest[11]) + int(rest[12]))
return out
def system(end):
tick = os.sysconf("SC_CLK_TCK")
zones = sorted(Path("/sys/class/thermal").glob("thermal_zone*"))
cpus = sorted(Path("/sys/devices/system/cpu").glob("cpu[0-9]*/cpufreq/scaling_cur_freq"),
key=lambda p: int(p.parent.parent.name[3:]))
before, t0 = cpu_times(), time.monotonic()
while time.monotonic() < end:
time.sleep(min(5, max(0.1, end - time.monotonic())))
now, t1 = cpu_times(), time.monotonic()
used = sorted(((v[1] - before[p][1]) / tick / (t1 - t0) * 100, v[0]) for p, v in now.items()
if p in before and before[p][0] == v[0])
top = ", ".join(f"{name} {pct:.0f}%" for pct, name in reversed(used[-5:]) if pct >= 1)
before, t0 = now, t1
temps = []
for z in zones:
t = read(z / "temp")
if t.lstrip("-").isdigit():
temps.append((int(t) / 1000, read(z / "type")))
hot = max(temps) if temps else (float("nan"), "?")
mhz = " ".join(str(int(read(c) or 0) // 1000) for c in cpus)
mem = {l.split(":")[0]: int(l.split()[1]) for l in open("/proc/meminfo") if l.split(":")[0] in ("MemAvailable", "MemTotal")}
say("system", f"load {read('/proc/loadavg').split(' ')[0]} CPU MHz {mhz} hottest {hot[0]:.1f} C ({hot[1]}) "
f"memory free {mem.get('MemAvailable', 0) // 1024} of {mem.get('MemTotal', 0) // 1024} MB top: {top or '-'}")
# ---- desktop
def desktop(end):
last = None
while time.monotonic() < end:
line = ask("ft_screens", "debug") or "no answer from ft-screens"
if line != last:
say("desktop", line.strip())
last = line
time.sleep(0.2)
# ---- gaze
GAZE_KEYS = ("tracker", "source", "awake", "idle", "ft_gaze", "own_running", "eyes_process", "headset_on",
"eye_misses", "lessons", "calibration_samples")
def gaze(end):
last = None
while time.monotonic() < end:
r = ask("ft_gazed", "status", 0.5)
try:
st = json.loads(r) if r else None
except ValueError:
st = None
if st is None:
text = "no answer from the gaze service (ft-gazed)"
else:
age = st.get("sample_age_s")
samples = "none" if age is None else "coming in" if age < 0.5 else f"stopped ({age:.0f} s ago)" if age < 600 else "stopped"
text = f"samples {samples} " + " ".join(f"{k} {json.dumps(st[k])}" for k in GAZE_KEYS if k in st)
if text != last:
say("gaze", text)
last = text
time.sleep(1)
# ---- hands (include/fh_hands.h)
HEAD = struct.Struct("<8sIIQQQII16x") # magic, version, size, seq, capture_ns, publish_ns, nhands, ncapsules
HAND = struct.Struct("<IIff252xI") # id, flags, confidence, reserved, pts[21][3], ncapsules: 272 bytes
V1_SIZE = HEAD.size + 2 * HAND.size + 64 * 32 # fh_hands_t
FIT_PINCH = 912 # fh_hand_fit_t.pinch_d (thumb-index, thumb-middle), then fit_rms
def hands(end):
path = RUN / "frametop-hands/hands"
sides_last, quiet = None, None
next_report = time.monotonic() + 1
frames, seen, conf, lat, tips = 0, {0: 0, 1: 0}, {0: [], 1: []}, [], {0: [], 1: []}
last_seq = None
m, ino = None, None
while time.monotonic() < end:
# under its sequence lock: seq, a copy, seq again; the copy counts if both are the same even number
data, again = b"", None
try:
st = path.stat() # a restarted ft-hands writes a new file
if m is None or (st.st_ino, st.st_size) != ino:
with open(path, "rb") as f:
m, ino = mmap.mmap(f.fileno(), 0, prot=mmap.PROT_READ), (st.st_ino, st.st_size)
first = struct.unpack_from("<Q", m, 16)[0]
data = bytes(m)
again = struct.unpack_from("<Q", m, 16)[0]
except (OSError, ValueError):
m = None
if len(data) >= V1_SIZE:
magic, version, _, seq, cap, pub, n, _ = HEAD.unpack_from(data)
if magic == b"FHHANDS1" and seq == first == again and seq % 2 == 0 and seq != last_seq and pub:
new, last_seq = last_seq is not None, seq
else:
new = False
if new: # not the frame already there when the watch began
frames += 1
lat.append((time.monotonic_ns() - cap) / 1e6)
for k in range(min(n, 2)):
_, flags, c, _, _ = HAND.unpack_from(data, HEAD.size + k * HAND.size)
side = flags & 1
seen[side] += 1
conf[side].append(c)
off = V1_SIZE + k * 1024
if version >= 2 and len(data) >= off + 1024 and struct.unpack_from("<I", data, off + 4)[0] & 1:
tips[side].append(struct.unpack_from("<fff", data, off + FIT_PINCH))
if time.monotonic() >= next_report:
next_report += 1
if frames:
lat.sort()
def side(s, name):
if not seen[s]:
return f"{name} -"
t = tips[s]
fit = (f" closest thumb-index {1000 * min(x[0] for x in t):.0f} mm, thumb-middle "
f"{1000 * min(x[1] for x in t):.0f} mm, fit {1000 * sum(x[2] for x in t) / len(t):.1f} mm") if t else ""
return f"{name} {100 * seen[s] // frames}% conf {sum(conf[s]) / len(conf[s]):.2f}{fit}"
say("hands", f"{frames} frames/s age at read {lat[len(lat) // 2]:.0f} ms (median) "
f"{side(0, 'left')} | {side(1, 'right')}")
quiet = False
elif quiet is not True:
say("hands", "no new frames from ft-hands" + ("" if path.exists() else f" ({path} missing)"))
quiet = True
frames, seen, conf, lat, tips = 0, {0: 0, 1: 0}, {0: [], 1: []}, [], {0: [], 1: []}
sides = read(RUN / "frametop-hands/sides.json")
try:
s = json.loads(sides)
sides = f"side cameras {s.get('state')}, swapped {s.get('swapped')} (by {s.get('decided_by')})"
except ValueError:
sides = sides or "no sides.json"
if sides != sides_last:
say("hands", sides)
sides_last = sides
time.sleep(1 / 120)
# ---- voice
SAID = ("text", "matched", "ran") # what was said, and the command it ran (its words)
def voice_events(end):
path = RUN / "frametop-voice/control.sock"
while time.monotonic() < end:
s = socket.socket(socket.AF_UNIX)
try:
s.connect(str(path))
except OSError as e:
say("voice", f"Frametop Voice isn't running ({e.strerror})")
s.close()
while time.monotonic() < end and not path.exists():
time.sleep(1)
continue
s.sendall(b"events\n")
s.settimeout(0.5)
buf = b""
while time.monotonic() < end:
try:
chunk = s.recv(4096)
except socket.timeout:
continue
except OSError:
chunk = b""
if not chunk:
say("voice", "Frametop Voice closed its events")
break
buf += chunk
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
try:
ev = json.loads(line)
except ValueError:
continue
ev.pop("time", None)
for k in SAID:
if isinstance(ev.get(k), str):
ev[k] = f"<{len(ev[k].split())} words>"
elif k in ev:
ev[k] = "<left out>"
say("voice", json.dumps(ev))
s.close()
time.sleep(1)
def gain():
"""The mic's ALSA gain, which SteamVR's proxmicmute sets to 0 while nobody wears the headset."""
vals = []
for numid in (23, 24): # VA_DEC0, VA_DEC1 on card 0
try:
out = subprocess.run(["amixer", "-c", "0", "cget", f"numid={numid}"], capture_output=True, text=True, timeout=3).stdout
except (OSError, subprocess.SubprocessError):
return "unknown"
vals += [l.split("=", 1)[1] for l in out.splitlines() if ": values=" in l]
if not vals:
return "unknown"
return " ".join(vals) + (" (muted by proxmicmute: the headset off)" if all(v == "0" for v in vals) else "")
def mic(end):
rate = 16000
try:
p = subprocess.Popen(["pw-record", "--raw", "--rate", str(rate), "--channels", "1", "--format", "s16", "-"],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
except OSError as e:
return say("voice", f"mic: can't run pw-record ({e})")
silent = None
try:
while time.monotonic() < end:
raw = p.stdout.read(rate * 2)
if not raw:
return say("voice", "mic: pw-record stopped")
a = array("h", raw[: len(raw) // 2 * 2])
peak = max(abs(x) for x in a) / 32768 if a else 0
rms = math.sqrt(sum(x * x for x in a) / len(a)) / 32768 if a else 0
if peak == 0: # digital silence: once, then how long it lasted
if silent is None:
say("voice", f"mic: digital silence (all zeros); its gain: {gain()}")
silent = time.monotonic()
continue
if silent is not None:
say("voice", f"mic: silent for {time.monotonic() - silent:.0f} s")
silent = None
say("voice", f"mic peak {20 * math.log10(peak):.0f} dBFS, RMS {20 * math.log10(max(rms, 1e-9)):.0f} dBFS")
if silent is not None:
say("voice", f"mic: silent all the {time.monotonic() - silent:.0f} s since")
finally:
p.kill()
def main():
if len(sys.argv) != 3 or not sys.argv[1].isdigit():
sys.exit(__doc__)
end = time.monotonic() + int(sys.argv[1])
jobs = {"system": [system], "desktop": [desktop], "gaze": [gaze], "hands": [hands], "voice": [voice_events, mic]}
areas = [a for a in sys.argv[2].split(",") if a]
if not areas or any(a not in jobs for a in areas):
sys.exit(f"areas: {', '.join(jobs)}")
threads = [threading.Thread(target=f, args=(end,), daemon=True) for a in areas for f in jobs[a]]
for t in threads:
t.start()
for t in threads:
t.join(max(0, end - time.monotonic()) + 3)
if __name__ == "__main__":
main()