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 === "motion-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.motionStatus.motion.programLine; const motionAxisBeforePause = { ...state.taskHalStatus.motionStatus.axis }; 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.motionStatus.motion.paused, true); assert.equal(state.programRuntimeFeedback.paused, true); assert.equal(state.activeLine, activeLineBeforePause); assert.equal(state.taskHalStatus.motionStatus.motion.programLine, motionLineBeforePause); assert.deepEqual(state.taskHalStatus.motionStatus.axis, 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.motionStatus.motion.programLine, motionLineBeforePause); assert.deepEqual(state.taskHalStatus.motionStatus.axis, 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.motionStatus.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)); }