Wire AXIS controls through operation gates
This commit is contained in:
@@ -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;
|
||||
|
||||
46
web/app/axis-operation-controller.mjs
Normal file
46
web/app/axis-operation-controller.mjs
Normal 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); }
|
||||
}
|
||||
16
web/app/test_axis_operation_controller.mjs
Normal file
16
web/app/test_axis_operation_controller.mjs
Normal 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 }));
|
||||
@@ -5,6 +5,7 @@ import { ThreeMachineView } from "./ThreeMachineView";
|
||||
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";
|
||||
|
||||
const DEFAULT_CASE = "linuxcnc/configs/sim/axis/minimal_xyz.ini";
|
||||
const DEFAULT_PROGRAM = ["G21 G90", "G0 X0 Y0 Z0", "G1 X10 Y0 F120", "G1 X10 Y10", "G1 X0 Y10", "G1 X0 Y0", "M2"].join("\n");
|
||||
@@ -68,6 +69,7 @@ function ProgramPane({ text, setText, activeGcodes, onLoad, onRun, onStep, ready
|
||||
|
||||
export function App() {
|
||||
const client = useMemo(() => new ReactWorkerClient(), []);
|
||||
const operationController = useMemo(() => new AxisOperationController(client), [client]);
|
||||
const [state, setState] = useState(INITIAL_STATE);
|
||||
const [casePath, setCasePath] = useState(DEFAULT_CASE);
|
||||
const [caseStatus, setCaseStatus] = useState("ready to load");
|
||||
@@ -87,10 +89,36 @@ export function App() {
|
||||
useEffect(() => { const unsubscribe = client.subscribe((message) => setState((previous) => reduceFrontendState(previous, message))); client.ensureSession().catch((error: Error) => setCommandStatus(`Error: ${error.message}`)); return () => { unsubscribe(); if (runTimer.current) clearInterval(runTimer.current); client.close(); }; }, [client]);
|
||||
const axes = state.snapshot?.motion?.axes ?? [0, 0, 0];
|
||||
const ready = ["ready", "running", "paused"].includes(state.status);
|
||||
const execute: Action = (kind, payload = {}) => { setCommandStatus(`${kind}...`); client.send(kind, payload, `axis-${kind.toLowerCase()}-${Date.now()}`).then(() => setCommandStatus("Acknowledged")).catch((error: Error) => setCommandStatus(`Blocked: ${error.message}`)); };
|
||||
const runProgram = () => { execute("PROGRAM_RUN"); if (runTimer.current) clearInterval(runTimer.current); runTimer.current = setInterval(() => { client.send("SIM_STEP", { cycles: 1 }, `axis-tick-${Date.now()}`).catch(() => undefined); }, 50); };
|
||||
const axisState = { task: { ...(state.task ?? {}), state: state.task?.state ?? (ready ? "ON" : "OFF"), mode: state.task?.mode ?? "MANUAL", interp_state: state.task?.interp_state ?? (state.status === "running" ? "READING" : state.status === "paused" ? "PAUSED" : "IDLE") }, program: state.program ?? {}, motion: state.snapshot?.motion ?? state.snapshot?.motion ?? {}, devices: state.devices ?? {} };
|
||||
const execute: Action = (kind, payload = {}) => {
|
||||
setCommandStatus(`${kind} pending`);
|
||||
operationController.execute(kind, payload, { state: axisState }).then(() => setCommandStatus(`${kind} acknowledged`)).catch((error: Error) => {
|
||||
const code = error instanceof AxisGateError ? error.code : error.message;
|
||||
setCommandStatus(`${kind} blocked: ${code}`);
|
||||
});
|
||||
};
|
||||
const runProgram = () => { execute("PROGRAM_RUN"); if (runTimer.current) clearInterval(runTimer.current); runTimer.current = setInterval(() => { operationController.execute("SIM_STEP", { cycles: 1 }, { state: axisState, gate: false }).catch(() => undefined); }, 50); };
|
||||
const stopProgram = () => { if (runTimer.current) { clearInterval(runTimer.current); runTimer.current = null; } execute("PROGRAM_ABORT"); };
|
||||
const loadCase = async () => { setCaseStatus("loading..."); try { await client.ensureSession(); const bundle = await loadSimulationCase({ path: casePath }); if (bundle.status !== "ready") { setCaseStatus(`blocked: ${bundle.missing.join(", ")}`); return; } const result = await client.send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }, "axis-load-case"); const resultPayload = result.payload as { configuration?: { state?: string } }; setCaseStatus(`${bundle.entrypoint} -> ${resultPayload.configuration?.state ?? "READY"}`); } catch (error) { setCaseStatus(`blocked: ${error instanceof Error ? error.message : "case_load_failed"}`); } };
|
||||
const loadCase = async () => { setCaseStatus("loading..."); try { await client.ensureSession(); const bundle = await loadSimulationCase({ path: casePath }); if (bundle.status !== "ready") { setCaseStatus(`blocked: ${bundle.missing.join(", ")}`); return; } const operation = await operationController.execute("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }, { state: axisState, gate: false }); const resultPayload = (operation.response as { payload?: { configuration?: { state?: string } } }).payload; setCaseStatus(`${bundle.entrypoint} -> ${resultPayload?.configuration?.state ?? "READY"}`); } catch (error) { setCaseStatus(`blocked: ${error instanceof Error ? error.message : "case_load_failed"}`); } };
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target && ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)) return;
|
||||
const key = event.key.toLowerCase();
|
||||
if (event.ctrlKey && key === "k") { event.preventDefault(); setCommandStatus("Live plot cleared"); return; }
|
||||
if (key === "f3") { event.preventDefault(); setLeftTab("manual"); return; }
|
||||
if (key === "f5") { event.preventDefault(); setLeftTab("mdi"); return; }
|
||||
if (key === "f1") { event.preventDefault(); execute("ESTOP", { active: true }); return; }
|
||||
if (key === "f2") { event.preventDefault(); execute("IO_RECOVER"); return; }
|
||||
if (key === "r") { event.preventDefault(); runProgram(); return; }
|
||||
if (key === "t") { event.preventDefault(); execute("PROGRAM_STEP"); return; }
|
||||
if (key === "p") { event.preventDefault(); execute("PROGRAM_PAUSE"); return; }
|
||||
if (key === "s") { event.preventDefault(); execute("PROGRAM_RESUME"); return; }
|
||||
if (key === "escape") { event.preventDefault(); stopProgram(); }
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [axisState, execute, runProgram, stopProgram]);
|
||||
const onSpindle = (direction: number) => { setSpindleOn(direction !== 0); execute("SPINDLE_COMMAND", { spindle: 0, direction, rpm: direction === 0 ? 0 : 600, enabled: direction !== 0 }); };
|
||||
const onCoolant = (key: "mist" | "flood") => { const next = { ...coolant, [key]: !coolant[key] }; setCoolant(next); execute("COOLANT_COMMAND", { mist: next.mist, flood: next.flood, lube: false }); };
|
||||
const activeGcodes = state.program?.active_gcodes ? String(state.program.active_gcodes) : "G21 G90\nG17 G40 G49\nG54 G64\nM5 M9";
|
||||
|
||||
6
web/react/src/legacy.d.ts
vendored
6
web/react/src/legacy.d.ts
vendored
@@ -3,6 +3,12 @@ declare module "*.mjs" {
|
||||
export function loadSimulationCase(options: { path: string }): Promise<{ status: string; entrypoint: string; files: Record<string, string>; missing: string[]; fetched: string[]; state_hash: string; halManifest: unknown[] }>;
|
||||
export function toWorkerConfiguration(bundle: unknown): Record<string, unknown>;
|
||||
export class SimSession { contextId: string; sessionId: string; constructor(core: unknown); accept(message: unknown): unknown; }
|
||||
export class AxisOperationController {
|
||||
constructor(client: { send(kind: string, payload?: unknown, operationId?: string): Promise<unknown> });
|
||||
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 }; }
|
||||
}
|
||||
|
||||
declare module "*.js" { const createModule: (options?: unknown) => Promise<unknown>; export default createModule; }
|
||||
|
||||
Reference in New Issue
Block a user