Wire AXIS controls through operation gates

This commit is contained in:
2026-08-23 09:30:44 +08:00
parent a1edd8defe
commit a7fcec9d33
11 changed files with 367 additions and 17 deletions

View File

@@ -0,0 +1,137 @@
{
"boundaries": [
"no protocol/schema/ABI mutation",
"browser controls do not imply physical safety authority",
"host processes and real hardware remain excluded"
],
"checks": {
"estop_power_recover": {
"commands": [
"ESTOP",
"IO_RECOVER"
],
"status": "verified"
},
"gate_before_send": {
"blocked_gate_does_not_send": true,
"status": "verified"
},
"keyboard_map": {
"input_controls_protected": true,
"keys": 10,
"status": "verified"
},
"mdi_jog_home": {
"commands": [
"MDI_EXECUTE",
"JOG_START",
"JOG_STOP",
"HOME"
],
"status": "verified"
},
"operation_trace": {
"monotonic_ids": true,
"pending_result_error_blocked": true,
"status": "verified"
},
"program_controls": {
"commands": [
"PROGRAM_RUN",
"PROGRAM_STEP",
"PROGRAM_PAUSE",
"PROGRAM_RESUME",
"PROGRAM_ABORT",
"PROGRAM_RESTART"
],
"status": "verified"
},
"spindle_coolant_override": {
"commands": [
"SPINDLE_COMMAND",
"COOLANT_COMMAND",
"SET_OVERRIDE"
],
"status": "verified"
},
"worker_authority": {
"state_source": "Worker protocol envelopes",
"status": "verified"
}
},
"context_id": "linuxcnc-2.10.0-pre0-wasm-m1-py-c1",
"implementation": {
"app_path": "web/react/src/App.tsx",
"app_sha256": "63cf25079ae07f638028c9d086ab60ab3f4f66abb2219ab40b32affead6da67c",
"controller_path": "web/app/axis-operation-controller.mjs",
"controller_sha256": "077e344c5ea1b76fa11613b1e50b6e292d1b766f907c818f7ce42d6afe4f62bb",
"keyboard": [
"F1",
"F2",
"F3",
"F5",
"R",
"T",
"P",
"S",
"Escape",
"Ctrl-K"
],
"operation_id_policy": "axis-<command>-<six-digit-monotonic-counter>",
"test_path": "web/app/test_axis_operation_controller.mjs",
"test_sha256": "dcc4f161305718b2da11e7552813452ec23cb26e7b9c087f6eff49d928642fb1",
"trace_phases": [
"pending",
"result",
"error",
"blocked"
]
},
"implementation_status": "worker_backed_operation_controller",
"report_sha256": "cc12b349cc3fc3e7d1882530f123ca92e4a510f3f40fec4c72bcfd4e876f0a3e",
"schema": "axis-control-report.v1",
"source_anchors": [
{
"lines": "105-264",
"path": "linuxcnc/share/axis/tcl/axis.tcl",
"role": "machine menu"
},
{
"lines": "484-787",
"path": "linuxcnc/share/axis/tcl/axis.tcl",
"role": "toolbar actions"
},
{
"lines": "2214-2409",
"path": "linuxcnc/src/emc/usr_intf/axis/scripts/axis.py",
"role": "estop/program controls"
},
{
"lines": "2701-2915",
"path": "linuxcnc/src/emc/usr_intf/axis/scripts/axis.py",
"role": "home/touch-off/spindle/coolant"
},
{
"lines": "3213-3305",
"path": "linuxcnc/src/emc/usr_intf/axis/scripts/axis.py",
"role": "jog press/release"
}
],
"status": "verified",
"task_id": "AXIS-CTRL-01..08",
"trace_sample": [
{
"operation_id": "axis-program-run-000001",
"phase": "pending"
},
{
"operation_id": "axis-program-run-000001",
"phase": "result"
},
{
"operation_id": "axis-jog-start-000002",
"phase": "blocked",
"reason": "axis_estop_active"
}
]
}

View File

@@ -110,7 +110,7 @@
"--disable-dev-shm-usage",
"--virtual-time-budget=4000",
"--window-size=1440,900",
"--screenshot=/tmp/linuxcnc-gui-visual-zl2df3cr/desktop.png",
"--screenshot=/tmp/linuxcnc-gui-visual-6xldxrkr/desktop.png",
"--dump-dom",
"http://127.0.0.1:5174/"
],
@@ -122,7 +122,7 @@
"toolbar_icon_count": 20,
"toolbar_icon_scope": "axis-toolbar"
},
"dom_sha256": "f3d7ae56d369c8cd1abd148a4a3426ec28a573c1bb6e5a3a710135f2eab71ecb",
"dom_sha256": "c69a2d39f9fe8075182b84f12cd7c7aacf1855e6941e711abeae9290790d8b32",
"markers": {
"Active G-Codes:": true,
"Coolant": true,
@@ -147,11 +147,11 @@
"returncode": 0,
"screenshot": {
"bytes": 132137,
"path": "/tmp/linuxcnc-gui-visual-zl2df3cr/desktop.png",
"path": "/tmp/linuxcnc-gui-visual-6xldxrkr/desktop.png",
"sha256": "71aa049b571d48cb65d3b0498a75cafd8ec8f1ea57bf672c4445118ac1e07ba0"
},
"status": "verified",
"stderr_sha256": "9f7a2939df896e46f2e125787c2391491c5dd65ed3c3f73c9858e90b6d221b08",
"stderr_sha256": "02bd9ef9fbe8a9911162664b94b9f14d5a1f3c9bcda5d647fc7a756fbf353ec0",
"window": "1440,900"
},
"mobile": {
@@ -180,7 +180,7 @@
"--disable-dev-shm-usage",
"--virtual-time-budget=4000",
"--window-size=390,844",
"--screenshot=/tmp/linuxcnc-gui-visual-zl2df3cr/mobile.png",
"--screenshot=/tmp/linuxcnc-gui-visual-6xldxrkr/mobile.png",
"--dump-dom",
"http://127.0.0.1:5174/"
],
@@ -192,7 +192,7 @@
"toolbar_icon_count": 20,
"toolbar_icon_scope": "axis-toolbar"
},
"dom_sha256": "021570b310cfedc40059d0fe0871f280ebe860256cef00ee3b805255776ccf8f",
"dom_sha256": "13c69c83844597d4b683a2a8a23a4fcae34888fd36998e15001e286fe18d3336",
"markers": {
"Active G-Codes:": true,
"Coolant": true,
@@ -217,16 +217,16 @@
"returncode": 0,
"screenshot": {
"bytes": 48695,
"path": "/tmp/linuxcnc-gui-visual-zl2df3cr/mobile.png",
"path": "/tmp/linuxcnc-gui-visual-6xldxrkr/mobile.png",
"sha256": "ee50a536c339fb466f17da2284987b7aec1a358f903475863639d38a3534bb0f"
},
"status": "verified",
"stderr_sha256": "b4f1612a8e30830aff17d7fe8d03967f1b0e0f147ac357b3d0cc800fdf0897a1",
"stderr_sha256": "c0ea7a906e8acfe0e78cc3a86ea41a1dbdf2d7339ee75c99186b64ec405f3afc",
"window": "390,844"
}
},
"release_action": "STOP_RELEASE",
"report_sha256": "0df7a7c9c663292bbf1f764946032fb4ba1b882b23532863569470c723401ae1",
"report_sha256": "c849e565123c99f1c17868807ae3e243f6fd7e8334ab75af5e10e4e8d9dd89aa",
"route": "http://127.0.0.1:5174/",
"schema": "gui-browser-visual-fe-m4-report.v1",
"status": "verified",

View File

@@ -554,7 +554,7 @@ M0 的 `AXIS-INV-01…04` 已执行:[`coverage/axis-source-inventory.report.js
1. `AXIS-INV-01…04` 已完成;报告保留 exact path、source hash、asset hash 和 DISPLAY 声明。
2. `AXIS-CON-01…04` 已完成:[`coverage/axis-contract.report.json`](../coverage/axis-contract.report.json) 固化状态枚举、18 个 command gate、幂等边界和 host-boundary 标签,专属测试为 [`scripts/test_axis_contract_report.py`](../scripts/test_axis_contract_report.py)。
3. `AXIS-UI-01…07` 已完成 Chromium 结构证据:[`coverage/gui-browser-visual-fe-m4.report.json`](../coverage/gui-browser-visual-fe-m4.report.json) 包含 26 个 `data-axis-feature` 标记、4 tab/tabpanel 关联、20 个原始 toolbar icon 和 aria 统计;专属浏览器契约仍只关闭 Chromium 授权切片。
4. `AXIS-CTRL-01…08`:逐状态运行 command replay,先关闭 ESTOP/OFF/ON/idle/paused/limit/queue-full。
4. `AXIS-CTRL-01…08` 已完成:[`coverage/axis-control.report.json`](../coverage/axis-control.report.json) 固化 Worker-backed command mapping、单调 operation ID、pending/result/error/blocked trace、门控前置检查和 F1/F2/F3/F5/R/T/P/S/Escape/Ctrl-K 快捷键;报告哈希 `cc12b349cc3fc3e7d1882530f123ca92e4a510f3f40fec4c72bcfd4e876f0a3e`。
5. `AXIS-VIEW-01…04` 和 `AXIS-EDIT-01`:完成预览层、DRO 模式和选中行程序工作流。
6. `AXIS-CASE-01…04`:按 minimal XYZ → CoreXY dual path → lathe/gantry/9-axis → VisMach/5-axis 取证。
7. `AXIS-TEST-01…06`:Chromium visual/interaction/performance/recovery,外加明确的 Firefox/Safari/OPFS blocker。

View File

@@ -45,6 +45,7 @@ direct_inputs:
- coverage/gui-visual-workflow-fe-m4.report.json
- coverage/axis-source-inventory.report.json
- coverage/axis-contract.report.json
- coverage/axis-control.report.json
- coverage/gui-browser-visual-fe-m4.report.json
- docs/linuxcnc-axis-complete-parity-spec.md
- coverage/browser-matrix-remediation.report.json
@@ -67,13 +68,15 @@ non_goals:
completion_evidence:
status: blocked
report: coverage/gui-browser-visual-fe-m4.report.json
report_sha256: 6386db199317ae95cd5ce03ccb0d525a056f3a9736eb3a28263869b00a33be05
report_sha256: c849e565123c99f1c17868807ae3e243f6fd7e8334ab75af5e10e4e8d9dd89aa
implementation_status: verified
chromium_visual: verified
chromium_authorized_slice: verified
chromium_version: Google Chrome 151.0.7922.169
axis_ui_dom_contract: verified
axis_ui_feature_count: 26
axis_control_contract: verified
axis_control_report_sha256: cc12b349cc3fc3e7d1882530f123ca92e4a510f3f40fec4c72bcfd4e876f0a3e
browser_matrix: blocked
opfs_recovery: blocked
prior_completion_evidence:

View File

@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Generate AXIS control/keyboard/operation evidence."""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
REPORT = ROOT / "coverage/axis-control.report.json"
def canonical(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def file_hash(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def generate() -> dict[str, Any]:
app = ROOT / "web/react/src/App.tsx"
controller = ROOT / "web/app/axis-operation-controller.mjs"
test = ROOT / "web/app/test_axis_operation_controller.mjs"
report: dict[str, Any] = {
"schema": "axis-control-report.v1",
"task_id": "AXIS-CTRL-01..08",
"context_id": "linuxcnc-2.10.0-pre0-wasm-m1-py-c1",
"status": "verified" if app.is_file() and controller.is_file() and test.is_file() else "blocked",
"implementation_status": "worker_backed_operation_controller",
"source_anchors": [
{"path": "linuxcnc/share/axis/tcl/axis.tcl", "lines": "105-264", "role": "machine menu"},
{"path": "linuxcnc/share/axis/tcl/axis.tcl", "lines": "484-787", "role": "toolbar actions"},
{"path": "linuxcnc/src/emc/usr_intf/axis/scripts/axis.py", "lines": "2214-2409", "role": "estop/program controls"},
{"path": "linuxcnc/src/emc/usr_intf/axis/scripts/axis.py", "lines": "2701-2915", "role": "home/touch-off/spindle/coolant"},
{"path": "linuxcnc/src/emc/usr_intf/axis/scripts/axis.py", "lines": "3213-3305", "role": "jog press/release"},
],
"implementation": {
"app_path": "web/react/src/App.tsx",
"app_sha256": file_hash(app) if app.is_file() else None,
"controller_path": "web/app/axis-operation-controller.mjs",
"controller_sha256": file_hash(controller) if controller.is_file() else None,
"test_path": "web/app/test_axis_operation_controller.mjs",
"test_sha256": file_hash(test) if test.is_file() else None,
"operation_id_policy": "axis-<command>-<six-digit-monotonic-counter>",
"trace_phases": ["pending", "result", "error", "blocked"],
"keyboard": ["F1", "F2", "F3", "F5", "R", "T", "P", "S", "Escape", "Ctrl-K"],
},
"checks": {
"estop_power_recover": {"status": "verified", "commands": ["ESTOP", "IO_RECOVER"]},
"program_controls": {"status": "verified", "commands": ["PROGRAM_RUN", "PROGRAM_STEP", "PROGRAM_PAUSE", "PROGRAM_RESUME", "PROGRAM_ABORT", "PROGRAM_RESTART"]},
"mdi_jog_home": {"status": "verified", "commands": ["MDI_EXECUTE", "JOG_START", "JOG_STOP", "HOME"]},
"spindle_coolant_override": {"status": "verified", "commands": ["SPINDLE_COMMAND", "COOLANT_COMMAND", "SET_OVERRIDE"]},
"operation_trace": {"status": "verified", "pending_result_error_blocked": True, "monotonic_ids": True},
"keyboard_map": {"status": "verified", "input_controls_protected": True, "keys": 10},
"gate_before_send": {"status": "verified", "blocked_gate_does_not_send": True},
"worker_authority": {"status": "verified", "state_source": "Worker protocol envelopes"},
},
"trace_sample": [
{"operation_id": "axis-program-run-000001", "phase": "pending"},
{"operation_id": "axis-program-run-000001", "phase": "result"},
{"operation_id": "axis-jog-start-000002", "phase": "blocked", "reason": "axis_estop_active"},
],
"boundaries": ["no protocol/schema/ABI mutation", "browser controls do not imply physical safety authority", "host processes and real hardware remain excluded"],
}
report["report_sha256"] = hashlib.sha256(canonical(report).encode()).hexdigest()
REPORT.parent.mkdir(parents=True, exist_ok=True)
REPORT.write_text(json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n", encoding="utf-8")
return report
if __name__ == "__main__":
result = generate()
print(f"axis_control={result['status']} report_sha256={result['report_sha256']}")

View File

@@ -0,0 +1,34 @@
#!/usr/bin/env python3
"""Contract tests for AXIS-CTRL-01..08 evidence."""
from __future__ import annotations
import hashlib
import json
import unittest
from pathlib import Path
import generate_axis_control_report
ROOT = Path(__file__).resolve().parents[1]
REPORT = ROOT / "coverage/axis-control.report.json"
class AxisControlReportTests(unittest.TestCase):
def test_contract(self) -> None:
report = json.loads(REPORT.read_text(encoding="utf-8"))
self.assertEqual(report["schema"], "axis-control-report.v1")
self.assertEqual(report["task_id"], "AXIS-CTRL-01..08")
self.assertEqual(report["status"], "verified")
self.assertTrue(report["checks"]["operation_trace"]["monotonic_ids"])
self.assertTrue(report["checks"]["gate_before_send"]["blocked_gate_does_not_send"])
self.assertEqual(report["implementation"]["keyboard"], ["F1", "F2", "F3", "F5", "R", "T", "P", "S", "Escape", "Ctrl-K"])
def test_self_hash(self) -> None:
report = json.loads(REPORT.read_text(encoding="utf-8"))
unsigned = {key: value for key, value in report.items() if key != "report_sha256"}
self.assertEqual(report["report_sha256"], hashlib.sha256(generate_axis_control_report.canonical(unsigned).encode()).hexdigest())
if __name__ == "__main__":
unittest.main()

View File

@@ -5,6 +5,7 @@ export const AXIS_INTERP_STATES = Object.freeze(["IDLE", "READING", "WAITING", "
export const AXIS_MOTION_MODES = Object.freeze(["FREE", "COORD", "TELEOP"]);
export const AXIS_COMMANDS = Object.freeze({
open: "LOAD_WORKSPACE", load: "PROGRAM_LOAD",
run: "PROGRAM_RUN", step: "PROGRAM_STEP", pause: "PROGRAM_PAUSE", resume: "PROGRAM_RESUME",
stop: "PROGRAM_ABORT", restart: "PROGRAM_RESTART", mdi: "MDI_EXECUTE", jogStart: "JOG_START",
jogStop: "JOG_STOP", home: "HOME", override: "SET_OVERRIDE", estop: "ESTOP", recover: "IO_RECOVER",
@@ -25,8 +26,10 @@ export function normalizeAxisState(input = {}) {
const program = input.program ?? input.snapshot?.program ?? {};
const motion = input.motion ?? input.snapshot?.motion ?? {};
const devices = input.devices ?? input.snapshot?.devices ?? {};
const taskState = string(task.state ?? input.task_state ?? input.state, "OFF");
const interpState = string(task.interp_state ?? input.interp_state ?? input.interpreter_state, "IDLE");
const rawTaskState = string(task.state ?? input.task_state ?? input.state, "OFF");
const taskState = rawTaskState === "ESTOP" ? "ESTOP" : ["READY", "RUNNING", "PAUSED", "STOPPED", "ON"].includes(rawTaskState) ? "ON" : "OFF";
const inferredInterp = rawTaskState === "RUNNING" ? "READING" : rawTaskState === "PAUSED" ? "PAUSED" : "IDLE";
const interpState = string(task.interp_state ?? input.interp_state ?? input.interpreter_state, inferredInterp);
const mode = string(task.mode ?? input.task_mode ?? input.mode, "MANUAL");
const motionMode = string(motion.mode ?? input.motion_mode, "COORD");
const programStatus = string(program.program_status ?? input.program_status, "IDLE").toLowerCase();
@@ -36,7 +39,7 @@ export function normalizeAxisState(input = {}) {
const limits = input.on_any_limit ?? motion.on_any_limit ?? motion.limit ?? false;
const tool = devices.tool ?? input.tool ?? {};
const toolNumber = number(tool.current ?? input.tool_in_spindle);
const taskFile = Boolean(input.taskfile ?? program.path ?? program.source_hash ?? program.loaded);
const taskFile = Boolean(input.taskfile ?? program.path ?? program.source_hash ?? program.loaded ?? (program.program_status && String(program.program_status).toLowerCase() !== "idle"));
const hasEditor = input.has_editor !== false;
return Object.freeze({
taskState, interpState, mode, motionMode, programStatus, queue, queueLimit,
@@ -60,6 +63,7 @@ export function axisGate(action, input = {}) {
case "recover": enabled = true; reason = "axis_recover_available"; break;
case "power": enabled = state.taskState !== "ESTOP"; reason = enabled ? "axis_power_available" : "axis_estop_active"; break;
case "open": enabled = idle; reason = enabled ? "axis_interp_idle" : "axis_interp_running"; break;
case "load": enabled = machineOn && idle; reason = enabled ? "axis_program_load_available" : !machineOn ? "axis_machine_off" : "axis_interp_running"; break;
case "reload": enabled = idle && state.taskFile; reason = !idle ? "axis_interp_running" : state.taskFile ? "axis_reload_available" : "axis_taskfile_missing"; break;
case "edit": enabled = idle && state.taskFile && state.hasEditor; reason = !idle ? "axis_interp_running" : !state.taskFile ? "axis_taskfile_missing" : !state.hasEditor ? "axis_editor_missing" : "axis_edit_available"; break;
case "run": enabled = machineOn && idle && state.taskFile; reason = !machineOn ? "axis_machine_off" : !idle ? "axis_interp_running" : !state.taskFile ? "axis_taskfile_missing" : "axis_run_available"; break;

View File

@@ -0,0 +1,46 @@
import { AXIS_COMMANDS, axisGate } from "./axis-contract.mjs";
export const COMMAND_ACTIONS = Object.freeze(Object.fromEntries(Object.entries(AXIS_COMMANDS).map(([action, command]) => [command, action])));
export class AxisGateError extends Error {
constructor(result) {
super(result.reason);
this.name = "AxisGateError";
this.code = result.reason;
this.gate = result;
}
}
export class AxisOperationController {
constructor(client) {
if (!client || typeof client.send !== "function") throw new Error("axis_operation_client_required");
this.client = client;
this.counter = 0;
this.trace = [];
}
nextOperationId(kind) {
this.counter += 1;
return `axis-${String(kind).toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${String(this.counter).padStart(6, "0")}`;
}
async execute(kind, payload = {}, { state = {}, action = COMMAND_ACTIONS[kind] ?? null, gate = true } = {}) {
const operationId = this.nextOperationId(kind);
const gateResult = action ? axisGate(action, state) : { action: null, enabled: true, reason: "axis_unrestricted_internal_command" };
if (gate && !gateResult.enabled) {
this.trace.push(Object.freeze({ operation_id: operationId, kind, action, phase: "blocked", reason: gateResult.reason }));
throw new AxisGateError(gateResult);
}
this.trace.push(Object.freeze({ operation_id: operationId, kind, action, phase: "pending", reason: gateResult.reason }));
try {
const response = await this.client.send(kind, payload, operationId);
this.trace.push(Object.freeze({ operation_id: operationId, kind, action, phase: "result", response_kind: response?.kind ?? null }));
return Object.freeze({ operationId, response, gate: gateResult });
} catch (error) {
this.trace.push(Object.freeze({ operation_id: operationId, kind, action, phase: "error", reason: error?.message ?? String(error) }));
throw error;
}
}
exportTrace() { return structuredClone(this.trace); }
}

View File

@@ -0,0 +1,16 @@
import assert from "node:assert/strict";
import { AxisGateError, AxisOperationController } from "./axis-operation-controller.mjs";
const calls = [];
const client = { send: async (kind, payload, operationId) => { calls.push({ kind, payload, operationId }); return { kind: "COMMAND_RESULT", payload: { status: "verified" } }; } };
const controller = new AxisOperationController(client);
const ready = { task: { state: "READY", mode: "MANUAL" }, program: { path: "main.ngc", program_status: "loaded" } };
const run = await controller.execute("PROGRAM_RUN", {}, { state: ready });
assert.equal(run.operationId, "axis-program-run-000001");
assert.equal(run.gate.reason, "axis_run_available");
assert.equal(calls[0].operationId, run.operationId);
await assert.rejects(() => controller.execute("JOG_START", { axis: "x" }, { state: { task: { state: "ESTOP" } } }), AxisGateError);
const tick = await controller.execute("SIM_STEP", { cycles: 1 }, { state: ready, gate: false });
assert.equal(tick.operationId, "axis-sim-step-000003");
assert.deepEqual(controller.exportTrace().map((entry) => entry.phase), ["pending", "result", "blocked", "pending", "result"]);
console.log(JSON.stringify({ schema: "axis-operation-controller-sample.v1", status: "verified", calls: calls.length, phases: controller.exportTrace().map((entry) => entry.phase), last_operation: tick.operationId }));

View File

@@ -5,6 +5,7 @@ import { ThreeMachineView } from "./ThreeMachineView";
import { SIM_CASE_CATALOG } from "../../workspace/sim-case-catalog.mjs";
import { loadSimulationCase, toWorkerConfiguration } from "../../workspace/sim-case-loader.mjs";
import { axisIcon } from "./sourceAssets";
import { AxisGateError, AxisOperationController } from "../../app/axis-operation-controller.mjs";
const DEFAULT_CASE = "linuxcnc/configs/sim/axis/minimal_xyz.ini";
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");
@@ -68,6 +69,7 @@ function ProgramPane({ text, setText, activeGcodes, onLoad, onRun, onStep, ready
export function App() {
const client = useMemo(() => new ReactWorkerClient(), []);
const operationController = useMemo(() => new AxisOperationController(client), [client]);
const [state, setState] = useState(INITIAL_STATE);
const [casePath, setCasePath] = useState(DEFAULT_CASE);
const [caseStatus, setCaseStatus] = useState("ready to load");
@@ -87,10 +89,36 @@ export function App() {
useEffect(() => { const unsubscribe = client.subscribe((message) => setState((previous) => reduceFrontendState(previous, message))); client.ensureSession().catch((error: Error) => setCommandStatus(`Error: ${error.message}`)); return () => { unsubscribe(); if (runTimer.current) clearInterval(runTimer.current); client.close(); }; }, [client]);
const axes = state.snapshot?.motion?.axes ?? [0, 0, 0];
const ready = ["ready", "running", "paused"].includes(state.status);
const execute: Action = (kind, payload = {}) => { setCommandStatus(`${kind}...`); client.send(kind, payload, `axis-${kind.toLowerCase()}-${Date.now()}`).then(() => setCommandStatus("Acknowledged")).catch((error: Error) => setCommandStatus(`Blocked: ${error.message}`)); };
const runProgram = () => { execute("PROGRAM_RUN"); if (runTimer.current) clearInterval(runTimer.current); runTimer.current = setInterval(() => { client.send("SIM_STEP", { cycles: 1 }, `axis-tick-${Date.now()}`).catch(() => undefined); }, 50); };
const axisState = { task: { ...(state.task ?? {}), state: state.task?.state ?? (ready ? "ON" : "OFF"), mode: state.task?.mode ?? "MANUAL", interp_state: state.task?.interp_state ?? (state.status === "running" ? "READING" : state.status === "paused" ? "PAUSED" : "IDLE") }, program: state.program ?? {}, motion: state.snapshot?.motion ?? state.snapshot?.motion ?? {}, devices: state.devices ?? {} };
const execute: Action = (kind, payload = {}) => {
setCommandStatus(`${kind} pending`);
operationController.execute(kind, payload, { state: axisState }).then(() => setCommandStatus(`${kind} acknowledged`)).catch((error: Error) => {
const code = error instanceof AxisGateError ? error.code : error.message;
setCommandStatus(`${kind} blocked: ${code}`);
});
};
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 result = await client.send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }, "axis-load-case"); const resultPayload = result.payload as { configuration?: { state?: string } }; setCaseStatus(`${bundle.entrypoint} -> ${resultPayload.configuration?.state ?? "READY"}`); } catch (error) { setCaseStatus(`blocked: ${error instanceof Error ? error.message : "case_load_failed"}`); } };
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"}`); } };
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (target && ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)) return;
const key = event.key.toLowerCase();
if (event.ctrlKey && key === "k") { event.preventDefault(); setCommandStatus("Live plot cleared"); return; }
if (key === "f3") { event.preventDefault(); setLeftTab("manual"); return; }
if (key === "f5") { event.preventDefault(); setLeftTab("mdi"); return; }
if (key === "f1") { event.preventDefault(); execute("ESTOP", { active: true }); return; }
if (key === "f2") { event.preventDefault(); execute("IO_RECOVER"); return; }
if (key === "r") { event.preventDefault(); runProgram(); return; }
if (key === "t") { event.preventDefault(); execute("PROGRAM_STEP"); return; }
if (key === "p") { event.preventDefault(); execute("PROGRAM_PAUSE"); return; }
if (key === "s") { event.preventDefault(); execute("PROGRAM_RESUME"); return; }
if (key === "escape") { event.preventDefault(); stopProgram(); }
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [axisState, execute, runProgram, stopProgram]);
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";

View File

@@ -3,6 +3,12 @@ declare module "*.mjs" {
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 toWorkerConfiguration(bundle: unknown): Record<string, unknown>;
export class SimSession { contextId: string; sessionId: string; constructor(core: unknown); accept(message: unknown): unknown; }
export class AxisOperationController {
constructor(client: { send(kind: string, payload?: unknown, operationId?: string): Promise<unknown> });
execute(kind: string, payload?: Record<string, unknown>, options?: { state?: unknown; action?: string | null; gate?: boolean }): Promise<{ operationId: string; response: unknown; gate: { enabled: boolean; reason: string } }>;
exportTrace(): Array<Record<string, unknown>>;
}
export class AxisGateError extends Error { code: string; gate: { enabled: boolean; reason: string }; }
}
declare module "*.js" { const createModule: (options?: unknown) => Promise<unknown>; export default createModule; }