Implement configuration, runtime, frontend, and task governance closure
This commit is contained in:
104
web/worker/python-bridge.mjs
Normal file
104
web/worker/python-bridge.mjs
Normal file
@@ -0,0 +1,104 @@
|
||||
import manifest from "../../python-runtime/manifest.json" with { type: "json" };
|
||||
import { fnv1a64 } from "./replay.mjs";
|
||||
|
||||
export const PYTHON_BRIDGE_SCHEMA = "python-worker-bridge.v1";
|
||||
|
||||
function clone(value) { return structuredClone(value); }
|
||||
function finiteJson(value) {
|
||||
if (value === null || typeof value !== "object") return true;
|
||||
if (typeof value === "number") return Number.isFinite(value);
|
||||
return Array.isArray(value) ? value.every(finiteJson) : Object.values(value).every(finiteJson);
|
||||
}
|
||||
|
||||
export class PythonBridge {
|
||||
constructor({ loadPyodide = globalThis.loadPyodide, maxSourceBytes = 65536, maxCalls = 256 } = {}) {
|
||||
this.loadPyodide = loadPyodide;
|
||||
this.maxSourceBytes = maxSourceBytes;
|
||||
this.maxCalls = maxCalls;
|
||||
this.pyodide = null;
|
||||
this.initialized = false;
|
||||
this.state = { imports: [], calls: [], errors: [], capabilities: [], tick: 0 };
|
||||
}
|
||||
|
||||
capabilities() {
|
||||
return {
|
||||
schema: PYTHON_BRIDGE_SCHEMA,
|
||||
runtime: manifest.runtime,
|
||||
version: manifest.version,
|
||||
python_version: manifest.python_version,
|
||||
abi_version: manifest.abi_version,
|
||||
platform: manifest.platform,
|
||||
worker_only: manifest.worker_only,
|
||||
virtual_modules: clone(manifest.virtual_modules),
|
||||
denied_capabilities: [...manifest.denied_capabilities],
|
||||
initialized: this.initialized,
|
||||
};
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
if (this.initialized) return { status: "verified", capabilities: this.capabilities() };
|
||||
if (typeof this.loadPyodide !== "function") return this.fail("python_runtime_unavailable", "python.runtime_unavailable");
|
||||
try {
|
||||
this.pyodide = await this.loadPyodide({ indexURL: manifest.index_url });
|
||||
this.initialized = true;
|
||||
return { status: "verified", capabilities: this.capabilities() };
|
||||
} catch (error) {
|
||||
return this.fail("python_runtime_init", String(error?.message ?? error));
|
||||
}
|
||||
}
|
||||
|
||||
importModule(module) {
|
||||
const symbols = manifest.virtual_modules[module];
|
||||
if (!symbols) return this.fail("python_unsupported_module", "python.unsupported_module", { module });
|
||||
if (!this.state.imports.includes(module)) this.state.imports.push(module);
|
||||
return { status: "verified", module, symbols: [...symbols], state_hash: this.hash() };
|
||||
}
|
||||
|
||||
checkCapability(capability) {
|
||||
if (manifest.denied_capabilities.includes(capability)) return this.fail("real_hardware_excluded", "python.host_capability_denied", { capability, status: "REAL-EXCLUDED" });
|
||||
if (typeof capability !== "string" || capability.length === 0) return this.fail("python_capability_invalid", "python.capability_invalid");
|
||||
if (!this.state.capabilities.includes(capability)) this.state.capabilities.push(capability);
|
||||
return { status: "verified", capability, state_hash: this.hash() };
|
||||
}
|
||||
|
||||
invoke(module, symbol, args = []) {
|
||||
const symbols = manifest.virtual_modules[module];
|
||||
if (!symbols || !symbols.includes(symbol)) return this.fail("python_symbol_unsupported", "python.symbol_unsupported", { module, symbol });
|
||||
if (!Array.isArray(args) || !finiteJson(args)) return this.fail("python_argument_invalid", "python.argument_invalid");
|
||||
if (this.state.calls.length >= this.maxCalls) return this.fail("python_call_limit", "python.call_limit");
|
||||
if (!this.state.imports.includes(module)) this.state.imports.push(module);
|
||||
const call = { module, symbol, args: clone(args), tick: this.state.tick };
|
||||
this.state.calls.push(call);
|
||||
return { status: "verified", value: { module, symbol, args: clone(args) }, state_hash: this.hash() };
|
||||
}
|
||||
|
||||
async execute(source) {
|
||||
if (typeof source !== "string" || new TextEncoder().encode(source).length > this.maxSourceBytes) return this.fail("python_source_limit", "python.source_limit");
|
||||
const denied = manifest.denied_capabilities.find((capability) => new RegExp(`(?:^|[^A-Za-z0-9_])${capability.replaceAll(".", "\\.")}(?:$|[^A-Za-z0-9_])`).test(source));
|
||||
if (denied) return this.fail("real_hardware_excluded", "python.host_capability_denied", { capability: denied, status: "REAL-EXCLUDED" });
|
||||
if (!this.pyodide || typeof this.pyodide.runPythonAsync !== "function") return this.fail("python_runtime_unavailable", "python.runtime_unavailable");
|
||||
try {
|
||||
const value = await this.pyodide.runPythonAsync(source);
|
||||
return { status: "verified", value: value === undefined ? null : value, state_hash: this.hash() };
|
||||
} catch (error) {
|
||||
return this.fail("python_exception", "python.exception", { detail: String(error?.message ?? error) });
|
||||
}
|
||||
}
|
||||
|
||||
fail(code, message_key, extra = {}) {
|
||||
this.state.errors.push({ code, tick: this.state.tick });
|
||||
return { status: extra.status ?? "blocked", code, message_key, retryable: false, ...extra, state_hash: this.hash() };
|
||||
}
|
||||
|
||||
setTick(tick) { if (Number.isSafeInteger(tick) && tick >= 0) this.state.tick = tick; }
|
||||
exportState() { return { schema: PYTHON_BRIDGE_SCHEMA, initialized: this.initialized, state: clone(this.state) }; }
|
||||
restoreState(value) {
|
||||
if (!value || value.schema !== PYTHON_BRIDGE_SCHEMA || !value.state) return this.fail("python_state_invalid", "python.state_invalid");
|
||||
this.initialized = Boolean(value.initialized);
|
||||
this.state = clone(value.state);
|
||||
return { status: "verified", state_hash: this.hash() };
|
||||
}
|
||||
hash() { return fnv1a64(this.state); }
|
||||
}
|
||||
|
||||
export { manifest };
|
||||
@@ -2,6 +2,10 @@ import { ReplayTimeline, fnv1a64 } from "./replay.mjs";
|
||||
import { capabilities, validateLimit } from "./capabilities.mjs";
|
||||
import { mapCoreError } from "./error-map.mjs";
|
||||
import { createCheckpoint, recoverSimulation } from "../workspace/recovery.mjs";
|
||||
import { preflightConfiguration } from "../workspace/configuration.mjs";
|
||||
import { preflightHalConfiguration } from "../workspace/hal-configuration.mjs";
|
||||
import { DEVICE_COMMANDS, VirtualDevices } from "./virtual-devices.mjs";
|
||||
import { PythonBridge } from "./python-bridge.mjs";
|
||||
|
||||
const PROTOCOL_SCHEMA = 1;
|
||||
const CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
|
||||
@@ -11,7 +15,10 @@ const OPERATION_KINDS = new Set([
|
||||
"COMMAND", "START", "STOP", "RESET", "PROGRAM_LOAD", "PROGRAM_RUN",
|
||||
"PROGRAM_PAUSE", "PROGRAM_RESUME", "PROGRAM_ABORT", "JOG_START", "JOG_STOP",
|
||||
"HOME", "SET_MODE", "SET_OVERRIDE", "SPINDLE_COMMAND", "TOOL_PREPARE",
|
||||
"TOOL_CHANGE", "PROBE_CONFIG", "HAL_COMMAND", "HAL_SET",
|
||||
"TOOL_CHANGED", "TOOL_PREPARED", "TOOL_CHANGE", "TOOL_ABORT", "TOOL_FAULT", "TOOL_CLEAR_FAULT",
|
||||
"SPINDLE_FAULT", "SPINDLE_INHIBIT", "SPINDLE_ORIENT", "SPINDLE_ORIENTED", "IO_STEP", "IO_RECOVER",
|
||||
"PROBE_CONFIG", "LIMIT_SET", "ESTOP", "HAL_COMMAND", "HAL_SET",
|
||||
"PYTHON_IMPORT", "PYTHON_CALL", "PYTHON_CAPABILITY",
|
||||
]);
|
||||
|
||||
function text(value) {
|
||||
@@ -52,6 +59,10 @@ export class SimSession {
|
||||
this.created = false;
|
||||
this.timeline = new ReplayTimeline();
|
||||
this.operations = new Map();
|
||||
this.devices = new VirtualDevices();
|
||||
this.python = new PythonBridge();
|
||||
this.configurationState = null;
|
||||
this.halState = null;
|
||||
call(core, "sim_protocol_reset");
|
||||
if (typeof core.ccall === "function") core.ccall("sim_event_reset", null, [], []);
|
||||
}
|
||||
@@ -97,9 +108,27 @@ export class SimSession {
|
||||
tick: this.tick,
|
||||
created: this.created,
|
||||
protocol_seq: this.timeline.events.cursor,
|
||||
devices: this.devices.export(),
|
||||
configuration: this.configurationState,
|
||||
hal: this.halState,
|
||||
python: this.python.exportState(),
|
||||
};
|
||||
}
|
||||
|
||||
workspacePackageState() {
|
||||
const value = {
|
||||
context_id: this.contextId,
|
||||
session_id: this.sessionId,
|
||||
configuration: this.configurationState,
|
||||
hal: this.halState,
|
||||
devices: this.devices.export(),
|
||||
python: this.python.exportState(),
|
||||
tick: this.tick,
|
||||
event_cursor: this.timeline.events.cursor,
|
||||
};
|
||||
return { ...value, state_hash: fnv1a64(value) };
|
||||
}
|
||||
|
||||
createCheckpoint() {
|
||||
const latest = this.timeline.snapshots.latest();
|
||||
if (!latest || latest.seq !== this.timeline.events.cursor || latest.tick !== this.timeline.events.tick) {
|
||||
@@ -119,15 +148,29 @@ export class SimSession {
|
||||
}
|
||||
|
||||
restoreCheckpoint(checkpoint, events = null, reconcile = undefined) {
|
||||
const replayDevices = new VirtualDevices({ state: checkpoint?.state?.devices });
|
||||
const replayPython = new PythonBridge();
|
||||
if (checkpoint?.state?.python) replayPython.restoreState(checkpoint.state.python);
|
||||
const result = recoverSimulation({
|
||||
checkpoint,
|
||||
contextId: this.contextId,
|
||||
events,
|
||||
reconcile,
|
||||
apply: (state, event) => ({ ...state, tick: event.tick }),
|
||||
apply: (state, event) => {
|
||||
if (DEVICE_COMMANDS.has(event.kind)) replayDevices.applyEvent(event.kind, event.payload);
|
||||
if (event.kind === "PYTHON_IMPORT") replayPython.importModule(event.payload?.module);
|
||||
if (event.kind === "PYTHON_CALL") replayPython.invoke(event.payload?.module, event.payload?.symbol, event.payload?.args ?? []);
|
||||
if (event.kind === "PYTHON_CAPABILITY") replayPython.checkCapability(event.payload?.capability);
|
||||
return { ...state, tick: event.tick, devices: replayDevices.export(), python: replayPython.exportState() };
|
||||
},
|
||||
});
|
||||
if (result.status === "recovered" || result.status === "aborted") {
|
||||
this.tick = result.simulation_tick;
|
||||
this.devices = new VirtualDevices({ state: result.state?.devices ?? checkpoint?.state?.devices });
|
||||
this.configurationState = result.state?.configuration ?? checkpoint?.state?.configuration ?? null;
|
||||
this.halState = result.state?.hal ?? checkpoint?.state?.hal ?? null;
|
||||
this.python = new PythonBridge();
|
||||
if (result.state?.python) this.python.restoreState(result.state.python);
|
||||
this.timeline = new ReplayTimeline(checkpoint.timeline);
|
||||
if (events?.schema === "replay-timeline.v1") {
|
||||
for (const event of events.events.events.slice(this.timeline.events.cursor)) this.timeline.appendEvent(event.seq, event.tick, event.kind, event.payload, event.payload_hash);
|
||||
@@ -198,6 +241,17 @@ export class SimSession {
|
||||
return response;
|
||||
};
|
||||
|
||||
const deviceResponse = (kind, payload) => {
|
||||
const result = this.devices.command(kind, payload ?? {});
|
||||
const responseKind = result.status === "verified" ? "COMMAND_RESULT" : "ERROR";
|
||||
return finish(this.envelope(responseKind, input, {
|
||||
...result,
|
||||
command: kind,
|
||||
operation_id: input.operation_id ?? null,
|
||||
simulation_tick: this.tick,
|
||||
}), result.status === "verified" ? "verified" : "failed");
|
||||
};
|
||||
|
||||
switch (input.kind) {
|
||||
case "HELLO":
|
||||
return finish(this.envelope("CAPABILITIES", input, capabilities(this.contextId)), "verified");
|
||||
@@ -211,6 +265,41 @@ export class SimSession {
|
||||
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");
|
||||
if (input.payload?.configuration) {
|
||||
const preflight = preflightConfiguration(input.payload.configuration);
|
||||
if (preflight.status !== "ready") {
|
||||
return finish(this.envelope("ERROR", input, {
|
||||
status: "blocked",
|
||||
code: "configuration_preflight",
|
||||
message_key: "configuration.preflight_blocked",
|
||||
retryable: false,
|
||||
preflight,
|
||||
}), "failed");
|
||||
}
|
||||
this.configurationState = {
|
||||
entrypoint: input.payload.configuration.entrypoint ?? null,
|
||||
status: preflight.status,
|
||||
resource_count: preflight.resource_count,
|
||||
state_hash: fnv1a64(preflight),
|
||||
};
|
||||
}
|
||||
if (input.payload?.hal) {
|
||||
const preflight = preflightHalConfiguration(input.payload.hal);
|
||||
if (preflight.status !== "ready") {
|
||||
return finish(this.envelope("ERROR", input, {
|
||||
status: "blocked",
|
||||
code: "hal_preflight",
|
||||
message_key: "hal.preflight_blocked",
|
||||
retryable: false,
|
||||
preflight,
|
||||
}), "failed");
|
||||
}
|
||||
this.halState = {
|
||||
status: preflight.status,
|
||||
record_count: preflight.record_count,
|
||||
state_hash: fnv1a64(preflight),
|
||||
};
|
||||
}
|
||||
const loadResult = call(this.core, "sim_load_workspace", rootPath);
|
||||
return loadResult === 0 ? finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, state_hash: fnv1a64({ tick: this.tick }) })) : finish(this.coreError(input, loadResult, "sim_load_workspace"), "failed");
|
||||
}
|
||||
@@ -221,11 +310,30 @@ export class SimSession {
|
||||
const stepResult = call(this.core, "sim_step", cycles);
|
||||
if (stepResult !== 0) return finish(this.coreError(input, stepResult, "sim_step"), "failed");
|
||||
this.tick += cycles;
|
||||
this.python.setTick(this.tick);
|
||||
const protocolTick = call(this.core, "sim_protocol_set_tick", this.tick);
|
||||
if (protocolTick !== 0) return finish(this.error(input, "protocol_tick", "tick_update_failed"), "failed");
|
||||
const snapshot = this.timeline.checkpoint(input.seq, this.tick, { tick: this.tick, cycles }, this.timeline.snapshots.latest()?.snapshot_id ?? null);
|
||||
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", cycles, state_hash: snapshot.state_hash, snapshot }));
|
||||
}
|
||||
case "SPINDLE_COMMAND": case "SPINDLE_FAULT": case "SPINDLE_INHIBIT":
|
||||
case "SPINDLE_ORIENT": case "SPINDLE_ORIENTED": case "IO_STEP":
|
||||
case "TOOL_PREPARE": case "TOOL_PREPARED": case "TOOL_CHANGE":
|
||||
case "TOOL_CHANGED": case "TOOL_ABORT": case "TOOL_FAULT": case "TOOL_CLEAR_FAULT":
|
||||
case "PROBE_CONFIG": case "LIMIT_SET": case "ESTOP": case "IO_RECOVER":
|
||||
return deviceResponse(input.kind, input.payload);
|
||||
case "PYTHON_IMPORT": {
|
||||
const result = this.python.importModule(input.payload?.module);
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, result), result.status === "verified" ? "verified" : "failed");
|
||||
}
|
||||
case "PYTHON_CALL": {
|
||||
const result = this.python.invoke(input.payload?.module, input.payload?.symbol, input.payload?.args ?? []);
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, result), result.status === "verified" ? "verified" : "failed");
|
||||
}
|
||||
case "PYTHON_CAPABILITY": {
|
||||
const result = this.python.checkCapability(input.payload?.capability);
|
||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, result), result.status === "verified" ? "verified" : "failed");
|
||||
}
|
||||
case "SNAPSHOT_REQUEST":
|
||||
return finish(this.envelope("STATE_SNAPSHOT", input, { snapshot_schema: 1, tick: this.tick, state_hash: fnv1a64({ tick: this.tick }), snapshot: this.timeline.snapshots.latest() }));
|
||||
case "CHECKPOINT": {
|
||||
|
||||
26
web/worker/test_configuration_preflight.mjs
Normal file
26
web/worker/test_configuration_preflight.mjs
Normal file
@@ -0,0 +1,26 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
|
||||
|
||||
let lastSeq = 0;
|
||||
const core = {
|
||||
_sim_protocol_reset() {},
|
||||
_sim_protocol_accept(...args) { if (args[1] !== lastSeq + 1) return -101; lastSeq = args[1]; return 0; },
|
||||
_sim_create() { return 0; },
|
||||
_sim_load_workspace() { return 0; },
|
||||
};
|
||||
const session = new SimSession(core);
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "config-s-1" };
|
||||
assert.equal(session.accept({ ...base, kind: "HELLO", seq: 1, payload: {} }).kind, "CAPABILITIES");
|
||||
assert.equal(session.accept({ ...base, kind: "SESSION_ATTACH", seq: 2, payload: {} }).kind, "SESSION_READY");
|
||||
const response = session.accept({ ...base, kind: "LOAD_WORKSPACE", seq: 3, operation_id: "op-config", payload: {
|
||||
root_path: "/workspace",
|
||||
configuration: {
|
||||
entrypoint: "linuxcnc/configs/sim/demo/main.ini",
|
||||
availablePaths: ["linuxcnc/configs/sim/demo/main.ini"],
|
||||
rows: [{ resource_source_id: "missing", entrypoint: "linuxcnc/configs/sim/demo/main.ini", raw_value: "missing.hal", path: "missing.hal", parse_status: "missing" }],
|
||||
},
|
||||
} });
|
||||
assert.equal(response.kind, "ERROR");
|
||||
assert.equal(response.payload.code, "configuration_preflight");
|
||||
assert.equal(response.payload.preflight.blocked_count, 1);
|
||||
console.log("configuration_preflight=passed");
|
||||
29
web/worker/test_device_recovery.mjs
Normal file
29
web/worker/test_device_recovery.mjs
Normal file
@@ -0,0 +1,29 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
|
||||
|
||||
let lastSeq = 0;
|
||||
const core = {
|
||||
_sim_protocol_reset() {},
|
||||
_sim_protocol_accept(...args) { if (args[1] !== lastSeq + 1) return -101; lastSeq = args[1]; return 0; },
|
||||
_sim_create() { return 0; },
|
||||
_sim_load_workspace() { return 0; },
|
||||
_sim_step() { return 0; },
|
||||
_sim_protocol_set_tick() { return 0; },
|
||||
};
|
||||
const session = new SimSession(core);
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "device-recovery-1" };
|
||||
session.accept({ ...base, kind: "HELLO", seq: 1, payload: {} });
|
||||
session.accept({ ...base, kind: "SESSION_ATTACH", seq: 2, payload: {} });
|
||||
session.accept({ ...base, kind: "SPINDLE_COMMAND", seq: 3, operation_id: "spindle", payload: { spindle: 0, rpm: 600, enabled: true } });
|
||||
session.accept({ ...base, kind: "IO_STEP", seq: 4, operation_id: "step-1", payload: { ticks: 5 } });
|
||||
const checkpointResponse = session.accept({ ...base, kind: "CHECKPOINT", seq: 5, operation_id: "checkpoint", payload: {} });
|
||||
assert.equal(checkpointResponse.kind, "CHECKPOINT_READY");
|
||||
const checkpoint = checkpointResponse.payload.checkpoint;
|
||||
session.accept({ ...base, kind: "SPINDLE_COMMAND", seq: 6, operation_id: "spindle-stop", payload: { spindle: 0, rpm: 0, enabled: false } });
|
||||
session.accept({ ...base, kind: "IO_STEP", seq: 7, operation_id: "step-2", payload: { ticks: 5 } });
|
||||
const expectedHash = session.devices.hash();
|
||||
const recovered = session.restoreCheckpoint(checkpoint, session.timeline.export(), (operation) => operation);
|
||||
assert.equal(recovered.status, "recovered");
|
||||
assert.equal(session.devices.hash(), expectedHash);
|
||||
assert.deepEqual(session.devices.export(), recovered.state.devices);
|
||||
console.log(`device_recovery=passed state_hash=${expectedHash}`);
|
||||
22
web/worker/test_hal_preflight.mjs
Normal file
22
web/worker/test_hal_preflight.mjs
Normal file
@@ -0,0 +1,22 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
|
||||
|
||||
let lastSeq = 0;
|
||||
const core = {
|
||||
_sim_protocol_reset() {},
|
||||
_sim_protocol_accept(...args) { if (args[1] !== lastSeq + 1) return -101; lastSeq = args[1]; return 0; },
|
||||
_sim_create() { return 0; },
|
||||
_sim_load_workspace() { return 0; },
|
||||
};
|
||||
const session = new SimSession(core);
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "hal-s-1" };
|
||||
assert.equal(session.accept({ ...base, kind: "HELLO", seq: 1, payload: {} }).kind, "CAPABILITIES");
|
||||
assert.equal(session.accept({ ...base, kind: "SESSION_ATTACH", seq: 2, payload: {} }).kind, "SESSION_READY");
|
||||
const response = session.accept({ ...base, kind: "LOAD_WORKSPACE", seq: 3, operation_id: "op-hal", payload: {
|
||||
root_path: "/workspace",
|
||||
hal: { manifest: [{ component: "demo" }], lines: ["loadrt demo names=d0", "loadusr -W external"] },
|
||||
} });
|
||||
assert.equal(response.kind, "ERROR");
|
||||
assert.equal(response.payload.code, "hal_preflight");
|
||||
assert.equal(response.payload.preflight.blocked_count, 1);
|
||||
console.log("hal_worker_preflight=passed");
|
||||
15
web/worker/test_python_bridge.mjs
Normal file
15
web/worker/test_python_bridge.mjs
Normal file
@@ -0,0 +1,15 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { PythonBridge, manifest } from "./python-bridge.mjs";
|
||||
|
||||
let loadOptions = null;
|
||||
const bridge = new PythonBridge({ loadPyodide: async (options) => { loadOptions = options; return { runPythonAsync: async (source) => source.length }; } });
|
||||
assert.equal((await bridge.initialize()).status, "verified");
|
||||
assert.equal(loadOptions.indexURL, manifest.index_url);
|
||||
assert.equal(bridge.importModule("linuxcnc").status, "verified");
|
||||
assert.equal(bridge.importModule("os").code, "python_unsupported_module");
|
||||
assert.equal(bridge.checkCapability("subprocess").status, "REAL-EXCLUDED");
|
||||
assert.equal(bridge.invoke("hal", "pin", ["demo.pin"]).status, "verified");
|
||||
assert.equal((await bridge.execute("1 + 1")).value, 5);
|
||||
assert.equal((await bridge.execute("import subprocess")).status, "REAL-EXCLUDED");
|
||||
assert.equal(bridge.restoreState(bridge.exportState()).status, "verified");
|
||||
console.log(`python_bridge=passed calls=${bridge.state.calls.length} errors=${bridge.state.errors.length} hash=${bridge.hash()}`);
|
||||
23
web/worker/test_python_session.mjs
Normal file
23
web/worker/test_python_session.mjs
Normal file
@@ -0,0 +1,23 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
|
||||
|
||||
let lastSeq = 0;
|
||||
const core = {
|
||||
_sim_protocol_reset() {},
|
||||
_sim_protocol_accept(...args) { if (args[1] !== lastSeq + 1) return -101; lastSeq = args[1]; return 0; },
|
||||
_sim_create() { return 0; },
|
||||
};
|
||||
const session = new SimSession(core);
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "python-session-1" };
|
||||
session.accept({ ...base, kind: "HELLO", seq: 1, payload: {} });
|
||||
session.accept({ ...base, kind: "SESSION_ATTACH", seq: 2, payload: {} });
|
||||
assert.equal(session.accept({ ...base, kind: "PYTHON_IMPORT", seq: 3, operation_id: "py-import", payload: { module: "hal" } }).payload.status, "verified");
|
||||
assert.equal(session.accept({ ...base, kind: "PYTHON_CALL", seq: 4, operation_id: "py-call", payload: { module: "hal", symbol: "pin", args: ["demo.pin"] } }).payload.status, "verified");
|
||||
const checkpointResponse = session.accept({ ...base, kind: "CHECKPOINT", seq: 5, operation_id: "checkpoint", payload: {} });
|
||||
const checkpoint = checkpointResponse.payload.checkpoint;
|
||||
session.accept({ ...base, kind: "PYTHON_CALL", seq: 6, operation_id: "py-call-2", payload: { module: "gstat", symbol: "state", args: [] } });
|
||||
const expected = session.python.hash();
|
||||
const result = session.restoreCheckpoint(checkpoint, session.timeline.export(), (operation) => operation);
|
||||
assert.equal(result.status, "recovered");
|
||||
assert.equal(session.python.hash(), expected);
|
||||
console.log(`python_session=passed hash=${expected}`);
|
||||
29
web/worker/test_recovery_offline_faults.mjs
Normal file
29
web/worker/test_recovery_offline_faults.mjs
Normal file
@@ -0,0 +1,29 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { MemoryStorage, WorkspaceJournal } from "../workspace/journal.mjs";
|
||||
import { verifyOfflinePackage } from "../workspace/migration.mjs";
|
||||
import { ProjectWorkflow } from "../app/project-workflow.mjs";
|
||||
import { ReplayTimeline } from "./replay.mjs";
|
||||
import { createCheckpoint, recoverSimulation } from "../workspace/recovery.mjs";
|
||||
|
||||
const contextId = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
|
||||
const storage = new MemoryStorage();
|
||||
const journal = new WorkspaceJournal(storage, { projectId: "faults" });
|
||||
journal.prepare("op-1", [{ path: "config/main.ini", data: "[EMC]\n" }]);
|
||||
journal.commit("op-1");
|
||||
const half = new MemoryStorage(Object.fromEntries(storage.entries()));
|
||||
const halfJournal = new WorkspaceJournal(half, { projectId: "faults" });
|
||||
halfJournal.prepare("op-half", [{ path: "programs/main.ngc", data: "G0 X1\n" }]);
|
||||
assert.equal(halfJournal.recover().half_commits.length, 1);
|
||||
const timeline = new ReplayTimeline();
|
||||
timeline.appendEvent(1, 0, "SESSION_READY", {});
|
||||
timeline.checkpoint(1, 0, { tick: 0 });
|
||||
const checkpoint = createCheckpoint({ contextId, timeline, state: { tick: 0 }, operations: [] });
|
||||
const damaged = structuredClone(checkpoint); damaged.state.tick = 9;
|
||||
assert.equal(recoverSimulation({ checkpoint: damaged }).status, "blocked");
|
||||
const workflow = new ProjectWorkflow({ contextId, storage: new MemoryStorage(), projectId: "offline" });
|
||||
workflow.importFiles([{ path: "config/main.ini", data: "[EMC]\n" }]);
|
||||
const pkg = workflow.package({ timeline, checkpoint, committedSeq: 1 });
|
||||
const offline = workflow.offline(pkg, [{ path: "ui/index.html", data: "<main>" }]);
|
||||
assert.throws(() => verifyOfflinePackage(offline, { contextId, workspacePackage: pkg, available: [], online: false }), /offline_resource_invalid/);
|
||||
assert.equal(verifyOfflinePackage(offline, { contextId, workspacePackage: pkg, available: [], online: true }).status, "network_required");
|
||||
console.log("recovery_offline_faults=passed scenarios=5");
|
||||
60
web/worker/test_virtual_devices.mjs
Normal file
60
web/worker/test_virtual_devices.mjs
Normal file
@@ -0,0 +1,60 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { ReplayTimeline } from "./replay.mjs";
|
||||
import { VirtualDevices } from "./virtual-devices.mjs";
|
||||
|
||||
const commands = [
|
||||
["SPINDLE_COMMAND", { spindle: 0, rpm: 1200, enabled: true }],
|
||||
["IO_STEP", { ticks: 20 }],
|
||||
["TOOL_PREPARE", { tool: 7, pocket: 3 }],
|
||||
["TOOL_PREPARED", { prepared: true }],
|
||||
["TOOL_CHANGE", {}],
|
||||
["SPINDLE_COMMAND", { spindle: 0, rpm: 0, enabled: false }],
|
||||
["IO_STEP", { ticks: 20 }],
|
||||
["TOOL_CHANGE", {}],
|
||||
["TOOL_CHANGED", { changed: true }],
|
||||
["TOOL_ABORT", { reason: 17 }],
|
||||
["TOOL_PREPARE", { tool: 7, pocket: 3 }],
|
||||
["TOOL_PREPARED", { prepared: true }],
|
||||
["TOOL_CHANGE", {}],
|
||||
["TOOL_CHANGED", { changed: true }],
|
||||
["PROBE_CONFIG", { action: "arm", probe_type: 0 }],
|
||||
["PROBE_CONFIG", { action: "update", input: true, position: { x: 1, y: 2, z: 3 } }],
|
||||
["LIMIT_SET", { name: "x-positive", active: true }],
|
||||
["TOOL_FAULT", { fault: true, reason: 42 }],
|
||||
["TOOL_CLEAR_FAULT", {}],
|
||||
["TOOL_FAULT", { fault: false, reason: 0 }],
|
||||
["TOOL_CLEAR_FAULT", {}],
|
||||
["ESTOP", { active: true }],
|
||||
["SPINDLE_COMMAND", { spindle: 0, rpm: 100, enabled: true }],
|
||||
["ESTOP", { active: false }],
|
||||
["IO_RECOVER", {}],
|
||||
];
|
||||
|
||||
function run() {
|
||||
const devices = new VirtualDevices();
|
||||
const timeline = new ReplayTimeline();
|
||||
const scenarios = [];
|
||||
let seq = 0;
|
||||
const nonfinite = devices.command("SPINDLE_COMMAND", { spindle: 0, rpm: Number.NaN, enabled: true });
|
||||
scenarios.push({ kind: "NONFINITE_INPUT", code: nonfinite.code, status: nonfinite.status, state_hash: nonfinite.state_hash });
|
||||
for (const [kind, payload] of commands) {
|
||||
seq += 1;
|
||||
const result = devices.command(kind, payload);
|
||||
timeline.appendEvent(seq, devices.state.tick, kind, payload);
|
||||
scenarios.push({ kind, code: result.code, status: result.status, state_hash: result.state_hash });
|
||||
}
|
||||
const replay = new VirtualDevices();
|
||||
for (const event of timeline.events.events) replay.applyEvent(event.kind, event.payload);
|
||||
return { schema: "virtual-device-fault-sample.v1", scenarios, events: timeline.events.cursor, state_hash: devices.hash(), replay_state_hash: replay.hash(), state_hash_equal: devices.hash() === replay.hash(), final: devices.export() };
|
||||
}
|
||||
|
||||
const first = run();
|
||||
const second = run();
|
||||
assert.equal(first.state_hash_equal, true);
|
||||
assert.equal(first.state_hash, first.replay_state_hash);
|
||||
assert.deepEqual(first, second);
|
||||
assert.equal(first.scenarios.find((row) => row.kind === "TOOL_CHANGE" && row.code === "sim_io_busy")?.status, "blocked");
|
||||
assert.equal(first.scenarios.find((row) => row.kind === "SPINDLE_COMMAND" && row.code === "sim_io_estop")?.status, "blocked");
|
||||
assert.equal(first.scenarios.find((row) => row.kind === "TOOL_CLEAR_FAULT" && row.code === "sim_io_fault")?.status, "blocked");
|
||||
assert.equal(first.scenarios.find((row) => row.kind === "NONFINITE_INPUT")?.code, "sim_io_nonfinite");
|
||||
console.log(JSON.stringify(first));
|
||||
68
web/worker/test_workspace_session_integration.mjs
Normal file
68
web/worker/test_workspace_session_integration.mjs
Normal file
@@ -0,0 +1,68 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
|
||||
import { MemoryStorage, WorkspaceJournal } from "../workspace/journal.mjs";
|
||||
import { createWorkspacePackage, migrateWorkspacePackage, verifyWorkspacePackage } from "../workspace/migration.mjs";
|
||||
import { fnv1a64 } from "./replay.mjs";
|
||||
|
||||
let lastSeq = 0;
|
||||
const core = {
|
||||
_sim_protocol_reset() {},
|
||||
_sim_protocol_accept(...args) { if (args[1] !== lastSeq + 1) return -101; lastSeq = args[1]; return 0; },
|
||||
_sim_create() { return 0; },
|
||||
_sim_load_workspace() { return 0; },
|
||||
_sim_step() { return 0; },
|
||||
_sim_protocol_set_tick() { return 0; },
|
||||
};
|
||||
const session = new SimSession(core);
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "workspace-session-1" };
|
||||
const config = { entrypoint: "linuxcnc/configs/sim/demo/main.ini", availablePaths: ["linuxcnc/configs/sim/demo/main.ini"], rows: [] };
|
||||
const hal = { manifest: [{ component: "demo" }], lines: ["loadrt demo names=d0", "addf d0 servo-thread"] };
|
||||
assert.equal(session.accept({ ...base, kind: "HELLO", seq: 1, payload: {} }).kind, "CAPABILITIES");
|
||||
assert.equal(session.accept({ ...base, kind: "SESSION_ATTACH", seq: 2, payload: {} }).kind, "SESSION_READY");
|
||||
assert.equal(session.accept({ ...base, kind: "LOAD_WORKSPACE", seq: 3, operation_id: "load", payload: { root_path: "/workspace", configuration: config, hal } }).payload.status, "verified");
|
||||
session.accept({ ...base, kind: "SPINDLE_COMMAND", seq: 4, operation_id: "spindle", payload: { spindle: 0, rpm: 600, enabled: true } });
|
||||
session.accept({ ...base, kind: "IO_STEP", seq: 5, operation_id: "step-1", payload: { ticks: 5 } });
|
||||
const checkpointResponse = session.accept({ ...base, kind: "CHECKPOINT", seq: 6, operation_id: "checkpoint", payload: {} });
|
||||
assert.equal(checkpointResponse.kind, "CHECKPOINT_READY");
|
||||
const checkpoint = checkpointResponse.payload.checkpoint;
|
||||
assert.equal(checkpoint.state.configuration.status, "ready");
|
||||
assert.equal(checkpoint.state.hal.status, "ready");
|
||||
assert.ok(checkpoint.state.devices);
|
||||
session.accept({ ...base, kind: "TOOL_PREPARE", seq: 7, operation_id: "tool", payload: { tool: 7, pocket: 3 } });
|
||||
session.accept({ ...base, kind: "TOOL_PREPARED", seq: 8, operation_id: "tool-prepared", payload: { prepared: true } });
|
||||
session.accept({ ...base, kind: "SPINDLE_COMMAND", seq: 9, operation_id: "spindle-stop", payload: { spindle: 0, rpm: 0, enabled: false } });
|
||||
session.accept({ ...base, kind: "IO_STEP", seq: 10, operation_id: "step-2", payload: { ticks: 10 } });
|
||||
session.accept({ ...base, kind: "TOOL_CHANGE", seq: 11, operation_id: "change", payload: {} });
|
||||
session.accept({ ...base, kind: "TOOL_CHANGED", seq: 12, operation_id: "changed", payload: { changed: true } });
|
||||
const expectedState = session.workspacePackageState();
|
||||
const recovery = session.restoreCheckpoint(checkpoint, session.timeline.export(), (operation) => operation);
|
||||
assert.equal(recovery.status, "recovered");
|
||||
assert.equal(session.workspacePackageState().state_hash, expectedState.state_hash);
|
||||
|
||||
const storage = new MemoryStorage();
|
||||
const journal = new WorkspaceJournal(storage, { projectId: "workspace-session" });
|
||||
journal.prepare("op-config", [{ path: "config/main.ini", data: "[EMC]\n" }]);
|
||||
journal.commit("op-config");
|
||||
const timeline = session.timeline;
|
||||
const packageV1 = createWorkspacePackage({ contextId: CONTEXT_ID, timeline, checkpoint, journal: journal.records(), committedSeq: journal.records().at(-1).commit_seq });
|
||||
const packageV2 = migrateWorkspacePackage(packageV1, 2);
|
||||
const roundTrip = migrateWorkspacePackage(packageV2, 1);
|
||||
assert.equal(verifyWorkspacePackage(packageV2, { contextId: CONTEXT_ID, expectedVersion: 2 }).format_version, 2);
|
||||
assert.deepEqual(roundTrip, packageV1);
|
||||
|
||||
const report = {
|
||||
schema: "workspace-session-integration-sample.v1",
|
||||
context_id: CONTEXT_ID,
|
||||
session_state_hash: expectedState.state_hash,
|
||||
recovery_state_hash: session.workspacePackageState().state_hash,
|
||||
checkpoint_hash: checkpoint.checkpoint_hash,
|
||||
package_v1_hash: packageV1.package_hash,
|
||||
package_v2_hash: packageV2.package_hash,
|
||||
round_trip_equal: roundTrip.package_hash === packageV1.package_hash,
|
||||
event_cursor: session.timeline.events.cursor,
|
||||
journal_hash: fnv1a64(journal.records()),
|
||||
operation_count: session.operations.size,
|
||||
};
|
||||
assert.equal(report.session_state_hash, report.recovery_state_hash);
|
||||
assert.equal(report.round_trip_equal, true);
|
||||
console.log(JSON.stringify(report));
|
||||
219
web/worker/virtual-devices.mjs
Normal file
219
web/worker/virtual-devices.mjs
Normal file
@@ -0,0 +1,219 @@
|
||||
import { fnv1a64 } from "./replay.mjs";
|
||||
|
||||
export const IO_CODES = Object.freeze({
|
||||
ok: "sim_io_ok",
|
||||
argument: "sim_io_argument",
|
||||
range: "sim_io_range",
|
||||
state: "sim_io_state",
|
||||
not_ready: "sim_io_not_ready",
|
||||
estop: "sim_io_estop",
|
||||
fault: "sim_io_fault",
|
||||
limit: "sim_io_limit",
|
||||
nonfinite: "sim_io_nonfinite",
|
||||
busy: "sim_io_busy",
|
||||
probe: "sim_io_probe",
|
||||
timeout: "sim_io_timeout",
|
||||
recovery: "sim_io_recovery",
|
||||
});
|
||||
|
||||
export const DEVICE_COMMANDS = Object.freeze(new Set([
|
||||
"SPINDLE_COMMAND", "SPINDLE_FAULT", "SPINDLE_INHIBIT", "SPINDLE_ORIENT", "SPINDLE_ORIENTED", "IO_STEP",
|
||||
"TOOL_PREPARE", "TOOL_PREPARED", "TOOL_CHANGE", "TOOL_CHANGED", "TOOL_ABORT", "TOOL_FAULT", "TOOL_CLEAR_FAULT",
|
||||
"PROBE_CONFIG", "LIMIT_SET", "ESTOP", "IO_RECOVER",
|
||||
]));
|
||||
|
||||
function clone(value) {
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
function finite(value) {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function spindleState() {
|
||||
return {
|
||||
command_rpm: 0,
|
||||
speed_rpm: 0,
|
||||
at_speed: true,
|
||||
enabled: false,
|
||||
inhibit: false,
|
||||
amp_fault: false,
|
||||
brake: false,
|
||||
orient: false,
|
||||
locked: false,
|
||||
is_oriented: false,
|
||||
orient_angle: 0,
|
||||
orient_mode: 0,
|
||||
orient_fault: 0,
|
||||
index_enable: false,
|
||||
revs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultState(spindleCount, toolCapacity) {
|
||||
return {
|
||||
ready: true,
|
||||
estop: false,
|
||||
fault: false,
|
||||
fault_reason: 0,
|
||||
tick: 0,
|
||||
recovery_epoch: 0,
|
||||
spindle_count: spindleCount,
|
||||
tool_capacity: toolCapacity,
|
||||
spindles: Array.from({ length: spindleCount }, spindleState),
|
||||
tool: { state: "idle", current: 0, prepared: null, pocket: null, prepare: false, prepared_ack: false, changed: false, fault: false, reason: 0 },
|
||||
probe: { state: "idle", type: 0, input: false, tripped: false, tick: 0, position: null },
|
||||
limits: {},
|
||||
last_error: IO_CODES.ok,
|
||||
};
|
||||
}
|
||||
|
||||
export class VirtualDevices {
|
||||
constructor({ spindleCount = 1, toolCapacity = 1001, state = null } = {}) {
|
||||
if (!Number.isSafeInteger(spindleCount) || spindleCount < 1 || spindleCount > 8) throw new Error("sim_io_spindle_capacity");
|
||||
if (!Number.isSafeInteger(toolCapacity) || toolCapacity < 1 || toolCapacity > 1001) throw new Error("sim_io_tool_capacity");
|
||||
this.state = state ? clone(state) : defaultState(spindleCount, toolCapacity);
|
||||
}
|
||||
|
||||
export() { return clone(this.state); }
|
||||
hash() { return fnv1a64(this.state); }
|
||||
result(code = IO_CODES.ok, extra = {}) {
|
||||
this.state.last_error = code;
|
||||
return { status: code === IO_CODES.ok ? "verified" : "blocked", code, state_hash: this.hash(), state: this.export(), ...extra };
|
||||
}
|
||||
|
||||
spindle(payload, action = "command") {
|
||||
const index = payload?.spindle ?? 0;
|
||||
const spindle = this.state.spindles[index];
|
||||
if (!spindle) return this.result(IO_CODES.argument);
|
||||
if (action === "command") {
|
||||
const rpm = payload?.rpm;
|
||||
if (!finite(rpm) || typeof payload?.enabled !== "boolean") return this.result(IO_CODES.nonfinite);
|
||||
if (Math.abs(rpm) > 100000) return this.result(IO_CODES.limit);
|
||||
if (this.state.estop) return this.result(IO_CODES.estop);
|
||||
if (!this.state.ready) return this.result(IO_CODES.not_ready);
|
||||
spindle.command_rpm = payload.enabled && !spindle.inhibit && !spindle.amp_fault ? rpm : 0;
|
||||
spindle.enabled = payload.enabled;
|
||||
} else if (action === "fault" || action === "inhibit") {
|
||||
if (typeof payload?.value !== "boolean") return this.result(IO_CODES.argument);
|
||||
spindle[action === "fault" ? "amp_fault" : "inhibit"] = payload.value;
|
||||
if (payload.value) spindle.command_rpm = 0;
|
||||
} else if (action === "orient") {
|
||||
if (!finite(payload?.angle) || !Number.isSafeInteger(payload?.mode) || payload.mode < 0 || payload.mode > 2) return this.result(IO_CODES.range);
|
||||
if (this.state.estop) return this.result(IO_CODES.estop);
|
||||
if (Math.abs(spindle.speed_rpm) > 5 || spindle.command_rpm !== 0) return this.result(IO_CODES.busy);
|
||||
spindle.orient = true; spindle.locked = false; spindle.is_oriented = false; spindle.orient_angle = payload.angle; spindle.orient_mode = payload.mode; spindle.orient_fault = 0;
|
||||
} else if (action === "oriented") {
|
||||
if (typeof payload?.oriented !== "boolean") return this.result(IO_CODES.argument);
|
||||
if (!spindle.orient) return this.result(IO_CODES.state);
|
||||
spindle.is_oriented = payload.oriented;
|
||||
spindle.orient_fault = payload.fault ?? 0;
|
||||
if (payload.fault) { spindle.orient = false; spindle.locked = false; }
|
||||
else if (payload.oriented) { spindle.orient = false; spindle.locked = true; }
|
||||
}
|
||||
return this.result();
|
||||
}
|
||||
|
||||
step(ticks) {
|
||||
if (!Number.isSafeInteger(ticks) || ticks < 0 || ticks > 1000000) return this.result(IO_CODES.range);
|
||||
for (let tick = 0; tick < ticks; tick += 1) {
|
||||
this.state.tick += 1;
|
||||
for (const spindle of this.state.spindles) {
|
||||
const target = this.state.ready && !this.state.estop && !spindle.amp_fault && !spindle.inhibit ? spindle.command_rpm : 0;
|
||||
const delta = 60;
|
||||
if (spindle.speed_rpm < target) spindle.speed_rpm = Math.min(target, spindle.speed_rpm + delta);
|
||||
else if (spindle.speed_rpm > target) spindle.speed_rpm = Math.max(target, spindle.speed_rpm - delta);
|
||||
spindle.at_speed = Math.abs(spindle.speed_rpm - target) <= 5;
|
||||
spindle.brake = target === 0 && Math.abs(spindle.speed_rpm) > 5;
|
||||
spindle.revs += spindle.speed_rpm * 0.1 / 60;
|
||||
if (spindle.orient && spindle.at_speed && spindle.speed_rpm === 0) { spindle.orient = false; spindle.locked = true; spindle.is_oriented = true; }
|
||||
}
|
||||
}
|
||||
return this.result(IO_CODES.ok, { ticks });
|
||||
}
|
||||
|
||||
toolAction(kind, payload = {}) {
|
||||
const tool = this.state.tool;
|
||||
if (kind === "TOOL_PREPARE") {
|
||||
if (!Number.isSafeInteger(payload.tool) || !Number.isSafeInteger(payload.pocket) || payload.tool < 0 || payload.pocket <= 0 || payload.pocket >= this.state.tool_capacity) return this.result(IO_CODES.range);
|
||||
if (this.state.estop) return this.result(IO_CODES.estop);
|
||||
if (!this.state.ready) return this.result(IO_CODES.not_ready);
|
||||
if (this.state.fault) return this.result(IO_CODES.fault);
|
||||
Object.assign(tool, { state: "preparing", prepared: payload.tool, pocket: payload.pocket, prepare: true, prepared_ack: false, changed: false });
|
||||
} else if (kind === "TOOL_PREPARED") {
|
||||
if (tool.state !== "preparing") return this.result(IO_CODES.state);
|
||||
if (payload.prepared !== true) return this.result();
|
||||
Object.assign(tool, { state: "prepared", prepare: false, prepared_ack: true });
|
||||
} else if (kind === "TOOL_CHANGE") {
|
||||
if (this.state.estop) return this.result(IO_CODES.estop);
|
||||
if (!this.state.ready) return this.result(IO_CODES.not_ready);
|
||||
if (this.state.fault) return this.result(IO_CODES.fault);
|
||||
if (tool.state !== "prepared" || !tool.prepared_ack) return this.result(IO_CODES.state);
|
||||
if (this.state.spindles.some((spindle) => Math.abs(spindle.speed_rpm) > 5 || spindle.command_rpm !== 0)) return this.result(IO_CODES.busy);
|
||||
Object.assign(tool, { state: "changing", changed: false });
|
||||
} else if (kind === "TOOL_CHANGED") {
|
||||
if (tool.state !== "changing") return this.result(IO_CODES.state);
|
||||
if (payload.changed !== true) return this.result();
|
||||
Object.assign(tool, { state: "ready", current: tool.prepared, changed: true, prepared_ack: false });
|
||||
} else if (kind === "TOOL_ABORT") {
|
||||
Object.assign(tool, { state: "aborted", prepare: false, prepared_ack: false, changed: false });
|
||||
this.state.last_error = IO_CODES.recovery;
|
||||
} else if (kind === "TOOL_FAULT") {
|
||||
if (typeof payload.fault !== "boolean") return this.result(IO_CODES.argument);
|
||||
tool.fault = payload.fault; tool.reason = payload.reason ?? 0; this.state.fault = payload.fault; this.state.fault_reason = tool.reason;
|
||||
if (payload.fault) Object.assign(tool, { state: "fault", prepare: false, prepared_ack: false, changed: false });
|
||||
} else if (kind === "TOOL_CLEAR_FAULT") {
|
||||
if (tool.fault) return this.result(IO_CODES.fault);
|
||||
this.state.fault = false; this.state.fault_reason = 0;
|
||||
if (tool.state === "fault" || tool.state === "aborted") tool.state = "idle";
|
||||
}
|
||||
return this.result();
|
||||
}
|
||||
|
||||
probeAction(payload = {}) {
|
||||
const probe = this.state.probe;
|
||||
if (payload.action === "arm") {
|
||||
if (this.state.estop) return this.result(IO_CODES.estop);
|
||||
if (!this.state.ready) return this.result(IO_CODES.not_ready);
|
||||
Object.assign(probe, { state: "armed", type: payload.probe_type ?? 0, input: false, tripped: false, tick: this.state.tick, position: null });
|
||||
} else if (payload.action === "update") {
|
||||
const position = payload.position;
|
||||
if (!position || !Object.values(position).every(finite)) return this.result(IO_CODES.nonfinite);
|
||||
probe.input = Boolean(payload.input);
|
||||
if (probe.state === "armed" && probe.input) Object.assign(probe, { state: "tripped", tripped: true, tick: this.state.tick, position: clone(position) });
|
||||
} else if (payload.action === "disarm") {
|
||||
Object.assign(probe, { state: "idle", type: 0, tripped: false });
|
||||
} else return this.result(IO_CODES.argument);
|
||||
return this.result();
|
||||
}
|
||||
|
||||
command(kind, payload = {}) {
|
||||
if (kind === "SPINDLE_COMMAND") return this.spindle(payload, "command");
|
||||
if (kind === "SPINDLE_FAULT") return this.spindle(payload, "fault");
|
||||
if (kind === "SPINDLE_INHIBIT") return this.spindle(payload, "inhibit");
|
||||
if (kind === "SPINDLE_ORIENT") return this.spindle(payload, "orient");
|
||||
if (kind === "SPINDLE_ORIENTED") return this.spindle(payload, "oriented");
|
||||
if (kind === "IO_STEP") return this.step(payload.ticks);
|
||||
if (kind.startsWith("TOOL_")) return this.toolAction(kind, payload);
|
||||
if (kind === "PROBE_CONFIG") return this.probeAction(payload);
|
||||
if (kind === "LIMIT_SET") {
|
||||
if (typeof payload.name !== "string" || typeof payload.active !== "boolean") return this.result(IO_CODES.argument);
|
||||
this.state.limits[payload.name] = payload.active; return this.result();
|
||||
}
|
||||
if (kind === "ESTOP") {
|
||||
if (typeof payload.active !== "boolean") return this.result(IO_CODES.argument);
|
||||
this.state.estop = payload.active;
|
||||
if (payload.active) { this.state.ready = false; this.state.tool.state = "aborted"; for (const spindle of this.state.spindles) { spindle.command_rpm = 0; spindle.enabled = false; spindle.brake = true; } return this.result(IO_CODES.estop); }
|
||||
return this.result();
|
||||
}
|
||||
if (kind === "IO_RECOVER") {
|
||||
if (this.state.estop) return this.result(IO_CODES.estop);
|
||||
if (this.state.fault || this.state.tool.fault) return this.result(IO_CODES.fault);
|
||||
if (this.state.spindles.some((spindle) => Math.abs(spindle.speed_rpm) > 5 || spindle.command_rpm !== 0)) return this.result(IO_CODES.busy);
|
||||
this.state.ready = true; this.state.recovery_epoch += 1; this.state.tool.state = "idle"; return this.result();
|
||||
}
|
||||
return this.result(IO_CODES.argument);
|
||||
}
|
||||
|
||||
applyEvent(kind, payload) { return this.command(kind, payload); }
|
||||
}
|
||||
Reference in New Issue
Block a user