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 #433: 1.0.104: close verbs keep another AI thread's document open (closes #139)
bf8eb20
5d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
//! Which AI threads opened or drove each KiCad document (wiki #139).
//!
//! Several AI threads share one desktop KiCad. A thread that closes "everything" must not
//! close a board another thread is working on, so every verb that drives the live editor on
//! a named document leaves a note here: document -> {thread -> when}. `kicad_close_all_documents`
//! and `kicad_close` read it and keep another thread's document open unless the caller forces
//! it with a reason.
//!
//! Documents are keyed on their lower-cased file stem, because that is all KiCad's window
//! titles give (`led-strip-500 — PCB Editor`); a board and its schematic share a stem and so
//! share a key. The table is mirrored to `~/.adom/kicad-bridge-doc-users.json` so a bridge
//! restart does not forget who has what open. A note older than `MAX_AGE_SECS` no longer
//! counts, and a document that is not open any more is dropped whenever a guard looks.
//!
//! The asker of the verb being served lives in a thread-local: each request runs on its own
//! worker thread, while the process-wide `ab` caller can be overwritten by a read verb that
//! another thread sends at the same moment.
use serde_json::Value;
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
/// A note older than this (12 h) no longer protects a document.
pub const MAX_AGE_SECS: u64 = 12 * 3600;
/// The argument keys a verb names its document with.
const PATH_KEYS: &[&str] = &["filePath", "path", "project", "boardPath", "schematicPath", "pcbPath", "file"];
const DOC_EXTS: &[&str] = &["kicad_pcb", "kicad_sch", "kicad_pro"];
/// Who is asking for the verb being served on this worker thread.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Asker {
pub thread: String,
pub reason: String,
}
thread_local! {
static ASKER: RefCell<Option<Asker>> = const { RefCell::new(None) };
}
/// Restores the previous asker when dropped, so a composite verb's inner call does not leak.
pub struct AskerScope(Option<Asker>);
impl Drop for AskerScope {
fn drop(&mut self) {
let prev = self.0.take();
ASKER.with(|a| *a.borrow_mut() = prev);
}
}
/// Make `thread` the asker on this worker thread until the returned scope drops.
pub fn set_asker(thread: &str, reason: &str) -> AskerScope {
let prev = ASKER.with(|a| a.borrow_mut().replace(Asker { thread: thread.to_string(), reason: reason.to_string() }));
AskerScope(prev)
}
/// The asker of the verb being served here (empty when none was set).
pub fn asker() -> Asker {
ASKER.with(|a| a.borrow().clone()).unwrap_or_default()
}
/// A thread name that identifies nobody: no header, the bridge itself, or a placeholder.
pub fn is_anonymous(thread: &str) -> bool {
let t = thread.trim();
t.is_empty() || t == "?" || t.eq_ignore_ascii_case(crate::progress::ANONYMOUS) || t == "kicad-bridge" || t == crate::ab::SELF_THREAD
}
/// The key of a document path (`C:/p/led-strip-500.kicad_pcb`) or of a title's name (`led-strip-500`).
pub fn doc_key(path_or_name: &str) -> Option<String> {
let s = path_or_name.trim().replace('\\', "/");
if s.is_empty() {
return None;
}
let p = Path::new(&s);
let ext = p.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase);
match ext.as_deref() {
Some(e) if DOC_EXTS.contains(&e) => p.file_stem().map(|x| x.to_string_lossy().to_lowercase()),
_ if !s.contains('/') => Some(s.to_lowercase()),
_ => None,
}
}
/// The documents a verb's arguments name (board, schematic or project paths only).
pub fn doc_keys_in(args: &Value) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for k in PATH_KEYS {
if let Some(s) = args.get(*k).and_then(Value::as_str) {
let has_ext = Path::new(&s.replace('\\', "/")).extension().and_then(|e| e.to_str()).is_some_and(|e| DOC_EXTS.contains(&e.to_ascii_lowercase().as_str()));
if let (true, Some(key)) = (has_ext, doc_key(s)) {
if !out.contains(&key) {
out.push(key);
}
}
}
}
out
}
type Table = BTreeMap<String, BTreeMap<String, u64>>;
struct Store {
table: Option<Table>,
path: Option<PathBuf>,
}
static STORE: Mutex<Store> = Mutex::new(Store { table: None, path: None });
fn store() -> std::sync::MutexGuard<'static, Store> {
STORE.lock().unwrap_or_else(|e| e.into_inner())
}
fn default_path() -> PathBuf {
let base = std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME")).map(PathBuf::from).unwrap_or_else(std::env::temp_dir);
base.join(".adom").join("kicad-bridge-doc-users.json")
}
/// Point the table at another file (tests); the next read comes from there.
pub fn set_state_path(path: Option<PathBuf>) {
let mut s = store();
s.path = path;
s.table = None;
}
fn now() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
fn with_table<R>(write: bool, f: impl FnOnce(&mut Table) -> R) -> R {
let mut s = store();
let path = s.path.clone().unwrap_or_else(default_path);
if s.table.is_none() {
let loaded: Table = std::fs::read_to_string(&path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
s.table = Some(loaded);
}
let table = s.table.as_mut().expect("loaded above");
let r = f(table);
if write {
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
if let Ok(text) = serde_json::to_string(table) {
let _ = std::fs::write(&path, text);
}
}
r
}
/// Record that `thread` drove the documents `args` names. Anonymous callers leave no note.
pub fn note(thread: &str, args: &Value) {
let keys = doc_keys_in(args);
if keys.is_empty() || is_anonymous(thread) {
return;
}
let t = now();
with_table(true, |tab| {
for k in keys {
tab.entry(k).or_default().insert(thread.to_string(), t);
}
tab.retain(|_, users| {
users.retain(|_, at| t.saturating_sub(*at) < MAX_AGE_SECS);
!users.is_empty()
});
});
}
/// The other named threads that opened or drove this document recently, most recent first.
pub fn others(name: &str, me: &str) -> Vec<String> {
let Some(key) = doc_key(name) else { return Vec::new() };
let t = now();
with_table(false, |tab| {
let mut v: Vec<(&String, &u64)> = tab.get(&key).map(|u| u.iter().filter(|(th, at)| th.as_str() != me && !is_anonymous(th) && t.saturating_sub(**at) < MAX_AGE_SECS).collect()).unwrap_or_default();
v.sort_by(|a, b| b.1.cmp(a.1));
v.into_iter().map(|(th, _)| th.clone()).collect()
})
}
/// Forget a document (it was closed).
pub fn forget(name: &str) {
if let Some(key) = doc_key(name) {
with_table(true, |tab| {
tab.remove(&key);
});
}
}
/// Forget every document that is not among `open` (closed some other way, by hand or by a crash).
pub fn keep_only(open: &[String]) {
let keys: Vec<String> = open.iter().filter_map(|n| doc_key(n)).collect();
with_table(true, |tab| tab.retain(|k, _| keys.contains(k)));
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
static TEST_LOCK: Mutex<()> = Mutex::new(());
fn fresh(tag: &str) -> (std::sync::MutexGuard<'static, ()>, PathBuf) {
let g = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let p = std::env::temp_dir().join(format!("kicad-doc-users-{tag}-{}.json", std::process::id()));
let _ = std::fs::remove_file(&p);
set_state_path(Some(p.clone()));
(g, p)
}
#[test]
fn keys_come_from_document_paths_and_titles() {
assert_eq!(doc_key("C:\\boards\\LED-Strip-500.kicad_pcb").as_deref(), Some("led-strip-500"));
assert_eq!(doc_key("C:/p/x.kicad_sch").as_deref(), Some("x"));
assert_eq!(doc_key("led-strip-500").as_deref(), Some("led-strip-500"));
assert_eq!(doc_key("C:/p/notes.txt"), None);
let keys = doc_keys_in(&json!({"filePath": "C:/a/b.kicad_pcb", "path": "C:/a/b.kicad_sch", "project": "C:/a/out.step", "x": "C:/a/c.kicad_pcb"}));
assert_eq!(keys, vec!["b".to_string()]);
}
#[test]
fn another_threads_document_is_reported_and_mine_is_not() {
let (_g, p) = fresh("others");
note("LED strip 500mm aiflow", &json!({"filePath": "C:/w/led-strip-500.kicad_pcb"}));
note("noah check", &json!({"filePath": "C:/w/am32-esc-v1-aiflow-check.kicad_pcb"}));
assert_eq!(others("led-strip-500", "noah check"), vec!["LED strip 500mm aiflow".to_string()]);
assert!(others("am32-esc-v1-aiflow-check", "noah check").is_empty());
assert!(others("led-strip-500", "LED strip 500mm aiflow").is_empty());
// an anonymous asker sees every named thread as someone else
assert_eq!(others("am32-esc-v1-aiflow-check", "?"), vec!["noah check".to_string()]);
// it survives a bridge restart
set_state_path(Some(p.clone()));
assert_eq!(others("led-strip-500", "noah check"), vec!["LED strip 500mm aiflow".to_string()]);
forget("led-strip-500");
assert!(others("led-strip-500", "noah check").is_empty());
let _ = std::fs::remove_file(&p);
}
#[test]
fn anonymous_callers_leave_no_note_and_closed_documents_drop_out() {
let (_g, p) = fresh("anon");
note("?", &json!({"filePath": "C:/w/a.kicad_pcb"}));
note("kicad-bridge", &json!({"filePath": "C:/w/a.kicad_pcb"}));
assert!(others("a", "me").is_empty());
note("t1", &json!({"filePath": "C:/w/a.kicad_pcb"}));
note("t2", &json!({"filePath": "C:/w/b.kicad_sch"}));
keep_only(&["b".to_string()]);
assert!(others("a", "me").is_empty());
assert_eq!(others("b", "me"), vec!["t2".to_string()]);
let _ = std::fs::remove_file(&p);
}
#[test]
fn an_old_note_no_longer_counts() {
let (_g, p) = fresh("age");
let old = now() - MAX_AGE_SECS - 5;
std::fs::write(&p, json!({"a": {"t-old": old, "t-new": now()}}).to_string()).unwrap();
set_state_path(Some(p.clone()));
assert_eq!(others("a", "me"), vec!["t-new".to_string()]);
let _ = std::fs::remove_file(&p);
}
#[test]
fn the_asker_is_per_thread_and_restored_after_an_inner_call() {
let _outer = set_asker("thread A", "close mine");
{
let _inner = set_asker("thread B", "");
assert_eq!(asker().thread, "thread B");
}
assert_eq!(asker(), Asker { thread: "thread A".into(), reason: "close mine".into() });
let other = std::thread::spawn(asker).join().unwrap();
assert_eq!(other, Asker::default());
}
}