app
Altium Bridge: AI for Altium Designer
Public Made by Adomby adom
Let an AI (Claude, ChatGPT, Codex or any agent) run Altium Designer for you: create symbols and footprints, place parts (also from Altium 365), wire and annotate schematics, Update PCB, set the stack-up and rules, route, pour copper, add mounting holes and stitching vias, run ERC and DRC, and make Gerbers, BOMs and STEP. Free in Hydrogen; bring your own AI and Altium licence.
← Commit history
0.2.12 source: install phases ledger, remembered receipt, altium_install_receipt, lastInstall on status
4 files changed
+118−3
bridge-rs/src/library.rs+95−1@@ -5,7 +5,8 @@ use crate::support::{self, s, s_or, timeout_of}; use crate::{ab, agent, shot}; use serde_json::{json, Value};-use std::time::Duration;+use std::sync::Mutex;+use std::time::{Duration, Instant}; /// `_lib_call`: gate, optionally open a library first, then one agent command. fn lib_call(args: &Value, cmd: &str, params: Vec<(&str, String)>, open_path: Option<&str>, tdef: u64) -> Value {@@ -466,7 +467,68 @@ fn valid_lib_name(name: &str) -> bool { /// Unpack a staged zip (<Lib>.SchLib + <Lib>.PcbLib [+ .IntLib], regardless of a wrapping /// folder), install into the user's Installed Libraries, optionally return a proof frame. /// Hardened for untrusted zips: size caps, zip-slip refusal.+/// Per-step wall clock of one bundle install, so a slow run names its step (the+/// 2026-09-08 report: an install outlived ab's 60 s budget with nothing saying+/// where the time went).+struct Phases { last: Instant, items: Vec<(String, u64)> }+impl Phases {+ fn start() -> Self { Phases { last: Instant::now(), items: Vec::new() } }+ fn mark(&mut self, name: &str) {+ self.items.push((name.to_string(), self.last.elapsed().as_millis() as u64));+ self.last = Instant::now();+ }+ fn to_json(&self) -> Value {+ let mut m = serde_json::Map::new();+ for (k, v) in &self.items { m.insert(k.clone(), json!(v)); }+ Value::Object(m)+ }+}++/// The last bundle install's receipt, proof frame included. ab drops a verb that+/// outlives its budget while this bridge, which serves one verb at a time,+/// finishes it anyway; a client that got "timed out" polls altium_status (the+/// light summary) until lastInstall carries its installId, then reads the full+/// receipt back through altium_install_receipt.+static LAST_INSTALL: Mutex<Option<Value>> = Mutex::new(None);+fn remember_install(receipt: &Value) {+ if let Ok(mut g) = LAST_INSTALL.lock() { *g = Some(receipt.clone()); }+}+pub fn last_install() -> Value {+ LAST_INSTALL.lock().ok().and_then(|g| g.clone()).unwrap_or(Value::Null)+}+/// The receipt without its heavy fields, for the status verb every poll reads.+pub fn last_install_summary() -> Value {+ let mut v = last_install();+ if let Some(o) = v.as_object_mut() {+ for k in ["thumbnailDataUrl", "shot", "components", "installed"] { o.remove(k); }+ }+ v+}+pub fn install_receipt(_args: &Value) -> Value {+ let r = last_install();+ if r.is_null() {+ return json!({"success": true, "receipt": Value::Null,+ "_hint": "No bundle install has run in this bridge process yet (the receipt lives in memory; a bridge restart clears it)."});+ }+ json!({"success": true, "receipt": r})+}+ pub fn install_library_bundle(args: &Value) -> Value {+ let t0 = Instant::now();+ let mut phases = Phases::start();+ let mut out = install_library_bundle_inner(args, &mut phases);+ if let Some(o) = out.as_object_mut() {+ o.insert("phasesMs".into(), phases.to_json());+ o.insert("totalMs".into(), json!(t0.elapsed().as_millis() as u64));+ o.insert("atMs".into(), json!((support::now() * 1000.0) as u64));+ if let Some(id) = s(args, "installId") { o.insert("installId".into(), json!(id)); }+ if !o.contains_key("library") { o.insert("library".into(), json!(s_or(args, "libraryName", ""))); }+ }+ remember_install(&out);+ out+}++fn install_library_bundle_inner(args: &Value, ph: &mut Phases) -> Value { // One-click parity with the KiCad/Fusion lanes (Colby, adom/altium-bridge#5): from a cold // state, launch Altium first (measured cold start on the taskbar bar), then register. The // resident agent bootstraps on the first agent call. Safe to ship now that the close@@ -492,9 +554,11 @@ pub fn install_library_bundle(args: &Value) -> Value { crate::instance::select(Some(inst)); } }+ ph.mark("launch"); if let Some(g) = support::need_licensed() { return g; }+ ph.mark("licence"); let zp = match s(args, "zipPath") { Some(z) if !z.is_empty() => z, _ => return json!({"success": false, "error": "zipPath is required (stage the bundle with write_file / write_file_chunk first)"}),@@ -600,6 +664,7 @@ pub fn install_library_bundle(args: &Value) -> Value { } else { schlibs.iter().chain(pcblibs.iter()).collect() };+ ph.mark("unpack"); let mut installed = Vec::new(); for lib in to_install { let winpath = lib.to_string_lossy().replace('/', "\\");@@ -607,6 +672,7 @@ pub fn install_library_bundle(args: &Value) -> Value { installed.push(json!({"lib": lib.file_name().map(|n| n.to_string_lossy().to_string()), "ok": ir.get("success").and_then(|b| b.as_bool()).unwrap_or(false)})); }+ ph.mark("register"); let mut out = json!({"success": true, "launched": launched, "library": if name.is_empty() { stem } else { name }, "symbolFiles": schlibs.len(), "footprintFiles": pcblibs.len(), "intlibs": intlibs.len(),@@ -623,12 +689,14 @@ pub fn install_library_bundle(args: &Value) -> Value { let kind = if schlibs.is_empty() { "pcb" } else { "sch" }; let o = support::agent_open(&show_path.to_string_lossy().replace('/', "\\"), 60); let opened = o.get("success").and_then(|b| b.as_bool()) == Some(true);+ ph.mark("open"); if opened { let lst = agent::call("lib_list", &[("kind", kind.into()), ("limit", "200".into())], None, Duration::from_secs(30)); if let Some(n) = lst.get("data").and_then(|d| d.get("count")) { out[if kind == "sch" { "symbolCount" } else { "footprintCount" }] = n.clone(); out["components"] = lst.get("data").and_then(|d| d.get("names")).cloned().unwrap_or(Value::Null); }+ ph.mark("list"); // land the user on the library LIST, not just the first symbol's canvas: the panel // tab wants the UIA select action, not a click (a click does not switch, measured) if let Some(hw) = crate::instance::licence_state().get("hwnd").and_then(|h| h.as_i64()) {@@ -637,7 +705,9 @@ pub fn install_library_bundle(args: &Value) -> Value { "reason": "land the user on their freshly installed library list (adom/altium-bridge#7)"}), &[], 10); out["panelSelected"] = json!(sel.get("success").and_then(|b| b.as_bool()).unwrap_or(false)); }+ ph.mark("panel"); let frame = shot::shot_of_main();+ ph.mark("capture"); if let Some(img) = frame.get("image").and_then(|i| i.as_str()) { out["thumbnailDataUrl"] = json!(format!("data:image/png;base64,{img}")); out["shot"] = frame.clone();@@ -651,6 +721,7 @@ pub fn install_library_bundle(args: &Value) -> Value { // close to wedge on. Every live loop is stopped before the reply goes out; the next verb // re-bootstraps in ~8s. The library stays open for the user; the ENGINE goes idle. let teardown = crate::lifecycle::stop_all_live();+ ph.mark("agentStop"); out["agentStopped"] = json!(teardown.get("success").and_then(|b| b.as_bool()).unwrap_or(false)); out["_hint"] = json!("Registered in Installed Libraries (live, no restart needed) and opened in Altium when shown:true - the SCH/PCB Library panel lists every part. The resident agent was stopped afterward so a user-initiated Altium close finds an idle engine (adom/altium-bridge#6)."); out@@ -660,6 +731,29 @@ pub fn install_library_bundle(args: &Value) -> Value { mod tests { use super::*; + #[test]+ fn install_receipt_is_remembered_and_the_status_summary_stays_light() {+ remember_install(&json!({"success": true, "library": "L", "installId": "L-1", "thumbnailDataUrl": "data:x",+ "shot": {"image": "y"}, "components": ["a"], "installed": [{"lib": "L.SchLib", "ok": true}], "shown": true}));+ assert_eq!(last_install()["installId"], "L-1");+ assert_eq!(install_receipt(&json!({}))["receipt"]["thumbnailDataUrl"], "data:x");+ let sum = last_install_summary();+ assert_eq!(sum["shown"], true);+ assert_eq!(sum["installId"], "L-1");+ for k in ["thumbnailDataUrl", "shot", "components", "installed"] { assert!(sum.get(k).is_none(), "{k} stripped"); }+ }++ #[test]+ fn phases_record_each_step_in_order() {+ let mut p = Phases::start();+ p.mark("launch");+ p.mark("unpack");+ let j = p.to_json();+ assert!(j.get("launch").is_some() && j.get("unpack").is_some());+ assert_eq!(p.items[0].0, "launch");+ assert_eq!(p.items[1].0, "unpack");+ }+ #[test] fn model_spec_separators_are_sanitised() { assert_eq!(spec_safe("a;b|c"), "a,b/c");
bridge-rs/src/misc.rs+2@@ -51,6 +51,8 @@ pub fn status(args: &Value) -> Value { r["windows"] = json!(instance::altium_windows()); r["dialogs"] = json!(dialogs::scan()); r["reapedDialogs"] = json!(dialogs::reaped_log(5));+ // The last bundle install, light: a client whose install outlived ab's budget polls this.+ r["lastInstall"] = crate::library::last_install_summary(); r }
bridge-rs/src/verbs.rs+20−1@@ -34,7 +34,7 @@ pub const KNOWN: &[&str] = &[ "add_models", "normalize_bodies", "set_params", "set_visibility", "read_symbol", "clone_symbol", "rename_symbol", "rename_footprint", "remove_symbol", "remove_footprint", "copy_footprint", "xcopy_footprint", "swap_footprint", "symbol_mode", "cleanup_libs",- "install_library", "install_library_bundle", "compile_intlib", "extract_intlib",+ "install_library", "install_library_bundle", "install_receipt", "compile_intlib", "extract_intlib", "place_on_sch", "place_on_pcb", "import_part", // licence / account "license", "license_status", "ensure_licensed", "setup_trial", "signin", "signin_status",@@ -58,6 +58,15 @@ fn verb_doc(name: &str, summary: &str, input: Value, output: Value, timeout: u64 "pitfalls": pitfalls}) } +/// A verb that can outlive ab's budget: the same doc, marked long-running with the verb to poll.+#[allow(clippy::too_many_arguments)]+fn verb_doc_long(name: &str, summary: &str, input: Value, output: Value, timeout: u64, status_verb: &str, hint: &str, related: &[&str], pitfalls: &[&str], example: Value) -> Value {+ let mut d = verb_doc(name, summary, input, output, timeout, hint, related, pitfalls, example);+ d["longRunning"] = json!(true);+ d["statusVerb"] = json!(format!("altium_{status_verb}"));+ d+}+ fn describe() -> Value { let board = json!({"success": "bool", "data": "object - the agent's payload", "roundtripMs": "int"}); let shot = json!({"success": "bool", "shot": "{image: base64 png, width, height}", "windowEtiquette": "background"});@@ -124,6 +133,15 @@ fn describe() -> Value { verb_doc("close", "Polite close (releases the licence seat); force needs acceptSeatLoss.", json!({"force": "bool", "acceptSeatLoss": "bool"}), json!({"closedInSec": "float"}), 30, "A save prompt on close NAMES the documents - read it; on a shared machine one may belong to another AI thread. Never defaulted.", &["license", "launch"], &["force:true without acceptSeatLoss is refused: a kill strands the On-Demand seat."], json!({})),+ verb_doc_long("install_library_bundle", "One-click install of a wiki library bundle (zip of .SchLib/.PcbLib): unpack, register, open the library, prove it with a frame.",+ json!({"zipPath": "string - staged zip", "libraryName": "string", "capture": "bool", "captureMaxWidth": "int", "installId": "string - echoed in the receipt so a client can match it"}),+ json!({"shown": "bool", "symbolCount": "int", "components": "[names]", "shot": "{image, ...}", "thumbnailDataUrl": "string", "phasesMs": "{launch, licence, unpack, register, open, list, panel, capture, agentStop}", "totalMs": "int", "installId": "string", "atMs": "int"}),+ 330, "status",+ "Launches a closed Altium first (up to 240s), then registers and opens the library. Past ab's budget the verb keeps running: poll altium_status until lastInstall.installId matches, then altium_install_receipt for the full receipt.",+ &["install_receipt", "status", "install_library"], &["The receipt lives in bridge memory; a bridge restart clears it."],+ json!({"zipPath": "C:\\Users\\me\\adom-wiki-libs\\Lib-altium.zip", "libraryName": "Lib", "capture": true, "installId": "Lib-1757350000000"})),+ verb_doc("install_receipt", "The last bundle install's full receipt (proof frame included), for a client whose install call outlived ab's budget.", json!({}),+ json!({"receipt": "object|null - the install_library_bundle reply"}), 10, "Read after altium_status shows lastInstall with your installId.", &["install_library_bundle", "status"], &[], json!({})), verb_doc("describe", "This list - the bridge's verbs and schemas for ab's Verbs tab.", json!({}), json!({"verbs": "[{name, summary, input, output, ...}]"}), 5, "Static; safe to call any time.", &["status"], &[], json!({})), ], "allVerbs": names()})@@ -441,6 +459,7 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) -> "cleanup_libs" => library::cleanup_libs(args), "install_library" => library::install_library(args), "install_library_bundle" => library::install_library_bundle(args),+ "install_receipt" => library::install_receipt(args), "compile_intlib" => library::compile_intlib(args), "extract_intlib" => library::extract_intlib(args), "place_on_sch" => library::place_on_sch(args),
page.json+1−1@@ -139,4 +139,4 @@ "skills/altium-wiki-import/SKILL.md", "uninstall.sh" ]-}+}