mirror of
https://github.com/MichaelScottsman/LiquidAss-Frame.git
synced 2026-10-17 15:01:02 +02:00
- native/glassd/prebuilt/glassd: the Frame's glassd built from these sources, shipped by sync. - glass.py install: the shipped binary in place first, then a build from source over it (kept on a failed build), then a check that glassd is ready. - lgs_shell start: puts the shipped binary in place when glassd is missing, so the Liquid Glass toggle run during an install never starts CSS-only (what left the glass transparent). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1234 lines
59 KiB
Python
1234 lines
59 KiB
Python
#!/usr/bin/env python3
|
|
"""PC-side driver for LiquidAss (Liquid Glass theme for the Steam Frame UI).
|
|
|
|
Talks to the headset through the steam-frame-ssh skill's helper (pinned host
|
|
key, STEAMFRAME_SSH_PASSWORD), the same way the run-liquid-glass-frame driver
|
|
does.
|
|
|
|
python glass.py check headset reachable? Steam devtools up?
|
|
python glass.py sync [SUB...] upload device/ theme/ lab/ native/ (sources),
|
|
or only the named subtrees (sync lab)
|
|
python glass.py native-build [fake] build glassd on the Frame into
|
|
~/.local/share/glass-shell/native/glassd/glassd
|
|
("fake": the stand-in native/spike/fakeglassd)
|
|
python glass.py shell [status|start|stop|log] [ARGS]
|
|
the lgs-shell unit (native layer daemon);
|
|
start passes ARGS (--native, --glassd PATH,
|
|
--glassd-args "...", --feed, --stay) on
|
|
python glass.py install sync + add "LiquidAss" to + > Launch Program
|
|
python glass.py uninstall theme off, remove launcher and files
|
|
python glass.py on|off|toggle|reload|status
|
|
python glass.py toast "text"
|
|
python glass.py shot SURF NAME [--route R] [--pre JS] [--theme on|off|keep]
|
|
[--settle S] [--back]
|
|
capture SURF to shots/NAME.png
|
|
python glass.py perf SURF [--route R] [--pre JS] [--seconds S] [--ab stock|theme [--rounds N]]
|
|
theme off vs on frame pacing while scrolling;
|
|
--ab: PLAN R2-13's ABBA verdict (contracts/lab.md)
|
|
python glass.py audit SURF [--route R] [--pre JS] [--json]
|
|
stock-vs-themed regression diff
|
|
python glass.py outline SURF [--route R] [--sel S] [--depth N] [--max N]
|
|
python glass.py styles SURF SELECTOR
|
|
python glass.py classes REGEX
|
|
python glass.py click SURF SELECTOR
|
|
python glass.py js 'EXPR' eval in SharedJSContext, L = lab helpers
|
|
python glass.py route | nav ROUTE | back | surfaces
|
|
python glass.py logs
|
|
python glass.py selftest [RT-1,...] [--mode=pad|laser] [--json]
|
|
P1's runtime selftest; JSON -> shots/p1-selftest.json
|
|
|
|
Phase 2 verification tools (P10; interface: docs/phase2/contracts/lab.md; exit 0 pass, 1 fail, 2 usage,
|
|
3 blocked). Offline (no device):
|
|
python glass.py check-theme [--json] theme/ and device/rt parse, the bundler's own check
|
|
python glass.py edge PNG Y X0 X1 WN 8.2 top-edge profile of a glass slab
|
|
python glass.py focus --png PNG --pair NAME=A:B[:MIN]... luma pairs in an image
|
|
python glass.py cmp MOCK.html [LIVE.png] [--id PKG] [--live] mockup vs live, per element
|
|
python glass.py ledger [--out FILE] the function ledger (audits x concepts)
|
|
python glass.py sgcheck --spec FILE the depth rules over a check model
|
|
Live (each step locked; step options --flags a,b=v --mode laser|pad --media reduce,contrast
|
|
--stock --hover SEL[,MS] apply to all of them):
|
|
python glass.py gates SURF [--route R] [--pre JS] [--only aud,size,type,outline,motion] [--shot NAME]
|
|
python glass.py pad-bfs [--route R] [--pre JS] [--budget S] [--out FILE]
|
|
python glass.py focus SURF [--route R] [--pre JS] --pairs FILE|JSON [--keep [NAME]]
|
|
python glass.py motion SURF --pre JS [--route R] [--name ID] [--at 0,.15,...] | --selftest MS
|
|
python glass.py sgcheck [--route R] [--pre JS] (as a native-session step)
|
|
python glass.py hv NAME [--offaxis DEG] [--rect x0,y0,x1,y1] [--full] [--look] [--route R] [--pre JS]
|
|
[--settle S] [--grabs N] [--gap S] | --clean
|
|
python glass.py conformance [--route R]... [--only P-..] [--pad] [--out FILE]
|
|
python glass.py native-session [--pre JS] --step "CMD ARGS"... native mode on, the steps, back to CSS
|
|
|
|
Install location on the Frame: ~/.local/share/glass-shell (launcher + code
|
|
only). The theme itself is never persisted; a reboot or Steam restart always
|
|
returns the stock UI.
|
|
"""
|
|
import importlib.util
|
|
import io
|
|
import json
|
|
import os
|
|
import re
|
|
import shlex
|
|
import sys
|
|
import tarfile
|
|
import time
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
HELPER = Path.home() / ".claude" / "skills" / "steam-frame-ssh" / "scripts" / "frame_ssh.py"
|
|
REMOTE = "/home/steamos/.local/share/glass-shell"
|
|
DESKTOP = "/home/steamos/.local/share/applications/glass-shell.desktop"
|
|
SHOTS = ROOT / "shots"
|
|
# -B and PYTHONDONTWRITEBYTECODE (REQ P8->P10): lab and lgs commands import lgs, lgs_shell, lab_p2cmd; without them
|
|
# Python writes device/__pycache__ and lab/__pycache__ on the Frame, which would outlive `lgs off` and a reboot
|
|
# (PLAN 7, hard rule: nothing persists). The variable also reaches every Python the command starts.
|
|
PY = "env -u LD_LIBRARY_PATH -u LD_PRELOAD PYTHONDONTWRITEBYTECODE=1 /usr/bin/python3 -B"
|
|
|
|
|
|
def connect():
|
|
spec = importlib.util.spec_from_file_location("frame_ssh", HELPER)
|
|
mod = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mod)
|
|
try:
|
|
client, _ = mod.connect()
|
|
except Exception as e: # noqa: BLE001
|
|
sys.exit(f"Cannot connect to the Frame: {e}\n(is it awake? FRAME_HOST=<ip> overrides 'frame')")
|
|
return client
|
|
|
|
|
|
def sh(client, cmd, timeout=180, echo=True):
|
|
_, out, err = client.exec_command(cmd, timeout=timeout)
|
|
o = out.read().decode(errors="replace")
|
|
e = err.read().decode(errors="replace")
|
|
code = out.channel.recv_exit_status()
|
|
if echo:
|
|
sys.stdout.write(o)
|
|
if e:
|
|
sys.stderr.write(e)
|
|
return code, o, e
|
|
|
|
|
|
def sh_stream(client, cmd, on_line, timeout=180):
|
|
"""Run cmd and call on_line(line) for each stdout line as it arrives (review R1 M5: a room frame a lab step
|
|
announces is fetched and deleted at once, not when the whole command ends). stderr is drained by a thread
|
|
and returned with the exit code. timeout: the longest silence allowed, as in sh()."""
|
|
import socket
|
|
import threading
|
|
chan = client.get_transport().open_session(timeout=timeout)
|
|
chan.settimeout(timeout)
|
|
chan.exec_command(cmd)
|
|
out = chan.makefile("rb", -1)
|
|
errbuf = []
|
|
done = threading.Event()
|
|
|
|
def drain():
|
|
# raw recv_stderr: a quiet stderr (longer than the timeout) must not lose what was already read
|
|
while not done.is_set():
|
|
try:
|
|
d = chan.recv_stderr(65536)
|
|
except socket.timeout:
|
|
continue
|
|
except Exception as e: # noqa: BLE001
|
|
errbuf.append(f"(stderr: {e})\n".encode())
|
|
return
|
|
if not d:
|
|
return
|
|
errbuf.append(d)
|
|
t = threading.Thread(target=drain, daemon=True)
|
|
t.start()
|
|
try:
|
|
for raw in iter(out.readline, b""):
|
|
on_line(raw.decode("utf-8", "replace").rstrip("\r\n"))
|
|
code = chan.recv_exit_status()
|
|
finally:
|
|
t.join(5)
|
|
done.set()
|
|
try:
|
|
chan.close()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
return code, b"".join(errbuf).decode("utf-8", "replace")
|
|
|
|
|
|
def sync(client, only=None):
|
|
"""Upload device/ theme/ lab/ native/ (or only the named subtrees, e.g.
|
|
`glass.py sync lab`: P10 tools without touching anyone's theme files)."""
|
|
subs = [x for x in ("device", "theme", "lab", "native") if not only or x in only]
|
|
buf = io.BytesIO()
|
|
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
|
for sub in subs:
|
|
p = ROOT / sub
|
|
if not p.exists():
|
|
continue
|
|
for f in sorted(p.rglob("*")):
|
|
if f.is_dir() or "__pycache__" in f.parts or f.suffix == ".pyc":
|
|
continue
|
|
# native/: sources only (builds happen on the Frame), plus the shipped glassd binary
|
|
prebuilt = sub == "native" and "prebuilt" in f.parts
|
|
if sub == "native" and not prebuilt and ("build" in f.parts or f.suffix in (".o", ".so", ".png", ".jpg")
|
|
or (f.suffix == "" and f.name not in ("Makefile",))):
|
|
continue
|
|
ti = tar.gettarinfo(str(f), arcname=str(f.relative_to(ROOT)).replace("\\", "/"))
|
|
ti.mode = 0o755 if f.suffix in (".py", ".sh") or f.name == "lgs" or prebuilt else 0o644
|
|
ti.uid = ti.gid = 1000
|
|
ti.uname = ti.gname = "steamos"
|
|
with open(f, "rb") as fh:
|
|
tar.addfile(ti, fh)
|
|
buf.seek(0)
|
|
tmp = f"/tmp/lgs/sync-{os.getpid()}-{int(time.time() * 1000)}.tgz"
|
|
sh(client, "mkdir -p /tmp/lgs", echo=False)
|
|
sftp = client.open_sftp()
|
|
sftp.putfo(buf, tmp)
|
|
sftp.close()
|
|
# Theme files are replaced as a set so a removed file does not linger.
|
|
clear = f"rm -rf {REMOTE}/theme && " if "theme" in subs else ""
|
|
code, _, _ = sh(client, f"mkdir -p {REMOTE} && flock /tmp/lgs/sync.lock sh -c "
|
|
+ shlex.quote(f"{clear}tar -xzf {tmp} -C {REMOTE} && rm -f {tmp}"))
|
|
if code:
|
|
sys.exit("sync failed")
|
|
print(f"synced {' '.join(subs)} -> {REMOTE}")
|
|
|
|
|
|
def native_build(client, fake=False):
|
|
"""Build glassd on the Frame. native/glassd/build.sh wins if present, else
|
|
CMake; the binary lands in native/glassd/glassd, where lgs-shell looks."""
|
|
if fake:
|
|
cmd = (f"cd {REMOTE}/native/spike && "
|
|
"g++ -O2 -std=c++17 -I$HOME/frametop/screens/build/include -I. fakeglassd.cpp -o fakeglassd "
|
|
"-L/opt/steamvr/bin/linuxarm64 -lopenvr_api -Wl,-rpath,/opt/steamvr/bin/linuxarm64 -lgbm "
|
|
f"&& echo built {REMOTE}/native/spike/fakeglassd")
|
|
else:
|
|
d = f"{REMOTE}/native/glassd"
|
|
cmd = (f"if [ ! -d {d} ]; then echo 'native/glassd is not there yet (nothing to build); "
|
|
"lgs-shell runs CSS only without it'; exit 3; fi; cd " + d + " && "
|
|
"if [ -f build.sh ]; then sh build.sh; "
|
|
"elif [ -f CMakeLists.txt ]; then cmake -S . -B build -DCMAKE_BUILD_TYPE=Release >/dev/null "
|
|
"&& cmake --build build -j8 && install -m755 build/glassd glassd; "
|
|
"else echo 'native/glassd has no build.sh or CMakeLists.txt'; exit 3; fi && "
|
|
f"ls -la {d}/glassd")
|
|
code, _, _ = sh(client, cmd, timeout=600)
|
|
return code
|
|
|
|
|
|
def install_glassd(client):
|
|
"""glassd for native mode: the shipped binary (native/glassd/prebuilt/glassd), then a build from
|
|
the synced sources over it. Exit code 0 when a working glassd is in place."""
|
|
d = f"{REMOTE}/native/glassd"
|
|
code, _, _ = sh(client, f"test -x {d}/prebuilt/glassd && install -m755 {d}/prebuilt/glassd {d}/glassd "
|
|
f"&& echo 'glassd: shipped binary in place'", timeout=60)
|
|
if native_build(client):
|
|
print("glassd: the build from source failed; the shipped binary stays")
|
|
code, _, _ = sh(client, f"{d}/glassd --help >/dev/null 2>&1; test -x {d}/glassd && echo 'glassd: ready ({d}/glassd)'",
|
|
timeout=60)
|
|
if code:
|
|
print("glassd: no working binary; native glass is off until `python glass.py native-build` succeeds")
|
|
return code
|
|
|
|
|
|
def lgs(client, *args):
|
|
q = " ".join(shlex.quote(a) for a in args)
|
|
code, _, _ = sh(client, f"{PY} {REMOTE}/device/lgs.py {q}", timeout=120)
|
|
return code
|
|
|
|
|
|
def lab(client, *args, timeout=300, echo=True):
|
|
q = " ".join(shlex.quote(a) for a in args)
|
|
return sh(client, f"{PY} {REMOTE}/lab/lab.py {q}", timeout=timeout, echo=echo)
|
|
|
|
|
|
def unmangle(arg):
|
|
"""Undo Git Bash's MSYS path conversion: '--route /library/home' reaches us
|
|
as 'C:/Program Files/Git/library/home'. Routes are never Windows paths."""
|
|
m = re.match(r"^[A-Za-z]:[/\\](?:Program Files[/\\])?Git([/\\].*)$", arg)
|
|
return m.group(1).replace("\\", "/") if m else arg
|
|
|
|
|
|
# ------------------------------------------------------------ Phase 2 tools (P10)
|
|
# docs/phase2/contracts/lab.md is the interface. Exit codes: 0 pass, 1 fail,
|
|
# 2 usage, 3 blocked (a dependency is not there yet).
|
|
|
|
def blocked(what):
|
|
print(f"BLOCKED: {what} (stub; see docs/phase2/contracts/lab.md section 9)")
|
|
return 3
|
|
|
|
|
|
def p2_stub(name):
|
|
def run(*_a):
|
|
return blocked(f"glass.py {name} is not implemented yet")
|
|
return run
|
|
|
|
|
|
def p2_tool(name):
|
|
"""tools/p2/<name>.py as a module."""
|
|
p = ROOT / "tools" / "p2" / f"{name}.py"
|
|
spec = importlib.util.spec_from_file_location(f"p2_{name}", p)
|
|
mod = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mod)
|
|
return mod
|
|
|
|
|
|
def cmd_check_theme(rest):
|
|
return p2_tool("check_theme").main(["check-theme"] + rest)
|
|
|
|
|
|
def cmd_edge(rest):
|
|
return p2_tool("edge_profile").main_edge(rest)
|
|
|
|
|
|
def P2_SGCHECK_OFFLINE(rest):
|
|
return p2_tool("sgcheck").main(rest)
|
|
|
|
|
|
def cmd_sgcheck(c, rest):
|
|
"""Live sgcheck: the lab gathers P6's report, the DOM and P7's dump in one lock; the rules run here."""
|
|
rest = list(rest)
|
|
as_json = "--json" in rest
|
|
outp = None
|
|
if "--out" in rest:
|
|
i = rest.index("--out")
|
|
outp = rest[i + 1]
|
|
del rest[i:i + 2]
|
|
results = {}
|
|
rc = lab_fetch(c, ["sgcheck"] + rest, timeout=300, results=results)
|
|
if not results.get("sgmodel"):
|
|
return rc or 3
|
|
data = results["sgmodel"][0]
|
|
sg = p2_tool("sgcheck")
|
|
res = sg.check_live(data)
|
|
res.update({k: data.get(k) for k in ("route", "build", "date", "step")})
|
|
if outp:
|
|
Path(outp).write_text(json.dumps(dict(res, live=data), indent=1), encoding="utf-8")
|
|
if as_json:
|
|
print(json.dumps(res, indent=1))
|
|
else:
|
|
print(sg.summary(res, f"sgcheck {data.get('route') or ''} (build {data.get('build')}, {data.get('date')}, native {data.get('native', '?')}, "
|
|
f"{res.get('nodes')} scene-graph nodes)"))
|
|
return 0 if res["pass"] else 1
|
|
|
|
|
|
TMP = Path(os.environ.get("TEMP", "/tmp"))
|
|
HV_LOOK = TMP / "lgs-hv" # legacy shared look folder (before review R1 m4): emptied by any command
|
|
HV_LOOK_PREFIX = "lgs-hv-look-" # per-invocation look folders, deleted by their own timer or after HV_LOOK_S
|
|
HV_LOOK_S = 120
|
|
|
|
|
|
def hv_clean(quiet=True, all_looks=False):
|
|
"""Delete kept headset-view frames on this PC (room imagery, LAB never-list): the legacy shared folder, and
|
|
every per-invocation look folder older than HV_LOOK_S s (all of them with all_looks, `hv --clean`)."""
|
|
import shutil
|
|
n = 0
|
|
if HV_LOOK.exists():
|
|
for f in HV_LOOK.iterdir():
|
|
try:
|
|
f.unlink()
|
|
n += 1
|
|
except OSError:
|
|
pass
|
|
now = time.time()
|
|
try:
|
|
for d in TMP.glob(HV_LOOK_PREFIX + "*"):
|
|
try:
|
|
if all_looks or now - d.stat().st_mtime > HV_LOOK_S:
|
|
n += sum(1 for _ in d.glob("*.png"))
|
|
shutil.rmtree(d, ignore_errors=True)
|
|
except OSError:
|
|
pass
|
|
except OSError:
|
|
pass
|
|
if n and not quiet:
|
|
print(f"hv: deleted {n} kept headset-view frame(s)")
|
|
return n
|
|
|
|
|
|
def hv_look_timer(folder):
|
|
"""A detached process that deletes one look folder HV_LOOK_S s from now (it ends with that; any glass.py
|
|
command also deletes look folders older than HV_LOOK_S)."""
|
|
import subprocess
|
|
code = f"import time, shutil; time.sleep({HV_LOOK_S}); shutil.rmtree({str(folder)!r}, ignore_errors=True)"
|
|
flags = 0
|
|
if os.name == "nt":
|
|
flags = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) \
|
|
| getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
try:
|
|
subprocess.Popen([sys.executable, "-c", code], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL, creationflags=flags, close_fds=True,
|
|
**({} if os.name == "nt" else {"start_new_session": True}))
|
|
except OSError as e:
|
|
print(f"hv: no look timer ({e}); the next glass.py command after {HV_LOOK_S} s deletes it")
|
|
|
|
|
|
def hv_measure(c, remote, name, opts):
|
|
"""Fetch one hvgrab frame, measure it, delete both copies (or keep the local one for --look in its own
|
|
folder, deleted after HV_LOOK_S s). Exit 3 when the verdict is withheld (no --rect: the auto rect is a hint,
|
|
review R1 m4), 1 on a failing verdict."""
|
|
import tempfile
|
|
tmpd = Path(tempfile.mkdtemp(prefix="lgs-hv-"))
|
|
local = tmpd / f"{name}.png"
|
|
try:
|
|
sftp = c.open_sftp()
|
|
try:
|
|
sftp.get(remote, str(local))
|
|
finally:
|
|
try:
|
|
sftp.remove(remote)
|
|
except OSError:
|
|
pass
|
|
sftp.close()
|
|
finally:
|
|
sh(c, f"rm -f {shlex.quote(remote)}", echo=False)
|
|
try:
|
|
res = p2_tool("hv_metrics").measure(str(local), rect=opts.get("rect"))
|
|
res["name"] = name
|
|
# the measured frame is the last of N grabs (REQ P7->P10: the first after a pause can be stale)
|
|
res["grabs"] = opts.get("grabs", 1)
|
|
if opts.get("layer"):
|
|
res["layer"] = True # a --route/--pre layer was held open for the capture (REQ C1c->P10)
|
|
for k in ("mode", "native"): # the input mode and native state the look was taken in (session 5)
|
|
if opts.get(k):
|
|
res[k] = opts[k]
|
|
print(json.dumps(res, indent=1), flush=True)
|
|
if opts.get("look"):
|
|
look = TMP / f"{HV_LOOK_PREFIX}{os.getpid()}-{int(time.time() * 1000) % 10000000}"
|
|
look.mkdir(parents=True, exist_ok=True)
|
|
keep = look / f"{name}.png"
|
|
local.replace(keep)
|
|
hv_look_timer(look)
|
|
print(f"LOOK: {keep} (room imagery: view it now; it is deleted in {HV_LOOK_S} s)", flush=True)
|
|
if res.get("pass") is None:
|
|
print(f"BLOCKED: G-HV verdict withheld for {name} ({res.get('note', 'no rect')})", flush=True)
|
|
return 3
|
|
return 1 if res.get("pass") is False else 0
|
|
finally:
|
|
if local.exists():
|
|
local.unlink()
|
|
try:
|
|
tmpd.rmdir()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def worst(*codes):
|
|
"""Combine exit codes: a FAIL (1) wins over BLOCKED (3), which wins over pass (0); other codes after that."""
|
|
cs = [c for c in codes if c]
|
|
for k in (1, 3):
|
|
if k in cs:
|
|
return k
|
|
return cs[0] if cs else 0
|
|
|
|
|
|
def lab_fetch(c, args, timeout=600, hv_opts=None, results=None, quiet_files=()):
|
|
"""Run a lab command whose output may announce files (@@file, @@hv) or
|
|
results (@@<kind> JSON); print the rest, fetch the files, collect results
|
|
into `results` ({kind: [obj]}), and return the exit code.
|
|
|
|
Lines are handled as they stream (review R1 M5): a headset-view frame is fetched, measured and deleted on
|
|
both machines while the command (a native-session) is still running. One failed fetch does not stop the
|
|
others, and any announced room frame not handled by the end is deleted on the Frame."""
|
|
q = " ".join(shlex.quote(a) for a in args)
|
|
state = {"rc": 0, "hv": []}
|
|
|
|
def handle(line):
|
|
if line.startswith("@@file "):
|
|
_, remote, rel = line.split(" ", 2)
|
|
local = ROOT / rel
|
|
try:
|
|
local.parent.mkdir(parents=True, exist_ok=True)
|
|
sftp = c.open_sftp()
|
|
try:
|
|
sftp.get(remote, str(local))
|
|
finally:
|
|
try:
|
|
sftp.remove(remote)
|
|
except OSError:
|
|
pass
|
|
sftp.close()
|
|
if not any(rel.startswith(qf) for qf in quiet_files):
|
|
print(f"saved {local}", flush=True)
|
|
except Exception as ex: # noqa: BLE001 - the next lines (and room frames) are still handled
|
|
print(f"fetch {remote} -> {rel} failed: {ex}", flush=True)
|
|
state["rc"] = worst(state["rc"], 1)
|
|
elif line.startswith("@@hv "):
|
|
parts = line.split(" ", 3)
|
|
remote, name = parts[1], parts[2]
|
|
state["hv"].append(remote)
|
|
try:
|
|
opts = dict(json.loads(parts[3])) if len(parts) > 3 else {}
|
|
opts = {k: v for k, v in opts.items() if v} # the step's own --look/--rect (native-session)
|
|
opts.update({k: v for k, v in (hv_opts or {}).items() if v})
|
|
r = hv_measure(c, remote, name, opts)
|
|
state["rc"] = worst(state["rc"], r)
|
|
state["hv"].remove(remote)
|
|
except Exception as ex: # noqa: BLE001
|
|
print(f"hv {name}: {ex}", flush=True)
|
|
state["rc"] = worst(state["rc"], 1)
|
|
elif line.startswith("@@") and " " in line and results is not None:
|
|
kind, payload = line[2:].split(" ", 1)
|
|
try:
|
|
results.setdefault(kind, []).append(json.loads(payload))
|
|
except ValueError:
|
|
print(line, flush=True)
|
|
else:
|
|
print(line, flush=True)
|
|
e = ""
|
|
try:
|
|
code, e = sh_stream(c, f"{PY} {REMOTE}/lab/lab.py {q}", handle, timeout=timeout)
|
|
finally:
|
|
if state["hv"]: # announced room frames that were not handled: delete them on the Frame now
|
|
try:
|
|
sh(c, "rm -f " + " ".join(shlex.quote(p) for p in state["hv"]), echo=False)
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
if e:
|
|
sys.stderr.write(e)
|
|
return worst(code, state["rc"]) if code not in (0, None) else state["rc"]
|
|
|
|
|
|
def edge_probe(ep, L, pr, dpr):
|
|
"""One G-OUTLINE edge probe on any side of a shape: the side is turned into a 'top edge' (flip for the
|
|
bottom, transpose for left and right) and measured with WN 8.2's profile (review R1 M2)."""
|
|
side = pr.get("side", "top")
|
|
if side in ("top", "bottom"):
|
|
y, a0, a1 = int(round(pr["y"] * dpr)), int(pr["x0"] * dpr), int(pr["x1"] * dpr)
|
|
P, yy = (L[::-1, :], L.shape[0] - y) if side == "bottom" else (L, y)
|
|
else:
|
|
x, a0, a1 = int(round(pr["x"] * dpr)), int(pr["y0"] * dpr), int(pr["y1"] * dpr)
|
|
P, yy = (L.T[::-1, :], L.shape[1] - x) if side == "right" else (L.T, x)
|
|
# A side on the capture edge (REQ C3b->P10 (2)): Steam's popup textures draw their outermost row uniformly
|
|
# brighter on the stock UI too (every stock barpopup capture: last row 31 L over 19-26), so that texel is not
|
|
# the slab's edge. The band starts one texel inside; the rim rows above it are still measured.
|
|
capture_edge = yy <= 0
|
|
if capture_edge:
|
|
yy = 1
|
|
if a1 - a0 < 16 or yy < 0 or yy + 12 >= P.shape[0]:
|
|
return None
|
|
r = ep.edge(P, yy, a0, a1)
|
|
return {"el": pr["el"], "side": side, "at": int(round((pr["y"] if side in ("top", "bottom") else pr["x"]) * dpr)),
|
|
"from": a0, "to": a1, "ratio": r["ratio"], "brightest": r["brightest"], "edge": r["edge"],
|
|
"segments": r["segments"], "pass": r["pass"], **({"captureEdge": True} if capture_edge else {})}
|
|
|
|
|
|
def gates_finish(res, keep_name=None):
|
|
"""PC half of gates: edge profiles on the OUTLINE capture, overall pass. The capture is the one file the
|
|
result names (shotLocal); a missing capture is an error, not a pass (review R1 m3)."""
|
|
g = res["gates"]
|
|
o = g.get("OUTLINE")
|
|
if o:
|
|
rel = o.get("shotLocal") or (f"shots/{keep_name}.png" if keep_name else None)
|
|
path = (ROOT / rel) if rel else None
|
|
edges = []
|
|
if path and path.exists():
|
|
ep = p2_tool("edge_profile")
|
|
L = ep.lum(str(path), ep.W601)
|
|
dpr = float(o.get("dpr") or 1.5)
|
|
for pr in o.get("probes", []):
|
|
e = edge_probe(ep, L, pr, dpr)
|
|
if e:
|
|
# High Contrast draws its 2 px edge on purpose (D2's HC glass; P-42 exempts it, as the DOM part
|
|
# already does for rings, rims and lines): measured, not judged (REQ C2a-R2->P10 (5), session 5)
|
|
if o.get("highContrast"):
|
|
e["highContrast"] = True
|
|
e["judgedPass"] = e["pass"]
|
|
e["pass"] = True
|
|
edges.append(e)
|
|
o["shot"] = rel if keep_name else None
|
|
if not keep_name and path.name.startswith("_gates_tmp_"):
|
|
path.unlink()
|
|
else:
|
|
o["error"] = f"no OUTLINE capture ({rel or 'none announced'}): edge probes not measured"
|
|
o["edges"] = edges
|
|
o.pop("shotRemote", None)
|
|
o["pass"] = bool(o.get("pass")) and all(e["pass"] for e in edges) and not o.get("error")
|
|
res["pass"] = all(v.get("pass") for v in g.values())
|
|
return res
|
|
|
|
|
|
def gates_summary(res):
|
|
out = [f"gates {res['surface']}{' ' + res['route'] if res.get('route') else ''} "
|
|
f"(build {res.get('build')}, {res.get('date')}, native {res.get('native', '?')}, step {json.dumps(res.get('step'))})"]
|
|
for k, v in res["gates"].items():
|
|
n = v.get("failCount", len(v.get("fails", v.get("issues", []))))
|
|
extra = ""
|
|
if k == "MOTION":
|
|
n = len(v["nonToken"]) + len(v["atRest"]["running"]) + len(v["atRest"]["lgsLeft"]) + len(v["cssNonToken"])
|
|
if k == "OUTLINE":
|
|
sides = {}
|
|
for e in v.get("edges", []):
|
|
sides[e.get("side", "top")] = sides.get(e.get("side", "top"), 0) + 1
|
|
extra = (f", {len(v.get('edges', []))} edge probes ({', '.join(f'{n} {s}' for s, n in sides.items()) or 'none'})"
|
|
+ (f", {v.get('pseudoChecked')} pseudo-elements checked" if v.get("pseudoChecked") is not None else "")
|
|
+ (" (High Contrast: rings, rims, lines and edge profiles not judged, P-42)" if v.get("highContrast") else "")
|
|
+ (f"; ERROR {v['error']}" if v.get("error") else ""))
|
|
out.append(f" G-{k:8} {'PASS' if v.get('pass') else 'FAIL'} {n} findings{extra}"
|
|
+ (f", {len(v.get('exempt', []))} exempt" if v.get("exempt") else ""))
|
|
items = []
|
|
if k == "SIZE" and (v.get("skipped") or v.get("exemptFail")):
|
|
sk = {}
|
|
for x in v.get("skipped", []):
|
|
sk[x["why"]] = sk.get(x["why"], 0) + 1
|
|
out[-1] += (f", skipped {', '.join(f'{n} {w}' for w, n in sk.items())}" if sk else "") + (f", {v['exemptFail']} exemption(s) failing their own criterion" if v.get("exemptFail") else "")
|
|
if k == "SIZE" and v.get("inPlace"): # obscured, but no scroll room to bring them clear: judged (passed)
|
|
out[-1] += f", {len(v['inPlace'])} obscured judged in place (cannot scroll clear)"
|
|
if k in ("SIZE", "TYPE", "OUTLINE"):
|
|
items = [f"{f.get('rule', '')} {f['el']} {f.get('rect', '')}: {f['why']}" for f in v.get("fails", [])[:12]]
|
|
if k == "SIZE":
|
|
items += [f"{e['id']} {e['el']}: {e.get('why', '')}" for e in v.get("exempt", []) if e.get("pass") is False][:8]
|
|
if k == "OUTLINE":
|
|
items += [f"edge {e['el']} ({e.get('side', 'top')} at {e.get('at')}): ratio {e['ratio']}, brightest dL {e.get('brightest')}"
|
|
for e in v.get("edges", []) if not e["pass"]][:8]
|
|
elif k == "AUD":
|
|
items = v.get("issues", [])[:12]
|
|
if v.get("rematched"):
|
|
out[-1] += f", {v['rematched']} matched across a DOM move"
|
|
if v.get("preRerun"):
|
|
out[-1] += f", pre run again for the {' and '.join(v['preRerun'])} snapshot"
|
|
# In native mode the acked plates' CSS fills are transparent and glassd draws the backing, which AUD's
|
|
# contrast model (CSS backgrounds over the room) cannot see (session 5: T3's labels read 1.5 over the
|
|
# bright room in a step that ran during another agent's native session)
|
|
if res.get("native") == "on" and any(i.startswith("CONTRAST") for i in v.get("issues", [])):
|
|
items.append("(native layer on during this step: CONTRAST models only the CSS backing, not glassd's "
|
|
"glass; a CSS-only verdict needs a native=off run)")
|
|
elif k == "MOTION":
|
|
items = (v["nonToken"] + v["atRest"]["running"] + v["atRest"]["lgsLeft"] + v["cssNonToken"])[:12]
|
|
if v["atRest"].get("steamRunning"):
|
|
items.append("(Steam's own, not judged: " + "; ".join(v["atRest"]["steamRunning"][:3]) + ")")
|
|
out += [" " + i for i in items]
|
|
out.append(" overall: " + ("PASS" if res["pass"] else "FAIL"))
|
|
return "\n".join(out)
|
|
|
|
|
|
def cmd_gates(c, rest):
|
|
rest = list(rest)
|
|
as_json = "--json" in rest
|
|
keep = None
|
|
if "--shot" in rest:
|
|
keep = rest[rest.index("--shot") + 1]
|
|
results = {}
|
|
rc = lab_fetch(c, ["gates"] + rest, timeout=900, results=results, quiet_files=("shots/_gates_tmp_",))
|
|
if not results.get("gates"):
|
|
return rc or 3
|
|
res = gates_finish(results["gates"][0], keep)
|
|
print(json.dumps(res, indent=1) if as_json else gates_summary(res))
|
|
return 0 if res["pass"] else 1
|
|
|
|
|
|
def bfs_summary(r):
|
|
name = lambda i: (lambda n: f"#{i} {n['el']}{' \"' + n['text'][:24] + '\"' if n.get('text') else ''}")(r["nodes"][i]) \
|
|
if isinstance(i, int) and i < len(r["nodes"]) else str(i)
|
|
out = [f"pad-bfs {r.get('route')} (build {r.get('build')}, {r.get('date')}, native {r.get('native', '?')}): {len(r['nodes'])} nodes, "
|
|
f"{r.get('moves')} moves, {r.get('seconds')} s{', TRUNCATED' if r.get('truncated') else ''}",
|
|
f" entry focus: {r['entry']['el']} \"{r['entry'].get('text', '')[:30]}\" {r['entry']['rect']}"]
|
|
exits = sorted({v for e in r["edges"].values() for v in e.values()
|
|
if isinstance(v, str) and v.split(":")[0] in ("exit", "route")})
|
|
if exits:
|
|
out.append(" leaves the window or route: " + ", ".join(exits))
|
|
if r.get("exits"): # REQ C1b->P10 #10, C2a->P10 #14: how focus came back into main after each exit
|
|
how = {}
|
|
for x in r["exits"]:
|
|
how[x.get("back") or "LOST"] = how.get(x.get("back") or "LOST", 0) + 1
|
|
out.append(f" exits from main: {len(r['exits'])}, focus back by " + ", ".join(f"{k} {n}" for k, n in how.items())
|
|
+ " (opposite = the reverse press returned)")
|
|
out += [f" {name(x['from'])} -{x['dir']}-> {x['to']}, back: {x.get('back') or 'LOST'}" for x in r["exits"][:8]]
|
|
# nodes whose focus could not be taken (session 5: the symptom of REQ C1b->P10 #10 / C2a #14), and takes that
|
|
# needed main's nav tree activated again first
|
|
untk = [int(i) if str(i).isdigit() else i for i, e in r["edges"].items() if "untakeable" in e.values()]
|
|
if untk or r.get("retook"):
|
|
out.append(f" untakeable nodes: {len(untk)}; takes that needed main's nav tree activated again: {r.get('retook', 0)}")
|
|
out += [f" {name(i)}" for i in untk[:6]]
|
|
out.append(f" routes visited: {', '.join(r.get('routes', []))}")
|
|
out.append(f" unreached visible focusables: {len(r['unreached'])} of {r.get('universe', '?')}")
|
|
if r.get("notFocusable"): # Steam's focusable: false nav nodes (laser-only): not G-PAD targets (session 5)
|
|
nf = r["notFocusable"]
|
|
out.append(f" not gamepad-focusable (Steam's focusable: false, laser-only; not counted): {len(nf)}: "
|
|
+ ", ".join(f"{u['el'][:40]} {u.get('rect')}" for u in nf[:4]))
|
|
out += [f" {u['el']} \"{u.get('text', '')[:30]}\" {u['rect']}" for u in r["unreached"][:12]]
|
|
if r.get("inner"): # REQ C2a-R2->P10 (6): role=button etc. with only an ancestor's nav node (laser targets)
|
|
inn = r["inner"]
|
|
out.append(f" inside a gamepad target, no nav node of their own (laser-only; not counted): {len(inn)}: "
|
|
+ ", ".join(f"{u['el'][:40]} {u.get('rect')}" for u in inn[:4]))
|
|
out.append(f" irreversible moves: {len(r['irreversible'])}")
|
|
out += [f" {name(x['from'])} -{x['dir']}-> {name(x['to'])}, back -> {name(x['back'])}"
|
|
+ (f"; re-measured from a D-pad path: {x['recheck'] if isinstance(x['recheck'], str) else 'back -> ' + name(x['recheck'])}"
|
|
if x.get("recheck") is not None else "") for x in r["irreversible"][:12]]
|
|
if r.get("takeOrder"): # REQ C2a-R2->P10 (8): irreversible only after a direct take; reversible on a D-pad path
|
|
out.append(f" sweep take-order only (reversible when the source is reached by D-pad; not counted): {len(r['takeOrder'])}")
|
|
out += [f" {name(x['from'])} -{x['dir']}-> {name(x['to'])}, first back -> {name(x['back'])}"
|
|
+ (f"; on the D-pad path -{x['dir']}-> {name(x['userTo'])} and back" if x.get("userTo") is not None else "; on the D-pad path no move")
|
|
for x in r["takeOrder"][:6]]
|
|
if r.get("untested"):
|
|
out.append(f" reversibility not tested (slider): {len(r['untested'])}")
|
|
if r.get("b"):
|
|
out.append(f" B from the entry focus: {r['b']['effect']}")
|
|
out.append(" overall: " + ("PASS" if r.get("pass") else "FAIL"))
|
|
return "\n".join(out)
|
|
|
|
|
|
def redact_text(t):
|
|
"""Element texts in saved files can hold personal data (friend names in feeds, a serial number, a Wi-Fi
|
|
name): the same text always maps to the same short hash, so saved keys still compare."""
|
|
import hashlib
|
|
if not t or re.match(r"^#[0-9a-f]{8}$", t): # empty, or already redacted
|
|
return t
|
|
return "#" + hashlib.sha1(t.encode("utf-8")).hexdigest()[:8]
|
|
|
|
|
|
def redact_bfs(r):
|
|
r = json.loads(json.dumps(r))
|
|
|
|
def fix(d):
|
|
if isinstance(d, dict):
|
|
if "text" in d:
|
|
d["text"] = redact_text(d["text"])
|
|
if isinstance(d.get("key"), str) and "|" in d["key"]:
|
|
c, _, rest = d["key"].partition("|")
|
|
m = re.match(r"^(.*)#(\d+)$", rest, re.S)
|
|
lab_, n = (m.group(1), "#" + m.group(2)) if m else (rest, "")
|
|
d["key"] = f"{c}|{redact_text(lab_)}{n}"
|
|
for n in r.get("nodes", []):
|
|
fix(n)
|
|
for u in r.get("unreached", []) + r.get("notFocusable", []) + r.get("inner", []):
|
|
fix(u)
|
|
fix(r.get("entry") or {})
|
|
fix((r.get("init") or {}).get("entry") or {})
|
|
r["redacted"] = True
|
|
return r
|
|
|
|
|
|
def cmd_pad_bfs(c, rest):
|
|
rest = list(rest)
|
|
as_json = "--json" in rest
|
|
raw = "--raw" in rest
|
|
if raw:
|
|
rest.remove("--raw")
|
|
outp = None
|
|
if "--out" in rest:
|
|
i = rest.index("--out")
|
|
outp = rest[i + 1]
|
|
del rest[i:i + 2]
|
|
results = {}
|
|
rc = lab_fetch(c, ["pad-bfs"] + rest, timeout=600, results=results)
|
|
if not results.get("bfs"):
|
|
return rc or 3
|
|
r = results["bfs"][0]
|
|
if outp: # texts hashed unless --raw (personal data, PLAN 7 rule 5)
|
|
Path(outp).write_text(json.dumps(r if raw else redact_bfs(r), indent=1), encoding="utf-8")
|
|
print(json.dumps(r, indent=1) if as_json else bfs_summary(r))
|
|
return 0 if r.get("pass") else 1
|
|
|
|
|
|
def focus_region(side, st, dpr):
|
|
"""Region spec (edge_profile.mask syntax) for one side of a pair, in shot px."""
|
|
shape = side.get("shape", "rect")
|
|
if side.get("rect"):
|
|
x0, y0, x1, y1 = [float(v) for v in side["rect"]]
|
|
else:
|
|
r = (st.get("rects") or {}).get(side.get("sel") or "")
|
|
if not r or "rect" not in r:
|
|
raise ValueError(f"{side.get('sel')}: {(r or {}).get('error', 'no rect')}")
|
|
x, y, w, h = r["rect"]
|
|
x0, y0, x1, y1 = x, y, x + w, y + h
|
|
if side.get("band"):
|
|
d0, d1 = [float(v) * dpr for v in side["band"]]
|
|
return f"band:{x0:.1f},{y0:.1f},{x1:.1f},{y1:.1f},{d0:.1f},{d1:.1f}", 0
|
|
inset = float(side.get("inset", 6))
|
|
if shape == "circle":
|
|
return f"circle:{(x0 + x1) / 2:.1f},{(y0 + y1) / 2:.1f},{min(x1 - x0, y1 - y0) / 2:.1f}", inset
|
|
if shape == "pill":
|
|
return f"pill:{x0:.1f},{y0:.1f},{x1:.1f},{y1:.1f}", inset
|
|
return f"rect:{x0:.1f},{y0:.1f},{x1:.1f},{y1:.1f}", inset
|
|
|
|
|
|
def focus_finish(res, pairs=None):
|
|
"""PC half of focus: luma of each pair from the captures (VP SHOT), then the temporary captures go."""
|
|
pairs = pairs if pairs is not None else res.get("pairsIn", [])
|
|
keep = res.get("keep")
|
|
ep = p2_tool("edge_profile")
|
|
# What translucent texels are seen over (REQ C2a-R2->P10 (3), session 5): a live capture keeps the window's
|
|
# alpha, and a windowless route's band is mostly transparent; read as stored it is black (P-16 band +228 where
|
|
# a bright room gives +8.9). `auto` measures such a pair over P-14's bright and dark test rooms and judges the
|
|
# worse; `--room R` picks one; `none` is the behaviour before session 5.
|
|
room = ep.parse_room(res.get("room") or "auto")
|
|
planes, alphas = {}, {}
|
|
|
|
def plane(path, rm):
|
|
k = (str(path), ep.room_name(rm))
|
|
if k not in planes:
|
|
planes[k] = ep.lum(str(path), ep.W601, rm)
|
|
return planes[k]
|
|
out = []
|
|
for p in pairs:
|
|
row = {"name": p.get("name"), "min": float(p.get("min", 40))}
|
|
try:
|
|
sides = []
|
|
for side in ("a", "b"):
|
|
sd = p[side]
|
|
st = next(s for s in res["states"] if s["state"] == (sd.get("state") or ""))
|
|
path = ROOT / st["file"]
|
|
if str(path) not in alphas:
|
|
alphas[str(path)] = ep.alpha_plane(str(path))
|
|
spec, inset = focus_region(sd, st, float(st.get("dpr") or 1.5))
|
|
sides.append((path, spec, inset))
|
|
row[side + "Region"] = spec
|
|
(pa, sa, ia), (pb, sb, ib) = sides
|
|
share = max(ep.translucent_share(alphas[str(pa)], sa, ia), ep.translucent_share(alphas[str(pb)], sb, ib))
|
|
per = {}
|
|
for rm in ep.rooms_for(room, share):
|
|
la = ep.region_mean(plane(pa, rm), sa, ia)
|
|
lb = ep.region_mean(plane(pb, rm), sb, ib)
|
|
per[ep.room_name(rm)] = (la, lb)
|
|
worst = min(per, key=lambda k: per[k][0] - per[k][1])
|
|
la, lb = per[worst]
|
|
row.update({"La": round(la, 1), "Lb": round(lb, 1), "dL": round(la - lb, 1), "room": worst,
|
|
"rooms": {k: round(v[0] - v[1], 1) for k, v in per.items()}, "translucent": round(share, 3)})
|
|
row["pass"] = row["dL"] >= row["min"]
|
|
except (ValueError, StopIteration, KeyError) as e:
|
|
row.update({"error": str(e), "pass": False})
|
|
out.append(row)
|
|
if not keep:
|
|
for s in res["states"]:
|
|
f = ROOT / s["file"]
|
|
if f.exists():
|
|
f.unlink()
|
|
res["pairs"] = out
|
|
res["pass"] = bool(out) and all(r["pass"] for r in out)
|
|
return res
|
|
|
|
|
|
def focus_summary(res):
|
|
out = [f"focus {res['surface']}{' ' + res['route'] if res.get('route') else ''} (build {res.get('build')}, "
|
|
f"{res.get('date')}, step {json.dumps(res.get('step'))})"]
|
|
for i, st in enumerate(res["states"]):
|
|
if st.get("error"):
|
|
out.append(f" state {i} ({st['state'][:60]}): ERROR {st['error']}")
|
|
for r in res["pairs"]:
|
|
if "error" in r:
|
|
out.append(f" {r['name']}: ERROR {r['error']}")
|
|
else:
|
|
over = "" if list(r.get("rooms") or {"stored": 0}) == ["stored"] else \
|
|
(" over " + ", ".join(f"{k} {v:+.1f}" for k, v in r["rooms"].items())
|
|
+ f" (translucent {r.get('translucent', 0):.0%}, judged: {r['room']})")
|
|
out.append(f" {r['name']}: L(A) {r['La']:.1f} L(B) {r['Lb']:.1f} dL {r['dL']:+.1f} (min {r['min']:g}){over} "
|
|
+ ("PASS" if r["pass"] else "FAIL"))
|
|
if res.get("keep"):
|
|
out.append(" shots: " + ", ".join(s["file"] for s in res["states"]))
|
|
out.append(" overall: " + ("PASS" if res["pass"] else "FAIL"))
|
|
return "\n".join(out)
|
|
|
|
|
|
def cmd_focus(c, rest):
|
|
"""glass.py focus SURF --pairs FILE|JSON (live, contracts/lab.md section 4)."""
|
|
rest = list(rest)
|
|
as_json = "--json" in rest
|
|
if as_json:
|
|
rest.remove("--json")
|
|
keep = None
|
|
if "--keep" in rest:
|
|
i = rest.index("--keep")
|
|
keep = rest[i + 1] if i + 1 < len(rest) and not rest[i + 1].startswith("--") else None
|
|
del rest[i:i + (2 if keep else 1)]
|
|
keep = keep or "p2_focus"
|
|
if "--pairs" not in rest:
|
|
print("usage: glass.py focus SURF [--route R] [--pre JS] --pairs FILE|JSON [--keep [NAME]] [--room auto|none|bright|dark|grey|black|R,G,B] [--json]")
|
|
return 2
|
|
if "--room" in rest:
|
|
try:
|
|
p2_tool("edge_profile").parse_room(rest[rest.index("--room") + 1])
|
|
except (ValueError, IndexError) as e:
|
|
print(e)
|
|
return 2
|
|
i = rest.index("--pairs")
|
|
src = rest[i + 1]
|
|
pairs = json.loads(Path(src).read_text(encoding="utf-8") if Path(src).exists() else src)
|
|
del rest[i:i + 2]
|
|
args = ["focus"] + rest + ["--pairs-json", json.dumps(pairs)] + (["--keep", keep] if keep else [])
|
|
results = {}
|
|
rc = lab_fetch(c, args, timeout=600, results=results, quiet_files=("shots/_focus_",))
|
|
if not results.get("focus"):
|
|
return rc or 3
|
|
res = focus_finish(results["focus"][0], pairs)
|
|
print(json.dumps(res, indent=1) if as_json else focus_summary(res))
|
|
return 0 if res["pass"] else 1
|
|
|
|
|
|
def motion_finish(res):
|
|
"""PC half of motion: geometry checks from the per-frame rects, Reduce Motion rules, overall verdict,
|
|
and a contact strip shots/p2_motion_<name>_strip.png (the frames side by side, cropped to the
|
|
animated region) for the agent who judges the filmstrip."""
|
|
anims = res.get("animations") or []
|
|
frames = res.get("frames") or []
|
|
width = float(res.get("width") or 1280)
|
|
geo = {}
|
|
if frames:
|
|
last = frames[-1]["rects"]
|
|
for k in last:
|
|
series = [fr["rects"].get(k) for fr in frames if fr["rects"].get(k)]
|
|
if not series:
|
|
continue
|
|
x1, y1, w1, h1 = last[k]
|
|
dx = max(abs(r[0] + r[2] / 2 - (x1 + w1 / 2)) for r in series)
|
|
dy = max(abs(r[1] + r[3] / 2 - (y1 + h1 / 2)) for r in series)
|
|
sc = [r[2] / w1 for r in series if w1 > 0]
|
|
a = next((a for a in anims if str(a.get("id")) == str(k)), {})
|
|
geo[k] = {"target": a.get("target"), "name": a.get("name"), "maxDx": round(dx, 1), "maxDy": round(dy, 1),
|
|
"scaleMin": round(min(sc), 4) if sc else None, "scaleMax": round(max(sc), 4) if sc else None,
|
|
"w": w1, "text": a.get("text"), "entering": a.get("entering")}
|
|
issues, moves = [], []
|
|
# P-53's 600 px are main-window px: 600 x m on a surface with D2 2.5's multiplier m (the bar's popups: 498 bar
|
|
# px, so the 288 px "+" list is no window). Until session 5 the threshold was scaled by the surface's width
|
|
# (600 x 300 / 1280 = 141 on the popup), which judged a popup's materialize scale as a window scale change.
|
|
mult = res.get("m")
|
|
thr = 600 * float(mult) if isinstance(mult, (int, float)) and mult > 0 else 600 * (width / 1280.0)
|
|
for k, g in geo.items():
|
|
wide = (g["w"] or 0) > thr
|
|
if wide and g["maxDx"] > 24:
|
|
issues.append(f"P-53 {g['name']} on {g['target']}: lateral travel {g['maxDx']} px on a {g['w']:.0f} px surface")
|
|
if wide and g["scaleMin"] is not None and (g["scaleMin"] < 0.985 or g["scaleMax"] > 1.015):
|
|
issues.append(f"P-53 {g['name']} on {g['target']}: window scale {g['scaleMin']}..{g['scaleMax']}")
|
|
if max(g["maxDx"], g["maxDy"]) > 16:
|
|
# P-54 judges what enters (a target not shown before the interaction; unknown counts as entering). A
|
|
# target that was shown and travels (a selection pill) is a move: listed, P-53 judges it on wide
|
|
# surfaces (REQ C2a-R2->P10 (4), session 5)
|
|
if g.get("entering") is False:
|
|
moves.append(f"{g['name']} on {g['target']}: travels {max(g['maxDx'], g['maxDy'])} px "
|
|
"(shown before the interaction: a move, not an entry)")
|
|
else:
|
|
issues.append(f"P-54 {g['name']} on {g['target']}: starts {max(g['maxDx'], g['maxDy'])} px from rest"
|
|
+ ("" if g.get("entering") else " (entry not known: an older lab)"))
|
|
reduce = "reduce" in (res.get("step") or {}).get("media", [])
|
|
rm = []
|
|
if reduce:
|
|
for a in anims:
|
|
pr = [p for p in a.get("props", []) if p not in ("opacity",)]
|
|
if pr:
|
|
rm.append(f"P-56 {a['name']} on {a['target']}: animates {', '.join(pr)} under Reduce Motion")
|
|
if (a.get("duration") or 0) > 200:
|
|
rm.append(f"P-56 {a['name']} on {a['target']}: {a['duration']} ms > 200 under Reduce Motion")
|
|
rest = res.get("atRest") or {}
|
|
res["geometry"] = geo
|
|
res["geometryIssues"] = issues
|
|
res["moves"] = moves
|
|
res["reduceIssues"] = rm
|
|
res["pass"] = not res.get("nonToken") and not rest.get("running") and not rest.get("lgsLeft") \
|
|
and not rest.get("infinite") and not issues and not rm
|
|
st = res.get("selftest")
|
|
if st is not None:
|
|
# The probe's boxes are white over opaque black, linear 0 -> 1: their luma / 255 is the frame's f.
|
|
try:
|
|
ep = p2_tool("edge_profile")
|
|
bx = st["boxes"]
|
|
dpr = float(bx.get("dpr") or 1.5)
|
|
rows = []
|
|
for fr in frames:
|
|
L = ep.lum(str(ROOT / fr["file"]), ep.W601)
|
|
v = {}
|
|
for k in ("a", "b"):
|
|
x, y, w, h = bx[k]
|
|
v[k] = ep.region_mean(L, f"rect:{x * dpr:.1f},{y * dpr:.1f},{(x + w) * dpr:.1f},{(y + h) * dpr:.1f}", 8) / 255.0
|
|
rows.append({"f": fr["f"], "waapi": round(v["a"], 3), "css": round(v["b"], 3)})
|
|
st["rows"] = rows
|
|
st["limit"] = 0.05
|
|
st["maxErr"] = round(max(max(abs(r["waapi"] - r["f"]), abs(r["css"] - r["f"])) for r in rows), 3) if rows else None
|
|
st["pass"] = bool(rows) and st["maxErr"] <= st["limit"]
|
|
except Exception as e: # noqa: BLE001
|
|
st["error"] = str(e)
|
|
st["pass"] = False
|
|
res["pass"] = st["pass"] # the self-test is judged by its ramp only
|
|
# contact strip
|
|
try:
|
|
from PIL import Image, ImageDraw
|
|
ims = [Image.open(ROOT / f["file"]).convert("RGB") for f in frames if (ROOT / f["file"]).exists()]
|
|
if ims:
|
|
dpr = float(res.get("dpr") or 1.5)
|
|
boxes = [r for fr in frames for r in fr["rects"].values()]
|
|
if boxes:
|
|
x0 = max(0, min(b[0] for b in boxes) * dpr - 24)
|
|
y0 = max(0, min(b[1] for b in boxes) * dpr - 24)
|
|
x1 = min(ims[0].width, max(b[0] + b[2] for b in boxes) * dpr + 24)
|
|
y1 = min(ims[0].height, max(b[1] + b[3] for b in boxes) * dpr + 24)
|
|
if x1 - x0 < 64 or y1 - y0 < 64:
|
|
x0, y0, x1, y1 = 0, 0, ims[0].width, ims[0].height
|
|
else:
|
|
x0, y0, x1, y1 = 0, 0, ims[0].width, ims[0].height
|
|
crops = [im.crop((int(x0), int(y0), int(x1), int(y1))) for im in ims]
|
|
sc = min(1.0, 640.0 / max(1, crops[0].width))
|
|
crops = [c.resize((max(1, int(c.width * sc)), max(1, int(c.height * sc)))) for c in crops]
|
|
W = sum(c.width for c in crops) + 8 * (len(crops) - 1)
|
|
strip = Image.new("RGB", (W, crops[0].height + 28), (16, 16, 16))
|
|
d = ImageDraw.Draw(strip)
|
|
try:
|
|
from PIL import ImageFont
|
|
font = ImageFont.load_default(size=18)
|
|
except (TypeError, OSError):
|
|
font = None
|
|
x = 0
|
|
for c, fr in zip(crops, frames):
|
|
strip.paste(c, (x, 28))
|
|
d.text((x + 6, 4), f"f = {fr['f']:g}", fill=(235, 235, 235), font=font)
|
|
x += c.width + 8
|
|
out = SHOTS / f"p2_motion_{res['name']}_strip.png"
|
|
strip.save(out)
|
|
res["strip"] = str(out.relative_to(ROOT))
|
|
except Exception as e: # noqa: BLE001
|
|
res["stripError"] = str(e)
|
|
return res
|
|
|
|
|
|
def motion_summary(res):
|
|
out = [f"motion {res['surface']} {res.get('route') or ''} '{res['name']}' (build {res.get('build')}, {res.get('date')}, "
|
|
f"native {res.get('native', '?')}, step {json.dumps(res.get('step'))})"]
|
|
if res.get("freezeError"):
|
|
out.append(f" timeline freeze: ERROR {res['freezeError']}")
|
|
if res.get("freeze"):
|
|
bad = [f"f {fr['f']:g}" for fr in res.get("frames", []) if fr.get("raf") is False]
|
|
out.append(f" freeze: {res['freeze']} (paused {res.get('frozen')} after the pre, released {res.get('released')})"
|
|
+ (f"; no animation frame before {', '.join(bad)} (capture may be stale)" if bad else ""))
|
|
st = res.get("selftest")
|
|
if st:
|
|
out.append(f" SELF-TEST two-box probe, linear {st.get('ms'):g} ms: max error {st.get('maxErr')} "
|
|
f"(limit {st.get('limit')}): {'PASS' if st.get('pass') else 'FAIL'}")
|
|
for r in st.get("rows", []):
|
|
out.append(f" f {r['f']:<5g} WAAPI {r['waapi']:.3f} CSS transition {r['css']:.3f}")
|
|
if st.get("error"):
|
|
out.append(f" ERROR {st['error']}")
|
|
anims = res.get("animations") or []
|
|
out.append(f" animations started by the pre: {len(anims)}" + (" (none: nothing to judge)" if not anims else ""))
|
|
for a in anims[:20]:
|
|
out.append(f" {a['name']} on {a['target']}: {a['duration']} ms, delay {a.get('delay')}, {str(a.get('easing'))[:40]}, "
|
|
f"props {','.join(a.get('props', []))[:50]}")
|
|
out.append(f" non-token durations/easings (P-58): {len(res.get('nonToken') or [])}")
|
|
out += [" " + x for x in (res.get("nonToken") or [])[:12]]
|
|
out.append(f" geometry (P-53, P-54): {len(res['geometryIssues'])} issues")
|
|
out += [" " + x for x in res["geometryIssues"][:12]]
|
|
if res.get("moves"):
|
|
out.append(f" moves (shown before, travel > 16 px; listed, not P-54): {len(res['moves'])}")
|
|
out += [" " + x for x in res["moves"][:8]]
|
|
if "reduce" in (res.get("step") or {}).get("media", []):
|
|
out.append(f" Reduce Motion (P-56): {len(res['reduceIssues'])} issues")
|
|
out += [" " + x for x in res["reduceIssues"][:12]]
|
|
r = res.get("atRest") or {}
|
|
out.append(f" 1 s later (P-52): {len(r.get('running', []))} running, {len(r.get('lgsLeft', []))} lgs-* left, "
|
|
f"{len(r.get('infinite', []))} infinite")
|
|
out += [" " + x for x in (r.get("running", []) + r.get("lgsLeft", []))[:8]]
|
|
out.append(" frames: " + ", ".join(f["file"] for f in res.get("frames", [])))
|
|
if res.get("strip"):
|
|
out.append(f" strip: {res['strip']} (view it: glass before content on entry, content before glass on exit, "
|
|
"no text scaling, no closed outline)")
|
|
out.append(" overall: " + ("PASS" if res["pass"] else "FAIL") + " (automatic part; the strip verdict is the viewer's)")
|
|
return "\n".join(out)
|
|
|
|
|
|
def cmd_motion(c, rest):
|
|
rest = list(rest)
|
|
as_json = "--json" in rest
|
|
results = {}
|
|
rc = lab_fetch(c, ["motion"] + rest, timeout=600, results=results)
|
|
if not results.get("motion"):
|
|
return rc or 3
|
|
res = motion_finish(results["motion"][0])
|
|
print(json.dumps(res, indent=1) if as_json else motion_summary(res))
|
|
return 0 if res["pass"] else 1
|
|
|
|
|
|
def finish_results(results):
|
|
"""PC halves of the measuring commands for results a native-session announced (@@<kind> lines).
|
|
Returns the worst exit code (1 fail over 3 blocked over 0 pass)."""
|
|
rc = 0
|
|
for kind, items in results.items():
|
|
for r in items:
|
|
try:
|
|
if kind == "conf": # a conformance step: with the native layer's depth model (review R1 m5)
|
|
rc = worst(rc, p2_tool("conformance").finish_one(r))
|
|
continue
|
|
if kind == "gates":
|
|
r = gates_finish(r, r.get("keep"))
|
|
print(gates_summary(r))
|
|
elif kind == "bfs":
|
|
print(bfs_summary(r))
|
|
elif kind == "focus":
|
|
r = focus_finish(r)
|
|
print(focus_summary(r))
|
|
elif kind == "motion":
|
|
r = motion_finish(r)
|
|
print(motion_summary(r))
|
|
elif kind == "sgmodel":
|
|
sg = p2_tool("sgcheck")
|
|
res = sg.check_live(r)
|
|
print(sg.summary(res, f"sgcheck {r.get('route') or ''} (native, build {r.get('build')}, "
|
|
f"{r.get('date')}, {res.get('nodes')} scene-graph nodes)"))
|
|
r = res
|
|
else:
|
|
print(f"@@{kind} " + json.dumps(r))
|
|
continue
|
|
except Exception as e: # noqa: BLE001 - one bad result must not hide the others
|
|
print(f"{kind}: could not finish the result: {e}")
|
|
rc = worst(rc, 1)
|
|
continue
|
|
rc = worst(rc, 0 if r.get("pass") else 1)
|
|
return rc
|
|
|
|
|
|
def cmd_native_session(c, rest):
|
|
results = {}
|
|
rc = lab_fetch(c, ["native-session"] + rest, timeout=2400, results=results,
|
|
quiet_files=("shots/_gates_tmp_", "shots/_focus_"))
|
|
return worst(rc, finish_results(results))
|
|
|
|
|
|
def cmd_hv(c, rest):
|
|
rest = list(rest)
|
|
if "--clean" in rest:
|
|
hv_clean(quiet=False, all_looks=True)
|
|
sh(c, "rm -f /tmp/lgs/hv-*.png", echo=False)
|
|
return 0
|
|
opts = {"look": "--look" in rest} # --full passes through to the device (scale 1)
|
|
if "--look" in rest:
|
|
rest.remove("--look")
|
|
if "--rect" in rest:
|
|
i = rest.index("--rect")
|
|
opts["rect"] = [int(float(v)) for v in rest[i + 1].split(",")]
|
|
del rest[i:i + 2]
|
|
return lab_fetch(c, ["hv-grab"] + rest, timeout=180, hv_opts=opts)
|
|
|
|
|
|
def cmd_cmp(rest):
|
|
"""cmp works offline when it has the live shot and rects; it connects only for the live step."""
|
|
state = {}
|
|
|
|
def live_fn(args):
|
|
if "c" not in state:
|
|
state["c"] = connect()
|
|
results = {}
|
|
lab_fetch(state["c"], ["cmp-rects"] + args, timeout=300, results=results)
|
|
return (results.get("cmprects") or [None])[0]
|
|
try:
|
|
return p2_tool("cmp").run(rest, live_fn)
|
|
finally:
|
|
if "c" in state:
|
|
state["c"].close()
|
|
|
|
|
|
# Offline commands: no device connection.
|
|
P2_OFFLINE = {"cmp": cmd_cmp, "ledger": lambda rest: p2_tool("ledger").main(rest)}
|
|
P2_OFFLINE.update({"check-theme": cmd_check_theme, "edge": cmd_edge})
|
|
# Live commands: get the SSH client first.
|
|
def cmd_conformance(c, rest):
|
|
def lab_conf(args):
|
|
results = {}
|
|
lab_fetch(c, ["conformance"] + args, timeout=600, results=results)
|
|
return (results.get("conf") or [None])[0]
|
|
|
|
def lab_bfs(args):
|
|
results = {}
|
|
lab_fetch(c, ["pad-bfs", "--budget", "150"] + args, timeout=600, results=results)
|
|
return (results.get("bfs") or [None])[0]
|
|
return p2_tool("conformance").run(rest, lab_conf, lab_bfs)
|
|
|
|
|
|
P2_LIVE = {}
|
|
P2_LIVE.update({"native-session": cmd_native_session, "hv": cmd_hv, "gates": cmd_gates, "pad-bfs": cmd_pad_bfs,
|
|
"focus": cmd_focus, "motion": cmd_motion, "sgcheck": cmd_sgcheck, "conformance": cmd_conformance})
|
|
|
|
|
|
def main(argv):
|
|
argv = [argv[0]] + [unmangle(a) for a in argv[1:]]
|
|
if len(argv) < 2 or argv[1] in ("-h", "--help"):
|
|
print(__doc__)
|
|
return 0
|
|
cmd, rest = argv[1], argv[2:]
|
|
hv_clean(quiet=False) # kept --look frames older than HV_LOOK_S s (their own timer normally got them)
|
|
if cmd in P2_OFFLINE:
|
|
return P2_OFFLINE[cmd](rest)
|
|
if cmd == "focus" and "--png" in rest: # offline form: luma pairs in an image
|
|
return p2_tool("edge_profile").main_focus_png(rest)
|
|
if cmd == "sgcheck" and "--spec" in rest: # offline form: rules over a check model
|
|
return P2_SGCHECK_OFFLINE(rest)
|
|
c = connect()
|
|
try:
|
|
if cmd == "check":
|
|
sh(c, "hostname; uptime; curl -s --max-time 3 http://127.0.0.1:8080/json/version | head -3; "
|
|
f"ls {REMOTE} 2>/dev/null; ls -la {DESKTOP} 2>/dev/null")
|
|
elif cmd == "sync":
|
|
bad = [x for x in rest if x not in ("device", "theme", "lab", "native")]
|
|
if bad:
|
|
print("usage: glass.py sync [device|theme|lab|native]...")
|
|
return 2
|
|
sync(c, only=rest or None)
|
|
elif cmd == "install":
|
|
sync(c)
|
|
# native glass out of the box: the shipped glassd goes in place first (the Liquid Glass toggle
|
|
# may run at once), then a build from these sources replaces it; a failed build keeps it
|
|
code = install_glassd(c)
|
|
if code:
|
|
return code
|
|
sh(c, f"install -Dm644 {REMOTE}/device/glass-shell.desktop {DESKTOP} && "
|
|
f"chmod 755 {REMOTE}/device/lgs {REMOTE}/device/lgs.py && "
|
|
f"(update-desktop-database ~/.local/share/applications >/dev/null 2>&1 || true) && "
|
|
f"echo installed {DESKTOP}")
|
|
elif cmd == "uninstall":
|
|
lgs(c, "off", "--quiet")
|
|
sh(c, f"rm -f {DESKTOP} && rm -rf {REMOTE} /tmp/lgs \"${{XDG_CONFIG_HOME:-$HOME/.config}}/glass-shell\" "
|
|
"&& echo removed")
|
|
elif cmd in ("on", "off", "toggle", "reload", "status", "toast", "dial"):
|
|
if cmd in ("on", "reload", "toggle"):
|
|
sync(c)
|
|
return lgs(c, cmd, *rest)
|
|
elif cmd == "shot":
|
|
surface, name = rest[0], rest[1]
|
|
remote = f"/tmp/lgs/shots/{name}-{os.getpid()}.png" # under /tmp/lgs (hard rule 7)
|
|
code, o, e = lab(c, "shot", surface, remote, *rest[2:])
|
|
if code:
|
|
return code
|
|
SHOTS.mkdir(exist_ok=True)
|
|
local = SHOTS / f"{name}.png"
|
|
sftp = c.open_sftp()
|
|
sftp.get(remote, str(local))
|
|
sftp.remove(remote)
|
|
sftp.close()
|
|
print(f"saved {local}")
|
|
elif cmd == "native-build":
|
|
sync(c)
|
|
return native_build(c, fake=bool(rest and rest[0] == "fake"))
|
|
elif cmd == "shell":
|
|
sub = rest[0] if rest else "status"
|
|
if sub == "log":
|
|
return sh(c, "journalctl --user -u lgs-shell --no-pager -n 80 -o cat", timeout=30)[0]
|
|
if sub not in ("status", "start", "stop"):
|
|
print(__doc__)
|
|
return 2
|
|
extra = " ".join(shlex.quote(a) for a in rest[1:]) # start: --native, --glassd-args "...", --stay
|
|
return sh(c, f"{PY} {REMOTE}/device/lgs_shell.py {sub} {extra}".rstrip(), timeout=60)[0]
|
|
elif cmd == "selftest":
|
|
# P1's runtime acceptance tests (REQ P1->P10): each test takes the lab lock for its own steps, so
|
|
# no lock here; the exit code is kept and the JSON copied to shots/p1-selftest.json.
|
|
q = " ".join(shlex.quote(a) for a in rest)
|
|
code, _, _ = sh(c, f"{PY} {REMOTE}/device/lgs.py selftest {q}".rstrip(), timeout=1200)
|
|
SHOTS.mkdir(exist_ok=True)
|
|
sftp = c.open_sftp()
|
|
try:
|
|
sftp.get("/tmp/lgs/p1-selftest.json", str(SHOTS / "p1-selftest.json"))
|
|
print(f"saved {SHOTS / 'p1-selftest.json'}")
|
|
except OSError:
|
|
print("(no /tmp/lgs/p1-selftest.json)")
|
|
finally:
|
|
sftp.close()
|
|
return code
|
|
elif cmd == "logs":
|
|
sh(c, "tail -n 60 /tmp/lgs/lgs.log 2>/dev/null")
|
|
elif cmd in ("outline", "styles", "classes", "click", "js", "route", "nav", "back", "surfaces", "eval", "audit", "perf"):
|
|
code, _, _ = lab(c, cmd, *rest)
|
|
return code
|
|
elif cmd in P2_LIVE:
|
|
return P2_LIVE[cmd](c, rest)
|
|
else:
|
|
print(__doc__)
|
|
return 2
|
|
finally:
|
|
c.close()
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
for stream in (sys.stdout, sys.stderr): # Steam UI text is full of non-cp1252 glyphs
|
|
try:
|
|
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
except AttributeError:
|
|
pass
|
|
sys.exit(main(sys.argv))
|