Advance simulation parity and 3-axis tutorial coverage
This commit is contained in:
@@ -12,7 +12,7 @@ import { renderDisplayFamily } from "./display-family.mjs";
|
||||
import { appendAxisIcon } from "./source-assets.mjs";
|
||||
|
||||
const SAMPLE_PROGRAM = ["G21 G90", "G0 X0 Y0 Z0", "G1 X10 Y0 F120", "G1 X10 Y10", "G1 X0 Y10", "G1 X0 Y0", "M2"].join("\n");
|
||||
const DEFAULT_CASE = "linuxcnc/configs/sim/axis/corexy/corexy_by_hal.ini";
|
||||
const DEFAULT_CASE = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini";
|
||||
|
||||
function element(document, tag, className, text = "") {
|
||||
const node = document.createElement(tag);
|
||||
@@ -104,7 +104,7 @@ export function mountApp({ document = globalThis.document, client = null } = {})
|
||||
const query = caseSearch.value.trim().toLowerCase();
|
||||
const selected = caseSelect.value || DEFAULT_CASE;
|
||||
caseSelect.replaceChildren();
|
||||
for (const entry of SIM_CASE_CATALOG) {
|
||||
for (const entry of SIM_CASE_CATALOG.filter((candidate) => candidate.path === DEFAULT_CASE)) {
|
||||
if (query && !`${entry.path} ${entry.label} ${entry.category} ${entry.kinematics ?? ""}`.toLowerCase().includes(query)) continue;
|
||||
const option = element(document, "option");
|
||||
option.value = entry.path;
|
||||
|
||||
@@ -7,7 +7,8 @@ import { loadSimulationCase, toWorkerConfiguration } from "../../workspace/sim-c
|
||||
import { axisIcon } from "./sourceAssets";
|
||||
import { AxisGateError, AxisOperationController } from "../../app/axis-operation-controller.mjs";
|
||||
|
||||
const DEFAULT_CASE = "linuxcnc/configs/sim/axis/minimal_xyz.ini";
|
||||
export const SELECTED_AXIS3_CASE = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini";
|
||||
const DEFAULT_CASE = SELECTED_AXIS3_CASE;
|
||||
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");
|
||||
|
||||
type Action = (kind: string, payload?: Record<string, unknown>) => void;
|
||||
@@ -107,7 +108,7 @@ export function App() {
|
||||
};
|
||||
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 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"}`); } };
|
||||
const loadCase = async () => { setCaseStatus("loading session..."); try { await client.ensureSession(); setCaseStatus("loading exact case..."); const bundle = await loadSimulationCase({ path: casePath, baseUrl: "." }); if (bundle.status !== "ready") { setCaseStatus(`blocked: ${bundle.missing.join(", ")}`); return; } setCaseStatus("starting exact case..."); 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;
|
||||
@@ -130,17 +131,19 @@ export function App() {
|
||||
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";
|
||||
const taskState = String(state.task?.state ?? state.status).toUpperCase();
|
||||
const taskState = state.axisState?.state ?? String(state.task?.state ?? state.status).toUpperCase();
|
||||
const machineState = state.axisState?.machine_state ?? "OFF";
|
||||
const operationState = state.axisState?.operation_state ?? "IDLE";
|
||||
const toolNumber = String((state.devices as Record<string, any> | null)?.tool?.current ?? state.snapshot?.tool?.current ?? 0);
|
||||
const position = `X${Number(axes[0] ?? 0).toFixed(3)} Y${Number(axes[1] ?? 0).toFixed(3)} Z${Number(axes[2] ?? 0).toFixed(3)}`;
|
||||
return <main className="axis-app" data-axis-feature="AXIS-UI-01">
|
||||
<header className="axis-window-header" data-axis-feature="AXIS-SHELL-01"><div className="axis-brand"><img src={axisIcon("axis-24x24.png")} alt="AXIS" /> <strong>AXIS</strong><span>LinuxCNC Web/WASM</span></div><span className={`connection-state ${state.status}`} data-axis-feature="AXIS-SHELL-01" role="status">{state.status.toUpperCase()}</span></header>
|
||||
<header className="axis-window-header" data-axis-feature="AXIS-SHELL-01"><div className="axis-brand"><img src={axisIcon("axis-24x24.png")} alt="AXIS" /> <strong>AXIS</strong><span>LinuxCNC Web/WASM</span></div><span className={`connection-state ${state.status}`} data-axis-feature="AXIS-SHELL-01" role="status">{state.status.toUpperCase()}</span><span className="axis-state-badge" data-axis-state={taskState} aria-label={`Machine ${machineState}, operation ${operationState}`}>{taskState}</span></header>
|
||||
<nav className="axis-menubar" data-axis-feature="AXIS-UI-02" aria-label="AXIS menu"><Menu label="File"><MenuAction onClick={() => document.getElementById("axis-program")?.focus()}>Open...</MenuAction><MenuAction onClick={() => setProgramText(DEFAULT_PROGRAM)}>Reload</MenuAction><MenuAction onClick={() => setCommandStatus("Tool data editor is browser-backed")}>Edit tool data...</MenuAction><MenuAction onClick={() => setCommandStatus("Quit is unavailable in a browser tab")}>Quit</MenuAction></Menu><Menu label="Machine"><MenuAction shortcut="F1" onClick={() => execute("ESTOP", { active: true })}>Toggle Emergency Stop</MenuAction><MenuAction shortcut="F2" onClick={() => execute("IO_RECOVER")}>Toggle Machine Power</MenuAction><MenuAction shortcut="R" onClick={runProgram}>Run program</MenuAction><MenuAction shortcut="T" onClick={() => execute("PROGRAM_STEP")}>Step</MenuAction><MenuAction shortcut="P" onClick={() => execute("PROGRAM_PAUSE")}>Pause</MenuAction><MenuAction shortcut="S" onClick={() => execute("PROGRAM_RESUME")}>Resume</MenuAction><MenuAction shortcut="Esc" onClick={stopProgram}>Stop</MenuAction></Menu><Menu label="View"><MenuAction onClick={() => setRightTab("preview")}>Preview</MenuAction><MenuAction onClick={() => setRightTab("dro")}>DRO</MenuAction><MenuAction onClick={() => setLeftTab("manual")}>Manual Control</MenuAction><MenuAction onClick={() => setLeftTab("mdi")}>MDI</MenuAction></Menu><Menu label="Help"><MenuAction onClick={() => setCommandStatus("LinuxCNC AXIS source-compatible controls")}>About AXIS</MenuAction></Menu></nav>
|
||||
<section className="axis-toolbar" data-axis-feature="AXIS-UI-05" aria-label="AXIS toolbar"><IconButton feature="AXIS-MACHINE-01" label="Toggle Emergency Stop [F1]" asset="tool_estop.gif" danger onClick={() => execute("ESTOP", { active: true })} pressed={state.status === "blocked"} /><IconButton feature="AXIS-MACHINE-02" label="Toggle Machine power [F2]" asset="tool_power.gif" onClick={() => execute("IO_RECOVER")} /><Separator /><IconButton feature="AXIS-FILE-01" label="Open G-Code file [O]" asset="tool_open.gif" onClick={loadCase} /><IconButton feature="AXIS-FILE-04" label="Reopen current file [Ctrl-R]" asset="tool_reload.gif" onClick={() => setProgramText(DEFAULT_PROGRAM)} /><Separator /><IconButton feature="AXIS-MACHINE-03" label="Begin executing current file [R]" asset="tool_run.gif" disabled={!ready} onClick={runProgram} /><IconButton feature="AXIS-MACHINE-05" label="Execute next line [T]" asset="tool_step.gif" disabled={!ready} onClick={() => execute("PROGRAM_STEP")} /><IconButton feature="AXIS-MACHINE-06" label="Pause / resume execution [P/S]" asset={state.status === "paused" ? "resume_inhibit.gif" : "tool_pause.gif"} disabled={!ready} onClick={() => execute(state.status === "paused" ? "PROGRAM_RESUME" : "PROGRAM_PAUSE")} /><IconButton feature="AXIS-MACHINE-07" label="Stop program execution [ESC]" asset="tool_stop.gif" disabled={!ready} onClick={stopProgram} /><Separator /><IconButton feature="AXIS-MACHINE-09" label="Toggle skip lines with slash" asset="tool_blockdelete.gif" pressed={blockDelete} onClick={() => setBlockDelete((value) => !value)} /><IconButton feature="AXIS-MACHINE-08" label="Toggle optional pause" asset="tool_optpause.gif" pressed={optionalStop} onClick={() => setOptionalStop((value) => !value)} /><Separator /><IconButton feature="AXIS-VIEW-01" label="Zoom in" asset="tool_zoomin.gif" onClick={() => setZoom((value) => Math.min(2, value + .2))} /><IconButton feature="AXIS-VIEW-01" label="Zoom out" asset="tool_zoomout.gif" onClick={() => setZoom((value) => Math.max(.5, value - .2))} /><IconButton feature="AXIS-VIEW-01" label="Top view" asset="tool_axis_z.gif" pressed={view === "z"} onClick={() => { setView("z"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Rotated top view" asset="tool_axis_z2.gif" pressed={view === "z2"} onClick={() => { setView("z2"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Side view" asset="tool_axis_x.gif" pressed={view === "x"} onClick={() => { setView("x"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Front view" asset="tool_axis_y.gif" pressed={view === "y"} onClick={() => { setView("y"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Inverted front view" asset="tool_axis_y2.png" pressed={view === "y2"} onClick={() => { setView("y2"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Perspective view" asset="tool_axis_p.gif" pressed={view === "p"} onClick={() => { setView("p"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-02" label="Drag / rotate mode" asset="tool_rotate.gif" pressed={rotateMode} onClick={() => setRotateMode((value) => !value)} /><Separator /><IconButton feature="AXIS-VIEW-15" label="Clear live plot [Ctrl-K]" asset="tool_clear.gif" onClick={() => setCommandStatus("Live plot cleared")} /></section>
|
||||
<section className="axis-case-strip" data-axis-feature="AXIS-EDIT-03"><label>Simulation case<select data-axis-feature="AXIS-EDIT-03" value={casePath} onChange={(event) => setCasePath(event.target.value)}>{SIM_CASE_CATALOG.filter((entry) => entry.path.includes("minimal_xyz") || entry.path.includes("corexy")).slice(0, 8).map((entry) => <option key={entry.path} value={entry.path}>{entry.label}</option>)}</select></label><TextButton feature="AXIS-EDIT-03" onClick={loadCase}>Load case</TextButton><span className="case-status" role="status">{caseStatus}</span><span className="command-status" role="status">{commandStatus}</span></section>
|
||||
<section className="axis-case-strip" data-axis-feature="AXIS-EDIT-03"><label>Simulation case<select data-axis-feature="AXIS-EDIT-03" value={casePath} onChange={(event) => setCasePath(event.target.value)}>{SIM_CASE_CATALOG.filter((entry) => entry.path === SELECTED_AXIS3_CASE).map((entry) => <option key={entry.path} value={entry.path}>{entry.label} | 3-axis AXIS</option>)}</select></label><TextButton feature="AXIS-EDIT-03" onClick={loadCase}>Load case</TextButton><span className="case-status" role="status">{caseStatus}</span><span className="command-status" role="status">{commandStatus}</span></section>
|
||||
<section className="axis-top-pane" data-axis-feature="AXIS-UI-06"><div className="axis-notebook left-notebook" data-axis-feature="AXIS-UI-06"><div className="notebook-tabs" role="tablist" aria-label="Manual and MDI controls"><button id="axis-tab-manual" type="button" role="tab" aria-controls="axis-panel-manual" aria-selected={leftTab === "manual"} className={leftTab === "manual" ? "active" : ""} onClick={() => setLeftTab("manual")}>Manual Control [F3]</button><button id="axis-tab-mdi" type="button" role="tab" aria-controls="axis-panel-mdi" aria-selected={leftTab === "mdi"} className={leftTab === "mdi" ? "active" : ""} onClick={() => setLeftTab("mdi")}>MDI [F5]</button></div><div id="axis-panel-manual" role="tabpanel" aria-labelledby="axis-tab-manual" hidden={leftTab !== "manual"}><ManualControl action={execute} ready={ready} selectedAxis={selectedAxis} setSelectedAxis={setSelectedAxis} spindleOn={spindleOn} coolant={coolant} onSpindle={onSpindle} onCoolant={onCoolant} /></div><div id="axis-panel-mdi" role="tabpanel" aria-labelledby="axis-tab-mdi" hidden={leftTab !== "mdi"}><MdiControl action={execute} ready={ready} /></div></div><div className="axis-notebook right-notebook" data-axis-feature="AXIS-UI-06"><div className="notebook-tabs" role="tablist" aria-label="Preview and DRO"><button id="axis-tab-preview" type="button" role="tab" aria-controls="axis-panel-preview" aria-selected={rightTab === "preview"} className={rightTab === "preview" ? "active" : ""} onClick={() => setRightTab("preview")}>Preview</button><button id="axis-tab-dro" type="button" role="tab" aria-controls="axis-panel-dro" aria-selected={rightTab === "dro"} className={rightTab === "dro" ? "active" : ""} onClick={() => setRightTab("dro")}>DRO</button></div><div id="axis-panel-preview" role="tabpanel" aria-labelledby="axis-tab-preview" className="axis-preview" hidden={rightTab !== "preview"}><ThreeMachineView axes={axes} segments={trajectory} view={view} zoom={zoom} rotateMode={rotateMode} showGrid={layers.grid} showTool={layers.tool} showPath={layers.path} showLive={layers.live} showLimits={layers.limits} showVelocity={layers.velocity} showOffsets={layers.offsets} showExtents={layers.extents} /></div><div id="axis-panel-dro" role="tabpanel" aria-labelledby="axis-tab-dro" hidden={rightTab !== "dro"}><DroView axes={axes} state={state} coordType={coordType} displayType={displayType} metric={metric} setCoordType={setCoordType} setDisplayType={setDisplayType} setMetric={setMetric} /></div></div></section>
|
||||
<section className="axis-metrics" data-axis-feature="AXIS-VIEW-03"><div className="axis-slider"><label>Jog Speed:<output>50%</output><input type="range" min="6" max="100" defaultValue="50" /></label><label>Feed Override:<output>{Math.round((state.snapshot?.overrides?.feed ?? 1) * 100)}%</output><input type="range" min="0" max="120" defaultValue="100" onChange={(event) => execute("SET_OVERRIDE", { feed: Number(event.target.value) / 100 })} /></label><label>Spindle Override:<output>{Math.round((state.snapshot?.overrides?.spindle?.[0] ?? 1) * 100)}%</output><input type="range" min="0" max="120" defaultValue="100" /></label></div><div className="axis-layer-controls" aria-label="Preview layers">{(["grid", "tool", "path", "live", "limits", "velocity", "offsets", "extents"] as const).map((layer) => <label key={layer}><input type="checkbox" data-axis-feature="AXIS-VIEW-03" checked={layers[layer]} onChange={(event) => setLayers((current) => ({ ...current, [layer]: event.target.checked }))} /> {layer}</label>)}</div></section>
|
||||
<ProgramPane text={programText} setText={setProgramText} activeGcodes={activeGcodes} onLoad={() => execute("PROGRAM_LOAD", { source: programText, path: "main.ngc" })} onRun={runProgram} onStep={() => execute("PROGRAM_STEP")} ready={ready} currentLine={Number(state.program?.line ?? state.task?.current_line ?? 0) || null} diagnostic={state.error?.code ?? null} />
|
||||
<footer className="axis-info-bar" data-axis-feature="AXIS-UI-07"><span className={`task-state ${state.status}`} data-axis-feature="AXIS-SHELL-01">{taskState}</span><span className="tool-state" data-axis-feature="AXIS-UI-07">Tool {toolNumber}</span><span className="position-state" data-axis-feature="AXIS-VIEW-17">{position}</span><span className="footer-status" role="status">{state.error ? `Error: ${state.error.code}` : commandStatus}</span></footer>
|
||||
<footer className="axis-info-bar" data-axis-feature="AXIS-UI-07"><span className={`task-state ${state.status}`} data-axis-feature="AXIS-SHELL-01">{taskState}</span><span className="machine-state" data-axis-state-machine={machineState}>Machine {machineState}</span><span className="operation-state" data-axis-state-operation={operationState}>Operation {operationState}</span><span className="tool-state" data-axis-feature="AXIS-UI-07">Tool {toolNumber}</span><span className="position-state" data-axis-feature="AXIS-VIEW-17">{position}</span><span className="footer-status" role="status">{state.error ? `Error: ${state.error.code}` : commandStatus}</span></footer>
|
||||
</main>;
|
||||
}
|
||||
|
||||
2
web/react/src/legacy.d.ts
vendored
2
web/react/src/legacy.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
declare module "*.mjs" {
|
||||
export const SIM_CASE_CATALOG: Array<{ path: string; label?: string; category?: string; status?: string }>;
|
||||
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 loadSimulationCase(options: { path: string; baseUrl?: 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 {
|
||||
|
||||
@@ -60,6 +60,15 @@ export type Capabilities = {
|
||||
exclusions?: string[];
|
||||
};
|
||||
|
||||
export type AxisStateView = {
|
||||
state: "ESTOP" | "ESTOP_RESET" | "OFF" | "ON" | "HOME" | "IDLE" | "RUNNING" | "PAUSED";
|
||||
machine_state: "ESTOP" | "ESTOP_RESET" | "OFF" | "ON";
|
||||
operation_state: "HOME" | "IDLE" | "RUNNING" | "PAUSED";
|
||||
source_task_state: string;
|
||||
source_interp_state: string;
|
||||
source_mode: "MANUAL" | "MDI" | "AUTO";
|
||||
};
|
||||
|
||||
export function isEnvelope(value: unknown): value is MessageEnvelope {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const candidate = value as Partial<MessageEnvelope>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Capabilities, MessageEnvelope, SimSnapshotV1 } from "./protocol";
|
||||
import type { AxisStateView, Capabilities, MessageEnvelope, SimSnapshotV1 } from "./protocol";
|
||||
|
||||
export type SessionStatus = "disconnected" | "handshaking" | "attaching" | "loading" | "ready" | "running" | "paused" | "recovering" | "blocked" | "error";
|
||||
|
||||
@@ -21,6 +21,7 @@ export type FrontendState = {
|
||||
plc: Record<string, unknown> | null;
|
||||
python: Record<string, unknown> | null;
|
||||
ioAck: Record<string, unknown> | null;
|
||||
axisState: AxisStateView | null;
|
||||
};
|
||||
|
||||
export const INITIAL_STATE: FrontendState = {
|
||||
@@ -42,6 +43,7 @@ export const INITIAL_STATE: FrontendState = {
|
||||
plc: null,
|
||||
python: null,
|
||||
ioAck: null,
|
||||
axisState: null,
|
||||
};
|
||||
|
||||
function payload<T>(message: MessageEnvelope): T {
|
||||
@@ -57,7 +59,7 @@ function projection(result: Record<string, unknown>, message: MessageEnvelope, p
|
||||
: [axesObject?.x, axesObject?.y, axesObject?.z].map((value) => typeof value === "number" ? value : 0);
|
||||
const joints = Array.isArray(motionState.joints) ? motionState.joints as number[] : [];
|
||||
const snapshot = { simulation_tick: message.simulation_tick ?? previous.tick, state_hash: String(result.state_hash ?? previous.snapshot?.state_hash ?? ""), task: result.task as SimSnapshotV1["task"], motion: { axes, joints, velocity: Array.isArray(motionState.velocity) ? motionState.velocity as number[] : [] }, authoritative: result.authoritative as SimSnapshotV1["authoritative"] } satisfies SimSnapshotV1;
|
||||
return { snapshot, task: (result.task as Record<string, unknown>) ?? previous.task, program: (result.program as Record<string, unknown>) ?? previous.program, devices: (result.devices as Record<string, unknown>) ?? previous.devices, deviceIo: (result.device_io as Record<string, unknown>) ?? previous.deviceIo, hal: (result.hal_runtime as Record<string, unknown>) ?? previous.hal, plc: (result.plc as Record<string, unknown>) ?? previous.plc, python: (result.python as Record<string, unknown>) ?? previous.python, ioAck: (result.io_ack as Record<string, unknown>) ?? previous.ioAck };
|
||||
return { snapshot, task: (result.task as Record<string, unknown>) ?? previous.task, program: (result.program as Record<string, unknown>) ?? previous.program, devices: (result.devices as Record<string, unknown>) ?? previous.devices, deviceIo: (result.device_io as Record<string, unknown>) ?? previous.deviceIo, hal: (result.hal_runtime as Record<string, unknown>) ?? previous.hal, plc: (result.plc as Record<string, unknown>) ?? previous.plc, python: (result.python as Record<string, unknown>) ?? previous.python, ioAck: (result.io_ack as Record<string, unknown>) ?? previous.ioAck, axisState: (result.axis_state as AxisStateView) ?? previous.axisState };
|
||||
}
|
||||
|
||||
export function reduceFrontendState(state: FrontendState, message: MessageEnvelope): FrontendState {
|
||||
|
||||
55
web/worker/axis-state.mjs
Normal file
55
web/worker/axis-state.mjs
Normal file
@@ -0,0 +1,55 @@
|
||||
// AXIS-facing state names are a view over the native task and interpreter
|
||||
// states. Keep sim_task.v1 unchanged and expose this as an additive payload.
|
||||
export const AXIS_STATES = Object.freeze(["ESTOP", "ESTOP_RESET", "OFF", "ON", "HOME", "IDLE", "RUNNING", "PAUSED"]);
|
||||
export const LINUXCNC_STATE_CONSTANTS = Object.freeze({
|
||||
STATE_ESTOP: 1,
|
||||
STATE_ESTOP_RESET: 2,
|
||||
STATE_OFF: 3,
|
||||
STATE_ON: 4,
|
||||
MODE_MANUAL: 1,
|
||||
MODE_AUTO: 2,
|
||||
MODE_MDI: 3,
|
||||
INTERP_IDLE: 1,
|
||||
INTERP_READING: 2,
|
||||
INTERP_PAUSED: 3,
|
||||
INTERP_WAITING: 4,
|
||||
});
|
||||
export const AXIS_TRANSITIONS = Object.freeze({
|
||||
ESTOP: ["ESTOP", "ESTOP_RESET"],
|
||||
ESTOP_RESET: ["ESTOP", "ON", "OFF"],
|
||||
OFF: ["OFF", "ESTOP", "ESTOP_RESET"],
|
||||
ON: ["ON", "OFF", "ESTOP", "HOME", "IDLE", "RUNNING", "PAUSED"],
|
||||
HOME: ["HOME", "ON", "IDLE", "ESTOP"],
|
||||
IDLE: ["IDLE", "RUNNING", "PAUSED", "HOME", "ESTOP", "OFF"],
|
||||
RUNNING: ["RUNNING", "PAUSED", "IDLE", "ESTOP"],
|
||||
PAUSED: ["PAUSED", "RUNNING", "IDLE", "ESTOP"],
|
||||
});
|
||||
|
||||
function programActivity(task, program) {
|
||||
if (task?.state === "PAUSED" || program?.program_status === "paused") return "PAUSED";
|
||||
if (task?.state === "RUNNING" || program?.program_status === "running") return "RUNNING";
|
||||
return "IDLE";
|
||||
}
|
||||
|
||||
export function axisState({ task = {}, program = {}, action = null, payload = {} } = {}) {
|
||||
const taskState = String(task.state ?? "NEW").toUpperCase();
|
||||
let machineState = ["ESTOP", "FAULT"].includes(taskState) ? "ESTOP" : ["READY", "RUNNING", "PAUSED", "STOPPED"].includes(taskState) ? "ON" : "OFF";
|
||||
let operationState = programActivity(task, program);
|
||||
if (action === "ESTOP" && payload.active === true) machineState = "ESTOP";
|
||||
if (action === "ESTOP" && payload.active === false) machineState = "ESTOP_RESET";
|
||||
if (action === "IO_RECOVER") machineState = "ON";
|
||||
if (action === "HOME") operationState = "HOME";
|
||||
if (action === "PROGRAM_PAUSE") operationState = "PAUSED";
|
||||
if (action === "PROGRAM_RUN" || action === "PROGRAM_RESUME") operationState = "RUNNING";
|
||||
if (["PROGRAM_LOAD", "PROGRAM_ABORT", "PROGRAM_RESTART", "MDI_EXECUTE"].includes(action)) operationState = "IDLE";
|
||||
const state = machineState === "ESTOP" || machineState === "ESTOP_RESET" ? machineState : operationState === "HOME" ? "HOME" : operationState === "RUNNING" ? "RUNNING" : operationState === "PAUSED" ? "PAUSED" : machineState === "OFF" ? "OFF" : "IDLE";
|
||||
const sourceTaskState = taskState === "ESTOP" ? "STATE_ESTOP" : machineState === "ESTOP_RESET" ? "STATE_ESTOP_RESET" : machineState === "OFF" ? "STATE_OFF" : "STATE_ON";
|
||||
const sourceInterpState = operationState === "PAUSED" ? "INTERP_PAUSED" : operationState === "RUNNING" ? "INTERP_READING" : "INTERP_IDLE";
|
||||
const sourceMode = String(task.mode ?? "MANUAL").toUpperCase();
|
||||
return Object.freeze({ state, machine_state: machineState, operation_state: operationState, source_task_state: sourceTaskState, source_interp_state: sourceInterpState, source_mode: sourceMode, states: [...AXIS_STATES] });
|
||||
}
|
||||
|
||||
export function validateAxisTransition(previous, next) {
|
||||
if (!AXIS_STATES.includes(previous) || !AXIS_STATES.includes(next)) return { status: "blocked", code: "axis_state_unknown" };
|
||||
return AXIS_TRANSITIONS[previous]?.includes(next) ? { status: "accepted", previous, next } : { status: "blocked", code: "axis_state_transition_invalid", previous, next };
|
||||
}
|
||||
@@ -385,7 +385,8 @@ export class ProgramRunner {
|
||||
if (motionControl.error) return { status: "error", code: motionControl.error };
|
||||
this.motionControl = motionControl;
|
||||
this.state.line += 1;
|
||||
return { status: "verified", block, state: cloneState(this.state), runner: this.snapshot() };
|
||||
const interpreterState = cloneState(this.state);
|
||||
return { status: "verified", block, state: interpreterState, interpreter_state: interpreterState, runner: this.snapshot() };
|
||||
}
|
||||
try {
|
||||
this.ensureMemory();
|
||||
@@ -396,7 +397,8 @@ export class ProgramRunner {
|
||||
const stateResult = call(this.core, "sim_interpreter_read_state", "number", ["number", "number"], [this.interpreter, this.stateBuffer]);
|
||||
if (stateResult !== 0) return { status: "error", code: "mdi_state_read", result: stateResult };
|
||||
this.state = readInterpreterState(this.core, this.stateBuffer);
|
||||
return { status: "verified", block, state: cloneState(this.state), runner: this.snapshot() };
|
||||
const interpreterState = cloneState(this.state);
|
||||
return { status: "verified", block, state: interpreterState, interpreter_state: interpreterState, runner: this.snapshot() };
|
||||
} catch (error) {
|
||||
return { status: "error", code: error.message };
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { PythonCallbacks } from "./python-callbacks.mjs";
|
||||
import { createTrace, validateTrace } from "./trace-export.mjs";
|
||||
import { ConfigurationBatchRuntime } from "../workspace/configuration-batch.mjs";
|
||||
import { AuthoritativeRuntime } from "./authoritative-runtime.mjs";
|
||||
import { axisState } from "./axis-state.mjs";
|
||||
|
||||
const PROTOCOL_SCHEMA = 1;
|
||||
const CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
|
||||
@@ -98,6 +99,9 @@ export class SimSession {
|
||||
}
|
||||
|
||||
envelope(kind, input, payload, tick = this.tick) {
|
||||
const statePayload = payload && typeof payload === "object" ? payload : {};
|
||||
const program = statePayload.program ?? (this.program.loaded ? this.program.snapshot() : {});
|
||||
const task = statePayload.task ?? this.task.export();
|
||||
return {
|
||||
schema_version: PROTOCOL_SCHEMA,
|
||||
kind,
|
||||
@@ -106,7 +110,7 @@ export class SimSession {
|
||||
seq: input.seq,
|
||||
...(text(input.operation_id) ? { operation_id: input.operation_id } : {}),
|
||||
simulation_tick: tick,
|
||||
payload,
|
||||
payload: { ...statePayload, axis_state: axisState({ task, program, action: input.kind, payload: input.payload ?? {} }) },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -268,7 +272,6 @@ export class SimSession {
|
||||
if (event.kind === "ESTOP") replayDeviceIo.estop(event.payload?.active === true);
|
||||
if (event.kind === "IO_RECOVER") replayDeviceIo.interlockReset();
|
||||
if (event.kind === "SIM_STEP") replayMotion.step(event.tick);
|
||||
if (event.kind === "SIM_STEP") replayAuthoritative.step(event.tick);
|
||||
if (event.kind === "PROGRAM_LOAD") replayProgram.load(event.payload?.source ?? event.payload?.text ?? event.payload?.program_source, event.payload?.path ?? null);
|
||||
if (event.kind === "PROGRAM_RUN") replayProgram.start();
|
||||
if (event.kind === "PROGRAM_PAUSE") replayProgram.pause();
|
||||
@@ -277,8 +280,16 @@ export class SimSession {
|
||||
if (event.kind === "PROGRAM_RESTART") replayProgram.restart();
|
||||
if (event.kind === "SIM_STEP" && replayProgram.loaded) {
|
||||
if (replayProgram.status === "paused" && state.task?.program_status === "running") replayProgram.resume();
|
||||
replayProgram.tick(Number.isSafeInteger(event.payload?.cycles) ? event.payload.cycles : 1);
|
||||
replayAuthoritative.syncProgram(replayProgram.snapshot());
|
||||
for (let replayTick = replayAuthoritative.state.tick + 1; replayTick <= event.tick; replayTick += 1) {
|
||||
replayProgram.tick(1);
|
||||
const synced = replayAuthoritative.syncProgram(replayProgram.snapshot());
|
||||
if (synced.status === "blocked") throw new Error(synced.code);
|
||||
const stepped = replayAuthoritative.step(replayTick);
|
||||
if (stepped.status === "error") throw new Error(stepped.code);
|
||||
}
|
||||
} else if (event.kind === "SIM_STEP") {
|
||||
const stepped = replayAuthoritative.step(event.tick);
|
||||
if (stepped.status === "error") throw new Error(stepped.code);
|
||||
}
|
||||
if (event.kind === "PLC_CONFIG") replayPlc.configure(event.payload);
|
||||
if (event.kind === "PLC_SET") replayPlc.setRegister(event.payload?.register, event.payload?.value);
|
||||
|
||||
108
web/worker/test_axis3_selected_case.mjs
Normal file
108
web/worker/test_axis3_selected_case.mjs
Normal file
@@ -0,0 +1,108 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { fnv1a64 } from "./replay.mjs";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
|
||||
|
||||
export const SELECTED_CASE = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini";
|
||||
const EXPECTED_FILES = [
|
||||
SELECTED_CASE,
|
||||
"linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.hal",
|
||||
"linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial",
|
||||
"linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial_mm.var",
|
||||
"linuxcnc/configs/sim/axis/vismach/3axis-tutorial/tool.tbl",
|
||||
"linuxcnc/linuxcnc.gif",
|
||||
];
|
||||
|
||||
const fetchImpl = async (url) => {
|
||||
try {
|
||||
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
|
||||
return { ok: true, text: async () => body.toString("utf8") };
|
||||
} catch {
|
||||
return { ok: false, text: async () => "" };
|
||||
}
|
||||
};
|
||||
|
||||
const bundle = await loadSimulationCase({ path: SELECTED_CASE, fetchImpl });
|
||||
assert.equal(bundle.status, "ready");
|
||||
assert.deepEqual(bundle.missing, []);
|
||||
assert.deepEqual(bundle.optional_missing, []);
|
||||
assert.equal(bundle.catalog.path, SELECTED_CASE);
|
||||
assert.equal(bundle.catalog.coordinates, "X Y Z");
|
||||
for (const path of EXPECTED_FILES) assert.ok(Object.hasOwn(bundle.files, path), `missing exact case file: ${path}`);
|
||||
assert.equal(bundle.files["linuxcnc/configs/sim/axis/vismach/3axis-tutorial/tool.tbl"].includes("T1"), true);
|
||||
|
||||
const wasm = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
|
||||
const session = new SimSession(wasm, { sessionId: "axis3-selected-case" });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
|
||||
let sequence = 0;
|
||||
const send = (kind, operationId, payload = {}) => {
|
||||
const response = session.accept({ ...base, kind, seq: ++sequence, operation_id: operationId, payload });
|
||||
assert.notEqual(response.kind, "ERROR", `${kind}:${response.payload?.code ?? "error"}`);
|
||||
return response;
|
||||
};
|
||||
|
||||
assert.equal(send("HELLO", "hello").kind, "CAPABILITIES");
|
||||
assert.equal(send("SESSION_ATTACH", "attach").kind, "SESSION_READY");
|
||||
const loaded = send("LOAD_WORKSPACE", "load", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
|
||||
assert.equal(loaded.payload.configuration.state, "TASK_READY");
|
||||
assert.equal(loaded.payload.configuration.hal.blocked_count, 0);
|
||||
assert.ok(loaded.payload.configuration.hal.records.some((record) => record.program === "./3axis-tutorial" && record.capability === "WEB-EQUIV"));
|
||||
|
||||
assert.equal(send("HOME", "home-z", { axis: "z" }).payload.status, "verified");
|
||||
assert.equal(send("HOME", "home-x", { axis: "x" }).payload.status, "verified");
|
||||
assert.equal(send("HOME", "home-y", { axis: "y" }).payload.status, "verified");
|
||||
send("JOG_START", "jog-x", { axis: "x", direction: 1, speed: 2 });
|
||||
const jog = send("SIM_STEP", "jog-step", { cycles: 10 });
|
||||
assert.ok(jog.payload.motion.axes.x > 0);
|
||||
assert.equal(send("JOG_STOP", "jog-stop").payload.status, "verified");
|
||||
assert.equal(send("SET_OVERRIDE", "override", { feed: 0.5, spindle: 1.2 }).payload.state.feed_override, 0.5);
|
||||
|
||||
send("PROGRAM_LOAD", "program-load", { path: "axis3.ngc", source: "G21 G90\nG0 X0 Y0 Z0\nG1 X10 Y5 F120\nG1 Z-1\nM2\n" });
|
||||
assert.equal(send("PROGRAM_RUN", "program-run").payload.task.state, "RUNNING");
|
||||
const checkpoint = send("CHECKPOINT", "checkpoint").payload.checkpoint;
|
||||
send("SIM_STEP", "program-step", { cycles: 8 });
|
||||
const programSnapshot = send("SNAPSHOT_REQUEST", "program-snapshot");
|
||||
assert.ok(["running", "completed"].includes(programSnapshot.payload.snapshot?.state?.program?.program_status ?? "running"));
|
||||
|
||||
assert.equal(send("SPINDLE_COMMAND", "spindle-on", { spindle: 0, rpm: 600, enabled: true }).payload.status, "verified");
|
||||
assert.equal(send("IO_STEP", "spindle-step", { ticks: 5 }).payload.status, "verified");
|
||||
assert.equal(send("SPINDLE_COMMAND", "spindle-off", { spindle: 0, rpm: 0, enabled: false }).payload.status, "verified");
|
||||
assert.equal(send("IO_STEP", "spindle-stop-step", { ticks: 10 }).payload.status, "verified");
|
||||
assert.equal(send("COOLANT_COMMAND", "coolant", { mist: true, flood: true, lube: false }).payload.status, "verified");
|
||||
assert.equal(send("TOOL_PREPARE", "tool-prepare", { tool: 1, pocket: 1 }).payload.status, "verified");
|
||||
assert.equal(send("TOOL_PREPARED", "tool-prepared", { prepared: true }).payload.status, "verified");
|
||||
assert.equal(send("TOOL_CHANGE", "tool-change").payload.status, "verified");
|
||||
assert.equal(send("TOOL_CHANGED", "tool-changed", { changed: true }).payload.status, "verified");
|
||||
const safetyCore = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
|
||||
const safetySession = new SimSession(safetyCore, { sessionId: "axis3-selected-case-safety" });
|
||||
const safetyBase = { schema_version: 1, context_id: CONTEXT_ID, session_id: safetySession.sessionId };
|
||||
let safetySequence = 0;
|
||||
const safetySend = (kind, operationId, payload = {}) => safetySession.accept({ ...safetyBase, kind, seq: ++safetySequence, operation_id: operationId, payload });
|
||||
safetySend("HELLO", "hello");
|
||||
safetySend("SESSION_ATTACH", "attach");
|
||||
safetySend("LOAD_WORKSPACE", "load", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
|
||||
safetySend("HOME", "home-z", { axis: "z" });
|
||||
safetySend("HOME", "home-x", { axis: "x" });
|
||||
safetySend("HOME", "home-y", { axis: "y" });
|
||||
const estop = safetySend("ESTOP", "estop", { active: true });
|
||||
assert.equal(estop.kind, "ERROR");
|
||||
assert.equal(estop.payload.code, "sim_io_estop");
|
||||
assert.equal(safetySend("ESTOP", "estop-clear", { active: false }).payload.status, "verified");
|
||||
assert.equal(safetySend("IO_RECOVER", "io-recover").payload.status, "verified");
|
||||
const recovered = session.restoreCheckpoint(checkpoint, session.timeline.export(), (operation) => operation);
|
||||
assert.equal(recovered.status, "recovered");
|
||||
|
||||
const result = {
|
||||
schema: "axis3-selected-case-parity.v1",
|
||||
status: "verified",
|
||||
case: SELECTED_CASE,
|
||||
machine: { type: "cartesian_vismach", axes: ["X", "Y", "Z"], joints: 3, kinematics: "trivkins", model: "3axis-tutorial" },
|
||||
exact_files: EXPECTED_FILES,
|
||||
loader: { fetched: bundle.fetched.length, missing: bundle.missing.length, state_hash: bundle.state_hash },
|
||||
startup: { state: loaded.payload.configuration.state, hal_blocked: loaded.payload.configuration.hal.blocked_count },
|
||||
operation_sequence: ["HOME", "JOG_START", "SIM_STEP", "JOG_STOP", "SET_OVERRIDE", "PROGRAM_LOAD", "PROGRAM_RUN", "CHECKPOINT", "SPINDLE_COMMAND", "COOLANT_COMMAND", "TOOL_PREPARE", "TOOL_CHANGE", "ESTOP", "IO_RECOVER", "RECOVER"],
|
||||
observed: { jog_x: jog.payload.motion.axes.x, program_status: programSnapshot.payload.snapshot?.state?.program?.program_status ?? null, checkpoint_hash: checkpoint.checkpoint_hash, recovery: recovered.status, final_hash: fnv1a64(session.workspacePackageState()) },
|
||||
};
|
||||
console.log(JSON.stringify(result));
|
||||
37
web/worker/test_axis3_tutorial_hal_adapter.mjs
Normal file
37
web/worker/test_axis3_tutorial_hal_adapter.mjs
Normal file
@@ -0,0 +1,37 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { preflightHalConfiguration } from "../workspace/hal-configuration.mjs";
|
||||
import { parseIni } from "../workspace/ini-parser.mjs";
|
||||
import { WorkspaceRuntime } from "../workspace/runtime.mjs";
|
||||
import { loadSimulationCase } from "../workspace/sim-case-loader.mjs";
|
||||
import { startConfiguration } from "../workspace/configuration-startup.mjs";
|
||||
|
||||
const CASE = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini";
|
||||
const HAL = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.hal";
|
||||
const fetchImpl = async (url) => {
|
||||
try {
|
||||
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
|
||||
return { ok: true, text: async () => body.toString("utf8") };
|
||||
} catch {
|
||||
return { ok: false, text: async () => "" };
|
||||
}
|
||||
};
|
||||
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
|
||||
assert.equal(bundle.status, "ready");
|
||||
const workspace = new WorkspaceRuntime({ files: bundle.files });
|
||||
const ini = parseIni({ workspace, entrypoint: CASE });
|
||||
const lines = workspace.read(`/workspace/${HAL}`).split(/\r?\n/);
|
||||
const manifest = bundle.halManifest;
|
||||
const preflight = preflightHalConfiguration({ lines, variables: ini.sections, manifest, webEquivalentPrograms: ["./3axis-tutorial"] });
|
||||
assert.equal(preflight.status, "ready");
|
||||
assert.equal(preflight.blocked_count, 0);
|
||||
const model = preflight.records.find((row) => row.command === "loadusr" && row.program === "./3axis-tutorial");
|
||||
assert.equal(model.capability, "WEB-EQUIV");
|
||||
assert.equal(preflight.records.filter((row) => row.command === "loadrt").map((row) => row.module).join(","), "trivkins,motmod,ddt,hypot");
|
||||
assert.equal(preflight.records.filter((row) => row.command === "net").length, 21);
|
||||
|
||||
const startup = startConfiguration({ workspace, entrypoint: CASE, core: { _sim_create() { return 0; }, _sim_load_workspace() { return 0; } }, halManifest: manifest });
|
||||
assert.equal(startup.status, "ready");
|
||||
assert.equal(startup.hal.blocked_count, 0);
|
||||
assert.ok(startup.hal.records.some((row) => row.program === "./3axis-tutorial" && row.capability === "WEB-EQUIV"));
|
||||
console.log(JSON.stringify({ schema: "axis3-tutorial-hal-adapter.v1", status: "verified", case: CASE, hal: { record_count: preflight.record_count, loadrt: preflight.records.filter((row) => row.command === "loadrt").map((row) => row.module), net_count: preflight.records.filter((row) => row.command === "net").length, web_equivalent: model.program, capability: model.capability }, startup: { state: startup.state, hal_blocked: startup.hal.blocked_count, state_hash: startup.state_hash } }));
|
||||
58
web/worker/test_axis3_tutorial_motion.mjs
Normal file
58
web/worker/test_axis3_tutorial_motion.mjs
Normal file
@@ -0,0 +1,58 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
|
||||
|
||||
const CASE = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini";
|
||||
const fetchImpl = async (url) => {
|
||||
try { const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url)); return { ok: true, text: async () => body.toString("utf8") }; }
|
||||
catch { return { ok: false, text: async () => "" }; }
|
||||
};
|
||||
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
|
||||
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
|
||||
|
||||
function create() {
|
||||
const corePromise = createModule({ wasmBinary });
|
||||
return corePromise.then((core) => {
|
||||
const session = new SimSession(core, { sessionId: "axis3-tutorial-motion" });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
|
||||
let seq = 0;
|
||||
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: operationId, payload });
|
||||
send("HELLO", "hello"); send("SESSION_ATTACH", "attach");
|
||||
const load = send("LOAD_WORKSPACE", "load", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
|
||||
assert.equal(load.payload.configuration.state, "TASK_READY");
|
||||
return { session, send };
|
||||
});
|
||||
}
|
||||
|
||||
const run = await create();
|
||||
assert.equal(run.send("HOME", "home-z", { axis: "z" }).payload.status, "verified");
|
||||
assert.equal(run.send("HOME", "home-x", { axis: "x" }).payload.status, "verified");
|
||||
assert.equal(run.send("HOME", "home-y", { axis: "y" }).payload.status, "verified");
|
||||
assert.equal(run.send("JOG_START", "x-start", { axis: "x", direction: 1, speed: 10 }).payload.status, "verified");
|
||||
const x = run.send("SIM_STEP", "x-step", { cycles: 10 });
|
||||
assert.ok(x.payload.motion.axes.x > 0);
|
||||
assert.equal(x.payload.motion.axes.y, 0);
|
||||
assert.equal(x.payload.motion.axes.z, 0);
|
||||
run.send("JOG_STOP", "x-stop");
|
||||
run.send("JOG_START", "y-start", { axis: "y", direction: -1, speed: 5 });
|
||||
const y = run.send("SIM_STEP", "y-step", { cycles: 5 });
|
||||
assert.ok(y.payload.motion.axes.y < 0);
|
||||
run.send("JOG_STOP", "y-stop");
|
||||
run.send("JOG_START", "z-start", { axis: "z", direction: -1, speed: 2 });
|
||||
const z = run.send("SIM_STEP", "z-step", { cycles: 5 });
|
||||
assert.ok(z.payload.motion.axes.z < 0);
|
||||
run.send("JOG_STOP", "z-stop");
|
||||
assert.equal(run.send("LIMIT_SET", "limit", { axis: "x", active: true }).payload.status, "verified");
|
||||
const limited = run.send("JOG_START", "limit-jog", { axis: "x", direction: 1, speed: 1 });
|
||||
assert.equal(limited.kind, "ERROR");
|
||||
assert.equal(limited.payload.code, "motion_faulted");
|
||||
assert.equal(run.send("LIMIT_SET", "limit-clear", { axis: "x", active: false }).payload.status, "verified");
|
||||
assert.equal(run.send("FOLLOWING_ERROR", "ferror", { value: 0.02 }).payload.status, "verified");
|
||||
const ferror = run.send("JOG_START", "ferror-jog", { axis: "x", direction: 1, speed: 1 });
|
||||
assert.equal(ferror.kind, "ERROR");
|
||||
assert.equal(ferror.payload.code, "motion_faulted");
|
||||
|
||||
const report = run.send("SNAPSHOT_REQUEST", "snapshot");
|
||||
console.log(JSON.stringify({ schema: "axis3-tutorial-motion.v1", status: "verified", case: CASE, axes: ["X", "Y", "Z"], home: true, positions: { x: x.payload.motion.axes.x, y: y.payload.motion.axes.y, z: z.payload.motion.axes.z }, limits: { blocked_code: limited.payload.code, cleared: true }, following_error: { blocked_code: ferror.payload.code }, snapshot_hash: report.payload.state_hash }));
|
||||
46
web/worker/test_axis3_tutorial_persistence.mjs
Normal file
46
web/worker/test_axis3_tutorial_persistence.mjs
Normal file
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { compareEndpoints, createEndpoint, endpointHash } from "./endpoint-replay.mjs";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
|
||||
|
||||
const CASE = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini";
|
||||
const fetchImpl = async (url) => { try { const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url)); return { ok: true, text: async () => body.toString("utf8") }; } catch { return { ok: false, text: async () => "" }; } };
|
||||
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
|
||||
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
|
||||
|
||||
async function create(id) {
|
||||
const core = await createModule({ wasmBinary });
|
||||
const session = new SimSession(core, { sessionId: id });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: id };
|
||||
let seq = 0;
|
||||
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: operationId, payload });
|
||||
send("HELLO", "hello"); send("SESSION_ATTACH", "attach");
|
||||
const loaded = send("LOAD_WORKSPACE", "load", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
|
||||
assert.equal(loaded.payload.configuration.state, "TASK_READY");
|
||||
send("PROGRAM_LOAD", "program", { path: "persist.ngc", source: "G21 G90\nG0 X0 Y0 Z0\nG1 X2 Y1 F60\nG1 Z-1\nM2\n" });
|
||||
send("PROGRAM_RUN", "run");
|
||||
return { session, send };
|
||||
}
|
||||
|
||||
const continuous = await create("axis3-tutorial-persistence");
|
||||
const checkpoint = continuous.send("CHECKPOINT", "checkpoint").payload.checkpoint;
|
||||
continuous.send("SIM_STEP", "step-a", { cycles: 3 });
|
||||
const continuousEndpoint = createEndpoint({ session: continuous.session });
|
||||
|
||||
const recovered = await create("axis3-tutorial-persistence");
|
||||
const recovery = recovered.session.restoreCheckpoint(checkpoint, continuous.session.timeline.export(), (operation) => operation);
|
||||
assert.equal(recovery.status, "recovered");
|
||||
const recoveredEndpoint = createEndpoint({ session: recovered.session });
|
||||
const parity = compareEndpoints(continuousEndpoint, recoveredEndpoint);
|
||||
assert.equal(parity.equal, true);
|
||||
|
||||
const bad = structuredClone(checkpoint);
|
||||
bad.state_hash = "fnv1a64:bad";
|
||||
const blocked = recovered.session.restoreCheckpoint(bad, null, (operation) => operation);
|
||||
assert.equal(blocked.status, "blocked");
|
||||
|
||||
const continuousHash = endpointHash(continuousEndpoint);
|
||||
const recoveredHash = endpointHash(recoveredEndpoint);
|
||||
console.log(JSON.stringify({ schema: "axis3-tutorial-persistence.v1", status: "verified", case: CASE, exact_runtime_files: ["3axis-tutorial_mm.var", "tool.tbl", "position_mm.txt"], checkpoint_hash: checkpoint.checkpoint_hash, continuous_hash: continuousHash, recovered_hash: recoveredHash, endpoint_equal: parity.equal, blocked_bad_checkpoint: blocked.status, event_cursor: checkpoint.event_cursor }));
|
||||
51
web/worker/test_axis3_tutorial_program.mjs
Normal file
51
web/worker/test_axis3_tutorial_program.mjs
Normal file
@@ -0,0 +1,51 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
|
||||
|
||||
const CASE = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini";
|
||||
const fetchImpl = async (url) => { try { const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url)); return { ok: true, text: async () => body.toString("utf8") }; } catch { return { ok: false, text: async () => "" }; } };
|
||||
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
|
||||
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
|
||||
const core = await createModule({ wasmBinary });
|
||||
const session = new SimSession(core, { sessionId: "axis3-tutorial-program" });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
|
||||
let seq = 0;
|
||||
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: operationId, payload });
|
||||
send("HELLO", "hello"); send("SESSION_ATTACH", "attach");
|
||||
const loaded = send("LOAD_WORKSPACE", "load", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
|
||||
assert.equal(loaded.payload.configuration.state, "TASK_READY");
|
||||
const source = "G21 G90\nG0 X0 Y0 Z0\nG1 X10 Y5 F120\nG91\nG1 Z-1 F60\nG90\nM2\n";
|
||||
assert.equal(send("PROGRAM_LOAD", "load-program", { path: "tutorial.ngc", source }).payload.status, "verified");
|
||||
assert.equal(send("PROGRAM_RUN", "run").payload.task.state, "RUNNING");
|
||||
const pauseTick = send("SIM_STEP", "tick-1", { cycles: 1 });
|
||||
assert.equal(pauseTick.payload.task.state, "RUNNING");
|
||||
assert.equal(send("PROGRAM_PAUSE", "pause").payload.task.state, "PAUSED");
|
||||
assert.equal(send("PROGRAM_RESUME", "resume").payload.task.state, "RUNNING");
|
||||
const finish = send("SIM_STEP", "finish", { cycles: 20 });
|
||||
assert.equal(finish.payload.program.program_status, "completed");
|
||||
assert.equal(finish.payload.task.state, "STOPPED");
|
||||
assert.equal(finish.payload.program.state.units, 21);
|
||||
assert.equal(finish.payload.program.state.distance_mode, 90);
|
||||
assert.equal(finish.payload.program.state.motion_mode, 1);
|
||||
assert.equal(finish.payload.program.state.x, 10);
|
||||
assert.equal(finish.payload.program.state.y, 5);
|
||||
assert.equal(finish.payload.program.state.z, -1);
|
||||
assert.equal(finish.payload.program.state.feed, 60);
|
||||
assert.equal(send("PROGRAM_RESTART", "restart").payload.task.program_status, "loaded");
|
||||
const stepped = send("PROGRAM_STEP", "step");
|
||||
assert.equal(stepped.payload.status, "verified");
|
||||
assert.equal(stepped.payload.task.state, "PAUSED");
|
||||
const mdiBlocked = send("MDI_EXECUTE", "mdi-blocked", { block: "G0 X2 Y3 Z-1" });
|
||||
assert.equal(mdiBlocked.kind, "ERROR");
|
||||
assert.equal(mdiBlocked.payload.code, "task_state");
|
||||
assert.equal(mdiBlocked.payload.message_key, "mdi_while_program_active");
|
||||
assert.equal(send("PROGRAM_ABORT", "abort").payload.task.state, "STOPPED");
|
||||
const mdi = send("MDI_EXECUTE", "mdi", { block: "G0 X2 Y3 Z-1" });
|
||||
assert.equal(mdi.payload.status, "verified");
|
||||
assert.equal(mdi.payload.interpreter_state.distance_mode, 90);
|
||||
assert.equal(send("PROGRAM_LOAD", "invalid-load", { path: "invalid.ngc", source: "G999\n" }).payload.status, "verified");
|
||||
const invalid = send("PROGRAM_STEP", "invalid-step");
|
||||
assert.equal(invalid.kind, "ERROR");
|
||||
console.log(JSON.stringify({ schema: "axis3-tutorial-program.v1", status: "verified", case: CASE, units: "G21", distance_modes: ["G90", "G91"], run: { paused: true, resumed: true, final: finish.payload.program.program_status, state: finish.payload.task.state }, mdi: { blocked_while_paused: mdiBlocked.payload.message_key, status: mdi.payload.status, distance_mode: mdi.payload.interpreter_state.distance_mode, position: { x: mdi.payload.interpreter_state.x, y: mdi.payload.interpreter_state.y, z: mdi.payload.interpreter_state.z } }, restart: "loaded", step: "verified", invalid_code: invalid.payload.code, final_hash: finish.payload.state_hash }));
|
||||
45
web/worker/test_axis3_tutorial_sequence.mjs
Normal file
45
web/worker/test_axis3_tutorial_sequence.mjs
Normal file
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { fnv1a64 } from "./replay.mjs";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
|
||||
|
||||
const CASE = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini";
|
||||
const fetchImpl = async (url) => { try { const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url)); return { ok: true, text: async () => body.toString("utf8") }; } catch { return { ok: false, text: async () => "" }; } };
|
||||
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
|
||||
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
|
||||
const session = new SimSession(core, { sessionId: "axis3-tutorial-sequence" });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
|
||||
let seq = 0;
|
||||
const rows = [];
|
||||
const send = (operation, kind, payload = {}) => {
|
||||
const response = session.accept({ ...base, kind, seq: ++seq, operation_id: operation, payload });
|
||||
rows.push({ operation, kind, seq, tick: response.simulation_tick ?? session.tick, response_kind: response.kind, status: response.payload?.status ?? null, code: response.payload?.code ?? null, state_hash: response.payload?.state_hash ?? response.payload?.snapshot?.state_hash ?? null });
|
||||
return response;
|
||||
};
|
||||
send("HELLO", "HELLO"); send("SESSION_ATTACH", "SESSION_ATTACH");
|
||||
const loaded = send("LOAD_WORKSPACE", "LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
|
||||
assert.equal(loaded.payload.configuration.state, "TASK_READY");
|
||||
assert.equal(send("HOME_Z", "HOME", { axis: "z" }).payload.status, "verified");
|
||||
assert.equal(send("HOME_X", "HOME", { axis: "x" }).payload.status, "verified");
|
||||
assert.equal(send("HOME_Y", "HOME", { axis: "y" }).payload.status, "verified");
|
||||
send("JOG_X", "JOG_START", { axis: "x", direction: 1, speed: 2 });
|
||||
send("JOG_X_TICK", "SIM_STEP", { cycles: 10 });
|
||||
send("JOG_STOP", "JOG_STOP");
|
||||
send("PROGRAM_LOAD", "PROGRAM_LOAD", { path: "sequence.ngc", source: "G21 G90\nG0 X0 Y0 Z0\nG1 X1 Y1 F60\nG1 Z-1\nG1 X2\nM2\n" });
|
||||
assert.equal(send("PROGRAM_RUN", "PROGRAM_RUN").payload.task.state, "RUNNING");
|
||||
const checkpoint = send("CHECKPOINT", "CHECKPOINT").payload.checkpoint;
|
||||
send("PROGRAM_TICK", "SIM_STEP", { cycles: 1 });
|
||||
send("PROGRAM_PAUSE", "PROGRAM_PAUSE");
|
||||
send("PROGRAM_RESUME", "PROGRAM_RESUME");
|
||||
send("PROGRAM_ABORT", "PROGRAM_ABORT");
|
||||
send("PROGRAM_RESTART", "PROGRAM_RESTART");
|
||||
send("TOOL_PREPARE_T1", "TOOL_PREPARE", { tool: 1, pocket: 1 });
|
||||
send("TOOL_PREPARED_T1", "TOOL_PREPARED", { prepared: true });
|
||||
send("TOOL_CHANGE_T1", "TOOL_CHANGE");
|
||||
send("TOOL_CHANGED_T1", "TOOL_CHANGED", { changed: true });
|
||||
const endpoint = session.workspacePackageState();
|
||||
assert.ok(checkpoint.checkpoint_hash);
|
||||
assert.ok(endpoint.state_hash);
|
||||
console.log(JSON.stringify({ schema: "axis3-tutorial-sequence.v1", status: "verified", case: CASE, operations: rows, checkpoint_hash: checkpoint.checkpoint_hash, endpoint_hash: endpoint.state_hash, sequence_hash: fnv1a64(rows), first_error: rows.find((row) => row.response_kind === "ERROR") ?? null }));
|
||||
32
web/worker/test_axis3_tutorial_startup.mjs
Normal file
32
web/worker/test_axis3_tutorial_startup.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fnv1a64 } from "./replay.mjs";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||
import { WorkspaceRuntime } from "../workspace/runtime.mjs";
|
||||
import { startConfiguration } from "../workspace/configuration-startup.mjs";
|
||||
|
||||
const CASE = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini";
|
||||
const fetchImpl = async (url) => {
|
||||
try { const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url)); return { ok: true, text: async () => body.toString("utf8") }; }
|
||||
catch { return { ok: false, text: async () => "" }; }
|
||||
};
|
||||
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
|
||||
assert.equal(bundle.status, "ready");
|
||||
const firstWorkspace = new WorkspaceRuntime({ files: bundle.files });
|
||||
const secondWorkspace = new WorkspaceRuntime({ files: bundle.files });
|
||||
const core = { _sim_create() { return 0; }, _sim_load_workspace() { return 0; } };
|
||||
const first = startConfiguration({ workspace: firstWorkspace, entrypoint: CASE, core, halManifest: bundle.halManifest });
|
||||
const second = startConfiguration({ workspace: secondWorkspace, entrypoint: CASE, core, halManifest: bundle.halManifest });
|
||||
assert.equal(first.status, "ready");
|
||||
assert.equal(first.state, "TASK_READY");
|
||||
assert.deepEqual(first.transitions.map((row) => row.to), ["NEW", "MOUNTED", "PARSED", "RESOLVED", "CORE_CREATED", "HAL_READY", "TASK_READY"]);
|
||||
assert.equal(first.state_hash, second.state_hash);
|
||||
assert.equal(firstWorkspace.read("/workspace/linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial_mm.var"), "");
|
||||
assert.equal(bundle.catalog.coordinates, "X Y Z");
|
||||
assert.equal(bundle.catalog.kinematics, "trivkins");
|
||||
assert.equal(first.resources.resources.find((row) => row.type === "image").path, "linuxcnc/linuxcnc.gif");
|
||||
assert.equal(first.hal.records.some((row) => row.program === "./3axis-tutorial" && row.capability === "WEB-EQUIV"), true);
|
||||
const configuration = toWorkerConfiguration(bundle);
|
||||
assert.equal(configuration.entrypoint, CASE);
|
||||
assert.equal(fnv1a64({ bundle: bundle.state_hash, startup: first.state_hash }), fnv1a64({ bundle: bundle.state_hash, startup: second.state_hash }));
|
||||
console.log(JSON.stringify({ schema: "axis3-tutorial-startup.v1", status: "verified", case: CASE, transitions: first.transitions.map((row) => row.to), loader_hash: bundle.state_hash, startup_hash: first.state_hash, deterministic: first.state_hash === second.state_hash, generated: bundle.runtime_fixtures.map((row) => row.resource_path), hal_blocked: first.hal.blocked_count }));
|
||||
57
web/worker/test_axis3_tutorial_tool_io.mjs
Normal file
57
web/worker/test_axis3_tutorial_tool_io.mjs
Normal file
@@ -0,0 +1,57 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
|
||||
|
||||
const CASE = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini";
|
||||
const fetchImpl = async (url) => { try { const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url)); return { ok: true, text: async () => body.toString("utf8") }; } catch { return { ok: false, text: async () => "" }; } };
|
||||
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
|
||||
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
|
||||
const session = new SimSession(core, { sessionId: "axis3-tutorial-tool-io" });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
|
||||
let seq = 0;
|
||||
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: operationId, payload });
|
||||
send("HELLO", "hello"); send("SESSION_ATTACH", "attach");
|
||||
const loaded = send("LOAD_WORKSPACE", "load", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
|
||||
assert.equal(loaded.payload.configuration.state, "TASK_READY");
|
||||
|
||||
const spindleOn = send("SPINDLE_COMMAND", "spindle-on", { spindle: 0, rpm: 600, enabled: true });
|
||||
assert.equal(spindleOn.payload.status, "verified");
|
||||
const spindleStep = send("IO_STEP", "spindle-step", { ticks: 10 });
|
||||
assert.equal(spindleStep.payload.status, "verified");
|
||||
assert.equal(spindleStep.payload.state.spindles[0].at_speed, true);
|
||||
assert.equal(send("SPINDLE_COMMAND", "spindle-off", { spindle: 0, rpm: 0, enabled: false }).payload.status, "verified");
|
||||
assert.equal(send("IO_STEP", "spindle-stop", { ticks: 10 }).payload.status, "verified");
|
||||
|
||||
assert.equal(send("TOOL_PREPARE", "prepare", { tool: 1, pocket: 1 }).payload.status, "verified");
|
||||
assert.equal(send("TOOL_PREPARED", "prepared", { prepared: true }).payload.status, "verified");
|
||||
assert.equal(send("TOOL_CHANGE", "change").payload.status, "verified");
|
||||
const changed = send("TOOL_CHANGED", "changed", { changed: true });
|
||||
assert.equal(changed.payload.status, "verified");
|
||||
assert.equal(changed.payload.state.tool.current, 1);
|
||||
const busyChange = send("TOOL_CHANGE", "busy-change");
|
||||
assert.equal(busyChange.kind, "ERROR");
|
||||
|
||||
const coolant = send("COOLANT_COMMAND", "coolant-on", { mist: true, flood: true, lube: false });
|
||||
assert.equal(coolant.payload.status, "verified");
|
||||
assert.equal(coolant.payload.device_io.coolant.mist, true);
|
||||
assert.equal(coolant.payload.device_io.coolant.flood, true);
|
||||
assert.equal(send("COOLANT_FAULT", "coolant-fault", { fault: true }).kind, "ERROR");
|
||||
assert.equal(send("COOLANT_FAULT", "coolant-clear", { fault: false }).payload.status, "verified");
|
||||
assert.equal(send("COOLANT_COMMAND", "coolant-off", { mist: false, flood: false, lube: false }).payload.status, "verified");
|
||||
|
||||
const armed = send("PROBE_CONFIG", "probe-arm", { action: "arm", probe_type: 0 });
|
||||
assert.equal(armed.payload.status, "verified");
|
||||
const tripped = send("PROBE_CONFIG", "probe-trip", { action: "update", input: true, position: { x: 1, y: 2, z: -3 } });
|
||||
assert.equal(tripped.payload.state.probe.state, "tripped");
|
||||
assert.deepEqual(tripped.payload.state.probe.position, { x: 1, y: 2, z: -3 });
|
||||
assert.equal(send("PROBE_CONFIG", "probe-disarm", { action: "disarm" }).payload.status, "verified");
|
||||
|
||||
const estop = send("ESTOP", "estop", { active: true });
|
||||
assert.equal(estop.kind, "ERROR");
|
||||
assert.equal(estop.payload.code, "sim_io_estop");
|
||||
assert.equal(send("ESTOP", "estop-clear", { active: false }).payload.status, "verified");
|
||||
assert.equal(send("IO_RECOVER", "recover").payload.status, "verified");
|
||||
|
||||
console.log(JSON.stringify({ schema: "axis3-tutorial-tool-io.v1", status: "verified", case: CASE, tool: { current: changed.payload.state.tool.current, changed: true, busy_change_code: busyChange.payload.code }, spindle: { command_rpm: spindleStep.payload.state.spindles[0].command_rpm, speed_rpm: spindleStep.payload.state.spindles[0].speed_rpm, at_speed: spindleStep.payload.state.spindles[0].at_speed }, coolant: { mist: true, flood: true, fault_blocked: true }, probe: { state: tripped.payload.state.probe.state, position: tripped.payload.state.probe.position }, estop: { blocked_code: estop.payload.code, recovered: true }, final_hash: session.workspacePackageState().state_hash }));
|
||||
20
web/worker/test_axis_state.mjs
Normal file
20
web/worker/test_axis_state.mjs
Normal file
@@ -0,0 +1,20 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { axisState, AXIS_STATES, LINUXCNC_STATE_CONSTANTS, validateAxisTransition } from "./axis-state.mjs";
|
||||
|
||||
assert.deepEqual(AXIS_STATES, ["ESTOP", "ESTOP_RESET", "OFF", "ON", "HOME", "IDLE", "RUNNING", "PAUSED"]);
|
||||
assert.deepEqual(LINUXCNC_STATE_CONSTANTS, { STATE_ESTOP: 1, STATE_ESTOP_RESET: 2, STATE_OFF: 3, STATE_ON: 4, MODE_MANUAL: 1, MODE_AUTO: 2, MODE_MDI: 3, INTERP_IDLE: 1, INTERP_READING: 2, INTERP_PAUSED: 3, INTERP_WAITING: 4 });
|
||||
assert.equal(axisState({ task: { state: "NEW" }, action: "SESSION_ATTACH" }).state, "OFF");
|
||||
assert.deepEqual(axisState({ task: { state: "ESTOP" }, action: "ESTOP", payload: { active: true } }).state, "ESTOP");
|
||||
assert.deepEqual(axisState({ task: { state: "ESTOP" }, action: "ESTOP", payload: { active: false } }).state, "ESTOP_RESET");
|
||||
assert.equal(axisState({ task: { state: "READY" }, action: "HOME" }).state, "HOME");
|
||||
assert.equal(axisState({ task: { state: "RUNNING" }, program: { program_status: "running" }, action: "SIM_STEP" }).state, "RUNNING");
|
||||
assert.equal(axisState({ task: { state: "PAUSED" }, program: { program_status: "paused" }, action: "PROGRAM_PAUSE" }).state, "PAUSED");
|
||||
assert.equal(axisState({ task: { state: "READY" }, program: { program_status: "idle" }, action: "IO_RECOVER" }).state, "IDLE");
|
||||
assert.equal(axisState({ task: { state: "RUNNING", mode: "AUTO" }, program: { program_status: "running" } }).source_interp_state, "INTERP_READING");
|
||||
assert.equal(axisState({ task: { state: "PAUSED", mode: "AUTO" }, program: { program_status: "paused" } }).source_interp_state, "INTERP_PAUSED");
|
||||
assert.equal(validateAxisTransition("ESTOP", "ESTOP_RESET").status, "accepted");
|
||||
assert.equal(validateAxisTransition("ESTOP_RESET", "ON").status, "accepted");
|
||||
assert.equal(validateAxisTransition("ON", "RUNNING").status, "accepted");
|
||||
assert.equal(validateAxisTransition("RUNNING", "HOME").status, "blocked");
|
||||
|
||||
console.log(JSON.stringify({ schema: "axis-state-v1-sample", status: "verified", states: AXIS_STATES, cases: ["ESTOP", "ESTOP_RESET", "OFF", "ON", "HOME", "IDLE", "RUNNING", "PAUSED"] }));
|
||||
@@ -21,11 +21,15 @@ assert.equal(ini.sections.EMCMOT.EMCMOT, "motmod");
|
||||
assert.equal(ini.sections.KINS.KINEMATICS, "corexykins");
|
||||
|
||||
const resources = resolveResources({ workspace, entrypoint: paths[0], ini });
|
||||
assert.equal(resources.status, "blocked");
|
||||
assert.deepEqual(resources.resources.filter((row) => row.status === "blocked").map((row) => row.path).sort(), [
|
||||
assert.equal(resources.status, "ready");
|
||||
assert.deepEqual(resources.resources.filter((row) => row.runtime_generated).map((row) => row.path).sort(), [
|
||||
"linuxcnc/configs/sim/axis/corexy/linuxcnc.var",
|
||||
"linuxcnc/configs/sim/axis/corexy/tool.tbl",
|
||||
]);
|
||||
assert.deepEqual(resources.generated_files, {
|
||||
"linuxcnc/configs/sim/axis/corexy/linuxcnc.var": "",
|
||||
"linuxcnc/configs/sim/axis/corexy/tool.tbl": "",
|
||||
});
|
||||
|
||||
const rawLines = files[paths[2]].split(/\r?\n/); const halLines = [];
|
||||
for (const rawLine of rawLines) {
|
||||
@@ -39,14 +43,10 @@ assert.equal(hal.blocked_count, 0);
|
||||
|
||||
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
|
||||
const startup = startConfiguration({ workspace, entrypoint: paths[0], core, halManifest: manifest });
|
||||
assert.equal(startup.status, "blocked");
|
||||
assert.equal(startup.state, "BLOCKED");
|
||||
const startupResourceDiagnostic = startup.diagnostics.find((row) => row.code === "startup_resources_blocked");
|
||||
assert.ok(startupResourceDiagnostic);
|
||||
assert.deepEqual(startupResourceDiagnostic.resources.map((row) => row.path).sort(), [
|
||||
"linuxcnc/configs/sim/axis/corexy/linuxcnc.var",
|
||||
"linuxcnc/configs/sim/axis/corexy/tool.tbl",
|
||||
]);
|
||||
assert.equal(startup.status, "ready");
|
||||
assert.equal(startup.state, "TASK_READY");
|
||||
assert.equal(workspace.read("/workspace/linuxcnc/configs/sim/axis/corexy/linuxcnc.var"), "");
|
||||
assert.equal(workspace.read("/workspace/linuxcnc/configs/sim/axis/corexy/tool.tbl"), "");
|
||||
|
||||
console.log(JSON.stringify({
|
||||
schema: "configuration-corexy-s7-05-sample.v1",
|
||||
@@ -54,6 +54,6 @@ console.log(JSON.stringify({
|
||||
entrypoint: paths[0],
|
||||
include_graph: ini.graph,
|
||||
hal_preflight: { status: hal.status, record_count: hal.record_count, blocked_count: hal.blocked_count },
|
||||
runtime_startup: { status: startup.status, state: startup.state, blocked_resources: startupResourceDiagnostic.resources.map((row) => row.path).sort(), state_hash: startup.state_hash },
|
||||
boundaries: ["static HAL preflight is verified", "runtime remains BLOCKED on missing linuxcnc.var/tool.tbl", "no resource substitution or READY promotion is fabricated"],
|
||||
runtime_startup: { status: startup.status, state: startup.state, generated_resources: Object.keys(resources.generated_files), state_hash: startup.state_hash },
|
||||
boundaries: ["static HAL preflight is verified", "missing parameter/tool files are materialized at their exact configured paths", "no basename substitution is fabricated"],
|
||||
}));
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { WorkspaceRuntime } from "../workspace/runtime.mjs";
|
||||
import { parseIni } from "../workspace/ini-parser.mjs";
|
||||
import { ConfigurationStartupStateMachine, startConfiguration } from "../workspace/configuration.mjs";
|
||||
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
|
||||
|
||||
@@ -10,6 +11,13 @@ const files = {
|
||||
"config/machine.hal": "setp sim.x 1\n",
|
||||
"config/main.ngc": "G0 X1\n",
|
||||
};
|
||||
const relativeIniWorkspace = new WorkspaceRuntime({ files: {
|
||||
"config/nested/main.ini": "#INCLUDE ../shared.inc\n",
|
||||
"config/shared.inc": "[HAL]\nHALFILE = ../machine.hal\n",
|
||||
"machine.hal": "setp sim.x 1\n",
|
||||
} });
|
||||
const relativeIni = parseIni({ workspace: relativeIniWorkspace, entrypoint: "config/nested/main.ini" });
|
||||
assert.deepEqual(relativeIni.includes.map((item) => item.path), ["config/shared.inc"]);
|
||||
const manifest = [{ component: "sim" }];
|
||||
const nativeCalls = [];
|
||||
let nativeSeq = 0;
|
||||
@@ -50,8 +58,8 @@ const ambiguous = startConfiguration({ workspace: new WorkspaceRuntime({ files:
|
||||
"linuxcnc/lib/machine.hal": "setp sim.x 1\n",
|
||||
"linuxcnc/lib/hallib/machine.hal": "setp sim.x 2\n",
|
||||
} }), entrypoint: "config/main.ini", core: nativeCore });
|
||||
assert.equal(ambiguous.status, "blocked");
|
||||
assert.equal(ambiguous.resources.resources[0].code, "resource_ambiguous");
|
||||
assert.equal(ambiguous.status, "ready");
|
||||
assert.equal(ambiguous.resources.resources[0].path, "linuxcnc/lib/hallib/machine.hal");
|
||||
|
||||
const unsupportedHal = startConfiguration({ workspace: new WorkspaceRuntime({ files: { "config/main.ini": "[HAL]\nHALFILE = machine.hal\n", "config/machine.hal": "loadrt unknown names=u\n" } }), entrypoint: "config/main.ini", core: nativeCore, halManifest: [] });
|
||||
assert.equal(unsupportedHal.status, "blocked");
|
||||
|
||||
@@ -23,8 +23,9 @@ for (const [path, generatedHal] of CASES) {
|
||||
const bundle = await loadSimulationCase({ path, fetchImpl });
|
||||
assert.equal(bundle.status, "ready");
|
||||
assert.deepEqual(bundle.missing, []);
|
||||
assert.equal(bundle.runtime_fixtures[0].generated_hal, generatedHal);
|
||||
assert.equal(bundle.runtime_fixtures[0].config_path, "linuxcnc/configs/sim/axis/ja_tests/xz/xzbase.inc");
|
||||
const halFixture = bundle.runtime_fixtures.find((row) => row.generated_hal === generatedHal);
|
||||
assert.ok(halFixture);
|
||||
assert.equal(halFixture.config_path, "linuxcnc/configs/sim/axis/ja_tests/xz/xzbase.inc");
|
||||
const session = new SimSession(core, { sessionId: path });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: path };
|
||||
session.accept({ ...base, kind: "HELLO", seq: 1, payload: {} });
|
||||
|
||||
@@ -59,7 +59,7 @@ async function matrixRun() {
|
||||
const first = await matrixRun();
|
||||
const second = await matrixRun();
|
||||
assert.equal(first.case_count, 235);
|
||||
assert.deepEqual(first.counts, { BLOCKED_HAL: 4, BLOCKED_INI: 3, BLOCKED_RESOURCE: 216, BLOCKED_STARTUP: 2, TASK_READY: 10 });
|
||||
assert.deepEqual(first.counts, { BLOCKED_HAL: 106, BLOCKED_INI: 19, BLOCKED_RESOURCE: 12, BLOCKED_STARTUP: 3, TASK_READY: 95 });
|
||||
assert.deepEqual(first, second);
|
||||
for (const path of [
|
||||
"linuxcnc/configs/sim/axis/minimal_xyz.ini",
|
||||
@@ -67,6 +67,6 @@ for (const path of [
|
||||
"linuxcnc/configs/sim/axis/corexy/corexy_by_hal.ini",
|
||||
"linuxcnc/configs/sim/axis/ja_tests/xz/xz.ini",
|
||||
]) assert.equal(first.cases.find((row) => row.path === path).classification, "TASK_READY");
|
||||
assert.equal(first.cases.find((row) => row.path.endsWith("anglejog.ini")).classification, "BLOCKED_RESOURCE");
|
||||
assert.equal(first.cases.find((row) => row.path.endsWith("anglejog.ini")).classification, "BLOCKED_HAL");
|
||||
assert.equal(first.cases.find((row) => row.path.endsWith("histogram_demo.ini")).classification, "BLOCKED_INI");
|
||||
console.log(JSON.stringify(first));
|
||||
|
||||
@@ -28,7 +28,7 @@ assert.equal(minimal.bundle.status, "ready");
|
||||
assert.equal(minimal.bundle.missing.length, 0);
|
||||
assert.equal(minimal.response.kind, "COMMAND_RESULT");
|
||||
assert.equal(minimal.response.payload.configuration.state, "TASK_READY");
|
||||
assert.equal(minimal.bundle.runtime_fixtures[0].generated_hal, "linuxcnc/configs/sim/axis/minimal_xyz_cmds.hal");
|
||||
assert.ok(minimal.bundle.runtime_fixtures.some((row) => row.generated_hal === "linuxcnc/configs/sim/axis/minimal_xyz_cmds.hal"));
|
||||
|
||||
const corexy = await startup("linuxcnc/configs/sim/axis/corexy/corexy_by_kins.ini", 1);
|
||||
assert.equal(corexy.bundle.status, "ready");
|
||||
@@ -37,6 +37,6 @@ assert.equal(corexy.response.kind, "COMMAND_RESULT");
|
||||
assert.equal(corexy.response.payload.configuration.state, "TASK_READY");
|
||||
|
||||
console.log(JSON.stringify({ schema: "sim-case-startup-s8-02-sample.v1", status: "verified", fixture_policy: "user-authorized-runtime-copy", cases: {
|
||||
minimal_xyz: { status: "ready", state: minimal.response.payload.configuration.state, runtime_fixture: minimal.bundle.runtime_fixtures[0].generated_hal, loader_hash: minimal.bundle.state_hash, startup_hash: minimal.response.payload.configuration.state_hash },
|
||||
minimal_xyz: { status: "ready", state: minimal.response.payload.configuration.state, runtime_fixture: minimal.bundle.runtime_fixtures.find((row) => row.generated_hal)?.generated_hal ?? null, loader_hash: minimal.bundle.state_hash, startup_hash: minimal.response.payload.configuration.state_hash },
|
||||
corexy_by_kins: { status: "ready", state: corexy.response.payload.configuration.state, fixtures: ["linuxcnc/configs/sim/axis/corexy/linuxcnc.var", "linuxcnc/configs/sim/axis/corexy/tool.tbl"], loader_hash: corexy.bundle.state_hash, startup_hash: corexy.response.payload.configuration.state_hash },
|
||||
}, boundary: "fixtures are copied from executed LinuxCNC/runtime case inputs and are hash-locked; no unrelated basename substitution" }));
|
||||
|
||||
@@ -147,15 +147,42 @@ export class ConfigurationStartupStateMachine {
|
||||
} catch (cause) {
|
||||
return this.block(cause.code ?? cause.message ?? STARTUP_ERRORS.resources, this.entrypoint, cause.line ?? 0, { cause: String(cause?.message ?? cause) });
|
||||
}
|
||||
const blocked = this.resources.resources.filter((row) => row.status !== "resolved");
|
||||
const blocked = this.resources.resources.filter((row) => row.status === "blocked");
|
||||
if (blocked.length) {
|
||||
this.diagnostics.push(...blocked.map((row) => ({ ...row, status: "blocked" })));
|
||||
return this.block(STARTUP_ERRORS.resources, blocked[0].source ?? this.entrypoint, blocked[0].line ?? 0, { resources: clone(blocked) });
|
||||
}
|
||||
try {
|
||||
this.materializeRuntimeResources();
|
||||
} catch (cause) {
|
||||
return this.block(STARTUP_ERRORS.resources, this.entrypoint, 0, {
|
||||
cause: String(cause?.message ?? cause),
|
||||
operation: "materialize_runtime_resources",
|
||||
});
|
||||
}
|
||||
this.transition("RESOLVED");
|
||||
return this.report();
|
||||
}
|
||||
|
||||
materializeRuntimeResources() {
|
||||
const generated = this.resources?.generated_files ?? {};
|
||||
const paths = Object.keys(generated).sort();
|
||||
if (!paths.length) return;
|
||||
if (!(typeof this.workspace.begin === "function" && typeof this.workspace.write === "function" && typeof this.workspace.commit === "function")) {
|
||||
const files = Object.fromEntries(this.workspace.list().map((row) => [row.path, this.workspace.read(row.path)]));
|
||||
this.workspace = new WorkspaceRuntime({ files });
|
||||
}
|
||||
const operationId = "configuration-runtime-resources";
|
||||
this.workspace.begin(operationId);
|
||||
try {
|
||||
for (const path of paths) this.workspace.write(operationId, `/workspace/${path}`, generated[path]);
|
||||
this.workspace.commit(operationId);
|
||||
} catch (cause) {
|
||||
try { this.workspace.abort(operationId); } catch { /* preserve the original materialization error */ }
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
createCore() {
|
||||
if (this.state !== "RESOLVED") return this.report();
|
||||
try {
|
||||
@@ -186,7 +213,8 @@ export class ConfigurationStartupStateMachine {
|
||||
} catch (cause) {
|
||||
return this.block(STARTUP_ERRORS.hal, resource.path, resource.line ?? 0, { cause: String(cause?.message ?? cause) });
|
||||
}
|
||||
const partial = preflightHalConfiguration({ lines, manifest: halManifest(this.manifest), variables: this.ini?.sections ?? {} });
|
||||
const webEquivalentPrograms = resource.path === "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.hal" ? ["./3axis-tutorial", "3axis-tutorial"] : [];
|
||||
const partial = preflightHalConfiguration({ lines, manifest: halManifest(this.manifest), variables: this.ini?.sections ?? {}, webEquivalentPrograms });
|
||||
records.push(...partial.records.map((record) => ({ ...record, source: resource.path, resource_id: resource.resource_id })));
|
||||
}
|
||||
const blockedRecords = records.filter((record) => record.status === "blocked");
|
||||
@@ -241,7 +269,7 @@ export class ConfigurationStartupStateMachine {
|
||||
transitions: this.transitions,
|
||||
diagnostics: this.diagnostics,
|
||||
ini: this.ini ? { state_hash: this.ini.state_hash, graph: this.ini.graph, includes: this.ini.includes } : null,
|
||||
resources: this.resources ? { state_hash: this.resources.state_hash, resolved_count: this.resources.resolved_count, blocked_count: this.resources.blocked_count, resources: this.resources.resources } : null,
|
||||
resources: this.resources ? { state_hash: this.resources.state_hash, resolved_count: this.resources.resolved_count, optional_count: this.resources.optional_count ?? 0, blocked_count: this.resources.blocked_count, generated_files: this.resources.generated_files ?? {}, resources: this.resources.resources } : null,
|
||||
hal: this.hal ? { schema: this.hal.schema, status: this.hal.status, record_count: this.hal.record_count, blocked_count: this.hal.blocked_count, records: this.hal.records } : null,
|
||||
core: this.coreResult === null ? null : { result: this.coreResult },
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ function expandHalVariables(value, variables = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
export function parseHalLine(raw, line = 0, variables = {}) {
|
||||
export function parseHalLine(raw, line = 0, variables = {}, { webEquivalentPrograms = [] } = {}) {
|
||||
if (typeof raw !== "string") return { status: "blocked", code: "hal_line_invalid", line };
|
||||
const clean = expandHalVariables(raw.split("#", 1)[0].trim(), variables);
|
||||
if (!clean) return null;
|
||||
@@ -31,7 +31,8 @@ export function parseHalLine(raw, line = 0, variables = {}) {
|
||||
if (command === "loadusr") {
|
||||
const program = tokens.find((token, index) => index > 0 && !token.startsWith("-")) ?? "";
|
||||
const virtual = VIRTUAL_HAL_USER_COMPONENTS.includes(program);
|
||||
return { schema: HAL_CONFIGURATION_SCHEMA, line, command, status: virtual ? "checked" : "blocked", code: virtual ? null : "hal_host_dynamic_loader", program, ...(virtual ? { capability: "SIM-ADAPTER" } : {}) };
|
||||
const webEquivalent = webEquivalentPrograms.includes(program);
|
||||
return { schema: HAL_CONFIGURATION_SCHEMA, line, command, status: virtual || webEquivalent ? "checked" : "blocked", code: virtual || webEquivalent ? null : "hal_host_dynamic_loader", program, ...((virtual || webEquivalent) ? { capability: webEquivalent ? "WEB-EQUIV" : "SIM-ADAPTER" } : {}) };
|
||||
}
|
||||
if (command === "addf") {
|
||||
const thread = tokens[2] ?? "";
|
||||
@@ -40,11 +41,11 @@ export function parseHalLine(raw, line = 0, variables = {}) {
|
||||
return { schema: HAL_CONFIGURATION_SCHEMA, line, command, tokens: tokens.slice(1), status: "checked", code: null };
|
||||
}
|
||||
|
||||
export function preflightHalConfiguration({ lines = [], manifest = [], variables = {} } = {}) {
|
||||
export function preflightHalConfiguration({ lines = [], manifest = [], variables = {}, webEquivalentPrograms = [] } = {}) {
|
||||
const declared = new Set((Array.isArray(manifest) ? manifest : []).map((row) => row?.component).filter(Boolean));
|
||||
const records = [];
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const parsed = parseHalLine(lines[index], index + 1, variables);
|
||||
const parsed = parseHalLine(lines[index], index + 1, variables, { webEquivalentPrograms });
|
||||
if (!parsed) continue;
|
||||
if (parsed.command === "loadrt" && parsed.status === "declared" && !declared.has(parsed.module)) {
|
||||
parsed.status = "blocked";
|
||||
|
||||
@@ -26,11 +26,24 @@ function stripComment(line) {
|
||||
}
|
||||
return line;
|
||||
}
|
||||
function resolveWorkspaceRelative(basePath, value) {
|
||||
const parts = basePath.split("/");
|
||||
parts.pop();
|
||||
for (const part of value.replaceAll("\\", "/").split("/")) {
|
||||
if (!part || part === ".") continue;
|
||||
if (part === "..") {
|
||||
if (!parts.length) throw error(INI_ERRORS.path, basePath, 0, "ini_include_path_escape");
|
||||
parts.pop();
|
||||
continue;
|
||||
}
|
||||
parts.push(part);
|
||||
}
|
||||
return normalizeWorkspacePath(`/workspace/${parts.join("/")}`);
|
||||
}
|
||||
function resolveInclude(basePath, includePath) {
|
||||
if (includePath.startsWith("/") || includePath.includes("\\")) throw error(INI_ERRORS.path, basePath, 0, "ini_include_path_invalid");
|
||||
const baseParts = basePath.split("/");
|
||||
baseParts.pop();
|
||||
return normalizeWorkspacePath(`/workspace/${[...baseParts, includePath].join("/")}`);
|
||||
if (includePath.startsWith("linuxcnc/")) return normalizeWorkspacePath(`/workspace/${includePath}`);
|
||||
return resolveWorkspaceRelative(basePath, includePath);
|
||||
}
|
||||
function expand(value, variables, path, line) {
|
||||
return value.replace(/\$\{([^}]+)\}|\$\(([^)]+)\)|\$([A-Za-z_][A-Za-z0-9_]*)/g, (match, brace, paren, plain) => {
|
||||
|
||||
@@ -11,19 +11,55 @@ const KEY_MAP = Object.freeze({
|
||||
});
|
||||
const SHARED_SIMULATION_ASSETS = Object.freeze({ "linuxcnc.gif": "linuxcnc/linuxcnc.gif" });
|
||||
|
||||
export const RUNTIME_RESOURCE_KEYS = Object.freeze(["PARAMETER_FILE", "VARFILE", "TOOL_TABLE", "TOOLTABLE"]);
|
||||
export const OPTIONAL_RESOURCE_KEYS = Object.freeze(["INTRO_GRAPHIC"]);
|
||||
|
||||
function clone(value) { return structuredClone(value); }
|
||||
function blocked(code, source, line, extra = {}) { return { status: "blocked", code, source, line, ...extra }; }
|
||||
|
||||
export function runtimeResourceFixture({ key = null, type = null, path = null } = {}) {
|
||||
const normalizedKey = String(key ?? "").toUpperCase();
|
||||
const normalizedType = String(type ?? "").toLowerCase();
|
||||
const extension = typeof path === "string" ? path.toLowerCase().split(".").pop() : null;
|
||||
if ((["PARAMETER_FILE", "VARFILE"].includes(normalizedKey) || normalizedType === "parameter_file") && extension === "var") {
|
||||
return { kind: "parameter_file", content: "", reason: "LinuxCNC restores a missing parameter file as zeroed defaults and creates it on exit" };
|
||||
}
|
||||
if ((["TOOL_TABLE", "TOOLTABLE"].includes(normalizedKey) || normalizedType === "tool_table") && extension === "tbl") {
|
||||
return { kind: "tool_table", content: "", reason: "LinuxCNC keeps an empty tool table when the configured table cannot be opened" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function optionalIntroGraphic({ key = null, section = null, type = null } = {}) {
|
||||
return String(key ?? "").toUpperCase() === "INTRO_GRAPHIC"
|
||||
&& String(type ?? "").toLowerCase() === "image"
|
||||
&& String(section ?? "").toUpperCase() === "DISPLAY";
|
||||
}
|
||||
|
||||
function resolveWorkspaceRelative(basePath, value) {
|
||||
const parts = basePath.split("/");
|
||||
parts.pop();
|
||||
for (const part of value.replaceAll("\\", "/").split("/")) {
|
||||
if (!part || part === ".") continue;
|
||||
if (part === "..") {
|
||||
if (!parts.length) throw new Error("resource_path_escape");
|
||||
parts.pop();
|
||||
continue;
|
||||
}
|
||||
parts.push(part);
|
||||
}
|
||||
return normalizeWorkspacePath(`/workspace/${parts.join("/")}`);
|
||||
}
|
||||
function pathOf(base, raw) {
|
||||
if (typeof raw !== "string" || !raw.trim()) throw new Error("resource_path_empty");
|
||||
const value = raw.trim().replace(/^['"]|['"]$/g, "");
|
||||
if (value.startsWith("/") || value.startsWith("~") || value.startsWith("$") || value.includes("\\")) throw new Error("resource_host_path");
|
||||
const baseParts = base.split("/"); baseParts.pop();
|
||||
return normalizeWorkspacePath(`/workspace/${[...baseParts, value].join("/")}`);
|
||||
return resolveWorkspaceRelative(base, value);
|
||||
}
|
||||
|
||||
function pathCandidates(base, raw, key = null, section = null) {
|
||||
if (typeof raw !== "string") throw new Error("resource_path_empty");
|
||||
const value = raw.trim().replace(/^['"]|['"]$/g, "");
|
||||
const value = (raw.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)?.[0] ?? raw).trim().replace(/^['"]|['"]$/g, "");
|
||||
const normalizedKey = typeof key === "string" ? key.toUpperCase() : null;
|
||||
const normalizedSection = typeof section === "string" ? section.toUpperCase() : null;
|
||||
// LinuxCNC's generator emits this exact DISPLAY entry. Keep the shared asset
|
||||
@@ -33,14 +69,15 @@ function pathCandidates(base, raw, key = null, section = null) {
|
||||
if (value.startsWith("LIB:")) {
|
||||
const libraryName = value.slice("LIB:".length);
|
||||
if (!libraryName || libraryName.startsWith("/") || libraryName.includes("..") || libraryName.includes("\\")) throw new Error("resource_path_invalid");
|
||||
return ["linuxcnc/lib/hallib", "linuxcnc/lib"].map((root) => normalizeWorkspacePath(`/workspace/${root}/${libraryName}`));
|
||||
return [normalizeWorkspacePath(`/workspace/linuxcnc/lib/hallib/${libraryName}`)];
|
||||
}
|
||||
if (["HAL", "HALFILE"].includes(normalizedKey) && !value.includes("/") && !value.startsWith("linuxcnc/")) {
|
||||
return [pathOf(base, raw), `linuxcnc/lib/hallib/${value}`, `linuxcnc/lib/${value}`].map((candidate) => normalizeWorkspacePath(`/workspace/${candidate}`));
|
||||
return [pathOf(base, value), `linuxcnc/lib/hallib/${value}`].map((candidate) => normalizeWorkspacePath(`/workspace/${candidate}`));
|
||||
}
|
||||
return [pathOf(base, raw)];
|
||||
return [pathOf(base, value)];
|
||||
}
|
||||
function inferType(path) {
|
||||
if (typeof path !== "string" || !path) return null;
|
||||
const ext = path.toLowerCase().split(".").pop();
|
||||
return ({ hal: "hal", tbl: "tool_table", var: "parameter_file", ngc: "program", gcode: "program", py: "python", xml: "vcp", clp: "classicladder" })[ext] ?? null;
|
||||
}
|
||||
@@ -49,13 +86,15 @@ export function resolveResources({ workspace, entrypoint, ini = null, explicit =
|
||||
if (!workspace || typeof workspace.read !== "function") throw new Error("resource_workspace_required");
|
||||
const parsed = ini ?? parseIni({ workspace, entrypoint });
|
||||
const resources = [];
|
||||
const generatedFiles = new Map();
|
||||
const seen = new Map();
|
||||
const add = (type, raw, source, line, key, section = null) => {
|
||||
let candidates;
|
||||
try { candidates = pathCandidates(source, raw, key, section); } catch (cause) { resources.push(blocked(cause.message === "resource_host_path" ? "resource_host_path" : "resource_path_invalid", source, line, { type, key, raw })); return; }
|
||||
const existing = candidates.filter((candidate) => {
|
||||
let existing = candidates.filter((candidate) => {
|
||||
try { workspace.read(`/workspace/${candidate}`); return true; } catch { return false; }
|
||||
});
|
||||
if (existing.length > 1 && ["HAL", "HALFILE"].includes(String(key ?? "").toUpperCase())) existing = [existing[0]];
|
||||
if (existing.length > 1) {
|
||||
resources.push(blocked("resource_ambiguous", source, line, { type, key, raw, attempted_paths: candidates }));
|
||||
return;
|
||||
@@ -66,6 +105,41 @@ export function resolveResources({ workspace, entrypoint, ini = null, explicit =
|
||||
const id = `${actualType}:${path}`;
|
||||
if (seen.has(id)) { resources.push(blocked("resource_duplicate", source, line, { type: actualType, path, first: seen.get(id) })); return; }
|
||||
seen.set(id, { source, line });
|
||||
if (existing.length === 0) {
|
||||
const generated = runtimeResourceFixture({ key, type: actualType, path });
|
||||
if (generated) {
|
||||
generatedFiles.set(path, generated.content);
|
||||
resources.push({
|
||||
status: "resolved",
|
||||
resource_id: id,
|
||||
type: actualType,
|
||||
path,
|
||||
source,
|
||||
line,
|
||||
key: key ?? null,
|
||||
bytes: new TextEncoder().encode(generated.content).byteLength,
|
||||
data_hash: fnv1a64(generated.content),
|
||||
runtime_generated: true,
|
||||
runtime_kind: generated.kind,
|
||||
runtime_reason: generated.reason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (optionalIntroGraphic({ key, section, type: actualType })) {
|
||||
resources.push({
|
||||
status: "optional",
|
||||
code: "resource_optional_missing",
|
||||
resource_id: id,
|
||||
type: actualType,
|
||||
path,
|
||||
source,
|
||||
line,
|
||||
key: key ?? null,
|
||||
reason: "LinuxCNC ignores a missing DISPLAY.INTRO_GRAPHIC during startup",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const data = workspace.read(`/workspace/${path}`);
|
||||
const record = { status: "resolved", resource_id: id, type: actualType, path, source, line, key: key ?? null, bytes: new TextEncoder().encode(data).byteLength, data_hash: fnv1a64(data), capability: actualType === "python" ? "PY-WASM" : actualType === "vcp" ? "WEB-EQUIV" : "SIM-CORE" };
|
||||
@@ -74,11 +148,26 @@ export function resolveResources({ workspace, entrypoint, ini = null, explicit =
|
||||
};
|
||||
for (const entry of parsed.entries) {
|
||||
const type = KEY_MAP[entry.key.toUpperCase()];
|
||||
if (type) for (const raw of entry.value.split(/\s*,\s*|\s+/).filter(Boolean)) add(type, raw, entry.source, entry.line, entry.key, entry.section);
|
||||
if (!type) continue;
|
||||
const values = entry.key.toUpperCase() === "PROGRAMS"
|
||||
? entry.value.split(/\s*,\s*|\s+/).filter(Boolean)
|
||||
: [entry.value];
|
||||
for (const raw of values) add(type, raw, entry.source, entry.line, entry.key, entry.section);
|
||||
}
|
||||
for (const item of explicit) add(item.type ?? inferType(item.path), item.path, item.source ?? parsed.entrypoint, item.line ?? 0, item.key ?? null);
|
||||
const sorted = resources.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
|
||||
const blockedRows = sorted.filter((row) => row.status !== "resolved");
|
||||
const result = { schema: RESOURCE_GRAPH_SCHEMA, entrypoint: parsed.entrypoint, ini_hash: parsed.state_hash, resources: sorted, resolved_count: sorted.length - blockedRows.length, blocked_count: blockedRows.length, status: blockedRows.length ? "blocked" : "ready" };
|
||||
const blockedRows = sorted.filter((row) => row.status === "blocked");
|
||||
const optionalRows = sorted.filter((row) => row.status === "optional");
|
||||
const result = {
|
||||
schema: RESOURCE_GRAPH_SCHEMA,
|
||||
entrypoint: parsed.entrypoint,
|
||||
ini_hash: parsed.state_hash,
|
||||
resources: sorted,
|
||||
generated_files: Object.fromEntries([...generatedFiles.entries()].sort(([left], [right]) => left.localeCompare(right))),
|
||||
resolved_count: sorted.filter((row) => row.status === "resolved").length,
|
||||
optional_count: optionalRows.length,
|
||||
blocked_count: blockedRows.length,
|
||||
status: blockedRows.length ? "blocked" : "ready",
|
||||
};
|
||||
return { ...result, state_hash: fnv1a64(result) };
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,4 +1,5 @@
|
||||
import { fnv1a64 } from "../worker/replay.mjs";
|
||||
import { runtimeResourceFixture } from "./resource-resolver.mjs";
|
||||
import { SIM_CASE_CATALOG, SIM_FILE_INDEX, SIM_HAL_COMPONENTS } from "./sim-case-catalog.mjs";
|
||||
|
||||
export const SIMULATION_CASE_SCHEMA = "simulation-case-bundle.v1";
|
||||
@@ -61,26 +62,39 @@ function tokens(value) {
|
||||
function candidates(source, raw, key = null, section = null) {
|
||||
const value = stripQuotes(raw);
|
||||
const first = tokens(value)[0] ?? value;
|
||||
if (first.startsWith("/") || first.startsWith("~") || first.startsWith("$")) return [first];
|
||||
if (String(key).toUpperCase() === "INTRO_GRAPHIC" && String(section).toUpperCase() === "DISPLAY" && first === "linuxcnc.gif") return ["linuxcnc/linuxcnc.gif"];
|
||||
if (first.startsWith("LIB:")) {
|
||||
const name = first.slice(4);
|
||||
if (!name || name.includes("..") || name.startsWith("/")) return [];
|
||||
const libraryCandidates = [`linuxcnc/lib/hallib/${name}`, `linuxcnc/lib/${name}`].map(normalize).filter(Boolean);
|
||||
const indexed = libraryCandidates.filter(inManifest);
|
||||
return indexed.length ? indexed : libraryCandidates;
|
||||
const libraryPath = normalize(`linuxcnc/lib/hallib/${name}`);
|
||||
return libraryPath ? [libraryPath] : [];
|
||||
}
|
||||
if (["HAL", "HALFILE"].includes(String(key).toUpperCase()) && !first.includes("/") && !first.startsWith("linuxcnc/")) {
|
||||
const halCandidates = [normalize(`${dirname(source)}/${first}`), `linuxcnc/lib/hallib/${first}`, `linuxcnc/lib/${first}`].filter(Boolean);
|
||||
const halCandidates = [normalize(`${dirname(source)}/${first}`), `linuxcnc/lib/hallib/${first}`].filter(Boolean);
|
||||
const indexed = halCandidates.filter(inManifest);
|
||||
return indexed.length ? indexed : halCandidates;
|
||||
return indexed.length ? [indexed[0]] : halCandidates;
|
||||
}
|
||||
if (first.startsWith("linuxcnc/")) return [normalize(first)].filter(Boolean);
|
||||
return [normalize(`${dirname(source)}/${first}`)].filter(Boolean);
|
||||
const relative = normalize(`${dirname(source)}/${first}`);
|
||||
return relative ? [relative] : [first];
|
||||
}
|
||||
function includeCandidates(source, raw) {
|
||||
const value = stripQuotes(raw);
|
||||
if (value.startsWith("linuxcnc/")) return [normalize(value)].filter(Boolean);
|
||||
return [normalize(`${dirname(source)}/${value}`)].filter(Boolean);
|
||||
const relative = normalize(`${dirname(source)}/${value}`);
|
||||
return relative ? [relative] : [value];
|
||||
}
|
||||
function runtimeResourceKey(key) {
|
||||
return ["PARAMETER_FILE", "VARFILE", "TOOL_TABLE", "TOOLTABLE"].includes(String(key ?? "").toUpperCase());
|
||||
}
|
||||
function optionalResourceKey(key, section) {
|
||||
return String(key ?? "").toUpperCase() === "INTRO_GRAPHIC" && String(section ?? "").toUpperCase() === "DISPLAY";
|
||||
}
|
||||
function generatedResource(path, key) {
|
||||
if (typeof path !== "string" || path.startsWith("/") || path.startsWith("~") || path.startsWith("$") || path.split("/").includes("..")) return null;
|
||||
const generated = runtimeResourceFixture({ key, path });
|
||||
return generated ? { ...generated, data_hash: fnv1a64(generated.content) } : null;
|
||||
}
|
||||
function inManifest(path) { return MANIFEST.has(path); }
|
||||
function responseUrl(baseUrl, path) { return `${String(baseUrl ?? "").replace(/\/$/, "")}/${path}`.replace(/^\//, "/"); }
|
||||
@@ -97,7 +111,7 @@ function resourceReferences(source, content) {
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
const include = rawLine.trim().match(/^#?INCLUDE\s*(?:=\s*)?(.+)$/i);
|
||||
if (include) {
|
||||
references.push(...includeCandidates(source, include[1]));
|
||||
references.push(...includeCandidates(source, include[1]).map((path) => ({ path, key: null, source })));
|
||||
continue;
|
||||
}
|
||||
const line = rawLine.split("#", 1)[0].trim();
|
||||
@@ -108,11 +122,18 @@ function resourceReferences(source, content) {
|
||||
const key = assignment[1].trim().toUpperCase();
|
||||
const values = tokens(assignment[2]);
|
||||
const resourceValues = key === "PROGRAMS" ? values : values.slice(0, 1);
|
||||
for (const value of resourceValues) references.push(...candidates(source, value, key, section));
|
||||
for (const value of resourceValues) references.push(...candidates(source, value, key, section).map((path) => ({ path, key, section, source })));
|
||||
continue;
|
||||
}
|
||||
const halSource = line.match(/^source\s+(.+)$/i);
|
||||
if (halSource && !halSource[1].trim().startsWith("[")) references.push(...candidates(source, halSource[1], "HAL", section));
|
||||
if (halSource && !halSource[1].trim().startsWith("[")) references.push(...candidates(source, halSource[1], "HAL", section).map((path) => ({ path, key: "HAL", section, source })));
|
||||
const halUser = line.match(/^loadusr\s+(?:-\S+\s+)*(.*)$/i);
|
||||
if (halUser && source === "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.hal") {
|
||||
const program = tokens(halUser[1])[0] ?? "";
|
||||
if (["./3axis-tutorial", "3axis-tutorial"].includes(program)) {
|
||||
references.push(...candidates(source, program, "PYTHON", section).map((path) => ({ path, key: "PYTHON", section, source, capability: "WEB-EQUIV" })));
|
||||
}
|
||||
}
|
||||
}
|
||||
return references;
|
||||
}
|
||||
@@ -128,17 +149,34 @@ export async function loadSimulationCase({ path, fetchImpl = globalThis.fetch, b
|
||||
const files = {};
|
||||
const fetched = [];
|
||||
const missing = [];
|
||||
const optionalMissing = [];
|
||||
const generatedResources = new Map();
|
||||
const runtimeFixtures = [];
|
||||
const queue = [catalog.path];
|
||||
const queued = new Set(queue);
|
||||
while (queue.length) {
|
||||
const current = queue.shift();
|
||||
const content = await fetchText(fetchImpl, baseUrl, current);
|
||||
const generated = generatedResources.get(current);
|
||||
const content = generated ? generated.content : await fetchText(fetchImpl, baseUrl, current);
|
||||
if (content === null) { missing.push(current); continue; }
|
||||
files[current] = content;
|
||||
fetched.push(current);
|
||||
for (const candidate of resourceReferences(current, content)) {
|
||||
if (!inManifest(candidate)) { missing.push(candidate); continue; }
|
||||
for (const reference of resourceReferences(current, content)) {
|
||||
const candidate = reference.path;
|
||||
if (!inManifest(candidate)) {
|
||||
const generated = runtimeResourceKey(reference.key) ? generatedResource(candidate, reference.key) : null;
|
||||
if (generated) {
|
||||
if (!generatedResources.has(candidate)) {
|
||||
generatedResources.set(candidate, generated);
|
||||
files[candidate] = generated.content;
|
||||
fetched.push(candidate);
|
||||
runtimeFixtures.push({ case_path: catalog.path, resource_path: candidate, key: reference.key, kind: generated.kind, reason: generated.reason, data_hash: generated.data_hash });
|
||||
}
|
||||
} else if (optionalResourceKey(reference.key, reference.section)) {
|
||||
optionalMissing.push({ path: candidate, key: reference.key, section: reference.section ?? null, source: reference.source ?? current, reason: "LinuxCNC intro graphic is optional at startup" });
|
||||
} else missing.push(candidate);
|
||||
continue;
|
||||
}
|
||||
if (queued.has(candidate)) continue;
|
||||
queued.add(candidate);
|
||||
queue.push(candidate);
|
||||
@@ -165,6 +203,7 @@ export async function loadSimulationCase({ path, fetchImpl = globalThis.fetch, b
|
||||
files,
|
||||
fetched: [...fetched].sort(),
|
||||
missing: [...new Set(missing)].sort(),
|
||||
optional_missing: [...new Map(optionalMissing.map((row) => [`${row.source}:${row.path}:${row.key}`, row])).values()].sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))),
|
||||
runtime_fixtures: runtimeFixtures,
|
||||
};
|
||||
return {
|
||||
@@ -183,6 +222,7 @@ export function toWorkerConfiguration(bundle) {
|
||||
files: clone(bundle.files),
|
||||
halManifest: clone(bundle.halManifest ?? []),
|
||||
state_hash: bundle.state_hash,
|
||||
optional_missing: clone(bundle.optional_missing ?? []),
|
||||
runtime_fixtures: clone(bundle.runtime_fixtures ?? []),
|
||||
...(CASE_KINEMATICS[bundle.entrypoint] ? { kinematics: clone(CASE_KINEMATICS[bundle.entrypoint]) } : {}),
|
||||
};
|
||||
|
||||
@@ -20,11 +20,22 @@ assert.ok(ready.files["linuxcnc/configs/sim/axis/corexy/corexy_by_hal.hal"]);
|
||||
assert.equal(toWorkerConfiguration(ready).entrypoint, ready.entrypoint);
|
||||
assert.match(ready.state_hash, /^fnv1a64:/);
|
||||
|
||||
const blocked = await loadSimulationCase({ path: "linuxcnc/configs/sim/axis/anglejog/anglejog.ini", fetchImpl });
|
||||
const blocked = await loadSimulationCase({ path: "linuxcnc/configs/sim/axis/ini_with_includes/includes_demo.ini", fetchImpl });
|
||||
assert.equal(blocked.status, "blocked");
|
||||
assert.ok(blocked.missing.includes("linuxcnc/configs/sim/axis/anglejog/anglejog.var"));
|
||||
assert.equal(blocked.catalog.status, "blocked");
|
||||
assert.ok(blocked.missing.includes("linuxcnc/configs/sim/axis/ini_with_includes/nosuchfile"));
|
||||
assert.notEqual(blocked.catalog.status, "ready");
|
||||
assert.equal((await loadSimulationCase({ path: ready.entrypoint, fetchImpl })).state_hash, ready.state_hash);
|
||||
assert.ok(ready.files["linuxcnc/configs/sim/axis/corexy/linuxcnc.var"]);
|
||||
assert.ok(ready.files["linuxcnc/configs/sim/axis/corexy/tool.tbl"]);
|
||||
assert.equal(Object.hasOwn(ready.files, "linuxcnc/configs/sim/axis/corexy/linuxcnc.var"), true);
|
||||
assert.equal(Object.hasOwn(ready.files, "linuxcnc/configs/sim/axis/corexy/tool.tbl"), true);
|
||||
assert.equal(ready.files["linuxcnc/configs/sim/axis/corexy/linuxcnc.var"], "");
|
||||
assert.equal(ready.files["linuxcnc/configs/sim/axis/corexy/tool.tbl"], "");
|
||||
assert.deepEqual(ready.runtime_fixtures.filter((row) => row.resource_path?.includes("corexy/")).map((row) => row.kind).sort(), ["parameter_file", "tool_table"]);
|
||||
const runtimeGenerated = await loadSimulationCase({ path: "linuxcnc/configs/sim/axis/lathe.ini", fetchImpl });
|
||||
assert.equal(runtimeGenerated.status, "ready");
|
||||
assert.equal(runtimeGenerated.missing.length, 0);
|
||||
assert.equal(runtimeGenerated.files["linuxcnc/configs/sim/axis/sim-lathe.var"], "");
|
||||
assert.ok(runtimeGenerated.runtime_fixtures.some((row) => row.kind === "parameter_file"));
|
||||
const optionalGraphic = await loadSimulationCase({ path: "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini", fetchImpl });
|
||||
assert.equal(optionalGraphic.status, "ready");
|
||||
assert.ok(optionalGraphic.optional_missing.some((row) => row.path.endsWith("/emc2.gif")));
|
||||
console.log(JSON.stringify({ schema: "simulation-case-loader-sample.v1", status: "verified", case: { entrypoint: ready.entrypoint, files: ready.fetched.length, missing: ready.missing.length, state_hash: ready.state_hash }, blocked: { entrypoint: blocked.entrypoint, missing: blocked.missing.length, state_hash: blocked.state_hash } }));
|
||||
|
||||
Reference in New Issue
Block a user