feat: add selection detail and pad netlist APIs

This commit is contained in:
Milind Sharma 2026-02-19 11:54:09 +08:00
parent 7e76a1396d
commit 08c2b6f919
7 changed files with 454 additions and 6 deletions

View File

@ -83,6 +83,24 @@ Show summary of current PCB selection by item type:
cargo run --bin kicad-ipc-cli -- selection-summary 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): Get current project path (derived from open PCB docs):
```bash ```bash

View File

@ -6,10 +6,11 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::envelope; use crate::envelope;
use crate::error::KiCadError; use crate::error::KiCadError;
use crate::model::board::{ use crate::model::board::{
BoardEnabledLayers, BoardLayerInfo, BoardNet, BoardOriginKind, Vector2Nm, BoardEnabledLayers, BoardLayerInfo, BoardNet, BoardOriginKind, PadNetEntry, Vector2Nm,
}; };
use crate::model::common::{ 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::commands as board_commands;
use crate::proto::kiapi::board::types as board_types; 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_VISIBLE_LAYERS: &str = "kiapi.board.commands.GetVisibleLayers";
const CMD_GET_BOARD_ORIGIN: &str = "kiapi.board.commands.GetBoardOrigin"; const CMD_GET_BOARD_ORIGIN: &str = "kiapi.board.commands.GetBoardOrigin";
const CMD_GET_SELECTION: &str = "kiapi.common.commands.GetSelection"; 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_VERSION: &str = "kiapi.common.commands.GetVersionResponse";
const RES_GET_OPEN_DOCUMENTS: &str = "kiapi.common.commands.GetOpenDocumentsResponse"; 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_BOARD_LAYERS: &str = "kiapi.board.commands.BoardLayers";
const RES_VECTOR2: &str = "kiapi.common.types.Vector2"; const RES_VECTOR2: &str = "kiapi.common.types.Vector2";
const RES_SELECTION_RESPONSE: &str = "kiapi.common.commands.SelectionResponse"; const RES_SELECTION_RESPONSE: &str = "kiapi.common.commands.SelectionResponse";
const RES_GET_ITEMS_RESPONSE: &str = "kiapi.common.commands.GetItemsResponse";
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct KiCadClient { pub struct KiCadClient {
@ -314,6 +317,51 @@ impl KiCadClient {
Ok(summarize_selection(payload.items)) Ok(summarize_selection(payload.items))
} }
pub async fn get_selection_raw(&self) -> Result<Vec<prost_types::Any>, 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<Vec<SelectionItemDetail>, 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<Vec<PadNetEntry>, 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( async fn send_command(
&self, &self,
command: prost_types::Any, command: prost_types::Any,
@ -357,6 +405,36 @@ impl KiCadClient {
let selected = select_single_board_document(&docs)?; let selected = select_single_board_document(&docs)?;
Ok(model_document_to_proto(selected)) Ok(model_document_to_proto(selected))
} }
async fn get_items_raw(&self, types: Vec<i32>) -> Result<Vec<prost_types::Any>, 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<DocumentSpecifier> { fn map_document_specifier(source: common_types::DocumentSpecifier) -> Option<DocumentSpecifier> {
@ -444,6 +522,156 @@ fn summarize_selection(items: Vec<prost_types::Any>) -> SelectionSummary {
} }
} }
fn decode_any<T: prost::Message + Default>(
payload: &prost_types::Any,
expected_type_name: &str,
) -> Result<T, KiCadError> {
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<prost_types::Any>,
) -> Result<Vec<PadNetEntry>, 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::<board_types::FootprintInstance>(
&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::<board_types::Pad>(&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<String, KiCadError> {
if item.type_url == envelope::type_url("kiapi.board.types.Track") {
let track = decode_any::<board_types::Track>(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::<board_types::FootprintInstance>(
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::<board_types::Field>(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::<board_types::BoardGraphicShape>(
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( fn select_single_board_document(
docs: &[DocumentSpecifier], docs: &[DocumentSpecifier],
) -> Result<&DocumentSpecifier, KiCadError> { ) -> Result<&DocumentSpecifier, KiCadError> {
@ -562,10 +790,12 @@ fn default_client_name() -> String {
mod tests { mod tests {
use super::{ use super::{
layer_to_model, model_document_to_proto, normalize_socket_uri, 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::error::KiCadError;
use crate::model::common::{DocumentSpecifier, DocumentType, ProjectInfo}; use crate::model::common::{DocumentSpecifier, DocumentType, ProjectInfo};
use prost::Message;
use std::path::PathBuf; use std::path::PathBuf;
#[test] #[test]
@ -726,4 +956,120 @@ mod tests {
"type.googleapis.com/kiapi.board.types.Via" "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));
}
} }

View File

@ -28,6 +28,9 @@ pub enum KiCadError {
#[error("API status error `{code}`: {message}")] #[error("API status error `{code}`: {message}")]
ApiStatus { code: String, message: String }, ApiStatus { code: String, message: String },
#[error("item request status error `{code}`")]
ItemStatus { code: String },
#[error("API response missing payload for `{expected_type_url}`")] #[error("API response missing payload for `{expected_type_url}`")]
MissingPayload { expected_type_url: String }, MissingPayload { expected_type_url: String },

View File

@ -21,8 +21,9 @@ pub(crate) mod proto;
pub use crate::client::{ClientBuilder, KiCadClient}; pub use crate::client::{ClientBuilder, KiCadClient};
pub use crate::error::KiCadError; pub use crate::error::KiCadError;
pub use crate::model::board::{ pub use crate::model::board::{
BoardEnabledLayers, BoardLayerInfo, BoardNet, BoardOriginKind, Vector2Nm, BoardEnabledLayers, BoardLayerInfo, BoardNet, BoardOriginKind, PadNetEntry, Vector2Nm,
}; };
pub use crate::model::common::{ pub use crate::model::common::{
DocumentSpecifier, DocumentType, SelectionSummary, SelectionTypeCount, VersionInfo, DocumentSpecifier, DocumentType, SelectionItemDetail, SelectionSummary, SelectionTypeCount,
VersionInfo,
}; };

View File

@ -53,6 +53,16 @@ pub struct Vector2Nm {
pub y_nm: i64, pub y_nm: i64,
} }
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PadNetEntry {
pub footprint_reference: Option<String>,
pub footprint_id: Option<String>,
pub pad_id: Option<String>,
pub pad_number: String,
pub net_code: Option<i32>,
pub net_name: Option<String>,
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::str::FromStr; use std::str::FromStr;

View File

@ -104,3 +104,10 @@ pub struct SelectionSummary {
pub total_items: usize, pub total_items: usize,
pub type_url_counts: Vec<SelectionTypeCount>, pub type_url_counts: Vec<SelectionTypeCount>,
} }
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SelectionItemDetail {
pub type_url: String,
pub detail: String,
pub raw_len: usize,
}

View File

@ -24,6 +24,9 @@ enum Command {
VisibleLayers, VisibleLayers,
BoardOrigin { kind: BoardOriginKind }, BoardOrigin { kind: BoardOriginKind },
SelectionSummary, SelectionSummary,
SelectionDetails,
SelectionRaw,
NetlistPads,
Smoke, Smoke,
Help, Help,
} }
@ -166,6 +169,46 @@ async fn run() -> Result<(), KiCadError> {
println!("type_url={} count={}", entry.type_url, entry.count); 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 => { Command::Smoke => {
client.ping().await?; client.ping().await?;
let version = client.get_version().await?; let version = client.get_version().await?;
@ -254,6 +297,9 @@ fn parse_args() -> Result<(CliConfig, Command), KiCadError> {
Command::BoardOrigin { kind } Command::BoardOrigin { kind }
} }
"selection-summary" => Command::SelectionSummary, "selection-summary" => Command::SelectionSummary,
"selection-details" => Command::SelectionDetails,
"selection-raw" => Command::SelectionRaw,
"netlist-pads" => Command::NetlistPads,
"smoke" => Command::Smoke, "smoke" => Command::Smoke,
"open-docs" => { "open-docs" => {
let mut document_type = DocumentType::Pcb; let mut document_type = DocumentType::Pcb;
@ -292,6 +338,23 @@ fn default_config() -> CliConfig {
fn print_help() { fn print_help() {
println!( println!(
"kicad-ipc-cli\n\nUSAGE:\n cargo run --bin kicad-ipc-cli -- [--socket URI] [--token TOKEN] [--timeout-ms N] <command> [command options]\n\nCOMMANDS:\n ping Check IPC connectivity\n version Fetch KiCad version\n open-docs [--type <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 <t>] 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> [command options]\n\nCOMMANDS:\n ping Check IPC connectivity\n version Fetch KiCad version\n open-docs [--type <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 <t>] 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)),
_ => '?',
}
}