From 522c46ed6f87f41752dc7ef19f455a7a06c10e9b Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:29:58 +1000 Subject: [PATCH] feat(comfort): run safe session timers and alerts on the Frame --- tests/test_comfort.py | 194 ++++++++++++++++++++++++++++++++++ tests/test_server.py | 3 + ui/frame_comfort.py | 234 ++++++++++++++++++++++++++++++++++++++++++ ui/frame_status.py | 77 +++++++++----- ui/server.py | 34 +++++- 5 files changed, 517 insertions(+), 25 deletions(-) create mode 100644 tests/test_comfort.py create mode 100644 ui/frame_comfort.py diff --git a/tests/test_comfort.py b/tests/test_comfort.py new file mode 100644 index 0000000..e164cb6 --- /dev/null +++ b/tests/test_comfort.py @@ -0,0 +1,194 @@ +"""Fake-Frame session clock/actions plus real helper serialization and sensor probes.""" +import os +import shutil +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import Mock, patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui')) +import frame_comfort as comfort +import frame_status as status + +OPTIONS = {'action': 'start', 'minutes': 3, 'breakMinutes': 1, 'stillMinutes': 1, + 'batteryAlert': True, 'heatAlert': True} + + +class SessionTests(unittest.TestCase): + def setUp(self): + self.s = comfort.new_session(OPTIONS, 0, 'boot-one') + self.warn, self.home = Mock(), Mock() + + def step(self, now, **sample): + comfort.tick(self.s, now, sample, self.warn, self.home) + + def test_warning_then_home_never_closes_a_game(self): + self.step(119) + self.warn.assert_not_called() + self.step(120) + self.warn.assert_called_once() + self.step(179) + self.home.assert_not_called() + self.step(180) + self.home.assert_called_once() + self.assertFalse(self.s['active']) + self.step(181) + self.home.assert_called_once() + + def test_late_wakeup_always_gets_a_full_warning_minute(self): + self.step(400) + self.home.assert_not_called() + self.step(459) + self.home.assert_not_called() + self.step(460) + self.home.assert_called_once() + + def test_failed_warning_never_stops_session(self): + self.warn.side_effect = RuntimeError('offline') + with self.assertRaises(RuntimeError): + self.step(200) + self.assertIsNone(self.s['warned']) + self.home.assert_not_called() + self.warn.side_effect = None + self.step(300) + self.step(359) + self.home.assert_not_called() + self.step(360) + self.home.assert_called_once() + + def test_failed_home_stays_active_and_retries(self): + self.step(120) + self.home.side_effect = RuntimeError('Steam offline') + with self.assertRaises(RuntimeError): + self.step(180) + self.assertTrue(self.s['active']) + self.home.side_effect = None + self.step(185) + self.assertFalse(self.s['active']) + + def test_cancel_prevents_all_actions(self): + self.s['active'] = False + self.step(999, battery={'percent': 1, 'status': 'Discharging'}) + self.warn.assert_not_called() + self.home.assert_not_called() + self.assertEqual(self.s['events'], []) + + def test_breaks_and_checkin_require_measured_activity(self): + self.step(20, activity=1) + self.step(40, activity=2) + self.step(60, activity=1) + self.assertEqual([e['kind'] for e in self.s['events']], ['break', 'still']) + self.step(70, activity=1) + self.assertEqual(len(self.s['events']), 2) + self.step(75, activity=3) + self.assertEqual(self.s['used'], 0) + self.assertFalse(self.s['stillSent']) + + def test_unknown_activity_and_gaps_do_not_count_as_wear(self): + self.step(25) + self.assertEqual(self.s['used'], 0) + self.assertEqual(self.s['unavailable'], ['battery', 'temperature', 'activity']) + self.step(100, activity=1) + self.assertEqual(self.s['used'], 30) + + def test_alerts_latch_and_rearm_without_battery_chatter(self): + low = {'percent': 10, 'status': 'Discharging'} + self.step(1, battery=low, thermal=['cpu']) + self.step(2, battery=low, thermal=['cpu']) + self.step(3) + self.assertEqual(len(self.s['events']), 2) + self.step(4, battery={'percent': 16, 'status': 'Discharging'}, thermal=[]) + self.step(5, battery=low, thermal=[]) + self.assertEqual(len(self.s['events']), 2) + self.step(6, battery={'percent': 22, 'status': 'Discharging'}, thermal=[]) + self.step(7, battery=low, thermal=['cpu']) + self.assertEqual([e['kind'] for e in self.s['events']], ['battery', 'heat', 'battery', 'heat']) + + def test_disabled_alerts_and_charging(self): + self.s['options']['heatAlert'] = False + self.step(1, battery={'percent': 2, 'status': 'Charging'}, thermal=['cpu']) + self.assertEqual(self.s['events'], []) + + def test_invalid_options(self): + for key, value in [('minutes', 0), ('minutes', 241), ('minutes', True), ('minutes', 2.5), + ('breakMinutes', -1), ('stillMinutes', '1'), ('heatAlert', 1)]: + with self.subTest(key=key, value=value), self.assertRaises(ValueError): + comfort.validate({**OPTIONS, key: value}) + for value in (None, [], {'action': 'shutdown'}): + with self.assertRaises(ValueError): + comfort.validate(value) + + def test_restart_invalidates_session_and_stale_worker_is_explicit(self): + with patch.object(comfort, 'boot', return_value='boot-one'), patch.object(comfort.time, 'time', return_value=999): + current = comfort.current(self.s, 100) + self.assertIn('not responding', current['error']) + self.assertEqual(current['time'], 999) + with patch.object(comfort, 'boot', return_value='boot-two'): + result = comfort.current(self.s, 100) + self.assertFalse(result['active']) + self.assertIn('restarted', result['error']) + + @unittest.skipUnless(os.name == "posix", "on-headset state uses POSIX flock") + def test_real_state_commands_share_one_session_and_cancel(self): + with tempfile.TemporaryDirectory() as tmp, patch.object(comfort, 'ROOT', Path(tmp)), \ + patch.object(comfort, 'boot', return_value='boot-one'), \ + patch.object(comfort, 'clock', return_value=0), patch.object(comfort.subprocess, 'Popen') as spawn: + started = comfort.command(OPTIONS) + self.assertEqual(comfort.command({'action': 'status'})['id'], started['id']) + with self.assertRaises(ValueError): + comfort.command(OPTIONS) + self.assertFalse(comfort.command({'action': 'cancel'})['active']) + spawn.assert_called_once() + self.assertEqual((Path(tmp) / 'session.json').stat().st_mode & 0o777, 0o600) + + def test_native_warning_reports_failures_and_quotes_as_one_argument(self): + with patch.object(comfort.subprocess, 'run') as run: + run.return_value = subprocess.CompletedProcess([], 0, 'Notification succeeded', '') + comfort.notify('Save "now"; $(nothing)') + args = run.call_args.args[0] + self.assertEqual(args, [comfort.VRCMD, '--notify', 'Frame Control: Save "now"; $(nothing)']) + run.return_value.stdout = 'Notification failed with error 1' + with self.assertRaises(RuntimeError): + comfort.notify('test') + + @unittest.skipUnless(shutil.which("node"), "Node exercises the fake Steam JS context") + def test_home_javascript_against_fake_steam_preserves_game(self): + # Same JS runs in Steam CDP. This fake records navigation and refuses any + # unexpected API call; it offers no shutdown or terminate-game primitive. + js = '''let running = [123], path = '/routes/library/app/123', visible = false; +const location = {get pathname() {return path;}}; +const SteamUIStore = {Navigate(p) {path = '/routes' + p;}}; +const SteamClient = {OpenVR: {VROverlay: { + async ShowDashboard(key) {if (key !== 'valve.steam.gamepadui.main') throw Error(key); visible = true;}, + async IsDashboardVisible() {return visible;} +}}}; +''' + js += comfort.HOME_JS + '.then(result => console.log(JSON.stringify({result, running, visible})));' + r = subprocess.run(['node', '-e', js], capture_output=True, text=True, check=True) + result = json.loads(r.stdout) + self.assertEqual(result['running'], [123]) + self.assertTrue(result['visible']) + self.assertEqual(result['result']['path'], '/routes/library/home') + + +class SensorTests(unittest.TestCase): + def test_hot_trip_uses_its_own_zone_not_hottest_unrelated_chip(self): + values = {'/z/a/temp': '90000', '/z/a/trip_point_0_type': 'hot', '/z/a/trip_point_0_temp': '110000', + '/z/b/temp': '45000', '/z/b/trip_point_0_type': 'hot', '/z/b/trip_point_0_temp': '44000', '/z/b/type': 'battery'} + def glob(pattern): + if pattern.endswith('thermal_zone*'): + return ['/z/a', '/z/b'] + return [pattern.replace('*', '0')] + with patch.object(status.glob, 'glob', side_effect=glob), patch.object(status, 'read', side_effect=values.get): + self.assertEqual(status.thermal_alerts(), [{'zone': 'battery', 'tempC': 45, 'limitC': 44}]) + + def test_missing_thermal_and_activity_are_unknown(self): + with patch.object(status.glob, 'glob', return_value=[]): + self.assertIsNone(status.thermal_alerts()) + with patch.object(status, 'run', return_value='unavailable'): + self.assertIsNone(status.activity_level()) + with patch.object(status, 'run', return_value='[{"operation":"status","activity_level":3}]'): + self.assertEqual(status.activity_level(), 3) diff --git a/tests/test_server.py b/tests/test_server.py index ec389bd..86ea8d1 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -83,6 +83,7 @@ class ServerGuards(unittest.TestCase): def test_api_needs_custom_header(self): # and plain form posts from other sites can't set it. + self.assertEqual(self.request("POST", "/api/comfort", {"action": "start"})[0], 403) self.assertEqual(self.request("GET", "/api/status")[0], 403) self.assertEqual(self.request("GET", "/api/screenshot?view=headset")[0], 403) self.assertEqual(self.request("GET", "/api/shots")[0], 403) @@ -98,6 +99,8 @@ class ServerGuards(unittest.TestCase): def test_input_validation(self): cases = [ + ("/api/comfort", {"action": "poweroff"}), + ("/api/comfort", {"action": "start", "minutes": 0}), ("/api/launch", {"appid": "620; rm -rf ~"}), ("/api/launch", {"appid": ""}), ("/api/flatpak", {"id": "org.example.App;id", "action": "install"}), diff --git a/ui/frame_comfort.py b/ui/frame_comfort.py new file mode 100644 index 0000000..b0db39c --- /dev/null +++ b/ui/frame_comfort.py @@ -0,0 +1,234 @@ +"""Opt-in session worker ON the Frame; no root, extra apps, or power actions. + +One worker per user, shared by desktop and phone. State survives companion +connections, not headset reboots. See docs/family-comfort.md for guarantees. +""" +import contextlib +import json +import os +from pathlib import Path +import subprocess +import sys +import time +import uuid + +from frame_steam import Page +from frame_status import battery, thermal_alerts, activity_level + +ROOT = Path.home() / '.local/state/frame-control/comfort' +VRCMD = '/opt/steamvr/bin/linuxarm64/vrcmd' +HOME_JS = """(async () => { + SteamUIStore.Navigate('/library/home'); + await SteamClient.OpenVR.VROverlay.ShowDashboard('valve.steam.gamepadui.main'); + if (!await SteamClient.OpenVR.VROverlay.IsDashboardVisible()) throw Error('Steam dashboard did not open'); + return {path: location.pathname}; +})()""" + + +def clock(): + # CLOCK_BOOTTIME includes headset suspend; wall-clock corrections don't alter limits. + return time.clock_gettime(time.CLOCK_BOOTTIME) + + +def boot(): + return Path('/proc/sys/kernel/random/boot_id').read_text().strip() + + +def validate(body): + if not isinstance(body, dict) or body.get('action') not in ('status', 'start', 'cancel'): + raise ValueError('Choose status, start or cancel') + if body['action'] == 'start': + for key, low, high in (('minutes', 1, 240), ('breakMinutes', 0, 120), ('stillMinutes', 0, 240)): + n = body.get(key) + if type(n) is not int or not low <= n <= high: + raise ValueError(f'{key} must be a whole number from {low} to {high}') + for key in ('batteryAlert', 'heatAlert'): + if type(body.get(key)) is not bool: + raise ValueError(f'{key} must be true or false') + return body + + +def new_session(body, now, boot_id): + return {'id': uuid.uuid4().hex, 'boot': boot_id, 'active': True, + 'options': {k: body[k] for k in ('minutes', 'breakMinutes', 'stillMinutes', 'batteryAlert', 'heatAlert')}, + 'started': now, 'deadline': now + body['minutes'] * 60, 'lastSample': now, + 'used': 0, 'nextBreak': body['breakMinutes'] * 60, 'stillSent': False, + 'warned': None, 'events': [], 'seq': 0, 'latched': [], 'error': None} + + +def event(s, kind, message): + s['seq'] += 1 + s['events'].append({'id': s['id'] + ':' + str(s['seq']), 'kind': kind, + 'message': message, 'time': time.time()}) + s['events'] = s['events'][-40:] + + +def notify(message): + r = subprocess.run([VRCMD, '--notify', 'Frame Control: ' + message], + capture_output=True, text=True, timeout=20) + if r.returncode or 'succeeded' not in r.stdout: + raise RuntimeError('SteamVR could not show the reminder: ' + (r.stderr or r.stdout)[-300:]) + + +def home(): + page = Page() + try: + result = page.eval(HOME_JS) + if result.get('path') != '/routes/library/home': + raise RuntimeError('Steam did not navigate Home') + finally: + page.sock.close() + + +def tick(s, now, sample, warn=notify, go_home=home): + """One deterministic step; injected actions/samples also exercise a fake Frame.""" + if not s.get('active'): + return + o = s['options'] + s['heartbeat'] = now + delta = max(0, min(30, now - s['lastSample'])) + s['lastSample'] = now + level = sample.get('activity') + b = sample.get('battery') or {} + s['unavailable'] = [] + if o['batteryAlert'] and b.get('percent') is None: + s['unavailable'].append('battery') + if o['heatAlert'] and sample.get('thermal') is None: + s['unavailable'].append('temperature') + if (o['breakMinutes'] or o['stillMinutes']) and level is None: + s['unavailable'].append('activity') + s['activity'] = level + if level in (1, 2): + s['used'] += delta + elif level is not None: + s['used'] = 0 + s['nextBreak'] = o['breakMinutes'] * 60 + s['stillSent'] = False + # Missing samples never count as time worn. No catch-up burst after a disconnect. + if now >= s['deadline'] - 60 and s['warned'] is None: + warn('One minute left. Save your progress; Steam Home will open.') + s['warned'] = now + event(s, 'warning', 'One minute left. Save your progress; Steam Home will open.') + if s['warned'] is not None and now >= max(s['deadline'], s['warned'] + 60): + go_home() + s['active'] = False + event(s, 'finished', 'Session ended: Steam Home opened. Your game is still running.') + return + if o['breakMinutes'] and s['used'] >= s['nextBreak']: + warn('Time for a break. Take off the headset and rest your eyes.') + event(s, 'break', 'Time for a break. Take off the headset and rest your eyes.') + s['nextBreak'] = s['used'] + o['breakMinutes'] * 60 + if o['stillMinutes'] and not s['stillSent'] and s['used'] >= o['stillMinutes'] * 60: + event(s, 'still', f"Headset still active after {o['stillMinutes']} active minute(s). Check in with the wearer.") + s['stillSent'] = True + low = b.get('percent') is not None and b['percent'] <= 15 and b.get('status') == 'Discharging' + hot = sample.get('thermal') + for kind, enabled, value, message in ( + ('battery', o['batteryAlert'], low if b else None, 'Frame battery is low (15% or less).'), + ('heat', o['heatAlert'], bool(hot) if hot is not None else None, + 'Frame reports a hot/critical thermal trip or battery overheat. Take a break.')): + if enabled and value and kind not in s['latched']: + event(s, kind, message) + s['latched'].append(kind) + elif value is False and kind in s['latched']: + # Battery hysteresis prevents repeated alerts around 15%. + if kind != 'battery' or b.get('status') == 'Charging' or (b.get('percent') or 0) >= 20: + s['latched'].remove(kind) + + +@contextlib.contextmanager +def locked(name='state.lock', nonblocking=False): + import fcntl # only needed ON the Linux headset, not by desktop validation/tests + ROOT.mkdir(parents=True, exist_ok=True, mode=0o700) + with (ROOT / name).open('a') as f: + fcntl.flock(f, fcntl.LOCK_EX | (fcntl.LOCK_NB if nonblocking else 0)) + yield f + + +def read_state(): + try: + return json.loads((ROOT / 'session.json').read_text()) + except FileNotFoundError: + return {'active': False, 'events': []} + + +def save(s): + p = ROOT / 'session.tmp' + p.write_text(json.dumps(s)) + p.chmod(0o600) + p.replace(ROOT / 'session.json') + + +def current(s, now): + if s.get('active') and s.get('boot') != boot(): + s['active'] = False + s['error'] = 'Headset restarted. Start a new session.' + out = dict(s) + out['time'] = time.time() # event age uses the Frame's clock, not the phone's + out['remaining'] = max(0, max(s.get('deadline', now), (s.get('warned') or 0) + 60) - now) if s.get('active') else 0 + if s.get('active') and now - s.get('heartbeat', s['started']) > 90: + out['error'] = 'Session worker is not responding. Timer enforcement is unverified; cancel and start again.' + return out + + +def watch(): + try: + with locked('worker.lock', nonblocking=True) as worker: + while True: + with locked(): + s = current(read_state(), clock()) + if not s.get('active'): + save(s) + # Release ownership before state.lock: a concurrent start + # cannot miss the gap between an old worker and its exit. + import fcntl + fcntl.flock(worker, fcntl.LOCK_UN) + return + try: + b = battery() + hot = thermal_alerts() + if b and b.get('health') == 'Overheat': + hot = (hot or []) + ['battery'] + tick(s, clock(), {'battery': b, 'thermal': hot, 'activity': activity_level()}) + s['error'] = None + except Exception as e: + error = str(e) + if s.get('error') != error: + event(s, 'error', 'Session action failed: ' + error) + s['error'] = error + save(s) + time.sleep(5) + except BlockingIOError: + pass # another connection already started the single worker + + +def command(body): + validate(body) + with locked(): + s = current(read_state(), clock()) + if body['action'] == 'start': + if s.get('active'): + raise ValueError('A session is already running. Cancel it before starting another.') + s = new_session(body, clock(), boot()) + event(s, 'started', 'Session started. Steam Home opens at the limit; games are not closed.') + elif body['action'] == 'cancel': + s['active'] = False + if s.get('id'): + event(s, 'cancelled', 'Session timer and monitoring cancelled.') + save(s) + if body['action'] == 'start': + subprocess.Popen([sys.executable, str(Path(__file__).resolve()), '--watch'], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True, close_fds=True) + return current(s, clock()) + + +if __name__ == '__main__': + if sys.argv[1:] == ['--watch']: + watch() + else: + try: + print(json.dumps(command(json.loads(sys.argv[1])))) + except Exception as e: + print(json.dumps({'error': str(e)})) + sys.exit(1) diff --git a/ui/frame_status.py b/ui/frame_status.py index 2047abf..af9fe5c 100644 --- a/ui/frame_status.py +++ b/ui/frame_status.py @@ -156,27 +156,56 @@ def flatpaks(): return out -uptime = read("/proc/uptime") -procs = process_names() -print(json.dumps({ - "time": time.time(), - "hostname": socket.gethostname(), - "os": os_release(), - "uptime": float(uptime.split()[0]) if uptime else None, - "battery": battery(), - "power": power_source(), - "disk": {"root": disk("/"), "home": disk("/home")}, - "memory": memory(), - "temp": max_temp(), - "wifi": wifi(), - "ip": ip_addr(), - "volume": volume(), - "services": { - "steamvr": "vrserver" in procs, - "desktop": "plasmashell" in procs, - "lepton": port_listening(5555), - "rdp": "xrdp" in procs, - }, - "games": games(), - "flatpaks": flatpaks(), -})) +def thermal_alerts(): + """Use the kernel's per-zone hot/critical trips, never a guessed chip limit.""" + alerts, known = [], False + for z in glob.glob("/sys/class/thermal/thermal_zone*"): + t = num(z + "/temp", 0.001) + for trip in glob.glob(z + "/trip_point_*_type"): + if read(trip) not in ("hot", "critical"): + continue + limit = num(trip[:-4] + "temp", 0.001) + if t is not None and limit is not None and limit > 0: + known = True + if t >= limit: + alerts.append({"zone": read(z + "/type"), "tempC": t, "limitC": limit}) + return alerts if known else None + + +def activity_level(): + try: + rows = json.loads(run("/opt/steamvr/bin/linuxarm64/vrcmd", "--stats")) + return next((r.get("activity_level") for r in rows if r.get("operation") == "status"), None) + except (ValueError, TypeError): + return None + + +def main(): + uptime = read("/proc/uptime") + procs = process_names() + print(json.dumps({ + "time": time.time(), + "hostname": socket.gethostname(), + "os": os_release(), + "uptime": float(uptime.split()[0]) if uptime else None, + "battery": battery(), + "power": power_source(), + "disk": {"root": disk("/"), "home": disk("/home")}, + "memory": memory(), + "temp": max_temp(), + "wifi": wifi(), + "ip": ip_addr(), + "volume": volume(), + "services": { + "steamvr": "vrserver" in procs, + "desktop": "plasmashell" in procs, + "lepton": port_listening(5555), + "rdp": "xrdp" in procs, + }, + "games": games(), + "flatpaks": flatpaks(), + })) + + +if __name__ == "__main__": + main() diff --git a/ui/server.py b/ui/server.py index dd86ff6..4704eeb 100755 --- a/ui/server.py +++ b/ui/server.py @@ -39,6 +39,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) import frame_android # noqa: E402 import frame_apk_versions # noqa: E402 import frame_catalog # noqa: E402 +import frame_comfort # noqa: E402 import frame_host # noqa: E402 import frame_store # noqa: E402 import frame_titles # noqa: E402 @@ -252,6 +253,37 @@ def status(_body): return json.loads(ssh("python3 -", stdin=(HERE / "frame_status.py").read_text(), timeout=20)) +def comfort(body): + try: + frame_comfort.validate(body) + except ValueError as e: + raise Failure(str(e), 400) + # Content-addressed, user-only helper bundle. Desktop and phone use the same + # on-headset state/lock; no listener, service registration or third-party app. + import hashlib + files = {name: (HERE / name).read_text() for name in + ("frame_comfort.py", "frame_status.py", "frame_steam.py")} + version = hashlib.sha256(json.dumps(files, sort_keys=True).encode()).hexdigest()[:16] + script = """import json, os, pathlib, subprocess, sys +os.umask(0o077) +files = %r +root = pathlib.Path.home() / '.cache/frame-control/comfort' / %r +root.mkdir(parents=True, exist_ok=True) +for name, source in files.items(): + path = root / name + if not path.exists(): + tmp = root / (name + '.' + str(os.getpid())) + tmp.write_text(source) + tmp.replace(path) +r = subprocess.run([sys.executable, str(root / 'frame_comfort.py'), %r], capture_output=True, text=True) +print(r.stdout, end='') +""" % (files, version, json.dumps(body)) + out = json.loads(ssh("python3 -", stdin=script, timeout=65)) + if out.get("error") and "active" not in out: + raise Failure(out["error"], 409) + return out + + def headset_view(): """Both eyes as SteamVR composites them (see frame_vrshot.py); PNG bytes.""" # `timeout`: VR_Init can block if SteamVR is restarting. @@ -1227,7 +1259,7 @@ def _sweep_one(prefix, d): pass -POST = {"/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard, +POST = {"/api/comfort": comfort, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard, "/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots, "/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start, "/api/webinstall/cancel": webinstall_cancel}