app
adom-desktop-altium-bridge
Public Unreviewedby barrett-land
Adom Desktop bridge for Altium Designer — build and edit file-based symbol/footprint/Integrated libraries with LCSC/JLCPCB part numbers attached, and generate batch outputs (STEP, PDF, Gerber, BOM, CPL) from an OutJob, by driving Altium via DelphiScript.
← Commit history
stamp page.json to 0.0.40; deploy.sh now stamps it
24 files changed
+3426−2
adom-desktop-altium-bridge/BRIDGE_VERSION+1adom-desktop-altium-bridge/README.md+74adom-desktop-altium-bridge/SKILL.md+132adom-desktop-altium-bridge/altium-bridge-manifest.json+3adom-desktop-altium-bridge/altium-bridge-v0.0.35.zipadom-desktop-altium-bridge/altium-bridge-v0.0.40.zipadom-desktop-altium-bridge/bridge-src/BRIDGE_VERSION+1adom-desktop-altium-bridge/bridge-src/addin/AdomAltium/AdomAltium.PrjScr+14adom-desktop-altium-bridge/bridge-src/addin/AdomAltium/AdomAltium.pas+1758adom-desktop-altium-bridge/bridge-src/addin_client.py+174adom-desktop-altium-bridge/bridge-src/altium_detect.py+150adom-desktop-altium-bridge/bridge-src/bridge.json+78adom-desktop-altium-bridge/bridge-src/check_wiring.py+101adom-desktop-altium-bridge/bridge-src/deploy.sh+189adom-desktop-altium-bridge/bridge-src/server.py+439adom-desktop-altium-bridge/bridge-src/test_addin_client.py+117adom-desktop-altium-bridge/bridge.json+73adom-desktop-altium-bridge/docs/hero.pngadom-desktop-altium-bridge/install.sh+18adom-desktop-altium-bridge/package.json+47adom-desktop-altium-bridge/page.json+51adom-desktop-altium-bridge/uninstall.sh+4bridge-src/deploy.sh+1−1page.json+1−1adom-desktop-altium-bridge/BRIDGE_VERSIONadded+1@@ -0,0 +1 @@+0.0.35
adom-desktop-altium-bridge/README.mdadded+74@@ -0,0 +1,74 @@+# adom-desktop-altium-bridge++Drive **Altium Designer** on a Windows desktop from a container — build and edit file-based+libraries, and generate batch outputs from an OutJob.++Altium ships no headless mode and no CLI. This bridge forwards work into the running GUI as+DelphiScript via `X2.EXE`, exposed as ordinary [Adom Desktop](https://wiki.adom.inc/adom-desktop)+verbs.++## Install++```bash+adom-wiki pkg install barrett-land/adom-desktop-altium-bridge # the skill, in the container+adom-desktop bridge_install '{"manifestUrl":"https://wiki.adom.inc/download/barrett-land/adom-desktop-altium-bridge/0.0.40/altium-bridge-manifest.json"}'+adom-desktop --ai-thread "<your thread>" altium_ping '{}'+```++## What it can do++| Area | Verbs |+|---|---|+| Host-side (no Altium round-trip) | `altium_ping` `altium_detect` `altium_script_state` |+| Inspect | `altium_addin_ping` `altium_list_docs` `altium_list_symbols` `altium_read_symbol` `altium_read_params` |+| Author | `altium_build_symbol` `altium_build_footprint` `altium_add_body` `altium_create_resistor` `altium_create_and_attach` `altium_install_library` |+| Clone / copy | `altium_clone_symbol` `altium_copy_sch_component` `altium_copy_footprint` `altium_import_part` |+| Edit | `altium_add_component` `altium_set_visibility` `altium_rename_component` `altium_rename_footprint` `altium_remove_component` |+| Package | `altium_compile_intlib` `altium_extract_intlib` `altium_cleanup_libs` `altium_close_doc` |+| Batch output | `altium_export_step` `altium_run_outjob` |++## What it can't++The library verbs act on `.SchLib` / `.PcbLib` / `.IntLib` only — **no design documents**. There is+no placement, routing, netlist editing, DRC or ERC. To read a `.PcbDoc`, `.PcbLib` or `.SchLib`+without Altium at all, use the native Rust crates (`altium-pcbdoc`, `altium-pcblib`,+`altium-schlib`) — no Windows, no GUI, far faster.++Calls are serialized; overlapping ones make Altium raise a modal that blocks everything until+dismissed.++## Architecture++```+container: adom-desktop altium_<verb>+ ↓ Adom Desktop relay+Windows: server.py on :8801 host-side verbs answer here+ ↓ C:\Users\Public\AdomAltium\{request.txt,response.json}+Altium: X2.EXE -RScriptingSystem:RunScript(AdomAltium>Run)+```++One `X2.EXE` forward per command. DelphiScript cannot host a resident callback server, so this is+the ceiling — the way to go fast is fewer, fatter commands (`altium_import_part` imports a whole+part in a single forward).++## Develop++Source is in `bridge-src/`. Three files each declare what exists — `bridge.json` (`verbs[]`),+`server.py` (`COMMAND_HANDLERS`) and `AdomAltium.pas` (`Dispatch`) — and they have drifted apart+before, shipping verbs the script could not run. `check_wiring.py` proves they agree and runs as a+build gate.++```bash+./bridge-src/deploy.sh 0.0.38 # wiring check + tests + build+./bridge-src/deploy.sh 0.0.38 --publish # + push the page repo+./bridge-src/deploy.sh 0.0.38 --publish --install # + reinstall onto the desktop+```++Publishing goes through `adom-wiki repo push` (the git layer), never `pkg publish`, which would+overwrite page files from the tarball.++To iterate on the DelphiScript, drop an edited `AdomAltium.pas` into `C:\Users\Public\AdomAltium\`+and call verbs normally — a newer script there wins and is left alone. `altium_script_state` shows+which copy is live. Fold it back into `bridge-src/addin/AdomAltium/` before publishing.++See [SKILL.md](SKILL.md) for the full verb reference and the OutJob caveats.
adom-desktop-altium-bridge/SKILL.mdadded+132@@ -0,0 +1,132 @@+---+name: adom-desktop-altium-bridge+description: Adom Desktop bridge for Altium Designer. Drives Altium on the user's Windows machine from a container — build, clone and edit file-based symbol/footprint/Integrated libraries with LCSC/JLCPCB part numbers attached, and generate batch outputs (STEP, PDF, Gerber, BOM, CPL) from an OutJob. Install when the user wants Claude to add parts to their Altium library or export from an Altium board. Pairs with the adom-library-importer skill.+---++# adom-desktop-altium-bridge++A dynamic [Adom Desktop](https://wiki.adom.inc/adom-desktop) bridge that lets a container drive+**Altium Designer** on the user's Windows machine. Altium has no headless or CLI mode, so the bridge+forwards work into the running GUI as DelphiScript via `X2.EXE`.++## Install++```bash+adom-desktop bridge_install '{"manifestUrl":"https://wiki.adom.inc/download/barrett-land/adom-desktop-altium-bridge/0.0.40/altium-bridge-manifest.json"}'+adom-desktop --ai-thread "<your thread>" altium_ping '{}' # -> {"altiumInstalled": true, "version": "..."}+```++Every verb needs `--ai-thread`; without it Adom Desktop refuses the call with+`caller_identity_required`.++## How it works++```+container: adom-desktop altium_<verb>+ ↓ Adom Desktop relay+Windows: server.py on :8801 host-side verbs answer here+ ↓ C:\Users\Public\AdomAltium\{request.txt,response.json}+Altium: X2.EXE -RScriptingSystem:RunScript(AdomAltium>Run)+```++One `X2.EXE` forward per command, serialized by a module lock. **The way to go fast is fewer, fatter+commands** — see `altium_import_part`, which does a whole part import in a single forward.++## Verbs++**Host-side** (no Altium round-trip, fast): `altium_ping` · `altium_detect` · `altium_script_state`++**Inspect:** `altium_addin_ping` · `altium_list_docs` · `altium_list_symbols` · `altium_read_symbol`+· `altium_read_params`++**Author:** `altium_build_symbol` · `altium_build_footprint` (pads + optional STEP body) ·+`altium_add_body` (extruded 3D) · `altium_create_resistor` · `altium_create_and_attach` ·+`altium_install_library`++**Clone / copy:** `altium_clone_symbol` · `altium_copy_sch_component` · `altium_copy_footprint` ·+`altium_import_part` (batched, one round-trip)++**Edit:** `altium_add_component` (LCSC + identity params) · `altium_set_visibility` ·+`altium_rename_component` · `altium_rename_footprint` · `altium_remove_component`++**Package:** `altium_compile_intlib` · `altium_extract_intlib` · `altium_cleanup_libs` ·+`altium_close_doc`++**Batch output:** `altium_export_step` · `altium_run_outjob`++## What this bridge does NOT do++Be straight with the user about the boundary rather than attempting a workaround:++- **It does not touch designs.** Every library verb operates on `.SchLib` / `.PcbLib` / `.IntLib`.+ There is no placement, routing, netlist, or schematic-sheet editing. To *read* a `.PcbDoc` or+ `.SchLib` without Altium at all, use the native Rust crates (`altium-pcbdoc`, `altium-pcblib`,+ `altium-schlib`) instead — no bridge, no Windows, much faster.+- **No DRC or ERC.** Altium's rule engine is not scriptable through this path.+- **Not concurrent.** One command at a time. Overlapping calls make Altium raise a modal+ "another instance is busy" dialog that then blocks *every* subsequent command until dismissed.+- **Windows only**, and only while Altium is actually running.++## Batch output (STEP / PDF / Gerber / BOM / CPL)++`altium_run_outjob` is the real export path — Altium exposes no direct board-to-STEP process, so+outputs go through an OutJob run by `WorkspaceManager:GenerateReport`.++```bash+adom-desktop --ai-thread "<thread>" altium_run_outjob '{+ "outjob": "C:\\path\\to\\Outputs.OutJob",+ "project": "C:\\path\\to\\Board.PrjPcb",+ "expect": "C:\\path\\to\\Project Outputs for Board\\PCB Print\\Composite Drawing.PDF"+}'+```++- Always pass `project`. The script opens **and compiles** it first, which is what populates an+ OutJob's `[Project Physical Documents]` target. Without it the OutJob has nothing to act on.+- Pass `expect` to get an authoritative `out_exists` in the reply instead of inferring success.+- An OutJob targeting `[Project Physical Documents]` is **generic and reusable** across boards —+ add it to the project, compile, run.+- ⚠️ **Known limitation:** `GenerateReport` is observed to no-op in configurations where the GUI's+ *Generate content* button on the same OutJob works. `ran: true` with `out_exists: false` means+ the trigger did not fire — it does **not** mean the OutJob is misconfigured. Ask the user to click+ *Generate content* once rather than rebuilding a working OutJob.++`altium_export_step` opens a `.PcbDoc` and reports that the board resolved. With `run: true` it also+fires the process named by `proc` (default `Pcb:ExportSTEP`, overridable so candidates can be probed+without redeploying the script). If that process opens a modal, the call times out with the export+possibly still pending — **check the output path on disk before concluding it failed.**++## Developing the bridge++Source lives in the page repo under `bridge-src/`. Three files each carry their own list of what+exists, and they have drifted before:++| File | Carries |+|---|---|+| `bridge.json` | `verbs[]` — what Adom Desktop advertises |+| `server.py` | `COMMAND_HANDLERS` — what the HTTP server routes |+| `addin/AdomAltium/AdomAltium.pas` | `Dispatch` — what Altium implements |++`check_wiring.py` proves all three agree and `deploy.sh` runs it as a build gate, so a verb that is+routed but not implemented fails the build instead of shipping.++```bash+./bridge-src/deploy.sh 0.0.38 # verify + test + build the zip+./bridge-src/deploy.sh 0.0.38 --publish # + push the page repo+./bridge-src/deploy.sh 0.0.38 --publish --install # + reinstall onto the desktop+```++**Iterating on the DelphiScript:** drop an edited `AdomAltium.pas` straight into+`C:\Users\Public\AdomAltium\` and call verbs normally — a script there that is newer than the+packaged one wins and is left alone. `altium_script_state` tells you which copy is live and flags+the override; set `ADOM_ALTIUM_NO_SCRIPT_SYNC=1` to stop syncing entirely. Fold the result back into+`bridge-src/addin/AdomAltium/` before publishing, or the next install silently reverts it.++## Use it++Pair with **[adom-library-importer](https://wiki.adom.inc/adom/adom-library-importer)**, which+orchestrates these verbs to add catalog parts (with the JLC part number) to the user's libraries.+It supersedes the deprecated `adom-altium-library-importer`.++> The bridge is single-threaded: a slow DelphiScript save can report "Request timed out" while the+> work persists. Verify with `altium_read_params` or `altium_list_symbols` rather than trusting the+> timeout — and never fire a retry blind, since that is what raises the blocking modal.
adom-desktop-altium-bridge/altium-bridge-manifest.jsonadded+3@@ -0,0 +1,3 @@+{ "manifest_version": 1, "name": "altium", "version": "0.0.40",+ "url": "https://wiki.adom.inc/download/barrett-land/adom-desktop-altium-bridge/0.0.40/altium-bridge-v0.0.40.zip",+ "sha256": "49a6ac3d9ca77f5be2c43a1c1f257a427fd99a8296f93391b8faf105f6042ec1", "size": 26085, "released_at": "2026-08-05T20:55:37Z" }
adom-desktop-altium-bridge/altium-bridge-v0.0.35.zipadded⋯ 1 unchanged line ⋯
adom-desktop-altium-bridge/altium-bridge-v0.0.40.zipadded⋯ 1 unchanged line ⋯
adom-desktop-altium-bridge/bridge-src/BRIDGE_VERSIONadded+1@@ -0,0 +1 @@+0.0.40
adom-desktop-altium-bridge/bridge-src/addin/AdomAltium/AdomAltium.PrjScradded+14@@ -0,0 +1,14 @@+[Design]+Version=1.0+HierarchyMode=0+DefaultConfiguration=Default - All Constraints+UserID=0xFFFFFFFF++[Preferences]+PrefsVaultGUID=+PrefsRevisionGUID=++[Document1]+DocumentPath=AdomAltium.pas+DItemRevisionGUID=+DocumentUniqueId=
adom-desktop-altium-bridge/bridge-src/addin/AdomAltium/AdomAltium.pasadded+1758@@ -0,0 +1,1758 @@+// AdomAltium.pas — Adom Altium bridge in-host dispatcher (original Adom code).+//+// Invoked by the bridge via:+// X2.EXE -RScriptingSystem:RunScript(ProjectName=...AdomAltium.PrjScr|ProcName=AdomAltium>Run)+// Reads C:\Users\Public\AdomAltium\request.txt (simple key=value lines)+// Writes C:\Users\Public\AdomAltium\response.json (hand-built JSON)+//+// Request keys (per command):+// command = ping | add_lcsc_param | read_params+// add_lcsc_param: schlib, component, lcsc, mpn, manufacturer, value, description, package, datasheet+// read_params: schlib, component++const+ EX_DIR = 'C:\Users\Public\AdomAltium\';++var+ Req : TStringList;+ LastServerDoc : IServerDocument; // set by OpenSchLibDoc, used to save+ GStage : String; // last stage reached, for error pinpointing+ GReqId : String; // id of the request being served (echoed in response)++{ ---- JSON string escaping (minimal, original) ---- }+function JStr(const S : String) : String;+var i : Integer; c : Char; o : String;+begin+ o := '';+ for i := 1 to Length(S) do+ begin+ c := S[i];+ if (c = '\') or (c = '"') then o := o + '\' + c+ else if c = #9 then o := o + '\t'+ else if c = #10 then o := o + '\n'+ else if c = #13 then o := o + '\r'+ else o := o + c;+ end;+ Result := '"' + o + '"';+end;++function BoolStr(b : Boolean) : String;+begin+ if b then Result := 'true' else Result := 'false';+end;++procedure WriteResp(const JsonBody : String);+var R : TStringList;+begin+ if not DirectoryExists(EX_DIR) then ForceDirectories(EX_DIR);+ R := TStringList.Create;+ try+ R.Text := JsonBody;+ R.SaveToFile(EX_DIR + 'response.json');+ finally+ R.Free;+ end;+end;++procedure RespOk(const ExtraPairs : String);+begin+ WriteResp('{ "ok": true, "id": ' + JStr(GReqId) + ExtraPairs + ' }');+end;++procedure RespErr(const Msg : String);+begin+ WriteResp('{ "ok": false, "id": ' + JStr(GReqId) + ', "error": ' + JStr(Msg) + ' }');+end;++{ ---- open a SchLib; returns ISch_Lib and stashes the server doc for saving ---- }+function OpenSchLibDoc(const Path : String) : ISch_Lib;+begin+ Result := nil;+ LastServerDoc := nil;+ if SchServer = nil then Exit;+ LastServerDoc := Client.OpenDocument('SCHLIB', Path);+ if LastServerDoc <> nil then+ Client.ShowDocument(LastServerDoc);+ Result := SchServer.GetSchDocumentByPath(Path);+end;++procedure SaveCurrentDoc;+begin+ if LastServerDoc <> nil then+ LastServerDoc.DoFileSave('SCHLIB');+end;++{ ---- find a component in a SchLib by LibReference (empty = first) ---- }+function FindComponent(SchLib : ISch_Lib; const NameRef : String) : ISch_Component;+var It : ISch_Iterator; Cmp : ISch_Component;+begin+ Result := nil;+ It := SchLib.SchLibIterator_Create;+ It.AddFilter_ObjectSet(MkSet(eSchComponent));+ try+ Cmp := It.FirstSchObject;+ while Cmp <> nil do+ begin+ if (NameRef = '') or (Cmp.LibReference = NameRef) then+ begin+ Result := Cmp;+ Break;+ end;+ Cmp := It.NextSchObject;+ end;+ finally+ SchLib.SchIterator_Destroy(It);+ end;+end;++{ ---- set or replace a parameter on a component ---- }+procedure SetParam(Cmp : ISch_Component; const PName, PVal : String);+var It : ISch_Iterator; P, Existing : ISch_Parameter;+begin+ Existing := nil;+ It := Cmp.SchIterator_Create;+ It.AddFilter_ObjectSet(MkSet(eParameter));+ try+ P := It.FirstSchObject;+ while P <> nil do+ begin+ if P.Name = PName then begin Existing := P; Break; end;+ P := It.NextSchObject;+ end;+ finally+ Cmp.SchIterator_Destroy(It);+ end;++ if Existing <> nil then+ Existing.Text := PVal+ else+ begin+ P := SchServer.SchObjectFactory(eParameter, eCreate_GlobalCopy);+ P.Name := PName;+ P.Text := PVal;+ P.IsHidden := False;+ Cmp.AddSchObject(P);+ end;+end;++procedure MaybeSet(Cmp : ISch_Component; const PName, Key : String);+var V : String;+begin+ V := Req.Values[Key];+ if V <> '' then SetParam(Cmp, PName, V);+end;++{ ---- command: add LCSC + identity params to a component ---- }+procedure CmdAddLcscParam;+var SchLibPath, CompRef : String; SchLib : ISch_Lib; Cmp : ISch_Component;+begin+ SchLibPath := Req.Values['schlib'];+ CompRef := Req.Values['component'];+ if SchLibPath = '' then begin RespErr('missing schlib'); Exit; end;++ SchLib := OpenSchLibDoc(SchLibPath);+ if SchLib = nil then begin RespErr('could not open schlib: ' + SchLibPath); Exit; end;++ Cmp := FindComponent(SchLib, CompRef);+ if Cmp = nil then begin RespErr('component not found: ' + CompRef); Exit; end;++ MaybeSet(Cmp, 'LCSC Part #', 'lcsc');+ MaybeSet(Cmp, 'Manufacturer Part Number', 'mpn');+ MaybeSet(Cmp, 'Manufacturer', 'manufacturer');+ MaybeSet(Cmp, 'Value', 'value');+ MaybeSet(Cmp, 'Description', 'description');+ MaybeSet(Cmp, 'Package', 'package');+ MaybeSet(Cmp, 'Datasheet', 'datasheet');++ SchLib.GraphicallyInvalidate;+ SaveCurrentDoc;++ RespOk(', "component": ' + JStr(Cmp.LibReference) ++ ', "lcsc": ' + JStr(Req.Values['lcsc']));+end;++{ ---- command: read back a component's parameters (verification) ---- }+procedure CmdReadParams;+var SchLibPath, CompRef, Acc : String; SchLib : ISch_Lib; Cmp : ISch_Component;+ It : ISch_Iterator; P : ISch_Parameter; First : Boolean;+begin+ SchLibPath := Req.Values['schlib'];+ CompRef := Req.Values['component'];+ SchLib := OpenSchLibDoc(SchLibPath);+ if SchLib = nil then begin RespErr('could not open schlib: ' + SchLibPath); Exit; end;+ Cmp := FindComponent(SchLib, CompRef);+ if Cmp = nil then begin RespErr('component not found: ' + CompRef); Exit; end;++ Acc := ''; First := True;+ It := Cmp.SchIterator_Create;+ It.AddFilter_ObjectSet(MkSet(eParameter));+ try+ P := It.FirstSchObject;+ while P <> nil do+ begin+ if not First then Acc := Acc + ', ';+ Acc := Acc + JStr(P.Name) + ': ' + JStr(P.Text);+ First := False;+ P := It.NextSchObject;+ end;+ finally+ Cmp.SchIterator_Destroy(It);+ end;+ RespOk(', "component": ' + JStr(Cmp.LibReference) + ', "params": { ' + Acc + ' }');+end;++{ ---- read back all params of a component into a JSON object body ---- }+function ParamsJSON(Cmp : ISch_Component) : String;+var It : ISch_Iterator; P : ISch_Parameter; Acc : String; First : Boolean;+begin+ Acc := ''; First := True;+ It := Cmp.SchIterator_Create;+ It.AddFilter_ObjectSet(MkSet(eParameter));+ try+ P := It.FirstSchObject;+ while P <> nil do+ begin+ if not First then Acc := Acc + ', ';+ Acc := Acc + JStr(P.Name) + ': ' + JStr(P.Text);+ First := False;+ P := It.NextSchObject;+ end;+ finally+ Cmp.SchIterator_Destroy(It);+ end;+ Result := '{ ' + Acc + ' }';+end;++{ ---- command: create a new SchLib + component, attach LCSC/identity params,+ read them back, and best-effort save. Proves the keystone end-to-end+ with no external symbol source. Stage-tracked for error pinpointing. ---- }+procedure CmdCreateAndAttach;+var+ CompName, Path : String;+ SD : IServerDocument;+ SchLib : ISch_Lib;+ Cmp : ISch_Component;+ SavedOK : Boolean;+begin+ CompName := Req.Values['component']; if CompName = '' then CompName := 'TESTPART';+ Path := Req.Values['schlib'];++ GStage := 'SchServer';+ if SchServer = nil then begin RespErr('SchServer is nil'); Exit; end;++ GStage := 'CreateNewDocumentFromDocumentKind(SchLib)';+ SD := CreateNewDocumentFromDocumentKind('SchLib');+ GStage := 'ShowDocument';+ if SD <> nil then Client.ShowDocument(SD);+ LastServerDoc := SD;++ GStage := 'GetCurrentSchDocument';+ SchLib := SchServer.GetCurrentSchDocument;+ if SchLib = nil then begin RespErr('GetCurrentSchDocument nil at ' + GStage); Exit; end;++ GStage := 'SchObjectFactory(eSchComponent)';+ Cmp := SchServer.SchObjectFactory(eSchComponent, eCreate_Default);+ if Cmp = nil then begin RespErr('component factory nil'); Exit; end;++ GStage := 'set component props';+ Cmp.CurrentPartID := 1;+ Cmp.DisplayModeCount := 1;+ Cmp.LibReference := CompName;+ Cmp.Designator.Text := 'U?';+ Cmp.Comment.Text := CompName;++ GStage := 'AddSchComponent';+ SchLib.AddSchComponent(Cmp);++ GStage := 'register primitive';+ SchServer.RobotManager.SendMessage(SchLib.I_ObjectAddress, c_BroadCast,+ SCHM_PrimitiveRegistration, Cmp.I_ObjectAddress);++ GStage := 'set params';+ MaybeSet(Cmp, 'LCSC Part #', 'lcsc');+ MaybeSet(Cmp, 'Manufacturer Part Number', 'mpn');+ MaybeSet(Cmp, 'Manufacturer', 'manufacturer');+ MaybeSet(Cmp, 'Value', 'value');+ MaybeSet(Cmp, 'Description', 'description');++ GStage := 'invalidate';+ SchLib.GraphicallyInvalidate;++ GStage := 'save';+ SavedOK := False;+ if (Path <> '') and (LastServerDoc <> nil) then+ begin+ LastServerDoc.SetFileName(Path);+ SavedOK := LastServerDoc.DoFileSave('');+ end;++ GStage := 'done';+ if SavedOK then+ RespOk(', "component": ' + JStr(CompName) ++ ', "saved": true, "saved_to": ' + JStr(Path) ++ ', "params": ' + ParamsJSON(Cmp))+ else+ RespOk(', "component": ' + JStr(CompName) ++ ', "saved": false, "params": ' + ParamsJSON(Cmp));+end;++{ ---- command: create a proper 2-pin resistor symbol with body, params, and a+ footprint implementation link, then save the SchLib ---- }+procedure CmdCreateResistor;+var+ SD : IServerDocument;+ Lib : ISch_Lib;+ Cmp : ISch_Component;+ R : ISch_Rectangle;+ P1, P2 : ISch_Pin;+ Impl : ISch_Implementation;+ SchPath, FpName, FpLib, SavedStr : String;+ SavedOK : Boolean;+begin+ SchPath := Req.Values['schlib'];+ FpName := Req.Values['footprint']; if FpName = '' then FpName := 'R0603';+ FpLib := Req.Values['footprint_lib']; if FpLib = '' then FpLib := 'Resistors.PcbLib';++ GStage := 'create SchLib doc';+ SD := CreateNewDocumentFromDocumentKind('SchLib');+ if SD <> nil then Client.ShowDocument(SD);+ LastServerDoc := SD;+ Lib := SchServer.GetCurrentSchDocument;+ if Lib = nil then begin RespErr('no SchLib'); Exit; end;++ GStage := 'create component';+ Cmp := SchServer.SchObjectFactory(eSchComponent, eCreate_Default);+ if Cmp = nil then begin RespErr('component factory nil'); Exit; end;+ Cmp.CurrentPartID := 1;+ Cmp.DisplayMode := 0;+ Cmp.LibReference := Req.Values['component'];+ if Cmp.LibReference = '' then Cmp.LibReference := 'R';++ GStage := 'rectangle body';+ R := SchServer.SchObjectFactory(eRectangle, eCreate_Default);+ R.LineWidth := eSmall;+ R.Location := Point(MilsToCoord(0), MilsToCoord(-50));+ R.Corner := Point(MilsToCoord(300), MilsToCoord(50));+ R.IsSolid := False;+ R.OwnerPartId := Cmp.CurrentPartID;+ R.OwnerPartDisplayMode := Cmp.DisplayMode;++ GStage := 'pin 1';+ P1 := SchServer.SchObjectFactory(ePin, eCreate_Default);+ P1.Location := Point(MilsToCoord(-100), MilsToCoord(0));+ P1.Orientation := eRotate0;+ P1.Designator := '1';+ P1.Name := '1';+ P1.OwnerPartId := Cmp.CurrentPartID;+ P1.OwnerPartDisplayMode := Cmp.DisplayMode;++ GStage := 'pin 2';+ P2 := SchServer.SchObjectFactory(ePin, eCreate_Default);+ P2.Location := Point(MilsToCoord(400), MilsToCoord(0));+ P2.Orientation := eRotate180;+ P2.Designator := '2';+ P2.Name := '2';+ P2.OwnerPartId := Cmp.CurrentPartID;+ P2.OwnerPartDisplayMode := Cmp.DisplayMode;++ GStage := 'add primitives';+ Cmp.AddSchObject(P1);+ Cmp.AddSchObject(P2);+ Cmp.AddSchObject(R);++ GStage := 'designator + params';+ Cmp.Designator.Text := 'R?';+ if Req.Values['value'] <> '' then Cmp.Comment.Text := Req.Values['value'];+ MaybeSet(Cmp, 'LCSC Part #', 'lcsc');+ MaybeSet(Cmp, 'Manufacturer Part Number', 'mpn');+ MaybeSet(Cmp, 'Manufacturer', 'manufacturer');+ MaybeSet(Cmp, 'Value', 'value');+ MaybeSet(Cmp, 'Description', 'description');++ GStage := 'footprint implementation';+ Impl := Cmp.AddSchImplementation;+ Impl.ClearAllDatafileLinks;+ Impl.ModelName := FpName;+ Impl.ModelType := 'PCBLIB';+ Impl.AddDataFileLink(FpName, '', 'PCBLIB');+ Impl.IsCurrent := True;++ GStage := 'AddSchComponent';+ Lib.AddSchComponent(Cmp);+ SchServer.RobotManager.SendMessage(Lib.I_ObjectAddress, c_BroadCast,+ SCHM_PrimitiveRegistration, Cmp.I_ObjectAddress);++ GStage := 'save';+ SavedOK := False;+ if (SchPath <> '') and (LastServerDoc <> nil) then+ begin+ LastServerDoc.SetFileName(SchPath);+ SavedOK := LastServerDoc.DoFileSave('');+ end;+ if SavedOK then SavedStr := 'true' else SavedStr := 'false';++ GStage := 'done';+ RespOk(', "component": ' + JStr(Cmp.LibReference) ++ ', "saved": ' + SavedStr ++ ', "saved_to": ' + JStr(SchPath) ++ ', "footprint_link": ' + JStr(FpLib + ':' + FpName) ++ ', "params": ' + ParamsJSON(Cmp));+end;++{ ---- command: create a 2-pad SMD 0603 footprint in a PcbLib, then save ---- }+procedure CmdCreateFootprint;+var+ SD : IServerDocument;+ PcbLib : IPCB_Library;+ FpComp : IPCB_LibComponent;+ Pad : IPCB_Pad;+ PcbPath, FpName, SavedStr : String;+ SavedOK : Boolean;+begin+ PcbPath := Req.Values['pcblib'];+ FpName := Req.Values['footprint']; if FpName = '' then FpName := 'R0603';++ GStage := 'create PcbLib doc';+ SD := CreateNewDocumentFromDocumentKind('PcbLib');+ if SD <> nil then Client.ShowDocument(SD);+ LastServerDoc := SD;++ GStage := 'GetCurrentPCBLibrary';+ if PCBServer = nil then begin RespErr('PCBServer nil'); Exit; end;+ PcbLib := PCBServer.GetCurrentPCBLibrary;+ if PcbLib = nil then begin RespErr('no current PcbLib'); Exit; end;++ GStage := 'CreatePCBLibComp';+ FpComp := PCBServer.CreatePCBLibComp;+ FpComp.Name := FpName;+ PcbLib.RegisterComponent(FpComp);++ GStage := 'PreProcess';+ PCBServer.PreProcess;++ GStage := 'pads';+ MakePad('1', -30); { centers ~1.5 mm apart }+ MakePad('2', 30);++ GStage := 'PostProcess';+ PCBServer.PostProcess;+ PcbLib.Board.ViewManager_FullUpdate;++ GStage := 'save';+ SavedOK := False;+ if (PcbPath <> '') and (LastServerDoc <> nil) then+ begin+ LastServerDoc.SetFileName(PcbPath);+ SavedOK := LastServerDoc.DoFileSave('');+ end;+ if SavedOK then SavedStr := 'true' else SavedStr := 'false';++ GStage := 'done';+ RespOk(', "footprint": ' + JStr(FpName) + ', "pads": 2, "saved": ' + SavedStr ++ ', "saved_to": ' + JStr(PcbPath));+end;++{ ---- command: build/append a symbol in a SchLib with N pins + a footprint link.+ pins as pin0..padN (CommaText): designator,name,x,y,orient(0/90/180/270) mils. ---- }+procedure CmdBuildSymbol;+var+ SD : IServerDocument;+ Lib : ISch_Lib;+ Cmp : ISch_Component;+ R : ISch_Rectangle;+ El : ISch_Ellipse;+ Pin : ISch_Pin;+ Impl : ISch_Implementation;+ flds : TStringList;+ SchPath, CompName, FpName, DesigPfx, v, SavedStr : String;+ npins, i, ori : Integer;+ SavedOK : Boolean;+begin+ SchPath := Req.Values['schlib'];+ CompName := Req.Values['component']; if CompName = '' then CompName := 'COMP';+ FpName := Req.Values['footprint'];+ DesigPfx := Req.Values['designator']; if DesigPfx = '' then DesigPfx := 'U';+ npins := StrToIntDef(Req.Values['pincount'], 1);++ GStage := 'open-or-create SchLib';+ if (SchPath <> '') and FileExists(SchPath) then+ SD := Client.OpenDocument('SCHLIB', SchPath)+ else+ SD := CreateNewDocumentFromDocumentKind('SchLib');+ if SD <> nil then Client.ShowDocument(SD);+ LastServerDoc := SD;+ Lib := SchServer.GetCurrentSchDocument;+ if Lib = nil then begin RespErr('no SchLib'); Exit; end;++ GStage := 'create component';+ Cmp := SchServer.SchObjectFactory(eSchComponent, eCreate_Default);+ Cmp.CurrentPartID := 1;+ Cmp.DisplayMode := 0;+ Cmp.LibReference := CompName;++ GStage := 'body';+ if Req.Values['body'] = 'circle' then+ begin+ El := SchServer.SchObjectFactory(eEllipse, eCreate_Default);+ El.Location := Point(MilsToCoord(50), MilsToCoord(0));+ El.Radius := MilsToCoord(50);+ El.SecondaryRadius := MilsToCoord(50);+ El.LineWidth := eSmall;+ El.OwnerPartId := Cmp.CurrentPartID;+ El.OwnerPartDisplayMode := Cmp.DisplayMode;+ Cmp.AddSchObject(El);+ end+ else+ begin+ R := SchServer.SchObjectFactory(eRectangle, eCreate_Default);+ R.LineWidth := eSmall;+ R.Location := Point(MilsToCoord(0), MilsToCoord(-50));+ R.Corner := Point(MilsToCoord(200), MilsToCoord(50));+ R.OwnerPartId := Cmp.CurrentPartID;+ R.OwnerPartDisplayMode := Cmp.DisplayMode;+ Cmp.AddSchObject(R);+ end;++ GStage := 'pins';+ flds := TStringList.Create;+ try+ for i := 0 to npins - 1 do+ begin+ v := Req.Values['pin' + IntToStr(i)];+ if v = '' then Continue;+ flds.CommaText := v; { designator,name,x,y,orient }+ Pin := SchServer.SchObjectFactory(ePin, eCreate_Default);+ Pin.Location := Point(MilsToCoord(StrToIntDef(flds[2], -100)),+ MilsToCoord(StrToIntDef(flds[3], 0)));+ ori := StrToIntDef(flds[4], 0);+ if ori = 90 then Pin.Orientation := eRotate90+ else if ori = 180 then Pin.Orientation := eRotate180+ else if ori = 270 then Pin.Orientation := eRotate270+ else Pin.Orientation := eRotate0;+ Pin.Designator := flds[0];+ Pin.Name := flds[1];+ Pin.OwnerPartId := Cmp.CurrentPartID;+ Pin.OwnerPartDisplayMode := Cmp.DisplayMode;+ Cmp.AddSchObject(Pin);+ end;+ finally+ flds.Free;+ end;++ GStage := 'designator + params';+ Cmp.Designator.Text := DesigPfx + '?';+ if Req.Values['value'] <> '' then Cmp.Comment.Text := Req.Values['value'];+ MaybeSet(Cmp, 'LCSC Part #', 'lcsc');+ MaybeSet(Cmp, 'Manufacturer Part Number', 'mpn');+ MaybeSet(Cmp, 'Manufacturer', 'manufacturer');+ MaybeSet(Cmp, 'Value', 'value');+ MaybeSet(Cmp, 'Description', 'description');++ GStage := 'footprint link';+ if FpName <> '' then+ begin+ Impl := Cmp.AddSchImplementation;+ Impl.ClearAllDatafileLinks;+ Impl.ModelName := FpName;+ Impl.ModelType := 'PCBLIB';+ Impl.AddDataFileLink(FpName, '', 'PCBLIB');+ Impl.IsCurrent := True;+ end;++ GStage := 'AddSchComponent';+ Lib.AddSchComponent(Cmp);+ SchServer.RobotManager.SendMessage(Lib.I_ObjectAddress, c_BroadCast,+ SCHM_PrimitiveRegistration, Cmp.I_ObjectAddress);++ GStage := 'save';+ SavedOK := False;+ if (SchPath <> '') and (LastServerDoc <> nil) then+ begin+ LastServerDoc.SetFileName(SchPath);+ SavedOK := LastServerDoc.DoFileSave('');+ end;+ if SavedOK then SavedStr := 'true' else SavedStr := 'false';++ RespOk(', "component": ' + JStr(CompName) + ', "footprint": ' + JStr(FpName) ++ ', "pins": ' + IntToStr(npins) + ', "saved": ' + SavedStr);+end;++{ ---- command: build an Altium footprint from a pad spec + optional STEP 3D body.+ pads passed as pad0..padN (CommaText): smd,x,y,w,h,drill,name (all mils). ---- }+procedure CmdBuildFootprint;+var+ SD : IServerDocument;+ PcbLib : IPCB_Library;+ Fp : IPCB_LibComponent;+ Pad : IPCB_Pad;+ Body : IPCB_ComponentBody;+ Mdl : IPCB_Model;+ PcbPath, FpName, StepPath, v, SavedStr, BodyNote : String;+ n, i : Integer;+ flds : TStringList;+ SavedOK : Boolean;+begin+ PcbPath := Req.Values['pcblib'];+ FpName := Req.Values['footprint']; if FpName = '' then FpName := 'FP';+ StepPath := Req.Values['step'];+ n := StrToIntDef(Req.Values['padcount'], 0);++ GStage := 'open-or-create PcbLib doc';+ if (PcbPath <> '') and FileExists(PcbPath) then+ SD := Client.OpenDocument('PCBLIB', PcbPath) { append to existing lib }+ else+ SD := CreateNewDocumentFromDocumentKind('PcbLib');+ if SD <> nil then Client.ShowDocument(SD);+ LastServerDoc := SD;++ GStage := 'GetCurrentPCBLibrary';+ if PCBServer = nil then begin RespErr('PCBServer nil'); Exit; end;+ PcbLib := PCBServer.GetCurrentPCBLibrary;+ if PcbLib = nil then begin RespErr('no current PcbLib'); Exit; end;++ GStage := 'CreatePCBLibComp';+ Fp := PCBServer.CreatePCBLibComp;+ Fp.Name := FpName;+ PcbLib.RegisterComponent(Fp);+ PCBServer.PreProcess;++ GStage := 'pads';+ flds := TStringList.Create;+ try+ for i := 0 to n - 1 do+ begin+ v := Req.Values['pad' + IntToStr(i)];+ if v = '' then Continue;+ flds.CommaText := v; { smd,x,y,w,h,drill,name,shape (mils) }+ Pad := PCBServer.PCBObjectFactory(ePadObject, eNoDimension, eCreate_Default);+ Pad.X := MilsToCoord(StrToIntDef(flds[1], 0));+ Pad.Y := MilsToCoord(StrToIntDef(flds[2], 0));+ Pad.TopXSize := MilsToCoord(StrToIntDef(flds[3], 50));+ Pad.TopYSize := MilsToCoord(StrToIntDef(flds[4], 50));+ if (flds.Count >= 8) and ((flds[7] = 'circle') or (flds[7] = 'oval')) then+ Pad.TopShape := eRounded+ else+ Pad.TopShape := eRectangular;+ if flds[0] = '1' then+ begin+ Pad.HoleSize := 0;+ Pad.Layer := eTopLayer; { SMD }+ end+ else+ begin+ Pad.HoleSize := MilsToCoord(StrToIntDef(flds[5], 0));+ Pad.Layer := eMultiLayer; { through-hole }+ end;+ Pad.Name := flds[6];+ Fp.AddPCBObject(Pad);+ PCBServer.SendMessageToRobots(Fp.I_ObjectAddress, c_Broadcast,+ PCBM_BoardRegisteration, Pad.I_ObjectAddress);+ end;+ finally+ flds.Free;+ end;++ GStage := 'step body';+ BodyNote := 'none';+ if StepPath <> '' then+ begin+ Body := PCBServer.PCBObjectFactory(eComponentBodyObject, eNoDimension, eCreate_Default);+ Mdl := Body.ModelFactory_FromFilename(StepPath, False);+ if Mdl <> nil then+ begin+ Body.SetState_FromModel;+ Body.Model := Mdl;+ Fp.AddPCBObject(Body);+ BodyNote := 'added';+ end+ else BodyNote := 'model load failed';+ end;++ GStage := 'PostProcess';+ PCBServer.PostProcess;++ GStage := 'save';+ SavedOK := False;+ if (PcbPath <> '') and (LastServerDoc <> nil) then+ begin+ LastServerDoc.SetFileName(PcbPath);+ SavedOK := LastServerDoc.DoFileSave('');+ end;+ if SavedOK then SavedStr := 'true' else SavedStr := 'false';++ RespOk(', "footprint": ' + JStr(FpName) + ', "pads": ' + IntToStr(n) ++ ', "step_body": ' + JStr(BodyNote) + ', "saved": ' + SavedStr ++ ', "saved_to": ' + JStr(PcbPath));+end;++{ ---- command: compile a Library Package (.LibPkg) into a .IntLib ---- }+procedure CmdCompileIntLib;+var+ LibPkgPath, OutDir : String;+ Proj : IProject;+begin+ LibPkgPath := Req.Values['libpkg'];+ OutDir := Req.Values['output'];+ if LibPkgPath = '' then begin RespErr('missing libpkg'); Exit; end;++ GStage := 'DM_OpenProject';+ Proj := GetWorkspace.DM_OpenProject(LibPkgPath, True);+ if Proj = nil then begin RespErr('could not open LibPkg: ' + LibPkgPath); Exit; end;++ GStage := 'DM_Compile';+ Proj.DM_Compile;++ GStage := 'CreateIntegratedLibrary';+ IntegratedLibraryManager.CreateIntegratedLibrary(Proj, OutDir, True);++ GStage := 'done';+ RespOk(', "libpkg": ' + JStr(LibPkgPath) + ', "output_dir": ' + JStr(OutDir));+end;++{ ---- command: extract the editable sources (SchLib + PcbLib) from a compiled+ .IntLib, then report the extracted document paths so the caller can edit+ them (e.g. add an LCSC param) before re-installing/compiling. ---- }+procedure CmdExtractIntLib;+var+ IntLibPath, P, SchAcc, PcbAcc : String;+ WS : IWorkspace;+ Proj : IProject;+ Doc : IDocument;+ i, j : Integer;+ FirstS, FirstP : Boolean;+begin+ IntLibPath := Req.Values['intlib'];+ if IntLibPath = '' then begin RespErr('missing intlib'); Exit; end;++ GStage := 'ExtractSources';+ IntegratedLibraryManager.ExtractSources(IntLibPath);++ GStage := 'scan projects';+ WS := GetWorkspace;+ SchAcc := ''; PcbAcc := '';+ FirstS := True; FirstP := True;+ if WS <> nil then+ for i := 0 to WS.DM_ProjectCount - 1 do+ begin+ Proj := WS.DM_Projects(i);+ if Proj = nil then Continue;+ for j := 0 to Proj.DM_LogicalDocumentCount - 1 do+ begin+ Doc := Proj.DM_LogicalDocuments(j);+ if Doc = nil then Continue;+ P := Doc.DM_FullPath;+ if Pos('.SchLib', P) > 0 then+ begin+ if not FirstS then SchAcc := SchAcc + ', ';+ SchAcc := SchAcc + JStr(P); FirstS := False;+ end+ else if Pos('.PcbLib', P) > 0 then+ begin+ if not FirstP then PcbAcc := PcbAcc + ', ';+ PcbAcc := PcbAcc + JStr(P); FirstP := False;+ end;+ end;+ end;++ GStage := 'done';+ RespOk(', "intlib": ' + JStr(IntLibPath) ++ ', "schlibs": [ ' + SchAcc + ' ]' ++ ', "pcblibs": [ ' + PcbAcc + ' ]');+end;++{ ---- helper: does S contain any of the comma-separated lowercase needles? ---- }+function ContainsAny(const S, CsvLower : String) : Boolean;+var parts : TStringList; i : Integer;+begin+ Result := False;+ parts := TStringList.Create;+ try+ parts.CommaText := CsvLower;+ for i := 0 to parts.Count - 1 do+ if (parts[i] <> '') and (Pos(parts[i], S) > 0) then+ begin Result := True; Break; end;+ finally+ parts.Free;+ end;+end;++{ ---- command: list open documents — disabled (Client.DocumentCount is not a+ valid DelphiScript identifier on AD26; calling it threw a script-error+ dialog). Returns an empty list instead of raising. ---- }+procedure CmdListDocs;+begin+ RespOk(', "docs": [], "note": "doc enumeration unsupported on this Altium build"');+end;++{ ---- command: rename a component in a SchLib (in place), optionally updating its+ Description, footprint link, and designator. Preserves the exact symbol+ graphics/pins/params — only identity/metadata changes. ---- }+procedure CmdRenameComponent;+var+ SchPath, OldName, NewName, FpName, Desig, SavedStr : String;+ Lib : ISch_Lib;+ Cmp : ISch_Component;+ It : ISch_Iterator;+ Impl : ISch_Implementation;+ Found : Boolean;+ SavedOK : Boolean;+begin+ SchPath := Req.Values['schlib'];+ OldName := Req.Values['component'];+ NewName := Req.Values['newname'];+ FpName := Req.Values['footprint'];+ Desig := Req.Values['designator'];+ if (SchPath = '') or (OldName = '') or (NewName = '') then+ begin RespErr('need schlib + component + newname'); Exit; end;++ GStage := 'open schlib';+ Lib := OpenSchLibDoc(SchPath);+ if Lib = nil then begin RespErr('could not open: ' + SchPath); Exit; end;++ GStage := 'find component';+ Cmp := FindComponent(Lib, OldName);+ if Cmp = nil then begin RespErr('component not found: ' + OldName); Exit; end;++ GStage := 'set identity';+ Cmp.LibReference := NewName;+ Cmp.DesignItemID := NewName;+ if Desig <> '' then Cmp.Designator.Text := Desig;++ GStage := 'description';+ MaybeSet(Cmp, 'Description', 'description');++ if FpName <> '' then+ begin+ GStage := 'update footprint link';+ Found := False;+ It := Cmp.SchIterator_Create;+ It.AddFilter_ObjectSet(MkSet(eImplementation));+ try+ Impl := It.FirstSchObject;+ while Impl <> nil do+ begin+ Impl.ClearAllDatafileLinks;+ Impl.ModelName := FpName;+ Impl.ModelType := 'PCBLIB';+ Impl.AddDataFileLink(FpName, '', 'PCBLIB');+ Impl.IsCurrent := True;+ Found := True;+ Impl := It.NextSchObject;+ end;+ finally+ Cmp.SchIterator_Destroy(It);+ end;+ if not Found then+ begin+ Impl := Cmp.AddSchImplementation;+ Impl.ClearAllDatafileLinks;+ Impl.ModelName := FpName;+ Impl.ModelType := 'PCBLIB';+ Impl.AddDataFileLink(FpName, '', 'PCBLIB');+ Impl.IsCurrent := True;+ end;+ end;++ GStage := 'save';+ Lib.GraphicallyInvalidate;+ SavedOK := False;+ if LastServerDoc <> nil then SavedOK := LastServerDoc.DoFileSave('');+ if SavedOK then SavedStr := 'true' else SavedStr := 'false';+ RespOk(', "renamed": ' + JStr(OldName) + ' -> ' + JStr(NewName) ++ ', "footprint": ' + JStr(FpName) + ', "saved": ' + SavedStr);+end;++{ ---- command: rename a footprint (IPCB_LibComponent) inside a PcbLib, in place ---- }+procedure CmdRenameFootprint;+var+ PcbPath, OldName, NewName, SavedStr : String;+ SD : IServerDocument;+ PcbLib : IPCB_Library;+ Fp : IPCB_LibComponent;+ i : Integer;+ Found : Boolean;+ SavedOK : Boolean;+begin+ PcbPath := Req.Values['pcblib'];+ OldName := Req.Values['footprint'];+ NewName := Req.Values['newname'];+ if (PcbPath = '') or (NewName = '') then+ begin RespErr('need pcblib + newname'); Exit; end;++ GStage := 'open pcblib';+ SD := Client.OpenDocument('PCBLIB', PcbPath);+ if SD <> nil then Client.ShowDocument(SD);+ LastServerDoc := SD;+ if PCBServer = nil then begin RespErr('PCBServer nil'); Exit; end;+ PcbLib := PCBServer.GetCurrentPCBLibrary;+ if PcbLib = nil then begin RespErr('no current PcbLib'); Exit; end;++ GStage := 'rename component';+ Found := False;+ for i := 0 to PcbLib.ComponentCount - 1 do+ begin+ Fp := PcbLib.GetComponent(i);+ if Fp = nil then Continue;+ if (OldName = '') or (Fp.Name = OldName) then+ begin+ Fp.Name := NewName;+ Found := True;+ if OldName <> '' then Break; { rename only the first if OldName given }+ end;+ end;+ if not Found then begin RespErr('footprint not found: ' + OldName); Exit; end;++ GStage := 'save';+ PcbLib.Board.ViewManager_FullUpdate;+ SavedOK := False;+ if LastServerDoc <> nil then SavedOK := LastServerDoc.DoFileSave('');+ if SavedOK then SavedStr := 'true' else SavedStr := 'false';+ RespOk(', "renamed_footprint": ' + JStr(OldName) + ' -> ' + JStr(NewName) ++ ', "saved": ' + SavedStr);+end;++{ ---- command: import a part in ONE round-trip. Given the already-extracted source+ SchLib+PcbLib, it: renames the footprint -> friendly name + saves-as into the+ target PcbLib; attaches LCSC/identity params + renames the symbol + links it+ to the footprint + saves-as into the target SchLib; installs both; and reads+ the params back. Collapses ~8 separate X2 launches into one. Every primitive+ used here is already proven by the per-step verbs. ---- }+procedure CmdImportPart;+var+ SrcSch, SrcPcb, TgtSch, TgtPcb, NewName, NewFp, Desig, OldComp : String;+ PcbSD, SchSD : IServerDocument;+ PcbLib : IPCB_Library;+ Fp : IPCB_LibComponent;+ SchLib : ISch_Lib;+ Cmp : ISch_Component;+ It : ISch_Iterator;+ Impl : ISch_Implementation;+ i : Integer;+ FoundFp, FoundImpl, Saved1, Saved2 : Boolean;+begin+ SrcSch := Req.Values['src_sch'];+ SrcPcb := Req.Values['src_pcb'];+ TgtSch := Req.Values['tgt_sch'];+ TgtPcb := Req.Values['tgt_pcb'];+ NewName := Req.Values['name'];+ NewFp := Req.Values['footprint'];+ Desig := Req.Values['designator']; if Desig = '' then Desig := 'U?';+ if (SrcSch = '') or (SrcPcb = '') or (TgtSch = '') or (TgtPcb = '')+ or (NewName = '') or (NewFp = '') then+ begin RespErr('need src_sch, src_pcb, tgt_sch, tgt_pcb, name, footprint'); Exit; end;++ { 1) footprint: rename first footprint -> NewFp, save-as into target PcbLib }+ GStage := 'open src pcb';+ PcbSD := Client.OpenDocument('PCBLIB', SrcPcb);+ if PcbSD <> nil then Client.ShowDocument(PcbSD);+ if PCBServer = nil then begin RespErr('PCBServer nil'); Exit; end;+ PcbLib := PCBServer.GetCurrentPCBLibrary;+ if PcbLib = nil then begin RespErr('no current PcbLib'); Exit; end;++ GStage := 'rename footprint';+ FoundFp := False;+ for i := 0 to PcbLib.ComponentCount - 1 do+ begin+ Fp := PcbLib.GetComponent(i);+ if Fp <> nil then begin Fp.Name := NewFp; FoundFp := True; Break; end;+ end;+ if not FoundFp then begin RespErr('no footprint in ' + SrcPcb); Exit; end;+ PcbLib.Board.ViewManager_FullUpdate;++ GStage := 'save pcb as target';+ PcbSD.SetFileName(TgtPcb);+ Saved1 := PcbSD.DoFileSave('');++ { 2) symbol: params + rename + footprint link, save-as into target SchLib }+ GStage := 'open src sch';+ SchSD := Client.OpenDocument('SCHLIB', SrcSch);+ if SchSD <> nil then Client.ShowDocument(SchSD);+ SchLib := SchServer.GetSchDocumentByPath(SrcSch);+ if SchLib = nil then begin RespErr('open src sch failed'); Exit; end;+ LastServerDoc := SchSD;++ GStage := 'find component';+ Cmp := FindComponent(SchLib, ''); { first component }+ if Cmp = nil then begin RespErr('no component in ' + SrcSch); Exit; end;+ OldComp := Cmp.LibReference;++ GStage := 'params';+ MaybeSet(Cmp, 'LCSC Part #', 'lcsc');+ MaybeSet(Cmp, 'Manufacturer Part Number', 'mpn');+ MaybeSet(Cmp, 'Manufacturer', 'manufacturer');+ MaybeSet(Cmp, 'Value', 'value');+ MaybeSet(Cmp, 'Description', 'description');+ MaybeSet(Cmp, 'Package', 'package');++ GStage := 'rename + link';+ Cmp.LibReference := NewName;+ Cmp.DesignItemID := NewName;+ Cmp.Designator.Text := Desig;+ FoundImpl := False;+ It := Cmp.SchIterator_Create;+ It.AddFilter_ObjectSet(MkSet(eImplementation));+ try+ Impl := It.FirstSchObject;+ while Impl <> nil do+ begin+ Impl.ClearAllDatafileLinks;+ Impl.ModelName := NewFp;+ Impl.ModelType := 'PCBLIB';+ Impl.AddDataFileLink(NewFp, '', 'PCBLIB');+ Impl.IsCurrent := True;+ FoundImpl := True;+ Impl := It.NextSchObject;+ end;+ finally+ Cmp.SchIterator_Destroy(It);+ end;+ if not FoundImpl then+ begin+ Impl := Cmp.AddSchImplementation;+ Impl.ClearAllDatafileLinks;+ Impl.ModelName := NewFp;+ Impl.ModelType := 'PCBLIB';+ Impl.AddDataFileLink(NewFp, '', 'PCBLIB');+ Impl.IsCurrent := True;+ end;+ SchLib.GraphicallyInvalidate;++ GStage := 'save sch as target';+ SchSD.SetFileName(TgtSch);+ Saved2 := SchSD.DoFileSave('');++ GStage := 'install';+ IntegratedLibraryManager.InstallLibrary(TgtPcb);+ IntegratedLibraryManager.InstallLibrary(TgtSch);++ GStage := 'done';+ RespOk(', "component": ' + JStr(NewName) + ', "footprint": ' + JStr(NewFp) ++ ', "from": ' + JStr(OldComp) ++ ', "saved_pcb": ' + BoolStr(Saved1) + ', "saved_sch": ' + BoolStr(Saved2) ++ ', "params": ' + ParamsJSON(Cmp));+end;++{ ---- command: remove a component (by LibReference) from a SchLib, then save ---- }+procedure CmdRemoveComponent;+var SchLibPath, CompName : String; Lib : ISch_Lib; Cmp, Target : ISch_Component;+ It : ISch_Iterator; SavedOK : Boolean;+begin+ SchLibPath := Req.Values['schlib'];+ CompName := Req.Values['component'];+ if (SchLibPath = '') or (CompName = '') then begin RespErr('need schlib + component'); Exit; end;++ GStage := 'open schlib';+ Lib := OpenSchLibDoc(SchLibPath);+ if Lib = nil then begin RespErr('could not open: ' + SchLibPath); Exit; end;++ GStage := 'find component';+ Target := nil;+ It := Lib.SchLibIterator_Create;+ It.AddFilter_ObjectSet(MkSet(eSchComponent));+ try+ Cmp := It.FirstSchObject;+ while Cmp <> nil do+ begin+ if Cmp.LibReference = CompName then begin Target := Cmp; Break; end;+ Cmp := It.NextSchObject;+ end;+ finally+ Lib.SchIterator_Destroy(It);+ end;+ if Target = nil then begin RespErr('component not found: ' + CompName); Exit; end;++ GStage := 'remove component';+ Lib.RemoveSchComponent(Target);++ GStage := 'save';+ SavedOK := False;+ if LastServerDoc <> nil then SavedOK := LastServerDoc.DoFileSave('');+ if SavedOK then RespOk(', "removed_component": ' + JStr(CompName) + ', "saved": true')+ else RespOk(', "removed_component": ' + JStr(CompName) + ', "saved": false');+end;++{ ---- command: close an open document by its file path (no save prompt if unmodified) ---- }+procedure CmdCloseDoc;+var Path, Kind : String; SD : IServerDocument;+begin+ Path := Req.Values['path'];+ if Path = '' then begin RespErr('missing path'); Exit; end;+ GStage := 'get handle';+ Kind := 'SCHLIB';+ if Pos('.pcblib', LowerCase(Path)) > 0 then Kind := 'PCBLIB';+ SD := Client.OpenDocument(Kind, Path);+ if SD = nil then begin RespOk(', "closed": false, "note": "no handle for path"'); Exit; end;+ GStage := 'CloseDocument';+ Client.CloseDocument(SD);+ RespOk(', "closed": true, "kind": ' + JStr(Kind) + ', "path": ' + JStr(Path));+end;++{ ---- command: list all components (LibReference) in a SchLib ---- }+procedure CmdListSymbols;+var SchLibPath, Acc : String; Lib : ISch_Lib; Cmp : ISch_Component; It : ISch_Iterator; First : Boolean;+begin+ SchLibPath := Req.Values['schlib'];+ Lib := OpenSchLibDoc(SchLibPath);+ if Lib = nil then begin RespErr('could not open: ' + SchLibPath); Exit; end;+ Acc := ''; First := True;+ It := Lib.SchLibIterator_Create;+ It.AddFilter_ObjectSet(MkSet(eSchComponent));+ try+ Cmp := It.FirstSchObject;+ while Cmp <> nil do+ begin+ if not First then Acc := Acc + ', ';+ Acc := Acc + JStr(Cmp.LibReference);+ First := False;+ Cmp := It.NextSchObject;+ end;+ finally+ Lib.SchIterator_Destroy(It);+ end;+ RespOk(', "components": [ ' + Acc + ' ]');+end;++{ ---- command: clone a template component's graphics exactly into a new component,+ set a fresh footprint link + designator. ---- }+procedure CmdCloneSymbol;+var+ SchPath, TemplateName, NewName, FpName, DesigPfx, SavedStr : String;+ Lib : ISch_Lib;+ Tmpl, NewCmp : ISch_Component;+ It : ISch_Iterator;+ Prim, NewPrim : ISch_GraphicalObject;+ Impl : ISch_Implementation;+ SavedOK : Boolean;+ o : Integer;+begin+ SchPath := Req.Values['schlib'];+ TemplateName := Req.Values['template'];+ NewName := Req.Values['component'];+ FpName := Req.Values['footprint'];+ DesigPfx := Req.Values['designator']; if DesigPfx = '' then DesigPfx := 'MC';++ GStage := 'open schlib';+ Lib := OpenSchLibDoc(SchPath);+ if Lib = nil then begin RespErr('open failed'); Exit; end;++ GStage := 'find template';+ Tmpl := FindComponent(Lib, TemplateName);+ if Tmpl = nil then begin RespErr('template not found: ' + TemplateName); Exit; end;++ GStage := 'create new component';+ NewCmp := SchServer.SchObjectFactory(eSchComponent, eCreate_Default);+ NewCmp.CurrentPartID := 1;+ NewCmp.DisplayMode := 0;+ NewCmp.LibReference := NewName;++ GStage := 'copy graphics by ObjectId';+ It := Tmpl.SchIterator_Create; { no type filter — pick graphics by ObjectId }+ try+ Prim := It.FirstSchObject;+ while Prim <> nil do+ begin+ o := Prim.ObjectId;+ { copy pins + ALL graphics; skip only metadata that's set explicitly:+ 25=designator, 27=parameter, 45=comment, 46=implementation }+ if (o <> 25) and (o <> 27) and (o <> 45) and (o <> 46) then+ begin+ NewPrim := Prim.Replicate;+ NewPrim.OwnerPartId := NewCmp.CurrentPartID;+ NewPrim.OwnerPartDisplayMode := NewCmp.DisplayMode;+ NewCmp.AddSchObject(NewPrim);+ end;+ Prim := It.NextSchObject;+ end;+ finally+ Tmpl.SchIterator_Destroy(It);+ end;++ GStage := 'designator';+ NewCmp.Designator.Text := DesigPfx + '?';++ GStage := 'footprint link';+ if FpName <> '' then+ begin+ Impl := NewCmp.AddSchImplementation;+ Impl.ClearAllDatafileLinks;+ Impl.ModelName := FpName;+ Impl.ModelType := 'PCBLIB';+ Impl.AddDataFileLink(FpName, '', 'PCBLIB');+ Impl.IsCurrent := True;+ end;++ GStage := 'AddSchComponent';+ Lib.AddSchComponent(NewCmp);+ SchServer.RobotManager.SendMessage(Lib.I_ObjectAddress, c_BroadCast,+ SCHM_PrimitiveRegistration, NewCmp.I_ObjectAddress);++ GStage := 'save';+ SavedOK := False;+ if LastServerDoc <> nil then SavedOK := LastServerDoc.DoFileSave('');+ if SavedOK then SavedStr := 'true' else SavedStr := 'false';++ RespOk(', "cloned": ' + JStr(TemplateName) + ' -> ' + JStr(NewName) ++ ', "footprint": ' + JStr(FpName) + ', "saved": ' + SavedStr);+end;++{ ---- command: dump a component's primitives (ObjectId + location) ---- }+procedure CmdReadSymbol;+var SchLibPath, CompName, Acc : String; Lib : ISch_Lib; Cmp : ISch_Component;+ It : ISch_Iterator; Prim : ISch_GraphicalObject; First : Boolean;+begin+ SchLibPath := Req.Values['schlib']; CompName := Req.Values['component'];+ Lib := OpenSchLibDoc(SchLibPath);+ if Lib = nil then begin RespErr('open failed'); Exit; end;+ Cmp := FindComponent(Lib, CompName);+ if Cmp = nil then begin RespErr('component not found: ' + CompName); Exit; end;+ Acc := ''; First := True;+ It := Cmp.SchIterator_Create;+ try+ Prim := It.FirstSchObject;+ while Prim <> nil do+ begin+ if not First then Acc := Acc + ', ';+ Acc := Acc + IntToStr(Prim.ObjectId);+ First := False;+ Prim := It.NextSchObject;+ end;+ finally+ Cmp.SchIterator_Destroy(It);+ end;+ RespOk(', "component": ' + JStr(CompName) + ', "primitives": [ ' + Acc + ' ]');+end;++{ ---- command: install a file-based library (.SchLib/.PcbLib/.IntLib) into Available Libraries ---- }+procedure CmdInstallLibrary;+var Path : String;+begin+ Path := Req.Values['path'];+ if Path = '' then begin RespErr('missing path'); Exit; end;+ GStage := 'InstallLibrary';+ IntegratedLibraryManager.InstallLibrary(Path);+ RespOk(', "installed": ' + JStr(Path));+end;++{ ---- command: uninstall available libraries whose path matches any needle ---- }+procedure CmdCleanupLibs;+var Match, Path, Removed : String; i, n : Integer;+begin+ Match := LowerCase(Req.Values['match']);+ if Match = '' then begin RespErr('missing match (comma-separated path substrings)'); Exit; end;+ Removed := '';+ GStage := 'InstalledLibraryCount';+ n := IntegratedLibraryManager.InstalledLibraryCount;+ GStage := 'iterate+uninstall';+ for i := n - 1 downto 0 do+ begin+ Path := IntegratedLibraryManager.InstalledLibraryPath(i);+ if ContainsAny(LowerCase(Path), Match) then+ begin+ IntegratedLibraryManager.UninstallLibrary(Path);+ if Removed <> '' then Removed := Removed + ', ';+ Removed := Removed + JStr(Path);+ end;+ end;+ RespOk(', "removed": [ ' + Removed + ' ]');+end;++procedure CmdPing;+var WS : IWorkspace; n : Integer;+begin+ n := -1;+ try+ WS := GetWorkspace;+ if WS <> nil then n := WS.DM_ProjectCount;+ except+ end;+ RespOk(', "addin": "AdomAltium", "open_project_count": ' + IntToStr(n));+end;++{ ---- command: attach a simple extruded 3D body (a box) to a footprint, sized in+ mils. Gives passives a 3D model without needing a STEP file. ---- }+procedure CmdAddBody;+var+ PcbPath, FpName, SavedStr : String;+ SD : IServerDocument;+ PcbLib : IPCB_Library;+ Fp : IPCB_LibComponent;+ Body : IPCB_ComponentBody;+ Contour : IPCB_Contour;+ bw, bh, hgt, soff, i : Integer;+ Found, SavedOK : Boolean;+begin+ PcbPath := Req.Values['pcblib'];+ FpName := Req.Values['footprint'];+ bw := StrToIntDef(Req.Values['bw_mil'], 39); { body length, mils }+ bh := StrToIntDef(Req.Values['bh_mil'], 20); { body width, mils }+ hgt := StrToIntDef(Req.Values['h_mil'], 16); { body height, mils }+ soff := StrToIntDef(Req.Values['so_mil'], 1); { standoff, mils }+ if (PcbPath = '') or (FpName = '') then begin RespErr('need pcblib + footprint'); Exit; end;++ GStage := 'open pcblib';+ SD := Client.OpenDocument('PCBLIB', PcbPath);+ if SD <> nil then Client.ShowDocument(SD);+ LastServerDoc := SD;+ if PCBServer = nil then begin RespErr('PCBServer nil'); Exit; end;+ PcbLib := PCBServer.GetCurrentPCBLibrary;+ if PcbLib = nil then begin RespErr('no current PcbLib'); Exit; end;++ GStage := 'find footprint';+ Found := False;+ for i := 0 to PcbLib.ComponentCount - 1 do+ begin+ Fp := PcbLib.GetComponent(i);+ if (Fp <> nil) and (Fp.Name = FpName) then begin Found := True; Break; end;+ end;+ if not Found then begin RespErr('footprint not found: ' + FpName); Exit; end;++ GStage := 'create body';+ PCBServer.PreProcess;+ Body := PCBServer.PCBObjectFactory(eComponentBodyObject, eNoDimension, eCreate_Default);+ Body.BodyProjection := eBoardSide_Top;+ Body.StandoffHeight := MilsToCoord(soff);+ Body.OverallHeight := MilsToCoord(soff + hgt);+ Body.BodyColor3D := $00202020;++ GStage := 'outline';+ Contour := PCBServer.PCBContourFactory;+ Contour.AddPoint(MilsToCoord(-(bw div 2)), MilsToCoord(-(bh div 2)));+ Contour.AddPoint(MilsToCoord( (bw div 2)), MilsToCoord(-(bh div 2)));+ Contour.AddPoint(MilsToCoord( (bw div 2)), MilsToCoord( (bh div 2)));+ Contour.AddPoint(MilsToCoord(-(bw div 2)), MilsToCoord( (bh div 2)));+ Body.SetOutlineContour(Contour);++ GStage := 'add to footprint';+ Fp.AddPCBObject(Body);+ PCBServer.SendMessageToRobots(Fp.I_ObjectAddress, c_Broadcast,+ PCBM_BoardRegisteration, Body.I_ObjectAddress);+ PCBServer.PostProcess;++ GStage := 'save';+ LastServerDoc.SetFileName(PcbPath);+ SavedOK := LastServerDoc.DoFileSave('');+ if SavedOK then SavedStr := 'true' else SavedStr := 'false';+ RespOk(', "footprint": ' + JStr(FpName) + ', "body": "extruded", "saved": ' + SavedStr);+end;++{ ---- command: set which parameters are VISIBLE on a component. Names in 'visible'+ (comma list) stay shown; every other parameter is hidden, and the Comment is+ hidden too unless 'Comment' is listed. Controls what appears when the symbol+ is placed on a schematic. ---- }+procedure CmdSetVisibility;+var+ SchPath, CompRef, Vis, SavedStr : String;+ Lib : ISch_Lib;+ Cmp : ISch_Component;+ It : ISch_Iterator;+ P : ISch_Parameter;+ visList : TStringList;+ SavedOK : Boolean;+ shown, hidden : Integer;+begin+ SchPath := Req.Values['schlib'];+ CompRef := Req.Values['component'];+ Vis := Req.Values['visible']; if Vis = '' then Vis := 'Value';+ if SchPath = '' then begin RespErr('missing schlib'); Exit; end;++ GStage := 'open schlib';+ Lib := OpenSchLibDoc(SchPath);+ if Lib = nil then begin RespErr('could not open: ' + SchPath); Exit; end;+ Cmp := FindComponent(Lib, CompRef);+ if Cmp = nil then begin RespErr('component not found: ' + CompRef); Exit; end;++ visList := TStringList.Create;+ visList.CommaText := Vis; { case-insensitive IndexOf by default }+ shown := 0; hidden := 0;+ try+ GStage := 'set parameter visibility';+ It := Cmp.SchIterator_Create;+ It.AddFilter_ObjectSet(MkSet(eParameter));+ try+ P := It.FirstSchObject;+ while P <> nil do+ begin+ if visList.IndexOf(P.Name) >= 0 then+ begin P.IsHidden := False; Inc(shown); end+ else+ begin P.IsHidden := True; Inc(hidden); end;+ P := It.NextSchObject;+ end;+ finally+ Cmp.SchIterator_Destroy(It);+ end;++ GStage := 'comment visibility';+ if visList.IndexOf('Comment') < 0 then Cmp.Comment.IsHidden := True+ else Cmp.Comment.IsHidden := False;+ finally+ visList.Free;+ end;++ GStage := 'save';+ Lib.GraphicallyInvalidate;+ SavedOK := False;+ if LastServerDoc <> nil then SavedOK := LastServerDoc.DoFileSave('');+ if SavedOK then SavedStr := 'true' else SavedStr := 'false';+ RespOk(', "component": ' + JStr(Cmp.LibReference) + ', "visible": ' + JStr(Vis) ++ ', "shown": ' + IntToStr(shown) + ', "hidden": ' + IntToStr(hidden) ++ ', "saved": ' + SavedStr);+end;++{ ---- command: copy a component from one SchLib (src) into another (dst), appending.+ Faithful copy (pins + graphics + params); designator + footprint link set fresh.+ Enables adding a new part TYPE into an EXISTING library. ---- }+procedure CmdCopySchComponent;+var+ SrcPath, DstPath, CompRef, NewName, FpName, Desig, SavedStr : String;+ SrcDoc, DstDoc : IServerDocument;+ SrcLib, DstLib : ISch_Lib;+ Src, NewCmp : ISch_Component;+ It : ISch_Iterator;+ Prim, NewPrim : ISch_GraphicalObject;+ Impl : ISch_Implementation;+ o : Integer;+ SavedOK : Boolean;+begin+ SrcPath := Req.Values['src'];+ DstPath := Req.Values['dst'];+ CompRef := Req.Values['component'];+ NewName := Req.Values['newname']; if NewName = '' then NewName := CompRef;+ FpName := Req.Values['footprint'];+ Desig := Req.Values['designator']; if Desig = '' then Desig := 'U?';+ if (SrcPath = '') or (DstPath = '') then begin RespErr('need src + dst'); Exit; end;+ if SchServer = nil then begin RespErr('SchServer nil'); Exit; end;++ GStage := 'open src schlib';+ SrcDoc := Client.OpenDocument('SCHLIB', SrcPath);+ if SrcDoc <> nil then Client.ShowDocument(SrcDoc);+ SrcLib := SchServer.GetSchDocumentByPath(SrcPath);+ if SrcLib = nil then begin RespErr('open src failed: ' + SrcPath); Exit; end;++ GStage := 'find src component';+ Src := FindComponent(SrcLib, CompRef);+ if Src = nil then begin RespErr('component not found in src: ' + CompRef); Exit; end;++ GStage := 'open dst schlib';+ DstDoc := Client.OpenDocument('SCHLIB', DstPath);+ if DstDoc <> nil then Client.ShowDocument(DstDoc);+ DstLib := SchServer.GetSchDocumentByPath(DstPath);+ if DstLib = nil then begin RespErr('open dst failed: ' + DstPath); Exit; end;+ LastServerDoc := DstDoc;++ GStage := 'create new component';+ NewCmp := SchServer.SchObjectFactory(eSchComponent, eCreate_Default);+ NewCmp.CurrentPartID := 1;+ NewCmp.DisplayMode := 0;+ NewCmp.LibReference := NewName;+ NewCmp.DesignItemID := NewName;++ GStage := 'copy primitives';+ It := Src.SchIterator_Create; { no filter — copy pins + graphics + params }+ try+ Prim := It.FirstSchObject;+ while Prim <> nil do+ begin+ o := Prim.ObjectId;+ { skip designator/comment/implementation (25/45/46); they are set fresh below }+ if (o <> 25) and (o <> 45) and (o <> 46) then+ begin+ NewPrim := Prim.Replicate;+ NewPrim.OwnerPartId := NewCmp.CurrentPartID;+ NewPrim.OwnerPartDisplayMode := NewCmp.DisplayMode;+ NewCmp.AddSchObject(NewPrim);+ end;+ Prim := It.NextSchObject;+ end;+ finally+ Src.SchIterator_Destroy(It);+ end;++ GStage := 'designator + footprint link';+ NewCmp.Designator.Text := Desig;+ if FpName <> '' then+ begin+ Impl := NewCmp.AddSchImplementation;+ Impl.ClearAllDatafileLinks;+ Impl.ModelName := FpName;+ Impl.ModelType := 'PCBLIB';+ Impl.AddDataFileLink(FpName, '', 'PCBLIB');+ Impl.IsCurrent := True;+ end;++ GStage := 'add to dst + save';+ DstLib.AddSchComponent(NewCmp);+ SchServer.RobotManager.SendMessage(DstLib.I_ObjectAddress, c_BroadCast,+ SCHM_PrimitiveRegistration, NewCmp.I_ObjectAddress);+ DstLib.GraphicallyInvalidate;+ SavedOK := False;+ if DstDoc <> nil then SavedOK := DstDoc.DoFileSave('');+ if SavedOK then SavedStr := 'true' else SavedStr := 'false';+ RespOk(', "copied": ' + JStr(CompRef) + ' -> ' + JStr(NewName) ++ ', "into": ' + JStr(DstPath) + ', "saved": ' + SavedStr);+end;++{ ---- command: copy a footprint from one PcbLib (src) into another (dst), appending. ---- }+procedure CmdCopyFootprint;+var+ SrcPath, DstPath, FpName, NewName, SavedStr : String;+ SrcDoc, DstDoc : IServerDocument;+ SrcLib, DstLib : IPCB_Library;+ SrcFp, NewFp : IPCB_LibComponent;+ GIter : IPCB_GroupIterator;+ Prim, NewPrim : IPCB_Primitive;+ i : Integer;+ Found, SavedOK : Boolean;+begin+ SrcPath := Req.Values['src'];+ DstPath := Req.Values['dst'];+ FpName := Req.Values['footprint'];+ NewName := Req.Values['newname'];+ if (SrcPath = '') or (DstPath = '') then+ begin RespErr('need src + dst'); Exit; end;+ if PCBServer = nil then begin RespErr('PCBServer nil'); Exit; end;++ GStage := 'open src pcblib';+ SrcDoc := Client.OpenDocument('PCBLIB', SrcPath);+ if SrcDoc <> nil then Client.ShowDocument(SrcDoc);+ SrcLib := PCBServer.GetCurrentPCBLibrary;+ if SrcLib = nil then begin RespErr('no src pcblib'); Exit; end;++ GStage := 'find src footprint';+ Found := False;+ for i := 0 to SrcLib.ComponentCount - 1 do+ begin+ SrcFp := SrcLib.GetComponent(i);+ if (SrcFp <> nil) and ((FpName = '') or (SrcFp.Name = FpName)) then begin Found := True; Break; end;+ end;+ if not Found then begin RespErr('footprint not found in src: ' + FpName); Exit; end;+ if NewName = '' then NewName := SrcFp.Name; { keep source name if no rename given }++ GStage := 'open dst pcblib';+ DstDoc := Client.OpenDocument('PCBLIB', DstPath);+ if DstDoc <> nil then Client.ShowDocument(DstDoc);+ DstLib := PCBServer.GetCurrentPCBLibrary;+ if DstLib = nil then begin RespErr('no dst pcblib'); Exit; end;+ LastServerDoc := DstDoc;++ GStage := 'create new footprint';+ PCBServer.PreProcess;+ NewFp := PCBServer.CreatePCBLibComp;+ NewFp.Name := NewName;+ DstLib.RegisterComponent(NewFp);++ GStage := 'copy primitives';+ GIter := SrcFp.GroupIterator_Create;+ try+ Prim := GIter.FirstPCBObject;+ while Prim <> nil do+ begin+ NewPrim := Prim.Replicate;+ NewFp.AddPCBObject(NewPrim);+ PCBServer.SendMessageToRobots(NewFp.I_ObjectAddress, c_Broadcast,+ PCBM_BoardRegisteration, NewPrim.I_ObjectAddress);+ Prim := GIter.NextPCBObject;+ end;+ finally+ SrcFp.GroupIterator_Destroy(GIter);+ end;+ PCBServer.PostProcess;++ GStage := 'save dst';+ DstLib.Board.ViewManager_FullUpdate;+ DstDoc.SetFileName(DstPath);+ SavedOK := DstDoc.DoFileSave('');+ if SavedOK then SavedStr := 'true' else SavedStr := 'false';+ RespOk(', "copied_footprint": ' + JStr(FpName) + ' -> ' + JStr(NewName) ++ ', "into": ' + JStr(DstPath) + ', "saved": ' + SavedStr);+end;++{ ---- command: probe/open a BOARD (.PcbDoc) via the PCB API. This is the first+ half of the molecule STEP-export path (altium-export-molecule Stage 1):+ prove we can open a board document and obtain its IPCB_Board on this+ Altium build, WITHOUT firing any export process yet (a modal export+ dialog would hang the -Wait bridge call). The STEP export itself is+ wired in a follow-up once this board-open half is confirmed.+ req: pcbdoc=<path to .PcbDoc> ---- }+procedure CmdExportStep;+var+ InPath, OutPath, ProcId : String;+ SD : IServerDocument;+ Board : IPCB_Board;+begin+ InPath := Req.Values['pcbdoc'];+ if InPath = '' then begin RespErr('export_step: missing pcbdoc'); Exit; end;+ if not FileExists(InPath) then begin RespErr('export_step: pcbdoc not found: ' + InPath); Exit; end;++ GStage := 'export_step: open board';+ SD := Client.OpenDocument('PCB', InPath);+ if SD = nil then begin RespErr('export_step: OpenDocument(PCB) returned nil'); Exit; end;+ Client.ShowDocument(SD);++ GStage := 'export_step: GetCurrentPCBBoard';+ if PCBServer = nil then begin RespErr('export_step: PCBServer nil'); Exit; end;+ Board := PCBServer.GetCurrentPCBBoard;+ if Board = nil then begin RespErr('export_step: GetCurrentPCBBoard nil'); Exit; end;++ { run=true: attempt the actual STEP export via a server process. The process+ id is overridable via req 'proc' so we can probe candidates without+ re-deploying. If the process opens a modal, RunProcess blocks here and no+ response is written — the caller detects that via poll-timeout and recovers+ (screenshot + dismiss). Sandbox only until a non-modal path is confirmed. }+ if Req.Values['run'] = 'true' then+ begin+ OutPath := Req.Values['out'];+ if OutPath = '' then begin RespErr('export_step: run=true needs out'); Exit; end;+ ProcId := Req.Values['proc'];+ if ProcId = '' then ProcId := 'Pcb:ExportSTEP';+ if FileExists(OutPath) then DeleteFile(OutPath);+ GStage := 'export_step: RunProcess ' + ProcId;+ ResetParameters;+ AddStringParameter('FileName', OutPath);+ RunProcess(ProcId);+ RespOk(', "pcbdoc": ' + JStr(InPath) ++ ', "ran": true, "proc": ' + JStr(ProcId) ++ ', "out": ' + JStr(OutPath) ++ ', "out_exists": ' + BoolStr(FileExists(OutPath)));+ Exit;+ end;++ RespOk(', "pcbdoc": ' + JStr(InPath) ++ ', "opened": true' ++ ', "export_wired": false' ++ ', "board_name": ' + JStr(Board.FileName));+end;++{ ---- command: run an OutJob output (e.g. an ExportSTEP output) headlessly via+ WorkspaceManager:GenerateReport. This is the sanctioned headless STEP-export+ path (Altium exposes no direct board->STEP process). Action/ObjectKind/Medium+ are overridable from the request so the exact GenerateReport params can be+ tuned WITHOUT redeploying the script.+ req: outjob=<path.OutJob>, action(=Run), objectkind(=OutputBatch),+ medium(optional OutputMedium name) ---- }+procedure CmdRunOutJob;+var+ OjPath, Action, ObjKind, Medium, PrjPath, Expect : String;+ SD : IServerDocument;+ WS : IWorkspace;+ Proj : IProject;+ ProjStr : String;+begin+ OjPath := Req.Values['outjob'];+ if OjPath = '' then begin RespErr('run_outjob: missing outjob'); Exit; end;+ if not FileExists(OjPath) then begin RespErr('run_outjob: outjob not found: ' + OjPath); Exit; end;+ Action := Req.Values['action']; if Action = '' then Action := 'Run';+ ObjKind := Req.Values['objectkind']; if ObjKind = '' then ObjKind := 'OutputBatch';+ Medium := Req.Values['medium'];+ PrjPath := Req.Values['project'];+ Expect := Req.Values['expect'];+ ProjStr := 'false';++ { Open + focus the owning project FIRST so an OutJob's [Project Physical+ Documents] target resolves. An OutJob opened standalone binds to the+ focused project as its container. }+ if PrjPath <> '' then+ begin+ if not FileExists(PrjPath) then begin RespErr('run_outjob: project not found: ' + PrjPath); Exit; end;+ GStage := 'run_outjob: open project';+ WS := GetWorkspace;+ if WS = nil then begin RespErr('run_outjob: GetWorkspace nil'); Exit; end;+ Proj := WS.DM_OpenProject(PrjPath, True);+ if Proj = nil then begin RespErr('run_outjob: DM_OpenProject nil'); Exit; end;+ Proj.DM_Compile;+ ProjStr := 'true';+ end;++ { Altium may already hold this OutJob OPEN in the editor with a STALE in-memory+ copy; OpenDocument would return that cached version and ignore disk edits.+ Close it first (unmodified from Altium's view -> no save prompt) so the reopen+ reads the freshly-deployed file from disk. Skip with reload=false to use the+ already-loaded (e.g. GUI-configured) in-memory copy verbatim. }+ if Req.Values['reload'] <> 'false' then+ begin+ GStage := 'run_outjob: close stale outjob';+ SD := Client.OpenDocument('OUTPUTJOB', OjPath);+ if SD <> nil then Client.CloseDocument(SD);+ end;++ GStage := 'run_outjob: open outjob';+ SD := Client.OpenDocument('OUTPUTJOB', OjPath);+ if SD = nil then begin RespErr('run_outjob: OpenDocument(OUTPUTJOB) nil'); Exit; end;+ Client.ShowDocument(SD);++ if (Expect <> '') and FileExists(Expect) then DeleteFile(Expect);++ GStage := 'run_outjob: GenerateReport';+ ResetParameters;+ AddStringParameter('Action', Action);+ AddStringParameter('ObjectKind', ObjKind);+ if Medium <> '' then AddStringParameter('OutputMedium', Medium);+ if Req.Values['disable_dialog'] <> 'false' then AddStringParameter('DisableDialog', 'True');+ RunProcess('WorkspaceManager:GenerateReport');++ RespOk(', "outjob": ' + JStr(OjPath) ++ ', "action": ' + JStr(Action) ++ ', "objectkind": ' + JStr(ObjKind) ++ ', "medium": ' + JStr(Medium) ++ ', "project_opened": ' + ProjStr ++ ', "expect": ' + JStr(Expect) ++ ', "out_exists": ' + BoolStr((Expect <> '') and FileExists(Expect)) ++ ', "ran": true');+end;++{ ---- shared command dispatch. Assumes the global Req is loaded and GReqId set. ---- }+procedure Dispatch;+var Cmd : String;+begin+ try+ Cmd := Req.Values['command'];+ GStage := 'dispatch ' + Cmd;+ if Cmd = 'ping' then CmdPing+ else if Cmd = 'add_lcsc_param' then CmdAddLcscParam+ else if Cmd = 'read_params' then CmdReadParams+ else if Cmd = 'create_and_attach' then CmdCreateAndAttach+ else if Cmd = 'compile_intlib' then CmdCompileIntLib+ else if Cmd = 'extract_intlib' then CmdExtractIntLib+ else if Cmd = 'cleanup_libs' then CmdCleanupLibs+ else if Cmd = 'create_resistor' then CmdCreateResistor+ else if Cmd = 'list_docs' then CmdListDocs+ else if Cmd = 'remove_component' then CmdRemoveComponent+ else if Cmd = 'close_doc' then CmdCloseDoc+ else if Cmd = 'build_footprint' then CmdBuildFootprint+ else if Cmd = 'build_symbol' then CmdBuildSymbol+ else if Cmd = 'install_library' then CmdInstallLibrary+ else if Cmd = 'list_symbols' then CmdListSymbols+ else if Cmd = 'read_symbol' then CmdReadSymbol+ else if Cmd = 'clone_symbol' then CmdCloneSymbol+ else if Cmd = 'rename_component' then CmdRenameComponent+ else if Cmd = 'rename_footprint' then CmdRenameFootprint+ else if Cmd = 'import_part' then CmdImportPart+ else if Cmd = 'copy_sch_component' then CmdCopySchComponent+ else if Cmd = 'copy_footprint' then CmdCopyFootprint+ else if Cmd = 'add_body' then CmdAddBody+ else if Cmd = 'set_visibility' then CmdSetVisibility+ else if Cmd = 'export_step' then CmdExportStep+ else if Cmd = 'run_outjob' then CmdRunOutJob+ else RespErr('unknown command: ' + Cmd);+ except+ RespErr('exception at stage: ' + GStage);+ end;+end;++{ ---- one-shot entry (X2 ...>Run): read request.txt, dispatch, write response. ---- }+procedure Run;+begin+ Req := TStringList.Create;+ try+ try+ if FileExists(EX_DIR + 'request.txt') then+ Req.LoadFromFile(EX_DIR + 'request.txt');+ GReqId := Req.Values['id'];+ Dispatch;+ except+ RespErr('exception at stage: ' + GStage);+ end;+ finally+ Req.Free;+ end;+end;
adom-desktop-altium-bridge/bridge-src/addin_client.pyadded+174@@ -0,0 +1,174 @@+#!/usr/bin/env python3+"""addin_client — drive the in-Altium AdomAltium dispatcher from the bridge.++Runs ON the Windows desktop (the bridge process). For each call it:+ 1. copies the AdomAltium script project into the fixed exchange dir (no spaces),+ 2. writes request.txt (key=value lines) + clears any old response.json,+ 3. launches X2.EXE with -RScriptingSystem:RunScript(...) — single-instance+ forwarding runs the script inside the already-open Altium,+ 4. polls for response.json and returns the parsed result.++The exchange dir is C:\\Users\\Public\\AdomAltium (deliberately space-free so the+RunScript ProjectName needs no quoting, sidestepping cmd/argv escaping issues).++NOTE on speed: Altium has no headless mode and DelphiScript can't host a resident+callback server (a script proc can't bind to a native VCL event), so each command+is one X2.EXE forward. The way we go fast is FEWER commands — see the batched+`import_part` verb, which does a whole part import in a single forward.++A module lock serializes the file exchange because the bridge HTTP server is+threaded (request.txt / response.json are shared, one command at a time).+"""++import hashlib+import json+import os+import shutil+import subprocess+import threading+import time+from pathlib import Path++from altium_detect import detect_altium++EXCHANGE_DIR = r"C:\Users\Public\AdomAltium"+ADDIN_SRC = Path(__file__).parent / "addin" / "AdomAltium"+SCRIPT_FILES = ("AdomAltium.pas", "AdomAltium.PrjScr")++# Set to 1 to stop syncing the packaged scripts into the exchange dir entirely.+NO_SYNC_ENV = "ADOM_ALTIUM_NO_SCRIPT_SYNC"++_LOCK = threading.Lock()++# Per-file note on what _ensure_scripts last did: "synced" | "same" | "kept-newer"+# | "skipped-env". Surfaced by script_state() so a caller can tell WHICH script is+# actually live without shelling onto the desktop.+_SYNC_NOTES = {}+++def _sha256(path):+ h = hashlib.sha256()+ with open(path, "rb") as fh:+ for chunk in iter(lambda: fh.read(65536), b""):+ h.update(chunk)+ return h.hexdigest()+++def _ensure_scripts():+ """Sync the packaged AdomAltium scripts into the exchange dir, NON-destructively.++ The exchange dir is also where a script is hand-deployed during development, so+ an unconditional copy silently destroyed in-progress work: every verb call+ reverted C:\\Users\\Public\\AdomAltium\\AdomAltium.pas to the packaged copy, and+ the only way to test an edited script was to bypass this client and drive+ X2.EXE directly. A newer file in the exchange dir now WINS and is left alone.+ """+ os.makedirs(EXCHANGE_DIR, exist_ok=True)+ if os.environ.get(NO_SYNC_ENV) == "1":+ for fname in SCRIPT_FILES:+ _SYNC_NOTES[fname] = "skipped-env"+ return++ for fname in SCRIPT_FILES:+ src = ADDIN_SRC / fname+ if not src.exists():+ continue+ dst = os.path.join(EXCHANGE_DIR, fname)++ if not os.path.exists(dst):+ shutil.copyfile(str(src), dst)+ _SYNC_NOTES[fname] = "synced"+ continue++ if _sha256(str(src)) == _sha256(dst):+ _SYNC_NOTES[fname] = "same"+ continue++ # Different content: the newer file wins. mtime is meaningful here because+ # copyfile() does NOT preserve it — a synced file always carries its sync+ # time, so a later mtime means someone deployed by hand after that sync.+ if os.path.getmtime(dst) > src.stat().st_mtime:+ _SYNC_NOTES[fname] = "kept-newer"+ continue++ shutil.copyfile(str(src), dst)+ _SYNC_NOTES[fname] = "synced"+++def script_state():+ """Which in-Altium script is live, and is it a dev override of the packaged one?++ Cheap (no Altium round-trip) and the fastest way to diagnose "the verb exists in+ server.py but the script says unknown command" — the two halves drifting apart.+ """+ out = {"exchangeDir": EXCHANGE_DIR, "scripts": {}}+ for fname in SCRIPT_FILES:+ src = ADDIN_SRC / fname+ dst = os.path.join(EXCHANGE_DIR, fname)+ entry = {"note": _SYNC_NOTES.get(fname, "not-yet-synced")}+ if src.exists():+ entry["packagedSha256"] = _sha256(str(src))[:16]+ entry["packagedBytes"] = src.stat().st_size+ if os.path.exists(dst):+ entry["liveSha256"] = _sha256(dst)[:16]+ entry["liveBytes"] = os.path.getsize(dst)+ entry["devOverride"] = entry.get("note") == "kept-newer"+ out["scripts"][fname] = entry+ out["devOverride"] = any(s.get("devOverride") for s in out["scripts"].values())+ return out+++def _write_request(command, params):+ lines = ["command=" + command]+ for k, v in (params or {}).items():+ if v is None or v == "":+ continue+ # key=value lines: keep values single-line so TStringList.Values parses cleanly+ lines.append("{}={}".format(k, str(v).replace("\r", " ").replace("\n", " ")))+ with open(os.path.join(EXCHANGE_DIR, "request.txt"), "w", encoding="utf-8") as fh:+ fh.write("\n".join(lines) + "\n")+++def addin_call(command, params=None, timeout=90):+ """Invoke a command in the in-Altium dispatcher and return its parsed JSON result."""+ info = detect_altium()+ if not info.get("installed"):+ return {"ok": False, "error": "Altium Designer not installed",+ "_hint": "Run altium_detect to confirm the install path."}+ exe = info["exe"]++ with _LOCK:+ _ensure_scripts()+ resp_path = os.path.join(EXCHANGE_DIR, "response.json")+ try:+ if os.path.exists(resp_path):+ os.remove(resp_path)+ except OSError:+ pass++ _write_request(command, params)++ prjscr = os.path.join(EXCHANGE_DIR, "AdomAltium.PrjScr") # no spaces -> no quoting+ arg = "-RScriptingSystem:RunScript(ProjectName={}|ProcName=AdomAltium>Run)".format(prjscr)+ try:+ # list form -> no shell, no cmd escaping; arg has no spaces so it passes verbatim+ subprocess.Popen([exe, arg], close_fds=True)+ except Exception as e:+ return {"ok": False, "error": "failed to launch Altium: {}".format(e)}++ deadline = time.time() + timeout+ while time.time() < deadline:+ if os.path.exists(resp_path):+ time.sleep(0.15) # let the script finish writing+ try:+ with open(resp_path, "r", encoding="utf-8") as fh:+ return json.load(fh)+ except (OSError, ValueError):+ time.sleep(0.25)+ continue+ time.sleep(0.4)++ return {"ok": False,+ "error": "timeout after {}s waiting for Altium".format(timeout),+ "_hint": "Is Altium running and not blocked by a modal dialog? "+ "The script writes C:\\Users\\Public\\AdomAltium\\response.json."}
adom-desktop-altium-bridge/bridge-src/altium_detect.pyadded+150@@ -0,0 +1,150 @@+#!/usr/bin/env python3+"""Detect the Altium Designer install + library locations on this Windows machine.++Runs INSIDE the user's desktop (the bridge is spawned by Adom Desktop on Windows),+so it reads the registry and filesystem directly — no relay/shell round-trip.++Verified facts on the reference machine (BARRETTLAND / user barre):+ - Install: C:\\Program Files\\Altium\\AD26\\X2.exe+ - Uninstall key: HKLM\\...\\Uninstall\\Altium Designer {GUID} (DisplayName "Altium Designer 26",+ InstallLocation, DisplayIcon → X2.exe)+ - Config: %LOCALAPPDATA%\\Altium\\Altium Designer {GUID} and %APPDATA%\\Altium\\...+ - Data: C:\\Users\\Public\\Documents\\Altium\\AD26\\{Library,OutputJobs,Templates,Examples}+"""++import os+import glob++try:+ import winreg # type: ignore+except ImportError: # non-Windows (dev/import only)+ winreg = None++UNINSTALL_PATHS = [+ r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",+ r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",+]+++def _reg_altium_uninstall():+ """Walk the Uninstall keys for an entry whose DisplayName starts with 'Altium Designer'.++ Returns dict {display_name, display_version, install_location, display_icon} or None.+ """+ if winreg is None:+ return None+ for base in UNINSTALL_PATHS:+ try:+ root = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, base)+ except OSError:+ continue+ i = 0+ while True:+ try:+ sub = winreg.EnumKey(root, i)+ except OSError:+ break+ i += 1+ if "Altium Designer" not in sub:+ continue+ try:+ k = winreg.OpenKey(root, sub)+ except OSError:+ continue++ def _get(name):+ try:+ return winreg.QueryValueEx(k, name)[0]+ except OSError:+ return None++ name = _get("DisplayName")+ if not name or not str(name).startswith("Altium Designer"):+ continue+ return {+ "display_name": name,+ "display_version": _get("DisplayVersion"),+ "install_location": _get("InstallLocation"),+ "display_icon": _get("DisplayIcon"),+ }+ return None+++def _scan_program_files():+ """Fallback: find the newest C:\\Program Files\\Altium\\AD<NN>\\X2.exe."""+ candidates = []+ for pf in (os.environ.get("ProgramFiles", r"C:\Program Files"),+ os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")):+ for exe in glob.glob(os.path.join(pf, "Altium", "AD*", "X2.exe")):+ ad = os.path.basename(os.path.dirname(exe)) # e.g. AD26+ num = "".join(ch for ch in ad if ch.isdigit())+ candidates.append((int(num or 0), ad, exe))+ candidates.sort(reverse=True)+ return candidates[0] if candidates else None+++def _config_dirs():+ """Find the Altium config dirs (carry the {GUID} and any Workspace/365 state)."""+ out = []+ for env in ("LOCALAPPDATA", "APPDATA"):+ base = os.path.join(os.environ.get(env, ""), "Altium")+ if os.path.isdir(base):+ for d in os.listdir(base):+ if d.startswith("Altium Designer"):+ out.append(os.path.join(base, d))+ return out+++def detect_altium():+ """Return a structured snapshot of the Altium install on this machine.++ {installed, exe, version, install_location, config_dirs[], data_dir,+ library_dir, outputjobs_dir}+ """+ reg = _reg_altium_uninstall()+ exe = None+ install_location = None+ version = None++ if reg:+ version = reg.get("display_version") or reg.get("display_name")+ install_location = reg.get("install_location")+ icon = reg.get("display_icon")+ if icon and icon.lower().endswith("x2.exe") and os.path.isfile(icon):+ exe = icon+ elif install_location:+ cand = os.path.join(install_location, "X2.exe")+ if os.path.isfile(cand):+ exe = cand++ if not exe:+ scanned = _scan_program_files()+ if scanned:+ _, ad, exe = scanned+ install_location = os.path.dirname(exe)+ version = version or ad++ # Public data tree — derive AD<NN> from the install dir name when possible.+ ad_name = os.path.basename(install_location) if install_location else "AD26"+ public = os.path.join(+ os.environ.get("PUBLIC", r"C:\Users\Public"), "Documents", "Altium", ad_name+ )+ data_dir = public if os.path.isdir(public) else None+ library_dir = os.path.join(public, "Library") if data_dir else None+ outputjobs_dir = os.path.join(public, "OutputJobs") if data_dir else None++ return {+ "installed": bool(exe),+ "exe": exe,+ "version": version,+ "install_location": install_location,+ "config_dirs": _config_dirs(),+ "data_dir": data_dir,+ "library_dir": library_dir,+ "outputjobs_dir": outputjobs_dir,+ }+++if __name__ == "__main__":+ import json+ print(json.dumps(detect_altium(), indent=2))
adom-desktop-altium-bridge/bridge-src/bridge.jsonadded+78@@ -0,0 +1,78 @@+{+ "manifest_version": 1,+ "name": "altium",+ "displayName": "Altium Bridge",+ "version": "0.0.40",+ "description": "Drives Altium Designer from a container: build, clone and edit file-based symbol/footprint/Integrated libraries with LCSC/JLCPCB part numbers attached, and generate batch outputs (STEP, PDF, Gerber, BOM, CPL) from an OutJob. Pairs with the container-side altium-jlc orchestrator (adom-jlcpcb + chip-fetcher).",+ "homepage": "https://wiki.adom.inc/adom/adom-desktop-altium-bridge",+ "author": "Adom Inc.",+ "license": "MIT",+ "spawn": {+ "kind": "python",+ "entrypoint": "server.py",+ "port": 8801,+ "healthEndpoint": "/status",+ "stopMethod": "kill",+ "killImageName": "python.exe"+ },+ "verbPrefixes": [+ "altium_"+ ],+ "verbs": [+ "altium_ping",+ "altium_detect",+ "altium_addin_ping",+ "altium_add_component",+ "altium_read_params",+ "altium_create_and_attach",+ "altium_compile_intlib",+ "altium_extract_intlib",+ "altium_cleanup_libs",+ "altium_create_resistor",+ "altium_build_footprint",+ "altium_build_symbol",+ "altium_clone_symbol",+ "altium_install_library",+ "altium_list_symbols",+ "altium_read_symbol",+ "altium_list_docs",+ "altium_remove_component",+ "altium_close_doc",+ "altium_rename_component",+ "altium_rename_footprint",+ "altium_import_part",+ "altium_add_body",+ "altium_set_visibility",+ "altium_copy_sch_component",+ "altium_copy_footprint",+ "altium_script_state",+ "altium_export_step",+ "altium_run_outjob"+ ],+ "dependencies": {+ "python": ">=3.8"+ },+ "category": "CAD",+ "tags": [+ "altium",+ "lcsc",+ "jlcpcb",+ "bom",+ "cpl",+ "library"+ ],+ "docs": "https://wiki.adom.inc/adom/adom-desktop-altium-bridge",+ "platforms": {+ "windows": {+ "supported": true+ },+ "macos": {+ "supported": false,+ "reason": "Altium Designer is Windows-only; no host app to drive on macOS."+ },+ "linux": {+ "supported": false,+ "reason": "Altium Designer is Windows-only; no host app to drive on Linux."+ }+ }+}
adom-desktop-altium-bridge/bridge-src/check_wiring.pyadded+101@@ -0,0 +1,101 @@+#!/usr/bin/env python3+"""check_wiring — prove the three halves of this bridge agree, before publishing.++The bridge is three files that each carry their own list of what exists:++ bridge.json verbs[] what Adom Desktop advertises+ server.py COMMAND_HANDLERS what the HTTP server routes+ AdomAltium.pas Dispatch what Altium actually implements++Nothing enforced that they matched, and they drifted: the shipped package routed+verbs its own script could not run, and the repo's readable source was two verbs+behind the zip that was actually installed. Run this in deploy.sh so a mismatch+fails the build instead of shipping.++Pure static parsing (regex over the sources) so it runs in the Linux container,+where the Windows-only bridge modules cannot be imported.++Usage: python3 check_wiring.py [bridge-src-dir] -> exit 0 clean, 1 on mismatch+"""++import json+import re+import sys+from pathlib import Path++# Verbs the server answers itself, with no in-Altium counterpart to match against.+HOST_ONLY = {"altium_ping", "altium_detect", "altium_script_state"}+++def routed_verbs(server_py: Path) -> dict:+ """{verb: pas_command_or_None} from COMMAND_HANDLERS plus each handler's body."""+ text = server_py.read_text(encoding="utf-8")++ table = re.search(r"COMMAND_HANDLERS\s*=\s*\{(.*?)\n\}", text, re.S)+ if not table:+ sys.exit("check_wiring: COMMAND_HANDLERS table not found in server.py")+ pairs = re.findall(r'"(altium_[a-z_]+)"\s*:\s*(\w+)', table.group(1))++ # Map handler function -> the addin_call command it issues (if any).+ bodies = re.split(r"\ndef ", text)+ handler_cmd = {}+ for body in bodies:+ name = re.match(r"(\w+)\s*\(", body)+ if not name:+ continue+ call = re.search(r'addin_call\(\s*"([a-z_]+)"', body)+ handler_cmd[name.group(1)] = call.group(1) if call else None++ return {verb: handler_cmd.get(fn) for verb, fn in pairs}+++def pas_commands(pas: Path) -> set:+ """Command strings the DelphiScript Dispatch procedure compares against."""+ text = pas.read_text(encoding="utf-8", errors="replace")+ block = re.search(r"procedure Dispatch;(.*?)\nend;", text, re.S)+ if not block:+ sys.exit("check_wiring: Dispatch procedure not found in AdomAltium.pas")+ return set(re.findall(r"Cmd\s*=\s*'([a-z_]+)'", block.group(1)))+++def main() -> int:+ root = Path(sys.argv[1] if len(sys.argv) > 1 else Path(__file__).parent)+ routed = routed_verbs(root / "server.py")+ implemented = pas_commands(root / "addin" / "AdomAltium" / "AdomAltium.pas")+ advertised = set(json.loads((root / "bridge.json").read_text())["verbs"])++ problems = []++ # 1. Every routed verb must reach a command the script implements.+ for verb, cmd in sorted(routed.items()):+ if verb in HOST_ONLY:+ continue+ if cmd is None:+ problems.append(f"{verb}: handler issues no addin_call (host-only? add to HOST_ONLY)")+ elif cmd not in implemented:+ problems.append(f"{verb}: routes to '{cmd}', which AdomAltium.pas does not dispatch")++ # 2. bridge.json is what Adom Desktop shows the user; an omission hides a verb.+ for verb in sorted(set(routed) - advertised):+ problems.append(f"{verb}: routed by server.py but missing from bridge.json verbs[]")+ for verb in sorted(advertised - set(routed)):+ problems.append(f"{verb}: advertised in bridge.json but not routed by server.py")++ # 3. Script commands with no verb are dead weight — a warning, not a failure:+ # 'add_lcsc_param' is legitimately reached via the altium_add_component verb.+ unreachable = implemented - {c for c in routed.values() if c}+ if unreachable:+ print(f"note: script commands with no verb: {', '.join(sorted(unreachable))}")++ if problems:+ print(f"\nFAIL — {len(problems)} wiring mismatch(es):")+ for p in problems:+ print(f" - {p}")+ return 1++ print(f"OK — {len(routed)} verbs routed, {len(implemented)} script commands, bridge.json agrees.")+ return 0+++if __name__ == "__main__":+ sys.exit(main())
adom-desktop-altium-bridge/bridge-src/deploy.shadded+189@@ -0,0 +1,189 @@+#!/usr/bin/env bash+# deploy.sh — verify, build, publish and (re)install the Altium bridge.+#+# ./deploy.sh <version> [--publish] [--install]+# ./deploy.sh 0.0.37 # verify + build the zip locally, nothing else+# ./deploy.sh 0.0.37 --publish # + push the page repo to wiki.adom.inc+# ./deploy.sh 0.0.37 --publish --install # + reinstall onto the Windows desktop+#+# Replaces the previous version, which could no longer run: it built from a+# $ROOT/altium-bridge directory that does not exist, and served the zip off a+# hardcoded :8088 proxy on a different container. The source of truth is now this+# repo, and the artifact is served by the wiki itself.+#+# Publishing writes three separate layers, in this order:+# 1. pkg publish — the registry: install payload + the release row itself+# 2. repo push — the git layer behind the Files tab (the readable source)+# 3. release upload — the downloadable bridge zip + its manifest+# The order matters: `release upload` needs the release to exist, and `pkg publish`+# rewrites page files (README/SKILL/package.json) from the tarball, so the repo+# push has to follow it to have the last word.+set -euo pipefail++VER="${1:?usage: deploy.sh <version> [--publish] [--install]}"; shift || true+DO_PUBLISH=0; DO_INSTALL=0+for a in "$@"; do+ case "$a" in+ --publish) DO_PUBLISH=1 ;;+ --install) DO_INSTALL=1 ;;+ *) echo "unknown flag: $a" >&2; exit 2 ;;+ esac+done++SRC="$(cd "$(dirname "$0")" && pwd)" # bridge-src/+REPO="$(dirname "$SRC")" # page repo root+REF="barrett-land/adom-desktop-altium-bridge"+ZIP_NAME="altium-bridge-v${VER}.zip"+ZIP="$REPO/$ZIP_NAME"+MANIFEST="$REPO/altium-bridge-manifest.json"++# Adom Desktop attributes every command to a thread; without this the call is refused.+AI_THREAD="${ADOM_AI_THREAD:-altium-bridge-deploy}"+ad() { adom-desktop --ai-thread "$AI_THREAD" "$@"; }++# --- 1. verify the three halves agree before anything is built ----------------+echo "==> checking verb wiring"+python3 "$SRC/check_wiring.py" "$SRC"+echo "==> running tests"+( cd "$SRC" && python3 -m unittest discover -p 'test_*.py' -q )+python3 -m py_compile "$SRC"/*.py++# --- 2. stamp the version into every file that carries one -------------------+echo "==> stamping v${VER}"+echo "$VER" > "$SRC/BRIDGE_VERSION"+python3 - "$VER" "$SRC/bridge.json" "$REPO/package.json" <<'PY'+import json, collections, sys+ver, paths = sys.argv[1], sys.argv[2:]+for p in paths:+ d = json.load(open(p), object_pairs_hook=collections.OrderedDict)+ d["version"] = ver+ with open(p, "w") as fh:+ json.dump(d, fh, indent=2); fh.write("\n")+ print(f" {p} -> {ver}")+PY++# --- 3. build the artifact ---------------------------------------------------+# The docs quote a manifest URL that users paste into bridge_install, so it has to+# name this exact version. The /latest/ alias is NOT usable: it is served with+# `cache-control: immutable, max-age=31536000`, so it pins to whatever it first+# served and keeps handing out a stale manifest long after a new release lands.+echo "==> stamping the manifest URL into the docs"+for f in "$REPO/install.sh" "$REPO/SKILL.md" "$REPO/README.md"; do+ python3 - "$f" "$VER" <<'PY'+import re, sys+path, ver = sys.argv[1], sys.argv[2]+src = open(path).read()+new = re.sub(+ r"(/download/barrett-land/adom-desktop-altium-bridge/)[^/]+(/altium-bridge-manifest\.json)",+ lambda m: m.group(1) + ver + m.group(2), src)+if new != src:+ open(path, "w").write(new)+ print(f" {path}")+PY+done++echo "==> building $ZIP_NAME"+# Exactly one zip in the repo at a time. Remember which ones we drop so they can be+# removed from the page too — a superseded zip stays served until it is git-rm'd.+OLD_ZIPS=()+for z in "$REPO"/altium-bridge-v*.zip; do+ [ -e "$z" ] || continue+ [ "$(basename "$z")" = "$ZIP_NAME" ] && continue+ OLD_ZIPS+=("$(basename "$z")")+done+rm -f "$REPO"/altium-bridge-v*.zip+# NOTE: deliberately NOT normalising mtimes to make the zip reproducible. The script+# sync on the desktop resolves a content conflict by mtime, so a packaged AdomAltium.pas+# stamped to a fixed epoch would always look older than a stale copy in the exchange+# dir and could never upgrade it. Re-runnability is handled below instead, by+# re-uploading the assets when the version already exists.+( cd "$SRC" && zip -rq "$ZIP" . \+ -x '*__pycache__*' -x '*.pyc' -x 'check_wiring.py' -x 'test_*.py' -x 'deploy.sh' )+SHA=$(sha256sum "$ZIP" | cut -d' ' -f1)+SIZE=$(stat -c%s "$ZIP")++# /download/<owner>/<slug>/<version>/<file> serves RELEASE ASSETS. Always address+# them by VERSION, never through the /latest/ alias: that alias is served with+# `cache-control: immutable, max-age=31536000`, so it keeps handing out whatever it+# first served — currently a manifest from the 0.0.33 asset bucket advertising+# v0.0.35 — no matter how many newer releases land. Uploading a corrected manifest+# onto that bucket does not dislodge it either.+cat > "$MANIFEST" <<EOF+{ "manifest_version": 1, "name": "altium", "version": "${VER}",+ "url": "https://wiki.adom.inc/download/${REF}/${VER}/${ZIP_NAME}",+ "sha256": "${SHA}", "size": ${SIZE}, "released_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)" }+EOF+echo " ${ZIP_NAME} size=${SIZE} sha256=${SHA:0:16}…"++[ "$DO_PUBLISH" = 1 ] || { echo "==> built only (pass --publish to ship)"; exit 0; }++# --- 4. publish the page repo ------------------------------------------------+# Cut the registry release FIRST: `release upload` needs the version to exist, and+# `pkg publish` rewrites page files (README/SKILL/package.json) from the tarball —+# so the repo push has to come after it to have the last word.+echo "==> publishing the package (registry layer)"+# A re-run of the same version is normal (an earlier attempt failed after publishing).+# Registry releases are immutable, but release ASSETS are not, so carry on and let the+# upload below overwrite them — that is what re-syncs the published zip with this build.+if PUB_OUT=$(adom-wiki pkg publish -y --skip-lint 2>&1); then+ echo " published $VER"+elif grep -q VERSION_EXISTS <<<"$PUB_OUT"; then+ echo " $VER already in the registry; refreshing its assets"+else+ echo "$PUB_OUT" >&2; exit 1+fi++echo "==> pushing page repo to $REF"+# `repo push` takes an explicit file list, so enumerate everything tracked. Skip the+# .git dir and any stale zip that step 3 already deleted from disk.+mapfile -t FILES < <(cd "$REPO" && find . -type f \+ -not -path './.git/*' -not -path '*/__pycache__/*' -not -name '*.pyc' -printf '%P\n' | sort)+printf ' %s\n' "${FILES[@]}"+( cd "$REPO" && adom-wiki repo push "$REF" --message "altium bridge v${VER}" --files "${FILES[@]}" )++for old in ${OLD_ZIPS+"${OLD_ZIPS[@]}"}; do+ echo " removing superseded $old"+ adom-wiki repo rm "$REF" "$old" || true+done++echo "==> uploading release assets"+# The package tarball is the install payload; the bridge zip is a downloadable+# RELEASE asset. Platform tags matter — see the manifest comment above.+adom-wiki release upload "$REF" "$VER" "$ZIP" --platform windows --arch any >/dev/null+adom-wiki release upload "$REF" "$VER" "$MANIFEST" --platform any --arch any >/dev/null++echo "==> verifying the published artifacts resolve"+PUB_URL="https://wiki.adom.inc/download/${REF}/${VER}/altium-bridge-manifest.json"+curl -fsS "$PUB_URL" > /tmp/altium-manifest-check.json+# Fetch with curl, not urllib: the CDN in front of wiki.adom.inc 403s python-urllib's+# default User-Agent, which looks exactly like a broken upload.+ZIP_URL=$(python3 -c "import json;print(json.load(open('/tmp/altium-manifest-check.json'))['url'])")+curl -fsS "$ZIP_URL" > /tmp/altium-zip-check.zip+python3 - "$VER" <<'PY'+import hashlib, json, sys+ver = sys.argv[1]+d = json.load(open('/tmp/altium-manifest-check.json'))+assert d['version'] == ver, f"served manifest is v{d['version']}, expected v{ver}"+body = open('/tmp/altium-zip-check.zip', 'rb').read()+assert len(body) == d['size'], f"zip is {len(body)} bytes, manifest claims {d['size']}"+got = hashlib.sha256(body).hexdigest()+assert got == d['sha256'], f"zip sha {got[:16]} != manifest sha {d['sha256'][:16]}"+print(f" manifest v{ver} resolves, and its url serves a {len(body)}-byte zip with a matching sha256")+PY++[ "$DO_INSTALL" = 1 ] || { echo "==> published (pass --install to update the desktop)"; exit 0; }++# --- 5. reinstall onto the Windows desktop -----------------------------------+echo "==> installing onto the desktop"+ad bridge_install "{\"manifestUrl\":\"${PUB_URL}\"}" \+ | python3 -c "import sys,json;d=json.load(sys.stdin);o=json.loads(d['output']) if isinstance(d.get('output'),str) and d['output'] else d;print(' install ok:',o.get('ok'),o.get('error') or '')"+sleep 2+ad altium_ping '{}' \+ | python3 -c "+import sys, json+d = json.load(sys.stdin)+o = json.loads(d.get('output') or '{}')+live = o.get('version')+print(' bridge now v%s' % live)+sys.exit(0 if live == '${VER}' else 'FAILED: desktop still reports v%s — the old process may still hold the port' % live)"+echo "==> done"
adom-desktop-altium-bridge/bridge-src/server.pyadded+439@@ -0,0 +1,439 @@+#!/usr/bin/env python3+"""Adom Altium Bridge — localhost HTTP server (runs on the user's Windows desktop).++Spawned on demand by Adom Desktop (v1.8.0+ dynamic bridge). Implements the standard+dynamic-bridge contract:+ GET /status -> {"ok": true, "version": "...", ...}+ POST /command body {"command"|"verb": "altium_*", "args": {...}}+ -> {"success": true, "output": "<json-string>"} | {"success": false, "error", "_hint"}++Host-side verbs (no Altium round-trip): altium_ping, altium_detect, altium_script_state.+Everything else forwards into the in-Altium AdomAltium DelphiScript dispatcher via+addin_client — library authoring (symbols, footprints, IntLibs) plus the batch-output+verbs altium_export_step and altium_run_outjob.++Usage: python server.py [--port 8801]+"""++import json+import sys+import traceback+from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler+from pathlib import Path++sys.path.insert(0, str(Path(__file__).parent))++from altium_detect import detect_altium+from addin_client import NO_SYNC_ENV, addin_call, script_state++DEFAULT_PORT = 8801++VERSION = (Path(__file__).parent / "BRIDGE_VERSION").read_text().strip() \+ if (Path(__file__).parent / "BRIDGE_VERSION").exists() else "0.0.0"++# Address of the in-Altium add-in (Phase 2+); not used by Phase 0 verbs.+ADDIN_URL = "http://127.0.0.1:8775"+++def handle_ping(args: dict) -> dict:+ """Liveness + a quick Altium-presence summary."""+ info = detect_altium()+ return {+ "ok": True,+ "bridge": "altium",+ "version": VERSION,+ "altiumInstalled": info["installed"],+ "altiumVersion": info.get("version"),+ "altiumExe": info.get("exe"),+ "addinUrl": ADDIN_URL,+ }+++def handle_detect(args: dict) -> dict:+ """Full Altium install + library-location snapshot for this machine."""+ info = detect_altium()+ info["ok"] = True+ if not info["installed"]:+ info["_hint"] = (+ "Altium Designer not found. Expected C:\\Program Files\\Altium\\AD<NN>\\X2.exe "+ "or an 'Altium Designer' Uninstall registry entry."+ )+ return info+++def handle_addin_ping(args: dict) -> dict:+ """Round-trip the in-Altium dispatcher (proves request/response works live)."""+ return addin_call("ping", {}, timeout=args.get("timeout", 60))+++def handle_add_component(args: dict) -> dict:+ """Add/refresh a component's identity params (incl. 'LCSC Part #') in a SchLib."""+ if not args.get("schlib"):+ return {"ok": False, "error": "missing 'schlib' (path to the .SchLib on the desktop)"}+ keys = ("schlib", "component", "lcsc", "mpn", "manufacturer",+ "value", "description", "package", "datasheet")+ return addin_call("add_lcsc_param", {k: args.get(k) for k in keys},+ timeout=args.get("timeout", 90))+++def handle_read_params(args: dict) -> dict:+ """Read back a component's parameters (verification)."""+ return addin_call("read_params",+ {"schlib": args.get("schlib"), "component": args.get("component")},+ timeout=args.get("timeout", 60))+++def handle_create_and_attach(args: dict) -> dict:+ """Create a fresh SchLib + component, attach LCSC/identity params, read back, save.+ No external symbol source needed — proves the keystone end to end."""+ keys = ("component", "schlib", "lcsc", "mpn", "manufacturer", "value", "description")+ return addin_call("create_and_attach", {k: args.get(k) for k in keys},+ timeout=args.get("timeout", 90))+++def handle_compile_intlib(args: dict) -> dict:+ """Generate a .LibPkg wrapping the .SchLib, then compile it to .IntLib."""+ import os+ schlib = args.get("schlib") or r"C:\Users\Public\AdomAltium\Adom.SchLib"+ d = os.path.dirname(schlib)+ libname = os.path.splitext(os.path.basename(schlib))[0]+ libpkg = os.path.join(d, libname + ".LibPkg")+ # CreateIntegratedLibrary wants the full .IntLib FILE path, not a folder.+ output = args.get("output_file") or os.path.join(d, libname + ".IntLib")+ # .LibPkg is an INI project file; reference the SchLib by relative name (same dir).+ content = (+ "[Design]\r\n"+ "Version=1.0\r\n"+ "HierarchyMode=1\r\n"+ "\r\n"+ "[Document1]\r\n"+ "DocumentPath=" + os.path.basename(schlib) + "\r\n"+ )+ try:+ with open(libpkg, "w", encoding="utf-8") as fh:+ fh.write(content)+ except OSError as e:+ return {"ok": False, "error": "could not write LibPkg: {}".format(e)}+ return addin_call("compile_intlib", {"libpkg": libpkg, "output": output},+ timeout=args.get("timeout", 180))+++def handle_extract_intlib(args: dict) -> dict:+ """Decompile a compiled .IntLib into editable source libs (.SchLib + .PcbLib);+ returns the extracted document paths so the caller can edit them."""+ if not args.get("intlib"):+ return {"ok": False, "error": "missing 'intlib' (path to the .IntLib on the desktop)"}+ return addin_call("extract_intlib", {"intlib": args.get("intlib")},+ timeout=args.get("timeout", 150))+++def handle_create_resistor(args: dict) -> dict:+ """Create a 2-pin resistor symbol with params + footprint link, save to a Resistors lib."""+ import os+ schlib = args.get("schlib") or r"C:\Users\Public\AdomLibs\Resistors.SchLib"+ try:+ os.makedirs(os.path.dirname(schlib), exist_ok=True)+ except OSError:+ pass+ payload = {+ "schlib": schlib,+ "component": args.get("component") or "R",+ "lcsc": args.get("lcsc"),+ "mpn": args.get("mpn"),+ "manufacturer": args.get("manufacturer"),+ "value": args.get("value"),+ "description": args.get("description"),+ "footprint": args.get("footprint") or "R0603",+ "footprint_lib": args.get("footprint_lib") or "Resistors.PcbLib",+ }+ return addin_call("create_resistor", payload, timeout=args.get("timeout", 120))+++def handle_clone_symbol(args: dict) -> dict:+ keys = ("schlib", "template", "component", "footprint", "designator")+ return addin_call("clone_symbol", {k: args.get(k) for k in keys}, timeout=args.get("timeout", 120))+++def handle_rename_component(args: dict) -> dict:+ """Rename a SchLib component in place; optionally update Description, footprint+ link, and designator. Preserves the exact symbol graphics/pins/params."""+ keys = ("schlib", "component", "newname", "footprint", "designator", "description")+ return addin_call("rename_component", {k: args.get(k) for k in keys},+ timeout=args.get("timeout", 120))+++def handle_rename_footprint(args: dict) -> dict:+ """Rename a footprint (IPCB_LibComponent) inside a PcbLib in place."""+ keys = ("pcblib", "footprint", "newname")+ return addin_call("rename_footprint", {k: args.get(k) for k in keys},+ timeout=args.get("timeout", 120))+++def handle_set_visibility(args: dict) -> dict:+ """Set which parameters are visible on a component (others hidden); controls+ what shows when the symbol is placed on a schematic."""+ keys = ("schlib", "component", "visible")+ return addin_call("set_visibility", {k: args.get(k) for k in keys},+ timeout=args.get("timeout", 90))+++def handle_add_body(args: dict) -> dict:+ """Attach a simple extruded 3D body (box, sized in mils) to a footprint."""+ keys = ("pcblib", "footprint", "bw_mil", "bh_mil", "h_mil", "so_mil")+ return addin_call("add_body", {k: args.get(k) for k in keys},+ timeout=args.get("timeout", 120))+++def handle_import_part(args: dict) -> dict:+ """Batched single-round-trip import: from already-extracted src SchLib+PcbLib,+ rename footprint + save-as into the target PcbLib; attach LCSC/params + rename+ symbol + link + save-as into the target SchLib; install both; read back params."""+ keys = ("src_sch", "src_pcb", "tgt_sch", "tgt_pcb", "name", "footprint",+ "designator", "lcsc", "mpn", "manufacturer", "value", "description", "package")+ return addin_call("import_part", {k: args.get(k) for k in keys},+ timeout=args.get("timeout", 180))+++def handle_copy_sch_component(args: dict) -> dict:+ """Append a component from one SchLib (src) into another (dst) — faithful copy+ (pins+graphics+params), fresh designator + footprint link. Adds a new part TYPE+ into an EXISTING library."""+ keys = ("src", "dst", "component", "newname", "footprint", "designator")+ return addin_call("copy_sch_component", {k: args.get(k) for k in keys},+ timeout=args.get("timeout", 150))+++def handle_copy_footprint(args: dict) -> dict:+ """Append a footprint from one PcbLib (src) into another (dst)."""+ keys = ("src", "dst", "footprint", "newname")+ return addin_call("copy_footprint", {k: args.get(k) for k in keys},+ timeout=args.get("timeout", 150))+++def handle_list_symbols(args: dict) -> dict:+ return addin_call("list_symbols", {"schlib": args.get("schlib")}, timeout=args.get("timeout", 60))+++def handle_read_symbol(args: dict) -> dict:+ return addin_call("read_symbol",+ {"schlib": args.get("schlib"), "component": args.get("component")},+ timeout=args.get("timeout", 60))+++def handle_install_library(args: dict) -> dict:+ """Register a file-based library (already on the desktop) into Available Libraries."""+ return addin_call("install_library", {"path": args.get("path")}, timeout=args.get("timeout", 60))+++def handle_build_symbol(args: dict) -> dict:+ """Build/append a symbol (N pins + footprint-implementation link) into a SchLib."""+ return addin_call("build_symbol", args, timeout=args.get("timeout", 120))+++def handle_build_footprint(args: dict) -> dict:+ """Build an Altium footprint from a pad spec + optional STEP body. The STEP must+ already be on the desktop (the orchestrator send_files it first). Pads come as+ pad0..padN keys (CommaText: smd,x,y,w,h,drill,name in mils)."""+ return addin_call("build_footprint", args, timeout=args.get("timeout", 150))+++def handle_list_docs(args: dict) -> dict:+ return addin_call("list_docs", {}, timeout=args.get("timeout", 60))+++def handle_remove_component(args: dict) -> dict:+ return addin_call("remove_component",+ {"schlib": args.get("schlib"), "component": args.get("component")},+ timeout=args.get("timeout", 90))+++def handle_close_doc(args: dict) -> dict:+ return addin_call("close_doc", {"path": args.get("path")}, timeout=args.get("timeout", 60))+++def handle_cleanup_libs(args: dict) -> dict:+ """Uninstall available libraries whose path matches any comma-separated needle."""+ match = args.get("match") or "adomaltium,schlib1,adom.intlib,adom.schlib"+ return addin_call("cleanup_libs", {"match": match}, timeout=args.get("timeout", 60))+++def handle_script_state(args: dict) -> dict:+ """Which in-Altium script is live vs. the one this bridge package ships.++ No Altium round-trip. Use it when a verb routes fine here but the script answers+ 'unknown command' — that means the two halves have drifted apart.+ """+ state = script_state()+ state["ok"] = True+ state["bridgeVersion"] = VERSION+ if state.get("devOverride"):+ state["_hint"] = (+ "A hand-deployed script in the exchange dir is newer than the packaged one "+ "and is being used as-is. Fold it back into bridge-src/addin/AdomAltium/ "+ f"and re-publish, or delete it to fall back to the packaged copy. Set "+ f"{NO_SYNC_ENV}=1 to stop syncing altogether."+ )+ return state+++def handle_export_step(args: dict) -> dict:+ """Export a .PcbDoc's 3D board assembly to STEP.++ Two modes. Without run=true it only OPENS the board and reports that it resolved+ (the safe probe). With run=true it also fires the export process named by 'proc'+ (default Pcb:ExportSTEP) writing to 'out'.++ Altium exposes no documented headless board->STEP process, so 'proc' is+ overridable to probe candidates without redeploying the script. If the process+ opens a modal dialog the script blocks and never writes a response — the call+ times out with the export possibly still pending. Check 'out' on disk before+ assuming failure.+ """+ if not args.get("pcbdoc"):+ return {"ok": False, "error": "missing 'pcbdoc' (path to the .PcbDoc on the desktop)"}+ if args.get("run") and not args.get("out"):+ return {"ok": False, "error": "run=true also needs 'out' (destination .step path)"}+ params = {+ "pcbdoc": args.get("pcbdoc"),+ "run": "true" if args.get("run") else None,+ "out": args.get("out"),+ "proc": args.get("proc"),+ }+ return addin_call("export_step", params, timeout=args.get("timeout", 240))+++def handle_run_outjob(args: dict) -> dict:+ """Generate an OutJob's outputs (STEP, PDF, Gerber, BOM, CPL) via+ WorkspaceManager:GenerateReport — the sanctioned batch-output path.++ Pass 'project' so an OutJob targeting [Project Physical Documents] resolves: the+ script opens and COMPILES that project first, which is what populates the target.+ Pass 'expect' (a path the run should produce) to get an authoritative out_exists+ in the reply instead of guessing from the process return.++ KNOWN LIMITATION: GenerateReport is observed to no-op in some configurations+ where the GUI's 'Generate content' button on the same OutJob works. Treat+ out_exists=false with ran=true as "the trigger did not fire", not "the OutJob is+ misconfigured", and verify by hand before rebuilding the OutJob.+ """+ if not args.get("outjob"):+ return {"ok": False, "error": "missing 'outjob' (path to the .OutJob on the desktop)"}+ params = {+ "outjob": args.get("outjob"),+ "action": args.get("action"),+ "objectkind": args.get("objectkind"),+ "medium": args.get("medium"),+ "project": args.get("project"),+ "expect": args.get("expect"),+ }+ # These two are meaningful ONLY as the literal string "false" (the script tests+ # for it), so a Python False must not collapse to None and get dropped.+ for key in ("reload", "disable_dialog"):+ if key in args:+ params[key] = "false" if args[key] in (False, "false") else "true"+ return addin_call("run_outjob", params, timeout=args.get("timeout", 300))+++COMMAND_HANDLERS = {+ "altium_ping": handle_ping,+ "altium_detect": handle_detect,+ "altium_script_state": handle_script_state,+ "altium_export_step": handle_export_step,+ "altium_run_outjob": handle_run_outjob,+ "altium_compile_intlib": handle_compile_intlib,+ "altium_extract_intlib": handle_extract_intlib,+ "altium_cleanup_libs": handle_cleanup_libs,+ "altium_addin_ping": handle_addin_ping,+ "altium_add_component": handle_add_component,+ "altium_read_params": handle_read_params,+ "altium_create_and_attach": handle_create_and_attach,+ "altium_create_resistor": handle_create_resistor,+ "altium_list_docs": handle_list_docs,+ "altium_remove_component": handle_remove_component,+ "altium_close_doc": handle_close_doc,+ "altium_build_footprint": handle_build_footprint,+ "altium_build_symbol": handle_build_symbol,+ "altium_install_library": handle_install_library,+ "altium_list_symbols": handle_list_symbols,+ "altium_read_symbol": handle_read_symbol,+ "altium_clone_symbol": handle_clone_symbol,+ "altium_rename_component": handle_rename_component,+ "altium_rename_footprint": handle_rename_footprint,+ "altium_import_part": handle_import_part,+ "altium_add_body": handle_add_body,+ "altium_set_visibility": handle_set_visibility,+ "altium_copy_sch_component": handle_copy_sch_component,+ "altium_copy_footprint": handle_copy_footprint,+}+++def dispatch(verb: str, args: dict) -> dict:+ handler = COMMAND_HANDLERS.get(verb)+ if handler is None:+ return {+ "success": False,+ "error": f"Unknown verb: {verb}",+ "_hint": f"Valid altium verbs: {', '.join(sorted(COMMAND_HANDLERS))}.",+ }+ payload = handler(args or {})+ # Dynamic-bridge contract: success envelope with output as a JSON string.+ return {"success": True, "output": json.dumps(payload)}+++class AltiumBridgeHandler(BaseHTTPRequestHandler):+ def do_GET(self):+ if self.path == "/status":+ self._respond(200, {"ok": True, "bridge": "altium", "version": VERSION})+ else:+ self._respond(404, {"ok": False, "error": "Not found"})++ def do_POST(self):+ if self.path != "/command":+ self._respond(404, {"success": False, "error": "Not found"})+ return+ length = int(self.headers.get("Content-Length", 0))+ try:+ request = json.loads(self.rfile.read(length) or b"{}")+ except json.JSONDecodeError as e:+ self._respond(400, {"success": False, "error": f"Invalid JSON: {e}"})+ return+ verb = request.get("command") or request.get("verb") or ""+ args = request.get("args", {})+ print(f"[Altium Bridge] {verb} | {json.dumps(args)}")+ try:+ self._respond(200, dispatch(verb, args))+ except Exception as e:+ traceback.print_exc()+ self._respond(500, {"success": False, "error": f"Internal error: {e}"})++ def _respond(self, status: int, data: dict):+ body = json.dumps(data).encode("utf-8")+ self.send_response(status)+ self.send_header("Content-Type", "application/json")+ self.send_header("Content-Length", str(len(body)))+ self.end_headers()+ self.wfile.write(body)++ def log_message(self, fmt, *args):+ print(f"[Altium Bridge] {args[0]} {args[1]} {args[2]}")+++def main():+ port = DEFAULT_PORT+ if "--port" in sys.argv:+ idx = sys.argv.index("--port")+ if idx + 1 < len(sys.argv):+ port = int(sys.argv[idx + 1])+ info = detect_altium()+ print(f"[Altium Bridge] v{VERSION} | Altium installed: {info['installed']} "+ f"({info.get('version')}) exe={info.get('exe')}")+ server = ThreadingHTTPServer(("127.0.0.1", port), AltiumBridgeHandler)+ print(f"[Altium Bridge] Listening on http://127.0.0.1:{port} (status: /status)")+ try:+ server.serve_forever()+ except KeyboardInterrupt:+ server.server_close()+++if __name__ == "__main__":+ main()
adom-desktop-altium-bridge/bridge-src/test_addin_client.pyadded+117@@ -0,0 +1,117 @@+#!/usr/bin/env python3+"""Regression tests for the script sync in addin_client.++The bug these exist to prevent: _ensure_scripts() used to copy the packaged+AdomAltium.pas over the exchange copy unconditionally, on EVERY verb call. Since+the exchange dir is also where a script gets hand-deployed while developing, a+work-in-progress script was destroyed by the next bridge call, and the only way to+test an edit was to bypass the bridge and drive X2.EXE directly.++Runs on Linux (the container) — altium_detect guards its winreg import, and these+tests never reach Altium.++Usage: python3 -m unittest test_addin_client -v+"""++import os+import sys+import tempfile+import time+import unittest+from pathlib import Path++sys.path.insert(0, str(Path(__file__).parent))++import addin_client+++class ScriptSyncTest(unittest.TestCase):+ def setUp(self):+ self.tmp = tempfile.TemporaryDirectory()+ root = Path(self.tmp.name)++ self.packaged = root / "packaged"+ (self.packaged).mkdir()+ (self.packaged / "AdomAltium.pas").write_text("PACKAGED")+ (self.packaged / "AdomAltium.PrjScr").write_text("PRJ")++ self.exchange = root / "exchange"++ self._saved = (addin_client.ADDIN_SRC, addin_client.EXCHANGE_DIR)+ addin_client.ADDIN_SRC = self.packaged+ addin_client.EXCHANGE_DIR = str(self.exchange)+ addin_client._SYNC_NOTES.clear()+ os.environ.pop(addin_client.NO_SYNC_ENV, None)++ def tearDown(self):+ addin_client.ADDIN_SRC, addin_client.EXCHANGE_DIR = self._saved+ os.environ.pop(addin_client.NO_SYNC_ENV, None)+ self.tmp.cleanup()++ def live_pas(self):+ return (self.exchange / "AdomAltium.pas").read_text()++ def write_live(self, text, newer_than_packaged):+ """Put a script in the exchange dir, older or newer than the packaged one."""+ self.exchange.mkdir(exist_ok=True)+ p = self.exchange / "AdomAltium.pas"+ p.write_text(text)+ base = (self.packaged / "AdomAltium.pas").stat().st_mtime+ stamp = base + 60 if newer_than_packaged else base - 60+ os.utime(p, (stamp, stamp))++ # --- the actual behaviours -------------------------------------------------++ def test_missing_destination_is_populated(self):+ addin_client._ensure_scripts()+ self.assertEqual(self.live_pas(), "PACKAGED")+ self.assertEqual(addin_client._SYNC_NOTES["AdomAltium.pas"], "synced")++ def test_identical_destination_is_not_rewritten(self):+ addin_client._ensure_scripts()+ before = (self.exchange / "AdomAltium.pas").stat().st_mtime_ns+ time.sleep(0.01)+ addin_client._ensure_scripts()+ self.assertEqual(addin_client._SYNC_NOTES["AdomAltium.pas"], "same")+ self.assertEqual((self.exchange / "AdomAltium.pas").stat().st_mtime_ns, before)++ def test_newer_hand_deployed_script_survives(self):+ """THE regression guard: a dev script in the exchange dir is never clobbered."""+ self.write_live("DEV WORK IN PROGRESS", newer_than_packaged=True)+ for _ in range(3): # every verb call used to overwrite it+ addin_client._ensure_scripts()+ self.assertEqual(self.live_pas(), "DEV WORK IN PROGRESS")+ self.assertEqual(addin_client._SYNC_NOTES["AdomAltium.pas"], "kept-newer")++ def test_older_destination_is_upgraded(self):+ """A normal package upgrade must still overwrite a stale exchange copy."""+ self.write_live("STALE", newer_than_packaged=False)+ addin_client._ensure_scripts()+ self.assertEqual(self.live_pas(), "PACKAGED")+ self.assertEqual(addin_client._SYNC_NOTES["AdomAltium.pas"], "synced")++ def test_env_var_disables_sync_entirely(self):+ self.write_live("UNTOUCHABLE", newer_than_packaged=False)+ os.environ[addin_client.NO_SYNC_ENV] = "1"+ addin_client._ensure_scripts()+ self.assertEqual(self.live_pas(), "UNTOUCHABLE")+ self.assertEqual(addin_client._SYNC_NOTES["AdomAltium.pas"], "skipped-env")++ def test_script_state_reports_dev_override(self):+ self.write_live("DEV", newer_than_packaged=True)+ addin_client._ensure_scripts()+ state = addin_client.script_state()+ self.assertTrue(state["devOverride"])+ entry = state["scripts"]["AdomAltium.pas"]+ self.assertNotEqual(entry["liveSha256"], entry["packagedSha256"])++ def test_script_state_clean_when_in_sync(self):+ addin_client._ensure_scripts()+ state = addin_client.script_state()+ self.assertFalse(state["devOverride"])+ entry = state["scripts"]["AdomAltium.pas"]+ self.assertEqual(entry["liveSha256"], entry["packagedSha256"])+++if __name__ == "__main__":+ unittest.main()
adom-desktop-altium-bridge/bridge.jsonadded+73@@ -0,0 +1,73 @@+{+ "manifest_version": 1,+ "name": "altium",+ "displayName": "Altium Bridge",+ "version": "0.0.33",+ "description": "Adds components to an Altium Designer library (Integrated, Database, or Altium 365) with the LCSC/JLCPCB part number attached as a parameter, and exports JLC-ready BOM + pick-and-place (CPL). Pairs with the container-side altium-jlc orchestrator (adom-jlcpcb + chip-fetcher).",+ "homepage": "https://wiki.adom.inc/adom/adom-desktop-altium-bridge",+ "author": "Adom Inc.",+ "license": "MIT",+ "spawn": {+ "kind": "python",+ "entrypoint": "server.py",+ "port": 8801,+ "healthEndpoint": "/status",+ "stopMethod": "kill",+ "killImageName": "python.exe"+ },+ "verbPrefixes": [+ "altium_"+ ],+ "verbs": [+ "altium_ping",+ "altium_detect",+ "altium_addin_ping",+ "altium_add_component",+ "altium_read_params",+ "altium_create_and_attach",+ "altium_compile_intlib",+ "altium_extract_intlib",+ "altium_cleanup_libs",+ "altium_create_resistor",+ "altium_build_footprint",+ "altium_build_symbol",+ "altium_clone_symbol",+ "altium_install_library",+ "altium_list_symbols",+ "altium_read_symbol",+ "altium_list_docs",+ "altium_remove_component",+ "altium_close_doc",+ "altium_rename_component",+ "altium_rename_footprint",+ "altium_import_part",+ "altium_add_body",+ "altium_set_visibility"+ ],+ "dependencies": {+ "python": ">=3.8"+ },+ "category": "CAD",+ "tags": [+ "altium",+ "lcsc",+ "jlcpcb",+ "bom",+ "cpl",+ "library"+ ],+ "docs": "https://wiki.adom.inc/adom/adom-desktop-altium-bridge",+ "platforms": {+ "windows": {+ "supported": true+ },+ "macos": {+ "supported": false,+ "reason": "Altium Designer is Windows-only; no host app to drive on macOS."+ },+ "linux": {+ "supported": false,+ "reason": "Altium Designer is Windows-only; no host app to drive on Linux."+ }+ }+}
adom-desktop-altium-bridge/docs/hero.pngadded⋯ 1 unchanged line ⋯
adom-desktop-altium-bridge/install.shadded+18@@ -0,0 +1,18 @@+#!/bin/bash+# Install the container-side half of this package: the skill. The bridge itself+# runs on the user's Windows desktop and is installed separately by Adom Desktop+# from the release manifest (printed below).+set -euo pipefail+H="$(cd "$(dirname "$0")" && pwd)"+SLUG="adom-desktop-altium-bridge"+DEST="$HOME/.claude/skills/$SLUG"+MANIFEST="https://wiki.adom.inc/download/barrett-land/$SLUG/latest/altium-bridge-manifest.json"++mkdir -p "$DEST"+# Symlink rather than copy (the Adom convention): edits in the module dir+# propagate immediately, and a reinstall cannot clobber local work.+ln -sfn "$H/SKILL.md" "$DEST/SKILL.md"++echo "[$SLUG] skill installed (symlinked from $H)."+echo "Install the bridge onto your Windows desktop with:"+echo " adom-desktop bridge_install '{\"manifestUrl\":\"$MANIFEST\"}'"
adom-desktop-altium-bridge/package.jsonadded+47@@ -0,0 +1,47 @@+{+ "slug": "adom-desktop-altium-bridge",+ "version": "0.0.40",+ "type": "app",+ "description": "Adom Desktop bridge for Altium Designer \u2014 build and edit file-based symbol/footprint/Integrated libraries with LCSC/JLCPCB part numbers attached, and generate batch outputs (STEP, PDF, Gerber, BOM, CPL) from an OutJob, by driving Altium via DelphiScript.",+ "tags": [+ "app",+ "adom-desktop",+ "bridge",+ "altium",+ "lcsc",+ "jlcpcb"+ ],+ "dependencies": {},+ "scripts": {+ "install": "./install.sh",+ "uninstall": "./uninstall.sh"+ },+ "needs_sudo": false,+ "hero": {+ "path": "docs/hero.png"+ },+ "discovery_triggers": [+ "add a part to my altium library",+ "drive altium from claude",+ "altium bridge",+ "export step from altium",+ "run an altium outjob",+ "build an altium footprint",+ "altium symbol library"+ ],+ "discovery_pitch": "Use when the user wants Claude to author or edit Altium libraries, or to generate STEP/PDF/Gerber/BOM/CPL from an Altium board on their Windows machine.",+ "sample_prompts": [+ {+ "label": "Add a part",+ "prompt": "Add the LCSC part C25804 to my Altium library with its footprint and 3D model"+ },+ {+ "label": "Export STEP",+ "prompt": "Export a STEP file from my Altium board"+ },+ {+ "label": "Check the bridge",+ "prompt": "Is the Altium bridge connected and what version is it?"+ }+ ]+}
adom-desktop-altium-bridge/page.jsonadded+51@@ -0,0 +1,51 @@+{+ "slug": "adom-desktop-altium-bridge",+ "version": "0.0.37",+ "type": "app",+ "description": "Adom Desktop bridge for Altium Designer \u2014 build and edit file-based symbol/footprint/Integrated libraries with LCSC/JLCPCB part numbers attached, and generate batch outputs (STEP, PDF, Gerber, BOM, CPL) from an OutJob, by driving Altium via DelphiScript.",+ "tags": [+ "app",+ "adom-desktop",+ "bridge",+ "altium",+ "lcsc",+ "jlcpcb"+ ],+ "dependencies": {},+ "scripts": {+ "install": "./install.sh",+ "uninstall": "./uninstall.sh"+ },+ "needs_sudo": false,+ "hero": {+ "path": "docs/hero.png"+ },+ "author": {+ "name": "barrett-land",+ "email": "[email protected]"+ },+ "discovery_triggers": [+ "add a part to my altium library",+ "drive altium from claude",+ "altium bridge",+ "export step from altium",+ "run an altium outjob",+ "build an altium footprint",+ "altium symbol library"+ ],+ "discovery_pitch": "Use when the user wants Claude to author or edit Altium libraries, or to generate STEP/PDF/Gerber/BOM/CPL from an Altium board on their Windows machine.",+ "sample_prompts": [+ {+ "label": "Add a part",+ "prompt": "Add the LCSC part C25804 to my Altium library with its footprint and 3D model"+ },+ {+ "label": "Export STEP",+ "prompt": "Export a STEP file from my Altium board"+ },+ {+ "label": "Check the bridge",+ "prompt": "Is the Altium bridge connected and what version is it?"+ }+ ]+}
adom-desktop-altium-bridge/uninstall.shadded+4@@ -0,0 +1,4 @@+#!/bin/bash+set -euo pipefail+rm -rf "$HOME/.claude/skills/adom-desktop-altium-bridge"+echo "[adom-desktop-altium-bridge] skill removed (desktop bridge: adom-desktop bridge_uninstall '{\"name\":\"altium\"}')."
bridge-src/deploy.sh+1−1@@ -51,7 +51,7 @@ python3 -m py_compile "$SRC"/*.py # --- 2. stamp the version into every file that carries one ------------------- echo "==> stamping v${VER}" echo "$VER" > "$SRC/BRIDGE_VERSION"-python3 - "$VER" "$SRC/bridge.json" "$REPO/package.json" <<'PY'+python3 - "$VER" "$SRC/bridge.json" "$REPO/package.json" "$REPO/page.json" <<'PY' import json, collections, sys ver, paths = sys.argv[1], sys.argv[2:] for p in paths:
page.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-desktop-altium-bridge",- "version": "0.0.37",+ "version": "0.0.40", "type": "app", "description": "Adom Desktop bridge for Altium Designer \u2014 build and edit file-based symbol/footprint/Integrated libraries with LCSC/JLCPCB part numbers attached, and generate batch outputs (STEP, PDF, Gerber, BOM, CPL) from an OutJob, by driving Altium via DelphiScript.", "tags": [