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
rust: multithreaded server with a GUI lock for window and IPC verbs; export_gerbers alias; retired plugin verbs answer honestly; runner phases for the schematic edits
10 files changed
+221−75
rust/crates/kicad-bridge/src/groups.rs+1rust/crates/kicad-bridge/src/main.rs+90−61rust/crates/kicad-bridge/src/util.rs+20−8rust/crates/kicad-bridge/src/verbs.rs+11−1rust/crates/kicad-bridge/src/verbs_misc.rs+79rust/crates/kicad-bridge/src/verbs_show.rs+11rust/crates/kicad-core/src/install_kicad.rs+1rust/crates/kicad-core/src/lib.rs+2rust/crates/kicad-core/src/progress.rs+1skills/kicad-bridge-test/run_verb_tests.py+5−5rust/crates/kicad-bridge/src/groups.rs+1@@ -24,6 +24,7 @@ pub static GROUPS: &[Group] = &[ Group { name: "install", verbs: || crate::verbs_install::VERBS, dispatch: crate::verbs_install::dispatch }, Group { name: "routing", verbs: || crate::verbs_routing::VERBS, dispatch: crate::verbs_routing::dispatch }, Group { name: "windows", verbs: || crate::verbs_windows::VERBS, dispatch: crate::verbs_windows::dispatch },+ Group { name: "show", verbs: || crate::verbs_show::VERBS, dispatch: crate::verbs_show::dispatch }, ]; pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {
rust/crates/kicad-bridge/src/main.rs+90−61@@ -19,8 +19,11 @@ mod verbs_misc; mod verbs_netlist; mod verbs_routing; mod verbs_schematic;+mod verbs_show; mod verbs_windows; +use std::sync::atomic::{AtomicU64, Ordering};+use std::sync::Arc; use std::time::Instant; use serde::Deserialize;@@ -82,81 +85,107 @@ fn json_response(body: Value, code: u16) -> Response<std::io::Cursor<Vec<u8>>> { .with_header(Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap()) } +/// Requests are served by a small pool: ab polls /status every 15 s and the AI polls+/// kicad_progress while a long verb runs, and neither may wait behind it (the Python bridge+/// was a ThreadingHTTPServer for the same reason). Serialisation of the verbs that need it+/// happens on util::GUI_LOCK inside dispatch.+const WORKERS: usize = 6;+ fn main() { let port = parse_port_arg().unwrap_or(0); let host = std::env::var("ADOM_BIND_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); let started = Instant::now();- let mut request_count: u64 = 0;- let mut verb_count: u64 = 0;+ let request_count = Arc::new(AtomicU64::new(0));+ let verb_count = Arc::new(AtomicU64::new(0)); let server = Server::http((host.as_str(), port)) .unwrap_or_else(|e| panic!("[kicad-bridge] bind {host}:{port} failed: {e}")); let actual_port = server.server_addr().to_ip().map(|a| a.port()).unwrap_or(port);- eprintln!("[kicad-bridge] v{VERSION} listening on {host}:{actual_port}");+ eprintln!("[kicad-bridge] v{VERSION} listening on {host}:{actual_port} ({WORKERS} workers)"); kicad_platform::native().init_process();- let mut state = util::State::new();+ let server = Arc::new(server);+ let mut handles = Vec::new();+ for _ in 0..WORKERS {+ let server = Arc::clone(&server);+ let request_count = Arc::clone(&request_count);+ let verb_count = Arc::clone(&verb_count);+ handles.push(std::thread::spawn(move || loop {+ let req = match server.recv() {+ Ok(r) => r,+ Err(e) => {+ eprintln!("[kicad-bridge] recv failed: {e}");+ break;+ }+ };+ let n = request_count.fetch_add(1, Ordering::Relaxed) + 1;+ handle(req, started, n, &verb_count);+ }));+ }+ for h in handles {+ let _ = h.join();+ }+} - for mut req in server.incoming_requests() {- request_count += 1;- let uptime = started.elapsed().as_secs();- let (body, code) = match (req.method(), req.url()) {- (Method::Get, "/status") | (Method::Get, "/health") => {- let info = state.kicad_info();- let led = if info.installed { "green" } else { "yellow" };- (- json!({- "ok": true,- "bridge": "kicad",- "version": VERSION,- "runtime": "native",- "uptimeSec": uptime,- "requestCount": request_count,- "led": led,- "summary": if info.installed { format!("KiCad {}", info.version) } else { "KiCad not found".into() },- "tooltip": format!("kicad-bridge v{VERSION} (native)\nup {uptime}s, {request_count} requests\n{}", info.summary()),- }),- 200,- )+fn handle(mut req: tiny_http::Request, started: Instant, request_count: u64, verb_count: &AtomicU64) {+ let uptime = started.elapsed().as_secs();+ let mut state = util::State::new();+ let (body, code) = match (req.method(), req.url()) {+ (Method::Get, "/status") | (Method::Get, "/health") => {+ let info = state.kicad_info();+ let led = if info.installed { "green" } else { "yellow" };+ (+ json!({+ "ok": true,+ "bridge": "kicad",+ "version": VERSION,+ "runtime": "native",+ "uptimeSec": uptime,+ "requestCount": request_count,+ "led": led,+ "summary": if info.installed { format!("KiCad {}", info.version) } else { "KiCad not found".into() },+ "tooltip": format!("kicad-bridge v{VERSION} (native)\nup {uptime}s, {request_count} requests\n{}", info.summary()),+ }),+ 200,+ )+ }+ (Method::Post, "/command") => {+ let caller = Caller {+ thread: header_value(&req, "X-Adom-Caller-Thread").unwrap_or_else(|| "?".into()),+ container: header_value(&req, "X-Adom-Caller-Container").unwrap_or_else(|| "?".into()),+ reason: header_value(&req, "X-Adom-Caller-Reason").unwrap_or_default(),+ };+ let mut raw = String::new();+ let _ = req.as_reader().read_to_string(&mut raw);+ if raw.trim().is_empty() {+ raw = "{}".into(); }- (Method::Post, "/command") => {- let caller = Caller {- thread: header_value(&req, "X-Adom-Caller-Thread").unwrap_or_else(|| "?".into()),- container: header_value(&req, "X-Adom-Caller-Container").unwrap_or_else(|| "?".into()),- reason: header_value(&req, "X-Adom-Caller-Reason").unwrap_or_default(),- };- let mut raw = String::new();- let _ = req.as_reader().read_to_string(&mut raw);- if raw.trim().is_empty() {- raw = "{}".into();- }- match serde_json::from_str::<Command>(&raw) {- Err(e) => (- json!({"success": false, "error": format!("bad JSON: {e}"), "_reportIssues": report_issues_line()}),- 400,- ),- Ok(cmd) => {- verb_count += 1;- eprintln!(- "[kicad-bridge] {} <- thread={} container={} reason={}",- cmd.command, caller.thread, caller.container, caller.reason- );- let t0 = Instant::now();- let mut out = verbs::dispatch(&mut state, &cmd.command, &cmd.args, &caller);- if let Some(o) = out.as_object_mut() {- o.insert("elapsedMs".into(), json!(t0.elapsed().as_millis() as u64));- o.entry("bridgeVersion").or_insert(json!(VERSION));- let failed = !o.get("success").and_then(Value::as_bool).unwrap_or(false);- if failed || verb_count == 1 {- o.insert("_reportIssues".into(), json!(report_issues_line()));- }+ match serde_json::from_str::<Command>(&raw) {+ Err(e) => (+ json!({"success": false, "error": format!("bad JSON: {e}"), "_reportIssues": report_issues_line()}),+ 400,+ ),+ Ok(cmd) => {+ let nth = verb_count.fetch_add(1, Ordering::Relaxed) + 1;+ eprintln!(+ "[kicad-bridge] {} <- thread={} container={} reason={}",+ cmd.command, caller.thread, caller.container, caller.reason+ );+ let t0 = Instant::now();+ let mut out = verbs::dispatch(&mut state, &cmd.command, &cmd.args, &caller);+ if let Some(o) = out.as_object_mut() {+ o.insert("elapsedMs".into(), json!(t0.elapsed().as_millis() as u64));+ o.entry("bridgeVersion").or_insert(json!(VERSION));+ let failed = !o.get("success").and_then(Value::as_bool).unwrap_or(false);+ if failed || nth == 1 {+ o.insert("_reportIssues".into(), json!(report_issues_line())); }- (out, 200) }+ (out, 200) } }- _ => (json!({"success": false, "error": "not found"}), 404),- };- let _ = req.respond(json_response(body, code));- }+ }+ _ => (json!({"success": false, "error": "not found"}), 404),+ };+ let _ = req.respond(json_response(body, code)); }
rust/crates/kicad-bridge/src/util.rs+20−8@@ -7,30 +7,42 @@ use serde_json::{json, Value}; use kicad_core::detect::{self, KicadInfo}; -pub struct State {- info: Option<(std::time::Instant, KicadInfo)>,-}+/// Per-request handle. The detection cache behind it is process-wide (one probe per+/// 60 s no matter how many worker threads serve requests), so a fresh State per request+/// costs nothing and every verb group keeps its `&mut State` signature.+pub struct State;++static DETECT_CACHE: std::sync::Mutex<Option<(std::time::Instant, KicadInfo)>> = std::sync::Mutex::new(None); impl State { pub fn new() -> Self {- State { info: None }+ State } /// Detection is cached for 60 s; a verb that needs fresh truth calls refresh(). pub fn kicad_info(&mut self) -> KicadInfo {- if let Some((t, ref i)) = self.info {- if t.elapsed() < Duration::from_secs(60) {- return i.clone();+ if let Ok(g) = DETECT_CACHE.lock() {+ if let Some((t, ref i)) = *g {+ if t.elapsed() < Duration::from_secs(60) {+ return i.clone();+ } } } self.refresh() } pub fn refresh(&mut self) -> KicadInfo { let i = detect::detect();- self.info = Some((std::time::Instant::now(), i.clone()));+ if let Ok(mut g) = DETECT_CACHE.lock() {+ *g = Some((std::time::Instant::now(), i.clone()));+ } i } } +/// Window, IPC and show verbs serialise on this lock (they share KiCad's UI thread and the+/// etiquette ledger); everything else runs in parallel so status and progress polls never+/// wait behind a long open or a routing commit.+pub static GUI_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());+ pub fn fail(code: &str, msg: impl Into<String>, hint: impl Into<String>) -> Value { json!({"success": false, "errorCode": code, "error": msg.into(), "_hint": hint.into()}) }
rust/crates/kicad-bridge/src/verbs.rs+11−1@@ -15,12 +15,22 @@ use crate::Caller; pub fn dispatch(state: &mut State, command_in: &str, args: &Value, _caller: &Caller) -> Value { // ab forwards the bare name (it strips the `kicad_` prefix it routed on); the CLI and // hand callers send the full one. Accept both, like the Python bridge.- let full: String = if command_in.starts_with("kicad_") { command_in.to_string() } else { format!("kicad_{command_in}") };+ let mut full: String = if command_in.starts_with("kicad_") { command_in.to_string() } else { format!("kicad_{command_in}") };+ if full == "kicad_export_gerbers" {+ full = "kicad_export_gerber".into(); // alias kept from the Python bridge+ } let command = full.as_str(); let Some(verb) = catalog::find(command) else { let names: Vec<&str> = catalog::all().iter().map(|v| v.name).collect(); return fail("unknown_verb", format!("Unknown command: {command}"), format!("Valid commands: {}", names.join(", "))); };+ // One KiCad UI thread, one etiquette ledger: window, IPC and show verbs run one at a+ // time. Read-only and file verbs do not take the lock (a poisoned lock is still usable:+ // a verb that panicked mid-way must not wedge the bridge).+ let _serial = match verb.mechanism {+ catalog::Mechanism::Window | catalog::Mechanism::Ipc => Some(GUI_LOCK.lock().unwrap_or_else(|e| e.into_inner())),+ _ => None,+ }; // The ab callback client forwards this request's caller identity (headers first, args.caller as fallback). kicad_core::ab::set_caller(kicad_core::ab::caller_from(args, &_caller.thread, &_caller.container, &_caller.reason)); // Verb groups ported later live in their own files; each returns Some when it owns the verb.
rust/crates/kicad-bridge/src/verbs_misc.rs+79@@ -64,10 +64,46 @@ pub static VERBS: &[Verb] = &[ "the native build has no launch verb yet, so it only reads logs the Python bridge's kicad_launch wrote to <LOCALAPPDATA>/Adom Bridge/kicad-logs; available:false is the honest answer on a box that never launched through Python", ], },+ Verb {+ name: "kicad_bridge_status",+ summary: "Retired: the in-KiCad Python plugin (reverse bridge) is not part of the native bridge. Reports that, plus what replaced each of its jobs.",+ mechanism: Mechanism::Local, risk: "read", timeout_sec: 10,+ input: "{}", example: "kicad_bridge_status {}",+ hint: "Nothing to deploy or probe: editor opening uses the native menu walk and UI Automation, board state uses the IPC API, library rescans reload the tree in place.",+ related: &["kicad_open_editors", "kicad_routing_state", "kicad_rescan_libraries"], pitfalls: &["KiCad 11 removes the SWIG scripting the old plugin ran on; nothing in this bridge depends on it."],+ },+ Verb {+ name: "kicad_bridge_call",+ summary: "Retired: there is no in-KiCad plugin to call. Use the verb that does the job directly.",+ mechanism: Mechanism::Local, risk: "read", timeout_sec: 10,+ input: "{\"method\": \"...\"}", example: "kicad_bridge_call {\"method\":\"ping\"}",+ hint: "Returns errorCode plugin_retired with the replacement verb for the method you asked for.",+ related: &["kicad_routing_state", "kicad_open_footprint_editor", "kicad_open_3d_viewer"], pitfalls: &[],+ },+ Verb {+ name: "kicad_install_plugin",+ summary: "Retired: the native bridge installs no Python plugin into KiCad. Returns success with nothing to do so old scripts keep working.",+ mechanism: Mechanism::Local, risk: "read", timeout_sec: 10,+ input: "{}", example: "kicad_install_plugin {}",+ hint: "kicad_uninstall removes a plugin left behind by the Python bridge.",+ related: &["kicad_uninstall"], pitfalls: &[],+ },+ Verb {+ name: "kicad_plugin_diagnose",+ summary: "Retired: reports that no plugin is expected and whether a leftover Python plugin payload is still on disk.",+ mechanism: Mechanism::File, risk: "read", timeout_sec: 10,+ input: "{}", example: "kicad_plugin_diagnose {}",+ hint: "A leftover payload is harmless; kicad_uninstall removes it.",+ related: &["kicad_uninstall"], pitfalls: &[],+ }, ]; pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> { Some(match command {+ "kicad_bridge_status" => retired("bridge_status", None),+ "kicad_bridge_call" => retired("bridge_call", args.get("method").and_then(Value::as_str)),+ "kicad_install_plugin" => json!({"success": true, "installed": false, "retired": true, "output": "nothing to install: the native bridge has no in-KiCad plugin", "_hint": "Editor opening, board state and rescans no longer need a plugin. kicad_uninstall removes any payload the Python bridge left behind."}),+ "kicad_plugin_diagnose" => plugin_diagnose(), "kicad_ipc_api" => ipc_api(args), "kicad_export_molecule" => export_molecule(state, args), "kicad_errors" => errors(args),@@ -129,3 +165,46 @@ fn log_tail(args: &Value) -> Value { } v }++fn retired(what: &str, method: Option<&str>) -> Value {+ let replacement = match method.unwrap_or("") {+ m if m.contains("menu") || m.contains("wm_command") => "kicad_open_footprint_editor, kicad_open_symbol_editor or kicad_open_3d_viewer (native menu walk)",+ m if m.contains("symbol") => "kicad_open_symbol_editor",+ m if m.contains("footprint") || m.contains("tree") || m.contains("refresh") => "kicad_open_footprint_editor or kicad_rescan_libraries",+ m if m.contains("board") || m.contains("net") || m.contains("track") => "kicad_routing_state and the routing verbs (IPC API)",+ _ => "the verb that does the job directly (see kicad_describe)",+ };+ json!({+ "success": false,+ "errorCode": "plugin_retired",+ "retired": true,+ "plugin": {"deployed": false, "expected": false, "alive": 0, "plugins": []},+ "error": format!("kicad_{what}: the in-KiCad Python plugin is retired in the native bridge"),+ "replacement": replacement,+ "_hint": format!("Use {replacement}. KiCad 11 removes the SWIG scripting the plugin ran on; nothing here depends on it."),+ })+}++fn plugin_diagnose() -> Value {+ let plat = kicad_platform::native();+ let mut leftovers: Vec<String> = Vec::new();+ if let Some(root) = plat.user_root() {+ if let Ok(rd) = std::fs::read_dir(&root) {+ for e in rd.flatten() {+ let site = e.path().join("3rdparty").join("Python311").join("site-packages");+ for f in ["adom_bridge.py", "usercustomize.py"] {+ let p = site.join(f);+ if p.is_file() { leftovers.push(p.to_string_lossy().replace('\\', "/")); }+ }+ }+ }+ }+ json!({+ "success": true,+ "retired": true,+ "pluginExpected": false,+ "leftoverPayload": leftovers,+ "output": if leftovers.is_empty() { "no plugin expected, none on disk".to_string() } else { format!("{} leftover payload file(s) from the Python bridge; harmless, kicad_uninstall removes them", leftovers.len()) },+ "_hint": "The native bridge drives KiCad through the IPC API, the native menu bar and UI Automation; the SWIG plugin is not used.",+ })+}
rust/crates/kicad-bridge/src/verbs_show.rsadded+11@@ -0,0 +1,11 @@+//! Verb group "show". Placeholder until phase 3b lands.+use serde_json::Value;++use crate::catalog::Verb;+use crate::util::State;++pub static VERBS: &[Verb] = &[];++pub fn dispatch(_state: &mut State, _command: &str, _args: &Value) -> Option<Value> {+ None+}
rust/crates/kicad-core/src/install_kicad.rsadded+1@@ -0,0 +1 @@+//! Placeholder: KiCad silent install and upgrade, filled in by phase 3b.
rust/crates/kicad-core/src/lib.rs+2@@ -20,3 +20,5 @@ pub mod ipc; pub mod uninstall; pub mod ab; pub mod windows_model;+pub mod progress;+pub mod install_kicad;
rust/crates/kicad-core/src/progress.rsadded+1@@ -0,0 +1 @@+//! Placeholder: operation progress registry, filled in by phase 3b.
skills/kicad-bridge-test/run_verb_tests.py+5−5@@ -135,11 +135,11 @@ def matrix(w: dict) -> dict: "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,+ "sch_wire": dict(phase=2.2, args={"filePath": S, "points": [[200, 146.19], [200, 140]]}, timeout=150),+ "sch_label": dict(phase=2.2, args={"filePath": S, "text": "VERBTEST_NET", "at": [200, 140], "kind": "local"}, timeout=150),+ "sch_set_property": dict(phase=2.3, args={"filePath": S, "reference": "R_VERBTEST", "name": "Value", "value": "2k2"}, timeout=150),+ "sch_move": dict(phase=2.4, args={"filePath": S, "reference": "R_VERBTEST", "to": [210, 150]}, timeout=150),+ "sch_delete": dict(phase=2.5, 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"),