mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 02:00:19 +02:00
feat(comfort): run safe session timers and alerts on the Frame
This commit is contained in:
1 parent
dcf9689f64
commit
522c46ed6f
5 files changed
+517
-25
No files matched your search
@@ -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)
|
||||
@@ -83,6 +83,7 @@ class ServerGuards(unittest.TestCase):
|
||||
|
||||
def test_api_needs_custom_header(self):
|
||||
# <img src> 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"}),
|
||||
|
||||
@@ -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)
|
||||
+53
-24
@@ -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()
|
||||
+33
-1
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user