Enforce AXIS source parity across panel controls

This commit is contained in:
2026-09-02 23:46:00 +08:00
parent 8b7fc5221b
commit 03c7fdf8f0
10 changed files with 357 additions and 14 deletions

View File

@@ -549,6 +549,9 @@ export class SimSession {
});
if (result === null) return { action, enabled: false, reason: "linuxcnc_wasm_gate_unavailable", taskState: task.state, interpState: "UNKNOWN" };
let reason = result === 0 ? "linuxcnc_wasm_authority" : WASM_GATE_REASONS[result] ?? "linuxcnc_wasm_gate_unknown_command";
if (input.kind === "PROGRAM_RUN" && Number(program.status) === 5) {
return { action, enabled: false, reason: "xyzac_program_aborted_requires_restart", taskState: task.state, interpState: "IDLE", wasm: { io_ready: ready, io_estop: estop, program_status: program.program_status, queue_depth: wasmMotion.state.queue_depth, gate_result: result } };
}
if (result === 2 && input.kind === "POWER" && input.payload?.active !== true) reason = "xyzac_power_already_off";
if (result === 3 && input.kind === "POWER") reason = "xyzac_power_already_on";
if (result === 4 && input.kind === "MDI_EXECUTE") reason = "mdi_while_program_active";
@@ -1953,7 +1956,14 @@ export class SimSession {
if (result.status === "verified") this.xyzacProgram.setRapidVelocity(this.xyzacRapidVelocity);
if (result.status === "verified") { const subroutines = this.loadXyzacSubroutines(input.payload?.path); if (subroutines.status !== "verified") return finish(this.error(input, "program_subroutine", subroutines.code), "failed"); }
const preview = result.status === "verified" ? this.xyzacProgram.preview() : result;
if (result.status === "verified" && preview.status === "verified") { this.previewState = preview; this.task.setProgram("loaded", 0); }
if (result.status === "verified" && preview.status === "verified") {
this.previewState = preview;
// AXIS open_file_guts()/reload_file() leaves the task usable for
// AUTO single-step after an earlier abort. Recover the task edge
// before publishing the newly loaded interpreter state.
if (this.task.state.state === "STOPPED") this.task.recover();
this.task.setProgram("loaded", 0);
}
const finalStatus = result.status === "verified" && preview.status === "verified" ? "verified" : "error";
return finish(this.envelope(finalStatus === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, preview, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), finalStatus);
}
@@ -2116,6 +2126,13 @@ export class SimSession {
if (current.status === 1) this.xyzacProgram.start();
const result = this.xyzacProgram.step();
if (result.status === "verified") {
// AXIS AUTO_STEP returns to an interpreter-paused state after the
// single canonical block. Keep the WASM interpreter status and
// task status aligned so the Resume gate is authoritative.
if (result.program_status !== 4) {
const pauseResult = this.xyzacProgram.pause();
if (pauseResult.status !== "verified") return finish(this.error(input, "program_pause", pauseResult.code ?? "program_pause_failed"), "failed");
}
if (this.task.state.state === "READY") this.task.transition("RUNNING");
if (result.motion) this.wasmMotion.enqueue(result.motion);
this.wasmMotion.setPaused(true);
@@ -2175,6 +2192,11 @@ export class SimSession {
const block = input.payload?.block ?? input.payload?.command;
if (this.xyzacProgram) {
this.task.setMode("MDI");
// AXIS send_mdi_command() leaves the interpreter/TP runnable after
// a previous manual jog stop. JOG_STOP intentionally pauses the
// WASM TP, so clear that transport pause before queuing MDI motion;
// the queue itself remains authoritative for execution.
this.wasmMotion.setPaused(false);
const result = this.xyzacProgram.executeBlock(block);
if (result.status === "verified") {
const controlCode = Number(result.program?.control_code ?? 0);
@@ -2233,7 +2255,11 @@ export class SimSession {
if (this.motionAuthority === "wasm") {
if (input.kind === "JOG_STOP") {
this.wasmMotion.abort();
this.wasmMotion.setPaused(true);
// AXIS jog_off() stops the active jog, but it does not leave the
// task/TP in AUTO_PAUSE. Keep the LinuxCNC TP runnable so an
// already queued servo sample, MDI block, or spindle/IO command
// cannot surface a synthetic linuxcnc_tp_paused scheduler error.
this.wasmMotion.setPaused(false);
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, motion: this.wasmMotionProjection(), motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified");
}
if (input.kind === "SET_OVERRIDE") {

View File

@@ -67,13 +67,16 @@ assert.equal(sent.length, 2);
assert.deepEqual(controller.exportTrace().map((item) => item.phase), ["pending", "result", "pending", "result"]);
const appSource = await readFile(new URL("../react/src/App.tsx", import.meta.url), "utf8");
const ironLaw = await readFile(new URL("../../docs/linuxcnc-axis-web-iron-law.md", import.meta.url), "utf8");
for (const source of [ironLaw, appSource]) assert.ok(source.includes("linuxcnc"), "axis_source_authority");
for (const symbol of ["manual_ok()", "PROGRAM_RUN/PAUSE/RESUME/ABORT", "JOG_START/JOG_STOP", "MDI_EXECUTE", "COOLANT_COMMAND", "blocked_by_abi"]) assert.ok(ironLaw.includes(symbol), `iron_law_${symbol}`);
const panelSource = await readFile(new URL("../react/src/XyzacTrtPanel.tsx", import.meta.url), "utf8");
const contractSource = await readFile(new URL("../react/src/xyzac-panel-contract.ts", import.meta.url), "utf8");
const sessionSource = await readFile(new URL("./sim-session.mjs", import.meta.url), "utf8");
const tpSource = await readFile(new URL("./wasm-linuxcnc-tp.mjs", import.meta.url), "utf8");
const combined = `${appSource}\n${panelSource}\n${contractSource}`;
for (const symbol of ["XyzacTrtPanel", "XyzacProgramPane", "XyzacKinematicsControl", "XyzacManualPanel", "XyzacLiveParameters", "XyzacDiagnostics", "Spindle", "Feed actual", "TP velocity", "Live parameters", "KINEMATICS_SELECT", "VISUAL_CLEAR", "POWER", "switchkinsXyzac", "Operation trace", "Alarms / recovery"]) assert.ok(combined.includes(symbol), symbol);
for (const symbol of ["wasmSnapshotReady", "manualReady", "sourceJogScale", "sourceVelocityScale", "spindleOverrideSource", "SET_JOG_SCALE", "SET_MAX_VELOCITY", "SET_SPINDLE_OVERRIDE", "WASM PENDING", "LinuxCNC WASM override-limits command unavailable"]) assert.ok(appSource.includes(symbol), symbol);
for (const symbol of ["wasmSnapshotReady", "manualReady", "sourceJogScale", "sourceVelocityScale", "spindleOverrideSource", "reopenDefaults", "gates.reset.enabled", "SET_JOG_SCALE", "SET_MAX_VELOCITY", "SET_SPINDLE_OVERRIDE", "WASM PENDING", "LinuxCNC WASM override-limits command unavailable"]) assert.ok(appSource.includes(symbol), symbol);
for (const forbidden of [/defaultValue=\"50\"/, /defaultValue=\"100\"/, /setBlockDelete/, /setOptionalStop/, /state\.status === \"running\" \? \"READING\"/]) assert.equal(forbidden.test(appSource), false, String(forbidden));
for (const forbidden of [/_sim_/, /_malloc/, /\bHEAP(?:U|F|I)?\d*\b/, /navigator\.storage/, /\bOPFS\b/i, /postMessage\s*\(/]) assert.equal(forbidden.test(combined), false, String(forbidden));
assert.ok(appSource.includes("SELECTED_XYZAC_CASE"));