286 lines
12 KiB
JavaScript
286 lines
12 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import { dirname, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { createLinuxCncTaskHalSdk } from "../../../wasm-port/runtime/sdk/src/linuxcnc-task-hal.js";
|
|
import { getFiveAxisProfile } from "../../app/src/profiles/index.js";
|
|
import { createLinuxCncInterpreterRuntime } from "../../app/src/runtime/linuxcnc-interpreter-runtime.js";
|
|
import { createLinuxCncKinematicsRuntime } from "../../app/src/runtime/linuxcnc-kinematics-runtime.js";
|
|
import { parseLinuxCncIni } from "../../app/src/runtime/linuxcnc-ini-runtime.js";
|
|
import { wrapTaskHalSdk } from "../../app/src/runtime/linuxcnc-task-hal-runtime.js";
|
|
import { createSimulationStore } from "../../app/src/state/store.js";
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
const rootDir = resolve(__dirname, "../../..");
|
|
const wasmPath = resolve(rootDir, "wasm-port/build/wasm/task-hal/linuxcnc_task_hal.wasm");
|
|
|
|
await verifyRunFeedbackLoop({
|
|
profileId: "xyzac-trt",
|
|
sourceRel: "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins_test_1.ngc",
|
|
stopAction: "STOP",
|
|
});
|
|
|
|
await verifyRunReadySequence({
|
|
profileId: "xyzac-trt",
|
|
sourceRel: "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins_test_1.ngc",
|
|
});
|
|
|
|
await verifyRunFeedbackLoop({
|
|
profileId: "xyzac-trt",
|
|
sourceRel: "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins_test_1.ngc",
|
|
stopAction: "ABORT",
|
|
});
|
|
|
|
await verifyRunFeedbackLoop({
|
|
profileId: "xyzbc-trt",
|
|
sourceRel: "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/boat-xyzbc.ngc",
|
|
stopAction: "STOP",
|
|
});
|
|
|
|
console.log("run_feedback_status_loop_smoke=ok");
|
|
console.log("run_ready_sequence_smoke=ok");
|
|
console.log("pause_uses_motion_paused_gate=ok");
|
|
console.log("pause_freezes_task_hal_status_loop=ok");
|
|
|
|
async function verifyRunFeedbackLoop({ profileId, sourceRel, stopAction }) {
|
|
const sdk = await createLinuxCncTaskHalSdk({
|
|
wasmBinary: readFileSync(wasmPath),
|
|
print() {},
|
|
printErr(message) {
|
|
console.error(message);
|
|
},
|
|
});
|
|
const profile = getFiveAxisProfile(profileId);
|
|
const iniText = readFileSync(resolve(rootDir, "wasm-port/vendor/linuxcnc", profile.iniPath), "utf8");
|
|
const iniConfig = parseLinuxCncIni(iniText, {
|
|
path: profile.iniPath,
|
|
profileId: profile.id,
|
|
});
|
|
const kinematicsModuleId = iniConfig.kinematicsModuleId;
|
|
const store = createSimulationStore();
|
|
|
|
store.dispatch({ type: "ATTACH_INI_CONFIG", profileId: profile.id, iniConfig });
|
|
store.dispatch({
|
|
type: "ATTACH_KINEMATICS_RUNTIME",
|
|
runtime: await createLinuxCncKinematicsRuntime({ moduleId: kinematicsModuleId }),
|
|
});
|
|
store.dispatch({
|
|
type: "ATTACH_INTERPRETER_RUNTIME",
|
|
runtime: await createLinuxCncInterpreterRuntime(),
|
|
});
|
|
store.dispatch({ type: "ATTACH_TASK_HAL_RUNTIME", runtime: wrapTaskHalSdk(sdk) });
|
|
await store.stageMachineFiles();
|
|
store.dispatch({ type: "LOAD_LINUXCNC_GCODE_SOURCE", sourceRel });
|
|
await waitForState(store, (state) => (
|
|
state.taskHalSession?.programPath?.endsWith(sourceRel.split("/").at(-1)) &&
|
|
state.rtcpFrame?.sourceMode === "source-derived-kinematics-wasm"
|
|
));
|
|
|
|
store.dispatch({ type: "TOGGLE_POWER" });
|
|
await waitForTaskHalCommand(store);
|
|
store.dispatch({ type: "HOME" });
|
|
store.dispatch({ type: "SET_MODE", mode: "auto" });
|
|
await waitForTaskHalCommand(store);
|
|
|
|
store.dispatch({ type: "RUN" });
|
|
await waitForTaskHalCommand(store);
|
|
await waitForState(store, (state) => distinctActiveLines(state.programRuntimeFeedbackHistory).length >= 3);
|
|
|
|
let state = store.getState();
|
|
const history = state.programRuntimeFeedbackHistory;
|
|
if (state.taskHalStatusLoop.profileId) {
|
|
assert.equal(state.taskHalStatusLoop.profileId, profileId);
|
|
}
|
|
if (state.taskHalStatusLoop.kinematicsModuleId) {
|
|
assert.equal(state.taskHalStatusLoop.kinematicsModuleId, kinematicsModuleId);
|
|
}
|
|
assert.equal(state.taskHalStatusLoop.taskPeriodNs, 10000000);
|
|
assert.equal(state.taskHalStatusLoop.servoPeriodNs, 1000000);
|
|
assert.equal(history.length >= 3, true);
|
|
assert.equal(history.every((entry) => entry.sourceMode === "linuxcnc-task-motion-hal-wasm"), true);
|
|
assert.equal(history.every((entry) => entry.semanticBoundary === "linuxcnc_task_motion_hal_wasm_simulation_runtime"), true);
|
|
assert.equal(history.some((entry) => entry.sourceMode === "fixture-line-playback"), false);
|
|
assert.equal(history.every((entry) => entry.activeLineSource === "emc-status"), true);
|
|
assert.equal(history.every((entry) => entry.activeLineHalSynced === true), true);
|
|
assert.equal(history.every((entry) => entry.line === entry.motionProgramLine), true);
|
|
assert.equal(history.every((entry) => entry.line === entry.halProgramLine), true);
|
|
assert.equal(history.some((entry) => entry.currentVelocityMmPerMin > 0), true);
|
|
assert.equal(history.some((entry) => entry.currentVelocityMmPerMin !== 3600), true);
|
|
assert.equal(isMonotonic(history.map((entry) => entry.taskCycle).reverse()), true);
|
|
assert.equal(isMonotonic(history.map((entry) => entry.cycle).reverse()), true);
|
|
const activeLines = distinctActiveLines(history);
|
|
assert.equal(activeLines.length >= 3, true, `${sourceRel} activeLines=${activeLines.join(",")}`);
|
|
assert.equal(isMonotonic(activeLines), true, `${sourceRel} activeLines=${activeLines.join(",")}`);
|
|
assert.equal(
|
|
sourceRel.endsWith("boat-xyzbc.ngc") ? activeLines.join(",") !== "1,10" : true,
|
|
true,
|
|
`${sourceRel} activeLines=${activeLines.join(",")}`,
|
|
);
|
|
assert.equal(
|
|
sourceRel.endsWith("boat-xyzbc.ngc") ? activeLines.some((line) => line > activeLines.indexOf(line) + 1) : true,
|
|
true,
|
|
`${sourceRel} activeLines=${activeLines.join(",")}`,
|
|
);
|
|
assert.equal(state.programExecutionSourceMode, "linuxcnc-task-motion-hal-wasm");
|
|
assert.equal(state.taskHalStatus.summary.taskRuntimeReady, true);
|
|
assert.equal(state.taskHalStatus.summary.halSyncReady, true);
|
|
|
|
const activeLineBeforePause = state.activeLine;
|
|
const motionLineBeforePause = state.taskHalStatus.emcStatus.motion.programLine;
|
|
const motionAxisBeforePause = { ...state.taskHalStatus.ui.axisPose };
|
|
store.dispatch({ type: "PAUSE_RESUME" });
|
|
await waitForTaskHalCommand(store);
|
|
state = store.getState();
|
|
assert.equal(state.runState, "paused");
|
|
assert.equal(state.machine.motionPaused, true);
|
|
assert.equal(state.machine.taskPaused, true);
|
|
assert.equal(state.taskHalStatus.ui.motionPaused, true);
|
|
assert.equal(state.taskHalStatus.emcStatus.motion.paused, true);
|
|
assert.equal(state.programRuntimeFeedback.paused, true);
|
|
assert.equal(state.activeLine, activeLineBeforePause);
|
|
assert.equal(state.taskHalStatus.emcStatus.motion.programLine, motionLineBeforePause);
|
|
assert.deepEqual(state.taskHalStatus.ui.axisPose, motionAxisBeforePause);
|
|
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
state = store.getState();
|
|
assert.equal(state.runState, "paused");
|
|
assert.equal(state.activeLine, activeLineBeforePause);
|
|
assert.equal(state.taskHalStatus.emcStatus.motion.programLine, motionLineBeforePause);
|
|
assert.deepEqual(state.taskHalStatus.ui.axisPose, motionAxisBeforePause);
|
|
|
|
store.dispatch({ type: "PAUSE_RESUME" });
|
|
await waitForTaskHalCommand(store);
|
|
await waitForState(store, (nextState) => nextState.machine.motionPaused === false && nextState.runState !== "paused");
|
|
state = store.getState();
|
|
assert.equal(state.machine.motionPaused, false);
|
|
assert.equal(state.taskHalStatus.emcStatus.motion.paused, false);
|
|
|
|
const cycleAfterRun = state.taskHalStatus.ui.taskCycle;
|
|
store.dispatch({ type: "STEP" });
|
|
await waitForTaskHalCommand(store);
|
|
state = store.getState();
|
|
assert.equal(state.programRuntimeFeedback.sourceMode, "linuxcnc-task-motion-hal-wasm");
|
|
assert.equal(state.programExecutionSourceMode, "linuxcnc-task-motion-hal-wasm");
|
|
|
|
store.dispatch({ type: stopAction });
|
|
await waitForTaskHalCommand(store);
|
|
state = store.getState();
|
|
assert.equal(state.taskHalStatusLoop.active, false);
|
|
assert.equal(["stopped", "complete", "idle"].includes(state.runState), true);
|
|
assert.equal(
|
|
stopAction !== "ABORT" || ["aborted", "stopped", "complete", "idle"].includes(state.taskHalStatusLoop.stopReason),
|
|
true,
|
|
);
|
|
assert.equal(state.taskHalStatus.ui.taskCycle >= cycleAfterRun, true);
|
|
}
|
|
|
|
async function verifyRunReadySequence({ profileId, sourceRel }) {
|
|
const sdk = await createLinuxCncTaskHalSdk({
|
|
wasmBinary: readFileSync(wasmPath),
|
|
print() {},
|
|
printErr(message) {
|
|
console.error(message);
|
|
},
|
|
});
|
|
const profile = getFiveAxisProfile(profileId);
|
|
const iniText = readFileSync(resolve(rootDir, "wasm-port/vendor/linuxcnc", profile.iniPath), "utf8");
|
|
const iniConfig = parseLinuxCncIni(iniText, {
|
|
path: profile.iniPath,
|
|
profileId: profile.id,
|
|
});
|
|
const store = createSimulationStore();
|
|
|
|
store.dispatch({ type: "ATTACH_INI_CONFIG", profileId: profile.id, iniConfig });
|
|
store.dispatch({
|
|
type: "ATTACH_KINEMATICS_RUNTIME",
|
|
runtime: await createLinuxCncKinematicsRuntime({ moduleId: iniConfig.kinematicsModuleId }),
|
|
});
|
|
store.dispatch({
|
|
type: "ATTACH_INTERPRETER_RUNTIME",
|
|
runtime: await createLinuxCncInterpreterRuntime(),
|
|
});
|
|
store.dispatch({ type: "ATTACH_TASK_HAL_RUNTIME", runtime: wrapTaskHalSdk(sdk) });
|
|
await store.stageMachineFiles();
|
|
store.dispatch({ type: "LOAD_LINUXCNC_GCODE_SOURCE", sourceRel });
|
|
await waitForState(store, (state) => (
|
|
state.taskHalSession?.programPath?.endsWith(sourceRel.split("/").at(-1)) &&
|
|
state.rtcpFrame?.sourceMode === "source-derived-kinematics-wasm"
|
|
));
|
|
|
|
store.dispatch({ type: "RUN_READY" });
|
|
await waitForTaskHalCommand(store);
|
|
await waitForState(store, (state) => state.taskHalSession?.programPath?.endsWith(sourceRel.split("/").at(-1)));
|
|
|
|
let state = store.getState();
|
|
assert.equal(state.machine.powerOn, false);
|
|
assert.equal(state.machine.taskState, "estop-reset");
|
|
assert.equal(state.machine.mode, "manual");
|
|
assert.equal(state.machine.allHomed, false);
|
|
assert.equal(state.taskHalSession?.programPath?.endsWith(sourceRel.split("/").at(-1)), true);
|
|
|
|
store.dispatch({ type: "TOGGLE_POWER" });
|
|
await waitForTaskHalCommand(store);
|
|
store.dispatch({ type: "HOME" });
|
|
store.dispatch({ type: "SET_MODE", mode: "auto" });
|
|
await waitForTaskHalCommand(store);
|
|
state = store.getState();
|
|
assert.equal(state.machine.powerOn, true);
|
|
assert.equal(state.machine.taskState, "on");
|
|
assert.equal(state.machine.mode, "auto");
|
|
assert.equal(state.machine.allHomed, true);
|
|
|
|
store.dispatch({ type: "RUN" });
|
|
await waitForTaskHalCommand(store);
|
|
await waitForState(store, (nextState) => distinctActiveLines(nextState.programRuntimeFeedbackHistory).length >= 2);
|
|
|
|
state = store.getState();
|
|
assert.equal(state.programExecutionSourceMode, "linuxcnc-task-motion-hal-wasm");
|
|
assert.equal(state.programRuntimeFeedback.sourceMode, "linuxcnc-task-motion-hal-wasm");
|
|
assert.notEqual(state.operatorMessage, "run blocked: home machine first");
|
|
assert.equal(state.machine.allHomed, true);
|
|
|
|
const activeLineBeforeStop = state.activeLine;
|
|
store.dispatch({ type: "STOP" });
|
|
await waitForTaskHalCommand(store);
|
|
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
state = store.getState();
|
|
assert.equal(state.taskHalStatusLoop.active, false);
|
|
assert.equal(state.runState, "stopped");
|
|
assert.equal(state.machine.interpState, "idle");
|
|
assert.equal(state.feed.currentVelocity, 0);
|
|
assert.equal(state.activeLine, activeLineBeforeStop);
|
|
}
|
|
|
|
async function waitForTaskHalCommand(store) {
|
|
for (let attempt = 0; attempt < 80; attempt += 1) {
|
|
if (!store.getState().taskHalExecutionPending) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
if (!store.getState().taskHalExecutionPending) return store.getState();
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
throw new Error("task/HAL command did not settle");
|
|
}
|
|
|
|
async function waitForState(store, predicate) {
|
|
for (let attempt = 0; attempt < 160; attempt += 1) {
|
|
const state = store.getState();
|
|
if (predicate(state)) return state;
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
}
|
|
throw new Error("store state condition did not settle");
|
|
}
|
|
|
|
function isMonotonic(values) {
|
|
for (let index = 1; index < values.length; index += 1) {
|
|
if (Number(values[index]) < Number(values[index - 1])) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function distinctActiveLines(history = []) {
|
|
return [...new Set(history.map((entry) => Number(entry.line)).reverse())]
|
|
.filter((line) => Number.isFinite(line));
|
|
}
|