Emergency Stop
Release E-stop
-
Power On
+
{machineOn ? "Power Off" : "Power On"}
HOME ALL
Run Program
Run Startup Sequence
@@ -148,7 +147,8 @@ export function App() {
const programRunning = operationState === "RUNNING";
const programPaused = operationState === "PAUSED";
const idle = operationState === "IDLE";
- const gates = Object.keys(state.gates).length ? state.gates : { load: axisGate("load", axisState), run: axisGate("run", axisState), step: axisGate("step", axisState), pause: axisGate("pause", axisState), resume: axisGate("resume", axisState), abort: axisGate("abort", axisState), mdi: axisGate("mdi", axisState), jog: axisGate("jogStart", axisState), home: axisGate("home", axisState) };
+ const wasmGate = (action: string) => state.gates[action] ?? { action, enabled: false, reason: "linuxcnc_wasm_snapshot_pending" };
+ const gates = { load: wasmGate("load"), run: wasmGate("run"), step: wasmGate("step"), pause: wasmGate("pause"), resume: wasmGate("resume"), abort: wasmGate("abort"), mdi: wasmGate("mdi"), jog: wasmGate("jog"), home: wasmGate("home"), power: wasmGate("power"), reset: wasmGate("reset") };
const planner = state.snapshot?.authoritative?.planner;
const trajectory = [...(planner?.queue ?? []), ...(planner?.active ? [planner.active] : [])];
const stopRunTimer = () => { if (runTimer.current) { clearInterval(runTimer.current); runTimer.current = null; } };
@@ -161,7 +161,7 @@ export function App() {
setOperationTrace(operationController.exportTrace());
return pending.then(() => { setOperationTrace(operationController.exportTrace()); setCommandStatus(`${kind} acknowledged`); if (kind === "PROGRAM_RUN") startRunTimer(); if (kind === "PROGRAM_ABORT" || kind === "PROGRAM_RESTART") stopRunTimer(); if (kind === "JOG_START") startJogTimer(); if (kind === "JOG_STOP") stopJogTimer(); }).catch((error: Error) => {
setOperationTrace(operationController.exportTrace());
- const code = error instanceof AxisGateError ? error.code : error.message;
+ const code = error.message;
setCommandStatus(`${kind} blocked: ${code}`);
throw error;
});
@@ -170,14 +170,15 @@ export function App() {
const runProgram = () => { void dispatch("PROGRAM_RUN").catch(() => undefined); };
const stopProgram = () => { const wasRunning = Boolean(runTimer.current) || gates.abort.enabled; stopRunTimer(); if (wasRunning) void dispatch("PROGRAM_ABORT", {}, gates.abort.enabled).catch(() => undefined); };
const emergencyStop = () => { stopRunTimer(); stopJogTimer(); void dispatch("ESTOP", { active: true }, false).catch(() => undefined); };
- const releaseEstop = () => dispatch("ESTOP", { active: false }, false).then(() => dispatch("IO_RECOVER", {}, false));
+ const releaseEstop = () => dispatch("ESTOP", { active: false }, false);
const toggleEstop = () => { if (!isEstop) { emergencyStop(); return; } void releaseEstop().catch(() => undefined); };
- const powerOn = () => { const power = () => dispatch("POWER", { active: true }, false); if (isEstop) void releaseEstop().then(power).catch(() => undefined); else void power().catch(() => undefined); };
- const resetMachine = () => { stopRunTimer(); stopJogTimer(); const reset = () => dispatch("RESET", {}, false); if (programRunning) void dispatch("PROGRAM_ABORT").then(() => isEstop ? dispatch("ESTOP", { active: false }) : Promise.resolve()).then(reset).catch(() => undefined); else if (isEstop) void dispatch("ESTOP", { active: false }).then(reset).catch(() => undefined); else void reset().catch(() => undefined); };
+ const powerOn = () => { if (isEstop || programRunning || !gates.power.enabled) return; void dispatch("POWER", { active: !machineOn }, false).catch(() => undefined); };
+ const resetMachine = () => { stopRunTimer(); stopJogTimer(); if (isEstop || programRunning || !machineOn) return; void dispatch("RESET").catch(() => undefined); };
const homeAll = () => { void dispatch("HOME", { all: true }).catch(() => undefined); };
const runStartupSequence = () => {
const release = isEstop ? releaseEstop() : Promise.resolve();
- void release.then(() => dispatch("POWER", { active: true }, false))
+ const power = () => machineOn ? Promise.resolve() : dispatch("POWER", { active: true }, false);
+ void release.then(power)
.then(() => dispatch("HOME", { all: true }, false))
.then(() => dispatch("PROGRAM_RUN", {}, false))
.catch(() => undefined);
@@ -224,10 +225,10 @@ export function App() {
const position = `X${Number(xyzacView.pose?.x ?? axes[0] ?? 0).toFixed(3)} Y${Number(xyzacView.pose?.y ?? axes[1] ?? 0).toFixed(3)} Z${Number(xyzacView.pose?.z ?? axes[2] ?? 0).toFixed(3)} A${Number(xyzacView.pose?.a ?? 0).toFixed(3)} C${Number(xyzacView.pose?.c ?? 0).toFixed(3)}`;
return
AXISLinuxCNC Web/WASM {state.status.toUpperCase()}{taskState}
-
- { setProgramText(DEFAULT_PROGRAM); setPlotCleared(false); }} /> execute("PROGRAM_STEP")} /> execute(programPaused ? "PROGRAM_RESUME" : "PROGRAM_PAUSE")} /> setBlockDelete((value) => !value)} /> setOptionalStop((value) => !value)} /> setZoom((value) => Math.min(2, value + .2))} /> setZoom((value) => Math.max(.5, value - .2))} /> { setView("z"); setRightTab("preview"); }} /> { setView("z2"); setRightTab("preview"); }} /> { setView("x"); setRightTab("preview"); }} /> { setView("y"); setRightTab("preview"); }} /> { setView("y2"); setRightTab("preview"); }} /> { setView("p"); setRightTab("preview"); }} /> setRotateMode((value) => !value)} /> xyzacCommand("VISUAL_CLEAR", {}, "vismachClear")} />
+
+ { setProgramText(DEFAULT_PROGRAM); setPlotCleared(false); }} /> execute("PROGRAM_STEP")} /> execute(programPaused ? "PROGRAM_RESUME" : "PROGRAM_PAUSE")} /> setBlockDelete((value) => !value)} /> setOptionalStop((value) => !value)} /> setZoom((value) => Math.min(2, value + .2))} /> setZoom((value) => Math.max(.5, value - .2))} /> { setView("z"); setRightTab("preview"); }} /> { setView("z2"); setRightTab("preview"); }} /> { setView("x"); setRightTab("preview"); }} /> { setView("y"); setRightTab("preview"); }} /> { setView("y2"); setRightTab("preview"); }} /> { setView("p"); setRightTab("preview"); }} /> setRotateMode((value) => !value)} /> xyzacCommand("VISUAL_CLEAR", {}, "vismachClear")} />
Load case{caseStatus}{commandStatus}
- { void releaseEstop().catch(() => undefined); }} onPower={powerOn} onHomeAll={homeAll} onRun={runProgram} onSequence={runStartupSequence} />
+ { void releaseEstop().catch(() => undefined); }} onPower={powerOn} onHomeAll={homeAll} onRun={runProgram} onSequence={runStartupSequence} />
diff --git a/web/react/src/legacy.d.ts b/web/react/src/legacy.d.ts
index be22613..c3f35f8 100644
--- a/web/react/src/legacy.d.ts
+++ b/web/react/src/legacy.d.ts
@@ -9,8 +9,6 @@ declare module "*.mjs" {
execute(kind: string, payload?: Record, options?: { state?: unknown; action?: string | null; gate?: boolean }): Promise<{ operationId: string; response: unknown; gate: { enabled: boolean; reason: string } }>;
exportTrace(): Array>;
}
- export class AxisGateError extends Error { code: string; gate: { enabled: boolean; reason: string }; }
- export function axisGate(action: string, input?: unknown): { enabled: boolean; reason: string; action: string; taskState?: string; interpState?: string };
}
declare module "*.js" { const createModule: (options?: unknown) => Promise; export default createModule; }
diff --git a/web/worker/axis-state.mjs b/web/worker/axis-state.mjs
index a414c4e..0e9ea46 100644
--- a/web/worker/axis-state.mjs
+++ b/web/worker/axis-state.mjs
@@ -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] });
diff --git a/web/worker/capabilities.mjs b/web/worker/capabilities.mjs
index 02cfc1e..4a408e5 100644
--- a/web/worker/capabilities.mjs
+++ b/web/worker/capabilities.mjs
@@ -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",
diff --git a/web/worker/sim-session.mjs b/web/worker/sim-session.mjs
index 40d9889..47a06af 100644
--- a/web/worker/sim-session.mjs
+++ b/web/worker/sim-session.mjs
@@ -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();
diff --git a/web/worker/test_xyzac_button_constraints.mjs b/web/worker/test_xyzac_button_constraints.mjs
new file mode 100644
index 0000000..3b040da
--- /dev/null
+++ b/web/worker/test_xyzac_button_constraints.mjs
@@ -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,
+}));
diff --git a/web/worker/test_xyzac_gate_matrix.mjs b/web/worker/test_xyzac_gate_matrix.mjs
index 6e2eed6..4018e73 100644
--- a/web/worker/test_xyzac_gate_matrix.mjs
+++ b/web/worker/test_xyzac_gate_matrix.mjs
@@ -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);
diff --git a/web/worker/test_xyzac_hal_tool_alarm.mjs b/web/worker/test_xyzac_hal_tool_alarm.mjs
index dfdc029..b8ae679 100644
--- a/web/worker/test_xyzac_hal_tool_alarm.mjs
+++ b/web/worker/test_xyzac_hal_tool_alarm.mjs
@@ -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");
diff --git a/web/worker/test_xyzac_machine_sequence.mjs b/web/worker/test_xyzac_machine_sequence.mjs
index e858467..8034c12 100644
--- a/web/worker/test_xyzac_machine_sequence.mjs
+++ b/web/worker/test_xyzac_machine_sequence.mjs
@@ -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,
diff --git a/web/worker/test_xyzac_manual_control.mjs b/web/worker/test_xyzac_manual_control.mjs
index f40e4d4..1c4f4f6 100644
--- a/web/worker/test_xyzac_manual_control.mjs
+++ b/web/worker/test_xyzac_manual_control.mjs
@@ -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 }));
diff --git a/web/worker/test_xyzac_protocol_react.mjs b/web/worker/test_xyzac_protocol_react.mjs
index 1724785..8b1cfcc 100644
--- a/web/worker/test_xyzac_protocol_react.mjs
+++ b/web/worker/test_xyzac_protocol_react.mjs
@@ -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");
diff --git a/web/worker/test_xyzac_recovery.mjs b/web/worker/test_xyzac_recovery.mjs
index 581fa03..9dcadea 100644
--- a/web/worker/test_xyzac_recovery.mjs
+++ b/web/worker/test_xyzac_recovery.mjs
@@ -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 };
}
diff --git a/web/worker/test_xyzac_rtcp.mjs b/web/worker/test_xyzac_rtcp.mjs
index 48ae805..0a03244 100644
--- a/web/worker/test_xyzac_rtcp.mjs
+++ b/web/worker/test_xyzac_rtcp.mjs
@@ -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]]) {
diff --git a/web/worker/test_xyzac_trace_parity.mjs b/web/worker/test_xyzac_trace_parity.mjs
index fcd6415..a8300fb 100644
--- a/web/worker/test_xyzac_trace_parity.mjs
+++ b/web/worker/test_xyzac_trace_parity.mjs
@@ -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 });
}
diff --git a/web/worker/test_xyzac_trt_case.mjs b/web/worker/test_xyzac_trt_case.mjs
index 704c995..919a43b 100644
--- a/web/worker/test_xyzac_trt_case.mjs
+++ b/web/worker/test_xyzac_trt_case.mjs
@@ -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]]) {
diff --git a/web/worker/test_xyzac_trt_io_authority.mjs b/web/worker/test_xyzac_trt_io_authority.mjs
index a5fd2cd..525a8bf 100644
--- a/web/worker/test_xyzac_trt_io_authority.mjs
+++ b/web/worker/test_xyzac_trt_io_authority.mjs
@@ -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);
diff --git a/web/worker/test_xyzac_trt_wasm_program_authority.mjs b/web/worker/test_xyzac_trt_wasm_program_authority.mjs
index be94946..dc72f20 100644
--- a/web/worker/test_xyzac_trt_wasm_program_authority.mjs
+++ b/web/worker/test_xyzac_trt_wasm_program_authority.mjs
@@ -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);
diff --git a/web/worker/test_xyzac_workflow.mjs b/web/worker/test_xyzac_workflow.mjs
index 4c646ed..7e0dd8d 100644
--- a/web/worker/test_xyzac_workflow.mjs
+++ b/web/worker/test_xyzac_workflow.mjs
@@ -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");
diff --git a/web/worker/wasm-xyzac-io.mjs b/web/worker/wasm-xyzac-io.mjs
index 15831d8..4c6e63b 100644
--- a/web/worker/wasm-xyzac-io.mjs
+++ b/web/worker/wasm-xyzac-io.mjs
@@ -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" };
diff --git a/web/workspace/xyzac-trt-case-contract.mjs b/web/workspace/xyzac-trt-case-contract.mjs
index 84d7da5..66a3c28 100644
--- a/web/workspace/xyzac-trt-case-contract.mjs
+++ b/web/workspace/xyzac-trt-case-contract.mjs
@@ -6,12 +6,16 @@ export const XYZAC_TRT_CASE_CONTRACT = Object.freeze({
"status": "indexed"
},
"commands": [
+ "POWER",
+ "RESET",
+ "ESTOP",
"MDI_EXECUTE",
"PROGRAM_LOAD",
"PROGRAM_RUN",
"PROGRAM_PAUSE",
"PROGRAM_RESUME",
"PROGRAM_ABORT",
+ "PROGRAM_RESTART",
"JOG_START",
"JOG_STOP",
"HOME",
@@ -20,7 +24,7 @@ export const XYZAC_TRT_CASE_CONTRACT = Object.freeze({
],
"context_id": "linuxcnc-2.10.0-pre0-wasm-m1-py-c1",
"js_authority": 0,
- "report_sha256": "817531c109c341ccda78b085d2613f35379a716a98f54c9561a118f995b6cd5e",
+ "report_sha256": "6fdc4530f450c3305a2ab4ddc6cdb3296a6cec0f62ab87cc2cd6cfcd0d43f00f",
"resources": [
{
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini",
diff --git a/web/workspace/xyzac-trt-source-slices.mjs b/web/workspace/xyzac-trt-source-slices.mjs
index cae7c61..0f97398 100644
--- a/web/workspace/xyzac-trt-source-slices.mjs
+++ b/web/workspace/xyzac-trt-source-slices.mjs
@@ -1152,6 +1152,10 @@ export const XYZAC_TRT_SOURCE_SLICES = Object.freeze({
"command": "PROGRAM_ABORT",
"slice_id": "AXIS-XYZAC-PROGRAM-MDI"
},
+ {
+ "command": "PROGRAM_RESTART",
+ "slice_id": "AXIS-XYZAC-PROGRAM-MDI"
+ },
{
"command": "MDI_EXECUTE",
"slice_id": "AXIS-XYZAC-PROGRAM-MDI"
@@ -1168,6 +1172,18 @@ export const XYZAC_TRT_SOURCE_SLICES = Object.freeze({
"command": "HOME",
"slice_id": "AXIS-XYZAC-JOG-HOME"
},
+ {
+ "command": "POWER",
+ "slice_id": "AXIS-XYZAC-SHELL-MENU"
+ },
+ {
+ "command": "ESTOP",
+ "slice_id": "AXIS-XYZAC-SHELL-MENU"
+ },
+ {
+ "command": "RESET",
+ "slice_id": "AXIS-XYZAC-SHELL-MENU"
+ },
{
"command": "M428",
"slice_id": "AXIS-XYZAC-M428-M429-M430"
@@ -1247,7 +1263,7 @@ export const XYZAC_TRT_SOURCE_SLICES = Object.freeze({
"slice_id": "AXIS-XYZAC-ERROR-RECOVERY"
}
],
- "report_sha256": "817531c109c341ccda78b085d2613f35379a716a98f54c9561a118f995b6cd5e",
+ "report_sha256": "6fdc4530f450c3305a2ab4ddc6cdb3296a6cec0f62ab87cc2cd6cfcd0d43f00f",
"schema": "xyzac-trt-source-slice-inventory.v1",
"slices": [
{
@@ -1954,6 +1970,22 @@ export const XYZAC_TRT_SOURCE_SLICES = Object.freeze({
"slice_id": "AXIS-XYZAC-PROGRAM-MDI",
"state": "program.current_line"
},
+ {
+ "slice_id": "AXIS-XYZAC-SHELL-MENU",
+ "state": "axis_state.machine_state"
+ },
+ {
+ "slice_id": "AXIS-XYZAC-SHELL-MENU",
+ "state": "axis_state.operation_state"
+ },
+ {
+ "slice_id": "AXIS-XYZAC-SHELL-MENU",
+ "state": "device_io.state.estop"
+ },
+ {
+ "slice_id": "AXIS-XYZAC-SHELL-MENU",
+ "state": "device_io.state.ready"
+ },
{
"slice_id": "AXIS-XYZAC-KINEMATICS",
"state": "motion.joints"