Align Web execute-next-line with LinuxCNC

This commit is contained in:
2026-09-10 08:30:04 +08:00
parent 0a202e5841
commit aac1ceb250
5 changed files with 226 additions and 17 deletions

View File

@@ -395,8 +395,9 @@ export function App() {
setOperationTrace(operationController.exportTrace()); setCommandStatus(`${kind} acknowledged`);
if (kind === "PROGRAM_RUN" || kind === "PROGRAM_RESUME") startRunTimer();
if (["PROGRAM_PAUSE", "PROGRAM_ABORT", "PROGRAM_RESTART"].includes(kind)) stopRunTimer();
const result = (operation.response as { payload?: { homing?: { phase?: string }; panel_snapshot?: { motion?: { homing?: boolean } } } }).payload;
const result = (operation.response as { payload?: { homing?: { phase?: string }; step_waiting_for_motion?: boolean; panel_snapshot?: { motion?: { homing?: boolean } } } }).payload;
if (kind === "HOME" && result?.homing?.phase === "accepted") startHomeTimer();
if (kind === "PROGRAM_STEP" && result?.step_waiting_for_motion === true) startRunTimer();
// Ordinary abort keeps sampling the native free_tp deceleration.
if (kind === "PROGRAM_ABORT") { if (result?.panel_snapshot?.motion?.homing) startHomeTimer(); else stopHomeTimer(); }
if (["ESTOP", "POWER"].includes(kind)) stopHomeTimer();

View File

@@ -145,6 +145,7 @@ export class SimSession {
this.pendingProgramControl = 0;
this.pendingTaskSync = 0;
this.pendingXyzacMotion = null;
this.xyzacStepTargetId = 0;
this.pendingServoSync = 0;
this.xyzacExecutionWaitNextMotion = false;
this.xyzacExecutionWaitSamples = 0;
@@ -2322,20 +2323,68 @@ export class SimSession {
case "PROGRAM_STEP": {
if (this.xyzacProgram) {
try {
const current = this.xyzacProgram.snapshot();
const idle = !["RUNNING", "PAUSED"].includes(this.task.state.state);
let current = this.xyzacProgram.snapshot();
let queueDepth = Number(this.wasmMotion.read().state?.queue_depth ?? 0);
let stepPhase = "source_line_pause";
let stepWaitingForMotion = false;
let stepSourceLine = current.execution_source_line ?? current.current_line ?? 0;
let stepTargetId = null;
let stepResult = null;
let stepTrace = [];
this.task.setMode("AUTO");
this.wasmHomeLimit.setMode("COORD");
if (this.wasmMotion.isStepping()) {
const motion = this.wasmMotion.read();
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, step_phase: "motion_step_pending", step_waiting_for_motion: true, step_target_id: Number(motion.state.active_id ?? 0) || this.xyzacStepTargetId || null, program_steps: [], execution_trace: [], program: current, task: this.task.export(), wasm_motion: motion, motion: this.wasmMotionProjection(), motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified");
}
if (queueDepth > 0) {
const targetId = Number(this.wasmMotion.read().state.active_id ?? 0) || this.xyzacStepTargetId;
this.xyzacStepTargetId = targetId;
if (current.status === 2) {
const paused = this.xyzacProgram.pause();
if (paused.status !== "verified") return finish(this.error(input, "program_pause", paused.code), "failed");
if (this.task.state.state === "READY") this.task.transition("RUNNING");
if (this.task.state.state === "RUNNING") this.task.transition("PAUSED");
this.task.setProgram("paused", current.current_line);
current = this.xyzacProgram.snapshot();
}
this.wasmMotion.stepLine();
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, step_phase: "motion_step", step_waiting_for_motion: true, step_target_id: targetId || null, step_source_line: current.execution_source_line ?? current.current_line, program_steps: [], execution_trace: [], program: current, task: this.task.export(), wasm_motion: this.wasmMotion.read(), motion: this.wasmMotionProjection(), motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified");
}
if (["completed", "aborted"].includes(current.program_status) && this.task.state.state === "STOPPED") {
const reset = this.resetXyzacProgramToLoaded();
if (reset.status !== "verified") return finish(this.error(input, "program_step", reset.code ?? "program_reload_failed"), "failed");
this.wasmMotion.setPaused(false);
this.xyzacProgramKinematicsMode = XYZAC_MODES.identity;
if (typeof this.wasmMotion.setActiveKinematicsTag === "function") this.wasmMotion.setActiveKinematicsTag(XYZAC_MODES.identity);
this.task.recover();
this.task.setProgram("loaded", 0);
current = this.xyzacProgram.snapshot();
}
const idle = !["RUNNING", "PAUSED"].includes(this.task.state.state) && ![2, 3].includes(current.status);
if (idle) {
const started = this.xyzacProgram.start();
if (started.status !== "verified") return finish(this.error(input, "program_step", started.code), "failed");
} else if (current.status === 3 && this.wasmMotion.queueDepth() > 0) {
this.wasmMotion.stepLine();
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, program: current, task: this.task.export(), motion: this.wasmMotionProjection() }), "verified");
stepPhase = "start_pause";
stepSourceLine = 0;
} else {
if (current.status === 3) this.xyzacProgram.resume();
if (current.status === 3) {
const resumed = this.xyzacProgram.resume();
if (resumed.status !== "verified") return finish(this.error(input, "program_resume", resumed.code), "failed");
current = this.xyzacProgram.snapshot();
if (current.status === 4) {
this.task.setProgram("completed", current.current_line);
if (["RUNNING", "PAUSED"].includes(this.task.state.state)) this.task.transition("STOPPED");
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, step_phase: "terminal", step_waiting_for_motion: false, step_source_line: current.current_line, program_steps: [], execution_trace: [], program: current, task: this.task.export(), wasm_motion: this.wasmMotion.read(), motion: this.wasmMotionProjection(), motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified");
}
if (current.status === 1) {
const started = this.xyzacProgram.start();
if (started.status !== "verified") return finish(this.error(input, "program_step", started.code), "failed");
}
}
const stepped = this.xyzacProgram.step();
if (stepped.status !== "verified") return finish(this.error(input, "program_step", stepped.code), "failed");
stepResult = stepped;
const sync = this.syncXyzacProgramSpindle(stepped.program);
if (sync.status !== "verified" && sync.status !== "skipped") return finish(this.error(input, "program_spindle", sync.code), "failed");
if (stepped.has_control && [428, 429, 430].includes(stepped.control_code)) {
@@ -2346,20 +2395,30 @@ export class SimSession {
stepped.motion.kinematics_mode = this.xyzacProgramKinematicsMode;
const queued = this.wasmMotion.enqueue(stepped.motion);
if (queued.status === "error") return finish(this.error(input, "motion_queue", queued.code), "failed");
// AUTO_STEP releases the current trajectory line. The WASM TP
// will stop again when its execution ID changes.
this.wasmMotion.stepLine();
stepTargetId = Number(stepped.motion.source_line ?? stepped.line ?? 0) || Number(this.wasmMotion.read().state.active_id ?? 0) || null;
this.xyzacStepTargetId = stepTargetId ?? 0;
stepPhase = "source_line_motion";
stepWaitingForMotion = true;
}
stepSourceLine = stepped.program?.execution_source_line ?? stepped.line ?? stepSourceLine;
stepTrace = [{ line: stepSourceLine, text: stepped.program?.execution_line_text ?? "", program_status: stepped.program?.program_status ?? null }].filter((entry) => Number.isSafeInteger(entry.line) && entry.line > 0);
}
if (this.xyzacProgram.snapshot().status === 4 && this.wasmMotion.read().state.queue_depth === 0) {
this.task.setProgram("completed", this.xyzacProgram.snapshot().current_line);
if (this.task.state.state !== "STOPPED") this.task.transition("STOPPED");
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion: this.wasmMotionProjection() }), "verified");
if (["RUNNING", "PAUSED"].includes(this.task.state.state)) this.task.transition("STOPPED");
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, step_phase: "terminal", step_waiting_for_motion: false, step_source_line: this.xyzacProgram.snapshot().current_line, program: this.xyzacProgram.snapshot(), task: this.task.export(), wasm_motion: this.wasmMotion.read(), motion: this.wasmMotionProjection(), motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified");
}
const paused = this.xyzacProgram.pause();
if (paused.status !== "verified") return finish(this.error(input, "program_pause", paused.code), "failed");
this.wasmMotion.setPaused(true);
if (["READY", "STOPPED"].includes(this.task.state.state)) this.task.transition("RUNNING");
if (!this.wasmMotion.isStepping()) this.wasmMotion.setPaused(true);
if (this.task.state.state === "STOPPED") this.task.recover();
if (this.task.state.state === "READY") this.task.transition("RUNNING");
if (this.task.state.state === "RUNNING") this.task.transition("PAUSED");
this.task.setProgram("paused", this.xyzacProgram.snapshot().current_line);
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion: this.wasmMotionProjection() }), "verified");
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, step_phase: stepPhase, step_waiting_for_motion: stepWaitingForMotion || this.wasmMotion.isStepping(), step_source_line: stepSourceLine, step_target_id: stepTargetId, program_steps: stepResult ? [stepResult] : [], execution_trace: stepTrace, program: this.xyzacProgram.snapshot(), task: this.task.export(), wasm_motion: this.wasmMotion.read(), motion: this.wasmMotionProjection(), motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified");
} catch (error) {
return finish(this.error(input, "program_step", String(error?.message ?? error)), "failed");
}

View File

@@ -0,0 +1,100 @@
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 { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
const fetchImpl = async (url) => {
try {
const body = await readFile(String(url).replace(/^\//, ""), "utf8");
return { ok: true, text: async () => body };
} catch {
return { ok: false, text: async () => "" };
}
};
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
assert.equal(bundle.status, "ready");
const core = await createModule({ wasmBinary: await readFile("./build-wasm/linuxcnc-sim-core.wasm") });
const session = new SimSession(core, { sessionId: "xyzac-step-flow" });
let seq = 0;
const send = (kind, payload = {}) => session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, seq: ++seq, operation_id: `step-flow-${seq}`, kind, payload });
const payload = (kind, body = {}) => {
const response = send(kind, body);
assert.notEqual(response.kind, "ERROR", `${kind}: ${response.payload?.gate_reason ?? response.payload?.message_key ?? response.payload?.code}`);
return response.payload;
};
const step = () => payload("PROGRAM_STEP");
const drain = () => {
let cycles = 0;
while (session.wasmMotion.isStepping() && cycles < 1000) {
payload("SIM_STEP", { cycles: 1, response: "motion_delta" });
cycles += 1;
}
assert.equal(session.wasmMotion.isStepping(), false, "single-step motion must reach an execution-id boundary");
return cycles;
};
payload("HELLO");
payload("SESSION_ATTACH");
payload("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
payload("PROGRAM_LOAD", { path: "step-flow.ngc", source: "G21 G90\nG1 X1 F600\nG1 X2 F600\nM2\n" });
payload("POWER", { active: true });
payload("HOME", { all: true });
payload("SIM_STEP", { cycles: 1 });
let result = step();
assert.equal(result.step_phase, "start_pause");
assert.equal(result.step_source_line, 0);
assert.equal(result.step_waiting_for_motion, false);
assert.equal(result.task.state, "PAUSED");
result = step();
assert.equal(result.step_phase, "source_line_pause");
assert.equal(result.step_source_line, 1);
assert.equal(result.program_steps.length, 1);
result = step();
assert.equal(result.step_phase, "source_line_motion");
assert.equal(result.step_waiting_for_motion, true);
assert.equal(result.step_source_line, 2);
assert.equal(result.program_steps.length, 1);
const firstTarget = result.step_target_id ?? result.wasm_motion.state.active_id;
assert.ok(Number.isSafeInteger(result.step_target_id) && result.step_target_id > 0);
assert.equal(result.motion.stepping, true);
const duplicate = step();
assert.equal(duplicate.step_phase, "motion_step_pending");
assert.equal(duplicate.step_target_id, firstTarget);
assert.equal(duplicate.program_steps.length, 0);
drain();
assert.ok(Math.abs(session.wasmMotion.read().state.position.x - 1) < 1e-8);
result = step();
assert.equal(result.step_phase, "source_line_motion");
assert.equal(result.step_source_line, 3);
drain();
assert.ok(Math.abs(session.wasmMotion.read().state.position.x - 2) < 1e-8);
result = step();
assert.equal(result.step_phase, "terminal");
assert.equal(result.program.program_status, "completed");
assert.equal(result.task.state, "STOPPED");
result = step();
assert.equal(result.step_phase, "start_pause");
assert.equal(result.program.program_status, "paused");
assert.equal(result.task.state, "PAUSED");
payload("PROGRAM_ABORT");
result = step();
assert.equal(result.step_phase, "start_pause");
assert.equal(result.program.program_status, "paused");
console.log(JSON.stringify({
schema: "xyzac-step-flow.v2",
status: "verified",
source: ["axis.py::task_step", "emctaskmain.cc::EMC_TASK_PLAN_STEP_TYPE", "sim_linuxcnc_tp_step_line"],
phases: ["start_pause", "source_line_pause", "source_line_motion", "motion_step_pending", "terminal"],
duplicate_step_idempotent: true,
completed_and_aborted_rerun: true,
}));