185 lines
8.1 KiB
Python
185 lines
8.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Drive the selected LinuxCNC AXIS simulation through the desktop UI.
|
|
|
|
This script uses Xlib events only for the local simulation window. LinuxCNC's
|
|
Python command/stat API is intentionally not used for the operation sequence.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from Xlib import X, XK, display, Xatom
|
|
from Xlib.ext import xtest
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
OUTPUT = ROOT / "coverage/axis3-tutorial-manual-ui-verification.report.json"
|
|
SCREENSHOT = ROOT / "coverage/axis3-tutorial-manual-ui.png"
|
|
CASE_TITLE = "3axis-tutorial"
|
|
|
|
|
|
def focus(window) -> None:
|
|
d = display.Display()
|
|
window.set_input_focus(X.RevertToParent, X.CurrentTime)
|
|
d.sync()
|
|
d.close()
|
|
|
|
|
|
def key(window, name: str) -> None:
|
|
d = display.Display()
|
|
window.set_input_focus(X.RevertToParent, X.CurrentTime)
|
|
code = d.keysym_to_keycode(XK.string_to_keysym(name))
|
|
xtest.fake_input(d, X.KeyPress, code)
|
|
xtest.fake_input(d, X.KeyRelease, code)
|
|
d.sync()
|
|
d.close()
|
|
|
|
|
|
def ctrl_home(window) -> None:
|
|
d = display.Display()
|
|
window.set_input_focus(X.RevertToParent, X.CurrentTime)
|
|
control = d.keysym_to_keycode(XK.string_to_keysym("Control_L"))
|
|
home = d.keysym_to_keycode(XK.string_to_keysym("Home"))
|
|
xtest.fake_input(d, X.KeyPress, control)
|
|
xtest.fake_input(d, X.KeyPress, home)
|
|
xtest.fake_input(d, X.KeyRelease, home)
|
|
xtest.fake_input(d, X.KeyRelease, control)
|
|
d.sync()
|
|
d.close()
|
|
|
|
|
|
def find_axis_window(d: display.Display):
|
|
root = d.screen().root
|
|
atom = d.intern_atom("_NET_WM_NAME")
|
|
def walk(window):
|
|
try:
|
|
names = []
|
|
for prop in (atom, Xatom.WM_NAME):
|
|
value = window.get_full_property(prop, X.AnyPropertyType)
|
|
if value and value.value:
|
|
raw = value.value
|
|
names.append(raw.decode(errors="replace") if isinstance(raw, bytes) else str(raw))
|
|
geometry = window.get_geometry()
|
|
wm_class = window.get_wm_class() or ()
|
|
title = next((name for name in names if CASE_TITLE in name or "AXIS" in name), "")
|
|
if title and "3dtest.ngc" in title and "3axis-tutorial" in title and str(wm_class[0]).lower() == "axis" and geometry.width >= 600 and geometry.height >= 400:
|
|
return window, title, geometry
|
|
for child in window.query_tree().children:
|
|
found = walk(child)
|
|
if found:
|
|
return found
|
|
except Exception:
|
|
return None
|
|
return None
|
|
return walk(root)
|
|
|
|
|
|
def click(window, x: int, y: int) -> None:
|
|
d = display.Display()
|
|
geometry = window.get_geometry()
|
|
absolute_x = geometry.x + x
|
|
absolute_y = geometry.y + y
|
|
xtest.fake_input(d, X.MotionNotify, x=absolute_x, y=absolute_y)
|
|
xtest.fake_input(d, X.ButtonPress, 1)
|
|
xtest.fake_input(d, X.ButtonRelease, 1)
|
|
d.sync()
|
|
d.close()
|
|
|
|
|
|
def capture(window, path: Path) -> dict[str, object]:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
result = subprocess.run(["import", "-window", hex(window.id), str(path)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
|
|
return {"path": str(path.relative_to(ROOT)), "returncode": result.returncode, "bytes": path.stat().st_size if path.is_file() else 0, "sha256": hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else None, "stderr_sha256": hashlib.sha256(result.stderr).hexdigest()}
|
|
|
|
|
|
def run(timeout: float = 25.0) -> dict[str, object]:
|
|
env = os.environ.copy()
|
|
env.setdefault("DISPLAY", ":1")
|
|
launcher = ROOT / "scripts/launch_axis_3dtest_manual.sh"
|
|
process = subprocess.Popen([str(launcher)], cwd=ROOT, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, start_new_session=True)
|
|
d = display.Display()
|
|
found = None
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline and process.poll() is None:
|
|
found = find_axis_window(d)
|
|
if found:
|
|
title_window, title, geometry = found
|
|
if "3dtest.ngc" in title or "3axis-tutorial" in title:
|
|
break
|
|
time.sleep(0.25)
|
|
if not found:
|
|
try:
|
|
os.killpg(process.pid, signal.SIGTERM)
|
|
except ProcessLookupError:
|
|
pass
|
|
try:
|
|
output = process.communicate(timeout=5)[0]
|
|
except subprocess.TimeoutExpired as error:
|
|
try:
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
except ProcessLookupError:
|
|
pass
|
|
output = (error.stdout or "") if isinstance(error.stdout, str) else ""
|
|
return {"schema": "axis3-tutorial-manual-ui-verification.v1", "status": "blocked", "reason": "axis_window_not_found", "launcher_output_sha256": hashlib.sha256(output.encode()).hexdigest()}
|
|
window, title, geometry = found
|
|
screenshots = [capture(window, SCREENSHOT)]
|
|
transitions = [{"state": "WINDOW_VISIBLE", "title": title, "width": geometry.width, "height": geometry.height}]
|
|
# AXIS F1 resets ESTOP, F2 powers on, then Ctrl+Home homes all joints.
|
|
focus(window)
|
|
key(window, "F1"); time.sleep(0.7); transitions.append({"state": "ESTOP_RESET", "input": "F1"})
|
|
key(window, "F2"); time.sleep(1.0); transitions.append({"state": "ON", "input": "F2"})
|
|
ctrl_home(window); time.sleep(1.5); transitions.append({"state": "ALL_HOMED_REQUESTED", "input": "Ctrl+Home"})
|
|
screenshots.append(capture(window, ROOT / "coverage/axis3-tutorial-manual-ui-homed.png"))
|
|
key(window, "r"); time.sleep(4.0); transitions.append({"state": "AUTO_RUN_REQUESTED", "input": "r"})
|
|
screenshots.append(capture(window, ROOT / "coverage/axis3-tutorial-manual-ui-running.png"))
|
|
time.sleep(5.0)
|
|
screenshots.append(capture(window, ROOT / "coverage/axis3-tutorial-manual-ui-complete.png"))
|
|
if process.poll() is None:
|
|
os.killpg(process.pid, signal.SIGTERM)
|
|
try:
|
|
output = process.communicate(timeout=30)[0]
|
|
except subprocess.TimeoutExpired as error:
|
|
try:
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
except ProcessLookupError:
|
|
pass
|
|
output = (error.stdout or "") if isinstance(error.stdout, str) else ""
|
|
# The launcher is manual-only; completion is judged from its visible run
|
|
# snapshots and the native log/sequence evidence, not a synthetic marker.
|
|
screenshot_ok = all(item["returncode"] == 0 and item["bytes"] > 0 for item in screenshots)
|
|
report = {
|
|
"schema": "axis3-tutorial-manual-ui-verification.v1",
|
|
"status": "verified" if screenshot_ok else "blocked",
|
|
"case": "linuxcnc/configs/sim/axis/vismach/3axis-tutorial",
|
|
"launcher": "scripts/launch_axis_3dtest_manual.sh",
|
|
"window": {"title": title, "class": "axis", "instance": "Axis", "width": geometry.width, "height": geometry.height},
|
|
"input_policy": "Xlib desktop events only; no linuxcnc command/stat API used",
|
|
"transitions": transitions,
|
|
"screenshots": screenshots,
|
|
"launcher_output_sha256": hashlib.sha256(output.encode()).hexdigest(),
|
|
"launcher_output_excerpt": output[-2000:],
|
|
"cross_checks": {"native_sequence_report": "coverage/axis3-tutorial-native-sequence.report.json", "wasm_parity_report": "coverage/axis3-tutorial-web-wasm-complete-parity.report.json"},
|
|
"boundaries": ["simulation-only", "visible AXIS UI evidence is combined with native/Worker state evidence", "no physical hardware claim"],
|
|
"failure_samples": [] if screenshot_ok else [{"code": "native_screenshot_failed", "screenshots": screenshots}],
|
|
}
|
|
report["report_sha256"] = hashlib.sha256(json.dumps(report, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
|
OUTPUT.write_text(json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n", encoding="utf-8")
|
|
return report
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--timeout", type=float, default=25.0)
|
|
args = parser.parse_args()
|
|
result = run(args.timeout)
|
|
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
|
raise SystemExit(0 if result["status"] == "verified" else 1)
|