From 01ed710ae29be808a849354e04e1bbbdaa591035 Mon Sep 17 00:00:00 2001 From: Milind Sharma Date: Fri, 20 Feb 2026 18:34:23 +0800 Subject: [PATCH] feat(common): add CreateItems API and CLI command --- README.md | 7 +- docs/TEST_CLI.md | 6 ++ src/client.rs | 84 +++++++++++++++++++++++- test-scripts/kicad-ipc-cli.rs | 120 +++++++++++++++++++++++++++++++++- 4 files changed, 211 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 40bdd82..721d1fd 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Deferred manual/runtime verification (implemented after 2026-02-20 while user un - `SaveCopyOfDocument` - `RevertDocument` - `RunAction` +- `CreateItems` ## KiCad v10 RC1.1 API Completion Matrix @@ -59,11 +60,11 @@ Legend: | Section | Proto Commands | Implemented | Coverage | | --- | ---: | ---: | ---: | | Common (base) | 6 | 6 | 100% | -| Common editor/document | 23 | 19 | 83% | +| Common editor/document | 23 | 20 | 87% | | Project manager | 5 | 3 | 60% | | Board editor (PCB) | 22 | 20 | 91% | | Schematic editor (dedicated proto commands) | 0 | 0 | n/a | -| **Total** | **56** | **48** | **86%** | +| **Total** | **56** | **49** | **88%** | ### Common (base) @@ -88,7 +89,7 @@ Legend: | `RunAction` | Implemented | `KiCadClient::run_action_raw`, `KiCadClient::run_action` | | `BeginCommit` | Implemented | `KiCadClient::begin_commit_raw`, `KiCadClient::begin_commit` | | `EndCommit` | Implemented | `KiCadClient::end_commit_raw`, `KiCadClient::end_commit` | -| `CreateItems` | Not yet | - | +| `CreateItems` | Implemented | `KiCadClient::create_items_raw`, `KiCadClient::create_items` | | `GetItems` | Implemented | `KiCadClient::get_items_raw_by_type_codes`, `KiCadClient::get_items_by_type_codes`, `KiCadClient::get_items_details_by_type_codes`, `KiCadClient::get_all_pcb_items_raw`, `KiCadClient::get_all_pcb_items`, `KiCadClient::get_all_pcb_items_details`, `KiCadClient::get_pad_netlist` | | `GetItemsById` | Implemented | `KiCadClient::get_items_by_id_raw`, `KiCadClient::get_items_by_id`, `KiCadClient::get_items_by_id_details` | | `UpdateItems` | Not yet | - | diff --git a/docs/TEST_CLI.md b/docs/TEST_CLI.md index bab07a3..1d53d60 100644 --- a/docs/TEST_CLI.md +++ b/docs/TEST_CLI.md @@ -187,6 +187,12 @@ Run a raw KiCad tool action: cargo run --bin kicad-ipc-cli -- run-action --action pcbnew.InteractiveSelection.ClearSelection ``` +Create raw Any item payload(s): + +```bash +cargo run --bin kicad-ipc-cli -- create-items --item type.googleapis.com/kiapi.board.types.Text= +``` + Show summary of current PCB selection by item type: ```bash diff --git a/src/client.rs b/src/client.rs index d27ebd7..ba03b73 100644 --- a/src/client.rs +++ b/src/client.rs @@ -76,6 +76,7 @@ const CMD_REMOVE_FROM_SELECTION: &str = "kiapi.common.commands.RemoveFromSelecti const CMD_CLEAR_SELECTION: &str = "kiapi.common.commands.ClearSelection"; const CMD_BEGIN_COMMIT: &str = "kiapi.common.commands.BeginCommit"; const CMD_END_COMMIT: &str = "kiapi.common.commands.EndCommit"; +const CMD_CREATE_ITEMS: &str = "kiapi.common.commands.CreateItems"; const CMD_GET_ITEMS: &str = "kiapi.common.commands.GetItems"; const CMD_GET_ITEMS_BY_ID: &str = "kiapi.common.commands.GetItemsById"; const CMD_GET_BOUNDING_BOX: &str = "kiapi.common.commands.GetBoundingBox"; @@ -114,6 +115,7 @@ const RES_VECTOR2: &str = "kiapi.common.types.Vector2"; const RES_SELECTION_RESPONSE: &str = "kiapi.common.commands.SelectionResponse"; const RES_BEGIN_COMMIT_RESPONSE: &str = "kiapi.common.commands.BeginCommitResponse"; const RES_END_COMMIT_RESPONSE: &str = "kiapi.common.commands.EndCommitResponse"; +const RES_CREATE_ITEMS_RESPONSE: &str = "kiapi.common.commands.CreateItemsResponse"; const RES_GET_ITEMS_RESPONSE: &str = "kiapi.common.commands.GetItemsResponse"; const RES_GET_BOUNDING_BOX_RESPONSE: &str = "kiapi.common.commands.GetBoundingBoxResponse"; const RES_HIT_TEST_RESPONSE: &str = "kiapi.common.commands.HitTestResponse"; @@ -616,6 +618,45 @@ impl KiCadClient { Ok(()) } + pub async fn create_items_raw( + &self, + items: Vec, + container_id: Option, + ) -> Result { + let command = common_commands::CreateItems { + header: Some(self.current_board_item_header().await?), + items, + container: container_id.map(|value| common_types::Kiid { value }), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_CREATE_ITEMS)) + .await?; + response_payload_as_any(response, RES_CREATE_ITEMS_RESPONSE) + } + + pub async fn create_items( + &self, + items: Vec, + container_id: Option, + ) -> Result, KiCadError> { + let payload = self.create_items_raw(items, container_id).await?; + let response: common_commands::CreateItemsResponse = + decode_any(&payload, RES_CREATE_ITEMS_RESPONSE)?; + ensure_item_request_ok(response.status)?; + + response + .created_items + .into_iter() + .map(|row| { + ensure_item_status_ok(row.status)?; + row.item.ok_or_else(|| KiCadError::InvalidResponse { + reason: "CreateItemsResponse missing created item payload".to_string(), + }) + }) + .collect() + } + pub async fn get_nets(&self) -> Result, KiCadError> { let board = self.current_board_document_proto().await?; let command = board_commands::GetNets { @@ -2063,6 +2104,24 @@ fn ensure_item_request_ok(status: i32) -> Result<(), KiCadError> { Ok(()) } +fn ensure_item_status_ok(status: Option) -> Result<(), KiCadError> { + let status = status.unwrap_or_default(); + let code = common_commands::ItemStatusCode::try_from(status.code) + .unwrap_or(common_commands::ItemStatusCode::IscUnknown); + + if code != common_commands::ItemStatusCode::IscOk { + let detail = if status.error_message.is_empty() { + code.as_str_name().to_string() + } else { + format!("{}: {}", code.as_str_name(), status.error_message) + }; + + return Err(KiCadError::ItemStatus { code: detail }); + } + + Ok(()) +} + fn map_item_bounding_boxes( item_ids: Vec, boxes: Vec, @@ -3184,8 +3243,8 @@ fn default_client_name() -> String { mod tests { use super::{ any_to_pretty_debug, board_editor_appearance_settings_to_proto, commit_action_to_proto, - drc_severity_to_proto, ensure_item_request_ok, layer_to_model, map_commit_session, - map_hit_test_result, map_item_bounding_boxes, map_polygon_with_holes, + drc_severity_to_proto, ensure_item_request_ok, ensure_item_status_ok, layer_to_model, + map_commit_session, map_hit_test_result, map_item_bounding_boxes, map_polygon_with_holes, map_run_action_status, model_document_to_proto, normalize_socket_uri, pad_netlist_from_footprint_items, response_payload_as_any, select_single_board_document, select_single_project_path, selection_item_detail, summarize_item_details, @@ -3595,6 +3654,27 @@ mod tests { .is_err()); } + #[test] + fn ensure_item_status_ok_accepts_ok_and_rejects_non_ok() { + assert!( + ensure_item_status_ok(Some(crate::proto::kiapi::common::commands::ItemStatus { + code: crate::proto::kiapi::common::commands::ItemStatusCode::IscOk as i32, + error_message: String::new(), + })) + .is_ok() + ); + + let err = ensure_item_status_ok(Some(crate::proto::kiapi::common::commands::ItemStatus { + code: crate::proto::kiapi::common::commands::ItemStatusCode::IscInvalidType as i32, + error_message: "bad item type".to_string(), + })) + .expect_err("non-OK item status should fail"); + match err { + KiCadError::ItemStatus { code } => assert!(code.contains("ISC_INVALID_TYPE")), + _ => panic!("expected item status error"), + } + } + #[test] fn summarize_item_details_reports_unknown_payload_as_unparsed() { let items = vec![prost_types::Any { diff --git a/test-scripts/kicad-ipc-cli.rs b/test-scripts/kicad-ipc-cli.rs index 779d3aa..05bd803 100644 --- a/test-scripts/kicad-ipc-cli.rs +++ b/test-scripts/kicad-ipc-cli.rs @@ -100,6 +100,10 @@ enum Command { RunAction { action: String, }, + CreateItems { + items: Vec, + container_id: Option, + }, AddToSelection { item_ids: Vec, }, @@ -474,6 +478,20 @@ async fn run() -> Result<(), KiCadError> { let status = client.run_action(action).await?; println!("run_action_status={status:?}"); } + Command::CreateItems { + items, + container_id, + } => { + let created = client.create_items(items, container_id).await?; + println!("created_item_count={}", created.len()); + for (index, item) in created.iter().enumerate() { + println!( + "[{index}] type_url={} raw_len={}", + item.type_url, + item.value.len() + ); + } + } Command::AddToSelection { item_ids } => { let summary = client.add_to_selection(item_ids).await?; println!("selection_total={}", summary.total_items); @@ -1334,6 +1352,51 @@ fn parse_args_from(mut args: Vec) -> Result<(CliConfig, Command), KiCadE })?, } } + "create-items" => { + let mut items = Vec::new(); + let mut container_id = None; + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--item" => { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for create-items --item".to_string(), + })?; + let (type_url, hex) = + value.split_once('=').ok_or_else(|| KiCadError::Config { + reason: "create-items --item requires `=`" + .to_string(), + })?; + items.push(prost_types::Any { + type_url: type_url.to_string(), + value: hex_to_bytes(hex) + .map_err(|reason| KiCadError::Config { reason })?, + }); + i += 2; + } + "--container-id" => { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for create-items --container-id".to_string(), + })?; + container_id = Some(value.clone()); + i += 2; + } + _ => i += 1, + } + } + + if items.is_empty() { + return Err(KiCadError::Config { + reason: "create-items requires one or more `--item =` values" + .to_string(), + }); + } + + Command::CreateItems { + items, + container_id, + } + } "add-to-selection" => { let item_ids = parse_item_ids(&args[1..], "add-to-selection")?; Command::AddToSelection { item_ids } @@ -1808,7 +1871,7 @@ fn default_config() -> CliConfig { fn print_help() { println!( - "kicad-ipc-cli\n\nUSAGE:\n cargo run --bin kicad-ipc-cli -- [--socket URI] [--token TOKEN] [--client-name NAME] [--timeout-ms N] [command options]\n\nCOMMANDS:\n ping Check IPC connectivity\n version Fetch KiCad version\n kicad-binary-path [--binary-name ]\n Resolve absolute path for a KiCad binary (default: kicad-cli)\n plugin-settings-path [--identifier ]\n Resolve writeable plugin settings directory (default: kicad-ipc-rust)\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 net-classes List project netclass definitions\n text-variables List text variables for current board document\n expand-text-variables Expand variables in provided text values\n Options: --text (repeatable)\n text-extents Measure text bounding box\n Options: --text \n text-as-shapes Convert text to rendered shapes\n Options: --text (repeatable)\n nets List board nets (requires one open PCB)\n netlist-pads Emit pad-level netlist data (with footprint context)\n items-by-id --id ... Show parsed details for specific item IDs\n item-bbox --id ... Show bounding boxes for item IDs\n hit-test --id --x-nm --y-nm [--tolerance-nm ]\n Hit-test one item at a point\n types-pcb List PCB KiCad object type IDs from proto enum\n items-raw --type-id ... Dump raw Any payloads for requested item type IDs\n items-raw-all-pcb [--debug] Dump all PCB item payloads across all PCB object types\n pad-shape-polygon --pad-id ... --layer-id [--debug]\n Dump pad polygons on a target layer\n padstack-presence --item-id ... --layer-id ... [--debug]\n Check padstack shape presence matrix across layers\n title-block Show title block fields\n board-as-string Dump board as KiCad s-expression text\n selection-as-string Dump current selection as KiCad s-expression text\n stackup Show typed board stackup\n graphics-defaults Show typed graphics defaults\n appearance Show typed editor appearance settings\n set-appearance --inactive-layer-display \n --net-color-display \n --board-flip \n --ratsnest-display \n Set editor appearance settings\n inject-drc-error --severity --message [--x-nm --y-nm ] [--item-id ...]\n Inject a DRC marker (severity: warning|error|exclusion|ignore|info|action|debug|undefined)\n refill-zones [--zone-id ...]\n Refill all zones or a provided subset\n netclass Show typed netclass map for current board nets\n proto-coverage-board-read Print board-read command coverage vs proto\n board-read-report [--out P] Write markdown board reconstruction report\n enabled-layers List enabled board layers\n set-enabled-layers --copper-layer-count [--layer-id ...]\n Set enabled board layer set\n active-layer Show active board layer\n set-active-layer --layer-id \n Set active board layer\n visible-layers Show currently visible board layers\n set-visible-layers --layer-id ...\n Set visible board layers\n board-origin [--type ] Show board origin (`grid` default, or `drill`)\n set-board-origin --type --x-nm --y-nm \n Set board origin (`grid` or `drill`)\n refresh-editor [--frame ] Refresh a specific editor frame (default: pcb)\n begin-commit Start staged commit and print commit ID\n end-commit --id [--action ] [--message ]\n End staged commit with commit/drop action\n save-doc Save current board document\n save-copy --path [--overwrite] [--include-project]\n Save current board document to a new location\n revert-doc Revert current board document from disk\n run-action --action Run a raw KiCad tool action\n add-to-selection --id ...\n Add items to current selection\n remove-from-selection --id ...\n Remove items from current selection\n clear-selection Clear current item selection\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" + "kicad-ipc-cli\n\nUSAGE:\n cargo run --bin kicad-ipc-cli -- [--socket URI] [--token TOKEN] [--client-name NAME] [--timeout-ms N] [command options]\n\nCOMMANDS:\n ping Check IPC connectivity\n version Fetch KiCad version\n kicad-binary-path [--binary-name ]\n Resolve absolute path for a KiCad binary (default: kicad-cli)\n plugin-settings-path [--identifier ]\n Resolve writeable plugin settings directory (default: kicad-ipc-rust)\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 net-classes List project netclass definitions\n text-variables List text variables for current board document\n expand-text-variables Expand variables in provided text values\n Options: --text (repeatable)\n text-extents Measure text bounding box\n Options: --text \n text-as-shapes Convert text to rendered shapes\n Options: --text (repeatable)\n nets List board nets (requires one open PCB)\n netlist-pads Emit pad-level netlist data (with footprint context)\n items-by-id --id ... Show parsed details for specific item IDs\n item-bbox --id ... Show bounding boxes for item IDs\n hit-test --id --x-nm --y-nm [--tolerance-nm ]\n Hit-test one item at a point\n types-pcb List PCB KiCad object type IDs from proto enum\n items-raw --type-id ... Dump raw Any payloads for requested item type IDs\n items-raw-all-pcb [--debug] Dump all PCB item payloads across all PCB object types\n pad-shape-polygon --pad-id ... --layer-id [--debug]\n Dump pad polygons on a target layer\n padstack-presence --item-id ... --layer-id ... [--debug]\n Check padstack shape presence matrix across layers\n title-block Show title block fields\n board-as-string Dump board as KiCad s-expression text\n selection-as-string Dump current selection as KiCad s-expression text\n stackup Show typed board stackup\n graphics-defaults Show typed graphics defaults\n appearance Show typed editor appearance settings\n set-appearance --inactive-layer-display \n --net-color-display \n --board-flip \n --ratsnest-display \n Set editor appearance settings\n inject-drc-error --severity --message [--x-nm --y-nm ] [--item-id ...]\n Inject a DRC marker (severity: warning|error|exclusion|ignore|info|action|debug|undefined)\n refill-zones [--zone-id ...]\n Refill all zones or a provided subset\n netclass Show typed netclass map for current board nets\n proto-coverage-board-read Print board-read command coverage vs proto\n board-read-report [--out P] Write markdown board reconstruction report\n enabled-layers List enabled board layers\n set-enabled-layers --copper-layer-count [--layer-id ...]\n Set enabled board layer set\n active-layer Show active board layer\n set-active-layer --layer-id \n Set active board layer\n visible-layers Show currently visible board layers\n set-visible-layers --layer-id ...\n Set visible board layers\n board-origin [--type ] Show board origin (`grid` default, or `drill`)\n set-board-origin --type --x-nm --y-nm \n Set board origin (`grid` or `drill`)\n refresh-editor [--frame ] Refresh a specific editor frame (default: pcb)\n begin-commit Start staged commit and print commit ID\n end-commit --id [--action ] [--message ]\n End staged commit with commit/drop action\n save-doc Save current board document\n save-copy --path [--overwrite] [--include-project]\n Save current board document to a new location\n revert-doc Revert current board document from disk\n run-action --action Run a raw KiCad tool action\n create-items --item = ... [--container-id ]\n Create raw Any payload items in current board document\n add-to-selection --id ...\n Add items to current selection\n remove-from-selection --id ...\n Remove items from current selection\n clear-selection Clear current item selection\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" ); } @@ -2358,6 +2421,33 @@ fn hex_char(value: u8) -> char { } } +fn hex_to_bytes(hex: &str) -> Result, String> { + if !hex.len().is_multiple_of(2) { + return Err("hex payload must have an even number of characters".to_string()); + } + + let mut bytes = Vec::with_capacity(hex.len() / 2); + let chars: Vec = hex.chars().collect(); + let mut i = 0; + while i < chars.len() { + let high = hex_nibble(chars[i])?; + let low = hex_nibble(chars[i + 1])?; + bytes.push((high << 4) | low); + i += 2; + } + + Ok(bytes) +} + +fn hex_nibble(c: char) -> Result { + match c { + '0'..='9' => Ok((c as u8) - b'0'), + 'a'..='f' => Ok((c as u8) - b'a' + 10), + 'A'..='F' => Ok((c as u8) - b'A' + 10), + _ => Err(format!("invalid hex character `{c}`")), + } +} + #[cfg(test)] mod tests { use super::{parse_args_from, Command}; @@ -2571,6 +2661,34 @@ mod tests { } } + #[test] + fn parse_args_parses_create_items() { + let (_, command) = parse_args_from(vec![ + "create-items".to_string(), + "--item".to_string(), + "type.googleapis.com/kiapi.board.types.Text=0a00".to_string(), + "--container-id".to_string(), + "container-1".to_string(), + ]) + .expect("create-items args should parse"); + + match command { + Command::CreateItems { + items, + container_id, + } => { + assert_eq!(items.len(), 1); + assert_eq!( + items[0].type_url, + "type.googleapis.com/kiapi.board.types.Text" + ); + assert_eq!(items[0].value, vec![0x0a, 0x00]); + assert_eq!(container_id.as_deref(), Some("container-1")); + } + other => panic!("unexpected command variant: {other:?}"), + } + } + #[test] fn parse_args_parses_set_enabled_layers() { let (_, command) = parse_args_from(vec![