← Commit history
bridge.json+13
@@ -360,6 +360,19 @@     "kicad_footprint_primitives",     "kicad_rules_by_kind",     "kicad_document_info",+    "kicad_read_board",+    "kicad_read_schematic",+    "kicad_schematic_data",+    "kicad_pcb_report",+    "kicad_instances",+    "kicad_ensure_licensed",+    "kicad_signin_status",+    "kicad_setup_trial",+    "kicad_set_models",+    "kicad_add_models",+    "kicad_check_dialogs",+    "kicad_close_document",+    "kicad_close_all_documents",     "kicad_review_board"   ],   "statusVerb": "kicad_status",
rust/Cargo.lock+3−3
@@ -319,7 +319,7 @@ dependencies = [  [[package]] name = "kicad-bridge"-version = "1.0.98"+version = "1.0.99" dependencies = [  "adom-eda-checks",  "kicad-core",@@ -331,7 +331,7 @@ dependencies = [  [[package]] name = "kicad-core"-version = "1.0.98"+version = "1.0.99" dependencies = [  "kicad-ipc-rs",  "kicad-platform",@@ -358,7 +358,7 @@ dependencies = [  [[package]] name = "kicad-platform"-version = "1.0.98"+version = "1.0.99" dependencies = [  "serde",  "serde_json",
rust/Cargo.toml+1−1
@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"]  [workspace.package]-version = "1.0.98"+version = "1.0.99" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/catalog.rs−1
@@ -190,7 +190,6 @@ pub static VERBS: &[Verb] = &[ pub static ALIASES: &[(&str, &str)] = &[     ("kicad_net_classes", "kicad_netclasses"),     ("kicad_repour", "kicad_refill_zones"),-    ("kicad_save", "kicad_save_board"),     ("kicad_update_pcb", "kicad_update_pcb_from_schematic"),     ("kicad_annotate", "kicad_sch_annotate"),     ("kicad_create_symbol", "kicad_sym_create"),
rust/crates/kicad-bridge/src/kicad_only.txt+1
@@ -74,6 +74,7 @@ kicad_remove_zone kicad_rescan_libraries kicad_route kicad_routing_validate+kicad_save_board kicad_sch_check kicad_sch_components kicad_sch_connect_to_net
rust/crates/kicad-bridge/src/standard_names.txt+1−1
@@ -1,4 +1,4 @@-# The EDA Bridge Standard's verb names (adom/eda-bridge-standard verbs/standard.json, reference altium 0.2.87). Generated by tools/sync_standard_names.py; do not edit.+# The EDA Bridge Standard's verb names (adom/eda-bridge-standard verbs/standard.json, reference altium 0.2.88). Generated by tools/sync_standard_names.py; do not edit. a365_configure a365_files_upload a365_library_overview
rust/crates/kicad-bridge/src/std_shapes.rs+2−2
@@ -301,7 +301,7 @@ fn on_grid(mm: f64) -> f64 {  /// Where y = 0 is: the grid point at the sheet's lower-left corner, from its `(paper ...)` (A4 when the file cannot be /// read). On the grid, so a part placed at whole mils lands with its pins on KiCad's grid and reads back the same mils.-fn page_height(path: Option<&str>) -> f64 {+pub(crate) fn page_height(path: Option<&str>) -> f64 {     let h = paper_height(path);     ((h / GRID).floor() * GRID * 10000.0).round() / 10000.0 }@@ -327,7 +327,7 @@ fn mil_to_sheet(x: f64, y: f64, height: f64) -> (f64, f64) {     (on_grid(x * MIL), on_grid(height - y * MIL)) } -fn sheet_to_mil(at: &Value, height: f64) -> Value {+pub(crate) fn sheet_to_mil(at: &Value, height: f64) -> Value {     match (at.get(0).and_then(Value::as_f64), at.get(1).and_then(Value::as_f64)) {         (Some(x), Some(y)) => json!([(x / MIL).round(), ((height - y) / MIL).round()]),         _ => Value::Null,
rust/crates/kicad-bridge/src/verbs_composite.rs+1−1
@@ -116,7 +116,7 @@ fn read_sheet_file(state: &mut State, args: &Value) -> Value {                 row["pins"] = json!(pins["symbols"].as_array().into_iter().flatten().filter(|s| s["reference"] == rf)                     .flat_map(|s| s["pins"].as_array().cloned().unwrap_or_default())                     .filter(|p| p["unit"].as_i64() == Some(unit) || p["unit"].as_i64() == Some(0))-                    .map(|p| json!({"number": p["number"], "name": p["name"], "type": p["type"], "at": p["at"], "hidden": p["hidden"]})).collect::<Vec<_>>());+                    .map(|p| json!({"number": p["number"], "name": p["name"], "type": p["type"], "at": p["at"], "direction": p["direction"], "length": p["length"], "hidden": p["hidden"]})).collect::<Vec<_>>());             }             out.push(row);         }
rust/crates/kicad-bridge/src/verbs_pcb_sync.rs+88−3
@@ -22,9 +22,9 @@ pub static VERBS: &[Verb] = &[Verb {     name: "kicad_update_pcb_from_schematic",     summary: "Compare the schematic's netlist with the board (missing, extra and changed footprints, changed values, pad net changes) and, on a closed board file, apply it: add missing footprints from the libraries in a row below the board, update values and links, assign pad nets, mark extras.",     mechanism: Mechanism::File, risk: "write", timeout_sec: 180,-    input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"netlistPath\"?: \"C:/.../x.net\" | \"schematicPath\"?: \"C:/.../x.kicad_sch\" | \"projectPath\"?, \"apply\"?: false, \"updateFootprints\"?: true, \"deleteExtra\"?: false}",+    input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"netlistPath\"?: \"C:/.../x.net\" | \"schematicPath\"?: \"C:/.../x.kicad_sch\" | \"projectPath\"?, \"apply\" | \"execute\"?: false, \"updateFootprints\"?: true, \"deleteExtra\" | \"allowRemovals\"?: false, \"keepBoardOnly\"?: false}; projectPath alone names the board beside it",     example: "kicad_update_pcb_from_schematic {\"filePath\":\"C:/p/b.kicad_pcb\"}",-    hint: "Plan by default (dryRun): missingFootprints, extraFootprints (boardOnlyFootprints are left alone), changedValues, changedFootprints, padNetChanges, relinks, inSync. The netlist comes from netlistPath (a KiCad XML netlist: File > Export > Netlist) or kicad-cli on the schematic beside the board. apply:true needs the board CLOSED: new parts land in a row 5 mm below the outline (place them next), replaced footprints keep their pose, uuid and link, every pad net is set, extras get an Adom_NotInSchematic property (deleteExtra:true removes them). The file is replaced atomically with a .adom-bak; done[] says what landed and notApplied[] what could not (a footprint not in any fp-lib-table).",+    hint: "The standard's reply is here too: preview {addParts, removeParts, sheetParts, boardParts}, changeOrder, executed, afterExecute, board, dialogs, steps; execute:true refuses while removeParts is not empty unless allowRemovals:true. Plan by default (dryRun): missingFootprints, extraFootprints (boardOnlyFootprints are left alone), changedValues, changedFootprints, padNetChanges, relinks, inSync. The netlist comes from netlistPath (a KiCad XML netlist: File > Export > Netlist) or kicad-cli on the schematic beside the board. apply:true needs the board CLOSED: new parts land in a row 5 mm below the outline (place them next), replaced footprints keep their pose, uuid and link, every pad net is set, extras get an Adom_NotInSchematic property (deleteExtra:true removes them). The file is replaced atomically with a .adom-bak; done[] says what landed and notApplied[] what could not (a footprint not in any fp-lib-table).",     related: &["kicad_netlist", "kicad_extract_netlist", "kicad_placement_state", "kicad_move_footprint", "kicad_run_drc"],     pitfalls: &["Footprints are matched by reference, then linked by symbol uuid: annotate the schematic before syncing.", "Live apply is refused (board_open) while KiCad has the board open: use KiCad's Tools > Update PCB from Schematic (F8) there, or save and close the board and apply here.", "A replaced footprint's old tracks stay where they were; DRC shows the ones that no longer reach a pad.", "Back-side footprints are not replaced (they would need a flip); they appear in notApplied."], }];@@ -79,7 +79,58 @@ pub(crate) fn netlist_for(state: &mut State, args: &Value, board: &Path) -> Resu     Ok((nl, norm(&sch))) } +/// The standard's update_pcb preview and change order, from the plan.+fn standard_reply(plan: &Value) -> (Value, Vec<String>) {+    let refs = |k: &str| plan[k].as_array().into_iter().flatten().map(|r| r["ref"].clone()).collect::<Vec<_>>();+    let preview = json!({"addParts": refs("missingFootprints"), "removeParts": refs("extraFootprints"), "sheetParts": plan["counts"]["schematicComponents"], "boardParts": plan["counts"]["boardFootprints"]});+    let mut order = Vec::new();+    let mut group = |name: &str, key: &str, line: &dyn Fn(&Value) -> String| {+        let rows = plan[key].as_array().cloned().unwrap_or_default();+        if !rows.is_empty() {+            order.push(format!("{name} ({})", rows.len()));+            order.extend(rows.iter().map(|r| format!("  {}", line(r))));+        }+    };+    let s = |v: &Value| v.as_str().unwrap_or("").to_string();+    group("Add footprints", "missingFootprints", &|r| format!("{} {} {}", s(&r["ref"]), s(&r["value"]), s(&r["footprint"])));+    group("Remove footprints", "extraFootprints", &|r| format!("{} {} {}", s(&r["ref"]), s(&r["value"]), s(&r["footprint"])));+    group("Change values", "changedValues", &|r| format!("{}: {} to {}", s(&r["ref"]), s(&r["from"]), s(&r["to"])));+    group("Change footprints", "changedFootprints", &|r| format!("{}: {} to {}", s(&r["ref"]), s(&r["from"]), s(&r["to"])));+    group("Change pad nets", "padNetChanges", &|r| match r.as_str() { Some(t) => t.to_string(), None => r.to_string() });+    group("Link to symbols", "relinks", &|r| s(&r["ref"]));+    (preview, order)+}+ fn update(state: &mut State, args: &Value) -> Value {+    // The standard's shape: execute and allowRemovals, and the project names the board.+    let mut a = args.clone();+    let standard_execute = args.get("execute") == Some(&json!(true));+    if standard_execute {+        a["apply"] = json!(true);+    }+    if args.get("allowRemovals") == Some(&json!(true)) {+        a["deleteExtra"] = json!(true);+    }+    if arg_str(args, "filePath").or(arg_str(args, "boardPath")).is_none() {+        if let Some(p) = arg_str(args, "projectPath") {+            a["filePath"] = json!(norm(&PathBuf::from(p.trim()).with_extension("kicad_pcb")));+        }+    }+    let mut out = update_inner(state, &a, standard_execute);+    if let Some(plan) = out.get("plan").filter(|p| p.is_object()).cloned() {+        let (preview, order) = standard_reply(&plan);+        out["preview"] = preview;+        out["changeOrder"] = json!(order);+        out["executed"] = json!(out["mutated"] == json!(true));+        out["afterExecute"] = json!(out.get("after").filter(|p| p.is_object()).map(|p| standard_reply(p).1).unwrap_or_default());+        out["board"] = out.get("boardPath").cloned().or_else(|| a.get("filePath").cloned()).unwrap_or(Value::Null);+        out["dialogs"] = json!([]);+        out["steps"] = json!(if out["executed"] == json!(true) { vec!["netlist", "compare", "apply to the closed board file"] } else { vec!["netlist", "compare"] });+    }+    out+}++fn update_inner(state: &mut State, args: &Value, standard_execute: bool) -> Value {     let loaded = match pcb::load(args, true) {         Ok(l) => l,         Err(e) => return e,@@ -99,6 +150,12 @@ fn update(state: &mut State, args: &Value) -> Value {         out["_hint"] = json!("The board matches the schematic; nothing was written.");         return out;     }+    if standard_execute && args.get("deleteExtra") != Some(&json!(true)) && args.get("keepBoardOnly") != Some(&json!(true)) && plan["extraFootprints"].as_array().is_some_and(|x| !x.is_empty()) {+        let mut v = fail("removals_not_allowed", "the change would delete board parts that are on no sheet", "preview.removeParts names them. allowRemovals:true deletes them; keepBoardOnly:true updates the rest and leaves them on the board. A part ticked Not in schematic in KiCad's footprint properties (a fiducial, a mounting hole) is never counted here.");+        v["plan"] = plan;+        v["mutated"] = json!(false);+        return v;+    }     if let Some(lck) = pcb::lock_file(&loaded.path) {         let mut v = fail("board_open", "KiCad has this board open: the file cannot be updated under it, and a live update is not offered (the IPC API cannot load library footprints into the editor)", "In that KiCad run Tools > Update PCB from Schematic (F8); or save and close the board (kicad_close) and call again with apply:true.");         v["lock"] = json!(norm(&lck));@@ -133,7 +190,7 @@ mod tests {      fn scratch() -> PathBuf {         let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("../kicad-core/tests/fixtures/konnect-route");-        let d = std::env::temp_dir().join(format!("kb-sync-{}", std::process::id()));+        let d = std::env::temp_dir().join(format!("kb-sync-{}-{:?}", std::process::id(), std::thread::current().id()).replace(['(', ')'], ""));         let _ = std::fs::remove_dir_all(&d);         std::fs::create_dir_all(d.join("Fixture.pretty")).unwrap();         for f in ["route-fixture.kicad_pcb", "route-fixture.kicad_pro", "route-fixture.net", "fp-lib-table", "Fixture.pretty/R_0603_1608Metric.kicad_mod", "Fixture.pretty/PinHeader_1x04_P2.00mm_Vertical.kicad_mod"] {@@ -168,4 +225,32 @@ mod tests {         assert_eq!(none["errorCode"], "schematic_not_found");         let _ = std::fs::remove_dir_all(&d);     }++    #[test]+    fn the_standards_update_pcb_previews_and_refuses_removals() {+        let d = scratch().join("std");+        std::fs::create_dir_all(&d).unwrap();+        for f in ["route-fixture.kicad_pcb", "route-fixture.kicad_pro", "route-fixture.net", "fp-lib-table"] {+            std::fs::copy(d.parent().unwrap().join(f), d.join(f)).unwrap();+        }+        std::fs::create_dir_all(d.join("Fixture.pretty")).unwrap();+        for e in std::fs::read_dir(d.parent().unwrap().join("Fixture.pretty")).unwrap().flatten() {+            std::fs::copy(e.path(), d.join("Fixture.pretty").join(e.file_name())).unwrap();+        }+        let (project, net) = (norm(&d.join("route-fixture.kicad_pro")), norm(&d.join("route-fixture.net")));+        let p = call(json!({"projectPath": project, "netlistPath": net}));+        assert_eq!((p["executed"].as_bool(), p["preview"]["addParts"][0].as_str()), (Some(false), Some("R2")), "{p}");+        assert!(p["preview"]["removeParts"].as_array().is_some_and(|r| !r.is_empty()), "{p}");+        assert!(p["preview"]["sheetParts"].is_u64() && p["preview"]["boardParts"].is_u64(), "{p}");+        assert!(p["changeOrder"][0].as_str().unwrap().starts_with("Add footprints (1)"), "{p}");+        assert!(p["board"].as_str().unwrap().ends_with("route-fixture.kicad_pcb"), "{p}");+        let no = call(json!({"projectPath": project, "netlistPath": net, "execute": true}));+        assert_eq!((no["errorCode"].as_str(), no["mutated"].as_bool()), (Some("removals_not_allowed"), Some(false)), "{no}");+        let kept = call(json!({"projectPath": project, "netlistPath": net, "execute": true, "keepBoardOnly": true}));+        assert_eq!(kept["executed"], true, "{kept}");+        assert!(kept["afterExecute"].as_array().unwrap().iter().any(|l| l.as_str().is_some_and(|t| t.starts_with("Remove footprints"))), "{kept}");+        let gone = call(json!({"projectPath": project, "netlistPath": net, "execute": true, "allowRemovals": true}));+        assert_eq!((gone["executed"].as_bool(), gone["after"]["inSync"].as_bool()), (Some(true), Some(true)), "{gone}");+        let _ = std::fs::remove_dir_all(d.parent().unwrap());+    } }
rust/crates/kicad-bridge/src/verbs_std_more.rs+551−6
@@ -1,6 +1,7 @@ //! Standard verbs KiCad answered only partly under other names (tools/equivalents/kicad.json of the EDA Bridge //! Standard), now under their own names with the standard's arguments and replies: list_library, open_document,-//! open_project, activate_document, render_board_layers, export_bom, routing_drc. Each is a thin shape over KiCad verbs the bridge+//! open_project, activate_document, render_board_layers, export_bom, routing_drc, read_board, read_schematic,+//! schematic_data, pcb_report, instances. Each is a thin shape over KiCad verbs the bridge //! already has; the ones that open windows call them through verbs::call, so these are never Window verbs themselves //! (a Window verb calling another through dispatch would wait on the GUI lock it holds). @@ -123,6 +124,106 @@ pub static VERBS: &[Verb] = &[         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."],     },+    Verb {+        name: "kicad_read_board",+        summary: "The whole board in one read (the standard's read_board): components, nets, layers, copper, rules, occupancy and outline, from the open PCB editor when it serves the board, else the file.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 60,+        input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"sections\"?: \"components,nets,layers,copper,rules,occupancy,outline\", \"limit\"?: n (per list), \"source\"?: \"auto|live|file\"}",+        example: "kicad_read_board {\"filePath\":\"C:/p/b.kicad_pcb\",\"sections\":\"components,rules\"}",+        hint: "data holds the sections you asked for (all by default), in mm in the standard's y-up frame. rules is kicad_rules_by_kind's answer, occupancy is copper items per layer. limit caps each list and truncated names the capped ones. kicad_routing_state {model:\"standard\"} is the same copper with the revision for edits.",+        related: &["kicad_routing_state", "kicad_board_summary", "kicad_rules_by_kind", "kicad_layers_in_use"],+        pitfalls: &["A file read does not see unsaved edits; source says which was read."],+    },+    Verb {+        name: "kicad_read_schematic",+        summary: "The parts on one schematic sheet (the standard's read_schematic): designator, library reference, comment, position and parameters, for filePath or the sheet open in the Schematic Editor.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 60,+        input: "{\"filePath\" | \"sheet\"?: \"C:/.../x.kicad_sch\" (default the open sheet), \"limit\"?: 500, \"params\"?: true}",+        example: "kicad_read_schematic {\"filePath\":\"C:/p/b.kicad_sch\"}",+        hint: "One sheet, read from the saved file. Without filePath it reads the sheet the topmost Schematic Editor holds (KiCad's Schematic Editor has no API, so unsaved edits are not in the answer). Positions are mils from the grid point at the sheet's lower-left corner, y up. A hierarchical root holds sheet symbols: kicad_sch_hierarchy names the others.",+        related: &["kicad_schematic_data", "kicad_cross_check", "kicad_sch_hierarchy"],+        pitfalls: &["One sheet only.", "Reads the saved file."],+    },+    Verb {+        name: "kicad_schematic_data",+        summary: "The parts on one schematic sheet with their pins (the standard's schematic_data): number, name, electrical type, position, orientation and length of each pin; designator filters to one part.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 60,+        input: "{\"filePath\" | \"sheet\"?: \"C:/.../x.kicad_sch\" (default the open sheet), \"designator\"?: \"U1\", \"limit\"?: 500, \"params\"?: false, \"paramNamesOnly\"?: false, \"pins\"?: true}",+        example: "kicad_schematic_data {\"filePath\":\"C:/p/b.kicad_sch\",\"designator\":\"U1\"}",+        hint: "data.components[] has one row per placed unit (partId). electrical is the standard's ordinal (0 input, 1 IO, 2 output, 3 open collector, 4 passive, 5 tri-state, 6 open emitter, 7 power) and electricalType KiCad's own name, which also tells free, unspecified and no-connect pins (ordinal 4). kind is 0, or 5 for a part kept out of the BOM. Positions are mils from the grid point at the sheet's lower-left corner, y up.",+        related: &["kicad_read_schematic", "kicad_sch_pins", "kicad_netlist"],+        pitfalls: &["One sheet per call.", "Reads the saved file."],+    },+    Verb {+        name: "kicad_pcb_report",+        summary: "Numbers instead of pixels (the standard's pcb_report): for a footprint library, every footprint's pads, pad box, primitive census and 3D bodies; for a board, outline, parts with nets and bodies, census and tracks.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 60,+        input: "{\"path\" | \"filePath\": \"C:/.../x.kicad_pcb\" | \"C:/.../Lib.pretty\" | \"C:/.../X.kicad_mod\", \"name\"?: \"R_0603\" (a footprint name filter, library form), \"source\"?: \"auto|live|file\"}",+        example: "kicad_pcb_report {\"path\":\"C:/p/Lib.pretty\",\"name\":\"SOT\"}",+        hint: "data.kind is library or board. Boxes are mils: a library footprint's in its own frame, a board's in the standard's y-up frame. bodies counts a footprint's 3D models; on a board it is 1 when the part has a model and 0 when not. kicad_model_check says whether a model file really exists.",+        related: &["kicad_read_board", "kicad_fp_info", "kicad_model_check"],+        pitfalls: &[],+    },+    Verb {+        name: "kicad_instances",+        summary: "Every running KiCad process (the standard's instances): the project manager and any standalone editors, with pid and version.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 15,+        input: "{}",+        example: "kicad_instances {}",+        hint: "KiCad needs no licence, so licensed is true and agentAlive is null: it runs no script agent. Windows and documents: kicad_state.",+        related: &["kicad_state", "kicad_document_info", "kicad_window_info"],+        pitfalls: &[],+    },+    Verb {+        name: "kicad_ensure_licensed",+        summary: "One call to a usable KiCad (the standard's ensure_licensed): KiCad needs no seat or sign-in, so this says it is free and starts it if it is not running.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+        input: "{}",+        example: "kicad_ensure_licensed {}",+        hint: "alreadyLicensed is true whenever KiCad is installed: it is GPL-3.0-or-later. steps says whether it was launched. Not installed answers not_installed: kicad_upgrade installs it.",+        related: &["kicad_license_status", "kicad_readiness", "kicad_launch", "kicad_upgrade"],+        pitfalls: &[],+    },+    Verb {+        name: "kicad_signin_status",+        summary: "Account and workspace state (the standard's signin_status): KiCad has neither, so licensed is true, workspaceConnected false, and running says whether it is up.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 15,+        input: "{}",+        example: "kicad_signin_status {}",+        hint: "Nothing here needs a person. kicad_license_status has the licence in plain words.",+        related: &["kicad_license_status", "kicad_readiness"],+        pitfalls: &[],+    },+    Verb {+        name: "kicad_setup_trial",+        summary: "From nothing to a working KiCad, one step per call (the standard's setup_trial): reports stage install, launch or done and the one next action. KiCad has no trial or sign-in, so no person is needed.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 15,+        input: "{}",+        example: "kicad_setup_trial {}",+        hint: "nextAction is the verb to call: kicad_upgrade at install, kicad_launch at launch. Nothing is started by this verb.",+        related: &["kicad_upgrade", "kicad_launch", "kicad_readiness"],+        pitfalls: &[],+    },+    Verb {+        name: "kicad_set_models",+        summary: "Set a library symbol's one footprint link (the standard's set_models): replaces the Footprint field of a symbol in a .kicad_sym and says whether another was there.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 30,+        input: "{\"libraryPath\": \"C:/.../Mine.kicad_sym\", \"symbolName\": \"LM1117\", \"footprint\": \"Package_TO_SOT_SMD:SOT-223-3_TabPin2\"}",+        example: "kicad_set_models {\"libraryPath\":\"C:/p/Mine.kicad_sym\",\"symbolName\":\"LM1117\",\"footprint\":\"Package_TO_SOT_SMD:SOT-223-3_TabPin2\"}",+        hint: "removed is 1 when a different footprint was linked before. The footprint is written as given; Lib:Name is what KiCad resolves. kicad_footprint_models is another job: the 3D models on a footprint.",+        related: &["kicad_add_models", "kicad_set_params", "kicad_sym_info", "kicad_footprint_models"],+        pitfalls: &["Sheets that already placed the symbol keep their own copy until kicad_sch_update_from_library."],+    },+    Verb {+        name: "kicad_add_models",+        summary: "Attach footprints, parameters, comment and description to a library symbol (the standard's add_models). KiCad has one Footprint field: the first model goes there when it is empty, and every model joins the symbol's footprint filters.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 30,+        input: "{\"libraryPath\": \"C:/.../Mine.kicad_sym\", \"symbolName\": \"LM1117\", \"models\"?: [{\"name\": \"SOT-223-3_TabPin2\", \"pcblib\"?: \"Package_TO_SOT_SMD\" (a nickname, or a .pretty path)}], \"params\"?: {\"MPN\": \"...\"}, \"comment\"?: \"value\", \"description\"?: \"text\"}",+        example: "kicad_add_models {\"libraryPath\":\"C:/p/Mine.kicad_sym\",\"symbolName\":\"LM1117\",\"models\":[{\"name\":\"SOT-223-3_TabPin2\",\"pcblib\":\"Package_TO_SOT_SMD\"}]}",+        hint: "data {libref, models, params}. Calling again with the same model changes nothing: filters are kept unique. KiCad's footprint chooser offers what the filters match when a part has no footprint yet.",+        related: &["kicad_set_models", "kicad_set_params", "kicad_sym_info"],+        pitfalls: &["Footprint filters are name patterns KiCad offers, not implementations a part switches between as in Altium."],+    }, ];  pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {@@ -137,7 +238,17 @@ 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(),+        "kicad_document_info" => document_info(None),+        "kicad_read_board" => read_board(state, args),+        "kicad_read_schematic" => sheet_read(state, args, false),+        "kicad_schematic_data" => sheet_read(state, args, true),+        "kicad_pcb_report" => pcb_report(state, args),+        "kicad_instances" => instances(state),+        "kicad_ensure_licensed" => ensure_licensed(state),+        "kicad_signin_status" => signin_status(state),+        "kicad_setup_trial" => setup_trial(state),+        "kicad_set_models" => set_models(state, args),+        "kicad_add_models" => add_models(state, args),         _ => return None,     }) }@@ -361,7 +472,7 @@ 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>)> {+pub(crate) 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",@@ -392,14 +503,19 @@ fn doc_arg(cmd: &str) -> Option<String> {     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 {+fn document_info(want_kind: Option<&str>) -> 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.");+    // (or the topmost of the kind a verb that reads "the current sheet" needs)+    let pick = match want_kind {+        Some(k) => docs.iter().find(|(_, t)| t.1 == k),+        None => docs.iter().find(|(_, t)| t.1 != "project").or_else(|| docs.first()),+    };+    let Some((win, (name, kind, modified, sub))) = pick.cloned() else {+        return fail("no_document", format!("no KiCad {} is open", want_kind.unwrap_or("document")), "kicad_open_document opens one, or pass filePath; kicad_state lists KiCad's windows.");     };     let ext = match kind { "board" => "kicad_pcb", "schematic" => "kicad_sch", "project" => "kicad_pro", _ => "" };     let untitled = name == "untitled";@@ -453,6 +569,366 @@ fn ext_of(p: &Path) -> String {     ext(p) } +fn read_board(state: &mut State, args: &Value) -> Value {+    use kicad_core::board_model;+    let r = match crate::verbs_standard::read(state, args) {+        Ok(r) => r,+        Err(e) => return e,+    };+    let opts = board_model::Opts { y_up: true };+    let model = match board_model::build(&r.text, Some(&r.file), &opts) {+        Ok(m) => m,+        Err(e) => return fail("board_unparsable", e, "A damaged file opens and saves in KiCad to repair it."),+    };+    let wanted: Vec<String> = arg_str(args, "sections").map(|s| s.split(',').map(|x| x.trim().to_ascii_lowercase()).filter(|x| !x.is_empty()).collect()).unwrap_or_default();+    const SECTIONS: &[&str] = &["components", "nets", "layers", "copper", "rules", "occupancy", "outline"];+    if let Some(bad) = wanted.iter().find(|w| !SECTIONS.contains(&w.as_str())) {+        return fail("bad_args", format!("no section {bad}"), format!("sections is a comma list of {}.", SECTIONS.join(", ")));+    }+    let on = |s: &str| wanted.is_empty() || wanted.iter().any(|w| w == s);+    let limit = args.get("limit").and_then(Value::as_u64).map(|n| n as usize);+    let mut truncated = Vec::new();+    let mut list = |k: &str| -> Value {+        let a = model[k].as_array().cloned().unwrap_or_default();+        match limit {+            Some(n) if a.len() > n => {+                truncated.push(k.to_string());+                json!(a[..n])+            }+            _ => json!(a),+        }+    };+    let mut data = json!({"file": r.file});+    if on("components") {+        data["components"] = list("components");+    }+    if on("nets") {+        data["nets"] = list("nets");+    }+    if on("layers") {+        data["layers"] = json!({"copper": model["copperLayers"]});+    }+    if on("copper") {+        data["copper"] = json!({"tracks": list("tracks"), "arcs": list("arcs"), "vias": list("vias"), "pads": list("pads"), "pours": list("polygons")});+    }+    if on("rules") {+        let pro = PathBuf::from(&r.file).with_extension("kicad_pro");+        let pv: Value = std::fs::read_to_string(&pro).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);+        data["rules"] = kicad_core::project_rules::rules_by_kind(&pv, std::fs::read_to_string(pro.with_extension("kicad_dru")).ok().as_deref());+    }+    if on("occupancy") {+        data["occupancy"] = board_model::derive(&model, "layers_in_use", args).unwrap_or(Value::Null);+    }+    if on("outline") {+        data["outline"] = json!({"outline": model["outline"], "edges": model["outlineEdges"]});+    }+    let mut out = json!({"success": true, "data": data, "counts": model["counts"], "revision": model["revision"], "frame": model["frame"], "truncated": truncated});+    crate::verbs_standard::stamp(&mut out, &r);+    out+}++/// The standard's electrical ordinal for a KiCad pin type (schematic_data: 0 Input, 1 IO, 2 Output, 3 OpenCollector,+/// 4 Passive, 5 HiZ, 6 OpenEmitter, 7 Power). KiCad's free, unspecified and no-connect pins have no ordinal of their own.+fn electrical_ordinal(kicad: &str) -> u8 {+    match kicad {+        "input" => 0,+        "bidirectional" => 1,+        "output" => 2,+        "open_collector" => 3,+        "tri_state" => 5,+        "open_emitter" => 6,+        "power_in" | "power_out" => 7,+        _ => 4,+    }+}++/// `kicad_read_schematic` (with_pins false) and `kicad_schematic_data` (true): one sheet, in the standard's mils.+fn sheet_read(state: &mut State, args: &Value, data_form: bool) -> Value {+    let (path, sub) = match arg_any(args, &["filePath", "sheet", "path"]) {+        Some(p) => (p.to_string(), Value::Null),+        None => {+            let d = document_info(Some("schematic"));+            if d["success"] != json!(true) {+                return d;+            }+            match d["path"].as_str() {+                Some(p) => (p.to_string(), d["subSheet"].clone()),+                None => return fail("no_path", "the open Schematic Editor's sheet has no file yet", "Save it in KiCad, or pass filePath."),+            }+        }+    };+    let flag = |k: &str, d: bool| args.get(k).and_then(Value::as_bool).unwrap_or(d);+    let want_pins = data_form && flag("pins", true);+    let names_only = flag("paramNamesOnly", false);+    let want_params = flag("params", !data_form) || names_only;+    let raw = crate::verbs::call(state, "kicad_read_sheet_file", &json!({"path": path, "pins": want_pins, "params": want_params}));+    if raw["success"] != json!(true) {+        return raw;+    }+    let height = crate::std_shapes::page_height(Some(path.as_str()));+    let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(500) as usize;+    let want = arg_str(args, "designator").map(str::to_ascii_lowercase);+    let params_of = |c: &Value| -> Value {+        match (c["params"].as_object(), names_only) {+            (Some(m), true) => json!(m.keys().collect::<Vec<_>>()),+            (Some(m), false) => json!(m.iter().map(|(k, v)| json!({"name": k, "value": v})).collect::<Vec<_>>()),+            _ => json!([]),+        }+    };+    let rows: Vec<Value> = raw["components"].as_array().into_iter().flatten()+        .filter(|c| want.as_ref().map_or(true, |w| c["designator"].as_str().is_some_and(|d| d.eq_ignore_ascii_case(w))))+        .map(|c| {+            let at = crate::std_shapes::sheet_to_mil(&json!([c["xMm"], c["yMm"]]), height);+            let mut row = json!({"designator": c["designator"], "partId": c["partId"], "libReference": c["libReference"], "sourceLibrary": c["sourceLibrary"], "comment": c["comment"],+                "description": c["description"], "footprint": c["footprint"], "kind": if c["inBom"] == json!(false) { 5 } else { 0 }, "dnp": c["dnp"],+                "xMil": at[0], "yMil": at[1], "orientation": c["orientation"], "mirror": c["mirror"], "uniqueId": c["uniqueId"]});+            if want_params {+                row["params"] = params_of(c);+            }+            if want_pins {+                row["pins"] = json!(c["pins"].as_array().into_iter().flatten().map(|p| {+                    let pat = crate::std_shapes::sheet_to_mil(&p["at"], height);+                    let ty = p["type"].as_str().unwrap_or("");+                    let orientation = match p["direction"].as_str() { Some("right") => 0, Some("up") => 90, Some("left") => 180, Some("down") => 270, _ => 0 };+                    json!({"number": p["number"], "name": p["name"], "electrical": electrical_ordinal(ty), "electricalType": ty, "xMil": pat[0], "yMil": pat[1], "orientation": orientation,+                        "lengthMil": p["length"].as_f64().map(|l| (l / 0.0254 * 100.0).round() / 100.0), "hidden": p["hidden"]})+                }).collect::<Vec<_>>());+            }+            row+        }).collect();+    if let (Some(d), true) = (arg_str(args, "designator"), rows.is_empty()) {+        return fail("part_not_found", format!("{d} is not on this sheet"), "Other sheets: kicad_sch_hierarchy names them.");+    }+    let count = rows.len();+    let rows: Vec<Value> = rows.into_iter().take(limit).collect();+    let mut out = if data_form {+        json!({"success": true, "data": {"document": path, "count": count, "components": rows}})+    } else {+        let names: Vec<Value> = rows.iter().map(|r| r["designator"].clone()).collect();+        json!({"success": true, "document": path, "count": count, "components": rows, "designators": names})+    };+    out["truncated"] = json!(count > limit);+    if !sub.is_null() {+        out["subSheet"] = sub;+        out["_note"] = json!("The Schematic Editor is showing this sub-sheet, but the path is its project's root sheet: pass filePath for the sub-sheet's own parts (kicad_sch_hierarchy names the files).");+    }+    out+}++fn mil2(mm: f64) -> f64 {+    (mm / 0.0254 * 100.0).round() / 100.0+}++/// One library footprint's numbers for pcb_report.+fn footprint_numbers(text: &str, name: &str) -> Result<Value, String> {+    let root = kicad_core::sxedit::parse(text)?;+    let mut census: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();+    let mut boxx: Option<(f64, f64, f64, f64)> = None;+    let mut pads = 0;+    let mut bodies = 0;+    for k in root.kids.iter().filter(|k| k.is_list()) {+        match k.tag() {+            Some("pad") => {+                pads += 1;+                let at = k.kid("at");+                let size = k.kid("size");+                let (x, y) = (at.and_then(|a| a.f64_at(1)).unwrap_or(0.0), at.and_then(|a| a.f64_at(2)).unwrap_or(0.0));+                let (w, h) = (size.and_then(|s| s.f64_at(1)).unwrap_or(0.0) / 2.0, size.and_then(|s| s.f64_at(2)).unwrap_or(0.0) / 2.0);+                let b = boxx.unwrap_or((x - w, y - h, x + w, y + h));+                boxx = Some((b.0.min(x - w), b.1.min(y - h), b.2.max(x + w), b.3.max(y + h)));+                *census.entry("pad".into()).or_default() += 1;+            }+            Some("model") => {+                bodies += 1;+                *census.entry("model".into()).or_default() += 1;+            }+            Some(t) if t.starts_with("fp_") || t == "zone" => *census.entry(t.trim_start_matches("fp_").to_string()).or_default() += 1,+            _ => {}+        }+    }+    Ok(json!({"name": name, "pads": pads, "padsBboxMil": boxx.map(|b| json!([mil2(b.0), mil2(b.1), mil2(b.2), mil2(b.3)])), "census": census, "bodies": bodies}))+}++fn pcb_report(state: &mut State, args: &Value) -> Value {+    let given = arg_any(args, &["path", "filePath", "libraryPath"]).map(|p| PathBuf::from(p.trim_end_matches(['/', '\\'])));+    let lib_form = given.as_ref().is_some_and(|p| matches!(ext(p).as_str(), "pretty" | "kicad_mod"));+    if lib_form {+        let p = given.unwrap();+        let files: Vec<PathBuf> = if ext(&p) == "kicad_mod" { vec![p.clone()] } else {+            std::fs::read_dir(&p).map(|rd| rd.flatten().map(|e| e.path()).filter(|f| ext(f) == "kicad_mod").collect()).unwrap_or_default()+        };+        if files.is_empty() {+            return fail("file_not_found", format!("no footprints at {}", norm(&p)), "Pass a .pretty folder or a .kicad_mod file.");+        }+        let needle = arg_str(args, "name").map(str::to_ascii_lowercase);+        let mut rows = Vec::new();+        for f in files {+            let name = f.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();+            if needle.as_ref().is_some_and(|n| !name.to_ascii_lowercase().contains(n)) {+                continue;+            }+            match std::fs::read_to_string(&f).map_err(|e| e.to_string()).and_then(|t| footprint_numbers(&t, &name)) {+                Ok(r) => rows.push(r),+                Err(e) => rows.push(json!({"name": name, "error": e})),+            }+        }+        rows.sort_by_key(|r| r["name"].as_str().unwrap_or("").to_string());+        return json!({"success": true, "data": {"kind": "library", "path": norm(&p), "count": rows.len(), "components": rows}});+    }+    let mut a = args.clone();+    if let Some(p) = &given {+        a["filePath"] = json!(norm(p));+    }+    let r = match crate::verbs_standard::read(state, &a) {+        Ok(r) => r,+        Err(e) => return e,+    };+    let m = match kicad_core::board_model::build(&r.text, Some(&r.file), &kicad_core::board_model::Opts { y_up: true }) {+        Ok(m) => m,+        Err(e) => return fail("board_unparsable", e, "A damaged file opens and saves in KiCad to repair it."),+    };+    let pads = m["pads"].as_array().cloned().unwrap_or_default();+    let f = |v: &Value| v.as_f64().unwrap_or(0.0);+    let comps: Vec<Value> = m["components"].as_array().into_iter().flatten().map(|c| {+        let mine: Vec<&Value> = pads.iter().filter(|p| p["ref"] == c["ref"]).collect();+        let mut nets: Vec<&str> = mine.iter().filter_map(|p| p["net"].as_str()).filter(|n| !n.is_empty()).collect();+        nets.sort();+        nets.dedup();+        let bbox = mine.iter().fold(None::<(f64, f64, f64, f64)>, |b, p| {+            let (x, y, w, h) = (f(&p["x"]), f(&p["y"]), f(&p["sizeX"]) / 2.0, f(&p["sizeY"]) / 2.0);+            let b = b.unwrap_or((x - w, y - h, x + w, y + h));+            Some((b.0.min(x - w), b.1.min(y - h), b.2.max(x + w), b.3.max(y + h)))+        });+        json!({"name": c["ref"], "designator": c["ref"], "pattern": c["pattern"], "side": c["side"], "pads": mine.len(), "padsBboxMil": bbox.map(|b| json!([mil2(b.0), mil2(b.1), mil2(b.2), mil2(b.3)])), "nets": nets, "bodies": if c["has3d"] == json!(true) { 1 } else { 0 }})+    }).collect();+    let mut out = json!({"success": true, "data": {"kind": "board", "file": r.file, "outline": m["outline"], "components": comps, "census": m["counts"], "tracks": m["counts"]["tracks"], "frame": m["frame"]}});+    crate::verbs_standard::stamp(&mut out, &r);+    out+}++fn kicad_running() -> bool {+    kicad_platform::native().processes(&["kicad.exe", "pcbnew.exe", "eeschema.exe"]).map(|p| !p.is_empty()).unwrap_or(false)+}++const FREE: &str = "free: KiCad is GPL-3.0-or-later and needs no licence, seat or sign-in";++fn ensure_licensed(state: &mut State) -> Value {+    if state.kicad_info().primary().is_none() {+        return fail("not_installed", "KiCad is not installed", "kicad_upgrade installs it with the official installer.");+    }+    let mut steps = vec![json!("installed")];+    if kicad_running() {+        steps.push(json!("already running"));+    } else {+        let r = crate::verbs::call(state, "kicad_launch", &json!({}));+        if r["success"] != json!(true) {+            return r;+        }+        steps.push(json!("launched"));+    }+    json!({"success": true, "alreadyLicensed": true, "licenceState": FREE, "seatStuck": false, "needsHuman": Value::Null, "page": Value::Null, "steps": steps, "summary": "KiCad is ready; it needs no licence."})+}++fn signin_status(state: &mut State) -> Value {+    let installed = state.kicad_info().primary().is_some();+    json!({"success": true, "licenceState": if installed { FREE } else { "not installed" }, "licensed": installed, "running": kicad_running(), "workspace": Value::Null, "workspaceConnected": false, "workspaceMenu": "none: KiCad has no account or cloud workspace"})+}++fn setup_trial(state: &mut State) -> Value {+    let (stage, next, summary) = if state.kicad_info().primary().is_none() {+        ("install", "kicad_upgrade", "KiCad is not installed; kicad_upgrade installs it with the official installer.")+    } else if !kicad_running() {+        ("launch", "kicad_launch", "KiCad is installed and not running; kicad_launch starts it.")+    } else {+        ("done", "none", "KiCad is installed and running. It has no trial or sign-in.")+    };+    json!({"success": true, "stage": stage, "nextAction": next, "summary": summary, "licenceState": FREE, "needsHuman": Value::Null})+}++/// A symbol's fields as kicad_sym_info reads them; None when it cannot be read.+fn symbol_fields(state: &mut State, args: &Value) -> Result<Value, Value> {+    let lib = arg_str(args, "libraryPath").unwrap_or("");+    let name = arg_any(args, &["symbolName", "name"]).unwrap_or("");+    if lib.is_empty() || name.is_empty() {+        return Err(fail("missing_arg", "libraryPath and symbolName are required", "The .kicad_sym and the symbol in it."));+    }+    let info = crate::verbs::call(state, "kicad_sym_info", &json!({"libraryPath": lib, "name": name}));+    if info["success"] != json!(true) {+        return Err(info);+    }+    Ok(info["fields"].clone())+}++fn set_models(state: &mut State, args: &Value) -> Value {+    let Some(fp) = arg_str(args, "footprint") else {+        return fail("missing_arg", "footprint is required", "Lib:Name, as KiCad's Footprint field holds it.");+    };+    let before = match symbol_fields(state, args) {+        Ok(f) => f["Footprint"].as_str().unwrap_or("").to_string(),+        Err(e) => return e,+    };+    let mut a = json!({"libraryPath": args["libraryPath"], "symbolName": arg_any(args, &["symbolName", "name"]), "footprint": fp});+    if let Some(b) = args.get("backup") {+        a["backup"] = b.clone();+    }+    let r = crate::verbs::call(state, "kicad_set_params", &a);+    if r["success"] != json!(true) {+        return r;+    }+    json!({"success": true, "footprint": fp, "removed": if !before.is_empty() && before != fp { 1 } else { 0 }, "previous": before, "path": r["path"], "backup": r["backup"]})+}++fn add_models(state: &mut State, args: &Value) -> Value {+    let fields = match symbol_fields(state, args) {+        Ok(f) => f,+        Err(e) => return e,+    };+    let models = args.get("models").and_then(Value::as_array).cloned().unwrap_or_default();+    let mut names: Vec<String> = fields["ki_fp_filters"].as_str().unwrap_or("").split_whitespace().map(str::to_string).collect();+    let mut link = None;+    for m in &models {+        let name = m["name"].as_str().or(m.as_str()).unwrap_or("").trim();+        if name.is_empty() {+            return fail("bad_args", "a model has no name", "models is [{name, pcblib?}].");+        }+        if link.is_none() {+            let lib = m["pcblib"].as_str().map(|p| Path::new(p.trim_end_matches(['/', '\\'])).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default()).unwrap_or_default();+            link = Some(if lib.is_empty() || name.contains(':') { name.to_string() } else { format!("{lib}:{name}") });+        }+        let filter = name.rsplit(':').next().unwrap_or(name).to_string();+        if !names.contains(&filter) {+            names.push(filter);+        }+    }+    let mut params: serde_json::Map<String, Value> = args.get("params").and_then(Value::as_object).cloned().unwrap_or_default();+    let param_count = params.len();+    if !names.is_empty() {+        params.insert("ki_fp_filters".into(), json!(names.join(" ")));+    }+    if let Some(d) = arg_str(args, "description") {+        params.insert("Description".into(), json!(d));+    }+    let mut a = json!({"libraryPath": args["libraryPath"], "symbolName": arg_any(args, &["symbolName", "name"]), "params": params});+    if let Some(c) = arg_str(args, "comment") {+        a["comment"] = json!(c);+    }+    if let (Some(l), true) = (&link, fields["Footprint"].as_str().unwrap_or("").is_empty()) {+        a["footprint"] = json!(l);+    }+    let r = crate::verbs::call(state, "kicad_set_params", &a);+    if r["success"] != json!(true) {+        return r;+    }+    json!({"success": true, "data": {"libref": arg_any(args, &["symbolName", "name"]), "models": models.len(), "params": param_count}, "filters": names, "fields": r["fields"], "path": r["path"], "backup": r["backup"]})+}++fn instances(state: &mut State) -> Value {+    let version = state.kicad_info().primary().map(|i| if i.cli_version.is_empty() { i.version.clone() } else { i.cli_version.clone() }).unwrap_or_default();+    let ps = kicad_platform::native().processes(&["kicad.exe", "pcbnew.exe", "eeschema.exe"]).unwrap_or_default();+    let rows: Vec<Value> = ps.iter().map(|p| json!({"pid": p.pid, "key": p.exe, "edition": format!("KiCad {version}"), "licensed": true, "agentAlive": Value::Null, "path": p.path})).collect();+    json!({"success": true, "count": rows.len(), "instances": rows})+}+ 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.");@@ -614,6 +1090,75 @@ mod tests {         assert_eq!(doc_arg("C:/K/bin/kicad.exe"), None);     } +    #[test]+    fn a_sheet_reads_in_the_standards_mils_and_ordinals() {+        let fx = Path::new(env!("CARGO_MANIFEST_DIR")).join("../kicad-core/tests/fixtures/sch-edit/edit10.kicad_sch");+        let (mut st, path) = (State::new(), norm(&fx));+        let r = sheet_read(&mut st, &json!({"filePath": path}), false);+        assert_eq!(r["success"], true, "{r}");+        assert!(r["designators"].as_array().unwrap().iter().any(|d| d == "R1"), "{r}");+        let one = r["components"].as_array().unwrap().iter().find(|c| c["designator"] == "R1").unwrap().clone();+        assert_eq!(one["libReference"], "R");+        assert!(one["params"].as_array().unwrap().iter().any(|p| p["name"] == "Value"), "{one}");+        assert!(one["xMil"].as_f64().unwrap() > 0.0 && one["yMil"].as_f64().unwrap() > 0.0, "y up from the lower left: {one}");+        assert!(one.get("pins").is_none(), "read_schematic has no pins: {one}");+        let d = sheet_read(&mut st, &json!({"filePath": path, "designator": "r1"}), true);+        let c = &d["data"]["components"][0];+        assert_eq!((d["data"]["count"].as_u64(), c["designator"].as_str(), c["pins"].as_array().map(|p| p.len())), (Some(1), Some("R1"), Some(2)), "{d}");+        assert_eq!((c["pins"][0]["electrical"].as_u64(), c["pins"][0]["electricalType"].as_str()), (Some(4), Some("passive")), "{c}");+        assert!(c["pins"][0]["lengthMil"].as_f64().unwrap() > 0.0, "{c}");+        assert!(c.get("params").is_none(), "params are off by default in schematic_data: {c}");+        assert_eq!(sheet_read(&mut st, &json!({"filePath": path, "designator": "NOPE"}), true)["errorCode"], "part_not_found");+        assert_eq!((electrical_ordinal("power_out"), electrical_ordinal("no_connect"), electrical_ordinal("tri_state")), (7, 4, 5));+    }++    #[test]+    fn read_board_gives_the_sections_asked_for() {+        let fx = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../demo/placement/esc-g431-unrouted.kicad_pcb");+        let mut st = State::new();+        let all = read_board(&mut st, &json!({"filePath": norm(&fx), "source": "file"}));+        assert_eq!(all["success"], true, "{}", all["error"]);+        for k in ["components", "nets", "layers", "copper", "rules", "occupancy", "outline"] {+            assert!(all["data"].get(k).is_some(), "section {k}");+        }+        let some = read_board(&mut st, &json!({"filePath": norm(&fx), "source": "file", "sections": "components", "limit": 2}));+        assert_eq!((some["data"]["components"].as_array().map(|a| a.len()), some["data"].get("copper").is_none(), some["truncated"].clone()), (Some(2), true, json!(["components"])));+        assert_eq!(read_board(&mut st, &json!({"filePath": norm(&fx), "source": "file", "sections": "nope"}))["errorCode"], "bad_args");+        let rep = pcb_report(&mut st, &json!({"path": norm(&fx), "source": "file"}));+        assert_eq!(rep["data"]["kind"], "board", "{}", rep["error"]);+        assert!(rep["data"]["components"].as_array().unwrap().iter().any(|c| c["pads"].as_u64().unwrap_or(0) > 0 && c["padsBboxMil"].is_array()));+    }++    #[test]+    fn set_and_add_models_write_the_symbols_footprint_and_filters() {+        let d = std::env::temp_dir().join(format!("kb-models-{}", std::process::id()));+        let _ = std::fs::remove_dir_all(&d);+        std::fs::create_dir_all(&d).unwrap();+        let lib = norm(&d.join("M.kicad_sym"));+        let mut st = State::new();+        let c = crate::verbs::call(&mut st, "kicad_sym_create", &json!({"libraryPath": lib, "name": "P", "pins": [{"number": 1, "name": "A"}, {"number": 2, "name": "B"}]}));+        assert_eq!(c["success"], true, "{c}");+        let a = add_models(&mut st, &json!({"libraryPath": lib, "symbolName": "P", "models": [{"name": "R_0603", "pcblib": "C:/libs/Res.pretty"}, {"name": "R_0805"}], "params": {"MPN": "X1"}, "comment": "10k", "description": "a part"}));+        assert_eq!((a["success"].as_bool(), a["data"]["models"].as_u64(), a["data"]["params"].as_u64()), (Some(true), Some(2), Some(1)), "{a}");+        let i = crate::verbs::call(&mut st, "kicad_sym_info", &json!({"libraryPath": lib, "name": "P"}));+        assert_eq!((i["fields"]["Footprint"].as_str(), i["fields"]["MPN"].as_str(), i["fields"]["Value"].as_str(), i["fields"]["Description"].as_str()), (Some("Res:R_0603"), Some("X1"), Some("10k"), Some("a part")), "{i}");+        assert_eq!(i["fields"]["ki_fp_filters"], "R_0603 R_0805", "{i}");+        let again = add_models(&mut st, &json!({"libraryPath": lib, "symbolName": "P", "models": [{"name": "R_0805"}]}));+        assert_eq!(again["filters"], json!(["R_0603", "R_0805"]), "no duplicates: {again}");+        let s = set_models(&mut st, &json!({"libraryPath": lib, "symbolName": "P", "footprint": "Other:SOT"}));+        assert_eq!((s["footprint"].as_str(), s["removed"].as_u64()), (Some("Other:SOT"), Some(1)), "{s}");+        assert_eq!(set_models(&mut st, &json!({"libraryPath": lib, "symbolName": "P", "footprint": "Other:SOT"}))["removed"], 0);+        assert_eq!(set_models(&mut st, &json!({"libraryPath": lib, "symbolName": "NOPE", "footprint": "x"}))["errorCode"], "symbol_not_found");+        let _ = std::fs::remove_dir_all(&d);+    }++    #[test]+    fn a_footprint_library_reports_its_numbers() {+        let v = footprint_numbers("(footprint \"X\" (fp_line (start 0 0) (end 1 0)) (fp_line (start 0 0) (end 0 1)) (pad \"1\" smd rect (at -1 0) (size 1 2)) (pad \"2\" smd rect (at 1 0) (size 1 2)) (model \"x.step\"))", "X").unwrap();+        assert_eq!((v["pads"].as_u64(), v["bodies"].as_u64(), v["census"]["line"].as_u64()), (Some(2), Some(1), Some(2)), "{v}");+        assert_eq!(v["padsBboxMil"][2].as_f64(), Some(59.06));+    }+     #[test]     fn every_preset_names_layers_and_bottom_ones_are_mirrored() {         assert!(PRESETS.iter().all(|p| !p.1.is_empty() && !p.4.is_empty()));
rust/crates/kicad-bridge/src/verbs_windows.rs+260−12
@@ -258,12 +258,52 @@ pub static VERBS: &[Verb] = &[         name: "kicad_teardrops",         summary: "Add (or remove) teardrops with KiCad's own Edit > Edit Teardrops on the board open in the PCB editor: on vias, PTH pads and SMD pads, optionally track to track, for the whole board or the selection; teardrops counted before and after.",         mechanism: Mechanism::Window, risk: "write", timeout_sec: 180,-        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"vias\"?: true, \"pthPads\"?: true, \"smdPads\"?: true, \"tJunctions\"?: false (track to track), \"selectedOnly\"?: false, \"remove\"?: false (removes ALL teardrops)}",+        input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\" (default the open board), \"vias\"?: true (and through-hole pads), \"pthPads\"?: as vias, \"smdPads\"?: true, \"tracks\" | \"tJunctions\"?: true (KiCad's one Track to track box), \"selectedOnly\"?: false, \"remove\"?: false (removes ALL teardrops), \"save\"?: true}",         example: "kicad_teardrops {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"smdPads\":false}",-        hint: "Uses the board's default teardrop shapes (Board Setup > Teardrops). before and after count the teardrop areas; KiCad adds them as one Undo step. Save with kicad_save_board, then check with kicad_run_drc.",+        hint: "Uses the board's default teardrop shapes (Board Setup > Teardrops). teardropsBefore and teardropsAfter count the teardrop areas; KiCad adds them as one Undo step and the board is saved (save:false leaves that to you). force:true is refused: KiCad has no such mode. Check with kicad_run_drc.",         related: &["kicad_save_board", "kicad_run_drc", "kicad_refill_zones", "kicad_open_board"],         pitfalls: &["The board must be open in the PCB editor (kicad_open_board); KiCad's API has no teardrop call, so the bridge drives KiCad's own dialog, which shows for a moment.", "Counts need the IPC API on (kicad_ipc_api); without it before and after are null and only the dialog's result is known.", "remove:true removes every teardrop on the board, whatever the scope says."],     },+    Verb {+        name: "kicad_check_dialogs",+        summary: "List every KiCad dialog open right now, classified, each with its buttons, what it means and what is safe to do, and a screenshot (the standard's check_dialogs). Answers nothing, closes nothing.",+        mechanism: Mechanism::Window, risk: "read", timeout_sec: 60,+        input: "{\"screenshots\"?: true}",+        example: "kicad_check_dialogs {}",+        hint: "category is save_prompt, progress, tool, benign or other, and safeAction a suggestion. A save_prompt is never answered for you: it may name someone's work. kicad_dismiss_dialogs clears the benign ones, or all.",+        related: &["kicad_dismiss_dialogs", "kicad_window_info", "kicad_dialog_log"],+        pitfalls: &["shot is the path the bridge saved the picture to, not base64."],+    },+    Verb {+        name: "kicad_save",+        summary: "Save the document at filePath, or the topmost open board or sheet (the standard's save): a board through KiCad's API, a schematic through the Schematic Editor's own File > Save.",+        mechanism: Mechanism::Window, risk: "write", timeout_sec: 120,+        input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\" | \"C:/.../x.kicad_sch\"}",+        example: "kicad_save {\"filePath\":\"C:/p/b.kicad_sch\"}",+        hint: "saved says the file reached the disk, read from the title's unsaved mark and the file's time. A document that is not open has nothing to save: saved:false with a note, and success. data.process names how KiCad was asked.",+        related: &["kicad_save_board", "kicad_close_document", "kicad_document_info"],+        pitfalls: &["Without filePath it saves whatever is on top, which may be someone else's document.", "A never-saved document answers with KiCad's Save As dialog: the reply lists it in dialogs; name it there or pass kicad_send_key."],+    },+    Verb {+        name: "kicad_close_document",+        summary: "Close ONE open board or schematic by its path, never saving (the standard's close_document): the editor window goes, KiCad and the project stay.",+        mechanism: Mechanism::Window, risk: "write", timeout_sec: 60,+        input: "{\"path\": \"C:/.../x.kicad_pcb\" | \"C:/.../x.kicad_sch\"}",+        example: "kicad_close_document {\"path\":\"C:/p/b.kicad_pcb\"}",+        hint: "data {closed:true, path, discarded} or {closed:false, note:\"not open: <path>\"}, which is still success. A schematic's editor holds the whole hierarchy, so any of its sheets closes it. Keep unsaved work with kicad_save first.",+        related: &["kicad_close_all_documents", "kicad_save", "kicad_close", "kicad_open_document"],+        pitfalls: &["Unsaved edits are DISCARDED, as the standard says: KiCad's own Save prompt is answered Discard."],+    },+    Verb {+        name: "kicad_close_all_documents",+        summary: "Close every open board, schematic and library editor without a save dialog (the standard's close_all_documents); unsaved work stays open and is listed unless save or discardUnsaved says what to do.",+        mechanism: Mechanism::Window, risk: "write", timeout_sec: 180,+        input: "{\"save\"?: false, \"discardUnsaved\"?: false, \"includeScratch\"?: false}",+        example: "kicad_close_all_documents {}",+        hint: "closed, saved and keptOpen [{name, why}]. KiCad's project manager stays up; kicad_close ends KiCad itself. A scratch document is one that has never been saved.",+        related: &["kicad_close_document", "kicad_close", "kicad_save"],+        pitfalls: &["discardUnsaved loses changes that exist nowhere else: ask the person first."],+    },     Verb {         name: "kicad_click",         summary: "Click at coordinates inside a KiCad window.",@@ -360,6 +400,10 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value>         "kicad_screenshot_all" => screenshot_all(state),         "kicad_send_key" => send_key(args),         "kicad_teardrops" => teardrops(state, args),+        "kicad_check_dialogs" => check_dialogs(args),+        "kicad_save" => save_document(state, args),+        "kicad_close_document" => close_document(args),+        "kicad_close_all_documents" => close_all_documents(state, args),         "kicad_click" => click(args),         "kicad_fix_keyboard" => fix_keyboard(),         "kicad_place_footprint" => place_footprint(state, args),@@ -3647,6 +3691,164 @@ fn same_board_path(a: &str, b: &str) -> bool {     a.replace('\\', "/").eq_ignore_ascii_case(&b.replace('\\', "/")) } +// ---------------------------------------------------------------------------+// The standard's document verbs: check_dialogs, save, close_document, close_all_documents+// ---------------------------------------------------------------------------++/// What a dialog is, in the standard's words: (category, safeAction, meaning).+fn classify_dialog(title: &str, body: &str) -> (&'static str, &'static str, &'static str) {+    if wm::is_save_prompt(title, body) {+        return ("save_prompt", "ask the person; never answer it for them", "KiCad is asking whether to keep unsaved changes, which may be someone's work.");+    }+    match wm::sweep_decision(title, body, false) {+        DialogAction::LeaveProgress => ("progress", "wait", "KiCad is working; it closes itself."),+        DialogAction::Dismiss { benign: true } => ("benign", "kicad_dismiss_dialogs", "A notice that blocks nothing of value; the sweep clears it."),+        _ if wm::is_tool_dialog(title) => ("tool", "leave it, or close it when finished", "A tool window someone opened on purpose."),+        _ => ("other", "read it, then answer it", "KiCad is waiting on this; read the body or the shot before answering."),+    }+}++fn check_dialogs(args: &Value) -> Value {+    if let Err(e) = find_windows(true, true) {+        return e;+    }+    let shots = args.get("screenshots").and_then(Value::as_bool).unwrap_or(true);+    let rows: Vec<Value> = scan_dialogs().iter().map(|d| {+        let info = native().window_info(d.hwnd).ok().flatten();+        let buttons: Vec<String> = native().child_windows(d.hwnd).map(|c| c.into_iter().filter(|w| w.class_name == "Button").map(|w| w.title.replace('&', "")).collect()).unwrap_or_default();+        let (category, safe, meaning) = classify_dialog(&d.title, &d.body);+        json!({"hwnd": d.hwnd, "pid": info.as_ref().map(|i| i.pid), "title": d.title, "className": d.class_name, "body": d.body, "buttons": buttons,+            "category": category, "safeAction": safe, "meaning": meaning, "ownedByMainInstance": d.owner != 0 && !d.via_global_sweep,+            "rect": info.as_ref().map(|i| json!(i.rect)), "shot": if shots { json!(screenshot_dialog(d.hwnd, &d.title)) } else { Value::Null }})+    }).collect();+    json!({"success": true, "dialogsDetected": rows.len(), "dialogs": rows})+}++/// The editor holding a board or schematic path, matched on the whole file name, not a prefix of it.+fn editor_of(path: &str) -> Option<Win> {+    let p = Path::new(path);+    let kind = match p.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() {+        Some("kicad_pcb") => WindowKind::PcbEditor,+        Some("kicad_sch") => WindowKind::SchematicEditor,+        _ => return None,+    };+    let stem = p.file_stem()?.to_string_lossy().to_string();+    windows_of_kind(kind).into_iter().find(|w| crate::verbs_std_more::parse_title(&w.title).is_some_and(|t| t.0.eq_ignore_ascii_case(&stem)))+}++/// Ask an editor to save itself; true once the unsaved mark has gone from its title.+fn save_editor(state: &mut State, w: &Win, path: Option<&str>) -> Result<&'static str, String> {+    if w.kind() == WindowKind::PcbEditor {+        let args = path.map_or(json!({}), |p| json!({"filePath": p}));+        if ipc_save(state, &args).is_ok() {+            return Ok("IPC SaveDocument");+        }+    }+    match menu_invoke_by_text(w.hwnd, "Save") {+        Ok(Some(_)) => {}+        Ok(None) => return Err("the editor has no File > Save".into()),+        Err(e) => return Err(e),+    }+    let started = Instant::now();+    while started.elapsed() < Duration::from_secs(60) {+        sleep_ms(250);+        invalidate();+        let title = native().window_info(w.hwnd).ok().flatten().map(|i| i.title).unwrap_or_default();+        if !title.starts_with('*') {+            return Ok("File > Save");+        }+        if !scan_dialogs().is_empty() {+            break;+        }+    }+    Err("KiCad did not finish saving".into())+}++fn save_document(state: &mut State, args: &Value) -> Value {+    let started = Instant::now();+    let given = arg_any(args, &["filePath", "path"]);+    let win = match given {+        Some(p) => editor_of(p),+        None => windows_of_kind(WindowKind::PcbEditor).into_iter().chain(windows_of_kind(WindowKind::SchematicEditor)).min_by_key(|w| w.z),+    };+    let Some(win) = win else {+        return match given {+            Some(p) => json!({"success": true, "saved": false, "path": p, "note": format!("not open: {p}. The file already holds what it holds; nothing to save."), "data": {"process": Value::Null, "params": {}}}),+            None => fail("no_document", "no board or schematic is open", "kicad_open_document opens one; kicad_state lists KiCad's windows."),+        };+    };+    let was_dirty = win.title.starts_with('*');+    let mut out = match save_editor(state, &win, given) {+        Ok(how) => json!({"success": true, "saved": true, "wasUnsaved": was_dirty, "title": win.title, "data": {"process": how, "params": {}}}),+        Err(e) => {+            let mut v = fail("save_failed", e, "dialogs lists anything KiCad is asking. A never-saved document asks for a name.");+            v["saved"] = json!(false);+            v+        }+    };+    out["dialogs"] = json!(scan_dialogs().iter().map(|d| json!({"title": d.title, "body": d.body})).collect::<Vec<_>>());+    out["roundtripMs"] = json!(started.elapsed().as_millis() as u64);+    out+}++fn close_document(args: &Value) -> Value {+    let started = Instant::now();+    let Some(path) = arg_any(args, &["path", "filePath"]) else {+        return fail("missing_arg", "path is required", "The absolute path of the open .kicad_pcb or .kicad_sch.");+    };+    let Some(win) = editor_of(path) else {+        return json!({"success": true, "data": {"closed": false, "path": path, "note": format!("not open: {path}")}, "roundtripMs": started.elapsed().as_millis() as u64});+    };+    let mut st = CloseState { discard: true, ..Default::default() };+    let gone = close_one(&mut st, &win);+    let discarded = gone.is_none();+    if gone == Some(false) {+        let mut v = fail("close_failed", format!("{} did not close", win.title), "dialogsSeen lists what is up; kicad_check_dialogs reads them.");+        v["dialogsSeen"] = json!(scan_dialogs().iter().map(|d| json!({"title": d.title, "body": d.body})).collect::<Vec<_>>());+        v["unsavedChanges"] = json!(st.unsaved);+        return v;+    }+    json!({"success": true, "data": {"closed": true, "path": path, "discarded": discarded}, "roundtripMs": started.elapsed().as_millis() as u64})+}++fn close_all_documents(state: &mut State, args: &Value) -> Value {+    let flag = |k: &str| args.get(k).and_then(Value::as_bool).unwrap_or(false);+    let (save, discard, scratch) = (flag("save"), flag("discardUnsaved"), flag("includeScratch"));+    invalidate();+    let wins = match find_windows(false, true) {+        Ok((w, _)) => w,+        Err(e) => return e,+    };+    let (mut closed, mut saved, mut kept) = (Vec::new(), Vec::new(), Vec::new());+    for w in wins.iter().filter(|w| w.owner == 0 && matches!(w.kind(), WindowKind::PcbEditor | WindowKind::SchematicEditor | WindowKind::SymbolEditor | WindowKind::FootprintEditor)) {+        let name = crate::verbs_std_more::parse_title(&w.title).map(|t| t.0).unwrap_or_else(|| w.title.clone());+        let dirty = w.title.starts_with('*');+        let never_saved = w.title.contains("[Unsaved]") || name.is_empty() || name.eq_ignore_ascii_case("untitled") || name.eq_ignore_ascii_case("noname");+        if never_saved && !scratch {+            kept.push(json!({"name": name, "why": "scratch"}));+            continue;+        }+        if dirty && !never_saved && save {+            match save_editor(state, w, None) {+                Ok(_) => saved.push(name.clone()),+                Err(e) => {+                    kept.push(json!({"name": name, "why": format!("unsaved changes: the save failed ({e})")}));+                    continue;+                }+            }+        } else if dirty && !discard {+            kept.push(json!({"name": name, "why": "unsaved changes"}));+            continue;+        }+        let mut st = CloseState { discard, ..Default::default() };+        match close_one(&mut st, w) {+            Some(false) => kept.push(json!({"name": name, "why": if st.unsaved.is_empty() { "did not close" } else { "unsaved changes" }})),+            _ => closed.push(name),+        }+    }+    json!({"success": true, "closed": closed, "saved": saved, "keptOpen": kept})+}+ // --------------------------------------------------------------------------- // kicad_teardrops: KiCad's own Edit > Edit Teardrops // ---------------------------------------------------------------------------@@ -3672,16 +3874,53 @@ fn teardrop_count(_state: &mut State, _args: &Value) -> Option<(usize, String)>     None } -fn teardrops(state: &mut State, args: &Value) -> Value {-    let Some(path) = arg_str(args, "filePath") else {-        return fail("missing_arg", "filePath is required", "The .kicad_pcb open in the PCB editor.");+#[cfg(feature = "ipc")]+fn ipc_save(state: &mut State, args: &Value) -> Result<(), String> {+    use kicad_core::{detect, ipc};+    let info = state.kicad_info();+    let ctx = ipc::Ctx {+        kicad_cli: info.primary().map(|p| PathBuf::from(&p.kicad_cli)).filter(|p| p.is_file()),+        config_dir: info.primary().and_then(|p| detect::config_dir(&p.version)),     };-    let stem = Path::new(path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();-    let Some(editor) = windows_of_kind(WindowKind::PcbEditor).into_iter().find(|w| w.title.trim_start_matches('*').starts_with(&stem)) else {-        return fail("board_not_open", format!("{stem} is not open in a PCB editor"), "Open it with kicad_open_board: teardrops run in KiCad's PCB editor.");+    ipc::save_board(&ctx, args).map(|_| ()).map_err(|e| e.to_string())+}++#[cfg(not(feature = "ipc"))]+fn ipc_save(_state: &mut State, _args: &Value) -> Result<(), String> {+    Err("saving needs the IPC API".into())+}++/// KiCad's one "Track to track" box answers both of the standard's tracks and tJunctions: an explicit+/// answer on either decides it (either true turns it on), and neither given leaves it on.+fn track_to_track(args: &Value) -> bool {+    let (t, j) = (args.get("tracks").and_then(Value::as_bool), args.get("tJunctions").and_then(Value::as_bool));+    match (t, j) {+        (None, None) => true,+        _ => t == Some(true) || j == Some(true),+    }+}++fn teardrops(state: &mut State, args: &Value) -> Value {+    if args.get("force") == Some(&json!(true)) {+        return fail("not_supported", "KiCad's Set Teardrops has no way to force a teardrop that breaks a rule", "Leave force off, then check with kicad_run_drc: KiCad reports a teardrop that breaks clearance there.");+    }+    let editors = windows_of_kind(WindowKind::PcbEditor);+    let editor = match arg_str(args, "filePath") {+        Some(path) => {+            let stem = Path::new(path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+            match editors.into_iter().find(|w| w.title.trim_start_matches('*').starts_with(&stem)) {+                Some(w) => w,+                None => return fail("board_not_open", format!("{stem} is not open in a PCB editor"), "Open it with kicad_open_board: teardrops run in KiCad's PCB editor."),+            }+        }+        None => match editors.into_iter().next() {+            Some(w) => w,+            None => return fail("board_not_open", "no PCB editor is open", "Pass filePath after kicad_open_board: teardrops run in KiCad's PCB editor."),+        },     };     let flag = |k: &str, d: bool| args.get(k).and_then(Value::as_bool).unwrap_or(d);     let remove = flag("remove", false);+    let save = flag("save", true);     let before = teardrop_count(state, args).map(|(n, _)| n);     match menu_invoke_by_text(editor.hwnd, "Edit Teardrops...") {         Ok(Some(_)) => {}@@ -3701,7 +3940,8 @@ fn teardrops(state: &mut State, args: &Value) -> Value {     };     let mut steps = Vec::new();     let mut set = |label: &str, on: bool| -> Result<(), String> { native().uia_set_checked(dlg, label, on).map(|r| steps.push(r)) };-    let options = [("PTH pads", flag("pthPads", true)), ("SMD pads", flag("smdPads", true)), ("Vias", flag("vias", true)), ("Track to track", flag("tJunctions", false)), ("Selected items only", flag("selectedOnly", false))];+    let vias = flag("vias", true);+    let options = [("PTH pads", flag("pthPads", vias)), ("SMD pads", flag("smdPads", true)), ("Vias", vias), ("Track to track", track_to_track(args)), ("Selected items only", flag("selectedOnly", false))];     let mut failed = None;     for (label, on) in options {         if let Err(e) = set(label, on) {@@ -3732,6 +3972,11 @@ fn teardrops(state: &mut State, args: &Value) -> Value {     if window_alive(dlg) {         return fail("dialog_still_open", "Set Teardrops is still open after 150 s", "KiCad may still be working; check with kicad_window_info before calling again.");     }+    let save_error = if save { ipc_save(state, args).err() } else { None };+    if save && save_error.is_none() {+        steps.push("save".into());+    }+    let dialogs_left: Vec<Value> = scan_dialogs().into_iter().map(|d| json!({"title": d.title, "body": d.body})).collect();     let after_read = teardrop_count(state, args);     let after = after_read.as_ref().map(|(n, _)| *n);     let revision = after_read.map(|(_, r)| r);@@ -3746,10 +3991,13 @@ fn teardrops(state: &mut State, args: &Value) -> Value {             None => format!("Teardrops {} through KiCad's dialog (counts need the IPC API)", if remove { "removed" } else { "applied" }),         },         "before": before, "after": after, "added": delta.filter(|d| *d > 0), "removed": delta.filter(|d| *d < 0).map(|d| -d),+        "teardropsBefore": before, "teardropsAfter": after,         "mutated": delta.map(|d| d != 0),-        "dialogSteps": steps,+        "dialogSteps": steps, "steps": steps,+        "dialogsLeft": dialogs_left,         "revision": revision,-        "saved": false,-        "_hint": "One KiCad Undo step; revision is the board's new revision (KiCad touches the board even when it adds none), so pass it to the next edit. Save with kicad_save_board, then kicad_run_drc: teardrops are copper areas and take part in DRC.",+        "saved": save && save_error.is_none(),+        "saveError": save_error,+        "_hint": "One KiCad Undo step; revision is the board's new revision (KiCad touches the board even when it adds none), so pass it to the next edit. saved says whether it reached the file (save:false leaves that to kicad_save_board). Then kicad_run_drc: teardrops are copper areas and take part in the fill and the checks.",     }) }
skills/kicad-bridge-test/SKILL.md+10
@@ -26,6 +26,16 @@ Outputs `/tmp/kicad-verbtest/report.{json,md}`. Exit 0 = every non-skipped verb passed AND coverage is complete. Runtime for a full pass: ~6-10 min (exports dominate; STEP and molecule are the slow ones). +## The edit drill: did the edit really happen?++The battery proves a verb answers, and many of its edit checks stop at `success:true` or a dry run. `edit_drill.py` runs the edit verbs for real on scratch copies, then confirms each change with a separate read: a different verb, or KiCad's own netlist through kicad-cli.++```+python3 skills/kicad-bridge-test/edit_drill.py --target DESKTOP-BAR6S0F [--part sheet,library,file,live] [--out drill.json]+```++The `sheet`, `library` and `file` parts touch only files. `live` opens KiCad, so take the laptop in turn. Run it before a release that changes an edit verb, and add a step when you add one.+ ## What it does, phase by phase  0. **Fixture push** — sends the rp2040 tour pack (board/schematic/project — the
skills/kicad-bridge-test/edit_drill.pyadded+457
@@ -0,0 +1,457 @@+#!/usr/bin/env python3+"""Run every kicad-bridge edit verb for real, on scratch copies, and confirm each change with a separate read.++The battery (run_verb_tests.py) proves a verb answers. Many of its edit checks stop at success:true or a dry run,+so a verb that answers and changes nothing, or changes the wrong thing, passes. This drill runs the edit, then asks+a different verb, or KiCad itself through kicad-cli's netlist, whether the change happened.++  python3 edit_drill.py --target DESKTOP-BAR6S0F [--part sheet,library,file,live] [--out drill.json]++Exit 1 when any step fails. Files live under Documents/adom-kicad-drill/<stamp> on the target. The live part opens+KiCad, so take the laptop in turn; the other parts touch only files.+"""+import argparse+import json+import pathlib+import subprocess+import sys+import time++AI_THREAD = "kicad-lane"+REPO = pathlib.Path(__file__).resolve().parents[2]+ROUTE_FIXTURE = REPO / "rust/crates/kicad-core/tests/fixtures/konnect-route"+LIVE_BOARD = REPO / "demo/placement/esc-g431-unrouted.kicad_pcb"+++class Drill:+    def __init__(self, target):+        self.target = target+        self.results = []++    def call(self, verb, args=None, timeout=240):+        cmd = ["adom-bridge", "--ai-thread", AI_THREAD, "--target", self.target, verb if verb.startswith(("kicad_", "send_", "desktop_")) else "kicad_" + verb, json.dumps(args or {})]+        try:+            p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)+            return json.loads(p.stdout.strip() or p.stderr.strip())+        except subprocess.TimeoutExpired:+            return {"success": False, "error": f"no reply in {timeout}s"}+        except Exception as e:+            return {"success": False, "error": f"unreadable reply: {e}"}++    def check(self, verb, what, reply, good, seen=None):+        """Record one step: the edit's reply and what the separate read saw."""+        passed = bool(good)+        self.results.append({"verb": verb, "what": what, "pass": passed, "seen": seen, "reply": None if passed else {k: reply.get(k) for k in ("success", "errorCode", "error", "_hint") if k in reply}})+        print(f"  {'PASS' if passed else 'FAIL'}  {verb:32} {what}" + ("" if passed else f"\n        seen: {json.dumps(seen)[:300]}\n        reply: {json.dumps(reply.get('error') or reply.get('errorCode'))[:300]}"), flush=True)+        return passed+++def near(a, b, tol=0.02):+    if a is None or b is None or len(a) != len(b) or None in a or None in b:+        return False+    return all(abs(x - y) <= tol for x, y in zip(a, b))+++def same_net(nets, *pins):+    return any(all(p in members for p in pins) for members in nets.values())+++def sheet_part(d, base):+    print("[sheet] sch_* verbs on a new project")+    pd = base + "/sheet/dr"+    pro, sch = pd + "/dr.kicad_pro", pd + "/dr.kicad_sch"+    r = d.call("project_create", {"parentDir": base + "/sheet", "name": "dr", "layers": 2})+    if not d.check("project_create", "the project's sheet reads", r, d.call("sch_items", {"filePath": sch}).get("success") is True):+        return++    def nets():+        return d.call("netlist", {"filePath": sch}, 120).get("nets") or {}++    def items(*kinds):+        return d.call("sch_items", {"filePath": sch, "kinds": list(kinds)})++    def comp(ref):+        for c in d.call("sch_components", {"filePath": sch}).get("components") or []:+            if c.get("reference") == ref:+                return c+        return {}++    def field(c, name):+        return next((f.get("value") for f in c.get("fields") or [] if f.get("name") == name), None)++    r = d.call("sch_page", {"filePath": sch, "paper": "A3", "title": "drill title"})+    pg = d.call("sch_page", {"filePath": sch})+    d.check("sch_page", "paper A3 reads back", r, (pg.get("page") or {}).get("paper") == "A3", pg.get("page"))++    # The anchors sit on the grid. Device:R's pins are 3.81 mm above and below its anchor.+    for ref, x in (("R1", 50.8), ("R2", 76.2)):+        r = d.call("sch_place_symbol", {"filePath": sch, "libId": "Device:R", "reference": ref, "at": [x, 50.8]})+        c = comp(ref)+        d.check("sch_place_symbol", f"{ref} is at its point", r, near((c.get("units") or [{}])[0].get("at"), [x, 50.8]), c.get("units"))++    r = d.call("sch_connect_pins", {"filePath": sch, "from": "R1.2", "to": "R2.2"})+    n = nets()+    d.check("sch_connect_pins", "KiCad's netlist joins R1.2 and R2.2", r, same_net(n, "R1.2", "R2.2"), n)++    r = d.call("sch_split_wire", {"filePath": sch, "at": [63.5, 54.61], "junction": False})+    w = items("wires").get("wires") or []+    d.check("sch_split_wire", "one wire is now two", r, len(w) == 2, w)++    r = d.call("sch_junction", {"filePath": sch, "at": [63.5, 54.61]})+    j = items("junctions").get("junctions") or []+    d.check("sch_junction", "the junction is there", r, len(j) == 1 and near(j[0].get("at"), [63.5, 54.61]), j)+    if j:+        r = d.call("sch_junction", {"filePath": sch, "uuid": j[0].get("uuid") or j[0].get("id"), "remove": True})+        j2 = items("junctions").get("junctions") or []+        d.check("sch_junction", "remove by id takes it away", r, not j2, j2)++    r = d.call("sch_delete_wire", {"filePath": sch, "at": [57.15, 54.61]})+    n = nets()+    d.check("sch_delete_wire", "the netlist no longer joins R1.2 and R2.2", r, not same_net(n, "R1.2", "R2.2"), n)++    r = d.call("sch_wire", {"filePath": sch, "points": [[50.8, 54.61], [63.5, 54.61]]})+    n = nets()+    d.check("sch_wire", "a drawn wire joins them again", r, same_net(n, "R1.2", "R2.2"), n)++    r = d.call("sch_connect_to_net", {"filePath": sch, "pin": "R1.1", "net": "VIN"})+    n = nets()+    d.check("sch_connect_to_net", "R1.1 is on VIN", r, "R1.1" in (n.get("VIN") or n.get("/VIN") or []), n)++    lab = next((x for x in items("labels").get("labels") or [] if x.get("text") == "VIN"), {})+    r = d.call("sch_edit_label", {"filePath": sch, "net": "VIN", "rename": "VIN2"})+    n = nets()+    d.check("sch_edit_label", "rename: the net is VIN2", r, "R1.1" in (n.get("VIN2") or n.get("/VIN2") or []) and "VIN" not in n and "/VIN" not in n, n)+    r = d.call("sch_edit_label", {"filePath": sch, "net": "VIN2", "by": [2.54, 0]})+    lab2 = next((x for x in items("labels").get("labels") or [] if x.get("text") == "VIN2"), {})+    d.check("sch_edit_label", "by: the label moved 2.54 mm in x", r, lab.get("at") and lab2.get("at") and abs(lab2["at"][0] - lab["at"][0] - 2.54) < 0.02, [lab.get("at"), lab2.get("at")])++    r = d.call("sch_label", {"filePath": sch, "text": "SIGX", "at": [57.15, 54.61], "kind": "local"})+    n = nets()+    d.check("sch_label", "the wire's net is named SIGX", r, same_net({k: v for k, v in n.items() if "SIGX" in k}, "R1.2"), n)+    r = d.call("sch_delete", {"filePath": sch, "label": "SIGX", "at": [57.15, 54.61]})+    n = nets()+    d.check("sch_delete", "the label is gone from the netlist", r, not any("SIGX" in k for k in n), n)++    r = d.call("sch_no_connect", {"filePath": sch, "pin": "R2.1"})+    nc = items("noConnects").get("noConnects") or []+    d.check("sch_no_connect", "a marker on R2.1's end", r, len(nc) == 1 and near(nc[0].get("at"), [76.2, 46.99]), nc)+    if nc:+        r = d.call("sch_no_connect", {"filePath": sch, "uuid": nc[0].get("uuid") or nc[0].get("id"), "remove": True})+        nc2 = items("noConnects").get("noConnects") or []+        d.check("sch_no_connect", "remove by id takes it away", r, not nc2, nc2)++    r = d.call("sch_text", {"filePath": sch, "text": "drill note", "at": [30.48, 30.48]})+    tx = [t for t in items("texts").get("texts") or [] if t.get("text") == "drill note"]+    d.check("sch_text", "the note is there at its point", r, len(tx) == 1 and near(tx[0].get("at"), [30.48, 30.48]), tx)+    if tx:+        r = d.call("sch_delete", {"filePath": sch, "uuid": tx[0].get("uuid") or tx[0].get("id")})+        tx2 = [t for t in items("texts").get("texts") or [] if t.get("text") == "drill note"]+        d.check("sch_delete", "delete by id takes the note away", r, not tx2, tx2)++    r = d.call("sch_bus", {"filePath": sch, "points": [[101.6, 38.1], [101.6, 76.2]], "label": "D[0..1]"})+    b = items("buses").get("buses") or []+    d.check("sch_bus", "the bus runs between its points", r, len(b) == 1, b)+    r = d.call("sch_bus_entry", {"filePath": sch, "at": [99.06, 68.58], "direction": "up-right"})+    be = items("busEntries").get("busEntries") or []+    d.check("sch_bus_entry", "the entry is there", r, len(be) == 1, be)+    r = d.call("sch_connect_to_bus", {"filePath": sch, "pins": ["R2.1"], "prefix": "D", "busLabel": "D[0..1]"})+    n = nets()+    d.check("sch_connect_to_bus", "R2.1 is on the bus member D0", r, any("D0" in k and "R2.1" in v for k, v in n.items()), n)++    before = (comp("R1").get("units") or [{}])[0].get("at")+    r = d.call("sch_move", {"filePath": sch, "reference": "R1", "by": [0, -2.54]})+    after = (comp("R1").get("units") or [{}])[0].get("at")+    d.check("sch_move", "R1 moved by the amount asked", r, before and after and near([after[0], after[1] + 2.54], before[:2]), [before, after])+    r = d.call("sch_move", {"filePath": sch, "reference": "R1", "by": [0, 2.54]})++    r = d.call("sch_rotate", {"filePath": sch, "reference": "R2", "rotation": 90})+    pr = [p for p in d.call("sch_pins", {"filePath": sch, "reference": "R2"}).get("pins") or []]+    d.check("sch_rotate", "R2's pins now lie left and right", r, sorted(p.get("direction") for p in pr) == ["left", "right"], [(p.get("number"), p.get("direction")) for p in pr])+    r = d.call("sch_rotate", {"filePath": sch, "reference": "R2", "rotation": 0})++    wires0 = items("wires").get("wires") or []+    r = d.call("sch_move_region", {"filePath": sch, "box": [40, 40, 90, 60], "by": [0, 2.54]})+    r1 = (comp("R1").get("units") or [{}])[0].get("at")+    d.check("sch_move_region", "R1 inside the box moved", r, r1 and before and near([r1[0], r1[1] - 2.54], before[:2]), [before, r1, len(wires0)])+    d.call("sch_move_region", {"filePath": sch, "box": [40, 40, 90, 65], "by": [0, -2.54]})++    r = d.call("sch_set_property", {"filePath": sch, "reference": "R1", "name": "Value", "value": "4k7"})+    d.check("sch_set_property", "R1's Value is 4k7", r, field(comp("R1"), "Value") == "4k7", comp("R1").get("fields"))++    d.call("sch_place_symbol", {"filePath": sch, "libId": "Device:R", "reference": "R?", "at": [50.8, 88.9]})+    r = d.call("sch_annotate", {"filePath": sch})+    d.check("sch_annotate", "R? became R3", r, comp("R3") and not comp("R?"), [c.get("reference") for c in d.call("sch_components", {"filePath": sch}).get("components") or []])++    r = d.call("sch_replace_symbol", {"filePath": sch, "reference": "R3", "libId": "Device:R_Small"})+    d.check("sch_replace_symbol", "R3 is now Device:R_Small", r, comp("R3").get("libId") == "Device:R_Small", comp("R3").get("libId"))++    r = d.call("sch_set_property", {"filePath": sch, "reference": "R3", "name": "Value", "value": "1k"})+    r = d.call("sch_reset_fields", {"filePath": sch, "references": ["R3"]})+    d.check("sch_reset_fields", "R3 keeps its reference and value", r, r.get("success") is True and field(comp("R3"), "Value") == "1k" and comp("R3"), comp("R3").get("fields"))++    r = d.call("sch_update_from_library", {"filePath": sch})+    d.check("sch_update_from_library", "the parts still read, same symbols", r, r.get("success") is True and comp("R3").get("libId") == "Device:R_Small", {k: r.get(k) for k in ("updated", "changed", "symbols")})++    # a wire that stops 0.2 mm short of R3's pin: fix_connectivity must join it, and the netlist must say so+    pins = {p["number"]: p for p in d.call("sch_pins", {"filePath": sch, "reference": "R3"}).get("pins") or []}+    pa = pins.get("1", {}).get("at") or [50.8, 85.09]+    d.call("sch_wire", {"filePath": sch, "points": [[pa[0] + 0.2, pa[1]], [pa[0] + 12.7, pa[1]]], "junctions": False})+    d.call("sch_connect_to_net", {"filePath": sch, "pin": "R3.2", "net": "FIXNET"})+    d.call("sch_label", {"filePath": sch, "text": "FARNET", "at": [pa[0] + 12.7, pa[1]], "kind": "local"})+    n0 = nets()+    r = d.call("sch_fix_connectivity", {"filePath": sch, "dryRun": False, "tolerance": 0.5})+    n = nets()+    d.check("sch_fix_connectivity", "the 0.2 mm gap is closed: R3.1 on FARNET", r, not any("FARNET" in k and "R3.1" in v for k, v in n0.items()) and any("FARNET" in k and "R3.1" in v for k, v in n.items()), {k: v for k, v in n.items() if "FARNET" in k or "R3" in str(v)})++    r = d.call("sch_sheet", {"filePath": sch, "action": "add", "name": "Child", "file": "child.kicad_sch", "at": [127, 30.48], "size": [25.4, 20.32], "projectPath": pro})+    h = d.call("sch_hierarchy", {"projectPath": pro})+    d.check("sch_sheet", "the hierarchy has Child", r, h.get("sheetCount") == 2 and any(str(f).endswith("child.kicad_sch") for f in h.get("files") or []), h.get("files"))+    r = d.call("sch_sheet_pins", {"filePath": sch, "sheet": "Child", "action": "add", "name": "VIO", "shape": "input", "side": "left"})+    sp = d.call("sch_sheet_pins", {"filePath": sch, "sheet": "Child", "action": "list"})+    d.check("sch_sheet_pins", "the sheet has pin VIO", r, "VIO" in json.dumps(sp), sp.get("pins"))++    had = bool(comp("R3"))+    r = d.call("sch_delete", {"filePath": sch, "reference": "R3"})+    d.check("sch_delete", "R3 was there and is gone", r, had and not comp("R3"))+    # KiCad itself must still read the finished sheet+    e = d.call("run_erc", {"filePath": sch}, 200)+    d.check("run_erc", "kicad-cli still reads the sheet", e, e.get("success") is True, {k: e.get(k) for k in ("errors", "warnings", "error")})+++def library_part(d, base):+    print("[library] symbol and footprint verbs on new libraries")+    lib, pretty = base + "/lib/D.kicad_sym", base + "/lib/D.pretty"+    pins = [{"number": 1, "name": "A"}, {"number": 2, "name": "B", "type": "output"}]++    def info(name):+        return d.call("sym_info", {"libraryPath": lib, "name": name})++    def fp(name):+        return d.call("fp_info", {"libraryPath": pretty, "name": name})++    r = d.call("sym_create", {"libraryPath": lib, "name": "P", "pins": pins})+    i = info("P")+    d.check("sym_create", "two pins read back with their types", r, len(i.get("pins") or []) == 2 and any(p.get("type") == "output" for p in i.get("pins") or []), i.get("pins"))+    r = d.call("set_params", {"libraryPath": lib, "symbolName": "P", "params": {"MPN": "X1"}, "comment": "10k", "footprint": "Lib:FP"})+    f = info("P").get("fields") or {}+    d.check("set_params", "MPN, Value and Footprint read back", r, (f.get("MPN"), f.get("Value"), f.get("Footprint")) == ("X1", "10k", "Lib:FP"), f)+    r = d.call("set_visibility", {"libraryPath": lib, "symbolName": "P", "param": "MPN", "visible": True})+    d.check("set_visibility", "the verb says MPN is shown, and the symbol still reads", r, r.get("success") is True and info("P").get("success") is True, r.get("visible"))+    r = d.call("clone_symbol", {"libraryPath": lib, "from": "P", "to": "Q"})+    d.check("clone_symbol", "Q exists with P's pins", r, len(info("Q").get("pins") or []) == 2)+    r = d.call("rename_symbol", {"libraryPath": lib, "from": "Q", "to": "Q2"})+    d.check("rename_symbol", "Q2 exists, Q is gone", r, info("Q2").get("success") is True and info("Q").get("success") is not True)+    r = d.call("sym_delete", {"libraryPath": lib, "name": "Q2"})+    d.check("sym_delete", "Q2 is gone, P stays", r, info("Q2").get("success") is not True and info("P").get("success") is True)+    r = d.call("set_models", {"libraryPath": lib, "symbolName": "P", "footprint": "Lib:FP2"})+    d.check("set_models", "the footprint link was replaced", r, (info("P").get("fields") or {}).get("Footprint") == "Lib:FP2" and r.get("removed") == 1, r.get("removed"))+    r = d.call("add_models", {"libraryPath": lib, "symbolName": "P", "models": [{"name": "FP3"}], "description": "d"})+    f = info("P").get("fields") or {}+    d.check("add_models", "the filter and description read back", r, f.get("ki_fp_filters") == "FP3" and f.get("Description") == "d", f)++    pads = [{"number": 1, "size": [1, 1.5], "at": [-1, 0]}, {"number": 2, "size": [1, 1.5], "at": [1, 0]}]+    r = d.call("fp_create", {"libraryPath": pretty, "name": "F", "pads": pads, "bodySize": [3, 2]})+    i = fp("F")+    d.check("fp_create", "two pads read back at their points", r, len(i.get("pads") or []) == 2, i.get("pads"))+    r = d.call("fp_edit", {"libraryPath": pretty, "name": "F", "pads": [{"number": "1", "set": {"size": [2, 2]}}]})+    p1 = next((p for p in fp("F").get("pads") or [] if str(p.get("number")) == "1"), {})+    d.check("fp_edit", "pad 1 is now 2 by 2", r, near(p1.get("size"), [2, 2]), p1)+    r = d.call("copy_footprint", {"libraryPath": pretty, "from": "F", "to": "G"})+    d.check("copy_footprint", "G exists", r, fp("G").get("success") is True)+    r = d.call("rename_footprint", {"libraryPath": pretty, "from": "G", "to": "G2"})+    d.check("rename_footprint", "G2 exists, G is gone", r, fp("G2").get("success") is True and fp("G").get("success") is not True)+    r = d.call("xcopy_footprint", {"fromLib": pretty, "from": "G2", "toLib": base + "/lib/E.pretty", "to": "H"})+    d.check("xcopy_footprint", "H exists in the other library", r, d.call("fp_info", {"libraryPath": base + "/lib/E.pretty", "name": "H"}).get("success") is True)+    r = d.call("remove_footprint", {"libraryPath": pretty, "footprintName": "G2"})+    d.check("remove_footprint", "G2 is gone, F stays", r, fp("G2").get("success") is not True and fp("F").get("success") is True)+    r = d.call("footprint_from_land_pattern", {"libraryPath": pretty, "name": "S8", "family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padLength": 1.55, "padWidth": 0.6})+    d.check("footprint_from_land_pattern", "eight pads read back", r, len(fp("S8").get("pads") or []) == 8, len(fp("S8").get("pads") or []))+    # a thru-hole pad with no plating: plate_vias must plate it+    d.call("fp_create", {"libraryPath": pretty, "name": "V", "pads": [{"number": 1, "type": "thru_hole", "shape": "circle", "size": 1, "drill": 0.5, "at": [0, 0]}], "bodySize": [2, 2]})+    r = d.call("plate_vias", {"libraryPath": pretty, "footprintName": "V"})+    d.check("plate_vias", "the verb reports and the footprint still reads", r, r.get("success") is True and fp("V").get("success") is True, {k: r.get(k) for k in ("changed", "plated", "footprints", "pads")})+++def file_part(d, base):+    print("[file] closed-board edits on a copy")+    bdir = base + "/board"+    src = [str(ROUTE_FIXTURE / n) for n in ("route-fixture.kicad_pcb", "route-fixture.kicad_pro", "route-fixture.net", "fp-lib-table")]+    d.call("send_files", {"filePaths": src, "dest": bdir}, 200)+    d.call("send_files", {"filePaths": [str(LIVE_BOARD)], "dest": bdir}, 200)+    d.call("send_files", {"filePaths": [str(p) for p in (ROUTE_FIXTURE / "Fixture.pretty").iterdir()], "dest": bdir + "/Fixture.pretty"}, 200)+    b = bdir + "/esc-g431-unrouted.kicad_pcb"+    rf = bdir + "/route-fixture.kicad_pcb"++    def part(path, ref):+        s = d.call("routing_state", {"filePath": path, "source": "file", "model": "standard"})+        return next((c for c in s.get("components") or [] if c.get("ref") == ref), {})++    def layers(path):+        return d.call("layers", {"filePath": path, "source": "file"})++    r = d.call("set_layer_count", {"filePath": rf, "count": 4})+    l = layers(rf)+    d.check("set_layer_count", "the board now has 4 copper layers", r, r.get("countAfter") == 4 and "In1.Cu" in json.dumps(l), {k: l.get(k) for k in ("copper", "copperLayers", "count")})++    r = d.call("update_pcb_from_schematic", {"projectPath": bdir + "/route-fixture.kicad_pro", "filePath": bdir + "/route-fixture.kicad_pcb", "netlistPath": bdir + "/route-fixture.net", "execute": True, "allowRemovals": True})+    p = d.call("update_pcb_from_schematic", {"filePath": bdir + "/route-fixture.kicad_pcb", "netlistPath": bdir + "/route-fixture.net"})+    d.check("update_pcb_from_schematic", "execute: a second compare says in sync", r, r.get("executed") is True and (p.get("plan") or {}).get("inSync") is True, {"exec": r.get("error"), "after": (p.get("plan") or {}).get("changeCount"), "changes": p.get("changeOrder")})+++def live_part(d, base):+    print("[live] edits on a copy open in the PCB editor")+    ldir = base + "/live"+    d.call("send_files", {"filePaths": [str(LIVE_BOARD)], "dest": ldir}, 200)+    b = ldir + "/esc-g431-unrouted.kicad_pcb"+    def file_tracks():+        return [t for t in d.call("routing_state", {"filePath": b, "source": "file", "model": "standard"}).get("tracks") or [] if t.get("net") == "GND"]+    t0 = file_tracks()+    r = d.call("add_track", {"filePath": b, "net": "GND", "layer": "F.Cu", "width": 0.2, "start": [100, 60], "end": [104, 60]})+    t1 = file_tracks()+    d.check("add_track", "closed file: one more GND track", r, len(t1) == len(t0) + 1, [len(t0), len(t1), r.get("errorCode"), r.get("error")])+    o = d.call("open_board", {"filePath": b}, 240)+    if o.get("success") is not True:+        d.check("open_board", "the copy opened", o, False, o.get("error"))+        return+    st = {}+    for _ in range(10):+        st = d.call("routing_state", {"filePath": b})+        if st.get("revision"):+            break+        time.sleep(5)+    rev = [st.get("revision")]++    def state():+        s = d.call("routing_state", {"filePath": b, "model": "standard"})+        if s.get("revision"):+            rev[0] = s["revision"]+        return s++    def comp(ref):+        return next((c for c in state().get("components") or [] if c.get("ref") == ref), {})++    def outline():+        o = d.call("read_board", {"filePath": b, "sections": "outline"}).get("data", {}).get("outline", {})+        pts = [p for e in o.get("edges") or [] for p in (e.get("start"), e.get("end")) if p]+        return (max(p[0] for p in pts) - min(p[0] for p in pts), max(p[1] for p in pts) - min(p[1] for p in pts)) if pts else None++    def edit(verb, **args):+        return d.call(verb, dict({"filePath": b, "expectedRevision": rev[0], "validate": False}, **args))++    # edits take KiCad's frame (y down), the standard model reads y up+    M = "C7"+    c0 = comp(M)+    r = edit("move_footprint", ref=M, x=c0.get("x", 0) + 1, y=-c0.get("y", 0), allowOverlap=True, allowOutside=True)+    c1 = comp(M)+    d.check("move_footprint", f"live: {M} moved 1 mm in x", r, near([c1.get("x"), c1.get("y")], [c0.get("x", 0) + 1, c0.get("y")], 0.01), [c0.get("x"), c1.get("x"), r.get("errorCode"), r.get("error")])++    r = edit("align_footprints", refs=[M, "C12"], mode="top", by="anchor", allowOverlap=True, allowOutside=True)+    a, c = comp(M), comp("C12")+    d.check("align_footprints", f"live: {M} and C12 share a y", r, a.get("y") is not None and abs(a.get("y") - c.get("y")) < 0.01, [a.get("y"), c.get("y"), r.get("errorCode"), r.get("error")])++    pool = [c.get("ref") for c in state().get("components") or [] if str(c.get("ref")).startswith("C") and c.get("ref") not in (M, "C12")]+    ta, tb = pool[0], pool[1]+    t0 = comp(ta)+    r = edit("replicate_layout", source=[M, "C12"], targets=[[ta, tb]], anchor=M, allowOverlap=True, allowOutside=True)+    t2, t3, s0, s1 = comp(ta), comp(tb), comp(M), comp("C12")+    # the target group keeps the source's shape, turned by the difference between the anchors' rotations+    dist = lambda p, q: round(((p.get("x", 0) - q.get("x", 0)) ** 2 + (p.get("y", 0) - q.get("y", 0)) ** 2) ** 0.5, 2)+    d.check("replicate_layout", f"live: {ta} and {tb} are as far apart as {M} and C12", r, r.get("success") is True and abs(dist(s0, s1) - dist(t2, t3)) < 0.02, [dist(s0, s1), dist(t2, t3), t0.get("x"), t2.get("x"), s0.get("rotation"), t2.get("rotation"), r.get("movedRefs"), r.get("errorCode"), r.get("error")])++    before = {c.get("ref"): (c.get("x"), c.get("y")) for c in state().get("components") or []}+    r = edit("place_decoupling", ic="U5", dryRun=False, allowOverlap=True)+    after = {c.get("ref"): (c.get("x"), c.get("y")) for c in state().get("components") or []}+    moved = [k for k in before if before[k] != after.get(k)]+    d.check("place_decoupling", "live: the capacitors really moved", r, r.get("success") is True and moved, [moved[:6], r.get("errorCode"), r.get("error"), r.get("mutated")])++    r = edit("flip_footprint", ref=M)+    a = comp(M)+    d.check("flip_footprint", f"live: {M} is on the back", r, a.get("side") == "bottom", [a.get("side"), r.get("errorCode"), r.get("error")])++    r = edit("edit_footprint", ref=M, value="drillval")+    a = comp(M)+    d.check("edit_footprint", f"live: {M}'s value reads back", r, a.get("value") == "drillval", [a.get("value"), r.get("errorCode"), r.get("error")])++    def silk_fields():+        return [f for c in d.call("silk_state", {"filePath": b}).get("parts") or [] for f in c.values() if isinstance(f, dict) and f.get("id") and f.get("at")]+    fld = next((x for x in silk_fields() if x.get("layer") == "F.SilkS"), None)+    if fld:+        nx = round(fld["at"][0] + 0.5, 3)+        r = edit("silk_fields_batch", update=[{"id": fld["id"], "x": nx, "y": -fld["at"][1], "layer": "F.SilkS", "height": fld.get("heightMm") or 0.8, "stroke": fld.get("thicknessMm") or 0.12}])+        fld2 = next((x for x in silk_fields() if x["id"] == fld["id"]), {})+        moved = fld2.get("at") and abs(fld2["at"][0] - nx) < 0.01+        refused = r.get("errorCode") in ("native_field_update_unsupported", "drc_rejected") and fld2.get("at") == fld["at"]+        d.check("silk_fields_batch", "live: the field moved 0.5 mm, or a named refusal with nothing changed", r, moved or refused, [fld["at"], fld2.get("at"), r.get("errorCode"), r.get("error")])+    else:+        d.check("silk_fields_batch", "live: a field to move", {}, False, None)++    n0 = len(state().get("components") or [])+    hx, hy = c0.get("x", 0) - 20, -c0.get("y", 0) - 30+    r = edit("add_mounting_hole", x=hx, y=hy, drill=2.2)+    s1 = state()+    d.check("add_mounting_hole", "live: one more part on the board", r, len(s1.get("components") or []) == n0 + 1, [n0, len(s1.get("components") or []), r.get("errorCode"), r.get("error")])++    g0 = [t for t in state().get("tracks") or [] if t.get("net") == "GND"]+    r = edit("copy_routing", source={"minX": 99, "minY": 59, "maxX": 105, "maxY": 61}, offset={"dx": 0, "dy": 3}, keepUnmappedNets=True)+    g1 = [t for t in state().get("tracks") or [] if t.get("net") == "GND"]+    d.check("copy_routing", "live: the GND track was copied", r, len(g1) == len(g0) + 1, [len(g0), len(g1), r.get("errorCode"), r.get("error")])++    nets = [n.get("name") if isinstance(n, dict) else n for n in state().get("nets") or []]+    nets = [n for n in nets if n and n not in ("GND", "")]+    pa, pb = nets[0], nets[1]+    p0 = len([t for t in state().get("tracks") or [] if t.get("net") in (pa, pb)])+    r = edit("route_diff_pair", netP=pa, netN=pb, centerline=[[100, 70], [106, 70]], width=0.2, gap=0.2)+    p1 = len([t for t in state().get("tracks") or [] if t.get("net") in (pa, pb)])+    d.check("route_diff_pair", "live: both nets got copper", r, p1 >= p0 + 2, [pa, pb, p0, p1, r.get("errorCode"), r.get("error")])++    r = edit("ripup_nets", nets=["GND"], dryRun=False)+    t2 = [t for t in state().get("tracks") or [] if t.get("net") == "GND"]+    d.check("ripup_nets", "live: GND has no tracks left", r, not t2 and len(g1) > 0, [len(g1), len(t2), r.get("errorCode"), r.get("error")])++    r = edit("board_outline", shape="rectangle", width=90, height=80, x=90, y=50, replace=True)+    ol = outline()+    d.check("board_outline", "live: the outline is 90 by 80", r, ol and near(list(ol), [90, 80], 0.1), [ol, r.get("errorCode"), r.get("error")])++    r = d.call("save_board", {"filePath": b})+    fs = d.call("routing_state", {"filePath": b, "source": "file", "model": "standard"})+    cf = next((c for c in fs.get("components") or [] if c.get("ref") == M), {})+    d.check("save_board", f"the file holds the live edits ({M} on the back)", r, cf.get("side") == "bottom" and cf.get("value") == "drillval", [cf.get("side"), cf.get("value"), r.get("error")])++    r = d.call("teardrops", {"filePath": b})+    d.check("teardrops", "the reply counts, and the board saved", r, r.get("success") is True and r.get("saved") is True and isinstance(r.get("teardropsAfter"), int), {k: r.get(k) for k in ("teardropsBefore", "teardropsAfter", "saved", "errorCode", "error")})++    d.call("close", {"discardChanges": True}, 120)+++def main():+    ap = argparse.ArgumentParser()+    ap.add_argument("--target", required=True)+    ap.add_argument("--part", default="sheet,library,file,live")+    ap.add_argument("--out")+    a = ap.parse_args()+    d = Drill(a.target)+    diag = d.call("readiness", {})+    user = None+    for inst in diag.get("installs") or []:+        parts = str(inst.get("baseDir") or "").split("/")+        if len(parts) > 2 and parts[1].lower() == "users":+            user = parts[2]+    print(f"bridge {diag.get('bridgeVersion')} on {a.target}, KiCad {diag.get('kicadVersion') or (diag.get('installs') or [{}])[0].get('version')}")+    if not user:+        print("FATAL: could not learn the Windows user from kicad_readiness")+        return 2+    base = f"C:/Users/{user}/Documents/adom-kicad-drill/{time.strftime('%m%d%H%M%S')}"+    parts = {"sheet": sheet_part, "library": library_part, "file": file_part, "live": live_part}+    for name in a.part.split(","):+        parts[name.strip()](d, base)+    fails = [r for r in d.results if not r["pass"]]+    print(f"\n{len(d.results) - len(fails)} passed, {len(fails)} failed")+    for f in fails:+        print(f"  FAIL {f['verb']}: {f['what']}")+    if a.out:+        pathlib.Path(a.out).write_text(json.dumps(d.results, indent=1))+    return 1 if fails else 0+++if __name__ == "__main__":+    sys.exit(main())
skills/kicad-bridge-test/run_verb_tests.py+59−3
@@ -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", "document_info")},+            "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", "read_board", "read_schematic", "schematic_data", "pcb_report", "instances", "ensure_licensed", "signin_status", "setup_trial", "set_models", "add_models", "check_dialogs", "save", "close_document", "close_all_documents")},         "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),@@ -1240,7 +1240,7 @@ def main():         r = K("render_schematic", {"filePath": sch, "outputDir": OUT + "/render-sch"}, 220); rec("render_schematic", r, ok(r))         r = K("design_review", {"projectPath": pro}, 220); rec("design_review", r, ok(r))         r = K("update_pcb_from_schematic", {"filePath": pcb, "schematicPath": sch}, 220)-        rec("update_pcb_from_schematic", r, ok(r) and r.get("mutated") is not True)+        rec("update_pcb_from_schematic", r, ok(r) and r.get("mutated") is not True and r.get("executed") is False and isinstance(r.get("preview"), dict) and isinstance((r.get("preview") or {}).get("addParts"), list) and isinstance(r.get("changeOrder"), list) and r.get("board"))         r = K("netclasses", {"projectPath": pro, "set": {"name": "PWR", "trackWidth": 0.5}, "assign": {"netclass": "PWR", "patterns": ["VIN"]}})         rec("netclasses", r, ok(r))         al = ab(a.target, "kicad_net_classes", {"projectPath": pro}, timeout=120)   # the standard's name for it@@ -1345,7 +1345,10 @@ def main():         # 1.0.66: teardrops through KiCad's own Edit Teardrops dialog, added then removed again         r = E("teardrops", {"filePath": L2}, 240)         r2 = E("teardrops", {"filePath": L2, "remove": True}, 240) if ok(r) else {}-        record("teardrops", {"add": r, "remove": r2}, ok(r) and ok(r2) and (r.get("after") or 0) >= (r.get("before") or 0) and r2.get("after") == 0, "teardrops were not added and removed again")+        r3 = E("teardrops", {"filePath": L2, "force": True}, 60, chain=False)+        record("teardrops", {"add": r, "remove": r2, "force": r3}, ok(r) and ok(r2) and (r.get("teardropsAfter") or 0) >= (r.get("teardropsBefore") or 0) and r2.get("teardropsAfter") == 0+               and r.get("saved") is True and "save" in (r.get("steps") or []) and isinstance(r.get("dialogsLeft"), list) and r3.get("errorCode") == "not_supported",+               "teardrops were not added, saved and removed again, or force:true was not refused")         # 1.0.67: thermal vias in Q1's drain tab, live as one Undo step after a DRC preflight         r = E("thermal_vias", {"ref": "Q1"}, 300)         record("thermal_vias_live", r, ok(r) and r.get("source") == "live-editor" and (r.get("applied") or 0) > 0 and r.get("undoSteps") == 1, "thermal vias were not committed live")@@ -1804,6 +1807,59 @@ def main():         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")+        # 1.0.99: the standard's read_board, read_schematic, schematic_data, pcb_report and instances+        ins = C("instances", {}, 60)+        own("instances", ins, ok(ins) and ins.get("count", 0) >= 1 and all(i.get("pid") and i.get("licensed") is True and "KiCad" in str(i.get("edition")) for i in ins.get("instances") or []), "the running KiCad was not listed")+        xpcb, xsch = xd + "/rp2040-breakout.kicad_pcb", xd + "/rp2040-breakout.kicad_sch"+        rb = C("read_board", {"filePath": xpcb, "source": "file"}, 120)+        rbd = rb.get("data") or {}+        rb2 = C("read_board", {"filePath": xpcb, "source": "file", "sections": "components,rules", "limit": 3}, 120)+        rb2d = rb2.get("data") or {}+        own("read_board", {"all": {"counts": rb.get("counts"), "sections": sorted(rbd)}, "some": sorted(rb2d)}, ok(rb) and all(k in rbd for k in ("components", "nets", "layers", "copper", "rules", "occupancy", "outline")) and len(rbd["components"]) > 3+            and ok(rb2) and len(rb2d.get("components") or []) == 3 and "copper" not in rb2d and "rulesByKind" in (rb2d.get("rules") or {}) and rb2.get("truncated") == ["components"], "the board's sections did not come back, or sections and limit were ignored")+        rs = C("read_schematic", {"filePath": xsch}, 120)+        rsr = next((c for c in rs.get("components") or [] if c.get("designator") == "U1"), {})+        own("read_schematic", rs, ok(rs) and rs.get("count", 0) >= 2 and "U1" in (rs.get("designators") or []) and rs.get("document") == xsch and rsr.get("libReference") and rsr.get("xMil", 0) > 0 and rsr.get("yMil", 0) > 0 and "pins" not in rsr,+            "the sheet's parts did not come back with designator, library reference and position in mils")+        sd = C("schematic_data", {"filePath": xsch, "designator": "U1"}, 120)+        sdc = ((sd.get("data") or {}).get("components") or [{}])[0]+        spins = sdc.get("pins") or []+        own("schematic_data", {"count": (sd.get("data") or {}).get("count"), "pins": len(spins), "first": spins[:2], "error": sd.get("error")}, ok(sd) and sdc.get("designator") == "U1" and len(spins) >= 56 and all(isinstance(q.get("electrical"), int) and q.get("orientation") in (0, 90, 180, 270) and q.get("lengthMil", 0) > 0 for q in spins) and any(q.get("electrical") == 7 for q in spins),+            "U1's pins did not come back with the standard's electrical ordinal, orientation and length")+        pr = C("pcb_report", {"path": twd + "/route-fixture.kicad_pcb", "source": "file"}, 120)+        prl = C("pcb_report", {"path": kb + "/share/kicad/footprints/Resistor_SMD.pretty", "name": "R_0603"}, 120)+        prd, prld = pr.get("data") or {}, prl.get("data") or {}+        own("pcb_report", {"boardCount": len(prd.get("components") or []), "census": prd.get("census"), "lib": (prld.get("components") or [])[:2], "error": pr.get("error") or prl.get("error")},+            ok(pr) and prd.get("kind") == "board" and any(c.get("pads", 0) > 0 and c.get("padsBboxMil") and c.get("nets") for c in prd.get("components") or []) and ok(prl) and prld.get("kind") == "library" and prld.get("count", 0) >= 1 and all(c.get("pads") == 2 and c.get("padsBboxMil") for c in prld.get("components") or []),+            "the board or library report did not give pads, boxes and nets")+        # 1.0.99: the standard's account answers, library models, dialogs, save and close, with KiCad up on the scratch board+        cdg = C("check_dialogs", {"screenshots": False}, 60)+        own("check_dialogs", cdg, ok(cdg) and cdg.get("dialogsDetected") == len(cdg.get("dialogs") or []) and all(d.get("category") and d.get("safeAction") for d in cdg.get("dialogs") or []), "the dialog list did not come back classified")+        el = C("ensure_licensed", {}, 120)+        own("ensure_licensed", el, ok(el) and el.get("alreadyLicensed") is True and "already running" in (el.get("steps") or []), "KiCad was not reported ready")+        sgn = C("signin_status", {}, 30)+        own("signin_status", sgn, ok(sgn) and sgn.get("licensed") is True and sgn.get("workspaceConnected") is False and sgn.get("running") is True, "KiCad's account state was wrong")+        stt = C("setup_trial", {}, 30)+        own("setup_trial", stt, ok(stt) and stt.get("stage") == "done" and stt.get("needsHuman") is None, "a running KiCad was not at stage done")+        mlib = w["out"] + "/ModelTest.kicad_sym"+        msc = C("sym_create", {"libraryPath": mlib, "name": "MT", "pins": [{"number": 1, "name": "A"}, {"number": 2, "name": "B"}]}, 60)+        am = C("add_models", {"libraryPath": mlib, "symbolName": "MT", "models": [{"name": "R_0603_1608Metric", "pcblib": "Resistor_SMD"}, {"name": "R_0805_2012Metric"}], "params": {"MPN": "X1"}, "comment": "10k"}, 60)+        smi = C("sym_info", {"libraryPath": mlib, "name": "MT"}, 60)+        own("add_models", {"create": msc, "add": am, "fields": smi.get("fields")}, ok(am) and (am.get("data") or {}).get("models") == 2 and (smi.get("fields") or {}).get("Footprint") == "Resistor_SMD:R_0603_1608Metric" and (smi.get("fields") or {}).get("ki_fp_filters") == "R_0603_1608Metric R_0805_2012Metric",+            "the footprint link and filters were not written")+        sm = C("set_models", {"libraryPath": mlib, "symbolName": "MT", "footprint": "Resistor_SMD:R_0805_2012Metric"}, 60)+        smi2 = C("sym_info", {"libraryPath": mlib, "name": "MT"}, 60)+        own("set_models", sm, ok(sm) and sm.get("removed") == 1 and (smi2.get("fields") or {}).get("Footprint") == "Resistor_SMD:R_0805_2012Metric", "the footprint link was not replaced")+        osch = C("open_document", {"filePath": xsch}, 200)+        sv = C("save", {"filePath": xsch}, 120)+        sv2 = C("save", {"filePath": w["out"] + "/nothing-open-here.kicad_sch"}, 60)+        own("save", {"open": osch.get("success"), "sheet": sv, "notOpen": sv2}, ok(sv) and sv.get("saved") is True and (sv.get("data") or {}).get("process") and ok(sv2) and sv2.get("saved") is False, "the open sheet was not saved, or a document not open was not told apart")+        cd1 = C("close_document", {"path": xsch}, 60)+        cd2 = C("close_document", {"path": xsch}, 60)+        own("close_document", {"first": cd1, "second": cd2}, ok(cd1) and (cd1.get("data") or {}).get("closed") is True and ok(cd2) and (cd2.get("data") or {}).get("closed") is False and "not open" in str((cd2.get("data") or {}).get("note")), "the sheet did not close, or closing it again did not say it was not open")+        cad = C("close_all_documents", {"discardUnsaved": True}, 180)+        left = ab(a.target, "kicad_window_info", {}, timeout=60).get("editors") or []+        own("close_all_documents", {"reply": cad, "editorsLeft": [e.get("title") for e in left]}, ok(cad) and "rp2040-breakout" in (cad.get("closed") or []) and not [e for e in left if "PCB Editor" in str(e.get("title")) or "Schematic Editor" in str(e.get("title"))], "the scratch board was not closed")         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")