From 385ae8290b8c8dce514d1ba0d4b6871cf7c656aa Mon Sep 17 00:00:00 2001 From: SAPHID Date: Mon, 26 Oct 2026 11:45:51 +0000 Subject: [PATCH] Require proximity telemetry before worn Framey input --- .../framey-worn-proximity-gate-2026-10-26.md | 20 +++++++++ docs/framey-vr-check.md | 6 ++- scripts/framey-worn-check.py | 18 ++++++-- tests/test_framey_worn.py | 42 +++++++++++++++++-- 4 files changed, 76 insertions(+), 10 deletions(-) create mode 100644 docs/evidence/framey-worn-proximity-gate-2026-10-26.md diff --git a/docs/evidence/framey-worn-proximity-gate-2026-10-26.md b/docs/evidence/framey-worn-proximity-gate-2026-10-26.md new file mode 100644 index 0000000..f354037 --- /dev/null +++ b/docs/evidence/framey-worn-proximity-gate-2026-10-26.md @@ -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. diff --git a/docs/framey-vr-check.md b/docs/framey-vr-check.md index 330087b..1469947 100644 --- a/docs/framey-vr-check.md +++ b/docs/framey-vr-check.md @@ -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 diff --git a/scripts/framey-worn-check.py b/scripts/framey-worn-check.py index 0928ba3..ddf21ed 100644 --- a/scripts/framey-worn-check.py +++ b/scripts/framey-worn-check.py @@ -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)) diff --git a/tests/test_framey_worn.py b/tests/test_framey_worn.py index 99435a9..fb59264 100644 --- a/tests/test_framey_worn.py +++ b/tests/test_framey_worn.py @@ -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'], [])