mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-26 15:02:22 +01:00
Require proximity telemetry before worn Framey input
This commit is contained in:
1 parent
f5213b7c44
commit
385ae8290b
4 files changed
+76
-10
No files matched your search
@@ -0,0 +1,20 @@
|
||||
# Interactive Framey worn-check proximity gate, 26 October 2026
|
||||
|
||||
The interactive `scripts/framey-worn-check.py` already required an explicit
|
||||
operator wear confirmation and a unique `/tmp/frame-test.lock` owner before
|
||||
reading SteamVR activity. It did not read proximity. This change makes its
|
||||
locked preflight read the raw proximity sensor and stop before its interaction
|
||||
window when the reading is missing or invalid. It checks activity, proximity
|
||||
and battery again before input. The same owned-lock cleanup applies on failure.
|
||||
|
||||
The focused local gate suite passes 9/9 tests, including missing and nonfinite
|
||||
proximity, proximity disappearing between reads, metrics preceding the SteamVR status row,
|
||||
standby, low battery, no operator confirmation and a foreign lock. No headset
|
||||
input was sent for this change. A separate owner-locked read-only Frame check
|
||||
found SteamVR activity 3 (standby), proximity raw 3.0625 and battery 93%; it
|
||||
released its lock without a worn test.
|
||||
|
||||
The raw proximity value is not calibrated as proof of wear. The operator's
|
||||
in-person confirmation remains required; awake activity, proximity and trusted
|
||||
synthetic input alone do not prove wearer-visible pixels or physical controller
|
||||
usability. The hourly worn-check schedule remains enabled.
|
||||
@@ -112,8 +112,10 @@ the same-plugin navigation regression without a headset.
|
||||
interaction window, use the physical controller to open the sample and
|
||||
toggle **Example setting**. Confirm the laser aims where expected and the
|
||||
script records a fresh input/panel event and visible stereo output. The flag
|
||||
is the operator’s statement; sensor activity and trusted pointers can also
|
||||
be produced synthetically and cannot identify controller hardware.
|
||||
is the operator’s statement. The script requires valid locked SteamVR activity
|
||||
and proximity readings before input; proximity alone cannot establish wear.
|
||||
Sensor activity and trusted pointers can also be produced synthetically and
|
||||
cannot identify controller hardware.
|
||||
3. **1:15–2:15:** Use the controller to open Settings, return to Panel, invoke
|
||||
**Read saved setting**, close/reopen the plugin, and confirm the value
|
||||
remains. Try controller scroll, a slider and a harmless text field in an
|
||||
|
||||
@@ -36,13 +36,19 @@ def remote(command, stdin=None, timeout=15):
|
||||
|
||||
|
||||
PROBE = r'''
|
||||
import json, pathlib, subprocess
|
||||
import json, math, pathlib, subprocess
|
||||
power=[]
|
||||
for item in pathlib.Path('/sys/class/power_supply').glob('*/capacity'):
|
||||
try:
|
||||
if (item.parent/'type').read_text().strip() == 'Battery':
|
||||
power.append({'name':item.parent.name,'percent':int(item.read_text().strip())})
|
||||
except (OSError,ValueError): pass
|
||||
proximity=[]
|
||||
for item in pathlib.Path('/sys/bus/iio/devices').glob('iio:device*/in_proximity_raw'):
|
||||
try:
|
||||
value=float(item.read_text().strip())
|
||||
if math.isfinite(value) and value >= 0: proximity.append(value)
|
||||
except (OSError,ValueError): pass
|
||||
stats=subprocess.run(['/opt/steamvr/bin/linuxarm64/vrcmd','--stats'],capture_output=True,text=True,timeout=5)
|
||||
try:
|
||||
rows=json.loads(stats.stdout) if stats.returncode == 0 else None
|
||||
@@ -62,7 +68,7 @@ if base.is_dir() and not base.is_symlink():
|
||||
if isinstance(p.get('storeId'),int): plugins.append({'id':p['storeId'],'name':manifest['name']})
|
||||
except (OSError,ValueError,KeyError): pass
|
||||
service=subprocess.run(['systemctl','--user','is-active','frame-control-headset.service'],capture_output=True,text=True)
|
||||
print(json.dumps({'battery':power,'activity':activity,'panel':panel,
|
||||
print(json.dumps({'battery':power,'proximity':proximity,'activity':activity,'panel':panel,
|
||||
'panelService':service.returncode==0,'plugins':plugins}))
|
||||
'''
|
||||
|
||||
@@ -120,11 +126,15 @@ def main(worn_confirmed=False):
|
||||
raise RuntimeError('Battery capacity unavailable or below 20%; no device session started')
|
||||
if before['activity'] is None or before['activity'] >= 3:
|
||||
raise RuntimeError('SteamVR is in standby; no worn-headset check started')
|
||||
if not before.get('proximity'):
|
||||
raise RuntimeError('Proximity reading unavailable; no worn-headset check started')
|
||||
if not before['panelService'] or not before['plugins']:
|
||||
raise RuntimeError('Install and open the Frame Control panel and a Framey plugin before this check')
|
||||
after_lock = probe()
|
||||
if after_lock['activity'] >= 3 or min(x['percent'] for x in after_lock['battery']) < 20:
|
||||
raise RuntimeError('Activity or battery changed after acquiring the lock')
|
||||
if (not isinstance(after_lock.get('activity'), int) or after_lock['activity'] >= 3 or
|
||||
not after_lock.get('proximity') or not after_lock.get('battery') or
|
||||
min(x['percent'] for x in after_lock['battery']) < 20):
|
||||
raise RuntimeError('Activity, proximity or battery changed after acquiring the lock')
|
||||
state = json.dumps({'nonce':token,'started':time.time()})
|
||||
code = "import pathlib; p=pathlib.Path.home()/'.local/share/frame-control/headset/worn-check.json'; p.write_text(" + repr(state) + ")"
|
||||
remote('python3 -c ' + shlex.quote(code))
|
||||
|
||||
@@ -32,7 +32,7 @@ class WornGate(unittest.TestCase):
|
||||
self.assertIn('trusted pointer events alone do not prove',out.getvalue())
|
||||
|
||||
def test_standby_is_read_only_under_owned_lock_and_releases_it(self):
|
||||
state = {'battery':[{'percent':99}], 'activity':3, 'panelService':True,
|
||||
state = {'battery':[{'percent':99}], 'activity':3, 'proximity':[6.25], 'panelService':True,
|
||||
'plugins':[{'id':14,'name':'ProtonDB Badges'}], 'panel':None}
|
||||
code, report, remote, probe = self.run_gate(state)
|
||||
self.assertEqual(code, 1)
|
||||
@@ -43,7 +43,7 @@ class WornGate(unittest.TestCase):
|
||||
self.assertIn('rmdir /tmp/frame-test.lock', remote.call_args_list[1].args[0])
|
||||
|
||||
def test_low_battery_releases_owned_lock(self):
|
||||
state = {'battery':[{'percent':19}], 'activity':0, 'panelService':True,
|
||||
state = {'battery':[{'percent':19}], 'activity':0, 'proximity':[6.25], 'panelService':True,
|
||||
'plugins':[{'id':14,'name':'ProtonDB Badges'}], 'panel':None}
|
||||
code, report, remote, probe = self.run_gate(state)
|
||||
self.assertEqual(code, 1)
|
||||
@@ -52,7 +52,7 @@ class WornGate(unittest.TestCase):
|
||||
self.assertEqual(remote.call_count, 2)
|
||||
|
||||
def test_foreign_lock_prevents_all_sensor_reads_and_is_never_removed(self):
|
||||
state = {'battery':[{'percent':99}], 'activity':0, 'panelService':True,
|
||||
state = {'battery':[{'percent':99}], 'activity':0, 'proximity':[6.25], 'panelService':True,
|
||||
'plugins':[{'id':14,'name':'ProtonDB Badges'}], 'panel':None}
|
||||
code, report, remote, probe = self.run_gate(state, RuntimeError('File exists'))
|
||||
self.assertEqual(code, 1)
|
||||
@@ -64,6 +64,29 @@ class WornGate(unittest.TestCase):
|
||||
def test_screenshot_palette_counts_distinct_colors(self):
|
||||
self.assertEqual(worn.color_count('0 #ff00aa 1 #ff00aa 2 #00ff00'), 2)
|
||||
|
||||
def test_missing_proximity_stops_before_panel_or_input_and_releases_lock(self):
|
||||
state = {'battery':[{'percent':99}], 'activity':0, 'proximity':[],
|
||||
'panelService':True, 'plugins':[{'id':14,'name':'ProtonDB Badges'}], 'panel':None}
|
||||
code, report, remote, probe = self.run_gate(state)
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn('Proximity reading unavailable', report)
|
||||
probe.assert_called_once()
|
||||
self.assertEqual(remote.call_count, 2)
|
||||
self.assertIn('rmdir /tmp/frame-test.lock', remote.call_args_list[-1].args[0])
|
||||
|
||||
def test_proximity_disappearing_on_second_read_stops_before_input(self):
|
||||
first = {'battery':[{'percent':99}], 'activity':0, 'proximity':[200.0],
|
||||
'panelService':True, 'plugins':[{'id':14,'name':'ProtonDB Badges'}], 'panel':None}
|
||||
second = {**first, 'proximity':[]}
|
||||
out = io.StringIO()
|
||||
with patch.object(worn, 'probe', side_effect=[first, second]) as probe, \
|
||||
patch.object(worn, 'remote') as remote, contextlib.redirect_stdout(out):
|
||||
code = worn.main(worn_confirmed=True)
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn('Activity, proximity or battery changed', out.getvalue())
|
||||
self.assertEqual(probe.call_count, 2)
|
||||
self.assertEqual(remote.call_count, 2)
|
||||
|
||||
def test_probe_reads_activity_from_status_after_metrics(self):
|
||||
rows = [{'operation':'performance','frame_presents':12},
|
||||
{'operation':'status','activity_level':1}]
|
||||
@@ -81,4 +104,15 @@ class WornGate(unittest.TestCase):
|
||||
out = io.StringIO()
|
||||
with patch('subprocess.run', side_effect=run), contextlib.redirect_stdout(out):
|
||||
exec(worn.PROBE, {})
|
||||
self.assertEqual(json.loads(out.getvalue())['activity'], expected)
|
||||
result = json.loads(out.getvalue())
|
||||
self.assertEqual(result['activity'], expected)
|
||||
self.assertIsInstance(result['proximity'], list)
|
||||
|
||||
def test_probe_discards_nonfinite_proximity(self):
|
||||
def glob(_path, pattern):
|
||||
return [SimpleNamespace(read_text=lambda: 'nan')] if pattern == 'iio:device*/in_proximity_raw' else []
|
||||
with patch('pathlib.Path.glob', autospec=True, side_effect=glob), \
|
||||
patch('subprocess.run', return_value=SimpleNamespace(returncode=1, stdout='')), \
|
||||
contextlib.redirect_stdout(io.StringIO()) as out:
|
||||
exec(worn.PROBE, {})
|
||||
self.assertEqual(json.loads(out.getvalue())['proximity'], [])
|
||||
Reference in new issue
Block a user