Drop Altium 365 Part Request scripts + reference (skill ends at Gitea push) (by admin)

main
admin 2026-07-16 08:19:59 +00:00
parent 6083e4ee78
commit 594f315c0f
No known key found for this signature in database
5 changed files with 0 additions and 547 deletions

View File

@ -1,129 +0,0 @@
# Submitting components as Altium 365 Part Requests (web browser)
When the central library is a managed **Altium 365 Workspace** and the operator is **not an
admin**, the skill's end task can be to submit each finished component as a **Part Request**
through the Workspace's web UI, driven by browser automation (Claude-in-Chrome). A librarian
then approves each request into the managed library. This runs in the operator's **own Chrome,
using their existing Altium 365 login**, so it needs **no API token and no admin rights** — it
just does what the engineer would do by hand, for every component in the run.
This is the browser alternative to the headless API push (`altium365_push.py`, if a token is
ever available) and to the Gitea push. Use whichever matches how the org consumes the library.
## Prerequisites (each run)
- **Chrome open** with the Claude-in-Chrome extension enabled, and **site permission granted**
for the Workspace domain (e.g. `vecmocon.altium365.com`).
- **Signed in** to the Altium 365 Workspace as any member with rights to create Part Requests.
- **The component files on the LOCAL machine** — the browser uploads attachments from local
disk, so each component's `.SchLib` (with parameters written), `.PcbLib`, and datasheet must
exist on the operator's computer. If the skill produced them in the cloud, commit them to the
device first (device bridge) into a known folder, and put those local paths in the manifest.
- **The manifest** of components to submit this run (below).
- **Decisions the operator confirms once**: the default **Assign to** (the librarian/group), the
**Component Type** mapping for each typeid, and an optional **Required By** date.
## Per-component field mapping (the Part Request form)
| Form field | Value (from the skill's per-part data) |
|---|---|
| Manufacturer | `Manufacturer` param (e.g. `Taiyo Yuden`) |
| Manufacturer Part Numbers | `Manufacturer Part` param (the MPN) |
| Description | `Description` param (SOP format, e.g. `CHIP_CAP_1uF_6.3v_±10%_0402_x5r`) |
| Component Type | the typeid mapped to the Workspace's matching component type (confirm once per typeid) |
| State | leave `Opened: New` |
| Required By Date | optional org default |
| Assign to | the configured librarian / group |
| Parameters → Add | every parameter from the full set (name + value) |
| Attachments | the `.SchLib`, `.PcbLib`, and datasheet files |
| Parts List → Add | optional: add the MPN as a part choice so the librarian's mapping is pre-seeded |
## The manifest
The skill writes one `part_requests.json` per run listing every component it processed, so the
browser step can loop without re-deriving anything:
```json
{"requests":[
{"manufacturer":"Taiyo Yuden","mpn":"JMK105BJ105KV-F",
"description":"CHIP_CAP_1uF_6.3v_±10%_0402_x5r","component_type":"Capacitor",
"parameters":{"Value":"1u","Voltage(V)":"6.3","Tolerance":"±10%","...":"..."},
"files":["C:\\...\\JMK105BJ105KV-F.SchLib","C:\\...\\<footprint>.PcbLib","C:\\...\\<mpn>.pdf"],
"assignee":"<librarian>","required_by":""}
]}
```
Build it from each component's `params.json` plus the local file paths (after committing files
to the device).
## Browser procedure (looped per component)
Start the browser session with `tabs_context_mcp`, then for each entry in the manifest:
1. Navigate to **Library → Part Requests → new request**.
2. Fill **Manufacturer**, **Manufacturer Part Numbers**, **Description**.
3. Choose **Component Type**; set **Assign to**; optional **Required By Date**. Leave State as
`Opened: New`.
4. **Parameters → Add**: add each parameter name + value.
5. **Attachments** (Choose file / drop): upload the `.SchLib`, `.PcbLib`, and datasheet.
6. Review, then **Save**. Record the auto-assigned **Request Id**.
7. Move to the next entry.
## Standalone Selenium path (token-free at scale) — `scripts/altium365_part_request.py`
Live browser-driving costs Claude tokens per component. For a whole library, use the Selenium
script instead: it's authored once, then runs on the operator's machine over any number of
components with **zero Claude tokens per part**. It attaches to the operator's already-signed-in
Chrome (remote-debugging port), so it reuses the Altium 365 session — no credentials handled.
Captured selectors (live form at `<workspace>/partrequestsmanagement/Tasks/Add`; Altium "afs"
design system + Selectize.js dropdowns):
- Manufacturer → `input#Manufacturer`
- Manufacturer Part Numbers → `textarea#ManufacturerPartNumbers`
- Description → `textarea#Description`
- Component Type / Assign to → Selectize: click the `.selectize-input` after the label, then the
`.selectize-dropdown-content .option` whose text matches.
- Parameters → afs-table under the "Parameters" label; each "Add" (`a.afs-link`) click adds a
`div.afs-table__tr` with two `input.afs-input__control` (name, value).
- Attachments → `input#fileupload` (Selenium `send_keys` the local file path(s)).
- Save → `button.afs-btn_primary` (text "Save").
Component Type options (must match one exactly): Test Points, Clock&Timing, Memory, Wireless,
Transformers, Mechanicals, Capacitors, Transistors, Drivers, Optoelectronics, Power Supply,
Audio, Fuses, Switches, Integrated Circuits, Logic, Diodes, Interface, Miscellaneous, Radio&RF,
Sensors, Processors, Mechanical, Inductors, LED, Amplifiers, Resistors, Data Converters, Relays,
Batteries, Connectors, Crystals & Oscillators. (Map each typeid to one of these when building the
manifest — e.g. `CER → Capacitors`.)
Run it:
```bash
# 1) close Chrome, relaunch on the operator's own profile with a debug port:
# chrome.exe --remote-debugging-port=9222 --user-data-dir="%LOCALAPPDATA%\Google\Chrome\User Data"
# 2) sign into the Workspace in that Chrome, then:
python scripts/altium365_part_request.py --manifest part_requests.json \
--base https://<workspace>.365.altium.com [--review-first | --no-submit]
```
Default operation is **review mode**: the runner (`local_runner.py`) invokes the submitter with
`--no-submit`, so it fills the form completely — fields, parameters, and all attachments — and
**leaves it on screen for the operator to review and click Save**. This is the review-first gate
and works even unattended (a console "press Enter" can't). Set `"auto_submit": true` in
`runner_config.json` only to submit without review. (`--review-first` is a console-only variant
that pauses for Enter.)
Build the manifest with `scripts/build_part_request_manifest.py` (it maps the typeid to the
Workspace Component Type and pulls manufacturer/MPN/Description/parameters from the schlib
`params.json`). The skill writes `part_requests.json` and commits it + each component's files to
the operator's inbox (device bridge) so the local `files` paths resolve.
## Safety and auditing
- On the **first component of a run**, fill everything and **stop at Save** for the operator to
eyeball the mapping. Once they confirm it looks right, Save it and loop the rest unattended.
- **Log every submitted Request Id** (and any component that failed) so the run is auditable and
re-runnable — never silently skip a component.
- Browser automation follows the live UI. If a field, dropdown option, or a popup doesn't match
what's expected, **pause and ask** rather than guessing — a wrong Component Type or a
half-filled request is worse than one clarifying question.
- Don't trigger native file-dialog blocking: use the extension's file-upload path for
attachments, not an OS dialog.

View File

@ -1,205 +0,0 @@
#!/usr/bin/env python3
"""Submit Altium 365 **Part Requests** with Selenium, looping over a manifest — the skill's
end task when the central library is a managed Altium 365 Workspace and the operator is not an
admin.
It runs on the OPERATOR'S machine and **attaches to their already-signed-in Chrome** via the
remote-debugging port, so it reuses the existing Altium 365 session no credentials are ever
handled here. It then fills and submits the "Add new request" web form for each component.
Selectors were captured from the live form at
`<workspace>/partrequestsmanagement/Tasks/Add` (Altium's "afs" design system + Selectize.js
dropdowns):
- Manufacturer : input#Manufacturer
- Manufacturer Part Numbers: textarea#ManufacturerPartNumbers
- Description : textarea#Description
- Component Type / Assign to: Selectize dropdowns (click control after the label, click the
option div by text)
- Parameters : an afs-table under the "Parameters" label; each "Add" click adds a
row of two inputs (name, value)
- Attachments : input#fileupload (Selenium send_keys the local file path)
- Save : button.afs-btn_primary with text "Save"
--------------------------------------------------------------------------------------------
SETUP (once, on the operator's machine)
1. pip install selenium (Selenium 4+ auto-manages chromedriver; or pass --chromedriver)
2. Fully close Chrome, then start it with a debugging port on the operator's own profile so
the Altium 365 login is reused, e.g. on Windows:
chrome.exe --remote-debugging-port=9222 --user-data-dir="%LOCALAPPDATA%\\Google\\Chrome\\User Data"
Sign in to the Workspace in that Chrome if not already.
3. Run:
python altium365_part_request.py --manifest part_requests.json \
--base https://vecmocon-technologies-pvt-ltd.365.altium.com
Add --no-submit to fill every form but NOT click Save (dry run), or --review-first to
pause after filling the first request so you can eyeball it before the batch proceeds.
MANIFEST (part_requests.json) one entry per component:
{"requests":[
{"manufacturer":"Taiyo Yuden","mpn":"JMK105BJ105KV-F",
"description":"CHIP_CAP_1uF_6.3v_±10%_0402_x5r","component_type":"Capacitors",
"assignee":"", // optional: exact name as shown in the Assign-to list
"parameters":{"Value":"1u","Voltage(V)":"6.3","Tolerance":"±10%"},
"files":["C:\\\\lib\\\\JMK105BJ105KV-F.SchLib","C:\\\\lib\\\\<fp>.PcbLib","C:\\\\lib\\\\<mpn>.pdf"]}
]}
Component Type must be one of the Workspace's list (captured live):
Test Points, Clock&Timing, Memory, Wireless, Transformers, Mechanicals, Capacitors,
Transistors, Drivers, Optoelectronics, Power Supply, Audio, Fuses, Switches,
Integrated Circuits, Logic, Diodes, Interface, Miscellaneous, Radio&RF, Sensors, Processors,
Mechanical, Inductors, LED, Amplifiers, Resistors, Data Converters, Relays, Batteries,
Connectors, Crystals & Oscillators
"""
import argparse, json, os, sys, time
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import TimeoutException
def make_driver(debugger_address, chromedriver):
opts = Options()
opts.add_experimental_option("debuggerAddress", debugger_address)
return webdriver.Chrome(service=Service(chromedriver), options=opts) if chromedriver \
else webdriver.Chrome(options=opts)
def _wait(driver, cond, t=25):
return WebDriverWait(driver, t).until(cond)
def _set_text(driver, by, sel, value):
el = _wait(driver, EC.presence_of_element_located((by, sel)))
driver.execute_script("arguments[0].scrollIntoView({block:'center'});", el)
el.clear()
el.send_keys(value or "")
def _select_dropdown(driver, label, value):
"""Selectize: click the control after `label`, then the visible option whose text == value."""
control = _wait(driver, EC.element_to_be_clickable((By.XPATH,
f"//*[normalize-space(text())='{label}']/following::div[contains(@class,'selectize-input')][1]")))
driver.execute_script("arguments[0].scrollIntoView({block:'center'});", control)
control.click()
opt = _wait(driver, EC.element_to_be_clickable((By.XPATH,
"//div[contains(@class,'selectize-dropdown') and not(contains(@style,'display: none'))]"
f"//div[contains(@class,'option') and normalize-space()=\"{value}\"]")))
opt.click()
def _add_parameters(driver, params):
"""Click the Parameters 'Add' link once per parameter, then fill each row's (name,value)."""
if not params:
return
add_link_xpath = ("//*[normalize-space(text())='Parameters']/descendant-or-self::*"
"/a[contains(@class,'afs-link')] | "
"//*[normalize-space(text())='Parameters']/following::a[contains(@class,'afs-link')][1]")
for _ in params:
link = _wait(driver, EC.element_to_be_clickable((By.XPATH, add_link_xpath)))
driver.execute_script("arguments[0].click();", link)
time.sleep(0.25)
# the Parameters afs-table tbody that sits under the "Parameters" label
tbody = _wait(driver, EC.presence_of_element_located((By.XPATH,
"//*[normalize-space(text())='Parameters']/following::div[contains(@class,'afs-table__tbody')][1]")))
rows = tbody.find_elements(By.XPATH, ".//div[contains(@class,'afs-table__tr')]")
for row, (name, val) in zip(rows, params.items()):
cells = row.find_elements(By.XPATH, ".//input[contains(@class,'afs-input__control')]")
if len(cells) >= 2:
cells[0].clear(); cells[0].send_keys(str(name))
cells[1].clear(); cells[1].send_keys(str(val))
def _attach_files(driver, files):
present = [f for f in (files or []) if f and os.path.exists(f)]
missing = [f for f in (files or []) if f and not os.path.exists(f)]
for f in present: # one at a time so multiple files accumulate
inp = _wait(driver, EC.presence_of_element_located((By.ID, "fileupload")))
inp.send_keys(os.path.abspath(f))
time.sleep(0.8)
return present, missing
def submit_request(driver, base, req, no_submit=False):
url = f"{base}/partrequestsmanagement/Tasks/Add"
err = None
for attempt in range(3): # retry: first nav can be slow/cold
driver.get(url)
try:
_wait(driver, EC.presence_of_element_located((By.ID, "Manufacturer")), t=20)
err = None
break
except TimeoutException as e:
err = e
time.sleep(2)
if err is not None:
cur = driver.current_url
if any(k in cur.lower() for k in ("auth.altium.com", "signin", "login", "oauth")):
raise RuntimeError(
"NOT SIGNED IN — this Chrome (the one on the debug port) is on the Altium login "
"page, so the form never loaded. Open that same Chrome profile, sign into the "
"Workspace (keep me signed in), leave it open, and re-run.")
raise RuntimeError(f"The 'Add new request' form did not load (no #Manufacturer field). "
f"Current page URL: {cur}")
_set_text(driver, By.ID, "Manufacturer", req.get("manufacturer", ""))
_set_text(driver, By.ID, "ManufacturerPartNumbers", req.get("mpn", ""))
_set_text(driver, By.ID, "Description", req.get("description", ""))
if req.get("component_type"):
_select_dropdown(driver, "Component Type", req["component_type"])
if req.get("assignee"):
_select_dropdown(driver, "Assign to", req["assignee"])
_add_parameters(driver, req.get("parameters") or {})
attached, missing = _attach_files(driver, req.get("files"))
if no_submit:
return {"mpn": req.get("mpn"), "status": "filled_not_submitted",
"attached": attached, "missing_files": missing}
save = _wait(driver, EC.element_to_be_clickable((By.XPATH,
"//button[contains(@class,'afs-btn_primary') and normalize-space()='Save']")))
driver.execute_script("arguments[0].click();", save)
time.sleep(2.5) # let the SPA post and return to the list
return {"mpn": req.get("mpn"), "status": "submitted",
"attached": attached, "missing_files": missing}
def main():
ap = argparse.ArgumentParser(description="Submit Altium 365 Part Requests via Selenium.")
ap.add_argument("--manifest", required=True, help="part_requests.json")
ap.add_argument("--base", required=True, help="Workspace base URL, e.g. https://<ws>.365.altium.com")
ap.add_argument("--debugger-address", default="127.0.0.1:9222")
ap.add_argument("--chromedriver", default=None, help="path to chromedriver (optional in Selenium 4+)")
ap.add_argument("--no-submit", action="store_true", help="fill every form but do not click Save")
ap.add_argument("--review-first", action="store_true",
help="after filling the first request, pause for Enter before submitting the rest")
ap.add_argument("--out", default="part_requests_result.json")
a = ap.parse_args()
data = json.load(open(a.manifest, encoding="utf-8"))
requests = data.get("requests", [])
driver = make_driver(a.debugger_address, a.chromedriver)
results = []
try:
for i, req in enumerate(requests):
first = (i == 0)
no_submit = a.no_submit or (a.review_first and first)
try:
r = submit_request(driver, a.base.rstrip("/"), req, no_submit=no_submit)
except Exception as e:
r = {"mpn": req.get("mpn"), "status": "error", "error": str(e).splitlines()[0][:400]}
results.append(r)
print(f"[{i+1}/{len(requests)}] {req.get('mpn')}: {r['status']}"
+ (f" -> {r['error']}" if r.get("error") else "")
+ (f" MISSING {r['missing_files']}" if r.get("missing_files") else ""))
if a.review_first and first and not a.no_submit:
input("Review the first request in the browser, then press Enter to submit the rest...")
# re-submit the first for real now that it's been reviewed
results[-1] = submit_request(driver, a.base.rstrip("/"), req, no_submit=False)
finally:
json.dump({"results": results}, open(a.out, "w"), indent=2, ensure_ascii=False)
print(f"\nwrote {a.out} ({sum(1 for r in results if r['status']=='submitted')} submitted, "
f"{sum(1 for r in results if r['status']=='error')} errors)")
# NOTE: attaching to your Chrome — the script does not close your browser.
if __name__ == "__main__":
main()

View File

@ -1,94 +0,0 @@
#!/usr/bin/env python3
"""Build (or append to) a `part_requests.json` manifest for the Altium 365 Part Request end task,
from a component's schlib `params.json` plus its local file paths. The skill runs this at the end
of a run, then commits the manifest + the component's `.SchLib`/`.PcbLib`/datasheet into the
operator's Altium Runner inbox; the local runner picks it up and fills the Part Request.
Usage:
python build_part_request_manifest.py --params <schlib params.json> --typeid CER \
--file "C:/Altium Runner/inbox/JMK105BJ105KV-F.SchLib" \
--file "C:/Altium Runner/inbox/JMK105BJ105KV-F.PcbLib" \
--file "C:/Altium Runner/inbox/JMK105BJ105KV-F.pdf" \
--out "C:/Altium Runner/inbox/part_requests.json"
`--component-type` overrides the typeidtype mapping; `--assignee` sets the librarian.
"""
import argparse, json, os
# Vecmocon typeid -> Altium 365 Part Request "Component Type" (options captured live from the
# form's dropdown). Edit as your Workspace's component-type list evolves; --component-type wins.
TYPEID_TO_COMPONENT_TYPE = {
"CER": "Capacitors", "ELE": "Capacitors", "TAN": "Capacitors", "PLY": "Capacitors",
"FLM": "Capacitors", "SFY": "Capacitors", "SUP": "Capacitors",
"FIX": "Resistors", "TFR": "Resistors", "MFR": "Resistors", "CFR": "Resistors",
"MOR": "Resistors", "WWR": "Resistors", "SHT": "Resistors", "ARR": "Resistors",
"POT": "Resistors", "FSR": "Resistors", "NTC": "Resistors", "PTC": "Resistors",
"PWR": "Inductors", "FBD": "Inductors", "CMC": "Inductors", "RFI": "Inductors",
"XFM": "Inductors", "CTX": "Inductors", "CPL": "Inductors",
"REC": "Diodes", "FRD": "Diodes", "SCH": "Diodes", "SIC": "Diodes", "ZEN": "Diodes",
"TVS": "Diodes", "ESD": "Diodes", "SWI": "Diodes", "BRG": "Diodes", "LED": "LED",
"BJT": "Transistors", "MOS": "Transistors", "SCM": "Transistors", "GAN": "Transistors",
"IGBT": "Transistors", "JFET": "Transistors", "DIG": "Transistors",
"MCU": "Processors", "LDO": "Power Supply", "DCD": "Power Supply", "PMU": "Power Supply",
"BMS": "Integrated Circuits", "DRV": "Drivers", "AMP": "Amplifiers", "CMP": "Amplifiers",
"VRF": "Data Converters", "ADC": "Data Converters", "DAC": "Data Converters",
"ISO": "Optoelectronics", "XCV": "Interface", "AFE": "Integrated Circuits", "MEM": "Memory",
"LOG": "Logic", "SEN": "Sensors", "IFC": "Interface", "CLK": "Crystals & Oscillators",
"SVR": "Power Supply", "MTR": "Integrated Circuits",
"FUS": "Fuses", "RSF": "Fuses", "VAR": "Miscellaneous", "GDT": "Miscellaneous", "CBK": "Switches",
"DCM": "Power Supply", "IDC": "Power Supply", "INV": "Power Supply", "OBC": "Power Supply",
"CHG": "Power Supply", "PSU": "Power Supply", "RCM": "Power Supply",
"RLS": "Relays", "RLP": "Relays", "SSR": "Relays", "RLR": "Relays", "CTC": "Relays",
"SWT": "Switches", "PBT": "Switches", "DSW": "Switches", "RSW": "Switches", "RSY": "Switches",
"CWB": "Connectors", "CBB": "Connectors", "HDR": "Connectors", "FFC": "Connectors",
"USB": "Connectors", "PWC": "Connectors", "TBK": "Connectors",
"ANC": "Radio&RF", "ANP": "Radio&RF", "ANE": "Radio&RF", "SAW": "Radio&RF", "RFM": "Radio&RF",
"XTL": "Crystals & Oscillators", "OSC": "Crystals & Oscillators", "MMO": "Crystals & Oscillators",
"RSN": "Crystals & Oscillators",
"CLI": "Batteries", "CLF": "Batteries", "CCO": "Batteries", "CNI": "Batteries",
"BPK": "Batteries", "CHL": "Batteries",
"BUZ": "Audio", "PBZ": "Audio", "SPK": "Audio", "IND": "Optoelectronics",
"DSG": "Optoelectronics", "OLE": "Optoelectronics", "LCD": "Optoelectronics", "TFT": "Optoelectronics",
"STE": "Sensors", "SCU": "Sensors", "SVO": "Sensors", "SHA": "Sensors", "SIM": "Sensors",
"SPR": "Sensors",
"FAN": "Mechanicals", "HSK": "Mechanicals", "TPD": "Mechanicals",
}
def build_entry(params, typeid, component_type, files, assignee):
p = params.get("parameters", {})
ctype = component_type or TYPEID_TO_COMPONENT_TYPE.get((typeid or "").upper(), "Miscellaneous")
mpn = p.get("Manufacturer Part") or params.get("component") or ""
return {
"manufacturer": p.get("Manufacturer", ""),
"mpn": mpn,
"description": p.get("Description", ""),
"component_type": ctype,
"assignee": assignee or params.get("assignee", ""),
"parameters": {k: v for k, v in p.items() if v not in ("", None)},
"files": files,
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--params", required=True, help="the schlib params.json for the component")
ap.add_argument("--typeid", help="component typeid (auto-maps to an Altium Component Type)")
ap.add_argument("--component-type", help="Altium Component Type (overrides the typeid map)")
ap.add_argument("--assignee", default="", help="librarian to assign the request to (optional)")
ap.add_argument("--file", action="append", default=[],
help="local path to attach (repeatable): .SchLib, .PcbLib, datasheet")
ap.add_argument("--out", required=True, help="part_requests.json (created or appended to)")
a = ap.parse_args()
params = json.load(open(a.params, encoding="utf-8"))
entry = build_entry(params, a.typeid, a.component_type, a.file, a.assignee)
data = json.load(open(a.out, encoding="utf-8")) if os.path.exists(a.out) else {"requests": []}
data.setdefault("requests", []).append(entry)
json.dump(data, open(a.out, "w", encoding="utf-8"), indent=2, ensure_ascii=False)
print(f"wrote {a.out} (+1 request: {entry['mpn']} -> {entry['component_type']}, "
f"{len(a.file)} file(s))")
if __name__ == "__main__":
main()

View File

@ -1,108 +0,0 @@
#!/usr/bin/env python3
"""LOCAL RUNNER — runs on the operator's own machine (Windows), set up ONCE, to make Altium 365
Part Request submission the automatic tail of a skill run.
Why this exists: the library-manager skill runs in the cloud, but the Part Request web form must
be driven from the operator's signed-in Chrome on their desktop — the cloud can't reach it. So at
the end of a run the skill drops the finished files + a `part_requests.json` manifest into an
**inbox folder on this machine** (via the device bridge). This runner watches that inbox and, for
each manifest: makes sure Chrome is running with a debugging port on the operator's profile
(launching it if needed the persisted login is reused), runs `altium365_part_request.py`, then
moves the manifest to a `processed/` folder.
Set it up once (Task Scheduler at logon, or a startup shortcut) and every future skill run
auto-submits its Part Requests with no further action as long as this machine is on and the
Chrome profile has been signed into the Workspace at least once.
Config: a `runner_config.json` next to this file, e.g.
{
"base": "https://vecmocon-technologies-pvt-ltd.365.altium.com",
"inbox": "D:/User Data/Desktop/altium-library-master/_part_request_inbox",
"processed": "D:/User Data/Desktop/altium-library-master/_part_request_inbox/processed",
"chrome": "C:/Program Files/Google/Chrome/Application/chrome.exe",
"user_data_dir": "%LOCALAPPDATA%/Google/Chrome/User Data",
"debug_port": 9222,
"chromedriver": null,
"review_first": false,
"poll_seconds": 30
}
Run:
python local_runner.py # watch the inbox forever (use for Task Scheduler / startup)
python local_runner.py --once # process whatever is in the inbox now, then exit
"""
import glob, json, os, shutil, socket, subprocess, sys, time
HERE = os.path.dirname(os.path.abspath(__file__))
def load_cfg():
with open(os.path.join(HERE, "runner_config.json"), encoding="utf-8") as f:
return json.load(f)
def _port_open(port):
s = socket.socket(); s.settimeout(1)
try:
s.connect(("127.0.0.1", int(port))); return True
except Exception:
return False
finally:
s.close()
def ensure_chrome(cfg):
"""Make sure a Chrome with the debug port is up (reusing the operator's logged-in profile)."""
if _port_open(cfg["debug_port"]):
return
udd = os.path.expandvars(cfg["user_data_dir"])
subprocess.Popen([cfg["chrome"], f"--remote-debugging-port={cfg['debug_port']}",
f"--user-data-dir={udd}"])
for _ in range(40):
if _port_open(cfg["debug_port"]):
time.sleep(2) # give the profile a moment to restore the session
return
time.sleep(1)
raise RuntimeError("Chrome did not expose a debug port — check 'chrome' path and 'debug_port'.")
def process(cfg, manifest):
ensure_chrome(cfg)
cmd = [sys.executable, os.path.join(HERE, "altium365_part_request.py"),
"--manifest", manifest, "--base", cfg["base"],
"--debugger-address", f"127.0.0.1:{cfg['debug_port']}",
"--out", manifest + ".result.json"]
if cfg.get("chromedriver"):
cmd += ["--chromedriver", cfg["chromedriver"]]
# Default is REVIEW mode: fill the form completely (fields + parameters + attachments) and
# leave it on screen WITHOUT saving, so the operator reviews and clicks Save themselves.
# This is the review-first gate and it works even when the runner runs unattended (unlike a
# console "press Enter"). Set "auto_submit": true in the config only to submit without review.
if not cfg.get("auto_submit", False):
cmd += ["--no-submit"]
subprocess.run(cmd, check=False)
def main():
cfg = load_cfg()
os.makedirs(cfg["inbox"], exist_ok=True)
os.makedirs(cfg["processed"], exist_ok=True)
once = "--once" in sys.argv
print(f"watching {cfg['inbox']} (base {cfg['base']})")
while True:
for m in sorted(glob.glob(os.path.join(cfg["inbox"], "*.json"))):
if m.endswith(".result.json"):
continue
print("processing", m)
try:
process(cfg, m)
except Exception as e:
print(" error:", e)
shutil.move(m, os.path.join(cfg["processed"], os.path.basename(m)))
if once:
break
time.sleep(int(cfg.get("poll_seconds", 30)))
if __name__ == "__main__":
main()

View File

@ -1,11 +0,0 @@
{
"base": "https://vecmocon-technologies-pvt-ltd.365.altium.com",
"inbox": "C:/Altium Runner/inbox",
"processed": "C:/Altium Runner/inbox/processed",
"chrome": "C:/Program Files/Google/Chrome/Application/chrome.exe",
"user_data_dir": "C:/altium-runner/chrome-profile",
"debug_port": 9222,
"chromedriver": null,
"auto_submit": false,
"poll_seconds": 30
}