app
Altium - the Altium Bridge
Public Made by Adomby adom
Adom Bridge for Altium Designer: a resident DelphiScript agent gives the AI eval, the 1,100-command process launcher, all 8 EDA surfaces with screenshots, compile/ERC, DRC, OutJob outputs, native SchLib/PcbLib authoring with STEP 3D bodies, and dialog handling by PID.
← Commit history
0.2.49 source (not yet released, needs signing): licence read on Altium Designer 26.x (a title with a version and no licence tail is licensed; ensure_licensed no longer reports no_seat_available on a licensed Altium) and the Use License pixel fallback works through the bridge (screen space from the window rectangle; ab's direct API returns no shotId) (#19). No -R helper is spawned while a dialog other than the script Error box blocks Altium, so a save prompt can no longer turn into a stream of 'another instance is busy' notices; recover stops and names the dialog. build_board's project reopen and build_schematic's project rewrite read the project's members from Altium itself, save them before closing, and never close documents the project file does not list. The battery refuses to start over a running Altium. Battery on DESKTOP-BAR6S0F (AD 26.11.1), twice in a row: PASS 145, FAIL 0, SKIP 1 (a365_logout), uncovered 0.
11 files changed
+163−33
bridge-rs/src/instance.rs+13−1@@ -194,6 +194,13 @@ fn licence_of(win: &Value) -> (Option<bool>, Option<String>) { None if win.get("edition").and_then(|e| e.as_str()) == Some("Develop") => { (Some(true), Some("Licensed (Develop reports no licence tail)".into())) }+ // Altium Designer writes a tail only when it does NOT hold a seat ("- No License", "- Viewer"),+ // like Develop. AD 26.11.1 on a Develop licence titles itself "<doc> - Altium Designer (26.11.1)"+ // while every scripted edit runs (Altium refuses scripts without a licence), and reading that as+ // unknown sent ensure_licensed to click Use License on a licensed Altium (#19).+ None if win.get("adVersion").and_then(|v| v.as_str()).is_some() => {+ (Some(true), Some("Licensed (no licence tail)".into()))+ } None => (None, None), } }@@ -350,9 +357,14 @@ mod tests { // ...but "- Viewer" on Develop means NOT licensed let w = json!({"edition": "Develop", "licenceState": "Viewer"}); assert_eq!(licence_of(&w).0, Some(false));- // Designer with no tail stays unknown+ // Designer with a version and no tail holds its seat (AD 26.11.1, #19)+ let w = json!({"edition": null, "adVersion": "26.11.1", "licenceState": null});+ assert_eq!(licence_of(&w), (Some(true), Some("Licensed (no licence tail)".to_string())));+ // ...a title with neither (starting up, or not parsed) stays unknown let w = json!({"edition": null, "licenceState": null}); assert_eq!(licence_of(&w).0, None);+ // and a tail always decides, whatever the version+ assert_eq!(licence_of(&json!({"adVersion": "26.11.1", "licenceState": "No License"})).0, Some(false)); // Viewer / No License are unlicensed; anything else licensed assert_eq!(licence_of(&json!({"licenceState": "No License"})).0, Some(false)); assert_eq!(licence_of(&json!({"licenceState": "Trial"})).0, Some(true));
bridge-rs/src/license.rs+56−7@@ -38,7 +38,7 @@ fn page_automation_id(action: &str) -> Option<&'static str> { /// UIA first (quiet), image-space click only if UIA cannot reach the control. fn click_by_name_then_pixels(hwnd: i64, name: &str, automation_id: Option<&str>, pixel: impl FnOnce() -> Value) -> Value {- let mut args = json!({"hwnd": hwnd, "name": name, "role": "button"});+ let mut args = json!({"hwnd": hwnd, "name": name, "role": "button", "reason": "altium licence page: click the named button"}); if let Some(id) = automation_id { args["automationId"] = json!(id); }@@ -57,6 +57,29 @@ fn click_by_name_then_pixels(hwnd: i64, name: &str, automation_id: Option<&str>, out } +/// The licence-page positions in `page_coords` are pixels of a 1400-px-wide capture of the maximised+/// main window (the safe frame ab hands back). Map one onto physical screen pixels through the+/// window's own rectangle.+fn page_to_screen(rect: &Value, x: i64, y: i64) -> Option<(i64, i64)> {+ let g = |a: &str, b: &str| rect.get(a).or_else(|| rect.get(b)).and_then(|v| v.as_i64());+ let (left, top, w) = (g("left", "x")?, g("top", "y")?, g("width", "w")?);+ if w < 200 { return None; }+ let k = w as f64 / 1400.0;+ Some((left + (x as f64 * k).round() as i64, top + (y as f64 * k).round() as i64))+}++/// The shotId of a desktop_screenshot_window reply, wherever ab nests it (top level, coordMap, data).+fn find_shot_id(v: &Value) -> Option<Value> {+ match v {+ Value::Object(m) => {+ if let Some(Value::String(id)) = m.get("shotId") { return Some(json!(id)); }+ m.values().find_map(find_shot_id)+ }+ Value::Array(a) => a.iter().find_map(find_shot_id),+ _ => None,+ }+}+ /// Drive the License Management page. Every call returns a capture so the caller can see the /// state and click anything else itself. pub fn license(args: &Value) -> Value {@@ -78,22 +101,34 @@ pub fn license(args: &Value) -> Value { agent::call_no_autostart("run_process", &[("process", "Client:MyAccount".into()), ("params", "".into())], Duration::from_secs(20)); } std::thread::sleep(Duration::from_secs(1));- let win = ab::call("desktop_screenshot_window", json!({"hwnd": hwnd}), &[], 20);+ // the pixel fallback clicks in THIS screenshot's coordinates, so it needs its shotId (#19: the+ // call carried no reason and came back without one, so the fallback never ran)+ let win = ab::call("desktop_screenshot_window", json!({"hwnd": hwnd, "reason": "altium licence page: the frame the pixel fallback clicks in"}), &[], 30); let mut frame = shot::shot_of_main();- let shot_id = win.get("coordMap").and_then(|c| c.get("shotId")).or_else(|| win.get("shotId")).cloned();+ let shot_id = find_shot_id(&win); if frame.is_object() { frame["shotId"] = shot_id.clone().unwrap_or(Value::Null); frame["coordMap"] = win.get("coordMap").cloned().unwrap_or(Value::Null); } let mut out = json!({"success": true, "licenceState": instance::licence_state().get("state"), "shot": frame, "windowEtiquette": "background"});+ if shot_id.is_none() {+ out["pixelFallbackUnavailable"] = json!({"screenshotSuccess": win.get("success"), "error": win.get("error"),+ "errorCode": win.get("errorCode"), "keys": win.as_object().map(|o| o.keys().cloned().collect::<Vec<_>>())});+ } if let Some((x, y)) = page_coords(&action) { let sid = shot_id.clone();+ let rect = instance::main_window().map(|w| instance::norm_rect(&w)); let r = click_by_name_then_pixels(hwnd, page_name(&action), page_automation_id(&action), move || {- match sid {- Some(id) if !id.is_null() =>- ab::call("desktop_click", json!({"space": "image", "shotId": id, "x": x, "y": y, "hwnd": hwnd}), &[], 20),- _ => json!({"success": false, "error": "no shotId for the pixel fallback"}),+ let why = "altium licence page: the pixel fallback for a button UI Automation did not find";+ match (sid, rect.as_ref().and_then(|r| page_to_screen(r, x, y))) {+ (Some(id), _) if !id.is_null() =>+ ab::call("desktop_click", json!({"space": "image", "shotId": id, "x": x, "y": y, "hwnd": hwnd, "reason": why}), &[], 20),+ // ab's direct API returns a bare image with no shotId (#19), so the bridge maps the page+ // position onto the window itself: screen = window origin + position x (window width / 1400)+ (_, Some((sx, sy))) =>+ ab::call("desktop_click", json!({"space": "screen", "x": sx, "y": sy, "hwnd": hwnd, "reason": why}), &[], 20),+ _ => json!({"success": false, "error": "no shotId and no window rectangle for the pixel fallback"}), } }); out["windowEtiquette"] = r.get("windowEtiquette").cloned().unwrap_or(json!("foreground"));@@ -268,3 +303,17 @@ pub fn connect_workspace(_args: &Value) -> Value { json!({"success": true, "picker": picker, "shot": shot, "windowEtiquette": "background", "_hint": "The picker lists candidate workspaces; click the wanted row with ab desktop_click {space:'image', shotId, x, y}. It is a popup — it closes on focus loss."}) }++#[cfg(test)]+mod page_tests {+ use super::*;++ #[test]+ fn page_to_screen_maps_through_the_window_rect() {+ // the laptop's maximised AD 26.11.1 window: 2582 x 1550 physical px at (-11, -11), safe frame 1400 px+ let r = json!({"left": -11, "top": -11, "width": 2582, "height": 1550});+ assert_eq!(page_to_screen(&r, 1288, 178), Some((-11 + 2375, -11 + 328)));+ assert_eq!(page_to_screen(&json!({"x": 0, "y": 0, "w": 1400, "h": 840}), 700, 420), Some((700, 420)));+ assert_eq!(page_to_screen(&json!({"left": 0, "top": 0, "width": 10}), 5, 5), None);+ }+}
bridge-rs/src/lifecycle.rs+25@@ -139,10 +139,31 @@ fn x2_exe() -> Option<String> { .and_then(|a| a.iter().find_map(|p| p.get("commandLine").and_then(|s| s.as_str()).and_then(crate::instance::exe_from_command_line))) } +/// The first dialog on the main Altium window that a -R hand-off would bounce off: anything but the+/// script Error / X2 notice (a lone OK), which recover answers itself.+pub fn blocking_dialog() -> Option<Value> {+ crate::dialogs::scan_scoped(true).into_iter().find(|d| {+ let title = d.get("title").and_then(|t| t.as_str()).unwrap_or("").trim().to_string();+ let lone_ok = d.get("buttons").and_then(|b| b.as_array()).map(|a| a.len() == 1 && a[0].as_str().map(|x| x.eq_ignore_ascii_case("ok")).unwrap_or(false)).unwrap_or(false);+ !(lone_ok && (title.eq_ignore_ascii_case("error") || title.eq_ignore_ascii_case("x2")))+ })+}+ /// Spawn the X2 helper. Process spawning is NOT desktop-shaped, and ab's direct API refuses /// `shell_execute` anyway (412 — that verb is CLI-only, alongside pull_file/send_files), so the /// bridge owns the child directly: no shell, no quoting layer, and a real handle to wait on. fn spawn_helper(exe: &str, arg: &str) -> Result<std::process::Child, String> {+ // A -R hand-off cannot reach an Altium frozen behind a modal: the helper bounces off it with+ // "another instance is busy" on the user's screen. agent::call's auto-recover used to spawn one+ // per failing verb, so a single save prompt left mid-battery turned into fifteen minutes of those+ // notices (2026-10-16). The script Error box is the one dialog recover answers itself, so it does+ // not block; anything else (a save prompt, a reload confirm) does.+ if !arg.is_empty() {+ if let Some(d) = blocking_dialog() {+ return Err(format!("blocked_by_dialog: '{}' is open on Altium's main window; answer it first (altium_check_dialogs / altium_dismiss_dialogs)",+ d.get("title").and_then(|t| t.as_str()).unwrap_or("?")));+ }+ } let mut cmd = std::process::Command::new(exe); #[cfg(windows)] {@@ -678,6 +699,10 @@ fn recover_impl(reason: &str, verify_by_restart: bool) -> Value { steps.push(json!({"dialog": title, "pressed": "OK"})); } }+ if let Some(d) = blocking_dialog() {+ return json!({"success": false, "errorCode": "blocked_by_dialog", "dialog": d, "steps": steps,+ "_hint": "A dialog that is not the script Error box is open on Altium's main window (a save prompt, a reload confirm). recover never answers those, and every -R hand-off would bounce off it with an 'another instance is busy' notice on the user's screen. Read it (altium_check_dialogs), answer it with altium_dismiss_dialogs, then retry."});+ } // 2. tell the editor to stop whatever unit it is holding (raises the busy notice → reap it). // EditScript:Stop ONLY acts when a SCRIPT DOCUMENT is the active document. With a board or a // library in front it is a silent no-op: recover answered success while the script stayed on
bridge-rs/src/main.rs+1−1@@ -31,7 +31,7 @@ use tiny_http::{Header, Method, Response, Server}; /// Attached to every failed reply (see the /command handler). const REPORT_ISSUES: &str = "If this looks like an altium-bridge BUG (not a refusal you expected), report it: check for a duplicate with `adom-wiki issue list adom/altium-bridge`, then `adom-wiki issue create adom/altium-bridge --title \"<verb>: <one-line symptom>\" --category bug-report --body \"<what you ran, what you expected, what happened, the VERBATIM response JSON (and errorShot if present), bridgeVersion, the Altium version from altium_status, the OS>\"`. A relay, routing, toast or lifecycle problem belongs on adom/adom-bridge instead. Reports from real machines are how this bridge gets correct: three different Altium builds behaved three different ways in one weekend."; -pub const BRIDGE_VERSION: &str = "0.2.48";+pub const BRIDGE_VERSION: &str = "0.2.49"; fn json_response(status: u16, body: &serde_json::Value) -> Response<std::io::Cursor<Vec<u8>>> { let data = serde_json::to_vec(body).unwrap_or_else(|_| b"{}".to_vec());
bridge-rs/src/routing.rs+53−17@@ -808,35 +808,61 @@ fn same_file_name(a: &std::path::Path, b: &std::path::Path) -> bool { } } +/// Altium's own view of a project: the documents it holds OPEN under that project, and those of them+/// the project FILE does not list (they joined in memory only). Closing the project over an unlisted,+/// modified member raises "Save changes?" on the user's screen.+fn project_members(prj: &std::path::Path) -> (Vec<String>, Vec<String>) {+ let dir = prj.parent().unwrap_or(std::path::Path::new("."));+ let listed = std::fs::read(prj).map(|b| listed_documents(&String::from_utf8_lossy(&b), dir)).unwrap_or_default();+ let name = prj.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();+ let st = agent::call("state", &[], None, Duration::from_secs(30));+ let open: Vec<String> = st.get("data").and_then(|d| d.get("documents")).and_then(|d| d.as_array()).cloned().unwrap_or_default()+ .iter().filter(|d| d["project"].as_str().map(|p| p.eq_ignore_ascii_case(&name)).unwrap_or(false) && d["open"].as_bool() == Some(true))+ .filter_map(|d| d["path"].as_str().map(str::to_string)).collect();+ let unlisted = open.iter().filter(|o| !listed.iter().any(|l| same_file_name(l, std::path::Path::new(o.as_str())))).cloned().collect();+ (open, unlisted)+}++/// Save every open member of a project, then close them and the project: no prompt, nothing lost.+fn close_project_saved(prj: &str, open: &[String]) {+ for d in open {+ let _ = crate::surfaces::save(&json!({"filePath": d}));+ }+ for d in open.iter().map(|s| s.as_str()).chain(std::iter::once(prj)) {+ let _ = agent::call("close_document", &[("path", d.to_string())], None, Duration::from_secs(30));+ }+}+ /// A board built into a folder whose project already lists it (altium_build_schematic writes the /// project first) is created as a new document that joins the project and is then renamed onto the /// listed path: Altium holds the file twice, one entry greyed, and a second hidden view survives a-/// project close (#23). Save the project's sheets, boards and libraries, close them and the project,-/// reopen the project and show the board: one entry, one view. None when no project lists the board.-fn reopen_listing_project(pcb: &str) -> Option<String> {+/// project close (#23). Save every open member of that project, close them and the project, reopen it+/// and show the board: one entry, one view.+/// The members come from Altium's own view (the agent's state), not the file: a document that joined+/// the project in memory only is not in the file, and closing the project over it raised "Save+/// changes? battery.PcbDoc" mid-battery. When the project holds such a member the reopen is skipped+/// (the caller's documents are not this verb's to close). None when no project lists the board.+fn reopen_listing_project(pcb: &str) -> Option<Value> { let pcb_p = std::path::Path::new(pcb); let dir = pcb_p.parent()?;- let (prj, docs) = std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path())+ let (prj, listed) = std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path()) .filter(|p| p.extension().map(|x| x.eq_ignore_ascii_case("prjpcb")).unwrap_or(false)) .find_map(|prj| { let text = String::from_utf8_lossy(&std::fs::read(&prj).ok()?).to_string(); let docs = listed_documents(&text, dir); docs.iter().any(|d| same_file_name(d, pcb_p)).then_some((prj, docs)) })?;- const KINDS: [&str; 4] = ["schdoc", "pcbdoc", "schlib", "pcblib"];- let docs: Vec<String> = docs.iter()- .filter(|d| d.extension().map(|x| KINDS.contains(&x.to_string_lossy().to_lowercase().as_str())).unwrap_or(false))- .map(|d| d.display().to_string()).collect();- for d in &docs {- let _ = crate::surfaces::save(&json!({"filePath": d}));- }- for d in docs.iter().chain(std::iter::once(&prj.display().to_string())) {- let _ = agent::call("close_document", &[("path", d.clone())], None, Duration::from_secs(30));- } let prj_s = prj.display().to_string();+ let _ = listed;+ let (open, unlisted) = project_members(&prj);+ if !unlisted.is_empty() {+ return Some(json!({"project": prj_s, "reopened": false,+ "skipped": format!("the open project also holds documents its file does not list ({}); they are not this verb's to close", unlisted.join(", "))}));+ }+ close_project_saved(&prj_s, &open); let _ = crate::support::agent_open(&prj_s, 90); let _ = crate::support::agent_open(pcb, 90);- Some(prj_s)+ Some(json!({"project": prj_s, "reopened": true, "closed": open})) } pub fn build_board(args: &Value) -> Value {@@ -1162,8 +1188,18 @@ pub fn build_schematic(args: &Value) -> Value { // written only when it changes: rewriting an OPEN project's file makes Altium ask to reload it // (a modal that blocked the next verb, measured 2026-10-15) if std::fs::read_to_string(&prj).ok().as_deref() != Some(text.as_str()) {- // an open project closes first, so the new file opens clean instead of asking to reload- if std::path::Path::new(&prj).exists() { agent::call("close_document", &[("path", prj.clone())], None, Duration::from_secs(60)); }+ // an open project closes first, so the new file opens clean instead of asking to reload; its+ // open members are saved and closed with it (closing it over a modified one raised "Save+ // changes?", then the rewrite raised "Reload?", mid-battery 2026-10-16). Documents that joined+ // it in memory only are the caller's: refuse rather than close them.+ if std::path::Path::new(&prj).exists() {+ let (open, unlisted) = project_members(std::path::Path::new(&prj));+ if !unlisted.is_empty() {+ return err("project_has_unlisted_documents", format!("{prj} is open in Altium with documents its file does not list: {}", unlisted.join(", ")),+ "Save or close those documents (or the project) first: altium_save / altium_close_document, then call again. The bridge will not close documents it did not make.");+ }+ close_project_saved(&prj, &open);+ } if let Err(e) = std::fs::write(&prj, &text) { return err("write_failed", format!("could not write {prj}: {e}"), ""); } } // the project open first, so the sheet the agent opens is the project's
bridge-rs/src/support.rs+3−3@@ -59,9 +59,9 @@ pub fn seat_hints(mut out: Value, licence_state: Option<&str>, instance_count: u } if instance_count > 1 { out["_seatHint"] = json!(format!(- "{SEAT_RULE} {SEAT_SWAP_HOWTO} NOTE: Altium Designer Develop writes NO licence tail in \- its title when it HOLDS the seat (only '- Viewer' when it does not), so a blank \- licenceState on Develop does not mean unlicensed — try the verb."+ "{SEAT_RULE} {SEAT_SWAP_HOWTO} NOTE: Altium writes NO licence tail in its title when it \+ HOLDS the seat (Develop, and Altium Designer 26.x: only '- Viewer' / '- No License' when it \+ does not), so a blank licenceState does not mean unlicensed — try the verb." )); } out
dev-skills/altium-bridge-test/run_verb_tests.py+8@@ -310,6 +310,14 @@ def main(): sys.exit(f"REFUSED: {T} had keyboard/mouse input {idle} s ago (need {a.min_idle} s idle). Someone is using it; " "the battery would raise Altium over their work. Ask the user, then pass --user-is-away.") print(f"{T} idle for {idle} s: ok to run")+ # The battery starts from a CLOSED Altium: phase 0 tests the closed state, and an Altium left open+ # holds the previous run's scratch project in memory, which turned build_board and build_schematic+ # into save and reload prompts mid-run (2026-10-16). Closing it is the user's call: their documents.+ if not a.only:+ inst = ab(T, TH, "altium_instances", {})+ if (inst.get("count") or len(inst.get("instances") or [])) > 0:+ sys.exit(f"REFUSED: Altium is already running on {T}. The battery starts from a closed Altium; save anything "+ "that matters and close it (altium_close), then run again.") home = "C:/Users/john" r = ab(T, TH, "shell_execute", {"command": "cmd /c echo %USERPROFILE%"}) if r.get("stdout"):
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "altium-bridge",- "version": "0.2.48",+ "version": "0.2.49", "type": "app", "description": "Skills for your container so your AI knows how to drive the Altium Designer bridge (altium_* verbs). The bridge runtime itself is the release zip; Adom Bridge loads that.", "tags": [
page.json+1−1@@ -4,7 +4,7 @@ "type": "app", "title": "Altium - the Altium Bridge", "brief": "Adom Bridge for Altium Designer: a resident DelphiScript agent gives the AI eval, the 1,100-command process launcher, all 8 EDA surfaces with screenshots, compile/ERC, DRC, OutJob outputs, native SchLib/PcbLib authoring with STEP 3D bodies, and dialog handling by PID.",- "version": "0.2.48",+ "version": "0.2.49", "hero": { "type": "image", "path": "screenshots/hero.png"
src/BRIDGE_VERSION+1−1@@ -1 +1 @@-0.2.48+0.2.49
src/bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "altium", "displayName": "Altium Designer",- "version": "0.2.48",+ "version": "0.2.49", "description": "Drive Altium Designer from Adom: a resident DelphiScript agent (eval + the 1,100-command process launcher), the X2 -R command line, UIA over the VCL shell, dialogs by PID, screenshots of every Altium window, and Altium 365.", "homepage": "https://wiki.adom.inc/adom/altium-bridge", "docs": "https://wiki.adom.inc/adom/altium-bridge",