diff --git a/README.md b/README.md index 8e205b0..8d4b643 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,34 @@ Early scaffold phase. Core architecture + step-by-step implementation plan: - CLI runbook: `/Users/milindsharma/Developer/kicad-oss/kicad-ipc-rust/docs/TEST_CLI.md` +## Runtime Compatibility Notes (Current Test Rig) + +- Last verified: 2026-02-20 +- KiCad version (`kicad-ipc-cli version`): `10.0.0 (10.0.0-rc1)` + +Commands wrapped in this crate but currently unhandled/unsupported by this KiCad build: + +| Command | Runtime status | Notes | +| --- | --- | --- | +| `RefreshEditor` | `AS_UNHANDLED` | KiCad responds `no handler available for request of type kiapi.common.commands.RefreshEditor`. | + +Deferred manual/runtime verification (implemented after 2026-02-20 while user unavailable): + +- `GetKiCadBinaryPath` +- `GetPluginSettingsPath` +- `SaveDocument` +- `SaveCopyOfDocument` +- `RevertDocument` +- `RunAction` +- `CreateItems` +- `UpdateItems` +- `DeleteItems` +- `ParseAndCreateItemsFromString` +- `SetNetClasses` +- `SetTextVariables` +- `UpdateBoardStackup` +- `InteractiveMoveItems` + ## KiCad v10 RC1.1 API Completion Matrix Source of truth for this matrix: @@ -38,12 +66,12 @@ Legend: | Section | Proto Commands | Implemented | Coverage | | --- | ---: | ---: | ---: | -| Common (base) | 6 | 4 | 67% | -| Common editor/document | 23 | 9 | 39% | -| Project manager | 5 | 3 | 60% | -| Board editor (PCB) | 22 | 13 | 59% | +| Common (base) | 6 | 6 | 100% | +| Common editor/document | 23 | 23 | 100% | +| Project manager | 5 | 5 | 100% | +| Board editor (PCB) | 22 | 22 | 100% | | Schematic editor (dedicated proto commands) | 0 | 0 | n/a | -| **Total** | **56** | **29** | **52%** | +| **Total** | **56** | **56** | **100%** | ### Common (base) @@ -51,75 +79,75 @@ Legend: | --- | --- | --- | | `Ping` | Implemented | `KiCadClient::ping` | | `GetVersion` | Implemented | `KiCadClient::get_version` | -| `GetKiCadBinaryPath` | Not yet | - | +| `GetKiCadBinaryPath` | Implemented | `KiCadClient::get_kicad_binary_path_raw`, `KiCadClient::get_kicad_binary_path` | | `GetTextExtents` | Implemented | `KiCadClient::get_text_extents_raw`, `KiCadClient::get_text_extents` | | `GetTextAsShapes` | Implemented | `KiCadClient::get_text_as_shapes_raw`, `KiCadClient::get_text_as_shapes` | -| `GetPluginSettingsPath` | Not yet | - | +| `GetPluginSettingsPath` | Implemented | `KiCadClient::get_plugin_settings_path_raw`, `KiCadClient::get_plugin_settings_path` | ### Common editor/document | KiCad Command | Status | Rust API | | --- | --- | --- | -| `RefreshEditor` | Not yet | - | +| `RefreshEditor` | Implemented | `KiCadClient::refresh_editor` | | `GetOpenDocuments` | Implemented | `KiCadClient::get_open_documents`, `KiCadClient::get_current_project_path`, `KiCadClient::has_open_board` | -| `SaveDocument` | Not yet | - | -| `SaveCopyOfDocument` | Not yet | - | -| `RevertDocument` | Not yet | - | -| `RunAction` | Not yet | - | -| `BeginCommit` | Not yet | - | -| `EndCommit` | Not yet | - | -| `CreateItems` | Not yet | - | +| `SaveDocument` | Implemented | `KiCadClient::save_document_raw`, `KiCadClient::save_document` | +| `SaveCopyOfDocument` | Implemented | `KiCadClient::save_copy_of_document_raw`, `KiCadClient::save_copy_of_document` | +| `RevertDocument` | Implemented | `KiCadClient::revert_document_raw`, `KiCadClient::revert_document` | +| `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` | 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 | - | -| `DeleteItems` | Not yet | - | +| `UpdateItems` | Implemented | `KiCadClient::update_items_raw`, `KiCadClient::update_items` | +| `DeleteItems` | Implemented | `KiCadClient::delete_items_raw`, `KiCadClient::delete_items` | | `GetBoundingBox` | Implemented | `KiCadClient::get_item_bounding_boxes` | | `GetSelection` | Implemented | `KiCadClient::get_selection_raw`, `KiCadClient::get_selection`, `KiCadClient::get_selection_summary`, `KiCadClient::get_selection_details` | -| `AddToSelection` | Not yet | - | -| `RemoveFromSelection` | Not yet | - | -| `ClearSelection` | Not yet | - | +| `AddToSelection` | Implemented | `KiCadClient::add_to_selection_raw`, `KiCadClient::add_to_selection` | +| `RemoveFromSelection` | Implemented | `KiCadClient::remove_from_selection_raw`, `KiCadClient::remove_from_selection` | +| `ClearSelection` | Implemented | `KiCadClient::clear_selection_raw`, `KiCadClient::clear_selection` | | `HitTest` | Implemented | `KiCadClient::hit_test_item` | | `GetTitleBlockInfo` | Implemented | `KiCadClient::get_title_block_info` | | `SaveDocumentToString` | Implemented | `KiCadClient::get_board_as_string` | | `SaveSelectionToString` | Implemented | `KiCadClient::get_selection_as_string` | -| `ParseAndCreateItemsFromString` | Not yet | - | +| `ParseAndCreateItemsFromString` | Implemented | `KiCadClient::parse_and_create_items_from_string_raw`, `KiCadClient::parse_and_create_items_from_string` | ### Project manager | KiCad Command | Status | Rust API | | --- | --- | --- | | `GetNetClasses` | Implemented | `KiCadClient::get_net_classes_raw`, `KiCadClient::get_net_classes` | -| `SetNetClasses` | Not yet | - | +| `SetNetClasses` | Implemented | `KiCadClient::set_net_classes_raw`, `KiCadClient::set_net_classes` | | `ExpandTextVariables` | Implemented | `KiCadClient::expand_text_variables_raw`, `KiCadClient::expand_text_variables` | | `GetTextVariables` | Implemented | `KiCadClient::get_text_variables_raw`, `KiCadClient::get_text_variables` | -| `SetTextVariables` | Not yet | - | +| `SetTextVariables` | Implemented | `KiCadClient::set_text_variables_raw`, `KiCadClient::set_text_variables` | ### Board editor (PCB) | KiCad Command | Status | Rust API | | --- | --- | --- | | `GetBoardStackup` | Implemented | `KiCadClient::get_board_stackup_raw`, `KiCadClient::get_board_stackup` | -| `UpdateBoardStackup` | Not yet | - | +| `UpdateBoardStackup` | Implemented | `KiCadClient::update_board_stackup_raw`, `KiCadClient::update_board_stackup` | | `GetBoardEnabledLayers` | Implemented | `KiCadClient::get_board_enabled_layers` | -| `SetBoardEnabledLayers` | Not yet | - | +| `SetBoardEnabledLayers` | Implemented | `KiCadClient::set_board_enabled_layers` | | `GetGraphicsDefaults` | Implemented | `KiCadClient::get_graphics_defaults_raw`, `KiCadClient::get_graphics_defaults` | | `GetBoardOrigin` | Implemented | `KiCadClient::get_board_origin` | -| `SetBoardOrigin` | Not yet | - | +| `SetBoardOrigin` | Implemented | `KiCadClient::set_board_origin` | | `GetNets` | Implemented | `KiCadClient::get_nets` | | `GetItemsByNet` | Implemented | `KiCadClient::get_items_by_net_raw`, `KiCadClient::get_items_by_net` | | `GetItemsByNetClass` | Implemented | `KiCadClient::get_items_by_net_class_raw`, `KiCadClient::get_items_by_net_class` | | `GetNetClassForNets` | Implemented | `KiCadClient::get_netclass_for_nets_raw`, `KiCadClient::get_netclass_for_nets` | -| `RefillZones` | Not yet | - | +| `RefillZones` | Implemented | `KiCadClient::refill_zones` | | `GetPadShapeAsPolygon` | Implemented | `KiCadClient::get_pad_shape_as_polygon_raw`, `KiCadClient::get_pad_shape_as_polygon` | | `CheckPadstackPresenceOnLayers` | Implemented | `KiCadClient::check_padstack_presence_on_layers_raw`, `KiCadClient::check_padstack_presence_on_layers` | -| `InjectDrcError` | Not yet | - | +| `InjectDrcError` | Implemented | `KiCadClient::inject_drc_error_raw`, `KiCadClient::inject_drc_error` | | `GetVisibleLayers` | Implemented | `KiCadClient::get_visible_layers` | -| `SetVisibleLayers` | Not yet | - | +| `SetVisibleLayers` | Implemented | `KiCadClient::set_visible_layers` | | `GetActiveLayer` | Implemented | `KiCadClient::get_active_layer` | -| `SetActiveLayer` | Not yet | - | +| `SetActiveLayer` | Implemented | `KiCadClient::set_active_layer` | | `GetBoardEditorAppearanceSettings` | Implemented | `KiCadClient::get_board_editor_appearance_settings_raw`, `KiCadClient::get_board_editor_appearance_settings` | -| `SetBoardEditorAppearanceSettings` | Not yet | - | -| `InteractiveMoveItems` | Not yet | - | +| `SetBoardEditorAppearanceSettings` | Implemented | `KiCadClient::set_board_editor_appearance_settings` | +| `InteractiveMoveItems` | Implemented | `KiCadClient::interactive_move_items_raw`, `KiCadClient::interactive_move_items` | ### Schematic editor diff --git a/docs/TEST_CLI.md b/docs/TEST_CLI.md index 204131d..ab0fe02 100644 --- a/docs/TEST_CLI.md +++ b/docs/TEST_CLI.md @@ -29,6 +29,18 @@ Version: cargo run --bin kicad-ipc-cli -- version ``` +Resolve KiCad binary path (default `kicad-cli`): + +```bash +cargo run --bin kicad-ipc-cli -- kicad-binary-path --binary-name kicad-cli +``` + +Resolve plugin settings path (default identifier `kicad-ipc-rust`): + +```bash +cargo run --bin kicad-ipc-cli -- plugin-settings-path --identifier kicad-ipc-rust +``` + List open PCB docs: ```bash @@ -53,12 +65,24 @@ List project net classes: cargo run --bin kicad-ipc-cli -- net-classes ``` +Write current net classes back with selected merge mode: + +```bash +cargo run --bin kicad-ipc-cli -- set-net-classes --merge-mode merge +``` + List text variables for current board document: ```bash cargo run --bin kicad-ipc-cli -- text-variables ``` +Set text variables: + +```bash +cargo run --bin kicad-ipc-cli -- set-text-variables --merge-mode merge --var REV=A +``` + Expand text variables in one or more input strings: ```bash @@ -83,18 +107,36 @@ List enabled board layers: cargo run --bin kicad-ipc-cli -- enabled-layers ``` +Set enabled board layers: + +```bash +cargo run --bin kicad-ipc-cli -- set-enabled-layers --copper-layer-count 2 --layer-id 47 --layer-id 52 +``` + Show active layer: ```bash cargo run --bin kicad-ipc-cli -- active-layer ``` +Set active layer: + +```bash +cargo run --bin kicad-ipc-cli -- set-active-layer --layer-id 0 +``` + Show visible layers: ```bash cargo run --bin kicad-ipc-cli -- visible-layers ``` +Set visible layers: + +```bash +cargo run --bin kicad-ipc-cli -- set-visible-layers --layer-id 0 --layer-id 31 +``` + Show board origin (grid origin by default): ```bash @@ -107,6 +149,80 @@ Show drill origin: cargo run --bin kicad-ipc-cli -- board-origin --type drill ``` +Set board origin: + +```bash +cargo run --bin kicad-ipc-cli -- set-board-origin --type grid --x-nm 1000000 --y-nm 2000000 +``` + +Refresh PCB editor: + +```bash +cargo run --bin kicad-ipc-cli -- refresh-editor --frame pcb +``` + +If your KiCad build does not expose this handler yet, this call may return `AS_UNHANDLED`. + +Start a staged commit and print commit ID: + +```bash +cargo run --bin kicad-ipc-cli -- --client-name write-test begin-commit +``` + +End a staged commit: + +```bash +cargo run --bin kicad-ipc-cli -- --client-name write-test end-commit --id --action drop --message "cli test cleanup" +``` + +Save current board document: + +```bash +cargo run --bin kicad-ipc-cli -- save-doc +``` + +Save a copy of current board document: + +```bash +cargo run --bin kicad-ipc-cli -- save-copy --path /tmp/example.kicad_pcb --overwrite --include-project +``` + +Revert current board document from disk: + +```bash +cargo run --bin kicad-ipc-cli -- revert-doc +``` + +Run a raw KiCad tool action: + +```bash +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= +``` + +Update raw Any item payload(s): + +```bash +cargo run --bin kicad-ipc-cli -- update-items --item type.googleapis.com/kiapi.board.types.Text= +``` + +Delete items by ID: + +```bash +cargo run --bin kicad-ipc-cli -- delete-items --id --id +``` + +Parse and create items from s-expression: + +```bash +cargo run --bin kicad-ipc-cli -- parse-create-items --contents "(kicad_pcb (version 20240108))" +``` + Show summary of current PCB selection by item type: ```bash @@ -125,6 +241,24 @@ Show raw protobuf payload bytes for selected items: cargo run --bin kicad-ipc-cli -- selection-raw ``` +Add items to current selection: + +```bash +cargo run --bin kicad-ipc-cli -- add-to-selection --id --id +``` + +Remove items from current selection: + +```bash +cargo run --bin kicad-ipc-cli -- remove-from-selection --id --id +``` + +Clear current selection: + +```bash +cargo run --bin kicad-ipc-cli -- clear-selection +``` + Show pad-level netlist entries (footprint/pad/net): ```bash @@ -207,10 +341,35 @@ Show typed stackup/graphics/appearance: ```bash cargo run --bin kicad-ipc-cli -- stackup +cargo run --bin kicad-ipc-cli -- update-stackup cargo run --bin kicad-ipc-cli -- graphics-defaults cargo run --bin kicad-ipc-cli -- appearance ``` +Set editor appearance: + +```bash +cargo run --bin kicad-ipc-cli -- set-appearance --inactive-layer-display hidden --net-color-display all --board-flip normal --ratsnest-display all-layers +``` + +Inject DRC marker: + +```bash +cargo run --bin kicad-ipc-cli -- inject-drc-error --severity error --message "API marker test" --x-nm 1000000 --y-nm 1000000 +``` + +Refill all zones: + +```bash +cargo run --bin kicad-ipc-cli -- refill-zones +``` + +Start interactive move tool for one or more item IDs: + +```bash +cargo run --bin kicad-ipc-cli -- interactive-move --id --id +``` + Show typed netclass map: ```bash @@ -259,6 +418,12 @@ Custom token: cargo run --bin kicad-ipc-cli -- --token "$KICAD_API_TOKEN" version ``` +Stable client name (needed when pairing `begin-commit` and `end-commit` across separate CLI runs): + +```bash +cargo run --bin kicad-ipc-cli -- --client-name write-test begin-commit +``` + Custom timeout: ```bash diff --git a/src/client.rs b/src/client.rs index e238c32..10d7a42 100644 --- a/src/client.rs +++ b/src/client.rs @@ -9,7 +9,7 @@ use crate::model::board::{ ArcStartMidEndNm, BoardEditorAppearanceSettings, BoardEnabledLayers, BoardFlipMode, BoardLayerClass, BoardLayerGraphicsDefault, BoardLayerInfo, BoardNet, BoardOriginKind, BoardStackup, BoardStackupDielectricProperties, BoardStackupLayer, BoardStackupLayerType, - ColorRgba, GraphicsDefaults, InactiveLayerDisplayMode, NetClassBoardSettings, + ColorRgba, DrcSeverity, GraphicsDefaults, InactiveLayerDisplayMode, NetClassBoardSettings, NetClassForNetEntry, NetClassInfo, NetClassType, NetColorDisplayMode, PadNetEntry, PadShapeAsPolygonEntry, PadstackPresenceEntry, PadstackPresenceState, PcbArc, PcbBoardGraphicShape, PcbBoardText, PcbBoardTextBox, PcbDimension, PcbField, PcbFootprint, @@ -18,8 +18,9 @@ use crate::model::board::{ Vector2Nm, }; use crate::model::common::{ - DocumentSpecifier, DocumentType, ItemBoundingBox, ItemHitTestResult, PcbObjectTypeCode, - ProjectInfo, SelectionItemDetail, SelectionSummary, SelectionTypeCount, TextAsShapesEntry, + CommitAction, CommitSession, DocumentSpecifier, DocumentType, EditorFrameType, ItemBoundingBox, + ItemHitTestResult, MapMergeMode, PcbObjectTypeCode, ProjectInfo, RunActionStatus, + SelectionItemDetail, SelectionSummary, SelectionTypeCount, TextAsShapesEntry, TextAttributesSpec, TextBoxSpec, TextExtents, TextHorizontalAlignment, TextObjectSpec, TextShape, TextShapeGeometry, TextSpec, TextVerticalAlignment, TitleBlockInfo, VersionInfo, }; @@ -36,37 +37,68 @@ const KICAD_API_TOKEN_ENV: &str = "KICAD_API_TOKEN"; const CMD_PING: &str = "kiapi.common.commands.Ping"; const CMD_GET_VERSION: &str = "kiapi.common.commands.GetVersion"; +const CMD_GET_KICAD_BINARY_PATH: &str = "kiapi.common.commands.GetKiCadBinaryPath"; +const CMD_GET_PLUGIN_SETTINGS_PATH: &str = "kiapi.common.commands.GetPluginSettingsPath"; const CMD_GET_NET_CLASSES: &str = "kiapi.common.commands.GetNetClasses"; +const CMD_SET_NET_CLASSES: &str = "kiapi.common.commands.SetNetClasses"; const CMD_GET_TEXT_VARIABLES: &str = "kiapi.common.commands.GetTextVariables"; +const CMD_SET_TEXT_VARIABLES: &str = "kiapi.common.commands.SetTextVariables"; const CMD_EXPAND_TEXT_VARIABLES: &str = "kiapi.common.commands.ExpandTextVariables"; const CMD_GET_TEXT_EXTENTS: &str = "kiapi.common.commands.GetTextExtents"; const CMD_GET_TEXT_AS_SHAPES: &str = "kiapi.common.commands.GetTextAsShapes"; +const CMD_REFRESH_EDITOR: &str = "kiapi.common.commands.RefreshEditor"; const CMD_GET_OPEN_DOCUMENTS: &str = "kiapi.common.commands.GetOpenDocuments"; +const CMD_RUN_ACTION: &str = "kiapi.common.commands.RunAction"; const CMD_GET_NETS: &str = "kiapi.board.commands.GetNets"; const CMD_GET_BOARD_ENABLED_LAYERS: &str = "kiapi.board.commands.GetBoardEnabledLayers"; +const CMD_SET_BOARD_ENABLED_LAYERS: &str = "kiapi.board.commands.SetBoardEnabledLayers"; const CMD_GET_ACTIVE_LAYER: &str = "kiapi.board.commands.GetActiveLayer"; +const CMD_SET_ACTIVE_LAYER: &str = "kiapi.board.commands.SetActiveLayer"; const CMD_GET_VISIBLE_LAYERS: &str = "kiapi.board.commands.GetVisibleLayers"; +const CMD_SET_VISIBLE_LAYERS: &str = "kiapi.board.commands.SetVisibleLayers"; const CMD_GET_BOARD_ORIGIN: &str = "kiapi.board.commands.GetBoardOrigin"; +const CMD_SET_BOARD_ORIGIN: &str = "kiapi.board.commands.SetBoardOrigin"; const CMD_GET_BOARD_STACKUP: &str = "kiapi.board.commands.GetBoardStackup"; +const CMD_UPDATE_BOARD_STACKUP: &str = "kiapi.board.commands.UpdateBoardStackup"; const CMD_GET_GRAPHICS_DEFAULTS: &str = "kiapi.board.commands.GetGraphicsDefaults"; const CMD_GET_BOARD_EDITOR_APPEARANCE_SETTINGS: &str = "kiapi.board.commands.GetBoardEditorAppearanceSettings"; +const CMD_SET_BOARD_EDITOR_APPEARANCE_SETTINGS: &str = + "kiapi.board.commands.SetBoardEditorAppearanceSettings"; +const CMD_INTERACTIVE_MOVE_ITEMS: &str = "kiapi.board.commands.InteractiveMoveItems"; const CMD_GET_ITEMS_BY_NET: &str = "kiapi.board.commands.GetItemsByNet"; const CMD_GET_ITEMS_BY_NET_CLASS: &str = "kiapi.board.commands.GetItemsByNetClass"; const CMD_GET_NETCLASS_FOR_NETS: &str = "kiapi.board.commands.GetNetClassForNets"; +const CMD_REFILL_ZONES: &str = "kiapi.board.commands.RefillZones"; const CMD_GET_PAD_SHAPE_AS_POLYGON: &str = "kiapi.board.commands.GetPadShapeAsPolygon"; const CMD_CHECK_PADSTACK_PRESENCE_ON_LAYERS: &str = "kiapi.board.commands.CheckPadstackPresenceOnLayers"; +const CMD_INJECT_DRC_ERROR: &str = "kiapi.board.commands.InjectDrcError"; const CMD_GET_SELECTION: &str = "kiapi.common.commands.GetSelection"; +const CMD_ADD_TO_SELECTION: &str = "kiapi.common.commands.AddToSelection"; +const CMD_REMOVE_FROM_SELECTION: &str = "kiapi.common.commands.RemoveFromSelection"; +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_UPDATE_ITEMS: &str = "kiapi.common.commands.UpdateItems"; +const CMD_DELETE_ITEMS: &str = "kiapi.common.commands.DeleteItems"; +const CMD_PARSE_AND_CREATE_ITEMS_FROM_STRING: &str = + "kiapi.common.commands.ParseAndCreateItemsFromString"; 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"; const CMD_HIT_TEST: &str = "kiapi.common.commands.HitTest"; const CMD_GET_TITLE_BLOCK_INFO: &str = "kiapi.common.commands.GetTitleBlockInfo"; +const CMD_SAVE_DOCUMENT: &str = "kiapi.common.commands.SaveDocument"; +const CMD_SAVE_COPY_OF_DOCUMENT: &str = "kiapi.common.commands.SaveCopyOfDocument"; +const CMD_REVERT_DOCUMENT: &str = "kiapi.common.commands.RevertDocument"; const CMD_SAVE_DOCUMENT_TO_STRING: &str = "kiapi.common.commands.SaveDocumentToString"; const CMD_SAVE_SELECTION_TO_STRING: &str = "kiapi.common.commands.SaveSelectionToString"; const RES_GET_VERSION: &str = "kiapi.common.commands.GetVersionResponse"; +const RES_PATH_RESPONSE: &str = "kiapi.common.commands.PathResponse"; +const RES_STRING_RESPONSE: &str = "kiapi.common.commands.StringResponse"; const RES_NET_CLASSES_RESPONSE: &str = "kiapi.common.commands.NetClassesResponse"; const RES_TEXT_VARIABLES: &str = "kiapi.common.project.TextVariables"; const RES_EXPAND_TEXT_VARIABLES_RESPONSE: &str = @@ -74,6 +106,7 @@ const RES_EXPAND_TEXT_VARIABLES_RESPONSE: &str = const RES_BOX2: &str = "kiapi.common.types.Box2"; const RES_GET_TEXT_AS_SHAPES_RESPONSE: &str = "kiapi.common.commands.GetTextAsShapesResponse"; const RES_GET_OPEN_DOCUMENTS: &str = "kiapi.common.commands.GetOpenDocumentsResponse"; +const RES_RUN_ACTION_RESPONSE: &str = "kiapi.common.commands.RunActionResponse"; const RES_GET_NETS: &str = "kiapi.board.commands.NetsResponse"; const RES_GET_BOARD_ENABLED_LAYERS: &str = "kiapi.board.commands.BoardEnabledLayersResponse"; const RES_BOARD_LAYER_RESPONSE: &str = "kiapi.board.commands.BoardLayerResponse"; @@ -85,14 +118,21 @@ const RES_BOARD_EDITOR_APPEARANCE_SETTINGS: &str = const RES_NETCLASS_FOR_NETS_RESPONSE: &str = "kiapi.board.commands.NetClassForNetsResponse"; const RES_PAD_SHAPE_AS_POLYGON_RESPONSE: &str = "kiapi.board.commands.PadShapeAsPolygonResponse"; const RES_PADSTACK_PRESENCE_RESPONSE: &str = "kiapi.board.commands.PadstackPresenceResponse"; +const RES_INJECT_DRC_ERROR_RESPONSE: &str = "kiapi.board.commands.InjectDrcErrorResponse"; 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_UPDATE_ITEMS_RESPONSE: &str = "kiapi.common.commands.UpdateItemsResponse"; +const RES_DELETE_ITEMS_RESPONSE: &str = "kiapi.common.commands.DeleteItemsResponse"; 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"; const RES_TITLE_BLOCK_INFO: &str = "kiapi.common.types.TitleBlockInfo"; const RES_SAVED_DOCUMENT_RESPONSE: &str = "kiapi.common.commands.SavedDocumentResponse"; const RES_SAVED_SELECTION_RESPONSE: &str = "kiapi.common.commands.SavedSelectionResponse"; +const RES_PROTOBUF_EMPTY: &str = "google.protobuf.Empty"; const PAD_QUERY_CHUNK_SIZE: usize = 256; @@ -288,6 +328,40 @@ impl KiCadClient { Ok(()) } + pub async fn refresh_editor(&self, frame: EditorFrameType) -> Result<(), KiCadError> { + let command = envelope::pack_any( + &common_commands::RefreshEditor { + frame: frame.to_proto(), + }, + CMD_REFRESH_EDITOR, + ); + self.send_command(command).await?; + Ok(()) + } + + pub async fn run_action_raw( + &self, + action: impl Into, + ) -> Result { + let command = common_commands::RunAction { + action: action.into(), + }; + let response = self + .send_command(envelope::pack_any(&command, CMD_RUN_ACTION)) + .await?; + response_payload_as_any(response, RES_RUN_ACTION_RESPONSE) + } + + pub async fn run_action( + &self, + action: impl Into, + ) -> Result { + let payload = self.run_action_raw(action).await?; + let response: common_commands::RunActionResponse = + decode_any(&payload, RES_RUN_ACTION_RESPONSE)?; + Ok(map_run_action_status(response.status)) + } + pub async fn get_version(&self) -> Result { let command = envelope::pack_any(&common_commands::GetVersion {}, CMD_GET_VERSION); let response = self.send_command(command).await?; @@ -307,6 +381,50 @@ impl KiCadClient { }) } + pub async fn get_kicad_binary_path_raw( + &self, + binary_name: impl Into, + ) -> Result { + let command = common_commands::GetKiCadBinaryPath { + binary_name: binary_name.into(), + }; + let response = self + .send_command(envelope::pack_any(&command, CMD_GET_KICAD_BINARY_PATH)) + .await?; + response_payload_as_any(response, RES_PATH_RESPONSE) + } + + pub async fn get_kicad_binary_path( + &self, + binary_name: impl Into, + ) -> Result { + let payload = self.get_kicad_binary_path_raw(binary_name).await?; + let response: common_commands::PathResponse = decode_any(&payload, RES_PATH_RESPONSE)?; + Ok(response.path) + } + + pub async fn get_plugin_settings_path_raw( + &self, + identifier: impl Into, + ) -> Result { + let command = common_commands::GetPluginSettingsPath { + identifier: identifier.into(), + }; + let response = self + .send_command(envelope::pack_any(&command, CMD_GET_PLUGIN_SETTINGS_PATH)) + .await?; + response_payload_as_any(response, RES_STRING_RESPONSE) + } + + pub async fn get_plugin_settings_path( + &self, + identifier: impl Into, + ) -> Result { + let payload = self.get_plugin_settings_path_raw(identifier).await?; + let response: common_commands::StringResponse = decode_any(&payload, RES_STRING_RESPONSE)?; + Ok(response.response) + } + pub async fn get_open_documents( &self, document_type: DocumentType, @@ -351,6 +469,33 @@ impl KiCadClient { Ok(classes) } + pub async fn set_net_classes_raw( + &self, + net_classes: Vec, + merge_mode: MapMergeMode, + ) -> Result { + let command = common_commands::SetNetClasses { + net_classes: net_classes + .into_iter() + .map(net_class_info_to_proto) + .collect(), + merge_mode: map_merge_mode_to_proto(merge_mode), + }; + let response = self + .send_command(envelope::pack_any(&command, CMD_SET_NET_CLASSES)) + .await?; + response_payload_as_any(response, RES_PROTOBUF_EMPTY) + } + + pub async fn set_net_classes( + &self, + net_classes: Vec, + merge_mode: MapMergeMode, + ) -> Result, KiCadError> { + let _ = self.set_net_classes_raw(net_classes, merge_mode).await?; + self.get_net_classes().await + } + pub async fn get_text_variables_raw(&self) -> Result { let command = common_commands::GetTextVariables { document: Some(self.current_board_document_proto().await?), @@ -367,6 +512,33 @@ impl KiCadClient { Ok(response.variables.into_iter().collect()) } + pub async fn set_text_variables_raw( + &self, + variables: BTreeMap, + merge_mode: MapMergeMode, + ) -> Result { + let command = common_commands::SetTextVariables { + document: Some(self.current_board_document_proto().await?), + variables: Some(common_project::TextVariables { + variables: variables.into_iter().collect(), + }), + merge_mode: map_merge_mode_to_proto(merge_mode), + }; + let response = self + .send_command(envelope::pack_any(&command, CMD_SET_TEXT_VARIABLES)) + .await?; + response_payload_as_any(response, RES_PROTOBUF_EMPTY) + } + + pub async fn set_text_variables( + &self, + variables: BTreeMap, + merge_mode: MapMergeMode, + ) -> Result, KiCadError> { + let _ = self.set_text_variables_raw(variables, merge_mode).await?; + self.get_text_variables().await + } + pub async fn expand_text_variables_raw( &self, text: Vec, @@ -462,6 +634,208 @@ impl KiCadClient { Ok(!docs.is_empty()) } + pub async fn begin_commit_raw(&self) -> Result { + let command = common_commands::BeginCommit {}; + let response = self + .send_command(envelope::pack_any(&command, CMD_BEGIN_COMMIT)) + .await?; + response_payload_as_any(response, RES_BEGIN_COMMIT_RESPONSE) + } + + pub async fn begin_commit(&self) -> Result { + let payload = self.begin_commit_raw().await?; + let response: common_commands::BeginCommitResponse = + decode_any(&payload, RES_BEGIN_COMMIT_RESPONSE)?; + map_commit_session(response) + } + + pub async fn end_commit_raw( + &self, + session: CommitSession, + action: CommitAction, + message: impl Into, + ) -> Result { + if session.id.is_empty() { + return Err(KiCadError::Config { + reason: "end_commit_raw requires a non-empty commit session id".to_string(), + }); + } + + let command = common_commands::EndCommit { + id: Some(common_types::Kiid { value: session.id }), + action: commit_action_to_proto(action), + message: message.into(), + }; + let response = self + .send_command(envelope::pack_any(&command, CMD_END_COMMIT)) + .await?; + response_payload_as_any(response, RES_END_COMMIT_RESPONSE) + } + + pub async fn end_commit( + &self, + session: CommitSession, + action: CommitAction, + message: impl Into, + ) -> Result<(), KiCadError> { + self.end_commit_raw(session, action, message).await?; + 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 update_items_raw( + &self, + items: Vec, + ) -> Result { + let command = common_commands::UpdateItems { + header: Some(self.current_board_item_header().await?), + items, + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_UPDATE_ITEMS)) + .await?; + response_payload_as_any(response, RES_UPDATE_ITEMS_RESPONSE) + } + + pub async fn update_items( + &self, + items: Vec, + ) -> Result, KiCadError> { + let payload = self.update_items_raw(items).await?; + let response: common_commands::UpdateItemsResponse = + decode_any(&payload, RES_UPDATE_ITEMS_RESPONSE)?; + ensure_item_request_ok(response.status)?; + + response + .updated_items + .into_iter() + .map(|row| { + ensure_item_status_ok(row.status)?; + row.item.ok_or_else(|| KiCadError::InvalidResponse { + reason: "UpdateItemsResponse missing updated item payload".to_string(), + }) + }) + .collect() + } + + pub async fn delete_items_raw( + &self, + item_ids: Vec, + ) -> Result { + let command = common_commands::DeleteItems { + header: Some(self.current_board_item_header().await?), + item_ids: item_ids + .into_iter() + .map(|value| common_types::Kiid { value }) + .collect(), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_DELETE_ITEMS)) + .await?; + response_payload_as_any(response, RES_DELETE_ITEMS_RESPONSE) + } + + pub async fn delete_items(&self, item_ids: Vec) -> Result, KiCadError> { + let payload = self.delete_items_raw(item_ids).await?; + let response: common_commands::DeleteItemsResponse = + decode_any(&payload, RES_DELETE_ITEMS_RESPONSE)?; + ensure_item_request_ok(response.status)?; + + response + .deleted_items + .into_iter() + .map(|row| { + ensure_item_deletion_status_ok(row.status)?; + row.id + .map(|id| id.value) + .ok_or_else(|| KiCadError::InvalidResponse { + reason: "DeleteItemsResponse missing deleted item id".to_string(), + }) + }) + .collect() + } + + pub async fn parse_and_create_items_from_string_raw( + &self, + contents: impl Into, + ) -> Result { + let command = common_commands::ParseAndCreateItemsFromString { + document: Some(self.current_board_document_proto().await?), + contents: contents.into(), + }; + + let response = self + .send_command(envelope::pack_any( + &command, + CMD_PARSE_AND_CREATE_ITEMS_FROM_STRING, + )) + .await?; + response_payload_as_any(response, RES_CREATE_ITEMS_RESPONSE) + } + + pub async fn parse_and_create_items_from_string( + &self, + contents: impl Into, + ) -> Result, KiCadError> { + let payload = self + .parse_and_create_items_from_string_raw(contents) + .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 { @@ -496,10 +870,28 @@ impl KiCadClient { let payload: board_commands::BoardEnabledLayersResponse = envelope::unpack_any(&response, RES_GET_BOARD_ENABLED_LAYERS)?; - Ok(BoardEnabledLayers { - copper_layer_count: payload.copper_layer_count, - layers: payload.layers.into_iter().map(layer_to_model).collect(), - }) + Ok(map_board_enabled_layers_response(payload)) + } + + pub async fn set_board_enabled_layers( + &self, + copper_layer_count: u32, + layer_ids: Vec, + ) -> Result { + let board = self.current_board_document_proto().await?; + let command = board_commands::SetBoardEnabledLayers { + board: Some(board), + copper_layer_count, + layers: layer_ids, + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_SET_BOARD_ENABLED_LAYERS)) + .await?; + + let payload: board_commands::BoardEnabledLayersResponse = + envelope::unpack_any(&response, RES_GET_BOARD_ENABLED_LAYERS)?; + Ok(map_board_enabled_layers_response(payload)) } pub async fn get_active_layer(&self) -> Result { @@ -516,6 +908,18 @@ impl KiCadClient { Ok(layer_to_model(payload.layer)) } + pub async fn set_active_layer(&self, layer_id: i32) -> Result<(), KiCadError> { + let board = self.current_board_document_proto().await?; + let command = board_commands::SetActiveLayer { + board: Some(board), + layer: layer_id, + }; + + self.send_command(envelope::pack_any(&command, CMD_SET_ACTIVE_LAYER)) + .await?; + Ok(()) + } + pub async fn get_visible_layers(&self) -> Result, KiCadError> { let board = self.current_board_document_proto().await?; let command = board_commands::GetVisibleLayers { board: Some(board) }; @@ -530,6 +934,18 @@ impl KiCadClient { Ok(payload.layers.into_iter().map(layer_to_model).collect()) } + pub async fn set_visible_layers(&self, layer_ids: Vec) -> Result<(), KiCadError> { + let board = self.current_board_document_proto().await?; + let command = board_commands::SetVisibleLayers { + board: Some(board), + layers: layer_ids, + }; + + self.send_command(envelope::pack_any(&command, CMD_SET_VISIBLE_LAYERS)) + .await?; + Ok(()) + } + pub async fn get_board_origin(&self, kind: BoardOriginKind) -> Result { let board = self.current_board_document_proto().await?; let command = board_commands::GetBoardOrigin { @@ -548,6 +964,23 @@ impl KiCadClient { }) } + pub async fn set_board_origin( + &self, + kind: BoardOriginKind, + origin: Vector2Nm, + ) -> Result<(), KiCadError> { + let board = self.current_board_document_proto().await?; + let command = board_commands::SetBoardOrigin { + board: Some(board), + r#type: board_origin_kind_to_proto(kind), + origin: Some(vector2_nm_to_proto(origin)), + }; + + self.send_command(envelope::pack_any(&command, CMD_SET_BOARD_ORIGIN)) + .await?; + Ok(()) + } + pub async fn get_selection_summary(&self) -> Result { let document = self.current_board_document_proto().await?; let command = common_commands::GetSelection { @@ -595,6 +1028,107 @@ impl KiCadClient { decode_pcb_items(items) } + pub async fn add_to_selection_raw( + &self, + item_ids: Vec, + ) -> Result, KiCadError> { + let command = common_commands::AddToSelection { + header: Some(self.current_board_item_header().await?), + items: item_ids + .into_iter() + .map(|value| common_types::Kiid { value }) + .collect(), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_ADD_TO_SELECTION)) + .await?; + + match envelope::unpack_any::( + &response, + RES_SELECTION_RESPONSE, + ) { + Ok(payload) => Ok(payload.items), + Err(KiCadError::UnexpectedPayloadType { + expected_type_url: _, + actual_type_url, + }) if actual_type_url == envelope::type_url(RES_PROTOBUF_EMPTY) => Ok(Vec::new()), + Err(err) => Err(err), + } + } + + pub async fn add_to_selection( + &self, + item_ids: Vec, + ) -> Result { + let items = self.add_to_selection_raw(item_ids).await?; + Ok(summarize_selection(items)) + } + + pub async fn clear_selection_raw(&self) -> Result, KiCadError> { + let command = common_commands::ClearSelection { + header: Some(self.current_board_item_header().await?), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_CLEAR_SELECTION)) + .await?; + + match envelope::unpack_any::( + &response, + RES_SELECTION_RESPONSE, + ) { + Ok(payload) => Ok(payload.items), + Err(KiCadError::UnexpectedPayloadType { + expected_type_url: _, + actual_type_url, + }) if actual_type_url == envelope::type_url(RES_PROTOBUF_EMPTY) => Ok(Vec::new()), + Err(err) => Err(err), + } + } + + pub async fn clear_selection(&self) -> Result { + let items = self.clear_selection_raw().await?; + Ok(summarize_selection(items)) + } + + pub async fn remove_from_selection_raw( + &self, + item_ids: Vec, + ) -> Result, KiCadError> { + let command = common_commands::RemoveFromSelection { + header: Some(self.current_board_item_header().await?), + items: item_ids + .into_iter() + .map(|value| common_types::Kiid { value }) + .collect(), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_REMOVE_FROM_SELECTION)) + .await?; + + match envelope::unpack_any::( + &response, + RES_SELECTION_RESPONSE, + ) { + Ok(payload) => Ok(payload.items), + Err(KiCadError::UnexpectedPayloadType { + expected_type_url: _, + actual_type_url, + }) if actual_type_url == envelope::type_url(RES_PROTOBUF_EMPTY) => Ok(Vec::new()), + Err(err) => Err(err), + } + } + + pub async fn remove_from_selection( + &self, + item_ids: Vec, + ) -> Result { + let items = self.remove_from_selection_raw(item_ids).await?; + Ok(summarize_selection(items)) + } + pub async fn get_pad_netlist(&self) -> Result, KiCadError> { let footprint_items = self .get_items_raw(vec![common_types::KiCadObjectType::KotPcbFootprint as i32]) @@ -770,6 +1304,23 @@ impl KiCadClient { Ok(map_netclass_for_nets_response(response)) } + pub async fn refill_zones(&self, zone_ids: Vec) -> Result<(), KiCadError> { + let board = self.current_board_document_proto().await?; + let command = board_commands::RefillZones { + board: Some(board), + zones: zone_ids + .into_iter() + .map(|value| common_types::Kiid { value }) + .collect(), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_REFILL_ZONES)) + .await?; + let _ = response_payload_as_any(response, RES_PROTOBUF_EMPTY)?; + Ok(()) + } + pub async fn get_pad_shape_as_polygon_raw( &self, pad_ids: Vec, @@ -916,6 +1467,46 @@ impl KiCadClient { Ok(entries) } + pub async fn inject_drc_error_raw( + &self, + severity: DrcSeverity, + message: impl Into, + position: Option, + item_ids: Vec, + ) -> Result { + let board = self.current_board_document_proto().await?; + let command = board_commands::InjectDrcError { + board: Some(board), + severity: drc_severity_to_proto(severity), + message: message.into(), + position: position.map(vector2_nm_to_proto), + items: item_ids + .into_iter() + .map(|value| common_types::Kiid { value }) + .collect(), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_INJECT_DRC_ERROR)) + .await?; + response_payload_as_any(response, RES_INJECT_DRC_ERROR_RESPONSE) + } + + pub async fn inject_drc_error( + &self, + severity: DrcSeverity, + message: impl Into, + position: Option, + item_ids: Vec, + ) -> Result, KiCadError> { + let payload = self + .inject_drc_error_raw(severity, message, position, item_ids) + .await?; + let response: board_commands::InjectDrcErrorResponse = + decode_any(&payload, RES_INJECT_DRC_ERROR_RESPONSE)?; + Ok(response.marker.map(|marker| marker.value)) + } + pub async fn get_board_stackup_raw(&self) -> Result { let command = board_commands::GetBoardStackup { board: Some(self.current_board_document_proto().await?), @@ -935,6 +1526,32 @@ impl KiCadClient { Ok(map_board_stackup(response.stackup.unwrap_or_default())) } + pub async fn update_board_stackup_raw( + &self, + stackup: BoardStackup, + ) -> Result { + let command = board_commands::UpdateBoardStackup { + board: Some(self.current_board_document_proto().await?), + stackup: Some(board_stackup_to_proto(stackup)), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_UPDATE_BOARD_STACKUP)) + .await?; + + response_payload_as_any(response, RES_BOARD_STACKUP_RESPONSE) + } + + pub async fn update_board_stackup( + &self, + stackup: BoardStackup, + ) -> Result { + let payload = self.update_board_stackup_raw(stackup).await?; + let response: board_commands::BoardStackupResponse = + decode_any(&payload, RES_BOARD_STACKUP_RESPONSE)?; + Ok(map_board_stackup(response.stackup.unwrap_or_default())) + } + pub async fn get_graphics_defaults_raw(&self) -> Result { let command = board_commands::GetGraphicsDefaults { board: Some(self.current_board_document_proto().await?), @@ -978,6 +1595,53 @@ impl KiCadClient { Ok(map_board_editor_appearance_settings(response)) } + pub async fn set_board_editor_appearance_settings( + &self, + settings: BoardEditorAppearanceSettings, + ) -> Result { + let command = board_commands::SetBoardEditorAppearanceSettings { + settings: Some(board_editor_appearance_settings_to_proto(settings)), + }; + + let response = self + .send_command(envelope::pack_any( + &command, + CMD_SET_BOARD_EDITOR_APPEARANCE_SETTINGS, + )) + .await?; + let _ = response_payload_as_any(response, RES_PROTOBUF_EMPTY)?; + self.get_board_editor_appearance_settings().await + } + + pub async fn interactive_move_items_raw( + &self, + item_ids: Vec, + ) -> Result { + if item_ids.is_empty() { + return Err(KiCadError::Config { + reason: "interactive_move_items_raw requires at least one item id".to_string(), + }); + } + + let command = board_commands::InteractiveMoveItems { + board: Some(self.current_board_document_proto().await?), + items: item_ids + .into_iter() + .map(|value| common_types::Kiid { value }) + .collect(), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_INTERACTIVE_MOVE_ITEMS)) + .await?; + response_payload_as_any(response, RES_PROTOBUF_EMPTY) + } + + pub async fn interactive_move_items(&self, item_ids: Vec) -> Result<(), KiCadError> { + let _ = self.interactive_move_items_raw(item_ids).await?; + Ok(()) + } + pub async fn get_title_block_info(&self) -> Result { let command = common_commands::GetTitleBlockInfo { document: Some(self.current_board_document_proto().await?), @@ -1013,6 +1677,71 @@ impl KiCadClient { }) } + pub async fn save_document_raw(&self) -> Result { + let command = common_commands::SaveDocument { + document: Some(self.current_board_document_proto().await?), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_SAVE_DOCUMENT)) + .await?; + response_payload_as_any(response, RES_PROTOBUF_EMPTY) + } + + pub async fn save_document(&self) -> Result<(), KiCadError> { + let _ = self.save_document_raw().await?; + Ok(()) + } + + pub async fn save_copy_of_document_raw( + &self, + path: impl Into, + overwrite: bool, + include_project: bool, + ) -> Result { + let command = common_commands::SaveCopyOfDocument { + document: Some(self.current_board_document_proto().await?), + path: path.into(), + options: Some(common_commands::SaveOptions { + overwrite, + include_project, + }), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_SAVE_COPY_OF_DOCUMENT)) + .await?; + response_payload_as_any(response, RES_PROTOBUF_EMPTY) + } + + pub async fn save_copy_of_document( + &self, + path: impl Into, + overwrite: bool, + include_project: bool, + ) -> Result<(), KiCadError> { + let _ = self + .save_copy_of_document_raw(path, overwrite, include_project) + .await?; + Ok(()) + } + + pub async fn revert_document_raw(&self) -> Result { + let command = common_commands::RevertDocument { + document: Some(self.current_board_document_proto().await?), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_REVERT_DOCUMENT)) + .await?; + response_payload_as_any(response, RES_PROTOBUF_EMPTY) + } + + pub async fn revert_document(&self) -> Result<(), KiCadError> { + let _ = self.revert_document_raw().await?; + Ok(()) + } + pub async fn get_board_as_string(&self) -> Result { let command = common_commands::SaveDocumentToString { document: Some(self.current_board_document_proto().await?), @@ -1509,6 +2238,15 @@ fn layer_to_model(layer_id: i32) -> BoardLayerInfo { BoardLayerInfo { id: layer_id, name } } +fn map_board_enabled_layers_response( + payload: board_commands::BoardEnabledLayersResponse, +) -> BoardEnabledLayers { + BoardEnabledLayers { + copper_layer_count: payload.copper_layer_count, + layers: payload.layers.into_iter().map(layer_to_model).collect(), + } +} + fn board_origin_kind_to_proto(kind: BoardOriginKind) -> i32 { match kind { BoardOriginKind::Grid => board_commands::BoardOriginType::BotGrid as i32, @@ -1516,6 +2254,33 @@ fn board_origin_kind_to_proto(kind: BoardOriginKind) -> i32 { } } +fn drc_severity_to_proto(value: DrcSeverity) -> i32 { + match value { + DrcSeverity::Warning => board_commands::DrcSeverity::DrsWarning as i32, + DrcSeverity::Error => board_commands::DrcSeverity::DrsError as i32, + DrcSeverity::Exclusion => board_commands::DrcSeverity::DrsExclusion as i32, + DrcSeverity::Ignore => board_commands::DrcSeverity::DrsIgnore as i32, + DrcSeverity::Info => board_commands::DrcSeverity::DrsInfo as i32, + DrcSeverity::Action => board_commands::DrcSeverity::DrsAction as i32, + DrcSeverity::Debug => board_commands::DrcSeverity::DrsDebug as i32, + DrcSeverity::Undefined => board_commands::DrcSeverity::DrsUndefined as i32, + } +} + +fn commit_action_to_proto(action: CommitAction) -> i32 { + match action { + CommitAction::Commit => common_commands::CommitAction::CmaCommit as i32, + CommitAction::Drop => common_commands::CommitAction::CmaDrop as i32, + } +} + +fn map_merge_mode_to_proto(value: MapMergeMode) -> i32 { + match value { + MapMergeMode::Merge => common_types::MapMergeMode::MmmMerge as i32, + MapMergeMode::Replace => common_types::MapMergeMode::MmmReplace as i32, + } +} + fn summarize_selection(items: Vec) -> SelectionSummary { let mut counts = BTreeMap::::new(); @@ -1551,6 +2316,22 @@ fn summarize_item_details( Ok(details) } +fn map_commit_session( + response: common_commands::BeginCommitResponse, +) -> Result { + let id = response.id.ok_or_else(|| KiCadError::InvalidResponse { + reason: "BeginCommit response missing commit id".to_string(), + })?; + + if id.value.is_empty() { + return Err(KiCadError::InvalidResponse { + reason: "BeginCommit response returned empty commit id".to_string(), + }); + } + + Ok(CommitSession { id: id.value }) +} + fn ensure_item_request_ok(status: i32) -> Result<(), KiCadError> { let request_status = common_types::ItemRequestStatus::try_from(status) .unwrap_or(common_types::ItemRequestStatus::IrsUnknown); @@ -1564,6 +2345,37 @@ 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 ensure_item_deletion_status_ok(status: i32) -> Result<(), KiCadError> { + let code = common_commands::ItemDeletionStatus::try_from(status) + .unwrap_or(common_commands::ItemDeletionStatus::IdsUnknown); + + if code != common_commands::ItemDeletionStatus::IdsOk { + return Err(KiCadError::ItemStatus { + code: code.as_str_name().to_string(), + }); + } + + Ok(()) +} + fn map_item_bounding_boxes( item_ids: Vec, boxes: Vec, @@ -1600,6 +2412,18 @@ fn map_hit_test_result(value: i32) -> ItemHitTestResult { } } +fn map_run_action_status(value: i32) -> RunActionStatus { + let status = common_commands::RunActionStatus::try_from(value) + .unwrap_or(common_commands::RunActionStatus::RasUnknown); + + match status { + common_commands::RunActionStatus::RasOk => RunActionStatus::Ok, + common_commands::RunActionStatus::RasInvalid => RunActionStatus::Invalid, + common_commands::RunActionStatus::RasFrameNotOpen => RunActionStatus::FrameNotOpen, + common_commands::RunActionStatus::RasUnknown => RunActionStatus::Unknown(value), + } +} + fn map_polygon_with_holes( polygon: common_types::PolygonWithHoles, ) -> Result { @@ -1743,6 +2567,28 @@ fn map_board_stackup_layer_type(value: i32) -> BoardStackupLayerType { } } +fn board_stackup_layer_type_to_proto(value: BoardStackupLayerType) -> i32 { + match value { + BoardStackupLayerType::Copper => board_proto::BoardStackupLayerType::BsltCopper as i32, + BoardStackupLayerType::Dielectric => { + board_proto::BoardStackupLayerType::BsltDielectric as i32 + } + BoardStackupLayerType::Silkscreen => { + board_proto::BoardStackupLayerType::BsltSilkscreen as i32 + } + BoardStackupLayerType::SolderMask => { + board_proto::BoardStackupLayerType::BsltSoldermask as i32 + } + BoardStackupLayerType::SolderPaste => { + board_proto::BoardStackupLayerType::BsltSolderpaste as i32 + } + BoardStackupLayerType::Undefined => { + board_proto::BoardStackupLayerType::BsltUndefined as i32 + } + BoardStackupLayerType::Unknown(value) => value, + } +} + fn map_board_layer_class(value: i32) -> BoardLayerClass { match board_proto::BoardLayerClass::try_from(value) { Ok(board_proto::BoardLayerClass::BlcSilkscreen) => BoardLayerClass::Silkscreen, @@ -1770,6 +2616,21 @@ fn map_inactive_layer_display_mode(value: i32) -> InactiveLayerDisplayMode { } } +fn inactive_layer_display_mode_to_proto(value: InactiveLayerDisplayMode) -> i32 { + match value { + InactiveLayerDisplayMode::Normal => { + board_commands::InactiveLayerDisplayMode::IldmNormal as i32 + } + InactiveLayerDisplayMode::Dimmed => { + board_commands::InactiveLayerDisplayMode::IldmDimmed as i32 + } + InactiveLayerDisplayMode::Hidden => { + board_commands::InactiveLayerDisplayMode::IldmHidden as i32 + } + InactiveLayerDisplayMode::Unknown(value) => value, + } +} + fn map_net_color_display_mode(value: i32) -> NetColorDisplayMode { match board_commands::NetColorDisplayMode::try_from(value) { Ok(board_commands::NetColorDisplayMode::NcdmAll) => NetColorDisplayMode::All, @@ -1779,6 +2640,15 @@ fn map_net_color_display_mode(value: i32) -> NetColorDisplayMode { } } +fn net_color_display_mode_to_proto(value: NetColorDisplayMode) -> i32 { + match value { + NetColorDisplayMode::All => board_commands::NetColorDisplayMode::NcdmAll as i32, + NetColorDisplayMode::Ratsnest => board_commands::NetColorDisplayMode::NcdmRatsnest as i32, + NetColorDisplayMode::Off => board_commands::NetColorDisplayMode::NcdmOff as i32, + NetColorDisplayMode::Unknown(value) => value, + } +} + fn map_board_flip_mode(value: i32) -> BoardFlipMode { match board_commands::BoardFlipMode::try_from(value) { Ok(board_commands::BoardFlipMode::BfmNormal) => BoardFlipMode::Normal, @@ -1787,6 +2657,14 @@ fn map_board_flip_mode(value: i32) -> BoardFlipMode { } } +fn board_flip_mode_to_proto(value: BoardFlipMode) -> i32 { + match value { + BoardFlipMode::Normal => board_commands::BoardFlipMode::BfmNormal as i32, + BoardFlipMode::FlippedX => board_commands::BoardFlipMode::BfmFlippedX as i32, + BoardFlipMode::Unknown(value) => value, + } +} + fn map_ratsnest_display_mode(value: i32) -> RatsnestDisplayMode { match board_commands::RatsnestDisplayMode::try_from(value) { Ok(board_commands::RatsnestDisplayMode::RdmAllLayers) => RatsnestDisplayMode::AllLayers, @@ -1797,6 +2675,16 @@ fn map_ratsnest_display_mode(value: i32) -> RatsnestDisplayMode { } } +fn ratsnest_display_mode_to_proto(value: RatsnestDisplayMode) -> i32 { + match value { + RatsnestDisplayMode::AllLayers => board_commands::RatsnestDisplayMode::RdmAllLayers as i32, + RatsnestDisplayMode::VisibleLayers => { + board_commands::RatsnestDisplayMode::RdmVisibleLayers as i32 + } + RatsnestDisplayMode::Unknown(value) => value, + } +} + fn map_board_stackup(stackup: board_proto::BoardStackup) -> BoardStackup { let finish_type_name = stackup .finish @@ -1807,6 +2695,7 @@ fn map_board_stackup(stackup: board_proto::BoardStackup) -> BoardStackup { .map(|impedance| impedance.is_controlled) .unwrap_or(false); let edge = stackup.edge.unwrap_or_default(); + let edge_has_connector = edge.connector.is_some(); let edge_has_castellated_pads = edge .castellation .map(|value| value.has_castellated_pads) @@ -1845,12 +2734,75 @@ fn map_board_stackup(stackup: board_proto::BoardStackup) -> BoardStackup { BoardStackup { finish_type_name, impedance_controlled, + edge_has_connector, edge_has_castellated_pads, edge_has_edge_plating, layers, } } +fn board_stackup_to_proto(stackup: BoardStackup) -> board_proto::BoardStackup { + board_proto::BoardStackup { + finish: (!stackup.finish_type_name.is_empty()).then_some(board_proto::BoardFinish { + type_name: stackup.finish_type_name, + }), + impedance: Some(board_proto::BoardImpedanceControl { + is_controlled: stackup.impedance_controlled, + }), + edge: Some(board_proto::BoardEdgeSettings { + connector: stackup + .edge_has_connector + .then_some(board_proto::BoardEdgeConnector {}), + castellation: Some(board_proto::Castellation { + has_castellated_pads: stackup.edge_has_castellated_pads, + }), + plating: Some(board_proto::EdgePlating { + has_edge_plating: stackup.edge_has_edge_plating, + }), + }), + layers: stackup + .layers + .into_iter() + .map(board_stackup_layer_to_proto) + .collect(), + } +} + +fn board_stackup_layer_to_proto(layer: BoardStackupLayer) -> board_proto::BoardStackupLayer { + board_proto::BoardStackupLayer { + thickness: layer + .thickness_nm + .map(|value_nm| common_types::Distance { value_nm }), + layer: layer.layer.id, + enabled: layer.enabled, + r#type: board_stackup_layer_type_to_proto(layer.layer_type), + dielectric: (!layer.dielectric_layers.is_empty()).then(|| { + board_proto::BoardStackupDielectricLayer { + layer: layer + .dielectric_layers + .into_iter() + .map(|dielectric| board_proto::BoardStackupDielectricProperties { + epsilon_r: dielectric.epsilon_r, + loss_tangent: dielectric.loss_tangent, + material_name: dielectric.material_name, + thickness: dielectric + .thickness_nm + .map(|value_nm| common_types::Distance { value_nm }), + }) + .collect(), + } + }), + color: layer.color.map(|color| common_types::Color { + r: color.r, + g: color.g, + b: color.b, + a: color.a, + }), + material_name: layer.material_name, + user_name: layer.user_name, + } +} + fn map_graphics_defaults(defaults: board_proto::GraphicsDefaults) -> GraphicsDefaults { GraphicsDefaults { layers: defaults @@ -1886,6 +2838,75 @@ fn map_board_editor_appearance_settings( } } +fn board_editor_appearance_settings_to_proto( + settings: BoardEditorAppearanceSettings, +) -> board_commands::BoardEditorAppearanceSettings { + board_commands::BoardEditorAppearanceSettings { + inactive_layer_display: inactive_layer_display_mode_to_proto( + settings.inactive_layer_display, + ), + net_color_display: net_color_display_mode_to_proto(settings.net_color_display), + board_flip: board_flip_mode_to_proto(settings.board_flip), + ratsnest_display: ratsnest_display_mode_to_proto(settings.ratsnest_display), + } +} + +fn net_class_type_to_proto(value: NetClassType) -> i32 { + match value { + NetClassType::Explicit => common_project::NetClassType::NctExplicit as i32, + NetClassType::Implicit => common_project::NetClassType::NctImplicit as i32, + NetClassType::Unknown(raw) => raw, + } +} + +fn net_class_info_to_proto(value: NetClassInfo) -> common_project::NetClass { + let board = value + .board + .map(|board| common_project::NetClassBoardSettings { + clearance: board + .clearance_nm + .map(|value_nm| common_types::Distance { value_nm }), + track_width: board + .track_width_nm + .map(|value_nm| common_types::Distance { value_nm }), + diff_pair_track_width: board + .diff_pair_track_width_nm + .map(|value_nm| common_types::Distance { value_nm }), + diff_pair_gap: board + .diff_pair_gap_nm + .map(|value_nm| common_types::Distance { value_nm }), + diff_pair_via_gap: board + .diff_pair_via_gap_nm + .map(|value_nm| common_types::Distance { value_nm }), + via_stack: if board.has_via_stack { + Some(board_types::PadStack::default()) + } else { + None + }, + microvia_stack: if board.has_microvia_stack { + Some(board_types::PadStack::default()) + } else { + None + }, + color: board.color.map(|color| common_types::Color { + r: color.r, + g: color.g, + b: color.b, + a: color.a, + }), + tuning_profile: board.tuning_profile, + }); + + common_project::NetClass { + name: value.name, + priority: value.priority, + board, + schematic: None, + r#type: net_class_type_to_proto(value.class_type), + constituents: value.constituents, + } +} + fn map_net_class_type(value: i32) -> NetClassType { match common_project::NetClassType::try_from(value) { Ok(common_project::NetClassType::NctExplicit) => NetClassType::Explicit, @@ -2617,17 +3638,23 @@ fn default_client_name() -> String { #[cfg(test)] mod tests { use super::{ - any_to_pretty_debug, ensure_item_request_ok, layer_to_model, map_hit_test_result, - map_item_bounding_boxes, map_polygon_with_holes, model_document_to_proto, - normalize_socket_uri, pad_netlist_from_footprint_items, select_single_board_document, - select_single_project_path, selection_item_detail, summarize_item_details, - summarize_selection, text_horizontal_alignment_to_proto, text_spec_to_proto, - PCB_OBJECT_TYPES, + any_to_pretty_debug, board_editor_appearance_settings_to_proto, board_stackup_to_proto, + commit_action_to_proto, drc_severity_to_proto, ensure_item_deletion_status_ok, + ensure_item_request_ok, ensure_item_status_ok, layer_to_model, map_board_stackup, + map_commit_session, map_hit_test_result, map_item_bounding_boxes, map_merge_mode_to_proto, + 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, summarize_selection, text_horizontal_alignment_to_proto, + text_spec_to_proto, PCB_OBJECT_TYPES, }; use crate::error::KiCadError; + use crate::model::board::{ + BoardLayerInfo, BoardStackup, BoardStackupLayer, BoardStackupLayerType, + }; use crate::model::common::{ - DocumentSpecifier, DocumentType, ProjectInfo, TextAttributesSpec, TextHorizontalAlignment, - TextSpec, + CommitAction, DocumentSpecifier, DocumentType, ProjectInfo, TextAttributesSpec, + TextHorizontalAlignment, TextSpec, }; use prost::Message; use std::path::PathBuf; @@ -2759,6 +3786,241 @@ mod tests { assert_eq!(project.path, "/tmp/demo"); } + #[test] + fn map_commit_session_maps_commit_id() { + let response = crate::proto::kiapi::common::commands::BeginCommitResponse { + id: Some(crate::proto::kiapi::common::types::Kiid { + value: "commit-123".to_string(), + }), + }; + + let session = map_commit_session(response).expect("commit id should map"); + assert_eq!(session.id, "commit-123"); + } + + #[test] + fn map_commit_session_requires_commit_id() { + let response = crate::proto::kiapi::common::commands::BeginCommitResponse { id: None }; + let err = map_commit_session(response).expect_err("missing id must fail"); + assert!(matches!(err, KiCadError::InvalidResponse { .. })); + } + + #[test] + fn commit_action_to_proto_maps_known_variants() { + assert_eq!( + commit_action_to_proto(CommitAction::Commit), + crate::proto::kiapi::common::commands::CommitAction::CmaCommit as i32 + ); + assert_eq!( + commit_action_to_proto(CommitAction::Drop), + crate::proto::kiapi::common::commands::CommitAction::CmaDrop as i32 + ); + } + + #[test] + fn map_merge_mode_to_proto_maps_known_variants() { + assert_eq!( + map_merge_mode_to_proto(crate::model::common::MapMergeMode::Merge), + crate::proto::kiapi::common::types::MapMergeMode::MmmMerge as i32 + ); + assert_eq!( + map_merge_mode_to_proto(crate::model::common::MapMergeMode::Replace), + crate::proto::kiapi::common::types::MapMergeMode::MmmReplace as i32 + ); + } + + #[test] + fn drc_severity_to_proto_maps_known_variants() { + assert_eq!( + drc_severity_to_proto(crate::model::board::DrcSeverity::Warning), + crate::proto::kiapi::board::commands::DrcSeverity::DrsWarning as i32 + ); + assert_eq!( + drc_severity_to_proto(crate::model::board::DrcSeverity::Error), + crate::proto::kiapi::board::commands::DrcSeverity::DrsError as i32 + ); + } + + #[test] + fn board_editor_appearance_settings_to_proto_maps_known_variants() { + let proto = board_editor_appearance_settings_to_proto( + crate::model::board::BoardEditorAppearanceSettings { + inactive_layer_display: crate::model::board::InactiveLayerDisplayMode::Hidden, + net_color_display: crate::model::board::NetColorDisplayMode::Ratsnest, + board_flip: crate::model::board::BoardFlipMode::FlippedX, + ratsnest_display: crate::model::board::RatsnestDisplayMode::VisibleLayers, + }, + ); + + assert_eq!( + proto.inactive_layer_display, + crate::proto::kiapi::board::commands::InactiveLayerDisplayMode::IldmHidden as i32 + ); + assert_eq!( + proto.net_color_display, + crate::proto::kiapi::board::commands::NetColorDisplayMode::NcdmRatsnest as i32 + ); + assert_eq!( + proto.board_flip, + crate::proto::kiapi::board::commands::BoardFlipMode::BfmFlippedX as i32 + ); + assert_eq!( + proto.ratsnest_display, + crate::proto::kiapi::board::commands::RatsnestDisplayMode::RdmVisibleLayers as i32 + ); + } + + #[test] + fn map_board_stackup_defaults_missing_optional_messages() { + let mapped = map_board_stackup(crate::proto::kiapi::board::BoardStackup::default()); + assert_eq!(mapped.finish_type_name, ""); + assert!(!mapped.impedance_controlled); + assert!(!mapped.edge_has_connector); + assert!(!mapped.edge_has_castellated_pads); + assert!(!mapped.edge_has_edge_plating); + assert!(mapped.layers.is_empty()); + } + + #[test] + fn map_board_stackup_maps_unknown_layer_type_enum() { + let mapped = map_board_stackup(crate::proto::kiapi::board::BoardStackup { + finish: None, + impedance: None, + edge: None, + layers: vec![crate::proto::kiapi::board::BoardStackupLayer { + thickness: None, + layer: crate::proto::kiapi::board::types::BoardLayer::BlFCu as i32, + enabled: true, + r#type: 777, + dielectric: None, + color: None, + material_name: String::new(), + user_name: String::new(), + }], + }); + assert!(matches!( + mapped.layers.first().map(|layer| layer.layer_type), + Some(BoardStackupLayerType::Unknown(777)) + )); + } + + #[test] + fn board_stackup_to_proto_maps_unknown_layer_type_and_missing_nested_messages() { + let proto = board_stackup_to_proto(BoardStackup { + finish_type_name: String::new(), + impedance_controlled: false, + edge_has_connector: false, + edge_has_castellated_pads: false, + edge_has_edge_plating: false, + layers: vec![BoardStackupLayer { + layer: BoardLayerInfo { + id: crate::proto::kiapi::board::types::BoardLayer::BlFCu as i32, + name: "BL_F_Cu".to_string(), + }, + user_name: "F.Cu".to_string(), + material_name: "Copper".to_string(), + enabled: true, + thickness_nm: None, + layer_type: BoardStackupLayerType::Unknown(321), + color: None, + dielectric_layers: Vec::new(), + }], + }); + + assert!(proto.finish.is_none()); + assert_eq!( + proto + .impedance + .expect("impedance should always be present") + .is_controlled, + false + ); + let edge = proto.edge.expect("edge should always be present"); + assert!(edge.connector.is_none()); + assert_eq!( + edge.castellation + .expect("castellation should be present") + .has_castellated_pads, + false + ); + assert_eq!( + edge.plating + .expect("plating should be present") + .has_edge_plating, + false + ); + let layer = proto.layers.first().expect("one layer should be present"); + assert!(layer.thickness.is_none()); + assert_eq!(layer.r#type, 321); + assert!(layer.dielectric.is_none()); + assert!(layer.color.is_none()); + } + + #[test] + fn board_stackup_to_proto_preserves_edge_connector_presence() { + let proto = board_stackup_to_proto(BoardStackup { + finish_type_name: "ENIG".to_string(), + impedance_controlled: true, + edge_has_connector: true, + edge_has_castellated_pads: true, + edge_has_edge_plating: true, + layers: Vec::new(), + }); + assert_eq!( + proto.finish.expect("finish should be present").type_name, + "ENIG" + ); + let edge = proto.edge.expect("edge should be present"); + assert!(edge.connector.is_some()); + assert_eq!( + edge.castellation + .expect("castellation should be present") + .has_castellated_pads, + true + ); + assert_eq!( + edge.plating + .expect("plating should be present") + .has_edge_plating, + true + ); + } + + #[test] + fn response_payload_as_any_validates_type_url() { + let response = crate::proto::kiapi::common::ApiResponse { + header: None, + status: None, + message: Some(prost_types::Any { + type_url: super::envelope::type_url("kiapi.common.commands.GetVersionResponse"), + value: Vec::new(), + }), + }; + + let err = response_payload_as_any(response, "kiapi.common.commands.BeginCommitResponse") + .expect_err("wrong type_url must fail"); + assert!(matches!(err, KiCadError::UnexpectedPayloadType { .. })); + } + + #[test] + fn response_payload_as_any_accepts_google_protobuf_empty_type() { + let response = crate::proto::kiapi::common::ApiResponse { + header: None, + status: None, + message: Some(prost_types::Any { + type_url: super::envelope::type_url("google.protobuf.Empty"), + value: Vec::new(), + }), + }; + + let payload = response_payload_as_any(response, "google.protobuf.Empty") + .expect("google.protobuf.Empty payload type should be accepted"); + assert_eq!( + payload.type_url, + super::envelope::type_url("google.protobuf.Empty") + ); + } + #[test] fn summarize_selection_counts_payload_types() { let items = vec![ @@ -2920,6 +4182,44 @@ 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 ensure_item_deletion_status_ok_accepts_ok_and_rejects_non_ok() { + assert!(ensure_item_deletion_status_ok( + crate::proto::kiapi::common::commands::ItemDeletionStatus::IdsOk as i32 + ) + .is_ok()); + + let err = ensure_item_deletion_status_ok( + crate::proto::kiapi::common::commands::ItemDeletionStatus::IdsNonexistent as i32, + ) + .expect_err("non-OK item deletion status should fail"); + match err { + KiCadError::ItemStatus { code } => assert_eq!(code, "IDS_NONEXISTENT"), + _ => panic!("expected item status error"), + } + } + #[test] fn summarize_item_details_reports_unknown_payload_as_unparsed() { let items = vec![prost_types::Any { @@ -2970,6 +4270,32 @@ mod tests { ); } + #[test] + fn map_run_action_status_covers_known_variants() { + assert_eq!( + map_run_action_status( + crate::proto::kiapi::common::commands::RunActionStatus::RasOk as i32 + ), + crate::model::common::RunActionStatus::Ok + ); + assert_eq!( + map_run_action_status( + crate::proto::kiapi::common::commands::RunActionStatus::RasInvalid as i32 + ), + crate::model::common::RunActionStatus::Invalid + ); + assert_eq!( + map_run_action_status( + crate::proto::kiapi::common::commands::RunActionStatus::RasFrameNotOpen as i32 + ), + crate::model::common::RunActionStatus::FrameNotOpen + ); + assert_eq!( + map_run_action_status(1234), + crate::model::common::RunActionStatus::Unknown(1234) + ); + } + #[test] fn text_horizontal_alignment_to_proto_covers_known_variants() { assert_eq!( diff --git a/src/lib.rs b/src/lib.rs index 3b11e53..5f4b03a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,7 +24,7 @@ pub use crate::model::board::{ ArcStartMidEndNm, BoardEditorAppearanceSettings, BoardEnabledLayers, BoardFlipMode, BoardLayerClass, BoardLayerGraphicsDefault, BoardLayerInfo, BoardNet, BoardOriginKind, BoardStackup, BoardStackupDielectricProperties, BoardStackupLayer, BoardStackupLayerType, - ColorRgba, GraphicsDefaults, InactiveLayerDisplayMode, NetClassBoardSettings, + ColorRgba, DrcSeverity, GraphicsDefaults, InactiveLayerDisplayMode, NetClassBoardSettings, NetClassForNetEntry, NetClassInfo, NetClassType, NetColorDisplayMode, PadNetEntry, PadShapeAsPolygonEntry, PadstackPresenceEntry, PadstackPresenceState, PcbArc, PcbBoardGraphicShape, PcbBoardText, PcbBoardTextBox, PcbDimension, PcbField, PcbFootprint, @@ -33,8 +33,9 @@ pub use crate::model::board::{ Vector2Nm, }; pub use crate::model::common::{ - DocumentSpecifier, DocumentType, ItemBoundingBox, ItemHitTestResult, PcbObjectTypeCode, - SelectionItemDetail, SelectionSummary, SelectionTypeCount, TextAsShapesEntry, - TextAttributesSpec, TextBoxSpec, TextExtents, TextHorizontalAlignment, TextObjectSpec, - TextShape, TextShapeGeometry, TextSpec, TextVerticalAlignment, TitleBlockInfo, VersionInfo, + CommitAction, CommitSession, DocumentSpecifier, DocumentType, EditorFrameType, ItemBoundingBox, + ItemHitTestResult, MapMergeMode, PcbObjectTypeCode, RunActionStatus, SelectionItemDetail, + SelectionSummary, SelectionTypeCount, TextAsShapesEntry, TextAttributesSpec, TextBoxSpec, + TextExtents, TextHorizontalAlignment, TextObjectSpec, TextShape, TextShapeGeometry, TextSpec, + TextVerticalAlignment, TitleBlockInfo, VersionInfo, }; diff --git a/src/model/board.rs b/src/model/board.rs index ca3f97d..86b5d36 100644 --- a/src/model/board.rs +++ b/src/model/board.rs @@ -164,6 +164,7 @@ pub struct BoardStackupLayer { pub struct BoardStackup { pub finish_type_name: String, pub impedance_controlled: bool, + pub edge_has_connector: bool, pub edge_has_castellated_pads: bool, pub edge_has_edge_plating: bool, pub layers: Vec, @@ -224,6 +225,54 @@ pub enum RatsnestDisplayMode { Unknown(i32), } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DrcSeverity { + Warning, + Error, + Exclusion, + Ignore, + Info, + Action, + Debug, + Undefined, +} + +impl std::fmt::Display for DrcSeverity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let value = match self { + Self::Warning => "warning", + Self::Error => "error", + Self::Exclusion => "exclusion", + Self::Ignore => "ignore", + Self::Info => "info", + Self::Action => "action", + Self::Debug => "debug", + Self::Undefined => "undefined", + }; + write!(f, "{value}") + } +} + +impl FromStr for DrcSeverity { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "warning" => Ok(Self::Warning), + "error" => Ok(Self::Error), + "exclusion" => Ok(Self::Exclusion), + "ignore" => Ok(Self::Ignore), + "info" => Ok(Self::Info), + "action" => Ok(Self::Action), + "debug" => Ok(Self::Debug), + "undefined" => Ok(Self::Undefined), + _ => Err(format!( + "unknown drc severity `{value}`; expected warning, error, exclusion, ignore, info, action, debug, or undefined" + )), + } + } +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct BoardEditorAppearanceSettings { pub inactive_layer_display: InactiveLayerDisplayMode, @@ -424,7 +473,7 @@ pub enum PcbItem { mod tests { use std::str::FromStr; - use super::BoardOriginKind; + use super::{BoardOriginKind, DrcSeverity}; #[test] fn board_origin_kind_parses_known_values() { @@ -443,4 +492,22 @@ mod tests { let result = BoardOriginKind::from_str("other"); assert!(result.is_err()); } + + #[test] + fn drc_severity_parses_known_values() { + assert_eq!( + DrcSeverity::from_str("warning").expect("warning should parse"), + DrcSeverity::Warning + ); + assert_eq!( + DrcSeverity::from_str("error").expect("error should parse"), + DrcSeverity::Error + ); + } + + #[test] + fn drc_severity_rejects_unknown_values() { + let result = DrcSeverity::from_str("fatal"); + assert!(result.is_err()); + } } diff --git a/src/model/common.rs b/src/model/common.rs index 6bfd6c5..745b0e3 100644 --- a/src/model/common.rs +++ b/src/model/common.rs @@ -12,6 +12,65 @@ pub struct VersionInfo { pub full_version: String, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum EditorFrameType { + ProjectManager, + SchematicEditor, + PcbEditor, + SpiceSimulator, + SymbolEditor, + FootprintEditor, + DrawingSheetEditor, +} + +impl EditorFrameType { + pub(crate) fn to_proto(self) -> i32 { + match self { + Self::ProjectManager => common_types::FrameType::FtProjectManager as i32, + Self::SchematicEditor => common_types::FrameType::FtSchematicEditor as i32, + Self::PcbEditor => common_types::FrameType::FtPcbEditor as i32, + Self::SpiceSimulator => common_types::FrameType::FtSpiceSimulator as i32, + Self::SymbolEditor => common_types::FrameType::FtSymbolEditor as i32, + Self::FootprintEditor => common_types::FrameType::FtFootprintEditor as i32, + Self::DrawingSheetEditor => common_types::FrameType::FtDrawingSheetEditor as i32, + } + } +} + +impl std::fmt::Display for EditorFrameType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let value = match self { + Self::ProjectManager => "project-manager", + Self::SchematicEditor => "schematic", + Self::PcbEditor => "pcb", + Self::SpiceSimulator => "spice", + Self::SymbolEditor => "symbol", + Self::FootprintEditor => "footprint", + Self::DrawingSheetEditor => "drawing-sheet", + }; + write!(f, "{value}") + } +} + +impl FromStr for EditorFrameType { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "project-manager" => Ok(Self::ProjectManager), + "schematic" => Ok(Self::SchematicEditor), + "pcb" => Ok(Self::PcbEditor), + "spice" => Ok(Self::SpiceSimulator), + "symbol" => Ok(Self::SymbolEditor), + "footprint" => Ok(Self::FootprintEditor), + "drawing-sheet" => Ok(Self::DrawingSheetEditor), + _ => Err(format!( + "unknown frame `{value}`; expected one of: project-manager, schematic, pcb, spice, symbol, footprint, drawing-sheet" + )), + } + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum DocumentType { Schematic, @@ -113,6 +172,77 @@ pub struct SelectionItemDetail { pub raw_len: usize, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CommitSession { + pub id: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CommitAction { + Commit, + Drop, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RunActionStatus { + Ok, + Invalid, + FrameNotOpen, + Unknown(i32), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MapMergeMode { + Merge, + Replace, +} + +impl std::fmt::Display for MapMergeMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Merge => write!(f, "merge"), + Self::Replace => write!(f, "replace"), + } + } +} + +impl FromStr for MapMergeMode { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "merge" => Ok(Self::Merge), + "replace" => Ok(Self::Replace), + _ => Err(format!( + "unknown merge mode `{value}`; expected `merge` or `replace`" + )), + } + } +} + +impl std::fmt::Display for CommitAction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Commit => write!(f, "commit"), + Self::Drop => write!(f, "drop"), + } + } +} + +impl FromStr for CommitAction { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "commit" => Ok(Self::Commit), + "drop" => Ok(Self::Drop), + _ => Err(format!( + "unknown commit action `{value}`; expected `commit` or `drop`" + )), + } + } +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct TitleBlockInfo { pub title: String, @@ -299,3 +429,48 @@ impl std::fmt::Display for ItemHitTestResult { write!(f, "{value}") } } + +#[cfg(test)] +mod tests { + use super::{CommitAction, EditorFrameType, MapMergeMode}; + use std::str::FromStr; + + #[test] + fn commit_action_parses_known_values() { + assert_eq!(CommitAction::from_str("commit"), Ok(CommitAction::Commit)); + assert_eq!(CommitAction::from_str("drop"), Ok(CommitAction::Drop)); + } + + #[test] + fn commit_action_rejects_unknown_values() { + assert!(CommitAction::from_str("rollback").is_err()); + } + + #[test] + fn editor_frame_type_parses_known_values() { + assert_eq!( + EditorFrameType::from_str("pcb"), + Ok(EditorFrameType::PcbEditor) + ); + assert_eq!( + EditorFrameType::from_str("project-manager"), + Ok(EditorFrameType::ProjectManager) + ); + } + + #[test] + fn editor_frame_type_rejects_unknown_values() { + assert!(EditorFrameType::from_str("layout").is_err()); + } + + #[test] + fn map_merge_mode_parses_known_values() { + assert_eq!(MapMergeMode::from_str("merge"), Ok(MapMergeMode::Merge)); + assert_eq!(MapMergeMode::from_str("replace"), Ok(MapMergeMode::Replace)); + } + + #[test] + fn map_merge_mode_rejects_unknown_values() { + assert!(MapMergeMode::from_str("upsert").is_err()); + } +} diff --git a/test-scripts/kicad-ipc-cli.rs b/test-scripts/kicad-ipc-cli.rs index 6d20915..942b6cd 100644 --- a/test-scripts/kicad-ipc-cli.rs +++ b/test-scripts/kicad-ipc-cli.rs @@ -6,8 +6,10 @@ use std::str::FromStr; use std::time::Duration; use kicad_ipc::{ - BoardOriginKind, ClientBuilder, DocumentType, KiCadClient, KiCadError, PadstackPresenceState, - PcbObjectTypeCode, TextObjectSpec, TextShapeGeometry, TextSpec, Vector2Nm, + BoardFlipMode, BoardOriginKind, ClientBuilder, CommitAction, CommitSession, DocumentType, + DrcSeverity, EditorFrameType, InactiveLayerDisplayMode, KiCadClient, KiCadError, MapMergeMode, + NetColorDisplayMode, PadstackPresenceState, PcbObjectTypeCode, RatsnestDisplayMode, + TextObjectSpec, TextShapeGeometry, TextSpec, Vector2Nm, }; const REPORT_MAX_PAD_NET_ROWS: usize = 2_000; @@ -20,6 +22,7 @@ const REPORT_MAX_BOARD_SNAPSHOT_CHARS: usize = 750_000; struct CliConfig { socket: Option, token: Option, + client_name: Option, timeout_ms: u64, } @@ -27,13 +30,26 @@ struct CliConfig { enum Command { Ping, Version, + KiCadBinaryPath { + binary_name: String, + }, + PluginSettingsPath { + identifier: String, + }, OpenDocs { document_type: DocumentType, }, ProjectPath, BoardOpen, NetClasses, + SetNetClasses { + merge_mode: MapMergeMode, + }, TextVariables, + SetTextVariables { + merge_mode: MapMergeMode, + variables: BTreeMap, + }, ExpandTextVariables { text: Vec, }, @@ -45,11 +61,72 @@ enum Command { }, Nets, EnabledLayers, + SetEnabledLayers { + copper_layer_count: u32, + layer_ids: Vec, + }, ActiveLayer, + SetActiveLayer { + layer_id: i32, + }, VisibleLayers, + SetVisibleLayers { + layer_ids: Vec, + }, BoardOrigin { kind: BoardOriginKind, }, + SetBoardOrigin { + kind: BoardOriginKind, + x_nm: i64, + y_nm: i64, + }, + InjectDrcError { + severity: DrcSeverity, + message: String, + x_nm: Option, + y_nm: Option, + item_ids: Vec, + }, + RefreshEditor { + frame: EditorFrameType, + }, + BeginCommit, + EndCommit { + id: String, + action: CommitAction, + message: String, + }, + SaveDoc, + SaveCopy { + path: String, + overwrite: bool, + include_project: bool, + }, + RevertDoc, + RunAction { + action: String, + }, + CreateItems { + items: Vec, + container_id: Option, + }, + UpdateItems { + items: Vec, + }, + DeleteItems { + item_ids: Vec, + }, + ParseCreateItemsFromString { + contents: String, + }, + AddToSelection { + item_ids: Vec, + }, + RemoveFromSelection { + item_ids: Vec, + }, + ClearSelection, SelectionSummary, SelectionDetails, SelectionRaw, @@ -89,8 +166,21 @@ enum Command { BoardAsString, SelectionAsString, Stackup, + UpdateStackup, GraphicsDefaults, Appearance, + SetAppearance { + inactive_layer_display: InactiveLayerDisplayMode, + net_color_display: NetColorDisplayMode, + board_flip: BoardFlipMode, + ratsnest_display: RatsnestDisplayMode, + }, + RefillZones { + zone_ids: Vec, + }, + InteractiveMoveItems { + item_ids: Vec, + }, NetClass, BoardReadReport { output: PathBuf, @@ -141,6 +231,9 @@ async fn run() -> Result<(), KiCadError> { if let Some(token) = config.token { builder = builder.token(token); } + if let Some(client_name) = config.client_name { + builder = builder.client_name(client_name); + } let client = builder.connect().await?; @@ -156,6 +249,14 @@ async fn run() -> Result<(), KiCadError> { version.major, version.minor, version.patch, version.full_version ); } + Command::KiCadBinaryPath { binary_name } => { + let path = client.get_kicad_binary_path(binary_name).await?; + println!("kicad_binary_path={path}"); + } + Command::PluginSettingsPath { identifier } => { + let path = client.get_plugin_settings_path(identifier).await?; + println!("plugin_settings_path={path}"); + } Command::OpenDocs { document_type } => { let docs = client.get_open_documents(document_type).await?; if docs.is_empty() { @@ -206,6 +307,15 @@ async fn run() -> Result<(), KiCadError> { ); } } + Command::SetNetClasses { merge_mode } => { + let classes = client.get_net_classes().await?; + let updated = client.set_net_classes(classes, merge_mode).await?; + println!( + "net_class_count={} merge_mode={}", + updated.len(), + merge_mode + ); + } Command::TextVariables => { let variables = client.get_text_variables().await?; println!("text_variable_count={}", variables.len()); @@ -213,6 +323,20 @@ async fn run() -> Result<(), KiCadError> { println!("name={} value={}", name, value); } } + Command::SetTextVariables { + merge_mode, + variables, + } => { + let updated = client.set_text_variables(variables, merge_mode).await?; + println!( + "text_variable_count={} merge_mode={}", + updated.len(), + merge_mode + ); + for (name, value) in updated { + println!("name={} value={}", name, value); + } + } Command::ExpandTextVariables { text } => { let expanded = client.expand_text_variables(text.clone()).await?; println!("expanded_count={}", expanded.len()); @@ -285,6 +409,18 @@ async fn run() -> Result<(), KiCadError> { println!("layer_id={} layer_name={}", layer.id, layer.name); } } + Command::SetEnabledLayers { + copper_layer_count, + layer_ids, + } => { + let enabled = client + .set_board_enabled_layers(copper_layer_count, layer_ids) + .await?; + println!("copper_layer_count={}", enabled.copper_layer_count); + for layer in enabled.layers { + println!("layer_id={} layer_name={}", layer.id, layer.name); + } + } Command::ActiveLayer => { let layer = client.get_active_layer().await?; println!( @@ -292,6 +428,10 @@ async fn run() -> Result<(), KiCadError> { layer.id, layer.name ); } + Command::SetActiveLayer { layer_id } => { + client.set_active_layer(layer_id).await?; + println!("set_active_layer_id={}", layer_id); + } Command::VisibleLayers => { let layers = client.get_visible_layers().await?; if layers.is_empty() { @@ -302,6 +442,10 @@ async fn run() -> Result<(), KiCadError> { } } } + Command::SetVisibleLayers { layer_ids } => { + client.set_visible_layers(layer_ids.clone()).await?; + println!("set_visible_layer_count={}", layer_ids.len()); + } Command::BoardOrigin { kind } => { let origin = client.get_board_origin(kind).await?; println!( @@ -309,6 +453,135 @@ async fn run() -> Result<(), KiCadError> { kind, origin.x_nm, origin.y_nm ); } + Command::SetBoardOrigin { kind, x_nm, y_nm } => { + client + .set_board_origin(kind, Vector2Nm { x_nm, y_nm }) + .await?; + println!("set_origin_kind={} x_nm={} y_nm={}", kind, x_nm, y_nm); + } + Command::InjectDrcError { + severity, + message, + x_nm, + y_nm, + item_ids, + } => { + let position = match (x_nm, y_nm) { + (Some(x_nm), Some(y_nm)) => Some(Vector2Nm { x_nm, y_nm }), + _ => None, + }; + let marker = client + .inject_drc_error(severity, message, position, item_ids) + .await?; + println!( + "drc_marker_id={}", + marker.unwrap_or_else(|| "-".to_string()) + ); + } + Command::RefreshEditor { frame } => { + client.refresh_editor(frame).await?; + println!("refresh_editor=ok frame={}", frame); + } + Command::BeginCommit => { + let session = client.begin_commit().await?; + println!("commit_id={}", session.id); + } + Command::EndCommit { + id, + action, + message, + } => { + client + .end_commit(CommitSession { id }, action, message) + .await?; + println!("end_commit=ok action={}", action); + } + Command::SaveDoc => { + client.save_document().await?; + println!("save_document=ok"); + } + Command::SaveCopy { + path, + overwrite, + include_project, + } => { + client + .save_copy_of_document(path, overwrite, include_project) + .await?; + println!( + "save_copy_of_document=ok overwrite={} include_project={}", + overwrite, include_project + ); + } + Command::RevertDoc => { + client.revert_document().await?; + println!("revert_document=ok"); + } + Command::RunAction { action } => { + 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::UpdateItems { items } => { + let updated = client.update_items(items).await?; + println!("updated_item_count={}", updated.len()); + for (index, item) in updated.iter().enumerate() { + println!( + "[{index}] type_url={} raw_len={}", + item.type_url, + item.value.len() + ); + } + } + Command::DeleteItems { item_ids } => { + let deleted = client.delete_items(item_ids).await?; + println!("deleted_item_count={}", deleted.len()); + for (index, item_id) in deleted.iter().enumerate() { + println!("[{index}] id={item_id}"); + } + } + Command::ParseCreateItemsFromString { contents } => { + let created = client.parse_and_create_items_from_string(contents).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); + for entry in summary.type_url_counts { + println!("type_url={} count={}", entry.type_url, entry.count); + } + } + Command::RemoveFromSelection { item_ids } => { + let summary = client.remove_from_selection(item_ids).await?; + println!("selection_total={}", summary.total_items); + for entry in summary.type_url_counts { + println!("type_url={} count={}", entry.type_url, entry.count); + } + } + Command::ClearSelection => { + let summary = client.clear_selection().await?; + println!("selection_total={}", summary.total_items); + } Command::SelectionSummary => { let summary = client.get_selection_summary().await?; println!("selection_total={}", summary.total_items); @@ -571,6 +844,11 @@ async fn run() -> Result<(), KiCadError> { let stackup = client.get_board_stackup().await?; println!("{stackup:#?}"); } + Command::UpdateStackup => { + let stackup = client.get_board_stackup().await?; + let updated = client.update_board_stackup(stackup).await?; + println!("{updated:#?}"); + } Command::GraphicsDefaults => { let defaults = client.get_graphics_defaults().await?; println!("{defaults:#?}"); @@ -579,6 +857,30 @@ async fn run() -> Result<(), KiCadError> { let appearance = client.get_board_editor_appearance_settings().await?; println!("{appearance:#?}"); } + Command::SetAppearance { + inactive_layer_display, + net_color_display, + board_flip, + ratsnest_display, + } => { + let updated = client + .set_board_editor_appearance_settings(kicad_ipc::BoardEditorAppearanceSettings { + inactive_layer_display, + net_color_display, + board_flip, + ratsnest_display, + }) + .await?; + println!("{updated:#?}"); + } + Command::RefillZones { zone_ids } => { + client.refill_zones(zone_ids).await?; + println!("refill_zones_dispatched=ok"); + } + Command::InteractiveMoveItems { item_ids } => { + client.interactive_move_items(item_ids.clone()).await?; + println!("interactive_move_item_count={}", item_ids.len()); + } Command::NetClass => { let nets = client.get_nets().await?; let netclasses = client.get_netclass_for_nets(nets).await?; @@ -610,8 +912,10 @@ async fn run() -> Result<(), KiCadError> { } fn parse_args() -> Result<(CliConfig, Command), KiCadError> { - let mut args: Vec = std::env::args().skip(1).collect(); + parse_args_from(std::env::args().skip(1).collect()) +} +fn parse_args_from(mut args: Vec) -> Result<(CliConfig, Command), KiCadError> { if args.is_empty() { return Ok((default_config(), Command::Help)); } @@ -635,6 +939,13 @@ fn parse_args() -> Result<(CliConfig, Command), KiCadError> { config.token = Some(value.clone()); args.drain(index..=index + 1); } + "--client-name" => { + let value = args.get(index + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for --client-name".to_string(), + })?; + config.client_name = Some(value.clone()); + args.drain(index..=index + 1); + } "--timeout-ms" => { let value = args.get(index + 1).ok_or_else(|| KiCadError::Config { reason: "missing value for --timeout-ms".to_string(), @@ -658,10 +969,93 @@ fn parse_args() -> Result<(CliConfig, Command), KiCadError> { "help" | "--help" | "-h" => Command::Help, "ping" => Command::Ping, "version" => Command::Version, + "kicad-binary-path" => { + let mut binary_name = "kicad-cli".to_string(); + let mut i = 1; + while i < args.len() { + if args[i] == "--binary-name" { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for kicad-binary-path --binary-name".to_string(), + })?; + binary_name = value.clone(); + i += 2; + continue; + } + i += 1; + } + Command::KiCadBinaryPath { binary_name } + } + "plugin-settings-path" => { + let mut identifier = "kicad-ipc-rust".to_string(); + let mut i = 1; + while i < args.len() { + if args[i] == "--identifier" { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for plugin-settings-path --identifier".to_string(), + })?; + identifier = value.clone(); + i += 2; + continue; + } + i += 1; + } + Command::PluginSettingsPath { identifier } + } "project-path" => Command::ProjectPath, "board-open" => Command::BoardOpen, "net-classes" => Command::NetClasses, + "set-net-classes" => { + let mut merge_mode = MapMergeMode::Merge; + let mut i = 1; + while i < args.len() { + if args[i] == "--merge-mode" { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for set-net-classes --merge-mode".to_string(), + })?; + merge_mode = MapMergeMode::from_str(value) + .map_err(|reason| KiCadError::Config { reason })?; + i += 2; + continue; + } + i += 1; + } + Command::SetNetClasses { merge_mode } + } "text-variables" => Command::TextVariables, + "set-text-variables" => { + let mut merge_mode = MapMergeMode::Merge; + let mut variables = BTreeMap::new(); + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--merge-mode" => { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for set-text-variables --merge-mode".to_string(), + })?; + merge_mode = MapMergeMode::from_str(value) + .map_err(|reason| KiCadError::Config { reason })?; + i += 2; + } + "--var" => { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for set-text-variables --var".to_string(), + })?; + let (name, text) = + value.split_once('=').ok_or_else(|| KiCadError::Config { + reason: "set-text-variables --var requires `=`" + .to_string(), + })?; + variables.insert(name.to_string(), text.to_string()); + i += 2; + } + _ => i += 1, + } + } + Command::SetTextVariables { + merge_mode, + variables, + } + } "expand-text-variables" => { let mut text = Vec::new(); let mut i = 1; @@ -742,8 +1136,99 @@ fn parse_args() -> Result<(CliConfig, Command), KiCadError> { } "nets" => Command::Nets, "enabled-layers" => Command::EnabledLayers, + "set-enabled-layers" => { + let mut copper_layer_count = None; + let mut layer_ids = Vec::new(); + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--copper-layer-count" => { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for set-enabled-layers --copper-layer-count" + .to_string(), + })?; + copper_layer_count = + Some(value.parse::().map_err(|err| KiCadError::Config { + reason: format!( + "invalid set-enabled-layers --copper-layer-count `{value}`: {err}" + ), + })?); + i += 2; + } + "--layer-id" => { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for set-enabled-layers --layer-id".to_string(), + })?; + layer_ids.push(value.parse::().map_err(|err| KiCadError::Config { + reason: format!( + "invalid set-enabled-layers --layer-id `{value}`: {err}" + ), + })?); + i += 2; + } + _ => { + i += 1; + } + } + } + + Command::SetEnabledLayers { + copper_layer_count: copper_layer_count.ok_or_else(|| KiCadError::Config { + reason: "set-enabled-layers requires `--copper-layer-count `".to_string(), + })?, + layer_ids, + } + } "active-layer" => Command::ActiveLayer, + "set-active-layer" => { + let mut layer_id = None; + let mut i = 1; + while i < args.len() { + if args[i] == "--layer-id" { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for set-active-layer --layer-id".to_string(), + })?; + layer_id = Some(value.parse::().map_err(|err| KiCadError::Config { + reason: format!("invalid set-active-layer --layer-id `{value}`: {err}"), + })?); + i += 2; + continue; + } + i += 1; + } + Command::SetActiveLayer { + layer_id: layer_id.ok_or_else(|| KiCadError::Config { + reason: "set-active-layer requires `--layer-id `".to_string(), + })?, + } + } "visible-layers" => Command::VisibleLayers, + "set-visible-layers" => { + let mut layer_ids = Vec::new(); + let mut i = 1; + while i < args.len() { + if args[i] == "--layer-id" { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for set-visible-layers --layer-id".to_string(), + })?; + layer_ids.push(value.parse::().map_err(|err| KiCadError::Config { + reason: format!("invalid set-visible-layers --layer-id `{value}`: {err}"), + })?); + i += 2; + continue; + } + i += 1; + } + + if layer_ids.is_empty() { + return Err(KiCadError::Config { + reason: "set-visible-layers requires one or more `--layer-id ` arguments" + .to_string(), + }); + } + + Command::SetVisibleLayers { layer_ids } + } "board-origin" => { let mut kind = BoardOriginKind::Grid; let mut i = 1; @@ -761,6 +1246,354 @@ fn parse_args() -> Result<(CliConfig, Command), KiCadError> { } Command::BoardOrigin { kind } } + "set-board-origin" => { + let mut kind = BoardOriginKind::Grid; + let mut x_nm = None; + let mut y_nm = None; + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--type" => { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for set-board-origin --type".to_string(), + })?; + kind = BoardOriginKind::from_str(value) + .map_err(|err| KiCadError::Config { reason: err })?; + i += 2; + } + "--x-nm" => { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for set-board-origin --x-nm".to_string(), + })?; + x_nm = Some(value.parse::().map_err(|err| KiCadError::Config { + reason: format!("invalid set-board-origin --x-nm `{value}`: {err}"), + })?); + i += 2; + } + "--y-nm" => { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for set-board-origin --y-nm".to_string(), + })?; + y_nm = Some(value.parse::().map_err(|err| KiCadError::Config { + reason: format!("invalid set-board-origin --y-nm `{value}`: {err}"), + })?); + i += 2; + } + _ => { + i += 1; + } + } + } + Command::SetBoardOrigin { + kind, + x_nm: x_nm.ok_or_else(|| KiCadError::Config { + reason: "set-board-origin requires `--x-nm `".to_string(), + })?, + y_nm: y_nm.ok_or_else(|| KiCadError::Config { + reason: "set-board-origin requires `--y-nm `".to_string(), + })?, + } + } + "inject-drc-error" => { + let mut severity = DrcSeverity::Error; + let mut message = None; + let mut x_nm = None; + let mut y_nm = None; + let mut item_ids = Vec::new(); + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--severity" => { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for inject-drc-error --severity".to_string(), + })?; + severity = parse_drc_severity(value) + .map_err(|err| KiCadError::Config { reason: err })?; + i += 2; + } + "--message" => { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for inject-drc-error --message".to_string(), + })?; + message = Some(value.clone()); + i += 2; + } + "--x-nm" => { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for inject-drc-error --x-nm".to_string(), + })?; + x_nm = Some(value.parse::().map_err(|err| KiCadError::Config { + reason: format!("invalid inject-drc-error --x-nm `{value}`: {err}"), + })?); + i += 2; + } + "--y-nm" => { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for inject-drc-error --y-nm".to_string(), + })?; + y_nm = Some(value.parse::().map_err(|err| KiCadError::Config { + reason: format!("invalid inject-drc-error --y-nm `{value}`: {err}"), + })?); + i += 2; + } + "--item-id" => { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for inject-drc-error --item-id".to_string(), + })?; + item_ids.push(value.clone()); + i += 2; + } + _ => { + i += 1; + } + } + } + + if (x_nm.is_some() && y_nm.is_none()) || (x_nm.is_none() && y_nm.is_some()) { + return Err(KiCadError::Config { + reason: + "inject-drc-error requires both --x-nm and --y-nm when providing a position" + .to_string(), + }); + } + + Command::InjectDrcError { + severity, + message: message.ok_or_else(|| KiCadError::Config { + reason: "inject-drc-error requires `--message `".to_string(), + })?, + x_nm, + y_nm, + item_ids, + } + } + "refresh-editor" => { + let mut frame = EditorFrameType::PcbEditor; + let mut i = 1; + while i < args.len() { + if args[i] == "--frame" { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for refresh-editor --frame".to_string(), + })?; + frame = EditorFrameType::from_str(value) + .map_err(|err| KiCadError::Config { reason: err })?; + i += 2; + continue; + } + i += 1; + } + Command::RefreshEditor { frame } + } + "begin-commit" => Command::BeginCommit, + "end-commit" => { + let mut id = None; + let mut action = CommitAction::Commit; + let mut message = String::new(); + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--id" => { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for end-commit --id".to_string(), + })?; + id = Some(value.clone()); + i += 2; + } + "--action" => { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for end-commit --action".to_string(), + })?; + action = CommitAction::from_str(value) + .map_err(|err| KiCadError::Config { reason: err })?; + i += 2; + } + "--message" => { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for end-commit --message".to_string(), + })?; + message = value.clone(); + i += 2; + } + _ => { + i += 1; + } + } + } + + Command::EndCommit { + id: id.ok_or_else(|| KiCadError::Config { + reason: "end-commit requires `--id `".to_string(), + })?, + action, + message, + } + } + "save-doc" => Command::SaveDoc, + "save-copy" => { + let mut path = None; + let mut overwrite = false; + let mut include_project = false; + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--path" => { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for save-copy --path".to_string(), + })?; + path = Some(value.clone()); + i += 2; + } + "--overwrite" => { + overwrite = true; + i += 1; + } + "--include-project" => { + include_project = true; + i += 1; + } + _ => i += 1, + } + } + + Command::SaveCopy { + path: path.ok_or_else(|| KiCadError::Config { + reason: "save-copy requires `--path `".to_string(), + })?, + overwrite, + include_project, + } + } + "revert-doc" => Command::RevertDoc, + "run-action" => { + let mut action = None; + let mut i = 1; + while i < args.len() { + if args[i] == "--action" { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for run-action --action".to_string(), + })?; + action = Some(value.clone()); + i += 2; + continue; + } + i += 1; + } + Command::RunAction { + action: action.ok_or_else(|| KiCadError::Config { + reason: "run-action requires `--action `".to_string(), + })?, + } + } + "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, + } + } + "update-items" => { + let mut items = Vec::new(); + let mut i = 1; + while i < args.len() { + if args[i] == "--item" { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for update-items --item".to_string(), + })?; + let (type_url, hex) = + value.split_once('=').ok_or_else(|| KiCadError::Config { + reason: "update-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; + continue; + } + i += 1; + } + + if items.is_empty() { + return Err(KiCadError::Config { + reason: "update-items requires one or more `--item =` values" + .to_string(), + }); + } + + Command::UpdateItems { items } + } + "delete-items" => { + let item_ids = parse_item_ids(&args[1..], "delete-items")?; + Command::DeleteItems { item_ids } + } + "parse-create-items" => { + let mut contents = None; + let mut i = 1; + while i < args.len() { + if args[i] == "--contents" { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for parse-create-items --contents".to_string(), + })?; + contents = Some(value.clone()); + i += 2; + continue; + } + i += 1; + } + + Command::ParseCreateItemsFromString { + contents: contents.ok_or_else(|| KiCadError::Config { + reason: "parse-create-items requires `--contents `".to_string(), + })?, + } + } + "add-to-selection" => { + let item_ids = parse_item_ids(&args[1..], "add-to-selection")?; + Command::AddToSelection { item_ids } + } + "remove-from-selection" => { + let item_ids = parse_item_ids(&args[1..], "remove-from-selection")?; + Command::RemoveFromSelection { item_ids } + } + "clear-selection" => Command::ClearSelection, "selection-summary" => Command::SelectionSummary, "selection-details" => Command::SelectionDetails, "selection-raw" => Command::SelectionRaw, @@ -1017,8 +1850,123 @@ fn parse_args() -> Result<(CliConfig, Command), KiCadError> { "board-as-string" => Command::BoardAsString, "selection-as-string" => Command::SelectionAsString, "stackup" => Command::Stackup, + "update-stackup" => Command::UpdateStackup, "graphics-defaults" => Command::GraphicsDefaults, "appearance" => Command::Appearance, + "set-appearance" => { + let mut inactive_layer_display = None; + let mut net_color_display = None; + let mut board_flip = None; + let mut ratsnest_display = None; + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--inactive-layer-display" => { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for set-appearance --inactive-layer-display" + .to_string(), + })?; + inactive_layer_display = Some( + parse_inactive_layer_display_mode(value) + .map_err(|err| KiCadError::Config { reason: err })?, + ); + i += 2; + } + "--net-color-display" => { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for set-appearance --net-color-display" + .to_string(), + })?; + net_color_display = Some( + parse_net_color_display_mode(value) + .map_err(|err| KiCadError::Config { reason: err })?, + ); + i += 2; + } + "--board-flip" => { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for set-appearance --board-flip".to_string(), + })?; + board_flip = Some( + parse_board_flip_mode(value) + .map_err(|err| KiCadError::Config { reason: err })?, + ); + i += 2; + } + "--ratsnest-display" => { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for set-appearance --ratsnest-display" + .to_string(), + })?; + ratsnest_display = Some( + parse_ratsnest_display_mode(value) + .map_err(|err| KiCadError::Config { reason: err })?, + ); + i += 2; + } + _ => { + i += 1; + } + } + } + + Command::SetAppearance { + inactive_layer_display: inactive_layer_display.ok_or_else(|| KiCadError::Config { + reason: "set-appearance requires `--inactive-layer-display `".to_string(), + })?, + net_color_display: net_color_display.ok_or_else(|| KiCadError::Config { + reason: "set-appearance requires `--net-color-display `" + .to_string(), + })?, + board_flip: board_flip.ok_or_else(|| KiCadError::Config { + reason: "set-appearance requires `--board-flip `" + .to_string(), + })?, + ratsnest_display: ratsnest_display.ok_or_else(|| KiCadError::Config { + reason: + "set-appearance requires `--ratsnest-display `" + .to_string(), + })?, + } + } + "refill-zones" => { + let mut zone_ids = Vec::new(); + let mut i = 1; + while i < args.len() { + if args[i] == "--zone-id" { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for refill-zones --zone-id".to_string(), + })?; + zone_ids.push(value.clone()); + i += 2; + continue; + } + i += 1; + } + Command::RefillZones { zone_ids } + } + "interactive-move" => { + let mut item_ids = Vec::new(); + let mut i = 1; + while i < args.len() { + if args[i] == "--id" { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for interactive-move --id".to_string(), + })?; + item_ids.push(value.clone()); + i += 2; + continue; + } + i += 1; + } + if item_ids.is_empty() { + return Err(KiCadError::Config { + reason: "interactive-move requires one or more `--id ` arguments" + .to_string(), + }); + } + Command::InteractiveMoveItems { item_ids } + } "netclass" => Command::NetClass, "proto-coverage-board-read" => Command::ProtoCoverageBoardRead, "board-read-report" => { @@ -1065,17 +2013,179 @@ fn parse_args() -> Result<(CliConfig, Command), KiCadError> { Ok((config, command)) } +fn parse_inactive_layer_display_mode(value: &str) -> Result { + match value { + "normal" => Ok(InactiveLayerDisplayMode::Normal), + "dimmed" => Ok(InactiveLayerDisplayMode::Dimmed), + "hidden" => Ok(InactiveLayerDisplayMode::Hidden), + _ => Err(format!( + "unknown inactive layer display `{value}`; expected normal, dimmed, or hidden" + )), + } +} + +fn parse_net_color_display_mode(value: &str) -> Result { + match value { + "all" => Ok(NetColorDisplayMode::All), + "ratsnest" => Ok(NetColorDisplayMode::Ratsnest), + "off" => Ok(NetColorDisplayMode::Off), + _ => Err(format!( + "unknown net color display `{value}`; expected all, ratsnest, or off" + )), + } +} + +fn parse_board_flip_mode(value: &str) -> Result { + match value { + "normal" => Ok(BoardFlipMode::Normal), + "flipped-x" => Ok(BoardFlipMode::FlippedX), + _ => Err(format!( + "unknown board flip mode `{value}`; expected normal or flipped-x" + )), + } +} + +fn parse_ratsnest_display_mode(value: &str) -> Result { + match value { + "all-layers" => Ok(RatsnestDisplayMode::AllLayers), + "visible-layers" => Ok(RatsnestDisplayMode::VisibleLayers), + _ => Err(format!( + "unknown ratsnest display `{value}`; expected all-layers or visible-layers" + )), + } +} + +fn parse_drc_severity(value: &str) -> Result { + match value { + "warning" => Ok(DrcSeverity::Warning), + "error" => Ok(DrcSeverity::Error), + "exclusion" => Ok(DrcSeverity::Exclusion), + "ignore" => Ok(DrcSeverity::Ignore), + "info" => Ok(DrcSeverity::Info), + "action" => Ok(DrcSeverity::Action), + "debug" => Ok(DrcSeverity::Debug), + "undefined" => Ok(DrcSeverity::Undefined), + _ => Err(format!( + "unknown drc severity `{value}`; expected warning, error, exclusion, ignore, info, action, debug, or undefined" + )), + } +} + fn default_config() -> CliConfig { CliConfig { socket: None, token: None, + client_name: None, timeout_ms: 15_000, } } 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 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 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 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" + r#"kicad-ipc-cli + +USAGE: + cargo run --bin kicad-ipc-cli -- [--socket URI] [--token TOKEN] [--client-name NAME] [--timeout-ms N] [command options] + +COMMANDS: + ping Check IPC connectivity + version Fetch KiCad version + kicad-binary-path [--binary-name ] + Resolve absolute path for a KiCad binary (default: kicad-cli) + plugin-settings-path [--identifier ] + Resolve writeable plugin settings directory (default: kicad-ipc-rust) + open-docs [--type ] List open docs (default type: pcb) + project-path Get current project path from open PCB docs + board-open Exit non-zero if no PCB doc is open + net-classes List project netclass definitions + set-net-classes [--merge-mode ] + Write current netclass set back with selected merge mode + text-variables List text variables for current board document + set-text-variables [--merge-mode ] [--var ...] + Set text variables for current board document + expand-text-variables Expand variables in provided text values + Options: --text (repeatable) + text-extents Measure text bounding box + Options: --text + text-as-shapes Convert text to rendered shapes + Options: --text (repeatable) + nets List board nets (requires one open PCB) + netlist-pads Emit pad-level netlist data (with footprint context) + items-by-id --id ... Show parsed details for specific item IDs + item-bbox --id ... Show bounding boxes for item IDs + hit-test --id --x-nm --y-nm [--tolerance-nm ] + Hit-test one item at a point + types-pcb List PCB KiCad object type IDs from proto enum + items-raw --type-id ... Dump raw Any payloads for requested item type IDs + items-raw-all-pcb [--debug] Dump all PCB item payloads across all PCB object types + pad-shape-polygon --pad-id ... --layer-id [--debug] + Dump pad polygons on a target layer + padstack-presence --item-id ... --layer-id ... [--debug] + Check padstack shape presence matrix across layers + title-block Show title block fields + board-as-string Dump board as KiCad s-expression text + selection-as-string Dump current selection as KiCad s-expression text + stackup Show typed board stackup + update-stackup Round-trip current stackup through UpdateBoardStackup + graphics-defaults Show typed graphics defaults + appearance Show typed editor appearance settings + set-appearance --inactive-layer-display + --net-color-display + --board-flip + --ratsnest-display + Set editor appearance settings + inject-drc-error --severity --message [--x-nm --y-nm ] [--item-id ...] + Inject a DRC marker (severity: warning|error|exclusion|ignore|info|action|debug|undefined) + refill-zones [--zone-id ...] + Refill all zones or a provided subset + interactive-move --id ... + Start interactive move tool for item IDs + netclass Show typed netclass map for current board nets + proto-coverage-board-read Print board-read command coverage vs proto + board-read-report [--out P] Write markdown board reconstruction report + enabled-layers List enabled board layers + set-enabled-layers --copper-layer-count [--layer-id ...] + Set enabled board layer set + active-layer Show active board layer + set-active-layer --layer-id + Set active board layer + visible-layers Show currently visible board layers + set-visible-layers --layer-id ... + Set visible board layers + board-origin [--type ] Show board origin (`grid` default, or `drill`) + set-board-origin --type --x-nm --y-nm + Set board origin (`grid` or `drill`) + refresh-editor [--frame ] Refresh a specific editor frame (default: pcb) + begin-commit Start staged commit and print commit ID + end-commit --id [--action ] [--message ] + End staged commit with commit/drop action + save-doc Save current board document + save-copy --path [--overwrite] [--include-project] + Save current board document to a new location + revert-doc Revert current board document from disk + run-action --action Run a raw KiCad tool action + create-items --item = ... [--container-id ] + Create raw Any payload items in current board document + update-items --item = ... + Update raw Any payload items in current board document + delete-items --id ... + Delete item IDs from current board document + parse-create-items --contents + Parse s-expression and create resulting items + add-to-selection --id ... + Add items to current selection + remove-from-selection --id ... + Remove items from current selection + clear-selection Clear current item selection + selection-summary Show current selection item type counts + selection-details Show parsed details for selected items + selection-raw Show raw Any payload bytes for selected items + smoke ping + version + board-open summary + help Show help + +TYPES: + schematic | symbol | pcb | footprint | drawing-sheet | project +"# ); } @@ -1624,3 +2734,544 @@ 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}; + use kicad_ipc::{ + BoardFlipMode, BoardOriginKind, CommitAction, DrcSeverity, InactiveLayerDisplayMode, + NetColorDisplayMode, RatsnestDisplayMode, + }; + + #[test] + fn parse_args_accepts_client_name_for_commit_flow() { + let (config, command) = parse_args_from(vec![ + "--client-name".to_string(), + "write-test".to_string(), + "begin-commit".to_string(), + ]) + .expect("client-name + begin-commit should parse"); + + assert_eq!(config.client_name.as_deref(), Some("write-test")); + assert!(matches!(command, Command::BeginCommit)); + } + + #[test] + fn parse_args_parses_end_commit_flags() { + let (_, command) = parse_args_from(vec![ + "end-commit".to_string(), + "--id".to_string(), + "commit-1".to_string(), + "--action".to_string(), + "drop".to_string(), + "--message".to_string(), + "cleanup".to_string(), + ]) + .expect("end-commit args should parse"); + + match command { + Command::EndCommit { + id, + action, + message, + } => { + assert_eq!(id, "commit-1"); + assert_eq!(action, CommitAction::Drop); + assert_eq!(message, "cleanup"); + } + other => panic!("unexpected command variant: {other:?}"), + } + } + + #[test] + fn parse_args_parses_refresh_editor_frame() { + let (_, command) = parse_args_from(vec![ + "refresh-editor".to_string(), + "--frame".to_string(), + "schematic".to_string(), + ]) + .expect("refresh-editor args should parse"); + + match command { + Command::RefreshEditor { frame } => { + assert_eq!(frame.to_string(), "schematic"); + } + other => panic!("unexpected command variant: {other:?}"), + } + } + + #[test] + fn parse_args_parses_clear_selection() { + let (_, command) = parse_args_from(vec!["clear-selection".to_string()]) + .expect("clear-selection should parse"); + assert!(matches!(command, Command::ClearSelection)); + } + + #[test] + fn parse_args_parses_add_to_selection() { + let (_, command) = parse_args_from(vec![ + "add-to-selection".to_string(), + "--id".to_string(), + "zone-1".to_string(), + "--id".to_string(), + "zone-2".to_string(), + ]) + .expect("add-to-selection args should parse"); + + match command { + Command::AddToSelection { item_ids } => { + assert_eq!(item_ids, vec!["zone-1".to_string(), "zone-2".to_string()]); + } + other => panic!("unexpected command variant: {other:?}"), + } + } + + #[test] + fn parse_args_parses_remove_from_selection() { + let (_, command) = parse_args_from(vec![ + "remove-from-selection".to_string(), + "--id".to_string(), + "zone-1".to_string(), + "--id".to_string(), + "zone-2".to_string(), + ]) + .expect("remove-from-selection args should parse"); + + match command { + Command::RemoveFromSelection { item_ids } => { + assert_eq!(item_ids, vec!["zone-1".to_string(), "zone-2".to_string()]); + } + other => panic!("unexpected command variant: {other:?}"), + } + } + + #[test] + fn parse_args_parses_set_active_layer() { + let (_, command) = parse_args_from(vec![ + "set-active-layer".to_string(), + "--layer-id".to_string(), + "31".to_string(), + ]) + .expect("set-active-layer args should parse"); + + match command { + Command::SetActiveLayer { layer_id } => assert_eq!(layer_id, 31), + other => panic!("unexpected command variant: {other:?}"), + } + } + + #[test] + fn parse_args_parses_kicad_binary_path() { + let (_, command) = parse_args_from(vec![ + "kicad-binary-path".to_string(), + "--binary-name".to_string(), + "kicad-cli".to_string(), + ]) + .expect("kicad-binary-path args should parse"); + + match command { + Command::KiCadBinaryPath { binary_name } => assert_eq!(binary_name, "kicad-cli"), + other => panic!("unexpected command variant: {other:?}"), + } + } + + #[test] + fn parse_args_parses_plugin_settings_path() { + let (_, command) = parse_args_from(vec![ + "plugin-settings-path".to_string(), + "--identifier".to_string(), + "com.example.test".to_string(), + ]) + .expect("plugin-settings-path args should parse"); + + match command { + Command::PluginSettingsPath { identifier } => { + assert_eq!(identifier, "com.example.test") + } + other => panic!("unexpected command variant: {other:?}"), + } + } + + #[test] + fn parse_args_parses_set_net_classes() { + let (_, command) = parse_args_from(vec![ + "set-net-classes".to_string(), + "--merge-mode".to_string(), + "replace".to_string(), + ]) + .expect("set-net-classes args should parse"); + + match command { + Command::SetNetClasses { merge_mode } => { + assert_eq!(merge_mode, kicad_ipc::MapMergeMode::Replace) + } + other => panic!("unexpected command variant: {other:?}"), + } + } + + #[test] + fn parse_args_parses_set_text_variables() { + let (_, command) = parse_args_from(vec![ + "set-text-variables".to_string(), + "--merge-mode".to_string(), + "replace".to_string(), + "--var".to_string(), + "REV=A".to_string(), + ]) + .expect("set-text-variables args should parse"); + + match command { + Command::SetTextVariables { + merge_mode, + variables, + } => { + assert_eq!(merge_mode, kicad_ipc::MapMergeMode::Replace); + assert_eq!(variables.get("REV").map(|value| value.as_str()), Some("A")); + } + other => panic!("unexpected command variant: {other:?}"), + } + } + + #[test] + fn parse_args_parses_save_doc() { + let (_, command) = + parse_args_from(vec!["save-doc".to_string()]).expect("save-doc should parse"); + assert!(matches!(command, Command::SaveDoc)); + } + + #[test] + fn parse_args_parses_save_copy() { + let (_, command) = parse_args_from(vec![ + "save-copy".to_string(), + "--path".to_string(), + "/tmp/example.kicad_pcb".to_string(), + "--overwrite".to_string(), + "--include-project".to_string(), + ]) + .expect("save-copy args should parse"); + + match command { + Command::SaveCopy { + path, + overwrite, + include_project, + } => { + assert_eq!(path, "/tmp/example.kicad_pcb"); + assert!(overwrite); + assert!(include_project); + } + other => panic!("unexpected command variant: {other:?}"), + } + } + + #[test] + fn parse_args_parses_revert_doc() { + let (_, command) = + parse_args_from(vec!["revert-doc".to_string()]).expect("revert-doc should parse"); + assert!(matches!(command, Command::RevertDoc)); + } + + #[test] + fn parse_args_parses_run_action() { + let (_, command) = parse_args_from(vec![ + "run-action".to_string(), + "--action".to_string(), + "pcbnew.InteractiveSelection.ClearSelection".to_string(), + ]) + .expect("run-action args should parse"); + + match command { + Command::RunAction { action } => { + assert_eq!(action, "pcbnew.InteractiveSelection.ClearSelection") + } + other => panic!("unexpected command variant: {other:?}"), + } + } + + #[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_update_items() { + let (_, command) = parse_args_from(vec![ + "update-items".to_string(), + "--item".to_string(), + "type.googleapis.com/kiapi.board.types.Text=0a00".to_string(), + ]) + .expect("update-items args should parse"); + + match command { + Command::UpdateItems { items } => { + 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]); + } + other => panic!("unexpected command variant: {other:?}"), + } + } + + #[test] + fn parse_args_parses_delete_items() { + let (_, command) = parse_args_from(vec![ + "delete-items".to_string(), + "--id".to_string(), + "item-1".to_string(), + "--id".to_string(), + "item-2".to_string(), + ]) + .expect("delete-items args should parse"); + + match command { + Command::DeleteItems { item_ids } => { + assert_eq!(item_ids, vec!["item-1".to_string(), "item-2".to_string()]); + } + other => panic!("unexpected command variant: {other:?}"), + } + } + + #[test] + fn parse_args_parses_parse_create_items() { + let (_, command) = parse_args_from(vec![ + "parse-create-items".to_string(), + "--contents".to_string(), + "(kicad_pcb (version 20240108))".to_string(), + ]) + .expect("parse-create-items args should parse"); + + match command { + Command::ParseCreateItemsFromString { contents } => { + assert_eq!(contents, "(kicad_pcb (version 20240108))"); + } + other => panic!("unexpected command variant: {other:?}"), + } + } + + #[test] + fn parse_args_parses_set_enabled_layers() { + let (_, command) = parse_args_from(vec![ + "set-enabled-layers".to_string(), + "--copper-layer-count".to_string(), + "2".to_string(), + "--layer-id".to_string(), + "47".to_string(), + "--layer-id".to_string(), + "52".to_string(), + ]) + .expect("set-enabled-layers args should parse"); + + match command { + Command::SetEnabledLayers { + copper_layer_count, + layer_ids, + } => { + assert_eq!(copper_layer_count, 2); + assert_eq!(layer_ids, vec![47, 52]); + } + other => panic!("unexpected command variant: {other:?}"), + } + } + + #[test] + fn parse_args_parses_set_visible_layers() { + let (_, command) = parse_args_from(vec![ + "set-visible-layers".to_string(), + "--layer-id".to_string(), + "3".to_string(), + "--layer-id".to_string(), + "47".to_string(), + ]) + .expect("set-visible-layers args should parse"); + + match command { + Command::SetVisibleLayers { layer_ids } => assert_eq!(layer_ids, vec![3, 47]), + other => panic!("unexpected command variant: {other:?}"), + } + } + + #[test] + fn parse_args_parses_set_board_origin() { + let (_, command) = parse_args_from(vec![ + "set-board-origin".to_string(), + "--type".to_string(), + "drill".to_string(), + "--x-nm".to_string(), + "123".to_string(), + "--y-nm".to_string(), + "456".to_string(), + ]) + .expect("set-board-origin args should parse"); + + match command { + Command::SetBoardOrigin { kind, x_nm, y_nm } => { + assert_eq!(kind, BoardOriginKind::Drill); + assert_eq!(x_nm, 123); + assert_eq!(y_nm, 456); + } + other => panic!("unexpected command variant: {other:?}"), + } + } + + #[test] + fn parse_args_parses_set_appearance() { + let (_, command) = parse_args_from(vec![ + "set-appearance".to_string(), + "--inactive-layer-display".to_string(), + "hidden".to_string(), + "--net-color-display".to_string(), + "off".to_string(), + "--board-flip".to_string(), + "flipped-x".to_string(), + "--ratsnest-display".to_string(), + "visible-layers".to_string(), + ]) + .expect("set-appearance args should parse"); + + match command { + Command::SetAppearance { + inactive_layer_display, + net_color_display, + board_flip, + ratsnest_display, + } => { + assert_eq!(inactive_layer_display, InactiveLayerDisplayMode::Hidden); + assert_eq!(net_color_display, NetColorDisplayMode::Off); + assert_eq!(board_flip, BoardFlipMode::FlippedX); + assert_eq!(ratsnest_display, RatsnestDisplayMode::VisibleLayers); + } + other => panic!("unexpected command variant: {other:?}"), + } + } + + #[test] + fn parse_args_parses_inject_drc_error() { + let (_, command) = parse_args_from(vec![ + "inject-drc-error".to_string(), + "--severity".to_string(), + "warning".to_string(), + "--message".to_string(), + "marker".to_string(), + "--x-nm".to_string(), + "100".to_string(), + "--y-nm".to_string(), + "200".to_string(), + ]) + .expect("inject-drc-error args should parse"); + + match command { + Command::InjectDrcError { + severity, + message, + x_nm, + y_nm, + item_ids, + } => { + assert_eq!(severity, DrcSeverity::Warning); + assert_eq!(message, "marker"); + assert_eq!(x_nm, Some(100)); + assert_eq!(y_nm, Some(200)); + assert!(item_ids.is_empty()); + } + other => panic!("unexpected command variant: {other:?}"), + } + } + + #[test] + fn parse_args_parses_refill_zones() { + let (_, command) = parse_args_from(vec![ + "refill-zones".to_string(), + "--zone-id".to_string(), + "zone-1".to_string(), + "--zone-id".to_string(), + "zone-2".to_string(), + ]) + .expect("refill-zones args should parse"); + + match command { + Command::RefillZones { zone_ids } => { + assert_eq!(zone_ids, vec!["zone-1".to_string(), "zone-2".to_string()]); + } + other => panic!("unexpected command variant: {other:?}"), + } + } + + #[test] + fn parse_args_parses_update_stackup() { + let (_, command) = parse_args_from(vec!["update-stackup".to_string()]) + .expect("update-stackup should parse"); + assert!(matches!(command, Command::UpdateStackup)); + } + + #[test] + fn parse_args_parses_interactive_move_items() { + let (_, command) = parse_args_from(vec![ + "interactive-move".to_string(), + "--id".to_string(), + "item-1".to_string(), + "--id".to_string(), + "item-2".to_string(), + ]) + .expect("interactive-move args should parse"); + + match command { + Command::InteractiveMoveItems { item_ids } => { + assert_eq!(item_ids, vec!["item-1".to_string(), "item-2".to_string()]); + } + other => panic!("unexpected command variant: {other:?}"), + } + } +}