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.
main
Colby Knox
0.2.50 source (not yet released, needs signing): build_schematic and export_manufacturing find Altium's sheet and OutJo…
ee90f38
5d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
//! Shared verb plumbing: the licence gate, seat hints, openable-document validation, install
//! detection, demo constants and small arg helpers. Ported from `server.py`'s top half.
use crate::{agent, dialogs, instance, shot};
use serde_json::{json, Value};
use std::time::Duration;
// ---- arg helpers (every verb speaks loosely-typed JSON) ----------------------
pub fn s(args: &Value, k: &str) -> Option<String> {
args.get(k).map(|v| match v {
Value::String(x) => x.clone(),
Value::Bool(b) => if *b { "1".into() } else { "0".into() },
other => other.to_string(),
})
}
pub fn s_or(args: &Value, k: &str, default: &str) -> String {
s(args, k).unwrap_or_else(|| default.to_string())
}
pub fn f_or(args: &Value, k: &str, default: f64) -> f64 {
args.get(k)
.and_then(|v| v.as_f64().or_else(|| v.as_str().and_then(|x| x.parse().ok())))
.unwrap_or(default)
}
pub fn timeout_of(args: &Value, default: u64) -> Duration {
Duration::from_secs_f64(f_or(args, "timeout", default as f64).max(1.0))
}
pub fn now() -> f64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0)
}
pub fn elapsed_round(t0: f64) -> f64 {
((now() - t0) * 10.0).round() / 10.0
}
// ---- licence-seat hints (John, 2026-09-01: "ai hints are way more valuable ... the AI is bad
// at reading skills") ----------------------------------------------------------
pub const SEAT_RULE: &str = "One On-Demand seat serves EVERY Altium app on this machine — Altium Designer and Altium Designer Develop share it. Only one can be licensed at a time.";
pub const SEAT_STRANDED_FIX: &str = "If a claim fails while the licence page shows Seats 0/1 (looks free, is not), the seat is STRANDED by an Altium that was KILLED instead of closed. Fix: altium_close on every other instance (polite WM_CLOSE releases the lease), then altium_license {action:'use_license'}. Waiting does not clear it, and Refresh is a link the UIA layer cannot invoke.";
pub const SEAT_SWAP_HOWTO: &str = "To move the seat between apps: altium_close the one holding it (answer any Unsaved Changes prompt deliberately — on a shared machine the document may belong to another AI thread), then LAUNCH the other app; it claims the free seat silently at startup. No clicking required.";
/// Attach the seat guidance to any reply where the licence is the thing about to bite.
pub fn seat_hints(mut out: Value, licence_state: Option<&str>, instance_count: usize) -> Value {
let unlicensed = matches!(
licence_state.map(|s| s.to_lowercase()).as_deref(),
Some("no license") | Some("no licence") | Some("viewer")
);
if unlicensed {
out["_licenceHint"] = json!(format!("{SEAT_RULE} {SEAT_STRANDED_FIX}"));
}
if instance_count > 1 {
out["_seatHint"] = json!(format!(
"{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
}
/// The gate every driving verb passes: running, licensed, and no modal freezing the engine.
/// None = clear to proceed; Some(refusal) = return it verbatim.
pub fn need_licensed() -> Option<Value> {
let lic = instance::licence_state();
if lic.get("running").and_then(|b| b.as_bool()) != Some(true) {
return Some(json!({"success": false, "errorCode": "altium_not_running", "_hint": "altium_launch first."}));
}
if lic.get("licensed").and_then(|b| b.as_bool()) == Some(false) {
let n = instance::instances().len();
let state = lic.get("state").and_then(|s| s.as_str()).map(str::to_string);
return Some(seat_hints(
json!({"success": false, "errorCode": "not_licensed", "licenceState": state,
"_hint": "Altium refuses scripts without a licence. altium_license {\"action\":\"use_license\"} claims a free seat."}),
state.as_deref(),
n,
));
}
let mut dl = dialogs::scan_scoped(true);
if !dl.is_empty() {
shot::attach_dialog_shots(&mut dl);
return Some(json!({"success": false, "errorCode": "blocked_by_dialog", "dialogs": dl,
"_hint": "A modal on the main window freezes scripting. Read dialogs[].shot (base64 PNG — the text is painted, not exposed), altium_dismiss_dialogs {hwnd, button}, retry. If it is a script error, altium_agent_recover."}));
}
None
}
// ---- openable-document validation --------------------------------------------
// Validated in the BRIDGE so an unsupported path is a clean error instead of Altium's
// "File type not recognised." modal on the user's screen (John: pre-empt, don't clean up).
pub const OPENABLE: &[&str] = &[".pcbdoc", ".schdoc", ".pcblib", ".schlib", ".outjob", ".pcb3dlib",
".prjpcb", ".prjscr", ".prjmbd", ".prjhar", ".libpkg", ".txt", ".pas", ".rep"];
/// ab gives a verb 60 s. A cold file open can exceed that; blowing the budget looks like a
/// failure while Altium is still working — wait a little under it and hand back a poll instead.
pub const OPEN_BUDGET_SEC: f64 = 45.0;
pub fn validate_openable(path: &str) -> Option<Value> {
if path.is_empty() {
return Some(json!({"success": false, "error": "path is required"}));
}
let ext = path.rsplit('.').next().map(|e| format!(".{}", e.to_lowercase())).unwrap_or_default();
if !path.contains('.') || !OPENABLE.contains(&ext.as_str()) {
return Some(json!({"success": false, "errorCode": "unsupported_document",
"error": format!("Altium does not open '{}' — asking it to would raise a 'File type not recognised' modal on the user's screen", if ext.is_empty() { path } else { &ext }),
"supported": OPENABLE}));
}
None
}
// ---- installs ----------------------------------------------------------------
pub const HOST_APP: &str = "Altium Designer";
/// Every Altium install on the box, newest major first.
pub fn detect_installs() -> Vec<Value> {
let mut out: Vec<(i64, Value)> = Vec::new();
for pf in ["C:\\Program Files", "C:\\Program Files (x86)"] {
let altium = std::path::Path::new(pf).join("Altium");
let Ok(rd) = std::fs::read_dir(&altium) else { continue };
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if !name.to_uppercase().starts_with("AD") {
continue;
}
let exe = e.path().join("X2.EXE");
if !exe.exists() {
continue;
}
let major: i64 = name.chars().filter(|c| c.is_ascii_digit()).collect::<String>().parse().unwrap_or(0);
out.push((major, json!({
"installed": true,
"base_dir": e.path().to_string_lossy(),
"exe": exe.to_string_lossy(),
"system_dir": e.path().join("System").to_string_lossy(),
"major": major.to_string(),
})));
}
}
out.sort_by_key(|(m, _)| -m);
out.into_iter().map(|(_, v)| v).collect()
}
pub fn first_install() -> Option<Value> {
detect_installs().into_iter().next()
}
pub fn not_installed() -> Value {
json!({"success": false, "errorCode": "host_app_not_installed", "hostApp": HOST_APP,
"error": "Altium Designer is not installed on this machine.",
"_hint": "Altium is a licensed host app (no silent installer). Offer to run the official AltiumDesignerSetup.exe for the user — the altium-install skill has the full drive."})
}
// ---- demo / example constants ------------------------------------------------
pub const EXAMPLES_DIR: &str = r"C:\Users\Public\Documents\Altium\AD26\Examples";
pub fn demo_project() -> String { format!(r"{EXAMPLES_DIR}\Bluetooth Sentinel\Bluetooth_Sentinel.PrjPcb") }
pub fn demo_pcb() -> String { format!(r"{EXAMPLES_DIR}\Bluetooth Sentinel\Bluetooth_Sentinel.PcbDoc") }
pub fn demo_sch() -> String { format!(r"{EXAMPLES_DIR}\Bluetooth Sentinel\Bluetooth_Sentinel.SchDoc") }
pub fn demo_schlib() -> String { format!(r"{EXAMPLES_DIR}\SpiritLevel-SL1\Libraries\SpiritLevel_2E.SchLib") }
pub fn demo_pcblib() -> String { format!(r"{EXAMPLES_DIR}\SpiritLevel-SL1\Libraries\Spirit_Level_Project.PcbLib") }
pub fn demo_outjob_fab() -> String { format!(r"{EXAMPLES_DIR}\Bluetooth Sentinel\Fabrication.OutJob") }
pub fn demo_outjob_asm() -> String { format!(r"{EXAMPLES_DIR}\Bluetooth Sentinel\Assembly.OutJob") }
/// A real 3-pin SOT in the SpiritLevel PcbLib; the first entry ("NT") is a 2-pad test point.
pub const DEMO_FOOTPRINT: &str = "SO-G3";
// ---- agent-call convenience --------------------------------------------------
/// The dominant Python idiom `agent.call("open", {"path": p})` with best-effort semantics.
pub fn agent_open(path: &str, timeout_s: u64) -> Value {
let r = agent::call("open", &[("path", path.to_string())], None, Duration::from_secs(timeout_s));
open_verdict(path, r, std::path::Path::new(path).is_file(), workspace_unavailable_seen())
}
/// True when this bridge session has seen Altium's "Workspace Unavailable" box (the reaper
/// acknowledges it at launch). Measured 2026-09-20: it means the Altium 365 workspace had no free
/// AUTHOR SEAT (another running Altium held it), so this instance dropped to view-only mode and
/// silently refused to open any NEW document while already-open ones kept working.
fn workspace_unavailable_seen() -> bool {
dialogs::reaped_log(200).iter().any(|e| {
e.get("title").and_then(|t| t.as_str()).map(|t| t.to_lowercase().contains("workspace unavailable")).unwrap_or(false)
})
}
pub fn workspace_unavailable_seen_pub() -> bool {
workspace_unavailable_seen()
}
fn is_project_path(path: &str) -> bool {
let l = path.to_lowercase();
[".prjpcb", ".prjscr", ".dsnwrk", ".libpkg", ".prjfpg", ".prjemb", ".prjmbd"].iter().any(|e| l.ends_with(e))
}
/// Altium's `open` answers ok:true even when the document never appeared (`shown:false`), and
/// every verb that then needs "the current document" failed with a misleading "open the SchLib
/// first". A document that did not open is a FAILURE, with the reason when we know it.
/// (Projects legitimately have no document view, so shown:false is normal for them.)
pub fn open_verdict(path: &str, mut r: Value, file_exists: bool, workspace_unavailable: bool) -> Value {
if r.get("success").and_then(|b| b.as_bool()) != Some(true) || is_project_path(path) {
return r;
}
let shown = r.get("data").and_then(|d| d.get("shown")).and_then(|b| b.as_bool());
if shown != Some(false) {
return r;
}
let (code, error, hint) = if !file_exists {
("file_not_found", format!("{path} does not exist on this machine"),
"Stage the file on the desktop first (send_files lands in Downloads; move it where you want it), then retry.".to_string())
} else if workspace_unavailable {
("view_only_no_author_seat", format!("Altium did not open {path}: this Altium is in VIEW-ONLY mode because the Altium 365 workspace has no free Author seat, and view-only refuses every new document"),
"Altium raised 'Workspace Unavailable' at launch (altium_dialog_log); the warning triangle beside the workspace name says 'There are no Author seats available'. Documents that were ALREADY open still work, which is what makes this confusing. The usual cause is ANOTHER running Altium signed in to the same workspace (another machine, or Altium Designer beside Altium Develop) holding the seat. Fix: close that other Altium CLEANLY (altium_close on its machine; a kill strands the seat until the lease expires), then altium_close + altium_launch this one so it re-requests the seat. No sign-in is involved. Retrying the open will not help.".to_string())
} else {
("document_not_opened", format!("Altium accepted the open command but {path} never became a document (shown:false)"),
"altium_check_dialogs for a box Altium raised, altium_state to see what IS open, and check the file is a valid Altium document of the kind its extension claims.".to_string())
};
r["success"] = json!(false);
r["errorCode"] = json!(code);
r["error"] = json!(error);
r["_hint"] = json!(hint);
r
}
/// Open `path` for a verb that NEEDS it to be the current document: Some(failure) to return
/// as-is, None when it opened.
pub fn must_open(path: &str, timeout_s: u64) -> Option<Value> {
let r = agent_open(path, timeout_s);
if r.get("success").and_then(|b| b.as_bool()) == Some(true) { None } else { Some(r) }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_document_that_did_not_open_is_a_failure() {
let ok = |shown: bool| json!({"success": true, "data": {"ok": true, "shown": shown}});
assert_eq!(open_verdict(r"C:\x\a.SchLib", ok(true), true, false)["success"], json!(true));
assert_eq!(open_verdict(r"C:\x\p.PrjPcb", ok(false), true, true)["success"], json!(true), "projects have no document view");
assert_eq!(open_verdict(r"C:\x\a.SchLib", ok(false), true, false)["errorCode"], json!("document_not_opened"));
assert_eq!(open_verdict(r"C:\x\a.SchLib", ok(false), true, true)["errorCode"], json!("view_only_no_author_seat"));
assert_eq!(open_verdict(r"C:\x\a.SchLib", ok(false), false, true)["errorCode"], json!("file_not_found"));
let failed = json!({"success": false, "errorCode": "agent_timeout"});
assert_eq!(open_verdict(r"C:\x\a.SchLib", failed, true, false)["errorCode"], json!("agent_timeout"), "an earlier failure passes through");
}
#[test]
fn openable_refuses_unknown_extensions_cleanly() {
assert!(validate_openable(r"C:\x\board.PcbDoc").is_none());
assert!(validate_openable(r"C:\x\part.step").is_some());
let r = validate_openable(r"C:\x\noext").unwrap();
assert_eq!(r["errorCode"], json!("unsupported_document"));
assert!(validate_openable("").is_some());
}
#[test]
fn seat_hints_attach_on_the_right_conditions() {
let out = seat_hints(json!({}), Some("Viewer"), 2);
assert!(out.get("_licenceHint").is_some());
assert!(out.get("_seatHint").is_some());
let out = seat_hints(json!({}), Some("Trial"), 1);
assert!(out.get("_licenceHint").is_none());
assert!(out.get("_seatHint").is_none());
}
}
/// A file under Altium's Public Documents tree (`<root>\<install key>\<rel>`, e.g. Templates\ANSI C
/// Landscape.SchDot or OutputJobs\Fabrication.OutJob). Each install keeps its own folder (AD26,
/// ADDevelop, ...): the running instance's first, then any install that has the file. The battery
/// found AD26 hard-coded when it ran on Altium Develop (2026-10-16).
pub fn altium_public_file(rel: &str) -> Result<String, String> {
const ROOT: &str = r"C:\Users\Public\Documents\Altium";
let mut keys: Vec<String> = Vec::new();
if let Some(k) = crate::instance::selected().and_then(|i| i.get("key").and_then(|k| k.as_str()).map(str::to_string)) {
keys.push(k);
}
if let Ok(rd) = std::fs::read_dir(ROOT) {
let mut others: Vec<String> = rd.flatten().filter(|e| e.path().is_dir()).map(|e| e.file_name().to_string_lossy().to_string()).collect();
others.sort();
others.reverse(); // the newest AD<nn> first
keys.extend(others);
}
for k in &keys {
let p = format!(r"{ROOT}\{k}\{rel}");
if std::path::Path::new(&p).is_file() { return Ok(p); }
}
Err(format!(r"{rel} is not under {ROOT}\<install> (looked in: {})", keys.join(", ")))
}