feat(automation): headless JSON server (--serve) + Python client
Add an external automation API (issue #29 / #100 track 2): `OpenCADStudio --serve` runs without a GUI and is driven over a line-based JSON protocol on stdin/stdout — open / new / run / entities / save. State persists across requests so a script or AI agent can act, observe, and act again. `run` drives the app's existing command system rather than a separate binding, so coverage grows with the app; synchronous commands apply now, pick-based interactive ones come once coordinate feeding is wired. Ships a ~100-line example ocs.py client over the same protocol — no FFI to maintain. Headless works because the app already constructs and dispatches GUI-less in tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
9cb11e77d2
commit
7f10b69e3c
6 changed files with 341 additions and 0 deletions
46
docs/automation/README.md
Normal file
46
docs/automation/README.md
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
# Headless automation API
|
||||||
|
|
||||||
|
Open CAD Studio can run without a GUI and be driven over a line-based JSON
|
||||||
|
protocol — for scripts, batch jobs, or AI agents.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
OpenCADStudio --serve
|
||||||
|
```
|
||||||
|
|
||||||
|
It reads one JSON request per line on **stdin** and writes one JSON response per
|
||||||
|
line on **stdout**. The active document persists across requests, so a caller
|
||||||
|
can act → observe → act.
|
||||||
|
|
||||||
|
## Protocol
|
||||||
|
|
||||||
|
| Request | Response |
|
||||||
|
|---------|----------|
|
||||||
|
| `{"op":"new"}` | `{"ok":true,"total":0,"by_type":{}}` |
|
||||||
|
| `{"op":"open","path":"plan.dwg"}` | entity summary |
|
||||||
|
| `{"op":"run","cmd":"LAYER Walls"}` | `{"ok":true,"cmd":...,"entities":N,"added":D}` |
|
||||||
|
| `{"op":"entities"}` | `{"ok":true,"total":N,"by_type":{"Line":42,...}}` |
|
||||||
|
| `{"op":"save","path":"out.dwg"}` | `{"ok":true,"saved":"out.dwg"}` (path optional once opened/saved) |
|
||||||
|
|
||||||
|
Every response has `"ok"`; failures carry `"error"`. `run` drives Open CAD
|
||||||
|
Studio's **own** command system — no separate bindings to maintain — so its
|
||||||
|
coverage grows with the app.
|
||||||
|
|
||||||
|
> **Status (first increment):** `run` applies synchronous commands (system
|
||||||
|
> variables, layer ops, …). Pick-based interactive commands (drawing by clicking
|
||||||
|
> points) need coordinate feeding and are not wired headless yet.
|
||||||
|
|
||||||
|
## Python client
|
||||||
|
|
||||||
|
[`ocs.py`](ocs.py) is a ~100-line client — nothing to compile:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from ocs import Ocs
|
||||||
|
|
||||||
|
with Ocs(binary="OpenCADStudio") as ocs: # spawns `--serve`
|
||||||
|
ocs.open("plan.dwg")
|
||||||
|
ocs.run("LAYER Walls")
|
||||||
|
print(ocs.entities())
|
||||||
|
ocs.save("plan_out.dwg")
|
||||||
|
```
|
||||||
|
|
||||||
|
Any language can speak the same protocol over a subprocess pipe.
|
||||||
BIN
docs/automation/__pycache__/ocs.cpython-312.pyc
Normal file
BIN
docs/automation/__pycache__/ocs.cpython-312.pyc
Normal file
Binary file not shown.
90
docs/automation/ocs.py
Normal file
90
docs/automation/ocs.py
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
"""Thin Python client for the Open CAD Studio headless automation server.
|
||||||
|
|
||||||
|
Launches `OpenCADStudio --serve` and talks to it over a line-based JSON protocol
|
||||||
|
(one request object per line on stdin, one response per line on stdout). There
|
||||||
|
is nothing to compile or maintain on the Python side — every method is one JSON
|
||||||
|
message; the real work is Open CAD Studio's own command system.
|
||||||
|
|
||||||
|
from ocs import Ocs
|
||||||
|
|
||||||
|
with Ocs(binary="OpenCADStudio") as ocs:
|
||||||
|
ocs.open("plan.dwg")
|
||||||
|
ocs.run("LAYER Walls")
|
||||||
|
print(ocs.entities()) # {"total": 42, "by_type": {...}}
|
||||||
|
ocs.save("plan_out.dwg")
|
||||||
|
|
||||||
|
Each call returns the parsed response dict and raises `OcsError` on `ok: false`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class OcsError(RuntimeError):
|
||||||
|
"""Raised when the server replies with `{"ok": false, ...}`."""
|
||||||
|
|
||||||
|
|
||||||
|
class Ocs:
|
||||||
|
def __init__(self, binary: str = "OpenCADStudio") -> None:
|
||||||
|
self.proc = subprocess.Popen(
|
||||||
|
[binary, "--serve"],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
bufsize=1,
|
||||||
|
)
|
||||||
|
self._read() # the {"ready": true} greeting
|
||||||
|
|
||||||
|
# ── protocol ────────────────────────────────────────────────────────────
|
||||||
|
def _read(self) -> dict[str, Any]:
|
||||||
|
line = self.proc.stdout.readline()
|
||||||
|
if not line:
|
||||||
|
raise OcsError("server closed the connection")
|
||||||
|
return json.loads(line)
|
||||||
|
|
||||||
|
def _send(self, **req: Any) -> dict[str, Any]:
|
||||||
|
self.proc.stdin.write(json.dumps(req) + "\n")
|
||||||
|
self.proc.stdin.flush()
|
||||||
|
resp = self._read()
|
||||||
|
if not resp.get("ok", False):
|
||||||
|
raise OcsError(resp.get("error", "unknown error"))
|
||||||
|
return resp
|
||||||
|
|
||||||
|
# ── operations ──────────────────────────────────────────────────────────
|
||||||
|
def new(self) -> dict[str, Any]:
|
||||||
|
"""Start an empty document."""
|
||||||
|
return self._send(op="new")
|
||||||
|
|
||||||
|
def open(self, path: str) -> dict[str, Any]:
|
||||||
|
"""Load a DWG/DXF drawing."""
|
||||||
|
return self._send(op="open", path=path)
|
||||||
|
|
||||||
|
def run(self, cmd: str) -> dict[str, Any]:
|
||||||
|
"""Run a command through Open CAD Studio's command system."""
|
||||||
|
return self._send(op="run", cmd=cmd)
|
||||||
|
|
||||||
|
def entities(self) -> dict[str, Any]:
|
||||||
|
"""Total entity count and a breakdown by type."""
|
||||||
|
return self._send(op="entities")
|
||||||
|
|
||||||
|
def save(self, path: Optional[str] = None) -> dict[str, Any]:
|
||||||
|
"""Write the document (defaults to the opened/last-saved path)."""
|
||||||
|
return self._send(op="save", path=path)
|
||||||
|
|
||||||
|
# ── lifecycle ───────────────────────────────────────────────────────────
|
||||||
|
def close(self) -> None:
|
||||||
|
if self.proc.stdin:
|
||||||
|
self.proc.stdin.close()
|
||||||
|
try:
|
||||||
|
self.proc.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self.proc.kill()
|
||||||
|
|
||||||
|
def __enter__(self) -> "Ocs":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_exc: object) -> None:
|
||||||
|
self.close()
|
||||||
194
src/app/automation.rs
Normal file
194
src/app/automation.rs
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
//! Headless automation server (`OpenCADStudio --serve`).
|
||||||
|
//!
|
||||||
|
//! Drives the app without a GUI over a line-based JSON protocol: one request
|
||||||
|
//! object per line on stdin, one response object per line on stdout. State (the
|
||||||
|
//! active document) persists across requests, so an external process — a script
|
||||||
|
//! or an AI agent — can act, observe, and act again.
|
||||||
|
//!
|
||||||
|
//! Operations:
|
||||||
|
//! - `{"op":"new"}` — start an empty document
|
||||||
|
//! - `{"op":"open","path":"file.dwg"}` — load a drawing
|
||||||
|
//! - `{"op":"run","cmd":"LAYER Walls"}` — run a command (the same dispatcher
|
||||||
|
//! the GUI command line uses)
|
||||||
|
//! - `{"op":"entities"}` — summary count by entity type
|
||||||
|
//! - `{"op":"save","path":"out.dwg"}` — write the document (path optional
|
||||||
|
//! once opened/saved)
|
||||||
|
|
||||||
|
use std::io::{BufRead, Write};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use super::OpenCADStudio;
|
||||||
|
|
||||||
|
/// Run the headless JSON server until stdin closes.
|
||||||
|
pub fn serve() {
|
||||||
|
let mut app = OpenCADStudio::new();
|
||||||
|
let stdin = std::io::stdin();
|
||||||
|
let stdout = std::io::stdout();
|
||||||
|
|
||||||
|
emit(
|
||||||
|
&stdout,
|
||||||
|
json!({"ok": true, "ready": true, "version": env!("CARGO_PKG_VERSION")}),
|
||||||
|
);
|
||||||
|
|
||||||
|
for line in stdin.lock().lines() {
|
||||||
|
let Ok(line) = line else { break };
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let resp = app.automation_op(line);
|
||||||
|
emit(&stdout, resp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit(stdout: &std::io::Stdout, value: Value) {
|
||||||
|
let mut o = stdout.lock();
|
||||||
|
let _ = writeln!(o, "{value}");
|
||||||
|
let _ = o.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn err(msg: impl std::fmt::Display) -> Value {
|
||||||
|
json!({ "ok": false, "error": msg.to_string() })
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenCADStudio {
|
||||||
|
/// Handle one JSON request line and return the JSON response.
|
||||||
|
pub(crate) fn automation_op(&mut self, line: &str) -> Value {
|
||||||
|
let req: Value = match serde_json::from_str(line) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => return err(format!("invalid JSON: {e}")),
|
||||||
|
};
|
||||||
|
match req["op"].as_str().unwrap_or("") {
|
||||||
|
"new" => {
|
||||||
|
let i = self.active_tab;
|
||||||
|
self.tabs[i].scene.document = acadrust::CadDocument::new();
|
||||||
|
self.tabs[i].current_path = None;
|
||||||
|
self.tabs[i].scene.bump_geometry();
|
||||||
|
self.entity_summary()
|
||||||
|
}
|
||||||
|
"open" => {
|
||||||
|
let Some(path) = req["path"].as_str() else {
|
||||||
|
return err("open: missing \"path\"");
|
||||||
|
};
|
||||||
|
let bytes = match std::fs::read(path) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => return err(format!("open: {e}")),
|
||||||
|
};
|
||||||
|
let name = PathBuf::from(path)
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|| path.to_string());
|
||||||
|
match crate::io::load_bytes(&name, bytes) {
|
||||||
|
Ok(doc) => {
|
||||||
|
let i = self.active_tab;
|
||||||
|
self.tabs[i].scene.document = doc;
|
||||||
|
self.tabs[i].current_path = Some(PathBuf::from(path));
|
||||||
|
self.tabs[i].scene.bump_geometry();
|
||||||
|
self.entity_summary()
|
||||||
|
}
|
||||||
|
Err(e) => err(format!("open: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"run" => {
|
||||||
|
let cmd = req["cmd"].as_str().unwrap_or("").to_string();
|
||||||
|
if cmd.is_empty() {
|
||||||
|
return err("run: missing \"cmd\"");
|
||||||
|
}
|
||||||
|
let i = self.active_tab;
|
||||||
|
let before = self.tabs[i].scene.document.entities().count();
|
||||||
|
// The returned Task drives GUI follow-up; synchronous commands
|
||||||
|
// (system variables, layer ops, …) have already applied. Pick-
|
||||||
|
// based interactive commands need coordinate feeding — not yet
|
||||||
|
// wired headless.
|
||||||
|
let _ = self.dispatch_command(&cmd);
|
||||||
|
let after = self.tabs[i].scene.document.entities().count();
|
||||||
|
json!({
|
||||||
|
"ok": true,
|
||||||
|
"cmd": cmd,
|
||||||
|
"entities": after,
|
||||||
|
"added": after as i64 - before as i64,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"entities" => self.entity_summary(),
|
||||||
|
"save" => {
|
||||||
|
let i = self.active_tab;
|
||||||
|
let path = req["path"]
|
||||||
|
.as_str()
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.or_else(|| self.tabs[i].current_path.clone());
|
||||||
|
let Some(path) = path else {
|
||||||
|
return err("save: no \"path\" and the document has none");
|
||||||
|
};
|
||||||
|
match crate::io::save(&self.tabs[i].scene.document, &path) {
|
||||||
|
Ok(()) => {
|
||||||
|
self.tabs[i].current_path = Some(path.clone());
|
||||||
|
json!({ "ok": true, "saved": path.to_string_lossy() })
|
||||||
|
}
|
||||||
|
Err(e) => err(format!("save: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"" => err("missing \"op\""),
|
||||||
|
other => err(format!("unknown op: {other}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count of entities in the active document, total and by type.
|
||||||
|
fn entity_summary(&self) -> Value {
|
||||||
|
let i = self.active_tab;
|
||||||
|
let mut by_type: std::collections::BTreeMap<String, u64> = Default::default();
|
||||||
|
let mut total = 0u64;
|
||||||
|
for e in self.tabs[i].scene.document.entities() {
|
||||||
|
*by_type
|
||||||
|
.entry(crate::entities::names::ui_name(e).to_string())
|
||||||
|
.or_default() += 1;
|
||||||
|
total += 1;
|
||||||
|
}
|
||||||
|
json!({ "ok": true, "total": total, "by_type": by_type })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::app::OpenCADStudio;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn automation_ops_round_trip() {
|
||||||
|
let mut app = OpenCADStudio::new_for_test();
|
||||||
|
|
||||||
|
let r = app.automation_op(r#"{"op":"new"}"#);
|
||||||
|
assert_eq!(r["ok"], true);
|
||||||
|
assert_eq!(r["total"], 0);
|
||||||
|
|
||||||
|
// A synchronous command runs through the real dispatcher.
|
||||||
|
let r = app.automation_op(r#"{"op":"run","cmd":"PDMODE 3"}"#);
|
||||||
|
assert_eq!(r["ok"], true);
|
||||||
|
assert_eq!(r["cmd"], "PDMODE 3");
|
||||||
|
|
||||||
|
let r = app.automation_op(r#"{"op":"entities"}"#);
|
||||||
|
assert_eq!(r["ok"], true);
|
||||||
|
|
||||||
|
// Errors are reported, never panics.
|
||||||
|
assert_eq!(app.automation_op(r#"{"op":"bogus"}"#)["ok"], false);
|
||||||
|
assert_eq!(app.automation_op("not json")["ok"], false);
|
||||||
|
assert_eq!(app.automation_op(r#"{"op":"run"}"#)["ok"], false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_then_open_round_trips() {
|
||||||
|
let mut app = OpenCADStudio::new_for_test();
|
||||||
|
let path = std::env::temp_dir().join("ocs_automation_test.dxf");
|
||||||
|
let p = path.to_string_lossy();
|
||||||
|
app.automation_op(r#"{"op":"new"}"#);
|
||||||
|
assert_eq!(
|
||||||
|
app.automation_op(&format!(r#"{{"op":"save","path":"{p}"}}"#))["ok"],
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
app.automation_op(&format!(r#"{{"op":"open","path":"{p}"}}"#))["ok"],
|
||||||
|
true
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
mod automation;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub use automation::serve;
|
||||||
mod cmd_result;
|
mod cmd_result;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod document;
|
mod document;
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,13 @@ fn main() -> iced::Result {
|
||||||
}
|
}
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
{
|
{
|
||||||
|
// Headless automation server — drive the app over JSON on stdin/stdout
|
||||||
|
// with no GUI (issue #29). `--serve` is the opt-in; everything else
|
||||||
|
// launches the editor.
|
||||||
|
if std::env::args().skip(1).any(|a| a == "--serve") {
|
||||||
|
app::serve();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
app::run()
|
app::run()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue