app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
← Commit history
Merge PR #370: kicad-lane: 1.0.98, document_info
5 files changed
+128−5
bridge.json+1@@ -359,6 +359,7 @@ "kicad_set_track_width", "kicad_footprint_primitives", "kicad_rules_by_kind",+ "kicad_document_info", "kicad_review_board" ], "statusVerb": "kicad_status",
rust/Cargo.lock+3−3@@ -319,7 +319,7 @@ dependencies = [ [[package]] name = "kicad-bridge"-version = "1.0.97"+version = "1.0.98" dependencies = [ "adom-eda-checks", "kicad-core",@@ -331,7 +331,7 @@ dependencies = [ [[package]] name = "kicad-core"-version = "1.0.97"+version = "1.0.98" dependencies = [ "kicad-ipc-rs", "kicad-platform",@@ -358,7 +358,7 @@ dependencies = [ [[package]] name = "kicad-platform"-version = "1.0.97"+version = "1.0.98" dependencies = [ "serde", "serde_json",
rust/Cargo.toml+1−1@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package]-version = "1.0.97"+version = "1.0.98" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/verbs_std_more.rs+119@@ -113,6 +113,16 @@ pub static VERBS: &[Verb] = &[ related: &["kicad_board_rules", "kicad_netclasses", "kicad_custom_rules", "kicad_run_drc"], pitfalls: &["Reads the saved project files: rules changed in an open Board Setup dialog count once saved."], },+ Verb {+ name: "kicad_document_info",+ summary: "The active KiCad document (the topmost KiCad window): its name, kind (board, schematic, library, project, model), whether it has unsaved changes, where it is saved, and its project (the standard's document_info). Read only; opens nothing.",+ mechanism: Mechanism::File, risk: "read", timeout_sec: 30,+ input: "{}",+ example: "kicad_document_info {}",+ hint: "modified is the leading * KiCad puts in the title. path comes from the editor's own command line (via Adom Bridge), or the project folder for an editor opened inside the project manager, else KiCad's recent-file list (pathFrom says which). version is null: KiCad keeps no versions. windows[] lists every KiCad window, topmost first.",+ related: &["kicad_state", "kicad_activate_document", "kicad_open_document"],+ pitfalls: &["With no KiCad window open it answers no_document.", "A new, never-saved sheet is untitled with saved false and path null."],+ }, ]; pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {@@ -127,6 +137,7 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> "kicad_set_track_width" => set_track_width(state, args), "kicad_footprint_primitives" => footprint_primitives(state, args), "kicad_rules_by_kind" => rules_by_kind(args),+ "kicad_document_info" => document_info(), _ => return None, }) }@@ -347,6 +358,101 @@ fn routing_drc(state: &mut State, args: &Value) -> Value { }) } +/// A KiCad window title: (name, kind, unsaved, sub-sheet), or None for a window that holds no document.+/// "*rp2040 — PCB Editor", "untitled [Unsaved] — Schematic Editor", "board [/power/] — Schematic Editor",+/// "rp2040 — KiCad 10.0" (the project manager).+fn parse_title(title: &str) -> Option<(String, &'static str, bool, Option<String>)> {+ let (left, right) = title.rsplit_once(" — ").or_else(|| title.rsplit_once(" - "))?;+ let kind = match right.trim() {+ "PCB Editor" => "board",+ "Schematic Editor" => "schematic",+ "Symbol Editor" | "Footprint Editor" => "library",+ r if r.starts_with("KiCad") => "project",+ r if r.contains("3D Viewer") => "model",+ _ => return None,+ };+ let modified = left.starts_with('*');+ let mut name = left.trim_start_matches('*').trim().to_string();+ let mut sub = None;+ if let Some(i) = name.find(" [") {+ let tag = name[i + 2..].trim_end_matches(']').to_string();+ if tag != "Unsaved" {+ sub = Some(tag);+ }+ name.truncate(i);+ }+ Some((name, kind, modified, sub))+}++/// The document a KiCad process was started on: the .kicad_pcb, .kicad_sch or .kicad_pro in its command line.+fn doc_arg(cmd: &str) -> Option<String> {+ let exts = [".kicad_pcb", ".kicad_sch", ".kicad_pro"];+ let quoted: Vec<&str> = cmd.split('"').skip(1).step_by(2).collect();+ let pool: Vec<&str> = if quoted.is_empty() { cmd.split_whitespace().collect() } else { quoted };+ pool.into_iter().rev().find(|t| exts.iter().any(|e| t.to_ascii_lowercase().ends_with(e))).map(|t| t.replace('\\', "/"))+}++fn document_info() -> Value {+ use kicad_platform::native;+ const KICAD: &[&str] = &["kicad.exe", "pcbnew.exe", "eeschema.exe", "kicad", "pcbnew", "eeschema"];+ let wins: Vec<_> = native().list_windows().unwrap_or_default().into_iter().filter(|w| KICAD.contains(&w.exe.as_str()) && w.owner == 0).collect();+ let docs: Vec<(kicad_platform::WindowInfo, (String, &'static str, bool, Option<String>))> = wins.iter().filter_map(|w| parse_title(&w.title).map(|t| (w.clone(), t))).collect();+ // the topmost editor; the project manager only when no editor is open+ let Some((win, (name, kind, modified, sub))) = docs.iter().find(|(_, t)| t.1 != "project").or_else(|| docs.first()).cloned() else {+ return fail("no_document", "no KiCad document is open", "kicad_open_document opens one; kicad_state lists KiCad's windows.");+ };+ let ext = match kind { "board" => "kicad_pcb", "schematic" => "kicad_sch", "project" => "kicad_pro", _ => "" };+ let untitled = name == "untitled";+ // the editor's command line (Adom Bridge's process_list), or the project manager's for an editor it hosts+ let cmd_of = |pid: u32| -> Option<String> {+ let r = kicad_core::ab::call("process_list", json!({}), Duration::from_secs(15)).ok()?;+ let v = kicad_core::ab::unwrap_payload(&r.raw);+ let ps = v.get("processes").or_else(|| v.get("data")).and_then(Value::as_array)?.clone();+ ps.iter().find(|p| p["pid"].as_u64() == Some(pid as u64)).and_then(|p| p["commandLine"].as_str().map(str::to_string))+ };+ let mut path: Option<String> = None;+ let mut path_from = "none";+ if !untitled && !ext.is_empty() {+ if let Some(arg) = cmd_of(win.pid).and_then(|c| doc_arg(&c)) {+ let p = PathBuf::from(&arg);+ let candidate = p.with_file_name(format!("{name}.{ext}"));+ if p.extension().map_or(false, |e| e == ext) && p.file_stem().map_or(false, |s| s.to_string_lossy() == name) {+ path = Some(norm(&p));+ path_from = "commandLine";+ } else if candidate.exists() || p.extension().map_or(false, |e| e == "kicad_pro") {+ path = Some(norm(&candidate));+ path_from = "projectFolder";+ }+ }+ if path.is_none() {+ // KiCad's recent-file list, newest first+ let file = match kind { "board" => "pcbnew.json", "schematic" => "eeschema.json", _ => "kicad.json" };+ let hist = kicad_core::detect::detect().primary().and_then(|i| kicad_core::detect::config_dir(&i.version))+ .and_then(|d| std::fs::read_to_string(d.join(file)).ok()).and_then(|t| serde_json::from_str::<Value>(&t).ok())+ .and_then(|v| v.pointer("/system/file_history").and_then(Value::as_array).cloned()).unwrap_or_default();+ if let Some(h) = hist.iter().filter_map(Value::as_str).find(|h| Path::new(&h.replace('\\', "/")).file_stem().map_or(false, |s| s.to_string_lossy() == name)) {+ path = Some(h.replace('\\', "/"));+ path_from = "recentFiles";+ }+ }+ }+ let project = path.as_ref().map(|p| PathBuf::from(p)).and_then(|p| {+ let pro = p.with_extension("kicad_pro");+ if pro.exists() { Some(norm(&pro)) } else { std::fs::read_dir(p.parent()?).ok()?.flatten().map(|e| e.path()).find(|x| ext_of(x) == "kicad_pro").map(|x| norm(&x)) }+ });+ json!({+ "success": true, "name": name, "kind": kind, "modified": modified,+ "saved": path.as_ref().map_or(false, |p| Path::new(p).exists()), "path": path, "pathFrom": path_from,+ "version": Value::Null, "project": project, "subSheet": sub,+ "window": {"title": win.title, "exe": win.exe, "pid": win.pid},+ "windows": wins.iter().map(|w| json!({"title": w.title, "exe": w.exe})).collect::<Vec<_>>(),+ })+}++fn ext_of(p: &Path) -> String {+ ext(p)+}+ fn rules_by_kind(args: &Value) -> Value { let Some(pro) = arg_any(args, &["projectPath", "filePath", "boardPath"]).map(|p| PathBuf::from(p).with_extension("kicad_pro")) else { return fail("missing_arg", "projectPath or filePath is required", "The project, or its board: the .kicad_pro and .kicad_dru sit beside it.");@@ -495,6 +601,19 @@ mod tests { let _ = std::fs::remove_dir_all(&d); } + #[test]+ fn window_titles_and_command_lines_name_the_document() {+ assert_eq!(parse_title("*esc — PCB Editor"), Some(("esc".into(), "board", true, None)));+ assert_eq!(parse_title("rp2040-breakout — Schematic Editor"), Some(("rp2040-breakout".into(), "schematic", false, None)));+ assert_eq!(parse_title("untitled [Unsaved] — Schematic Editor"), Some(("untitled".into(), "schematic", false, None)));+ assert_eq!(parse_title("board [/power/] — Schematic Editor"), Some(("board".into(), "schematic", false, Some("/power/".into()))));+ assert_eq!(parse_title("rp2040-breakout — KiCad 10.0").map(|t| t.1), Some("project"));+ assert_eq!(parse_title("Load PCB"), None);+ assert_eq!(doc_arg("C:/K/bin/pcbnew.exe C:/p/b.kicad_pcb").as_deref(), Some("C:/p/b.kicad_pcb"));+ assert_eq!(doc_arg("\"C:\\Program Files\\K\\eeschema.exe\" \"C:\\My Boards\\a.kicad_sch\"").as_deref(), Some("C:/My Boards/a.kicad_sch"));+ assert_eq!(doc_arg("C:/K/bin/kicad.exe"), None);+ }+ #[test] fn every_preset_names_layers_and_bottom_ones_are_mirrored() { assert!(PRESETS.iter().all(|p| !p.1.is_empty() && !p.4.is_empty()));
skills/kicad-bridge-test/run_verb_tests.py+4−1@@ -206,7 +206,7 @@ def matrix(w: dict) -> dict: "library_views", "generate_body", "plan_route", "export_spice", "simulate", "license_status", "eval", "run_script", "dialog_log", "foreground", "schematic_review", "review_board", "variants", "sch_zoom", "swap_footprint", "plate_vias", "normalize_bodies", "symbol_mode", "add_symbol_art", "build_symbol_modes", "cleanup_libs", "export_3d", "edit_pours", "import_design", "sch_part_verbs_standard", "tool_update", "import_step", "cross_check",- "list_library", "render_board_layers", "export_bom", "open_document", "activate_document", "open_project", "routing_drc", "set_track_width", "footprint_primitives", "rules_by_kind", "net_connections", "remove_items")},+ "list_library", "render_board_layers", "export_bom", "open_document", "activate_document", "open_project", "routing_drc", "set_track_width", "footprint_primitives", "rules_by_kind", "net_connections", "remove_items", "document_info")}, "panelize": dict(phase=8, scenario=True), # ---- phase 8: the 1.0.61 verbs (Konnect parity), run in order by k161_phase() on scratch files "add_footprint": dict(phase=8, scenario=True),@@ -1801,6 +1801,9 @@ def main(): ad = C("activate_document", {"path": xd + "/rp2040-breakout.kicad_pcb"}, 200) own("open_document", od, ok(od) and (od.get("data") or {}).get("objectKind") == "Board", "the scratch board did not open") own("activate_document", ad, ok(ad) and (ad.get("data") or {}).get("kind") == "pcb", "the open scratch board was not activated")+ di = C("document_info", {}, 60)+ own("document_info", di, ok(di) and di.get("kind") in ("board", "schematic", "project") and str(di.get("path") or "").startswith(xd) and di.get("pathFrom") != "none" and isinstance(di.get("modified"), bool),+ "the active KiCad document was not named with its kind and its path in the scratch project") ab(a.target, "kicad_close", {"force": True, "reason": "kicad-bridge verb test: close the scratch board open_document opened"}, timeout=120) op = C("open_project", {"filePath": xd + "/rp2040-breakout.kicad_pro"}, 200) own("open_project", op, ok(op) and (op.get("data") or {}).get("objectKind") == "Project", "the scratch project did not open in the project manager")