Complete 3axis tutorial native and Web/WASM parity tools
This commit is contained in:
51
tool/linuxcnc-axis-manual-execution/SKILL.md
Normal file
51
tool/linuxcnc-axis-manual-execution/SKILL.md
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: linuxcnc-axis-manual-execution
|
||||
description: "Run and validate LinuxCNC G-code through the AXIS graphical interface in the repository's isolated simulation workflow. Use when a manual UI execution, DRO movement check, or desktop shortcut needs to be reproduced; do not use this as permission to drive physical hardware."
|
||||
---
|
||||
|
||||
# Linuxcnc Axis Manual Execution
|
||||
|
||||
Use this skill for the repository's manual AXIS simulation workflow. The default target is the 3-axis tutorial configuration and `linuxcnc/nc_files/3dtest.ngc`.
|
||||
|
||||
## Scope and Safety
|
||||
|
||||
- Treat this workflow as simulation-only. Do not connect it to or operate a physical machine unless the user separately authorizes that and the hardware configuration, limits, and emergency controls have been verified.
|
||||
- A program is not considered executed merely because AXIS opened it or switched to AUTO. Require visible DRO/coordinate movement and a normal interpreter completion at `M2`.
|
||||
- Stop and report the first real LinuxCNC error. Debug/text messages are not motion errors by themselves.
|
||||
|
||||
## Repository Entrypoints
|
||||
|
||||
- Manual launcher: `scripts/launch_axis_3dtest_manual.sh`
|
||||
- Desktop shortcut: `/home/mes006/Desktop/3axis-tutorial.desktop`
|
||||
- Source program: `linuxcnc/nc_files/3dtest.ngc`
|
||||
- Source AXIS model/configuration: `linuxcnc/configs/sim/axis/vismach/3axis-tutorial`
|
||||
|
||||
The launcher copies the model/configuration into a temporary runtime directory, sets `AXIS_OPEN_FILE` to the source program, points `PROGRAM_PREFIX` at `linuxcnc/nc_files`, enables the AXIS homing prompt, and raises the temporary Z positive limit to `100 mm`. The source `.ngc`, `.ini`, variable, and position files must remain unchanged.
|
||||
|
||||
Before every startup and again after LinuxCNC exits, the launcher closes any existing LinuxCNC simulation by attempting `axis-remote --quit`, `halcmd stop`, `halcmd unload all`, and realtime stop; it then terminates matching LinuxCNC/AXIS service processes, waits for them to exit, and removes `/tmp/linuxcnc.lock` only when no matching service remains. The input-method daemon is not stopped: immediately before startup, IBus is switched to the English `xkb:us::eng` engine, English is made the first input source, and the X keyboard layout is set to `us`, so AXIS shortcuts are not intercepted while Chinese input remains available for later use. Cleanup output is printed so stale-session failures remain visible.
|
||||
|
||||
## Manual UI Procedure
|
||||
|
||||
1. Start the launcher directly or open the desktop shortcut. Wait for an AXIS window whose title contains `3dtest.ngc` and `3axis-tutorial`.
|
||||
2. Reset E-stop with the red E-stop control (AXIS `F1`), then turn machine power on (AXIS `F2`). Accept the homing prompt if it appears.
|
||||
3. Ensure all three axes are homed. If the prompt is absent, select X, Y, and Z in the Manual Control panel and click `Home Axis` for each, or use AXIS `Machine -> Home All` / `Ctrl+Home`. Homing only X is insufficient: AXIS can enter AUTO while refusing to move.
|
||||
4. Click the AXIS Run toolbar button (blue play icon) or press `r`. Do not use the LinuxCNC Python command API as a substitute when the request is specifically for a manual UI test.
|
||||
5. Watch the DRO and program highlight. Success requires `motion_line`/highlight progress, actual X/Y/Z values changing, and the interpreter returning to idle at the program's `M2`. Use `Esc` to stop an unexpected run.
|
||||
6. Close AXIS after the test. The launcher removes its temporary runtime directory when LinuxCNC exits.
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
Record at least:
|
||||
|
||||
- AXIS window title and selected source path.
|
||||
- State transitions: E-stop reset, machine ON, all homed, AUTO/RUNNING.
|
||||
- Initial and final DRO coordinates plus evidence that coordinates changed during motion.
|
||||
- Final motion/current line and interpreter idle state.
|
||||
- Any `NML_ERROR`/`RCS_ERROR` text; do not classify `(DEBUG,...)` messages as errors.
|
||||
|
||||
For `3dtest.ngc`, the expected geometry reaches roughly `56 mm` on each XYZ coordinate after the G20 inch-to-mm conversion. The unmodified tutorial configuration has a Z positive limit of `0.001 mm`, so use the launcher-managed temporary configuration rather than launching the base INI directly.
|
||||
|
||||
## Known Program Pitfalls
|
||||
|
||||
- `nestedcall.ngc` currently contains `O<g20sub> callsub`, which the installed LinuxCNC interpreter reports as `Unexpected character after O-word`; do not use it as a passing baseline without an explicit source-level compatibility decision.
|
||||
- A loaded file with `task_mode=AUTO` but `homed=[1,0,0]` is not a successful run. Home Y and Z before diagnosing G-code behavior.
|
||||
3
tool/linuxcnc-axis-manual-execution/agents/openai.yaml
Normal file
3
tool/linuxcnc-axis-manual-execution/agents/openai.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
interface:
|
||||
display_name: "LinuxCNC AXIS Manual Simulation"
|
||||
short_description: "Run G-code manually in AXIS and verify DRO motion"
|
||||
35
tool/linuxcnc-web-wasm-manual-execution/SKILL.md
Normal file
35
tool/linuxcnc-web-wasm-manual-execution/SKILL.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: linuxcnc-web-wasm-manual-execution
|
||||
description: "Run the selected 3axis-tutorial LinuxCNC Web/WASM panel in a real browser and compare the observable workflow with native AXIS."
|
||||
---
|
||||
|
||||
# LinuxCNC Web/WASM Manual Execution
|
||||
|
||||
This tool is the browser-side counterpart to
|
||||
`tool/linuxcnc-axis-manual-execution`. It is simulation-only and targets the
|
||||
exact `3axis-tutorial` case.
|
||||
|
||||
The runner starts the production React preview, uses a real Chromium page with
|
||||
the built WASM module and Dedicated Worker, sends user-level DOM/keyboard
|
||||
events, captures screenshots and DOM state, then closes the preview/browser.
|
||||
It must not replace the browser Worker with a mock, JS G-code preview, or a
|
||||
Node-only session.
|
||||
|
||||
Run it from the repository root:
|
||||
|
||||
```bash
|
||||
python3 tool/linuxcnc-web-wasm-manual-execution/run_web_axis_manual.py
|
||||
```
|
||||
|
||||
Required success evidence:
|
||||
|
||||
- the page title and selected `3axis-tutorial` case;
|
||||
- `TASK_READY`/machine state visible in the panel;
|
||||
- E-stop, power, Home All, and Run controls exercised through the DOM;
|
||||
- changing authoritative DRO and a non-empty Three.js canvas;
|
||||
- program completion at `M2`/idle or an explicit Worker diagnostic;
|
||||
- screenshot and DOM hashes;
|
||||
- preview/browser cleanup after the run.
|
||||
|
||||
The resulting report is written to
|
||||
`coverage/axis3-tutorial-web-manual-verification.report.json`.
|
||||
277
tool/linuxcnc-web-wasm-manual-execution/run_web_axis_manual.py
Normal file
277
tool/linuxcnc-web-wasm-manual-execution/run_web_axis_manual.py
Normal file
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the real React/WASM 3axis tutorial panel through Chromium CDP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import signal
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
REPORT = ROOT / "coverage/axis3-tutorial-web-manual-verification.report.json"
|
||||
PORT = 5175
|
||||
CDP_PORT = 9223
|
||||
CASE = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini"
|
||||
|
||||
|
||||
def sha256_bytes(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
def sha256_value(value: Any) -> str:
|
||||
return sha256_bytes(json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode())
|
||||
|
||||
|
||||
class WebSocket:
|
||||
def __init__(self, url: str):
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(url)
|
||||
self.sock = socket.create_connection((parsed.hostname, parsed.port), timeout=10)
|
||||
key = base64.b64encode(secrets.token_bytes(16)).decode()
|
||||
path = parsed.path or "/"
|
||||
self.sock.sendall((f"GET {path} HTTP/1.1\r\nHost: {parsed.hostname}:{parsed.port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n").encode())
|
||||
response = b""
|
||||
while b"\r\n\r\n" not in response:
|
||||
response += self.sock.recv(4096)
|
||||
if b"101" not in response.split(b"\r\n", 1)[0]:
|
||||
raise RuntimeError(f"cdp_websocket_handshake_failed:{response[:120]!r}")
|
||||
self.buffer = response.split(b"\r\n\r\n", 1)[1]
|
||||
|
||||
def send(self, value: str) -> None:
|
||||
payload = value.encode()
|
||||
length = len(payload)
|
||||
mask = secrets.token_bytes(4)
|
||||
masked = bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload))
|
||||
if length < 126:
|
||||
header = bytes([0x81, 0x80 | length])
|
||||
elif length < 65536:
|
||||
header = bytes([0x81, 0x80 | 126]) + struct.pack("!H", length)
|
||||
else:
|
||||
header = bytes([0x81, 0x80 | 127]) + struct.pack("!Q", length)
|
||||
self.sock.sendall(header + mask + masked)
|
||||
|
||||
def recv(self) -> tuple[int, bytes]:
|
||||
def read(size: int) -> bytes:
|
||||
while len(self.buffer) < size:
|
||||
self.buffer += self.sock.recv(65536)
|
||||
value, self.buffer = self.buffer[:size], self.buffer[size:]
|
||||
return value
|
||||
first, second = read(2)
|
||||
opcode = first & 0x0F
|
||||
length = second & 0x7F
|
||||
if length == 126:
|
||||
length = struct.unpack("!H", read(2))[0]
|
||||
elif length == 127:
|
||||
length = struct.unpack("!Q", read(8))[0]
|
||||
masked = second & 0x80
|
||||
mask = read(4) if masked else b""
|
||||
payload = read(length)
|
||||
if masked:
|
||||
payload = bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload))
|
||||
return opcode, payload
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
self.sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
class CDP:
|
||||
def __init__(self, ws_url: str):
|
||||
self.ws = WebSocket(ws_url)
|
||||
self.counter = 0
|
||||
self.events: list[dict[str, Any]] = []
|
||||
|
||||
def call(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
self.counter += 1
|
||||
self.ws.send(json.dumps({"id": self.counter, "method": method, "params": params or {}}))
|
||||
while True:
|
||||
opcode, payload = self.ws.recv()
|
||||
if opcode != 1:
|
||||
continue
|
||||
message = json.loads(payload.decode())
|
||||
if message.get("id") == self.counter:
|
||||
if "error" in message:
|
||||
raise RuntimeError(f"cdp_error:{message['error']}")
|
||||
return message.get("result", {})
|
||||
self.events.append(message)
|
||||
|
||||
def evaluate(self, expression: str, await_promise: bool = False) -> Any:
|
||||
result = self.call("Runtime.evaluate", {"expression": expression, "returnByValue": True, "awaitPromise": await_promise, "userGesture": True})
|
||||
exception = result.get("exceptionDetails")
|
||||
if exception:
|
||||
raise RuntimeError(f"page_evaluation_failed:{exception}")
|
||||
return result.get("result", {}).get("value")
|
||||
|
||||
|
||||
def fetch_json(url: str) -> Any:
|
||||
with urllib.request.urlopen(url, timeout=5) as response:
|
||||
return json.loads(response.read().decode())
|
||||
|
||||
|
||||
def terminate_group(process: subprocess.Popen[str] | None) -> None:
|
||||
if not process or process.poll() is not None:
|
||||
return
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
try:
|
||||
process.wait(timeout=8)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
|
||||
def run(timeout: float = 40.0) -> dict[str, Any]:
|
||||
preview = subprocess.Popen(["npm", "--prefix", "web/react", "run", "preview", "--", "--host", "127.0.0.1", "--port", str(PORT)], cwd=ROOT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, start_new_session=True)
|
||||
browser: subprocess.Popen[str] | None = None
|
||||
browser_profile = tempfile.mkdtemp(prefix="linuxcnc-web-axis-cdp-")
|
||||
cdp: CDP | None = None
|
||||
screenshots: list[dict[str, Any]] = []
|
||||
states: list[dict[str, Any]] = []
|
||||
preview_output = ""
|
||||
start = time.monotonic()
|
||||
try:
|
||||
while time.monotonic() - start < 15:
|
||||
try:
|
||||
urllib.request.urlopen(f"http://127.0.0.1:{PORT}/", timeout=1).close()
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(0.2)
|
||||
browser = subprocess.Popen(["google-chrome", "--headless=new", "--no-sandbox", "--disable-gpu", "--enable-unsafe-swiftshader", "--use-angle=swiftshader", "--use-gl=swiftshader", "--disable-dev-shm-usage", "--disable-extensions", "--no-first-run", "--no-default-browser-check", "--window-size=1440,1000", f"--remote-debugging-port={CDP_PORT}", f"--user-data-dir={browser_profile}", f"http://127.0.0.1:{PORT}/"], cwd=ROOT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, start_new_session=True)
|
||||
ws_url = None
|
||||
for _ in range(100):
|
||||
try:
|
||||
targets = fetch_json(f"http://127.0.0.1:{CDP_PORT}/json")
|
||||
page = next(item for item in targets if item.get("type") == "page")
|
||||
ws_url = page["webSocketDebuggerUrl"]
|
||||
break
|
||||
except (OSError, StopIteration, KeyError):
|
||||
time.sleep(0.2)
|
||||
if not ws_url:
|
||||
raise RuntimeError("chrome_cdp_target_missing")
|
||||
cdp = CDP(ws_url)
|
||||
cdp.call("Runtime.enable")
|
||||
cdp.call("Log.enable")
|
||||
cdp.call("Page.enable")
|
||||
page_ready = None
|
||||
for _ in range(75):
|
||||
page_ready = cdp.evaluate("({href: location.href, readyState: document.readyState, root: Boolean(document.querySelector('#root')), app: Boolean(document.querySelector('.axis-app')), htmlLength: document.documentElement?.outerHTML?.length || 0})")
|
||||
if page_ready.get("app"):
|
||||
break
|
||||
if _ in {10, 30}:
|
||||
cdp.call("Page.reload", {"ignoreCache": True})
|
||||
time.sleep(0.2)
|
||||
if not page_ready or not page_ready.get("app"):
|
||||
diagnostics = [event for event in (cdp.events if cdp else []) if event.get("method") in {"Runtime.exceptionThrown", "Runtime.consoleAPICalled", "Log.entryAdded"}]
|
||||
raise RuntimeError(f"web_page_boot_failed:{page_ready}:diagnostics={diagnostics[-8:]}")
|
||||
|
||||
def snapshot(label: str) -> dict[str, Any]:
|
||||
value = cdp.evaluate("""(() => ({
|
||||
label: %s,
|
||||
href: location.href,
|
||||
ready_state: document.readyState,
|
||||
root_html: document.querySelector('#root')?.innerHTML?.slice(0, 120) || '',
|
||||
title: document.title,
|
||||
case_status: document.querySelector('.case-status')?.textContent?.trim() || '',
|
||||
connection: document.querySelector('.connection-state')?.textContent?.trim() || '',
|
||||
axis_state: document.querySelector('.axis-state-badge')?.textContent?.trim() || '',
|
||||
machine_state: document.querySelector('.machine-state')?.textContent?.trim() || '',
|
||||
operation_state: document.querySelector('.operation-state')?.textContent?.trim() || '',
|
||||
program_status: document.querySelector('[data-axis-program-status]')?.getAttribute('data-axis-program-status') || '',
|
||||
position: document.querySelector('.position-state')?.textContent?.trim() || '',
|
||||
footer: document.querySelector('.footer-status')?.textContent?.trim() || '',
|
||||
error_text: document.querySelector('.diagnostic-error')?.textContent?.trim() || document.querySelector('.program-diagnostic')?.textContent?.trim() || document.querySelector('.axis-diagnostics')?.textContent?.trim() || '',
|
||||
task: document.querySelector('.task-state')?.textContent?.trim() || '',
|
||||
canvases: [...document.querySelectorAll('canvas')].map(canvas => ({ width: canvas.width, height: canvas.height }))
|
||||
}))()""" % json.dumps(label), await_promise=False)
|
||||
states.append(value)
|
||||
return value
|
||||
|
||||
def screenshot(label: str) -> None:
|
||||
data = cdp.call("Page.captureScreenshot", {"format": "png", "captureBeyondViewport": False})["data"]
|
||||
path = ROOT / "coverage" / f"axis3-tutorial-web-{label}.png"
|
||||
path.write_bytes(base64.b64decode(data))
|
||||
screenshots.append({"label": label, "path": str(path.relative_to(ROOT)), "bytes": path.stat().st_size, "sha256": sha256_bytes(path.read_bytes())})
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
state = snapshot("ready_poll")
|
||||
if state["connection"] in {"READY", "RUNNING", "PAUSED"} or state["case_status"]:
|
||||
break
|
||||
time.sleep(0.5)
|
||||
screenshot("ready")
|
||||
snapshot("ready")
|
||||
cdp.evaluate("document.querySelector('button[aria-label^=\"Emergency Stop\"]')?.click()")
|
||||
time.sleep(0.8)
|
||||
snapshot("estop")
|
||||
cdp.evaluate("document.querySelector('button[aria-label^=\"Power on\"]')?.click()")
|
||||
time.sleep(0.8)
|
||||
snapshot("power")
|
||||
cdp.evaluate("[...document.querySelectorAll('button')].find(button => button.textContent?.trim() === 'Home All')?.click()")
|
||||
time.sleep(1.5)
|
||||
screenshot("homed")
|
||||
snapshot("homed")
|
||||
cdp.evaluate("document.querySelector('button[aria-label^=\"Begin executing current file\"]')?.click()")
|
||||
time.sleep(2.0)
|
||||
screenshot("running")
|
||||
snapshot("running")
|
||||
complete_deadline = time.monotonic() + timeout
|
||||
final = None
|
||||
while time.monotonic() < complete_deadline:
|
||||
final = snapshot("complete_poll")
|
||||
if final["program_status"] == "completed" and final["operation_state"] in {"Operation IDLE", "IDLE"} and final["connection"] == "READY":
|
||||
break
|
||||
time.sleep(0.5)
|
||||
screenshot("complete")
|
||||
final = snapshot("complete")
|
||||
status = "verified" if final["canvases"] and any(item["width"] > 0 and item["height"] > 0 for item in final["canvases"]) and final["program_status"] == "completed" and final["operation_state"] in {"Operation IDLE", "IDLE"} and final["connection"] == "READY" else "blocked"
|
||||
diagnostics = [event for event in cdp.events if event.get("method") in {"Runtime.exceptionThrown", "Runtime.consoleAPICalled", "Log.entryAdded"}] if cdp else []
|
||||
return {"schema": "axis3-tutorial-web-manual-verification.v1", "status": status, "case": CASE, "tool": "tool/linuxcnc-web-wasm-manual-execution", "browser": "Google Chrome headless", "url": f"http://127.0.0.1:{PORT}/", "input_policy": "real browser DOM click events through CDP; real Vite/WASM/Dedicated Worker", "states": states, "screenshots": screenshots, "browser_diagnostics": diagnostics[-40:], "preview_output_sha256": sha256_bytes(preview_output.encode()), "cross_checks": {"native_tool": "tool/linuxcnc-axis-manual-execution", "parity_report": "coverage/axis3-tutorial-web-wasm-complete-parity.report.json"}, "boundaries": ["selected 3axis-tutorial case", "real hardware excluded", "browser run is Chromium evidence"]}
|
||||
finally:
|
||||
if cdp:
|
||||
cdp.ws.close()
|
||||
terminate_group(browser)
|
||||
terminate_group(preview)
|
||||
if preview:
|
||||
try:
|
||||
preview_output = preview.communicate(timeout=5)[0]
|
||||
except subprocess.TimeoutExpired:
|
||||
preview_output = ""
|
||||
try:
|
||||
import shutil
|
||||
shutil.rmtree(browser_profile, ignore_errors=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--timeout", type=float, default=40.0)
|
||||
args = parser.parse_args()
|
||||
report = run(args.timeout)
|
||||
report["report_sha256"] = sha256_value({key: value for key, value in report.items() if key != "report_sha256"})
|
||||
REPORT.write_text(json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps({key: report[key] for key in ("schema", "status", "case", "browser", "screenshots", "report_sha256")}, ensure_ascii=False, sort_keys=True))
|
||||
return 0 if report["status"] == "verified" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user