From 08c2b6f9196296235962e8ff77343d7ed76bc403 Mon Sep 17 00:00:00 2001 From: Milind Sharma Date: Thu, 19 Feb 2026 11:54:09 +0800 Subject: [PATCH] feat: add selection detail and pad netlist APIs --- docs/TEST_CLI.md | 18 ++ src/client.rs | 352 +++++++++++++++++++++++++++++++++- src/error.rs | 3 + src/lib.rs | 5 +- src/model/board.rs | 10 + src/model/common.rs | 7 + test-scripts/kicad-ipc-cli.rs | 65 ++++++- 7 files changed, 454 insertions(+), 6 deletions(-) diff --git a/docs/TEST_CLI.md b/docs/TEST_CLI.md index 00d7b4c..6ba9652 100644 --- a/docs/TEST_CLI.md +++ b/docs/TEST_CLI.md @@ -83,6 +83,24 @@ Show summary of current PCB selection by item type: cargo run --bin kicad-ipc-cli -- selection-summary ``` +Show parsed details for currently selected items: + +```bash +cargo run --bin kicad-ipc-cli -- selection-details +``` + +Show raw protobuf payload bytes for selected items: + +```bash +cargo run --bin kicad-ipc-cli -- selection-raw +``` + +Show pad-level netlist entries (footprint/pad/net): + +```bash +cargo run --bin kicad-ipc-cli -- netlist-pads +``` + Get current project path (derived from open PCB docs): ```bash diff --git a/src/client.rs b/src/client.rs index 44063c0..6534551 100644 --- a/src/client.rs +++ b/src/client.rs @@ -6,10 +6,11 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use crate::envelope; use crate::error::KiCadError; use crate::model::board::{ - BoardEnabledLayers, BoardLayerInfo, BoardNet, BoardOriginKind, Vector2Nm, + BoardEnabledLayers, BoardLayerInfo, BoardNet, BoardOriginKind, PadNetEntry, Vector2Nm, }; use crate::model::common::{ - DocumentSpecifier, DocumentType, ProjectInfo, SelectionSummary, SelectionTypeCount, VersionInfo, + DocumentSpecifier, DocumentType, ProjectInfo, SelectionItemDetail, SelectionSummary, + SelectionTypeCount, VersionInfo, }; use crate::proto::kiapi::board::commands as board_commands; use crate::proto::kiapi::board::types as board_types; @@ -29,6 +30,7 @@ const CMD_GET_ACTIVE_LAYER: &str = "kiapi.board.commands.GetActiveLayer"; const CMD_GET_VISIBLE_LAYERS: &str = "kiapi.board.commands.GetVisibleLayers"; const CMD_GET_BOARD_ORIGIN: &str = "kiapi.board.commands.GetBoardOrigin"; const CMD_GET_SELECTION: &str = "kiapi.common.commands.GetSelection"; +const CMD_GET_ITEMS: &str = "kiapi.common.commands.GetItems"; const RES_GET_VERSION: &str = "kiapi.common.commands.GetVersionResponse"; const RES_GET_OPEN_DOCUMENTS: &str = "kiapi.common.commands.GetOpenDocumentsResponse"; @@ -38,6 +40,7 @@ const RES_BOARD_LAYER_RESPONSE: &str = "kiapi.board.commands.BoardLayerResponse" const RES_BOARD_LAYERS: &str = "kiapi.board.commands.BoardLayers"; const RES_VECTOR2: &str = "kiapi.common.types.Vector2"; const RES_SELECTION_RESPONSE: &str = "kiapi.common.commands.SelectionResponse"; +const RES_GET_ITEMS_RESPONSE: &str = "kiapi.common.commands.GetItemsResponse"; #[derive(Clone, Debug)] pub struct KiCadClient { @@ -314,6 +317,51 @@ impl KiCadClient { Ok(summarize_selection(payload.items)) } + pub async fn get_selection_raw(&self) -> Result, KiCadError> { + let document = self.current_board_document_proto().await?; + let command = common_commands::GetSelection { + header: Some(common_types::ItemHeader { + document: Some(document), + container: None, + field_mask: None, + }), + types: Vec::new(), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_GET_SELECTION)) + .await?; + + let payload: common_commands::SelectionResponse = + envelope::unpack_any(&response, RES_SELECTION_RESPONSE)?; + + Ok(payload.items) + } + + pub async fn get_selection_details(&self) -> Result, KiCadError> { + let items = self.get_selection_raw().await?; + let mut details = Vec::with_capacity(items.len()); + for item in items { + let raw_len = item.value.len(); + let type_url = item.type_url.clone(); + let detail = selection_item_detail(&item)?; + details.push(SelectionItemDetail { + type_url, + detail, + raw_len, + }); + } + + Ok(details) + } + + pub async fn get_pad_netlist(&self) -> Result, KiCadError> { + let footprint_items = self + .get_items_raw(vec![common_types::KiCadObjectType::KotPcbFootprint as i32]) + .await?; + pad_netlist_from_footprint_items(footprint_items) + } + async fn send_command( &self, command: prost_types::Any, @@ -357,6 +405,36 @@ impl KiCadClient { let selected = select_single_board_document(&docs)?; Ok(model_document_to_proto(selected)) } + + async fn get_items_raw(&self, types: Vec) -> Result, KiCadError> { + let document = self.current_board_document_proto().await?; + let command = common_commands::GetItems { + header: Some(common_types::ItemHeader { + document: Some(document), + container: None, + field_mask: None, + }), + types, + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_GET_ITEMS)) + .await?; + + let payload: common_commands::GetItemsResponse = + envelope::unpack_any(&response, RES_GET_ITEMS_RESPONSE)?; + + let request_status = common_types::ItemRequestStatus::try_from(payload.status) + .unwrap_or(common_types::ItemRequestStatus::IrsUnknown); + + if request_status != common_types::ItemRequestStatus::IrsOk { + return Err(KiCadError::ItemStatus { + code: request_status.as_str_name().to_string(), + }); + } + + Ok(payload.items) + } } fn map_document_specifier(source: common_types::DocumentSpecifier) -> Option { @@ -444,6 +522,156 @@ fn summarize_selection(items: Vec) -> SelectionSummary { } } +fn decode_any( + payload: &prost_types::Any, + expected_type_name: &str, +) -> Result { + let expected_type_url = envelope::type_url(expected_type_name); + if payload.type_url != expected_type_url { + return Err(KiCadError::UnexpectedPayloadType { + expected_type_url, + actual_type_url: payload.type_url.clone(), + }); + } + + T::decode(payload.value.as_slice()).map_err(|err| KiCadError::ProtobufDecode(err.to_string())) +} + +fn pad_netlist_from_footprint_items( + footprint_items: Vec, +) -> Result, KiCadError> { + let mut entries = Vec::new(); + for item in footprint_items { + if item.type_url != envelope::type_url("kiapi.board.types.FootprintInstance") { + continue; + } + + let footprint = decode_any::( + &item, + "kiapi.board.types.FootprintInstance", + )?; + + let footprint_reference = footprint + .reference_field + .as_ref() + .and_then(|field| field.text.as_ref()) + .and_then(|board_text| board_text.text.as_ref()) + .map(|text| text.text.clone()) + .filter(|value| !value.is_empty()); + + let footprint_id = footprint.id.as_ref().map(|id| id.value.clone()); + + let footprint_definition = footprint.definition.unwrap_or_default(); + for sub_item in footprint_definition.items { + if sub_item.type_url != envelope::type_url("kiapi.board.types.Pad") { + continue; + } + + let pad = decode_any::(&sub_item, "kiapi.board.types.Pad")?; + let (net_code, net_name) = match pad.net { + Some(net) => { + let code = net.code.map(|code| code.value); + let name = if net.name.is_empty() { + None + } else { + Some(net.name) + }; + (code, name) + } + None => (None, None), + }; + + entries.push(PadNetEntry { + footprint_reference: footprint_reference.clone(), + footprint_id: footprint_id.clone(), + pad_id: pad.id.map(|id| id.value), + pad_number: pad.number, + net_code, + net_name, + }); + } + } + + Ok(entries) +} + +fn selection_item_detail(item: &prost_types::Any) -> Result { + if item.type_url == envelope::type_url("kiapi.board.types.Track") { + let track = decode_any::(item, "kiapi.board.types.Track")?; + let id = track.id.map_or_else(|| "-".to_string(), |id| id.value); + let start = track + .start + .map_or_else(|| "-".to_string(), |v| format!("{},{}", v.x_nm, v.y_nm)); + let end = track + .end + .map_or_else(|| "-".to_string(), |v| format!("{},{}", v.x_nm, v.y_nm)); + let width = track + .width + .map_or_else(|| "-".to_string(), |w| w.value_nm.to_string()); + let layer = layer_to_model(track.layer).name; + let net = track + .net + .map(|n| format!("{}:{}", n.code.map_or(0, |c| c.value), n.name)) + .unwrap_or_else(|| "-".to_string()); + + return Ok(format!( + "track id={id} start_nm={start} end_nm={end} width_nm={width} layer={layer} net={net}" + )); + } + + if item.type_url == envelope::type_url("kiapi.board.types.FootprintInstance") { + let fp = decode_any::( + item, + "kiapi.board.types.FootprintInstance", + )?; + let id = fp.id.map_or_else(|| "-".to_string(), |id| id.value); + let reference = fp + .reference_field + .as_ref() + .and_then(|field| field.text.as_ref()) + .and_then(|board_text| board_text.text.as_ref()) + .map(|text| text.text.clone()) + .unwrap_or_else(|| "-".to_string()); + let position = fp + .position + .map_or_else(|| "-".to_string(), |v| format!("{},{}", v.x_nm, v.y_nm)); + let layer = layer_to_model(fp.layer).name; + return Ok(format!( + "footprint id={id} ref={reference} pos_nm={position} layer={layer}" + )); + } + + if item.type_url == envelope::type_url("kiapi.board.types.Field") { + let field = decode_any::(item, "kiapi.board.types.Field")?; + let text = field + .text + .as_ref() + .and_then(|board_text| board_text.text.as_ref()) + .map(|text| text.text.clone()) + .unwrap_or_else(|| "-".to_string()); + return Ok(format!( + "field name={} visible={} text={}", + field.name, field.visible, text + )); + } + + if item.type_url == envelope::type_url("kiapi.board.types.BoardGraphicShape") { + let shape = decode_any::( + item, + "kiapi.board.types.BoardGraphicShape", + )?; + let id = shape.id.map_or_else(|| "-".to_string(), |id| id.value); + let layer = layer_to_model(shape.layer).name; + let net = shape + .net + .map(|n| format!("{}:{}", n.code.map_or(0, |c| c.value), n.name)) + .unwrap_or_else(|| "-".to_string()); + return Ok(format!("graphic id={id} layer={layer} net={net}")); + } + + Ok(format!("unparsed payload ({} bytes)", item.value.len())) +} + fn select_single_board_document( docs: &[DocumentSpecifier], ) -> Result<&DocumentSpecifier, KiCadError> { @@ -562,10 +790,12 @@ fn default_client_name() -> String { mod tests { use super::{ layer_to_model, model_document_to_proto, normalize_socket_uri, - select_single_board_document, select_single_project_path, summarize_selection, + pad_netlist_from_footprint_items, select_single_board_document, select_single_project_path, + selection_item_detail, summarize_selection, }; use crate::error::KiCadError; use crate::model::common::{DocumentSpecifier, DocumentType, ProjectInfo}; + use prost::Message; use std::path::PathBuf; #[test] @@ -726,4 +956,120 @@ mod tests { "type.googleapis.com/kiapi.board.types.Via" ); } + + #[test] + fn selection_item_detail_reports_track_fields() { + let track = crate::proto::kiapi::board::types::Track { + id: Some(crate::proto::kiapi::common::types::Kiid { + value: "track-id".to_string(), + }), + start: Some(crate::proto::kiapi::common::types::Vector2 { x_nm: 1, y_nm: 2 }), + end: Some(crate::proto::kiapi::common::types::Vector2 { x_nm: 3, y_nm: 4 }), + width: Some(crate::proto::kiapi::common::types::Distance { value_nm: 99 }), + locked: 0, + layer: crate::proto::kiapi::board::types::BoardLayer::BlFCu as i32, + net: Some(crate::proto::kiapi::board::types::Net { + code: Some(crate::proto::kiapi::board::types::NetCode { value: 12 }), + name: "GND".to_string(), + }), + }; + + let item = prost_types::Any { + type_url: super::envelope::type_url("kiapi.board.types.Track"), + value: track.encode_to_vec(), + }; + + let detail = selection_item_detail(&item).expect("track detail should decode"); + assert!(detail.contains("track id=track-id")); + assert!(detail.contains("layer=BL_F_Cu")); + assert!(detail.contains("net=12:GND")); + } + + #[test] + fn pad_netlist_from_footprint_items_extracts_pad_entries() { + let pad = crate::proto::kiapi::board::types::Pad { + id: Some(crate::proto::kiapi::common::types::Kiid { + value: "pad-id".to_string(), + }), + locked: 0, + number: "1".to_string(), + net: Some(crate::proto::kiapi::board::types::Net { + code: Some(crate::proto::kiapi::board::types::NetCode { value: 5 }), + name: "Net-(P1-PM)".to_string(), + }), + r#type: crate::proto::kiapi::board::types::PadType::PtPth as i32, + pad_stack: None, + position: None, + copper_clearance_override: None, + pad_to_die_length: None, + symbol_pin: None, + pad_to_die_delay: None, + }; + + let footprint = crate::proto::kiapi::board::types::FootprintInstance { + id: Some(crate::proto::kiapi::common::types::Kiid { + value: "fp-id".to_string(), + }), + position: None, + orientation: None, + layer: crate::proto::kiapi::board::types::BoardLayer::BlFCu as i32, + locked: 0, + definition: Some(crate::proto::kiapi::board::types::Footprint { + id: None, + anchor: None, + attributes: None, + overrides: None, + net_ties: Vec::new(), + private_layers: Vec::new(), + reference_field: None, + value_field: None, + datasheet_field: None, + description_field: None, + items: vec![prost_types::Any { + type_url: super::envelope::type_url("kiapi.board.types.Pad"), + value: pad.encode_to_vec(), + }], + jumpers: None, + }), + reference_field: Some(crate::proto::kiapi::board::types::Field { + id: None, + name: "Reference".to_string(), + text: Some(crate::proto::kiapi::board::types::BoardText { + id: None, + text: Some(crate::proto::kiapi::common::types::Text { + position: None, + attributes: None, + text: "P1".to_string(), + hyperlink: String::new(), + }), + layer: 0, + knockout: false, + locked: 0, + }), + visible: true, + }), + value_field: None, + datasheet_field: None, + description_field: None, + attributes: None, + overrides: None, + symbol_path: None, + symbol_sheet_name: String::new(), + symbol_sheet_filename: String::new(), + symbol_footprint_filters: String::new(), + }; + + let items = vec![prost_types::Any { + type_url: super::envelope::type_url("kiapi.board.types.FootprintInstance"), + value: footprint.encode_to_vec(), + }]; + + let netlist = pad_netlist_from_footprint_items(items) + .expect("pad netlist should decode from footprint"); + assert_eq!(netlist.len(), 1); + let entry = &netlist[0]; + assert_eq!(entry.footprint_reference.as_deref(), Some("P1")); + assert_eq!(entry.pad_number, "1"); + assert_eq!(entry.net_code, Some(5)); + } } diff --git a/src/error.rs b/src/error.rs index 256d4ea..39e8f0a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -28,6 +28,9 @@ pub enum KiCadError { #[error("API status error `{code}`: {message}")] ApiStatus { code: String, message: String }, + #[error("item request status error `{code}`")] + ItemStatus { code: String }, + #[error("API response missing payload for `{expected_type_url}`")] MissingPayload { expected_type_url: String }, diff --git a/src/lib.rs b/src/lib.rs index 3db6ee3..6970844 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,8 +21,9 @@ pub(crate) mod proto; pub use crate::client::{ClientBuilder, KiCadClient}; pub use crate::error::KiCadError; pub use crate::model::board::{ - BoardEnabledLayers, BoardLayerInfo, BoardNet, BoardOriginKind, Vector2Nm, + BoardEnabledLayers, BoardLayerInfo, BoardNet, BoardOriginKind, PadNetEntry, Vector2Nm, }; pub use crate::model::common::{ - DocumentSpecifier, DocumentType, SelectionSummary, SelectionTypeCount, VersionInfo, + DocumentSpecifier, DocumentType, SelectionItemDetail, SelectionSummary, SelectionTypeCount, + VersionInfo, }; diff --git a/src/model/board.rs b/src/model/board.rs index 851417f..c998574 100644 --- a/src/model/board.rs +++ b/src/model/board.rs @@ -53,6 +53,16 @@ pub struct Vector2Nm { pub y_nm: i64, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PadNetEntry { + pub footprint_reference: Option, + pub footprint_id: Option, + pub pad_id: Option, + pub pad_number: String, + pub net_code: Option, + pub net_name: Option, +} + #[cfg(test)] mod tests { use std::str::FromStr; diff --git a/src/model/common.rs b/src/model/common.rs index cfba36f..8a11631 100644 --- a/src/model/common.rs +++ b/src/model/common.rs @@ -104,3 +104,10 @@ pub struct SelectionSummary { pub total_items: usize, pub type_url_counts: Vec, } + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SelectionItemDetail { + pub type_url: String, + pub detail: String, + pub raw_len: usize, +} diff --git a/test-scripts/kicad-ipc-cli.rs b/test-scripts/kicad-ipc-cli.rs index d457176..3e06c8f 100644 --- a/test-scripts/kicad-ipc-cli.rs +++ b/test-scripts/kicad-ipc-cli.rs @@ -24,6 +24,9 @@ enum Command { VisibleLayers, BoardOrigin { kind: BoardOriginKind }, SelectionSummary, + SelectionDetails, + SelectionRaw, + NetlistPads, Smoke, Help, } @@ -166,6 +169,46 @@ async fn run() -> Result<(), KiCadError> { println!("type_url={} count={}", entry.type_url, entry.count); } } + Command::SelectionDetails => { + let details = client.get_selection_details().await?; + println!("selection_total={}", details.len()); + for (index, item) in details.iter().enumerate() { + println!( + "[{index}] type_url={} raw_len={} detail={}", + item.type_url, item.raw_len, item.detail + ); + } + } + Command::SelectionRaw => { + let items = client.get_selection_raw().await?; + println!("selection_total={}", items.len()); + for (index, item) in items.iter().enumerate() { + println!( + "[{index}] type_url={} raw_len={} raw_hex={}", + item.type_url, + item.value.len(), + bytes_to_hex(&item.value) + ); + } + } + Command::NetlistPads => { + let entries = client.get_pad_netlist().await?; + println!("pad_net_entries={}", entries.len()); + for entry in entries { + println!( + "footprint_ref={} footprint_id={} pad_id={} pad_number={} net_code={} net_name={}", + entry.footprint_reference.as_deref().unwrap_or("-"), + entry.footprint_id.as_deref().unwrap_or("-"), + entry.pad_id.as_deref().unwrap_or("-"), + entry.pad_number, + entry + .net_code + .map(|code| code.to_string()) + .unwrap_or_else(|| "-".to_string()), + entry.net_name.as_deref().unwrap_or("-") + ); + } + } Command::Smoke => { client.ping().await?; let version = client.get_version().await?; @@ -254,6 +297,9 @@ fn parse_args() -> Result<(CliConfig, Command), KiCadError> { Command::BoardOrigin { kind } } "selection-summary" => Command::SelectionSummary, + "selection-details" => Command::SelectionDetails, + "selection-raw" => Command::SelectionRaw, + "netlist-pads" => Command::NetlistPads, "smoke" => Command::Smoke, "open-docs" => { let mut document_type = DocumentType::Pcb; @@ -292,6 +338,23 @@ fn default_config() -> CliConfig { fn print_help() { println!( - "kicad-ipc-cli\n\nUSAGE:\n cargo run --bin kicad-ipc-cli -- [--socket URI] [--token TOKEN] [--timeout-ms N] [command options]\n\nCOMMANDS:\n ping Check IPC connectivity\n version Fetch KiCad version\n open-docs [--type ] List open docs (default type: pcb)\n project-path Get current project path from open PCB docs\n board-open Exit non-zero if no PCB doc is open\n nets List board nets (requires one open PCB)\n enabled-layers List enabled board layers\n active-layer Show active board layer\n visible-layers Show currently visible board layers\n board-origin [--type ] Show board origin (`grid` default, or `drill`)\n selection-summary Show current selection item type counts\n smoke ping + version + board-open summary\n help Show help\n\nTYPES:\n schematic | symbol | pcb | footprint | drawing-sheet | project\n" + "kicad-ipc-cli\n\nUSAGE:\n cargo run --bin kicad-ipc-cli -- [--socket URI] [--token TOKEN] [--timeout-ms N] [command options]\n\nCOMMANDS:\n ping Check IPC connectivity\n version Fetch KiCad version\n open-docs [--type ] List open docs (default type: pcb)\n project-path Get current project path from open PCB docs\n board-open Exit non-zero if no PCB doc is open\n nets List board nets (requires one open PCB)\n netlist-pads Emit pad-level netlist data (with footprint context)\n enabled-layers List enabled board layers\n active-layer Show active board layer\n visible-layers Show currently visible board layers\n board-origin [--type ] Show board origin (`grid` default, or `drill`)\n selection-summary Show current selection item type counts\n selection-details Show parsed details for selected items\n selection-raw Show raw Any payload bytes for selected items\n smoke ping + version + board-open summary\n help Show help\n\nTYPES:\n schematic | symbol | pcb | footprint | drawing-sheet | project\n" ); } + +fn bytes_to_hex(data: &[u8]) -> String { + let mut output = String::with_capacity(data.len() * 2); + for byte in data { + output.push(hex_char((byte >> 4) & 0x0f)); + output.push(hex_char(byte & 0x0f)); + } + output +} + +fn hex_char(value: u8) -> char { + match value { + 0..=9 => char::from(b'0' + value), + 10..=15 => char::from(b'a' + (value - 10)), + _ => '?', + } +}