mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 06:00:09 +02:00
Turn the displays off while the headset isn't used, and keep it awake on a charger
A display mount that covers the proximity sensor makes the headset seem worn, so SteamVR never turned its displays off and they stayed on all night. The new power service, ft-powerd (frametop-power.service), goes by use instead: after DISPLAY_OFF_MIN minutes in which the headset and controllers didn't move and no input device was used, it turns the backlight off, and the next movement or input turns it back on. The new Power tab in Frametop Display Settings sets that time and has a Stay awake while plugged in switch. The switch sets Steam's own "When Plugged In and Idle -> Sleep after" to Never through Steam's UI, so the Frame stays reachable remotely while the power button still works. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f01c84f9a3
commit
b99fb97f32
13 files changed
+963
-24
No files matched your search
@@ -0,0 +1,140 @@
|
||||
"""Steam's sleep settings, read and written through Steam's own UI.
|
||||
|
||||
Steam, not systemd, puts the Frame to sleep: after "When Plugged In and Idle -> Sleep after"
|
||||
(an hour by default) without input, even while it charges. That's a Steam client setting,
|
||||
`system_idle_suspend_ac_sec` (0 = never), with no file or command line to change it. Steam
|
||||
on the Frame runs with -cef-enable-debugging, so its UI's JavaScript context
|
||||
(SharedJSContext) is reachable over the Chrome DevTools Protocol on 127.0.0.1:8080. There
|
||||
`settingsStore.clientSettings` has the current values, and `SteamClient.Settings.SetSetting`
|
||||
takes a change as a serialized CMsgClientSettings protobuf, which is what Steam's own
|
||||
Settings -> Power page sends. Standard library only (a minimal WebSocket client).
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import urllib.request
|
||||
|
||||
CDP_PORT = 8080
|
||||
# CMsgClientSettings field numbers (Steam's UI bundle maps the names to these).
|
||||
FIELDS = {"system_idle_suspend_ac_sec": 24004, "system_idle_suspend_battery_sec": 24003}
|
||||
|
||||
|
||||
class SteamUnreachable(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class _WebSocket:
|
||||
def __init__(self, url, timeout=5):
|
||||
host_port, path = url[len("ws://"):].split("/", 1)
|
||||
host, port = host_port.rsplit(":", 1)
|
||||
self.sock = socket.create_connection((host, int(port)), timeout=timeout)
|
||||
key = base64.b64encode(os.urandom(16)).decode()
|
||||
self.sock.sendall((f"GET /{path} HTTP/1.1\r\nHost: {host_port}\r\nUpgrade: websocket\r\n"
|
||||
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n").encode())
|
||||
head = b""
|
||||
while b"\r\n\r\n" not in head:
|
||||
chunk = self.sock.recv(4096)
|
||||
if not chunk:
|
||||
raise SteamUnreachable("Steam closed the connection")
|
||||
head += chunk
|
||||
if b" 101 " not in head.split(b"\r\n", 1)[0]:
|
||||
raise SteamUnreachable(head.split(b"\r\n", 1)[0].decode(errors="replace"))
|
||||
self.buf = head.split(b"\r\n\r\n", 1)[1]
|
||||
|
||||
def send(self, text):
|
||||
data, mask = text.encode(), os.urandom(4)
|
||||
n = len(data)
|
||||
if n < 126:
|
||||
head = struct.pack(">BB", 0x81, 0x80 | n)
|
||||
elif n < 65536:
|
||||
head = struct.pack(">BBH", 0x81, 0x80 | 126, n)
|
||||
else:
|
||||
head = struct.pack(">BBQ", 0x81, 0x80 | 127, n)
|
||||
self.sock.sendall(head + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(data)))
|
||||
|
||||
def _take(self, n):
|
||||
while len(self.buf) < n:
|
||||
chunk = self.sock.recv(65536)
|
||||
if not chunk:
|
||||
raise SteamUnreachable("Steam closed the connection")
|
||||
self.buf += chunk
|
||||
out, self.buf = self.buf[:n], self.buf[n:]
|
||||
return out
|
||||
|
||||
def recv(self):
|
||||
message = b""
|
||||
while True:
|
||||
b0, b1 = self._take(2)
|
||||
n = b1 & 0x7F
|
||||
if n == 126:
|
||||
n = struct.unpack(">H", self._take(2))[0]
|
||||
elif n == 127:
|
||||
n = struct.unpack(">Q", self._take(8))[0]
|
||||
message += self._take(n)
|
||||
if b0 & 0x80:
|
||||
return message.decode(errors="replace")
|
||||
|
||||
def close(self):
|
||||
self.sock.close()
|
||||
|
||||
|
||||
def _evaluate(expression):
|
||||
"""Runs JavaScript in Steam's SharedJSContext and returns its (awaited) value."""
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{CDP_PORT}/json", timeout=3) as r:
|
||||
targets = json.load(r)
|
||||
except OSError as e:
|
||||
raise SteamUnreachable(f"Steam isn't reachable on port {CDP_PORT} ({e})") from e
|
||||
url = next((t["webSocketDebuggerUrl"] for t in targets if t.get("title") == "SharedJSContext"), None)
|
||||
if not url:
|
||||
raise SteamUnreachable("Steam's UI isn't running")
|
||||
try:
|
||||
ws = _WebSocket(url)
|
||||
try:
|
||||
ws.send(json.dumps({"id": 1, "method": "Runtime.evaluate",
|
||||
"params": {"expression": expression, "awaitPromise": True, "returnByValue": True}}))
|
||||
while True:
|
||||
reply = json.loads(ws.recv())
|
||||
if reply.get("id") == 1:
|
||||
break
|
||||
finally:
|
||||
ws.close()
|
||||
except OSError as e:
|
||||
raise SteamUnreachable(str(e)) from e
|
||||
result = reply.get("result", {})
|
||||
if "exceptionDetails" in result:
|
||||
details = result["exceptionDetails"]
|
||||
raise SteamUnreachable(details.get("exception", {}).get("description") or details.get("text", "error"))
|
||||
return result.get("result", {}).get("value")
|
||||
|
||||
|
||||
def sleep_settings():
|
||||
"""{"ac": seconds, "battery": seconds}: when Steam puts the Frame to sleep without input,
|
||||
plugged in and on battery (0 = never)."""
|
||||
value = _evaluate("(() => { const c = settingsStore.clientSettings; "
|
||||
"return {ac: c.system_idle_suspend_ac_sec, battery: c.system_idle_suspend_battery_sec}; })()")
|
||||
if not isinstance(value, dict) or not all(isinstance(value.get(k), int) for k in ("ac", "battery")):
|
||||
raise SteamUnreachable("Steam's settings don't have the sleep timeouts")
|
||||
return value
|
||||
|
||||
|
||||
def set_sleep_setting(name, seconds):
|
||||
"""Sets one of FIELDS to a whole number of seconds and checks that Steam took it."""
|
||||
field, seconds = FIELDS[name], int(seconds)
|
||||
if seconds < 0:
|
||||
raise ValueError("seconds must be 0 (never) or more")
|
||||
ok = _evaluate(f"""(async () => {{
|
||||
const bytes = [];
|
||||
const varint = n => {{ while (n > 127) {{ bytes.push((n & 127) | 128); n = Math.floor(n / 128); }} bytes.push(n); }};
|
||||
varint({field} * 8); varint({seconds});
|
||||
await SteamClient.Settings.SetSetting(btoa(String.fromCharCode(...bytes)));
|
||||
for (let i = 0; i < 40; i++) {{
|
||||
if (settingsStore.clientSettings.{name} === {seconds}) return true;
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
}}
|
||||
return false;
|
||||
}})()""")
|
||||
if ok is not True:
|
||||
raise SteamUnreachable(f"Steam didn't take {name} = {seconds}")
|
||||
Reference in new issue
Block a user