mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 01:00:18 +02:00
Package the Frame Control web UI as an installable Electron Mac app and bring in the tooling built alongside it. - app/: Electron wrapper that starts ui/server.py on a free loopback port, hardened window (sandbox, no navigation, runAsNode fuse off), login-shell PATH so Homebrew tools work from Finder, first-run offer to run connect.sh, ad-hoc signed DMG/zip via electron-builder. - ui/: headset view (OpenVR screenshots), device status, library, Steam "Get games" (owned games, install, store search), Android apps as persistent Lepton instances with a rated F-Droid catalogue and a private compatibility database, Android display controls over ADB, file and clipboard transfer, Flatpaks, remote and power actions. - apk-catalog/, compat-db/, frame/: catalogue build pipeline, Lakebed capsule for compatibility reports, Frame-side launchers. - tests/ and CI: server guard and validation tests plus Steam helper tests, run on Python 3.9 with script and app syntax checks. - Docs: README leads with the Mac app; new Android, panels, Steam games and field-notes docs; security notes on LAN-exposed ADB ports. Screenshot values for the headset's IP and Wi-Fi name are placeholders. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
183 lines
5.9 KiB
Python
183 lines
5.9 KiB
Python
"""Runs ON the Steam Frame (piped over SSH as `python3 -`); prints one JSON object.
|
|
|
|
Read-only. Every probe is best effort: a missing tool or file gives null, not an
|
|
error. Paths verified on SteamOS 0.3.0 (vr), build 20260922.
|
|
"""
|
|
import glob
|
|
import json
|
|
import os
|
|
import re
|
|
import socket
|
|
import subprocess
|
|
import time
|
|
|
|
HOME = os.path.expanduser("~")
|
|
STEAM = os.path.join(HOME, ".local/share/Steam")
|
|
# Runtimes and compatibility tools that show up as "apps" in steamapps/.
|
|
TOOL_NAME = re.compile(r"^(Steam Linux Runtime|Proton|Steamworks Common|FEX$|Lepton Development$)")
|
|
|
|
|
|
def read(path):
|
|
try:
|
|
with open(path) as f:
|
|
return f.read().strip()
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def run(*cmd):
|
|
try:
|
|
return subprocess.run(cmd, capture_output=True, text=True, timeout=2).stdout.strip()
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return ""
|
|
|
|
|
|
def num(path, scale=1.0):
|
|
v = read(path)
|
|
try:
|
|
return int(v) * scale
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def battery():
|
|
for d in glob.glob("/sys/class/power_supply/*"):
|
|
if read(d + "/type") != "Battery":
|
|
continue
|
|
cap = read(d + "/capacity")
|
|
# max1720x reports microvolts/microamps; current is positive while charging.
|
|
volts, amps = num(d + "/voltage_now", 1e-6), num(d + "/current_now", 1e-6)
|
|
return {"percent": int(cap) if cap and cap.isdigit() else None,
|
|
"status": read(d + "/status"),
|
|
"watts": round(volts * amps, 2) if volts is not None and amps is not None else None,
|
|
"timeToFull": num(d + "/time_to_full_now"),
|
|
"timeToEmpty": num(d + "/time_to_empty_now"),
|
|
"tempC": num(d + "/temp", 0.1),
|
|
"health": read(d + "/health")}
|
|
return None
|
|
|
|
|
|
def power_source():
|
|
"""The plugged-in charger, if any: {'type': 'C PD [PD_PPS]', 'watts': 20.0}."""
|
|
for d in glob.glob("/sys/class/power_supply/*"):
|
|
if read(d + "/type") == "USB" and read(d + "/online") == "1":
|
|
volts, amps = num(d + "/voltage_now", 1e-6), num(d + "/current_now", 1e-6)
|
|
return {"type": read(d + "/usb_type"),
|
|
"watts": round(volts * amps, 1) if volts and amps else None}
|
|
for d in glob.glob("/sys/class/power_supply/*"):
|
|
if read(d + "/type") != "Battery" and read(d + "/online") == "1":
|
|
return {"type": None, "watts": None}
|
|
return None
|
|
|
|
|
|
def disk(path):
|
|
try:
|
|
s = os.statvfs(path)
|
|
except OSError:
|
|
return None
|
|
return {"total": s.f_blocks * s.f_frsize, "free": s.f_bavail * s.f_frsize}
|
|
|
|
|
|
def memory():
|
|
info = {}
|
|
for line in (read("/proc/meminfo") or "").splitlines():
|
|
k, _, v = line.partition(":")
|
|
info[k] = int(v.split()[0]) * 1024 if v.split() else 0
|
|
if "MemTotal" not in info:
|
|
return None
|
|
return {"total": info["MemTotal"], "available": info.get("MemAvailable", 0)}
|
|
|
|
|
|
def max_temp():
|
|
temps = []
|
|
for z in glob.glob("/sys/class/thermal/thermal_zone*/temp"):
|
|
t = read(z)
|
|
if t and t.lstrip("-").isdigit():
|
|
temps.append(int(t) / 1000)
|
|
return max(temps) if temps else None
|
|
|
|
|
|
def wifi():
|
|
for line in run("nmcli", "-t", "-f", "active,ssid,signal", "dev", "wifi").splitlines():
|
|
# nmcli escapes ':' inside fields as '\:'.
|
|
parts = re.split(r"(?<!\\):", line)
|
|
if len(parts) >= 3 and parts[0] == "yes":
|
|
return {"ssid": parts[1].replace("\\:", ":"),
|
|
"signal": int(parts[2]) if parts[2].isdigit() else None}
|
|
return None
|
|
|
|
|
|
def ip_addr():
|
|
m = re.search(r"\s(\d+\.\d+\.\d+\.\d+)/", run("ip", "-4", "-brief", "addr", "show", "scope", "global"))
|
|
return m.group(1) if m else None
|
|
|
|
|
|
def os_release():
|
|
out = {}
|
|
for line in (read("/etc/os-release") or "").splitlines():
|
|
k, _, v = line.partition("=")
|
|
out[k] = v.strip('"')
|
|
return {"version": out.get("VERSION_ID"), "build": out.get("BUILD_ID"),
|
|
"variant": out.get("VARIANT_ID")}
|
|
|
|
|
|
def volume():
|
|
m = re.search(r"Volume:\s*([\d.]+)(.*)", run("wpctl", "get-volume", "@DEFAULT_AUDIO_SINK@"))
|
|
if not m:
|
|
return None
|
|
return {"level": float(m.group(1)), "muted": "MUTED" in m.group(2)}
|
|
|
|
|
|
def process_names():
|
|
return set(run("ps", "-e", "-o", "comm=").split()) # xrdp runs as root
|
|
|
|
|
|
def port_listening(port):
|
|
return f":{port} " in run("ss", "-ltn")
|
|
|
|
|
|
def games():
|
|
out = []
|
|
for f in glob.glob(os.path.join(STEAM, "steamapps/appmanifest_*.acf")):
|
|
text = read(f) or ""
|
|
fields = dict(re.findall(r'^\s*"(appid|name|SizeOnDisk)"\s+"([^"]*)"', text, re.M))
|
|
if fields.get("appid", "").isdigit() and not TOOL_NAME.match(fields.get("name", "")):
|
|
out.append({"appid": fields["appid"], "name": fields.get("name", fields["appid"]),
|
|
"size": int(fields.get("SizeOnDisk", "0")) if fields.get("SizeOnDisk", "").isdigit() else 0})
|
|
return sorted(out, key=lambda g: g["name"].lower())
|
|
|
|
|
|
def flatpaks():
|
|
out = []
|
|
for line in run("flatpak", "list", "--app", "--columns=application,name,version,installation").splitlines():
|
|
p = line.split("\t")
|
|
if len(p) == 4:
|
|
out.append({"id": p[0], "name": p[1], "version": p[2], "installation": p[3]})
|
|
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(),
|
|
}))
|