Enforce LinuxCNC WASM XYZAC command authority

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

View File

@@ -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();