app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
master
Colby Knox
Merge PR #361: kicad-lane: 1.0.92, sch_batch and log_tail in the standard's shapes
daa023d
11d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
//! KiCad's own diagnostics, ported from handlers/kicad_log.py.
//!
//! KiCad writes NO log file. Its diagnostics go through wxLog to stderr, and a GUI
//! process on Windows has no console, so stderr goes to the void unless whoever
//! launched it supplies a handle. The Python bridge's launch verb captures that stream
//! into one file per launch under `<LOCALAPPDATA or ~/.local/share>/Adom Bridge/kicad-logs/`
//! with `KICAD_ENABLE_WXTRACE=1` set, and `kicad_log_tail` returns only what is NEW
//! since a byte offset the caller carries.
//!
//! The native bridge has no launch verb yet, so this module reads the same directory
//! the Python writes (a Python-launched KiCad on the same box still lands here) and
//! says so honestly when nothing is there. `new_log_path` and `trace_env` are ready
//! for the phase 3 launch verb.
//!
//! The dialog channel (`kicad_errors`): measured on KiCad 10, a board that fails to
//! parse produces a "KiCad PCB Editor Error" window with the exact reason while the
//! captured stderr for that launch is ZERO BYTES, because wxLogGui turns wxLogError
//! into a message box. So the dialogs ARE the log. The window walk is OS work (phase
//! 3); the classification of what it finds is pure and lives here as `dialog_entries`.
use std::path::{Path, PathBuf};
use serde_json::{json, Value};
/// One directory, one file per launch, newest wins.
pub const LOG_DIR_NAME: &str = "kicad-logs";
/// How many launch logs to keep (Python `_KEEP_LOGS`).
pub const KEEP_LOGS: usize = 10;
fn home() -> Option<PathBuf> {
std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from)
}
/// `<LOCALAPPDATA>/Adom Bridge/kicad-logs`, or `~/.local/share/Adom Bridge/kicad-logs`
/// where LOCALAPPDATA is unset (Python `log_dir`, without the mkdir: reading is read-only).
pub fn log_dir() -> Option<PathBuf> {
let base = std::env::var_os("LOCALAPPDATA").map(PathBuf::from).or_else(|| home().map(|h| h.join(".local/share")))?;
Some(base.join("Adom Bridge").join(LOG_DIR_NAME))
}
/// `~/.adom/bridge-logs/kicad.log`: the bridge's OWN stdout/stderr as ab captures it
/// (ab 2.0.44+). Not KiCad's output; reported so a caller knows where the second file is.
pub fn bridge_log_path() -> Option<PathBuf> {
home().map(|h| h.join(".adom").join("bridge-logs").join("kicad.log"))
}
/// `*.log` files in a directory, newest mtime first.
fn logs_by_mtime(dir: &Path) -> Vec<PathBuf> {
let Ok(rd) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut logs: Vec<(std::time::SystemTime, PathBuf)> = rd
.flatten()
.map(|e| e.path())
.filter(|p| p.is_file() && p.extension().map(|x| x == "log").unwrap_or(false))
.filter_map(|p| std::fs::metadata(&p).and_then(|m| m.modified()).ok().map(|t| (t, p)))
.collect();
logs.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| b.1.cmp(&a.1)));
logs.into_iter().map(|(_, p)| p).collect()
}
/// The newest launch log in a directory (Python `latest_log`).
pub fn latest_log(dir: &Path) -> Option<PathBuf> {
logs_by_mtime(dir).into_iter().next()
}
/// A fresh per-launch log path `<dir>/<YYYYmmdd-HHMMSS>-<tag>.log`, pruning old logs so
/// the directory never holds more than KEEP_LOGS (Python `new_log_path`). Creates the
/// directory. `stamp` is the local-time stamp the caller formats (kept out of here so
/// tests are deterministic and no time-zone crate is needed).
pub fn new_log_path(dir: &Path, stamp: &str, tag: &str) -> std::io::Result<PathBuf> {
std::fs::create_dir_all(dir)?;
for old in logs_by_mtime(dir).into_iter().skip(KEEP_LOGS - 1) {
let _ = std::fs::remove_file(old);
}
let mut safe = String::new();
let mut last_dash = false;
for ch in tag.chars() {
if ch.is_ascii_alphanumeric() || ch == '_' || ch == '.' || ch == '-' {
safe.push(ch);
last_dash = false;
} else if !last_dash {
safe.push('-');
last_dash = true;
}
}
let safe: String = safe.chars().take(40).collect();
let safe = if safe.is_empty() { "kicad".to_string() } else { safe };
Ok(dir.join(format!("{stamp}-{safe}.log")))
}
/// Environment additions that make KiCad actually say something (Python `trace_env`):
/// `KICAD_ENABLE_WXTRACE=1` always; `WXTRACE=<masks>` only when the caller wants the
/// firehose. Errors and warnings arrive without any mask once logging is enabled.
pub fn trace_env(masks: Option<&str>) -> Vec<(String, String)> {
let mut v = vec![("KICAD_ENABLE_WXTRACE".to_string(), "1".to_string())];
if let Some(m) = masks.filter(|m| !m.is_empty()) {
v.push(("WXTRACE".to_string(), m.to_string()));
}
v
}
const LEVEL_WORDS: [&str; 6] = ["error", "warning", "trace", "info", "debug", "fatal"];
/// Python `_classify`: an explicit `Error:`/`Warning ` prefix wins; otherwise keyword
/// sniffing; otherwise "info".
pub fn classify(line: &str) -> &'static str {
let t = line.trim_start();
let low = t.to_ascii_lowercase();
for w in LEVEL_WORDS {
if let Some(rest) = low.strip_prefix(w) {
if rest.starts_with(':') || rest.starts_with(' ') {
return w;
}
}
}
let low = line.to_lowercase();
if low.contains("error") || low.contains("failed") || low.contains("cannot") || low.contains("unable to") {
return "error";
}
if low.contains("warning") || low.contains("deprecat") {
return "warning";
}
"info"
}
const NO_LOG_HINT: &str = "No KiCad log yet. Logs exist only for KiCad instances a bridge launched with stderr captured (Python bridge 0.9.266+; the native bridge has no launch verb yet, so it reads what the Python bridge wrote). A KiCad the user started themselves writes nothing, because its stderr belongs to whoever launched it.";
/// kicad_log_tail (Python `handle_log_tail`): new output since a byte offset, read-only.
/// Args: sinceOffset (default 0), maxBytes (default 200000, newest kept), errorsOnly,
/// path (explicit file; default the newest launch log in `dir`).
pub fn log_tail(dir: Option<&Path>, args: &Value) -> Value {
let explicit = args.get("path").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()).map(PathBuf::from);
let p = explicit.or_else(|| dir.and_then(latest_log));
let bridge_log = bridge_log_path().filter(|p| p.is_file()).map(|p| p.to_string_lossy().replace('\\', "/"));
let Some(p) = p.filter(|p| p.is_file()) else {
return json!({
"success": true, "available": false, "lines": [], "nextOffset": 0, "entries": [], "next": 0,
"logDir": dir.map(|d| d.to_string_lossy().replace('\\', "/")),
"bridgeLogPath": bridge_log,
"_hint": NO_LOG_HINT,
});
};
let since_raw = int_arg(args, "sinceOffset").unwrap_or(0);
let max_bytes = int_arg(args, "maxBytes").filter(|v| *v > 0).unwrap_or(200_000) as usize;
let size = std::fs::metadata(&p).map(|m| m.len()).unwrap_or(0);
let mut since = since_raw.max(0) as u64;
if since > size {
since = 0; // file rotated or truncated under us
}
let blob = match std::fs::read(&p) {
Ok(b) => b,
Err(e) => {
return json!({"success": false, "errorCode": "log_unreadable", "error": format!("{}: {e}", p.display()),
"_hint": "The log exists but cannot be read; check permissions."});
}
};
let mut blob = &blob[(since as usize).min(blob.len())..];
let truncated = blob.len() > max_bytes;
if truncated {
blob = &blob[blob.len() - max_bytes..];
}
let text = String::from_utf8_lossy(blob);
let errors_only = args.get("errorsOnly").and_then(Value::as_bool).unwrap_or(false);
let mut counts: serde_json::Map<String, Value> = serde_json::Map::new();
let mut lines: Vec<Value> = Vec::new();
for ln in text.lines().filter(|l| !l.trim().is_empty()) {
let level = classify(ln);
if errors_only && !matches!(level, "error" | "warning" | "fatal") {
continue;
}
let n = counts.get(level).and_then(Value::as_u64).unwrap_or(0);
counts.insert(level.into(), json!(n + 1));
lines.push(json!({"level": level, "text": ln}));
}
json!({
"success": true, "available": true,
"logPath": p.to_string_lossy().replace('\\', "/"),
"sinceOffset": since, "nextOffset": size, "truncated": truncated,
"lineCount": lines.len(), "levels": counts, "lines": lines,
// the standard's log_tail (#29): entries and next, the same lines and offset
"entries": lines, "next": size,
"bridgeLogPath": bridge_log,
"_hint": "Carry nextOffset into the next call and you get ONLY what happened since: tail, act, tail again, and the second read is your action's consequences. errorsOnly:true filters to error/warning.",
})
}
/// Python `int(args.get(k) or 0)`: numbers, numeric strings; None otherwise.
fn int_arg(args: &Value, key: &str) -> Option<i64> {
match args.get(key)? {
Value::Number(n) => n.as_i64().or_else(|| n.as_f64().map(|f| f as i64)),
Value::String(s) => s.trim().parse::<i64>().ok(),
Value::Bool(b) => Some(*b as i64),
_ => None,
}
}
/// Python `_ERR_TITLE`: `\b(error|warning|problem|failed|cannot)\b`, case-insensitive.
pub fn is_error_title(title: &str) -> bool {
["error", "warning", "problem", "failed", "cannot"].iter().any(|w| contains_word(title, w))
}
fn is_word(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
fn contains_word(hay: &str, word: &str) -> bool {
let low = hay.to_lowercase();
let chars: Vec<char> = low.chars().collect();
let w: Vec<char> = word.chars().collect();
if w.is_empty() || chars.len() < w.len() {
return false;
}
for i in 0..=chars.len() - w.len() {
if chars[i..i + w.len()] == w[..] {
let before_ok = i == 0 || !is_word(chars[i - 1]);
let after_ok = i + w.len() == chars.len() || !is_word(chars[i + w.len()]);
if before_ok && after_ok {
return true;
}
}
}
false
}
/// One KiCad-owned top-level window as the OS layer will report it: handle, caption,
/// every text-bearing child (win32 GetWindowText, buttons excluded), and the UIA text
/// harvested when the children carried nothing (Windows TaskDialogs draw their text in
/// DirectUI with no per-string child controls).
#[derive(Clone, Debug, Default)]
pub struct DialogRow {
pub hwnd: u64,
pub title: String,
pub texts: Vec<String>,
pub uia_text: Option<String>,
}
/// Python `handle_errors` loop: which windows count, and each one's entry. The message
/// is the longest text (buttons are short); a window with no readable text says how to
/// read it. Read-only by construction: nothing is clicked, raised or screenshotted.
pub fn dialog_entries(rows: &[DialogRow], include_all: bool) -> Vec<Value> {
let mut out = Vec::new();
for row in rows {
let title = row.title.trim();
if title.is_empty() {
continue;
}
let errorish = is_error_title(title);
if !include_all && !errorish {
continue;
}
let mut msg = row.texts.iter().max_by_key(|t| t.len()).cloned().unwrap_or_default();
let mut via = "win32";
if msg.is_empty() {
if let Some(u) = row.uia_text.as_deref().filter(|u| !u.is_empty()) {
msg = u.to_string();
via = "uia";
}
}
let mut entry = json!({
"hwnd": row.hwnd,
"title": title,
"message": msg,
"messageVia": if msg.is_empty() { Value::Null } else { json!(via) },
"allText": row.texts.iter().take(12).collect::<Vec<_>>(),
"level": if errorish { "error" } else { "info" },
});
if msg.is_empty() {
entry["messageUnavailable"] = json!("taskdialog_directui_and_uia_unavailable");
entry["readTextWith"] = json!(format!("desktop_ui_tree {{\"hwnd\":{},\"maxDepth\":8}}: read it IMMEDIATELY; these dialogs are transient", row.hwnd));
}
out.push(entry);
}
out
}
pub const ERRORS_HINT: &str = "KiCad has NO log file and in GUI mode its errors go to DIALOGS, not stderr, so this is where a GUI failure reason lives. Read-only: nothing is clicked or raised. THREE channels, use all three: (1) this verb; (2) kicad_log_tail for what bypasses wxLog (crashes, asserts, stdio); (3) kicad_screenshot_all when the first two say nothing and the result is still wrong: the pixels have no preconditions and remain the floor under any automation loop. Dismiss with kicad_dismiss_dialogs (which DOES foreground, so caption first).";
/// kicad_errors response in the Python shape: count, dialogs, windowSource, _hint.
pub fn errors(rows: &[DialogRow], include_all: bool, window_source: &str) -> Value {
let dialogs = dialog_entries(rows, include_all);
json!({"success": true, "count": dialogs.len(), "dialogs": dialogs, "windowSource": window_source, "_hint": ERRORS_HINT})
}
#[cfg(test)]
mod tests {
use super::*;
struct Tmp(PathBuf);
impl Drop for Tmp {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn tmp(name: &str) -> Tmp {
let p = std::env::temp_dir().join(format!("kicad-core-log-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
Tmp(p)
}
#[test]
fn classify_matches_python() {
assert_eq!(classify("Error: cannot open"), "error");
assert_eq!(classify(" warning something"), "warning");
assert_eq!(classify("Trace: KICAD_LIBRARIES foo"), "trace");
assert_eq!(classify("Fatal: boom"), "fatal");
assert_eq!(classify("Errors: 3"), "error"); // prefix miss (no boundary), keyword hit
assert_eq!(classify("Information only"), "info"); // 'Info' + 'r' is no boundary
assert_eq!(classify("Failed to load board"), "error");
assert_eq!(classify("Unable to resolve ${KIPRJMOD}"), "error");
assert_eq!(classify("this API is deprecated"), "warning");
assert_eq!(classify("loaded 12 footprints"), "info");
}
#[test]
fn error_title_word_boundaries() {
assert!(is_error_title("KiCad PCB Editor Error"));
assert!(is_error_title("Warning"));
assert!(is_error_title("Load failed: x"));
assert!(is_error_title("Cannot open file"));
assert!(!is_error_title("Errors and Warnings panel")); // plural: no boundary
assert!(!is_error_title("KiCad 10.0 [demo/demo.kicad_pcb]"));
assert!(!is_error_title("Problematic"));
}
#[test]
fn tail_absent_is_honest() {
let t = tmp("absent");
let r = log_tail(Some(&t.0), &json!({}));
assert_eq!(r["success"], json!(true));
assert_eq!(r["available"], json!(false));
assert_eq!(r["lines"], json!([]));
assert_eq!(r["nextOffset"], json!(0));
assert!(r["_hint"].as_str().unwrap().contains("No KiCad log yet"));
let r = log_tail(None, &json!({}));
assert_eq!(r["available"], json!(false));
let r = log_tail(Some(&t.0), &json!({"path": t.0.join("nope.log").to_string_lossy()}));
assert_eq!(r["available"], json!(false));
}
#[test]
fn tail_offsets_filters_and_truncation() {
let t = tmp("tail");
let p = t.0.join("20260911-120000-launch.log");
std::fs::write(&p, "Info: start\n\nError: cannot load board\nplain line\nWarning: deprecated thing\n").unwrap();
let size = std::fs::metadata(&p).unwrap().len();
let r = log_tail(Some(&t.0), &json!({}));
assert_eq!(r["available"], json!(true));
assert_eq!(r["logPath"], json!(p.to_string_lossy().replace('\\', "/")));
assert_eq!(r["sinceOffset"], json!(0));
assert_eq!(r["nextOffset"], json!(size));
assert_eq!(r["truncated"], json!(false));
assert_eq!(r["lineCount"], json!(4));
assert_eq!(r["levels"], json!({"info": 2, "error": 1, "warning": 1}));
assert_eq!(r["lines"][1], json!({"level": "error", "text": "Error: cannot load board"}));
// Second read from nextOffset returns nothing new.
let r2 = log_tail(Some(&t.0), &json!({"sinceOffset": size}));
assert_eq!(r2["lineCount"], json!(0));
assert_eq!(r2["sinceOffset"], json!(size));
// Append, tail again: only the new line.
let mut f = std::fs::OpenOptions::new().append(true).open(&p).unwrap();
std::io::Write::write_all(&mut f, b"Fatal: crash\n").unwrap();
let r3 = log_tail(Some(&t.0), &json!({"sinceOffset": size.to_string()}));
assert_eq!(r3["lineCount"], json!(1));
assert_eq!(r3["lines"][0]["level"], json!("fatal"));
assert_eq!(r3["nextOffset"], json!(size + 13));
// errorsOnly keeps error/warning/fatal.
let r4 = log_tail(Some(&t.0), &json!({"errorsOnly": true}));
assert_eq!(r4["lineCount"], json!(3));
assert_eq!(r4["levels"], json!({"error": 1, "warning": 1, "fatal": 1}));
// Offset past the end (rotation): whole file again.
let r5 = log_tail(Some(&t.0), &json!({"sinceOffset": 999999}));
assert_eq!(r5["sinceOffset"], json!(0));
assert_eq!(r5["lineCount"], json!(5));
// maxBytes keeps the newest bytes and flags truncation.
let r6 = log_tail(Some(&t.0), &json!({"maxBytes": 13}));
assert_eq!(r6["truncated"], json!(true));
assert_eq!(r6["lineCount"], json!(1));
assert_eq!(r6["lines"][0]["text"], json!("Fatal: crash"));
assert_eq!(r6["nextOffset"], json!(size + 13));
// Explicit path wins over the newest file.
let other = t.0.join("00000000-000000-other.log");
std::fs::write(&other, "Warning: only me\n").unwrap();
let r7 = log_tail(Some(&t.0), &json!({"path": other.to_string_lossy()}));
assert_eq!(r7["lineCount"], json!(1));
assert_eq!(r7["logPath"], json!(other.to_string_lossy().replace('\\', "/")));
}
#[test]
fn latest_log_and_prune() {
let t = tmp("prune");
for i in 0..12 {
let p = t.0.join(format!("20260911-0000{i:02}-launch.log"));
std::fs::write(&p, "x").unwrap();
let when = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000 + i * 60);
let f = std::fs::File::open(&p).unwrap();
f.set_modified(when).unwrap();
}
std::fs::write(t.0.join("notes.txt"), "not a log").unwrap();
assert_eq!(latest_log(&t.0).unwrap().file_name().unwrap().to_string_lossy(), "20260911-000011-launch.log");
let p = new_log_path(&t.0, "20260911-130000", "launch demo/proj.kicad_pcb!!").unwrap();
assert_eq!(p.file_name().unwrap().to_string_lossy(), "20260911-130000-launch-demo-proj.kicad_pcb-.log");
let left = logs_by_mtime(&t.0);
assert_eq!(left.len(), KEEP_LOGS - 1);
assert!(t.0.join("20260911-000011-launch.log").exists());
assert!(!t.0.join("20260911-000000-launch.log").exists());
assert!(t.0.join("notes.txt").exists());
assert_eq!(new_log_path(&t.0, "s", "").unwrap().file_name().unwrap().to_string_lossy(), "s-kicad.log");
assert!(latest_log(&t.0.join("missing")).is_none());
}
#[test]
fn trace_env_shape() {
assert_eq!(trace_env(None), vec![("KICAD_ENABLE_WXTRACE".to_string(), "1".to_string())]);
assert_eq!(trace_env(Some("KICAD_LIBRARIES,KICAD_ENV_VARS")).len(), 2);
assert_eq!(trace_env(Some("")).len(), 1);
}
#[test]
fn dialog_entries_match_python() {
let rows = vec![
DialogRow { hwnd: 1, title: "KiCad PCB Editor Error".into(), texts: vec!["OK".into(), "Error loading PCB 'x'.\nExpecting thru_hole".into()], uia_text: None },
DialogRow { hwnd: 2, title: "KiCad 10.0 [demo]".into(), texts: vec!["Status".into()], uia_text: None },
DialogRow { hwnd: 3, title: "Warning".into(), texts: vec![], uia_text: Some("Zone fill is out of date".into()) },
DialogRow { hwnd: 4, title: "Problem".into(), texts: vec![], uia_text: None },
DialogRow { hwnd: 5, title: " ".into(), texts: vec!["ignored".into()], uia_text: None },
];
let d = dialog_entries(&rows, false);
assert_eq!(d.len(), 3);
assert_eq!(d[0]["message"], json!("Error loading PCB 'x'.\nExpecting thru_hole"));
assert_eq!(d[0]["messageVia"], json!("win32"));
assert_eq!(d[0]["level"], json!("error"));
assert_eq!(d[0]["allText"].as_array().unwrap().len(), 2);
assert_eq!(d[1]["hwnd"], json!(3));
assert_eq!(d[1]["messageVia"], json!("uia"));
assert_eq!(d[2]["messageVia"], Value::Null);
assert_eq!(d[2]["messageUnavailable"], json!("taskdialog_directui_and_uia_unavailable"));
assert!(d[2]["readTextWith"].as_str().unwrap().starts_with("desktop_ui_tree {\"hwnd\":4,"));
let all = dialog_entries(&rows, true);
assert_eq!(all.len(), 4);
assert_eq!(all[1]["level"], json!("info"));
let r = errors(&rows, false, "test");
assert_eq!(r["count"], json!(3));
assert_eq!(r["windowSource"], json!("test"));
}
}