feat: add initial PCB read APIs and CLI commands

This commit is contained in:
Milind Sharma 2026-02-18 23:31:45 +08:00
parent 46f5d8b731
commit a109ba7463
8 changed files with 283 additions and 31 deletions

View File

@ -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

View File

@ -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<Vec<BoardNet>, 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<BoardEnabledLayers, KiCadError> {
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<BoardLayerInfo, KiCadError> {
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<common_types::DocumentSpecifier, KiCadError> {
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<DocumentSpecifier> {
@ -261,6 +327,58 @@ fn map_document_specifier(source: common_types::DocumentSpecifier) -> Option<Doc
})
}
fn model_document_to_proto(document: &DocumentSpecifier) -> 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(|| "<unknown>".to_string())
})
.collect();
return Err(KiCadError::AmbiguousBoardSelection { boards });
}
Ok(&docs[0])
}
fn select_single_project_path(docs: &[DocumentSpecifier]) -> Result<PathBuf, KiCadError> {
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");
}
}

View File

@ -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: Message + Default>(
});
}
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"));
}

View File

@ -54,4 +54,7 @@ pub enum KiCadError {
#[error("multiple project paths found across open PCB docs: {paths:?}")]
AmbiguousProjectPath { paths: Vec<String> },
#[error("multiple PCB documents are open; unable to choose one board context: {boards:?}")]
AmbiguousBoardSelection { boards: Vec<String> },
}

View File

@ -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};

View File

@ -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<BoardLayerInfo>,
}

View File

@ -23,14 +23,16 @@ struct TransportRequest {
impl Transport {
pub(crate) fn connect(socket_uri: &str, timeout: Duration) -> Result<Self, KiCadError> {
let socket = configured_socket(socket_uri, timeout)?;
let (request_tx, mut request_rx) = mpsc::channel::<TransportRequest>(TRANSPORT_QUEUE_CAPACITY);
let (request_tx, mut request_rx) =
mpsc::channel::<TransportRequest>(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<Socket, KiCa
reason: err.to_string(),
})?;
socket.dial(socket_uri).map_err(|err| KiCadError::Connection {
socket_uri: socket_uri.to_string(),
reason: err.to_string(),
})?;
socket
.dial(socket_uri)
.map_err(|err| KiCadError::Connection {
socket_uri: socket_uri.to_string(),
reason: err.to_string(),
})?;
Ok(socket)
}

View File

@ -18,6 +18,9 @@ enum Command {
OpenDocs { document_type: DocumentType },
ProjectPath,
BoardOpen,
Nets,
EnabledLayers,
ActiveLayer,
Smoke,
Help,
}
@ -28,7 +31,10 @@ async fn main() -> 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> [command options]\n\nCOMMANDS:\n ping Check IPC connectivity\n version Fetch KiCad version\n open-docs [--type <type>] List open docs (default type: pcb)\n project-path Get current project path from open PCB docs\n board-open Exit non-zero if no PCB doc is open\n smoke ping + version + board-open summary\n help Show help\n\nTYPES:\n schematic | symbol | pcb | footprint | drawing-sheet | project\n"
"kicad-ipc-cli\n\nUSAGE:\n cargo run --bin kicad-ipc-cli -- [--socket URI] [--token TOKEN] [--timeout-ms N] <command> [command options]\n\nCOMMANDS:\n ping Check IPC connectivity\n version Fetch KiCad version\n open-docs [--type <type>] List open docs (default type: pcb)\n project-path Get current project path from open PCB docs\n board-open Exit non-zero if no PCB doc is open\n nets List board nets (requires one open PCB)\n 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"
);
}