Complete 3axis tutorial native and Web/WASM parity tools
This commit is contained in:
@@ -21,6 +21,9 @@ 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";
|
||||
import { WasmMotionQueue } from "./wasm-motion-queue.mjs";
|
||||
import { WasmHomeLimit } from "./wasm-home-limit.mjs";
|
||||
import { axisGate } from "../app/axis-contract.mjs";
|
||||
|
||||
const PROTOCOL_SCHEMA = 1;
|
||||
const CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
|
||||
@@ -40,6 +43,13 @@ const OPERATION_KINDS = new Set([
|
||||
"REMAP_REGISTER", "REMAP_CALL", "VCP_REGISTER", "VCP_UPDATE", "FILTER_EXEC",
|
||||
]);
|
||||
|
||||
const PANEL_GATE_ACTIONS = Object.freeze({
|
||||
PROGRAM_RUN: "run", PROGRAM_STEP: "step", PROGRAM_PAUSE: "pause",
|
||||
PROGRAM_RESUME: "resume", PROGRAM_ABORT: "abort", PROGRAM_RESTART: "restart",
|
||||
JOG_START: "jogStart", JOG_STOP: "jogStop", HOME: "home", SET_OVERRIDE: "override",
|
||||
ESTOP: "estop", IO_RECOVER: "recover",
|
||||
});
|
||||
|
||||
function text(value) {
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
@@ -90,10 +100,14 @@ export class SimSession {
|
||||
this.python = new PythonBridge();
|
||||
this.program = new ProgramRunner(core);
|
||||
this.authoritative = new AuthoritativeRuntime();
|
||||
this.wasmMotion = new WasmMotionQueue(core);
|
||||
this.wasmHomeLimit = new WasmHomeLimit(core);
|
||||
this.motionAuthority = "js";
|
||||
this.task = new SimTask(core, { contextId, sessionId: sessionId || null });
|
||||
this.scheduler = new SimScheduler(core);
|
||||
this.configurationState = null;
|
||||
this.halState = null;
|
||||
this.previewState = null;
|
||||
call(core, "sim_protocol_reset");
|
||||
if (typeof core.ccall === "function") core.ccall("sim_event_reset", null, [], []);
|
||||
}
|
||||
@@ -102,6 +116,7 @@ export class SimSession {
|
||||
const statePayload = payload && typeof payload === "object" ? payload : {};
|
||||
const program = statePayload.program ?? (this.program.loaded ? this.program.snapshot() : {});
|
||||
const task = statePayload.task ?? this.task.export();
|
||||
const panelSnapshot = this.panelSnapshot({ task, program, input, tick });
|
||||
return {
|
||||
schema_version: PROTOCOL_SCHEMA,
|
||||
kind,
|
||||
@@ -110,10 +125,70 @@ export class SimSession {
|
||||
seq: input.seq,
|
||||
...(text(input.operation_id) ? { operation_id: input.operation_id } : {}),
|
||||
simulation_tick: tick,
|
||||
payload: { ...statePayload, axis_state: axisState({ task, program, action: input.kind, payload: input.payload ?? {} }) },
|
||||
payload: {
|
||||
...statePayload,
|
||||
panel_snapshot: panelSnapshot,
|
||||
motion_authority: statePayload.motion_authority ?? this.motionAuthority,
|
||||
task_authority: statePayload.task_authority ?? this.task.authority(),
|
||||
wasm_home_limit: statePayload.wasm_home_limit ?? this.wasmHomeLimit.read(),
|
||||
axis_state: axisState({ task, program, action: input.kind, payload: input.payload ?? {} }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
panelSnapshot({ task = this.task.export(), program = this.program.loaded ? this.program.snapshot() : null, input = {}, tick = this.tick } = {}) {
|
||||
const motion = this.motion.export();
|
||||
const wasmMotion = this.wasmMotion.read();
|
||||
const homeLimit = this.wasmHomeLimit.read();
|
||||
const devices = this.devices.export();
|
||||
const deviceIo = this.deviceIo.export();
|
||||
const halRuntime = this.hal.export();
|
||||
const authoritative = this.authoritative.export();
|
||||
const axis = axisState({ task, program: program ?? {}, action: input.kind, payload: input.payload ?? {} });
|
||||
const gateInput = { task, program: program ?? {}, motion, devices, taskfile: Boolean(this.program.loaded), kinematicsIdentity: this.motionAuthority === "wasm" };
|
||||
const gateActions = ["load", "run", "step", "pause", "resume", "abort", "restart", "mdi", "jogStart", "jogStop", "home", "estop", "recover", "override", "reset", "spindle", "coolant"];
|
||||
const gates = Object.fromEntries(gateActions.map((action) => [action, axisGate(action, gateInput)]));
|
||||
const position = this.motionAuthority === "wasm" && wasmMotion.status === "verified"
|
||||
? wasmMotion.state.position
|
||||
: authoritative.kinematics.pose;
|
||||
const joints = this.motionAuthority === "wasm" && wasmMotion.status === "verified"
|
||||
? [position.x, position.y, position.z]
|
||||
: authoritative.kinematics.joints;
|
||||
const preview = this.previewState?.status === "verified" ? this.previewState : null;
|
||||
const visual = {
|
||||
schema: "axis-visual.v1",
|
||||
pose: { x: position.x, y: position.y, z: position.z },
|
||||
joints: Array.isArray(joints) ? [...joints] : [],
|
||||
toolpath: (preview?.segments ?? []).map((segment, index) => ({ id: index + 1, ...segment })),
|
||||
active_segment_id: wasmMotion.status === "verified" ? wasmMotion.state.active_id : authoritative.planner.active_id,
|
||||
source: "wasm_canonical_motion",
|
||||
};
|
||||
const unsigned = {
|
||||
schema: "axis-panel-snapshot.v1",
|
||||
context_id: this.contextId,
|
||||
session_id: this.sessionId,
|
||||
simulation_tick: tick,
|
||||
task,
|
||||
program,
|
||||
motion,
|
||||
coordinates: { machine: [position.x, position.y, position.z], relative: [position.x, position.y, position.z], commanded: [position.x, position.y, position.z] },
|
||||
overrides: { feed: motion.feed_override, spindle: [motion.spindle_override] },
|
||||
wasm_motion: wasmMotion,
|
||||
home_limit: homeLimit,
|
||||
hal: { configuration: this.halState, runtime: halRuntime },
|
||||
devices,
|
||||
device_io: deviceIo,
|
||||
configuration: this.configurationState,
|
||||
axis_state: axis,
|
||||
gates,
|
||||
visual,
|
||||
authoritative,
|
||||
motion_authority: this.motionAuthority,
|
||||
task_authority: this.task.authority(),
|
||||
};
|
||||
return { ...unsigned, state_hash: fnv1a64(unsigned) };
|
||||
}
|
||||
|
||||
error(input, code, messageKey, retryable = false) {
|
||||
return this.envelope("ERROR", input, {
|
||||
status: "error",
|
||||
@@ -135,6 +210,23 @@ export class SimSession {
|
||||
return this.operations.get(input.operation_id) ?? null;
|
||||
}
|
||||
|
||||
commandGate(input) {
|
||||
const action = PANEL_GATE_ACTIONS[input.kind];
|
||||
if (!action) return { action: null, enabled: true, reason: "axis_internal_command" };
|
||||
const task = this.task.export();
|
||||
const program = this.program.loaded ? this.program.snapshot() : {};
|
||||
const motion = this.motion.export();
|
||||
const devices = this.devices.export();
|
||||
return axisGate(action, {
|
||||
task,
|
||||
program,
|
||||
motion,
|
||||
devices,
|
||||
taskfile: Boolean(this.program.loaded),
|
||||
kinematicsIdentity: this.motionAuthority === "wasm",
|
||||
});
|
||||
}
|
||||
|
||||
stateForCheckpoint() {
|
||||
const state = {
|
||||
context_id: this.contextId,
|
||||
@@ -153,6 +245,11 @@ export class SimSession {
|
||||
configuration: this.configurationState,
|
||||
hal: this.halState,
|
||||
python: this.python.exportState(),
|
||||
motion_authority: this.motionAuthority,
|
||||
wasm_motion: this.wasmMotion.exportState(),
|
||||
task_authority: this.task.authority(),
|
||||
wasm_home_limit: this.wasmHomeLimit.exportState(),
|
||||
preview: this.previewState,
|
||||
task: this.task.export(),
|
||||
scheduler: this.scheduler.export(),
|
||||
authoritative: this.authoritative.export(),
|
||||
@@ -182,6 +279,10 @@ export class SimSession {
|
||||
task: this.task.export(),
|
||||
scheduler: this.scheduler.export(),
|
||||
authoritative: this.authoritative.export(),
|
||||
motion_authority: this.motionAuthority,
|
||||
wasm_motion: this.wasmMotion.exportState(),
|
||||
task_authority: this.task.authority(),
|
||||
wasm_home_limit: this.wasmHomeLimit.exportState(),
|
||||
};
|
||||
if (this.program.loaded) value.program = this.program.exportState();
|
||||
return { ...value, state_hash: fnv1a64(value) };
|
||||
@@ -221,7 +322,17 @@ export class SimSession {
|
||||
if (trace.state?.task) this.task.restore(trace.state.task);
|
||||
if (trace.state?.scheduler) this.scheduler.restore(trace.state.scheduler);
|
||||
if (trace.state?.authoritative) this.authoritative.restore(trace.state.authoritative);
|
||||
if (trace.state?.motion_authority) this.motionAuthority = trace.state.motion_authority;
|
||||
if (trace.state?.wasm_motion) this.wasmMotion.restoreState(trace.state.wasm_motion);
|
||||
if (trace.state?.wasm_home_limit) this.wasmHomeLimit.restoreState(trace.state.wasm_home_limit);
|
||||
if (trace.state?.motion) this.motion.restore(trace.state.motion);
|
||||
if (trace.state?.program) {
|
||||
this.program = new ProgramRunner(this.core);
|
||||
this.program.restoreState(trace.state.program);
|
||||
}
|
||||
if (trace.state?.configuration !== undefined) this.configurationState = trace.state.configuration;
|
||||
if (trace.state?.hal !== undefined) this.halState = trace.state.hal;
|
||||
if (trace.state?.preview !== undefined) this.previewState = trace.state.preview;
|
||||
if (trace.state?.plc) this.plc.restore(trace.state.plc);
|
||||
if (trace.state?.hal_runtime) this.hal.restore(trace.state.hal_runtime);
|
||||
if (trace.state?.device_io) this.deviceIo.restore(trace.state.device_io);
|
||||
@@ -235,6 +346,10 @@ export class SimSession {
|
||||
}
|
||||
|
||||
restoreCheckpoint(checkpoint, events = null, reconcile = undefined) {
|
||||
const replayMotionAuthority = checkpoint?.state?.motion_authority ?? this.motionAuthority;
|
||||
if (replayMotionAuthority === "wasm" && !checkpoint?.state?.wasm_motion) {
|
||||
return { schema: "simulation-recovery.v1", status: "blocked", context_id: this.contextId, checkpoint_hash: checkpoint?.checkpoint_hash ?? null, blocked: [{ reason: "wasm_motion_checkpoint_missing" }] };
|
||||
}
|
||||
const replayDevices = new VirtualDevices({ state: checkpoint?.state?.devices });
|
||||
const replayMotion = new MotionState();
|
||||
if (checkpoint?.state?.motion) replayMotion.restore(checkpoint.state.motion);
|
||||
@@ -256,6 +371,20 @@ export class SimSession {
|
||||
if (checkpoint?.state?.python) replayPython.restoreState(checkpoint.state.python);
|
||||
const replayAuthoritative = new AuthoritativeRuntime();
|
||||
if (checkpoint?.state?.authoritative) replayAuthoritative.restore(checkpoint.state.authoritative);
|
||||
let replayWasmMotion = null;
|
||||
let replayHomeLimit = null;
|
||||
try {
|
||||
if (replayMotionAuthority === "wasm") {
|
||||
replayWasmMotion = new WasmMotionQueue(this.core);
|
||||
replayWasmMotion.restoreState(checkpoint.state.wasm_motion);
|
||||
if (checkpoint.state.wasm_home_limit) {
|
||||
replayHomeLimit = new WasmHomeLimit(this.core);
|
||||
replayHomeLimit.restoreState(checkpoint.state.wasm_home_limit);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return { schema: "simulation-recovery.v1", status: "blocked", context_id: this.contextId, checkpoint_hash: checkpoint?.checkpoint_hash ?? null, blocked: [{ reason: String(error?.message ?? error) }] };
|
||||
}
|
||||
const result = recoverSimulation({
|
||||
checkpoint,
|
||||
contextId: this.contextId,
|
||||
@@ -263,7 +392,11 @@ export class SimSession {
|
||||
reconcile,
|
||||
apply: (state, event) => {
|
||||
if (DEVICE_COMMANDS.has(event.kind)) replayDevices.applyEvent(event.kind, event.payload);
|
||||
if (event.kind === "HOME") replayMotion.home(event.payload?.axis ?? null);
|
||||
if (event.kind === "HOME") {
|
||||
replayMotion.home(event.payload?.axis ?? null);
|
||||
const home = replayHomeLimit?.home(event.seq);
|
||||
if (home?.status === "error") throw new Error(home.code);
|
||||
}
|
||||
if (event.kind === "JOG_START") replayMotion.startJog(event.payload);
|
||||
if (event.kind === "JOG_STOP") replayMotion.stopJog();
|
||||
if (event.kind === "SET_OVERRIDE") replayMotion.setOverride(event.payload?.feed ?? event.payload?.feed_override, event.payload?.spindle ?? event.payload?.spindle_override);
|
||||
@@ -273,23 +406,61 @@ export class SimSession {
|
||||
if (event.kind === "IO_RECOVER") replayDeviceIo.interlockReset();
|
||||
if (event.kind === "SIM_STEP") replayMotion.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();
|
||||
if (event.kind === "PROGRAM_RESUME") replayProgram.resume();
|
||||
if (event.kind === "PROGRAM_ABORT") replayProgram.abort();
|
||||
if (event.kind === "PROGRAM_RESTART") replayProgram.restart();
|
||||
if (event.kind === "PROGRAM_RUN") { replayProgram.start(); replayWasmMotion?.setPaused(false); }
|
||||
if (event.kind === "PROGRAM_PAUSE") { replayProgram.pause(); replayWasmMotion?.setPaused(true); }
|
||||
if (event.kind === "PROGRAM_RESUME") { replayProgram.resume(); replayWasmMotion?.setPaused(false); }
|
||||
if (event.kind === "PROGRAM_ABORT") { replayProgram.abort(); replayWasmMotion?.abort(); }
|
||||
if (event.kind === "PROGRAM_RESTART") { replayProgram.restart(); replayWasmMotion?.reset(); }
|
||||
if (event.kind === "SIM_STEP" && replayProgram.loaded) {
|
||||
if (replayProgram.status === "paused" && state.task?.program_status === "running") replayProgram.resume();
|
||||
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);
|
||||
// PROGRAM_LOAD resets the planner clock, while the session clock is
|
||||
// monotonic. Recovery must start this replay at the checkpoint tick
|
||||
// or one logical step would consume the whole historical interval.
|
||||
if (replayMotionAuthority === "wasm") {
|
||||
if (!replayWasmMotion) throw new Error("wasm_motion_replay_unavailable");
|
||||
const queueTick = replayWasmMotion.read().state.tick;
|
||||
if (replayProgram.status === "paused" && state.task?.program_status === "running") replayProgram.resume();
|
||||
const cycles = Number.isSafeInteger(event.payload?.cycles) && event.payload.cycles >= 0 ? event.payload.cycles : 1;
|
||||
for (let cycle = 0; cycle < cycles; cycle += 1) {
|
||||
const replayTick = queueTick + cycle + 1;
|
||||
const steps = replayProgram.tick(1);
|
||||
for (const step of steps) {
|
||||
if (!step.motion) continue;
|
||||
const linearNoop = step.motion.motion_type === 1 && ["x", "y", "z", "a", "b", "c", "u", "v", "w"].every((axis) => step.motion.start[axis] === step.motion.end[axis]);
|
||||
if (!linearNoop) {
|
||||
const enqueue = replayWasmMotion.enqueue(step.motion);
|
||||
if (enqueue.status === "error") throw new Error(enqueue.code);
|
||||
}
|
||||
}
|
||||
const stepped = replayWasmMotion.step();
|
||||
if (stepped.status === "error" && stepped.code !== "wasm_motion_step_-605") throw new Error(stepped.code);
|
||||
if (stepped.status === "verified") {
|
||||
const projected = replayMotion.applyWasmPosition(stepped.state.position, stepped.state.velocity, replayTick);
|
||||
if (projected.status === "error") throw new Error(projected.code);
|
||||
if (replayHomeLimit?.read().state.homed) {
|
||||
const checked = replayHomeLimit.check([stepped.state.position.x, stepped.state.position.y, stepped.state.position.z]);
|
||||
if (checked.status === "error") throw new Error(checked.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (Number.isSafeInteger(state.tick) && replayAuthoritative.state.tick < state.tick) replayAuthoritative.state.tick = state.tick;
|
||||
if (replayProgram.status === "paused" && state.task?.program_status === "running") replayProgram.resume();
|
||||
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 (replayMotionAuthority === "wasm") {
|
||||
const stepped = replayWasmMotion?.step();
|
||||
if (stepped?.status === "error" && stepped.code !== "wasm_motion_step_-605") throw new Error(stepped.code);
|
||||
} else {
|
||||
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);
|
||||
@@ -352,7 +523,7 @@ export class SimSession {
|
||||
const programStatus = replayProgram.status === "completed" ? "completed" : replayProgram.status === "running" ? "running" : replayProgram.status === "paused" ? "paused" : replayProgram.status === "aborted" ? "aborted" : "loaded";
|
||||
task.current_line = replayProgram.currentLine;
|
||||
task.program_status = programStatus;
|
||||
if (programStatus === "completed" && task.state === "RUNNING") task.state = "STOPPED";
|
||||
if (programStatus === "completed" && task.state === "RUNNING" && (replayMotionAuthority !== "wasm" || replayWasmMotion?.read().state.queue_depth === 0)) task.state = "STOPPED";
|
||||
}
|
||||
const schedulerUnsigned = {
|
||||
schema: "sim_scheduler.v1",
|
||||
@@ -365,11 +536,16 @@ export class SimSession {
|
||||
last_cycles: event.kind === "SIM_STEP" && Number.isSafeInteger(event.payload?.cycles) ? event.payload.cycles : (state.scheduler?.last_cycles ?? 0),
|
||||
state_hash: fnv1a64(schedulerUnsigned),
|
||||
};
|
||||
return { ...state, tick: event.tick, task: { ...task, state_hash: fnv1a64(task) }, scheduler, devices: replayDevices.export(), motion: replayMotion.export(), authoritative: replayAuthoritative.export(), plc: replayPlc.export(), hal_runtime: replayHal.export(), device_io: replayDeviceIo.export(), configuration_batch: replayConfigurationBatch.export(), io_ack: replayIoAck.export(), callbacks: replayCallbacks.export(), python: replayPython.exportState(), ...(replayProgram.loaded ? { program: replayProgram.exportState() } : {}) };
|
||||
return { ...state, tick: event.tick, motion_authority: replayMotionAuthority, task_authority: replayMotionAuthority === "wasm" ? "wasm_task_canonical" : "js_compat", task: { ...task, state_hash: fnv1a64(task) }, scheduler, devices: replayDevices.export(), motion: replayMotion.export(), ...(replayWasmMotion ? { wasm_motion: replayWasmMotion.exportState() } : {}), ...(replayHomeLimit ? { wasm_home_limit: replayHomeLimit.exportState() } : {}), authoritative: replayAuthoritative.export(), plc: replayPlc.export(), hal_runtime: replayHal.export(), device_io: replayDeviceIo.export(), configuration_batch: replayConfigurationBatch.export(), io_ack: replayIoAck.export(), callbacks: replayCallbacks.export(), python: replayPython.exportState(), ...(replayProgram.loaded ? { program: replayProgram.exportState() } : {}) };
|
||||
},
|
||||
});
|
||||
replayWasmMotion?.destroy();
|
||||
replayHomeLimit?.destroy();
|
||||
if (result.status === "recovered" || result.status === "aborted") {
|
||||
this.tick = result.simulation_tick;
|
||||
this.motionAuthority = result.state?.motion_authority ?? checkpoint?.state?.motion_authority ?? this.motionAuthority;
|
||||
if (this.motionAuthority === "wasm" && result.state?.wasm_motion) this.wasmMotion.restoreState(result.state.wasm_motion);
|
||||
if (this.motionAuthority === "wasm" && result.state?.wasm_home_limit) this.wasmHomeLimit.restoreState(result.state.wasm_home_limit);
|
||||
if (result.state?.task) this.task.restore(result.state.task);
|
||||
if (result.state?.scheduler) this.scheduler.restore(result.state.scheduler);
|
||||
this.authoritative = new AuthoritativeRuntime();
|
||||
@@ -377,7 +553,11 @@ export class SimSession {
|
||||
this.devices = new VirtualDevices({ state: result.state?.devices ?? checkpoint?.state?.devices });
|
||||
this.motion = new MotionState();
|
||||
if (result.state?.motion) this.motion.restore(result.state.motion);
|
||||
this.authoritative.applyMotion(this.motion);
|
||||
if (this.motionAuthority !== "wasm") this.authoritative.applyMotion(this.motion);
|
||||
if (this.motionAuthority === "wasm") {
|
||||
const wasmMotion = this.wasmMotion.read();
|
||||
if (wasmMotion.status !== "verified" || !result.state?.motion || fnv1a64(result.state.motion.axes) !== fnv1a64(wasmMotion.state.position)) throw new Error("wasm_motion_recovery_projection_mismatch");
|
||||
}
|
||||
this.plc = new PlcRuntime();
|
||||
if (result.state?.plc) this.plc.restore(result.state.plc);
|
||||
this.hal = new HalRuntime();
|
||||
@@ -392,6 +572,7 @@ export class SimSession {
|
||||
if (result.state?.callbacks) this.callbacks.restore(result.state.callbacks);
|
||||
this.configurationState = result.state?.configuration ?? checkpoint?.state?.configuration ?? null;
|
||||
this.halState = result.state?.hal ?? checkpoint?.state?.hal ?? null;
|
||||
this.previewState = result.state?.preview ?? checkpoint?.state?.preview ?? null;
|
||||
this.python = new PythonBridge();
|
||||
if (result.state?.python) this.python.restoreState(result.state.python);
|
||||
this.program = new ProgramRunner(this.core);
|
||||
@@ -478,6 +659,28 @@ export class SimSession {
|
||||
if (text(input.operation_id) && OPERATION_KINDS.has(input.kind)) {
|
||||
this.operations.set(input.operation_id, { operation_id: input.operation_id, status: "executing", seq: input.seq, kind: input.kind, result_hash: null });
|
||||
}
|
||||
const gate = this.commandGate(input);
|
||||
const handlerOwnsFault = (input.kind === "JOG_START" || input.kind === "JOG_STOP") && Boolean(this.motion.state.fault);
|
||||
const restartAfterCompletion = input.kind === "PROGRAM_RESTART" && this.program.status === "completed";
|
||||
if (!gate.enabled && !handlerOwnsFault && !restartAfterCompletion) {
|
||||
if (text(input.operation_id)) this.operations.delete(input.operation_id);
|
||||
const rejectedPayload = { command: input.kind, gate_reason: gate.reason, payload: input.payload ?? {} };
|
||||
try {
|
||||
this.timeline.appendEvent(input.seq, this.tick, "COMMAND_REJECTED", rejectedPayload);
|
||||
const rejectedEvent = appendCoreEvent(this.core, input.seq, this.tick, "COMMAND_REJECTED", rejectedPayload);
|
||||
if (rejectedEvent !== 0) return this.error(input, "event_log", "core_event_append_failed");
|
||||
} catch (error) {
|
||||
return this.error(input, "event_log", String(error?.message ?? error));
|
||||
}
|
||||
return this.envelope("ERROR", input, {
|
||||
status: "blocked",
|
||||
code: "axis_gate",
|
||||
message_key: gate.reason,
|
||||
gate_reason: gate.reason,
|
||||
retryable: false,
|
||||
operation_id: input.operation_id ?? null,
|
||||
});
|
||||
}
|
||||
if (Number.isSafeInteger(input.simulation_tick) && input.simulation_tick >= this.tick) this.tick = input.simulation_tick;
|
||||
const eventTick = input.kind === "SIM_STEP" && Number.isSafeInteger(input.payload?.cycles)
|
||||
? this.tick + input.payload.cycles : this.tick;
|
||||
@@ -560,6 +763,19 @@ export class SimSession {
|
||||
}
|
||||
try { this.task.transition("READY"); } catch (error) { return finish(this.error(input, "task_state", String(error?.message ?? error)), "failed"); }
|
||||
return finish(this.envelope("SESSION_READY", input, { state_hash: this.task.hash(), task: this.task.export(), subscriptions: [] }), "verified");
|
||||
case "SET_MODE": {
|
||||
const mode = String(input.payload?.mode ?? "").toUpperCase();
|
||||
if (!["MANUAL", "MDI", "AUTO"].includes(mode)) return finish(this.error(input, "task_mode", "task_mode_invalid"), "failed");
|
||||
try { this.task.setMode(mode); } catch (error) { return finish(this.error(input, "task_mode", String(error?.message ?? error)), "failed"); }
|
||||
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, mode, task: this.task.export(), operation_id: input.operation_id ?? null }));
|
||||
}
|
||||
case "RESET": {
|
||||
if (["RUNNING", "PAUSED"].includes(this.task.state.state)) return finish(this.error(input, "task_state", "reset_while_program_active"), "failed");
|
||||
const result = this.motion.resetFault();
|
||||
if (result.status !== "verified") return finish(this.envelope("ERROR", input, { ...result, command: input.kind, task: this.task.export() }), "failed");
|
||||
if (this.task.state.state === "FAULT" || this.task.state.state === "STOPPED") this.task.recover();
|
||||
return finish(this.envelope("COMMAND_RESULT", input, { ...result, command: input.kind, task: this.task.export(), motion: this.motion.export(), operation_id: input.operation_id ?? null }));
|
||||
}
|
||||
case "LOAD_WORKSPACE": {
|
||||
const rootPath = input.payload?.root_path;
|
||||
if (!text(rootPath) || !rootPath.startsWith("/") || rootPath.includes("..")) return finish(this.error(input, "workspace_path", "invalid_workspace_path"), "failed");
|
||||
@@ -588,7 +804,7 @@ export class SimSession {
|
||||
startup: startupReport,
|
||||
}), "failed");
|
||||
}
|
||||
this.configurationState = startupReport;
|
||||
this.configurationState = startupReport;
|
||||
this.created = true;
|
||||
startupLoaded = true;
|
||||
} else {
|
||||
@@ -610,6 +826,11 @@ export class SimSession {
|
||||
};
|
||||
}
|
||||
}
|
||||
this.motionAuthority = configuration?.entrypoint?.includes("3axis-tutorial") ? "wasm" : "js";
|
||||
if (configuration?.home_limits && this.motionAuthority === "wasm") {
|
||||
this.wasmHomeLimit.destroy();
|
||||
this.wasmHomeLimit = new WasmHomeLimit(this.core, { limits: configuration.home_limits });
|
||||
}
|
||||
if (input.payload?.hal) {
|
||||
const preflight = preflightHalConfiguration(input.payload.hal);
|
||||
if (preflight.status !== "ready") {
|
||||
@@ -636,6 +857,7 @@ export class SimSession {
|
||||
return loadResult === 0 ? finish(this.envelope("COMMAND_RESULT", input, {
|
||||
status: "verified",
|
||||
command: input.kind,
|
||||
motion_authority: this.motionAuthority,
|
||||
state_hash: this.task.hash(),
|
||||
task: this.task.export(),
|
||||
...(this.configurationState?.schema === "configuration-startup-runtime.v1" ? { configuration: this.configurationState } : {}),
|
||||
@@ -648,19 +870,30 @@ export class SimSession {
|
||||
let program_steps = [];
|
||||
try {
|
||||
this.scheduler.step(cycles, {
|
||||
task: () => {
|
||||
program_steps.push(...this.program.tick(1));
|
||||
task: () => {
|
||||
const steps = this.program.tick(1);
|
||||
program_steps.push(...steps);
|
||||
for (const step of steps) {
|
||||
if (!step.motion) continue;
|
||||
const linearNoop = step.motion.motion_type === 1 && ["x", "y", "z", "a", "b", "c", "u", "v", "w"].every((axis) => step.motion.start[axis] === step.motion.end[axis]);
|
||||
if (!linearNoop) {
|
||||
const enqueue = this.wasmMotion.enqueue(step.motion);
|
||||
if (enqueue.status === "error") throw new Error(enqueue.code);
|
||||
}
|
||||
}
|
||||
},
|
||||
tp: (nextTick) => {
|
||||
if (this.program.loaded) {
|
||||
if (this.motionAuthority !== "wasm" && this.program.loaded) {
|
||||
const sync = this.authoritative.syncProgram(this.program.snapshot());
|
||||
if (sync.status === "blocked") throw new Error(sync.code);
|
||||
}
|
||||
const planner = this.authoritative.step(nextTick);
|
||||
if (planner.status === "error") throw new Error(planner.code);
|
||||
if (this.motionAuthority !== "wasm") {
|
||||
const planner = this.authoritative.step(nextTick);
|
||||
if (planner.status === "error") throw new Error(planner.code);
|
||||
}
|
||||
},
|
||||
kinematics: () => {
|
||||
if (!["trivkins", "corexy", "tripod", "lineardelta", "rotarydelta"].includes(this.authoritative.state.kinematics.type)) throw new Error("authoritative_kinematics_unsupported");
|
||||
if (this.motionAuthority !== "wasm" && !["trivkins", "corexy", "tripod", "lineardelta", "rotarydelta"].includes(this.authoritative.state.kinematics.type)) throw new Error("authoritative_kinematics_unsupported");
|
||||
},
|
||||
motion: (nextTick) => {
|
||||
const stepResult = call(this.core, "sim_step", 1);
|
||||
@@ -668,8 +901,19 @@ export class SimSession {
|
||||
this.tick = nextTick;
|
||||
this.task.setTick(nextTick);
|
||||
const motionState = this.motion.step(nextTick);
|
||||
const wasmMotion = this.wasmMotion.step();
|
||||
if (wasmMotion.status === "error" && wasmMotion.code !== "wasm_motion_step_-605") throw new Error(wasmMotion.code);
|
||||
if (motionState.fault && this.task.state.state === "RUNNING") this.task.fault(-1205);
|
||||
if (this.program.loaded) this.authoritative.applyMotion(this.motion);
|
||||
if (this.motionAuthority === "wasm") {
|
||||
if (wasmMotion.status === "verified") {
|
||||
const position = this.motion.applyWasmPosition(wasmMotion.state.position, wasmMotion.state.velocity, nextTick);
|
||||
if (position.status === "error") throw new Error(position.code);
|
||||
}
|
||||
} else if (this.program.loaded) this.authoritative.applyMotion(this.motion);
|
||||
if (this.motionAuthority === "wasm" && this.wasmHomeLimit.read().state.homed && !motionState.fault) {
|
||||
const homeCheck = this.wasmHomeLimit.check(this.motion.state.joints);
|
||||
if (homeCheck.status === "error") throw new Error(homeCheck.code);
|
||||
}
|
||||
const halState = this.hal.step(nextTick);
|
||||
if (halState.status === "error") throw new Error(halState.code);
|
||||
const deviceIoState = this.deviceIo.step(nextTick);
|
||||
@@ -692,12 +936,12 @@ export class SimSession {
|
||||
if (program) {
|
||||
const status = program.program_status === "completed" ? "completed" : program.program_status === "running" ? "running" : program.program_status;
|
||||
this.task.setProgram(status, program.line);
|
||||
if (status === "completed" && this.task.state.state === "RUNNING") this.task.transition("STOPPED");
|
||||
if (status === "completed" && this.task.state.state === "RUNNING" && (this.motionAuthority !== "wasm" || this.wasmMotion.read().state.queue_depth === 0)) this.task.transition("STOPPED");
|
||||
}
|
||||
const snapshotState = { tick: this.tick, cycles, task: this.task.export(), scheduler: this.scheduler.export(), authoritative: this.authoritative.export(), motion: this.motion.export(), plc: this.plc.export(), hal_runtime: this.hal.export(), device_io: this.deviceIo.export(), configuration_batch: this.configurationBatch.export(), io_ack: this.ioAck.export(), callbacks: this.callbacks.export() };
|
||||
const snapshotState = { tick: this.tick, cycles, motion_authority: this.motionAuthority, task: this.task.export(), scheduler: this.scheduler.export(), authoritative: this.authoritative.export(), wasm_motion: this.wasmMotion.read(), motion: this.motion.export(), plc: this.plc.export(), hal_runtime: this.hal.export(), device_io: this.deviceIo.export(), configuration_batch: this.configurationBatch.export(), io_ack: this.ioAck.export(), callbacks: this.callbacks.export() };
|
||||
if (program) snapshotState.program = program;
|
||||
const snapshot = this.timeline.checkpoint(input.seq, this.tick, snapshotState, this.timeline.snapshots.latest()?.snapshot_id ?? null);
|
||||
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", cycles, program_steps, task: this.task.export(), scheduler: this.scheduler.export(), authoritative: this.authoritative.export(), motion: this.motion.export(), plc: this.plc.export(), hal_runtime: this.hal.export(), device_io: this.deviceIo.export(), configuration_batch: this.configurationBatch.export(), io_ack: this.ioAck.export(), callbacks: this.callbacks.export(), ...(program ? { program } : {}), state_hash: snapshot.state_hash, snapshot }));
|
||||
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", cycles, program_steps, motion_authority: this.motionAuthority, task: this.task.export(), scheduler: this.scheduler.export(), authoritative: this.authoritative.export(), wasm_motion: this.wasmMotion.read(), motion: this.motion.export(), plc: this.plc.export(), hal_runtime: this.hal.export(), device_io: this.deviceIo.export(), configuration_batch: this.configurationBatch.export(), io_ack: this.ioAck.export(), callbacks: this.callbacks.export(), ...(program ? { program } : {}), state_hash: snapshot.state_hash, snapshot }));
|
||||
}
|
||||
case "PROGRAM_LOAD": {
|
||||
if (["RUNNING", "PAUSED"].includes(this.task.state.state)) return finish(this.error(input, "task_state", "program_load_while_active"), "failed");
|
||||
@@ -705,11 +949,20 @@ export class SimSession {
|
||||
const result = this.program.load(source, input.payload?.path ?? null);
|
||||
if (result.status === "verified") {
|
||||
this.authoritative.reset();
|
||||
this.wasmMotion.reset();
|
||||
this.wasmMotion.setPaused(false);
|
||||
this.motion.applyWasmPosition(this.wasmMotion.read().state.position, 0, this.tick);
|
||||
this.task.setProgram("loaded", 0);
|
||||
const preview = this.program.preview();
|
||||
result.preview = preview.preview ?? { status: "error", code: preview.code ?? "program_preview_failed" };
|
||||
this.previewState = result.preview;
|
||||
}
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, {
|
||||
...result,
|
||||
task: this.task.export(),
|
||||
motion_authority: this.motionAuthority,
|
||||
wasm_motion: this.wasmMotion.read(),
|
||||
motion: this.motion.export(),
|
||||
command: input.kind,
|
||||
operation_id: input.operation_id ?? null,
|
||||
}), result.status === "verified" ? "verified" : "failed");
|
||||
@@ -719,12 +972,16 @@ export class SimSession {
|
||||
if (result.status === "verified") {
|
||||
this.task.setMode("AUTO");
|
||||
if (this.task.state.state === "STOPPED") this.task.recover();
|
||||
if (this.motionAuthority === "wasm") this.wasmMotion.setPaused(false);
|
||||
this.task.transition("RUNNING");
|
||||
this.task.setProgram("running", this.program.currentLine);
|
||||
}
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, {
|
||||
...result,
|
||||
task: this.task.export(),
|
||||
motion_authority: this.motionAuthority,
|
||||
wasm_motion: this.wasmMotion.read(),
|
||||
motion: this.motion.export(),
|
||||
command: input.kind,
|
||||
operation_id: input.operation_id ?? null,
|
||||
}), result.status === "verified" ? "verified" : "failed");
|
||||
@@ -733,13 +990,25 @@ export class SimSession {
|
||||
const action = input.kind === "PROGRAM_PAUSE" ? "pause" : input.kind === "PROGRAM_RESUME" ? "resume" : "abort";
|
||||
const result = this.program[action]();
|
||||
if (result.status === "verified") {
|
||||
if (action === "pause") this.task.transition("PAUSED");
|
||||
else if (action === "resume") this.task.transition("RUNNING");
|
||||
else { this.authoritative.abort(); this.task.transition("STOPPED"); this.task.setProgram("aborted", this.program.currentLine); }
|
||||
if (action === "pause") {
|
||||
if (this.motionAuthority === "wasm") this.wasmMotion.setPaused(true);
|
||||
this.task.transition("PAUSED");
|
||||
} else if (action === "resume") {
|
||||
if (this.motionAuthority === "wasm") this.wasmMotion.setPaused(false);
|
||||
this.task.transition("RUNNING");
|
||||
} else {
|
||||
if (this.motionAuthority === "wasm") this.wasmMotion.abort();
|
||||
else this.authoritative.abort();
|
||||
this.task.transition("STOPPED");
|
||||
this.task.setProgram("aborted", this.program.currentLine);
|
||||
}
|
||||
}
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, {
|
||||
...result,
|
||||
task: this.task.export(),
|
||||
motion_authority: this.motionAuthority,
|
||||
wasm_motion: this.wasmMotion.read(),
|
||||
motion: this.motion.export(),
|
||||
command: input.kind,
|
||||
operation_id: input.operation_id ?? null,
|
||||
}), result.status === "verified" ? "verified" : "failed");
|
||||
@@ -748,9 +1017,13 @@ export class SimSession {
|
||||
const result = this.program.restart();
|
||||
if (result.status === "verified") {
|
||||
if (["ESTOP", "FAULT"].includes(this.task.state.state)) return finish(this.error(input, "task_state", "program_restart_faulted"), "failed");
|
||||
if (["RUNNING", "PAUSED"].includes(this.task.state.state)) this.task.transition("STOPPED");
|
||||
if (this.task.state.state === "STOPPED") this.task.recover();
|
||||
this.task.setMode("AUTO");
|
||||
this.authoritative.reset();
|
||||
this.wasmMotion.reset();
|
||||
this.wasmMotion.setPaused(false);
|
||||
this.motion.applyWasmPosition(this.wasmMotion.read().state.position, 0, this.tick);
|
||||
this.task.setProgram("loaded", 0);
|
||||
}
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, {
|
||||
@@ -767,14 +1040,26 @@ export class SimSession {
|
||||
}
|
||||
const result = this.program.step();
|
||||
if (result.status === "verified") {
|
||||
if (this.motionAuthority === "wasm" && result.motion) {
|
||||
const linearNoop = result.motion.motion_type === 1 && ["x", "y", "z", "a", "b", "c", "u", "v", "w"].every((axis) => result.motion.start[axis] === result.motion.end[axis]);
|
||||
if (!linearNoop) {
|
||||
const enqueue = this.wasmMotion.enqueue(result.motion);
|
||||
if (enqueue.status === "error") return finish(this.error(input, "motion_queue", enqueue.code), "failed");
|
||||
}
|
||||
this.wasmMotion.setPaused(true);
|
||||
}
|
||||
this.task.setProgram(result.program_status === "completed" ? "completed" : "paused", result.line);
|
||||
if (result.program_status === "completed") {
|
||||
if (this.task.state.state === "RUNNING") this.task.transition("STOPPED");
|
||||
const pending = this.motionAuthority === "wasm" ? this.wasmMotion.read().state.queue_depth > 0 : this.authoritative.state.planner.queue_depth > 0;
|
||||
if (this.task.state.state === "RUNNING") this.task.transition(pending ? "PAUSED" : "STOPPED");
|
||||
} else if (this.task.state.state === "RUNNING") this.task.transition("PAUSED");
|
||||
}
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, {
|
||||
...result,
|
||||
task: this.task.export(),
|
||||
motion_authority: this.motionAuthority,
|
||||
wasm_motion: this.wasmMotion.read(),
|
||||
motion: this.motion.export(),
|
||||
command: input.kind,
|
||||
operation_id: input.operation_id ?? null,
|
||||
}), result.status === "verified" ? "verified" : "failed");
|
||||
@@ -803,16 +1088,21 @@ export class SimSession {
|
||||
}
|
||||
case "HOME": {
|
||||
const result = this.motion.home(input.payload?.axis ?? null);
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, task: this.task.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||
if (result.status === "verified" && this.motionAuthority === "wasm") {
|
||||
const home = this.wasmHomeLimit.home(input.seq);
|
||||
if (home.status === "error") return finish(this.error(input, "home_limit", home.code), "failed");
|
||||
}
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, motion: this.motion.export(), task: this.task.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||
}
|
||||
case "JOG_START": case "JOG_STOP": case "SET_OVERRIDE": case "FOLLOWING_ERROR": {
|
||||
let result;
|
||||
if (input.kind === "JOG_START" && this.motionAuthority === "wasm" && !this.wasmHomeLimit.read().state.homed) return finish(this.error(input, "motion_state", "motion_unhomed"), "failed");
|
||||
if (input.kind === "JOG_START") result = this.motion.startJog(input.payload);
|
||||
else if (input.kind === "JOG_STOP") result = this.motion.stopJog();
|
||||
else if (input.kind === "SET_OVERRIDE") result = this.motion.setOverride(input.payload?.feed ?? input.payload?.feed_override, input.payload?.spindle ?? input.payload?.spindle_override);
|
||||
else result = this.motion.setFollowingError(input.payload?.value ?? input.payload?.following_error);
|
||||
if (result.status === "error" && result.state?.fault && this.task.state.state === "RUNNING") this.task.fault(-1205);
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, task: this.task.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, motion: this.motion.export(), task: this.task.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||
}
|
||||
case "SPINDLE_COMMAND": case "SPINDLE_FAULT": case "SPINDLE_INHIBIT":
|
||||
case "SPINDLE_ORIENT": case "SPINDLE_ORIENTED": case "IO_STEP":
|
||||
|
||||
Reference in New Issue
Block a user