diff --git a/crates/konnect-core/src/tools/pcb_board.rs b/crates/konnect-core/src/tools/pcb_board.rs index 69b3f4af..b3818dce 100644 --- a/crates/konnect-core/src/tools/pcb_board.rs +++ b/crates/konnect-core/src/tools/pcb_board.rs @@ -1,8 +1,10 @@ //! `pcb_board` toolset — board setup, layers, outlines, zones, and board-level items. //! //! Most operations use S-expression file manipulation so they work without a running -//! KiCAD instance. `get_board_extents` tries the IPC API first, falling back to -//! parsing the file for coordinate bounds. +//! KiCad instance. `get_board_info` and `get_board_extents` try the IPC API first, +//! falling back to parsing the file, and report which they used as `source` — +//! the file is the last save, so it disagrees with the IPC-backed writers here +//! whenever KiCad holds unsaved edits. use crate::mcp::protocol::CallToolResult; use crate::tool; @@ -326,7 +328,9 @@ pub fn tools() -> Vec { tool!( "get_board_info", "Return metadata about the PCB: title, revision, company, layer count, paper size, \ - and the number of distinct nets (excluding the unconnected pseudo-net).", + and the number of distinct nets (excluding the unconnected pseudo-net). \ + Reads the board open in KiCad when it is reachable, else the file — \ + 'source' says which. Paper size always comes from the file.", json!({ "type": "object", "properties": { @@ -548,11 +552,60 @@ async fn handle_set_board_size( }))) } +/// The page size, which is only ever read from the file: KiCad's API exposes +/// no page settings, so even the live path answers this one field from disk. +fn paper_from_file(board_path: &std::path::Path) -> String { + let Ok(content) = std::fs::read_to_string(board_path) else { + return "A4".to_string(); + }; + let Ok(tree) = parse_sexp(&content) else { + return "A4".to_string(); + }; + tree.find("paper") + .and_then(|n| n.get(1)) + .and_then(|n| n.as_str()) + .unwrap_or("A4") + .to_string() +} + async fn handle_get_board_info( args: &serde_json::Value, - _ctx: &ToolContext, + ctx: &ToolContext, ) -> anyhow::Result { let board_path = get_path(args, "board")?; + + // The board open in KiCad first. Reading only the file reported the state + // of the last save — on a board with unsaved edits it disagreed with the + // IPC-backed writers in this toolset, most visibly as layer_count 0 / + // net_count 0 on a board KiCad was showing fully populated. + let ipc_board = board_path.clone(); + if let Ok((title_block, enabled, nets)) = with_ipc(ctx.config.ipc_address.clone(), move |c| { + let document = c.find_open_board(&ipc_board)?; + Ok(( + c.get_title_block_in(document.clone())?, + c.get_enabled_layers_in(document.clone())?, + c.get_nets_in(document)?.len(), + )) + }) + .await? + { + // The copper count is KiCad's own field, not a tally of layer names + // ending in `.Cu` — the two agree on an ordinary stackup, and that is + // the kind of agreement that stops holding on an unusual one. + return Ok(CallToolResult::json(&json!({ + "file": board_path.display().to_string(), + "title": title_block.title, + "date": title_block.date, + "revision": title_block.revision, + "company": title_block.company, + "paper": paper_from_file(&board_path), + "layer_count": enabled.layers.len(), + "copper_layer_count": enabled.copper_layer_count, + "net_count": nets, + "source": "ipc" + }))); + } + let content = std::fs::read_to_string(&board_path)?; let tree = parse_sexp(&content)?; @@ -607,7 +660,8 @@ async fn handle_get_board_info( "paper": paper, "layer_count": layer_count, "copper_layer_count": copper_layer_count, - "net_count": net_count + "net_count": net_count, + "source": "file" }))) } @@ -617,11 +671,13 @@ async fn handle_get_board_extents( ) -> anyhow::Result { let board_path = get_path(args, "board")?; - // Try IPC first; fall through to file-based computation on error - let requested = board_path.clone(); + // Try IPC first; fall through to file-based computation on error. + // Addressed to the requested board, not the first open one — with two + // boards open, first-document targeting silently measures the other, and + // ensure_board_is_active only checks it is open somewhere. + let ipc_board = board_path.clone(); if let Ok(ext) = with_ipc(ctx.config.ipc_address.clone(), move |c| { - c.ensure_board_is_active(&requested)?; - c.get_board_extents() + c.get_board_extents_in(c.find_open_board(&ipc_board)?) }) .await? { @@ -1787,3 +1843,172 @@ mod zone_net_format_tests { assert_eq!(after, LEGACY); } } + +/// `get_board_info` used to read only the file — the last save — while every +/// writer in this toolset acts on the board KiCad holds. On a board with +/// unsaved edits the two disagreed completely, most visibly as layer_count 0 +/// and net_count 0 for a board KiCad was showing fully populated. +#[cfg(test)] +mod board_info_source_tests { + use super::*; + use crate::router::ToolRouter; + use crate::tools::ServerConfig; + use konnect_ipc::gen::kiapi; + use prost::Message; + use std::sync::Arc; + + /// A board saved before anything was placed on it: the empty stub the + /// file-only reader kept reporting. + const EMPTY_STUB: &str = "(kicad_pcb\n\t(version 20260206)\n\t(paper \"A3\")\n)\n"; + + fn ctx_talking_to(address: String) -> ToolContext { + ToolContext::new( + ServerConfig { + kicad_cli: String::new(), + kicad_binary: String::new(), + ipc_address: address, + project_dir: None, + jlcpcb_db_path: None, + auto_load_toolsets: false, + eager_toolsets: false, + }, + Arc::new(ToolRouter::new()), + ) + } + + /// A rep0 endpoint playing a KiCad holding `board` open with `layers` + /// enabled, `copper` of them copper, and `nets` named — none of it saved + /// to the file. + fn spawn_kicad_holding( + board: &std::path::Path, + layers: usize, + copper: u32, + nets: usize, + ) -> String { + use nng::options::Options; + + let port = { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.local_addr().unwrap().port() + }; + let url = format!("tcp://127.0.0.1:{port}"); + let socket = nng::Socket::new(nng::Protocol::Rep0).expect("mock rep socket"); + socket + .set_opt::(Some(std::time::Duration::from_secs(10))) + .unwrap(); + socket.listen(&url).expect("mock listen"); + + let board = board.to_string_lossy().to_string(); + std::thread::spawn(move || { + while let Ok(message) = socket.recv() { + let request = kiapi::common::ApiRequest::decode(message.as_slice()).unwrap(); + let command = request.message.expect("a command"); + let body = if command.type_url.ends_with("GetOpenDocuments") { + Some(konnect_ipc::builders::pack_any( + &kiapi::common::commands::GetOpenDocumentsResponse { + documents: vec![kiapi::common::types::DocumentSpecifier { + r#type: kiapi::common::types::DocumentType::DoctypePcb as i32, + project: None, + identifier: Some( + kiapi::common::types::document_specifier::Identifier::BoardFilename( + board.clone(), + ), + ), + }], + }, + "kiapi.common.commands.GetOpenDocumentsResponse", + )) + } else if command.type_url.ends_with("GetTitleBlockInfo") { + Some(konnect_ipc::builders::pack_any( + &kiapi::common::types::TitleBlockInfo { + title: "Live title".to_string(), + revision: "B".to_string(), + ..Default::default() + }, + "kiapi.common.types.TitleBlockInfo", + )) + } else if command.type_url.ends_with("GetBoardEnabledLayers") { + Some(konnect_ipc::builders::pack_any( + &kiapi::board::commands::BoardEnabledLayersResponse { + copper_layer_count: copper, + layers: (0..layers as i32).collect(), + }, + "kiapi.board.commands.BoardEnabledLayersResponse", + )) + } else if command.type_url.ends_with("GetNets") { + Some(konnect_ipc::builders::pack_any( + &kiapi::board::commands::NetsResponse { + nets: (0..nets) + .map(|index| kiapi::board::types::Net { + code: None, + name: format!("N{index}"), + }) + .collect(), + }, + "kiapi.board.commands.NetsResponse", + )) + } else { + None + }; + let response = kiapi::common::ApiResponse { + status: Some(kiapi::common::ApiResponseStatus { + status: kiapi::common::ApiStatusCode::AsOk as i32, + error_message: String::new(), + }), + header: None, + message: body, + }; + let out = nng::Message::from(response.encode_to_vec().as_slice()); + if socket.send(out).is_err() { + break; + } + } + }); + url + } + + async fn board_info(board: &std::path::Path, ctx: &ToolContext) -> serde_json::Value { + let result = handle_get_board_info(&json!({ "board": board.to_str().unwrap() }), ctx) + .await + .expect("handler should succeed"); + assert!(!result.is_error, "{:?}", result.content); + match &result.content[0] { + crate::mcp::protocol::ToolContent::Text { text } => serde_json::from_str(text).unwrap(), + other => panic!("expected text content, got {other:?}"), + } + } + + #[tokio::test] + async fn a_live_board_is_reported_instead_of_the_last_save() { + let dir = tempfile::tempdir().expect("tempdir"); + let board = dir.path().join("board.kicad_pcb"); + std::fs::write(&board, EMPTY_STUB).unwrap(); + // Six copper layers among 27 enabled. Ids 3..26 are all `*.Cu`, so a + // tally of layer names would say 24 — the response field says 6. + let address = spawn_kicad_holding(&board, 27, 6, 99); + + let info = board_info(&board, &ctx_talking_to(address)).await; + + assert_eq!(info["source"], json!("ipc")); + assert_eq!(info["layer_count"], json!(27)); + assert_eq!(info["copper_layer_count"], json!(6)); + assert_eq!(info["net_count"], json!(99)); + assert_eq!(info["title"], json!("Live title")); + assert_eq!(info["revision"], json!("B")); + // Page size has no IPC equivalent, so it stays a file reading. + assert_eq!(info["paper"], json!("A3")); + } + + #[tokio::test] + async fn an_offline_session_still_reads_the_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let board = dir.path().join("board.kicad_pcb"); + std::fs::write(&board, EMPTY_STUB).unwrap(); + + let info = board_info(&board, &ctx_talking_to(String::new())).await; + + assert_eq!(info["source"], json!("file")); + assert_eq!(info["net_count"], json!(0)); + assert_eq!(info["paper"], json!("A3")); + } +} diff --git a/crates/konnect-core/src/tools/pcb_components.rs b/crates/konnect-core/src/tools/pcb_components.rs index cfc4f2c7..bc95730a 100644 --- a/crates/konnect-core/src/tools/pcb_components.rs +++ b/crates/konnect-core/src/tools/pcb_components.rs @@ -1847,9 +1847,11 @@ pub fn tools() -> Vec { tool!( "get_component_pads", "Return the pad positions and net assignments for a footprint. \ - A pad's 'net' is its net name, \"\" if the pad carries no net node \ - (unconnected), or null if the node is present but unreadable — \ - treat null as an error, not as an unconnected pad.", + Reads the board open in KiCad when it is reachable, else the file — \ + 'source' says which, so unsaved placements are visible without a save. \ + A pad's 'net' is its net name, \"\" if the pad carries no net \ + (unconnected), or — reading the file — null if the net node is present \ + but unreadable; treat null as an error, not as an unconnected pad.", json!({ "type": "object", "properties": { @@ -2372,9 +2374,25 @@ async fn handle_find_component( }))) } +/// How many pads the saved file gives `reference`, or `None` when the file +/// has no footprint by that name. +fn saved_pad_count(board_path: &std::path::Path, reference: &str) -> Option { + let content = std::fs::read_to_string(board_path).ok()?; + let tree = konnect_sexp::parser::parse_sexp(&content).ok()?; + tree.find_all("footprint") + .into_iter() + .find(|fp| { + fp.find_all("property").iter().any(|p| { + p.get(1).and_then(|n| n.as_str()) == Some("Reference") + && p.get(2).and_then(|n| n.as_str()) == Some(reference) + }) + }) + .map(|fp| fp.find_all("pad").len()) +} + async fn handle_get_component_pads( args: &serde_json::Value, - _ctx: &ToolContext, + ctx: &ToolContext, ) -> anyhow::Result { let board_path = get_path(args, "board")?; let reference = match require_str(args, "reference") { @@ -2382,6 +2400,62 @@ async fn handle_get_component_pads( Err(e) => return Ok(e), }; + // The board open in KiCad first: a part placed but not yet saved has no + // pads in the file at all, so reading the file would either error or + // answer about a stale board while the writers in this toolset act on the + // live one. The file stays the fallback for an offline session. + let ipc_board = board_path.clone(); + let ipc_reference = reference.clone(); + let live = with_ipc(ctx.config.ipc_address.clone(), move |c| { + let document = c.find_open_board(&ipc_board)?; + c.get_footprint_pads_in(document, &ipc_reference) + }) + .await?; + match live { + // KiCad has the part and reports pads for it. + Ok(Some(pads)) if !pads.is_empty() => { + let items: Vec = pads + .iter() + .map(|pad| json!({ "number": pad.number, "x": pad.x, "y": pad.y, "net": pad.net })) + .collect(); + return Ok(CallToolResult::json(&json!({ + "reference": reference, + "pad_count": items.len(), + "pads": items, + "source": "ipc" + }))); + } + // KiCad has the part and reports no pads. A pad-less footprint is + // legal — a logo, a mounting graphic — so this is not wrong by + // itself. But "no pads" is also what an unread response shape would + // look like, and it reads as a plausible answer rather than a + // failure, so it is refused whenever the saved file disagrees. + Ok(Some(_)) => { + if saved_pad_count(&board_path, &reference).is_some_and(|count| count > 0) { + return Ok(CallToolResult::error(format!( + "KiCad reports no pads for footprint '{reference}' while the saved file \ + has some. Refusing to answer 'no pads' — save the board in KiCad and \ + retry, and report this if it persists." + ))); + } + return Ok(CallToolResult::json(&json!({ + "reference": reference, + "pad_count": 0, + "pads": [], + "source": "ipc" + }))); + } + // KiCad holds this board and does not have the part: the file may + // still carry a footprint the user has deleted, so answering from it + // would be answering about a board that no longer exists. + Ok(None) => { + return Ok(CallToolResult::error(format!( + "Footprint '{reference}' not found on the board open in KiCad" + ))) + } + Err(_) => {} + } + let content = std::fs::read_to_string(&board_path)?; let tree = konnect_sexp::parser::parse_sexp(&content)?; @@ -2437,9 +2511,12 @@ async fn handle_get_component_pads( }) .collect(); - Ok(CallToolResult::json( - &json!({ "reference": reference, "pad_count": pads.len(), "pads": pads }), - )) + Ok(CallToolResult::json(&json!({ + "reference": reference, + "pad_count": pads.len(), + "pads": pads, + "source": "file" + }))) } async fn handle_get_pad_position( @@ -2451,6 +2528,9 @@ async fn handle_get_pad_position( Err(e) => return Ok(e), }; let pads_result = handle_get_component_pads(args, ctx).await?; + if pads_result.is_error { + return Ok(pads_result); + } // Parse the result and filter for the specific pad number if let Some(crate::mcp::protocol::ToolContent::Text { text }) = pads_result.content.first() { if let Ok(parsed) = serde_json::from_str::(text) { @@ -2459,7 +2539,15 @@ async fn handle_get_pad_position( .iter() .find(|p| p["number"].as_str() == Some(&pad_number)) { - return Ok(CallToolResult::json(pad)); + // Carry the pad list's source, so a caller measuring one + // pad can still tell whether it measured the live board. + let mut pad = pad.clone(); + if let (Some(object), Some(source)) = + (pad.as_object_mut(), parsed.get("source")) + { + object.insert("source".to_string(), source.clone()); + } + return Ok(CallToolResult::json(&pad)); } } } @@ -4394,6 +4482,275 @@ mod tests { ); } + // ─── Reading pads: live board first, file second ────────────────────────── + + /// A board file whose R1 is stale — the state of the last save. + const SAVED_BOARD_WITH_R1: &str = "(kicad_pcb\n\ + \t(version 20260206)\n\ + \t(footprint \"R_0805\"\n\ + \t\t(at 5 5 0)\n\ + \t\t(property \"Reference\" \"R1\" (at 0 -1 0) (layer \"F.SilkS\"))\n\ + \t\t(pad \"1\" smd roundrect (at -0.9 0) (net \"SAVED\"))\n\ + \t)\n\ + )\n"; + + fn live_pad(number: &str, x: f64, y: f64, net: &str) -> prost_types::Any { + konnect_ipc::builders::pack_any( + &konnect_ipc::gen::kiapi::board::types::Pad { + number: number.to_string(), + position: Some(konnect_ipc::builders::vec2(x, y)), + net: Some(konnect_ipc::gen::kiapi::board::types::Net { + code: None, + name: net.to_string(), + }), + ..Default::default() + }, + "kiapi.board.types.Pad", + ) + } + + fn live_footprint(reference: &str, pads: Vec) -> prost_types::Any { + use konnect_ipc::gen::kiapi; + konnect_ipc::builders::pack_any( + &kiapi::board::types::FootprintInstance { + position: Some(konnect_ipc::builders::vec2(100.0, 100.0)), + reference_field: Some(kiapi::board::types::Field { + name: "Reference".to_string(), + text: Some(kiapi::board::types::BoardText { + text: Some(kiapi::common::types::Text { + text: reference.to_string(), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }), + definition: Some(kiapi::board::types::Footprint { + items: pads, + ..Default::default() + }), + ..Default::default() + }, + "kiapi.board.types.FootprintInstance", + ) + } + + /// A rep0 endpoint playing a KiCad that holds `board` open with `items` on + /// it — a live board carrying edits the file on disk has never seen. + fn spawn_kicad_holding(board: &Path, items: Vec) -> String { + use konnect_ipc::gen::kiapi; + use nng::options::Options; + use prost::Message; + + let port = { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.local_addr().unwrap().port() + }; + let url = format!("tcp://127.0.0.1:{port}"); + let socket = nng::Socket::new(nng::Protocol::Rep0).expect("mock rep socket"); + socket + .set_opt::(Some(std::time::Duration::from_secs(10))) + .unwrap(); + socket.listen(&url).expect("mock listen"); + + let board = board.to_string_lossy().to_string(); + std::thread::spawn(move || { + while let Ok(message) = socket.recv() { + let request = kiapi::common::ApiRequest::decode(message.as_slice()).unwrap(); + let command = request.message.expect("a command"); + let body = if command.type_url.ends_with("GetOpenDocuments") { + Some(konnect_ipc::builders::pack_any( + &kiapi::common::commands::GetOpenDocumentsResponse { + documents: vec![kiapi::common::types::DocumentSpecifier { + r#type: kiapi::common::types::DocumentType::DoctypePcb as i32, + project: None, + identifier: Some( + kiapi::common::types::document_specifier::Identifier::BoardFilename( + board.clone(), + ), + ), + }], + }, + "kiapi.common.commands.GetOpenDocumentsResponse", + )) + } else if command.type_url.ends_with("GetItems") { + Some(konnect_ipc::builders::pack_any( + &kiapi::common::commands::GetItemsResponse { + header: None, + status: kiapi::common::types::ItemRequestStatus::IrsOk as i32, + items: items.clone(), + }, + "kiapi.common.commands.GetItemsResponse", + )) + } else { + None + }; + let response = kiapi::common::ApiResponse { + status: Some(kiapi::common::ApiResponseStatus { + status: kiapi::common::ApiStatusCode::AsOk as i32, + error_message: String::new(), + }), + header: None, + message: body, + }; + let out = nng::Message::from(response.encode_to_vec().as_slice()); + if socket.send(out).is_err() { + break; + } + } + }); + url + } + + fn ctx_talking_to(address: String) -> ToolContext { + ToolContext::new( + crate::tools::ServerConfig { + kicad_cli: String::new(), + kicad_binary: String::new(), + ipc_address: address, + project_dir: None, + jlcpcb_db_path: None, + auto_load_toolsets: false, + eager_toolsets: false, + }, + std::sync::Arc::new(crate::router::ToolRouter::new()), + ) + } + + fn parsed(res: &CallToolResult) -> serde_json::Value { + serde_json::from_str(&result_text(res)).expect("json result") + } + + #[tokio::test] + async fn pads_come_from_the_board_kicad_holds_not_the_last_save() { + let tmp = tempfile::tempdir().unwrap(); + let board = tmp.path().join("b.kicad_pcb"); + std::fs::write(&board, SAVED_BOARD_WITH_R1).unwrap(); + let address = spawn_kicad_holding( + &board, + vec![live_footprint( + "R1", + vec![live_pad("1", 101.155, 66.11, "/VBUS")], + )], + ); + + let res = handle_get_component_pads( + &json!({ "board": board.to_string_lossy(), "reference": "R1" }), + &ctx_talking_to(address), + ) + .await + .unwrap(); + + assert!(!res.is_error, "{:?}", res.content); + let body = parsed(&res); + assert_eq!(body["source"], json!("ipc")); + assert_eq!(body["pads"][0]["net"], json!("/VBUS")); + assert_eq!(body["pads"][0]["x"], json!(101.155)); + } + + #[tokio::test] + async fn a_part_deleted_in_kicad_is_not_answered_from_the_file() { + let tmp = tempfile::tempdir().unwrap(); + let board = tmp.path().join("b.kicad_pcb"); + std::fs::write(&board, SAVED_BOARD_WITH_R1).unwrap(); + let address = spawn_kicad_holding(&board, vec![]); + + let res = handle_get_component_pads( + &json!({ "board": board.to_string_lossy(), "reference": "R1" }), + &ctx_talking_to(address), + ) + .await + .unwrap(); + + assert!(res.is_error, "the live board no longer has R1"); + assert!(result_text(&res).contains("open in KiCad")); + } + + #[tokio::test] + async fn no_pads_from_kicad_is_refused_when_the_file_has_some() { + let tmp = tempfile::tempdir().unwrap(); + let board = tmp.path().join("b.kicad_pcb"); + std::fs::write(&board, SAVED_BOARD_WITH_R1).unwrap(); + // The part is there, its pads are not — the shape a response we + // failed to read would also take. + let address = spawn_kicad_holding(&board, vec![live_footprint("R1", vec![])]); + + let res = handle_get_component_pads( + &json!({ "board": board.to_string_lossy(), "reference": "R1" }), + &ctx_talking_to(address), + ) + .await + .unwrap(); + + assert!(res.is_error, "'no pads' must not pass as an answer here"); + assert!(result_text(&res).contains("no pads")); + } + + #[tokio::test] + async fn a_genuinely_pad_less_footprint_reads_as_zero() { + let tmp = tempfile::tempdir().unwrap(); + let board = tmp.path().join("b.kicad_pcb"); + std::fs::write(&board, SAVED_BOARD_WITH_R1).unwrap(); + // The saved file has no LOGO1 to disagree, so KiCad's answer stands. + let address = spawn_kicad_holding(&board, vec![live_footprint("LOGO1", vec![])]); + + let res = handle_get_component_pads( + &json!({ "board": board.to_string_lossy(), "reference": "LOGO1" }), + &ctx_talking_to(address), + ) + .await + .unwrap(); + + assert!(!res.is_error, "{:?}", res.content); + let body = parsed(&res); + assert_eq!(body["source"], json!("ipc")); + assert_eq!(body["pad_count"], json!(0)); + } + + #[tokio::test] + async fn pads_fall_back_to_the_file_when_kicad_is_unreachable() { + let tmp = tempfile::tempdir().unwrap(); + let board = tmp.path().join("b.kicad_pcb"); + std::fs::write(&board, SAVED_BOARD_WITH_R1).unwrap(); + + let res = handle_get_component_pads( + &json!({ "board": board.to_string_lossy(), "reference": "R1" }), + &test_ctx(), + ) + .await + .unwrap(); + + assert!(!res.is_error, "{:?}", res.content); + let body = parsed(&res); + assert_eq!(body["source"], json!("file")); + assert_eq!(body["pads"][0]["net"], json!("SAVED")); + } + + #[tokio::test] + async fn a_pad_position_carries_the_source_of_the_reading() { + let tmp = tempfile::tempdir().unwrap(); + let board = tmp.path().join("b.kicad_pcb"); + std::fs::write(&board, SAVED_BOARD_WITH_R1).unwrap(); + let address = spawn_kicad_holding( + &board, + vec![live_footprint( + "R1", + vec![live_pad("1", 101.155, 66.11, "/VBUS")], + )], + ); + + let res = handle_get_pad_position( + &json!({ "board": board.to_string_lossy(), "reference": "R1", "pad_number": "1" }), + &ctx_talking_to(address), + ) + .await + .unwrap(); + + let body = parsed(&res); + assert_eq!(body["source"], json!("ipc")); + assert_eq!(body["x"], json!(101.155)); + } + // ─── board_lib_id / helpers (ported from PR #66) ────────────────────────── /// `board_lib_id` for a path, with the library file's declared name. diff --git a/crates/konnect-ipc/src/client.rs b/crates/konnect-ipc/src/client.rs index 1a2ed16e..1021fab5 100644 --- a/crates/konnect-ipc/src/client.rs +++ b/crates/konnect-ipc/src/client.rs @@ -974,6 +974,67 @@ impl KiCadIpcClient { Ok(footprints.into_iter().find(|fp| fp.reference == reference)) } + /// Read a placed footprint's pads from the open board, or `None` when no + /// footprint carries `reference`. + /// + /// Pads come back in absolute board coordinates, because that is how + /// KiCad serializes a footprint's children (see the `transform` module) — + /// the anchor/rotation transform the file path has to apply is already + /// baked in here. + pub fn get_footprint_pads_in( + &self, + document: kiapi::common::types::DocumentSpecifier, + reference: &str, + ) -> Result>> { + let items = self.get_items_in( + document, + kiapi::common::types::KiCadObjectType::KotPcbFootprint, + )?; + for item in &items { + let Ok(fp) = kiapi::board::types::FootprintInstance::decode(item.value.as_slice()) + else { + continue; + }; + if footprint_reference(&fp) != reference { + continue; + } + let pads = fp + .definition + .iter() + .flat_map(|definition| definition.items.iter()) + .filter(|child| child.type_url.ends_with("kiapi.board.types.Pad")) + .filter_map(|child| kiapi::board::types::Pad::decode(child.value.as_slice()).ok()) + .map(|pad| IpcPad { + number: pad.number, + x: pad.position.map(|p| nm_to_mm(p.x_nm)).unwrap_or(0.0), + y: pad.position.map(|p| nm_to_mm(p.y_nm)).unwrap_or(0.0), + net: pad.net.map(|net| net.name).unwrap_or_default(), + }) + .collect(); + return Ok(Some(pads)); + } + Ok(None) + } + + /// Read the title block of a specific open document. + pub fn get_title_block_in( + &self, + document: kiapi::common::types::DocumentSpecifier, + ) -> Result { + let cmd = kiapi::common::commands::GetTitleBlockInfo { + document: Some(document), + }; + let response = self.send_command(&cmd, "kiapi.common.commands.GetTitleBlockInfo")?; + let info: kiapi::common::types::TitleBlockInfo = + unpack_required(response, "GetTitleBlockInfo")?; + Ok(IpcTitleBlock { + title: info.title, + date: info.date, + revision: info.revision, + company: info.company, + }) + } + /// Find a footprint's KIID by reference. fn find_footprint_kiid(&self, reference: &str) -> Result { let items = self.get_items(kiapi::common::types::KiCadObjectType::KotPcbFootprint)?; @@ -1737,32 +1798,55 @@ impl KiCadIpcClient { /// Get enabled layers. pub fn get_layers(&self) -> Result> { - let doc = self.get_board_document()?; - let cmd = kiapi::board::commands::GetBoardEnabledLayers { board: Some(doc) }; + self.get_layers_in(self.get_board_document()?) + } + + /// As [`Self::get_layers`], targeting a specific open document. + pub fn get_layers_in( + &self, + document: kiapi::common::types::DocumentSpecifier, + ) -> Result> { + Ok(self.get_enabled_layers_in(document)?.layers) + } + + /// As [`Self::get_layers_in`], keeping the copper count KiCad reports + /// beside the layer list instead of leaving callers to derive it. + pub fn get_enabled_layers_in( + &self, + document: kiapi::common::types::DocumentSpecifier, + ) -> Result { + let cmd = kiapi::board::commands::GetBoardEnabledLayers { + board: Some(document), + }; let resp_any = self.send_command(&cmd, "kiapi.board.commands.GetBoardEnabledLayers")?; - if let Some(any) = resp_any { - let resp: kiapi::board::commands::BoardEnabledLayersResponse = unpack_any(&any)?; - let layers = resp - .layers - .iter() - .map(|&l| { - let bl = kiapi::board::types::BoardLayer::try_from(l) - .unwrap_or(kiapi::board::types::BoardLayer::BlUndefined); - IpcLayer { - name: bl - .as_str_name() - .trim_start_matches("BL_") - .replace('_', ".") - .to_string(), - id: l, - kind: String::new(), - } - }) - .collect(); - Ok(layers) - } else { - Ok(vec![]) - } + let Some(any) = resp_any else { + return Ok(IpcEnabledLayers { + copper_layer_count: 0, + layers: vec![], + }); + }; + let resp: kiapi::board::commands::BoardEnabledLayersResponse = unpack_any(&any)?; + let layers = resp + .layers + .iter() + .map(|&l| { + let bl = kiapi::board::types::BoardLayer::try_from(l) + .unwrap_or(kiapi::board::types::BoardLayer::BlUndefined); + IpcLayer { + name: bl + .as_str_name() + .trim_start_matches("BL_") + .replace('_', ".") + .to_string(), + id: l, + kind: String::new(), + } + }) + .collect(); + Ok(IpcEnabledLayers { + copper_layer_count: resp.copper_layer_count, + layers, + }) } /// Run an arbitrary tool action in KiCAD (e.g. to trigger a refresh). @@ -1923,6 +2007,18 @@ fn build_graphic_child( } } +/// The reference designator text of a placed footprint, or `""` when the +/// instance carries no reference field. +fn footprint_reference(footprint: &kiapi::board::types::FootprintInstance) -> &str { + footprint + .reference_field + .as_ref() + .and_then(|field| field.text.as_ref()) + .and_then(|text| text.text.as_ref()) + .map(|text| text.text.as_str()) + .unwrap_or("") +} + fn header_for( document: kiapi::common::types::DocumentSpecifier, ) -> kiapi::common::types::ItemHeader { diff --git a/crates/konnect-ipc/src/types.rs b/crates/konnect-ipc/src/types.rs index 012417f4..aaac821a 100644 --- a/crates/konnect-ipc/src/types.rs +++ b/crates/konnect-ipc/src/types.rs @@ -16,6 +16,29 @@ pub struct IpcFootprint { pub layer: String, } +/// A pad of a footprint placed on the board, read back from KiCad. +/// +/// Coordinates are absolute board millimetres: KiCad serializes a +/// `FootprintInstance`'s children in board space (see the `transform` module), +/// so no anchor or rotation transform is applied on the way out. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IpcPad { + pub number: String, + pub x: f64, + pub y: f64, + /// Net name, empty when the pad carries no net. + pub net: String, +} + +/// The document's title block, which the board file also carries. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct IpcTitleBlock { + pub title: String, + pub date: String, + pub revision: String, + pub company: String, +} + #[derive(Debug, Clone)] pub struct IpcPadDefinition { pub number: String, @@ -166,6 +189,17 @@ pub struct IpcLayer { pub kind: String, } +/// The enabled layer set as KiCad reports it. +/// +/// `copper_layer_count` is the response's own field, not a count of `layers` +/// whose name ends in `.Cu` — the two agree on an ordinary stackup, and that +/// agreement is exactly what stops holding on an unusual one. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IpcEnabledLayers { + pub copper_layer_count: u32, + pub layers: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IpcBoardExtents { pub min: IpcVector2, diff --git a/crates/konnect-ipc/tests/mock_server_test.rs b/crates/konnect-ipc/tests/mock_server_test.rs index 015e5dfd..d5956201 100644 --- a/crates/konnect-ipc/tests/mock_server_test.rs +++ b/crates/konnect-ipc/tests/mock_server_test.rs @@ -966,3 +966,174 @@ fn a_pads_uuid_is_never_accepted_as_a_graphic_to_edit() { .expect("the actual graphic is still editable"); assert_eq!(*updates.lock().unwrap(), 1); } + +// ─── Reading pads back from the live board ─────────────────────────────────── +// +// The board file is the last save, so a footprint placed through IPC has no +// pads on disk until the user presses Ctrl+S. Reading them over IPC is what +// lets a caller place a part and immediately measure it. + +fn pad_at(number: &str, x_mm: f64, y_mm: f64, net: &str) -> prost_types::Any { + builders::pack_any( + &kiapi::board::types::Pad { + number: number.to_string(), + position: Some(builders::vec2(x_mm, y_mm)), + net: Some(kiapi::board::types::Net { + code: None, + name: net.to_string(), + }), + ..Default::default() + }, + "kiapi.board.types.Pad", + ) +} + +fn footprint_with_pads(reference: &str, pads: Vec) -> prost_types::Any { + let mut items = pads; + // A non-pad child must be skipped rather than decoded as a pad. + items.push(builders::pack_any( + &builders::board_segment("F.SilkS", 0.12, 0.0, 0.0, 1.0, 0.0), + "kiapi.board.types.BoardGraphicShape", + )); + builders::pack_any( + &kiapi::board::types::FootprintInstance { + position: Some(builders::vec2(100.0, 100.0)), + reference_field: Some(kiapi::board::types::Field { + name: "Reference".to_string(), + text: Some(kiapi::board::types::BoardText { + text: Some(kiapi::common::types::Text { + text: reference.to_string(), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }), + definition: Some(kiapi::board::types::Footprint { + items, + ..Default::default() + }), + ..Default::default() + }, + "kiapi.board.types.FootprintInstance", + ) +} + +fn spawn_kicad_with_footprints(items: Vec) -> MockKicad { + spawn_mock(move |request| { + let message = request.message.expect("request must pack a command"); + if message.type_url.ends_with("GetOpenDocuments") { + return Some(open_board_response()); + } + if message.type_url.ends_with("GetItems") { + let response = kiapi::common::commands::GetItemsResponse { + header: None, + status: kiapi::common::types::ItemRequestStatus::IrsOk as i32, + items: items.clone(), + }; + return Some(reply_with(builders::pack_any( + &response, + "kiapi.common.commands.GetItemsResponse", + ))); + } + Some(ok_response()) + }) +} + +#[test] +fn footprint_pads_come_back_in_board_coordinates_with_their_nets() { + let mock = spawn_kicad_with_footprints(vec![footprint_with_pads( + "U1", + vec![ + pad_at("A4", 101.155, 66.11, "/VBUS"), + pad_at("A5", 102.155, 66.11, ""), + ], + )]); + let client = KiCadIpcClient::new(&mock.url); + let document = client + .find_open_board(std::path::Path::new("test.kicad_pcb")) + .expect("the mock holds test.kicad_pcb"); + + let pads = client + .get_footprint_pads_in(document, "U1") + .expect("pad read") + .expect("U1 is on the board"); + + assert_eq!(pads.len(), 2, "the silk segment must not decode as a pad"); + assert_eq!(pads[0].number, "A4"); + assert_eq!(pads[0].x, 101.155); + assert_eq!(pads[0].y, 66.11); + assert_eq!(pads[0].net, "/VBUS"); + // KiCad names no net on an unconnected pad; "" is that, not a read failure. + assert_eq!(pads[1].net, ""); +} + +#[test] +fn a_footprint_absent_from_the_live_board_reads_as_none() { + let mock = spawn_kicad_with_footprints(vec![footprint_with_pads( + "U1", + vec![pad_at("1", 1.0, 2.0, "GND")], + )]); + let client = KiCadIpcClient::new(&mock.url); + let document = client + .find_open_board(std::path::Path::new("test.kicad_pcb")) + .expect("the mock holds test.kicad_pcb"); + + assert!(client + .get_footprint_pads_in(document, "R99") + .expect("pad read") + .is_none()); +} + +#[test] +fn pad_reads_target_the_named_board_among_several_open() { + let captured: Arc>> = Arc::new(Mutex::new(None)); + let captured_in_mock = captured.clone(); + + let mock = spawn_mock(move |request| { + let message = request.message.expect("request must pack a command"); + if message.type_url.ends_with("GetOpenDocuments") { + let response = kiapi::common::commands::GetOpenDocumentsResponse { + documents: vec![ + doc_for("other-project.kicad_pcb"), + doc_for("target.kicad_pcb"), + ], + }; + return Some(reply_with(builders::pack_any( + &response, + "kiapi.common.commands.GetOpenDocumentsResponse", + ))); + } + if message.type_url.ends_with("GetItems") { + let request = + kiapi::common::commands::GetItems::decode(message.value.as_slice()).unwrap(); + record_doc(&captured_in_mock, &request.header); + let response = kiapi::common::commands::GetItemsResponse { + header: None, + status: kiapi::common::types::ItemRequestStatus::IrsOk as i32, + items: vec![], + }; + return Some(reply_with(builders::pack_any( + &response, + "kiapi.common.commands.GetItemsResponse", + ))); + } + Some(ok_response()) + }); + + let client = KiCadIpcClient::new(&mock.url); + let document = client + .find_open_board(std::path::Path::new("target.kicad_pcb")) + .expect("target.kicad_pcb is open"); + let _ = client.get_footprint_pads_in(document, "R1"); + + let addressed = captured + .lock() + .unwrap() + .take() + .expect("a command carried a document"); + assert_eq!( + addressed, "target.kicad_pcb", + "a read must answer about the requested board, not the first open one" + ); +} diff --git a/tool-directory.md b/tool-directory.md index 6dafb9af..334789cd 100644 --- a/tool-directory.md +++ b/tool-directory.md @@ -206,7 +206,7 @@ Six tools, grouped into *discovery/routing* and *observability*. | Tool | Description | |------|-------------| | `set_board_size` | Set the PCB board outline to a rectangle on the Edge.Cuts layer. | -| `get_board_info` | Return metadata about the PCB: title, revision, company, paper size, `layer_count`, `copper_layer_count`, and `net_count` (counted from the tree, so KiCad 10 boards report real numbers instead of 0). | +| `get_board_info` | Return metadata about the PCB: title, revision, company, paper size, `layer_count`, `copper_layer_count`, and `net_count` (IPC, falls back to a file parse that counts from the tree, so KiCad 10 boards report real numbers instead of 0). | | `get_board_extents` | Return the bounding box of all objects on the board (IPC, falls back to file parse). | | `get_layer_list` | Return all layers defined in the board: `id`, `name`, `type`, plus the optional `user_name` label and a `copper` flag. | | `add_layer` | Add a new inner copper or technical layer to the board stack. Rejects a non-canonical layer name — KiCad refuses to open a board containing one. Use the canonical name and pass your own label as its user name. | @@ -232,7 +232,7 @@ Six tools, grouped into *discovery/routing* and *observability*. | `find_component` | Find a footprint by reference designator and return its position. | | `list_board_footprint_graphics` | List the graphic items inside a footprint placed on the board — silkscreen, fabrication, and courtyard artwork — with the UUID needed to edit one. Reports `editable`, plus `outlines` and `holes` for polygons. Requires KiCAD running with the board open. | | `edit_board_footprint_graphic` | Replace the vertices of a single-outline polygon inside a placed footprint, selected by UUID, without re-placing the part. Anything with multiple outlines or holes is refused by name rather than flattened. Requires KiCAD running with the board open. | -| `get_component_pads` | Return pad positions and net assignments for a footprint. A pad whose net node is present but unreadable reports `null` rather than an empty string, so "no net" stays distinguishable from "could not read it". | +| `get_component_pads` | Return pad positions and net assignments for a footprint (IPC, falls back to file parse). A pad whose net node is present but unreadable reports `null` rather than an empty string, so "no net" stays distinguishable from "could not read it". | | `get_pad_position` | Return the schematic-space position of a specific pad number on a footprint. | | `get_component_list` | List all footprints on the board with positions, layers, and values. | | `place_component_array` | Place multiple copies of a footprint in a grid or line array via KiCAD IPC. |