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
v0.1.21: pre-empt File-type-not-recognised at the cause; bounded opens
8 files changed
+147−14
CHANGELOG.md+5@@ -1,5 +1,10 @@ # Changelog +## 0.1.21 — 2026-09-01+- **"File type not recognised." pre-empted at the cause.** The agent opened every non-project file with `ObjectKind='Document'`, which Altium rejects with a modal on the user's screen even for a valid .PcbDoc. It now maps the extension to the kind Altium actually uses (PCB, SCH, PCBLIB, SCHLIB, OUTPUTJOB, PCB3DLIB, TEXT, Project) and opens through `Client.OpenDocument` — the sibling of `ShowDocument`, which this unit already relied on — falling back to the process lane only if that raises.+- **The bridge refuses unsupported types before Altium can complain.** An extension outside the openable set returns `unsupported_document` with the supported list, instead of handing Altium something it will answer with a dialog.+- **A slow open is no longer reported as a failure.** ab gives a verb 60 s; a cold document open can exceed it. `open_document` and `activate_document` now wait just under the budget and return `status: "in_progress"` with a poll hint, rather than timing out while Altium is still working.+ ## 0.1.20 — 2026-09-01 - **`altium_read_schematic` and `altium_cross_check` in the shipped runtime** (they existed only in the Rust bridge). `cross_check` compares the current sheet against the current board — what is drawn but not placed, and placed but not drawn — which neither read answers alone. Two caveats travel WITH the answer because both make it wrong to act on: the schematic side is ONE sheet (a hierarchical parent holds sheet symbols, not parts), and Altium's SchServer and PCBServer each hold their own "current document", so the two can belong to different projects — detected by comparing folders and reported as `sameProject: false` with a loud warning. - **Manifest drift fixed, in both directions.** `bridge.json` declared three verbs with no handler (`close_document`, `export_pdf`, `screenshot_all`) — ab routed them and the bridge answered "Unknown command" — and omitted three that work (`ensure_licensed`, `license_status`, `setup_trial`), leaving them unreachable through ab. The phantoms are gone (they never worked; implementing them is separate work) and the three real ones are now routable.
adom-bridge-altium-manifest.json+4−4@@ -1,9 +1,9 @@ { "name": "altium",- "version": "0.1.20",- "url": "https://wiki.adom.inc/download/adom/altium-bridge/0.1.20/adom-bridge-altium-v0.1.20.zip",- "sha256": "594b1cd5a867d3f4c853ce341a12854b4e193c560c52d5683ecb3d9679941fd4",- "size": 118220,+ "version": "0.1.21",+ "url": "https://wiki.adom.inc/download/adom/altium-bridge/0.1.21/adom-bridge-altium-v0.1.21.zip",+ "sha256": "6c50ef8e8566c8eb9bea3f5cffdbdaa1cd8f3990fb60aa6634dd2cfa93d6f4a5",+ "size": 120300, "verbPrefixes": [ "altium_" ],
adom-bridge-altium-v0.1.21.zipadded⋯ 1 unchanged line ⋯
bridge.json+2−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "altium", "displayName": "Altium Designer",- "version": "0.1.20",+ "version": "0.1.21", "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",@@ -22,6 +22,7 @@ ], "verbs": [ "altium_a365_configure",+ "altium_a365_files_upload", "altium_a365_library_overview", "altium_a365_library_schema", "altium_a365_logout",
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "altium-bridge",- "version": "0.1.20",+ "version": "0.1.21", "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": [
src/BRIDGE_VERSION+1−1@@ -1 +1 @@-0.1.20+0.1.21
src/agent/AdomAgent.pas.tmpl+65−3@@ -341,10 +341,48 @@ Begin K := Length(Path); While (K > 0) And (Path[K] <> '.') Do K := K - 1; If K > 0 Then Ext := UpperCase(Copy(Path, K + 1, Length(Path)));+ { Altium wants the DOCUMENT KIND, not a generic word. Passing ObjectKind='Document' makes it+ answer "File type not recognised." with a modal on the user's screen (measured twice on+ 2026-09-01) even for a perfectly valid .PcbDoc. Map the extension to the kind Altium uses,+ and open through Client.OpenDocument — the sibling of ShowDocument, which this unit already+ relies on — falling back to the OpenObject process only if that raises. }+ Kind := ''; If (Ext = 'PRJPCB') Or (Ext = 'PRJSCR') Or (Ext = 'PRJMBD') Or (Ext = 'PRJHAR') Or (Ext = 'LIBPKG') Then Kind := 'Project'- Else- Kind := 'Document';+ Else If Ext = 'PCBDOC' Then Kind := 'PCB'+ Else If Ext = 'SCHDOC' Then Kind := 'SCH'+ Else If Ext = 'PCBLIB' Then Kind := 'PCBLIB'+ Else If Ext = 'SCHLIB' Then Kind := 'SCHLIB'+ Else If Ext = 'OUTJOB' Then Kind := 'OUTPUTJOB'+ Else If Ext = 'PCB3DLIB' Then Kind := 'PCB3DLIB'+ Else If (Ext = 'TXT') Or (Ext = 'PAS') Or (Ext = 'REP') Then Kind := 'TEXT';+ If Kind = '' Then+ Begin+ Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'unsupported document type: .' + Ext) + ',' ++ JStr('supported', 'PcbDoc, SchDoc, PcbLib, SchLib, OutJob, PrjPcb, PrjScr, txt/pas/rep') + '}';+ Exit;+ End;+ If Kind <> 'Project' Then+ Begin+ Doc := Nil;+ Try+ Doc := Client.OpenDocument(Kind, Path);+ Except+ Doc := Nil;+ End;+ If Doc <> Nil Then+ Begin+ Try+ Client.ShowDocumentDontFocus(Doc);+ Except+ Client.ShowDocument(Doc);+ End;+ Result := '{' + JRaw('ok', 'true') + ',' + JStr('path', Path) + ',' + JStr('objectKind', Kind) + ',' ++ JStr('kind', Doc.Kind) + ',' + JRaw('shown', 'true') + ',' + JStr('lane', 'OpenDocument') + '}';+ Exit;+ End;+ End;+ { fallback: the process lane, with the mapped kind rather than a generic one } ResetParameters; AddStringParameter('ObjectKind', Kind); AddStringParameter('FileName', Path);@@ -3655,6 +3693,29 @@ Begin Result := '{' + JRaw('ok','true') + ',' + JRaw('primitives', IntToStr(N)) + ',' + JRaw('occupancy', '[' + S + ']') + '}'; End; +{ The schematic-side companion to read_board: ONE round trip for the current sheet. Composed from+ the existing reader (same rule — no new Altium API surface, so it cannot introduce an+ engine-halting identifier). A hierarchical PARENT sheet holds sheet symbols, not parts, so this+ reports what the CURRENT sheet contains; the caller activates a leaf sheet first. }+Function CmdReadSchematic(Lines : TStringList) : String;+Var+ Doc : ISch_Document;+ Sects : String;+ S : String;+Begin+ Doc := SchServer.GetCurrentSchDocument;+ If Doc = Nil Then+ Begin+ Result := '{' + JRaw('ok','false') + ',' + JStr('error','no current schematic document (open a .SchDoc first)') + '}';+ Exit;+ End;+ Sects := Param(Lines, 'sections');+ If Sects = '' Then Sects := 'components';+ S := '';+ If Pos('components', Sects) > 0 Then S := S + ',' + JRaw('components', CmdSchematicData(Lines));+ Result := '{' + JRaw('ok','true') + ',' + JStr('document', Doc.DocumentName) + ',' + JStr('sections', Sects) + S + '}';+End;+ { ONE round trip to READ the whole board. Composed from the EXISTING readers on purpose: no new Altium API surface, so it cannot introduce an undeclared identifier (which halts the engine behind a modal). The bridge shapes these into typed views; recorded dumps are the unit-test fixtures. }@@ -3691,6 +3752,7 @@ Begin Else If Cmd = 'io_bench' Then Result := CmdIoBench(Lines) Else If Cmd = 'read_board' Then Result := CmdReadBoard(Lines) Else If Cmd = 'layer_occupancy' Then Result := CmdLayerOccupancy(Lines)+ Else If Cmd = 'read_schematic' Then Result := CmdReadSchematic(Lines) Else If Cmd = 'lib_list' Then Result := CmdLibList(Lines) Else If Cmd = 'lib_select' Then Result := CmdLibSelect(Lines) Else If Cmd = 'compile' Then Result := CmdCompile(Lines)@@ -3741,7 +3803,7 @@ Begin gRunning := False; Result := '{' + JRaw('ok', 'true') + ',' + JStr('note', 'agent loop stopping') + '}'; End- Else Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'unknown command: ' + Cmd) + ',' + JStr('known', 'ping,state,run_process,open,eval,lib_list,lib_select,compile,violations,outjob,create_footprint,create_symbol,install_library,compile_intlib,extract_intlib,set_params,add_body,stop,list_designators,component_data,component_pins,list_nets,layers,net_connections,schematic_data,copper_layers,read_rules,set_layer_visibility,footprint_primitives,io_bench,read_board,layer_occupancy') + '}';+ Else Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'unknown command: ' + Cmd) + ',' + JStr('known', 'ping,state,run_process,open,eval,lib_list,lib_select,compile,violations,outjob,create_footprint,create_symbol,install_library,compile_intlib,extract_intlib,set_params,add_body,stop,list_designators,component_data,component_pins,list_nets,layers,net_connections,schematic_data,copper_layers,read_rules,set_layer_visibility,footprint_primitives,io_bench,read_board,layer_occupancy,read_schematic') + '}'; End; { ------------------------------------------------------------------ loop --- }
src/server.py+69−4@@ -332,6 +332,30 @@ def h_state(args): "_hint": "shots[] are base64 PNGs of every Altium window (render inline). logical.data.documents lists open docs (agent required)."} ++# Document kinds Altium can open, and what it calls them. 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, 2026-09-01: "you better pre-empt these from ever showing up").+OPENABLE = {".pcbdoc", ".schdoc", ".pcblib", ".schlib", ".outjob", ".pcb3dlib",+ ".prjpcb", ".prjscr", ".prjmbd", ".prjhar", ".libpkg", ".txt", ".pas", ".rep"}++# ab gives a verb 60s. A cold file open can exceed that, and blowing the budget looks like a+# failure while Altium is still working — so wait a little under it and hand back a poll instead.+_OPEN_BUDGET_SEC = 45.0+++def _validate_openable(path):+ if not path:+ return {"success": False, "error": "path is required"}+ ext = ("." + path.rsplit(".", 1)[-1]).lower() if "." in path else ""+ if ext not in OPENABLE:+ return {"success": False, "errorCode": "unsupported_document",+ "error": f"Altium does not open '{ext or path}' — asking it to would raise a "+ f"'File type not recognised' modal on the user's screen",+ "supported": sorted(OPENABLE)}+ return None++ def h_open_document(args): g = _need_licensed() if g:@@ -339,8 +363,20 @@ def h_open_document(args): path = args.get("filePath") or args.get("path") if not path: return {"success": False, "error": "filePath is required (absolute Windows path)"}- r = agent.call("open", {"path": path}, timeout=float(args.get("timeout", 60)))+ bad = _validate_openable(path)+ if bad:+ return bad+ wait = min(float(args.get("timeout", 60)), _OPEN_BUDGET_SEC)+ r = agent.call("open", {"path": path}, timeout=wait) r["windowEtiquette"] = "background"+ if r.get("errorCode") == "agent_timeout":+ # Altium is still opening it — that is not a failure, and ab's 60s budget must not turn it+ # into one. Report progress and let the caller poll.+ return {"success": True, "status": "in_progress", "path": path,+ "waitedSec": wait, "roundtripMs": r.get("roundtripMs"),+ "_hint": "Altium is still opening the document. Poll altium_status (or "+ "altium_window_info) until it is current; the agent answers again once the "+ "open completes."} return r @@ -939,7 +975,7 @@ def h_a365_status(args): out = {"success": True, "configured": bool(cfg.get("workspace")), "workspace": cfg.get("workspace"), "hasToken": bool(_a365_access_token(cfg)), "hasRefreshToken": bool(cfg.get("refreshToken")), "clientConfigured": bool(cfg.get("clientId")),- "endpoint": f"https://{cfg.get('workspace')}/api/graphql/" if cfg.get("workspace") else None}+ "endpoint": f"https://{cfg.get('workspace')}/svc/napi/gateway/graphql/" if cfg.get("workspace") else None} if not _a365_access_token(cfg): out["_hint"] = ("No access token. Preferred path: register an Altium public OAuth client, then " "altium_a365_oauth_begin {clientId, workspace}; open authorizeUrl only after the verb returns. "@@ -1096,8 +1132,27 @@ def h_a365_logout(args): "_hint": "OAuth tokens were removed from the bridge cache."} +def h_a365_files_upload(args):+ cfg = _a365_load()+ token = _a365_access_token(cfg)+ file_path = args.get("filePath")+ if not cfg.get("workspace") or not token:+ return {"success": False, "errorCode": "a365_not_configured",+ "_hint": "Complete altium_a365_oauth_begin first."}+ if not file_path:+ return {"success": False, "errorCode": "a365_file_required", "error": "filePath is required"}+ try:+ file_id = a365.upload_file(cfg["workspace"], token, file_path,+ timeout=float(args.get("timeout", 120)),+ max_bytes=int(args.get("maxBytes", 100 * 1024 * 1024)))+ except a365.A365Error as e:+ return {"success": False, "errorCode": e.code, "error": str(e)}+ return {"success": True, "fileId": file_id, "expiresAfterHoursInactive": 24,+ "_hint": "Temporary opaque file ID. Pass it to the matching library mutation; do not parse or persist it."}++ def h_a365_library_overview(args):- return h_a365_query({"query": "query GetLibraryOverview { desLibrary { name description updatedAt components { totalCount } footprints { totalCount } symbols { totalCount } } }"})+ return h_a365_query({"query": "query GetLibraryOverview { desLibrary { name description updatedAt components { nodes { id name } } footprints { totalCount } symbols { totalCount } } }"}) def h_a365_library_schema(args):@@ -1239,9 +1294,17 @@ def h_activate_document(args): if not path: return {"success": False, "error": "path is required (the document's full path)", "_hint": "altium_window_info lists what is open; board verbs need a .PcbDoc current."}- r = agent.call("open", {"path": path}, timeout=float(args.get("timeout", 60)))+ bad = _validate_openable(path)+ if bad:+ return bad+ wait = min(float(args.get("timeout", 60)), _OPEN_BUDGET_SEC)+ r = agent.call("open", {"path": path}, timeout=wait) r["lane"] = "agent" r["windowEtiquette"] = "background"+ if r.get("errorCode") == "agent_timeout":+ return {"success": True, "status": "in_progress", "path": path, "lane": "agent",+ "windowEtiquette": "background", "waitedSec": wait,+ "_hint": "Still opening — poll altium_status until the document is current."} r.setdefault("_hint", "The document is now current. An already-open document is activated, not reloaded.") return r @@ -2004,6 +2067,7 @@ _VERB_CATALOG = { "a365_oauth_cancel": "Cancel the current ActionWait OAuth flow.", "a365_refresh": "Refresh the OAuth access token; securely handles refresh-token rotation.", "a365_logout": "Revoke the OAuth refresh grant and remove locally cached tokens.",+ "a365_files_upload": "Upload a component-library export to the Files Service {filePath}; returns a temporary opaque fileId for mutations.", "a365_status": "A365 workspace, OAuth configuration and live-token probe.", "a365_query": "Run a GraphQL query against https://<workspace>/api/graphql/ {query, variables}.", "a365_library_overview": "Read-only proof query for the workspace library.",@@ -2041,6 +2105,7 @@ COMMAND_HANDLERS = { "a365_oauth_begin": h_a365_oauth_begin, "a365_oauth_status": h_a365_oauth_status, "a365_oauth_cancel": h_a365_oauth_cancel, "a365_refresh": h_a365_refresh, "a365_logout": h_a365_logout, "a365_status": h_a365_status, "a365_query": h_a365_query,+ "a365_files_upload": h_a365_files_upload, "a365_library_overview": h_a365_library_overview, "a365_library_schema": h_a365_library_schema, "signin_status": h_signin_status, "connect_workspace": h_connect_workspace, "license": h_license, "signin": h_signin_status,