diff --git a/coverage/axis-control.report.json b/coverage/axis-control.report.json new file mode 100644 index 0000000..94b2393 --- /dev/null +++ b/coverage/axis-control.report.json @@ -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--", + "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" + } + ] +} diff --git a/coverage/gui-browser-visual-fe-m4.report.json b/coverage/gui-browser-visual-fe-m4.report.json index ea99ce2..7a10078 100644 --- a/coverage/gui-browser-visual-fe-m4.report.json +++ b/coverage/gui-browser-visual-fe-m4.report.json @@ -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", diff --git a/docs/linuxcnc-axis-complete-parity-spec.md b/docs/linuxcnc-axis-complete-parity-spec.md index a07595a..cc77704 100644 --- a/docs/linuxcnc-axis-complete-parity-spec.md +++ b/docs/linuxcnc-axis-complete-parity-spec.md @@ -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。 diff --git a/nextTask1.md b/nextTask1.md index e66c8ba..10252c2 100644 --- a/nextTask1.md +++ b/nextTask1.md @@ -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: diff --git a/scripts/generate_axis_control_report.py b/scripts/generate_axis_control_report.py new file mode 100644 index 0000000..d0f305c --- /dev/null +++ b/scripts/generate_axis_control_report.py @@ -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--", + "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']}") diff --git a/scripts/test_axis_control_report.py b/scripts/test_axis_control_report.py new file mode 100644 index 0000000..1c94e1b --- /dev/null +++ b/scripts/test_axis_control_report.py @@ -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() diff --git a/web/app/axis-contract.mjs b/web/app/axis-contract.mjs index 18c7a4b..7e4a447 100644 --- a/web/app/axis-contract.mjs +++ b/web/app/axis-contract.mjs @@ -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; diff --git a/web/app/axis-operation-controller.mjs b/web/app/axis-operation-controller.mjs new file mode 100644 index 0000000..895e111 --- /dev/null +++ b/web/app/axis-operation-controller.mjs @@ -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); } +} diff --git a/web/app/test_axis_operation_controller.mjs b/web/app/test_axis_operation_controller.mjs new file mode 100644 index 0000000..e8ef521 --- /dev/null +++ b/web/app/test_axis_operation_controller.mjs @@ -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 })); diff --git a/web/react/src/App.tsx b/web/react/src/App.tsx index 4e27d7f..d56cb45 100644 --- a/web/react/src/App.tsx +++ b/web/react/src/App.tsx @@ -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"; diff --git a/web/react/src/legacy.d.ts b/web/react/src/legacy.d.ts index 7575ec0..e3eeade 100644 --- a/web/react/src/legacy.d.ts +++ b/web/react/src/legacy.d.ts @@ -3,6 +3,12 @@ declare module "*.mjs" { export function loadSimulationCase(options: { path: string }): Promise<{ status: string; entrypoint: string; files: Record; missing: string[]; fetched: string[]; state_hash: string; halManifest: unknown[] }>; export function toWorkerConfiguration(bundle: unknown): Record; 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 }); + execute(kind: string, payload?: Record, options?: { state?: unknown; action?: string | null; gate?: boolean }): Promise<{ operationId: string; response: unknown; gate: { enabled: boolean; reason: string } }>; + exportTrace(): Array>; + } + export class AxisGateError extends Error { code: string; gate: { enabled: boolean; reason: string }; } } declare module "*.js" { const createModule: (options?: unknown) => Promise; export default createModule; }