From a2e8a90052700c2271f588cad9b215b65b778a43 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Wed, 3 Jun 2026 02:23:10 +0800 Subject: [PATCH] feat(automation): Python reference client --- tools/automation/orca_automation.py | 111 ++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 tools/automation/orca_automation.py diff --git a/tools/automation/orca_automation.py b/tools/automation/orca_automation.py new file mode 100644 index 0000000000..fdc696df8d --- /dev/null +++ b/tools/automation/orca_automation.py @@ -0,0 +1,111 @@ +"""Reference client for the OrcaSlicer UI automation JSON-RPC server. + +Usage: + from orca_automation import OrcaClient + orca = OrcaClient(port=13619) + print(orca.version()) + orca.click({"id": "btn_slice"}) + orca.wait_for({"id": "btn_export"}, state="enabled", timeout_ms=120000) + png = orca.screenshot_3d(width=1024, height=768) + open("preview.png", "wb").write(png) +""" +from __future__ import annotations +import base64 +import json +import urllib.request +from typing import Any, Optional + + +class OrcaError(RuntimeError): + def __init__(self, code: int, message: str): + super().__init__(f"[{code}] {message}") + self.code = code + self.message = message + + +class OrcaClient: + def __init__(self, host: str = "127.0.0.1", port: int = 13619, timeout: float = 30.0): + self._url = f"http://{host}:{port}/jsonrpc" + self._timeout = timeout + self._id = 0 + + def _call(self, method: str, params: Optional[dict] = None) -> Any: + self._id += 1 + payload = {"jsonrpc": "2.0", "id": self._id, "method": method} + if params is not None: + payload["params"] = params + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + self._url, data=data, headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=self._timeout) as resp: + body = json.loads(resp.read().decode("utf-8")) + if "error" in body: + err = body["error"] + raise OrcaError(err.get("code", -1), err.get("message", "unknown error")) + return body.get("result") + + # --- protocol methods --- + def version(self) -> dict: + return self._call("automation.version") + + def dump_tree(self, root: Optional[str] = None, max_depth: Optional[int] = None, + visible_only: bool = False, include_imgui: bool = True) -> dict: + params: dict = {"visible_only": visible_only, "include_imgui": include_imgui} + if root is not None: + params["root"] = root + if max_depth is not None: + params["max_depth"] = max_depth + return self._call("tree.dump", params) + + def find(self, **predicate) -> list: + # predicate keys: name, class, label, value, backend + return self._call("tree.find", predicate) + + def get(self, target: dict) -> dict: + return self._call("widget.get", {"target": target}) + + def click(self, target: dict, button: str = "left", + double: bool = False, modifiers: Optional[list] = None) -> dict: + params = {"target": target, "button": button, "double": double} + if modifiers: + params["modifiers"] = modifiers + return self._call("input.click", params) + + def type(self, text: str, target: Optional[dict] = None) -> dict: + params: dict = {"text": text} + if target is not None: + params["target"] = target + return self._call("input.type", params) + + def key(self, keys) -> dict: + # keys: "ctrl+s" or ["ctrl", "s"] + return self._call("input.key", {"keys": keys}) + + def wait_for(self, target: dict, state: str = "visible", + value: Optional[str] = None, timeout_ms: int = 5000, + poll_ms: int = 100) -> dict: + params = {"target": target, "state": state, + "timeout_ms": timeout_ms, "poll_ms": poll_ms} + if value is not None: + params["value"] = value + return self._call("sync.wait_for", params) + + def app_state(self) -> dict: + return self._call("app.state") + + def screenshot(self, target: Optional[dict] = None) -> bytes: + params = {"target": target} if target is not None else None + result = self._call("screenshot.window", params) + return base64.b64decode(result["png_base64"]) + + def screenshot_3d(self, plate: Optional[int] = None, + width: Optional[int] = None, height: Optional[int] = None) -> bytes: + params: dict = {} + if plate is not None: + params["plate"] = plate + if width is not None: + params["width"] = width + if height is not None: + params["height"] = height + result = self._call("screenshot.viewport3d", params or None) + return base64.b64decode(result["png_base64"])