Wire AXIS controls through operation gates

This commit is contained in:
2026-08-23 09:30:44 +08:00
parent a1edd8defe
commit a7fcec9d33
11 changed files with 367 additions and 17 deletions

View File

@@ -5,6 +5,7 @@ export const AXIS_INTERP_STATES = Object.freeze(["IDLE", "READING", "WAITING", "
export const AXIS_MOTION_MODES = Object.freeze(["FREE", "COORD", "TELEOP"]);
export const AXIS_COMMANDS = Object.freeze({
open: "LOAD_WORKSPACE", load: "PROGRAM_LOAD",
run: "PROGRAM_RUN", step: "PROGRAM_STEP", pause: "PROGRAM_PAUSE", resume: "PROGRAM_RESUME",
stop: "PROGRAM_ABORT", restart: "PROGRAM_RESTART", mdi: "MDI_EXECUTE", jogStart: "JOG_START",
jogStop: "JOG_STOP", home: "HOME", override: "SET_OVERRIDE", estop: "ESTOP", recover: "IO_RECOVER",
@@ -25,8 +26,10 @@ 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 taskState = string(task.state ?? input.task_state ?? input.state, "OFF");
const interpState = string(task.interp_state ?? input.interp_state ?? input.interpreter_state, "IDLE");
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 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");
const motionMode = string(motion.mode ?? input.motion_mode, "COORD");
const programStatus = string(program.program_status ?? input.program_status, "IDLE").toLowerCase();
@@ -36,7 +39,7 @@ export function normalizeAxisState(input = {}) {
const limits = input.on_any_limit ?? motion.on_any_limit ?? motion.limit ?? false;
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);
const taskFile = Boolean(input.taskfile ?? program.path ?? program.source_hash ?? program.loaded ?? (program.program_status && String(program.program_status).toLowerCase() !== "idle"));
const hasEditor = input.has_editor !== false;
return Object.freeze({
taskState, interpState, mode, motionMode, programStatus, queue, queueLimit,
@@ -60,6 +63,7 @@ export function axisGate(action, input = {}) {
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 "open": enabled = idle; reason = enabled ? "axis_interp_idle" : "axis_interp_running"; break;
case "load": enabled = machineOn && idle; reason = enabled ? "axis_program_load_available" : !machineOn ? "axis_machine_off" : "axis_interp_running"; break;
case "reload": enabled = idle && state.taskFile; reason = !idle ? "axis_interp_running" : state.taskFile ? "axis_reload_available" : "axis_taskfile_missing"; break;
case "edit": enabled = idle && state.taskFile && state.hasEditor; reason = !idle ? "axis_interp_running" : !state.taskFile ? "axis_taskfile_missing" : !state.hasEditor ? "axis_editor_missing" : "axis_edit_available"; break;
case "run": enabled = machineOn && idle && state.taskFile; reason = !machineOn ? "axis_machine_off" : !idle ? "axis_interp_running" : !state.taskFile ? "axis_taskfile_missing" : "axis_run_available"; break;

View File

@@ -0,0 +1,46 @@
import { AXIS_COMMANDS, axisGate } from "./axis-contract.mjs";
export const COMMAND_ACTIONS = Object.freeze(Object.fromEntries(Object.entries(AXIS_COMMANDS).map(([action, command]) => [command, action])));
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");
this.client = client;
this.counter = 0;
this.trace = [];
}
nextOperationId(kind) {
this.counter += 1;
return `axis-${String(kind).toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${String(this.counter).padStart(6, "0")}`;
}
async execute(kind, payload = {}, { state = {}, action = COMMAND_ACTIONS[kind] ?? null, gate = true } = {}) {
const operationId = this.nextOperationId(kind);
const gateResult = action ? axisGate(action, state) : { action: null, enabled: true, reason: "axis_unrestricted_internal_command" };
if (gate && !gateResult.enabled) {
this.trace.push(Object.freeze({ operation_id: operationId, kind, action, phase: "blocked", reason: gateResult.reason }));
throw new AxisGateError(gateResult);
}
this.trace.push(Object.freeze({ operation_id: operationId, kind, action, phase: "pending", reason: gateResult.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 }));
return Object.freeze({ operationId, response, gate: gateResult });
} catch (error) {
this.trace.push(Object.freeze({ operation_id: operationId, kind, action, phase: "error", reason: error?.message ?? String(error) }));
throw error;
}
}
exportTrace() { return structuredClone(this.trace); }
}

View File

@@ -0,0 +1,16 @@
import assert from "node:assert/strict";
import { AxisGateError, 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" } }; } };
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(calls[0].operationId, run.operationId);
await assert.rejects(() => controller.execute("JOG_START", { axis: "x" }, { state: { task: { state: "ESTOP" } } }), AxisGateError);
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"]);
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 }));