app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
← Commit history
Merge PR #399: 1.0.100: update_pcb sets every reference (closes #138); the verbs ruled on standard #29
17 files changed
+720−47
bridge.json+6rust/Cargo.lock+3−3rust/Cargo.toml+1−1rust/crates/kicad-bridge/src/kicad_only.txt−5rust/crates/kicad-bridge/src/standard_names.txt+34−1rust/crates/kicad-bridge/src/std_shapes.rs+7−2rust/crates/kicad-bridge/src/verbs_board.rs+41−2rust/crates/kicad-bridge/src/verbs_maint.rs+34−1rust/crates/kicad-bridge/src/verbs_routing.rs+41rust/crates/kicad-bridge/src/verbs_std_more.rs+275rust/crates/kicad-bridge/src/verbs_unsupported.rs+11rust/crates/kicad-core/src/board_model.rs+20−4rust/crates/kicad-core/src/freerouting.rs+31−1rust/crates/kicad-core/src/pcb.rs+11rust/crates/kicad-core/src/pcb_sync.rs+99−22skills/kicad-bridge-test/edit_drill.py+90−3skills/kicad-bridge-test/run_verb_tests.py+16−2bridge.json+6@@ -373,6 +373,12 @@ "kicad_check_dialogs", "kicad_close_document", "kicad_close_all_documents",+ "kicad_physical_properties",+ "kicad_cancel_run",+ "kicad_place_parts",+ "kicad_edit_tracks",+ "kicad_extensions",+ "kicad_preferences", "kicad_review_board" ], "statusVerb": "kicad_status",
rust/Cargo.lock+3−3@@ -319,7 +319,7 @@ dependencies = [ [[package]] name = "kicad-bridge"-version = "1.0.99"+version = "1.0.100" dependencies = [ "adom-eda-checks", "kicad-core",@@ -331,7 +331,7 @@ dependencies = [ [[package]] name = "kicad-core"-version = "1.0.99"+version = "1.0.100" dependencies = [ "kicad-ipc-rs", "kicad-platform",@@ -358,7 +358,7 @@ dependencies = [ [[package]] name = "kicad-platform"-version = "1.0.99"+version = "1.0.100" dependencies = [ "serde", "serde_json",
rust/Cargo.toml+1−1@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package]-version = "1.0.99"+version = "1.0.100" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/kicad_only.txt−5@@ -50,7 +50,6 @@ kicad_list_footprints kicad_list_symbols kicad_list_versions kicad_menu_items-kicad_model_check kicad_modify_track kicad_move_footprint kicad_open_3d_viewer@@ -79,18 +78,14 @@ kicad_sch_check kicad_sch_components kicad_sch_connect_to_net kicad_sch_delete-kicad_sch_label kicad_sch_move-kicad_sch_no_connect kicad_sch_place_symbol kicad_sch_replace_symbol kicad_sch_rotate kicad_sch_set_property-kicad_sch_wire kicad_score_placement kicad_screenshot_all kicad_select_clear-kicad_select_items kicad_send_key kicad_set_settings kicad_silk_fields_batch
rust/crates/kicad-bridge/src/standard_names.txt+34−1@@ -1,4 +1,4 @@-# 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.+# The EDA Bridge Standard's verb names (adom/eda-bridge-standard verbs/standard.json, reference altium 0.2.89). Generated by tools/sync_standard_names.py; do not edit. a365_configure a365_files_upload a365_library_overview@@ -24,6 +24,18 @@ animate_view annotate apply_pours apply_silkscreen+aps_browse+aps_create_folder+aps_download+aps_file_info+aps_open+aps_recent+aps_search+aps_set_client_id+aps_signin+aps_status+aps_upload+aps_versions assembly_check auto_classes autoroute@@ -38,9 +50,11 @@ build_board build_footprint build_schematic build_symbol_modes+cancel_run check_clearance check_dialogs check_orientation+cleanup_cloud_files cleanup_libs clone_symbol close@@ -56,12 +70,14 @@ copper_balance copper_layers copy_footprint copy_routing+create_cloud_folder create_footprint create_part_project create_symbol cross_check cross_probe current_capacity+delete_cloud_file demo describe diagnostics@@ -73,6 +89,7 @@ document_info edit_footprint edit_parts edit_pours+edit_tracks ensure_licensed estimate_cost eval@@ -91,6 +108,7 @@ export_pos export_spice export_step extension_ping+extensions extract_intlib fab_check find_space@@ -122,12 +140,20 @@ library_views license license_status lint_library+list_cloud_files+list_cloud_projects list_commands list_designators list_installed_libraries list_library list_nets log_tail+managed_library_create+managed_library_link+managed_library_pull+managed_library_push+managed_library_unlink+model_check move_parts net_classes net_connections@@ -141,16 +167,19 @@ panel panelize part_parameters pcb_report+physical_properties pin_pad_check place_decoupling place_on_pcb place_on_sch+place_parts place_vias placement_check plan_fanout plan_route plate_vias pour_state+preferences prewarm progress project_create@@ -206,7 +235,9 @@ sch_fix_connectivity sch_hierarchy sch_items sch_junction+sch_label sch_move_region+sch_no_connect sch_page sch_pins sch_place_part@@ -218,9 +249,11 @@ sch_swap_part sch_text sch_trace_point sch_update_from_library+sch_wire sch_zoom schematic_data schematic_review+select_items select_net selection selftest_script_error
rust/crates/kicad-bridge/src/std_shapes.rs+7−2@@ -39,7 +39,7 @@ const SHEET_VERBS: &[&str] = &[ "kicad_sch_split_wire", "kicad_sch_delete_wire", "kicad_sch_edit_label", "kicad_sch_move_region", "kicad_sch_text", "kicad_sch_page", "kicad_sch_items", "kicad_sch_pins", "kicad_sch_trace_point", "kicad_sch_hierarchy", "kicad_sch_sheet_pins", "kicad_sch_reset_fields", "kicad_sch_update_from_library", "kicad_sch_fix_connectivity",- "kicad_render_schematic", "kicad_sch_create",+ "kicad_render_schematic", "kicad_sch_create", "kicad_sch_no_connect", "kicad_sch_label", "kicad_sch_wire", "kicad_sch_delete", ]; const BOARD_FRAME_VERBS: &[&str] = &["kicad_align_footprints", "kicad_import_svg", "kicad_copy_routing"]; const OTHER_VERBS: &[&str] = &[@@ -61,7 +61,9 @@ pub fn accepts(command: &str) -> bool { /// Argument renames per verb: (the standard's name, KiCad's). Applied only when KiCad's is absent. fn renames(command: &str) -> &'static [(&'static str, &'static str)] { match command {- "kicad_sch_split_wire" => &[("id", "uuid")],+ "kicad_sch_split_wire" | "kicad_sch_no_connect" => &[("id", "uuid")],+ // sch_label {remove:true} is kicad_sch_delete: its label is the text, its uuid the id+ "kicad_sch_delete" => &[("id", "uuid"), ("text", "label")], "kicad_sch_delete_wire" => &[("ids", "uuids"), ("id", "uuid")], "kicad_sch_page" | "kicad_sch_create" => &[("widthMm", "width"), ("heightMm", "height")], "kicad_sch_trace_point" | "kicad_sch_fix_connectivity" => &[("toleranceMm", "tolerance")],@@ -589,6 +591,9 @@ pub fn to_native(called: &str, command: &str, args: &Value) -> Option<Result<Sha if command == "kicad_edit_footprint" && is_library_footprint_edit(o0) { command = "kicad_fp_edit".into(); }+ if command == "kicad_sch_label" && o0.get("remove") == Some(&json!(true)) {+ command = "kicad_sch_delete".into();+ } let c = command.as_str(); let sheet = SHEET_VERBS.contains(&c); let board_frame = BOARD_FRAME_VERBS.contains(&c);
rust/crates/kicad-bridge/src/verbs_board.rs+41−2@@ -221,7 +221,7 @@ pub static VERBS: &[Verb] = &[ name: "kicad_select_items", summary: "Set the PCB editor's selection to items by id and/or footprints by reference: replace (default), add or remove.", mechanism: Mechanism::Ipc, risk: "input", timeout_sec: 30,- input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"itemIds\"?: [\"...\"], \"refs\"?: [\"U1\", \"C3\"], \"mode\"?: \"replace|add|remove\", \"socketPath\"?: \"ipc://...\"}",+ input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"itemIds\"?: [\"...\"], \"refs\"?: [\"U1\", \"C3\"], \"mode\"?: \"replace|add|remove\", \"clear\"?: false, \"where\"?: \"board\" (\"sheet\" is refused: the Schematic Editor has no API), \"socketPath\"?: \"ipc://...\"}", example: "kicad_select_items {\"filePath\":\"C:/p/b.kicad_pcb\",\"refs\":[\"U1\",\"C3\"]}", hint: "Live only. verified says every id took (notApplied lists the ones that did not); the reply carries the selection as kicad_get_selection reads it. replace with nothing clears it. Selection is not an edit: no Undo step, the revision is unchanged.", related: &["kicad_get_selection", "kicad_select_net", "kicad_select_clear"],@@ -264,7 +264,7 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> "kicad_footprint_graphics" => listed_edit(state, args, Listed::Graphics), "kicad_footprint_models" => listed_edit(state, args, Listed::Models), "kicad_get_selection" => live_only(state, args, LiveOnly::GetSelection),- "kicad_select_items" => live_only(state, args, LiveOnly::Select),+ "kicad_select_items" => select_items_std(state, args), "kicad_cross_probe" => cross_probe(state, args), _ => return None, })@@ -883,6 +883,45 @@ enum LiveOnly { Select, } +/// The standard's select_items over KiCad's: where, clear, and failed per designator (KiCad's own call refuses the+/// whole list when one is missing).+fn select_items_std(state: &mut State, args: &Value) -> Value {+ if args.get("where").and_then(Value::as_str) == Some("sheet") {+ return fail("not_supported", "KiCad's Schematic Editor has no API for its selection, so parts can't be selected on a sheet", "kicad_sch_zoom shows parts on a sheet in a picture of the saved file; where:\"board\" selects in the PCB editor.");+ }+ let mut a = args.clone();+ let clear = args.get("clear") == Some(&json!(true));+ if clear {+ a["refs"] = json!([]);+ a["itemIds"] = json!([]);+ a["mode"] = json!("replace");+ }+ let mut failed = Vec::new();+ let mut out = live_only(state, &a, LiveOnly::Select);+ if out["errorCode"] == json!("unknown_footprint") {+ let missing: Vec<String> = out["missing"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect();+ let rest: Vec<Value> = args["refs"].as_array().into_iter().flatten().filter(|r| !r.as_str().is_some_and(|x| missing.iter().any(|m| m.eq_ignore_ascii_case(x)))).cloned().collect();+ failed = missing.iter().map(|m| json!({"ref": m, "error": "not on this board"})).collect();+ if rest.is_empty() {+ let mut v = out;+ v["failed"] = json!(failed);+ v["selected"] = json!([]);+ v["verified"] = json!(false);+ return v;+ }+ a["refs"] = json!(rest);+ out = live_only(state, &a, LiveOnly::Select);+ }+ if out["success"] == json!(true) {+ out["selected"] = out.get("references").cloned().unwrap_or_else(|| json!([]));+ out["failed"] = json!(failed);+ if !failed.is_empty() {+ out["verified"] = json!(false);+ }+ }+ out+}+ #[cfg(feature = "ipc")] fn live_only(state: &mut State, args: &Value, which: LiveOnly) -> Value { use kicad_core::ipc;
rust/crates/kicad-bridge/src/verbs_maint.rs+34−1@@ -156,6 +156,39 @@ pub static VERBS: &[Verb] = &[ }, ]; +/// The standard's parts[] beside KiCad's own rows: per part, its models, whether each is found and holds geometry.+fn standard_model_check(mut out: Value) -> Value {+ if out["success"] != json!(true) {+ return out;+ }+ let row = |r: &Value| {+ let warned = |c: &str| r["warnings"].as_array().into_iter().flatten().any(|w| w["code"] == json!(c));+ let found = r["exists"] == json!(true);+ json!({"path": r["resolved"].as_str().or(r["reference"].as_str()), "location": "file", "found": found, "hasGeometry": found && !warned("empty_geometry") && !warned("unreadable")})+ };+ let issues = |rows: &[Value]| -> Vec<Value> { rows.iter().flat_map(|r| r["warnings"].as_array().cloned().unwrap_or_default()).filter_map(|w| w["code"].as_str().map(|c| json!(c))).collect() };+ let parts: Vec<Value> = if let Some(bind) = out["modelBindings"].as_array() {+ bind.iter().map(|b| {+ let rows = b["models"].as_array().cloned().unwrap_or_default();+ let mut iss = issues(&rows);+ if rows.is_empty() && b["noModelExpected"] != json!(true) {+ iss.push(json!("no_model_reference"));+ }+ json!({"name": b["reference"], "footprint": b["footprint"], "noModelExpected": b["noModelExpected"], "models": rows.iter().map(row).collect::<Vec<_>>(), "issues": iss})+ }).collect()+ } else {+ let rows = out["models"].as_array().cloned().unwrap_or_default();+ let mut iss = issues(&rows);+ if rows.is_empty() {+ iss.push(json!("no_model_reference"));+ }+ vec![json!({"name": out["footprint"], "models": rows.iter().map(row).collect::<Vec<_>>(), "issues": iss})]+ };+ out["checked"] = json!(parts.len());+ out["parts"] = json!(parts);+ out+}+ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> { let out = match command { "kicad_taskbar_repair" => taskbar_repair(&state.kicad_info().version),@@ -174,7 +207,7 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> } "kicad_model_check" => { let ctx = Ctx::from_info(&state.kicad_info());- model_check::handle(&ctx, args)+ standard_model_check(model_check::handle(&ctx, args)) } "kicad_pcm_list" => pcm::list(&Ctx::from_info(&state.kicad_info()), args, &pcm::http_fetch), "kicad_pcm_install" => {
rust/crates/kicad-bridge/src/verbs_routing.rs+41@@ -205,6 +205,16 @@ pub static VERBS: &[Verb] = &[ related: &["kicad_add_track", "kicad_board_pads", "kicad_run_drc"], pitfalls: &["draws exactly the waypoints you give; it does not search for a path or avoid other nets", "for a visible demo, call kicad_route_net per segment"], },+ Verb {+ name: "kicad_cancel_run",+ summary: "Stop the bridge's own long job (the standard's cancel_run): a kicad_autoroute Freerouting job in the background, keeping what is done. Nothing else in KiCad can be interrupted from outside.",+ mechanism: Mechanism::File, risk: "write", timeout_sec: 15,+ input: "{\"runId\"?: \"autoroute-1-abcd1234\" (default the latest job running)}",+ example: "kicad_cancel_run {}",+ hint: "cancelRequested:true with runId and stage means Freerouting is being stopped; poll kicad_autoroute {job: runId}, which ends with errorCode cancelled. Nothing from the stopped run reaches the board. With nothing running, cancelRequested:false and reason, and it is still a success.",+ related: &["kicad_autoroute", "kicad_status"],+ pitfalls: &["A kicad_autoroute run without job:true holds the call, so there is no run to name."],+ }, Verb { name: "kicad_autoroute", summary: "Route a board with the engine the USER picked: \"ai\" (recommended) returns the plan request you drive with kicad_route_net; \"freerouting\" runs Freerouting headless (installed on demand) and lands its copper live as one undo step per net, or into the closed file.",@@ -250,6 +260,7 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> "kicad_add_track" => add_track(args), "kicad_add_via" => add_via(args), "kicad_route" => route(args),+ "kicad_cancel_run" => cancel_run(args), "kicad_autoroute" => autoroute(state, args), "kicad_freerouting" => freerouting_verb(args), _ => return None,@@ -1045,6 +1056,14 @@ fn freerouting_execute(state: &mut State, prep: Prepared, phase: &Phase, own_gui "unrouted": run.unrouted, "violations": run.violations, "exitCode": run.exit_code, "timedOut": run.timed_out, "stdoutTail": run.stdout_tail, "args": run.args, });+ if run.cancelled {+ let mut v = fail("cancelled", "Freerouting was stopped by kicad_cancel_run; nothing was applied", "The board is as it was. The DSN is kept in workDir for inspection.");+ v["engine"] = json!("freerouting");+ v["freerouting"] = fr;+ v["workDir"] = json!(norm_path(&work));+ v["mutated"] = json!(false);+ return v;+ } if run.timed_out { let mut v = fail("freerouting_timeout", format!("Freerouting did not finish within {timeout_sec} s and was stopped; nothing was applied"), "Raise timeoutSec, lower passes, or route fewer nets (nets:[...]). The DSN is kept in workDir for inspection."); v["engine"] = json!("freerouting");@@ -1377,6 +1396,28 @@ fn autoroute_job_start(args: &Value, dry_run: bool) -> Value { }) } +fn cancel_run(args: &Value) -> Value {+ let mut t = jobs();+ let now = Instant::now();+ t.expire(now);+ let wanted = arg_any(args, &["runId", "job"]).map(str::to_string);+ let running: Vec<(String, Value)> = t.ids().as_array().into_iter().flatten().filter_map(|j| j["job"].as_str().map(str::to_string)).filter_map(|id| t.snapshot(&id, now).map(|v| (id, v))).filter(|(_, v)| v["status"] == json!("running")).collect();+ let pick = match &wanted {+ Some(w) => running.iter().find(|(id, _)| id == w),+ None => running.last(),+ };+ match pick {+ Some((id, snap)) => {+ kicad_core::freerouting::request_cancel(id);+ json!({"success": true, "cancelRequested": true, "runId": id, "stage": snap["stepLabel"], "_hint": format!("Poll kicad_autoroute {{\"job\":\"{id}\"}}: it ends with errorCode cancelled, and nothing from this run reaches the board.")})+ }+ None => json!({"success": true, "cancelRequested": false, "runId": wanted, "reason": match &wanted {+ Some(w) => format!("{w} is not running."),+ None => "no job is running. Only kicad_autoroute's Freerouting jobs can be stopped: KiCad itself can't be interrupted from outside.".to_string(),+ }}),+ }+}+ /// `job:"<id>"`: the job's state. fn autoroute_job_poll(id: &str) -> Value { let mut t = jobs();
rust/crates/kicad-bridge/src/verbs_std_more.rs+275@@ -224,6 +224,46 @@ pub static VERBS: &[Verb] = &[ 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."], },+ Verb {+ name: "kicad_place_parts",+ summary: "Put footprints from a library onto a board, each with its position, rotation and side (the standard's place_parts), and read them back.",+ mechanism: Mechanism::File, risk: "write", timeout_sec: 300,+ input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"parts\": [{\"library\": \"Resistor_SMD\", \"footprint\": \"R_0603_1608Metric\", \"ref\"?: \"R12\" | \"R\", \"value\"?: \"10k\", \"x\": 120, \"y\": 95, \"rotation\"?: 0, \"side\"?: \"top|bottom\", \"nets\"?: {\"1\": \"GND\"}}], \"frame\"?: \"standard\", \"dryRun\"?: false, \"validate\"?: true, \"expectedRevision\"?}",+ example: "kicad_place_parts {\"filePath\":\"C:/p/b.kicad_pcb\",\"parts\":[{\"library\":\"Resistor_SMD\",\"footprint\":\"R_0603_1608Metric\",\"ref\":\"R12\",\"x\":120,\"y\":95}]}",+ hint: "results has ok or err:... per part, in order, and placed reads each one back from KiCad. One call to kicad_add_footprint per part. On a board with a schematic, parts normally arrive through kicad_update_pcb: a part placed here is on no sheet.",+ related: &["kicad_add_footprint", "kicad_update_pcb_from_schematic", "kicad_move_footprint", "kicad_routing_state"],+ pitfalls: &["On an open board each part is its own Undo step: KiCad's API takes them one at a time here.", "A failing part does not undo the ones before it; results says which landed."],+ },+ Verb {+ name: "kicad_edit_tracks",+ summary: "Change given tracks by their routing_state id: width, layer and ends (the standard's edit_tracks). One Undo step, read back.",+ mechanism: Mechanism::File, risk: "write", timeout_sec: 180,+ input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"tracks\": [{\"id\": \"<uuid>\", \"width\"?: 0.3, \"layer\"?: \"B.Cu\", \"start\"?: [x, y], \"end\"?: [x, y]}], \"dryRun\"?: false, \"expectedRevision\"?}",+ example: "kicad_edit_tracks {\"filePath\":\"C:/p/b.kicad_pcb\",\"tracks\":[{\"id\":\"<uuid>\",\"width\":0.5}]}",+ hint: "The ids are in kicad_routing_state: tracks[].id with model:\"standard\", segments[].uuid without. changes shows before and after, verified says each was read back, and results has ok per track. KiCad's DRC checks the change first and refuses new errors (drc_rejected, nothing changed).",+ related: &["kicad_modify_track", "kicad_routing_state", "kicad_set_track_width", "kicad_remove_items"],+ pitfalls: &["The board must be open in the PCB editor.", "Changing a track's layer does not add the vias its ends now need."],+ },+ Verb {+ name: "kicad_extensions",+ summary: "KiCad's plugins and add-ons through its Plugin and Content Manager (the standard's extensions): list what is installed, install one, remove one.",+ mechanism: Mechanism::File, risk: "write", timeout_sec: 300,+ input: "{\"action\"?: \"list|install|remove\", \"id\"?: \"com.vendor.plugin\", \"source\"?: \"https://.../repository.json\" | \"C:/.../package.zip\", \"version\"?: \"1.1.0\", \"checkUpdates\"?: true}",+ example: "kicad_extensions {\"action\":\"list\"}",+ hint: "extensions [{id, name, version, kind, updateAvailable, latestVersion}]. install takes a repository URL with id, or a local package zip; remove takes id. KiCad loads plugins when it starts, so restart it after a change.",+ related: &["kicad_pcm_list", "kicad_pcm_install", "kicad_pcm_uninstall", "kicad_pcm_search"],+ pitfalls: &["Not install_extension: KiCad needs no helper of the bridge's inside it."],+ },+ Verb {+ name: "kicad_preferences",+ summary: "Read KiCad's own settings, all or one by dotted path, or change them with set (the standard's preferences). KiCad rewrites its settings on exit, so set is refused while it runs.",+ mechanism: Mechanism::File, risk: "write", timeout_sec: 30,+ input: "{\"key\"?: \"pcbnew.editing.track_width_increment\" (<file>.<path>, default every file), \"set\"?: {\"kicad_common.appearance.show_scrollbars\": true}}",+ example: "kicad_preferences {\"key\":\"kicad_common.appearance\"}",+ hint: "The first part of a key names a JSON file in KiCad's settings folder (kicad, kicad_common, pcbnew, eeschema...), and the rest is the path inside it. current has what you asked for; applied has {ok, requested} per key. set makes a .bak of each file it changes and writes only existing files. With KiCad running, set answers tool_running with reason:\"tool_running\".",+ related: &["kicad_readiness", "kicad_ipc_api", "kicad_set_settings"],+ pitfalls: &["Keys come from KiCad's own files and change between versions: read first.", "kicad_settings is another job: the bridge's own settings."],+ }, ]; pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {@@ -244,6 +284,10 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> "kicad_schematic_data" => sheet_read(state, args, true), "kicad_pcb_report" => pcb_report(state, args), "kicad_instances" => instances(state),+ "kicad_place_parts" => place_parts(state, args),+ "kicad_edit_tracks" => edit_tracks(state, args),+ "kicad_extensions" => extensions(state, args),+ "kicad_preferences" => preferences(state, args), "kicad_ensure_licensed" => ensure_licensed(state), "kicad_signin_status" => signin_status(state), "kicad_setup_trial" => setup_trial(state),@@ -806,6 +850,218 @@ fn pcb_report(state: &mut State, args: &Value) -> Value { out } +fn place_parts(state: &mut State, args: &Value) -> Value {+ let Some(parts) = args.get("parts").and_then(Value::as_array).filter(|p| !p.is_empty()) else {+ return fail("missing_arg", "parts is required", "[{library, footprint, ref?, value?, x, y, rotation?, side?, nets?}]");+ };+ let mut rev = args.get("expectedRevision").cloned();+ let dry = args.get("dryRun") == Some(&json!(true));+ let mut q = json!({"model": "standard"});+ if let Some(f) = args.get("filePath") {+ q["filePath"] = f.clone();+ }+ // The standard's expectedRevision is optional and KiCad's live add wants one. The parts already there tell the+ // new ones apart afterwards, including a reference like "R" that KiCad numbers itself.+ let first = crate::verbs::call(state, "kicad_routing_state", &q);+ if rev.is_none() {+ rev = first.get("revision").filter(|r| r.is_string()).cloned();+ }+ let before: Vec<String> = first["components"].as_array().into_iter().flatten().filter_map(|c| c["ref"].as_str().map(str::to_string)).collect();+ let (mut results, mut placed) = (Vec::new(), Vec::new());+ let mut landed = false;+ for p in parts {+ let (lib, fp) = (p["library"].as_str().unwrap_or(""), p["footprint"].as_str().unwrap_or(""));+ let id = if fp.contains(':') || lib.is_empty() { fp.to_string() } else { format!("{lib}:{fp}") };+ if id.is_empty() || !p["x"].is_number() || !p["y"].is_number() {+ results.push(json!("err:needs library, footprint, x and y"));+ continue;+ }+ let side = match p["side"].as_str() { Some("bottom") | Some("B.Cu") => "B.Cu", _ => "F.Cu" };+ let mut a = json!({"libraryId": id, "x": p["x"], "y": p["y"], "side": side, "reference": p["ref"].as_str().unwrap_or("REF"), "dryRun": dry});+ for (k, v) in [("filePath", args.get("filePath")), ("frame", args.get("frame")), ("validate", args.get("validate")), ("rotation", p.get("rotation")), ("value", p.get("value")), ("nets", p.get("nets")), ("expectedRevision", rev.as_ref())] {+ if let Some(v) = v.filter(|v| !v.is_null()) {+ a[k] = v.clone();+ }+ }+ let r = crate::verbs::call(state, "kicad_add_footprint", &a);+ if r["success"] == json!(true) {+ results.push(json!("ok"));+ if let Some(n) = r.get("revision").filter(|n| n.is_string()) {+ rev = Some(n.clone());+ }+ landed = true;+ } else {+ results.push(json!(format!("err:{}", r["error"].as_str().unwrap_or("not placed"))));+ }+ }+ if !dry && landed {+ let st = crate::verbs::call(state, "kicad_routing_state", &q);+ for c in st["components"].as_array().into_iter().flatten().filter(|c| c["ref"].as_str().is_some_and(|r| !before.iter().any(|n| n == r))) {+ placed.push(json!({"ref": c["ref"], "x": c["x"], "y": c["y"], "rotation": c["rotation"], "side": c["side"]}));+ }+ }+ let ok = results.iter().all(|r| r == "ok");+ let mut out = json!({"success": ok, "results": results, "placed": placed, "mutated": landed && !dry, "dryRun": dry, "revision": rev, "frame": "standard: placed reads y up"});+ if !ok {+ out["errorCode"] = json!("part_failed");+ out["error"] = json!("not every part was placed: results says which");+ out["_hint"] = json!("The parts that landed stay; fix the others and call again with only those.");+ }+ out+}++fn edit_tracks(state: &mut State, args: &Value) -> Value {+ let Some(tracks) = args.get("tracks").and_then(Value::as_array).filter(|t| !t.is_empty()) else {+ return fail("missing_arg", "tracks is required", "[{id, width?, layer?, start?, end?}]; the ids come from kicad_routing_state.");+ };+ let mut a = args.clone();+ a["changes"] = json!(tracks);+ if let Some(o) = a.as_object_mut() {+ o.remove("tracks");+ }+ let mut r = crate::verbs::call(state, "kicad_modify_track", &a);+ if r["success"] == json!(true) {+ r["results"] = json!(tracks.iter().map(|_| json!("ok")).collect::<Vec<_>>());+ }+ r+}++fn extensions(state: &mut State, args: &Value) -> Value {+ let action = arg_str(args, "action").unwrap_or("list");+ match action {+ "list" => {+ let mut a = json!({});+ if let Some(c) = args.get("checkUpdates") {+ a["checkUpdates"] = c.clone();+ }+ let r = crate::verbs::call(state, "kicad_pcm_list", &a);+ if r["success"] != json!(true) {+ return r;+ }+ let rows: Vec<Value> = r["packages"].as_array().into_iter().flatten().map(|p| json!({"id": p["identifier"], "name": p["name"], "version": p["version"], "kind": p["type"], "updateAvailable": p["updateAvailable"], "latestVersion": p["latestVersion"]})).collect();+ json!({"success": true, "count": rows.len(), "extensions": rows})+ }+ "install" => {+ let Some(src) = arg_str(args, "source") else {+ return fail("missing_arg", "source is required to install", "A repository.json URL with id, or a local package .zip.");+ };+ let mut a = if src.to_ascii_lowercase().ends_with(".zip") && !src.contains("://") { json!({"zipPath": src}) } else { json!({"repositoryUrl": src, "identifier": arg_str(args, "id")}) };+ if let Some(v) = args.get("version") {+ a["version"] = v.clone();+ }+ let mut r = crate::verbs::call(state, "kicad_pcm_install", &a);+ if r["success"] == json!(true) {+ r["installed"] = json!({"id": r.get("identifier").cloned().or_else(|| args.get("id").cloned()), "version": r.get("version")});+ }+ r+ }+ "remove" => {+ let Some(id) = arg_str(args, "id") else {+ return fail("missing_arg", "id is required to remove", "kicad_extensions {action:\"list\"} names them.");+ };+ let mut r = crate::verbs::call(state, "kicad_pcm_uninstall", &json!({"identifier": id}));+ if r["success"] == json!(true) {+ r["removed"] = json!(id);+ }+ r+ }+ other => fail("bad_args", format!("no action {other}"), "action is list, install or remove."),+ }+}++/// `dir`'s settings as {file stem: contents}, or just the ones a dotted key reaches.+fn read_preference(dir: &Path, key: Option<&str>) -> Result<Value, String> {+ match key {+ None => {+ let mut all = serde_json::Map::new();+ for e in std::fs::read_dir(dir).map_err(|e| e.to_string())?.flatten() {+ let p = e.path();+ if ext(&p) == "json" {+ if let (Some(stem), Some(v)) = (p.file_stem().map(|s| s.to_string_lossy().to_string()), std::fs::read_to_string(&p).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok())) {+ all.insert(stem, v);+ }+ }+ }+ Ok(Value::Object(all))+ }+ Some(k) => {+ let (file, path) = preference_key(k)?;+ let text = std::fs::read_to_string(dir.join(format!("{file}.json"))).map_err(|_| format!("KiCad has no settings file {file}.json"))?;+ let mut v: Value = serde_json::from_str(&text).map_err(|e| format!("{file}.json: {e}"))?;+ for seg in &path {+ v = v.get(*seg).cloned().ok_or_else(|| format!("{k}: no {seg} there"))?;+ }+ Ok(json!({ k: v }))+ }+ }+}++/// `pcbnew.editing.width` into its file and its path; the file part is a plain name, never a path.+fn preference_key(key: &str) -> Result<(&str, Vec<&str>), String> {+ let mut it = key.split('.');+ let file = it.next().unwrap_or("");+ if file.is_empty() || !file.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {+ return Err(format!("{key}: the first part must name a settings file, such as pcbnew"));+ }+ Ok((file, it.collect()))+}++/// Apply set: each key to an existing file, with a .bak; a key's parents must already exist.+fn write_preferences(dir: &Path, set: &serde_json::Map<String, Value>) -> Value {+ let mut applied = serde_json::Map::new();+ for (k, requested) in set {+ let done = (|| -> Result<(), String> {+ let (file, path) = preference_key(k)?;+ if path.is_empty() {+ return Err("name a setting inside the file".into());+ }+ let p = dir.join(format!("{file}.json"));+ let text = std::fs::read_to_string(&p).map_err(|_| format!("KiCad has no settings file {file}.json"))?;+ let mut root: Value = serde_json::from_str(&text).map_err(|e| e.to_string())?;+ let mut at = &mut root;+ for seg in &path[..path.len() - 1] {+ at = at.get_mut(*seg).ok_or_else(|| format!("no {seg} there"))?;+ }+ let o = at.as_object_mut().ok_or("the parent is not a group of settings")?;+ o.insert(path[path.len() - 1].to_string(), requested.clone());+ std::fs::write(p.with_extension("json.bak"), &text).map_err(|e| e.to_string())?;+ std::fs::write(&p, serde_json::to_string_pretty(&root).map_err(|e| e.to_string())?).map_err(|e| e.to_string())+ })();+ applied.insert(k.clone(), match done {+ Ok(()) => json!({"ok": true, "requested": requested}),+ Err(e) => json!({"ok": false, "requested": requested, "error": e}),+ });+ }+ Value::Object(applied)+}++fn preferences(state: &mut State, args: &Value) -> Value {+ let info = state.kicad_info();+ let Some(dir) = info.primary().and_then(|p| kicad_core::detect::config_dir(&p.version)) else {+ return fail("not_installed", "KiCad's settings folder was not found", "kicad_readiness says whether KiCad is installed; it writes its settings the first time it runs.");+ };+ if let Some(set) = args.get("set").and_then(Value::as_object) {+ if kicad_running() {+ let mut v = fail("tool_running", "KiCad rewrites its settings when it exits, so set is refused while it runs", "kicad_close, then call again.");+ v["reason"] = json!("tool_running");+ return v;+ }+ let applied = write_preferences(&dir, set);+ let keys: Vec<String> = set.keys().cloned().collect();+ let mut current = serde_json::Map::new();+ for k in keys {+ if let Ok(Value::Object(m)) = read_preference(&dir, Some(&k)) {+ current.extend(m);+ }+ }+ return json!({"success": true, "applied": applied, "current": current, "mutated": true});+ }+ match read_preference(&dir, arg_str(args, "key")) {+ Ok(current) => json!({"success": true, "current": current, "folder": norm(&dir)}),+ Err(e) => fail("key_not_found", e, "Call with no key to see every file and setting."),+ }+}+ fn kicad_running() -> bool { kicad_platform::native().processes(&["kicad.exe", "pcbnew.exe", "eeschema.exe"]).map(|p| !p.is_empty()).unwrap_or(false) }@@ -1152,6 +1408,25 @@ mod tests { let _ = std::fs::remove_dir_all(&d); } + #[test]+ fn preferences_read_by_path_and_write_only_what_exists() {+ let d = std::env::temp_dir().join(format!("kb-prefs-{}", std::process::id()));+ let _ = std::fs::remove_dir_all(&d);+ std::fs::create_dir_all(&d).unwrap();+ std::fs::write(d.join("pcbnew.json"), r#"{"editing":{"width":1,"snap":true}}"#).unwrap();+ assert_eq!(read_preference(&d, Some("pcbnew.editing.width")).unwrap()["pcbnew.editing.width"], 1);+ assert_eq!(read_preference(&d, None).unwrap()["pcbnew"]["editing"]["snap"], true);+ assert!(read_preference(&d, Some("pcbnew.nope")).is_err());+ assert!(read_preference(&d, Some("../secret.x")).is_err(), "a key never leaves the folder");+ let set = json!({"pcbnew.editing.width": 2, "pcbnew.nope.x": 3, "absent.a.b": 4}).as_object().cloned().unwrap();+ let a = write_preferences(&d, &set);+ assert_eq!((a["pcbnew.editing.width"]["ok"].as_bool(), a["pcbnew.nope.x"]["ok"].as_bool(), a["absent.a.b"]["ok"].as_bool()), (Some(true), Some(false), Some(false)), "{a}");+ assert_eq!(read_preference(&d, Some("pcbnew.editing.width")).unwrap()["pcbnew.editing.width"], 2);+ assert!(d.join("pcbnew.json.bak").is_file());+ assert!(!d.join("absent.json").exists());+ 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();
rust/crates/kicad-bridge/src/verbs_unsupported.rs+11@@ -10,6 +10,7 @@ use crate::util::*; /// (verb, why KiCad has no form of it, what to call instead). const WHY: &[(&str, &str, &str)] = &[ ("kicad_import_step", "KiCad places 3D models only as a footprint's own model: a board has no free 3D geometry of its own, so an enclosure, bracket or mating part cannot be placed with it as a STEP.", "kicad_add_body (the STEP as a footprint's model, e.g. on a pad-less mechanical footprint placed at the part's origin), or kicad_export_step and the fit check in an MCAD tool"),+ ("kicad_physical_properties", "KiCad has no materials or masses on a part, so it has no volume, mass or centre of mass to report.", "kicad_bodies (a part's 3D bodies and sizes)"), ("kicad_license", "KiCad is GPL-3.0-or-later and has no licence page to drive.", "kicad_license_status"), ("kicad_signin", "KiCad has no account to sign in to.", "kicad_license_status"), ("kicad_connect_workspace", "KiCad has no cloud workspace to connect to.", "kicad_install_library (KiCad's libraries are local files and PCM packages)"),@@ -24,6 +25,16 @@ const WHY: &[(&str, &str, &str)] = &[ ]; pub static VERBS: &[Verb] = &[+ Verb {+ name: "kicad_physical_properties",+ summary: "Volume, mass and centre of mass per part (the standard's physical_properties). Not in KiCad: it has no materials or masses on a part.",+ mechanism: Mechanism::Local, risk: "read", timeout_sec: 5,+ input: "{}",+ example: "kicad_physical_properties {}",+ hint: "Answers not_supported with why and instead: kicad_bodies gives a part's 3D bodies and sizes.",+ related: &["kicad_bodies", "kicad_model_check"],+ pitfalls: &[],+ }, Verb { name: "kicad_import_step", summary: "Place a STEP as free 3D geometry with the board (the standard's import_step). Not in KiCad: KiCad places 3D models only as a footprint's own model.",
rust/crates/kicad-core/src/board_model.rs+20−4@@ -289,6 +289,8 @@ struct Track { layer: String, net: String, owner: Option<String>,+ /// the segment's uuid: the handle edits take. Not part of the revision.+ id: Option<String>, } #[derive(Clone, Debug)]@@ -472,7 +474,7 @@ fn model(root: &Sx) -> Model { Some("segment") => { let (Some(a), Some(b)) = (xy_of(item.find("start")), xy_of(item.find("end"))) else { continue }; let layer = item.find("layer").and_then(Sx::value).unwrap_or("").to_string();- m.tracks.push(Track { a, b, width: item.find("width").and_then(|w| num(w, 1)).unwrap_or(0.0), layer, net: cx.net(item), owner: None });+ m.tracks.push(Track { a, b, width: item.find("width").and_then(|w| num(w, 1)).unwrap_or(0.0), layer, net: cx.net(item), owner: None, id: item.find("uuid").and_then(Sx::value).map(str::to_string) }); } Some("arc") => { let (Some(s), Some(mid), Some(e)) = (xy_of(item.find("start")), xy_of(item.find("mid")), xy_of(item.find("end"))) else { continue };@@ -480,7 +482,7 @@ fn model(root: &Sx) -> Model { let layer = item.find("layer").and_then(Sx::value).unwrap_or("").to_string(); match arc_of(s, mid, e) { Some(arc) => m.arcs.push(ArcT { arc, width, layer, net: cx.net(item), owner: None }),- None => m.tracks.push(Track { a: s, b: e, width, layer, net: cx.net(item), owner: None }),+ None => m.tracks.push(Track { a: s, b: e, width, layer, net: cx.net(item), owner: None, id: None }), } } Some("via") => {@@ -759,7 +761,7 @@ fn graphic(cx: &Ctx, g: &Sx, place: Place, owner: Option<&str>, m: &mut Model, e return; } for (a, b) in lines {- m.tracks.push(Track { a, b, width, layer: layer.to_string(), net: net.clone(), owner: owner_s.clone() });+ m.tracks.push(Track { a, b, width, layer: layer.to_string(), net: net.clone(), owner: owner_s.clone(), id: None }); } for a in arcs { m.arcs.push(ArcT { arc: a, width, layer: layer.to_string(), net: net.clone(), owner: owner_s.clone() });@@ -1505,6 +1507,20 @@ fn with_owner(mut v: Value, owner: &Option<String>) -> Value { v } +/// tracks_json with each free segment's id, which edit_tracks and kicad_modify_track take. The revision reads+/// tracks_json, so an id changes no revision.+fn tracks_with_ids(m: &Model, o: &Opts) -> Value {+ let mut v = tracks_json(m, o);+ if let Some(rows) = v.as_array_mut() {+ for (row, t) in rows.iter_mut().zip(&m.tracks) {+ if let Some(id) = &t.id {+ row["id"] = json!(id);+ }+ }+ }+ v+}+ fn tracks_json(m: &Model, o: &Opts) -> Value { Value::Array(m.tracks.iter().map(|t| with_owner(json!({"start": pj(t.a, o), "end": pj(t.b, o), "width": r6(t.width), "layer": t.layer, "net": t.net}), &t.owner)).collect()) }@@ -1612,7 +1628,7 @@ fn to_json(m: &Model, file: Option<&str>, revision: &str, o: &Opts) -> Value { "unconnected": conn.unconnected, "splitNets": conn.split, "nets": nets.iter().collect::<Vec<_>>(), "copperLayers": m.copper,- "tracks": tracks_json(m, o), "arcs": arcs_json(m, o), "vias": vias_json(m, o), "pads": pads_json(m, o),+ "tracks": tracks_with_ids(m, o), "arcs": arcs_json(m, o), "vias": vias_json(m, o), "pads": pads_json(m, o), "components": comps_json(m, o), "polygons": polys_json(m, o, true), "outline": outer.iter().map(|e| pa(e.a, o)).collect::<Vec<_>>(), "outlineEdges": edges_json(outer, o),
rust/crates/kicad-core/src/freerouting.rs+31−1@@ -347,6 +347,8 @@ pub struct RunOutcome { pub stdout_tail: Vec<String>, pub exit_code: i32, pub timed_out: bool,+ /// stopped by kicad_cancel_run+ pub cancelled: bool, pub version: Option<String>, pub args: Vec<String>, }@@ -379,6 +381,26 @@ pub fn parse_score(line: &str) -> Option<(u32, u32)> { Some((unrouted.parse().ok()?, violations.parse().ok()?)) } +static CANCEL_JOBS: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());++/// kicad_cancel_run: ask the job running on the thread named `kicad-<job>` to stop.+pub fn request_cancel(job: &str) {+ CANCEL_JOBS.lock().unwrap_or_else(|e| e.into_inner()).push(format!("kicad-{job}"));+}++/// True once this thread's job has been asked to stop; the request is used up here.+fn cancel_requested() -> bool {+ let name = std::thread::current().name().unwrap_or("").to_string();+ let mut jobs = CANCEL_JOBS.lock().unwrap_or_else(|e| e.into_inner());+ match jobs.iter().position(|j| *j == name) {+ Some(i) => {+ jobs.remove(i);+ true+ }+ None => false,+ }+}+ /// Run Freerouting headless: `-de <dsn> -do <ses> -mp <passes> [-mt <threads>]` plus the /// headless flags, working directory `work`, killed at `timeout`. `on_line` sees every /// stdout line as it arrives (the caller turns pass lines into progress).@@ -439,6 +461,7 @@ pub fn run(exe: &Path, dsn_path: &Path, ses_path: &Path, work: &Path, passes: u3 }; let exit_code; let mut timed_out = false;+ let mut cancelled = false; loop { match rx.recv_timeout(Duration::from_millis(100)) { Ok(line) => push(line, &mut tail),@@ -467,11 +490,18 @@ pub fn run(exe: &Path, dsn_path: &Path, ses_path: &Path, work: &Path, passes: u3 exit_code = -2; break; }+ if cancel_requested() {+ let _ = child.kill();+ let _ = child.wait();+ cancelled = true;+ exit_code = -3;+ break;+ } } for r in readers { let _ = r.join(); }- Ok(RunOutcome { passes_seen, unrouted: score.map(|s| s.0), violations: score.map(|s| s.1), seconds: (t0.elapsed().as_secs_f64() * 100.0).round() / 100.0, stdout_tail: tail.into_iter().collect(), exit_code, timed_out, version, args })+ Ok(RunOutcome { cancelled, passes_seen, unrouted: score.map(|s| s.0), violations: score.map(|s| s.1), seconds: (t0.elapsed().as_secs_f64() * 100.0).round() / 100.0, stdout_tail: tail.into_iter().collect(), exit_code, timed_out, version, args }) } // ---------------------------------------------------------------------------
rust/crates/kicad-core/src/pcb.rs+11@@ -425,6 +425,17 @@ fn extract_footprints(tree: &Sx, name_based: bool) -> Vec<Footprint> { props.insert(k.to_string(), v.to_string()); } }+ // KiCad 7 and older keep them as (fp_text reference "R1") and (fp_text value "10k")+ for t in node.find_all("fp_text") {+ let key = match t.atom_at(1) {+ Some("reference") => "Reference",+ Some("value") => "Value",+ _ => continue,+ };+ if let Some(v) = t.atom_at(2) {+ props.entry(key.to_string()).or_insert_with(|| v.to_string());+ }+ } let pads: Vec<FpPad> = node .find_all("pad") .filter_map(|pad| {
rust/crates/kicad-core/src/pcb_sync.rs+99−22@@ -170,30 +170,79 @@ pub fn child_spans(text: &str, span: (usize, usize)) -> Vec<(usize, usize)> { top_level_spans(inner).into_iter().map(|(a, b)| (a + span.0, b + span.0)).collect() } -fn set_property(block: &str, key: &str, value: &str) -> String {- let head = format!("(property {} ", q(key));- if let Some(i) = block.find(&head) {- let start = i + head.len();- if block[start..].starts_with('"') {- // the quoted value: find its closing quote, escapes respected- let mut j = start + 1;- let b = block.as_bytes();- while j < b.len() {- if b[j] == b'\\' {- j += 2;- continue;- }- if b[j] == b'"' {- break;- }- j += 1;+/// One s-expression token at `i` (quoted, escapes respected, or bare): its end.+fn token_end(block: &str, i: usize) -> usize {+ let b = block.as_bytes();+ if b.get(i) == Some(&b'"') {+ let mut j = i + 1;+ while j < b.len() {+ match b[j] {+ b'\\' => j += 2,+ b'"' => return j + 1,+ _ => j += 1, }- let mut out = block.to_string();- out.replace_range(start..=j.min(block.len() - 1), &q(value));- return out; }+ return b.len(); }- block.to_string()+ let mut j = i;+ while j < b.len() && !b[j].is_ascii_whitespace() && b[j] != b'(' && b[j] != b')' {+ j += 1;+ }+ j+}++fn skip_space(block: &str, mut i: usize) -> usize {+ let b = block.as_bytes();+ while i < b.len() && b[i].is_ascii_whitespace() {+ i += 1;+ }+ i+}++/// Where `open` (a list head such as `(property`) starts a list, however it is spaced: the positions after it.+fn list_heads<'a>(block: &'a str, open: &'a str) -> impl Iterator<Item = (usize, usize)> + 'a {+ block.match_indices(open).filter_map(move |(i, _)| {+ let after = i + open.len();+ block.as_bytes().get(after).filter(|c| c.is_ascii_whitespace()).map(|_| (i, after))+ })+}++/// A footprint's Reference or Value, whichever way its library wrote it (wiki #138: library files spaced or broken+/// over lines differently from KiCad's own, and KiCad 5/6's `fp_text`, kept their REF**). A footprint with neither+/// gets the property. Rewritten as KiCad writes it, because `board_only` and other readers look for that spelling.+fn set_property(block: &str, key: &str, value: &str) -> String {+ let unquote = |t: &str| t.trim_matches('"').to_string();+ for (start, after) in list_heads(block, "(property") {+ let k0 = skip_space(block, after);+ let k1 = token_end(block, k0);+ if unquote(&block[k0..k1]) != key {+ continue;+ }+ let v0 = skip_space(block, k1);+ let v1 = token_end(block, v0);+ let mut out = block.to_string();+ out.replace_range(start..v1, &format!("(property {} {}", q(key), q(value)));+ return out;+ }+ let legacy = key.to_ascii_lowercase();+ for (_, after) in list_heads(block, "(fp_text") {+ let k0 = skip_space(block, after);+ let k1 = token_end(block, k0);+ if block[k0..k1] != legacy {+ continue;+ }+ let v0 = skip_space(block, k1);+ let v1 = token_end(block, v0);+ let mut out = block.to_string();+ out.replace_range(v0..v1, &q(value));+ return out;+ }+ let layer = if key == "Reference" { "F.SilkS" } else { "F.Fab" };+ let prop = format!("(property {} {} (at 0 0 0) (layer {}) (uuid {}) (effects (font (size 1 1) (thickness 0.15))))\n\t\t", q(key), q(value), q(layer), q(&new_uuid()));+ let mut out = block.to_string();+ let at = list_heads(block, "(fp_").chain(list_heads(block, "(pad")).map(|(i, _)| i).min().or_else(|| block.rfind(')')).unwrap_or(block.len());+ out.insert_str(at, &prop);+ out } fn net_clause(fmt: NetFormat, number: Option<i64>, name: &str) -> String {@@ -245,7 +294,7 @@ fn add_angle(block: &str, rot: f64) -> String { return block.to_string(); } let mut out = block.to_string();- let targets: Vec<(usize, usize)> = child_spans(&out, (0, out.len())).into_iter().filter(|(s, _)| ["(pad ", "(property ", "(fp_text "].iter().any(|h| out[*s..].starts_with(h))).collect();+ let targets: Vec<(usize, usize)> = child_spans(&out, (0, out.len())).into_iter().filter(|(s, _)| ["(pad", "(property", "(fp_text"].iter().any(|h| out[*s..].starts_with(h) && out[*s + h.len()..].starts_with(|c: char| c.is_whitespace()))).collect(); for (s, e) in targets.into_iter().rev() { let item = out[s..e].to_string(); let Some(at) = child_spans(&item, (0, item.len())).into_iter().find(|(a, _)| item[*a..].starts_with("(at ")) else { continue };@@ -323,6 +372,10 @@ pub fn instantiate(module: &str, lib_id: &str, reference: &str, value: &str, at: let mut numbers_seen = Vec::new(); let parsed = pcb::parse_pcb_text(&format!("(kicad_pcb\n\t{block}\n)"), "lib").map_err(|e| RoutingError::new("library_unparsable", format!("{lib_id}: {e}")))?; if let Some(fp) = parsed.footprints.first() {+ // Wiki #138: never hand back a part that still answers to its library's placeholder.+ if fp.reference != reference {+ return Err(RoutingError::new("reference_not_set", format!("{lib_id}: the footprint reads as {:?} after setting its reference to {reference}", fp.reference)));+ } for p in &fp.pads { if p.number.is_empty() || numbers_seen.contains(&p.number) { continue;@@ -520,6 +573,30 @@ pub fn apply(text: &str, board: &Board, nl: &Netlist, opts: &ApplyOpts, module: mod tests { use super::*; + #[test]+ fn a_reference_is_set_however_the_library_spaced_it() {+ // wiki #138: each of these kept REF** and then read as a missing part+ let pad = "(pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\"))";+ for (name, field) in [+ ("KiCad's own", "(property \"Reference\" \"REF**\" (at 0 0 0) (layer \"F.SilkS\"))"),+ ("two spaces", "(property \"Reference\" \"REF**\" (at 0 0 0) (layer \"F.SilkS\"))"),+ ("value on its own line", "(property \"Reference\"\n\t\t\"REF**\"\n\t\t(at 0 0 0) (layer \"F.SilkS\"))"),+ ("CRLF", "(property \"Reference\"\r\n\t\"REF**\"\r\n\t(at 0 0 0) (layer \"F.SilkS\"))"),+ ("each token on a line", "(property\n\t\t\"Reference\"\n\t\t\"REF**\"\n\t\t(at 0 0 0) (layer \"F.SilkS\"))"),+ ("bare words", "(property Reference REF** (at 0 0 0) (layer \"F.SilkS\"))"),+ ("KiCad 6's fp_text", "(fp_text reference \"REF**\" (at 0 0) (layer \"F.SilkS\"))"),+ ("KiCad 5's fp_text, bare", "(fp_text reference REF** (at 0 0) (layer F.SilkS))"),+ ("no Reference at all", ""),+ ] {+ let module = format!("(footprint \"CR0402-FX-1002GLF\" (layer \"F.Cu\") {field} (property \"Value\" \"X\" (at 0 1 0) (layer \"F.Fab\")) {pad})");+ let b = instantiate(&module, "PCM_lib:CR0402-FX-1002GLF", "R15", "10k", (1.0, 2.0), 0.0, "F.Cu", "u-1", Some("/abc"), &|_| None, NetFormat::Name, &BTreeMap::new()).unwrap_or_else(|e| panic!("{name}: {}", e.message));+ assert!(!b.contains("REF**"), "{name}: {b}");+ let parsed = pcb::parse_pcb_text(&format!("(kicad_pcb\n{b}\n)"), "t").unwrap();+ assert_eq!(parsed.footprints[0].reference, "R15", "{name}: {b}");+ assert_eq!(parsed.footprints[0].value, "10k", "{name}: {b}");+ }+ }+ #[test] fn an_escaped_board_net_is_the_netlists_plain_name() { // pic_programmer: the label VPP/MCLR is /VPP{slash}MCLR on the board and /VPP/MCLR in the netlist
skills/kicad-bridge-test/edit_drill.py+90−3@@ -15,6 +15,7 @@ import json import pathlib import subprocess import sys+import tempfile import time AI_THREAD = "kicad-lane"@@ -140,6 +141,26 @@ def sheet_part(d, base): nc2 = items("noConnects").get("noConnects") or [] d.check("sch_no_connect", "remove by id takes it away", r, not nc2, nc2) + # the standard's shape of the same verbs: sheet and id for filePath and uuid, remove on sch_label+ r = d.call("sch_label", {"sheet": sch, "text": "STD1", "at": [57.15, 54.61]})+ n = nets()+ d.check("sch_label", "standard args: the net is STD1", r, any("STD1" in k for k in n), list(n)[:8])+ r = d.call("sch_label", {"sheet": sch, "text": "STD1", "at": [57.15, 54.61], "remove": True})+ n = nets()+ d.check("sch_label", "standard args: remove takes it away", r, not any("STD1" in k for k in n), list(n)[:8])+ r = d.call("sch_no_connect", {"sheet": sch, "at": [76.2, 46.99]})+ nc = items("noConnects").get("noConnects") or []+ if nc:+ r2 = d.call("sch_no_connect", {"sheet": sch, "id": nc[0].get("id"), "remove": True})+ d.check("sch_no_connect", "standard args: a point, then remove by id", r2, len(nc) == 1 and not items("noConnects").get("noConnects"), nc)+ else:+ d.check("sch_no_connect", "standard args: a marker at a point", r, False, nc)+ w0 = len(items("wires").get("wires") or [])+ r = d.call("sch_wire", {"sheet": sch, "points": [[30.48, 25.4], [35.56, 25.4]]})+ d.check("sch_wire", "standard args: one more wire", r, len(items("wires").get("wires") or []) == w0 + 1, w0)+ wid = (items("wires").get("wires") or [{}])[-1].get("id")+ d.call("sch_delete_wire", {"filePath": sch, "from": [30.48, 25.4], "to": [35.56, 25.4]})+ 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)@@ -293,9 +314,52 @@ def file_part(d, base): 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")}) + # wiki #138: a library footprint with its reference broken over lines kept REF** and read as missing+ odd = pathlib.Path(tempfile.mkdtemp()) / "R_0603_1608Metric.kicad_mod"+ odd.write_text((ROUTE_FIXTURE / "Fixture.pretty/R_0603_1608Metric.kicad_mod").read_text().replace('(property "Reference" "REF**"', '(property "Reference"\n\t\t"REF**"'))+ d.call("send_files", {"filePaths": [str(odd)], "dest": bdir + "/Fixture.pretty"}, 200) 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")})+ d.check("update_pcb_from_schematic", "execute: a second compare says in sync, R2 has its reference", r, r.get("executed") is True and (p.get("plan") or {}).get("inSync") is True and part(bdir + "/route-fixture.kicad_pcb", "R2"), {"exec": r.get("error"), "after": (p.get("plan") or {}).get("changeCount"), "changes": p.get("changeOrder")})+++def cancel_part(d, base):+ print("[cancel] stop a Freerouting job")+ bdir = base + "/cancel"+ d.call("send_files", {"filePaths": [str(LIVE_BOARD)], "dest": bdir}, 200)+ b = bdir + "/esc-g431-unrouted.kicad_pcb"+ rev0 = d.call("routing_state", {"filePath": b, "source": "file", "model": "standard"}).get("revision")+ j = d.call("autoroute", {"filePath": b, "engine": "freerouting", "job": True, "passes": 50}, 120)+ if j.get("errorCode") == "not_installed" or "not installed" in str(j.get("error")):+ print(" SKIP cancel_run Freerouting is not installed here, so a real stop is NOT proven")+ return+ if j.get("success") is not True:+ d.check("autoroute", "a job started", j, False, j.get("error"))+ return+ for _ in range(20):+ p = d.call("autoroute", {"job": j["job"]})+ if "freerouting" in str(p.get("stepLabel")).lower() or p.get("status") != "running":+ break+ time.sleep(3)+ c = d.call("cancel_run", {"runId": j["job"]})+ d.check("cancel_run", "the running job takes the request", c, c.get("cancelRequested") is True and c.get("runId") == j["job"], {k: c.get(k) for k in ("cancelRequested", "stage", "reason")})+ p = {}+ for _ in range(40):+ p = d.call("autoroute", {"job": j["job"]})+ if p.get("status") != "running":+ break+ time.sleep(3)+ rev1 = d.call("routing_state", {"filePath": b, "source": "file", "model": "standard"}).get("revision")+ d.check("cancel_run", "the job ended cancelled, the board unchanged", p, p.get("errorCode") == "cancelled" and rev0 == rev1, {"status": p.get("status"), "code": p.get("errorCode"), "same": rev0 == rev1})+++def prefs_part(d, base):+ print("[prefs] KiCad closed: a setting really changes, then back")+ d.call("close", {"discardChanges": True}, 120)+ key = "kicad_common.adom_drill_marker"+ r = d.call("preferences", {"set": {"kicad_common.adom_drill_marker": 41}})+ g = d.call("preferences", {"key": key})+ d.check("preferences", "set reads back", r, (g.get("current") or {}).get(key) == 41, r.get("applied")) def live_part(d, base):@@ -404,6 +468,29 @@ def live_part(d, base): 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")]) + g1 = [t for t in state().get("tracks") or [] if t.get("net") == "GND"]+ if g1:+ tid = g1[0].get("id") or g1[0].get("uuid")+ r = d.call("edit_tracks", {"filePath": b, "expectedRevision": rev[0], "validate": False, "tracks": [{"id": tid, "width": 0.33}]})+ g2 = next((t for t in state().get("tracks") or [] if (t.get("id") or t.get("uuid")) == tid), {})+ d.check("edit_tracks", "live: the track is 0.33 wide", r, abs((g2.get("width") or 0) - 0.33) < 0.001, [g2.get("width"), r.get("errorCode"), r.get("error")])+ r = d.call("place_parts", {"filePath": b, "expectedRevision": rev[0], "parts": [{"library": "Resistor_SMD", "footprint": "R_0603_1608Metric", "ref": "R900", "x": 130, "y": 60, "rotation": 90}, {"library": "Resistor_SMD", "footprint": "R_0603_1608Metric", "ref": "R901", "x": 134, "y": 60, "side": "bottom"}], "validate": False}, 300)+ a0, a1 = comp("R900"), comp("R901")+ d.check("place_parts", "live: both parts landed, position, rotation and side", r, near([a0.get("x"), a0.get("y")], [130, -60], 0.01) and abs((a0.get("rotation") or 0) - 90) < 0.01 and a1.get("side") == "bottom", [a0.get("x"), a0.get("y"), a0.get("rotation"), a1.get("side"), r.get("results")])+ r = d.call("place_parts", {"filePath": b, "parts": [{"library": "Resistor_SMD", "footprint": "R_0603_1608Metric", "ref": "TP", "x": 138, "y": 60}], "validate": False}, 300)+ got = (r.get("placed") or [{}])[0]+ d.check("place_parts", "live: a reference KiCad numbers itself reads back", r, str(got.get("ref")).startswith("TP") and near([got.get("x"), got.get("y")], [138, -60], 0.01), r.get("placed"))+ r = d.call("select_items", {"filePath": b, "refs": ["R900", "NOPE9"]})+ g = d.call("get_selection", {"filePath": b})+ d.check("select_items", "live: R900 selected, NOPE9 named as failed", r, "R900" in (g.get("references") or []) and (r.get("failed") or [{}])[0].get("ref") == "NOPE9" and r.get("verified") is False, [g.get("references"), r.get("failed"), r.get("verified")])+ r = d.call("select_items", {"filePath": b, "clear": True})+ g = d.call("get_selection", {"filePath": b})+ d.check("select_items", "live: clear empties the selection", r, not g.get("references"), g.get("references"))+ r = d.call("select_items", {"filePath": b, "where": "sheet", "refs": ["R1"]})+ d.check("select_items", "where:sheet is refused by name", r, r.get("errorCode") == "not_supported", r.get("errorCode"))+ r = d.call("model_check", {"filePath": b})+ d.check("model_check", "per part: found and geometry", r, r.get("checked", 0) > 3 and all("found" in m and "hasGeometry" in m for q in r.get("parts") or [] for m in q.get("models") or []) and any(q.get("models") for q in r.get("parts") or []), {"checked": r.get("checked"), "ok": r.get("ok"), "error": r.get("error")})+ state() 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")])@@ -426,7 +513,7 @@ def live_part(d, base): def main(): ap = argparse.ArgumentParser() ap.add_argument("--target", required=True)- ap.add_argument("--part", default="sheet,library,file,live")+ ap.add_argument("--part", default="sheet,library,file,cancel,live") ap.add_argument("--out") a = ap.parse_args() d = Drill(a.target)@@ -441,7 +528,7 @@ def main(): 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}+ parts = {"sheet": sheet_part, "library": library_part, "file": file_part, "live": live_part, "cancel": cancel_part, "prefs": prefs_part} for name in a.part.split(","): parts[name.strip()](d, base) fails = [r for r in d.results if not r["pass"]]
skills/kicad-bridge-test/run_verb_tests.py+16−2@@ -131,7 +131,7 @@ def matrix(w: dict) -> dict: "describe": dict(phase=1, args={}, check="verbs>=47"), **{v: dict(phase=1, args={}, check="explains_unsupported") for v in ( "license", "signin", "connect_workspace", "agent_start", "agent_stop",- "agent_status", "agent_recover", "selftest_script_error", "extension_ping", "install_extension", "install_receipt")},+ "agent_status", "agent_recover", "selftest_script_error", "extension_ping", "install_extension", "install_receipt", "physical_properties")}, "status": dict(phase=1, args={}), "readiness": dict(phase=1, args={}), "list_versions": dict(phase=1, args={}),@@ -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", "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")},+ "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", "cancel_run", "place_parts", "edit_tracks", "extensions", "preferences")}, "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),@@ -1857,6 +1857,20 @@ def main(): 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")+ # 1.0.100: the verbs ruled on #29, with KiCad still up on the scratch board+ cr = C("cancel_run", {}, 30)+ own("cancel_run", cr, ok(cr) and cr.get("cancelRequested") is False and cr.get("reason"), "with nothing running it did not say so")+ ext = C("extensions", {"action": "list", "checkUpdates": False}, 60)+ ext2 = C("extensions", {"action": "nope"}, 30)+ own("extensions", {"list": {"count": ext.get("count")}, "bad": ext2.get("errorCode")}, ok(ext) and isinstance(ext.get("extensions"), list) and ext.get("count") == len(ext.get("extensions")) and ext2.get("errorCode") == "bad_args", "the plugin list did not come back")+ prf = C("preferences", {"key": "kicad_common"}, 30)+ prs = C("preferences", {"set": {"kicad_common.adom_verb_test": 1}}, 30)+ own("preferences", {"read": list((prf.get("current") or {}).keys()), "set": prs.get("reason")}, ok(prf) and isinstance((prf.get("current") or {}).get("kicad_common"), dict) and prs.get("reason") == "tool_running", "the settings did not read, or set was not refused while KiCad runs")+ pl = C("place_parts", {"filePath": xpcb, "parts": [{"library": "Resistor_SMD", "footprint": "R_0603_1608Metric", "ref": "R", "x": 150, "y": 90}], "validate": False}, 200)+ pst = ab(a.target, "kicad_routing_state", {"filePath": xpcb, "model": "standard"}, timeout=120)+ own("place_parts", {"reply": {k: pl.get(k) for k in ("results", "placed", "errorCode", "error")}, "parts": (pst.get("counts") or {}).get("components")}, ok(pl) and pl.get("results") == ["ok"] and len(pl.get("placed") or []) == 1 and abs((pl["placed"][0].get("y") or 0) + 90) < 0.01, "the part did not land where asked")+ et = C("edit_tracks", {"filePath": xpcb, "tracks": [{"id": "not-a-track", "width": 0.5}]}, 60)+ own("edit_tracks", {"reply": {k: et.get(k) for k in ("success", "errorCode", "error")}}, et.get("success") is False and et.get("errorCode") and "tracks" not in str(et.get("error")), "a track that does not exist was not refused by name (edit_drill.py proves a real edit)") 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")