app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge: multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
← Commit history
runner: cover autoroute, model_check and the six sch_* verbs; ipc: no_pcb_frame for the one-API-server-per-machine case
2 files changed
+33
rust/crates/kicad-core/src/ipc.rs+11@@ -83,6 +83,16 @@ fn map_err(e: KiCadError) -> RoutingError { KiCadError::TransportSend { .. } | KiCadError::TransportReceive { .. } | KiCadError::TransportClosed | KiCadError::BlockingRuntimeClosed | KiCadError::RuntimeJoin(_) => RoutingError::new("ipc_unavailable", e.to_string()), KiCadError::BoardNotOpen => RoutingError::new("no_board_open", "No board is open in the PCB editor").with("openBoards", json!([])), KiCadError::AmbiguousBoardSelection { boards } => RoutingError::new("board_mismatch", "This IPC endpoint exposes more than one board").with("openBoards", json!(boards)),+ // KiCad runs ONE API server per machine (the first KiCad process takes api.sock; a+ // later standalone pcbnew.exe gets none, measured on arav-rog 2026-09-11). When that+ // first process is the project manager with no PCB frame, board commands come back+ // AS_UNHANDLED. Name the situation instead of a bare status code.+ KiCadError::ApiStatus { code, message } if code == "AS_UNHANDLED" => RoutingError::new(+ "no_pcb_frame",+ "The KiCad process that owns the API socket has no PCB editor frame (a project manager alone, or the board is open in a second, standalone pcbnew.exe that got no API server)",+ )+ .with("apiStatus", json!({"code": code, "message": message}))+ .with("_hint", json!("KiCad serves one API socket per machine and the first KiCad process wins. Either close the project manager and open the board with kicad_open_board (standalone pcbnew then owns the socket), or open the board from within the project manager (kicad_launch with the project, then the PCB Editor button) so the frame lives in the process that has the socket. Then read kicad_routing_state again.")), other => RoutingError::new("ipc_error", other.to_string()), } }@@ -930,6 +940,7 @@ mod tests { assert_eq!(map_err(KiCadError::BoardNotOpen).code, "no_board_open"); assert_eq!(map_err(KiCadError::SocketUnavailable { socket_uri: "ipc:///x".into() }).code, "ipc_unavailable"); assert_eq!(map_err(KiCadError::ApiStatus { code: "AS_BAD_REQUEST".into(), message: "nope".into() }).code, "ipc_error");+ assert_eq!(map_err(KiCadError::ApiStatus { code: "AS_UNHANDLED".into(), message: "no handler".into() }).code, "no_pcb_frame"); assert_eq!(timeout_of(&json!({})), Duration::from_millis(5000)); assert_eq!(timeout_of(&json!({"ipcTimeoutMs": 10})), Duration::from_millis(1000)); assert_eq!(timeout_of(&json!({"ipcTimeoutMs": 999999})), Duration::from_millis(60000));
skills/kicad-bridge-test/run_verb_tests.py+22@@ -126,6 +126,21 @@ def matrix(w: dict) -> dict: "run_erc": dict(phase=2, args={"filePath": S}, timeout=200), "lint_board": dict(phase=2, args={"filePath": B}, timeout=200), "lint_schematic": dict(phase=2, args={"filePath": S}, timeout=200),+ # ---- phase 2b: native-build verbs (Rust port). All read-only or on the fixture copy.+ "model_check": dict(phase=2, args={"boardPath": B}, timeout=120,+ note="file diagnostics only; renderVerified is always false here"),+ "autoroute": dict(phase=2, args={"filePath": B, "engine": "ai", "dryRun": True}, timeout=120,+ note="ai engine returns a plan request and mutates nothing"),+ # schematic edit in place, on the fixture copy: each write keeps a .bak beside the file+ "sch_place_symbol": dict(phase=2, args={"filePath": S, "libId": "Device:R", "reference": "R_VERBTEST",+ "at": [200, 150], "value": "1k"}, timeout=150,+ note="fixture copy only; the .bak is removed with the fixture dir"),+ "sch_wire": dict(phase=2, args={"filePath": S, "points": [[200, 146.19], [200, 140]]}, timeout=150),+ "sch_label": dict(phase=2, args={"filePath": S, "text": "VERBTEST_NET", "at": [200, 140], "kind": "local"}, timeout=150),+ "sch_set_property": dict(phase=2, args={"filePath": S, "reference": "R_VERBTEST", "name": "Value", "value": "2k2"}, timeout=150),+ "sch_move": dict(phase=2, args={"filePath": S, "reference": "R_VERBTEST", "to": [210, 150]}, timeout=150),+ "sch_delete": dict(phase=2, args={"__special": "sch_delete"}, timeout=150,+ note="deletes the R_VERBTEST symbol placed above, by the uuid sch_place_symbol returned"), "lint_library": dict(phase=2, args={"filePath": w["symlib"]}, timeout=120, note="Adom.kicad_sym installed in phase 4a; runs after it"), "export_gerber": dict(phase=2, args={"filePath": B, "outputDir": OUT + "/gerbers"}, timeout=220),@@ -463,6 +478,13 @@ def main(): results[verb] = {"result": "FAIL", "reason": "extract_netlist reported no nets to trace"} return args = {"filePath": w["sch"], "netName": real}+ elif special == "sch_delete":+ placed = (results.get("sch_place_symbol") or {}).get("response") or {}+ uid = placed.get("uuid")+ if not uid:+ results[verb] = {"result": "SKIP", "reason": "sch_place_symbol returned no uuid to delete"}+ return+ args = {"filePath": w["sch"], "uuid": uid} elif special == "install_symbol": import base64 as _b64 args = {"fileName": "ADOM_VERBTEST.kicad_sym",