Files
saphidandClaude Sonnet 5.5 4937caf310 fix(tracking): don't call an unworn eye-camera artifact a pulse
Unworn Frame runs gave a steady 'clear' 90-96 BPM. pulse now reads the
proximity sensor and refuses to report when the headset is not worn.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 20:20:23 +10:00

439 lines
18 KiB
Python

#!/usr/bin/env python3
"""Frame-local tracking tools. No network destination or data log by default."""
import argparse
import math
import os
from pathlib import Path
import signal
import socket
import struct
import subprocess
import time
class TrackingError(RuntimeError):
"""A safe, actionable status message containing no sensor data."""
HRS = "0000180d-0000-1000-8000-00805f9b34fb"
MEASUREMENT = "00002a37-0000-1000-8000-00805f9b34fb"
DEVICE = "org.bluez.Device1"
SERVICE = "org.bluez.GattService1"
CHARACTERISTIC = "org.bluez.GattCharacteristic1"
def heart_rate(data):
"""Validate the Bluetooth HRS measurement, returning BPM/contact only.
Energy and RR intervals are checked for length but never retained.
None means contact is supported and the strap reports no skin contact.
"""
data = bytes(data)
if len(data) < 2 or data[0] & 0xe0:
raise ValueError("invalid HRS measurement")
flags = data[0]
size = 2 if flags & 1 else 1
end = 1 + size + (2 if flags & 8 else 0)
if len(data) < end:
raise ValueError("truncated HRS measurement")
extra = len(data) - end
if (flags & 16 and (extra < 2 or extra % 2)) or (not flags & 16 and extra):
raise ValueError("invalid HRS optional fields")
if flags & 4 and not flags & 2:
return None
bpm = int.from_bytes(data[1:1 + size], "little")
return bpm if bpm else None
def gaze_angles(quaternion):
"""OpenXR head-relative -Z forward → VRChat degrees, down/right positive."""
if len(quaternion) != 4 or not all(math.isfinite(v) for v in quaternion):
raise ValueError("invalid gaze orientation")
norm = math.sqrt(sum(v * v for v in quaternion))
if not 0.9 < norm < 1.1:
raise ValueError("invalid gaze orientation")
x, y, z, w = (v / norm for v in quaternion)
# Rotate OpenXR's forward vector (0, 0, -1) into VIEW space.
dx, dy, dz = -2 * (x*z + w*y), 2 * (w*x - y*z), 2 * (x*x + y*y) - 1
return math.degrees(math.atan2(-dy, math.hypot(dx, dz))), math.degrees(math.atan2(dx, -dz))
def osc_message(address, values):
if not address.startswith("/") or any(c.isspace() or c in '\0#*,?[]{}' for c in address):
raise ValueError("OSC address must be a literal path")
def string(value):
encoded = value.encode("utf-8") + b"\0"
return encoded + b"\0" * (-len(encoded) % 4)
tags, payload = ",", b""
for value in values:
if type(value) is int:
tags += "i"
payload += struct.pack(">i", value)
else:
if not math.isfinite(value):
raise ValueError("OSC value must be finite")
tags += "f"
payload += struct.pack(">f", value)
return string(address) + string(tags) + payload
class Osc:
def __init__(self, endpoint=None):
self.sock = None
self.target = None
if endpoint:
import ipaddress
address = ipaddress.ip_address(endpoint[0])
port = int(endpoint[1])
if address.is_unspecified or address.is_multicast or not 1 <= port <= 65535:
raise ValueError("OSC needs a unicast IP address and port 1..65535")
self.sock = socket.socket(socket.AF_INET6 if address.version == 6 else socket.AF_INET, socket.SOCK_DGRAM)
self.target = (str(address), port)
def send(self, address, values):
if self.sock:
self.sock.sendto(osc_message(address, values), self.target)
def close(self):
if self.sock:
self.sock.close()
class HeartSession:
def __init__(self, osc, address, log=None, clock=time.monotonic):
self.osc, self.address, self.clock = osc, address, clock
self.bpm, self.updated = None, None
self.log = None
if log:
# Exclusive creation refuses existing files and symlinks; mode is
# private even with a permissive process umask.
fd = os.open(log, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
self.log = os.fdopen(fd, "w")
self.log.write("unix_seconds,bpm\n")
def notification(self, data):
self.bpm = heart_rate(data)
self.updated = self.clock()
if self.bpm is not None:
self.osc.send(self.address, [self.bpm])
if self.log:
self.log.write(f"{time.time():.3f},{self.bpm}\n")
self.log.flush()
def current(self):
if self.updated is None or self.clock() - self.updated > 5:
return None
return self.bpm
def close(self):
if self.log:
self.log.close()
class BluezHeart:
"""One explicitly selected, already discovered strap; no ambient scan."""
def __init__(self, bus, interface, address, on_value):
self.bus, self.interface, self.on_value = bus, interface, on_value
self.device = self.characteristic = None
self.connected_here = False
self.notifying = False
self.match = None
objects = self.objects()
matches = [path for path, interfaces in objects.items()
if str(interfaces.get(DEVICE, {}).get("Address", "")).upper() == address.upper()]
if len(matches) != 1:
raise TrackingError("Strap not found uniquely in BlueZ; pair/discover it in SteamOS Bluetooth settings first")
self.device = matches[0]
self.match = bus.add_signal_receiver(self.changed, signal_name="PropertiesChanged",
dbus_interface="org.freedesktop.DBus.Properties",
bus_name="org.bluez", path_keyword="path")
try:
if not objects[self.device][DEVICE].get("Connected"):
self.call(self.device, DEVICE).Connect(timeout=20)
self.connected_here = True
except Exception:
self.close()
raise
def objects(self):
return self.call("/", "org.freedesktop.DBus.ObjectManager").GetManagedObjects()
def call(self, path, kind):
return self.interface(self.bus.get_object("org.bluez", path), kind)
def subscribe(self):
objects = self.objects()
if not objects.get(self.device, {}).get(DEVICE, {}).get("ServicesResolved"):
return False
services = {p for p, obj in objects.items() if str(obj.get(SERVICE, {}).get("UUID", "")).lower() == HRS
and obj[SERVICE].get("Device") == self.device}
for path, obj in objects.items():
props = obj.get(CHARACTERISTIC, {})
if props.get("Service") in services and str(props.get("UUID", "")).lower() == MEASUREMENT:
if "notify" not in props.get("Flags", []):
raise TrackingError("Heart-rate characteristic does not support notifications")
self.characteristic = path
self.call(path, CHARACTERISTIC).StartNotify()
self.notifying = True
return True
raise TrackingError("Selected device has no standard Heart Rate Service measurement")
def changed(self, kind, changes, invalidated, path=None):
if kind == CHARACTERISTIC and path == self.characteristic and "Value" in changes:
self.on_value(changes["Value"])
elif kind == DEVICE and path == self.device and "Connected" in changes and not changes["Connected"]:
self.on_value(None)
def close(self):
try:
if self.notifying:
self.call(self.characteristic, CHARACTERISTIC).StopNotify()
finally:
if self.match:
self.match.remove()
if self.connected_here:
self.call(self.device, DEVICE).Disconnect()
def run_gaze(args, osc):
binary = Path(__file__).with_name("gaze")
command = [str(binary), "--seconds", str(args.seconds)]
if not args.osc:
return subprocess.call(command)
read_fd, write_fd = os.pipe()
process = None
try:
process = subprocess.Popen(command + ["--fd", str(write_fd)], pass_fds=(write_fd,))
os.close(write_fd)
write_fd = None
with os.fdopen(read_fd) as source:
read_fd = None
for line in source:
try:
angles = gaze_angles([float(v) for v in line.split()])
except ValueError:
continue
osc.send("/tracking/eye/CenterPitchYaw", angles)
return process.wait()
finally:
if read_fd is not None:
os.close(read_fd)
if write_fd is not None:
os.close(write_fd)
if process and process.poll() is None:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
class HeartPanel:
"""Our GTK panel, using the Frame's existing GTK4/GI platform libraries."""
def __init__(self):
os.environ["GDK_BACKEND"] = "x11"
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("GdkX11", "4.0")
from gi.repository import Gtk, Gdk, GdkX11, GLib
Gtk.init()
self.running = True
self.window = Gtk.Window(title="Frame Control · Heart rate")
self.window.set_default_size(480, 320)
self.window.connect("close-request", self.stop)
Gtk.Settings.get_default().set_property("gtk-application-prefer-dark-theme", True)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
box.set_valign(Gtk.Align.CENTER)
box.set_halign(Gtk.Align.CENTER)
box.set_size_request(440, -1)
for side in ("top", "bottom", "start", "end"):
getattr(box, "set_margin_" + side)(24)
self.window.set_child(box)
title = Gtk.Label(label="Heart rate")
title.add_css_class("title-2")
box.append(title)
self.reading = Gtk.Label(label="—")
self.reading.add_css_class("reading")
box.append(self.reading)
self.status = Gtk.Label(label="Waiting for strap")
box.append(self.status)
button = Gtk.Button(label="Stop")
button.connect("clicked", self.stop)
box.append(button)
css = Gtk.CssProvider()
css.load_from_data(b".reading { font-size: 144px; font-weight: 700; }")
Gtk.StyleContext.add_provider_for_display(Gdk.Display.get_default(), css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
self.window.present()
context = GLib.MainContext.default()
while context.pending():
context.iteration(False)
try:
xid = GdkX11.X11Surface.get_xid(self.window.get_surface())
subprocess.run(["xprop", "-id", str(xid), "-f", "STEAM_GAME", "32c", "-set", "STEAM_GAME", "2000000027"],
check=True, stdout=subprocess.DEVNULL)
except Exception:
self.window.destroy()
raise
def stop(self, *args):
self.running = False
return True
def update(self, bpm):
self.reading.set_label(str(bpm) if bpm is not None else "—")
self.status.set_label("beats per minute" if bpm is not None else "Waiting for strap")
def close(self):
self.window.destroy()
def run_heart(args, osc):
import dbus
from dbus.mainloop.glib import DBusGMainLoop
from gi.repository import GLib
DBusGMainLoop(set_as_default=True)
session = HeartSession(osc, args.address, args.log)
reader, root = None, None
failure = []
def value(data):
if data is None:
session.bpm = None
failure.append("Strap disconnected; reconnect and start again")
return
try:
session.notification(data)
except ValueError:
session.bpm = None
except OSError:
failure.append("OSC or session log write failed")
try:
reader = BluezHeart(dbus.SystemBus(), dbus.Interface, args.device, value)
context = GLib.MainContext.default()
deadline = time.monotonic() + 20
while not reader.subscribe():
if time.monotonic() > deadline:
raise TrackingError("Timed out waiting for the strap's GATT services")
while context.pending():
context.iteration(False)
time.sleep(0.1)
end = time.monotonic() + args.seconds
print("Heart-rate notifications started; readings stay local unless OSC or a log was selected.")
if args.panel:
root = HeartPanel()
running = lambda: root.running if root else True
while running() and time.monotonic() < end and not failure:
while context.pending():
context.iteration(False)
if root:
root.update(session.current())
time.sleep(0.05)
if failure:
raise TrackingError(failure[0])
return 0
finally:
if root:
root.close()
try:
if reader:
reader.close()
finally:
session.close()
def run_pulse(args, osc):
"""Experimental: estimate pulse from the IR eye cameras (see pulse.py)."""
import pulse
if not pulse.ET_BIN.exists():
raise TrackingError("SteamVR's eye-tracking tool is not installed on this Frame")
print(f"Capturing {args.seconds} s from the eye cameras. Wear the headset and keep still.", flush=True)
readings = [pulse.proximity()]
try:
frames = pulse.Capture(args.seconds).run()
readings.append(pulse.proximity())
except RuntimeError as error: # capture status only; no image data
raise TrackingError(str(error))
print(f"Captured {len(frames)} eye frames; images already deleted. Analysing...", flush=True)
try:
result = pulse.analyse(frames)
except ValueError as error:
raise TrackingError(str(error))
on_face = all(pulse.worn(reading) for reading in readings)
if not on_face:
# Unworn, the cameras still show a steady periodic artifact that looks
# like a pulse (verified on the Frame), so it is never called clear.
print("The proximity sensor says the headset is not being worn; no pulse can be read.")
clear = on_face and pulse.reliable(result)
offset = time.time() - time.monotonic() # the capture's timestamps are CLOCK_MONOTONIC
if args.log:
fd = os.open(args.log, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w") as log:
log.write("unix_seconds,bpm\n")
for when, bpm in result["series"]:
log.write(f"{when + offset:.3f},{bpm:.1f}\n")
if clear:
osc.send(args.address, [round(result["bpm"])])
if args.show:
print(f"Estimate: {result['bpm']:.1f} BPM ({'clear' if clear else 'NOT clear'}; "
f"patch agreement {result['agreement']:.0%}, signal/noise {result['snr']:.2f}, "
f"{result['patches']} of {result['usable']} usable patches)")
print("Per-second estimates (15 s windows): "
+ " ".join(str(round(bpm)) for _, bpm in result["series"]))
else:
print("A clear pulse was found." if clear else "No clear pulse was found.")
return 0 if clear else 3
def main():
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
gaze = commands.add_parser("gaze", help="headless OpenXR; prints counters only without --osc")
heart = commands.add_parser("heart", help="standard BLE HRS from an explicitly selected strap")
pulse = commands.add_parser("pulse", help="experimental pulse estimate from the IR eye cameras")
for command in (gaze, heart, pulse):
command.add_argument("--osc", nargs=2, metavar=("IP", "PORT"), help="explicit UDP destination; no default")
for command in (gaze, heart):
command.add_argument("--seconds", type=int, default=10, help="bounded run, 1..86400 seconds (default: 10)")
pulse.add_argument("--seconds", type=int, default=60, help="capture length, 20..300 seconds (default: 60)")
heart.add_argument("--device", required=True, help="strap Bluetooth address already discovered by BlueZ")
heart.add_argument("--panel", action="store_true", help="show our panel on gamescope DISPLAY=:0")
for command in (heart, pulse):
command.add_argument("--address", default="/avatar/parameters/HeartRate", help="integer BPM OSC parameter")
command.add_argument("--log", help="new private CSV file; disabled by default")
pulse.add_argument("--show", action="store_true", help="print the estimate and per-second series")
args = parser.parse_args()
if not 1 <= args.seconds <= 86400:
parser.error("--seconds must be 1..86400")
if args.command == "pulse" and not 20 <= args.seconds <= 300:
parser.error("pulse --seconds must be 20..300")
def interrupted(signum, frame):
raise KeyboardInterrupt
signal.signal(signal.SIGTERM, interrupted)
if hasattr(signal, "SIGHUP"):
signal.signal(signal.SIGHUP, interrupted)
osc = None
try:
if args.command in ("heart", "pulse"):
osc_message(args.address, [0])
if getattr(args, "panel", False):
os.environ["DISPLAY"] = ":0"
osc = Osc(args.osc)
run = {"gaze": run_gaze, "heart": run_heart, "pulse": run_pulse}[args.command]
return run(args, osc)
except TrackingError as error:
print(str(error))
return 1
except KeyboardInterrupt:
return 130
except Exception as error:
# Never dump notifications, gaze, BLE addresses or exception payloads.
print(f"Tracking stopped ({type(error).__name__}). Check the device, runtime and selected output.")
return 1
finally:
if osc:
osc.close()
if __name__ == "__main__":
raise SystemExit(main())