Verify XYZAC panel button parity

This commit is contained in:
2026-09-01 23:47:34 +08:00
parent 1342cff3f0
commit 492d862e45
12 changed files with 417 additions and 12 deletions

View File

@@ -37,7 +37,7 @@ const OPERATION_KINDS = new Set([
"SESSION_ATTACH", "LOAD_WORKSPACE", "SIM_STEP", "DESTROY", "CHECKPOINT", "RECOVER", "ABORT",
"COMMAND", "START", "STOP", "RESET", "PROGRAM_LOAD", "PROGRAM_RUN",
"PROGRAM_PAUSE", "PROGRAM_RESUME", "PROGRAM_ABORT", "PROGRAM_RESTART", "JOG_START", "JOG_STOP",
"HOME", "SET_MODE", "SET_OVERRIDE", "SPINDLE_COMMAND", "TOOL_PREPARE",
"HOME", "SET_MODE", "SET_OVERRIDE", "SPINDLE_COMMAND", "SPINDLE_BRAKE", "TOOL_PREPARE",
"TOOL_CHANGED", "TOOL_PREPARED", "TOOL_CHANGE", "TOOL_ABORT", "TOOL_FAULT", "TOOL_CLEAR_FAULT",
"SPINDLE_FAULT", "SPINDLE_INHIBIT", "SPINDLE_ORIENT", "SPINDLE_ORIENTED", "IO_STEP", "IO_RECOVER",
"PROBE_CONFIG", "LIMIT_SET", "ESTOP", "HAL_COMMAND", "HAL_SET",
@@ -1995,7 +1995,13 @@ export class SimSession {
if (current.status === 1 && this.task.state.mode !== "AUTO") this.task.setMode("AUTO");
if (current.status === 1) this.xyzacProgram.start();
const result = this.xyzacProgram.step();
if (result.status === "verified") { if (result.motion) this.wasmMotion.enqueue(result.motion); this.wasmMotion.setPaused(true); this.task.setProgram(result.program_status === 4 ? "completed" : "paused", result.line); this.task.transition(result.program_status === 4 ? "STOPPED" : "PAUSED"); }
if (result.status === "verified") {
if (this.task.state.state === "READY") this.task.transition("RUNNING");
if (result.motion) this.wasmMotion.enqueue(result.motion);
this.wasmMotion.setPaused(true);
this.task.setProgram(result.program_status === 4 ? "completed" : "paused", result.line);
this.task.transition(result.program_status === 4 ? "STOPPED" : "PAUSED");
}
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
}
if (this.task.state.state === "READY") {
@@ -2151,7 +2157,7 @@ export class SimSession {
if (result.status === "error" && result.state?.fault && this.task.state.state === "RUNNING") this.task.fault(-1205);
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, motion: this.motionProjection(), task: this.task.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
}
case "SPINDLE_COMMAND": case "SPINDLE_FAULT": case "SPINDLE_INHIBIT":
case "SPINDLE_COMMAND": case "SPINDLE_BRAKE": case "SPINDLE_FAULT": case "SPINDLE_INHIBIT":
case "SPINDLE_ORIENT": case "SPINDLE_ORIENTED": case "IO_STEP":
case "TOOL_PREPARE": case "TOOL_PREPARED": case "TOOL_CHANGE":
case "TOOL_CHANGED": case "TOOL_ABORT": case "TOOL_FAULT": case "TOOL_CLEAR_FAULT":

View File

@@ -45,14 +45,19 @@ assert.equal(send("HOME", { all: true }).kind, "COMMAND_RESULT");
const run = send("PROGRAM_RUN");
assert.equal(run.kind, "COMMAND_RESULT");
let stepped = null;
for (let cycle = 0; cycle < 300; cycle += 1) {
let executionTraceEntries = 0;
// The first long Z feed is planned at the configured LinuxCNC TP limit. Give
// the fixed-tick simulation enough servo time to complete it before checking
// that the interpreter has advanced beyond the historical line-82 boundary.
for (let cycle = 0; cycle < 1500; cycle += 1) {
stepped = send("SIM_STEP", { cycles: 1, response: "motion_delta" });
assert.equal(stepped.kind, "COMMAND_RESULT");
executionTraceEntries += stepped.payload.execution_trace.length;
}
assert.equal(stepped.kind, "COMMAND_RESULT");
assert.equal(stepped.payload.program.program_status, "running");
assert.ok(stepped.payload.program.current_line > 82);
assert.ok(stepped.payload.execution_trace.length > 0);
assert.ok(executionTraceEntries > 0);
assert.ok(stepped.payload.program.execution_line_text.length > 0);
assert.equal(stepped.payload.motion_authority, "wasm");
assert.equal(Object.hasOwn(stepped.payload.xyzac_ledger.entries.at(-1), "response"), false);
@@ -66,6 +71,6 @@ console.log(JSON.stringify({
line_count: loaded.payload.program.line_count,
preview_segments: loaded.payload.preview.segments.length,
execution_line: stepped.payload.program.current_line,
execution_trace: stepped.payload.execution_trace.length,
execution_trace: executionTraceEntries,
motion_authority: stepped.payload.motion_authority,
}));

View File

@@ -0,0 +1,144 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { XyzacSession, CONTEXT_ID } from "./xyzac-session.mjs";
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
const PROGRAM = "G21 G90\nG0 X0 Y0 Z10 A0 C0\nG1 X10 Y5 Z5 A30 C45 F100\nG1 X20 Y10 Z2 A35 C50 F100\nM2\n";
const fetchImpl = async (url) => {
try {
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
return { ok: true, text: async () => body.toString("utf8") };
} catch {
return { ok: false, text: async () => "" };
}
};
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
assert.equal(bundle.status, "ready");
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
const rows = [];
const record = (group, button, command, source, response, expected = "verified") => {
const actual = response?.kind === "ERROR" || response?.payload?.status === "error" || response?.payload?.status === "blocked" ? "error" : "verified";
assert.equal(actual, expected, `${group}/${button}: ${JSON.stringify(response?.payload ?? response)}`);
rows.push({ group, button, command, source, result: actual, reason: actual === "error" ? response.payload?.message_key ?? response.payload?.reason ?? response.payload?.code ?? null : null });
return response;
};
async function open(sessionId) {
const core = await createModule({ wasmBinary });
const session = new XyzacSession(core, { sessionId });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: sessionId };
let seq = 0;
const send = (kind, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: `${sessionId}-${seq}`, payload });
record("session", "HELLO", "HELLO", "axis.py/AXIS startup", send("HELLO"));
record("session", "SESSION_ATTACH", "SESSION_ATTACH", "axis.py/AXIS startup", send("SESSION_ATTACH"));
record("machine", "Load case", "LOAD_WORKSPACE", "axis.py:configuration/startup", send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }));
return { session, send };
}
const machine = await open("xyzac-panel-buttons-machine");
const off = machine.send("PROGRAM_LOAD", { source: PROGRAM, path: "panel-buttons.ngc" });
record("program", "Load", "PROGRAM_LOAD", "axis.py:open_file_guts/send_mdi_command", off);
for (const [button, command, payload, reason] of [
["Run while OFF", "PROGRAM_RUN", {}, "xyzac_machine_off"],
["HOME ALL while OFF", "HOME", { all: true }, "xyzac_machine_off"],
["JOG while OFF", "JOG_START", { axis: "x", direction: 1, speed: 1 }, "xyzac_machine_off"],
["MDI while OFF", "MDI_EXECUTE", { block: "G0 X1" }, "xyzac_machine_off"],
["M428 while OFF", "KINEMATICS_SELECT", { type: 1 }, "xyzac_machine_off"],
]) {
const response = machine.send(command, payload);
record("gates", button, command, "sim_task_command_gate / axis.py manual_ok", response, "error");
assert.equal(response.payload.gate_reason, reason, button);
}
record("machine", "Power on", "POWER", "axis.py:commands.power / emcTaskSetState", machine.send("POWER", { active: true }));
record("machine", "HOME ALL", "HOME", "axis.py:home_all_joints", machine.send("HOME", { all: true }));
record("machine", "Reset", "RESET", "axis.py:commands.reset", machine.send("RESET"));
for (const [button, type] of [["Identity", 0], ["TCP: XYZAC", 1], ["User K", 2], ["TCP: XYZAC", 1]]) {
const response = machine.send("KINEMATICS_SELECT", { type });
record("switchkins", button, "KINEMATICS_SELECT", "switchkins_postgui.hal / remap 428/429/430", response);
assert.equal(response.payload.kinematics.active_type, type);
}
record("view", "Clear plot", "VISUAL_CLEAR", "axis.py:clear_live_plot / glcanon.py", machine.send("VISUAL_CLEAR"));
record("override", "Feed 50%", "SET_OVERRIDE", "axis.py:set_feedrate -> c.feedrate", machine.send("SET_OVERRIDE", { feed: 0.5 }));
const invalidOverride = machine.send("SET_OVERRIDE", { feed: 2.01 });
record("override", "Feed 201% rejected", "SET_OVERRIDE", "axis.py:set_feedrate range", invalidOverride, "error");
assert.equal(invalidOverride.payload.message_key, "motion_override_range");
record("program", "Run", "PROGRAM_RUN", "axis.py:task_run -> c.auto(AUTO_RUN)", machine.send("PROGRAM_RUN"));
const step = machine.send("SIM_STEP", { cycles: 3 });
record("program", "Servo/task step", "SIM_STEP", "axis.py:LivePlotter/update + LinuxCNC TP", step);
record("program", "Pause", "PROGRAM_PAUSE", "axis.py:task_pause -> c.auto(AUTO_PAUSE)", machine.send("PROGRAM_PAUSE"));
const pausedSnapshot = machine.send("SNAPSHOT_REQUEST");
record("program", "Paused snapshot", "SNAPSHOT_REQUEST", "axis.py:stat.poll task_paused", pausedSnapshot);
assert.equal(pausedSnapshot.payload.task.state, "PAUSED");
record("program", "Resume", "PROGRAM_RESUME", "axis.py:task_resume -> c.auto(AUTO_RESUME)", machine.send("PROGRAM_RESUME"));
record("program", "Abort/Stop", "PROGRAM_ABORT", "axis.py:task_stop -> c.abort", machine.send("PROGRAM_ABORT"));
assert.equal(machine.session.wasmMotion.read().state.queue_depth, 0);
record("program", "Restart", "PROGRAM_RESTART", "axis.py:reload_file/set_first_line", machine.send("PROGRAM_RESTART"));
record("program", "Step", "PROGRAM_STEP", "axis.py:task_step -> c.auto(AUTO_STEP)", machine.send("PROGRAM_STEP"));
record("program", "Seek", "PROGRAM_SEEK", "axis.py:set_first_line/task_run_line", machine.send("PROGRAM_SEEK", { line: 3 }));
record("program", "Resume from line", "PROGRAM_RESUME", "axis.py:task_resume", machine.send("PROGRAM_RESUME"));
record("program", "Abort after seek", "PROGRAM_ABORT", "axis.py:task_stop", machine.send("PROGRAM_ABORT"));
const mdi = await open("xyzac-panel-buttons-mdi");
record("machine", "Power on", "POWER", "axis.py:commands.power", mdi.send("POWER", { active: true }));
record("machine", "HOME ALL", "HOME", "axis.py:home_all_joints", mdi.send("HOME", { all: true }));
record("mdi", "Go", "MDI_EXECUTE", "axis.py:send_mdi_command", mdi.send("MDI_EXECUTE", { block: "G21 G90 G1 X10 Y5 Z5 A30 C45 F100" }));
record("mdi", "Invalid G-code", "MDI_EXECUTE", "axis.py:send_mdi_command/error channel", mdi.send("MDI_EXECUTE", { block: "G999" }), "error");
record("mdi", "Load program", "PROGRAM_LOAD", "axis.py:open_file_guts", mdi.send("PROGRAM_LOAD", { source: PROGRAM, path: "mdi-panel.ngc" }));
record("mdi", "Run", "PROGRAM_RUN", "axis.py:task_run", mdi.send("PROGRAM_RUN"));
record("mdi", "MDI while running", "MDI_EXECUTE", "axis.py:manual_ok", mdi.send("MDI_EXECUTE", { block: "G0 X1" }), "error");
assert.equal(mdi.send("MDI_EXECUTE", { block: "G0 X1" }).payload.message_key, "mdi_while_program_active");
record("mdi", "Stop", "PROGRAM_ABORT", "axis.py:task_stop", mdi.send("PROGRAM_ABORT"));
for (const axis of ["x", "y", "z", "a", "c"]) {
const jog = await open(`xyzac-panel-buttons-jog-${axis}`);
record("machine", "Power on", "POWER", "axis.py:commands.power", jog.send("POWER", { active: true }));
record("machine", "HOME ALL", "HOME", "axis.py:home_all_joints", jog.send("HOME", { all: true }));
const start = jog.session.wasmMotion.read().state.position[axis] ?? 0;
record("jog", `JOG ${axis.toUpperCase()} +`, "JOG_START", "axis.py:jog_plus/jog_on", jog.send("JOG_START", { axis, direction: 1, speed: 1, increment: axis === "a" || axis === "c" ? 2 : 1 }));
record("jog", `JOG ${axis.toUpperCase()} sample`, "SIM_STEP", "axis.py:LivePlotter/update", jog.send("SIM_STEP", { cycles: 1 }));
const end = jog.session.wasmMotion.read().state.position[axis] ?? 0;
assert.notEqual(end, start, axis);
record("jog", `JOG ${axis.toUpperCase()} stop`, "JOG_STOP", "axis.py:jog_stop -> JOG_STOP", jog.send("JOG_STOP"));
}
const io = await open("xyzac-panel-buttons-io");
record("machine", "Power on", "POWER", "axis.py:commands.power", io.send("POWER", { active: true }));
record("machine", "HOME ALL", "HOME", "axis.py:home_all_joints", io.send("HOME", { all: true }));
record("spindle", "CW", "SPINDLE_COMMAND", "axis.py:spindle_forward_toggle/c.spindle", io.send("SPINDLE_COMMAND", { spindle: 0, rpm: 600, enabled: true }));
assert.equal(io.send("SPINDLE_COMMAND", { spindle: 0, delta: 100 }).payload.device_io.state.spindles[0].command_rpm, 700);
rows.push({ group: "spindle", button: "Speed +", command: "SPINDLE_COMMAND(delta)", source: "axis.py:spindle_increase -> EMCMOT_SPINDLE_INCREASE", result: "verified", reason: null });
assert.equal(io.send("SPINDLE_COMMAND", { spindle: 0, delta: -100 }).payload.device_io.state.spindles[0].command_rpm, 600);
rows.push({ group: "spindle", button: "Speed -", command: "SPINDLE_COMMAND(delta)", source: "axis.py:spindle_decrease -> EMCMOT_SPINDLE_DECREASE", result: "verified", reason: null });
record("spindle", "Stop", "SPINDLE_COMMAND", "axis.py:spindle -> SPINDLE_OFF", io.send("SPINDLE_COMMAND", { spindle: 0, rpm: 0, enabled: false }));
record("spindle", "Brake engage", "SPINDLE_BRAKE", "axis.py:brake -> EMC_SPINDLE_BRAKE_ENGAGE", io.send("SPINDLE_BRAKE", { spindle: 0, engaged: true }));
record("spindle", "Brake release", "SPINDLE_BRAKE", "axis.py:brake -> EMC_SPINDLE_BRAKE_RELEASE", io.send("SPINDLE_BRAKE", { spindle: 0, engaged: false }));
record("coolant", "Mist/Flood on", "COOLANT_COMMAND", "axis.py:mist_toggle/flood_toggle", io.send("COOLANT_COMMAND", { mist: true, flood: true }));
record("tool", "Prepare T1", "TOOL_PREPARE", "axis.py:change_tool / iocontrol", io.send("TOOL_PREPARE", { tool: 1, pocket: 1 }));
record("tool", "Prepared ack", "TOOL_PREPARED", "axis.py:tool-prepared HAL", io.send("TOOL_PREPARED", { prepared: true }));
record("tool", "Change", "TOOL_CHANGE", "axis.py:change_tool -> c.mdi(M6)", io.send("TOOL_CHANGE"));
record("tool", "Changed ack", "TOOL_CHANGED", "axis.py:tool-changed HAL", io.send("TOOL_CHANGED", { changed: true }));
record("probe", "Arm", "PROBE_CONFIG", "axis.py:probe/canonical probe", io.send("PROBE_CONFIG", { probe_type: 0 }));
record("probe", "Disarm", "PROBE_CONFIG", "axis.py:probe disarm", io.send("PROBE_CONFIG", { action: "disarm" }));
assert.equal(io.session.xyzacIo.canonical().probe.state, 0);
record("machine", "ESTOP", "ESTOP", "axis.py:General_Halt/emcTaskAbort", io.send("ESTOP", { active: true }));
record("machine", "Release E-stop", "ESTOP", "axis.py:ESTOP reset", io.send("ESTOP", { active: false }));
record("machine", "Power after ESTOP", "POWER", "axis.py:commands.power", io.send("POWER", { active: true }));
const unsupported = [
{ button: "Home Axis", reason: "WASM home state exposes aggregate homed only; no per-joint home mask" },
{ button: "Override Limits", reason: "selected XYZAC WASM configuration owns limits; no LinuxCNC override_limits command ABI" },
{ button: "Jog Speed slider", reason: "UI state is not connected to a WASM jog-speed command" },
{ button: "Max Velocity slider", reason: "UI state is not connected to a WASM max-velocity command" },
{ button: "Block delete / Optional pause", reason: "UI toggle is not forwarded to sim_program interpreter state" },
{ button: "Tool Touch Off", reason: "UI emits fixed G43 and does not expose LinuxCNC G10 prompt/value flow" },
{ button: "Edit tool data / Quit", reason: "desktop process boundary; browser intentionally host-excluded" },
];
for (const item of unsupported) rows.push({ group: "scope", button: item.button, command: null, source: "axis.py source mapped", result: "blocked_by_abi", reason: item.reason });
const counts = Object.fromEntries(["verified", "error", "blocked_by_abi"].map((status) => [status, rows.filter((row) => row.result === status).length]));
assert.ok(counts.verified > 0 && counts.error > 0 && counts.blocked_by_abi > 0);
console.log(JSON.stringify({ schema: "xyzac-panel-button-parity.v1", status: "verified", case: CASE, source: "linuxcnc-wasm-worker", counts, rows, selected_case_release_status: "STOP_RELEASE" }));

View File

@@ -29,13 +29,22 @@ const spindle = send("SPINDLE_COMMAND", "spindle-on", { spindle: 0, rpm: 1200, e
assert.equal(spindle.kind, "COMMAND_RESULT");
assert.equal(spindle.payload.device_io.source, "wasm_io");
assert.equal(spindle.payload.device_io.state.spindles[0].command_rpm, 1200);
const spindleSlower = send("SPINDLE_COMMAND", "spindle-decrease", { spindle: 0, delta: -100 });
assert.equal(spindleSlower.payload.device_io.state.spindles[0].command_rpm, 1100);
const spindleFaster = send("SPINDLE_COMMAND", "spindle-increase", { spindle: 0, delta: 100 });
assert.equal(spindleFaster.payload.device_io.state.spindles[0].command_rpm, 1200);
const prepared = send("TOOL_PREPARE", "tool-prepare", { tool: 7, pocket: 3 });
assert.equal(prepared.payload.device_io.source, "wasm_io");
assert.equal(send("TOOL_PREPARED", "tool-prepared", { prepared: true }).payload.status, "verified");
assert.equal(send("SPINDLE_COMMAND", "spindle-off", { spindle: 0, rpm: 0, enabled: false }).payload.status, "verified");
assert.equal(send("SPINDLE_BRAKE", "brake-release", { spindle: 0, engaged: false }).payload.status, "verified");
const brake = send("SPINDLE_BRAKE", "brake-engage", { spindle: 0, engaged: true });
assert.equal(brake.payload.device_io.state.spindles[0].brake, 1);
assert.equal(send("TOOL_CHANGE", "tool-change").payload.status, "verified");
assert.equal(send("TOOL_CHANGED", "tool-changed", { changed: true }).payload.status, "verified");
assert.equal(send("PROBE_CONFIG", "probe-arm", { probe_type: 0 }).payload.status, "verified");
assert.equal(send("PROBE_CONFIG", "probe-disarm", { action: "disarm" }).payload.status, "verified");
assert.equal(send("PROBE_CONFIG", "probe-rearm", { probe_type: 0 }).payload.status, "verified");
const probe = session.xyzacIo.probeUpdate({ x: 10, y: 20, z: 30, a: 30, c: 45 }, 1);
assert.equal(probe.status, "verified");
const hal = send("HAL_SET", "hal-probe", { target: "pin", name: "motion.probe-input", type: "bit", value: true });

View File

@@ -139,7 +139,11 @@ export class WasmXyzacIo {
command(kind, payload = {}) {
if (kind === "RESET") return this.invoke("sim_io_reset");
if (kind === "SPINDLE_COMMAND") return this.invoke("sim_io_spindle_command", [payload.spindle ?? 0, payload.rpm ?? payload.speed ?? 0, payload.enabled === false ? 0 : 1]);
if (kind === "SPINDLE_COMMAND") {
if (payload.delta !== undefined) return this.invoke("sim_io_spindle_adjust", [payload.spindle ?? 0, Number(payload.delta)]);
return this.invoke("sim_io_spindle_command", [payload.spindle ?? 0, payload.rpm ?? payload.speed ?? 0, payload.enabled === false ? 0 : 1]);
}
if (kind === "SPINDLE_BRAKE") return this.invoke("sim_io_spindle_brake", [payload.spindle ?? 0, payload.engaged === true ? 1 : 0]);
if (kind === "SPINDLE_FAULT") return this.invoke("sim_io_spindle_set_fault", [payload.spindle ?? 0, payload.fault === true ? 1 : 0]);
if (kind === "SPINDLE_INHIBIT") return this.invoke("sim_io_spindle_set_inhibit", [payload.spindle ?? 0, payload.inhibit === true ? 1 : 0]);
if (kind === "SPINDLE_ORIENT") return this.invoke("sim_io_spindle_orient", [payload.spindle ?? 0, payload.angle ?? 0, payload.mode ?? 0]);
@@ -151,10 +155,7 @@ export class WasmXyzacIo {
if (kind === "TOOL_ABORT") return this.invoke("sim_io_tool_abort", [payload.reason ?? 0]);
if (kind === "TOOL_FAULT") return this.invoke("sim_io_tool_set_fault", [payload.fault === true ? 1 : 0, payload.reason ?? 0]);
if (kind === "TOOL_CLEAR_FAULT") return this.invoke("sim_io_tool_clear_fault");
if (kind === "PROBE_CONFIG") {
const result = this.invoke("sim_io_probe_arm", [payload.probe_type ?? payload.type ?? 0]);
return result;
}
if (kind === "PROBE_CONFIG") return payload.action === "disarm" ? this.invoke("sim_io_probe_disarm") : this.invoke("sim_io_probe_arm", [payload.probe_type ?? payload.type ?? 0]);
if (kind === "ESTOP" || kind === "DEVICE_ESTOP") return this.invoke("sim_io_set_estop", [payload.active === true ? 1 : 0]);
if (kind === "POWER") return this.invoke("sim_io_set_power", [payload.active === true ? 1 : 0]);
if (kind === "IO_RECOVER" || kind === "INTERLOCK_RESET") return this.invoke("sim_io_recover");