feat: add initial PCB read APIs and CLI commands
This commit is contained in:
parent
46f5d8b731
commit
a109ba7463
|
|
@ -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
|
||||
|
|
|
|||
205
src/client.rs
205
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<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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> },
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue