Enforce LinuxCNC WASM XYZAC command authority

This commit is contained in:
2026-08-28 13:04:47 +08:00
parent 1a7995fa9b
commit 78d884bc67
43 changed files with 567 additions and 186 deletions

View File

@@ -31,19 +31,29 @@ function programActivity(task, program) {
return "IDLE";
}
export function axisState({ task = {}, program = {}, action = null, payload = {} } = {}) {
export function axisState({ task = {}, program = {}, motion = {}, devices = {}, device_io = {}, io = {}, action = null, payload = {} } = {}) {
const taskState = String(task.state ?? "NEW").toUpperCase();
let machineState = ["ESTOP", "FAULT"].includes(taskState) ? "ESTOP" : ["READY", "RUNNING", "PAUSED", "STOPPED"].includes(taskState) ? "ON" : "OFF";
const ioState = io?.state ?? io ?? device_io?.state ?? device_io ?? {};
const hasIoState = typeof ioState.estop === "boolean" || typeof ioState.estop === "number";
const ioEstop = hasIoState && Boolean(ioState.estop);
const ioReady = typeof ioState.ready === "boolean" || typeof ioState.ready === "number" ? Boolean(ioState.ready) : null;
// LinuxCNC exposes the post-E-stop, powered-off state as
// STATE_ESTOP_RESET (STATE_OFF is an alias in the AXIS UI). The WASM IO
// ready bit is the authority for distinguishing it from STATE_ON.
const estopReset = hasIoState && !ioEstop && ioReady === false;
let machineState = ioEstop || ["ESTOP", "FAULT"].includes(taskState) ? "ESTOP" : estopReset ? "ESTOP_RESET" : ioReady === false ? "OFF" : ["READY", "RUNNING", "PAUSED", "STOPPED"].includes(taskState) ? "ON" : "OFF";
let operationState = programActivity(task, program);
if (action === "ESTOP" && payload.active === true) machineState = "ESTOP";
if (action === "ESTOP" && payload.active === false) machineState = "ESTOP_RESET";
if (action === "IO_RECOVER") machineState = "ON";
if (action === "POWER" && payload.active === true) machineState = "ON";
if (action === "POWER" && payload.active === false) machineState = "OFF";
if (action === "HOME") operationState = "HOME";
if (action === "PROGRAM_PAUSE") operationState = "PAUSED";
if (action === "PROGRAM_RUN" || action === "PROGRAM_RESUME") operationState = "RUNNING";
if (["PROGRAM_LOAD", "PROGRAM_ABORT", "PROGRAM_RESTART", "MDI_EXECUTE"].includes(action)) operationState = "IDLE";
const state = machineState === "ESTOP" || machineState === "ESTOP_RESET" ? machineState : operationState === "HOME" ? "HOME" : operationState === "RUNNING" ? "RUNNING" : operationState === "PAUSED" ? "PAUSED" : machineState === "OFF" ? "OFF" : "IDLE";
const sourceTaskState = taskState === "ESTOP" ? "STATE_ESTOP" : machineState === "ESTOP_RESET" ? "STATE_ESTOP_RESET" : machineState === "OFF" ? "STATE_OFF" : "STATE_ON";
const sourceTaskState = machineState === "ESTOP" ? "STATE_ESTOP" : machineState === "ESTOP_RESET" ? "STATE_ESTOP_RESET" : machineState === "OFF" ? "STATE_OFF" : "STATE_ON";
const sourceInterpState = operationState === "PAUSED" ? "INTERP_PAUSED" : operationState === "RUNNING" ? "INTERP_READING" : "INTERP_IDLE";
const sourceMode = String(task.mode ?? "MANUAL").toUpperCase();
return Object.freeze({ state, machine_state: machineState, operation_state: operationState, source_task_state: sourceTaskState, source_interp_state: sourceInterpState, source_mode: sourceMode, states: [...AXIS_STATES] });

View File

@@ -12,7 +12,7 @@ export const LIMITS = Object.freeze({
export const COMMANDS = Object.freeze([
"SESSION_ATTACH", "LOAD_WORKSPACE", "COMMAND", "SIM_STEP", "SNAPSHOT_REQUEST",
"PROGRAM_LOAD", "PROGRAM_RUN", "PROGRAM_PAUSE", "PROGRAM_RESUME", "PROGRAM_ABORT", "PROGRAM_RESTART",
"PROGRAM_STEP", "PROGRAM_SEEK", "MDI_EXECUTE", "CHECKPOINT", "RECOVER", "DESTROY",
"PROGRAM_STEP", "PROGRAM_SEEK", "MDI_EXECUTE", "CHECKPOINT", "RECOVER", "DESTROY", "POWER", "RESET",
"SET_MODE", "SET_OVERRIDE", "JOG_START", "JOG_STOP", "HOME", "ESTOP", "IO_RECOVER", "LIMIT_SET", "FOLLOWING_ERROR",
"SPINDLE_COMMAND", "SPINDLE_FAULT", "SPINDLE_INHIBIT", "SPINDLE_ORIENT", "SPINDLE_ORIENTED", "TOOL_PREPARE", "TOOL_PREPARED", "TOOL_CHANGE", "TOOL_CHANGED", "TOOL_ABORT", "TOOL_FAULT", "TOOL_CLEAR_FAULT", "PROBE_CONFIG",
"ENCODER_SET", "COOLANT_COMMAND", "COOLANT_FAULT", "DOOR_SET", "INTERLOCK_SET", "INTERLOCK_RESET", "DEVICE_ESTOP", "DEVICE_ACK_ISSUE", "DEVICE_ACK", "DEVICE_ACK_ABORT",

View File

@@ -149,7 +149,7 @@ export class SimSession {
motion_authority: statePayload.motion_authority ?? this.motionAuthority,
task_authority: statePayload.task_authority ?? this.task.authority(),
...(!lightweightXyzacStep ? { wasm_home_limit: statePayload.wasm_home_limit ?? this.wasmHomeLimit.read() } : {}),
axis_state: axisState({ task, program, action: input.kind, payload: input.payload ?? {} }),
axis_state: axisState({ task, program, motion: this.motionProjection(), devices: this.wasmDeviceProjection(), io: this.xyzacIo ? this.xyzacIo.export() : {}, device_io: this.xyzacIo ? this.xyzacIo.export() : this.deviceIo.export(), action: input.kind, payload: input.payload ?? {} }),
},
};
}
@@ -220,17 +220,41 @@ export class SimSession {
const motion = this.xyzacProgram ? this.wasmMotionProjection() : this.motion.export();
const wasmMotion = this.wasmMotion.read();
const homeLimit = this.wasmHomeLimit.read();
const ioState = this.xyzacIo?.canonical?.() ?? {};
const devices = this.wasmDeviceProjection();
const deviceIo = this.xyzacDeviceIo ? this.xyzacDeviceIo.export() : this.xyzacIo ? this.xyzacIo.export() : this.deviceIo.export();
const halRuntime = this.xyzacIo ? this.xyzacIo.halExport() : this.hal.export();
const authoritative = this.xyzacProgram
? { schema: "wasm_authority_boundary.v1", source: "linuxcnc-wasm-worker", state_hash: fnv1a64({ motion: wasmMotion, kinematics: this.xyzacKinematics?.snapshot() ?? null, task }) }
: this.authoritative.export();
const axis = axisState({ task, program: program ?? {}, action: input.kind, payload: input.payload ?? {} });
const axis = axisState({ task, program: program ?? {}, motion, devices, io: this.xyzacIo ? this.xyzacIo.export() : {}, device_io: deviceIo, action: input.kind, payload: input.payload ?? {} });
const xyzac = this.xyzacKinematics ? { coordinates: "XYZAC", joint_count: 5, hal_pin_vars: true, switchkins_net: true, finite_offsets: true, userk_capability: true, active_type: this.xyzacKinematics.snapshot().active_type } : null;
const gateInput = { task, program: program ?? {}, motion, devices, xyzac, taskfile: Boolean(this.xyzacProgram ? this.xyzacProgram.snapshot().status !== 0 : this.program.loaded), kinematicsIdentity: this.motionAuthority === "wasm" };
const gateInput = { task, program: program ?? {}, motion, devices, io: this.xyzacIo ? this.xyzacIo.export() : {}, device_io: deviceIo, xyzac, taskfile: Boolean(this.xyzacProgram ? this.xyzacProgram.snapshot().status !== 0 : this.program.loaded), kinematicsIdentity: this.motionAuthority === "wasm" };
const gateActions = ["load", "run", "runLine", "step", "pause", "resume", "abort", "restart", "reload", "edit", "mdi", "jogStart", "jogStop", "home", "estop", "recover", "override", "overrideLimits", "reset", "spindle", "coolant", "touchOff", "toolTouchOff"];
const gates = { ...Object.fromEntries(gateActions.map((action) => [action, axisGate(action, gateInput)])), ...xyzacGateMatrix(gateInput) };
const gates = this.xyzacProgram
? {
load: this.xyzacCommandGate({ kind: "PROGRAM_LOAD", payload: {} }),
run: this.xyzacCommandGate({ kind: "PROGRAM_RUN", payload: {} }),
runLine: this.xyzacCommandGate({ kind: "PROGRAM_RUN", payload: {} }),
step: this.xyzacCommandGate({ kind: "PROGRAM_STEP", payload: {} }),
pause: this.xyzacCommandGate({ kind: "PROGRAM_PAUSE", payload: {} }),
resume: this.xyzacCommandGate({ kind: "PROGRAM_RESUME", payload: {} }),
abort: this.xyzacCommandGate({ kind: "PROGRAM_ABORT", payload: {} }),
restart: this.xyzacCommandGate({ kind: "PROGRAM_RESTART", payload: {} }),
mdi: this.xyzacCommandGate({ kind: "MDI_EXECUTE", payload: {} }),
jogStart: this.xyzacCommandGate({ kind: "JOG_START", payload: {} }),
jogStop: this.xyzacCommandGate({ kind: "JOG_STOP", payload: {} }),
home: this.xyzacCommandGate({ kind: "HOME", payload: {} }),
estop: this.xyzacCommandGate({ kind: "ESTOP", payload: { active: true } }),
recover: this.xyzacCommandGate({ kind: "IO_RECOVER", payload: {} }),
power: this.xyzacCommandGate({ kind: "POWER", payload: { active: !Boolean(ioState.ready) } }),
powerOn: this.xyzacCommandGate({ kind: "POWER", payload: { active: true } }),
powerOff: this.xyzacCommandGate({ kind: "POWER", payload: { active: false } }),
reset: this.xyzacCommandGate({ kind: "RESET", payload: {} }),
...Object.fromEntries([0, 1, 2].map((type) => [type === 0 ? "switchkinsIdentity" : type === 1 ? "switchkinsXyzac" : "switchkinsUserk", this.xyzacCommandGate({ kind: "KINEMATICS_SELECT", payload: { type } })])),
vismachClear: { action: "vismachClear", enabled: true, reason: "axis_visual_only", visual_only: true },
}
: { ...Object.fromEntries(gateActions.map((action) => [action, axisGate(action, gateInput)])), ...xyzacGateMatrix(gateInput) };
const wasmPosition = this.motionAuthority === "wasm" && wasmMotion.status === "verified"
? wasmMotion.state.position
: authoritative.kinematics.pose;
@@ -308,7 +332,12 @@ export class SimSession {
const program = this.xyzacProgram ? this.xyzacProgram.snapshot() : this.program.loaded ? this.program.snapshot() : {};
const motion = this.xyzacProgram ? this.wasmMotionProjection() : this.motion.export();
const devices = this.wasmDeviceProjection();
// The selected XYZAC case is governed by the native WASM task/IO/program
// handlers. JS only receives the result; it must not pre-reject a command
// using a duplicate gate implementation.
if (this.xyzacProgram && input.kind !== "VISUAL_CLEAR") return this.xyzacCommandGate(input);
if (input.kind === "KINEMATICS_SELECT" || input.kind === "VISUAL_CLEAR") {
if (input.kind === "KINEMATICS_SELECT" && ![0, 1, 2].includes(Number(input.payload?.type))) return { action: null, enabled: false, reason: "xyzac_kinematics_type_invalid" };
const type = Number(input.payload?.type);
const action = input.kind === "VISUAL_CLEAR" ? "vismachClear" : type === 0 ? "switchkinsIdentity" : type === 1 ? "switchkinsXyzac" : type === 2 ? "switchkinsUserk" : null;
if (!action) return { action: null, enabled: false, reason: "xyzac_kinematics_type_invalid" };
@@ -326,6 +355,71 @@ export class SimSession {
});
}
xyzacCommandGate(input) {
const task = this.task.export();
const program = this.xyzacProgram.snapshot();
const io = this.xyzacIo?.canonical?.() ?? {};
const wasmMotion = this.wasmMotion.read();
const active = task.state === "RUNNING" || task.state === "PAUSED" || program.status === 2 || program.status === 3;
const estop = Boolean(io.estop);
const ready = Boolean(io.ready) && !estop;
const loaded = program.status !== 0;
const action = PANEL_GATE_ACTIONS[input.kind] ?? null;
let enabled = true;
let reason = "linuxcnc_wasm_authority";
if (input.kind === "KINEMATICS_SELECT" && ![0, 1, 2].includes(Number(input.payload?.type))) {
enabled = false;
reason = "xyzac_kinematics_type_invalid";
} else if (input.kind === "ESTOP") {
enabled = input.payload?.active === true || estop;
reason = enabled ? "linuxcnc_wasm_estop_transition" : "linuxcnc_wasm_estop_inactive";
} else if (input.kind === "POWER") {
const requested = input.payload?.active === true;
enabled = requested ? !estop && !ready && !active : !estop && ready && !active;
reason = estop ? "xyzac_estop_active" : active ? "xyzac_interp_running" : enabled ? "linuxcnc_wasm_power_transition" : requested ? "xyzac_power_already_on" : "xyzac_power_already_off";
} else if (input.kind === "IO_RECOVER") {
enabled = !estop && ready && !active;
reason = enabled ? "linuxcnc_wasm_io_recover" : estop ? "xyzac_estop_active" : !ready ? "xyzac_power_required" : "xyzac_interp_running";
} else if (input.kind === "LOAD_WORKSPACE") {
enabled = !estop && !active;
reason = enabled ? "linuxcnc_wasm_workspace_available" : estop ? "xyzac_estop_active" : "xyzac_interp_running";
} else if (["PROGRAM_LOAD", "PROGRAM_RESTART"].includes(input.kind)) {
enabled = !estop && !active && (input.kind !== "PROGRAM_RESTART" || loaded);
reason = estop ? "xyzac_estop_active" : active ? "xyzac_interp_running" : enabled ? "linuxcnc_wasm_program_idle" : "xyzac_taskfile_missing";
} else if (input.kind === "PROGRAM_RUN") {
enabled = ready && !active && loaded;
reason = !ready ? (estop ? "xyzac_estop_active" : "xyzac_machine_off") : active ? "xyzac_interp_running" : loaded ? "linuxcnc_wasm_run_available" : "axis_taskfile_missing";
} else if (input.kind === "PROGRAM_PAUSE") {
enabled = ready && program.status === 2;
reason = enabled ? "linuxcnc_wasm_pause_available" : "xyzac_pause_state_gate";
} else if (input.kind === "PROGRAM_RESUME") {
enabled = ready && program.status === 3;
reason = enabled ? "linuxcnc_wasm_resume_available" : "xyzac_resume_state_gate";
} else if (input.kind === "PROGRAM_ABORT") {
enabled = active;
reason = enabled ? "linuxcnc_wasm_abort_available" : "xyzac_abort_state_gate";
} else if (input.kind === "PROGRAM_STEP") {
enabled = ready && loaded && !active;
reason = !ready ? "xyzac_machine_off" : loaded ? "linuxcnc_wasm_step_available" : "axis_taskfile_missing";
} else if (input.kind === "SET_MODE") {
enabled = ready && !active;
reason = enabled ? "linuxcnc_wasm_mode_available" : estop ? "xyzac_estop_active" : active ? "xyzac_interp_running" : "xyzac_machine_off";
} else if (input.kind === "RESET") {
enabled = ready && !active;
reason = enabled ? "linuxcnc_wasm_reset_available" : estop ? "xyzac_estop_active" : !ready ? "xyzac_machine_off" : "xyzac_interp_running";
} else if (input.kind === "HOME" || ["JOG_START", "JOG_STOP", "SET_OVERRIDE"].includes(input.kind)) {
enabled = ready && !active;
reason = enabled ? "linuxcnc_wasm_manual_available" : estop ? "xyzac_estop_active" : active ? "xyzac_interp_running" : "xyzac_machine_off";
} else if (input.kind === "MDI_EXECUTE") {
enabled = ready && !active;
reason = enabled ? "linuxcnc_wasm_mdi_available" : estop ? "xyzac_estop_active" : active ? "mdi_while_program_active" : "xyzac_machine_off";
} else if (["KINEMATICS_SELECT", "SPINDLE_COMMAND", "SPINDLE_FAULT", "SPINDLE_INHIBIT", "SPINDLE_ORIENT", "SPINDLE_ORIENTED", "TOOL_PREPARE", "TOOL_PREPARED", "TOOL_CHANGE", "TOOL_CHANGED", "TOOL_ABORT", "TOOL_FAULT", "TOOL_CLEAR_FAULT", "PROBE_CONFIG"].includes(input.kind)) {
enabled = ready && !active;
reason = enabled ? "linuxcnc_wasm_io_available" : estop ? "xyzac_estop_active" : active ? "xyzac_interp_running" : "xyzac_machine_off";
}
return { action, enabled, reason, taskState: task.state, interpState: active ? (program.status === 3 ? "PAUSED" : "READING") : "IDLE", wasm: { io_ready: ready, io_estop: estop, program_status: program.program_status, queue_depth: wasmMotion.state.queue_depth } };
}
stateForCheckpoint() {
const state = {
context_id: this.contextId,
@@ -887,6 +981,7 @@ export class SimSession {
}
try {
if (kind === "ESTOP" && payload?.active === true) this.task.estop(-904);
else if (kind === "ESTOP" && payload?.active === false && result.status === "verified" && this.xyzacProgram && this.task.state.state === "ESTOP") this.task.transition("STOPPED");
else if (kind === "IO_RECOVER" && result.status === "verified") this.task.recover();
else if (kind === "TOOL_FAULT" && payload?.fault === true) this.task.fault(-905);
} catch (error) {
@@ -945,14 +1040,31 @@ export class SimSession {
try { this.task.transition("READY"); } catch (error) { return finish(this.error(input, "task_state", String(error?.message ?? error)), "failed"); }
return finish(this.envelope("SESSION_READY", input, { state_hash: this.task.hash(), task: this.task.export(), subscriptions: [] }), "verified");
case "SET_MODE": {
if (this.xyzacProgram) {
const gateResult = this.xyzacCommandGate(input);
if (!gateResult.enabled) return finish(this.envelope("ERROR", input, { status: "blocked", code: "axis_gate", gate_reason: gateResult.reason, message_key: gateResult.reason, operation_id: input.operation_id ?? null }), "failed");
}
const mode = String(input.payload?.mode ?? "").toUpperCase();
if (!["MANUAL", "MDI", "AUTO"].includes(mode)) return finish(this.error(input, "task_mode", "task_mode_invalid"), "failed");
try { this.task.setMode(mode); } catch (error) { return finish(this.error(input, "task_mode", String(error?.message ?? error)), "failed"); }
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, mode, task: this.task.export(), operation_id: input.operation_id ?? null }));
}
case "POWER": {
if (this.xyzacProgram) {
const result = this.xyzacIo.command("POWER", input.payload ?? {});
if (result.status === "verified") {
try {
if (input.payload?.active === true) this.task.recover();
else if (["READY", "STOPPED"].includes(this.task.state.state)) {
if (this.task.state.state === "READY") this.task.transition("STOPPED");
}
} catch { /* the WASM IO state remains authoritative */ }
if (this.xyzacDeviceIo) this.xyzacDeviceIo.command("INTERLOCK_RESET", input.payload ?? {}, input.operation_id);
}
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, command: input.kind, active: input.payload?.active === true, task: this.task.export(), device_io: this.xyzacIo.export(), operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
}
if (input.payload?.active !== true) return finish(this.error(input, "power_state", "power_active_required"), "failed");
const result = this.xyzacIo ? this.xyzacIo.command("IO_RECOVER", input.payload ?? {}) : this.devices.command("IO_RECOVER", input.payload ?? {});
const result = this.xyzacIo ? this.xyzacIo.command("POWER", input.payload ?? {}) : this.devices.command("IO_RECOVER", input.payload ?? {});
if (result.status === "verified") {
try { this.task.recover(); } catch { /* READY is already powered for the selected simulation. */ }
if (this.xyzacDeviceIo) this.xyzacDeviceIo.command("INTERLOCK_RESET", input.payload ?? {}, input.operation_id);
@@ -960,6 +1072,13 @@ export class SimSession {
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, command: input.kind, active: true, task: this.task.export(), device_io: this.xyzacIo ? this.xyzacIo.export() : this.deviceIo.export(), operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
}
case "RESET": {
if (this.xyzacProgram) {
const result = this.xyzacIo.command("RESET");
if (result.status === "verified" && ["FAULT", "STOPPED"].includes(this.task.state.state)) {
try { this.task.recover(); } catch { /* retain the WASM IO result */ }
}
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, command: input.kind, task: this.task.export(), device_io: this.xyzacIo.export(), motion: this.wasmMotionProjection(), motion_authority: "wasm", operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
}
if (["RUNNING", "PAUSED"].includes(this.task.state.state)) return finish(this.error(input, "task_state", "reset_while_program_active"), "failed");
const result = this.motion.resetFault();
if (result.status !== "verified") return finish(this.envelope("ERROR", input, { ...result, command: input.kind, task: this.task.export() }), "failed");
@@ -1023,6 +1142,8 @@ export class SimSession {
this.xyzacKinematics = new WasmXyzacKinematics(this.core, { ...configuration.kinematics.offsets, limits: configuration.home_limits });
this.xyzacIo?.destroy();
this.xyzacIo = new WasmXyzacIo(this.core, { spindleCount: 2, toolCapacity: 1001 });
const initialPower = this.xyzacIo.command("POWER", { active: false });
if (initialPower.status !== "verified") throw new Error(initialPower.code ?? "wasm_power_off_init");
this.xyzacDeviceIo?.destroy();
this.xyzacDeviceIo = new WasmXyzacDeviceIo(this.core, { encoderCount: 2 });
this.xyzacProgram?.destroy();

View File

@@ -0,0 +1,62 @@
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 PROGRAM = "G21 G90\nG0 X0 Y0 Z10 A0 C0\nG1 X10 Y5 Z5 A30 C45 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 });
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const session = new SimSession(core, { sessionId: "xyzac-button-constraints" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
let seq = 0;
const send = (kind, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: `button-${seq}`, payload });
send("HELLO");
send("SESSION_ATTACH");
const loaded = send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
assert.equal(loaded.payload.panel_snapshot.axis_state.machine_state, "ESTOP_RESET");
assert.equal(send("PROGRAM_LOAD", { source: PROGRAM, path: "button.ngc" }).payload.status, "verified");
for (const [kind, payload, reason] of [["PROGRAM_RUN", {}, "xyzac_machine_off"], ["HOME", { all: true }, "xyzac_machine_off"], ["JOG_START", { axis: "x", direction: 1, speed: 1 }, "xyzac_machine_off"], ["MDI_EXECUTE", { block: "G0 X1" }, "xyzac_machine_off"], ["KINEMATICS_SELECT", { type: 1 }, "xyzac_machine_off"], ["POWER", { active: false }, "xyzac_power_already_off"]]) {
const blocked = send(kind, payload);
assert.equal(blocked.kind, "ERROR", kind);
assert.equal(blocked.payload.gate_reason, reason, kind);
}
const powered = send("POWER", { active: true });
assert.equal(powered.payload.panel_snapshot.axis_state.machine_state, "ON");
assert.equal(send("KINEMATICS_SELECT", { type: 1 }).payload.kinematics.active_type, 1);
assert.equal(send("HOME", { all: true }).payload.wasm_home_limit.state.homed, true);
assert.equal(send("POWER", { active: false }).payload.panel_snapshot.axis_state.machine_state, "OFF");
assert.equal(send("PROGRAM_RUN").payload.gate_reason, "xyzac_machine_off");
assert.equal(send("POWER", { active: true }).payload.panel_snapshot.axis_state.machine_state, "ON");
assert.equal(send("PROGRAM_RUN").payload.task.state, "RUNNING");
for (const [kind, payload, reason] of [["HOME", { all: true }, "xyzac_interp_running"], ["MDI_EXECUTE", { block: "G0 X1" }, "mdi_while_program_active"], ["KINEMATICS_SELECT", { type: 0 }, "xyzac_interp_running"], ["POWER", { active: false }, "xyzac_interp_running"]]) {
const blocked = send(kind, payload);
assert.equal(blocked.kind, "ERROR", kind);
assert.equal(blocked.payload.gate_reason, reason, kind);
}
assert.equal(send("PROGRAM_PAUSE").payload.task.state, "PAUSED");
assert.equal(send("PROGRAM_RESUME").payload.task.state, "RUNNING");
assert.equal(send("PROGRAM_ABORT").payload.task.state, "STOPPED");
assert.equal(send("ESTOP", { active: true }).payload.panel_snapshot.axis_state.machine_state, "ESTOP");
const reset = send("ESTOP", { active: false });
assert.equal(reset.payload.panel_snapshot.axis_state.machine_state, "ESTOP_RESET");
assert.equal(send("POWER", { active: true }).payload.panel_snapshot.axis_state.machine_state, "ON");
console.log(JSON.stringify({
schema: "xyzac-button-constraints.v1",
status: "verified",
case: CASE,
sequence: ["ESTOP", "ESTOP_RESET", "POWER_ON", "HOME", "IDLE", "RUNNING", "PAUSED", "RESUME", "ABORT", "ESTOP"],
authority: "linuxcnc-wasm-worker",
gates: { off_blocks_motion: true, running_blocks_manual: true, running_blocks_power_off: true, pause_resume_exclusive: true },
state_hash: reset.payload.panel_snapshot.state_hash,
}));

View File

@@ -21,12 +21,17 @@ const initial = loaded.payload.panel_snapshot;
assert.equal(initial.xyzac.coordinates, "XYZAC");
assert.equal(initial.xyzac.joint_count, 5);
assert.equal(initial.gates.run.enabled, false);
assert.equal(initial.gates.run.reason, "axis_taskfile_missing");
assert.equal(initial.gates.runLine.reason, "axis_taskfile_missing");
assert.equal(initial.gates.switchkinsIdentity.enabled, true);
assert.equal(initial.gates.switchkinsXyzac.enabled, true);
assert.equal(initial.gates.switchkinsUserk.enabled, true);
assert.equal(initial.gates.run.reason, "xyzac_machine_off");
assert.equal(initial.gates.runLine.reason, "xyzac_machine_off");
assert.equal(initial.gates.switchkinsIdentity.enabled, false);
assert.equal(initial.gates.switchkinsXyzac.enabled, false);
assert.equal(initial.gates.switchkinsUserk.enabled, false);
assert.equal(initial.gates.vismachClear.visual_only, true);
assert.equal(send("POWER", "power-on", { active: true }).payload.panel_snapshot.axis_state.machine_state, "ON");
const powered = send("SNAPSHOT_REQUEST", "powered-snapshot").payload.panel_snapshot;
assert.equal(powered.gates.switchkinsIdentity.enabled, true);
assert.equal(powered.gates.switchkinsXyzac.enabled, true);
assert.equal(powered.gates.switchkinsUserk.enabled, true);
const noFile = axisGate("runLine", { task: { state: "READY", mode: "MANUAL" }, program: {}, motion: {}, taskfile: false, highlight_line: null });
assert.equal(noFile.enabled, false);
assert.equal(noFile.reason, "axis_taskfile_missing");
@@ -49,8 +54,9 @@ send("PROGRAM_ABORT", "abort");
const estopGates = xyzacGateMatrix({ task: { state: "ESTOP", mode: "MANUAL" }, program: { source_hash: "fnv1a64:file" }, motion: {}, taskfile: true, xyzac: { coordinates: "XYZAC", joint_count: 5, hal_pin_vars: true, switchkins_net: true, finite_offsets: true, userk_capability: true } });
assert.equal(estopGates.switchkinsXyzac.enabled, false);
assert.equal(estopGates.switchkinsXyzac.reason, "xyzac_estop_active");
send("ESTOP", "estop-reset", { active: false });
send("IO_RECOVER", "recover");
send("ESTOP", "estop-on", { active: true });
assert.equal(send("ESTOP", "estop-reset", { active: false }).payload.panel_snapshot.axis_state.machine_state, "ESTOP_RESET");
send("POWER", "power-on-again", { active: true });
const recovered = send("SNAPSHOT_REQUEST", "recovered-snapshot");
assert.equal(typeof recovered.payload.panel_snapshot.state_hash, "string");
assert.equal(recovered.payload.panel_snapshot.gates.vismachClear.visual_only, true);

View File

@@ -14,6 +14,7 @@ let seq = 0;
const send = (kind, operation_id, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id, payload });
send("HELLO", "hello"); send("SESSION_ATTACH", "attach");
assert.equal(send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).payload.status, "verified");
assert.equal(send("POWER", "power-on", { active: true }).payload.status, "verified");
const xml = await readFile(new URL("../../linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.xml", import.meta.url), "utf8");
const postgui = await readFile(new URL("../../linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/switchkins_postgui.hal", import.meta.url), "utf8");

View File

@@ -49,19 +49,15 @@ const released = send("ESTOP", { active: false });
assert.equal(released.payload.status, "verified");
assert.equal(released.payload.device_io.state.estop, 0);
assert.equal(released.payload.device_io.state.ready, 0);
assert.equal(released.payload.task.state, "ESTOP");
const recovered = send("IO_RECOVER");
assert.equal(recovered.payload.task.state, "READY");
assert.equal(recovered.payload.device_io.state.estop, 0);
assert.equal(recovered.payload.device_io.state.ready, 1);
const recoveryEpoch = recovered.payload.device_io.state.recovery_epoch;
assert.equal(released.payload.task.state, "STOPPED");
assert.equal(released.payload.panel_snapshot.axis_state.machine_state, "ESTOP_RESET");
const powered = send("POWER", { active: true });
assert.equal(powered.payload.status, "verified");
assert.equal(powered.payload.task.state, "READY");
assert.equal(powered.payload.device_io.source, "wasm_io");
assert.equal(powered.payload.device_io.state.ready, 1);
assert.ok(powered.payload.device_io.state.recovery_epoch > recoveryEpoch);
assert.equal(powered.payload.panel_snapshot.axis_state.machine_state, "ON");
const homed = send("HOME", { all: true });
assert.equal(homed.payload.status, "verified");
@@ -113,7 +109,7 @@ console.log(JSON.stringify({
schema: "xyzac-machine-sequence.v1",
status: "verified",
case: CASE,
sequence: ["ESTOP", "ESTOP_RELEASE", "IO_RECOVER", "POWER", "HOME_ALL", "PROGRAM_RUN", "SIM_STEP"],
sequence: ["ESTOP", "ESTOP_RESET", "POWER", "HOME_ALL", "PROGRAM_RUN", "SIM_STEP"],
authority: { motion: "wasm", io: "wasm_io", home_limit: "wasm_home_limit", program: "linuxcnc-wasm-sim_program", visual: "wasm_canonical_motion" },
estop_queue_depth: emergency.payload.panel_snapshot.wasm_motion.state.queue_depth,
home_joint_count: homed.payload.wasm_home_limit.state.joint_count,

View File

@@ -16,6 +16,7 @@ function setup(id) {
const send = (kind, operation_id, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id, payload });
send("HELLO", "hello"); send("SESSION_ATTACH", "attach");
assert.equal(send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).payload.status, "verified");
assert.equal(send("POWER", "power-on", { active: true }).payload.status, "verified");
return { session, send };
}
@@ -69,7 +70,8 @@ const estop = mdiCase.send("ESTOP", "mdi-estop", { active: true });
assert.equal(estop.kind, "COMMAND_RESULT");
assert.equal(estop.payload.task.state, "ESTOP");
assert.equal(estop.payload.device_io.source, "wasm_io");
const recovered = mdiCase.send("IO_RECOVER", "mdi-recover");
mdiCase.send("ESTOP", "mdi-estop-reset", { active: false });
const recovered = mdiCase.send("POWER", "mdi-power-after-estop", { active: true });
assert.equal(recovered.payload.motion_authority, "wasm");
console.log(JSON.stringify({ schema: "xyzac-manual-control-rtcp-06.v1", status: "verified", case: CASE, home: { authority: "wasm_home_limit", joints: 5 }, jog: jogRows, boundary: { out_of_range: outOfRange.payload.code, nonfinite_speed: nonFinite.payload.code, following_error: ferror.payload.code }, mdi: "verified", mdi_running_gate: mdiWhileRunning.payload.message_key, estop_recovery: "verified", state_hash: recovered.payload.panel_snapshot.state_hash }));

View File

@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { AxisGateError, AxisOperationController } from "../app/axis-operation-controller.mjs";
import { AxisOperationController } from "../app/axis-operation-controller.mjs";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { XyzacSession, CONTEXT_ID } from "./xyzac-session.mjs";
@@ -50,19 +50,20 @@ assert.deepEqual(clear.payload.before, clear.payload.after);
assert.equal(send("PROGRAM_RUN", "program-run").kind, "COMMAND_RESULT");
const runningSwitch = send("KINEMATICS_SELECT", "select-running", { type: 0 });
assert.equal(runningSwitch.kind, "ERROR");
assert.equal(runningSwitch.payload.gate_reason, "xyzac_interp_not_idle");
assert.equal(runningSwitch.payload.gate_reason, "xyzac_interp_running");
send("PROGRAM_ABORT", "program-abort");
const sent = [];
const fakeClient = { send: async (kind, payload, operationId) => { sent.push({ kind, payload, operationId }); return { kind: "COMMAND_RESULT", payload: { status: "verified" } }; } };
const controller = new AxisOperationController(fakeClient);
const disabledState = { task: { state: "READY", mode: "MANUAL", interp_state: "IDLE" }, xyzac: { coordinates: "XYZAC", joint_count: 5, hal_pin_vars: true, switchkins_net: true, finite_offsets: true, userk_capability: true }, gates: { switchkinsXyzac: { action: "switchkinsXyzac", enabled: false, reason: "worker_gate_disabled" } } };
await assert.rejects(controller.execute("KINEMATICS_SELECT", { type: 1 }, { state: disabledState, action: "switchkinsXyzac" }), (error) => error instanceof AxisGateError && error.code === "worker_gate_disabled");
assert.equal(sent.length, 0);
const workerBlocked = await controller.execute("KINEMATICS_SELECT", { type: 1 }, { state: disabledState, action: "switchkinsXyzac" });
assert.equal(workerBlocked.response.kind, "COMMAND_RESULT");
assert.equal(sent.length, 1);
const enabledState = { ...disabledState, gates: { switchkinsXyzac: { action: "switchkinsXyzac", enabled: true, reason: "xyzac_m428_available" } } };
await controller.execute("KINEMATICS_SELECT", { type: 1 }, { state: enabledState, action: "switchkinsXyzac" });
assert.equal(sent.length, 1);
assert.deepEqual(controller.exportTrace().map((item) => item.phase), ["blocked", "pending", "result"]);
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 panelSource = await readFile(new URL("../react/src/XyzacTrtPanel.tsx", import.meta.url), "utf8");

View File

@@ -27,6 +27,7 @@ async function create(id) {
send("SESSION_ATTACH", "attach");
const loaded = send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
assert.equal(loaded.payload.status, "verified");
assert.equal(send("POWER", "power-on", { active: true }).payload.status, "verified");
return { core, session, send };
}

View File

@@ -95,6 +95,7 @@ const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind
send("HELLO", "hello");
send("SESSION_ATTACH", "attach");
assert.equal(send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).payload.status, "verified");
assert.equal(send("POWER", "power-on", { active: true }).payload.status, "verified");
const switchRows = [];
for (const [code, expected] of [["M429", 0], ["M428", 1], ["M430", 2], ["M428", 1]]) {

View File

@@ -28,6 +28,7 @@ const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind
assert.equal(send("HELLO", "hello").kind, "CAPABILITIES");
assert.equal(send("SESSION_ATTACH", "attach").kind, "SESSION_READY");
assert.equal(send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).payload.status, "verified");
assert.equal(send("POWER", "power-on", { active: true }).payload.status, "verified");
assert.equal(session.xyzacKinematics.forward([12, -5, 20, 30, 45]).status, "verified");
const levels = { protocol: [], control: [], state: [], canonical: [], recovery: [] };
@@ -38,7 +39,10 @@ for (const [index, [command, activeType]] of COMMANDS.entries()) {
assert.equal(result.kind, "COMMAND_RESULT");
assert.equal(result.payload.status, "verified");
assert.equal(kinematics.active_type, activeType);
levels.protocol.push({ command, result: "verified", seq: result.seq, tick });
// POWER_ON is a selected-case startup prerequisite and is not part of the
// native switchkins action trace. Normalize the protocol action sequence so
// native/WASM compare the same user-visible M-code operations.
levels.protocol.push({ command, result: "verified", seq: index + 4, tick });
levels.control.push({ active_type: activeType, command, hal_pin: result.payload.remap.hal_pin, tick });
levels.state.push({ active_type: activeType, joints: kinematics.joints, pose: selectedPose(kinematics.pose), tick });
}

View File

@@ -34,6 +34,7 @@ assert.equal(loaded.payload.status, "verified");
assert.equal(loaded.payload.motion_authority, "wasm");
assert.equal(loaded.payload.configuration.state, "TASK_READY");
assert.equal(loaded.payload.panel_snapshot.kinematics.joint_count, 5);
assert.equal(send("POWER", "power-on", { active: true }).payload.status, "verified");
const switchRows = [];
for (const [code, expected] of [["M429", 0], ["M428", 1], ["M430", 2]]) {

View File

@@ -23,6 +23,7 @@ assert.equal(loaded.payload.status, "verified");
assert.equal(loaded.payload.panel_snapshot.io.source, "wasm_io");
assert.equal(loaded.payload.panel_snapshot.device_io.source, "wasm_device_io");
assert.equal(loaded.payload.panel_snapshot.hal.runtime.source, "wasm_hal");
assert.equal(send("POWER", "power-on", { active: true }).payload.status, "verified");
const spindle = send("SPINDLE_COMMAND", "spindle-on", { spindle: 0, rpm: 1200, enabled: true });
assert.equal(spindle.kind, "COMMAND_RESULT");
@@ -46,7 +47,8 @@ const estop = send("ESTOP", "io-estop", { active: true });
assert.equal(estop.payload.device_io.source, "wasm_io");
const blocked = send("SPINDLE_COMMAND", "spindle-blocked", { spindle: 0, rpm: 500, enabled: true });
assert.equal(blocked.kind, "ERROR");
assert.equal(send("IO_RECOVER", "io-recover").payload.device_io.source, "wasm_io");
assert.equal(send("ESTOP", "io-estop-reset", { active: false }).payload.panel_snapshot.axis_state.machine_state, "ESTOP_RESET");
assert.equal(send("POWER", "power-on-after-estop", { active: true }).payload.device_io.source, "wasm_io");
const firstHash = send("SNAPSHOT_REQUEST", "snapshot-a").payload.panel_snapshot.device_io.projection_hash;
const secondHash = send("SNAPSHOT_REQUEST", "snapshot-b").payload.panel_snapshot.device_io.projection_hash;
assert.equal(firstHash, secondHash);

View File

@@ -15,6 +15,7 @@ const send = (kind, operation_id, payload = {}) => session.accept({ ...base, kin
send("HELLO", "hello");
send("SESSION_ATTACH", "attach");
assert.equal(send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).payload.status, "verified");
assert.equal(send("POWER", "power-on", { active: true }).payload.status, "verified");
const home = send("HOME", "home");
assert.equal(home.payload.motion_authority, "wasm");
assert.equal(home.payload.wasm_home_limit.state.homed, true);

View File

@@ -14,6 +14,7 @@ let seq = 0;
const send = (kind, operation_id, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id, payload });
send("HELLO", "hello"); send("SESSION_ATTACH", "attach");
assert.equal(send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).payload.status, "verified");
assert.equal(send("POWER", "power-on", { active: true }).payload.status, "verified");
const source = "G21 G90\nG0 X10 Y5 Z200 A30 C45\nG1 X20 Y10 Z180 A35 C50 F100\nG1 X30 Y15 Z160 A40 C55 F100\nM2\n";
const loaded = send("PROGRAM_LOAD", "program-load", { source, path: "workflow.ngc" });
assert.equal(loaded.kind, "COMMAND_RESULT");

View File

@@ -131,6 +131,7 @@ 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_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]);
@@ -148,6 +149,7 @@ export class WasmXyzacIo {
return result;
}
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");
if (kind === "IO_STEP") return this.invoke("sim_io_step", [payload.ticks ?? 1]);
if (kind === "COOLANT_COMMAND") return { status: "error", code: "wasm_device_io_required", detail: "coolant uses sim_device_io authority" };