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 }));
|
||||
|
||||
Reference in New Issue
Block a user