Enforce LinuxCNC WASM XYZAC command authority
This commit is contained in:
@@ -28,8 +28,15 @@ export function normalizeAxisState(input = {}) {
|
||||
const program = input.program ?? input.snapshot?.program ?? {};
|
||||
const motion = input.motion ?? input.snapshot?.motion ?? {};
|
||||
const devices = input.devices ?? input.snapshot?.devices ?? {};
|
||||
const ioSource = input.io ?? input.snapshot?.io ?? input.device_io ?? input.deviceIo ?? input.snapshot?.device_io ?? {};
|
||||
const ioState = ioSource?.state ?? ioSource ?? {};
|
||||
const rawTaskState = string(task.state ?? input.task_state ?? input.state, "OFF");
|
||||
const taskState = rawTaskState === "ESTOP" ? "ESTOP" : ["READY", "RUNNING", "PAUSED", "STOPPED", "ON"].includes(rawTaskState) ? "ON" : "OFF";
|
||||
const ioHasEstop = typeof ioState.estop === "boolean" || typeof ioState.estop === "number";
|
||||
const ioEstop = ioHasEstop && Boolean(ioState.estop);
|
||||
const ioHasReady = typeof ioState.ready === "boolean" || typeof ioState.ready === "number";
|
||||
const ioReady = ioHasReady && Boolean(ioState.ready);
|
||||
const ioEstopReset = ioHasEstop && !ioEstop && ioHasReady && !ioReady && Number(ioState.last_error) === -904;
|
||||
const taskState = ioEstop ? "ESTOP" : ioEstopReset ? "ESTOP_RESET" : ioHasReady && !ioReady ? "OFF" : rawTaskState === "ESTOP" ? "ESTOP" : ["READY", "RUNNING", "PAUSED", "STOPPED", "ON"].includes(rawTaskState) ? "ON" : "OFF";
|
||||
const inferredInterp = rawTaskState === "RUNNING" ? "READING" : rawTaskState === "PAUSED" ? "PAUSED" : "IDLE";
|
||||
const interpState = string(task.interp_state ?? input.interp_state ?? input.interpreter_state, inferredInterp);
|
||||
const mode = string(task.mode ?? input.task_mode ?? input.mode, "MANUAL");
|
||||
@@ -47,7 +54,7 @@ export function normalizeAxisState(input = {}) {
|
||||
? [true, true, true]
|
||||
: [];
|
||||
const limits = input.on_any_limit ?? motion.on_any_limit ?? motion.limit ?? false;
|
||||
const fault = rawTaskState === "FAULT" || Boolean(motion.fault || devices.fault || input.fault);
|
||||
const fault = rawTaskState === "FAULT" || Boolean(motion.fault || devices.fault || ioState.fault || input.fault);
|
||||
const tool = devices.tool ?? input.tool ?? {};
|
||||
const toolNumber = number(tool.current ?? input.tool_in_spindle);
|
||||
const taskFile = Boolean(input.taskfile ?? program.path ?? program.source_hash ?? program.loaded ?? (program.program_status && String(program.program_status).toLowerCase() !== "idle"));
|
||||
@@ -73,7 +80,12 @@ export function axisGate(action, input = {}) {
|
||||
switch (action) {
|
||||
case "estop": enabled = true; reason = estop ? "axis_estop_reset_available" : "axis_estop_available"; break;
|
||||
case "recover": enabled = true; reason = "axis_recover_available"; break;
|
||||
case "power": enabled = state.taskState !== "ESTOP"; reason = enabled ? "axis_power_available" : "axis_estop_active"; break;
|
||||
case "power": {
|
||||
const requested = input.payload?.active;
|
||||
enabled = requested === false ? machineOn && !running : requested === true ? !estop && !machineOn && !running : !estop && !running;
|
||||
reason = estop ? "axis_estop_active" : running ? "axis_interp_running" : enabled ? "axis_power_available" : requested === false ? "axis_machine_off" : "axis_machine_on";
|
||||
break;
|
||||
}
|
||||
case "override": enabled = machineOn && !estop; reason = enabled ? "axis_override_available" : estop ? "axis_estop_active" : "axis_machine_off"; break;
|
||||
case "reset": enabled = machineOn && !running && !state.onAnyLimit; reason = enabled ? "axis_reset_available" : state.onAnyLimit ? "axis_limit_active" : "axis_reset_state_gate"; break;
|
||||
case "open": enabled = idle; reason = enabled ? "axis_interp_idle" : "axis_interp_running"; break;
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import { AXIS_COMMANDS, XYZAC_GATE_ACTIONS, axisGate, xyzacGate } from "./axis-contract.mjs";
|
||||
import { AXIS_COMMANDS } from "./axis-contract.mjs";
|
||||
|
||||
export const COMMAND_ACTIONS = Object.freeze({ ...Object.fromEntries(Object.entries(AXIS_COMMANDS).map(([action, command]) => [command, action])), VISUAL_CLEAR: "vismachClear" });
|
||||
|
||||
export class AxisGateError extends Error {
|
||||
constructor(result) {
|
||||
super(result.reason);
|
||||
this.name = "AxisGateError";
|
||||
this.code = result.reason;
|
||||
this.gate = result;
|
||||
}
|
||||
}
|
||||
|
||||
export class AxisOperationController {
|
||||
constructor(client) {
|
||||
if (!client || typeof client.send !== "function") throw new Error("axis_operation_client_required");
|
||||
@@ -26,17 +17,13 @@ export class AxisOperationController {
|
||||
|
||||
async execute(kind, payload = {}, { state = {}, action = COMMAND_ACTIONS[kind] ?? null, gate = true } = {}) {
|
||||
const operationId = this.nextOperationId(kind);
|
||||
const gateResult = action ? (XYZAC_GATE_ACTIONS.includes(action) ? xyzacGate(action, state) : axisGate(action, state)) : { action: null, enabled: true, reason: "axis_unrestricted_internal_command" };
|
||||
const authoritativeGate = action && state?.gates?.[action];
|
||||
const effectiveGate = authoritativeGate ?? gateResult;
|
||||
if (gate && !effectiveGate.enabled) {
|
||||
this.trace.push(Object.freeze({ operation_id: operationId, kind, action, phase: "blocked", reason: effectiveGate.reason }));
|
||||
throw new AxisGateError(effectiveGate);
|
||||
}
|
||||
const effectiveGate = state?.gates?.[action] ?? { action, enabled: true, reason: "linuxcnc_wasm_worker_authority" };
|
||||
this.trace.push(Object.freeze({ operation_id: operationId, kind, action, phase: "pending", reason: effectiveGate.reason }));
|
||||
try {
|
||||
const response = await this.client.send(kind, payload, operationId);
|
||||
this.trace.push(Object.freeze({ operation_id: operationId, kind, action, phase: "result", response_kind: response?.kind ?? null }));
|
||||
const responsePayload = response?.payload && typeof response.payload === "object" ? response.payload : {};
|
||||
const workerBlocked = response?.kind === "ERROR" || responsePayload.status === "blocked";
|
||||
this.trace.push(Object.freeze({ operation_id: operationId, kind, action, phase: workerBlocked ? "blocked" : "result", reason: responsePayload.gate_reason ?? responsePayload.message_key ?? responsePayload.code ?? response?.kind ?? null }));
|
||||
return Object.freeze({ operationId, response, gate: effectiveGate });
|
||||
} catch (error) {
|
||||
this.trace.push(Object.freeze({ operation_id: operationId, kind, action, phase: "error", reason: error?.message ?? String(error) }));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { AxisGateError, AxisOperationController } from "./axis-operation-controller.mjs";
|
||||
import { AxisOperationController } from "./axis-operation-controller.mjs";
|
||||
|
||||
const calls = [];
|
||||
const client = { send: async (kind, payload, operationId) => { calls.push({ kind, payload, operationId }); return { kind: "COMMAND_RESULT", payload: { status: "verified" } }; } };
|
||||
@@ -7,10 +7,11 @@ const controller = new AxisOperationController(client);
|
||||
const ready = { task: { state: "READY", mode: "MANUAL" }, program: { path: "main.ngc", program_status: "loaded" } };
|
||||
const run = await controller.execute("PROGRAM_RUN", {}, { state: ready });
|
||||
assert.equal(run.operationId, "axis-program-run-000001");
|
||||
assert.equal(run.gate.reason, "axis_run_available");
|
||||
assert.equal(run.gate.reason, "linuxcnc_wasm_worker_authority");
|
||||
assert.equal(calls[0].operationId, run.operationId);
|
||||
await assert.rejects(() => controller.execute("JOG_START", { axis: "x" }, { state: { task: { state: "ESTOP" } } }), AxisGateError);
|
||||
const jog = await controller.execute("JOG_START", { axis: "x" }, { state: { task: { state: "ESTOP" } } });
|
||||
assert.equal(jog.operationId, "axis-jog-start-000002");
|
||||
const tick = await controller.execute("SIM_STEP", { cycles: 1 }, { state: ready, gate: false });
|
||||
assert.equal(tick.operationId, "axis-sim-step-000003");
|
||||
assert.deepEqual(controller.exportTrace().map((entry) => entry.phase), ["pending", "result", "blocked", "pending", "result"]);
|
||||
assert.deepEqual(controller.exportTrace().map((entry) => entry.phase), ["pending", "result", "pending", "result", "pending", "result"]);
|
||||
console.log(JSON.stringify({ schema: "axis-operation-controller-sample.v1", status: "verified", calls: calls.length, phases: controller.exportTrace().map((entry) => entry.phase), last_operation: tick.operationId }));
|
||||
|
||||
@@ -5,8 +5,7 @@ import { XyzacToolPreviewFromSnapshot } from "./XyzacToolPreview";
|
||||
import { SIM_CASE_CATALOG } from "../../workspace/sim-case-catalog.mjs";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../../workspace/sim-case-loader.mjs";
|
||||
import { axisIcon } from "./sourceAssets";
|
||||
import { AxisGateError, AxisOperationController } from "../../app/axis-operation-controller.mjs";
|
||||
import { axisGate } from "../../app/axis-contract.mjs";
|
||||
import { AxisOperationController } from "../../app/axis-operation-controller.mjs";
|
||||
import { XyzacDiagnostics, XyzacProgramPane, XyzacTrtPanel } from "./XyzacTrtPanel";
|
||||
import { XYZAC_CASE, projectXyzacPanel, type XyzacOperationTrace } from "./xyzac-panel-contract";
|
||||
|
||||
@@ -32,21 +31,21 @@ function Menu({ label, children }: { label: string; children: React.ReactNode })
|
||||
return <details className="axis-menu-item" data-axis-feature="AXIS-UI-02"><summary>{label}</summary><div className="axis-menu-popup">{children}</div></details>;
|
||||
}
|
||||
|
||||
function MenuAction({ children, onClick, shortcut }: { children: React.ReactNode; onClick?: () => void; shortcut?: string }) {
|
||||
return <button type="button" onClick={onClick}><span>{children}</span>{shortcut && <kbd>{shortcut}</kbd>}</button>;
|
||||
function MenuAction({ children, onClick, shortcut, disabled = false }: { children: React.ReactNode; onClick?: () => void; shortcut?: string; disabled?: boolean }) {
|
||||
return <button type="button" onClick={onClick} disabled={disabled}><span>{children}</span>{shortcut && <kbd>{shortcut}</kbd>}</button>;
|
||||
}
|
||||
|
||||
function AxisRadio({ axis, selected, onChange }: { axis: string; selected: string; onChange: (axis: string) => void }) {
|
||||
return <label className={`axis-radio${selected === axis ? " selected" : ""}`}><input type="radio" name="axis" value={axis} checked={selected === axis} onChange={() => onChange(axis)} /><span>{axis.toUpperCase()}</span></label>;
|
||||
}
|
||||
|
||||
function MachineSequence({ isEstop, machineOn, allHomed, programLoaded, programRunning, canHome, canRun, onEstop, onRelease, onPower, onHomeAll, onRun, onSequence }: { isEstop: boolean; machineOn: boolean; allHomed: boolean; programLoaded: boolean; programRunning: boolean; canHome: boolean; canRun: boolean; onEstop: () => void; onRelease: () => void; onPower: () => void; onHomeAll: () => void; onRun: () => void; onSequence: () => void }) {
|
||||
function MachineSequence({ isEstop, machineOn, allHomed, programLoaded, programRunning, canPower, canHome, canRun, onEstop, onRelease, onPower, onHomeAll, onRun, onSequence }: { isEstop: boolean; machineOn: boolean; allHomed: boolean; programLoaded: boolean; programRunning: boolean; canPower: boolean; canHome: boolean; canRun: boolean; onEstop: () => void; onRelease: () => void; onPower: () => void; onHomeAll: () => void; onRun: () => void; onSequence: () => void }) {
|
||||
return <section className="machine-sequence" data-axis-feature="AXIS-MACHINE-SEQUENCE" aria-label="Machine sequence">
|
||||
<div className="machine-sequence-heading"><strong>Machine sequence</strong><span>{isEstop ? "ESTOP" : machineOn ? "ON" : "OFF"} / {allHomed ? "HOME ALL" : "NOT HOMED"}</span></div>
|
||||
<div className="machine-sequence-actions" role="group" aria-label="Emergency stop, power, homing and program controls">
|
||||
<TextButton danger disabled={isEstop} onClick={onEstop} ariaLabel="Emergency stop">Emergency Stop</TextButton>
|
||||
<TextButton disabled={!isEstop} onClick={onRelease} ariaLabel="Release emergency stop">Release E-stop</TextButton>
|
||||
<TextButton disabled={isEstop} onClick={onPower} ariaLabel="Power on">Power On</TextButton>
|
||||
<TextButton disabled={isEstop || programRunning || !canPower} onClick={onPower} ariaLabel={machineOn ? "Power off" : "Power on"}>{machineOn ? "Power Off" : "Power On"}</TextButton>
|
||||
<TextButton disabled={!canHome} onClick={onHomeAll} ariaLabel="Home all axes">HOME ALL</TextButton>
|
||||
<TextButton disabled={!canRun} onClick={onRun} ariaLabel="Run program">Run Program</TextButton>
|
||||
<TextButton feature="AXIS-MACHINE-SEQUENCE" disabled={!programLoaded || programRunning} onClick={onSequence} ariaLabel="Run startup sequence">Run Startup Sequence</TextButton>
|
||||
@@ -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 <main className="axis-app" data-axis-feature="AXIS-UI-01" data-axis-run-enabled={String(gates.run.enabled)} data-axis-homed={JSON.stringify(state.snapshot?.motion?.homed_axes ?? state.snapshot?.motion?.homed ?? null)} data-axis-program-status={String(state.program?.program_status ?? "idle")}>
|
||||
<header className="axis-window-header" data-axis-feature="AXIS-SHELL-01"><div className="axis-brand"><img src={axisIcon("axis-24x24.png")} alt="AXIS" /> <strong>AXIS</strong><span>LinuxCNC Web/WASM</span></div><span className={`connection-state ${state.status}`} data-axis-feature="AXIS-SHELL-01" role="status">{state.status.toUpperCase()}</span><span className="axis-state-badge" data-axis-state={taskState} aria-label={`Machine ${machineState}, operation ${operationState}`}>{taskState}</span></header>
|
||||
<nav className="axis-menubar" data-axis-feature="AXIS-UI-02" aria-label="AXIS menu"><Menu label="File"><MenuAction onClick={openProgramFile}>Open G-code...</MenuAction><MenuAction onClick={() => setProgramText(DEFAULT_PROGRAM)}>Reload editor</MenuAction><MenuAction onClick={() => setCommandStatus("Tool data editor is browser-backed")}>Edit tool data...</MenuAction><MenuAction onClick={() => setCommandStatus("Quit is unavailable in a browser tab")}>Quit</MenuAction></Menu><Menu label="Machine"><MenuAction shortcut="F1" onClick={toggleEstop}>{isEstop ? "Cancel Emergency Stop" : "Emergency Stop"}</MenuAction><MenuAction shortcut="F2" onClick={powerOn}>Power on</MenuAction><MenuAction onClick={resetMachine}>Reset machine</MenuAction><MenuAction shortcut="R" onClick={runProgram}>Run program</MenuAction><MenuAction shortcut="T" onClick={() => execute("PROGRAM_STEP")}>Step</MenuAction><MenuAction shortcut="P" onClick={() => execute("PROGRAM_PAUSE")}>Pause</MenuAction><MenuAction shortcut="S" onClick={() => execute("PROGRAM_RESUME")}>Resume</MenuAction><MenuAction shortcut="Esc" onClick={stopProgram}>Stop</MenuAction></Menu><Menu label="View"><MenuAction onClick={() => setRightTab("preview")}>Preview</MenuAction><MenuAction onClick={() => setRightTab("dro")}>DRO</MenuAction><MenuAction onClick={enterManualMode}>Manual Control</MenuAction><MenuAction onClick={() => setLeftTab("mdi")}>MDI</MenuAction></Menu><Menu label="Help"><MenuAction onClick={() => setCommandStatus("LinuxCNC AXIS source-compatible controls")}>About AXIS</MenuAction></Menu></nav>
|
||||
<section className="axis-toolbar" data-axis-feature="AXIS-UI-05" aria-label="AXIS toolbar"><IconButton feature="AXIS-MACHINE-01" label={isEstop ? "Cancel Emergency Stop [F1]" : "Emergency Stop [F1]"} asset="tool_estop.gif" danger onClick={toggleEstop} pressed={isEstop} /><IconButton feature="AXIS-MACHINE-02" label="Power on / recover [F2]" asset="tool_power.gif" onClick={powerOn} /><IconButton feature="AXIS-MACHINE-03" label="Reset machine" asset="tool_reload.gif" onClick={resetMachine} /><Separator /><IconButton feature="AXIS-FILE-01" label="Open G-Code file [O]" asset="tool_open.gif" onClick={openProgramFile} /><IconButton feature="AXIS-FILE-02" label="Load exact XYZAC TRT case" asset="tool_verify.gif" disabled={!gates.load.enabled} onClick={loadCase} /><IconButton feature="AXIS-FILE-04" label="Reopen editor defaults" asset="tool_reload.gif" onClick={() => { setProgramText(DEFAULT_PROGRAM); setPlotCleared(false); }} /><Separator /><IconButton feature="AXIS-MACHINE-03" label="Begin executing current file [R]" asset="tool_run.gif" disabled={!gates.run.enabled} onClick={runProgram} /><IconButton feature="AXIS-MACHINE-05" label="Execute next line [T]" asset="tool_step.gif" disabled={!gates.step.enabled} onClick={() => execute("PROGRAM_STEP")} /><IconButton feature="AXIS-MACHINE-06" label={programPaused ? "Resume execution [S]" : "Pause execution [P]"} asset={programPaused ? "resume_inhibit.gif" : "tool_pause.gif"} disabled={programPaused ? !gates.resume.enabled : !gates.pause.enabled} onClick={() => execute(programPaused ? "PROGRAM_RESUME" : "PROGRAM_PAUSE")} /><IconButton feature="AXIS-MACHINE-07" label="Stop program execution [ESC]" asset="tool_stop.gif" disabled={!gates.abort.enabled} onClick={stopProgram} /><Separator /><IconButton feature="AXIS-MACHINE-09" label="Toggle skip lines with slash" asset="tool_blockdelete.gif" pressed={blockDelete} onClick={() => setBlockDelete((value) => !value)} /><IconButton feature="AXIS-MACHINE-08" label="Toggle optional pause" asset="tool_optpause.gif" pressed={optionalStop} onClick={() => setOptionalStop((value) => !value)} /><Separator /><IconButton feature="AXIS-VIEW-01" label="Zoom in" asset="tool_zoomin.gif" onClick={() => setZoom((value) => Math.min(2, value + .2))} /><IconButton feature="AXIS-VIEW-01" label="Zoom out" asset="tool_zoomout.gif" onClick={() => setZoom((value) => Math.max(.5, value - .2))} /><IconButton feature="AXIS-VIEW-01" label="Top view" asset="tool_axis_z.gif" pressed={view === "z"} onClick={() => { setView("z"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Rotated top view" asset="tool_axis_z2.gif" pressed={view === "z2"} onClick={() => { setView("z2"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Side view" asset="tool_axis_x.gif" pressed={view === "x"} onClick={() => { setView("x"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Front view" asset="tool_axis_y.gif" pressed={view === "y"} onClick={() => { setView("y"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Inverted front view" asset="tool_axis_y2.png" pressed={view === "y2"} onClick={() => { setView("y2"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Perspective view" asset="tool_axis_p.gif" pressed={view === "p"} onClick={() => { setView("p"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-02" label="Drag / rotate mode" asset="tool_rotate.gif" pressed={rotateMode} onClick={() => setRotateMode((value) => !value)} /><Separator /><IconButton feature="AXIS-VIEW-15" label="Clear live plot [Ctrl-K]" asset="tool_clear.gif" onClick={() => xyzacCommand("VISUAL_CLEAR", {}, "vismachClear")} /></section>
|
||||
<nav className="axis-menubar" data-axis-feature="AXIS-UI-02" aria-label="AXIS menu"><Menu label="File"><MenuAction onClick={openProgramFile} disabled={programRunning}>Open G-code...</MenuAction><MenuAction onClick={() => setProgramText(DEFAULT_PROGRAM)} disabled={programRunning}>Reload editor</MenuAction><MenuAction onClick={() => setCommandStatus("Tool data editor is browser-backed")}>Edit tool data...</MenuAction><MenuAction onClick={() => setCommandStatus("Quit is unavailable in a browser tab")}>Quit</MenuAction></Menu><Menu label="Machine"><MenuAction shortcut="F1" onClick={toggleEstop}>{isEstop ? "Cancel Emergency Stop" : "Emergency Stop"}</MenuAction><MenuAction shortcut="F2" onClick={powerOn} disabled={isEstop || programRunning || !gates.power.enabled}>{machineOn ? "Power off" : "Power on"}</MenuAction><MenuAction onClick={resetMachine} disabled={!gates.reset.enabled}>Reset machine</MenuAction><MenuAction shortcut="R" onClick={runProgram} disabled={!gates.run.enabled}>Run program</MenuAction><MenuAction shortcut="T" onClick={() => execute("PROGRAM_STEP")} disabled={!gates.step.enabled}>Step</MenuAction><MenuAction shortcut="P" onClick={() => execute("PROGRAM_PAUSE")} disabled={!gates.pause.enabled}>Pause</MenuAction><MenuAction shortcut="S" onClick={() => execute("PROGRAM_RESUME")} disabled={!gates.resume.enabled}>Resume</MenuAction><MenuAction shortcut="Esc" onClick={stopProgram} disabled={!gates.abort.enabled}>Stop</MenuAction></Menu><Menu label="View"><MenuAction onClick={() => setRightTab("preview")}>Preview</MenuAction><MenuAction onClick={() => setRightTab("dro")}>DRO</MenuAction><MenuAction onClick={enterManualMode} disabled={programRunning}>Manual Control</MenuAction><MenuAction onClick={() => setLeftTab("mdi")} disabled={programRunning}>MDI</MenuAction></Menu><Menu label="Help"><MenuAction onClick={() => setCommandStatus("LinuxCNC AXIS source-compatible controls")}>About AXIS</MenuAction></Menu></nav>
|
||||
<section className="axis-toolbar" data-axis-feature="AXIS-UI-05" aria-label="AXIS toolbar"><IconButton feature="AXIS-MACHINE-01" label={isEstop ? "Cancel Emergency Stop [F1]" : "Emergency Stop [F1]"} asset="tool_estop.gif" danger onClick={toggleEstop} pressed={isEstop} /><IconButton feature="AXIS-MACHINE-02" label={machineOn ? "Power off [F2]" : "Power on [F2]"} asset="tool_power.gif" disabled={isEstop || programRunning || !gates.power.enabled} onClick={powerOn} /><IconButton feature="AXIS-MACHINE-03" label="Reset machine" asset="tool_reload.gif" disabled={!gates.reset.enabled} onClick={resetMachine} /><Separator /><IconButton feature="AXIS-FILE-01" label="Open G-Code file [O]" asset="tool_open.gif" onClick={openProgramFile} /><IconButton feature="AXIS-FILE-02" label="Load exact XYZAC TRT case" asset="tool_verify.gif" disabled={!gates.load.enabled} onClick={loadCase} /><IconButton feature="AXIS-FILE-04" label="Reopen editor defaults" asset="tool_reload.gif" onClick={() => { setProgramText(DEFAULT_PROGRAM); setPlotCleared(false); }} /><Separator /><IconButton feature="AXIS-MACHINE-03" label="Begin executing current file [R]" asset="tool_run.gif" disabled={!gates.run.enabled} onClick={runProgram} /><IconButton feature="AXIS-MACHINE-05" label="Execute next line [T]" asset="tool_step.gif" disabled={!gates.step.enabled} onClick={() => execute("PROGRAM_STEP")} /><IconButton feature="AXIS-MACHINE-06" label={programPaused ? "Resume execution [S]" : "Pause execution [P]"} asset={programPaused ? "resume_inhibit.gif" : "tool_pause.gif"} disabled={programPaused ? !gates.resume.enabled : !gates.pause.enabled} onClick={() => execute(programPaused ? "PROGRAM_RESUME" : "PROGRAM_PAUSE")} /><IconButton feature="AXIS-MACHINE-07" label="Stop program execution [ESC]" asset="tool_stop.gif" disabled={!gates.abort.enabled} onClick={stopProgram} /><Separator /><IconButton feature="AXIS-MACHINE-09" label="Toggle skip lines with slash" asset="tool_blockdelete.gif" pressed={blockDelete} onClick={() => setBlockDelete((value) => !value)} /><IconButton feature="AXIS-MACHINE-08" label="Toggle optional pause" asset="tool_optpause.gif" pressed={optionalStop} onClick={() => setOptionalStop((value) => !value)} /><Separator /><IconButton feature="AXIS-VIEW-01" label="Zoom in" asset="tool_zoomin.gif" onClick={() => setZoom((value) => Math.min(2, value + .2))} /><IconButton feature="AXIS-VIEW-01" label="Zoom out" asset="tool_zoomout.gif" onClick={() => setZoom((value) => Math.max(.5, value - .2))} /><IconButton feature="AXIS-VIEW-01" label="Top view" asset="tool_axis_z.gif" pressed={view === "z"} onClick={() => { setView("z"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Rotated top view" asset="tool_axis_z2.gif" pressed={view === "z2"} onClick={() => { setView("z2"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Side view" asset="tool_axis_x.gif" pressed={view === "x"} onClick={() => { setView("x"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Front view" asset="tool_axis_y.gif" pressed={view === "y"} onClick={() => { setView("y"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Inverted front view" asset="tool_axis_y2.png" pressed={view === "y2"} onClick={() => { setView("y2"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Perspective view" asset="tool_axis_p.gif" pressed={view === "p"} onClick={() => { setView("p"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-02" label="Drag / rotate mode" asset="tool_rotate.gif" pressed={rotateMode} onClick={() => setRotateMode((value) => !value)} /><Separator /><IconButton feature="AXIS-VIEW-15" label="Clear live plot [Ctrl-K]" asset="tool_clear.gif" onClick={() => xyzacCommand("VISUAL_CLEAR", {}, "vismachClear")} /></section>
|
||||
<section className="axis-case-strip" data-axis-feature="AXIS-EDIT-03"><label>Simulation case<select data-axis-feature="AXIS-EDIT-03" value={casePath} onChange={(event) => setCasePath(event.target.value)}>{SIM_CASE_CATALOG.filter((entry) => entry.path === SELECTED_XYZAC_CASE).map((entry) => <option key={entry.path} value={entry.path}>{entry.label} | XYZAC TRT</option>)}</select></label><TextButton feature="AXIS-EDIT-03" onClick={loadCase} disabled={!gates.load.enabled}>Load case</TextButton><span className="case-status" role="status">{caseStatus}</span><span className="command-status" role="status">{commandStatus}</span></section>
|
||||
<MachineSequence isEstop={isEstop} machineOn={machineOn} allHomed={allHomed} programLoaded={programLoaded} programRunning={programRunning} canHome={gates.home.enabled} canRun={gates.run.enabled} onEstop={emergencyStop} onRelease={() => { void releaseEstop().catch(() => undefined); }} onPower={powerOn} onHomeAll={homeAll} onRun={runProgram} onSequence={runStartupSequence} />
|
||||
<MachineSequence isEstop={isEstop} machineOn={machineOn} allHomed={allHomed} programLoaded={programLoaded} programRunning={programRunning} canPower={gates.power.enabled} canHome={gates.home.enabled} canRun={gates.run.enabled} onEstop={emergencyStop} onRelease={() => { void releaseEstop().catch(() => undefined); }} onPower={powerOn} onHomeAll={homeAll} onRun={runProgram} onSequence={runStartupSequence} />
|
||||
<XyzacTrtPanel view={xyzacView} command={xyzacCommand} />
|
||||
<section className="axis-top-pane" data-axis-feature="AXIS-UI-06"><div className="axis-notebook left-notebook" data-axis-feature="AXIS-UI-06"><div className="notebook-tabs" role="tablist" aria-label="Manual, MDI and diagnostics controls"><button id="axis-tab-manual" type="button" role="tab" aria-controls="axis-panel-manual" aria-selected={leftTab === "manual"} className={leftTab === "manual" ? "active" : ""} onClick={enterManualMode}>Manual Control [F3]</button><button id="axis-tab-mdi" type="button" role="tab" aria-controls="axis-panel-mdi" aria-selected={leftTab === "mdi"} className={leftTab === "mdi" ? "active" : ""} onClick={() => setLeftTab("mdi")}>MDI [F5]</button><button id="axis-tab-diagnostics" type="button" role="tab" aria-controls="axis-panel-diagnostics" aria-selected={leftTab === "diagnostics"} className={leftTab === "diagnostics" ? "active" : ""} onClick={() => setLeftTab("diagnostics")}>Diagnostics</button></div><div id="axis-panel-manual" role="tabpanel" aria-labelledby="axis-tab-manual" hidden={leftTab !== "manual"}><ManualControl action={execute} ready={gates.jog.enabled} canHome={gates.home.enabled} onHomeAll={homeAll} selectedAxis={selectedAxis} setSelectedAxis={setSelectedAxis} spindleOn={spindleOn} coolant={coolant} onSpindle={onSpindle} onCoolant={onCoolant} devices={state.devices ?? {}} /></div><div id="axis-panel-mdi" role="tabpanel" aria-labelledby="axis-tab-mdi" hidden={leftTab !== "mdi"}><MdiControl action={execute} ready={gates.mdi.enabled} /></div><div id="axis-panel-diagnostics" role="tabpanel" aria-labelledby="axis-tab-diagnostics" hidden={leftTab !== "diagnostics"}><DiagnosticsPanel state={state} gates={gates} /><XyzacDiagnostics view={xyzacView} operations={operationTrace} alarms={state.alarms} error={state.error} /></div></div><div className="axis-notebook right-notebook" data-axis-feature="AXIS-UI-06"><div className="notebook-tabs" role="tablist" aria-label="Preview and DRO"><button id="axis-tab-preview" type="button" role="tab" aria-controls="axis-panel-preview" aria-selected={rightTab === "preview"} className={rightTab === "preview" ? "active" : ""} onClick={() => setRightTab("preview")}>Preview</button><button id="axis-tab-dro" type="button" role="tab" aria-controls="axis-panel-dro" aria-selected={rightTab === "dro"} className={rightTab === "dro" ? "active" : ""} onClick={() => setRightTab("dro")}>DRO</button></div><div id="axis-panel-preview" role="tabpanel" aria-labelledby="axis-tab-preview" className="axis-preview" hidden={rightTab !== "preview"}><XyzacToolPreviewFromSnapshot snapshot={state.snapshot} view={view} zoom={zoom} rotateMode={rotateMode} showPath={layers.path} showLive={layers.live} showLimits={layers.limits} showVelocity={layers.velocity} showOffsets={layers.offsets} showExtents={layers.extents} /></div><div id="axis-panel-dro" role="tabpanel" aria-labelledby="axis-tab-dro" hidden={rightTab !== "dro"}><DroView axes={axes} state={state} coordType={coordType} displayType={displayType} metric={metric} setCoordType={setCoordType} setDisplayType={setDisplayType} setMetric={setMetric} /></div></div></section>
|
||||
<section className="axis-metrics" data-axis-feature="AXIS-VIEW-03"><div className="axis-slider"><label>Jog Speed:<output>50%</output><input type="range" min="6" max="100" defaultValue="50" /></label><label>Feed Override:<output>{Math.round((state.snapshot?.overrides?.feed ?? 1) * 100)}%</output><input type="range" min="0" max="120" defaultValue="100" onChange={(event) => execute("SET_OVERRIDE", { feed: Number(event.target.value) / 100 })} /></label><label>Spindle Override:<output>{Math.round((state.snapshot?.overrides?.spindle?.[0] ?? 1) * 100)}%</output><input type="range" min="0" max="120" defaultValue="100" /></label></div><div className="axis-layer-controls" aria-label="Preview layers">{(["path", "live", "limits", "velocity", "offsets", "extents"] as const).map((layer) => <label key={layer}><input type="checkbox" data-axis-feature="AXIS-VIEW-03" checked={layers[layer]} onChange={(event) => setLayers((current) => ({ ...current, [layer]: event.target.checked }))} /> {layer}</label>)}</div></section>
|
||||
|
||||
2
web/react/src/legacy.d.ts
vendored
2
web/react/src/legacy.d.ts
vendored
@@ -9,8 +9,6 @@ declare module "*.mjs" {
|
||||
execute(kind: string, payload?: Record<string, unknown>, options?: { state?: unknown; action?: string | null; gate?: boolean }): Promise<{ operationId: string; response: unknown; gate: { enabled: boolean; reason: string } }>;
|
||||
exportTrace(): Array<Record<string, unknown>>;
|
||||
}
|
||||
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<unknown>; export default createModule; }
|
||||
|
||||
@@ -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] });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
62
web/worker/test_xyzac_button_constraints.mjs
Normal file
62
web/worker/test_xyzac_button_constraints.mjs
Normal 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,
|
||||
}));
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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]]) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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]]) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user