mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 01:00:18 +02:00
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
265 lines
11 KiB
Python
265 lines
11 KiB
Python
"""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():
|
|
# A total process deadline also bounds a CDP peer that keeps sending events
|
|
# without completing the request. Keep cancellation ordered after this action.
|
|
r = subprocess.run([sys.executable, str(Path(__file__).resolve()), '--home'],
|
|
capture_output=True, text=True, timeout=15)
|
|
if r.returncode:
|
|
raise RuntimeError('Steam Home failed: ' + (r.stdout or r.stderr)[-300:])
|
|
|
|
|
|
def open_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, read_clock=clock):
|
|
"""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'] = max(now, read_clock())
|
|
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. Ask the wearer to 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:
|
|
state = json.loads((ROOT / 'session.json').read_text())
|
|
if not isinstance(state, dict):
|
|
raise ValueError('Saved session must be an object')
|
|
return state
|
|
except FileNotFoundError:
|
|
return {'active': False, 'events': []}
|
|
except (ValueError, UnicodeDecodeError):
|
|
# Preserve the unreadable state for diagnosis, then allow a new session.
|
|
(ROOT / 'session.json').replace(ROOT / ('session-unreadable-' + uuid.uuid4().hex + '.json'))
|
|
return {'active': False, 'events': [],
|
|
'error': 'Saved session was unreadable. Start a new session.'}
|
|
|
|
|
|
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
|
|
beat = s.get('heartbeat', s.get('started', now)) # a hand-edited state may lack either
|
|
if s.get('active') and now - beat > 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
|
|
s['error'] = None
|
|
if s.get('id'):
|
|
event(s, 'cancelled', 'Session timer and monitoring cancelled.')
|
|
save(s)
|
|
if body['action'] == 'start':
|
|
try:
|
|
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)
|
|
except OSError as e:
|
|
s['active'] = False
|
|
s['error'] = 'Could not start session worker: ' + str(e)
|
|
event(s, 'error', s['error'])
|
|
save(s)
|
|
raise
|
|
return current(s, clock())
|
|
|
|
|
|
if __name__ == '__main__':
|
|
if sys.argv[1:] == ['--watch']:
|
|
watch()
|
|
else:
|
|
try:
|
|
if sys.argv[1:] == ['--home']:
|
|
open_home()
|
|
print(json.dumps({'home': True}))
|
|
else:
|
|
print(json.dumps(command(json.loads(sys.argv[1]))))
|
|
except Exception as e:
|
|
print(json.dumps({'error': str(e)}))
|
|
sys.exit(1)
|