diff --git a/docs/evidence/framey-vr-activity-row-2026-10-26.md b/docs/evidence/framey-vr-activity-row-2026-10-26.md new file mode 100644 index 0000000..a4eddf6 --- /dev/null +++ b/docs/evidence/framey-vr-activity-row-2026-10-26.md @@ -0,0 +1,28 @@ +# Read-only Framey activity gate, 26 October 2026 + +At 11:12 UTC, a read-only probe acquired `/tmp/frame-test.lock` by atomic +`mkdir` with a random owner token before reading the Frame. SteamVR +returned two `vrcmd --stats` rows: performance metrics first and the status +row second. The status row reported activity level **1**. The original +`framey-worn-check.py` looked only at row zero and reported unknown activity; +`framey-vr-check.py` used the same row-zero assumption for its wake gate. The +fixed gates select the unique status row and fail closed if it is missing, +duplicated or malformed. + +The first locked read found 90% battery, proximity raw 222.25, no active +managed headset panel service and no managed Decky plugin. A subsequent locked +read found activity 1 and proximity raw 223.625. No reliable wearer +confirmation was available. No wake, service install, settings write or input +was attempted. + +While activity was 1, a separate owned-lock read captured four seconds from +`/dev/video99` into a private 640×360 H.264 MP4. It decoded successfully and +contained active video imagery. The inspected sample did **not** establish +room passthrough, Frame Control panel visibility, ProtonDB Badges UI or +controller interaction. The private recording and decoded frame were removed +after inspection; no room or media images are committed. Each read released +only its owner-token lock. The hourly worn-check schedule remains enabled. + +The local focused safety suite covers a metrics row before the status row, +awake activity, ambiguous/missing status and foreign-lock refusal. A genuine +worn visual and physical-controller check remains outstanding. diff --git a/scripts/framey-vr-check.py b/scripts/framey-vr-check.py index c55eb1b..6bfe3e8 100644 --- a/scripts/framey-vr-check.py +++ b/scripts/framey-vr-check.py @@ -90,6 +90,15 @@ def settings(state): return pause, timeout +def steamvr_activity(rows): + """Read the unique status row; metrics can precede it in --stats output.""" + if not isinstance(rows, list): + return None + levels = [row.get('activity_level') for row in rows + if isinstance(row, dict) and row.get('operation') == 'status'] + return levels[0] if len(levels) == 1 and type(levels[0]) is int else None + + def diagnostic_source(): return (ROOT / 'scripts/framey_openvr.py').read_text() + ''' import json @@ -183,9 +192,7 @@ def main(argv=None): if not before['battery'] or min(before['battery']) < 20: raise RuntimeError('Battery unknown or below 20%') if args.wake or args.exercise: - stats = before.get('stats') - if (not isinstance(stats, list) or not stats or - not isinstance(stats[0], dict) or stats[0].get('activity_level') != 3): + if steamvr_activity(before.get('stats')) != 3: raise RuntimeError('Frame is awake or SteamVR activity is unknown; active session untouched') if args.wake or args.exercise: # Independent systemd timer restores power even if the controller is diff --git a/scripts/framey-worn-check.py b/scripts/framey-worn-check.py index 57c7713..0928ba3 100644 --- a/scripts/framey-worn-check.py +++ b/scripts/framey-worn-check.py @@ -44,8 +44,11 @@ for item in pathlib.Path('/sys/class/power_supply').glob('*/capacity'): power.append({'name':item.parent.name,'percent':int(item.read_text().strip())}) except (OSError,ValueError): pass stats=subprocess.run(['/opt/steamvr/bin/linuxarm64/vrcmd','--stats'],capture_output=True,text=True,timeout=5) -try: activity=json.loads(stats.stdout)[0]['activity_level'] if stats.returncode == 0 else None -except (ValueError,KeyError,IndexError,TypeError): activity=None +try: + rows=json.loads(stats.stdout) if stats.returncode == 0 else None + levels=[row.get('activity_level') for row in rows if isinstance(row,dict) and row.get('operation')=='status'] if isinstance(rows,list) else [] + activity=levels[0] if len(levels)==1 and type(levels[0]) is int else None +except (ValueError,TypeError): activity=None panels=subprocess.run(['/opt/steamvr/bin/linuxarm64/vrcmd','--overlays'],capture_output=True,text=True,timeout=5) panel=next((line.strip() for line in panels.stdout.splitlines() if 'valve.steam.desktopgame.2001947813' in line),None) base=pathlib.Path.home()/'.local/share/frame-control/decky/plugins' diff --git a/tests/test_framey_vr_check.py b/tests/test_framey_vr_check.py index 8cd6543..7dd7246 100644 --- a/tests/test_framey_vr_check.py +++ b/tests/test_framey_vr_check.py @@ -15,6 +15,18 @@ spec.loader.exec_module(check) class Safety(unittest.TestCase): + def test_status_row_can_follow_metrics_and_ambiguous_status_fails_closed(self): + self.assertEqual(check.steamvr_activity([ + {'operation':'performance','frame_presents':12}, + {'operation':'status','activity_level':3}]), 3) + self.assertEqual(check.steamvr_activity([ + {'operation':'performance','frame_presents':12}, + {'operation':'status','activity_level':1}]), 1) + self.assertIsNone(check.steamvr_activity([ + {'operation':'status','activity_level':3}, + {'operation':'status','activity_level':1}])) + self.assertIsNone(check.steamvr_activity([{'activity_level':3}])) + def test_overlay_wait_is_bounded_and_stops_when_visible(self): from unittest.mock import Mock remote = Mock() @@ -61,7 +73,7 @@ class Safety(unittest.TestCase): def test_power_restore_after_partial_setter_failure(self): state = {'battery':[99], 'pause':'power.pauseCompositorOnStandby=true', 'timeout':'power.turnOffScreensTimeout=17.000000','dashboardVisible':False, - 'stats':[{'activity_level':3}], 'proximity':[6.25]} + 'stats':[{'operation':'status','activity_level':3}], 'proximity':[6.25]} with tempfile.TemporaryDirectory() as folder, patch.object(check, 'Remote') as factory: remote = factory.return_value remote.python.return_value = state @@ -78,7 +90,9 @@ class Safety(unittest.TestCase): self.assertIn('rmdir /tmp/frame-test.lock', commands[-1]) def test_awake_or_unknown_activity_refuses_wake_and_install(self): - for stats in ([{'activity_level':1}], [{'activity_level':2}], [], None): + for stats in ([{'operation':'status','activity_level':1}], + [{'operation':'performance'}, {'operation':'status','activity_level':2}], + [], None): state = {'battery':[99], 'pause':'power.pauseCompositorOnStandby=true', 'timeout':'power.turnOffScreensTimeout=17.000000', 'dashboardVisible':False, 'stats':stats, 'proximity':[0.0]} diff --git a/tests/test_framey_worn.py b/tests/test_framey_worn.py index 8bb154c..99435a9 100644 --- a/tests/test_framey_worn.py +++ b/tests/test_framey_worn.py @@ -3,7 +3,9 @@ import sandbox # noqa: F401 import contextlib import importlib.util import io +import json from pathlib import Path +from types import SimpleNamespace import unittest from unittest.mock import patch @@ -61,3 +63,22 @@ 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_probe_reads_activity_from_status_after_metrics(self): + rows = [{'operation':'performance','frame_presents':12}, + {'operation':'status','activity_level':1}] + def run(argv, **_): + if argv[-1] == '--stats': + return SimpleNamespace(returncode=0, stdout=json.dumps(rows)) + return SimpleNamespace(returncode=1, stdout='') + for current, expected in ( + (rows, 1), + ([{'operation':'performance','frame_presents':12}], None), + (rows + [{'operation':'status','activity_level':3}], None), + ): + with self.subTest(rows=current): + rows = current + 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)