From a109ba746336bc39bd29d1471b6fc723f75112f8 Mon Sep 17 00:00:00 2001 From: Milind Sharma Date: Wed, 18 Feb 2026 23:31:45 +0800 Subject: [PATCH] feat: add initial PCB read APIs and CLI commands --- docs/TEST_CLI.md | 18 +++ src/client.rs | 205 ++++++++++++++++++++++++++++++++-- src/envelope.rs | 11 +- src/error.rs | 3 + src/lib.rs | 1 + src/model/board.rs | 17 ++- src/transport.rs | 16 ++- test-scripts/kicad-ipc-cli.rs | 43 ++++++- 8 files changed, 283 insertions(+), 31 deletions(-) diff --git a/docs/TEST_CLI.md b/docs/TEST_CLI.md index fccf6f4..a096117 100644 --- a/docs/TEST_CLI.md +++ b/docs/TEST_CLI.md @@ -41,6 +41,24 @@ Check board open: cargo run --bin kicad-ipc-cli -- board-open ``` +List nets: + +```bash +cargo run --bin kicad-ipc-cli -- nets +``` + +List enabled board layers: + +```bash +cargo run --bin kicad-ipc-cli -- enabled-layers +``` + +Show active layer: + +```bash +cargo run --bin kicad-ipc-cli -- active-layer +``` + Get current project path (derived from open PCB docs): ```bash diff --git a/src/client.rs b/src/client.rs index 1898090..1ec63bd 100644 --- a/src/client.rs +++ b/src/client.rs @@ -5,7 +5,10 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use crate::envelope; use crate::error::KiCadError; +use crate::model::board::{BoardEnabledLayers, BoardLayerInfo, BoardNet}; use crate::model::common::{DocumentSpecifier, DocumentType, ProjectInfo, VersionInfo}; +use crate::proto::kiapi::board::commands as board_commands; +use crate::proto::kiapi::board::types as board_types; use crate::proto::kiapi::common::commands as common_commands; use crate::proto::kiapi::common::types as common_types; use crate::transport::Transport; @@ -16,9 +19,15 @@ 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_OPEN_DOCUMENTS: &str = "kiapi.common.commands.GetOpenDocuments"; +const CMD_GET_NETS: &str = "kiapi.board.commands.GetNets"; +const CMD_GET_BOARD_ENABLED_LAYERS: &str = "kiapi.board.commands.GetBoardEnabledLayers"; +const CMD_GET_ACTIVE_LAYER: &str = "kiapi.board.commands.GetActiveLayer"; const RES_GET_VERSION: &str = "kiapi.common.commands.GetVersionResponse"; const RES_GET_OPEN_DOCUMENTS: &str = "kiapi.common.commands.GetOpenDocumentsResponse"; +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"; #[derive(Clone, Debug)] pub struct KiCadClient { @@ -94,10 +103,7 @@ impl ClientBuilder { .or_else(|| std::env::var(KICAD_API_TOKEN_ENV).ok()) .unwrap_or_default(); - let client_name = self - .config - .client_name - .unwrap_or_else(default_client_name); + let client_name = self.config.client_name.unwrap_or_else(default_client_name); Ok(KiCadClient { inner: Arc::new(ClientInner { @@ -147,11 +153,9 @@ impl KiCadClient { let payload: common_commands::GetVersionResponse = envelope::unpack_any(&response, RES_GET_VERSION)?; - let version = payload - .version - .ok_or_else(|| KiCadError::MissingPayload { - expected_type_url: "kiapi.common.types.KiCadVersion".to_string(), - })?; + let version = payload.version.ok_or_else(|| KiCadError::MissingPayload { + expected_type_url: "kiapi.common.types.KiCadVersion".to_string(), + })?; Ok(VersionInfo { major: version.major, @@ -193,6 +197,60 @@ impl KiCadClient { Ok(!docs.is_empty()) } + pub async fn get_nets(&self) -> Result, KiCadError> { + let board = self.current_board_document_proto().await?; + let command = board_commands::GetNets { + board: Some(board), + netclass_filter: Vec::new(), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_GET_NETS)) + .await?; + + let payload: board_commands::NetsResponse = envelope::unpack_any(&response, RES_GET_NETS)?; + + Ok(payload + .nets + .into_iter() + .map(|net| BoardNet { + code: net.code.map_or(0, |code| code.value), + name: net.name, + }) + .collect()) + } + + pub async fn get_board_enabled_layers(&self) -> Result { + let board = self.current_board_document_proto().await?; + let command = board_commands::GetBoardEnabledLayers { board: Some(board) }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_GET_BOARD_ENABLED_LAYERS)) + .await?; + + 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(), + }) + } + + pub async fn get_active_layer(&self) -> Result { + let board = self.current_board_document_proto().await?; + let command = board_commands::GetActiveLayer { board: Some(board) }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_GET_ACTIVE_LAYER)) + .await?; + + let payload: board_commands::BoardLayerResponse = + envelope::unpack_any(&response, RES_BOARD_LAYER_RESPONSE)?; + + Ok(layer_to_model(payload.layer)) + } + async fn send_command( &self, command: prost_types::Any, @@ -228,6 +286,14 @@ impl KiCadClient { Ok(response) } + + async fn current_board_document_proto( + &self, + ) -> Result { + let docs = self.get_open_documents(DocumentType::Pcb).await?; + let selected = select_single_board_document(&docs)?; + Ok(model_document_to_proto(selected)) + } } fn map_document_specifier(source: common_types::DocumentSpecifier) -> Option { @@ -261,6 +327,58 @@ fn map_document_specifier(source: common_types::DocumentSpecifier) -> Option common_types::DocumentSpecifier { + let identifier = document.board_filename.as_ref().map(|filename| { + common_types::document_specifier::Identifier::BoardFilename(filename.clone()) + }); + + let project = common_types::ProjectSpecifier { + name: document.project.name.clone().unwrap_or_default(), + path: document + .project + .path + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_default(), + }; + + common_types::DocumentSpecifier { + r#type: document.document_type.to_proto(), + project: Some(project), + identifier, + } +} + +fn layer_to_model(layer_id: i32) -> BoardLayerInfo { + let name = board_types::BoardLayer::try_from(layer_id) + .map(|layer| layer.as_str_name().to_string()) + .unwrap_or_else(|_| format!("UNKNOWN_LAYER({layer_id})")); + + BoardLayerInfo { id: layer_id, name } +} + +fn select_single_board_document( + docs: &[DocumentSpecifier], +) -> Result<&DocumentSpecifier, KiCadError> { + if docs.is_empty() { + return Err(KiCadError::BoardNotOpen); + } + + if docs.len() > 1 { + let boards = docs + .iter() + .map(|doc| { + doc.board_filename + .clone() + .unwrap_or_else(|| "".to_string()) + }) + .collect(); + return Err(KiCadError::AmbiguousBoardSelection { boards }); + } + + Ok(&docs[0]) +} + fn select_single_project_path(docs: &[DocumentSpecifier]) -> Result { let mut paths = BTreeSet::new(); for doc in docs { @@ -355,7 +473,10 @@ fn default_client_name() -> String { #[cfg(test)] mod tests { - use super::{normalize_socket_uri, select_single_project_path}; + use super::{ + layer_to_model, model_document_to_proto, normalize_socket_uri, + select_single_board_document, select_single_project_path, + }; use crate::error::KiCadError; use crate::model::common::{DocumentSpecifier, DocumentType, ProjectInfo}; use std::path::PathBuf; @@ -422,4 +543,68 @@ mod tests { let result = select_single_project_path(&docs); assert!(matches!(result, Err(KiCadError::BoardNotOpen))); } + + #[test] + fn select_single_board_document_errors_on_multiple_open_boards() { + let docs = vec![ + DocumentSpecifier { + document_type: DocumentType::Pcb, + board_filename: Some("a.kicad_pcb".to_string()), + project: ProjectInfo { + name: Some("a".to_string()), + path: Some(PathBuf::from("/tmp/a")), + }, + }, + DocumentSpecifier { + document_type: DocumentType::Pcb, + board_filename: Some("b.kicad_pcb".to_string()), + project: ProjectInfo { + name: Some("b".to_string()), + path: Some(PathBuf::from("/tmp/b")), + }, + }, + ]; + + let result = select_single_board_document(&docs); + assert!(matches!( + result, + Err(KiCadError::AmbiguousBoardSelection { .. }) + )); + } + + #[test] + fn layer_to_model_formats_unknown_id() { + let layer = layer_to_model(999); + assert_eq!(layer.name, "UNKNOWN_LAYER(999)"); + assert_eq!(layer.id, 999); + } + + #[test] + fn model_document_to_proto_carries_board_filename_and_project() { + let document = DocumentSpecifier { + document_type: DocumentType::Pcb, + board_filename: Some("demo.kicad_pcb".to_string()), + project: ProjectInfo { + name: Some("demo".to_string()), + path: Some(PathBuf::from("/tmp/demo")), + }, + }; + + let proto = model_document_to_proto(&document); + assert_eq!( + proto.r#type, + crate::model::common::DocumentType::Pcb.to_proto() + ); + let identifier = proto.identifier.expect("identifier should be present"); + match identifier { + crate::proto::kiapi::common::types::document_specifier::Identifier::BoardFilename( + filename, + ) => assert_eq!(filename, "demo.kicad_pcb"), + other => panic!("unexpected identifier variant: {other:?}"), + } + + let project = proto.project.expect("project should be present"); + assert_eq!(project.name, "demo"); + assert_eq!(project.path, "/tmp/demo"); + } } diff --git a/src/envelope.rs b/src/envelope.rs index 2eae4d9..78c525b 100644 --- a/src/envelope.rs +++ b/src/envelope.rs @@ -2,9 +2,7 @@ use prost::Message; use prost_types::Any; use crate::error::KiCadError; -use crate::proto::kiapi::common::{ - ApiRequest, ApiRequestHeader, ApiResponse, ApiStatusCode, -}; +use crate::proto::kiapi::common::{ApiRequest, ApiRequestHeader, ApiResponse, ApiStatusCode}; pub(crate) fn type_url(type_name: &str) -> String { format!("type.googleapis.com/{type_name}") @@ -36,8 +34,7 @@ pub(crate) fn unpack_any( }); } - T::decode(payload.value.as_slice()) - .map_err(|err| KiCadError::ProtobufDecode(err.to_string())) + T::decode(payload.value.as_slice()).map_err(|err| KiCadError::ProtobufDecode(err.to_string())) } pub(crate) fn encode_request( @@ -105,8 +102,8 @@ mod tests { message: None, }; - let err = status_error(&response) - .expect("non-ok API status should map to KiCadError::ApiStatus"); + let err = + status_error(&response).expect("non-ok API status should map to KiCadError::ApiStatus"); let message = err.to_string(); assert!(message.contains("AS_TOKEN_MISMATCH")); } diff --git a/src/error.rs b/src/error.rs index 8c5d48f..256d4ea 100644 --- a/src/error.rs +++ b/src/error.rs @@ -54,4 +54,7 @@ pub enum KiCadError { #[error("multiple project paths found across open PCB docs: {paths:?}")] AmbiguousProjectPath { paths: Vec }, + + #[error("multiple PCB documents are open; unable to choose one board context: {boards:?}")] + AmbiguousBoardSelection { boards: Vec }, } diff --git a/src/lib.rs b/src/lib.rs index fb607a5..37df82a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,4 +20,5 @@ pub(crate) mod proto; pub use crate::client::{ClientBuilder, KiCadClient}; pub use crate::error::KiCadError; +pub use crate::model::board::{BoardEnabledLayers, BoardLayerInfo, BoardNet}; pub use crate::model::common::{DocumentSpecifier, DocumentType, VersionInfo}; diff --git a/src/model/board.rs b/src/model/board.rs index c5131c6..1afb58d 100644 --- a/src/model/board.rs +++ b/src/model/board.rs @@ -1,4 +1,17 @@ #[derive(Clone, Debug, Eq, PartialEq)] -pub struct BoardDocument { - pub board_filename: String, +pub struct BoardNet { + pub code: i32, + pub name: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BoardLayerInfo { + pub id: i32, + pub name: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BoardEnabledLayers { + pub copper_layer_count: u32, + pub layers: Vec, } diff --git a/src/transport.rs b/src/transport.rs index ba3828b..8029d45 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -23,14 +23,16 @@ struct TransportRequest { impl Transport { pub(crate) fn connect(socket_uri: &str, timeout: Duration) -> Result { let socket = configured_socket(socket_uri, timeout)?; - let (request_tx, mut request_rx) = mpsc::channel::(TRANSPORT_QUEUE_CAPACITY); + let (request_tx, mut request_rx) = + mpsc::channel::(TRANSPORT_QUEUE_CAPACITY); let worker_name = format!("kicad-ipc-transport-{}", std::process::id()); thread::Builder::new() .name(worker_name) .spawn(move || { while let Some(request) = request_rx.blocking_recv() { - let response = socket_roundtrip(&socket, request.request_bytes.as_slice(), timeout); + let response = + socket_roundtrip(&socket, request.request_bytes.as_slice(), timeout); let _ = request.response_tx.send(response); } }) @@ -77,10 +79,12 @@ fn configured_socket(socket_uri: &str, timeout: Duration) -> Result ExitCode { Ok(()) => ExitCode::SUCCESS, Err(err) => { eprintln!("error: {err}"); - if matches!(err, KiCadError::BoardNotOpen | KiCadError::SocketUnavailable { .. }) { + if matches!( + err, + KiCadError::BoardNotOpen | KiCadError::SocketUnavailable { .. } + ) { eprintln!( "hint: launch KiCad, open a project, and open a PCB editor window before rerunning this command." ); @@ -109,6 +115,30 @@ async fn run() -> Result<(), KiCadError> { return Err(KiCadError::BoardNotOpen); } } + Command::Nets => { + let nets = client.get_nets().await?; + if nets.is_empty() { + println!("no nets returned"); + } else { + for net in nets { + println!("code={} name={}", net.code, net.name); + } + } + } + Command::EnabledLayers => { + let enabled = client.get_board_enabled_layers().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!( + "active_layer_id={} active_layer_name={}", + layer.id, layer.name + ); + } Command::Smoke => { client.ping().await?; let version = client.get_version().await?; @@ -175,6 +205,9 @@ fn parse_args() -> Result<(CliConfig, Command), KiCadError> { "version" => Command::Version, "project-path" => Command::ProjectPath, "board-open" => Command::BoardOpen, + "nets" => Command::Nets, + "enabled-layers" => Command::EnabledLayers, + "active-layer" => Command::ActiveLayer, "smoke" => Command::Smoke, "open-docs" => { let mut document_type = DocumentType::Pcb; @@ -184,10 +217,8 @@ fn parse_args() -> Result<(CliConfig, Command), KiCadError> { let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { reason: "missing value for open-docs --type".to_string(), })?; - document_type = - DocumentType::from_str(value).map_err(|err| KiCadError::Config { - reason: err, - })?; + document_type = DocumentType::from_str(value) + .map_err(|err| KiCadError::Config { reason: err })?; i += 2; continue; } @@ -215,6 +246,6 @@ fn default_config() -> CliConfig { fn print_help() { println!( - "kicad-ipc-cli\n\nUSAGE:\n cargo run --bin kicad-ipc-cli -- [--socket URI] [--token TOKEN] [--timeout-ms N] [command options]\n\nCOMMANDS:\n ping Check IPC connectivity\n version Fetch KiCad version\n open-docs [--type ] List open docs (default type: pcb)\n project-path Get current project path from open PCB docs\n board-open Exit non-zero if no PCB doc is open\n smoke ping + version + board-open summary\n help Show help\n\nTYPES:\n schematic | symbol | pcb | footprint | drawing-sheet | project\n" + "kicad-ipc-cli\n\nUSAGE:\n cargo run --bin kicad-ipc-cli -- [--socket URI] [--token TOKEN] [--timeout-ms N] [command options]\n\nCOMMANDS:\n ping Check IPC connectivity\n version Fetch KiCad version\n open-docs [--type ] List open docs (default type: pcb)\n project-path Get current project path from open PCB docs\n board-open Exit non-zero if no PCB doc is open\n nets List board nets (requires one open PCB)\n enabled-layers List enabled board layers\n active-layer Show active board layer\n smoke ping + version + board-open summary\n help Show help\n\nTYPES:\n schematic | symbol | pcb | footprint | drawing-sheet | project\n" ); }